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;
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;
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
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";
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;
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);
_stakeRaw(alice, ONE);
_stakeRaw(bob, minimalOverflowingAggregate - ONE);
_contributeBonus(carol, 1);
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));
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.