A staker is meant to lose the exit and start earning the bonus at the same moment: docs/DESIGN.md §9 says a staker "only forfeits the exit option once risk has actually materialised, which is exactly when they begin earning the risk premium."
In code these are two different signals. withdraw closes on the live UNDER_ATTACK state, but the bonus only accrues once riskWindowStart is sealed, which needs a transaction that succeeds while the registry is active-risk (stake / contributeBonus / pokeRiskWindow). A locked-in staker's own withdraw seals the flag and then reverts on the next line, so the revert rolls the seal back. Result: a staker locked in through a real attack that survives can receive zero bonus, and the whole bonus pool is swept to the sponsor's recoveryAddress.
Likelihood:
Occurs whenever the registry passes through an active-risk state and no stake / contributeBonus / pokeRiskWindow lands during that interval, i.e. the default for a passive pool (locked-in stakers cannot withdraw, and staking more only dilutes their share).
Occurs when the active-risk interval is short, e.g. a DAO instantPromote from UNDER_ATTACK straight to PRODUCTION in one transaction, so a watching staker gets no block in which to poke.
Impact:
Winning stakers who bore the full risk get none of the k=2 bonus (_bonusShare returns 0 for all).
sweepUnclaimedBonus then sends the entire bonus pool to the sponsor's recoveryAddress (with riskWindowStart == 0 only principal is reserved), reassigning participants' yield to the party that funded it.
PoC_LockedInStakerNoBonus.t.sol (in this folder) drops into test/unit/. Two tests share the same path (NEW_DEPLOYMENT -> UNDER_ATTACK -> PRODUCTION), stake, and bonus; a single pokeRiskWindow during the attack is the only difference and it flips the reward between zero and the full bonus.
Make the observation that closes the exit commit the seal instead of discarding it. _observePoolState() already seals riskWindowStart on an active-risk read; just stop withdraw from reverting that seal away.
This preserves the genuine §5 "no observable risk" case (a jump straight to a terminal state never seals riskWindowStart, so it still reverts and pays no bonus) while closing the gap for a demonstrably locked-in staker. WithdrawBlocked(address) is a new event so the caller learns the exit was closed; a plain return also works.
Impact - Low The staker keeps their principal, so what is lost is the premium they were accruing while locked, and it ends up at the sponsor's recoveryAddress. DESIGN.md #9 justifies the whole no-observed-risk rule by pairing two events, saying a staker forfeits the exit exactly when the premium starts. One call does the first and undoes the second. Likelihood - Low Any staker can cure this for free with pokeRiskWindow, which succeeds in the same state the withdrawal failed in. A permanent loss therefore needs the whole cohort to miss that step, and the trap is that the obvious move is the one that fails. A short active-risk interval can also close before anyone gets a block in which to poke. DESIGN.md #6 explains an unsealed window as nobody having interacted with the pool, which is exactly what did not happen here.
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.