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

sweepUnclaimedBonus moves the bonus without closing the moderator's re-flag window, so a SURVIVED→CORRUPTED correction pays the whitehat a bounty against an already-emptied pool

Author Revealed upon completion

Description

The moderator may re-flag a pool's outcome before the first claim, so an incorrect resolution can be corrected before any value is distributed. The protocol states the guarantee that makes this safe: "Once value has left the contract, a corrective re-flag cannot be honored" and "Finality is correctly tied to value movement" (docs/DESIGN.md §4). Under a good-faith CORRUPTED outcome, the entire pool is owed to the named whitehat.

The guarantee does not hold. sweepUnclaimedBonus is permissionless and transfers the accounted bonus out of the pool to recoveryAddress, yet it is the one value-moving path that never sets the claimsStarted finality latch. The correction window therefore remains open after the bonus has left the contract. When the moderator then corrects SURVIVED → good-faith CORRUPTED, flagOutcome re-snapshots a totalBonus the sweep has already drained, so the whitehat's bounty is computed against an emptied pool while the bonus sits permanently at recoveryAddress.

This defect is independent of whether the risk window was observed — it is purely a missing finality latch on a value-moving function.

// sweepUnclaimedBonus(): accounted bonus leaves the pool...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
@> stakeToken.safeTransfer(recoveryAddress, amount); // ...but claimsStarted is never set
// flagOutcome(): correction accepted because the window is still open...
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
@> snapshotTotalBonus = totalBonus; // ...re-read AFTER the sweep drained it
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

Risk

Likelihood:

  • The moderator uses the documented correction path — flags SURVIVED on a CORRUPTED registry (an initial "breach was out of this pool's scope" judgement), then corrects to good-faith CORRUPTED once the in-scope breach is confirmed — and any account calls the public sweepUnclaimedBonus in the interval between the two flags.

  • The bonus is sweepable during a SURVIVED resolution whenever totalEligibleStake == 0 (stakers have exited during the pre-attack window, which the protocol permits), so the sweep succeeds even when the risk window was fully observed on-chain.

Impact:

  • The named whitehat's good-faith CORRUPTED bounty is short by the entire bonus; where the pool holds only the bonus, the corrected bounty is 0 and the whitehat receives nothing.

  • The bonus is captured by the sponsor-controlled recoveryAddress instead of paying the whitehat, and a documented spec guarantee — the moderator's correction window, and "finality tied to value movement" — is silently void for any funds a permissionless sweep has already moved.

Proof of Concept

Save as test/ReflagBonusDiversion.t.sol and run forge test --match-test test_reflag_bonus_diverted_observedWindow -vvv. It reuses the repo's BaseConfidencePoolTest harness and passes. Note the risk window is positively observed (riskWindowStart != 0), isolating this from any observation-related issue — the loss is caused solely by the sweep moving value without latching finality.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ReflagBonusDiversion is BaseConfidencePoolTest {
function test_reflag_bonus_diverted_observedWindow() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// Alice exits during the open pre-attack window (allowed by design). Only the bonus remains.
vm.prank(alice);
pool.withdraw();
assertEq(pool.totalEligibleStake(), 0);
// The pool POSITIVELY observes the active-risk window (riskWindowStart != 0) — this is NOT
// the "unobserved risk window" case; the defect is independent of risk observation.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator's initial (to-be-corrected) call: SURVIVED (breach judged out-of-scope).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), 50 * ONE);
// Any account sweeps the bonus to recoveryAddress. Value leaves the pool, but this does NOT
// set claimsStarted, so the correction window is still (wrongly) considered open.
vm.prank(dave);
pool.sweepUnclaimedBonus();
assertFalse(pool.claimsStarted());
assertEq(token.balanceOf(recovery), 50 * ONE);
// Moderator corrects to good-faith CORRUPTED naming the whitehat. It succeeds, but the
// bounty is snapshotted against an already-emptied pool.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 0); // whitehat is owed nothing
assertEq(token.balanceOf(attacker), 0); // whitehat receives nothing
assertEq(token.balanceOf(recovery), 50 * ONE); // sponsor keeps the entire bonus
}
}

Recommended Mitigation

Treat a sweep of accounted bonus as the distribution-locking event it is, latching finality exactly as a claim does. The dust-donation protection is preserved: a sweep of donated excess does not enter the totalBonus-reducing branch and so still does not latch.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // Accounted bonus leaving the pool is a distribution-locking event: latch finality so a
+ // later SURVIVED -> good-faith CORRUPTED correction cannot pay a bounty against a bonus
+ // that has already left the contract.
+ if (!claimsStarted) claimsStarted = true;
}

Support

FAQs

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

Give us feedback!