The pool splits its bonus with a k=2, time-weighted formula. Its lower bound, riskWindowStart, is sealed only when some pool call observes the registry in an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED). If the registry passes through active risk and reaches a terminal state (PRODUCTION) without anyone interacting, riskWindowStart stays 0 forever - it can never be sealed retroactively, because a terminal state is not an active-risk state.
The bug is that two mechanisms key off different signals, and they desynchronize exactly in this state:
withdraw() closes on the LIVE registry state. During UNDER_ATTACK it reverts regardless of riskWindowStart, so stakers are captive and bear the real risk.
_bonusShare opens on the LATCH (riskWindowStart). With the latch never sealed it returns 0 for everyone.
So the stakers are locked in and carry the full downside, yet earn nothing. Worse, sweepUnclaimedBonus then does not reserve the bonus when riskWindowStart == 0, so the entire bonus pool is swept to recoveryAddress - the sponsor.
ConfidencePool.sol - withdraw() is gated on the live state, not the latch:
_bonusShare pays zero to everyone while the latch is unsealed:
sweepUnclaimedBonus then refuses to reserve the bonus, sending it all to the sponsor:
The decisive lemma - the natural defence cannot seal the window. A staker who sees UNDER_ATTACK and reacts will call withdraw(). That runs _observePoolState() (which would seal riskWindowStart), then reverts WithdrawsDisabled - and the revert rolls the seal back with the rest of the transaction.
Likelihood:
Two triggers, one signal apart. withdraw() locks on the live state while the bonus opens on the latch - they desync whenever the registry is in active risk but nobody has poked. Any UNDER_ATTACK → PRODUCTION transition with no sealing interaction reaches this state.
The victim cannot save themselves by reacting. Their instinctive withdraw() reverts and rolls back the very seal that would pay them; only an undocumented pokeRiskWindow() works.
The sponsor is both the beneficiary and the informed party.
Impact:
Stakers are captive but unpaid. Locked in for the whole attack phase (real downside borne), they receive principal back and zero bonus.
The entire bonus pool is exfiltrated to the sponsor via sweepUnclaimedBonus, not merely redistributed among stakers.
Both pass.
Running the suite:
The two tests are identical except for a single pokeRiskWindow() call: without it the sponsor takes 100% of the bonus while the captive staker gets nothing; with it the staker keeps her full premium.
The root cause is that withdraw() locks on the live registry state while the bonus is gated on the riskWindowStart latch, so stakers can be captive with the latch still unsealed. Align the two so a staker cannot bear risk without accruing the premium:
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.