The Protocol relies on lazy observation of the external attack registry. When a pool function calls _observePoolState() the pool checks the current registry state and, if the agreement is in an active-risk state it records riskWindowStart. The active risk states are:
then,
The problem is that several functions call _observePoolState() and then revert afterward. Because Ethereum transactions are atomic, the earlier call to riskWindowStart is also reverted. So the pool can temporarily “see” that risk has started, but loses that observation before the transaction is completed.
Affected functions are:
stake() / contributeBonus() call _observePoolState(), but if the state is PROMOTION_REQUESTED , _assertDepositsAllowed() reverts, that also rolls back the observed riskWindowStart.
setPoolScope() calls _observePoolState() , then reverts if scopeLocked is true. That can also roll back the observation.
withdraw() calls _observePoolState() , then reverts if riskWindowStart != 0, meaning withdrawals are disabled. Because the function reverts, the newly written riskWindowStart is not persisted.
If the agreement later resolves SURVIVED, and no successful call ever persisted riskWindowStart, stakers get no bonus. That favors recoveryAddress, because the bonus becomes sweepable.
If the registry later resolves to CORRUPTED and the moderator is unavailable, claimExpired() only auto-resolves as CORRUPTED when both conditions are true:
If the only active-risk observations were reverted, riskWindowStart remains zero, so the pool can fall through to EXPIRED instead of auto-CORRUPTED. That preserves staker principal when it may have been intended to sweep to recovery after the corrupted grace period.
Stakers deposit into the pool.
The registry enters UNDER_ATTACK.
A staker tries to withdraw.
withdraw() observes UNDER_ATTACK and would set riskWindowStart.
The same withdraw() call reverts because withdrawals are disabled after risk starts.
The write to riskWindowStart is reverted.
No one calls pokeRiskWindow().
The registry later reaches a terminal state.
The pool resolves while riskWindowStart == 0, causing bonus or principal routing to differ from the intended active-risk path.
There should be an atomic call to pokeRiskWindow() anytime the registry enters an active risk state instead of having to rely on a manual call or having to rely on functions that can roll back the observation if they revert.
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.