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

`sweepUnclaimedBonus()` can remove bonus while the outcome remains correctable, permanently reducing the whitehat bounty

Author Revealed upon completion

sweepUnclaimedBonus() can remove bonus while the outcome remains correctable, permanently reducing the whitehat bounty

Root + Impact

While a provisional SURVIVED outcome can still be re-flagged, any address can call sweepUnclaimedBonus() and transfer real, accounted bonus to recoveryAddress. Even when the moderator subsequently corrects the outcome to good-faith CORRUPTED, flagOutcome() builds bountyEntitlement from the already-reduced totalBonus. The whitehat permanently loses the bonus that should have been included in the bounty.

This is Medium severity. Re-flagging is a supported correction flow, the sweep is permissionless and can be front-run by any transaction, and the affected bonus can be arbitrarily large. The issue does not directly steal staker principal, so High severity is not appropriate.

Description

Under normal operation, the moderator may correct an outcome or the good-faith attacker in flagOutcome() before the first claim. This lets a provisional SURVIVED result be changed to CORRUPTED when new evidence appears, allowing the named whitehat to claim the Pool's stake and bonus.

When the Pool never observed active risk, sweepUnclaimedBonus() is normally intended to recover bonus that does not belong to stakers. The function does not wait for the outcome-correction window to close, however. After a provisional SURVIVED result and before the first claim, it transfers bonus and reduces totalBonus without setting claimsStarted. The control flow still permits a re-flag, but the assets can no longer be redistributed by the Pool.

// src/ConfidencePool.sol
function sweepUnclaimedBonus() external nonReentrant {
// ... the no-risk-window path reserves only principal
uint256 amount = balance - reserved;
totalBonus -= accountedBonusToSweep;
stakeToken.safeTransfer(recoveryAddress, amount);
// @> The transfer does not set claimsStarted, so flagOutcome remains re-flaggable.
}
function flagOutcome(Outcome newOutcome, bool goodFaith, address attacker) external onlyModerator {
if (claimsStarted) revert ClaimsAlreadyStarted();
// @> A corrected CORRUPTED outcome snapshots the already-reduced totalBonus.
snapshotTotalBonus = totalBonus;
bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus;
}

For example, suppose the Pool has 100 stake and 50 bonus with riskWindowStart == 0. The Registry is already CORRUPTED, but the moderator initially flags SURVIVED because the breach appears out of scope. Any address can then sweep the 50 bonus. The moderator may still correct the result to good-faith CORRUPTED, but the bounty is now only 100, rather than the correct 150.

Section 4 of DESIGN.md defines the first claim as the finality event for re-flagging, because value has then left the Pool. The action here is a sweep, not a claim, and the code explicitly does not treat it as claimsStarted. Section 5 permits sweeping bonus when there was no risk window, but does not state that such a sweep may reduce the bounty of a later corrected whitehat outcome. Asset finality and outcome finality are therefore inconsistent.

Risk

Likelihood:

  • The Pool contains stake and bonus while riskWindowStart == 0; the Registry may nevertheless already be terminal CORRUPTED.

  • The moderator provisionally flags SURVIVED and later needs to re-flag to good-faith CORRUPTED, which is precisely the correction case supported by flagOutcome().

  • Before the first claim, any address can monitor the outcome event and call permissionless sweepUnclaimedBonus() first. The caller need not control recoveryAddress.

Impact:

  • All or part of the real bonus recorded in the Pool irreversibly leaves the Pool, reducing the correct whitehat's bountyEntitlement by the same amount.

  • A caller controlling recoveryAddress directly benefits; otherwise, the caller can still break the corrected economic result. The Pool has no mechanism to recover the funds from recoveryAddress.

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";
/// @notice PoC for M-01: a sweep transfers accounted bonus while a SURVIVED outcome is still re-flaggable.
contract M01SweepDepletesReflagBountyPoC is BaseConfidencePoolTest {
function testPoC_SweepPermanentlyReducesCorrectedGoodFaithBounty() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
// The Registry is terminal CORRUPTED, but the moderator initially treats the breach as
// out of scope and selects SURVIVED. No active-risk observation keeps bonus sweepable.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no observed risk window");
assertFalse(pool.claimsStarted(), "re-flag window remains open");
emit log_named_uint("accounted bonus before sweep", pool.totalBonus());
emit log_named_uint("Pool balance before sweep", token.balanceOf(address(pool)));
// Any address can execute the sweep. Principal remains reserved, but the entire bonus
// is transferred to recovery and removed from the live accounting.
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(bob);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonusAmount, "bonus swept to recovery");
assertEq(pool.totalBonus(), 0, "live bonus accounting was depleted");
assertFalse(pool.claimsStarted(), "sweep did not close the correction window");
emit log_named_uint("bonus swept before correction", bonusAmount);
emit log_named_uint("Pool balance after sweep", token.balanceOf(address(pool)));
// The moderator corrects the outcome to good-faith CORRUPTED. The bounty is built from
// current accounting, so the already-swept 50 bonus can no longer be assigned to Alice.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 expectedCorrectBounty = stakeAmount + bonusAmount;
assertEq(pool.bountyEntitlement(), stakeAmount, "corrected bounty excludes swept bonus");
assertEq(expectedCorrectBounty - pool.bountyEntitlement(), bonusAmount, "whitehat loss equals sweep");
emit log_named_uint("bounty after corrected outcome", pool.bountyEntitlement());
emit log_named_uint("bounty lost to pre-reflag sweep", expectedCorrectBounty - pool.bountyEntitlement());
}
}
[PASS] testPoC_SweepPermanentlyReducesCorrectedGoodFaithBounty() (gas: 589535)
Logs:
accounted bonus before sweep: 50000000000000000000
Pool balance before sweep: 150000000000000000000
bonus swept before correction: 50000000000000000000
Pool balance after sweep: 100000000000000000000
bounty after corrected outcome: 100000000000000000000
bounty lost to pre-reflag sweep: 50000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.31ms (451.29µs CPU time)

Recommended Mitigation

Do not transfer accounted bonus that can affect a future corrected outcome while re-flagging remains available. The sweep can be deferred until outcome finality, or the funds can be kept in an escrow that remains available for a later bounty.

function sweepUnclaimedBonus() external nonReentrant {
+ // A transfer that consumes accounted bonus must not race the correction window.
+ if (!claimsStarted && outcome != PoolStates.Outcome.UNRESOLVED) {
+ revert OutcomeNotFinal();
+ }
// compute and sweep only funds that are safe to release
}

A more complete design separates proposeOutcome from finalizeOutcome: claims and sweeps of accounted assets become available only after finalization. Do not merely set claimsStarted = true on sweep, since that removes the correction capability the protocol explicitly intends to preserve.

Support

FAQs

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

Give us feedback!