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

Sequencer reordering transaction for MEV can permanently brick the moderator's ability to re-flag outcome

Author Revealed upon completion

Description

The flagOutcome function in ConfidencePool allows the moderator to re-flag (correct) the outcome as long as no one has claimed yet. This is tracked by the claimsStarted flag. The code comments and design docs both say this feature exists so the moderator can fix a wrong outcome before any value leaves the pool.

But this design is wrong. This can be easily gamed even on L2s like Battlechain where the contracts will be deployed. The problem is that on L2 chains like Battlechain, the centralized sequencer has full, deterministic control over transaction ordering within a block. When the moderator submits a correction (re-flag) transaction, the sequencer can reorder a staker's pending claimSurvived call to execute before the moderator's correction for MEV, even though the moderator submitted first. That single claim sets claimsStarted = true and permanently latches the wrong outcome. The moderator's correction transaction then reverts with OutcomeAlreadySet(), and there is no recovery path, the latch is one-way.

The same reordering also enables front-running on L1: an MEV bot watching for the moderator's correction in the mempool can sandwich a claimSurvived call ahead of it in the same block. But the sequencer scenario is the more realistic and dangerous vector because the sequencer doesn't need to monitor a public mempool, it simply controls the ordering of every transaction it sequences.

This is especially dangerous when the moderator makes a scope judgment error, for example, initially flagging SURVIVED because they believed the breach was on an out-of-scope contract, then discovering it was actually in-scope and needing to correct to CORRUPTED. The re-flag correction they try to submit gets permanently blocked by the reordered claim.

While the likelihood of re-ordering is low, but it is possible, and stakers can amplify the likelihood by calling claimSurvivedin the same block, which will increase the chances of re-ordering very much.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// Re-flag allowed pre-claim so the moderator can fix a typo'd outcome / attacker before
// any participant locks in the wrong distribution.
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ...
}
function claimSurvived() external nonReentrant {
// ...
@> if (!claimsStarted) claimsStarted = true; // locks re-flag permanently
stakeToken.safeTransfer(msg.sender, payout);
// ...
}

There is also a contradiction between the code and the docs. The code comment on line 328 says re-flagging is possible for typo recovery. The DESIGN.md §4 claims this window is "not a front-runnable race". Both of these statements contradict with each other, the sequencer on L2 can trivially reorder a claim ahead of the correction making the re-flagging impossible. The re-flag window is not protected by any time-lock, so a single reordered transaction is enough to close it permanently.

Risk

Likelihood:

  • The moderator flags SURVIVED on a CORRUPTED registry because their initial scope analysis concluded the breach was on an out-of-scope contract. This is a normal part of incident response, scope determination takes time and can be wrong on the first pass.

  • On L2 chains the sequencer has sole authority over transaction ordering. A staker who has already submitted a claimSurvived transaction (or submits one the moment the SURVIVED flag is observed) doesn't need to do anything special, the sequencer can place their claim before the moderator's correction in the same block (eg. for MEV), or even in an earlier block if the correction is still in the sequencer's queue.

So likelihood is LOWimo.

Impact:

  • The pool resolves as SURVIVED when it should have been CORRUPTED. Stakers recover their full principal + bonus instead of the entire pool going to the attacker/recovery address. The difference is the entire pool value.

  • The moderator permanently loses the ability to correct the outcome. The claimsStarted latch is one-way and cannot be undone. The whitehat attacker who legitimately found the in-scope vulnerability receives nothing.

  • Once one staker claims under the wrong SURVIVED outcome, all remaining stakers can also claim their principal + bonus via claimSurvived, draining the pool entirely. Pausing the pool won't do anything as well because claiming isn't secured by whenNotPausedmodifier.

Proof of Concept

  • Create a new test file in test/unit/*

  • Copy/paste the following test in the newly created file

  • Run the PoC via command forge test --mt test_M1_reflagWindow_frontrunnable -vvvv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
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";
contract ConfidencePoolReflagFrontrunTest is BaseConfidencePoolTest {
/// Full attack flow: moderator flags SURVIVED, staker front-runs the correction or sequencer re-orders the transaction
///in way that staker's claim transaction executes first than re-flag transaction,
///moderator's re-flag to CORRUPTED is permanently blocked.
function test_M1_reflagWindow_frontrunnable() external {
// ── Setup: Alice and Bob stake, Carol contributes bonus ──
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
// ── Registry transitions: UNDER_ATTACK → CORRUPTED (in-scope breach) ──
_passThroughUnderAttack();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
// ── Step 1: Moderator mistakenly flags SURVIVED ──
// (believes the breach was on an out-of-scope contract)
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Verify the wrong outcome is set
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertFalse(
pool.claimsStarted(),
"claimsStarted should still be false - re-flag window is open"
);
// ── Step 2: Front-run/Re-order — Alice calls claimSurvived in the SAME BLOCK ──
// (simulating MEV bot or L2 sequencer reordering: claim lands before the moderator's correction)
vm.prank(alice);
pool.claimSurvived();
// claimsStarted is now permanently true — the re-flag window is closed forever
assertTrue(
pool.claimsStarted(),
"claimsStarted should be true after the front-run claim"
);
assertTrue(
pool.hasClaimed(alice),
"Alice should have claimed under the wrong SURVIVED outcome"
);
// ── Step 3: Moderator tries to correct to CORRUPTED — REVERTS ──
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// ── Step 4: Remaining stakers can also drain under the wrong outcome ──
vm.prank(bob);
pool.claimSurvived();
assertTrue(
pool.hasClaimed(bob),
"Bob also claims under the wrong SURVIVED outcome"
);
// ── Verification: The pool resolved entirely under the wrong outcome ──
// The whitehat attacker who found the in-scope vulnerability receives NOTHING.
assertEq(
token.balanceOf(attacker),
0,
"Attacker (whitehat) received nothing - finding is confirmed"
);
// Alice and Bob recovered their full principal + bonus under SURVIVED,
// when the correct outcome should have been CORRUPTED (all funds to attacker/recovery).
assertGt(
token.balanceOf(alice),
100 * ONE,
"Alice got principal + bonus under wrong outcome"
);
assertGt(
token.balanceOf(bob),
50 * ONE,
"Bob got principal + bonus under wrong outcome"
);
}
}

Recommended Mitigation

Add a short time-lock after flagOutcome before claims become available. This gives the moderator a guaranteed correction window that cannot be bypassed by transaction reordering.

+ uint256 public constant REFLAG_GRACE_PERIOD = 1 hours;
+ uint256 public outcomeFlaggedTimestamp;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ...
outcome = newOutcome;
+ outcomeFlaggedTimestamp = block.timestamp;
// ...
}
function claimSurvived() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
+ if (block.timestamp < outcomeFlaggedTimestamp + REFLAG_GRACE_PERIOD) revert ReflagWindowActive();
// ...
}

This ensures the moderator always has at least 1 hour to correct a wrong flag before any claim can lock the outcome. The same grace check should be added to claimExpired for the auto-resolved SURVIVED/EXPIRED branches.

Support

FAQs

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

Give us feedback!