Merkle Mountain Range Carry Proofs

Statoblast uses a Merkle Mountain Range 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 onchain.

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 to the beneficiary vault.
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.

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.

Merkle Mountain Range peaks are determined by set bits in the leaf count A leaf count of thirteen is shown as binary one one zero one, corresponding to occupied peaks at heights zero, two, and three which are later bagged into one root. leafCount = 13 binary 1101 set bits at heights 0, 2, 3 peak 0 peak 2 peak 3 occupied peaks root

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().

snapshotLeafCount < 264

Carry Snapshot BoundThe 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.
A carried-deposit proof has an MMR membership lane and a separate nullifier lane The membership lane combines the carried deposit leaf with bottom-up siblings inside its selected peak, then with other occupied peak roots in ascending height order to reconstruct the snapshot root. The nullifier lane hashes the stable parent deposit index and combines it with a fixed-depth sibling path to prove the claim has not already been consumed. MMR membership lane Deposit leaf peak-local leafIndex In-peak siblings bottom-up count = peak height Selected peak peakIndex is its height Other peak roots ascending height order then bag right to left Snapshot root nullifier lane parentDepositIndex stable replay identity Nullifier siblings fixed depth = 64 Current nullifier root empty leaf proves unspent

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 offset inside that selected peak, so the verifier requires leafIndex < 2^merkleMountainRangePeakIndex. For example, if the full snapshot has 13 leaves, its occupied peak heights are 0, 2, and 3. A proof for a leaf inside the height-2 peak uses merkleMountainRangePeakIndex = 2 and a peak-local leafIndex in 0...3, not the leaf's global index across all 13 leaves.

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.

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.
inheritedUnresolvedTotal and localUnresolvedTotal The principal totals the current carry state must still represent.
currentNullifierRoot The replay-protection root after any carried proof has been consumed.

The continuation 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.

Sources