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

Zero Grace Period for Moderator When Risk Window Was Never Observed

Author Revealed upon completion

Zero Grace Period for Moderator When Risk Window Was Never Observed

claimExpired() grants a full MODERATOR_CORRUPTED_GRACE (180 days) before forcing bad-faith CORRUPTED only when riskWindowStart != 0. When riskWindowStart == 0, the same CORRUPTED registry state falls straight into the else branch and resolves EXPIRED immediately at expiry, with no grace period at all.

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
@> revert AgreementCorruptedAwaitingModerator();
@> }
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
@> // Reached instantly when riskWindowStart == 0, even if state == CORRUPTED.
@> // No grace check exists in this branch.
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
claimsStarted = true;
}
}

Impact: Any staker can call claimExpired() the instant the pool expires and permanently lock in EXPIRED via the claimsStarted latch, before the moderator has any chance to flag the correct in-scope CORRUPTED outcome — collapsing what should be a 180-day moderator review window down to zero. This is already acknowledged and accepted as an intended conservative default in DESIGN.md §5 ("the no-risk-window CORRUPTED race"), so this is submitted to surface the specific 0-day vs. 180-day asymmetry, not as a fund-loss claim.

Risk

Likelihood: Low

  • Requires the registry to transition directly to CORRUPTED without anyone ever observing an active-risk state first (no pokeRiskWindow() call, no pool interaction during UNDER_ATTACK/PROMOTION_REQUESTED) — a narrow precondition on top of an already rare event.

  • Requires a caller to act at or after the exact expiry timestamp, before the moderator flags the correct outcome.

Impact: Medium

  • Reduces the moderator's documented 180-day review window to zero for the same underlying CORRUPTED event, purely based on whether a risk window happened to be observed.

  • Lets any single caller foreclose a correct in-scope CORRUPTED resolution by being first, with no privilege required.

Proof of Concept

The PoC forces the registry to CORRUPTED without any prior risk-window observation, then warps to exactly expiry and calls claimExpired() in the same block — showing the pool resolves EXPIRED with zero grace elapsed, versus the 180-day wait required when a risk window was observed.

function test_Pashov_EvadingSlashing_UnobservedRiskWindow() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
// CORRUPTED with no prior active-risk observation -> riskWindowStart stays 0
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry()); // exactly expiry -- zero grace elapsed
vm.prank(dave);
pool.claimExpired(); // resolves EXPIRED immediately, no 180-day wait
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
uint256 aliceBalBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertGt(token.balanceOf(alice), aliceBalBefore); // stakers recover funds immediately
}

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) {
+ if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
+ outcome = PoolStates.Outcome.EXPIRED;
+ outcomeFlaggedAt = expiry;
+ emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}

This applies the same 180-day grace to the unobserved-risk CORRUPTED case as the observed-risk case already gets, so the moderator's review window no longer depends on whether a risk window happened to be sealed.

Support

FAQs

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

Give us feedback!