Skip to content

fix(diy-validium): reject non-canonical leaf indices in escape hatch - #78

Merged
oskarth merged 1 commit into
masterfrom
fix/validium-escape-index-replay
Jul 28, 2026
Merged

fix(diy-validium): reject non-canonical leaf indices in escape hatch#78
oskarth merged 1 commit into
masterfrom
fix/validium-escape-index-replay

Conversation

@oskarth

@oskarth oskarth commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

_verifyMerkleProof consumes only proof.length bits of leafIndex to derive left/right directions and discards the rest, while claimed is keyed on the full uint256 and leafIndex is unbounded. So leafIndex and leafIndex + k*2^depth are indistinguishable to the verifier but land in distinct claimed slots — one depositor can replay a single valid (leaf, proof) pair until the bridge is empty.

Reproduced against the existing depth-2 fixture: alice, entitled to 1000 at leaf 0, takes all 10000 in 10 replays, after which bob's legitimate escape reverts. escapeWithdraw never calls the RISC Zero verifier, so a real deployment behaves the same. The Rust host and guest carry explicit direction flags alongside the sibling path and are unaffected — the contract is the only place directions are derived from an integer.

Changes

  • Require index == 0 after the proof loop, bounding leafIndex to [0, 2^depth)
  • Regression test test_escapeWithdraw_revertsShiftedIndexReplay (fails before, passes after)
  • SPEC: "Index Canonicality" subsection alongside the existing front-running mitigation

Test plan

  • forge test — 59 passed, 0 failed

Reported by Semih Civelek.

_verifyMerkleProof consumed only proof.length bits of leafIndex while
claimed was keyed on the full uint256, so one valid (leaf, proof) pair
re-verified at leafIndex + k*2^depth and drained the bridge.

Require the index to be fully consumed after the proof loop.
@oskarth
oskarth requested a review from rymnc July 27, 2026 11:08
@oskarth

oskarth commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Written by Claude Code, posted from @oskarth's account.

Verification notes on this fix — what was checked beyond "the existing tests pass".

Fuzzed both directions, 200k runs. No non-canonical index verifies, and every canonical index still does. The second half is the one that matters: a verifier that rejected everything would satisfy the first property on its own, so there's a positive control asserting honest proofs still succeed.

Confirmed the tests aren't vacuous. Reverted the fix, and exactly five tests fail on the old code with concrete counterexamples (e.g. leafIndex = 10680 on the depth-2 fixture), then pass again once restored.

Wrong-length proofs rejected, truncated and over-long. Structurally they have to be: leaf preimages are 72 bytes (pubkey || balance_le || salt), internal-node preimages are 64, so an internal node can't be passed off as a leaf without a SHA-256 collision. That domain separation is load-bearing here.

No cross-root replay. claimed isn't keyed per root, which would matter if the root could move after freezing. It can't — frozen is only ever assigned true, and all three stateRoot writes sit behind frozen-guards, so claimed maps one-to-one onto positions in a single fixed tree.

Padding leaves unclaimable. Non-full trees pad with [0u8; 32] rather than a commitment, so claiming one needs a preimage of the all-zero hash, and balance == 0 reverts regardless.

Checked on a depth-3 tree as well, so the result isn't an artifact of the depth-2 fixture.

The fuzz/invariant tests behind these checks aren't in this PR yet — happy to fold them in if wanted.

To be clear about scope: this establishes that the reported replay is closed and honest escapes still work. It isn't an audit of the contract.

@oskarth
oskarth merged commit 94f1e5c into master Jul 28, 2026
1 check passed
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.

2 participants