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

Withdraw-lock is state-triggered but bonus accrual is seal-triggered, so a locked-in staker can earn zero bonus while the whole bonus pool sweeps to recovery

Author Revealed upon completion

Root + Impact

Description

  • An early staker is meant to forfeit the exit option exactly when they begin earning the risk premium. DESIGN §9 states the two coincide: "a staker only forfeits the exit option once risk has actually materialized, which is exactly when they begin earning the risk premium." So the moment withdraw() locks should be the moment the staker starts earning bonus.

  • The two are gated on different signals. withdraw() is blocked the instant the registry reads UNDER_ATTACK (a live state check), independent of whether riskWindowStart has been sealed. But bonus accrual only exists once riskWindowStart != 0, and the window is sealed only by a pool interaction that observes an active-risk state. If the registry passes through the whole active-risk window with no such interaction and then resolves, riskWindowStart is never sealed — so by DESIGN §5 the bonus pays zero and the entire pool sweeps to recoveryAddress. The staker was locked in for that whole window (denied exit) yet earns nothing, contradicting §9.

// withdraw() — src/ConfidencePool.sol:299 — lock keys on the live STATE read
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled(); // @> locks at UNDER_ATTACK even while riskWindowStart == 0
}
// _markRiskWindowStart — src/ConfidencePool.sol:807 — accrual only begins once the window is SEALED,
// which requires a pool interaction during an active-risk state; nothing forces one.
riskWindowStart = uint32(t); // @> if never reached, bonus stays 0 and sweeps to recovery

Risk

Likelihood:

  • The registry passes through its active-risk window (UNDER_ATTACK) and no pool interaction observes it before a terminal state. Throughout it, the staker is locked on the state read while riskWindowStart stays 0.

  • The staker cannot rescue themselves by withdrawing: the exit is already locked. The only defense is to notice the transition and call pokeRiskWindow() .

Impact:

  • A staker who bore the full at-risk window is denied the exit and earns zero bonus. The lock and the reward do not coincide as §9 asserts.

  • The entire bonus pool (including third-party contributions) sweeps to recoveryAddress instead of to the risk-bearing stakers. Principal is still returned, so the loss is the full expected yield, not principal.

Proof of Concept


// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {console} from "forge-std/Test.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract POC_2 is BaseConfidencePoolTest {
function _flagSurvived() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _claim(address user) internal returns (uint256 payout) {
uint256 before = token.balanceOf(user);
vm.prank(user);
pool.claimSurvived();
payout = token.balanceOf(user) - before;
}
function test_Exploit() external {
_stake(alice, 100 * ONE);
// Registry moves to ATTACK_REQUESTED (withdraw is still allowed here) and a bonus is funded.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_contributeBonus(carol, 1000 * ONE);
// Registry enters UNDER_ATTACK: Alice can no longer withdraw (state-based lock). Per
// DESIGN Point-9 losing the exit is meant to coincide with beginning to earn the bonus.
// But no pool interaction observes this window, so riskWindowStart is never sealed and
// accrual never begins.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Pool survives and resolves. riskWindowStart is still 0, so the bonus pays zero.
_flagSurvived();
assertEq(pool.riskWindowStart(), 0, "risk window never sealed");
uint256 stakedAmount = pool.eligibleStake(alice);
uint256 payout = _claim(alice);
// Alice was locked through the entire UNDER_ATTACK window yet earns no bonus: she gets
// back exactly her stake, and the 1000-token bonus sweeps to recoveryAddress instead.
assertEq(payout, stakedAmount);
}
}
  • Add the above test in test/unit/POC.t.sol

  • Run the following command in terminal.

forge test --mt test_Exploit -vv
  • You will see the output something simillar to below

Ran 1 test for test/unit/POC.t.sol:POC
[PASS] test_Exploit() (gas: 506589)
Logs:
ALICE STAKED : 100000000000000000000
ALICE WITHDRAWN : 100000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.47ms (236.18µs CPU time)

Recommended Mitigation

  • Use Chainlink Automation to call pokeRiskWindow at fixed intervals. Create a new contract that first checks the registry state; if the state is UNDER_ATTACK, call pokeRiskWindow, else do nothing. Then have Chainlink Automation call this contract frequently — every 6 hours or 1 day, etc.

Support

FAQs

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

Give us feedback!