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:
- prove deterministic binding-layout lemmas for
internalParamBindings;
- prove external entrypoint decoding and internal forwarding produce the same binding environment for directly forwarded values;
- prove helper execution under expanded params simulates source helper execution under source params;
- 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
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 calldataor 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
CompilationModelrejects any internal function whose parameter type is dynamic:Compiler/CompilationModel/ValidationCalls.lean:firstInternalDynamicParamCompiler/CompilationModel/Dispatch.lean:validateCompileInputsBeforeFieldWriteConflictthrowsIssue #753 (internal dynamic params unsupported)The reason is that
compileInternalFunctioncurrently 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_offsetStatic tuples/fixed arrays are also flattened into leaf bindings such as
<name>_0,<name>_1, etc. That logic lives inCompiler/CompilationModel/ParamLoading.leanviagenParamLoadsFrom,genDynamicParamLoads, andgenStaticTypeLoads.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
_transferWithBalanceCheckwants 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 depositorbytes calldata signatureuint256 amountbytes32 noteCommitmentToday the
bytes calldata signatureparameter trips the internal dynamic-param rejection.PermitTransferFromalso 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:
bytes calldataor tuple-like parameter.A clean implementation would let the translator preserve
_transferWithBalanceCheckas an internalFunctionSpec.isInternal := truehelper 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:
with rules matching the existing external-entrypoint binding vocabulary:
uint256,int256,uint8,address,bool,bytes32) lower to[name];bytes/stringlower 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];Expr.arrayLength,Expr.arrayElement,returnArray, event/custom-error ABI encoding, etc.;permit_0,permit_1, ...), matchinggenStaticTypeLoadsnaming;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
compileInternalFunctionChange
compileInternalFunctionfromspec.params.map (·.name)to the expanded internal parameter bindings.inScopeNamesshould 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, andExpr.internalCallcurrently callcompileExprListpositionally and emit one Yul argument per source argument. They need access to the calleeFunctionSpecso a single source argument can expand into multiple Yul arguments according to the callee parameter type.For the first implementation, keep this conservative:
Expr.param "signature"orExpr.localVar "signature"when the corresponding expanded companion names are known in scope;For example, forwarding
Expr.param "signature"to a calleebytesparameter should emit:Forwarding a static
PermitTransferFromtuple should emit the flattened leaf bindings expected by the helper, e.g. permitted token, permitted amount, nonce, deadline. Exact names should follow the existingtuple/fixedArraybinding convention inParamLoading.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 calldataor static structs.Translator-side requirements should be small:
ParamType.tupleor an equivalent Verity composite representation with stable field ordering;bytesvalues rather than attempting to copy bytes into memory;_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
_transferWithBalanceCheckand 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:
compileInternalFunction_output_shapeinCompiler/Proofs/IRGeneration/IRInterpreter.lean. It currently states that successful helper compilation emits paramsspec.params.map (·.name). It should instead state params equal the expanded internal binding list.findInternalFunction?_exact_of_compileInternalFunction_mem_uniqueand related helper-table lemmas to carry the expanded parameter list.compileStmt_internalCall_shapeandcompileStmt_internalCallAssign_shape. They currently characterize call lowering as one compiled Yul arg per source arg viacompileExprList. They need a callee-aware expanded-argument compiler and a corresponding shape theorem.execIRStmtsWithInternals_of_internalCall_compile,execIRStmtsWithInternals_of_internalCallAssign_compile) soargValscorrespond to the expanded ABI binding values, not source-argument count.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:
internalParamBindings;Acceptance criteria
bytes/string/ dynamic-array calldata params compile to valid Yul.bytesparams, including a shape matching Unlink's_transferWithBalanceCheck.Related context
_transferWithBalanceChecksolely because it containsbytes calldata.