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

Anyone Can Sweep The Bonus Before The Moderator Corrects A Wrong Outcome, So The Whitehat Loses The Bonus Money

Author Revealed upon completion

Anyone Can Sweep The Bonus Before The Moderator Corrects A Wrong Outcome, So The Whitehat Loses The Bonus Money

Description

  • Anyone can call sweepUnclaimedBonus() to send leftover bonus money to recoveryAddress. Separately, the moderator can fix a wrong outcome (a "re-flag") as long as no money has been claimed yet.

  • The problem is that sweepUnclaimedBonus() sends the bonus money out of the pool and can be called by anyone. So if the moderator later corrects a wrong outcome to CORRUPTED and names a whitehat who is owed the whole pool (stake + bonus), the bonus may already be gone. The whitehat ends up with only the stake, and recoveryAddress keeps bonus money it should not get.

https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L474-L509

@> function sweepUnclaimedBonus() external nonReentrant {
...
stakeToken.safeTransfer(recoveryAddress, amount);
}

Risk

Likelihood:

  • This happens whenever the moderator first flags SURVIVED by mistake, then later corrects it to CORRUPTED and names a whitehat.

  • Sweeping the bonus costs nothing and never hurts the caller, so anyone has a reason to do it.

Impact:

  • The whitehat named in the correction gets only the stake, not the bonus they were supposed to get.

  • recoveryAddress permanently keeps bonus money it should not get.

Proof of Concept

The codebase already has a test for this same sequence (testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement), but it only checks that nothing breaks — not that the whitehat gets shortchanged, which is what this test shows. Add this in SweepUnclaimedBonus.t.sol.

function testBonusPermanentlyDivertedToRecoveryWhenSweepRacesGoodFaithReflag() external {
uint256 stakeAmt = 100 * ONE;
uint256 bonusAmt = 50 * ONE;
_stake(alice, stakeAmt);
_contributeBonus(carol, bonusAmt);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
// Bonus is swept away while the outcome still says SURVIVED.
address searcher = makeAddr("searcher");
vm.prank(searcher);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), bonusAmt);
// Moderator corrects the outcome. This still works.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Whitehat only gets the stake, not stake + bonus.
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), stakeAmt);
assertEq(token.balanceOf(recovery), bonusAmt);
}

Recommended Mitigation

Only let the moderator call sweepUnclaimedBonus(). This closes off the third party racing to send out the bonus before a correction.

- function sweepUnclaimedBonus() external nonReentrant {
+ function sweepUnclaimedBonus() external nonReentrant onlyModerator {

Support

FAQs

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

Give us feedback!