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

Post-expiry risk-window poke arms later auto-CORRUPTED sweep

Author Revealed upon completion

Summary

The expiry path is intended to let a pool settle once its underwriting term has passed. If the registry is still active-risk at expiry, the design treats that as survival of the pool term and allows EXPIRED resolution. The bug is that pokeRiskWindow() remains callable after expiry while the pool is unresolved, and it can set riskWindowStart without resolving the pool.

This creates a payout-integrity issue. A third party can call pokeRiskWindow() after expiry during a later active-risk state, which records riskWindowStart = expiry and leaves the pool UNRESOLVED. If the registry is later marked CORRUPTED, claimExpired() sees a nonzero riskWindowStart and takes the auto-CORRUPTED branch, allowing the full pool to be swept to recoveryAddress even though the risk observation occurred after the pool term.

Affected Contract

File: src/ConfidencePool.sol

Lines: 512-607

Function/Type: claimExpired()

File: src/ConfidencePool.sol

Lines: 532-554

Function/Type: Auto-CORRUPTED branch in claimExpired()

File: src/ConfidencePool.sol

Lines: 649-657

Function/Type: pokeRiskWindow()

File: src/ConfidencePool.sol

Lines: 801-817

Function/Type: _markRiskWindowStart()

Vulnerability Details

pokeRiskWindow() is permissionless and only checks that the pool is unresolved. It does not check whether the pool has already expired. When called after expiry and the registry is in an active-risk state, _observePoolState() calls _markRiskWindowStart().

_markRiskWindowStart() caps t to expiry, then writes riskWindowStart = uint32(t). This means a post-expiry call can create a nonzero risk-window marker even though no active-risk state was observed during the pool term. The call does not resolve the pool.

Later, if the registry becomes CORRUPTED and the moderator grace period has passed, claimExpired() enters the auto-CORRUPTED branch whenever state == CORRUPTED && riskWindowStart != 0. The nonzero marker from the post-expiry poke satisfies that condition, so the pool resolves as bad-faith CORRUPTED. claimCorrupted() can then sweep principal and bonus to recoveryAddress.

Root Cause

The root cause is that pokeRiskWindow() can mutate risk-window state after expiry without atomically resolving the expired pool.

// File: src/ConfidencePool.sol:649-657
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
// File: src/ConfidencePool.sol:801-817
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
// File: src/ConfidencePool.sol:523-554
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Attack Vectors

Attack Vector 1: Post-expiry active-risk poke

Step 1 The pool reaches expiry without being resolved.
Step 2 The registry enters an active-risk state after the pool term.
Step 3 Any caller invokes pokeRiskWindow(), which sets riskWindowStart to the already-passed expiry.
Result: The pool remains unresolved but is now armed for the later auto-CORRUPTED branch.

Attack Vector 2: Later bad-faith auto-CORRUPTED sweep

Step 1 The post-expiry poke has made riskWindowStart nonzero.
Step 2 The registry later becomes CORRUPTED after the moderator grace period.
Step 3 A non-staker calls claimExpired() to mechanically resolve the pool as CORRUPTED.
Step 4 Any caller invokes claimCorrupted().
Result: Staker principal and bonus are swept to recoveryAddress instead of being paid through EXPIRED resolution.

Execution Flow

  • The pool passes expiry while still unresolved.

  • The registry enters UNDER_ATTACK after the pool term.

  • pokeRiskWindow() is called after expiry.

  • _observePoolState() detects active risk and calls _markRiskWindowStart().

  • _markRiskWindowStart() caps the timestamp to expiry and sets riskWindowStart.

  • The pool remains UNRESOLVED.

  • The registry later becomes CORRUPTED.

  • claimExpired() sees state == CORRUPTED && riskWindowStart != 0.

  • The pool resolves as CORRUPTED and claimCorrupted() sweeps the balance to recovery.

Impact

This is a Medium severity payout-integrity issue. A permissionless, non-claiming call after the underwriting term can change the later resolution from EXPIRED to bad-faith CORRUPTED. Existing stakers can lose all principal and bonus to the sponsor-controlled recovery address for a corruption state reached after the pool expired.

The attacker cost is low because pokeRiskWindow() is permissionless and does not require stake ownership. The protocol cost is high for affected stakers because the final value movement is a full-pool sweep rather than the expiry payout path.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

File 1: test/poc/ConfidencePoolPostExpiryPoC.t.sol

function testPoC_postExpiryPokeEnablesLaterAutoCorruptedSweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry, "post-expiry poke sealed the risk window");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "pool remains unresolved");
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "post-term corruption wins");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "principal and bonus swept");
assertEq(token.balanceOf(alice), 0, "expired staker receives nothing");
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
}

How to Run

forge test --match-path test/poc/ConfidencePoolPostExpiryPoC.t.sol -vvv

Captured Test Output

Ran 1 test for test/poc/ConfidencePoolPostExpiryPoC.t.sol:ConfidencePoolPostExpiryPoC
[PASS] testPoC_postExpiryPokeEnablesLaterAutoCorruptedSweep() (gas: 529693)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Log-to-Impact Walkthrough

The passing PoC shows that a post-expiry pokeRiskWindow() sets riskWindowStart to expiry while the outcome remains unresolved. After the registry is set to CORRUPTED, claimExpired() resolves the pool as CORRUPTED, and claimCorrupted() transfers 150 * ONE to recovery while Alice receives nothing.

Key Assertions Proven

assertEq(pool.riskWindowStart(), expiry) -> proves the post-expiry poke creates the risk-window marker -> PASS
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED)) -> proves the later expiry path resolves as CORRUPTED -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE) -> proves principal and bonus are swept to recovery -> PASS

Recommended Fix

Prevent pokeRiskWindow() from mutating risk-window state after expiry. Post-expiry observation should happen only inside claimExpired(), where observation and resolution occur atomically.

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

Support

FAQs

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

Give us feedback!