Skip to content

Commit c98ee8f

Browse files
authored
fix: pos exit proof non power of 2 merkle (#908)
* fix(pos exit-proof): pad merkle leaves to power of 2 Polygon's matic-js MerkleTree (and the pos-contracts / pos-portal test helpers it mirrors) builds a complete fixed-depth binary tree by padding the leaf layer with zero hashes to the next power of 2. The on-chain verifier (pos-contracts/contracts/common/lib/Merkle.sol checkMembership) requires that exact shape — it asserts index < 2^proofHeight and walks a fixed-depth tree. merkleProof was instead duplicating the last node per layer when an odd count was reached. That coincidentally matches matic-js when the leaf count is already a power of 2 (mainnet's 128-block checkpoints), so the bug never surfaced in production. On a fast-cadence devnet validators sometimes submit checkpoint ranges that aren't powers of 2 (e.g. 40 blocks), and the resulting tree root differs from L1's stored headerRoot — startExitWithBurntTokens then reverts with WITHDRAW_BLOCK_NOT_A_PART_OF_SUBMITTED_HEADER. Pad once at the leaf layer to the next power of 2 with zero hashes, then build cleanly. Adds cmd_test.go with an independent reproduction of the on-chain verifier and a regression case at 40 leaves. * chore(pos exit-proof): drop merkle test file
1 parent 4847986 commit c98ee8f

1 file changed

Lines changed: 22 additions & 8 deletions

File tree

cmd/pos/exitproof/cmd.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -666,19 +666,33 @@ func fetchBlockHashesBatched(ctx context.Context, rpc *ethrpc.Client, start, end
666666

667667
// merkleProof builds a binary Merkle tree from the given leaf hashes and returns
668668
// the concatenated sibling hashes (proof) for leafIdx.
669-
// Construction matches the matic.js MerkleTree: odd-length layers duplicate the last leaf.
670-
// Internal nodes: keccak256(left || right).
669+
//
670+
// Construction matches matic.js's MerkleTree (and pos-contracts/pos-portal test
671+
// helpers): pad the leaf layer to the next power of 2 with zero hashes, then
672+
// build a complete binary tree by hashing keccak256(left || right) per pair.
673+
// The on-chain verifier (Merkle.checkMembership in pos-contracts) requires this
674+
// exact shape — it asserts index < 2^proofHeight and walks a fixed-depth tree.
675+
//
676+
// Earlier revisions tried to handle odd layers by duplicating the last node
677+
// per-layer. That happens to coincide with matic.js when the leaf count is
678+
// already a power of 2 (mainnet's 128-block checkpoints), but produces a
679+
// different root for any other leaf count — which on a fast-cadence devnet
680+
// surfaced as WITHDRAW_BLOCK_NOT_A_PART_OF_SUBMITTED_HEADER reverts whenever
681+
// validators submitted a non-power-of-2 checkpoint range.
671682
func merkleProof(leaves []common.Hash, leafIdx uint64) []byte {
672-
layer := make([]common.Hash, len(leaves))
683+
// Pad to the next power of 2 with zero hashes. nextPow2(0) = 1.
684+
n := uint64(len(leaves))
685+
size := uint64(1)
686+
for size < n {
687+
size <<= 1
688+
}
689+
layer := make([]common.Hash, size)
673690
copy(layer, leaves)
691+
// remaining entries are already the zero hash (Go zero-initializes).
674692

675693
var siblings []common.Hash
676694
pos := leafIdx
677-
678695
for len(layer) > 1 {
679-
if len(layer)%2 == 1 {
680-
layer = append(layer, layer[len(layer)-1])
681-
}
682696
sibling := pos ^ 1
683697
siblings = append(siblings, layer[sibling])
684698

@@ -687,7 +701,7 @@ func merkleProof(leaves []common.Hash, leafIdx uint64) []byte {
687701
next[i] = crypto.Keccak256Hash(layer[i*2][:], layer[i*2+1][:])
688702
}
689703
layer = next
690-
pos = pos / 2
704+
pos /= 2
691705
}
692706

693707
result := make([]byte, len(siblings)*32)

0 commit comments

Comments
 (0)