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 is simple. The game stack owns storage, counters, escrow, carry state, and settlement effects. 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 under EscalationGameState, while the proof verifier stays outside that ladder because it should never write live game state.

EscalationGame module split between state-owning modules and the external proof verifier A left-hand inheritance chain runs from state into calculations, carry, escrow, settlement, and the public game contract. Shared types sit outside that chain as imported definitions, while a separate proof verifier sits to the right and feeds verified math back into the mutable stack without inheriting storage. Types State Calculations Carry Escrow Settlement EscalationGame ProofVerifier shared structs and constants storage-free proof math one inheritance chain shares the mutable state surface

Mutable Stack BoundaryEscalationGameState, EscalationGameCalculations, EscalationGameCarry, EscalationGameEscrow, EscalationGameSettlement, and EscalationGame form one inheritance chain that shares the mutable state surface. EscalationGameTypes is shared type and constant material, and the proof verifier stays separate so proof math can be reused without acquiring authority to mutate live game state.

currentCarryTotal = inheritedUnresolvedTotal + localUnresolvedTotal

Carry Ownership BoundaryThe split only stays safe while every module preserves the rule that an outcome's live carry equals inherited unresolved exposure plus unresolved local exposure.

Module Owns Why that boundary exists
EscalationGameTypes.sol Constants and structs shared across the stack. Common types stay stable even when execution modules move.
EscalationGameState.sol Storage layout, events, constructor wiring, access control, and primitive escrow and unresolved counters. All inherited modules need one authoritative storage 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 Forked escrow records, vault export cursors, batch export bounds, and child REP accounting. Escrow export has its own mutation surface and batching risks.
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 local deposit intake from SecurityPool. The top-level contract should stay small enough to show the public surface clearly.
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.

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.

Accounting Invariants

Safe refactors keep the local REP and unresolved-deposit counters in sync. The shortest mental model is that every active lock must still appear in both its vault-local counter and the corresponding global total until settlement, claim, or export consumes it.

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, CarriedDepositClaimed, and ClaimDeposit, returned by both exportUnresolvedDeposit overloads, 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 == inheritedUnresolvedTotal + localUnresolvedTotal.
Active local deposit A local deposit stays active while Deposit.amount > 0.
Local deposit consumption Settlement, claim, or export must zero the amount, mark the stable parent deposit index consumed, reduce unresolved totals, and update the current carry snapshot.
Inherited settlement order Proof-backed settlement consumes inheritedUnresolvedTotal first and only then uses local unresolved total for any remainder.
Forked escrow bounds Forked escrow settlement releases child REP proportionally against source principal, and claimed counters never exceed recorded principal or child REP.

The highest-risk paths already have regression coverage. Scenario tests exercise consumed local carry leaves and bounded unresolved-vault export, 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

The proof verifier split also exists because bytecode headroom is tight. Keeping proof math outside the state-owning contract 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 26,980 bytes
Deployed bytecode 23,927 bytes
Project deployed-bytecode budget headroom 73 bytes below 24,000
EIP-170 headroom 649 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 deployment outputs with the normal artifact workflow, and keep docs/mainnet-deployment-addresses.json aligned 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.