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();
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
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
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);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
assertEq(pool.riskWindowStart(), 0);
assertFalse(pool.scopeLocked());
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();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
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...
}