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

Post-expiry registry changes can retroactively forfeit a matured pool

Author Revealed upon completion

Root + Impact

Description

An unresolved pool keeps reading the registry after expiry. Registry states first reached after the covered term are capped backward to expiry, allowing a later CORRUPTED resolution to confiscate already-matured stake.

At expiry, an agreement that is still active-risk—or has not entered active risk—has survived the covered term and should resolve EXPIRED, returning principal and bonus. The contract does not checkpoint this entitlement and remains UNRESOLVED until someone submits a resolution transaction.

pokeRiskWindow() remains callable after expiry. A post-expiry active-risk observation creates riskWindowStart = expiry; a later terminal observation similarly creates riskWindowEnd = expiry. claimExpired() then treats the nonzero marker and the registry's live CORRUPTED state as covered corruption. After the expiry-anchored grace period, anyone can finalize CORRUPTED and call claimCorrupted() to transfer the entire pool to recoveryAddress.

A late corruption is sufficient even when riskWindowStart was validly recorded during the term. The post-expiry poke worsens the issue by allowing both predicates to be created after maturity. flagOutcome(CORRUPTED, ...) is also affected because it validates only the live enum value, without proving corruption occurred by expiry.

src/ConfidencePool.sol
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
return IAttackRegistry(attackRegistry).getAgreementState(agreement); // @> No transition timestamp.
}
function _markRiskWindowStart() internal {
// Cap at expiry: accrual is bounded by the pool's lifecycle. Without the cap, a late
// ... bonus-accounting comments omitted ...
uint256 t = block.timestamp;
if (t > expiry) t = expiry; // @> A wholly post-term observation is backdated to expiry.
// ... accumulator updates omitted ...
emit RiskWindowStarted(t);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
IAttackRegistry.ContractState state = _observePoolState(); // @> Reads live state after expiry.
// ...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) { // @>
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED; // @> Retroactively forfeits the matured pool.
// ...
}
// ... payout logic omitted ...
if (outcome == PoolStates.Outcome.SURVIVED) {
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
} else {
emit ClaimExpired(msg.sender, userEligible, bonusShare);
}
}

Risk

Likelihood:

  • The pool remains unresolved beyond expiry; there is no automatic maturity checkpoint or claim deadline.

  • The agreement enters active risk and becomes CORRUPTED after maturity. These are valid registry transitions, while pool finalization and sweeping are permissionless once they occur.

Impact:

  • Every remaining staker can lose 100% of matured principal and bonus because of events outside the advertised coverage term.

  • Corruption first observed after expiry + MODERATOR_CORRUPTED_GRACE receives no moderator-only review period before finalization.

Proof of Concept

Create test/audit/CP001PostExpiryRegistryChanges.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP001PostExpiryRegistryChangesTest is BaseConfidencePoolTest {
function test_PostExpiryRegistryChangesRetroactivelyForfeitMaturedPool() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 maturity = pool.expiry();
vm.warp(maturity + pool.MODERATOR_CORRUPTED_GRACE());
// Risk begins only after both the covered term and expiry-based grace have elapsed.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), maturity, "late risk is backdated to expiry");
// The agreement is then corrupted wholly outside the pool's covered term.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
pool.claimExpired();
assertEq(pool.riskWindowEnd(), maturity, "late corruption is also backdated to expiry");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "full stake and bonus are swept");
assertEq(token.balanceOf(alice), 0, "matured staker receives no principal");
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP001PostExpiryRegistryChanges.t.sol.

  2. Confirm that test_PostExpiryRegistryChangesRetroactivelyForfeitMaturedPool() passes and reports that one test passed with zero failures.

Recommended Mitigation

Extend the trusted registry interface to expose authoritative active-risk and corruption transition timestamps. Require both transitions to have occurred no later than pool expiry in claimExpired() and flagOutcome(). Capped timestamps may remain bonus-accounting bounds, but must not serve as historical evidence.

src/ConfidencePool.sol
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
- IAttackRegistry.ContractState state = _observePoolState();
+ (IAttackRegistry.ContractState state, uint64 activeRiskAt, uint64 corruptedAt) =
+ _observePoolStateWithHistory();
// ...
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ bool coveredRisk = activeRiskAt != 0 && activeRiskAt <= expiry;
+ bool coveredCorruption = corruptedAt != 0 && corruptedAt <= expiry;
+ if (state == IAttackRegistry.ContractState.CORRUPTED && coveredRisk && coveredCorruption) {
// CORRUPTED resolution
}
// ... unchanged payout logic ...
} else {
emit ClaimExpired(msg.sender, userEligible, bonusShare);
}
}

When registry history cannot be added, use separate pool-local riskObservedByExpiry and corruptionObservedByExpiry witnesses and default unprovable post-expiry corruption to EXPIRED. Do not derive either witness from capped riskWindowStart or riskWindowEnd values.

Support

FAQs

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

Give us feedback!