EscalationGame Architecture
EscalationGame is split so storage ownership, settlement
accounting, and proof math do not blur together inside one contract
body. The point of the split is not stylistic. Contributors need to
know which module is allowed to mutate live game state, which module is
only reconstructing proof-derived values, and which invariants a
refactor must preserve.
The main boundary starts with one shared storage layout. The inherited
game stack owns most counters, escrow, carry state, and settlement
effects. Deposit intake runs through a layout-compatible
EscalationGameDepositDelegate using delegatecall,
so those writes still land in the game. The external
EscalationGameProofVerifier owns storage-free proof and
logarithm helpers. That separation keeps verification math reusable and
testable without giving it authority to consume deposits, advance
nullifiers, or move accounting totals.
Refactors usually hit four pressure points: storage ownership, accounting invariants, interface drift, and bytecode headroom. Those are the places where the contract split has to stay explicit or the architecture stops buying clarity.
Module Split
The first question in this stack is always ownership: which file is
allowed to mutate which part of the game. The state-owning modules form
one inheritance ladder over EscalationGameStorage. The
deposit delegate uses the same layout and may mutate it only while the
game invokes it with delegatecall. The proof verifier stays
outside both mutation paths because it should never write live game
state.
Mutable Stack BoundaryThe inherited stack and the layout-compatible deposit delegate mutate the game's shared storage. Shared types provide definitions, while the external proof verifier returns derived values without acquiring storage authority.
Carry Ownership BoundaryEffective inherited principal starts from the stored inherited unresolved total, which carried-proof consumption decrements, and subtracts cumulative principal claimed directly in the immediate parent. After final resolution it is also zero for every inherited losing outcome. Local losing deposits are not part of that shortcut and retain their normal local settlement path.
| Module | Owns | Why that boundary exists |
|---|---|---|
EscalationGameTypes.sol |
Constants and structs shared across the stack. | Common types stay stable even when execution modules move. |
EscalationGameStorage.sol |
The storage layout shared by the inherited game stack and deposit delegate. | Keeping the layout in one base contract makes delegate writes target the same slots as direct game writes. |
EscalationGameState.sol |
Events, constructor wiring, access control, and primitive escrow and unresolved counters over the shared layout. | All inherited modules need one authoritative state-management surface. |
EscalationGameCalculations.sol |
Pure and view attrition, resolution, accepted-deposit, and payout math. | Readers can review formulas without reading mutation paths at the same time. |
EscalationGameCarry.sol |
Fork carry snapshots, Merkle Mountain Range state, nullifier roots, proof verification calls, and local carry consumption. | Carry logic is where proof-derived history starts affecting live state. |
EscalationGameEscrow.sol |
Local and direct-claim escrow records, per-vault aggregate outcome totals, one-time export state, and residual-sweep guards. | Optional parent-vault cleanup stays constant-size because it clears three outcome totals without scanning deposit history. Child continuation claims use aggregate game backing rather than copied per-vault escrow. |
EscalationGameSettlement.sol |
Claim, withdraw, proof-backed settlement, residual sweeping, and public deposit pagination. | Settlement is the place where user-visible balances actually move. |
EscalationGame.sol |
Start and resume entrypoints plus the access-controlled local-deposit entrypoint used by SecurityPool. |
The top-level contract validates the caller and delegates deposit mutation while preserving the public surface. |
EscalationGameDepositDelegate.sol |
Deposit validation, escrow and unresolved accounting, node and carry-leaf creation, and deposit events. | Moving this cohesive mutation path behind delegatecall reduces deployed game bytecode without changing storage ownership. |
EscalationGameProofVerifier.sol |
Storage-free Merkle Mountain Range, nullifier proof, and logarithm helpers. | Proof and attrition helpers can be reused and audited separately because they do not mutate game state. Logarithm normalization preserves extracted powers of two even when the residual term is exactly zero. |
This order matters. New modules should join the inheritance ladder only when they truly need the full state surface. If a helper can compute a value without touching storage, it belongs in the proof verifier, a free function, or a test helper instead of widening the mutable stack.
For the carry-proof data structure itself, including leaf fields, peak bagging, the 64-peak bound, and nullifier replay protection, see Merkle Mountain Range carry proofs.
Accounting Invariants
Security assumptions: A01 risk-adjusted incentives, A10 timely outcome evidence, A03 independent participants, A04 early alarm capital, A16 timely inclusion, A21 genesis REP behavior, A06 lifecycle executors, A20 proof-data availability, and A27 cryptographic security.
Safe refactors keep the local REP and unresolved-deposit counters in sync. Before aggregate export, every active vault lock appears in both its vault-local counter and the corresponding global total. Aggregate export is optional cleanup: it clears those counters and the parent vault's escrow, while the individual deposit rows remain only as canonical proof material and no longer represent parent logical authorization or physical escrow.
Child initialization copies the authenticated carry totals and funds the child game once at the applicable aggregate rate: one child REP per effective source REP for an unrelated fork, or the already-haircut aggregate backing for the escalation's own fork. It does not allocate that backing among vaults. After the child resolves, only proofs for winning deposits may settle. Any caller may relay a proof through the pool, while the authenticated depositor always receives the normal winning payout from the aggregate game balance. Inherited losing principal becomes terminal without a per-leaf transaction; local losing deposits still follow the ordinary local settlement rules.
The stable parent deposit identifier has two domains. In ordinary
games it stays equal to the local depositIndex. In
fork-continuation games it becomes
uint256(keccak256(abi.encode(address(this), outcomeIndex, depositIndex))),
so a child-local deposit and a grandchild-local deposit can never reuse
the same carry/nullifier key just because both were appended at local
index zero. That same identifier is stored in carry leaves, emitted by
LocalDepositAppended,
CarryDepositConsumed, and
ClaimDeposit, returned by
exportUnresolvedDeposit(uint256,...), and consumed through
consumedParentDepositIndexes.
| Invariant | What must remain true |
|---|---|
| Escrow totals | totalEscrowedRep equals the sum of active REP locks, and escrowedRepByVault[vault] is that vault's local component. |
| Local unresolved totals | totalLocalUnresolvedRep equals the sum of unresolved local deposits, and unresolvedRepByVault[vault] is that vault's local component. |
| Outcome carry | For each outcome, currentCarryTotal == effectiveInheritedUnresolvedTotal + localUnresolvedTotal. Effective inherited principal excludes direct parent claims and is zero for inherited losing outcomes after final resolution. |
| Active local deposit | Before aggregate export, Deposit.amount > 0 denotes an active local deposit. After localUnresolvedTotalsExportedByVault[vault] is set, retained nonzero rows are canonical proof material only, not active parent locks. |
| Local deposit consumption | Individual settlement, claim, or index-selected export must zero that deposit, mark its stable parent deposit index consumed, reduce unresolved totals, and update the current carry snapshot. |
| Aggregate vault export | The optional fixed-cost parent cleanup clears only the vault's three outcome aggregates and escrow counters. It preserves individual deposit rows and the canonical carry commitment, and child funding or proof claims do not depend on calling it. |
| Winning carried proof | A valid proof for the final winning outcome advances the nullifier, reduces effective carry, and pays the authenticated depositor from aggregate child-game REP. Settlement burns the winner haircut unless the escalation's own fork already paid it through Zoltar. A losing carried proof cannot receive or consume REP. |
| Continuation funding | An unrelated fork converts escalation source REP to child REP one-to-one; the escalation's own fork transfers post-haircut aggregate backing, while ordinary pool REP remains one-to-one. resumeFromFork keeps the continuation paused until the applicable aggregate backing is present and accounts for child REP already exported by valid direct pre-resume claims. See the canonical fork-migration derivation and FORK-08 for the raw and effective principal definitions and exact funding bound. Neither path scales or releases per-vault child escrow. |
| Inherited losing outcome | After final resolution, inherited principal on every losing outcome contributes zero to live carry and the residual-sweep guard. No losing depositor or third party must submit its leaf. |
| Direct parent claim identity | The stable parent deposit index remains in the carry leaf and nullifier proof. A direct own-fork claim records one factory-scoped lineage id in the shared forker; the fork-migration specification owns the exact construction. Every child checks the same id before consuming its local nullifier and subtracts the claimed principal from effective inherited carry, including children created after the claim. |
The highest-risk paths already have regression coverage. Scenario tests
exercise consumed local carry leaves, constant-size unresolved-vault
export, and lazy child materialization, while
escalationGameInterfaceRegression.test.ts snapshots the
inherited storage layout so refactors do not silently move live slots.
Interface Drift
Public-surface changes need an explicit review trail because a split architecture makes accidental ABI drift easy to miss. The ABI snapshot and bytecode snapshot are there to prove that a surface change was intentional rather than an unnoticed side effect of refactoring one of the modules.
| Artifact | Location | Refresh command | Why it matters |
|---|---|---|---|
| ABI snapshot | solidity/ts/tests/fixtures/escalationGameAbi.snapshot |
bun run update:escalation-game-abi-snapshot |
Detects intentional or accidental changes to public functions, events, errors, and tuple shapes. |
| Runtime bytecode snapshot | solidity/ts/tests/fixtures/escalationGameBytecode.snapshot.json |
bun run update:escalation-game-bytecode-snapshot |
Shows whether executable behavior changed after metadata is stripped from the runtime hash. |
Review the snapshot diff together with the Solidity diff. If the change was not deliberate, fix the contract rather than normalizing the drift by updating the fixture.
Deployment and Bytecode
Security assumptions: A02 viable operating costs, A23 verified deployment, and A26 executable Ethereum gas bounds.
The proof verifier and deposit delegate splits also exist because bytecode headroom is tight. Keeping proof math and the cohesive deposit mutation path outside the deployed game bytecode keeps the contract under the current bytecode budgets and leaves only limited headroom for small execution changes without collapsing the public game contract into an unreviewable blob.
| Measurement | Current value |
|---|---|
| Creation bytecode | 32,138 bytes |
| Deployed bytecode | 24,142 bytes |
| Project deployed-bytecode budget headroom | 434 bytes below 24,576 |
| EIP-170 headroom | 434 bytes below 24,576 |
Any Solidity change can also move deterministic deployment addresses, because address derivation uses init code. After contract changes, regenerate and review every network deployment manifest with the normal artifact workflow. Follow the Operator Reference launch release checklist when those addresses intentionally change.
For gas-sensitive work, measure instead of assuming. Scenario tests are
the first line of defense, and bun run gas-costs is the
follow-up when deployment or settlement costs are part of the change.
Future Extraction Criteria
A module should be extracted only when the ownership boundary becomes clearer after the split. File count by itself is not a reason. The question is whether the new boundary would reduce review risk by making state mutation, proof math, or settlement authority easier to see.
| Possible extraction | Only do it when | Do not do it when |
|---|---|---|
| More math helpers | The logic is storage-free and can be tested independently. | The helper still needs outcomeState, securityPool, or live mutation context. |
| More proof helpers | The caller still owns nullifier advancement, consumed-index accounting, and escrow or carry mutations. | The helper would start deciding which proof effects are consumed onchain. |
| Separate deployed escrow or settlement contracts | A concrete bytecode-limit or upgradeability need appears. | The split would create extra trust, approval, or accounting surfaces without solving a live constraint. |