FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

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!