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

Integer Overflow in k=2 Bonus Score Calculation Locks All Staker Funds

Author Revealed upon completion

Root + Impact


Description

  • The _bonusShare() function computes each staker's bonus using T² × eligibleStake where T is outcomeFlaggedAt (a uint32). Solidity 0.8.x uses checked arithmetic — when T² × eligibleStake exceeds 2^256 - 1, the transaction reverts.
    // --->> src/ConfidencePool.sol:704
    uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
    T can be up to 4,294,967,295 (max uint32). T² ≈ 1.84 × 10^19. Any userEligible greater than ≈ 6.27 × 10^57 causes overflow. The same issue exists on line 708 for snapshotTotalStaked.
    Once triggered, claimSurvived(), claimExpired(), and the auto-CORRUPTED path all revert — no claim path works. The claimsStarted flag never sets, so the moderator cannot re-flag. Funds are permanently locked.

* * // Root cause --->> ConfidencePool.sol:704-710
function \_bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// --->> OVERFLOW: T \* T \* userEligible exceeds uint256 when userEligible > 2^192
uint256 userPlus = T \* T \* userEligible + userSumStakeTimeSq\[u];
uint256 userMinus = 2 \* T \* userSumStakeTime\[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// --->> OVERFLOW: Same pattern in global score calculation
uint256 plus = T \* T \* snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 \* T \* snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
// ... rest of function
}
The overflow occurs because Solidity 0.8.x performs checked arithmetic by default — the multiplication T \* T \* userEligible reverts when the intermediate result exceeds 2^256 - 1. Since T (outcomeFlaggedAt) is a uint32 with maximum value 4,294,967,295, the product T² is approximately 1.84 × 10^19. Multiplying this by any userEligible value greater than approximately 6.27 × 10^57 (roughly 2^192) triggers the overflow.
Once the pool enters a terminal state (SURVIVED, CORRUPTED, or EXPIRED) and snapshotTotalStaked exceeds the threshold, every subsequent call to claimSurvived(), claimExpired(), or \_bonusShare() will revert. The claimsStarted flag cannot be set because no claim can execute, the moderator cannot re-flag the outcome, and stakers cannot recover their principal.

Risk

Likelihood:

  • Tokens with large total supplies (18+ decimals, supplies in quadrillions) can accumulate stake exceeding the overflow threshold across multiple depositors

  • The pool's stake token is owner-allowlisted — a governance token or high-supply asset could realistically reach this level

Impact:

  • All stakers lose access to principal and bonus with no recovery mechanism

  • The pool becomes insolvent — no value can ever leave the contract

Proof of Concept

The overflow occurs when T² × totalStaked > 2^256. Demonstrating with concrete values:
uint256 T = type(uint32).max; // 4,294,967,295
uint256 tSquared = T * T; // 18,446,744,065,119,617,025 (fits in uint256)
uint256 threshold = type(uint256).max / tSquared; // ~6.27 × 10^57
// Any stakeToken with total supply > threshold triggers the overflow.
// For an 18-decimal token, this is ~6.27 × 10^39 tokens — large but
// achievable for meme tokens, governance tokens, or test tokens with
// inflated supplies.
// After flagOutcome snapshots a totalEligibleStake above this threshold:
// claimSurvived() → reverts (overflow in _bonusShare)
// claimExpired() → reverts (overflow in _bonusShare)
// claimCorrupted() → reverts (sweep depends on claim flow)
//
// No staker can recover funds. The moderator cannot re-flag because
// claimsStarted is never set (the first claim always reverts).
To reproduce on a fork:
1. Deploy a pool with a high-supply ERC20 (e.g., 10^60 total supply)
2. Stake until totalEligibleStake > threshold
3. Trigger terminal registry state
4. Call flagOutcome(SURVIVED, false, address(0))
5. Call claimSurvived() — reverts with panic code 0x11 (arithmetic overflow)

Recommended Mitigation

Use Math.mulDiv (already imported) for the T² multiplication to leverage 512-bit intermediate arithmetic:
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 Tsq = Math.mulDiv(T, T, 1);
+ uint256 userPlus = Math.mulDiv(Tsq, userEligible, 1) + userSumStakeTimeSq[u];
+ uint256 userMinus = Math.mulDiv(2 * T, userSumStakeTime[u], 1);
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
- uint256 minus = 2 * T * snapshotSumStakeTime;
+ uint256 plus = Math.mulDiv(Tsq, snapshotTotalStaked, 1) + snapshotSumStakeTimeSq;
+ uint256 minus = Math.mulDiv(2 * T, snapshotSumStakeTime, 1);
uint256 globalScore = plus > minus ? plus - minus : 0;
Math.mulDiv computes a × b / c using 512-bit intermediates, preventing overflow. Passing 1 as the denominator performs pure multiplication without division. The same pattern is already used on line 719 for the final share calculation.

Support

FAQs

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

Give us feedback!