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

Late Terminal CORRUPTED Transitions Receive No Fresh Moderator Grace

Author Revealed upon completion

Description

claimExpired() gives the moderator a 180 day grace period before permissionless auto-CORRUPTED can finalize a pool as bad-faith CORRUPTED. That grace is anchored to pool expiry, not to the first time terminal CORRUPTED becomes visible.

This creates a classification gap when an agreement remains in an active-risk state beyond expiry + MODERATOR_CORRUPTED_GRACE and only later becomes terminal CORRUPTED. Before terminal CORRUPTED, the moderator cannot flag the pool as CORRUPTED because flagOutcome(CORRUPTED, ...) requires the registry state to already be CORRUPTED. Once the terminal state appears after the expiry-anchored grace has elapsed, any caller can immediately call claimExpired(), lock bad-faith CORRUPTED, set claimsStarted, block moderator good-faith classification, and allow the full pool to be swept to recoveryAddress.

The issue is not that auto-CORRUPTED exists. The design notes accept an expiry-anchored fallback for moderator unavailability, but this case is different: terminal CORRUPTED was not flaggable at all until after the whole expiry-anchored grace period had already elapsed. The moderator receives no actionable grace period after terminal corruption first becomes visible.

The moderator path in ConfidencePool.sol::flagOutcome cannot classify CORRUPTED before the registry itself is terminal CORRUPTED:

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// ... code ...
// @> Moderator cannot flag CORRUPTED before the registry is terminal CORRUPTED
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();

While the registry is still UNDER_ATTACK or another active-risk state, the moderator cannot flag CORRUPTED.

The auto-CORRUPTED branch in ConfidencePool.sol::claimExpired treats any nonzero risk window as eligible for bad-faith finalization:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// @> Delay is anchored only to pool expiry, not first observable terminal CORRUPTED
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
// @> Mechanical bad-faith finality closes the moderator correction window
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

The auto-CORRUPTED delay checks only expiry + MODERATOR_CORRUPTED_GRACE. It does not consider when CORRUPTED first became observable.

After bad-faith finalization, ConfidencePool.sol::claimCorrupted routes the remaining pool balance to recovery:

function claimCorrupted() external nonReentrant {
// ... code ...
// @> Bad-faith CORRUPTED routes the remaining pool balance to recovery
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Bad-faith CORRUPTED then routes the full pool balance to recovery.

Risk

Likelihood: Low

  • A pool observes active risk before expiry, so riskWindowStart != 0.

  • The agreement remains active-risk until after expiry + MODERATOR_CORRUPTED_GRACE.

  • The registry first becomes terminal CORRUPTED only after that expiry-anchored grace has already elapsed.

  • A caller invokes claimExpired() before the moderator can submit a good-faith classification.

Impact: High

  • A valid good-faith attacker can lose the entire bounty because the mechanical path sets claimsStarted before the moderator can name the attacker.

  • If the late terminal CORRUPTED is unrelated to the pool's committed scope and the moderator would have classified the pool as SURVIVED, staker principal and bonus can be swept to recoveryAddress through bad-faith CORRUPTED.

  • The moderator has no usable post-terminal review window even though CORRUPTED was not flaggable before the terminal transition.

Proof of Concept

The PoC file included with this report is:

LateTerminalCorruptedNoFreshGrace.t.sol

To run it in a fresh contest checkout:

  1. Copy LateTerminalCorruptedNoFreshGrace.t.sol into the repository's test/ directory.

  2. Run:

forge test --match-contract LateTerminalCorruptedNoFreshGraceTest -vv

Expected result:

1 passed, 0 failed

The PoC demonstrates:

  • If the registry remains UNDER_ATTACK until after expiry + MODERATOR_CORRUPTED_GRACE, then flips to CORRUPTED, a permissionless claimExpired() immediately locks bad-faith CORRUPTED.

  • The locked path blocks moderator reflagging, denies any attacker bounty, and lets claimCorrupted() sweep all stake plus bonus to recovery.

Inline PoC source (LateTerminalCorruptedNoFreshGrace.t.sol):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for a late terminal CORRUPTED transition consuming no fresh moderator grace.
/// @dev Copy this file into `test/unit/` in the Foundry project and run:
/// `forge test --match-contract LateTerminalCorruptedNoFreshGraceTest -vvv`
contract LateTerminalCorruptedNoFreshGraceTest is BaseConfidencePoolTest {
function testLateTerminalCorruptedAfterGraceImmediatelyLocksBadFaith() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
_passThroughUnderAttack();
uint256 afterGrace = pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1 days;
vm.warp(afterGrace);
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"registry is still non-terminal after grace"
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "auto-CORRUPTED");
assertFalse(pool.goodFaith(), "mechanical path is bad-faith");
assertEq(pool.attacker(), address(0), "no attacker can be named");
assertTrue(pool.claimsStarted(), "moderator reflag window is closed immediately");
assertEq(pool.corruptedReserve(), 180 * ONE, "stake plus bonus reserved for recovery");
assertEq(pool.bountyEntitlement(), 0, "whitehat bounty path denied");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 180 * ONE, "full pool swept");
assertEq(token.balanceOf(attacker), 0, "whitehat receives no bounty");
assertEq(token.balanceOf(alice), 0, "staker principal forfeited");
assertEq(token.balanceOf(bob), 0, "staker principal forfeited");
}
}

Recommended Mitigation

Track when terminal CORRUPTED is first observed and start the moderator grace from the later of pool expiry and first terminal visibility.

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ if (firstCorruptedObservedAt == 0) {
+ firstCorruptedObservedAt = uint32(block.timestamp);
+ }
+ uint256 graceStart = expiry > firstCorruptedObservedAt ? expiry : firstCorruptedObservedAt;
+ if (block.timestamp < graceStart + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
}

This preserves the unavailable-moderator backstop while ensuring the moderator receives an actionable window after terminal corruption is visible.

Support

FAQs

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

Give us feedback!