Root + Impact
Description
-
Normal behavior: users can stake into an unresolved pool before active risk and retain the ability to withdraw while the registry remains in pre-risk states. UNDER_ATTACK deposits are allowed, but they are materially different because the risk window is opened and withdrawals are disabled.
-
The issue: stake() has no user-supplied execution constraints such as expectedState, maxRiskWindowStart, maxEntryTime, or minBonusWeight. A transaction signed while the registry is pre-risk can execute after the registry moves to UNDER_ATTACK, still succeed, seal riskWindowStart, and lock the user's funds into the resolution path.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
...
@> uint256 newEntry = block.timestamp;
@> uint256 start = riskWindowStart;
@> if (start != 0 && newEntry < start) newEntry = start;
...
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
@>
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart();
@> }
...
}
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();
}
...
}
Risk
Likelihood:
-
Occurs when a user submits stake() after checking a pre-risk state and the registry transitions to UNDER_ATTACK before that transaction is included.
-
Occurs in normal asynchronous execution environments where mempool ordering, block inclusion, or sequencer timing changes the registry state between transaction signing and execution.
Impact:
-
The user's stake is accepted under active-risk conditions that were not bound into the transaction.
-
riskWindowStart is sealed and withdraw() immediately reverts, removing the pre-risk withdrawal escape hatch the user expected to preserve.
-
The user's bonus weight is recorded at the later active-risk execution timestamp, which can materially reduce the expected k=2 bonus share.
Proof of Concept
PoC command:
forge test --offline --match-path test/poc/StakeStateDeadlineSlippagePoC.t.sol
Run script content:
#!/usr/bin/env bash
set -euo pipefail
forge test --offline --match-path test/poc/StakeStateDeadlineSlippagePoC.t.sol
Complete runnable PoC:
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";
contract StakeStateDeadlineSlippagePoC is BaseConfidencePoolTest {
function testPoC_staleStakeExecutesAfterStateChangesToUnderAttack() external {
address user = alice;
uint256 amount = 10 * ONE;
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
token.mint(user, amount);
vm.prank(user);
token.approve(address(pool), amount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(user);
pool.stake(amount);
assertEq(pool.eligibleStake(user), amount, "attack: stake executed after state changed");
assertEq(pool.riskWindowStart(), block.timestamp, "attack: stake sealed active risk");
assertEq(token.balanceOf(address(pool)), amount, "attack: user funds are committed");
vm.prank(user);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}
}
Full output:
Compiling 64 files with Solc 0.8.26
Solc 0.8.26 finished in 5.47s
Compiler run successful!
Ran 1 test for test/poc/StakeStateDeadlineSlippagePoC.t.sol:StakeStateDeadlineSlippagePoC
[PASS] testPoC_staleStakeExecutesAfterStateChangesToUnderAttack() (gas: 326207)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 689.42µs (100.83µs CPU time)
Ran 1 test suite in 1.47ms (689.42µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
-function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+function stake(
+ uint256 amount,
+ IAttackRegistry.ContractState expectedState,
+ uint32 maxRiskWindowStart
+) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
- _assertDepositsAllowed(_observePoolState());
+ IAttackRegistry.ContractState observedState = _observePoolState();
+ if (observedState != expectedState) revert StakingClosed();
+ if (riskWindowStart != 0 && riskWindowStart > maxRiskWindowStart) revert StakingClosed();
+ _assertDepositsAllowed(observedState);
...
}
The exact revert error can be a new custom error, but the important property is that users can bind their intended registry state and risk-window deadline to execution.