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

Large Stake Can Overflow k=2 Bonus Accounting and Block Honest Claims

Author Revealed upon completion

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

// ConfidencePool.sol
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.

Support

FAQs

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

Give us feedback!