Root + Impact
Confidence pools distribute the bonus pool to surviving stakers using a k=2 time-weighted formula. Each staker’s score is based on stake amount and elapsed at-risk time, and after a SURVIVED or EXPIRED resolution, honest stakers should be able to claim their principal plus any bonus share.
The issue is that the implementation builds the k=2 score using absolute timestamps and unchecked upper-bound stake amounts. A sufficiently large standard ERC20 stake can make the intermediate score construction overflow before Math.mulDiv() is reached. This can make every SURVIVED / EXPIRED claim revert, locking honest users’ principal.
Description
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
...
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
...
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
Math.mulDiv() only protects the final multiplication/division. It does not protect the earlier T * T * snapshotTotalStaked + snapshotSumStakeTimeSq calculation.
Risk
Likelihood:
Low: this requires an allowlisted standard ERC20 with extremely large base-unit supply or mintable balances.
The factory owner controls the token allowlist, but the contract does not enforce any stake cap once a token is allowed.
Impact:
Honest stakers can be unable to claim principal and bonus after SURVIVED or EXPIRED.
sweepUnclaimedBonus() cannot rescue the funds because it reserves outstanding principal and bonus for non-claimers.
Proof of Concept
function testLargeStandardErc20StakeDoesNotBlockHonestClaim() external {
uint256 start = block.timestamp;
uint256 terminalObservation = uint256(pool.expiry()) - 1;
uint256 startSquared = start * start;
uint256 terminalSquared = terminalObservation * terminalObservation;
uint256 largeStake = type(uint256).max / (startSquared + terminalSquared) + 1;
uint256 honestStake = pool.minStake();
uint256 totalStake = largeStake + honestStake;
uint256 startTerm = startSquared * totalStake;
uint256 terminalTerm = terminalSquared * totalStake;
assertGt(terminalTerm, type(uint256).max - startTerm);
_stake(alice, largeStake);
_stake(bob, honestStake);
_enterRisk();
_contributeBonus(carol, 1);
vm.warp(terminalObservation);
_flagSurvived();
uint256 beforeBalance = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertGt(token.balanceOf(bob), beforeBalance);
}
Recommended Mitigation
Enforce a total stake cap derived from the maximum timestamp used in the k=2 formula.
+ function _maxTotalStakeForK2() internal view returns (uint256) {
+ uint256 t = uint256(expiry);
+ return type(uint256).max / (2 * t * t);
+ }
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ if (totalEligibleStake + received > _maxTotalStakeForK2()) {
+ revert InvalidAmount();
+ }
_clampUserSums(msg.sender);
...
}
A stronger fix is to avoid absolute timestamp expansion and compute the k=2 score with bounded elapsed-time values or a complete 512-bit score representation.