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

Withdrawal risk latch is rolled back on the disabled-withdrawal path

Author Revealed upon completion

Root + Impact

Description

Normal behavior: riskWindowStart is documented as a one-way latch — once any
pool interaction observes an active-risk registry state (UNDER_ATTACK /
PROMOTION_REQUESTED), withdrawals are meant to be permanently disabled, even
if the registry later rewinds to a withdraw-allowed state.

The issue: when withdraw() itself is the first call to observe an
active-risk state, _observePoolState() sets riskWindowStart as a side
effect, and withdraw() then immediately reverts because riskWindowStart != 0.
Since a revert unwinds every storage write made earlier in the same
transaction, the SSTORE that latched riskWindowStart is rolled back along
with the revert. The latch is never actually persisted. If the registry
subsequently rewinds to a withdraw-allowed state, a staker can call
withdraw() again and succeed, even though the pool previously observed
active risk.

// withdraw() — src/ConfidencePool.sol:288
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
@> IAttackRegistry.ContractState state = _observePoolState(); // may latch riskWindowStart here
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
@> revert WithdrawsDisabled(); // unwinds the latch set two lines above
}
// _observePoolState() — src/ConfidencePool.sol:784
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart(); // ordinary SSTORE, no independent persistence
}

The latch's entire purpose is to survive a registry rewind. Because the write
and the revert happen in the same call, the one case where the latch is
supposed to matter most — a staker trying to withdraw right as risk
materializes — is exactly the case where it fails to persist.

Risk

Likelihood:

  • Triggers whenever a staker calls withdraw() at the moment the registry has
    already moved to UNDER_ATTACK or PROMOTION_REQUESTED but no earlier pool
    interaction (stake, poke, etc.) latched the state first — plausible any time
    a staker reacts to a live attack by trying to exit rather than by staking.

  • Requires no privileged role or special timing beyond a registry rewind,
    which the design doc itself treats as a real event the latch exists to
    guard against (§9, §11).

  • Compounds with pokeRiskWindow() being separately callable — but if no one
    pokes before the affected withdraw() call, the window for the bypass stays
    open until someone does.

Impact:

  • Defeats the one-way latch's stated purpose (§9): "an upstream registry
    rewind cannot re-open" withdrawals — here it can.

  • Lets a staker exit with full principal after risk has genuinely
    materialized, which is the exact scenario withdraw()'s lock from
    UNDER_ATTACK onward (§3, §9) is designed to prevent.

  • Undermines the accounting other stakers and the moderator rely on: a
    withdrawal that should have been permanently foreclosed instead succeeds on
    a later call, changing totalEligibleStake after risk was observed.

Proof of Concept

This sequence shows a staker's own withdraw() call losing the very latch
that should have blocked them. The call observes active risk, reverts on that
same observation, and the failed latch write lets a later call (after a
registry rewind) succeed instead of permanently reverting.

// 1. Registry transitions to UNDER_ATTACK; no prior pool call has observed it
// (riskWindowStart == 0)
// 2. Staker calls withdraw()
pool.withdraw();
// _observePoolState() sets riskWindowStart = block.timestamp (in-memory effect)
// withdraw() then reverts: riskWindowStart != 0 -> WithdrawsDisabled
// Entire tx reverts -> riskWindowStart SSTORE rolled back -> storage still 0
// 3. Registry rewinds to ATTACK_REQUESTED (withdraw-allowed range)
// 4. Staker calls withdraw() again
pool.withdraw();
// riskWindowStart == 0 (never persisted in step 2)
// state == ATTACK_REQUESTED -> passes the allowed-state check
// Withdrawal succeeds despite risk having been observed in step 2

Recommended Mitigation

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
- revert WithdrawsDisabled();
+ // Do not revert: reverting here would unwind the riskWindowStart
+ // latch set by _observePoolState() above, which is the whole point
+ // of the one-way guard. Fail gracefully instead so the observation
+ // persists as part of a successful transaction.
+ emit WithdrawRejected(msg.sender, state);
+ return;
}

Alternative: keep withdraw() from mutating state itself — require the
active-risk observation to be latched by a prior, independent call (e.g.
pokeRiskWindow(), which already exists and succeeds/persists on its own),
and have withdraw() only read riskWindowStart and the live state to
decide eligibility, reverting freely since it no longer risks unwinding a
latch it just set.

Support

FAQs

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

Give us feedback!