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

Time-delayed accumulator overflow can permanently block settlement

Author Revealed upon completion

M-01: Time-delayed accumulator overflow can permanently block settlement

Description

The pool maintains O(1) time-weighted bonus-accounting accumulators. Before the risk window opens, stake() records each deposit using the current timestamp. On the first observation of UNDER_ATTACK or PROMOTION_REQUESTED, _markRiskWindowStart() resets the global accumulators so all existing eligible stake is treated as entering at the observed risk-window timestamp.

stake() only proves that the amount is safe for multiplication at the deposit timestamp. It does not reserve arithmetic headroom for the later, larger riskWindowStart timestamp. Consequently, a stake that is accepted before the risk window can make _markRiskWindowStart() overflow later. Every call that tries to observe the active-risk state then reverts, including pokeRiskWindow() and the expiry settlement path.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
uint256 newEntry = block.timestamp;
// The amount is validated only against `newEntry`, which is the current timestamp.
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
// ...
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> `t` can be larger than the timestamp used when a pre-risk stake was accepted.
// @> These unchecked-domain products revert and roll back the entire observation.
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}

Risk

Likelihood:

  • The factory accepts any owner-approved standard ERC-20 and imposes no on-chain maximum on raw token supply, token decimals, or aggregate pool stake. A standards-compliant high-decimal/high-supply token can provide the required raw amount without fee, rebase, or callback behavior.

  • UNDER_ATTACK and PROMOTION_REQUESTED are normal active-risk lifecycle states, not exceptional breach states. A stake accepted before this state can cross the multiplication boundary after time advances and the risk window is first observed.

  • The triggering stake is approximately 3.77e58 raw units. This is representable by a standard ERC-20 with a sufficiently high decimal precision or supply. Therefore, likelihood is low for conventional assets and meaningful for any approved asset without a conservative raw-unit bound.

Impact:

  • The first active-risk observation reverts. pokeRiskWindow() cannot seal the window; deposits and bonus contributions also fail because they observe state before proceeding.

  • When the pool reaches expiry while still in an active-risk state, claimExpired() should resolve EXPIRED and return staker principal plus bonus. Instead, it reverts during state observation, temporarily locking the pool’s stake and bonus until the registry reaches a terminal state without a successful active-risk observation.

  • A staker can poison the full pool’s settlement liveness with one accepted deposit. The attack does not steal funds and also leaves the attacker’s stake locked; nevertheless, it disrupts the protocol’s normal expiry guarantee for every participant. This supports Medium severity where full-pool, attacker-triggered settlement DoS

Proof of Concept

Create a new test file: test/unit/ConfidencePool.arithmetic.t.sol

Add this test below:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice Regression/PoC coverage for arithmetic boundaries in the time-weighted accumulator.
contract ConfidencePoolArithmeticTest is BaseConfidencePoolTest {
function testPoC_largeValidStakeOverflowsWhenRiskIsObservedLaterAndBlocksSettlement() external {
// This amount is safe for the wall-clock timestamp used by stake(), so the deposit is
// accepted. It becomes unsafe after the 31-day passage to expiry, when
// _markRiskWindowStart computes totalEligibleStake * expiry * expiry.
uint256 amount = type(uint256).max / block.timestamp / block.timestamp;
_stake(alice, amount);
vm.warp(pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Any first observation of active risk now overflows before it can seal the window.
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
// The permissionless expiry settlement path makes the same observation and is blocked.
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
}
}

Recommended Mitigation

Enforce a maximum aggregate raw stake that is safe for the entire pool lifetime, not merely safe at the deposit timestamp. The factor of two also preserves headroom for the two positive terms added in _bonusShare.

+ error StakeExceedsAccountingCapacity();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ // Keeps all `amount * timestamp * timestamp` accumulator terms, including
+ // the two-term additions in `_bonusShare`, inside uint256 for any timestamp
+ // up to the immutable expiry.
+ uint256 maxTotalStake =
+ type(uint256).max / 2 / uint256(expiry) / uint256(expiry);
+ if (
+ received > maxTotalStake
+ || totalEligibleStake > maxTotalStake - received
+ ) {
+ revert StakeExceedsAccountingCapacity();
+ }
+
_clampUserSums(msg.sender);
// existing stake accounting...
}

Support

FAQs

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

Give us feedback!