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

Permissionless claimExpired can foreclose CORRUPTED settlement when no risk window was observed

Author Revealed upon completion

Root + Impact

Description

When the registry has already reached CORRUPTED, a permissionless caller can still finalize the pool as EXPIRED if no pool interaction previously observed the active-risk phase and riskWindowStart therefore remains zero.

The affected route is:

  • _observePoolState()

  • claimExpired()

  • flagOutcome()

The automatic CORRUPTED branch inside claimExpired() requires both:

registry state == CORRUPTED
riskWindowStart != 0

If the registry reached CORRUPTED without the pool previously observing UNDER_ATTACK or PROMOTION_REQUESTED, the second condition is false.

After expiry, any caller can invoke claimExpired(). The function then falls through to the EXPIRED branch, returns principal through the expiration settlement path, and permanently latches the mechanical outcome.

Because claimsStarted is set during the mechanical resolution, the moderator can no longer correct the outcome to CORRUPTED.

Risk

Likelihood

Medium.

The route requires:

  • the registry to reach CORRUPTED;

  • no earlier pool interaction to seal riskWindowStart;

  • the pool to reach expiry; and

  • a permissionless caller to invoke claimExpired() before the moderator flags the corrupted outcome.

No privileged role, non-standard token behavior, or direct storage modification is required.

Impact

High.

For a genuine in-scope corruption, the pool can be irreversibly finalized as EXPIRED before the moderator applies the correct CORRUPTED settlement.

Stakers can recover their principal through the expiration path, while the later moderator action that would route the pool through corrupted settlement is blocked.

This can cause the entire staked principal to bypass the intended CORRUPTED outcome and its recovery or good-faith bounty path.

Proof of Concept

The PoC reproduces a terminal CORRUPTED registry state with no locally observed risk window.

Relevant code path

// @> Auto-CORRUPTED is entered only when a risk window was locally observed.
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
outcome = PoolStates.Outcome.CORRUPTED;
claimsStarted = true;
return;
}
// @> With riskWindowStart == 0, execution falls through to EXPIRED.
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
// @> Mechanical resolution permanently closes later moderator correction.
claimsStarted = true;

The later moderator route is blocked by:

if (
outcome != PoolStates.Outcome.UNRESOLVED
&& claimsStarted
) {
revert OutcomeAlreadySet();
}

Full PoC test

function testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// @> Registry reaches terminal CORRUPTED without any earlier
// pool interaction observing the active-risk interval.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
assertEq(pool.riskWindowStart(), 0);
vm.warp(
pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE()
);
uint256 aliceBefore = token.balanceOf(alice);
// @> Any staker can mechanically finalize the pool.
vm.prank(alice);
pool.claimExpired();
// @> The pool is finalized as EXPIRED, not CORRUPTED.
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
// @> Alice recovers the complete principal.
assertEq(
token.balanceOf(alice) - aliceBefore,
100 * ONE
);
// @> Mechanical resolution closes the moderator correction route.
assertTrue(pool.claimsStarted());
}

Command

forge test --match-path "test/unit/ConfidencePool.riskWindow.t.sol" --match-test testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened -vvv

Observed output

[PASS] testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened()
Suite result: ok.

What the PoC proves

Before claimExpired():

registry state = CORRUPTED
riskWindowStart = 0
pool outcome = UNRESOLVED
staker principal remains in the pool

After claimExpired():

pool outcome = EXPIRED
claimsStarted = true
staker principal is returned
later CORRUPTED correction is foreclosed

This proves that the absence of a locally observed risk window can convert a terminal corrupted state into an irreversible expired settlement.

Recommended Mitigation

The finalization logic should preserve a scope-aware decision boundary when the registry is already terminal CORRUPTED, even if the active-risk interval was not locally observed.

A safe correction should prevent the permissionless expiration path from irreversibly foreclosing a valid corrupted settlement before the relevant terminal state has been reconciled.

- Fall through immediately to irreversible EXPIRED settlement when
- registry == CORRUPTED and riskWindowStart == 0.
+ Preserve a scope-aware terminal decision boundary before final settlement.
+ Prevent permissionless EXPIRED resolution from permanently foreclosing
+ a valid CORRUPTED outcome.

A regression test should verify that a terminal CORRUPTED registry state cannot be irreversibly converted into EXPIRED solely because the pool did not locally observe the earlier active-risk phase.

Support

FAQs

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

Give us feedback!