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

Stakers can permissionlessly retain principal on in-scope breach when no risk window was observed

Author Revealed upon completion

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.

// src/ConfidencePool.sol
function claimExpired() external nonReentrant {
// ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
// ...
// Auto-CORRUPTED requires both registry CORRUPTED and an observed risk window;
// otherwise falls through to EXPIRED (returns principal).
@> 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 {
// Reached for EVERY non-terminal state — including CORRUPTED when riskWindowStart == 0
@> outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
@> claimsStarted = true;
}
// ...
@> uint256 bonusShare = _bonusShare(msg.sender, userEligible); // riskWindowStart == 0 → 0
@> uint256 payout = userEligible + bonusShare; // staker gets principal only
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

// test/unit/ConfidencePool.riskWindow.t.sol
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); // principal retained, zero bonus
vm.prank(moderator);
vm.expectRevert(); // OutcomeAlreadySet
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));
}

Support

FAQs

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

Give us feedback!