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

Post-expiry pokeRiskWindow() can make an already-expired pool auto-resolve as CORRUPTED, causing stakers to lose principal

Author Revealed upon completion

Root + Impact

Description

A confidence pool has a fixed expiry, and stakers deposit with the expectation that this timestamp is the end of the period they are underwriting. After the pool reaches expiry, claimExpired() is the mechanical settlement path.

The issue is that an unresolved pool can still be poked after it has already expired. When the registry enters UNDER_ATTACK after pool expiry, anyone can call pokeRiskWindow(). That call reaches _markRiskWindowStart(), which clamps the timestamp to expiry, but still sets riskWindowStart to a nonzero value.

Later, when the registry becomes CORRUPTED, claimExpired() sees riskWindowStart != 0 and resolves the already-expired pool as bad-faith CORRUPTED. This replaces the staker’s expired payout with a corrupted settlement, and the pool balance can be swept to recoveryAddress.

// src/ConfidencePool.sol
649: function pokeRiskWindow() external {
650: // No-op once resolved: the snapshot globals are frozen, so the risk-window markers must
651: // be too.
652: if (outcome != PoolStates.Outcome.UNRESOLVED) return;
653: // Revert only when nothing has been sealed — registry never reached active risk or
654: // a terminal state.
655: // aderyn-ignore-next-line(unchecked-return)
656: // @> no expiry check, so an already-expired but unresolved pool can still observe UNDER_ATTACK
657: _observePoolState();
658: if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
659: }
// src/ConfidencePool.sol
784: function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
785: state = _getAgreementState();
786: if (
787: !scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
788: && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
789: ) {
790: scopeLocked = true;
791: emit ScopeLocked(block.timestamp);
792: }
793: if (riskWindowStart == 0 && _isActiveRiskState(state)) {
794: // @> active-risk state after expiry still opens the risk window
795: _markRiskWindowStart();
796: }
797: if (riskWindowEnd == 0 && _isTerminalState(state)) {
798: _markRiskWindowEnd();
799: }
800: }
// src/ConfidencePool.sol
801: function _markRiskWindowStart() internal {
802: // Cap at expiry: accrual is bounded by the pool's lifecycle. Without the cap, a late
803: // observation could pin riskWindowStart > expiry, and `_clampUserSums` would record every
804: // pre-risk deposit as entering past T = expiry (EXPIRED path). The k=2 sums then encode
805: // post-deadline "at-risk" time that never actually existed.
806: uint256 t = block.timestamp;
807: // @> post-expiry observation is clamped to expiry
808: if (t > expiry) t = expiry;
809: // Cast is truncation-safe: `t` is capped at `expiry`, which is itself a uint32.
810: // forge-lint: disable-next-line(unsafe-typecast)
811: // @> but riskWindowStart is still made nonzero
812: riskWindowStart = uint32(t);
813: // Eagerly reset the global accumulators so every currently-eligible staker is treated as
814: // entering at `t`. Per-user sums stay stale until `_clampUserSums` runs on the next op
815: // touching that user.
816: sumStakeTime = totalEligibleStake * t;
817: sumStakeTimeSq = totalEligibleStake * t * t;
818: emit RiskWindowStarted(t);
819: }
// src/ConfidencePool.sol
512: function claimExpired() external nonReentrant {
513: if (block.timestamp < expiry) revert PoolNotExpired();
514: if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
515: revert InvalidOutcome();
516: }
517:
518: if (outcome == PoolStates.Outcome.UNRESOLVED) {
519: IAttackRegistry.ContractState state = _observePoolState();
520: snapshotTotalStaked = totalEligibleStake;
521: snapshotTotalBonus = totalBonus;
522: snapshotSumStakeTime = sumStakeTime;
523: snapshotSumStakeTimeSq = sumStakeTimeSq;
524:
525: // @> post-expiry riskWindowStart now enables auto-CORRUPTED
526: if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
527: if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
528: revert AgreementCorruptedAwaitingModerator();
529: }
530: outcome = PoolStates.Outcome.CORRUPTED;
531: outcomeFlaggedAt = riskWindowEnd;
532: corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
533: claimsStarted = true;
534: emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
535: return;
536: }
// src/ConfidencePool.sol
409: function claimCorrupted() external nonReentrant {
410: if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
411: if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
412:
413: uint256 toSweep = stakeToken.balanceOf(address(this));
414: if (toSweep == 0) revert NothingToSweep();
415:
416: corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
417: if (!goodFaith) {
418: bountyClaimed = bountyEntitlement;
419: }
420: if (!claimsStarted) claimsStarted = true;
421: // @> bad-faith CORRUPTED settlement sends the whole pool balance to recoveryAddress
422: stakeToken.safeTransfer(recoveryAddress, toSweep);
423:
424: emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
425: }

Risk

Likelihood:

  • This happens when a pool expires while the registry is still pre-risk, no one immediately calls claimExpired(), and the registry later enters UNDER_ATTACK.

  • The trigger is permissionless. Anyone can call pokeRiskWindow() on the expired but unresolved pool, and anyone can later finalize through claimExpired() after the corrupted grace period.

Impact:

  • A staker can lose principal even though the pool’s covered term had already ended before the registry entered active risk.

  • The pool settles through the bad-faith CORRUPTED path, so stake and bonus are swept to recoveryAddress instead of being paid through the expired path.

Proof of Concept

Add this test file as test/unit/AuditLifecycleEdgeCases.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";
contract AuditLifecycleEdgeCasesTest is BaseConfidencePoolTest {
function testPostExpiryPokeCannotEnableLaterAutoCorrupted() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry, "late risk observation is clamped to expiry");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(alice);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"post-expiry risk observation must not enable auto-CORRUPTED"
);
}
function testPostExpiryPokeAutoCorruptedCausesStakerLossIfVulnerable() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
uint256 expiry = pool.expiry();
uint256 aliceBefore = token.balanceOf(alice);
uint256 recoveryBefore = token.balanceOf(recovery);
vm.warp(expiry + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry, "late risk observation is clamped to expiry");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(alice);
pool.claimExpired();
if (pool.outcome() == PoolStates.Outcome.CORRUPTED) {
// Fund-impact proof for the vulnerable behavior:
// Alice's normal EXPIRED payout path is no longer available, and the pool can be
// swept through the CORRUPTED settlement path to recovery instead.
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
pool.claimCorrupted();
assertEq(token.balanceOf(alice) - aliceBefore, 0, "alice received no expired payout");
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE, "recovery received stake plus bonus");
fail("post-expiry poke enabled CORRUPTED settlement for an already-expired pool");
}
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 120 * ONE, "alice receives expired principal plus bonus");
assertEq(token.balanceOf(recovery) - recoveryBefore, 0, "recovery receives nothing");
}
}

Run:

forge test --match-path test/unit/AuditLifecycleEdgeCases.t.sol -vvv

The test shows the pool had already expired before UNDER_ATTACK was observed. The post-expiry pokeRiskWindow() still sets riskWindowStart, which later allows claimExpired() to settle as CORRUPTED. Alice does not receive the expired payout, and the full 120 * ONE balance is swept to recoveryAddress.

Observed Result

The PoC fails as expected because the pool resolves as CORRUPTED instead of EXPIRED.

[FAIL: post-expiry risk observation must not enable auto-CORRUPTED: 2 != 3]

Here:

2 = CORRUPTED
3 = EXPIRED
The trace confirms that `pokeRiskWindow()` emits `RiskWindowStarted(timestamp: expiry)`, `claimExpired()` emits `OutcomeFlagged(... outcome: 2 ...)`, Alice’s follow-up `claimExpired()` reverts with `InvalidOutcome`, and `claimCorrupted()` transfers `120 * ONE` to `recoveryAddress`.

Recommendation

Prevent riskWindowStart from being set after the pool has already expired.

This fix matches the documented lifecycle: once the pool reaches expiry, the covered term is over. A later UNDER_ATTACK observation should not make the already-expired pool eligible for the auto-CORRUPTED path.

This is not an admin, moderator, or centralization issue. The problematic flow is triggered through permissionless functions: pokeRiskWindow(), claimExpired(), and claimCorrupted().

Vulnerable Code

// src/ConfidencePool.sol:793-795
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}

This allows a post-expiry active-risk observation to set riskWindowStart.

Fixed Code

// src/ConfidencePool.sol:793-795
if (riskWindowStart == 0 && block.timestamp < expiry && _isActiveRiskState(state)) {
_markRiskWindowStart();
}

With this change, only active-risk observations made before expiry can open the risk window. A pool that has already expired cannot later be pushed into auto-CORRUPTED settlement by a post-expiry pokeRiskWindow().

Support

FAQs

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

Give us feedback!