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

A "Instant Promotion" permanently prevents moderator from resolving confirmed in-scope emergency vulnerabilities as CORRUPTED, incorrectly rewarding stakers

Author Revealed upon completion

Emergency instantPromote collapses confirmed in-scope vulnerability scenarios into a PRODUCTION registry state, preventing the moderator from expressing a CORRUPTED pool outcome and causing the pool to distribute the bonus as SURVIVED

Description

We know that ConfidencePool relies on the Battlechain Attack Registry to determine which pool outcomes are valid. Under the normal lifecycle, agreements transition through UNDER_ATTACK before eventually reaching either PRODUCTION or CORRUPTED, allowing the moderator to resolve the pool accordingly.

However, Battlechain also introduces an emergency lifecycle through instantPromote(). We can read more about it here: https://docs.battlechain.com/battlechain/how-to/instant-promotion

The interesting thing about instantPromote() is that it right away promotes the state to PRODUCTION during an emergency scenario. Well, let's talk about the emergency scenarios where this will be used (as also mentioned in the doc):

  1. Copycat Discovery:

    • In this case, whitehat finds a vulnerability on Battlechain which can be both an in-scope vulnerability and an out-of-scope one. We are caring about the in-scope here.

    • Within a standard scenario, after a successful attack, the attackModerator will call AttackRegistry::markCorrupted, which will set the state to CORRUPTED. But here, they are choosing to instantly promote this to PRODUCTION in order to stop further disclosure of the vulnerability because it could make the mainnet contract vulnerable.

  2. Protocol Emergency:

    • Here, the protocol discovers a critical issue and decides to patch it without whitehats or blackhats exploiting it.

    • So instantPromote is used to protect users after a confirmed in-scope vulnerability.

    • There's one more case under this one:

      • Battlechain frequently asks the whitehats not to publicly disclose a vulnerability that also exists on mainnet (check out the warning at the very last section of the Execute Attack page). Furthermore, it suggests contacting the protocol through their security contacts, which is obviously the right step.

      • Hence, instantPromote will be preferred here too for security purposes such that it can be patched up.

Therefore, the agreement exits attack mode through instantPromote() and immediately reaches the PRODUCTION registry state despite the emergency security event that triggered the promotion.


The problem is that ConfidencePool::flagOutcome() only permits the moderator to resolve the pool as CORRUPTED while the registry itself reports the CORRUPTED state.

Let's look at the flagOutcome function, especially line 347

https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L347

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
...
if (newOutcome == PoolStates.Outcome.SURVIVED) {
...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
} else {
if (attacker_ == address(0)) revert InvalidGoodFaithParams();
}
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome(); // Will revert because registry permanently reports PRODUCTION after the `instantPromote`
} else {
revert InvalidOutcome();
}
...
}

As a result, even when the moderator's off-chain security assessment concludes that an in-scope vulnerability has already been confirmed and determines that the pool should no longer reward stakers, he is simply unable to express that decision because flagOutcome(CORRUPTED) always reverts with InvalidOutcome().

And, if the moderator refrains from flagging any outcome, the pool eventually reaches expiry, where ConfidencePool::claimExpired() mechanically interprets every PRODUCTION state as SURVIVED:

https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L557-L560

function claimExpired() external nonReentrant {
...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
@> outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
}
...
}

Consequently, the implementation collapses two semantically different agreement lifecycles into the same terminal registry state:

  • "a normal promotion after successfully completing the Safe Harbor period, and"

  • "an emergency promotion performed because of a security event through an in-scope vulnerability."

Since ConfidencePool observes only the terminal registry state, both cases become indistinguishable and always resolve as SURVIVED, permanently preventing the moderator from expressing the latter case through a CORRUPTED outcome.

Risk

Likelihood: Medium

  • BattleChain explicitly documents instantPromote() as an emergency workflow for copycat discoveries and protocol-discovered critical vulnerabilities.

  • The issue deterministically occurs whenever an associated Confidence Pool exists, and the agreement exits attack mode through instantPromote() before a CORRUPTED registry state is reached.

Impact: High

  • The moderator loses the ability to resolve the pool according to their off-chain security judgement despite the documentation treating the moderator as the authoritative decision-maker for scope-related outcomes.

  • The pool incorrectly resolves as SURVIVED, allowing stakers to claim the sponsored bonus even though the emergency promotion was triggered because a confirmed in-scope vulnerability had already terminated the intended confidence assessment.

Proof of Concept

Before running the PoC, we have to add a mock instantPromote function to the mock attack registry provided in the test suite. So, please add the following code in test/mocks/MockAttackRegistry.sol:

function instantPromote() external {
state = IAttackRegistry.ContractState.PRODUCTION;
}

Now paste the following PoC in test/unit/ConfidencePool.t.sol:

function testModeratorCannotFlagCorruptedAfterInstantPromote() external {
// Pool is already deployed
// Bonus is contributed
_contributeBonus(carol, 150 * ONE);
// Stakers joined the pool
_stake(alice, 100 * ONE);
_stake(bob, 200 * ONE);
// Agreement reaches UNDER_ATTACK
_passThroughUnderAttack();
vm.warp(vm.getBlockTimestamp() + 5 days);
// Protocol decides to use `instantPromote` due to an emergency
attackRegistry.instantPromote();
// Registry now reports PRODUCTION...
// Moderator attempts to resolve according to the emergency
// security assessment but is permanently blocked.
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, address(0x123));
}

Running the test:

forge test --mt testModeratorCannotFlagCorruptedAfterInstantPromote -vv

Output:

Ran 1 test for test/unit/ConfidencePool.t.sol:ConfidencePoolTest
[PASS] testModeratorCannotFlagCorruptedAfterInstantPromote() (gas: 476490)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.88ms (311.39µs CPU time)

Recommended Mitigation

One possible approach is to allow the moderator to express emergency security outcomes after an emergency promotion instead of permanently coupling CORRUPTED pool resolution to the registry's terminal state.

For example:

- if (state != IAttackRegistry.ContractState.CORRUPTED)
+ if (
+ state != IAttackRegistry.ContractState.CORRUPTED &&
+ state != IAttackRegistry.ContractState.PRODUCTION
+ )
revert InvalidOutcome();

Alternatively, expose additional registry information indicating that the agreement reached PRODUCTION through an emergency instantPromote() so the moderator can distinguish ordinary successful promotions from emergency exits caused by confirmed security issues before resolving the pool.

Support

FAQs

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

Give us feedback!