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

The moderator's correction window is zero-width for bad-faith CORRUPTED: the sole beneficiary can sweep in the same block the outcome is flagged

Author Revealed upon completion

Description

  • §4 promises a correction window: "flagOutcome may be re-flagged pre-claim so the moderator can fix a typo'd outcome/attacker before any participant locks in the wrong distribution." It argues this is "not a front-runnable race" on one premise: "claimsStarted is a value-movement finality latch, not a moderator-only privilege. A staker claiming first is exercising a correct outcome, not usurping one."

  • Both halves of that premise fail on claimCorrupted:408. It is the only claim entrypoint whose caller is never its beneficiary - permissionless, always paying recoveryAddress - so its caller is not a staker, and on a mistaken bad-faith flag they are exercising an incorrect outcome. §4's conclusion does not follow for this path.

  • Nothing separates the flag from the sweep. flagOutcome sets outcome = CORRUPTED; claimCorrupted requires only outcome == CORRUPTED. No delay, no gate:

// ConfidencePool.sol:408 - callable in the same block flagOutcome lands
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
...
@> if (!claimsStarted) claimsStarted = true; // :422
stakeToken.safeTransfer(recoveryAddress, toSweep); // sponsor is paid
}
// ConfidencePool.sol:327 - flagOutcome: the correction is now impossible, forever
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

So the sponsor, the sole economic beneficiary of a bad-faith flag, watches for flagOutcome and sweeps immediately. The window §4 describes is never open. This is not a claim that the latch is wrong: §4 is right that "once value has left the contract, a corrective re-flag cannot be honored". The defect is that value can leave with zero delay, on the say-so of a party with no reliance on the outcome.

  • The developers identified this exact hazard and defended one function against it, stating the principle in code:

// ConfidencePool.sol:503-505 - sweepUnclaimedBonus
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
@> // documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.

"Genuine reliance only comes from claim entrypoints" is precisely what claimCorrupted violates: it is a claim entrypoint whose caller has none. claimCorrupted is never mentioned in DESIGN.md (one incidental hit at §12 line 301, about the bounty gate).

  • The foreclosed correction is the one the protocol most needs: good-faith CORRUPTED reserves the whole pool for the named whitehat (§12: "the entire pool is the named attacker's bounty"), so bad-faith to good-faith is the highest-value re-flag the window exists to permit

Risk

Severity: Medium - High Impact x Low Likelihood.

Likelihood: Low. Requires the moderator to flag bad-faith CORRUPTED and then determine a good-faith whitehat should be named - a supported reassessment (e.g. the reporter's whitehat status is confirmed after the flag) and exactly what §4's window exists to serve. Given that, the race is trivially won: claimCorrupted is permissionless and needs no privileged action or timing precision, and the sponsor's incentive is direct - bad-faith pays them the whole pool, good-faith pays them nothing.

Impact: High. The named whitehat loses their full bounty (snapshotTotalStaked + snapshotTotalBonus, i.e. 100% of pool value), and it cannot be undone: claimsStarted is one-way across all seven write sites, so flagOutcome reverts permanently. Value moves to a protocol-designated address rather than an arbitrary one, which bounds this below a direct drain.

Proof of Concept

Self-contained against the existing BaseConfidencePoolTest harness, no RPC needed. Note there is no vm.warp between the flag and the sweep, they are the same block. PASSES:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract M4ClaimCorruptedForeclosesTest is BaseConfidencePoolTest {
function test_beneficiaryForeclosesModeratorCorrection() public {
_stake(alice, 1000 * ONE);
_contributeBonus(bob, 500 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertFalse(pool.claimsStarted());
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 1500 * ONE);
assertTrue(pool.claimsStarted());
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Reverts at the `bountyClaimed == bountyEntitlement` guard (0 == 0 on the bad-faith path),
// before the goodFaith check is ever reached.
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 0);
}
}
[PASS] test_beneficiaryForeclosesModeratorCorrection()
flagOutcome(CORRUPTED, false, 0) -> claimsStarted == false // window nominally open
claimCorrupted() -> 1500e18 to recovery, claimsStarted := true // same block
flagOutcome(CORRUPTED, true, A) -> revert OutcomeAlreadySet // correction foreclosed
claimAttackerBounty() -> revert BountyAlreadyClaimed, whitehat gets 0

Recommended Mitigation

Give the window §4 promises a non-zero width, applying the reliance standard the developers already state at :503-505. Good-faith sweeps are already gated by MustClaimBountyFirst and need no delay.

+ uint32 public constant MODERATOR_REFLAG_WINDOW = 1 days;
+ /// @notice `block.timestamp` of the most recent CORRUPTED flag. Distinct from
+ /// `outcomeFlaggedAt`, which is the k=2 bound `T` pinned to `riskWindowEnd`.
+ uint32 internal _corruptedFlaggedAt;
+
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ // Bad-faith sweeps pay `recoveryAddress`, whose controller is not the caller and bears no
+ // reliance: keep the documented correction window open against them.
+ if (!goodFaith && block.timestamp < _corruptedFlaggedAt + MODERATOR_REFLAG_WINDOW) {
+ revert ClaimWindowNotExpired();
+ }

with _corruptedFlaggedAt = uint32(block.timestamp); set in flagOutcome and in claimExpired's auto-CORRUPTED branch. A lighter alternative: restrict the bad-faith path to msg.sender == recoveryAddress, which does not add a delay but makes the caller the beneficiary, restoring the reliance property §4's argument depends on

Support

FAQs

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

Give us feedback!