Merkle Mountain Range carry proofs
Statoblast uses a Merkle Mountain Range (MMR) only for inherited escalation carry. Parent escalation games export unresolved deposits into compact snapshots, and child continuations later verify withdrawals against those 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.
Leaf shape and hash order
Each leaf hashes one unresolved deposit with depositor, outcome, amount, parentDepositIndex, cumulativeAmount, and sourceNodeId. Leaf hashes use keccak256(abi.encode(...)). Parent hashes use keccak256(abi.encodePacked(left, right)).
| Field | Why it is committed |
|---|---|
depositor |
Binds the proof and payout to the original depositor. This ownership is immutable: liquidation moves only pool-held vault Reputation (REP) backing and cannot acquire or split the committed escalation claim. |
outcome |
Separates Invalid, Yes, and No carry domains. |
amount |
Commits to the source principal. |
parentDepositIndex |
Provides the stable identity later consumed by nullifiers. |
cumulativeAmount |
Preserves payout-order prefix data. |
sourceNodeId |
Distinguishes otherwise identical leaves copied from different local nodes. |
Hash order matters. Internal nodes always hash left before right, so proofs are position-sensitive inside each peak.
Normative encoding
The leaf application binary interface (ABI) types are (address,uint8,uint256,uint256,uint256,uint256), in the order depositor, outcome, amountAttoRep, parentDepositIndex, cumulativeAmountAttoRep, sourceNodeId. outcome is the Solidity enumeration ordinal. Leaf hashing uses standard ABI word padding through abi.encode; parent hashing uses the 64 raw bytes from abi.encodePacked(left, right). There is no additional domain separator.
leafHash = keccak256(abi.encode(depositor, outcome, amountAttoRep,
parentDepositIndex, cumulativeAmountAttoRep, sourceNodeId))
parentHash = keccak256(abi.encodePacked(left, right))
root = bagPeaks(occupiedPeaks, peakCount)
Proof verification rejects leafIndex >= leafCount, a merkleMountainRangePeakIndex that is not the height of the peak containing leafIndex, a sibling array whose length is not peakHeight + peakCount - 1, a nullifier path whose length is not 64, a root mismatch, or an already-consumed nullifier.
Two-leaf conformance vector
| 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 |
| Peak 1 / root | 0x1c9c7f3c2c8bebf92ce393deb5dccdd253f2b10084af72defb5dcd4486832b27 |
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.
That does not cap the snapshot at 64 leaves. It caps the number of peak positions. Any leaf count from 0 up to but not including 2^64 is valid because its binary decomposition fits inside 64 peak slots.
MMR peaksThe occupied peak heights are exactly the set bits in the snapshot leaf count. Those occupied peaks are later bagged into one root.
To form one root, the verifier collects occupied peaks in ascending height order and then bags them from right to left with bagPeaks().
The 64-peak constant means each inherited snapshot leaf count must fit below 2^64.
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 anatomyThe 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.
The index semantics are stricter than the field names might suggest. 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.
Separately, the verifier requires nullifierSiblings.length == NULLIFIER_DEPTH with NULLIFIER_DEPTH = 64.
Nullifier paths are keyed by uint256(keccak256(abi.encode(parentDepositIndex))), so the stable parent deposit index is the replay-prevention identity.
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. |
The continuation application programming interface (API) exposes the current descendant carry snapshot through getForkCarrySnapshot() and pages only local unresolved leaves through getCarryLeafPageByOutcome(). The immutable inherited baseline remains in snapshotPeaks and snapshotLeafCount inside outcome state, while getForkCarrySnapshot() reports the current carry peaks, current leaf counts, current totals, and current nullifier roots after local appends and proof consumption.