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

Unobserved risk window lets any staker foreclose a valid CORRUPTED resolution via the graceless EXPIRED fall-through

Author Revealed upon completion

Root + Impact

Description

When the registry reaches terminal CORRUPTED but the active-risk interval was never observed on-chain (riskWindowStart == 0), claimExpired skips the moderator grace window entirely and resolves EXPIRED, refunding stakers and latching claimsStarted. The sealed-window path (riskWindowStart != 0) instead defers to the moderator through expiry + MODERATOR_CORRUPTED_GRACE. The moderator's protection is therefore present exactly when the window was observed and absent exactly when it was not.

// ConfidencePool.sol — claimExpired() resolution (unresolved branch)
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
@> // Moderator grace is gated on riskWindowStart != 0. A terminal-CORRUPTED
@> // registry with an UNOBSERVED window (riskWindowStart == 0) never enters here.
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
@> revert AgreementCorruptedAwaitingModerator(); // moderator protected — sealed case only
@> }
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
@> } else {
@> // Reached for terminal CORRUPTED when riskWindowStart == 0: no grace, no
@> // moderator deferral — the breach silently resolves to a staker refund.
@> outcome = PoolStates.Outcome.EXPIRED;
@> outcomeFlaggedAt = expiry;
@> emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
@> claimsStarted = true; // latches finality — forecloses any later moderator flagOutcome()
}

Risk

Likelihood:

  • Occurs whenever the agreement transitions through an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED) into terminal CORRUPTED while no stake, withdraw, contributeBonus, or pokeRiskWindow call lands during that interval — leaving riskWindowStart == 0. Low-activity pools and fast breaches (short active-risk window before corruption) satisfy this by default.

  • Occurs on the very first post-expiry interaction: claimExpired is permissionless, so any staker calls it the moment block.timestamp >= expiry, ahead of a moderator who is waiting out the (nonexistent, in this case) grace window.

  • Requires no privileges, no capital beyond an ordinary stake, and no timing precision — the graceless branch is the default resolution for an unobserved-window CORRUPTED registry.

Impact:

  • The whitehat / recoveryAddress beneficiary receives nothing on a genuine in-scope breach: the full pool that CORRUPTED would have routed to them is instead refunded to the stakers whose contracts were breached.

The moderator's correct CORRUPTED resolution is permanently foreclosed — claimExpired latches claimsStarted, so a subsequent flagOutcome(CORRUPTED, …) reverts OutcomeAlreadySet, even while still inside what should have been the grace window.

Proof of Concept

Identical breach and timing to the protected (sealed-window) path; the only difference is that no interaction sealed riskWindowStart.

function test_L01_UnsealedWindow_StakerForeclosesCorrupted() public {
ConfidencePool pool = _deployPool();
// Staker deposits while pre-attack (NEW_DEPLOYMENT): no risk window opens.
vm.prank(staker);
pool.stake(STAKE_AMT);
assertEq(pool.riskWindowStart(), 0, "precondition: window unsealed");
// Agreement is breached: registry goes UNDER_ATTACK -> CORRUPTED with NOBODY
// interacting during the active-risk interval, so riskWindowStart stays 0.
attackRegistry.setState(address(agreement), IAttackRegistry.ContractState.CORRUPTED);
// Past expiry. Any staker permissionlessly resolves the pool.
vm.warp(expiry);
uint256 balBefore = token.balanceOf(staker);
vm.prank(staker);
pool.claimExpired();
// The in-scope breach resolved EXPIRED and refunded the staker.
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(staker), balBefore + STAKE_AMT);
// The moderator's correct CORRUPTED resolution is now permanently foreclosed.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}

The sealed-window control (test_L01_SealedWindow_ModeratorProtected) runs the same breach but seals the window with one pokeRiskWindow() call; there claimExpired() reverts AgreementCorruptedAwaitingModerator and the moderator resolves CORRUPTED with funds routing to recovery. The two tests diverge on exactly one sealed bit.

Recommended Mitigation

Defer to the moderator during grace in the unobserved-window case too, without auto-finalizing CORRUPTED . After grace elapses, the riskWindowStart == 0 path still falls through to the staker-favorable EXPIRED, preserving the documented default.

if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
+ // Give the moderator the same grace window regardless of whether the risk
+ // window was observed: a terminal-CORRUPTED registry warrants the moderator's
+ // scope judgement before a permissionless EXPIRED refund forecloses it. This
+ // does NOT auto-finalize CORRUPTED when unobserved — after grace, the
+ // riskWindowStart == 0 case still falls through to the staker-favorable EXPIRED.
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart == 0
+ && block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE
+ ) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
+
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}

Support

FAQs

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

Give us feedback!