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

Sweeping bonus before a corrective re-flag underfunds the attacker bounty

Author Revealed upon completion

Root + Impact

Description

sweepUnclaimedBonus() can transfer tracked bonus to recoveryAddress without setting the value-movement finality latch. A later permitted correction from SURVIVED to good-faith CORRUPTED therefore snapshots the reduced totalBonus and awards the named attacker less than the pool held when the initial outcome was flagged.

Before the first claim, the moderator may re-run flagOutcome() to correct an erroneous outcome or attacker address. This correction mechanism relies on claimsStarted to prove that no distribution-changing value movement has occurred. Every successful claim path that transfers pool value sets the latch and makes a later re-flag revert.

sweepUnclaimedBonus() is the exception. Under SURVIVED or EXPIRED, it transfers the live balance above the staker reserve to recoveryAddress, updates totalBonus when the sweep removes accounted bonus, but intentionally leaves claimsStarted false. Consequently, moving the tracked bonus does not close the correction window.

This becomes exploitable when the pool did not locally observe an active-risk state before the registry reached CORRUPTED. In that case, riskWindowStart == 0, so the bonus is not reserved for surviving stakers. The moderator can initially flag SURVIVED, which is valid even on a CORRUPTED registry because the moderator may judge the breach to be outside the pool's scope. Before the moderator corrects that decision to an in-scope, good-faith CORRUPTED outcome, any account can call sweepUnclaimedBonus().

The sweep reserves only outstanding principal, transfers all bonus to recoveryAddress, and decrements totalBonus to zero. Because claimsStarted remains false, the corrective re-flag succeeds and takes a fresh snapshot from that zeroed value. bountyEntitlement becomes principal alone instead of the pre-sweep principal-plus-bonus balance. The named whitehat can never recover the diverted bonus.

src/ConfidencePool.sol
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}
// ... outcome validation omitted ...
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus; // @> Re-snapshots the post-sweep value.
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED
? snapshotTotalStaked + snapshotTotalBonus
: 0;
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus // @> Swept bonus is excluded forever.
: 0;
// ...
}
function sweepUnclaimedBonus() external nonReentrant {
// ... SURVIVED/EXPIRED check omitted ...
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
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; // @> Removes tracked bonus.
}
// @> `claimsStarted` remains false even though accounted pool value leaves the contract.
stakeToken.safeTransfer(recoveryAddress, amount);
}

The accounting remains solvent: the attacker receives the remaining principal and recovery receives the bonus. The defect is the distribution. For principal P = 100 and bonus B = 50, a direct good-faith CORRUPTED flag awards the attacker 150; inserting the permissionless sweep between flags instead awards recovery 50 and the attacker only 100.

The fee-on-transfer scenario grouped into the canonical finding is a separate token-compatibility subcase. This PoC uses the repository's standard token and demonstrates that no nonstandard transfer behavior is required.

Risk

Likelihood:

  • The pool must have no persisted riskWindowStart, which requires the registry to pass through active risk without a successful pool observation before reaching CORRUPTED, or the pool to have no remaining eligible stakers.

  • The moderator must first flag SURVIVED and later correct it to good-faith CORRUPTED before any claim. This is the narrow correction sequence the pre-claim re-flag feature intentionally supports.

  • Once that sequence exists, exploitation is permissionless and deterministic. A caller can insert the sweep between the moderator's separate flag transactions; the caller does not need to control the recovery address or receive the proceeds directly.

Impact:

  • The named good-faith attacker loses the entire tracked bonus that was swept between flags. Bonus can comprise a substantial portion of the pool, so the shortfall can be material even though principal remains payable.

  • Funds are irreversibly redirected to the sponsor-selected recoveryAddress. Re-snapshotting the reduced accounting prevents insolvency but also makes the corrected bounty permanently smaller.

Proof of Concept

Create test/audit/CP009BonusSweepUnderfundsBounty.t.sol with the following contents:

// 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 CP009BonusSweepUnderfundsBountyTest is BaseConfidencePoolTest {
function test_BonusSweepBeforeCorrectiveReflagUnderfundsAttackerBounty() external {
uint256 principal = 100 * ONE;
uint256 bonus = 50 * ONE;
_stake(alice, principal);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
assertEq(pool.snapshotTotalBonus(), bonus);
assertFalse(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus);
assertEq(pool.totalBonus(), 0);
assertEq(token.balanceOf(address(pool)), principal);
assertFalse(pool.claimsStarted());
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalStaked(), principal);
assertEq(pool.snapshotTotalBonus(), 0);
assertEq(pool.bountyEntitlement(), principal);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), principal);
assertEq(token.balanceOf(recovery) - recoveryBefore, bonus);
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP009BonusSweepUnderfundsBounty.t.sol -vv.

  2. Confirm that test_BonusSweepBeforeCorrectiveReflagUnderfundsAttackerBounty() passes and the suite reports one test passed with zero failures.

Recommended Mitigation

Treat a sweep of tracked bonus as a distribution-finalizing value movement, while preserving the intended ability to sweep pure direct-transfer donations without closing the moderator's correction window. Compute the accounted portion of the sweep separately and set claimsStarted only when that portion is nonzero.

src/ConfidencePool.sol
function sweepUnclaimedBonus() external nonReentrant {
// ... reserve and amount calculation unchanged ...
+ uint256 accountedBonusSwept;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
}
- // Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
- // wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
- // documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
+ // Moving tracked bonus changes the distribution and therefore finalizes the current outcome.
+ // A donation-only sweep has accountedBonusSwept == 0 and cannot grief the correction window.
+ if (accountedBonusSwept != 0 && !claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);
}

With this change, the moderator must correct the outcome before tracked bonus is released. A failed token transfer reverts both the accounting update and the latch, preserving atomicity. Add regression tests showing that a tracked-bonus sweep blocks re-flagging, while sweeping only an unaccounted donation leaves re-flagging available.

If correction must remain possible after bonus becomes sweepable, introduce an explicit finalization step or bounded correction period and prohibit tracked-bonus sweeps until it closes. Merely retaining the old bonus in bountyEntitlement after transferring it out would create an unfunded claim and is not a valid fix.

Support

FAQs

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

Give us feedback!