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

Unbounded `totalEligibleStake` overflows the k=2 quadratic accounting, permanently closing every staker-favorable resolution path

Author Revealed upon completion

Unbounded totalEligibleStake overflows the k=2 quadratic accounting, closing every staker-favorable resolution path

Description

Normal behavior: ConfidencePool tracks a running quadratic accumulator (sumStakeTime, sumStakeTimeSq) alongside totalEligibleStake to compute time-weighted bonus shares. Whenever _observePoolState() first observes the registry entering an active-risk state (UNDER_ATTACK or PROMOTION_REQUESTED), _markRiskWindowStart() seals riskWindowStart and eagerly resets the global accumulators so every currently-eligible staker is treated as entering at the current timestamp t. This is a one-time, one-way operation — every subsequent entrypoint (stake, withdraw, flagOutcome, claimExpired, pokeRiskWindow) depends on _observePoolState() succeeding, since all of them call it before doing anything else.

The issue: neither stake() nor contributeBonus() enforces any upper bound on totalEligibleStake. _markRiskWindowStart() recomputes sumStakeTimeSq as totalEligibleStake * t * t using Solidity 0.8's default checked arithmetic no unchecked block anywhere near it. A stake can be individually safe at the timestamp it was deposited (its own per-deposit term, received * newEntry * newEntry, uses that deposit's own timestamp), yet still cause this global recomputation to overflow later, because _markRiskWindowStart() uses whatever timestamp is current whenever the risk window actually seals — which can be arbitrarily later than any individual deposit's entry time. Once this reverts, riskWindowStart never seals, and since every meaningful entrypoint calls _observePoolState() first, the revert propagates to all of them simultaneously, including withdraw() (it observes state before reaching its own gate check) and claimExpired() (the designated permissionless backstop).

A second, independent overflow site exists in _bonusShare, using the same unbounded pattern against snapshotTotalStaked at claim time rather than window-open time:

// src/ConfidencePool.sol
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t; // <-- site 1: window-open time
emit RiskWindowStarted(t);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
// ...
uint256 T = outcomeFlaggedAt;
uint256 snapshotTotalStaked = snapshotTotalStaked;
// ...
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq; // <-- site 2: claim time
// ...
}

Since deposits remain open during UNDER_ATTACK (by design — see _assertDepositsAllowed), totalEligibleStake can keep growing after the window opens without ever tripping site 1, and trip site 2 later at resolution instead. The root cause is the same in both places: no upper bound is enforced anywhere on totalEligibleStake relative to the term it gets multiplied against.

Risk

Likelihood: Low, and narrower than it may first appear once the numbers below are worked through precisely.

  • The factory's allowedStakeToken allowlist validates only the token's addresscreatePool/setStakeTokenAllowed perform no check on decimals() whatsoever. The ERC20 standard does not cap decimals(), and nothing in this codebase rejects a high-decimals token.

  • The base-unit overflow threshold is fixed by the timestamp at the moment the risk window seals (or at claim time, for site 2) — at a realistic ~2026 timestamp, this works out to roughly 3.8 × 10⁵⁸ base units, regardless of the token's decimals. What decimals does change is how that fixed base-unit threshold translates into a "whole token" count, since whole tokens = base units / 10^decimals:

    Decimals Whole-token threshold Plausibility
    18 (standard) ~3.8 × 10⁴⁰ Economically nonsensical; no real token has anywhere close to this supply.
    40 ~3.8 × 10¹⁸ (3.8 quintillion) Still economically nonsensical on its own — this is not meaningfully more reachable than the 18-decimal case.
    50 ~3.8 × 10⁸ (380 million) No longer outside the range that real (if unusual) high-decimals tokens use.

    Reaching the threshold requires both an unusual token-allowlisting decision (a governance/sponsor choice, not something an attacker can force) and cumulative deposits from any number of legitimate stakers approaching that base-unit threshold while the registry is still in an active-risk state. It is not attacker-input-dependent in the traditional sense — no single actor needs to act maliciously, but the precondition (a 50+-decimal token being allowlisted at all) is itself uncommon.

Impact: High disruption to protocol availability if the precondition is met; principal becomes unrecoverable through the intended path for every staker in the pool simultaneously.

  • Every entrypoint that calls _observePoolState()stake, withdraw, flagOutcome, claimExpired, pokeRiskWindow — reverts identically and simultaneously the instant the registry reaches an active-risk state, for as long as totalEligibleStake remains above threshold.

  • AttackRegistry.sol has no transition from UNDER_ATTACK directly to PRODUCTION that skips PROMOTION_REQUESTED, and CORRUPTED is reachable directly from UNDER_ATTACK/PROMOTION_REQUESTED via markCorrupted. ConfidencePool.sol classifies both UNDER_ATTACK and PROMOTION_REQUESTED as active-risk states via _isActiveRiskState(), so the identical overflow retriggers on the transition through PROMOTION_REQUESTED. _markRiskWindowEnd() — the function invoked on reaching a terminal state — never touches sumStakeTime/sumStakeTimeSq, so the CORRUPTED path does not retrigger the overflow.

  • The only resolution path that remains reachable is CORRUPTED — but that path never returns principal to the staker. It either sweeps the entire pool to recoveryAddress (bad-faith) or pays a named attacker (good-faith) via claimCorrupted/claimAttackerBounty. The staker's own funds are, through every reachable path, unrecoverable by them.

Proof of Concept

Full runnable file: test/poc/TotalEligibleStakeOverflow.t.sol.

// 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 {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract TotalEligibleStakeOverflowPoC is Test {
uint256 internal constant ONE = 1e18;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(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(
agreement, address(token), address(safeHarborRegistry), moderator,
block.timestamp + 60 days, ONE, recovery, address(this), scope
);
}
function _overflowThreshold(uint256 t) internal pure returns (uint256) {
return type(uint256).max / (t * t) + 1;
}
function test_PoC_overflowBricksRiskWindowObservation() external {
// Stake happens at t0 - safe, since the per-deposit term uses t0. The overflow is
// calibrated for a LATER t1 (30-day warp), which is what _markRiskWindowStart actually
// uses when the window seals - this gap is the crux of the bug.
uint256 t0 = block.timestamp;
uint256 t1 = t0 + 30 days;
uint256 threshold = _overflowThreshold(t1);
token.mint(alice, threshold);
vm.startPrank(alice);
token.approve(address(pool), threshold);
pool.stake(threshold);
vm.stopPrank();
assertEq(pool.totalEligibleStake(), threshold);
vm.warp(t1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(); // Panic(0x11): arithmetic overflow
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "riskWindowStart never sealed - the seal reverted");
}
/// Demonstrates the consequence end-to-end: the overflow retriggers on every active-risk
/// state (UNDER_ATTACK, PROMOTION_REQUESTED), and the only remaining resolution path
/// (CORRUPTED) diverts alice's principal away from her rather than returning it.
function test_PoC_overflowClosesEveryStakerFavorablePath() external {
uint256 t0 = block.timestamp;
uint256 t1 = t0 + 30 days;
uint256 threshold = _overflowThreshold(t1);
token.mint(alice, threshold);
vm.startPrank(alice);
token.approve(address(pool), threshold);
pool.stake(threshold);
vm.stopPrank();
vm.warp(t1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert();
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert();
pool.withdraw(); // alice cannot self-rescue by exiting either
// ConfidencePool.sol classifies PROMOTION_REQUESTED as active-risk too (see
// _isActiveRiskState) - if the real registry requires visiting this state before
// PRODUCTION, the same overflow fires again here.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.expectRevert();
pool.pokeRiskWindow();
// CORRUPTED is classified as terminal, not active-risk, so _markRiskWindowStart is
// never invoked here - only _markRiskWindowEnd, which does not touch the overflowing
// accumulators. This part is confirmed directly against ConfidencePool.sol.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 aliceBalanceBefore = token.balanceOf(alice);
pool.claimCorrupted();
assertEq(token.balanceOf(alice), aliceBalanceBefore, "alice recovers nothing through any path");
assertEq(token.balanceOf(recovery), threshold, "her full stake is diverted to recoveryAddress instead");
}
}

Recommended Mitigation

Enforce an upper bound on totalEligibleStake at every point it grows, sized so the worst-case t² · totalEligibleStake product can never exceed type(uint256).max, using type(uint32).max as the conservative upper bound on t (the value is always capped at expiry, itself a uint32):

+ uint256 public constant MAX_TOTAL_ELIGIBLE_STAKE = type(uint256).max / (uint256(type(uint32).max) ** 2);
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... existing checks ...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ if (totalEligibleStake + received > MAX_TOTAL_ELIGIBLE_STAKE) revert StakeExceedsMaxTotal();
// ... rest of function unchanged ...
}

This closes both overflow sites simultaneously, since both _markRiskWindowStart and _bonusShare key off the same totalEligibleStake/snapshotTotalStaked value that this check gates at the source. Using type(uint32).max rather than a realistic present-day timestamp keeps the bound correct for the entire lifetime the contract's own expiry type can represent (up to year ~2106), rather than a value that would itself need revisiting over time.

Support

FAQs

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

Give us feedback!