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

Permissionless sweepUnclaimedBonus Permanently Strands Bonus From a Later Good-Faith CORRUPTED Correction

Author Revealed upon completion

Summary

sweepUnclaimedBonus can move the entire bonus pot out of the pool while the moderator's outcome-correction window is still open. When a pool reaches a terminal CORRUPTED registry state without ever having observed an active-risk window (riskWindowStart == 0), the bonus is treated as owed to no staker and is fully sweepable to recoveryAddress by any unprivileged caller. Because this sweep deliberately does not set the claimsStarted finality latch, the moderator can subsequently correct the outcome to good-faith CORRUPTED — but the corrected snapshot captures a bonus pot of zero. The named attacker's bounty is silently reduced by the swept amount, which becomes permanently stranded at recoveryAddress with no code path to recover it into the bounty.

Description

Two behaviours that are each individually correct combine to produce the flaw:

  1. The correction window must stay open until genuine reliance. flagOutcome permits re-flagging until the first claim sets claimsStarted (ConfidencePool.sol:327). This is a deliberate typo-correction affordance — e.g. to fix a misjudged scope or a mistyped attacker address before anyone acts on the verdict.

  2. sweepUnclaimedBonus intentionally does not latch finality. It omits claimsStarted = true (ConfidencePool.sol:503-505) so that a 1-wei donation cannot be used to grief the moderator's correction window shut. Sweeping excess to recoveryAddress is not considered staker reliance on the verdict.

The gap appears when value is moved between two snapshots. In the riskWindowStart == 0 branch, sweepUnclaimedBonus reserves only principal and treats the whole bonus as unowed (ConfidencePool.sol:482-501), sweeping it out and zeroing totalBonus — all without closing the correction window. A later good-faith CORRUPTED re-flag re-snapshots totalBonus (now 0) and sets bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus = P + 0 (ConfidencePool.sol:362). The bonus that should have been part of the attacker's bounty is gone.

This is a localized violation of the contract's own "snapshot, then distribute" invariant: value distributed between two snapshots leaks out of the second one.


Attack sequence (P = 100, B = 50)

  1. Alice stakes P = 100; Carol contributes bonus B = 50. Pool balance = 150, riskWindowStart == 0.

  2. Registry reaches terminal CORRUPTED with no active-risk state ever observed by this pool.

  3. Moderator flags SURVIVED (an out-of-scope-breach judgement). Snapshot: snapshotTotalBonus = 50, claimsStarted == false.

  4. Any unprivileged caller invokes sweepUnclaimedBonus(). Because riskWindowStart == 0, all 50 is swept to recoveryAddress; totalBonus → 0; claimsStarted stays false.

  5. New evidence: the breach was in scope. Moderator correctly re-flags CORRUPTED, good-faith, attacker = A. Re-snapshot captures totalBonus = 0, so bountyEntitlement = 100.

  6. Attacker A claims and receives 100 only. The 50 is permanently stranded at recoveryAddress.

Risk

Likelihood:

Low. No unprivileged party profits — the swept funds land at recoveryAddress, a legitimate destination, so the trigger is unrewarded. Concrete harm materialises only if the moderator later reverses their own earlier judgement (SURVIVED → good-faith CORRUPTED), a two-step correction sequence. The permissionless sweep can, however, be executed by anyone the instant the SURVIVED flag lands, so an adversary aware of a pending correction could deliberately foreclose it. It requires no special privilege — only timing.

Impact:

Medium. The named good-faith attacker (a whitehat who performed a legitimate in-scope breach) is underpaid their bounty by the full bonus amount. The shortfall is permanently stranded at recoveryAddress with no on-chain path to redirect it to the attacker. This undermines the credibility of the bounty guarantee that the confidence-pool product depends on.

Bounded in scope:

  • Only the bonus pot is affected — staker principal is never at risk.

  • Reachable only when riskWindowStart == 0 (no active-risk window ever observed) and the first resolution was a moderator SURVIVED/CORRUPTED flag (not a mechanical EXPIRED, which reserves differently).

Proof of Concept

Copy the test into test/audit/economic_reflag_sweep.t.sol

Run forge test --match-path test/audit/economic_reflag_sweep.t.sol -vv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
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 EconomicReflagSweepTest is BaseConfidencePoolTest {
function testNoRiskBonusSweepDepletesLaterGoodFaithCorruptedCorrection() external {
uint256 principal = 100 * ONE;
uint256 bonus = 50 * ONE;
address sweeper = makeAddr("sweeper");
_stake(alice, principal);
_contributeBonus(carol, bonus);
assertEq(pool.totalEligibleStake(), principal, "initial principal tracked");
assertEq(pool.totalBonus(), bonus, "initial bonus tracked");
assertEq(token.balanceOf(address(pool)), principal + bonus, "initial pool balance");
// Registry reaches CORRUPTED without this pool observing an active-risk window.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// The moderator first flags SURVIVED, e.g. an out-of-scope judgement later corrected.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no active risk observed");
assertEq(pool.snapshotTotalStaked(), principal, "pre-sweep stake snapshot");
assertEq(pool.snapshotTotalBonus(), bonus, "pre-sweep bonus snapshot");
assertFalse(pool.claimsStarted(), "correction window still open");
// Any caller can sweep the no-risk bonus to the sponsor recovery address.
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(sweeper);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus, "bonus moved to recovery");
assertEq(token.balanceOf(address(pool)), principal, "only principal remains");
assertEq(pool.totalBonus(), 0, "tracked bonus depleted");
assertFalse(pool.claimsStarted(), "sweep did not latch finality");
// The moderator can still correct to good-faith CORRUPTED, but the corrected snapshot
// excludes the swept bonus that would have belonged to the named attacker.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), principal, "attacker entitlement lost the bonus");
assertLt(pool.bountyEntitlement(), principal + bonus, "corrected bounty is depleted");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), principal, "attacker receives principal only");
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus, "recovery keeps corrected bounty value");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.claimCorrupted();
}
}

Recommended Mitigation

Reserve the tracked bonus whenever the outcome is still legally re-flaggable, not only when a risk window was observed. This keeps the bonus fenced off until the correction window has genuinely closed, so a later good-faith correction can still fund the attacker in full:

// in sweepUnclaimedBonus, computing `reserved`
if (riskWindowStart != 0 || !claimsStarted) {
reserved += snapshotTotalBonus - claimedBonus;
}

This preserves both existing design goals:

  • The 1-wei griefing defense is retained — sweepUnclaimedBonus still does not set claimsStarted.

  • Genuine excess (dust, post-resolution donations) remains sweepable, since reserved never exceeds legitimate liabilities.

After the correction window closes (claimsStarted == true) and if still no risk window applies, the now-unowed bonus becomes sweepable as before.

Support

FAQs

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

Give us feedback!