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

Permissionless `claimExpired` allows non-stakers to finalize outcome before moderator can flag CORRUPTED

Author Revealed upon completion

DESCRIPTION

After pool expiry, any address — including a non-staker — can call claimExpired(). When the pool is unresolved, this function reads the registry state and auto-resolves the outcome. If the registry is in a CORRUPTED state but riskWindowStart == 0 (no local risk window was observed), claimExpired falls through to the else branch and resolves as EXPIRED — returning principal + bonus to the caller instead of sweeping as CORRUPTED.

Once claimExpired resolves the outcome, it sets claimsStarted = true, which permanently locks the outcome. The moderator can no longer re-flag to CORRUPTED.

This creates a race: a staker (or anyone) can call claimExpired first after expiry and lock the EXPIRED outcome, preventing the moderator from flagging CORRUPTED on a genuinely corrupted agreement.

Root cause:

@> ConfidencePool.sol:542-552

} else {
// Reached for EVERY non-terminal state, including active-risk ...
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
// ...
claimsStarted = true;

The EXPIRED branch is the catch-all for any registry state that is not PRODUCTION or CORRUPTED+riskWindowStart. When riskWindowStart == 0 and the registry is CORRUPTED, the condition at line 521 (state == CORRUPTED && riskWindowStart != 0) is false, so execution falls through to EXPIRED.

RISK

Likelihood: Medium

  • Occurs whenever: (1) the pool reaches expiry, (2) the agreement registry is CORRUPTED, (3) no pool interaction observed the registry in an active-risk state (riskWindowStart == 0), and (4) the moderator has not yet flagged the outcome.

  • The first caller after expiry can trigger this regardless of whether they are a staker or not. Stakers have an incentive to call it (they receive principal + bonus).

  • The moderator has the entire pool term plus the post-expiry window to flag, but the race window opens at expiry and closes on the first claimExpired call.

Impact: Medium

  • A genuinely corrupted agreement (in-scope breach) resolves as EXPIRED — stakers get their principal + bonus back as if the agreement survived.

  • The pool's CORRUPTED path (sweep to recoveryAddress) is permanently blocked.

  • Per DESIGN.md §5, this is an accepted race — auto-CORRUPTED when riskWindowStart == 0 would be scope-blind over-punishment. The moderator is expected to flag promptly.

PROOF OF CONCEPT

The PoC test test_MC03_claimExpiredFrontrunsModeratorCorrupted in test/PoCFindings.t.sol passes and demonstrates:

_stake(alice, 100 * ONE);
// Registry goes directly to CORRUPTED without passing through
// an active-risk state (e.g. via instantCorrupt)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// riskWindowStart == 0 (no active risk was observed)
assertEq(pool.riskWindowStart(), 0);
// Warp past expiry
vm.warp(pool.expiry() + 1);
// Alice calls claimExpired — falls through to EXPIRED
pool.claimExpired();
// Outcome is now EXPIRED and locked
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED));
// Moderator tries to flag CORRUPTED — blocked by claimsStarted
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));

RECOMMENDED MITIGATION

If this race is not acceptable for the protocol's risk model, restrict claimExpired outcome finalization to stakers only (non-stakers can still finalize without locking the outcome):

+ if (outcome == PoolStates.Outcome.UNRESOLVED) {
+ // Only stakers can trigger auto-resolution that locks the outcome
+ if (eligibleStake[msg.sender] == 0) {
+ // Non-stakers can observe and return, but cannot lock
+ return;
+ }
+ // ... existing resolution logic ...
+ }

Alternatively, add a configurable delay between when the registry reaches a terminal state and when claimExpired can auto-resolve, giving the moderator time to flag.

Support

FAQs

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

Give us feedback!