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

Claim-time overflow in `_bonusShare()` can permanently lock valid high-magnitude pools.

Author Revealed upon completion

Root + Impact

Description

ConfidencePool distributes the bonus pool using a k=2 time-weighted formula. The intended score for a staker is based on the elapsed at-risk time:

stakeAmount * (T - entryTime) ** 2

where T is the resolved outcome timestamp and entryTime is the staker's effective entry timestamp.

The issue is that the implementation computes this value using expanded absolute timestamp-squared terms. With a sufficiently large but valid ERC20 stake amount, the intermediate absolute terms can overflow before the subtraction/cancellation happens. As a result, a valid staker can be unable to claim after the pool resolves, permanently locking their principal and bonus.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// @> Absolute timestamp-squared terms can overflow before cancellation
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> Same overflow risk exists for the global denominator
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:

  • This occurs when a pool uses a standard ERC20 with a very large supply/decimals and a staker deposits an amount large enough for the absolute timestamp-squared terms to approach type(uint256).max.

  • This occurs after the pool resolves as SURVIVED or EXPIRED, when affected users call claimSurvived() or claimExpired() and _bonusShare() is evaluated.

Impact:

  • Affected stakers cannot claim their principal or bonus because the claim reverts with Solidity arithmetic panic.

  • Funds can remain stuck in the pool because sweepUnclaimedBonus() reserves outstanding principal and bonus for unclaimed stakers.

Proof of Concept

The PoC stakes an extremely large ERC20 amount that is still accepted by stake(). The pool then opens the risk window, waits
one second, contributes a small bonus, and resolves as SURVIVED.

When Alice calls claimSurvived(), _bonusShare() tries to compute T * T * userEligible + userSumStakeTimeSq[u] using absolute
timestamp-squared values. That intermediate addition overflows before the formula can cancel down to the true small elapsed-
time score, so the claim reverts with Solidity arithmetic panic.

Add the following import to test/unit/ConfidencePool.k2Bonus.t.sol:

import {stdError} from "forge-std/StdError.sol";

Then add this test inside ConfidencePoolK2BonusTest:

function testClaimCanOverflowWhenAbsoluteTimestampTermsAlmostFillUint256() external {
uint256 start = vm.getBlockTimestamp();
uint256 T = start + 1;
// This amount is large enough that the absolute timestamp-squared
// terms used in _bonusShare() overflow, even though the true elapsed
// score amount * (T - entryTime)^2 is only `huge`.
uint256 huge = type(uint256).max / (T * T);
_stake(alice, huge);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(T);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}

Run:

forge test --match-test testClaimCanOverflowWhenAbsoluteTimestampTermsAlmostFillUint256 -vvv

Expected result:

[PASS] testClaimCanOverflowWhenAbsoluteTimestampTermsAlmostFillUint256()

The test passes because claimSurvived() reverts with Solidity arithmetic panic, confirming the overflow in _bonusShare().

Recommended Mitigation

Cap the maximum tracked stake so the timestamp-squared accounting cannot overflow.

+ uint256 internal constant MAX_SCORE_STAKE =
+ type(uint256).max / (2 * uint256(type(uint32).max) * uint256(type(uint32).max));
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 > MAX_SCORE_STAKE) revert InvalidAmount();
_clampUserSums(msg.sender);
...
}

Alternatively, refactor the score accounting to store risk-window-relative offsets instead of absolute timestamp-squared values.

Support

FAQs

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

Give us feedback!