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

# A post-expiry `pokeRiskWindow()` satisfies the auto-CORRUPTED gate, letting a griefer sweep the principal of stakers whose agreement survived the entire pool term ##

Author Revealed upon completion

A post-expiry pokeRiskWindow() satisfies the auto-CORRUPTED gate, letting a griefer sweep the principal of stakers whose agreement survived the entire pool term

Severity

Low — Impact: High (100% staker principal loss), Likelihood: Low (requires a permanently-absent moderator across the 180-day grace window plus a post-expiry breach).

Summary

claimExpired()'s permissionless auto-CORRUPTED backstop uses riskWindowStart != 0 as its proxy for "the in-scope contracts were exposed to risk during the pool term." That proxy is unsound: riskWindowStart can be sealed by a post-expiry active-risk observation through the permissionless pokeRiskWindow(), capped to expiry. A griefer can thereby force the scope-blind auto-CORRUPTED resolution on an agreement that survived the full term the stakers underwrote but was corrupted after expiry — sweeping stakers' principal to recoveryAddress instead of returning it via EXPIRED. Without the poke, the identical post-expiry breach resolves to EXPIRED and returns principal, so the poke is the load-bearing act that manufactures the loss.

Vulnerability Details

The auto-CORRUPTED branch gates on the registry reading CORRUPTED and riskWindowStart != 0:

// ConfidencePool.claimExpired()
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus; // full pool -> recoveryAddress
claimsStarted = true;
...
}

riskWindowStart is meant to evidence in-term risk, but _markRiskWindowStart() caps a late observation to expiry rather than rejecting it, and pokeRiskWindow() is permissionless and callable at any time before the pool is resolved (it only no-ops on outcome != UNRESOLVED, not on block.timestamp >= expiry):

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry; // post-expiry observation still seals, at value = expiry
riskWindowStart = uint32(t);
...
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return; // still callable post-expiry
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Consequently riskWindowStart == expiry is ambiguous — it means either a genuine in-term observation at the expiry second, or a spurious post-expiry observation that got capped. The auto-CORRUPTED gate cannot distinguish them, so post-term risk qualifies the backstop exactly as in-term risk would.

Attack sequence (all states reachable in the real AttackRegistry)

  1. An agreement stays in a non-active-risk pre-attack state (ATTACK_REQUESTED, deadline beyond expiry, never approved) for the entire pool term. Stakers deposit; riskWindowStart stays 0.

  2. block.timestamp passes expiry. The agreement survived the term → the correct resolution is EXPIRED (principal + bonus returned).

  3. Post-expiry, the agreement becomes attackable (UNDER_ATTACK). A griefer calls pokeRiskWindow(), sealing riskWindowStart = expiry.

  4. The post-expiry attack culminates in markCorrupted → registry reads CORRUPTED (entirely post-term).

  5. The moderator (DAO) is absent for the full MODERATOR_CORRUPTED_GRACE (180 days).

  6. Anyone calls claimExpired(): state == CORRUPTED && riskWindowStart != 0auto-CORRUPTED; claimCorrupted() sweeps the full principal to recoveryAddress.

Impact

Stakers whose in-scope contracts survived every second of the term they insured lose 100% of principal to recoveryAddress, on account of a breach that occurred entirely after expiry. The precondition is a moderator absent for the whole 180-day grace window (a documented trust-model degradation), but the trigger within that window is permissionless and cheap, and — unlike the out-of-scope case in DESIGN.md §6 — the loss does not exist without the griefer's poke.

Proof of Concept

Confirmed against the real reachable registry state (ATTACK_REQUESTED; NEW_DEPLOYMENT is unreachable per AttackRegistry._getAgreementState). Drop into test/, run forge test --match-contract PostExpiryPokePoC -vvv. Both tests pass.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract PostExpiryPokePoC is BaseConfidencePoolTest {
// Griefer poke during a POST-EXPIRY active-risk blip seals riskWindowStart=expiry,
// forcing auto-CORRUPTED (principal loss) on an agreement that survived the term.
function test_postExpiryPoke_forcesAutoCorrupted_onSurvivedTerm() public {
// ATTACK_REQUESTED is the real reachable pre-attack state; non-active-risk => riskWindowStart stays 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(alice, 100 * ONE);
assertEq(pool.riskWindowStart(), 0, "no in-term risk window");
uint256 expiry = pool.expiry();
vm.warp(expiry + 1); // agreement survived the full term
// Post-expiry: agreement becomes attackable, griefer seals riskWindowStart (capped to expiry).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), uint32(expiry), "griefer sealed riskWindowStart=expiry post-expiry");
// Post-expiry corruption + absent moderator for the full grace window.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "auto-CORRUPTED fired");
uint256 recBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recBefore, 100 * ONE, "full principal swept on a survived-the-term agreement");
vm.prank(alice);
vm.expectRevert();
pool.claimExpired(); // Alice recovers nothing
}
// Control: WITHOUT the poke, riskWindowStart stays 0 -> EXPIRED -> principal returned.
// Proves the poke is load-bearing (manufactures the loss).
function test_noPoke_survivedTerm_returnsPrincipal() public {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(alice, 100 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "no risk window -> EXPIRED");
assertEq(token.balanceOf(alice), 100 * ONE, "principal returned without poke");
}
}

Tools Used

Manual review; Foundry (forge) PoC; multi-agent differential review grounded in the real lib/battlechain-safe-harbor-contracts registry source.

Recommended Mitigation

Gate the auto-CORRUPTED backstop on a risk window observed strictly before expiry, so a capped post-expiry observation cannot qualify it:

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ // riskWindowStart is capped to `expiry`; a value strictly < expiry proves the active-risk
+ // state was observed *during* the term. A post-expiry poke can only seal it *at* expiry.
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0 && riskWindowStart < expiry) {

The only excluded genuine case is an active-risk state first observed in the exact second of expiry, which then falls through to the staker-favorable EXPIRED (principal returned) — a negligible edge. Equivalently, record a dedicated inTermRiskObserved flag set only when _isActiveRiskState(state) && block.timestamp < expiry and gate on that.

Note for judges (disclosure)

This shares the absent-moderator precondition of the accepted trust assumption in DESIGN.md §6, which is why it is filed as Low. However, §6 dismisses the poke lever ("a poke does not 'manufacture' it") specifically for the in-term, out-of-scope breach, where the loss exists regardless of the poke. The post-expiry variant demonstrated here is materially different: the control test proves the loss does not occur without the griefer's poke. The one-line riskWindowStart < expiry gate removes the lever without affecting any documented behavior.

Support

FAQs

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

Give us feedback!