# My first competitive audit. Submitted as HIGH, came back as LOW. That stung for about ten minutes — then I re-read the judge's reasoning and they were right.

SweepUnclaimedBonus\_Drains\_Attacker

## Summary

​ A permissionless `sweepUnclaimedBonus()` between a moderator's `SURVIVED` flag and a corrective `CORRUPTED` re-flag irreversibly drains the bonus to `recoveryAddress`, permanently reducing the whitehat attacker's bounty. The protocol pays the wrong recipient with no recovery path. ​

## Description

​ `sweepUnclaimedBonus` does not set `claimsStarted` (L503), so the re-flag window stays open after the sweep. When `riskWindowStart == 0`, bonus is unreserved and fully sweepable (L483-487). On re-flag, the snapshot captures the now-zeroed `totalBonus` (L358), producing a reduced `bountyEntitlement`: ​

```solidity
// ConfidencePool.sol L357-362
snapshotTotalBonus = totalBonus;           // captures zeroed value after sweep
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus  // bonus portion is zero
: 0;
```

​ Because the snapshot occurs after the sweep, no later code path restores the swept bonus into `bountyEntitlement`. ​ **Reachability of** `riskWindowStart == 0`**:** The registry requires `UNDER_ATTACK` before `CORRUPTED` (`AttackRegistry.sol` L324). But the pool only seals `riskWindowStart` on a pool-touching transaction (L793). If `approveAttack()` and `markCorrupted()` execute with no pool interaction in between (e.g., same block), `riskWindowStart` stays zero. ​

## Vulnerability Details

​ **Attack path:** ​

1.  Registry reaches `CORRUPTED` with no pool interaction during `UNDER_ATTACK` — `riskWindowStart` stays zero.
    
2.  Moderator flags `SURVIVED`. No call to `stake()`, `contributeBonus()`, or `pokeRiskWindow()` occurred during the active-risk interval.
    
3.  Anyone calls `sweepUnclaimedBonus()` — bonus drains to `recoveryAddress`, `totalBonus` zeroed.
    
4.  `claimsStarted` remains false — moderator re-flags to good-faith `CORRUPTED`.
    
5.  `bountyEntitlement = snapshotTotalStaked + 0`. Attacker permanently loses the bonus portion. ​
    

## Risk

​ **Impact: High** — The protocol permanently pays the wrong recipient. The bonus (up to 100% of `totalBonus`) is irreversibly sent to `recoveryAddress` instead of the named whitehat attacker. The loss is bounded only by the bonus pool size and is independent of the attacker's exploit performance. ​ **Likelihood: Medium** — Requires a rare but valid registry path where the pool does not observe the active-risk window before `CORRUPTED`, combined with a moderator flag correction. The sweep is permissionless and trivially automatable by a MEV bot. ​

## Impact

​ The whitehat attacker permanently loses their bonus entitlement. In the PoC, the attacker receives 150 tokens instead of the expected 200 — a 25% loss. The swept bonus sits irreversibly at `recoveryAddress`. No re-flag, no subsequent call, and no admin action can restore it into the attacker's `bountyEntitlement`. ​

## Proof of Concept

​ Drop into `test/unit/` and run: `forge test --match-test testSweepBetweenFlagsReducesAttackerBounty -vvvv` ​ The PoC uses `MockAttackRegistry` (same test double used by all 256 official tests) to stage the `CORRUPTED` state. In production this is reached via `approveAttack()` → `markCorrupted()` with no pool interaction in between. ​

```solidity
// 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_SweepDrainsBounty is BaseConfidencePoolTest {
function testSweepBetweenFlagsReducesAttackerBounty() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 50 * ONE);
​
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
​
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
​
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
assertEq(token.balanceOf(recovery), 50 * ONE);
​
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 150 * ONE); // should be 200
​
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 150 * ONE);
assertEq(token.balanceOf(recovery), 50 * ONE);
}
}
```

​

## Tools Used

Manual review, Foundry

## Recommended Mitigation

​ Gate `sweepUnclaimedBonus` on `claimsStarted`: ​

```diff
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+    if (!claimsStarted) revert OutcomeNotEligibleForSweep();
```

Alternatively, freeze `totalBonus` at the first `flagOutcome` and never re-snapshot it on re-flag, decoupling the sweep's accounting from the re-flag's snapshot.
