Skip to content

Proof follow-up: dynamic/composite internal-helper argument lowering #1889

@Th0rgal

Description

@Th0rgal

Summary

Verity currently supports dynamic calldata at external entrypoints, but intentionally rejects dynamic parameters on internal helpers. This blocks clean translation of Solidity helpers whose signatures include bytes calldata or other ABI-dynamic values, such as Unlink's _transferWithBalanceCheck(PermitTransferFrom, address, bytes, uint256, bytes32) shape.

The current fail-fast guard is correct as a stopgap, but the feature we need is a first-class internal helper ABI/lowering convention for dynamic and composite parameters.

Current behavior

CompilationModel rejects any internal function whose parameter type is dynamic:

  • Compiler/CompilationModel/ValidationCalls.lean: firstInternalDynamicParam
  • Compiler/CompilationModel/Dispatch.lean: validateCompileInputsBeforeFieldWriteConflict throws Issue #753 (internal dynamic params unsupported)

The reason is that compileInternalFunction currently emits a Yul helper using only the source parameter names:

let paramNames := spec.params.map (·.name)
...
pure (YulStmt.funcDef (internalFunctionYulName spec.name) paramNames retNames bodyStmts)

External entrypoint ABI decoding is richer. For bytes / string / dynamic arrays it binds companion names such as:

  • <name>_offset
  • <name>_length
  • <name>_data_offset

Static tuples/fixed arrays are also flattened into leaf bindings such as <name>_0, <name>_1, etc. That logic lives in Compiler/CompilationModel/ParamLoading.lean via genParamLoadsFrom, genDynamicParamLoads, and genStaticTypeLoads.

If the guard were simply removed, helper bodies that read signature_length, signature_data_offset, tuple leaves, etc. would reference bindings that were never passed into the Yul helper. This is the invalid-Yul failure that #753 originally reported.

Concrete Unlink impact

The Solidity helper _transferWithBalanceCheck wants to be translated as an internal Verity helper because it is semantically a reusable internal routine. Its natural shape includes:

  • PermitTransferFrom (static composite / tuple-like value)
  • address depositor
  • bytes calldata signature
  • uint256 amount
  • bytes32 noteCommitment

Today the bytes calldata signature parameter trips the internal dynamic-param rejection. PermitTransferFrom also needs an explicit composite convention rather than pretending one Yul word contains the whole struct.

The practical result for Unlink translation is that this helper must remain inlined in each caller. That works around the compiler limitation, but it loses the source structure we want from Solidity-to-Verity translation:

  • less faithful translation of internal Solidity helper boundaries;
  • duplicated lowered statements at call sites;
  • harder source review because the translated Verity no longer mirrors the Solidity helper decomposition;
  • less reusable proof structure, since the helper cannot get a single summary/postcondition consumed by multiple callers;
  • avoidable churn in the translator whenever a helper has a bytes calldata or tuple-like parameter.

A clean implementation would let the translator preserve _transferWithBalanceCheck as an internal FunctionSpec.isInternal := true helper and lower calls like _transferWithBalanceCheck(_permit, _depositor, _signature, _amount, _noteCommitment) by expanding the ABI-shaped arguments at the call boundary.

Proposed lowering model

Add an explicit internal-call ABI lowering layer shared by internal helper definitions and call sites.

1. Define lowered internal parameter slots

Introduce a helper such as:

def internalParamBindings : Param -> List String

with rules matching the existing external-entrypoint binding vocabulary:

  • scalar static types (uint256, int256, uint8, address, bool, bytes32) lower to [name];
  • bytes / string lower to [name_offset, name_length, name_data_offset] or, if the offset word is not useful after forwarding, a documented smaller convention such as [name_length, name_data_offset];
  • dynamic arrays lower to their offset/length/data-offset bindings consistently with Expr.arrayLength, Expr.arrayElement, returnArray, event/custom-error ABI encoding, etc.;
  • static tuples and fixed arrays lower recursively to leaf names (permit_0, permit_1, ...), matching genStaticTypeLoads naming;
  • dynamic composites should initially be rejected unless/until their full recursive head/tail convention is implemented.

The important invariant is: any name that expression/statement lowering can emit for a source parameter must either be locally bound in an external entrypoint or appear in the internal helper's expanded Yul parameter list.

2. Use expanded params in compileInternalFunction

Change compileInternalFunction from spec.params.map (·.name) to the expanded internal parameter bindings.

inScopeNames should also include those expanded names so generated temporaries avoid collisions. Source-level parameter names may still be tracked for validation, but the generated Yul function signature must carry the names actually used by the compiled helper body.

3. Expand internal call arguments using the callee signature

Stmt.internalCall, Stmt.internalCallAssign, and Expr.internalCall currently call compileExprList positionally and emit one Yul argument per source argument. They need access to the callee FunctionSpec so a single source argument can expand into multiple Yul arguments according to the callee parameter type.

For the first implementation, keep this conservative:

  • allow expanded arguments only for direct forwarding from existing bindings, e.g. Expr.param "signature" or Expr.localVar "signature" when the corresponding expanded companion names are known in scope;
  • reject computed expressions for dynamic/composite parameters with a clear diagnostic, because materializing arbitrary dynamic ABI values into memory/calldata-shaped fat pointers is a separate feature;
  • require argument type compatibility, not just arity. Today validation checks only source argument count vs callee parameter count.

For example, forwarding Expr.param "signature" to a callee bytes parameter should emit:

signature_offset, signature_length, signature_data_offset

Forwarding a static PermitTransferFrom tuple should emit the flattened leaf bindings expected by the helper, e.g. permitted token, permitted amount, nonce, deadline. Exact names should follow the existing tuple/fixedArray binding convention in ParamLoading.lean.

4. Share naming/layout code with external ABI loading

Avoid inventing a second naming scheme. Either expose and reuse the existing static/dynamic binding-name logic from ParamLoading.lean, or factor it into a small ABI binding-layout module that both entrypoint decoding and internal helper lowering consume.

This keeps source translation, Yul codegen, event/custom-error encoding, and future proof statements aligned around one convention.

Translator impact for Unlink

Once this exists, the Unlink translator can preserve Solidity helper boundaries instead of selectively inlining helpers that contain bytes calldata or static structs.

Translator-side requirements should be small:

  • lower Solidity structs used as internal arguments to ParamType.tuple or an equivalent Verity composite representation with stable field ordering;
  • emit direct parameter/local forwarding for calldata bytes values rather than attempting to copy bytes into memory;
  • reject or inline only the cases that still require materializing a new dynamic value;
  • add a regression fixture covering _transferWithBalanceCheck(PermitTransferFrom, address, bytes, uint256, bytes32) so this helper remains non-inline after translation.

This should also reduce duplicated Unlink proof obligations: one helper summary/postcondition can be attached to _transferWithBalanceCheck and reused at every direct call site.

Proof-side work

This is not just a codegen change. The proof stack currently has multiple places that assume or characterize the old one-source-param-to-one-Yul-param shape.

Expected proof updates:

  • Generalize compileInternalFunction_output_shape in Compiler/Proofs/IRGeneration/IRInterpreter.lean. It currently states that successful helper compilation emits params spec.params.map (·.name). It should instead state params equal the expanded internal binding list.
  • Update findInternalFunction?_exact_of_compileInternalFunction_mem_unique and related helper-table lemmas to carry the expanded parameter list.
  • Generalize compileStmt_internalCall_shape and compileStmt_internalCallAssign_shape. They currently characterize call lowering as one compiled Yul arg per source arg via compileExprList. They need a callee-aware expanded-argument compiler and a corresponding shape theorem.
  • Update the helper-aware IR execution lemmas (execIRStmtsWithInternals_of_internalCall_compile, execIRStmtsWithInternals_of_internalCallAssign_compile) so argVals correspond to the expanded ABI binding values, not source-argument count.
  • Extend source-side helper-summary interfaces so a helper summary is parameterized over source params while the compiled-side helper receives expanded Yul params. The bridge needs a relation saying the expanded Yul argument vector represents the source argument values/fat pointers used by the source helper semantics.
  • Preserve the existing helper-free conservative-extension theorems. The new work should affect only contracts with internal helper calls that use expanded params; the current helper-free/legacy-compatible path should remain a specialization.
  • Add a SupportedSpec/body-surface widening step once the exact helper-call proof interface supports expanded dynamic/composite arguments. Until then, the compiler may support the feature operationally while the generic proof-complete fragment still gates it explicitly.

A useful proof decomposition would be:

  1. prove deterministic binding-layout lemmas for internalParamBindings;
  2. prove external entrypoint decoding and internal forwarding produce the same binding environment for directly forwarded values;
  3. prove helper execution under expanded params simulates source helper execution under source params;
  4. consume that simulation in the existing direct-helper-call / direct-helper-assign proof seams documented in the Layer 2 roadmap.

Acceptance criteria

  • The internal dynamic-param rejection is replaced with a narrower diagnostic: reject only unsupported dynamic/composite cases that cannot be represented by the new internal binding layout.
  • Internal helpers with direct-forwarded bytes / string / dynamic-array calldata params compile to valid Yul.
  • Internal helpers with static tuple/fixed-array params compile with flattened leaf Yul params.
  • Internal call validation checks type/layout compatibility, not just source arity.
  • Regression tests cover a helper with both static composite and bytes params, including a shape matching Unlink's _transferWithBalanceCheck.
  • Proof shape lemmas are updated to mention expanded helper params and expanded call args.
  • The current supported/proof-complete fragment either proves the new cases or continues to fail closed with an explicit, issue-linked boundary until those proofs land.

Related context

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions