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

`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.

Support

FAQs

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

Give us feedback!