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

Risk-window accumulator overflow can disable the active-risk expiry backstop

Author Revealed upon completion

Root + Impact

Description

Confidence pools are designed to remain resolvable through claimExpired() once their configured expiry is reached, including while the associated agreement remains in an active-risk state such as UNDER_ATTACK.

In this case, the pool should settle as EXPIRED, allowing stakers to recover their principal and applicable bonus.

However, the first interaction that observes an active-risk registry state calls _markRiskWindowStart(), which reconstructs the global time-weighted accumulators using the current aggregate eligible stake and the later risk-window timestamp.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
// @> This multiplication can overflow even when the original
// stake accounting at the earlier deposit timestamp succeeded.
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

A stake is initially accepted using the deposit timestamp:

uint256 contribTime = received * block.timestamp;
uint256 contribTimeSq =
received * block.timestamp * block.timestamp;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;

Therefore, an amount can satisfy:

amount × depositTimestamp² <= type(uint256).max

when deposited, while later violating:

amount × riskWindowStart² <= type(uint256).max

because riskWindowStart is greater than the original deposit timestamp.

When the agreement remains UNDER_ATTACK until pool expiry, claimExpired() invokes _observePoolState(), which attempts to mark the risk-window start before resolving the pool.

The accumulator calculation then reverts with Solidity arithmetic panic 0x11.

As a result, the pool remains unresolved and the expiry backstop cannot return the affected staker’s principal while the agreement remains in the active-risk state.

This behavior is not one of the intentional active-risk expiry behaviors documented in docs/DESIGN.md. The documented behavior is that expiry remains a mechanical backstop that returns principal and bonus even during active risk.

Risk

Likelihood:

  1. This occurs when the pool accepts an ERC-20 stake whose amount fits the squared-time accumulator at the deposit timestamp but no longer fits when the risk window is first observed at a later timestamp.

  2. The agreement must enter UNDER_ATTACK or PROMOTION_REQUESTED after the deposit, and the relevant pool interaction must observe that state at a sufficiently later timestamp.

  3. No fee-on-transfer behavior, rebasing, malicious callback, compromised moderator, or invalid registry transition is required. The proof of concept uses the repository’s standard MockERC20.

  4. The required token quantity is extremely large at current Unix timestamps. Therefore, the practical likelihood is very low.
    Impact:

  5. claimExpired() reverts before setting the pool outcome to EXPIRED, disabling the documented expiry-resolution path.

  6. The staker cannot recover their principal while the registry remains in the active-risk state.

  7. Other functions that attempt to observe the same active-risk transition also repeat the overflowing calculation, preventing the risk-window start from being successfully recorded.

Proof of Concept

Create test/PoC1.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from
"@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from
"test/helpers/BaseConfidencePoolTest.sol";
contract PoC1Test is BaseConfidencePoolTest {
function test_PoC_expiryClaimRevertsWhenRiskWindowResetOverflows()
public
{
uint256 depositTime = block.timestamp;
uint256 expiryTime = pool.expiry();
// Smallest amount that overflows when multiplied by expiry².
uint256 largeStake =
type(uint256).max / (expiryTime * expiryTime) + 1;
// The amount still fits when accounted for at the earlier
// deposit timestamp, allowing stake() to succeed.
assertLe(
largeStake * depositTime * depositTime,
type(uint256).max,
"initial stake-time-square must fit"
);
token.mint(alice, largeStake);
vm.startPrank(alice);
token.approve(address(pool), largeStake);
pool.stake(largeStake);
vm.stopPrank();
assertEq(pool.eligibleStake(alice), largeStake);
assertEq(token.balanceOf(address(pool)), largeStake);
// The agreement enters active risk and stays there through expiry.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
vm.warp(expiryTime);
// claimExpired()
// -> _observePoolState()
// -> _markRiskWindowStart()
// -> totalEligibleStake * expiry²
//
// The final calculation overflows.
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSignature("Panic(uint256)", 0x11)
);
pool.claimExpired();
// Expiry resolution did not occur and the principal remains held
// by the unresolved pool.
assertEq(
uint256(pool.outcome()),
0,
"pool remains unresolved"
);
assertEq(pool.eligibleStake(alice), largeStake);
assertEq(token.balanceOf(address(pool)), largeStake);
assertEq(token.balanceOf(alice), 0);
}
}

Run:

forge test \
--match-path test/PoC1.t.sol \
--match-test test_PoC_expiryClaimRevertsWhenRiskWindowResetOverflows \
-vvvv

The test passes and the trace shows claimExpired() reverting from _markRiskWindowStart() with arithmetic panic 0x11.

Recommended Mitigation

Use timestamps relative to a bounded pool accounting epoch instead of absolute Unix timestamps in the first- and second-moment accumulators.

For example, store the pool creation timestamp:

+ uint32 public accountingEpoch;

During initialization:

+ accountingEpoch = uint32(block.timestamp);

Use a relative timestamp when adding stake:

- uint256 t = block.timestamp;
+ uint256 t = block.timestamp - accountingEpoch;
uint256 contribTime = received * t;
uint256 contribTimeSq = received * t * t;

Use the same relative timestamp when resetting the accumulators:

function _markRiskWindowStart() internal {
uint256 absoluteTime = block.timestamp;
if (absoluteTime > expiry) absoluteTime = expiry;
riskWindowStart = uint32(absoluteTime);
- uint256 t = absoluteTime;
+ uint256 t = absoluteTime - accountingEpoch;
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(absoluteTime);
}

As defense in depth, reject stakes that would exceed the accumulator’s maximum capacity at expiry:

function stake(uint256 amount) external {
// Existing token transfer and received-amount calculation.
+ uint256 maxRelativeTime =
+ uint256(expiry) - accountingEpoch;
+
+ if (
+ totalEligibleStake + received
+ > type(uint256).max
+ / maxRelativeTime
+ / maxRelativeTime
+ ) {
+ revert StakeExceedsAccountingCapacity();
+ }
// Existing accounting.
}

Relative-time accounting substantially reduces accumulator magnitudes, while the explicit upper bound ensures that a future risk-window reset cannot overflow.

Support

FAQs

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

Give us feedback!