fix(diy-validium): reject non-canonical leaf indices in escape hatch - #78
Conversation
_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.
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. Wrong-length proofs rejected, truncated and over-long. Structurally they have to be: leaf preimages are 72 bytes ( No cross-root replay. Padding leaves unclaimable. Non-full trees pad with 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. |
_verifyMerkleProofconsumes onlyproof.lengthbits ofleafIndexto derive left/right directions and discards the rest, whileclaimedis keyed on the fulluint256andleafIndexis unbounded. SoleafIndexandleafIndex + k*2^depthare indistinguishable to the verifier but land in distinctclaimedslots — 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.
escapeWithdrawnever 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
index == 0after the proof loop, boundingleafIndexto[0, 2^depth)test_escapeWithdraw_revertsShiftedIndexReplay(fails before, passes after)Test plan
forge test— 59 passed, 0 failedReported by Semih Civelek.