Merkle Mountain Range carry proofs

Statoblast uses a Merkle Mountain Range (MMR) only for inherited escalation carry: the unresolved deposits a parent Escalation Game exports when its universe forks. Each child universe's continuation game, the child Escalation Game that resumes the dispute during fork continuation, verifies withdrawals against those compact snapshots without replaying the full parent history on-chain.

The hashing primitives live in MerkleMountainRange.sol. Snapshot storage, proof structs, peak bounds, and replay protection live in EscalationGameTypes.sol, EscalationGameCarry.sol, and EscalationGameProofVerifier.sol. Local carry appends live in EscalationGameDepositDelegate.sol.

Leaf fields

Each leaf commits to one unresolved deposit. The encoding fixes the field order and hashing.

Field Why it is committed
depositor Binds the payout to the original depositor; see reporter claims, the per-continuation record of claimed carried deposits that prevents a proof from being replayed.
cumulativeAmountAttoRep Preserves payout-order prefix data.
sourceNodeId Distinguishes otherwise identical leaves copied from different local nodes.

Normative encoding

The leaf ABI types are (address,uint8,uint256,uint256,uint256,uint256), in the order depositor, outcome, amountAttoRep, parentDepositIndex, cumulativeAmountAttoRep, sourceNodeId. outcome is the Solidity enumeration ordinal (0 Invalid, 1 Yes, 2 No). Leaf hashing uses standard ABI word padding through abi.encode; parent hashing uses the 64 raw bytes from abi.encodePacked(left, right), so the order of left and right is significant. There is no additional domain separator.

leafHash = keccak256(abi.encode(depositor, outcome, amountAttoRep,
    parentDepositIndex, cumulativeAmountAttoRep, sourceNodeId))
parentHash(left, right) = keccak256(abi.encodePacked(left, right))

// peaks[0..peakCount-1] are the occupied peaks in ascending height order
bagPeaks(peaks, peakCount):
    if peakCount == 0: return bytes32(0)
    root = peaks[peakCount - 1]
    for i = peakCount - 1 down to 1:
        root = parentHash(peaks[i - 1], root)
    return root

The fold starts at the highest occupied peak and places each lower peak as the left operand. With occupied heights 0, 2, and 3, the root is parentHash(peak0, parentHash(peak2, peak3)). A single peak is the root itself, and an empty range has root bytes32(0).

Proof verification rejects each of the following with the quoted revert string:

Conformance vectors

Input or intermediate Value
Leaf 0 ABI values 0x0000000000000000000000000000000000000001, 0, 1, 0, 1, 0
Leaf 0 hash 0xb01042d963a0c261d64893dfb7e1f221c93ef608653cb92f9ca2c8899ca4572b
Leaf 1 ABI values 0x0000000000000000000000000000000000000002, 1, 2, 1, 2, 1
Leaf 1 hash 0xe0ca39e8852dc96783e8b8f0859cfbf155338f9144ba4b9fdd6af847299fc867
Two-leaf root 0x1c9c7f3c2c8bebf92ce393deb5dccdd253f2b10084af72defb5dcd4486832b27
Leaf 2 ABI values 0x0000000000000000000000000000000000000003, 2, 3, 2, 3, 2
Leaf 2 hash 0x92b2040f7e9b4f1e1b587eda2fb255419704a6ea1693789658adfddbf98389ae
Three-leaf root 0x3488c83edd77c5259c9432e90f972d45d0ced819fd3691eddfe41fe05d676564
Empty nullifier root 0xe2c3ed4052eeb1d60514b4c38ece8d73a27f37fa5b36dcbf338e70de95798caa

Peaks and the 64-peak bound

The carry snapshot stores one peak per set bit in the leaf count. The system fixes MERKLE_MOUNTAIN_RANGE_MAX_PEAKS = 64, and carry initialization requires each snapshot leaf count to be less than 2^64.

The constant caps the number of peak positions, not the number of leaves: any leaf count from 0 up to but not including 2^64 fits inside 64 peak slots.

MMR peaks. The occupied peak heights are exactly the set bits in the snapshot leaf count.

To form one root, the verifier collects the occupied peaks in ascending height order and folds them with bagPeaks.

snapshotLeafCount < 264

Carry initialization rejects a larger leaf count with Leaf count high.

Local carry appends behave like binary addition with carries: a new leaf merges upward through occupied lower peaks until it finds the first empty peak slot. If that upward carry would reach height 64, the append reverts with MMR too tall.

Proof structure

A carried-deposit proof has two parts. First it proves membership in the inherited MMR snapshot for one outcome. Then it proves that the same parentDepositIndex has not already been consumed in this continuation's nullifier tree.

Component Purpose
leafIndex, merkleMountainRangePeakIndex, merkleMountainRangeSiblings Proves the carried deposit belongs to the inherited snapshot root for that outcome.
nullifierSiblings Proves the nullifier leaf is still empty so the carried deposit cannot be replayed.

Carry proof anatomy. The MMR sibling array first reconstructs one selected peak and then supplies every other occupied peak. The independent 64-level nullifier path prevents the same stable deposit identity from being consumed twice.

merkleMountainRangePeakIndex is the occupied peak height, not the ordinal position of that peak among occupied peaks. leafIndex is the leaf's global position across the whole snapshot. The verifier derives the peak that contains that position, requires merkleMountainRangePeakIndex to equal its height, and orders the in-peak siblings by the in-peak offset leafIndex - peakStart, where peakStart is the peakStartIndex returned by getCurrentCarryPeakForLeaf. Claim accounting also keys inherited-versus-local provenance and settled principal by the global position. For example, if the full snapshot has 13 leaves, the height-3 peak holds global leaves 0 through 7, the height-2 peak holds 8 through 11, and the height-0 peak holds 12. A proof for the third leaf of the height-2 peak uses merkleMountainRangePeakIndex = 2 and leafIndex = 10; its in-peak offset is 2.

merkleMountainRangeSiblings is also ordered in two phases. The first merkleMountainRangePeakIndex entries are the bottom-up Merkle siblings inside the selected peak. The remaining entries are the other occupied peak roots in ascending peak-height order, skipping the selected peak height. That exact ordering is why the verifier checks merkleMountainRangeSiblings.length == peakHeight + peakCount - 1.

Nullifier tree

Each continuation outcome keeps a binary Merkle tree of fixed depth NULLIFIER_DEPTH = 64, hashed with the same parentHash.

Plan a carry proof

Choose a snapshot leaf count and one occupied peak. The planner reports the global leafIndex range of that peak, checks an in-peak offset, and reports the exact sibling-array lengths the verifier enforces. It does not construct hashes or replace an off-chain proof builder.

SelectionValid in-peak offset
Selected peak capacity4 leaves; leafIndex 8…11
Snapshot
Proof position
MMR sibling hashes required 4
Nullifier sibling hashes required 64
Binary leaf count 1101₂
Occupied peak heights 0, 2, 3

Snapshots in child continuations

Each continuation outcome stores both an inherited snapshot baseline and a mutable current state.

Field Meaning
snapshotLeafCount and snapshotPeaks The immutable inherited carry commitment.
currentLeafCount and currentPeaks The descendant carry snapshot after inherited snapshot initialization plus local carry appends and local carry-leaf removal. Inherited proof consumption is tracked by currentNullifierRoot and unresolved totals rather than by mutating these peak fields directly.
inheritedUnresolvedTotalAttoRep and localUnresolvedTotalAttoRep The principal totals the current carry state must still represent.
currentNullifierRoot The replay-protection root after any carried proof has been consumed.

getForkCarrySnapshot() reports the current carry peaks, leaf counts, totals, and nullifier roots after local appends and proof consumption. getCarryLeafPageByOutcome() pages only local unresolved leaves in newest-first order. Its maxEntries parameter bounds visited nodes, including consumed leaves. An empty or short page can return a nonzero nextPageNodeId; pass that cursor as startNodeId to continue until the cursor is zero.

Sources