74427 sc low permanent freezing of first game bonds with no recovery path cascading liveness halt on descendants proof threshold 2
Submitted on Apr 22nd 2026 at 13:38:04 UTC by @stendal for Audit Comp | Base Azul
Report ID: #74427
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof
Impacts:
Permanent freezing of funds in the bridge or in dispute game bonds with no available recovery path
Description
With PROOF_THRESHOLD=2 (valid per the InvalidProofThreshold guard in the constructor), any first game whose parent is ANCHOR_STATE_REGISTRY and that reaches gameOver() with a single proof submitted cannot be resolved, cannot have credit claimed, and has no admin escape. The bond locks forever.
The exact chain through the code:
resolve()reverts atAggregateVerifier.sol:458withNotEnoughProofsbecauseproofCount == 1 < PROOF_THRESHOLD == 2.claimCredit()at:613-617branches onexpectedResolution == type(uint64).max. That equality comes from_getDelay()at:821only whenproofCount == 0. WithproofCount == 1,_getDelay()returnsSLOW_FINALIZATION_DELAY(7 days) — a finite value — soclaimCreditstays on theresolvedAt != 0branch and reverts withGameNotResolved. The 14-day timeout fallback is unreachable.verifyProposalProof()at:339containsif (gameOver()) revert GameOver(). Once the 7-day window lapses, the missing proof type cannot be supplied — even if the second prover comes back online the next block.challenge()does not rescue the game either._checkIntermediateRootat:1003requiresintermediateRootToProve != intermediateOutputRoot(index). A late, honest proof that agrees with the claim is rejected.nullify()needs a second valid proof for a different state. Without a soundness break in SP1 or Nitro attestation, that path is closed.
Admin-level actions I checked all fail for first games:
blacklistDisputeGame(stuckGame):resolve()consultsisGameBlacklisted(parentGame)inside_getParentGameStatusat:938. For a first gameparentAddress() == ANCHOR_STATE_REGISTRY, so execution falls into thereturn GameStatus.DEFENDER_WINSbranch at:943without ever checking the stuck game's own blacklist flag. Blacklisting is a no-op.setRespectedGameType(other): irrelevant —_getParentGameStatusreturnsDEFENDER_WINSforparent == ASRregardless of the respected type.updateRetirementTimestamp(): retirement is checked only on parent games. The parent here is ASR, which is not a game.setImplementation(newImpl)on the factory: affects newly-created clones only. Existing EIP-1167 minimal proxies carry the old implementation address hardcoded in runtime bytecode and cannot be upgraded.
The comment inside _increaseExpectedResolution at :809-811 reads:
"we give enough time to resolve the issue and possibly blacklist this game"
The developer-documented recovery expectation does not hold for first games. Blacklisting the stuck game itself is a no-op (see above); the comment describes a mechanism the contract does not provide in this scenario.
Cascade to descendant games
Stuck first games freeze the subtree built on top of them. Any child game that declares the stuck parent as its parentAddress() has _getParentGameStatus return IN_PROGRESS (live call to the stuck parent), and resolve() on the child reverts with ParentGameNotResolved. The child cannot close regardless of its own proofCount.
The only way to unblock descendants is an off-chain admin call to blacklistDisputeGame(stuckParent). After that, the child's _getParentGameStatus hits the blacklist branch at AggregateVerifier.sol:941-943 and returns CHALLENGER_WINS, the child's resolve() takes the branch at :453-454 and sets status = CHALLENGER_WINS without overwriting bondRecipient (which was set to gameCreator() at :408 during initialization). Descendant proposers get their bonds back after the manual admin step. The first game's bond remains frozen.
Net effect: a proof-system hiccup that leaves one first game with proofCount=1 forces a manual admin response before the dispute tree below it can progress.
Trigger conditions
PROOF_THRESHOLD=2 plus one-of:
ZK prover (SP1 / Succinct gateway) downtime past the 7-day window
TEE outage (AWS Nitro availability, enclave key issues) with ZK in first
Cert-chain expiry on Nitro between proof generation attempts
Any deployment event where one proof type is available within 7 days and the other is not
The 7-day SLOW_FINALIZATION_DELAY is the grace period. If the second proof lands inside that window, resolve() closes normally. The bug is only reachable when the window elapses with proofCount == 1.
Impact scaling
Per game:
bondAmountfrozen, permanently.Partial prover outage of
Ndays at rateKfirst-games/day: roughlyN × K × bondAmountpermanently frozen, plus the full subtree below each stuck game requires manual admin blacklist to unblock.proofThreshold == 2is one of two branches explicitly admitted by the constructor. Whatever branch the contract admits must be safe under the audit scope.
Proof of Concept
Target
Repo:
github.com/base/contractsat tagv8.1.0(commit01dad230390cd69bcf130b5fc7a7a580b31650a7)File under test:
src/multiproof/AggregateVerifier.solTest file to add:
test/multiproof/StuckBond_PROOF_THRESHOLD_2.t.solTooling: Foundry
forge 1.5.1-stable, solc0.8.15(matches the contract pragma)
The PoC exercises the full production stack — real AnchorStateRegistry, DisputeGameFactory, DelayedWETH, and AggregateVerifier — all from src/dispute/ and src/multiproof/ of the audited repository. The only substitution is src/multiproof/mocks/MockVerifier.sol, whose verify() returns true. The stuck-bond mechanics live entirely inside the AggregateVerifier state machine (proofCount tracking, expectedResolution progression, gameOver gating, parent-status dispatch) and do not depend on TEE/ZK proof internals. Plugging in SP1 + Nitro in place of MockVerifier produces the same revert sequence.
Setup
Save the file below as test/multiproof/StuckBond_PROOF_THRESHOLD_2.t.sol, then:
Test file (runnable, complete)
Expected output (actual forge test run against v8.1.0)
What each test demonstrates
ResolveAndClaimCreditBothRevert
Core: at gameOver() with proofCount=1, both resolve() and claimCredit() revert. Bond inaccessible.
StillStuckAfter14Days
14-day timeout fallback in claimCredit is unreachable when expectedResolution != max.
CannotAddSecondProofAfterGameOver
verifyProposalProof blocks late second proof via if (gameOver()) revert GameOver(). Missing proof cannot be supplied.
BlacklistSelfDoesNotUnstick
Admin blacklist of the stuck game is a no-op — resolve consults isGameBlacklisted(parentGame) at AggregateVerifier.sol:941, not the game itself.
ChangeRespectedGameTypeDoesNotUnstick
_getParentGameStatus hardcodes DEFENDER_WINS for parent == ASR regardless of respected type.
RetirementTimestampDoesNotUnstick
Retirement check is parent-only; ASR parent is not a game.
SetImplementationDoesNotUnstickExistingGame
Factory impl upgrade applies to new clones only. Existing EIP-1167 proxies retain old bytecode and PROOF_THRESHOLD.
ExpectedResolutionNotMax
Confirms expectedResolution is finite (7d) — closes the only recovery branch in claimCredit.
CascadeToChildren
Stuck first game propagates to descendants. Child resolve reverts with ParentGameNotResolved. Admin must blacklist parent to unblock children. Parent's own bond stays frozen.
MultipleGamesStuckDuringOutage
Quantified funds-at-risk: 10 first games × 1 ETH = 10 ETH permanently locked on one 7-day partial prover outage. Asserted on-chain by totalStuckBonds == numGames * INIT_BOND.
Funds at risk
Per stuck game:
bondAmountfrozen permanently.Outage-driven scaling:
N_days × K_games_per_day × bondAmountfrozen during a partial prover outage. Each stuck first game also requires off-chain adminblacklistDisputeGameto release its descendant subtree.Asserted in the PoC: 10 ETH locked per one 7-day outage with 10 first-games/day at 1 ETH bond. At mainnet production bond sizes (OP Stack precedent: 0.08–10 ETH per game) the impact grows proportionally.
proofThreshold == 2is one of two branches explicitly admitted by the constructor (InvalidProofThresholdguard permits only1or2). Whatever configuration the contract admits must be safe under the audit scope.
Independent validation on live fork (secondary, optional)
The same revert sequence reproduces on a Hoodi fork against the deployed DGF / ASR / DelayedWETH (FindingB_LiveExploit.t.sol):
Deployed addresses exercised: DGF 0x154972aB98A5e321a9c7aB7677973f1F501a8090, ASR 0x60A6C389F0BC5cE4269A40d9695927bC58700328, DelayedWETH from the live implementation. Result: NotEnoughProofs on resolve(), GameNotResolved on claimCredit(), bond 50000000000000000 wei stuck on-chain after vm.warp of 100+ days.
Environment
Was this helpful?