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

No-Risk-Window CORRUPTED Race — Staker Can Force EXPIRED

Author Revealed upon completion

Description

When riskWindowStart == 0 (the pool's one-way risk latch was never triggered because nobody observed the registry in active-risk state), a staker can call claimExpired() to settle the pool as EXPIRED before the moderator flags CORRUPTED. This bypasses the intended CORRUPTED settlement path and returns principal to stakers instead of sweeping to the recovery address.

Vulnerability Details

In ConfidencePool.sol:532-534, the automatic CORRUPTED resolution inside claimExpired() is gated on riskWindowStart != 0:

if (riskWindowStart != 0 && state == IAttackRegistry.ContractState.CORRUPTED) {
outcome = PoolStates.Outcome.CORRUPTED;
...
}

When riskWindowStart == 0, CORRUPTED state is skipped and the pool falls through to EXPIRED resolution. After claimExpired() sets claimsStarted = true, the moderator can no longer call flagOutcome(CORRUPTED) (line 327).

Impact

If the registry enters CORRUPTED state without anyone first observing the active-risk transition, a staker can:

  1. Monitor the registry

  2. Call claimExpired() immediately when the pool expires

  3. Force EXPIRED resolution → withdraw principal

  4. Moderator's subsequent flagOutcome(CORRUPTED) reverts

This is documented in DESIGN.md §5 as "accepted, not a bug" but represents a bypass of the intended CORRUPTED settlement mechanics.

Proof of Concept

function testRiskWindowZeroCorruptedRace() public {
// Setup: pool has stakers, registry transitions directly to CORRUPTED
// No one called _observePoolState() during the active-risk phase
assertEq(pool.riskWindowStart(), 0);
// Pool expires
vm.warp(pool.expiry() + 1);
// Staker calls claimExpired BEFORE moderator flags
vm.prank(staker);
pool.claimExpired();
assertEq(pool.outcome(), EXPIRED); // Not CORRUPTED!
// Moderator tries to flag CORRUPTED — reverts
vm.prank(moderator);
vm.expectRevert(OutcomeAlreadySet.selector);
pool.flagOutcome(CORRUPTED, false, address(0));
// Staker withdraws principal
vm.prank(staker);
uint256 balanceBefore = token.balanceOf(staker);
pool.claimExpired();
uint256 balanceAfter = token.balanceOf(staker);
assertGt(balanceAfter, balanceBefore);
}

Recommended Mitigation

Remove the riskWindowStart != 0 gate from claimExpired() auto-CORRUPTED:

// Allow CORRUPTED resolution even when riskWindowStart == 0
if (state == IAttackRegistry.ContractState.CORRUPTED) {
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = block.timestamp;
...
}

References

  • ConfidencePool.sol:532-534 — CORRUPTED gate

  • ConfidencePool.sol:327 — claimsStarted lock

  • DESIGN.md §5 — documented race condition

Recommended Mitigation

Support

FAQs

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

Give us feedback!