Normal behavior: an outcome is either judged by the moderator via flagOutcome, or mechanically resolved by the permissionless claimExpired() backstop — after either, claimsStarted finalizes it against re-flagging.
The issue: claimExpired()'s mechanical branch reads registry state live at call time and latches claimsStarted in the EXPIRED/SURVIVED branches too, not just the auto-CORRUPTED one. Whoever calls it first, at the exact moment expiry is reached, permanently decides the outcome — even if a CORRUPTED confirmation is already in flight and lands moments later.
Note: docs/DESIGN.md §5 already discusses a race on this function, but scopes it explicitly to riskWindowStart == 0 (no risk ever observed) and treats it as accepted. This finding is the broader, undocumented case — riskWindowStart != 0, a real attack window was observed and sealed on-chain — which the existing test suite (ClaimExpiredRegistryGate.t.sol) does not cover.
Likelihood:
claimExpired() carries no access control and reads registry state live at call time, so whichever caller — bot, bystander, or the sponsor — lands first at the exact expiry timestamp single-handedly decides the outcome for every other participant.
A real attack confirmation always has some on-chain latency between being decided upstream and being written to the registry, so there is a narrow but real window right at the boundary where the true state and the on-chain-visible state diverge.
Impact:
The moderator's flagOutcome(CORRUPTED, ...) becomes permanently unusable the instant the race is lost, no matter how strong the evidence of an in-scope breach is.
Every staker recovers full principal + bonus as though the pool simply survived, while an honest whitehat who found and reported a real vulnerability never receives their bounty.
recoveryAddress (the pool's insurance/treasury backstop) receives nothing, even though the underlying agreement was genuinely corrupted in-scope.
The test reproduces the race in four steps:
Alice stakes 100, Carol contributes 20 bonus. _passThroughUnderAttack() seals riskWindowStart — confirming this is not the already-accepted riskWindowStart == 0 case.
Time is warped to pool.expiry(). The registry still reads UNDER_ATTACK — the CORRUPTED confirmation hasn't landed yet.
dave, who holds zero stake, calls claimExpired(). Outcome locks EXPIRED; claimsStarted latches.
The registry updates to CORRUPTED a moment later. The moderator's flagOutcome(CORRUPTED, ...) reverts OutcomeAlreadySet. Alice claims her full 120 tokens back, as if nothing happened.
A second control test (testCorruptedConfirmationLandingFirstResolvesCorrectly) reproduces identical facts but flips the order of just two calls — CORRUPTED confirmation lands before anyone calls claimExpired() — and the pool resolves correctly, sweeping the full 120 tokens to recovery. The only variable that changes between the two tests is transaction order.
Verified locally: forge test --match-contract ConfidencePoolClaimExpiredRaceTest → 2 passed.
The core fix is to stop finalizing EXPIRED immediately whenever a real risk window was observed and the state isn't yet decidable — instead park it behind a short, fixed settlement buffer so an in-flight CORRUPTED confirmation has a real chance to land first.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.