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

Timestamp-squared accounting overflows and blocks active-risk lifecycle calls

Author Revealed upon completion

Description

Root + Impact

Normal behavior

ConfidencePool records time-weighted stake moments and opens riskWindowStart on the first observation of UNDER_ATTACK or PROMOTION_REQUESTED. This window is required for the protocol's k=2 bonus accounting, and the pool should remain able to process registry observations, expiry resolution, and moderator settlement throughout the agreement lifecycle.

Specific issue

The protocol uses absolute Unix timestamps in squared intermediate expressions. stake() first stores received * block.timestamp * block.timestamp; later, the first active-risk observation rewrites the global moment as totalEligibleStake * block.timestamp * block.timestamp.

An attacker can select an amount that is small enough for the first multiplication at timestamp t, but too large for the second multiplication at timestamp t + 1. Since Solidity 0.8 reverts on overflow, _markRiskWindowStart() always reverts. The revert also rolls back riskWindowStart, allowing the same denial of service to be repeated for every call made while the registry remains in an active-risk state.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
uint256 newEntry = block.timestamp;
// @> The initial stake succeeds with an amount bounded by newEntry².
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
// ...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
// @> Every active-state observation reaches the overflowing calculation below.
_markRiskWindowStart();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
// @> Reverts for a stake amount that was valid one second earlier.
sumStakeTimeSq = totalEligibleStake * t * t;
}

Risk

Likelihood: Medium

  1. The factory does not constrain an allowlisted token's raw-unit supply or a pool's total eligible stake.

  2. A standard ERC-20 balance of approximately 3.78e58 raw units at a production-scale timestamp is sufficient. The actual likelihood depends on the supply, decimals, and economics of tokens admitted to the factory allowlist.

Impact: Medium

  1. pokeRiskWindow, stake, contributeBonus, withdraw, flagOutcome, claimExpired, and setPoolScope all observe registry state. During an active registry state, each such call reverts before it can complete its intended lifecycle action.

  2. Principal and bonus settlement can be delayed for the entire active-risk period. The attack does not require a privileged protocol role once an eligible high-supply token exists.

Proof of Concept

Add the following test to test/unit/ConfidencePool.k2Bonus.t.sol. It reuses the project's BaseConfidencePoolTest fixture and its mock ERC-20/registry.

function testHighRawUnitStakeBricksRiskWindowStart() external {
uint256 t = BASE_TIMESTAMP; // 1_750_000_000
// amount * t² succeeds in stake(), while amount * (t + 1)² overflows
// in _markRiskWindowStart().
uint256 amount = type(uint256).max / ((t + 1) * (t + 1)) + 1;
_stake(alice, amount);
vm.warp(t + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(); // Panic(0x11): arithmetic overflow/underflow
pool.pokeRiskWindow();
// The revert rolls back the latch. Every later active-state observation
// reaches the same overflowing multiplication.
assertEq(pool.riskWindowStart(), 0);
vm.expectRevert();
pool.pokeRiskWindow();
}

For t = 1,750,000,000, this test uses:

amount = 37809661748565674862295987344354702787177971450175660792381
amount × t² <= type(uint256).max
amount × (t + 1)² > type(uint256).max

Recommended Mitigation

Set a total-stake cap derived from the maximum supported timestamp before storing any time-squared moment. The factor of two also protects the later T² * stake + sumStakeTimeSq and 2 * T * sumStakeTime expressions in _bonusShare.

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
@@
+ error TotalStakeTooLarge();
+
+ uint256 private constant _MAX_TOTAL_ELIGIBLE_STAKE =
+ type(uint256).max / (2 * uint256(type(uint32).max) * uint256(type(uint32).max));
@@ function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ if (received > _MAX_TOTAL_ELIGIBLE_STAKE - totalEligibleStake) {
+ revert TotalStakeTooLarge();
+ }
_clampUserSums(msg.sender);

As a defense in depth measure, the protocol can also rebase moment accounting to elapsed time from riskWindowStart rather than using absolute Unix timestamps. This reduces the magnitude of the arithmetic but does not replace the total-stake bound unless every elapsed-time expression is separately bounded.

Support

FAQs

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

Give us feedback!