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

No-staker SURVIVED sweep can zero a corrected good-faith bounty

Author Revealed upon completion

Summary

A pool that observed risk but has no eligible stakers can sweep its tracked bonus after a mistaken SURVIVED flag without closing the moderator's re-flag window. If the moderator then corrects the outcome to good-faith CORRUPTED, the corrected bounty snapshot excludes the already-swept bonus, denying the named whitehat the tracked bonus pool.

Description

sweepUnclaimedBonus treats any SURVIVED/EXPIRED pool with totalEligibleStake == 0 as having no outstanding staker reserve, so the whole token balance is sweepable to recoveryAddress even when riskWindowStart != 0 and the balance is protocol-tracked totalBonus. The same function intentionally does not set claimsStarted, leaving flagOutcome's correction window open. On a later correction to good-faith CORRUPTED, flagOutcome snapshots the now-decremented totalBonus and sets the whitehat bounty from snapshotTotalStaked + snapshotTotalBonus.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}

This is distinct from the documented no-risk-window behavior: here the pool did observe an active-risk window, so the bonus is part of the CORRUPTED bounty if the correct outcome is good-faith CORRUPTED. A mistaken SURVIVED flag creates an interval where anyone can move that tracked bonus to the sponsor-controlled recovery address before the moderator corrects the outcome.

Severity: Low

The issue requires a pool with tracked bonus but no eligible stakers, so exploitability is narrow. The impact is concrete value misrouting: funds that should be included in the good-faith whitehat bounty are irreversibly swept to recoveryAddress.

Impact

The named good-faith attacker can lose the entire tracked bonus pool. Once swept, the corrected CORRUPTED snapshot records snapshotTotalBonus == 0, so claimAttackerBounty has no entitlement to the bonus even though it was contributed through the supported contributeBonus path before resolution.

Proof of Concept

  1. A contributor adds 50e18 bonus tokens to a pool with no stakers.

  2. The registry enters UNDER_ATTACK, and pokeRiskWindow() records riskWindowStart > 0.

  3. The registry later becomes CORRUPTED, but the moderator first flags SURVIVED by mistake.

  4. Anyone calls sweepUnclaimedBonus(), which transfers the full tracked bonus to recoveryAddress and leaves claimsStarted == false.

  5. The moderator corrects to good-faith CORRUPTED.

  6. The corrected snapshot has snapshotTotalBonus == 0 and bountyEntitlement == 0.

Validated with:

forge test \
-R forge-std/=lib/forge-std/src/ \
-R @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ \
-R @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ \
-R @battlechain/=/tmp/bcsh-audit/src/ \
-R src/types/=/tmp/bcsh-audit/src/types/ \
--match-path test/unit/ConfidencePool.noStakerBonusSweep.poc.t.sol -vvv

The PoC passed and showed the corrected good-faith bounty was zero after the SURVIVED-path sweep.

// 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 ConfidencePoolNoStakerBonusSweepPoC is BaseConfidencePoolTest {
function testTrackedBonusCanBeSweptBeforeCorrectingToGoodFaithCorruptedWhenNoStakers() external {
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "risk window was observed");
assertEq(pool.totalEligibleStake(), 0, "no stakers are eligible");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 50 * ONE, "tracked bonus swept to recovery");
assertFalse(pool.claimsStarted(), "sweep does not close the re-flag window");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), 0, "corrected snapshot lost the bonus");
assertEq(pool.bountyEntitlement(), 0, "whitehat bounty is zeroed");
}
}

Mitigation

Do not allow sweepUnclaimedBonus to decrement or transfer tracked totalBonus while a moderator correction remains possible for a risk-observed pool. For example, either set claimsStarted on any sweep of tracked bonus, or block sweeping tracked bonus until finality is closed; post-resolution direct donations can still remain immediately recoverable.

Support

FAQs

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

Give us feedback!