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

claimExpired() auto-resolves as bad-faith CORRUPTED without warning — staker loses all principal

Author Revealed upon completion

Root + Impact

Description

  • After expiry + MODERATOR_CORRUPTED_GRACE (180 days), the first claimExpired() caller triggers the auto-CORRUPTED backstop when the registry is CORRUPTED and a risk window was observed. There is no preview function. A staker calling claimExpired() expecting their principal instead triggers bad-faith CORRUPTED — the caller gets ZERO and the entire pool is swept to recoveryAddress.

// @> ConfidencePool.sol:532-554
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:

  • Requires CORRUPTED registry + observed risk window + moderator inactive 180 days

  • Eventually someone will call claimExpired() — guaranteed

Impact:

  • Caller receives 0 ETH (loses entire principal)

  • All stakers lose their principal (swept to recoveryAddress)

Proof of Concept

function testCONFIRMED_autoCorrupted_StakerLosesAll() external {
_stake(alice, 10 * ONE);
_passThroughUnderAttack();
assertGt(pool.riskWindowStart(), 0);
vm.warp(pool.expiry() + 1 seconds);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1 seconds);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
uint256 aliceReceived = token.balanceOf(alice) - aliceBefore;
assertEq(aliceReceived, 0);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
}

Recommended Mitigation

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) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
- 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;
- }
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (block.timestamp >= expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert UseClaimExpiredCorrupted();
+ }
+ }
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
claimsStarted = true;
}
}

Support

FAQs

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

Give us feedback!