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

A rejected `UNDER_ATTACK` withdrawal rolls back risk observation and can forfeit the staker's entire bonus

Author Revealed upon completion

Root + Impact

Description

  • Withdrawals become permanently unavailable once the agreement reaches UNDER_ATTACK; at that same point, existing stakers should begin earning the risk premium. Ordinary pool interactions are intended to lazily persist riskWindowStart when they observe this state.

  • withdraw() first calls _observePoolState(), which sets riskWindowStart, but then necessarily reverts with WithdrawsDisabled() because that marker is now nonzero. The revert rolls the marker back. When the registry reaches PRODUCTION before another successful observer call, resolution records no observed risk, _bonusShare() returns zero, and the entire bonus can be swept to recoveryAddress. Consequently, the same interaction that denies a staker their exit also fails to begin their risk-premium accounting.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
// @> UNDER_ATTACK causes this call to set scopeLocked and riskWindowStart.
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
// @> The required withdrawal rejection also rolls back riskWindowStart,
// @> so the locked stake receives no risk premium unless another call pokes.
revert WithdrawsDisabled();
}
// ...withdrawal accounting...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// @> The rolled-back observation makes every staker's bonus zero.
if (riskWindowStart == 0) return 0;
// ...
}

Risk

Likelihood:

  • Occurs when a staker's rejected withdrawal is the first pool transaction to observe UNDER_ATTACK or PROMOTION_REQUESTED.

  • Produces loss when the upstream registry becomes terminal before another successful call to pokeRiskWindow(), stake(), or contributeBonus() persists the active-risk observation.

Impact:

  • Stakers lose their complete share of the sponsor-funded bonus despite being denied withdrawal and bearing the active-risk interval.

  • The unallocated bonus is transferred to the sponsor-controlled recoveryAddress, creating an incentive not to correct the missing marker before terminal resolution.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract WithdrawObservationRollbackPoC is BaseConfidencePoolTest {
function test_RevertingWithdrawForfeitsRiskPremium() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// This transaction observes active risk and emits RiskWindowStarted in its
// execution trace, but its revert rolls the marker back.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
assertEq(pool.riskWindowStart(), 0);
assertFalse(pool.scopeLocked());
// Alice bears ten days of risk while her principal remains locked.
vm.warp(block.timestamp + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
// Alice receives principal only, despite losing her exit at UNDER_ATTACK.
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
// The sponsor-controlled recovery address receives the complete bonus.
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE);
}
}

Recommended Mitigation

The blocked withdrawal path must complete successfully so the one-way observation can persist. Emit an event for the rejected request rather than reverting after state mutation.

+event WithdrawalBlocked(address indexed staker, IAttackRegistry.ContractState state);
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();
+ // Successful no-op: preserve scope/risk latches observed above.
+ emit WithdrawalBlocked(msg.sender, state);
+ return;
}
// ...withdrawal accounting...
}

Support

FAQs

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

Give us feedback!