FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

`sweepUnclaimedBonus()` permanently strands the entire bonus before a good-faith `CORRUPTED` re-flag, underpaying the named whitehat by the full bonus amount

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: docs/DESIGN.md §4 establishes that the moderator's flagOutcome re-flag window stays open until the first claim (claimsStarted), specifically so an honest mistake can be corrected — e.g. flagging SURVIVED and later learning the breach was actually in-scope, correcting to a good-faith CORRUPTED naming the whitehat. §12 guarantees that a good-faith CORRUPTED resolution entitles the named whitehat to the entire pool (snapshotTotalStaked + snapshotTotalBonus).

  • Specific issue: sweepUnclaimedBonus() is deliberately exempted from setting the claimsStarted finality latch (this is intentional — it stops a 1-wei donation from grief-closing the moderator's correction window). But the function does move real value out of the contract to recoveryAddress. When riskWindowStart == 0, the reserve calculation excludes the bonus entirely, so a single call sweeps the whole bonus B while the re-flag window stays open. A subsequent, legitimate good-faith CORRUPTED correction then re-snapshots totalBonus from its now-zeroed live value, collapsing the whitehat's entitlement from P+B down to just P. The bonus B is permanently stranded at the sponsor-controlled recoveryAddress instead of reaching the whitehat docs/DESIGN.md §12 promises it to.

/// @inheritdoc IConfidencePool
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;
@> }
// @> When riskWindowStart == 0, the bonus leg of `reserved` is skipped entirely,
// @> so the ENTIRE bonus B becomes sweepable even though a re-flag is still legally possible.
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
// @> Live totalBonus is decremented HERE, before any re-flag has to happen —
// @> so a later flagOutcome() re-snapshot reads this already-shrunk value.
}
// @> Intentionally does NOT set claimsStarted (anti dust-donation-grief) —
// @> but this also means a WHOLE-BONUS sweep never closes the re-flag window either.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
/// @inheritdoc IConfidencePool
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// @> claimsStarted is still false after sweepUnclaimedBonus(), so this guard does NOT block
// @> a correcting re-flag even though the bonus has already left the contract.
IAttackRegistry.ContractState state = _observePoolState();
// ... (SURVIVED / CORRUPTED branch validation omitted for brevity) ...
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
// @> Re-snapshot reads the LIVE totalBonus, which sweepUnclaimedBonus() already zeroed out.
bountyEntitlement = (newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_)
? snapshotTotalStaked + snapshotTotalBonus
: 0;
// @> bountyEntitlement collapses to snapshotTotalStaked (= P) only, instead of P + B.
}

Risk

Likelihood:

  • This occurs whenever the registry reaches terminal CORRUPTED while the pool never observed an active-risk state (riskWindowStart == 0) — a plausible, non-adversarial scenario for a fast breach or a dormant pool nobody polled during the active-risk window — and the moderator initially flags SURVIVED (explicitly allowed on a CORRUPTED registry per §8) before later correcting to good-faith CORRUPTED after learning the breach was in-scope after all.

  • The harmful step, sweepUnclaimedBonus(), is fully permissionless and rationally executed by the sponsor (who controls recoveryAddress and directly benefits from the stranded bonus), or can even front-run the moderator's pending correction transaction in the public mempool — it does not require any special privilege or race against unlikely odds.

Impact:

  • The named whitehat — an external, non-privileged party who is specifically entitled to the whole pool under a good-faith CORRUPTED resolution — is underpaid by exactly one full bonus contribution B, receiving only the staked principal P instead of P + B.

  • The misdirected bonus is not lost to the protocol but is permanently rerouted to the sponsor-controlled recoveryAddress, meaning the sponsor's own action (or a front-run of the moderator's correction) can retroactively reduce a whitehat's promised bounty with no way for the whitehat or moderator to reclaim it after the fact.

Proof of Concept

This is the exact, unmodified content of test/poc/F-001-PoC.t.sol. Save it to that path in the project (the test/helpers/BaseConfidencePoolTest.sol base class it inherits from already exists in the repository) and run:

forge test --match-path "test/poc/F-001-PoC.t.sol" -vv

Real captured output:

Ran 3 tests for test/poc/F-001-PoC.t.sol:ConfidencePoolF1PoC
[PASS] test_POC_bonusPreSweptUnderpaysGoodFaithWhitehat() (gas: 603156)
Logs:
whitehat SHOULD receive (P+B): 150000000000000000000
whitehat ACTUALLY received (P) : 100000000000000000000
bonus stranded at recovery (B) : 50000000000000000000
[PASS] test_POC_control_noSweep_whitehatGetsWholePool() (gas: 591819)
Logs:
CONTROL A (no sweep): whitehat received: 150000000000000000000
[PASS] test_POC_control_riskWindowObserved_sweepReverts_whitehatGetsWholePool() (gas: 606463)
Logs:
CONTROL B (riskWindow!=0): whitehat received: 150000000000000000000
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 2.34ms (1.33ms CPU time)
Ran 1 test suite in 19.18ms (2.34ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ConfidencePoolF1PoC is BaseConfidencePoolTest {
uint256 internal constant P = 100e18;
uint256 internal constant B = 50e18;
function test_POC_bonusPreSweptUnderpaysGoodFaithWhitehat() public {
_stake(alice, P);
_contributeBonus(bob, B);
assertEq(token.balanceOf(address(pool)), P + B, "pool holds P+B");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "outcome SURVIVED");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
assertEq(pool.snapshotTotalBonus(), B, "snapshot bonus == B at first flag");
assertFalse(pool.claimsStarted(), "claimsStarted NOT latched by flagOutcome");
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), B, "recovery banked the full bonus B");
assertEq(token.balanceOf(address(pool)), P, "pool now holds only principal P");
assertEq(pool.totalBonus(), 0, "live totalBonus shrunk to 0 by the sweep");
assertFalse(pool.claimsStarted(), "re-flag window still open after value moved");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), 0, "re-snapshot reads post-sweep totalBonus == 0");
assertEq(pool.bountyEntitlement(), P, "bounty entitlement collapses to principal only");
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 whitehatReceived = token.balanceOf(attacker);
assertEq(whitehatReceived, P, "whitehat received only principal (underpaid)");
assertLt(whitehatReceived, P + B, "whitehat underpaid vs the whole-pool entitlement");
assertEq(token.balanceOf(recovery), B, "bonus B misrouted to sponsor recovery, not whitehat");
}
function test_POC_control_noSweep_whitehatGetsWholePool() public {
_stake(alice, P);
_contributeBonus(bob, B);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.snapshotTotalBonus(), B, "bonus intact when no sweep occurred");
assertEq(pool.bountyEntitlement(), P + B, "entitlement is the WHOLE pool P+B");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), P + B, "whitehat correctly receives whole pool P+B");
assertEq(token.balanceOf(recovery), 0, "nothing stranded at recovery");
}
function test_POC_control_riskWindowObserved_sweepReverts_whitehatGetsWholePool() public {
_stake(alice, P);
_contributeBonus(bob, B);
_passThroughUnderAttack();
assertTrue(pool.riskWindowStart() != 0, "risk window observed");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), B, "bonus untouched (reserved for stakers)");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), P + B, "entitlement is the WHOLE pool P+B");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), P + B, "whitehat receives whole pool when window observed");
assertEq(token.balanceOf(recovery), 0, "nothing stranded at recovery");
}
}

Recommended Mitigation

The correct fix decouples the correction from both claimsStarted and the entitlement snapshot entirely, using a time-based cooldown so value cannot leave the contract until the correction window has meaningfully closed:

+ uint32 public survivedFlaggedAt;
+ uint32 public constant SURVIVED_SWEEP_COOLDOWN = 3 days;
+
/// @inheritdoc IConfidencePool
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Give the moderator a real window to self-correct a SURVIVED flag before any bonus
+ // value can leave the contract. EXPIRED resolutions are unaffected (no re-flag risk).
+ if (
+ outcome == PoolStates.Outcome.SURVIVED
+ && block.timestamp < survivedFlaggedAt + SURVIVED_SWEEP_COOLDOWN
+ ) {
+ revert SweepCooldownActive();
+ }
uint256 reserved;
// ... unchanged ...
if (newOutcome == PoolStates.Outcome.SURVIVED) {
if (goodFaith_ || attacker_ != address(0)) {
revert InvalidGoodFaithParams();
}
if (state != IAttackRegistry.ContractState.PRODUCTION && state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
+ survivedFlaggedAt = uint32(block.timestamp);
}

This preserves the anti-dust-donation-grief behavior (no claimsStarted change), never inflates bountyEntitlement past the real balance (no brick risk), and closes the actual defect: value cannot leave the contract during the window in which a correction is realistically still pending. If the sponsor prefers not to add new state, an acceptable fallback is to document in DESIGN.md §4/§12 that a SURVIVED-state bonus sweep is irreversible and operationally require moderators to wait a grace period before sweeping — but a code-enforced cooldown removes reliance on off-chain process discipline.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!