claimExpired resolves a genuinely CORRUPTED agreement as EXPIRED whenever the risk window was never observedWhen claimExpired() resolves an UNRESOLVED pool past expiry, it reads the live registry state. If that state is CORRUPTED and a risk window was previously observed (riskWindowStart != 0), the pool auto-resolves as CORRUPTED and later sweeps the full pool (principal + bonus) to recoveryAddress. Otherwise it falls through to EXPIRED, returning full principal to every staker.
riskWindowStart is only ever set by _observePoolState(), and only while it observes the registry sitting in an active-risk state (UNDER_ATTACK/PROMOTION_REQUESTED). That function runs lazily — only when someone calls stake/withdraw/pokeRiskWindow/flagOutcome/claimExpired. If nobody happens to interact with the pool while the agreement briefly sits in an active-risk state before flipping to CORRUPTED, the pool's first-ever observation of the registry is CORRUPTED itself, riskWindowStart stays 0, and claimExpired incorrectly takes the EXPIRED path despite the agreement being genuinely breached.
Likelihood:
Occurs whenever no staker/withdrawer/moderator happens to call into the pool during the window the registry sits in UNDER_ATTACK/PROMOTION_REQUESTED before it turns CORRUPTED — pokeRiskWindow is not part of any expected user flow, so this is the default case, not an edge case.
Requires zero attacker action; it is a passive consequence of normal staker inactivity.
Impact:
A genuinely corrupted agreement resolves as if it survived: every staker gets back 100% principal instead of forfeiting funds to the CORRUPTED path.
recoveryAddress receives nothing for a real breach, defeating the core "capital genuinely at risk" premise of the protocol.
Alice stakes 100, Bob stakes 50, Carol contributes 30 bonus.
The registry transitions straight to CORRUPTED (no one called pokeRiskWindow/stake/etc. while it was in UNDER_ATTACK), so riskWindowStart stays 0.
Time warps past expiry + MODERATOR_CORRUPTED_GRACE.
Alice calls claimExpired(). The CORRUPTED && riskWindowStart != 0 check fails, so the pool resolves as EXPIRED instead of CORRUPTED.
Bob calls claimExpired() and recovers his full 50 principal.
recoveryAddress ends up with a balance of 0, despite the agreement being genuinely CORRUPTED.
outcomeFlaggedAt = riskWindowEnd stays meaningful even when riskWindowStart was never latched, since riskWindowEnd is independently set on any first terminal-state observation (_isTerminalState covers CORRUPTED too). _bonusShare already returns 0 whenever riskWindowStart == 0, so this change only fixes outcome selection (CORRUPTED vs. EXPIRED), not bonus math.
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.