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:
- an outcome whose snapshot has no leaves:
Carry peak absent; - a zero
amountAttoRep:Proof amount zero; leafIndex >= leafCount:Bad carry leaf;- a
merkleMountainRangePeakIndexthat is not the height of the peak containingleafIndex:Bad carry peak; - a sibling array whose length is not
peakHeight + peakCount - 1:Bad MMR proof length; - a reconstructed root that differs from the bagged snapshot peaks:
Bad carry proof; - a nullifier path whose length is not
64:Bad nullifier length; - a nullifier path that does not reproduce the current nullifier root from an empty leaf, which includes an already-consumed nullifier:
Bad nullifier proof.
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.
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.
- The path is
uint256(keccak256(abi.encode(parentDepositIndex))). Only its low 64 bits are used: bitd, counted from the least significant bit, selects the side at depthd, starting at the leaf. A0bit hashesparentHash(node, sibling); a1bit hashesparentHash(sibling, node). - An unconsumed leaf is
bytes32(0); a consumed leaf isbytes32(uint256(1)). - The empty root is
bytes32(0)hashed with itself 64 times:root = parentHash(root, root). Its value is in the conformance vectors. nullifierSiblingslists the 64 siblings from the leaf upward. The verifier recomputes the current root with the empty leaf, then stores the root recomputed with the consumed leaf.
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.
Snapshot
Proof position
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.