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.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.