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

Permissionless `sweepUnclaimedBonus` Depletes Bonus Pool During Moderator Correction Window, Permanently Reducing Good-Faith Attacker Bounty

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

// When a pool resolves to `SURVIVED` or `CORRUPTED`, the moderator has a correction window to re-flag the outcome as long as `claimsStarted` is `false`. This is a deliberate design feature documented in DESIGN.md §4 — the moderator can fix a "typo'd outcome" before any participant locks in the wrong distribution. The `sweepUnclaimedBonus()` function was specifically designed to **not** set `claimsStarted` (L503-505) to prevent griefing this correction window.
// However, when `riskWindowStart == 0` (no on-chain active-risk observed), `sweepUnclaimedBonus()` drains the **entire bonus pool** to `recoveryAddress` and decrements the live `totalBonus` accounting variable. When the moderator subsequently re-flags from `SURVIVED` to `CORRUPTED(goodFaith=true)`, the re-flag's snapshot at L358 captures the **depleted** `totalBonus = 0`, permanently reducing the whitehat attacker's `bountyEntitlement`. The bonus is irrecoverable because `contributeBonus()` is blocked post-flag (L268), and `bountyEntitlement` caps the attacker's claim (L439).
// src/ConfidencePool.sol — sweepUnclaimedBonus()
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;
}
@> // When riskWindowStart == 0, bonus is NOT added to reserved — entire bonus is sweepable
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
@> if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // ← DEPLETES live totalBonus
@> }
@> // Intentionally does NOT set claimsStarted — correction window stays open
@> // BUT totalBonus is already depleted, corrupting any future re-flag snapshot
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
```
```solidity
// src/ConfidencePool.sol — flagOutcome() (re-flag path)
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ↑ Re-flag allowed because claimsStarted is still false (sweep didn't set it)
// ... state validation ...
snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus; // ← reads DEPLETED value (0 after sweep)
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
// ↑ bountyEntitlement = stake + 0 = REDUCED (should be stake + bonus)
}
// Likelihood: Medium
// Reason 1: The `riskWindowStart == 0` scenario occurs whenever the BattleChain registry transitions directly to a terminal state (`CORRUPTED` or `PRODUCTION`) without passing through `UNDER_ATTACK` — or when no pool interaction observes the intermediate active-risk state. This is a documented and expected registry behavior, not an edge case.
// Reason 2: The moderator correction window exists precisely because moderator mistakes are anticipated. DESIGN.md §4 devotes a full section to the re-flag mechanism. The moderator initially judging an in-scope breach as out-of-scope (flagging `SURVIVED` instead of `CORRUPTED`) is the exact scenario the correction window was designed to handle. The `sweepUnclaimedBonus()` call is permissionless and can be front-run by any on-chain observer monitoring `OutcomeFlagged` events.
// Impact: High
// Impact 1: The whitehat attacker permanently loses up to **100% of the bonus pool** from their entitled bounty. In a pool with 1,000,000 stake and 200,000 bonus, the attacker receives only 1,000,000 instead of 1,200,000 — a direct loss of 200,000 tokens. This is irrecoverable: `contributeBonus()` reverts post-flag (L268: `OutcomeAlreadySet`), and even direct token transfers cannot bypass the `bountyEntitlement` cap (L439).
// Impact 2: The swept bonus goes to `recoveryAddress` (controlled by the pool sponsor), effectively redirecting funds from the whitehat attacker to the sponsor. This breaks the protocol's core guarantee stated in the protocol-readme: that `CORRUPTED(goodFaith=true)` resolution sweeps *"the pool whole, bonus included"* to the named attacker.

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @title PoC: sweepUnclaimedBonus depletes bonus during moderator correction window
contract SweepBonusCorrectionWindowTest is BaseConfidencePoolTest {
uint256 constant STAKE_AMOUNT = 1_000_000e18;
uint256 constant BONUS_AMOUNT = 200_000e18;
address whitehat = makeAddr("whitehat");
address griefingCaller = makeAddr("griefingCaller");
function setUp() public override {
super.setUp();
// 1. Alice stakes into the pool
_stake(alice, STAKE_AMOUNT);
// 2. Sponsor contributes bonus
_contributeBonus(address(this), BONUS_AMOUNT);
// 3. Registry jumps directly to CORRUPTED (no intermediate UNDER_ATTACK)
// → riskWindowStart stays 0 (no active-risk observed)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "riskWindowStart should be 0");
}
function test_sweepBonusDuringCorrectionWindow_reducesAttackerBounty() external {
// ====== STEP 1: Moderator flags SURVIVED (judges breach as out-of-scope) ======
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.SURVIVED));
assertFalse(pool.claimsStarted(), "correction window open");
assertEq(pool.totalBonus(), BONUS_AMOUNT, "bonus intact before sweep");
// ====== STEP 2: Anyone sweeps the entire bonus (PERMISSIONLESS) ======
vm.prank(griefingCaller);
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0, "totalBonus depleted");
assertFalse(pool.claimsStarted(), "claimsStarted still false — window still open");
assertEq(token.balanceOf(recovery), BONUS_AMOUNT, "bonus sent to recoveryAddress");
// ====== STEP 3: Moderator corrects → re-flags CORRUPTED(goodFaith) ======
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// THE BUG: snapshot captured depleted totalBonus
assertEq(pool.snapshotTotalBonus(), 0, "BUG: snapshotTotalBonus is 0");
assertEq(pool.bountyEntitlement(), STAKE_AMOUNT, "BUG: bounty missing bonus");
// ====== STEP 4: Whitehat claims — receives reduced bounty ======
vm.prank(whitehat);
pool.claimAttackerBounty();
uint256 expectedCorrectBounty = STAKE_AMOUNT + BONUS_AMOUNT; // 1,200,000e18
uint256 actualReceived = token.balanceOf(whitehat);
uint256 loss = expectedCorrectBounty - actualReceived;
assertEq(actualReceived, STAKE_AMOUNT, "Whitehat only received stake");
assertEq(loss, BONUS_AMOUNT, "Whitehat permanently lost full bonus (200,000e18)");
assertEq(token.balanceOf(recovery), BONUS_AMOUNT, "Sponsor kept the bonus");
}
/// @notice Control: without the sweep, whitehat gets the full bounty
function test_withoutSweep_whitehatGetsFullBounty() external {
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// NO SWEEP — skip step 2
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(pool.snapshotTotalBonus(), BONUS_AMOUNT, "bonus preserved");
assertEq(pool.bountyEntitlement(), STAKE_AMOUNT + BONUS_AMOUNT, "full bounty");
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), STAKE_AMOUNT + BONUS_AMOUNT, "full payout");
}
}
// Run with:
// ```bash
// forge test --match-contract SweepBonusCorrectionWindowTest -vvv
// ```

Recommended Mitigation

--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ -474,6 +474,7 @@ contract ConfidencePool is ReentrancyGuard {
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Block sweep during the moderator's correction window to prevent depleting
+ // totalBonus before a potential re-flag to CORRUPTED can snapshot it.
+ if (!claimsStarted) revert CorrectionWindowStillOpen();
uint256 reserved;

Support

FAQs

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

Give us feedback!