FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

Late-observed CORRUPTED gives the moderator no grace window, allowing the good-faith whitehat bounty to be denied

Author Revealed upon completion

Description

  • Normally, the permissionless auto-CORRUPTED backstop in claimExpired defers to the moderator for MODERATOR_CORRUPTED_GRACE (180 days) so the DAO can flag the good-faith outcome and name the whitehat before anyone finalizes bad-faith CORRUPTED.

  • The grace window is measured from the pool's fixed expiry rather than from when the registry is first observed CORRUPTED, so when a breach is first observed on-chain only after expiry + 180d the moderator is given zero grace and a permissionless claimExpired front-run finalizes bad-faith CORRUPTED before the moderator can flag good-faith, denying the whitehat bounty.

// src/ConfidencePool.sol, ConfidencePool.claimExpired
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// ...
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) { // anchored to `expiry`, not to first CORRUPTED observation
@> revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED; // bad-faith (no attacker named)
@> outcomeFlaggedAt = riskWindowEnd;
@> corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
@> claimsStarted = true; // locks the moderator out (flagOutcome reverts OutcomeAlreadySet)
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

The moderator cannot pre-empt this by flagging early: flagOutcome(CORRUPTED, ...) requires the live registry state to already be CORRUPTED (src/ConfidencePool.sol:344), which is only true at T_corrupt. When T_corrupt > expiry + 180d, that moment is already past the grace gate.

Risk

Likelihood:

  • The registry first reaches CORRUPTED only after expiry + MODERATOR_CORRUPTED_GRACE has elapsed — a breach discovered late relative to a long-lived pool, which is realistic for vulnerabilities that surface months/years after a pool expires.

  • A permissionless caller (the sponsor with recoveryAddress set to self per DESIGN.md §10, or any searcher) lands claimExpired in an earlier block than the moderator's good-faith flagOutcome; for this late-observed edge the moderator has no grace buffer to fall back on, unlike an in-window breach where claimExpired reverts AgreementCorruptedAwaitingModerator.

Impact:

  • The good-faith whitehat bounty (the entire pool per DESIGN.md §12) is denied; claimCorrupted() sweeps the full pool to recoveryAddress instead of to the named whitehat.

  • Stakers are not additionally harmed versus any CORRUPTED resolution — CORRUPTED pays stakers zero by design — so the marginal loss is borne by the whitehat / protocol incentive, not by stakers.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract LateBreachGraceGapPoC is BaseConfidencePoolTest {
function test_lateObservedCorrupted_deniesWhitehatBounty() external {
// Live pool with stakers + bonus; risk window observed (normal operating mode).
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack(); // riskWindowStart != 0
assertNotEq(pool.riskWindowStart(), 0);
// Breach is FIRST observed AFTER expiry + 180d grace.
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Permissionless caller front-runs the moderator's good-faith flag.
vm.prank(dave);
pool.claimExpired(); // auto-resolves BAD-FAITH CORRUPTED
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertFalse(pool.goodFaith());
assertTrue(pool.claimsStarted());
// Moderator's good-faith flag is now permanently blocked.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Whitehat gets nothing; full pool swept to recovery.
uint256 recoveryBefore = token.balanceOf(recovery);
uint256 whitehatBefore = token.balanceOf(attacker);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 250 * ONE);
assertEq(token.balanceOf(attacker), whitehatBefore); // bounty DENIED
}
}

Run: forge test --match-path test/poc/LateBreachGraceGap.t.sol -vv (PASS). Also confirmed against the live BattleChain testnet fork: BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path 'test/fork/LateBreachGraceGap.fork.t.sol' -vv (2 PASS).

Recommended Mitigation

Anchor the moderator grace to the first on-chain observation of CORRUPTED (already tracked when a terminal state is sealed) instead of to expiry, preserving the §6 permanently-absent-moderator backstop:

// src/ConfidencePool.sol, ConfidencePool.claimExpired
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ // firstCorruptedObservedAt is set in _observePoolState when riskWindowEnd is first sealed
+ // on a CORRUPTED state (0 until then, so the grace starts when CORRUPTED is first observable).
+ uint256 graceAnchor = firstCorruptedObservedAt != 0 ? firstCorruptedObservedAt : block.timestamp;
+ if (block.timestamp < graceAnchor + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

MODERATOR_CORRUPTED_GRACE anchored to expiry instead of first CORRUPTED observation gives the moderator zero actionable window before permissionless bad-faith settlement

Impact - High The mechanical branch always picks bad-faith, so the whole corpus lands at recoveryAddress and claimsStarted makes it permanent in the same call. Both alternatives it forecloses send the money elsewhere: DESIGN.md #8 lets the moderator flag SURVIVED for an out-of-scope breach, returning everything to stakers, and good-faith CORRUPTED reserves the pool for a named whitehat. Losing the classification decides who gets paid. Likelihood - Low The pool has to sit unresolved through the whole window past expiry, which is the hard part, since claimExpired stays permissionless throughout and would settle it as EXPIRED against the live active-risk state. Any staker with principal waiting has reason to make that call. Once corruption does land late, the moderator's first legal moment to classify and the fallback's first to finalize arrive together, so ordering alone decides it.

Support

FAQs

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

Give us feedback!