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

Late agreement corruption can auto sweep an already expired confidence pool

Author Revealed upon completion

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.

// ConfidencePoolFactory.sol
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
// @> No AttackRegistry state or activation time check is performed.
// ConfidencePool.sol
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// @> Permissionless and callable after expiry.
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
// @> A post expiry observation is rewritten as though risk started at expiry.
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}
function claimExpired() external nonReentrant {
...
// @> Uses the current registry state, not the state at expiry.
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// @> Grace is measured from pool expiry, not from corruption.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// @> Once expiry + 180 days has passed, this is permissionless.
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`

// SPDX-License-Identifier: MIT
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);
// Late risk is recorded as starting at the old expiry.
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry);
// The agreement becomes corrupted after the pool term.
lifecycle.markCorrupted(agreement);
// Grace has already elapsed, so anyone can auto-resolve the pool.
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
// 100 stake + 30 bonus are swept.
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.

Support

FAQs

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

Give us feedback!