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