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

The Moderator Grace Can Expire Before CORRUPTED Exists, Letting Anyone Finalize the Pool Before the Moderator Gets a Protected Chance to Classify It

Author Revealed upon completion

Summary

A Pool can expire while its Agreement is still UNDER_ATTACK, and the moderator's entire protection window can burn down before there is anything to protect.

ConfidencePool holds the permissionless bad-faith CORRUPTED fallback back until expiry + MODERATOR_CORRUPTED_GRACE (180 days). That clock runs from Pool expiry, not from the moment CORRUPTED first exists, and it counts down whether or not the Agreement is corrupted. An Agreement can stay UNDER_ATTACK past Pool expiry and through the full 180 days. Throughout that period the Pool moderator cannot classify the Pool, because flagOutcome() rejects every CORRUPTED-dependent classification while the Agreement is non-terminal — the state that requires moderator judgment does not exist yet.

When markCorrupted() finally lands after the grace, the moderator's first opportunity to classify and the fallback's first opportunity to finalize arrive at the same timestamp. The moderator receives no protected post-CORRUPTED interval. Any caller can invoke claimExpired() at that moment: the fallback is scope-blind and cannot name a good-faith attacker, so it selects bad-faith CORRUPTED, snapshots the full 150e18 corpus into corruptedReserve, and sets claimsStarted = true. That flag closes moderator reclassification permanently, and claimCorrupted() then sends the complete balance to recoveryAddress.

The displaced decisions are the classifications only the moderator can make once CORRUPTED exists: SURVIVED for out-of-scope corruption, good-faith CORRUPTED with a named attacker, or bad-faith CORRUPTED. The selected branch controls the destination of the Pool's entire principal plus bonus.

Vulnerability Details

Two paths coexist after Pool expiry, gated differently.

flagOutcome() is the moderator's path. While the Agreement is non-terminal, flagOutcome(SURVIVED, ...) and flagOutcome(CORRUPTED, ...) both revert with InvalidOutcome, and flagOutcome(EXPIRED, ...) is never valid. The moderator cannot pre-commit a classification for a CORRUPTED state that has not happened. These choices become available only once the Agreement is actually CORRUPTED.

claimExpired() is permissionless and available the whole time. Before the Agreement is corrupted it would resolve the Pool to EXPIRED against the live active-risk state. Its bad-faith CORRUPTED branch is gated only by the absolute threshold expiry + MODERATOR_CORRUPTED_GRACE.

Those two gates are keyed to different things, and that is the defect. The moderator's gate opens when CORRUPTED appears. The fallback's gate opens when a fixed amount of time has passed since expiry. If CORRUPTED first appears after the expiry-based threshold has already elapsed, both gates are open simultaneously. A permissionless claimExpired() can finalize bad-faith CORRUPTED at that same timestamp, and because it sets claimsStarted = true in the same call, the outcome is irreversible before the moderator has any protected interval in which to act.

Affected Code

The affected production contract is src/ConfidencePool.sol:

  • flagOutcome()src/ConfidencePool.sol:322-350. Rejects SURVIVED and CORRUPTED while the Agreement is non-terminal, and rejects EXPIRED unconditionally.

  • flagOutcome()src/ConfidencePool.sol:330-375. Once the Agreement is CORRUPTED, allows the moderator's scope- and good-faith-specific classifications.

  • claimExpired()src/ConfidencePool.sol:512-554. Reads the current Agreement state and applies the absolute expiry + MODERATOR_CORRUPTED_GRACE threshold. The mechanical CORRUPTED branch at lines 529-554 is scope-blind, selects bad-faith CORRUPTED, and sets claimsStarted in the same call.

  • claimCorrupted()src/ConfidencePool.sol:408-425. Transfers the Pool's complete live token balance to recoveryAddress.

  • src/interfaces/IConfidencePool.sol:154-160 documents the first-post-expiry mechanical branch selection and confirms that non-stakers may resolve.

Root Cause

The moderator grace is anchored to Pool expiry instead of to the first observable CORRUPTED state.

The Pool never records when CORRUPTED first became observable, so the only delay before the mechanical branch measures time since Pool expiry and nothing else. The complete grace can elapse while the Agreement is still UNDER_ATTACK. When CORRUPTED later appears, the moderator's first valid opportunity to classify it and the fallback's first valid opportunity to finalize bad-faith CORRUPTED occur at the same time, and no new clock starts.

The decisive branch is src/ConfidencePool.sol:532-554:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

The check measures only elapsed time since expiry. It never measures how long the moderator has had a CORRUPTED state to classify.

Why the Moderator Grace Fails

The grace exists to give the moderator time to classify a corrupted Agreement before the scope-blind fallback takes over (src/ConfidencePool.sol:46-53; docs/DESIGN.md:131-147). That classification is impossible before CORRUPTED exists, because flagOutcome() rejects it while the Agreement is non-terminal (src/ConfidencePool.sol:322-350).

Because the clock is anchored to Pool expiry, its entire duration can run out before the classification question exists. When CORRUPTED finally appears, no new protected clock starts, and claimsStarted lets the fallback result become final in the same call. The moderator receives only the portion of the expiry-anchored grace that remains after CORRUPTED first appears. If CORRUPTED first appears after the deadline, no protected interval remains before the permissionless fallback becomes available.

The repair is to measure the moderator's protected interval from the first observation of CORRUPTED rather than from Pool expiry.

Moderator Decisions Lost to the Fallback

Once the Agreement is CORRUPTED, flagOutcome() offers the Pool moderator three narrowly gated classifications:

  • SURVIVED — with goodFaith == false and attacker == address(0). For a CORRUPTED Agreement this covers a breach that fell outside the Pool's fixed scope, judged against the published account list (src/ConfidencePool.sol:330-340; docs/DESIGN.md:222-227). Stakers recover stake plus bonus.

  • Good-faith CORRUPTED — with goodFaith == true and a nonzero attacker named. The named attacker receives the full-corpus bounty, subject to the claim window (src/ConfidencePool.sol:341-375; docs/DESIGN.md:294-307).

  • Bad-faith CORRUPTED — with goodFaith == false and attacker == address(0). The full live Pool balance sweeps to recoveryAddress through claimCorrupted() (src/ConfidencePool.sol:341-375; src/ConfidencePool.sol:408-425).

The permissionless fallback cannot evaluate Pool-local scope or name a good-faith attacker, so it mechanically selects bad-faith CORRUPTED. Once claimsStarted is set, the other two classifications are gone for good.

Step-by-Step Scenario

Let E be Pool expiry, 90 days after creation, and G be MODERATOR_CORRUPTED_GRACE = 180 days.

  1. One real Agreement backs one Pool. Its commitment covers E, and neither Agreement scope nor Pool scope changes.

  2. A staker deposits 100e18 and the Pool receives a 50e18 bonus, for a 150e18 corpus.

  3. Before E, the Agreement enters UNDER_ATTACK and the Pool observes active risk, setting a nonzero riskWindowStart and locking scope.

  4. At E, the Agreement is still UNDER_ATTACK and the Pool is still UNRESOLVED. From E through E + G it stays that way: claimExpired() is permissionlessly available and would resolve the Pool to EXPIRED against the active-risk state, but no caller invokes it.

  5. Throughout that same period, the moderator's flagOutcome() attempts for SURVIVED and CORRUPTED revert with InvalidOutcome, because no CORRUPTED state exists to classify.

  6. After E + G, the Agreement's attack moderator calls markCorrupted(). CORRUPTED exists for the first time — and the expiry-based grace is already spent.

  7. At the same timestamp, without advancing time, a permissionless non-staker calls claimExpired(). The threshold is already satisfied, so the Pool locks bad-faith CORRUPTED, snapshots the full corpus into corruptedReserve, and sets claimsStarted = true.

  8. The moderator's later flagOutcome() reverts with OutcomeAlreadySet, and claimCorrupted() sweeps all 150e18 to recoveryAddress.

Steps 6 and 7 are the finding. CORRUPTED first appears, no time passes, a non-staker resolves, and the outcome is final at the same instant the moderator's classification first became possible.

The moderator is not barred from submitting a transaction at that point — the issue is that no interval is reserved for it. Both paths open together, so transaction ordering decides which one lands, and the fallback's result is immediately irreversible.

Control Test

The control uses the same setup and reverses the order of the two decisive calls: claimExpired() runs immediately before the late markCorrupted(). The live Agreement state is still UNDER_ATTACK, so the same unresolved Pool selects EXPIRED and the same 150e18 corpus returns to the staker.

The live state observed at the resolution call selects the full-value branch and its beneficiary.

Protocol Documentation Support

  • The auto-CORRUPTED path is a backstop for a permanently unavailable moderator. It is scope-blind and cannot separate in-scope corruption from out-of-scope corruption (docs/DESIGN.md:131-147; protocol-readme.md:53-57).

  • The Pool's scope is a fixed local commitment, and the moderator's off-chain judgment is the source of truth for whether Agreement-level corruption touched that scope (docs/DESIGN.md:209-227).

  • Only the moderator can name a good-faith attacker; the mechanical branch always resolves bad-faith CORRUPTED (src/ConfidencePool.sol:341-375; src/ConfidencePool.sol:529-550).

  • The current delay before the mechanical branch is anchored to Pool expiry (src/ConfidencePool.sol:53; src/ConfidencePool.sol:540).

The documentation defines the moderator's job and the fallback's blindness, and it anchors the only delay to Pool expiry. Those three facts are what allow the fallback to impose a scope-blind branch at the same moment the moderator's judgment first becomes possible.

Impact

High. The fallback selects bad-faith CORRUPTED, snapshots the complete corpus into corruptedReserve, and sets claimsStarted, which makes the full live balance transferable to recoveryAddress (src/ConfidencePool.sol:543-554; src/ConfidencePool.sol:408-425). In the reproduced Pool:

100e18 principal + 50e18 bonus = 150e18 full Pool corpus

The branch is selected before the moderator can determine the Pool-local classification, and claimsStarted makes that selection irreversible. SURVIVED — which would return the corpus to stakers when the corruption fell outside the Pool's fixed scope — and good-faith CORRUPTED — which would reserve the corpus as a named attacker's bounty — both become unavailable.

The PoC quantifies the difference. The late-CORRUPTED-first ordering routes all 150e18 to recovery; the control, under the opposite ordering, routes the same 150e18 to the staker. The lasting impact is the irreversible loss of the moderator's Pool-local classification authority over the Pool's entire balance.

Likelihood

Low. The issue requires the Pool to remain unresolved beyond the 180-day grace, followed by a late CORRUPTED transition and permissionless resolution before the moderator classifies the Pool.

Severity

Medium. The impact is high because the race can irreversibly determine the destination of the complete principal-and-bonus corpus. Likelihood is low because the Pool must remain unresolved beyond the 180-day grace before a late CORRUPTED transition. High impact with low likelihood supports Medium severity.

Proof of Concept

The PoC deploys the real vendored Agreement factory, AttackRegistry, and Safe Harbor registry, creates a real Agreement and Pool, extends the Agreement commitment through Pool expiry, and reaches UNDER_ATTACK before executing a late markCorrupted(). It shows:

  • the expiry-based grace elapses while the upstream state stays UNDER_ATTACK;

  • no moderator flagOutcome() classification for the later CORRUPTED state is available during that interval;

  • a non-staker finalizes bad-faith CORRUPTED at the same timestamp that CORRUPTED first appears;

  • claimsStarted locks the outcome immediately;

  • the full 150e18 corpus reaches recovery; and

  • reversing markCorrupted() and claimExpired() selects a different live-state branch and beneficiary for the same corpus.

The helper's two revert checks, for SURVIVED and CORRUPTED, concern flagOutcome() only. They confirm that the moderator could not apply the CORRUPTED-dependent classifications while the Agreement remained UNDER_ATTACK. They do not imply that the permissionless claimExpired() path was unavailable after expiry.

The local reproduction recorded both tests passing at contest commit 58e8ba4ce3f3277866e4926f3140e597f9554a1e with BattleChain submodule commit fde1b2abe9e5c27175f5b6f7324bcce6afc3b059. No RPC or fork was used. Upstream artifacts compile with Solidity 0.8.34, while the PoC and in-scope project compile with Solidity 0.8.26.

The canonical PoC lives at:

test/poc/late_corruption_grace/LateCorruptionPreemptsModerator.t.sol

Reproduction Commands

Run the following from the contest repository root:

git submodule update --init --recursive
rm -rf \
out/battlechain-upstream \
cache/battlechain-upstream
forge build \
--root lib/battlechain-safe-harbor-contracts \
--out "$PWD/out/battlechain-upstream" \
--cache-path "$PWD/cache/battlechain-upstream" \
src/AgreementFactory.sol \
src/AttackRegistry.sol \
src/BattleChainSafeHarborRegistry.sol
forge test \
--match-path test/poc/late_corruption_grace/LateCorruptionPreemptsModerator.t.sol \
-vvv

Expected Results

Both tests pass. This run recorded:

Ran 2 tests for test/poc/late_corruption_grace/LateCorruptionPreemptsModerator.t.sol:LateCorruptionPreemptsModeratorTest
[PASS] test_LateCorruptionCanFinalizeBeforeModeratorClassificationWindow() (gas: 4573683)
[PASS] test_ResolvingBeforeLateCorruptionUsesExpiredBranch() (gas: 4530030)
Suite result: ok. 2 passed; 0 failed; 0 skipped

The asserted balance delta is:

Late CORRUPTED transition before resolution:
150e18 → recoveryAddress
0 → staker
Resolution before the late CORRUPTED transition:
150e18 → staker
0 → recoveryAddress

Critical Sequences

Primary ordering:

// CORRUPTED first appears after the expiry-based grace has elapsed.
vm.prank(agreementOwner);
attackRegistry.markCorrupted(scenario.agreement);
// No post-CORRUPTED moderator interval is enforced.
vm.prank(permissionlessResolver);
scenario.pool.claimExpired();

Control:

// Reverse only the decisive order: resolve while the Agreement is still UNDER_ATTACK.
vm.prank(permissionlessResolver);
scenario.pool.claimExpired();
vm.prank(agreementOwner);
attackRegistry.markCorrupted(scenario.agreement);

The moderator-opportunity helper asserts that flagOutcome(SURVIVED) and flagOutcome(CORRUPTED) each revert with InvalidOutcome while the real Agreement remains UNDER_ATTACK. The Pool remains UNRESOLVED because no caller invokes the separately available claimExpired() path.

Full PoC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
import {
Account as BCAccount,
AgreementDetails,
BountyTerms,
Chain as BCChain,
ChildContractScope,
Contact,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
// The upstream BattleChain contracts compile under Solidity 0.8.34 while this test and the in-scope
// ConfidencePool compile under 0.8.26. The upstream contracts are deployed from separately built
// artifacts via vm.getCode, so these minimal initializer interfaces are all that is needed here.
interface IUpstreamSafeHarborRegistry is IBattleChainSafeHarborRegistry {
function initialize(
address owner,
string[] memory initialValidChains,
address agreementFactory,
address attackRegistry
) external;
function setAgreementFactory(address factory) external;
function setAttackRegistry(address attackRegistryAddr) external;
}
interface IUpstreamAgreementFactory is IAgreementFactory {
function initialize(address initialOwner, address registry, string memory battleChainCaip2ChainId) external;
}
interface IUpstreamAttackRegistry is IAttackRegistry {
function initialize(
address initialOwner,
address registryModerator,
address safeHarborRegistry,
address agreementFactory,
address battleChainDeployer,
address treasury
) external;
}
/// @notice Shows that the expiry-based CORRUPTED grace can fully elapse while the Agreement is still
/// UNDER_ATTACK, so CORRUPTED first appears only after the grace has already expired.
///
/// @dev When CORRUPTED finally appears, a permissionless caller can immediately finalize bad-faith
/// CORRUPTED without any additional delay, before the Pool moderator receives a post-CORRUPTED
/// window to apply the protocol's scope and good-faith classification. The complete Pool
/// balance can then move through the recovery branch.
///
/// The second test reverses only markCorrupted() and claimExpired(). This changes the live
/// Agreement state observed at resolution and therefore changes the selected Pool branch and
/// the destination of the complete Pool balance.
contract LateCorruptionPreemptsModeratorTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant PRINCIPAL = 100e18;
uint256 internal constant BONUS = 50e18;
uint256 internal constant FULL_POOL_CORPUS = PRINCIPAL + BONUS;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant POOL_DURATION = 90 days;
bytes32 internal constant AGREEMENT_SALT = keccak256("late-corruption-preempts-moderator");
string internal constant BATTLECHAIN_CAIP2 = "eip155:31337";
string internal constant AGREEMENT_FACTORY_ARTIFACT =
"out/battlechain-upstream/AgreementFactory.sol/AgreementFactory.json";
string internal constant ATTACK_REGISTRY_ARTIFACT = "out/battlechain-upstream/AttackRegistry.sol/AttackRegistry.json";
string internal constant SAFE_HARBOR_REGISTRY_ARTIFACT =
"out/battlechain-upstream/BattleChainSafeHarborRegistry.sol/BattleChainSafeHarborRegistry.json";
address internal protocolOwner;
address internal registryModerator;
address internal battleChainDeployer;
address internal treasury;
// The AttackRegistry treats the Agreement owner as its attack moderator, so this is the account
// used for markCorrupted(). It also sponsors the Pool bonus.
address internal agreementOwner;
address internal poolModerator;
address internal staker;
address internal recoveryAddress;
address internal coveredAccount;
address internal permissionlessResolver;
MockERC20 internal token;
IUpstreamSafeHarborRegistry internal safeHarborRegistry;
IUpstreamAgreementFactory internal agreementFactory;
IUpstreamAttackRegistry internal attackRegistry;
ConfidencePoolFactory internal poolFactory;
struct PoolScenario {
address agreement;
ConfidencePool pool;
}
function setUp() public {
vm.warp(BASE_TIMESTAMP);
protocolOwner = makeAddr("protocolOwner");
registryModerator = makeAddr("registryModerator");
battleChainDeployer = makeAddr("battleChainDeployer");
treasury = makeAddr("treasury");
agreementOwner = makeAddr("agreementOwner");
poolModerator = makeAddr("poolModerator");
staker = makeAddr("staker");
recoveryAddress = makeAddr("recoveryAddress");
coveredAccount = makeAddr("coveredAccount");
permissionlessResolver = makeAddr("permissionlessResolver");
_deployBattleChainContracts();
_deployConfidencePoolFactory();
}
function test_LateCorruptionCanFinalizeBeforeModeratorClassificationWindow() public {
PoolScenario memory scenario = _createFundedPoolUnderAttack();
// 1. The expiry-based grace elapses while the Agreement is still UNDER_ATTACK, and the Pool
// moderator cannot yet classify a CORRUPTED state that does not exist.
_assertModeratorCannotClassifyUnderAttack(scenario.pool);
uint256 stakerBefore = token.balanceOf(staker);
uint256 recoveryBefore = token.balanceOf(recoveryAddress);
uint256 corruptionTimestamp = block.timestamp;
// 2. CORRUPTED first appears now, after the expiry-based grace has elapsed.
vm.prank(agreementOwner);
attackRegistry.markCorrupted(scenario.agreement);
_assertAgreementState(scenario.agreement, IAttackRegistry.ContractState.CORRUPTED, "late markCorrupted succeeds");
// 3. A non-staker immediately finalizes bad-faith CORRUPTED, with no time advance.
vm.prank(permissionlessResolver);
scenario.pool.claimExpired();
assertEq(block.timestamp, corruptionTimestamp, "no time passes between corruption and resolution");
assertEq(uint256(scenario.pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "pool resolved corrupted");
assertFalse(scenario.pool.goodFaith(), "mechanical resolution is bad-faith");
assertTrue(scenario.pool.claimsStarted(), "resolution starts claims");
assertEq(scenario.pool.corruptedReserve(), FULL_POOL_CORPUS, "full corpus reserved for recovery");
// 4. Finality prevents the Pool moderator from applying Pool-local judgment.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
scenario.pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 5. The complete Pool balance moves through the recovery branch.
vm.prank(permissionlessResolver);
scenario.pool.claimCorrupted();
assertEq(token.balanceOf(recoveryAddress) - recoveryBefore, FULL_POOL_CORPUS, "recovery receives full corpus");
assertEq(token.balanceOf(staker) - stakerBefore, 0, "staker receives nothing from the bad-faith branch");
}
function test_ResolvingBeforeLateCorruptionUsesExpiredBranch() public {
PoolScenario memory scenario = _createFundedPoolUnderAttack();
_assertModeratorCannotClassifyUnderAttack(scenario.pool);
uint256 stakerBefore = token.balanceOf(staker);
uint256 recoveryBefore = token.balanceOf(recoveryAddress);
// Reverse only the decisive order: resolve first, while the Agreement is still UNDER_ATTACK.
// The live-state branch is EXPIRED, so the same full corpus returns to the staker.
vm.prank(permissionlessResolver);
scenario.pool.claimExpired();
assertEq(uint256(scenario.pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolved expired");
vm.prank(staker);
scenario.pool.claimExpired();
assertEq(token.balanceOf(staker) - stakerBefore, FULL_POOL_CORPUS, "staker receives full corpus");
assertEq(token.balanceOf(recoveryAddress) - recoveryBefore, 0, "recovery receives nothing");
// The same late markCorrupted() still succeeds, but the already-resolved Pool stays EXPIRED.
vm.prank(agreementOwner);
attackRegistry.markCorrupted(scenario.agreement);
_assertAgreementState(scenario.agreement, IAttackRegistry.ContractState.CORRUPTED, "markCorrupted still legal");
assertEq(uint256(scenario.pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "resolved pool stays expired");
}
/// @dev Builds a funded Pool over a real Agreement, drives it to UNDER_ATTACK, observes the risk
/// window, and warps just past expiry + MODERATOR_CORRUPTED_GRACE while the Agreement is
/// still UNDER_ATTACK and the Pool is still UNRESOLVED.
function _createFundedPoolUnderAttack() internal returns (PoolScenario memory scenario) {
uint256 poolExpiry = block.timestamp + POOL_DURATION;
scenario.agreement = _createCommittedAgreement(poolExpiry);
scenario.pool = _createAndFundPool(scenario.agreement, poolExpiry);
assertGe(IAgreement(scenario.agreement).getCantChangeUntil(), scenario.pool.expiry(), "commitment covers expiry");
_enterUnderAttack(scenario.agreement);
// The mechanical CORRUPTED branch requires an observed risk window (riskWindowStart != 0).
scenario.pool.pokeRiskWindow();
assertTrue(scenario.pool.riskWindowStart() != 0, "risk window observed before expiry");
_advancePastCorruptedGrace(scenario.pool);
_assertAgreementState(scenario.agreement, IAttackRegistry.ContractState.UNDER_ATTACK, "under attack after grace");
assertEq(uint256(scenario.pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "pool unresolved after grace");
}
/// @dev While the Agreement is UNDER_ATTACK the moderator cannot select the CORRUPTED-only
/// classifications. Permissionless claimExpired() stays available and is not used here.
function _assertModeratorCannotClassifyUnderAttack(ConfidencePool pool) internal {
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}
function _advancePastCorruptedGrace(ConfidencePool pool) internal {
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
}
function _createCommittedAgreement(uint256 cantChangeUntil) internal returns (address agreement) {
vm.prank(agreementOwner);
agreement = agreementFactory.create(_agreementDetails(), agreementOwner, AGREEMENT_SALT);
vm.prank(agreementOwner);
IAgreement(agreement).extendCommitmentWindow(cantChangeUntil);
}
function _createAndFundPool(address agreement, uint256 poolExpiry) internal returns (ConfidencePool pool) {
address[] memory scope = new address[](1);
scope[0] = coveredAccount;
vm.prank(agreementOwner);
pool = ConfidencePool(poolFactory.createPool(agreement, address(token), poolExpiry, ONE, recoveryAddress, scope));
token.mint(staker, PRINCIPAL);
vm.startPrank(staker);
token.approve(address(pool), PRINCIPAL);
pool.stake(PRINCIPAL);
vm.stopPrank();
token.mint(agreementOwner, BONUS);
vm.startPrank(agreementOwner);
token.approve(address(pool), BONUS);
pool.contributeBonus(BONUS);
vm.stopPrank();
assertEq(token.balanceOf(address(pool)), FULL_POOL_CORPUS, "pool funded with the full corpus");
}
function _enterUnderAttack(address agreement) internal {
vm.prank(agreementOwner);
attackRegistry.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(registryModerator);
attackRegistry.approveAttack(agreement);
_assertAgreementState(agreement, IAttackRegistry.ContractState.UNDER_ATTACK, "agreement under attack");
}
function _assertAgreementState(address agreement, IAttackRegistry.ContractState expected, string memory label)
internal
view
{
assertEq(uint256(attackRegistry.getAgreementState(agreement)), uint256(expected), label);
}
// Real upstream deployment from the separately compiled 0.8.34 artifacts. Each initializer
// argument is passed explicitly so the deployment wiring is visible at each call site.
function _deployBattleChainContracts() internal {
address registryImpl = _deployArtifact(SAFE_HARBOR_REGISTRY_ARTIFACT);
address agreementFactoryImpl = _deployArtifact(AGREEMENT_FACTORY_ARTIFACT);
address attackRegistryImpl = _deployArtifact(ATTACK_REGISTRY_ARTIFACT);
string[] memory validChains = new string[](1);
validChains[0] = BATTLECHAIN_CAIP2;
address placeholderFactory = makeAddr("placeholderAgreementFactory");
address placeholderRegistry = makeAddr("placeholderAttackRegistry");
safeHarborRegistry = IUpstreamSafeHarborRegistry(
_deployProxy(
registryImpl,
abi.encodeCall(
IUpstreamSafeHarborRegistry.initialize,
(protocolOwner, validChains, placeholderFactory, placeholderRegistry)
)
)
);
agreementFactory = IUpstreamAgreementFactory(
_deployProxy(
agreementFactoryImpl,
abi.encodeCall(
IUpstreamAgreementFactory.initialize, (protocolOwner, address(safeHarborRegistry), BATTLECHAIN_CAIP2)
)
)
);
attackRegistry = IUpstreamAttackRegistry(
_deployProxy(
attackRegistryImpl,
abi.encodeCall(
IUpstreamAttackRegistry.initialize,
(
protocolOwner,
registryModerator,
address(safeHarborRegistry),
address(agreementFactory),
battleChainDeployer,
treasury
)
)
)
);
vm.startPrank(protocolOwner);
safeHarborRegistry.setAgreementFactory(address(agreementFactory));
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
vm.stopPrank();
}
function _deployConfidencePoolFactory() internal {
token = new MockERC20();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
poolFactory = ConfidencePoolFactory(
_deployProxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), poolModerator)
)
)
);
poolFactory.setStakeTokenAllowed(address(token), true);
}
function _deployProxy(address implementation, bytes memory initData) internal returns (address) {
return address(new ERC1967Proxy(implementation, initData));
}
function _deployArtifact(string memory artifact) internal returns (address deployed) {
bytes memory creationCode = vm.getCode(artifact);
require(creationCode.length != 0, "missing artifact");
assembly {
deployed := create(0, add(creationCode, 0x20), mload(creationCode))
}
require(deployed != address(0), "artifact deploy failed");
}
function _agreementDetails() internal view returns (AgreementDetails memory details) {
BCAccount[] memory accounts = new BCAccount[](1);
accounts[0] =
BCAccount({accountAddress: _addressToString(coveredAccount), childContractScope: ChildContractScope.None});
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain({
assetRecoveryAddress: _addressToString(recoveryAddress), accounts: accounts, caip2ChainId: BATTLECHAIN_CAIP2
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "Security", contact: "security@example.com"});
BountyTerms memory bountyTerms = BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 5_000_000,
retainable: false,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "none",
aggregateBountyCapUsd: 10_000_000
});
details = AgreementDetails({
protocolName: "Late Corruption Grace Regression",
contactDetails: contacts,
chains: chains,
bountyTerms: bountyTerms,
agreementURI: "ipfs://late-corruption-grace-regression"
});
}
function _addressToString(address addr) internal pure returns (string memory) {
bytes memory alphabet = "0123456789abcdef";
bytes memory str = new bytes(42);
str[0] = "0";
str[1] = "x";
for (uint256 i; i < 20; ++i) {
str[2 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i)) >> 4) & 0xf];
str[3 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i))) & 0xf];
}
return string(str);
}
}

Recommended Mitigation

Record the first observed CORRUPTED in a dedicated timestamp, separate from the bonus-distribution risk-window markers. Any caller may record that observation, but the observation transaction must not also finalize bad-faith CORRUPTED.

Block mechanical finalization until both of the following have elapsed:

  • the existing expiry-based threshold, retained as a separate minimum; and

  • an additional bounded interval measured from the first CORRUPTED observation.

This gives the moderator a real window to apply the classifications the protocol already defines — SURVIVED for out-of-scope corruption, good-faith CORRUPTED with a named attacker, or bad-faith CORRUPTED — before the scope-blind fallback becomes available.

Do not reuse riskWindowEnd for this interval. It is capped at Pool expiry (src/ConfidencePool.sol:819-828), so a late first CORRUPTED observation would inherit an already-expired clock. A distinct marker keeps bonus-accrual timing separate from moderator-classification timing.

Support

FAQs

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

Give us feedback!