Permanent freezing of funds in the bridge or in dispute game bonds with no available recovery path
Description
Bug Description
When AggregateVerifier is deployed with PROOF_THRESHOLD = 2 and only one proof type is submitted (or the second proof is nullified), the deposited bond becomes permanently unclaimable. There is no code path that allows the bond to be recovered — not through resolve(), not through claimCredit(), and not through any emergency mechanism, even after arbitrary time.
Relationship to prior audit finding
The Multiproof Audit 1 (Cantina, March 2026) reported a related issue: "Unconditional Proof Threshold Check in resolve Blocks Bond Recovery." That finding identified that resolve() enforced NotEnoughProofs even when the parent game was invalid (CHALLENGER_WINS). The fix (commit b11c86da) correctly moved the NotEnoughProofs check inside the else block, so games with invalid parents can now resolve without meeting the proof threshold.
However, the fix introduced a new dead state that was not present before. The current code has a gap between resolve() and claimCredit() when the parent is valid but proofCount < PROOF_THRESHOLD:
resolve() (line 458): if (proofCount < PROOF_THRESHOLD) revert NotEnoughProofs(); — correctly placed inside the else block (parent is valid), but no fallback exists
claimCredit() (line 613-617): The 14-day escape hatch only activates when proofCount == 0 (expectedResolution == type(uint64).max), NOT when 0 < proofCount < threshold
The root cause is in the claimCredit() escape hatch logic at AggregateVerifier.sol:613-617:
When proofCount == 1 and PROOF_THRESHOLD == 2:
expectedResolution = finite timestamp (now + 7 days via SLOW_FINALIZATION_DELAY) — NOT type(uint64).max
So claimCredit() takes the first branch and requires resolvedAt != 0
But resolve() reverts with NotEnoughProofs because 1 < 2
Neither function succeeds → bond locked permanently
proofCount
expectedResolution
resolve()
claimCredit()
Result
0
type(uint64).max
Reverts
Works after 14 days
Bond recoverable ✓
1 (threshold=1)
finite
Works
Works after resolve
Bond recoverable ✓
1 (threshold=2)
finite
Reverts (NotEnoughProofs)
Reverts (GameNotResolved)
BOND LOCKED FOREVER
2 (threshold=2)
finite
Works
Works after resolve
Bond recoverable ✓
This is a distinct bug from the Audit 1 finding:
Audit 1: resolve() blocked by NotEnoughProofs when parent was invalid → Fix: skip threshold check when parent is CHALLENGER_WINS
This finding: claimCredit() escape hatch has a gap for 0 < proofCount < threshold when parent is valid → No fix exists
Severity
High — Permanent freezing of user funds with no recovery path.
Impact
Scenario A — No second proof submitted:
A proposer creates a game with a TEE proof and deposits their bond (e.g., 1 ETH). The ZK prover experiences downtime, or no one provides the second proof before the game expires. After 7 days, gameOver() = true and no more proofs can be submitted. The bond is locked forever.
Scenario B — Proof nullification causes mass bond locking:
Many games exist with both TEE + ZK proofs. A legitimate soundness issue triggers ZK_VERIFIER.nullify() globally (as designed — Verifier.sol:44). All in-progress games lose their ZK proof (proofCount drops from 2 to 1). The ZK verifier is globally disabled, so no replacement ZK proof can be provided. All affected games' bonds are permanently locked.
This is particularly dangerous because:
The PROOF_THRESHOLD constructor parameter accepts both 1 and 2 as valid values
The bond locking is silent — there's no revert message indicating the state is unrecoverable
The proposer has no way to distinguish between "wait longer" and "your bond is gone forever"
Scenario B can affect many games simultaneously from a single nullification event
Recommendation
Extend the escape hatch in claimCredit() to cover the case where the game is expired and unresolvable:
Proof of Concept
File: test/multiproof/PoCBondLocked.t.sol
The test deploys AggregateVerifier with PROOF_THRESHOLD = 2 (custom setUp, not using BaseTest defaults). Two tests demonstrate the bug:
testPoCBondPermanentlyLocked: Creates a game with only 1 TEE proof. After 7 days, gameOver() = true but resolve() reverts with NotEnoughProofs. claimCredit() reverts with GameNotResolved. Even after 365 days, both functions still revert. Bond permanently locked.
testPoCBondLockedAfterNullification: Creates a game with both proofs (proofCount=2). ZK proof is nullified (legitimate soundness issue). proofCount drops to 1, ZK verifier is globally dead. Game expires, resolve fails, claimCredit fails. Bond permanently locked.
Run: forge test --match-path test/multiproof/PoCBondLocked.t.sol -vv
Both tests PASS, confirming the bond is unrecoverable in both scenarios.