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

`claimExpired` resolves a genuinely CORRUPTED agreement as EXPIRED whenever the risk window was never observed

Author Revealed upon completion

claimExpired resolves a genuinely CORRUPTED agreement as EXPIRED whenever the risk window was never observed

Description

  • When claimExpired() resolves an UNRESOLVED pool past expiry, it reads the live registry state. If that state is CORRUPTED and a risk window was previously observed (riskWindowStart != 0), the pool auto-resolves as CORRUPTED and later sweeps the full pool (principal + bonus) to recoveryAddress. Otherwise it falls through to EXPIRED, returning full principal to every staker.

  • riskWindowStart is only ever set by _observePoolState(), and only while it observes the registry sitting in an active-risk state (UNDER_ATTACK/PROMOTION_REQUESTED). That function runs lazily — only when someone calls stake/withdraw/pokeRiskWindow/flagOutcome/claimExpired. If nobody happens to interact with the pool while the agreement briefly sits in an active-risk state before flipping to CORRUPTED, the pool's first-ever observation of the registry is CORRUPTED itself, riskWindowStart stays 0, and claimExpired incorrectly takes the EXPIRED path despite the agreement being genuinely breached.

Risk

Likelihood:

  • Occurs whenever no staker/withdrawer/moderator happens to call into the pool during the window the registry sits in UNDER_ATTACK/PROMOTION_REQUESTED before it turns CORRUPTEDpokeRiskWindow is not part of any expected user flow, so this is the default case, not an edge case.

  • Requires zero attacker action; it is a passive consequence of normal staker inactivity.

Impact:

  • A genuinely corrupted agreement resolves as if it survived: every staker gets back 100% principal instead of forfeiting funds to the CORRUPTED path.

  • recoveryAddress receives nothing for a real breach, defeating the core "capital genuinely at risk" premise of the protocol.

Proof of Concept

  1. Alice stakes 100, Bob stakes 50, Carol contributes 30 bonus.

  2. The registry transitions straight to CORRUPTED (no one called pokeRiskWindow/stake/etc. while it was in UNDER_ATTACK), so riskWindowStart stays 0.

  3. Time warps past expiry + MODERATOR_CORRUPTED_GRACE.

  4. Alice calls claimExpired(). The CORRUPTED && riskWindowStart != 0 check fails, so the pool resolves as EXPIRED instead of CORRUPTED.

  5. Bob calls claimExpired() and recovers his full 50 principal.

  6. recoveryAddress ends up with a balance of 0, despite the agreement being genuinely CORRUPTED.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract FindingCorruptedAsExpiredTest is BaseConfidencePoolTest {
function testPoc() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "risk window was never observed");
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.expectEmit(true, false, false, true, address(pool));
emit IConfidencePool.OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "wrongly resolved as EXPIRED");
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimExpired();
assertEq(token.balanceOf(bob) - bobBefore, 50 * ONE, "bob recovers full principal despite CORRUPTED agreement");
assertEq(token.balanceOf(recovery), 0, "recoveryAddress gets nothing for a real breach");
}
}

Recommended Mitigation

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (state == IAttackRegistry.ContractState.CORRUPTED) {

outcomeFlaggedAt = riskWindowEnd stays meaningful even when riskWindowStart was never latched, since riskWindowEnd is independently set on any first terminal-state observation (_isTerminalState covers CORRUPTED too). _bonusShare already returns 0 whenever riskWindowStart == 0, so this change only fixes outcome selection (CORRUPTED vs. EXPIRED), not bonus math.

Support

FAQs

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

Give us feedback!