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

Unlatched `sweepUnclaimedBonus` strands the bonus during the pre-claim re-flag window, underpaying the whitehat by the entire bonus.

Author Revealed upon completion

Root + Impact

Description

  • Normally, a good-faith CORRUPTED resolution pays the named whitehat the entire pool (principal + bonus) as bounty (DESIGN.md §12), and resolution finality is protected by the invariant "claimsStarted == true ⇒ value has moved, so a corrective re-flag is only safe until then" (§4) — every value-moving entrypoint latches claimsStarted.

  • sweepUnclaimedBonus deliberately does not latch claimsStarted (to defeat 1-wei donation griefing), but in the riskWindowStart == 0 branch it also performs a genuine, irreversible value movement: it exports the entire bonus to recoveryAddress and zeroes totalBonus. Because finality is never latched, the outcome stays re-flaggable after the bonus has left the pool; a subsequent good-faith CORRUPTED re-flag re-snapshots the bounty against a totalBonus that has already been drained to 0, so the bounty silently excludes the bonus.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus; // skipped when riskWindowStart == 0 => bonus NOT reserved
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0; // == full bonus when unreserved
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // live totalBonus zeroed
}
@> // Intentionally does NOT set claimsStarted <-- ROOT CAUSE: value moves, finality NOT latched
stakeToken.safeTransfer(recoveryAddress, amount); // bonus irreversibly leaves the pool
}

Risk

Likelihood:

  • Occurs whenever the registry reaches terminal CORRUPTED without the pool ever observing an active-risk state (no pokeRiskWindow and no gated call during the UNDER_ATTACK/PROMOTION_REQUESTED interval), leaving riskWindowStart == 0.

  • Occurs when the moderator first flags SURVIVED on that CORRUPTED registry — an explicitly valid "out-of-scope breach" judgement that does not latch claimsStarted — and later uses the documented pre-claim re-flag (§4) to correct to good-faith CORRUPTED.

  • Occurs on the interleaving permissionless sweepUnclaimedBonus call, which the recoveryAddress beneficiary (the bonus sponsor) is directly incentivized to make; no malicious moderator and no special privilege are required.

  • Impact:

  • The named whitehat is underpaid by exactly the bonus amount: the bounty is re-snapshotted as principal-only, violating the DESIGN §12 guarantee that the whole pool is the bounty.

  • The entire bonus is permanently stranded at recoveryAddress (the sponsor) with no recovery path, and the resolution-finality invariant (§4) is broken because value moved while the outcome remained re-flaggable.

Proof of Concept

// 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 BonusStrandingReflagTest is BaseConfidencePoolTest {
uint256 internal constant PRINCIPAL = 100 * ONE;
uint256 internal constant BONUS = 50 * ONE;
function test_poc_bonusStrandedByReflagAfterSweep() external {
// 1. Alice stakes principal; a sponsor contributes bonus. Registry is in pre-attack
// staging (NEW_DEPLOYMENT), so deposits are allowed and riskWindowStart stays 0.
_stake(alice, PRINCIPAL);
_contributeBonus(makeAddr("sponsorBonus"), BONUS);
assertEq(pool.totalEligibleStake(), PRINCIPAL);
assertEq(pool.totalBonus(), BONUS);
assertEq(token.balanceOf(address(pool)), PRINCIPAL + BONUS);
// 2. Registry reaches terminal CORRUPTED, but the pool never observed an active-risk
// state, so the risk window never opened (riskWindowStart == 0).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 3. Moderator (mistakenly) flags SURVIVED — valid on a CORRUPTED registry, and does
// NOT latch claimsStarted.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
assertEq(pool.claimsStarted(), false);
// 4. Permissionless sweep. riskWindowStart == 0 => bonus unreserved => full BONUS exported
// to recoveryAddress, totalBonus zeroed, claimsStarted still false (root cause).
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), BONUS);
assertEq(token.balanceOf(address(pool)), PRINCIPAL);
assertEq(pool.totalBonus(), 0);
assertEq(pool.claimsStarted(), false);
// 5. Moderator corrects to good-faith CORRUPTED (allowed: claimsStarted == false).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Bounty re-snapshotted against depleted totalBonus (== 0) => excludes the bonus.
assertEq(pool.bountyEntitlement(), PRINCIPAL);
// 6. Whitehat claims full entitlement — principal only.
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), PRINCIPAL); // underpaid by exactly BONUS
assertEq(token.balanceOf(recovery), BONUS); // bonus stranded with sponsor
assertEq(token.balanceOf(address(pool)), 0);
}
// Control: direct good-faith CORRUPTED (no SURVIVED detour, no interleaved sweep) pays the
// whole pool — the DESIGN §12 behavior. Same funds; only the sequence differs.
function test_control_directGoodFaithCorruptedPaysWholePool() external {
_stake(alice, PRINCIPAL);
_contributeBonus(makeAddr("sponsorBonus"), BONUS);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), PRINCIPAL + BONUS);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), PRINCIPAL + BONUS);
assertEq(token.balanceOf(recovery), 0);
}
}

Recommended Mitigation

Latch finality only when the sweep moves genuine bonus, preserving the anti-griefing property for
dust/donation sweeps:
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ claimsStarted = true; // genuine bonus left the pool — lock the outcome
}

Support

FAQs

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

Give us feedback!