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

k=2 Score Accumulation Overflows for Tokens with Large Total Supplies, Permanently Locking Staker Funds

Author Revealed upon completion

Normal behavior:
The k=2 bonus formula computes amount × (T − entryTime)² per deposit. The protocol maintains two per-user accumulators (userSumStakeTime, userSumStakeTimeSq) and two global accumulators (sumStakeTime, sumStakeTimeSq) in uint256. These are computed in unchecked-width multiplications before any accumulation.

The issue:
Several intermediate multiplications in the scoring path compute amount × timestamp × timestamp directly, which can overflow uint256 for tokens with sufficiently large supplies. Solidity 0.8.26 uses checked arithmetic by default — an overflow reverts the transaction. If overflow is triggered at claim time (inside _clampUserSums or _bonusShare), the revert permanently locks the affected staker's funds with no recovery path.

Overflow sites:

// stake() — at deposit time (blocks the stake, staker can retry with smaller amount):
// @>
uint256 contribTimeSq = received * newEntry * newEntry;
// _markRiskWindowStart() — at risk-window-open time (affects all existing stakers):
// @>
sumStakeTimeSq = totalEligibleStake * t * t;
// _clampUserSums() — at claim time (can lock funds permanently):
// @>
userSumStakeTimeSq[u] = stake_ * start * start;
// _bonusShare() — at claim time (can lock funds permanently):
// @>
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
// @>
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;

Overflow threshold analysis:
With current timestamps t ≈ 1.75 × 10⁹ (seconds since epoch):

t² ≈ 3.06 × 10¹⁸
uint256 max ≈ 1.16 × 10⁷⁷
Safe stake ceiling (single user):
amount × t² ≤ 2²⁵⁶ - 1
amount ≤ 1.16×10⁷⁷ / 3.06×10¹⁸ ≈ 3.8 × 10⁵⁷ raw token units
For an 18-decimal token: ≈ 3.8 × 10³⁹ "whole" tokens

While this ceiling is astronomically high for any realistic ERC20, the factory allowlist (setStakeTokenAllowed) is the only guard against non-standard tokens. Governance error (admitting a high-supply token) or a proxy-token upgrade post-allowlisting could admit a token where stakers overflow at claim time with no recourse.

Critical difference between overflow sites:

  • stake() overflow → transaction reverts, staker receives their tokens back. Self-healing.

  • _markRiskWindowStart() overflow → risk-window-open transaction reverts, but no permanent lock.

  • _clampUserSums() / _bonusShare() overflow → claim-time revert. The staker's eligibleStake is non-zero, hasClaimed is false, but every claimSurvived / claimExpired call reverts. Funds are permanently locked since there is no admin recovery path.

Risk

Likelihood:

  • A standard ERC20 with realistic supply (≤ 10³⁰ raw units) is safe — the existing test testBonusShareDoesNotOverflowAtHighMagnitudes confirms correctness at 1e34 raw units

  • A governance error admits a token with supply > 10³⁸ raw units (e.g., a meme token with 18 decimals and 10²⁰ "whole" token supply)

  • A proxy-upgrade to a rebasing/high-supply model on an already-allowlisted token

Impact:

  • Stakers with eligibleStake > 0 on a resolved SURVIVED/EXPIRED pool cannot call claimSurvived() / claimExpired() — every call reverts at _clampUserSums or _bonusShare

  • Funds are permanently locked; no admin can force-transfer them out since there is no recovery entrypoint

Proof of Concept

The existing test suite already documents the safe ceiling:

// test/unit/ConfidencePool.k2Bonus.t.sol (existing)
// testBonusShareDoesNotOverflowAtHighMagnitudes confirms correctness at 1e34 raw units.
// Overflow would trigger at approximately 3.8e57 raw units for a single staker.

Demonstration of the claim-time lock (conceptual — requires a mock token with extreme supply):

// With a token where totalSupply = 1e60 raw units (18 decimals → 1e42 "whole" tokens):
// 1. Staker stakes 1e57 raw units (well within uint256)
// 2. stake() succeeds: contribTimeSq = 1e57 * 1.75e9 * 1.75e9 = ~3e75 — within uint256
// 3. riskWindowStart seals: sumStakeTimeSq = 1e57 * 1.75e9^2 ≈ 3e75 — within uint256
// (at riskWindowStart time, but as totalEligibleStake grows toward 3.8e57, this overflows)
// 4. At claim: _clampUserSums → stake_ * start * start → overflow revert → LOCKED

Recommended Mitigation

Option A — Tighten the allowlist with an explicit per-token supply cap:

function setStakeTokenAllowed(address token, bool allowed) external onlyOwner {
if (token == address(0)) revert ZeroAddress();
+ if (allowed) {
+ // Guard: totalSupply * (type(uint32).max)^2 must not overflow uint256.
+ // type(uint32).max ≈ 4.3e9; (4.3e9)^2 ≈ 1.85e19; safe ceiling ≈ 6.2e57 raw units.
+ uint256 supply = IERC20(token).totalSupply();
+ if (supply > type(uint256).max / (uint256(type(uint32).max) * type(uint32).max)) {
+ revert SupplyTooLarge();
+ }
+ }
allowedStakeToken[token] = allowed;
emit StakeTokenAllowedUpdated(token, allowed);
}

Option B — Add a per-pool stake cap in stake():

+ uint256 public constant MAX_SAFE_STAKE = 1e55; // leaves 100× margin below overflow
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
+ if (totalEligibleStake + received > MAX_SAFE_STAKE) revert StakeCapExceeded();
// ...
}

Option C — Use Math.mulDiv for the squared intermediates:
Restructure _clampUserSums and _bonusShare to defer the full multiply-then-divide to Math.mulDiv (512-bit intermediates), eliminating the overflow at claim time. The _bonusShare final step already uses mulDiv; the intermediate accumulator multiplications need the same treatment.

Option A is the lightest-touch fix and prevents the problematic token from entering the system entirely. Option C provides defense-in-depth but requires restructuring the accumulator math.

Support

FAQs

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

Give us feedback!