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

claimExpired can preempt moderator resolution in zero-risk-start edge cases

Author Revealed upon completion

Root + Impact

Description

At or after expiry, any address can call claimExpired() to mechanically resolve an unresolved pool. When the registry reports CORRUPTED, the function selects auto-CORRUPTED only if riskWindowStart != 0. If no successful call observed an earlier active-risk state, the same registry result instead falls through to EXPIRED.

Mechanical resolution sets claimsStarted = true even when the caller has no stake and receives no payout. A non-staker can therefore win the first post-expiry transaction, lock the principal-returning EXPIRED outcome, and permanently prevent the moderator from flagging the pool CORRUPTED. This changes the allocation of the staked principal from sponsor recovery to the stakers.

The auto-CORRUPTED branch combines the terminal registry state with a local observation latch:

src/ConfidencePool.sol
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
// ... snapshots omitted ...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) { // @> Both conditions are required.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
// ...
claimsStarted = true;
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
outcome = PoolStates.Outcome.EXPIRED; // @> CORRUPTED with start == 0 reaches this branch.
outcomeFlaggedAt = expiry;
}
claimsStarted = true; // @> A zero-stake resolver still closes moderator finality.
}
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) return; // @> No economic participation is needed to fix the outcome.
// ...
}

riskWindowStart is set only when UNDER_ATTACK or PROMOTION_REQUESTED is successfully observed. It can remain zero if the registry moves directly to CORRUPTED, if no one calls an observing function during the active-risk interval, or if the only attempted observations occur in transactions whose later checks revert and roll the marker back.

Once claimExpired() sets EXPIRED and latches claimsStarted, the moderator finality check prevents a corrective flag:

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet(); // @> The moderator cannot restore CORRUPTED after preemption.
}
// ...
}

The allocation difference is material. A bad-faith CORRUPTED outcome sends the full pool balance—principal and bonus—to recoveryAddress. EXPIRED returns principal to stakers; with no observed risk, stakers receive no bonus and only the bonus is sweepable to recovery. The permissionless caller need not profit directly to grief the sponsor or favor the stakers.

The behavior is acknowledged by the design as a resolution race, and the zero-start condition requires a missed risk observation. Those facts lower likelihood, but they do not prevent deterministic preemption once the edge state exists.

Risk

Likelihood:

  • The registry must report CORRUPTED at expiry while riskWindowStart remains zero, which requires a direct transition or a failure to persist an active-risk observation.

  • Any account can execute the preemption at the first post-expiry opportunity; no stake, token approval, or special role is required.

  • The moderator can avoid the outcome by flagging CORRUPTED before expiry resolution, but transaction ordering at the boundary is public and contestable.

Impact:

  • The final outcome changes from sponsor-recoverable CORRUPTED to principal-returning EXPIRED and cannot be corrected afterward.

  • All staked principal can be diverted away from the recovery recipient, while only unearned bonus remains sweepable.

  • Moderator good-faith classification and attacker-bounty selection are also permanently foreclosed by the finality latch.

Proof of Concept

Create test/audit/CP038ClaimExpiredPreemptsModerator.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP038ClaimExpiredPreemptsModeratorTest is BaseConfidencePoolTest {
function test_NonStakerForcesExpiredBeforeModeratorCanFlagCorrupted() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry());
assertEq(pool.riskWindowStart(), 0, "no active-risk state was observed");
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted(), "zero-stake resolution closes the moderator window");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "staker keeps principal after preemption");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "recovery receives only the bonus");
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP038ClaimExpiredPreemptsModerator.t.sol -vv.

  2. Confirm that the test passes. A non-staker resolves the zero-start CORRUPTED registry state as EXPIRED, the moderator's corrective CORRUPTED call reverts, the staker receives 100 principal, and recovery receives only the 50 bonus.

Recommended Mitigation

Apply the moderator grace period whenever the registry reports CORRUPTED, even if the local start marker is zero. During that interval the moderator can decide whether the breach was within this pool's scope and whether the missing observation should affect classification. After the grace period, preserve the existing liveness policy: auto-CORRUPTED when risk was observed, otherwise EXPIRED.

src/ConfidencePool.sol
-if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+if (state == IAttackRegistry.ContractState.CORRUPTED) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
+ if (riskWindowStart == 0) {
+ // Grace elapsed without a moderator decision; preserve the documented
+ // no-observed-risk fallback to EXPIRED below.
+ } else {
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
+ }
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
// ...
} else {
outcome = PoolStates.Outcome.EXPIRED;
// ...
}

Add explicit tests for direct CORRUPTED transitions, reverted active-risk observations, non-staker resolvers, and same-block moderator/resolver ordering. A stronger design would derive the risk interval from canonical registry transition data rather than a lazily persisted local marker, removing the observation-dependent ambiguity entirely.

Support

FAQs

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

Give us feedback!