Description
A pool’s expiry is fixed when it is initialized and represents the end of the period covered by stakers. Once the pool has expired, an unresolved pool should settle as EXPIRED while the agreement is still nonterminal.
The factory allows a pool to be created and funded before its agreement is registered in AttackRegistry. Because the pool lazily observes registry state, the agreement can become UNDER_ATTACK after expiry and a permissionless pokeRiskWindow() can record that late risk as starting at the pool’s expiry.
After the 180 day grace period has elapsed, a later CORRUPTED state immediately satisfies the permissionless auto corruption branch. The pool is therefore resolved as bad faith CORRUPTED, even though the agreement only became attackable and corrupted after the pool’s coverage period ended.
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}
function claimExpired() external nonReentrant {
...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
claimsStarted = true;
}
}
Risk
Likelihood: Low
A valid agreement remains NOT_DEPLOYED while the pool is funded, and no caller resolves the pool for expiry + 180 days.
The agreement is then normally registered and approved for attack, a caller invokes pokeRiskWindow(), and the agreement later becomes genuinely CORRUPTED before anyone resolves the pool.
Impact: Medium
Stakers lose their entire principal and bonus despite the corruption occurring after the pool’s expiry.
claimsStarted permanently prevents moderator correction, and claimCorrupted() sweeps the full pool balance to recoveryAddress.
Proof of Concept
Run `forge test --match-path test/unit/ConfidencePool.postExpiryCorruption.t.sol -vv`
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PostExpiryLifecycleRegistry {
mapping(address agreement => IAttackRegistry.ContractState) private states;
address public immutable agreementOwner;
constructor(address agreementOwner_) {
agreementOwner = agreementOwner_;
}
function requestUnderAttack(address agreement) external {
require(msg.sender == agreementOwner, "not agreement owner");
require(
states[agreement] == IAttackRegistry.ContractState.NOT_DEPLOYED,
"invalid request state"
);
states[agreement] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address agreement) external {
require(
states[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED,
"invalid approval state"
);
states[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address agreement) external {
require(
states[agreement] == IAttackRegistry.ContractState.UNDER_ATTACK,
"invalid corruption state"
);
states[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(address agreement)
external
view
returns (IAttackRegistry.ContractState)
{
return states[agreement];
}
}
contract ConfidencePoolPostExpiryCorruptionTest is BaseConfidencePoolTest {
PostExpiryLifecycleRegistry internal lifecycle;
function testPostExpiryCorruptionAutoSweepsPool() external {
lifecycle = new PostExpiryLifecycleRegistry(address(this));
safeHarborRegistry.setAttackRegistry(address(lifecycle));
_stake(alice, 100 * ONE);
_contributeBonus(carol, 30 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
lifecycle.requestUnderAttack(agreement);
lifecycle.approveAttack(agreement);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry);
lifecycle.markCorrupted(agreement);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 130 * ONE);
}
}
Recommended Mitigation
Track whether the risk window was observed before the pool expired, and permit permissionless auto corruption only for pools with pre expiry risk observation.
+ bool private riskObservedBeforeExpiry;
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ riskObservedBeforeExpiry = block.timestamp <= expiry;
_markRiskWindowStart();
}
}
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && riskObservedBeforeExpiry
+ ) {
...
}
A stronger solution is to expose the agreement’s actual corruption timestamp from AttackRegistry and require corruptedAt <= expiry before auto resolving CORRUPTED.