Root + Impact
Description
When the registry is terminal CORRUPTED and the breach is in-scope, the moderator calls flagOutcome(CORRUPTED, bad-faith) and recovery sweeps all stake and bonus via claimCorrupted(). If the pool observed active-risk (riskWindowStart != 0), the permissionless auto-CORRUPTED backstop after expiry + MODERATOR_CORRUPTED_GRACE also sweeps all funds to recovery.
When riskWindowStart == 0 (no pool interaction or pokeRiskWindow() during UNDER_ATTACK / PROMOTION_REQUESTED) and the registry is already CORRUPTED, claimExpired() skips the auto-CORRUPTED branch, mechanically resolves to EXPIRED, and sets claimsStarted = true. Stakers recover principal; subsequent moderator flagOutcome(CORRUPTED) reverts with OutcomeAlreadySet, so recovery cannot sweep staker principal that the bad-faith CORRUPTED path should capture.
function claimExpired() external nonReentrant {
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;
@> claimsStarted = true;
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
@> claimsStarted = true;
}
@> uint256 bonusShare = _bonusShare(msg.sender, userEligible);
@> uint256 payout = userEligible + bonusShare;
stakeToken.safeTransfer(msg.sender, payout);
}
function flagOutcome(...) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0;
}
Risk
Likelihood:
The pool sees no stake / withdraw / poke activity during the breach window, leaving riskWindowStart at 0 (low-activity pools are common in production)
The registry is terminal CORRUPTED, the moderator flags after expiry, or a staker / MEV bot front-runs with claimExpired() post-expiry
Impact:
Recovery / sponsor loses all staker principal that the bad-faith CORRUPTED path should sweep (bonus remains recoverable via sweepUnclaimedBonus)
Pool with 100 stake + 50 bonus: normal CORRUPTED path gives recovery 150; this path gives staker 100 principal, recovery only 50 bonus
Proof of Concept
function testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE);
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}
forge test --offline --match-test testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened
Recommended Mitigation
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
+ if (state == IAttackRegistry.ContractState.CORRUPTED) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}