FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: high

Risk Window Start Is Rolled Back When Triggering Calls Revert

Author Revealed upon completion

DESCRIPTION

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:

if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}

then,

return s == UNDER_ATTACK || s == PROMOTION_REQUESTED;

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.

IMPACT

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:

state == CORRUPTED && riskWindowStart != 0

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.

SCENARIO:

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.

RECOMMENDED MITIGATION

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.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!