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

Anyone Can Call `sweepUnclaimedBonus()` Between a Moderator's Erroneous SURVIVED Flag and a Corrective Good-Faith CORRUPTED Re-Flag, Permanently Reducing the Whitehat's Bounty Entitlement

Author Revealed upon completion

Normal behavior:
When the registry is CORRUPTED and the breach is in-scope, the moderator flags flagOutcome(CORRUPTED, true, attackerAddress). The named whitehat's bountyEntitlement is set to snapshotTotalStaked + snapshotTotalBonus — the full pool — as intended by docs/DESIGN.md §12. The moderator may correct an erroneous flag before any claim is made, since claimsStarted is only set on the first actual claim.

The issue:
sweepUnclaimedBonus() is callable by anyone at any time after a SURVIVED or EXPIRED outcome is flagged, and it intentionally does not set claimsStarted (by design, to preserve the moderator's re-flag window). When riskWindowStart == 0 (no active-risk interaction was ever observed), there is no bonus reservation — the full totalBonus is freeBalance and immediately sweepable to recoveryAddress.

This creates a griefing window: a moderator who mis-flags SURVIVED (e.g., initially believing the breach was out-of-scope) can have the entire bonus pool swept to recoveryAddress before they re-flag. When they subsequently re-flag CORRUPTED (good-faith), snapshotTotalBonus re-snapshots from totalBonus, which is now 0 after the sweep. The named whitehat's bountyEntitlement is permanently reduced to principal-only, even though the bonus was legitimately theirs.

// @> sweepUnclaimedBonus: no claimsStarted gate, bonus drained immediately when
// @> riskWindowStart == 0 (bonus unreserved), totalBonus decremented
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
// @> When riskWindowStart == 0: bonus is NOT added to reserved
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
// @> totalBonus decremented — permanently reduces the re-flag snapshot
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// @> intentionally does NOT set claimsStarted — re-flag window stays open
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
// @> flagOutcome re-snapshot: after the sweep, snapshotTotalBonus re-captures
// @> totalBonus == 0, reducing the whitehat's entitlement
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ...
snapshotTotalBonus = totalBonus; // @> 0 after the sweep
bountyEntitlement = willBeGoodFaithCorrupted // @> snapshotTotalStaked + 0
? snapshotTotalStaked + snapshotTotalBonus : 0;
}

Risk

Likelihood:

  • The moderator makes an error and initially flags SURVIVED on a CORRUPTED registry (e.g., they first believe the breach is out-of-scope, then learn it is in-scope)

  • A monitoring bot or griefer observes the SURVIVED flag on-chain and calls sweepUnclaimedBonus() in the same or next block before the moderator can correct

Impact:

  • The named whitehat attacker permanently loses the bonus portion of their bounty — they receive snapshotTotalStaked only instead of snapshotTotalStaked + snapshotTotalBonus

  • The swept bonus goes to recoveryAddress (the sponsor's address), not to the griefer — so the sponsor benefits from the moderator's error at the whitehat's expense

  • No loss of principal; stakers are unaffected


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_BountyGrief is BaseConfidencePoolTest {
function test_L01_grieferReducesWhitehatBounty() external {
// Pool has 100 stake (alice) and 50 bonus (carol).
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// Registry CORRUPTED — but riskWindowStart was never sealed (no active-risk interaction).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "no active-risk interaction was ever observed");
// Moderator mistakenly flags SURVIVED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window is still open");
// ATTACK: Griefer calls sweepUnclaimedBonus immediately.
// riskWindowStart == 0 → bonus is unreserved → 50 ONE swept to recoveryAddress.
vm.prank(makeAddr("griefer"));
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0, "bonus fully swept");
assertEq(token.balanceOf(address(pool)), 100 * ONE, "only principal remains");
// Moderator corrects: re-flags good-faith CORRUPTED naming alice as whitehat.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, alice);
// Alice's bountyEntitlement re-snapshots from totalBonus == 0.
uint256 aliceExpected = 150 * ONE; // 100 stake + 50 bonus
assertEq(pool.bountyEntitlement(), 100 * ONE, "entitlement = stake only (bonus lost)");
assertLt(pool.bountyEntitlement(), aliceExpected, "alice short-changed by 50 ONE");
// Alice receives only her principal — 50 ONE bonus permanently lost to recovery.
uint256 before = token.balanceOf(alice);
vm.prank(alice);
pool.claimAttackerBounty();
assertEq(token.balanceOf(alice) - before, 100 * ONE, "[PASS] alice receives 100, not 150");
}
}

Run with:

forge test --match-test test_L01_grieferReducesWhitehatBounty -v

Expected output: [PASS] test_L01_grieferReducesWhitehatBounty()


Recommended Mitigation

The core tension is that sweepUnclaimedBonus must not set claimsStarted (to preserve the re-flag window), but also must not drain totalBonus while a re-flag could still name a whitehat. Two options:

Option A — Gate the bonus sweep on claimsStarted:
When riskWindowStart == 0, allow sweeping only principal-excess (donations), but protect the snapshot bonus until the re-flag window closes.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
+ } else if (!claimsStarted) {
+ // Re-flag window still open: reserve the bonus until finalized.
+ reserved += snapshotTotalBonus;
}
}
// ...
}

Support

FAQs

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

Give us feedback!