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