FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

sweepUnclaimedBonus moves the entire bonus out without latching claimsStarted, letting a pre-claim re-flag to good-faith CORRUPTED underpay the named whitehat by the whole bonus

Author Revealed upon completion

Root + Impact

Description

  • claimsStarted is the value-movement finality latch. the moderator may re-flag a pool's outcome up until the first participant acts on it, so the outcome and named attacker stay adjustable across the entire pre-claim window (flagOutcome re-flag gate, ConfidencePool.sol#L322-L327):

if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

every value-moving exit latches claimsStarted the moment value leaves, which freezes the outcome, because a corrective re-flag re-snapshots the accounting inputs and would otherwise hand out funds the pool no longer holds. the docs state this invariant directly (DESIGN.md#L75-L90): "Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting", and "claimsStarted is a value-movement finality latch".

  • sweepUnclaimedBonus is the one value-moving exit that deliberately does not latch (ConfidencePool.sol#L474-L509). its comment justifies that by asserting only dust and donations ever leave through it: "Genuine reliance only comes from claim entrypoints". that assertion breaks in the riskWindowStart == 0 mode, which the same function handles two lines earlier. with riskWindowStart == 0, _bonusShare pays every staker zero, so the reserve withholds nothing for the bonus:

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake; // == S, the staked principal only
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus; // skipped when riskWindowStart == 0
}
}
...
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0; // == (S + B) - S == B
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus; // live totalBonus -> 0
}

the entire genuine bonus B moves to recoveryAddress and live totalBonus is zeroed, yet claimsStarted stays false. real value has left the pool and the re-flag window is still open over a pool already drained of its bonus.

snapshotTotalBonus = totalBonus; // == 0 after the sweep
...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // == S + 0

the named whitehat's entitlement is now S, short by the whole bonus B that already sits at recoveryAddress. claimAttackerBounty pays min(remaining, freeBalance) == S (ConfidencePool.sol#L432-L442), and contributeBonus is blocked once outcome != UNRESOLVED, so the depletion is permanent. there is no path to restore totalBonus, and even a corrective bad-faith re-flag cannot pull B back out of recoveryAddress.

  • this is exactly the balance-accounting break section 4 states cannot happen: the latch exists so that "value has left" implies "outcome frozen", and the bonus sweep is the single exit that severs the implication. the docs are on this side of the argument. the one no-risk-window race they already accept (DESIGN.md#L93-L127) is a different mechanism, a staker calling claimExpired to foreclose CORRUPTED with EXPIRED, which turns on claimExpired latching finality. this path turns on sweepUnclaimedBonus not latching it, and it strands the bonus under a corrected CORRUPTED outcome the docs never discuss. the same section only describes the SURVIVED sweep that sends the bonus to recoveryAddress at riskWindowStart == 0; it never addresses the corrected CORRUPTED outcome, where that same bonus is owed to the attacker but has already left the pool.

Risk

Likelihood:

  • the registry reaches terminal CORRUPTED with, in the design's own words, "no pool interaction observed the registry in an active-risk state before it reached a terminal one", leaving riskWindowStart == 0.

  • the moderator resolves an initial out-of-scope reading as SURVIVED and later corrects it to good-faith CORRUPTED, the exact pre-claim correction the re-flag window is built to allow. SURVIVED is accepted on a terminal-CORRUPTED registry, so both flags are valid.

  • the permissionless bonus sweep runs between the two flags, ahead of any claim latching finality. the sequence needs no malicious actor: sweeping unclaimed bonus is the routine post-SURVIVED cleanup any keeper performs. and the sponsor who controls recoveryAddress is directly paid by that sweep, so a motivated party exists to guarantee the timing.

Impact:

  • the entire sponsor bonus B, which the good-faith CORRUPTED path routes to the named whitehat as their bounty, is permanently captured by recoveryAddress instead. the whitehat receives S where a direct CORRUPTED resolution of the identical state pays S + B.

  • the shortfall is unrecoverable. contributeBonus is gated on UNRESOLVED so totalBonus can never be topped back up, and the moderator has no corrective flag that pulls B back out of recoveryAddress once the sweep has fired.

  • the finality invariant the claimsStarted latch enforces is broken: value has left the pool while the outcome was left unfrozen, the exact balance-accounting break the design states is impossible.

Proof of Concept

add test/poc/SweepReflagAttackerShortfall.t.sol. it deploys a pool through the in-repo BaseConfidencePoolTest helper and the existing registry/agreement mocks, no fork. the test walks the SURVIVED -> sweep -> good-faith CORRUPTED sequence and asserts the named whitehat receives only S while the whole bonus B sits at recoveryAddress.

// 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 SweepReflagAttackerShortfallTest is BaseConfidencePoolTest {
uint256 internal constant S = 100_000 * ONE; // staker principal
uint256 internal constant B = 40_000 * ONE; // sponsor bonus
function test_Exploit_ReflagAfterSweep_ShortChangesAttacker() external {
_stake(alice, S);
_contributeBonus(carol, B);
// registry jumps to terminal CORRUPTED with the pool never observing active-risk
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// moderator flags SURVIVED (out-of-scope-breach judgement); claimsStarted stays false
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "risk window never sealed");
assertFalse(pool.claimsStarted(), "flagOutcome does not latch finality");
// permissionless sweep moves the full bonus B out, zeroes totalBonus, no finality latch
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, B, "full bonus swept to recovery");
assertEq(pool.totalBonus(), 0, "live totalBonus zeroed");
assertFalse(pool.claimsStarted(), "sweep left the re-flag window open");
// moderator corrects to good-faith CORRUPTED; re-snapshot sees the depleted bonus
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), S, "attacker entitlement is missing the whole bonus B");
// attacker claims and receives only the principal; B is lost to recovery
vm.warp(block.timestamp + 1);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, S, "attacker received only principal");
assertEq(token.balanceOf(recovery) - recoveryBefore, B, "bonus B captured by recovery");
}
}

run:

forge test --match-path 'test/poc/SweepReflagAttackerShortfall.t.sol' -vv

output:

Ran 1 test for test/poc/SweepReflagAttackerShortfall.t.sol:SweepReflagAttackerShortfallTest
[PASS] test_Exploit_ReflagAfterSweep_ShortChangesAttacker() (gas: 593710)
Suite result: ok. 1 passed; 0 failed; 0 skipped

the test shows the named whitehat receiving 100_000e18 (principal only) instead of the 140_000e18 a good-faith CORRUPTED resolution entitles them to, with the 40_000e18 bonus permanently held by recoveryAddress. the shortfall is exactly the swept bonus B.

Recommended Mitigation

treat the bonus sweep as a finality event on the branch where it moves genuine bonus rather than dust. the 1-wei-donation anti-grief concern the current comment cites only applies to the reserved case, where the swept amount is pure excess. in the riskWindowStart == 0 || totalEligibleStake == 0 branch the swept amount is the real bonus and must freeze the outcome:

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // genuine bonus (not dust) has left the pool: freeze the outcome so a later re-flag
+ // cannot re-snapshot a totalBonus that no longer reflects held funds.
+ if (!claimsStarted) claimsStarted = true;
}

alternatively, gate flagOutcome's re-flag on a dedicated bonusSwept latch set whenever a non-dust bonus sweep occurs, so a correction cannot re-snapshot bountyEntitlement / corruptedReserve after the bonus has already been moved out.

both options bring the bonus sweep in line with the one-way finality the design already relies on: the riskWindowStart != 0 latch that keeps a benign upstream rewind from re-opening withdraw (DESIGN.md#L289-L290), the expiryLocked latch on expiry, and claimsStarted on every claim path. value that has left the pool should not be re-openable by a later re-flag, which is the invariant the design already upholds for every other latched position.

Support

FAQs

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

Give us feedback!