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

Future-time risk moment overflow can disable expiry resolution and lock pool funds

Author Revealed upon completion

Description

  • Normally, stake deposited before the risk window is initially recorded using its deposit timestamp. When active risk is first observed, _markRiskWindowStart resets the global moments so existing stake is
    treated as entering at the risk-window start.

  • Stake admission only proves the quadratic moment fits at the earlier deposit timestamp. _markRiskWindowStart later multiplies the already-accepted aggregate stake by a larger timestamp without reserving
    arithmetic headroom.

  • When the later multiplication overflows, every active-risk observation reverts. When active risk persists through pool expiry, claimExpired also reverts before it can resolve the pool.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> These moments are reconstructed at a later timestamp than
// @> the timestamp used when the stake was originally admitted.
sumStakeTime = totalEligibleStake * t;
// @> An aggregate that was valid at deposit time can overflow here.
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

The failure reaches the expiry backstop through _observePoolState:

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// @> Active-risk observation calls _markRiskWindowStart
// @> before claimExpired can select the EXPIRED branch.
IAttackRegistry.ContractState state = _observePoolState();
// Resolution logic...
}
}

Risk

Likelihood: Low

  • This occurs when an allowlisted high-raw-supply or high-decimal token produces an aggregate stake whose entry-time square fits but whose later risk-start square does not.

  • This causes an unbounded lock while UNDER_ATTACK or PROMOTION_REQUESTED persists through the pool’s expiry.

Impact:

  • pokeRiskWindow, withdrawal, moderator resolution, and permissionless expiry resolution can all revert on the same writer.

  • Principal and bonus remain locked until the external registry reaches a genuine terminal state. A later PRODUCTION or CORRUPTED state restores canonical routing.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {stdError} from "forge-std/StdError.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract RiskStartOverflowPoCToken is ERC20 {
constructor() ERC20("Risk Start Overflow PoC", "RSPOC") {}
function decimals() public pure override returns (uint8) {
return 60;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
/// "Accepted stake can overflow first risk-start moments and disable the active-risk expiry backstop".
contract AcceptedStakeCanOverflowFirstRiskStartMomentsAndDisableTheActiveRiskExpiryBackstopPoC is Test {
uint256 internal constant ENTRY_TIME = 1_750_000_000;
address internal constant SCOPE = address(0xC0FFEE);
RiskStartOverflowPoCToken internal token;
MockAgreement internal agreement;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal registry;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal whale = makeAddr("whale");
address internal victim = makeAddr("victim");
address internal donor = makeAddr("donor");
function setUp() external {
vm.warp(ENTRY_TIME);
token = new RiskStartOverflowPoCToken();
agreement = new MockAgreement(address(this));
agreement.setContractInScope(SCOPE, true);
attackRegistry = new MockAttackRegistry();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
registry = new MockSafeHarborRegistry();
registry.setAttackRegistry(address(attackRegistry));
registry.setAgreementValid(address(agreement), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(implementation), moderator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = SCOPE;
pool = ConfidencePool(
factory.createPool(address(agreement), address(token), ENTRY_TIME + 31 days, 1, recovery, scope)
);
}
function test_PoC_RiskStartOverflowDisablesActiveRiskExpiryBackstop() external {
uint256 hugeStake = type(uint256).max / (ENTRY_TIME * ENTRY_TIME) - 1_000_000 ether;
_stake(whale, hugeStake);
_stake(victim, 100 ether);
_contributeBonus(donor, 1 ether);
vm.warp(ENTRY_TIME + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// The later risk timestamp makes totalEligibleStake * t^2 overflow.
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
// Stake was committed in an earlier transaction and cannot be withdrawn.
vm.expectRevert(stdError.arithmeticError);
vm.prank(victim);
pool.withdraw();
// Active risk persists through expiry. Both a staker and non-staker resolver hit the same overflow.
vm.warp(pool.expiry());
vm.expectRevert(stdError.arithmeticError);
vm.prank(victim);
pool.claimExpired();
vm.expectRevert(stdError.arithmeticError);
vm.prank(donor);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
assertEq(token.balanceOf(address(pool)), hugeStake + 101 ether);
vm.expectRevert(IConfidencePool.OutcomeNotEligibleForSweep.selector);
pool.sweepUnclaimedBonus();
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Run with:

forge test --mt test_PoC_RiskStartOverflowDisablesActiveRiskExpiryBackstop

Recommended Mitigation

Store time moments relative to riskWindowStart instead of using absolute Unix timestamps. Pre-risk stake receives an offset of zero, so opening the risk window no longer requires multiplying the entire
accepted stake by a later timestamp.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
- uint256 newEntry = block.timestamp;
- uint256 start = riskWindowStart;
- if (start != 0 && newEntry < start) newEntry = start;
-
- uint256 contribTime = received * newEntry;
- uint256 contribTimeSq = received * newEntry * newEntry;
+ // Moments are measured relative to riskWindowStart.
+ // All pre-risk stake starts at offset zero.
+ uint256 entryOffset =
+ riskWindowStart == 0
+ ? 0
+ : block.timestamp - riskWindowStart;
+
+ uint256 contribTime = received * entryOffset;
+ uint256 contribTimeSq =
+ received * entryOffset * entryOffset;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
- sumStakeTime = totalEligibleStake * t;
- sumStakeTimeSq = totalEligibleStake * t * t;
+ // Pre-risk stake was already recorded at offset zero.
+ // Opening the window therefore requires no aggregate multiplication.
+ sumStakeTime = 0;
+ sumStakeTimeSq = 0;
emit RiskWindowStarted(t);
}
function _bonusShare(
address u,
uint256 userEligible
) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
- uint256 T = outcomeFlaggedAt;
+ uint256 duration =
+ outcomeFlaggedAt - riskWindowStart;
- uint256 userPlus =
- T * T * userEligible + userSumStakeTimeSq[u];
- uint256 userMinus =
- 2 * T * userSumStakeTime[u];
+ uint256 userPlus =
+ duration * duration * userEligible
+ + userSumStakeTimeSq[u];
+ uint256 userMinus =
+ 2 * duration * userSumStakeTime[u];
- uint256 plus =
- T * T * snapshotTotalStaked
- + snapshotSumStakeTimeSq;
- uint256 minus =
- 2 * T * snapshotSumStakeTime;
+ uint256 plus =
+ duration * duration * snapshotTotalStaked
+ + snapshotSumStakeTimeSq;
+ uint256 minus =
+ 2 * duration * snapshotSumStakeTime;
// ...
}

This preserves the intended behavior, pre-risk stake begins earning at riskWindowStart, while removing the later aggregate moment reconstruction that can make the first risk observation revert.

Support

FAQs

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

Give us feedback!