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

Large Stakes Can Prevent Risk Window Latching and Bypass Auto-Corrupted Resolution

Author Revealed upon completion

Description

When the registry first enters an active-risk state, ConfidencePool is supposed to latch riskWindowStart and reset the global stake-time accumulators. This observed-risk latch is later used by the permissionless claimExpired() backstop to distinguish a real observed-risk CORRUPTED agreement from the no-risk fallback.

The issue is that _markRiskWindowStart() multiplies the full live stake by an absolute timestamp squared. A sufficiently large but valid ERC20 stake can be accepted before risk starts, then make every later active-risk observation revert with arithmetic overflow. As a result, riskWindowStart remains zero, and a later CORRUPTED agreement can fall through to EXPIRED instead of the intended auto-CORRUPTED backstop.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> Large accepted totalEligibleStake can overflow these absolute timestamp terms
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function claimExpired() external nonReentrant {
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
...
// @> The auto-CORRUPTED backstop is skipped when the overflow prevented riskWindowStart
// @> from ever being latched.
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
...
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
return;
}
...
outcome = PoolStates.Outcome.EXPIRED;
}
...
}

Risk

Likelihood:

  • This occurs when a pool uses a standard ERC20 with a very large supply/decimals and a staker deposits an amount large enough that totalEligibleStake * block.timestamp ** 2 is near type(uint256).max.

  • This occurs when the active-risk state is first observed after that large stake was accepted, because _markRiskWindowStart() is evaluated by pokeRiskWindow(), stake(), contributeBonus(), withdraw(), flagOutcome(), or claimExpired().

Impact:

  • The observed-risk latch can become permanently unsealable while the registry is active-risk, disabling the permissionless auto-CORRUPTED backstop.

  • A staker with the large position can recover principal through EXPIRED after the agreement becomes CORRUPTED, instead of having the position swept to recoveryAddress.

Proof of Concept

Add this test to test/unit/ConfidencePool.k2Bonus.t.sol:

function testHugeStakeCanPreventRiskWindowStartFromBeingLatched() external {
uint256 start = vm.getBlockTimestamp();
uint256 huge = type(uint256).max / (start * start);
_stake(alice, huge);
vm.warp(start + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice), huge);
}

Run:

forge test --match-test testHugeStakeCanPreventRiskWindowStartFromBeingLatched -vvv

Expected result:

[PASS] testHugeStakeCanPreventRiskWindowStartFromBeingLatched()

The test passes because the active-risk observation reverts in _markRiskWindowStart(), leaving riskWindowStart == 0. The later claimExpired() call then skips the auto-CORRUPTED branch and resolves as EXPIRED.

Recommended Mitigation

Cap the maximum tracked stake so the timestamp-squared accounting cannot overflow.

+ uint256 internal constant MAX_SCORE_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 (totalEligibleStake + received > MAX_SCORE_STAKE) revert InvalidAmount();
_clampUserSums(msg.sender);
...
}

Alternatively, refactor the accounting to store risk-window-relative time offsets instead of absolute timestamp-squared values.

Support

FAQs

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

Give us feedback!