Normal behavior: docs/DESIGN.md section 2 is explicit about what happens when a pool reaches its deadline (expiry) while the protocol is still attackable:
"A pool reaching
expirywhile the registry is still in an active-risk state means the agreement survived the full term the stakers underwrote. ResolvingEXPIREDand returning principal + bonus is the intended payout — not an 'escape.' There is no breach to settle."
Stakers sign up to cover a fixed window of time. If nothing bad happens to the covered contracts during that window, they get their money back plus their share of the bonus.
The bug, in seven steps:
Registry goes UNDER_ATTACK (real danger, sealed — riskWindowStart is now nonzero).
Pool reaches expiry — still UNDER_ATTACK, nothing bad has actually happened yet.
Nobody calls claimExpired() in that window.
Registry flips to CORRUPTED (the real breach happens, 2 days late — after the term the staker underwrote).
The moderator sees this and calls flagOutcome, marking it CORRUPTED and naming a whitehat.
The whitehat calls claimAttackerBounty() and gets the whole pool.
The staker has no button left to press — she can't do anything anymore.
The root cause: flagOutcome() never checks block.timestamp or expiry anywhere. It only checks what the registry says right now. So step 5 succeeds regardless of how long after expiry the breach in step 4 actually happened.
No documentation anywhere covers this specific race. Every mention of the word "race" in docs/DESIGN.md, README.md, protocol-readme.md, and every code comment was checked — there are exactly four, and none of them is this one:
DESIGN §4 — the re-flag correction window (can the moderator change their mind after a claim?). Different question entirely.
DESIGN §5, "the no-risk-window CORRUPTED race" — requires riskWindowStart == 0 (no risk ever observed). This finding's precondition is the opposite: riskWindowStart is already sealed and nonzero (step 1), because real risk genuinely happened during the term. §5's whole justification — "we can't tell if this was in-scope, so default to the staker-favorable outcome" — doesn't apply here; there's no such ambiguity in this scenario.
DESIGN §7, "contested public race" — about biasing the bonus formula's upper bound (T) by delaying pokeRiskWindow(). A completely different mechanism (bonus math, not CORRUPTED eligibility).
DESIGN §9 — the staker-vs-flagOutcome race via withdraw(). Doesn't apply here either: withdraw() is already permanently dead the moment riskWindowStart sealed in step 1, long before the breach in step 4 happens.
Zero of the four documented races share this scenario's precondition (riskWindowStart != 0) or its trigger (a breach observed after expiry). This gap is genuinely undocumented.
Why this survives the "the moderator is trusted" objection. The moderator in step 5 isn't acting against its documented role — docs/DESIGN.md section 8 authorizes flagging CORRUPTED whenever "the in-scope contracts were the breach surface," with no mention of timing, and naming a genuine whitehat in good faith is exactly what the moderator is supposed to do. That is precisely why this finding is built around the good-faith path in step 5/6, not a bad-faith one: in a bad-faith flag, the swept funds would land on recoveryAddress — the sponsor's own wallet — so the only "beneficiary" would be a party already inside the pool's trust boundary. Here, the beneficiary in step 6 is the whitehat — an unprivileged, external, named third party with no special relationship to the pool. The moderator's untimed action in step 5 chains directly into the bountyEntitlement formula, and that formula pays an outsider the entire pool, principal included.
The evidence gets erased too. riskWindowEnd is capped at expiry no matter how late the real observation happens:
The remaining honest limit of this finding: the staker isn't defenseless. If she (or literally anyone) calls claimExpired() at step 2/3 before the breach lands, she locks in EXPIRED and forecloses the moderator's later flag entirely. The bug is that correct resolution depends entirely on who transacts first — nothing in the contract enforces the right outcome if nobody rushes to claim.
Likelihood: Low
Needs risk to materialize during the term (step 1 — common, that's the pool's purpose), survive to expiry still attackable (step 2 — the documented normal case per section 2), and then the actual breach lands after expiry before anyone calls claimExpired() (steps 3–4). That is a race any staker can win with one transaction the instant expiry arrives.
Not an admin abusing a privilege — the moderator (step 5) is following the documented rule for good-faith CORRUPTED.
Impact: Medium
If the staker loses the race, she loses 100% of her principal to an unprivileged third party (step 6), on funds the docs say she already survived to keep.
Staked capital is at risk, not just bonus.
Bounded by the fact that the correct outcome is always one permissionless transaction away, and by the brief self-correction window the moderator has before the whitehat actually claims.
To run: paste the function below into test/unit/ConfidencePool.t.sol (anywhere inside the existing contract ConfidencePoolTest block), then:
No new imports needed — that file already imports IConfidencePool, IAttackRegistry, PoolStates, and extends BaseConfidencePoolTest.
Result:
| Vulnerability (breach flagged first) | Control (staker claims first) | |
|---|---|---|
Who transacts first at/after expiry |
moderator's flagOutcome(CORRUPTED, true, whitehat) |
staker's claimExpired() |
| Outcome | CORRUPTED |
EXPIRED |
| Staker receives | 0 |
1500e18 (stake + bonus) |
| Unprivileged whitehat receives | 1500e18 |
0 (never named) |
Require the terminal observation behind a CORRUPTED flag to have landed at or before expiry. If the breach was only ever observed after the term ended, fall through to EXPIRED instead — the payout docs/DESIGN.md section 2 already says is correct in that situation.
Limit of this fix: it uses riskWindowEnd == expiry as a proxy for "observed after expiry," which works given the existing cap but can't distinguish "observed exactly at expiry" from "observed later and capped." A more precise fix records an uncapped terminal-observation timestamp separately and gates on that directly.
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.