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

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

Updates

Lead Judging Commences

inallhonesty Lead Judge
19 days ago
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #4

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #4

Appeal created

Hawk 1 Submitter
2 days ago

Support

FAQs

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

Give us feedback!