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

A permissionless claimExpired can foreclose a genuine CORRUPTED outcome when the in-scope breach is confirmed just after expiry, refunding stakers who should have forfeited

Author Revealed upon completion

Description

  • A Confidence Pool lets stakers back the claim that the in-scope contracts survive the agreement term. If the contracts are corrupted during the term, the pool is supposed to sweep its entire balance (stake + bonus) to the recovery address and the stakers forfeit; if the contracts survive, stakers get their principal back plus a share of the bonus. claimExpired() is the permissionless resolver: once block.timestamp >= expiry it reads the live registry state and latches finality with claimsStarted = true.

  • When the registry is still in an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED) at the first post-expiry claimExpired() call and only latches CORRUPTED shortly after expiry, the call falls into the else branch, resolves the pool to EXPIRED, and latches finality. From that point the moderator can never flag CORRUPTED — it is barred by OutcomeAlreadySet — and could not have flagged it earlier either, because flagOutcome(CORRUPTED) requires the registry to already read CORRUPTED, which by assumption only happens after expiry. The grace-period revert that protects a pre-expiry corruption never fires here, because it is gated on the registry already reading CORRUPTED. A permissionless caller therefore forecloses a genuine in-scope breach in a window where the moderator is structurally unable to act.

// claimExpired()
if (block.timestamp < expiry) revert PoolNotExpired();
IAttackRegistry.ContractState state = _observePoolState();
// Grace revert only fires when the registry is ALREADY CORRUPTED — it does NOT cover
// an active-risk state that latches CORRUPTED just after this call.
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
...
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
...
} else {
// Reached for EVERY non-terminal state, incl. active-risk UNDER_ATTACK / PROMOTION_REQUESTED
@> outcome = PoolStates.Outcome.EXPIRED; // in-scope breach imminent, but pool resolves EXPIRED
outcomeFlaggedAt = expiry;
...
}
@> claimsStarted = true; // finality latched with no post-expiry moderator window for an active-risk pool
// flagOutcome() — moderator can only set CORRUPTED once the registry already reads CORRUPTED...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
// ...and any flag is barred once finality is latched
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

Risk

Likelihood:

  • Occurs whenever the in-scope contracts are breached around the term boundary and the on-chain CORRUPTEDtransition latches shortly after expiry, while claimExpired() is already callable — the registry is still UNDER_ATTACK / PROMOTION_REQUESTED at the first post-expiry call, so the grace revert cannot fire and the call resolves EXPIRED.

  • Occurs whenever a remaining staker (or a staker-colluding attacker) calls claimExpired() in that window to lock EXPIRED before the registry reads CORRUPTED; they are directly incentivised to do so because winning the race returns their own principal plus the bonus they would otherwise forfeit, and a colluding attacker can deliberately arrange the on-chain CORRUPTED confirmation to land just after expiry.

Impact:

  • The recovery address (and bonus sponsor) receives nothing on a confirmed in-scope breach; the full pool — every staker's principal plus the bonus — is refunded to the stakers who bet on survival and lost.

  • Defeats the core economic guarantee of the mechanism: stakers are supposed to lose when the in-scope contracts are corrupted, and here they are paid in full instead.

Proof of Concept

Place in the project's test/ directory and run:

forge test --match-contract ExpiredForeclosesCorruptedPoC -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ExpiredForeclosesCorruptedPoC is BaseConfidencePoolTest {
function test_expiredForeclosesInScopeCorrupted() external {
// 1. Two stakers back the pool; the sponsor funds the bonus.
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 60 * ONE);
// 2. The in-scope contracts enter an active attack and the risk window is observed on-chain
// (riskWindowStart != 0), i.e. OUTSIDE the accepted "no risk window" carve-out.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.riskWindowStart() != 0, "risk window observed");
// 3. Term ends while the attack is still live; the registry has NOT yet latched CORRUPTED,
// so the moderator cannot flag CORRUPTED yet (flagOutcome requires state == CORRUPTED).
vm.warp(pool.expiry());
// 4. A staker front-runs the CORRUPTED transition: claimExpired resolves EXPIRED from the
// live active-risk state and latches finality (claimsStarted = true).
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "EXPIRED latched");
// 5. The in-scope breach is now confirmed on-chain.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 6. The moderator can no longer flag CORRUPTED — finality is latched, and it never had a
// prior opportunity (the registry only read CORRUPTED after expiry).
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// 7. The corrupted pool refunds the stakers principal + bonus; recovery gets nothing.
vm.prank(bob);
pool.claimExpired();
assertEq(token.balanceOf(alice), 130 * ONE, "alice recovered principal + bonus");
assertEq(token.balanceOf(bob), 130 * ONE, "bob recovered principal + bonus");
assertEq(token.balanceOf(recovery), 0, "recovery address swept nothing on an in-scope breach");
}
}

Result:

alice payout: 130 (100 principal + 30 bonus)
bob payout: 130 (100 principal + 30 bonus)
recovery: 0

The corrupted pool paid the stakers in full and swept nothing to the recovery address.

Recommended Mitigation

Extend the grace-period protection that already exists for a pre-expiry CORRUPTED so it also covers a breach confirmed shortly after expiry. When the registry is still in an active-risk state at the first post-expiry call and a risk window was observed, defer resolution for a bounded moderator window instead of immediately latching EXPIRED — mirroring AgreementCorruptedAwaitingModerator. Adapt the state list and revert name to the actual source.

} else {
+ // A risk window was observed and the pool is still in an active-risk state at expiry:
+ // give the moderator the same bounded window a pre-expiry CORRUPTED gets, so a CORRUPTED
+ // transition landing just after expiry can still be flagged instead of being foreclosed.
+ if (
+ riskWindowStart != 0 &&
+ (state == IAttackRegistry.ContractState.UNDER_ATTACK ||
+ state == IAttackRegistry.ContractState.PROMOTION_REQUESTED) &&
+ block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE
+ ) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
...
}
claimsStarted = true;

This preserves the intended "survived the term → EXPIRED" payout for pools that reach expiry in normal attackable operation, while closing the window in which a permissionless caller can foreclose a real, imminent in-scope CORRUPTEDoutcome the moderator has had no opportunity to flag.

Support

FAQs

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

Give us feedback!