The expiry path is intended to let a pool settle once its underwriting term has passed. If the registry is still active-risk at expiry, the design treats that as survival of the pool term and allows EXPIRED resolution. The bug is that pokeRiskWindow() remains callable after expiry while the pool is unresolved, and it can set riskWindowStart without resolving the pool.
This creates a payout-integrity issue. A third party can call pokeRiskWindow() after expiry during a later active-risk state, which records riskWindowStart = expiry and leaves the pool UNRESOLVED. If the registry is later marked CORRUPTED, claimExpired() sees a nonzero riskWindowStart and takes the auto-CORRUPTED branch, allowing the full pool to be swept to recoveryAddress even though the risk observation occurred after the pool term.
File: src/ConfidencePool.sol
Lines: 512-607
Function/Type: claimExpired()
File: src/ConfidencePool.sol
Lines: 532-554
Function/Type: Auto-CORRUPTED branch in claimExpired()
File: src/ConfidencePool.sol
Lines: 649-657
Function/Type: pokeRiskWindow()
File: src/ConfidencePool.sol
Lines: 801-817
Function/Type: _markRiskWindowStart()
pokeRiskWindow() is permissionless and only checks that the pool is unresolved. It does not check whether the pool has already expired. When called after expiry and the registry is in an active-risk state, _observePoolState() calls _markRiskWindowStart().
_markRiskWindowStart() caps t to expiry, then writes riskWindowStart = uint32(t). This means a post-expiry call can create a nonzero risk-window marker even though no active-risk state was observed during the pool term. The call does not resolve the pool.
Later, if the registry becomes CORRUPTED and the moderator grace period has passed, claimExpired() enters the auto-CORRUPTED branch whenever state == CORRUPTED && riskWindowStart != 0. The nonzero marker from the post-expiry poke satisfies that condition, so the pool resolves as bad-faith CORRUPTED. claimCorrupted() can then sweep principal and bonus to recoveryAddress.
The root cause is that pokeRiskWindow() can mutate risk-window state after expiry without atomically resolving the expired pool.
Step 1 The pool reaches expiry without being resolved.
Step 2 The registry enters an active-risk state after the pool term.
Step 3 Any caller invokes pokeRiskWindow(), which sets riskWindowStart to the already-passed expiry.
Result: The pool remains unresolved but is now armed for the later auto-CORRUPTED branch.
Step 1 The post-expiry poke has made riskWindowStart nonzero.
Step 2 The registry later becomes CORRUPTED after the moderator grace period.
Step 3 A non-staker calls claimExpired() to mechanically resolve the pool as CORRUPTED.
Step 4 Any caller invokes claimCorrupted().
Result: Staker principal and bonus are swept to recoveryAddress instead of being paid through EXPIRED resolution.
The pool passes expiry while still unresolved.
The registry enters UNDER_ATTACK after the pool term.
pokeRiskWindow() is called after expiry.
_observePoolState() detects active risk and calls _markRiskWindowStart().
_markRiskWindowStart() caps the timestamp to expiry and sets riskWindowStart.
The pool remains UNRESOLVED.
The registry later becomes CORRUPTED.
claimExpired() sees state == CORRUPTED && riskWindowStart != 0.
The pool resolves as CORRUPTED and claimCorrupted() sweeps the balance to recovery.
This is a Medium severity payout-integrity issue. A permissionless, non-claiming call after the underwriting term can change the later resolution from EXPIRED to bad-faith CORRUPTED. Existing stakers can lose all principal and bonus to the sponsor-controlled recovery address for a corruption state reached after the pool expired.
The attacker cost is low because pokeRiskWindow() is permissionless and does not require stake ownership. The protocol cost is high for affected stakers because the final value movement is a full-pool sweep rather than the expiry payout path.
Framework used: Foundry / forge
File 1: test/poc/ConfidencePoolPostExpiryPoC.t.sol
The passing PoC shows that a post-expiry pokeRiskWindow() sets riskWindowStart to expiry while the outcome remains unresolved. After the registry is set to CORRUPTED, claimExpired() resolves the pool as CORRUPTED, and claimCorrupted() transfers 150 * ONE to recovery while Alice receives nothing.
assertEq(pool.riskWindowStart(), expiry) -> proves the post-expiry poke creates the risk-window marker -> PASS
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED)) -> proves the later expiry path resolves as CORRUPTED -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE) -> proves principal and bonus are swept to recovery -> PASS
Prevent pokeRiskWindow() from mutating risk-window state after expiry. Post-expiry observation should happen only inside claimExpired(), where observation and resolution occur atomically.
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.