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

`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;
}

Support

FAQs

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

Give us feedback!