Terminology used below, to keep three different things distinct: G1 is the one agreement this pool is permanently pointed at (pool.agreement, set once at deployment). G2 is a second, unrelated agreement that a covered contract can later be re-registered under. The contract is the actual off-chain smart contract stakers underwrote (e.g. a specific protocol address) — whether it is breached is a real-world fact, separate from which agreement's registry entry reports it.
ConfidencePool proves CORRUPTED entirely through one fixed agreement's entry in the AttackRegistry: flagOutcome(CORRUPTED, ...) succeeds only when getAgreementState(agreement) == CORRUPTED, where agreement is set once in initialize() and never reassigned anywhere in the contract. This works as intended as long as a covered contract's registry entry stays under the one agreement the pool was deployed against — which is the implicit assumption baked into checking a single fixed address for the pool's entire lifetime.
That assumption doesn't always hold. BattleChain lets an agreement owner remove a contract from G1 once its commitment window ends (Agreement.removeAccounts() → AttackRegistry.unregisterContractForExistingAgreement()), and the registry's own getAgreementForContract() shows the binding can move to a different agreement, G2, afterward. If the contract is genuinely breached only after that move, the registry records the breach against G2 — never against G1. There is no code path and no moderator discretion that lets this pool prove that breach: the CORRUPTED check reads one fixed address for the pool's entire lifetime, and that address's registry entry will never show CORRUPTED for a breach that happened after the contract moved elsewhere. This is a hard, permanent dead end for that specific breach, not a delay.
DESIGN.md §8 discusses agreement narrowing, but only as a record-keeping residual: "the pool's locked scope may reference accounts no longer in the agreement — but the pool's own commitment to stakers remains the binding source of truth." Read in context, that sentence is about the local scope list possibly going stale while the moderator's off-chain judgement keeps working. It says nothing about G1's own registry entry becoming permanently unable to reflect a real breach because the breach materializes under G2 instead.
Likelihood:
Occurs whenever the agreement owner calls Agreement.removeAccounts() on a covered contract after the BattleChain commitment window ends, and that contract is later re-registered under a different Binding Agreement that goes on to be marked CORRUPTED — a supported, permitted BattleChain-side operation, not a malicious edge case.
Every pool holding that contract in its locked local scope is affected identically, since agreement can never be repointed and _getAgreementState() always resolves through it.
Impact:
Stakers who underwrote a contract according to the pool's locked local scope receive a SURVIVED/EXPIRED-level payout (principal + bonus) even though that contract was, in fact, corrupted — the pool has no on-chain path to reach CORRUPTED for that breach, permanently, regardless of what the moderator knows.
Funds that should route to the bad-faith recoveryAddress path or the good-faith attacker bounty path for that breach are unavailable, because flagOutcome(CORRUPTED, ...) cannot be satisfied for this pool no matter what the moderator believes or intends.
The harness models only G1 (this pool's fixed agreement) — it does not stand up a second G2 contract, since G2's state is irrelevant to what the pool can observe. attackRegistry.setAgreementState(UNDER_ATTACK) represents G1 never advancing to CORRUPTED, which is the only on-chain fact this pool can ever act on — standing in for the real-world case where the contract was genuinely breached, but under G2 after being removed from G1.
This needs a mechanism change, not a call-site fix. Pick one:
Track or query the current Binding Agreement (AttackRegistry.getAgreementForContract(account)) for each locked local-scope account, and allow CORRUPTED resolution when any locally-scoped account's current Binding Agreement is CORRUPTED, not just the pool's original agreement.
Snapshot enough per-account binding information at scope lock to prove later corruption of the covered account even after the agreement narrows.
If neither is feasible, treat this as a documentation fix instead: extend DESIGN.md §8 to state plainly that once an account is unregistered from the pool's original agreement, this pool can never prove CORRUPTED for a later breach of that account — not just that the local scope list may go stale.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.