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

Delayed expiry claim can let post-term corruption convert an expired active-risk pool into CORRUPTED loss

Author Revealed upon completion

Root + Impact

Description

When a ConfidencePool reaches expiry while the linked agreement is still in an active-risk state such as UNDER_ATTACK, the documented and implemented immediate behavior is EXPIRED: the agreement survived the pool term, so stakers recover principal plus bonus.

The implementation does not snapshot or bind the registry state at expiry. The first later claimExpired() call reads the live registry state at call time. When the pool already observed active risk during the term and nobody finalizes at expiry, a registry transition to CORRUPTED after the pool term can cause a delayed claimExpired() to auto-resolve CORRUPTED after the moderator grace period. The same pool state that would have paid stakers at expiry instead sweeps all principal and bonus to recoveryAddress solely because the first expired claim was delayed.

This is distinct from the post-expiry risk-window creation issue. Here riskWindowStart is already nonzero from a legitimate in-term UNDER_ATTACK observation before the pool reaches expiry; the bug is that the expired pool remains unresolved and later uses a post-term CORRUPTED registry read to change the outcome from the EXPIRED payout that was available at the deadline.

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();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
@> 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;
@> return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
@> outcomeFlaggedAt = expiry;
}
claimsStarted = true;
}
...
}

Affected locations:

  • src/ConfidencePool.sol:512-523 allows claimExpired() after expiry but resolves from the live registry state at claim time.

  • src/ConfidencePool.sol:532-555 auto-resolves CORRUPTED when the delayed live read is CORRUPTED and riskWindowStart != 0.

  • src/ConfidencePool.sol:561-570 only resolves EXPIRED when the live read at the first expired claim is non-terminal.

Risk

Likelihood:

Reason 1: This occurs when the pool observed active risk during its term, reaches expiry while the registry is still active-risk, nobody calls claimExpired() before a later registry CORRUPTED transition, and the moderator remains absent until expiry + MODERATOR_CORRUPTED_GRACE.

Reason 2: The finalizing caller does not need owner, moderator, sponsor, or staker privileges. After the grace period, any account can call claimExpired() and finalize the pool as CORRUPTED.

Impact:

Impact 1: Stakers who were entitled to the EXPIRED payout at the pool deadline can lose all principal and bonus.

Impact 2: The pool outcome depends on first-claim timing after expiry, not on the registry state at the end of the underwritten term.

Proof of Concept

Create test/unit/ConfidencePool.postExpiryCorruption.poc.t.sol with the following full local test contract:

// 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";
contract ConfidencePoolPostExpiryCorruptionPoCTest is BaseConfidencePoolTest {
function testControlClaimAtExpiryDuringUnderAttackResolvesExpiredAndReturnsPrincipalPlusBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry());
assertEq(uint256(attackRegistry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "alice recovers principal plus bonus");
}
function testIfNobodyClaimsAtExpiryLaterCorruptedRegistryAutoSweepsExpiredPool() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "risk observed during pool term");
uint256 expiryTs = pool.expiry();
// The pool reaches expiry while still UNDER_ATTACK. Per design, a claim at this point
// would resolve EXPIRED. Nobody calls claimExpired yet.
vm.warp(expiryTs);
assertEq(uint256(attackRegistry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
// After the pool term and after the moderator grace, the registry becomes CORRUPTED.
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(bob);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "auto-corrupted locks outcome");
assertEq(token.balanceOf(alice), 0, "alice receives nothing");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "principal plus bonus swept");
}
function testPostTermCorruptionDuringGraceRevertsThenSweepsAfterGrace() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs);
assertEq(uint256(attackRegistry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() - 1);
vm.prank(bob);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "grace-period revert does not finalize");
assertEq(token.balanceOf(alice), 0, "alice still cannot recover during corrupted grace path");
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "post-grace stranger finalizes auto-corrupted");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "principal plus bonus swept");
}
function testClaimAtExpiryPermanentlyPreventsLaterPostTermCorruptedSweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "alice gets the intended expiry payout");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(bob);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "later corruption cannot override finality");
uint256 recoveryBefore = token.balanceOf(recovery);
vm.expectRevert(IConfidencePool.OutcomeNotSet.selector);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), recoveryBefore, "no recovery sweep after timely expired claim");
}
}

Verified command:

forge test --match-path test/unit/ConfidencePool.postExpiryCorruption.poc.t.sol -vvv

Verified result:

Ran 4 tests for test/unit/ConfidencePool.postExpiryCorruption.poc.t.sol:ConfidencePoolPostExpiryCorruptionPoCTest
[PASS] testClaimAtExpiryPermanentlyPreventsLaterPostTermCorruptedSweep()
[PASS] testControlClaimAtExpiryDuringUnderAttackResolvesExpiredAndReturnsPrincipalPlusBonus()
[PASS] testIfNobodyClaimsAtExpiryLaterCorruptedRegistryAutoSweepsExpiredPool()
[PASS] testPostTermCorruptionDuringGraceRevertsThenSweepsAfterGrace()
Suite result: ok. 4 passed; 0 failed; 0 skipped

Live BattleChain testnet fork confirmation:

function testLiveRegistryExpiredUnderAttackWouldResolveExpiredIfClaimed() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
address attackRegistry = IBattleChainSafeHarborRegistry(SAFE_HARBOR_REGISTRY).getAttackRegistry();
vm.rollFork(PRE_EXPIRY_UNDER_ATTACK_BLOCK);
assertLt(block.timestamp, expiryTs, "checkpoint must be before expiry");
assertEq(
uint256(IAttackRegistry(attackRegistry).getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"live registry is UNDER_ATTACK before expiry"
);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "risk observed during term");
assertLt(pool.riskWindowStart(), expiryTs, "risk start is before expiry");
vm.rollFork(POST_EXPIRY_UNDER_ATTACK_BLOCK);
assertGt(block.timestamp, expiryTs, "checkpoint must be after expiry");
assertEq(
uint256(IAttackRegistry(attackRegistry).getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"live registry is still UNDER_ATTACK after expiry"
);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(ConfidencePool(address(pool)).outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "alice recovers principal plus bonus");
}

The fork uses live Safe Harbor registry 0x0a652e265336a0296816aC4D8400880e3E537C24 and demo agreement 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716. It proves the live prerequisite: the agreement is UNDER_ATTACK before this pool expiry and still UNDER_ATTACK after this pool expiry, so an immediate expired claim would resolve EXPIRED.

The second fork test uses the same real pre-expiry risk observation and real post-expiry UNDER_ATTACK checkpoint, then mocks only the later terminal CORRUPTED registry read because the demo agreement is not currently corrupted at the checkpoint. That proves the ConfidencePool consequence after the live prerequisite has occurred:

function testForkEndToEndDelayedClaimAfterRealExpiredUnderAttackThenTerminalCorruptedSweeps() external {
...
vm.rollFork(POST_EXPIRY_UNDER_ATTACK_BLOCK);
assertGt(block.timestamp, expiryTs, "checkpoint must be after expiry");
assertEq(
uint256(IAttackRegistry(attackRegistry).getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"live registry is still UNDER_ATTACK after expiry"
);
assertEq(uint256(ConfidencePool(address(pool)).outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
vm.mockCall(
attackRegistry,
abi.encodeWithSelector(IAttackRegistry.getAgreementState.selector, DEMO_AGREEMENT),
abi.encode(IAttackRegistry.ContractState.CORRUPTED)
);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(stranger);
pool.claimExpired();
assertEq(uint256(ConfidencePool(address(pool)).outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertEq(token.balanceOf(alice), 0, "alice receives nothing after delayed auto-CORRUPTED");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "principal plus bonus swept");
}

Verified command:

source .env && BATTLECHAIN_TESTNET_RPC="$BATTLECHAIN_TESTNET_RPC" forge test --match-path test/fork/ConfidencePoolPostExpiryCorruption.fork.t.sol -vvv

Verified result:

Ran 2 tests for test/fork/ConfidencePoolPostExpiryCorruption.fork.t.sol:ConfidencePoolPostExpiryCorruptionForkTest
[PASS] testForkEndToEndDelayedClaimAfterRealExpiredUnderAttackThenTerminalCorruptedSweeps()
[PASS] testLiveRegistryExpiredUnderAttackWouldResolveExpiredIfClaimed()
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Do not allow a registry transition that occurs after the pool's underwritten term to decide a pool that had already reached expiry while non-terminal. Auto-CORRUPTED should require evidence that the terminal CORRUPTED event occurred no later than expiry, not merely that the live registry reads CORRUPTED when the first delayed claim is submitted.

A robust fix is to store or query a terminal-state timestamp and gate auto-CORRUPTED on that timestamp:

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && attackRegistry.corruptedAt(agreement) <= expiry
+ ) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
}

If the registry cannot expose a corruption timestamp, add an explicit permissionless expiry finalization path that records the active/non-terminal expiry result before later registry transitions can overwrite it.

Support

FAQs

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

Give us feedback!