The protocol documentation distinguishes active-risk states from an actual breach.
UNDER_ATTACK and PROMOTION_REQUESTED only mean that the agreement is legally attackable. Reaching pool expiry while the registry remains in either state therefore means that the agreement survived the insured term, making EXPIRED the intended outcome.
By contrast, CORRUPTED is the terminal registry state indicating that an agreement-level breach occurred. The registry does not reveal whether that breach affected the pool’s committed scope or whether it was caused by a good-faith attacker. Those pool-specific determinations can only be made by the moderator.
The documentation explicitly accepts the general “no-risk-window CORRUPTED race.”
It states that when riskWindowStart == 0, a permissionless claimExpired() may finalize a currently CORRUPTED agreement as EXPIRED before the moderator acts:
“With
riskWindowStart == 0on a terminal-CORRUPTEDregistry,claimExpiredfalls through to EXPIRED and latchesclaimsStarted.”
The documentation explains that agreement-level CORRUPTED is scope-ambiguous. Automatically finalizing every such pool as CORRUPTED could confiscate staker principal when the breach was outside the pool’s committed scope.
It also states that deferring claimExpired() during the moderator grace period was considered and rejected for the general no-risk-window case.
This report does not dispute that agreement-level CORRUPTED alone is insufficient to automatically impose a pool-level CORRUPTED outcome.
The narrower case described here contains an additional persisted signal:
This state is reachable because the pool records active-risk and terminal observations independently:
For example:
pokeRiskWindow() observes the current terminal state and persists riskWindowEnd, but it does not resolve the pool:
Because terminal states do not count as active-risk observations, the resulting state is:
The riskWindowEnd marker does not preserve which terminal state was observed: it can be set for either PRODUCTION or CORRUPTED. It also does not establish whether a breach affected the pool’s committed scope.
It does, however, prove that the pool observed a terminal registry condition before the insured term ended. At resolution, the live registry separately establishes that the agreement is currently CORRUPTED.
claimExpired() ignores this persisted pre-expiry observation. Its moderator-grace branch depends exclusively on riskWindowStart != 0:
When riskWindowStart == 0, the moderator-grace branch is skipped regardless of a recorded riskWindowEnd < expiry.
Because the live registry state is CORRUPTED, it is also not equal to PRODUCTION, so execution reaches the generic EXPIRED branch:
Therefore, any caller can finalize the pool as EXPIRED immediately after expiry despite the recorded pre-expiry terminal observation.
A staker caller can additionally recover their principal in the same transaction. The remaining stakers can subsequently recover theirs through the finalized EXPIRED outcome.
Because riskWindowStart == 0, stakers receive no bonus share:
The remaining bonus is therefore not reserved for stakers and may be swept to recoveryAddress.
The resolution is irreversible because subsequent moderator correction is blocked once claimsStarted is true:
If the moderator would have classified the current CORRUPTED state as an in-scope good-faith breach, flagOutcome() would instead create a bounty equal to the full pool snapshot:
Permissionless EXPIRED finalization permanently prevents the snapshotted pool value from becoming a whitehat bounty.
The attacker cannot protect against this outcome independently. Before the moderator flags good-faith CORRUPTED, the attacker has no on-chain entitlement and cannot:
register themselves as the attacker;
prove that the breach affected the pool scope;
reserve the bounty;
block claimExpired();
request or extend the moderator review period.
The economically relevant race is therefore between the moderator and stakers.
A rational staker is incentivized to call claimExpired() as soon as expiry is reached:
waiting for the moderator may result in loss of principal under an in-scope CORRUPTED classification;
calling claimExpired() first returns principal and permanently finalizes the staker-favorable EXPIRED outcome.
The implementation therefore treats a pool that recorded a terminal condition before expiry identically to one that had no meaningful terminal observation before expiry.
The issue is not that the contract should immediately auto-resolve the pool as CORRUPTED. The pre-expiry marker still does not establish pool scope.
The issue is that the contract immediately and irreversibly resolves the pool as EXPIRED, even though the stored state supports preserving the moderator’s existing review period.
A temporary deferral would retain all pool-specific choices:
SURVIVED if the breach was outside the pool scope;
good-faith CORRUPTED with a named attacker;
bad-faith CORRUPTED;
the existing conservative fallback if the moderator remains unavailable.
Thus, riskWindowEnd < expiry can justify moderator review without treating the terminal marker itself as proof of an in-scope breach.
Likelihood:
This occurs when the registry passes through UNDER_ATTACK or PROMOTION_REQUESTED without any pool interaction, leaving riskWindowStart == 0, and a later pre-expiry interaction observes a terminal state and sets riskWindowEnd < expiry.
This occurs when the live registry remains CORRUPTED after expiry and the moderator has not yet classified the pool-specific outcome before the first claimExpired() call.
Stakers are economically incentivized to trigger the path immediately after expiry because EXPIRED preserves their principal, while an in-scope CORRUPTED resolution may allocate that principal to the attacker bounty.
Impact:
A pool associated with an in-scope good-faith breach can be irreversibly finalized as EXPIRED before the moderator performs the pool-specific scope determination.
Stakers recover principal that would otherwise form part of the whitehat bounty.
The remaining bonus may be swept to recoveryAddress instead of being assigned to the whitehat.
The complete value of snapshotTotalStaked + snapshotTotalBonus becomes unavailable for assignment as the attacker bounty.
The moderator permanently loses the ability to flag good-faith CORRUPTED, name the attacker, and create the corresponding bounty entitlement.
When the live registry state is CORRUPTED and the pool recorded a terminal observation strictly before expiry, preserve the moderator review period even if no intermediate active-risk state was observed.
For example:
A strict comparison is necessary:
_markRiskWindowEnd() caps observations made after expiry to expiry:
Therefore:
cannot distinguish a terminal observation made exactly at expiry from one first made after expiry.
By contrast:
unambiguously proves that the terminal state was observed before the pool term ended.
This mitigation does not infer pool scope from riskWindowEnd and does not automatically classify the outcome as CORRUPTED. It only prevents immediate irreversible EXPIRED finalization while the moderator’s existing decision period remains open.
The mitigation only gives the moderator time to perform the pool-specific scope and good-faith determination; it does not change the protocol’s eventual no-risk-window fallback.
After the grace period, the protocol may retain its current conservative EXPIRED resolution for the riskWindowStart == 0 case, avoiding any expansion of the scope-blind auto-CORRUPTED behavior.
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.