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

Post-expiry risk poke can arm the CORRUPTED backstop for out-of-term corruption

Author Revealed upon completion

Root + Impact

pokeRiskWindow() can seal riskWindowStart after the pool has already expired without resolving the pool. That post-expiry marker satisfies the later auto-CORRUPTED backstop and can make stakers lose principal for risk that was first observed after the term they underwrote.

Description

pokeRiskWindow() has no expiry guard. While the pool is still unresolved, it calls _observePoolState() and accepts any active-risk state:

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

If the registry first enters UNDER_ATTACK or PROMOTION_REQUESTED after the pool's expiry, _markRiskWindowStart() still sets riskWindowStart by clamping the late observation to expiry:

uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);

The call does not resolve the pool. Later, if the registry becomes CORRUPTED, claimExpired() only checks whether riskWindowStart != 0 before using the scope-blind auto-CORRUPTED backstop:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
return;
}

Without the post-expiry poke, the same CORRUPTED registry state falls through to EXPIRED because riskWindowStart remains zero. The non-resolving poke therefore changes the later settlement outcome.

Risk

Any caller can grief an expired but unresolved pool by poking after the agreement enters active risk outside the pool term. If the registry later becomes CORRUPTED and the moderator does not intervene before the 180-day grace period elapses, the pool auto-resolves as bad-faith CORRUPTED and claimCorrupted() sends stakers' principal and the bonus to recoveryAddress.

The impacted stakers would have received the EXPIRED path if riskWindowStart had not been armed by a post-expiry observation.

Proof of Concept

Place this file at test/unit/ConfidencePoolPostExpiryPokePoC.t.sol and run:

forge test --match-path 'test/unit/ConfidencePoolPostExpiryPokePoC.t.sol' -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolPostExpiryPokePoC is BaseConfidencePoolTest {
function testPostExpiryPokeArmsOutOfTermCorruption() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 expiryTs = pool.expiry();
// The pool term has ended before any active-risk state is observed.
vm.warp(expiryTs + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// This non-resolving call seals riskWindowStart to expiry after the term is over.
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
// A later out-of-term corruption now satisfies the auto-CORRUPTED backstop.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE);
}
function testWithoutPostExpiryPokeSameCorruptionExpires() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// No poke occurs, so no in-pool risk observation is recorded.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + 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);
}
}

The first test shows the post-expiry poke changing settlement to CORRUPTED and sweeping the full pool. The control test shows that the same later CORRUPTED state resolves to EXPIRED when the post-expiry poke is omitted.

Recommended Mitigation

Do not allow pokeRiskWindow() to mark riskWindowStart after block.timestamp >= expiry.

If post-expiry observations are still needed for bonus accounting, track a separate boolean indicating that active risk was observed before expiry and require that boolean, rather than only riskWindowStart != 0, for the auto-CORRUPTED backstop.

Support

FAQs

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

Give us feedback!