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.
649: function pokeRiskWindow() external {
650:
651:
652: if (outcome != PoolStates.Outcome.UNRESOLVED) return;
653:
654:
655:
656:
657: _observePoolState();
658: if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
659: }
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:
795: _markRiskWindowStart();
796: }
797: if (riskWindowEnd == 0 && _isTerminalState(state)) {
798: _markRiskWindowEnd();
799: }
800: }
801: function _markRiskWindowStart() internal {
802:
803:
804:
805:
806: uint256 t = block.timestamp;
807:
808: if (t > expiry) t = expiry;
809:
810:
811:
812: riskWindowStart = uint32(t);
813:
814:
815:
816: sumStakeTime = totalEligibleStake * t;
817: sumStakeTimeSq = totalEligibleStake * t * t;
818: emit RiskWindowStarted(t);
819: }
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:
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: }
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:
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.
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) {
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:
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
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
This allows a post-expiry active-risk observation to set riskWindowStart.
Fixed Code
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().