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.

currentCarryTotal = effectiveInheritedUnresolvedTotal + localUnresolvedTotal

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.