Root + Impact
Description
When a pool reaches expiry while the registry is still in an active-risk state, the intended behavior is that the insured term has ended and claimExpired() resolves the pool as EXPIRED, returning principal plus any earned bonus to stakers.
The issue is that claimExpired() does not bind resolution to the registry state at the pool expiry time. It reads the live registry state at the time of the first delayed claimExpired() call. If the agreement becomes CORRUPTED only after the pool has already expired, any caller can wait until expiry + MODERATOR_CORRUPTED_GRACE and retroactively resolve the already-expired pool as bad-faith CORRUPTED, causing all stake and bonus to be swept to recoveryAddress.
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;
}
...
}
}
Risk
Likelihood:
-
This occurs when a pool expires while the registry is still UNDER_ATTACK or PROMOTION_REQUESTED, and no one calls claimExpired() before the registry later becomes CORRUPTED.
-
This occurs after expiry + MODERATOR_CORRUPTED_GRACE, when any unprivileged caller invokes the first claimExpired() against the delayed pool.
Impact:
-
Stakers who should have recovered principal and bonus for the expired pool instead lose the full pool balance to recoveryAddress.
-
A caller can choose the settlement branch by delaying the first expiry call until after an unrelated post-expiry corruption.
Proof of Concept
Add this test to test/unit/ClaimExpiredRegistryGate.t.sol:
function testDelayedClaimExpiredCanUsePostExpiryCorruptionToSweepExpiredPool() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs);
assertEq(uint256(attackRegistry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
vm.warp(expiryTs + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertEq(pool.corruptedReserve(), 150 * ONE);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE);
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
}
Run:
forge test --match-test testDelayedClaimExpiredCanUsePostExpiryCorruptionToSweepExpiredPool -vvv
Expected result:
[PASS] testDelayedClaimExpiredCanUsePostExpiryCorruptionToSweepExpiredPool()
Recommended Mitigation
Do not auto-CORRUPT from a terminal state first observed after the pool has already expired. Track the raw terminal observation timestamp and only use the permissionless auto-CORRUPTED branch when the terminal corruption was observed before or at expiry.
+ uint256 public riskWindowEndObservedAt;
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
+ riskWindowEndObservedAt = t;
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && riskWindowEndObservedAt <= expiry
+ ) {
...
}
For CORRUPTED first observed after expiry, resolve EXPIRED or require moderator adjudication instead of using the permissionless bad-faith sweep.