Skip to content

verity#1849 G1: dynamic-member length on struct-array elements#1852

Merged
Th0rgal merged 2 commits into
mainfrom
feat/dynamic-member-length-g1-1849
May 13, 2026
Merged

verity#1849 G1: dynamic-member length on struct-array elements#1852
Th0rgal merged 2 commits into
mainfrom
feat/dynamic-member-length-g1-1849

Conversation

@Th0rgal
Copy link
Copy Markdown
Member

@Th0rgal Th0rgal commented May 13, 2026

Summary

Implements G1 of verity#1849 — the smallest of the three macro lifts needed to translate the UnlinkPool transfer / withdraw / emergencyWithdraw bodies. After this lands, verity_contract authors can write

arrayLength (arrayElement txs i).nullifierHashes

to read the length of a dynamic-array member nested inside a struct-array element — the macro lowers it to a new Expr.arrayElementDynamicMemberLength constructor with a verified Yul helper.

What's covered

  • IR: new Expr.arrayElementDynamicMemberLength (name : String) (index : Expr) (wordOffset : Nat) constructor in Compiler/CompilationModel/Types.lean, with calldata + memory Yul helpers in DynamicData.lean. The helpers bounds-check the index against the array length, the element-relative offset against the offset-table size, and the final member-data position against calldatasize (calldata variant only).
  • Surface defs (12 sites): wired through every Expr → ... def that the audit step (post-Fix audit-step exhaustiveness errors after #1761 / #1832 #1848) requires exhaustive — UsageAnalysis.lean (6 sites), Validation.lean (6 sites), ValidationInterop.lean, ValidationCalls.lean (2 sites), ValidationHelpers.lean, LogicalPurity.lean (2 sites), ScopeValidation.lean, TrustSurface.lean (5 sites), ExprCore.lean, SupportedSpec.lean (12 sites including the exhaustiveness cases and the featureOr / featureClosed decomposition proofs), and Dispatch.lean (emit the new helpers under the existing arrayElementWord gate).
  • Macro: new arrayElementDynamicMemberProjection? helper in Translate.lean that mirrors arrayElementStructProjection? but accepts dynamic-leaf field types. Extends arrayLength type-check + lowering to consult the new helper.
  • Smoke: Contracts/Smoke/ArrayElementDynamicMemberLengthSmoke.lean defines a Transaction struct with proof : Array Uint256 and a function proofLength that exercises the new constructor. rfl assertion over proofLength_modelBody proves the IR shape.

Posture

The new constructor joins paramDynamicHeadWord / arrayElementDynamicWord as an unsupported-core surface — contracts that use it are accepted by the macro but rejected by the strict SupportedSpec until the proof framework widens. This is the same posture #1832 / #1843 took for their codegen-only additions and is what lets the audit step pass without a new contractUsesArrayElementDynamicMemberLength_eq_false proof chain.

Test plan

  • lake build is fully green locally.
  • lake build Contracts.MacroTranslateInvariantTest passes the 8/8 randomized IR↔Yul differential checks and the ABI parity checks.
  • scripts/check_macro_health.py reports "macro health checks passed".
  • scripts/check_macro_property_test_generation.py --check is green (the new smoke is in EXCLUDED_CONTRACTS alongside DynamicStructArraySmoke because the property generator doesn't yet synthesize Solidity examples for struct-array parameters with nested dynamic members).
  • CI exercises the new constructor through the macro / invariant / round-trip paths.

What's next

This unblocks emitting shape-check reverts (PoolInvalidInputShape / PoolInvalidOutputShape / PoolCiphertextCountMismatch) inside loops over _transactions. The remaining gaps to write UnlinkPool bodies are:

🤖 Generated with Claude Code


Note

Medium Risk
Adds a new IR surface and Yul helper for reading dynamic-member lengths inside ABI-encoded struct-array elements, touching macro translation and multiple validation/proof exhaustiveness sites; mistakes could cause incorrect bounds checks or miscompiled calldata reads.

Overview
Enables arrayLength (arrayElement <structArrayParam> i).<dynamicField> by introducing a new IR constructor Expr.arrayElementDynamicMemberLength and lowering it to new calldata/memory Yul helpers that bounds-check the array index, element offset-table access, and (for calldata) the final member-data read.

Wires the new expression through expression compilation, scope/purity/usage/validation/trust-surface analyses, and updates helper emission in Dispatch.lean (reusing the existing arrayElementWord helper gate). Adds a dedicated smoke contract and includes it in macro invariant + round-trip fuzz test suites, while excluding it from the macro property-test generator.

Reviewed by Cursor Bugbot for commit 9f515b4. Bugbot is set up for automated code reviews on this repo. Configure here.

Th0rgal and others added 2 commits May 13, 2026 06:17
… IR side)

Introduces the new `Expr.arrayElementDynamicMemberLength` IR constructor
that reads the length word of a dynamically-sized member (`Array<T>`,
`bytes`, `string`) nested inside a struct-array element. This is G1 of
the three macro lifts identified in verity#1849 as the remaining
Verity-core gate on the UnlinkPool body translation.

## Semantics

Given a struct-array parameter `name` indexed at `index`, with the
dynamic member's head pointer at `wordOffset` (relative to the
element's head section), the Yul lowering:

1. bounds-checks `index < length`;
2. loads `__element_rel_offset` from the array's offset table;
3. bounds-checks the element offset against `length*32` (offset table size);
4. loads `__member_rel_offset` from the element head at `wordOffset`;
5. bounds-checks the final member-data position against `calldatasize`
   (calldata variant only — the memory variant trusts its source);
6. returns the length word at `element_head + __member_rel_offset`.

The dynamic-member head pointer is element-relative per Solidity's
ABI: `__member_data_pos = element_head + __member_rel_offset` where
`element_head = data_offset + __element_rel_offset`.

## Surface coverage

- `Compiler/CompilationModel/Types.lean` — constructor.
- `Compiler/CompilationModel/DynamicData.lean` — `checkedArrayElementDynamicMemberLength{Calldata,Memory}Helper` Yul helpers.
- `Compiler/CompilationModel/ExpressionCompile.lean` — IR-to-Yul lowering.
- `Compiler/CompilationModel/Dispatch.lean` — emit helpers under existing `arrayElementWord` gate (conservative; correctly tracks usage without a new predicate).
- `Compiler/CompilationModel/UsageAnalysis.lean` — 6 sites: `exprUsesArrayElementKind`, `exprUsesArrayElement`, `exprUsesParamDynamicHeadWord`, `exprUsesMulDiv512`, `exprUsesStorageArrayElement`, `exprUsesDynamicBytesEq`.
- `Compiler/CompilationModel/Validation*.lean` — read/write/external-call surfaces (`exprReadsStateOrEnv`, `exprWritesState`, `exprHasUntrackableWrites`, `exprContainsExternalCall`, `exprMayContainExternalCall`, `exprContainsAdtConstruct`, internal-call shapes, external-call targets, interop).
- `Compiler/CompilationModel/ValidationHelpers.lean` — `collectExprNames`.
- `Compiler/CompilationModel/LogicalPurity.lean` — `exprContainsCallLike`, `exprContainsUnsafeLogicalCallLike`.
- `Compiler/CompilationModel/ScopeValidation.lean` — scope/type check for the new constructor (mirrors `arrayElementDynamicWord`).
- `Compiler/CompilationModel/TrustSurface.lean` — 5 collect-* sites.
- `Compiler/Proofs/IRGeneration/ExprCore.lean` — `exprBoundNames`.
- `Compiler/Proofs/IRGeneration/SupportedSpec.lean` — 12 exhaustiveness cases across `exprTouchesUnsupported*Surface` defs and the `exprTouchesUnsupportedCallSurface_eq_featureOr` / `exprTouchesUnsupportedContractSurface_eq_false_of_featureClosed` / `exprTouchesUnsupportedHelperSurface_eq_false_of_contractSurfaceClosed` decomposition proofs, plus `exprInternalHelperCallNames`.

The new constructor joins `paramDynamicHeadWord` / `arrayElementDynamicWord` as an unsupported-core surface — contracts that use it are rejected by `SupportedSpec` until the proof framework widens, which is the same posture as the existing dynamic-member shape primitives.

`lake build` is fully green.

Macro-side parser extension (`Verity/Macro/Translate.lean`) plus the
companion smoke test and 5-site registration come in a follow-up
commit; the IR-level infrastructure is the audit-touching half and is
worth a separate commit for review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the previously added `Expr.arrayElementDynamicMemberLength` IR
constructor into the `verity_contract` DSL by extending `arrayLength`
to accept compound projections of the form
`arrayLength (arrayElement <param> <i>).<dynamicField>` when
`<dynamicField>` is a dynamic member (`Array<T>`, `bytes`, `string`) of
the struct-array element type.

## Changes

- `Verity/Macro/Translate.lean`:
  - New `arrayElementDynamicMemberProjection?` helper that mirrors
    `arrayElementStructProjection?` but accepts dynamic-leaf field
    types (Array / bytes / string) instead of single-word static
    leaves.
  - Type-check path: extend `arrayLength` to consult the new helper
    and accept the compound projection (returning `.uint256`).
  - Lowering path: extend `arrayLength` to emit
    `Expr.arrayElementDynamicMemberLength` with the resolved
    `(paramName, indexExpr, wordOffset)` when the new helper fires.

- `Contracts/Smoke/ArrayElementDynamicMemberLengthSmoke.lean`: new
  smoke contract with a `Transaction` struct whose `proof : Array
  Uint256` is the first dynamic member. The function
  `proofLength (txs, idx)` returns `arrayLength (arrayElement txs idx).proof`.
  An `rfl` assertion over `proofLength_modelBody` proves the macro
  lowers to `Expr.arrayElementDynamicMemberLength "txs" (param "idx") 0`.

- `Contracts/MacroTranslateInvariantTest.lean`: register the new smoke
  in the `macroSpecs` list, `expectedExternalSignatures`
  (`proofLength((uint256[],address,uint256)[],uint256)`), and
  `expectedExternalSelectors` (`0xfbb81f5b`).

- `Contracts/MacroTranslateRoundTripFuzz.lean`: register in the
  round-trip spec list.

- `scripts/check_macro_property_test_generation.py`: add
  `ArrayElementDynamicMemberLengthSmoke` to `EXCLUDED_CONTRACTS`
  alongside `DynamicStructArraySmoke` — the property generator does
  not yet synthesize Solidity examples for struct-array parameters
  with nested dynamic members, but Lean macro/invariant/round-trip
  coverage exercises the contract.

## Unblocks

This was G1 of verity#1849. Authors of contracts that translate
struct-array entrypoints (e.g. UnlinkPool's `transfer` / `withdraw` /
`emergencyWithdraw`) can now emit shape-check reverts such as

```lean
requireError
  (cmp_eq (arrayLength (arrayElement txs i).nullifierHashes) c.inputCount)
  PoolInvalidInputShape ()
```

`lake build Contracts.MacroTranslateInvariantTest` is green locally;
`scripts/check_macro_health.py` reports "macro health checks passed".

G2 (element indexing on struct-element dynamic members) and G3
(pass-through to external calls / emit / revertError) plus
verity#1824 (helpers with Array parameters) remain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9f515b4. Configure here.

YulStmt.expr (YulExpr.call "revert" [YulExpr.lit 0, YulExpr.lit 0])
],
YulStmt.let_ "__element_head_pos" elementHeadPos,
YulStmt.let_ "__member_rel_offset" (YulExpr.call loadOp [memberHeadSlot]),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing bounds check on intermediate calldataload position

Medium Severity

The checkedArrayElementDynamicMemberLengthHelper reads __member_rel_offset from memberHeadSlot (line 248) without first bounds-checking that position against calldatasize. The analogous checkedArrayElementDynamicWordHelper does bounds-check its equivalent read position (__element_word_pos) before loading. Since __member_rel_offset is attacker-controlled calldata, a crafted value can cause __element_head_pos + __member_rel_offset to overflow mod 2²⁵⁶, producing a small __member_data_pos that passes the final sizeCheck and reads from an unintended calldata position.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f515b4. Configure here.

@github-actions
Copy link
Copy Markdown
Contributor

\n### CI Failure Hints\n\nFailed jobs: `build`\n\nCopy-paste local triage:\n```bash\nmake check\nlake build\nFOUNDRY_PROFILE=difftest forge test -vv\n```

@Th0rgal Th0rgal merged commit 041a783 into main May 13, 2026
16 of 17 checks passed
Th0rgal added a commit that referenced this pull request May 13, 2026
…, IR + macro spike)

Introduces the new `Expr.arrayElementDynamicMemberElement` IR constructor
that reads element `inner_index` of a dynamic word-array member nested
inside a struct-array element. This is G2 of verity#1849.

## Semantics

Given a struct-array parameter `name` indexed at `index`, with the
dynamic member's head pointer at `wordOffset` (relative to the
element's head section), the Yul lowering:

1. bounds-checks `index < length` (outer array);
2. loads `__element_rel_offset` from the outer offset table;
3. bounds-checks the element offset against `length*32`;
4. loads `__member_rel_offset` from the element head at `wordOffset`;
5. computes `__member_data_pos = element_head + __member_rel_offset`,
   which holds the dynamic member's length word followed by its data;
6. loads `__member_length` from `__member_data_pos`;
7. bounds-checks `inner_index < __member_length`;
8. computes `__word_pos = __member_data_pos + 32 + inner_index*32`;
9. bounds-checks `__word_pos` against `calldatasize` (calldata variant
   only — the memory variant trusts its source);
10. returns `load(__word_pos)`.

## Surface coverage

The same 15-file pattern as G1 (#1852) plus an `set_option maxHeartbeats 800000`
in `ScopeValidation.lean` to keep the eq_def deriver inside its budget
after the new constructor was added to the giant mutual block.

Each `Expr → ...` surface def now handles the 2-sub-expression shape
(both `index` and `inner_index`) when recursion is appropriate, or
treats the new constructor as a leaf alongside `paramDynamicHeadWord` /
`arrayElementDynamicWord` / `arrayElementDynamicMemberLength` in the
`exprTouchesUnsupported*Surface` family.

## Macro spike

`Verity/Macro/Translate.lean` extends `arrayElement`'s `inferPureExprType`
and `translatePureExprWithTypes` arms to detect the compound projection
`(arrayElement <param> <i>).<dynamicField>` and lower through
`Expr.arrayElementDynamicMemberElement`. The detection uses the existing
`arrayElementDynamicMemberProjection?` helper from G1.

**Known follow-up**: an end-to-end smoke that reaches the new lowering
path (e.g. `function f (...) := return arrayElement (arrayElement txs idx).proof k`)
currently still errors on the compilation-model gate before the new
macro arm fires; the lowering edits are committed as a spike alongside
the IR-side infrastructure but a smoke proving the macro path lights up
requires a small additional fix to be debugged in a follow-up.

`lake build` is fully green; the new IR constructor is callable
directly via `Compiler.CompilationModel.Expr.arrayElementDynamicMemberElement`
which is enough for hand-built IR tests and Layer-2 proof callers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Th0rgal added a commit that referenced this pull request May 13, 2026
…, IR + macro spike) (#1853)

Introduces the new `Expr.arrayElementDynamicMemberElement` IR constructor
that reads element `inner_index` of a dynamic word-array member nested
inside a struct-array element. This is G2 of verity#1849.

## Semantics

Given a struct-array parameter `name` indexed at `index`, with the
dynamic member's head pointer at `wordOffset` (relative to the
element's head section), the Yul lowering:

1. bounds-checks `index < length` (outer array);
2. loads `__element_rel_offset` from the outer offset table;
3. bounds-checks the element offset against `length*32`;
4. loads `__member_rel_offset` from the element head at `wordOffset`;
5. computes `__member_data_pos = element_head + __member_rel_offset`,
   which holds the dynamic member's length word followed by its data;
6. loads `__member_length` from `__member_data_pos`;
7. bounds-checks `inner_index < __member_length`;
8. computes `__word_pos = __member_data_pos + 32 + inner_index*32`;
9. bounds-checks `__word_pos` against `calldatasize` (calldata variant
   only — the memory variant trusts its source);
10. returns `load(__word_pos)`.

## Surface coverage

The same 15-file pattern as G1 (#1852) plus an `set_option maxHeartbeats 800000`
in `ScopeValidation.lean` to keep the eq_def deriver inside its budget
after the new constructor was added to the giant mutual block.

Each `Expr → ...` surface def now handles the 2-sub-expression shape
(both `index` and `inner_index`) when recursion is appropriate, or
treats the new constructor as a leaf alongside `paramDynamicHeadWord` /
`arrayElementDynamicWord` / `arrayElementDynamicMemberLength` in the
`exprTouchesUnsupported*Surface` family.

## Macro spike

`Verity/Macro/Translate.lean` extends `arrayElement`'s `inferPureExprType`
and `translatePureExprWithTypes` arms to detect the compound projection
`(arrayElement <param> <i>).<dynamicField>` and lower through
`Expr.arrayElementDynamicMemberElement`. The detection uses the existing
`arrayElementDynamicMemberProjection?` helper from G1.

**Known follow-up**: an end-to-end smoke that reaches the new lowering
path (e.g. `function f (...) := return arrayElement (arrayElement txs idx).proof k`)
currently still errors on the compilation-model gate before the new
macro arm fires; the lowering edits are committed as a spike alongside
the IR-side infrastructure but a smoke proving the macro path lights up
requires a small additional fix to be debugged in a follow-up.

`lake build` is fully green; the new IR constructor is callable
directly via `Compiler.CompilationModel.Expr.arrayElementDynamicMemberElement`
which is enough for hand-built IR tests and Layer-2 proof callers.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@Th0rgal Th0rgal deleted the feat/dynamic-member-length-g1-1849 branch May 13, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant