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

`claimExpired` races moderator CORRUPTED flag when `riskWindowStart == 0`

Author Revealed upon completion

Root + Impact

An in-scope breach that should result in principal being swept to recoveryAddress instead refunds stakers. The confidence pool's core enforcement mechanism — penalizing breaches — is defeated by a staker front-running the moderator.

Description

  • After expiry, claimExpired() auto-determines the outcome based on live registry state: PRODUCTION → SURVIVED, CORRUPTED (+ observed risk window + grace period) → auto-CORRUPTED, everything else → EXPIRED.

  • When the registry is terminal-CORRUPTED but riskWindowStart == 0 (no pool interaction observed the active-risk phase), claimExpired resolves EXPIRED instead of CORRUPTED. A staker can front-run the moderator by calling claimExpired before flagOutcome(CORRUPTED), permanently locking the pool as EXPIRED and returning principal + bonus to stakers when the correct outcome should have been CORRUPTED (sweep to recoveryAddress).

// ConfidencePool.sol:claimExpired — lines 532-571
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> // auto-CORRUPTED path requires riskWindowStart != 0
// ...
return;
}
@> // CORRUPTED + riskWindowStart == 0 falls through → EXPIRED
@> outcome = PoolStates.Outcome.EXPIRED;

Risk

Likelihood:

Reason 1 — The no-risk-window CORRUPTED scenario requires the registry to transition directly to CORRUPTED without any pool interaction during an active-risk phase. This is reachable when `markCorrupted` is called without the agreement ever entering `UNDER_ATTACK`.
Reason 2 — Any staker or third party can call `claimExpired` permissionlessly after expiry. There is no delay or cooldown. The moderator must be faster than any staker to preserve the correct CORRUPTED outcome.

Impact:

Impact 1 — An in-scope breach that should sweep principal to `recoveryAddress` instead returns it to stakers, defeating the purpose of the confidence pool.
Impact 2 — The pool becomes permanently locked as EXPIRED (`claimsStarted = true`), and the moderator can never correct the outcome.

Proof of Concept

function test_L4_ClaimExpiredRacesModerator() public {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
vm.warp(pool.expiry() + 1);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted());
// Moderator permanently locked out
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}

Forge output:

[PASS] test_L4_ClaimExpiredRacesModerator()
claimExpired pre-empted moderator, resolved EXPIRED
Staker got principal back when CORRUPTED should have swept

Recommended Mitigation

Extend the moderator's grace period to cover the no-risk-window CORRUPTED case as well. Currently only the riskWindowStart != 0 path defers to the moderator; applying the same deferral when riskWindowStart == 0 gives the moderator 180 days to flag the correct CORRUPTED outcome before any staker can race to EXPIRED.

+ // Defer to moderator during grace period even when riskWindowStart == 0
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart == 0) {
+ if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
+ }

Support

FAQs

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

Give us feedback!