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

Delayed Expiry Resolution Can Retroactively Sweep Stakers After Post-Expiry Corruption

Author Revealed upon completion

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) {
// @> Reads the live registry state at the delayed claim time, not at pool expiry
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
// @> A CORRUPTED state reached after expiry is treated as if it corrupted the pool term
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();
// At the actual pool expiry the registry is still active-risk. Resolving here would take
// the EXPIRED branch, but no one calls claimExpired in this block.
vm.warp(expiryTs);
assertEq(uint256(attackRegistry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
// The upstream agreement is marked CORRUPTED only after the pool term has already ended.
vm.warp(expiryTs + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// A delayed first claimExpired reads the live post-expiry state and auto-CORRUPTs.
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.

Support

FAQs

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

Give us feedback!