FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

`sweepUnclaimedBonus` permanently diverts the good-faith whitehat bounty to the recovery address

Author Revealed upon completion

Description

Normal behavior. A good-faith CORRUPTED resolution entitles the named whitehat to the whole pool — staked principal plus the entire bonus. flagOutcome sets bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus, which docs/DESIGN.md §12 states as the protocol's own rule: "the entire pool is the named attacker's bounty."

Issue. sweepUnclaimedBonus moves accounted bonus out of the pool but never sets claimsStarted, so it stays callable during the moderator's pre-claim re-flag window. When a pool is resolved SURVIVED with no observed risk window (riskWindowStart == 0), the bonus is unreserved, sent to recoveryAddress, and removed from totalBonus. A later good-faith CORRUPTED re-flag re-snapshots the now-reduced totalBonus, so the whitehat bounty excludes the swept bonus. The bonus is permanently redirected from the whitehat to the sponsor.

// ConfidencePool.sol — sweepUnclaimedBonus()
// ... "keeping the accounting honest for any later re-snapshot" (L.496-497)
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // accounted bonus leaves the pool
}
// 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.
@> stakeToken.safeTransfer(recoveryAddress, amount); // bonus -> recovery, re-flag window still open
// ConfidencePool.sol — flagOutcome()
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet(); // re-flag allowed pre-claim
...
@> snapshotTotalBonus = totalBonus; // re-snapshots the reduced bonus
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

The code's own comment at L.503-505 states the invariant this breaks: "Genuine reliance only comes from claim entrypoints." A sweep is not a claim entrypoint, yet it irreversibly transfers real accounted bonus to a third party — creating exactly the reliance the comment assumes cannot arise here.


Why this is not an accepted design trade-off

The sequence is not an unconsidered edge case, and it is not a documented one either:

  • The code anticipates it. L.496-497 drops the swept amount from totalBonus "keeping the accounting honest for any later re-snapshot." The author explicitly contemplated a re-flag after a sweep — and verified the arithmetic, but not the whitehat's entitlement.

  • sweepUnclaimedBonus appears nowhere in docs/DESIGN.md. The document devotes whole sections to justifying accepted trade-offs (§4 re-flag finality, §5 no-risk-window race, §6 scope-blind backstop). No such rationale exists for this path.

  • §5's bonus rule does not survive a CORRUPTED outcome. §5 says that with riskWindowStart == 0 the bonus sweeps to recoveryAddress — but that describes a final SURVIVED/EXPIRED resolution. Under CORRUPTED, §5 states the moderator sweeps "the pool whole" and §12 assigns the entire pool to the whitehat. Path A of the PoC proves this on-chain: with the identical riskWindowStart == 0, a direct CORRUPTED flag yields bountyEntitlement == 150, bonus included.

  • The re-flag is the window's documented purpose. §4 provides it precisely so the moderator can correct an outcome/attacker before value is locked in.


Risk

Likelihood:

  • The registry reaches CORRUPTED while the pool never observed an active-risk state, so riskWindowStart == 0 (the protocol's documented "no observed risk" case).

  • The moderator resolves SURVIVED first (breach judged out of scope) and later corrects to good-faith CORRUPTED through the re-flag window §4 provides for this exact purpose.

  • Any address calls sweepUnclaimedBonus() between the two flags — the function is permissionless.

Impact:

  • The good-faith whitehat receives principal only; 100% of the bonus is redirected to the sponsor's recoveryAddress (asserted in the PoC).

  • The loss equals the entire bonus, so it scales with how bonus-funded the pool is: in a bonus-dominant pool the whitehat loses nearly the whole intended bounty.


Proof of Concept

Two identical pools. The only difference: pool B has one permissionless sweepUnclaimedBonus() inserted during the re-flag window. Same stake (100), same bonus (50), same CORRUPTED registry, same named whitehat.

Place in test/unit/ and run:
forge test --match-test test_MAIN_sweepDivertsWhitehatBounty -vv

Output:

DIRECT whitehat bounty : 150
SWEEP whitehat bounty : 100
LOSS to whitehat : 50
gained by sponsor recov : 50
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {console2 as console} from "forge-std/console2.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract PoC_SweepBonusDivertsBounty is BaseConfidencePoolTest {
uint256 constant STAKE = 100 * ONE;
uint256 constant BONUS = 50 * ONE;
function _stakeTo(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function _bonusTo(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.contributeBonus(amount);
vm.stopPrank();
}
function test_MAIN_sweepDivertsWhitehatBounty() external {
ConfidencePool pA = _deployPool(); // direct-CORRUPTED path
ConfidencePool pB = _deployPool(); // SURVIVED -> sweep -> CORRUPTED path
// identical funding (deposits allowed while the registry is still in staging)
_stakeTo(pA, alice, STAKE);
_bonusTo(pA, carol, BONUS);
_stakeTo(pB, alice, STAKE);
_bonusTo(pB, carol, BONUS);
// identical reality for both pools: agreement CORRUPTED, no active risk ever observed
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// PATH A: moderator flags good-faith CORRUPTED directly.
// Proves riskWindowStart == 0 does NOT send the bonus to recovery under CORRUPTED.
vm.prank(moderator);
pA.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pA.riskWindowStart(), 0, "no risk window observed");
assertEq(pA.bountyEntitlement(), STAKE + BONUS, "protocol rule (DESIGN sec12): whitehat owed 150");
uint256 a0 = token.balanceOf(attacker);
vm.prank(attacker);
pA.claimAttackerBounty();
uint256 payoutDirect = token.balanceOf(attacker) - a0;
// PATH B: SURVIVED -> permissionless sweep -> re-flag CORRUPTED
vm.prank(moderator);
pB.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pB.riskWindowStart(), 0, "no risk window observed");
assertFalse(pB.claimsStarted(), "re-flag window OPEN by design");
address griefer = makeAddr("griefer"); // ANY address, not the moderator
uint256 rec0 = token.balanceOf(recovery);
vm.prank(griefer);
pB.sweepUnclaimedBonus();
uint256 diverted = token.balanceOf(recovery) - rec0;
assertFalse(pB.claimsStarted(), "sweep did NOT close the re-flag window");
vm.prank(moderator);
pB.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pB.bountyEntitlement(), STAKE, "whitehat entitlement deflated by the sweep");
uint256 b0 = token.balanceOf(attacker);
vm.prank(attacker);
pB.claimAttackerBounty();
uint256 payoutSwept = token.balanceOf(attacker) - b0;
// not a brick: nothing left, accounting is consistent -> pure misallocation
vm.prank(griefer);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pB.claimCorrupted();
// the differential
assertTrue(recovery != attacker, "diverted to a DIFFERENT party: sponsor recovery");
assertEq(payoutDirect, STAKE + BONUS, "A: whitehat got 150");
assertEq(payoutSwept, STAKE, "B: whitehat got only 100");
assertEq(payoutDirect - payoutSwept, diverted, "whitehat loss == bonus diverted to recovery");
assertEq(diverted, BONUS, "100% of the bonus diverted");
console.log("DIRECT whitehat bounty :", payoutDirect / ONE);
console.log("SWEEP whitehat bounty :", payoutSwept / ONE);
console.log("LOSS to whitehat :", (payoutDirect - payoutSwept) / ONE);
console.log("gained by sponsor recov :", diverted / ONE);
}
}

Boundary check (proves the exact precondition): with an observed risk window the bonus is reserved and sweepUnclaimedBonus() reverts NothingToSweep, so the issue does not apply.


Recommended Mitigation

Make a bonus sweep a finality event, but only when it actually removes accounted bonus — this preserves the exact property L.503-505 protects, since a dust/donation sweep (accountedSwept == 0) still cannot latch the flag or grief the re-flag window:

if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 accountedSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedSwept;
+ // Releasing accounted bonus is final: a later CORRUPTED re-flag must not pay a whitehat
+ // bounty that excludes the swept bonus. Dust/donation sweeps (accountedSwept == 0) do
+ // not latch, so they still cannot block the re-flag window.
+ if (accountedSwept > 0 && !claimsStarted) claimsStarted = true;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!