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

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();
}

Support

FAQs

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

Give us feedback!