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

"sweepUnclaimedBonus" bypasses "claimsStarted" finality latch, irreversibly eroding whitehat's DESIGN §12 bounty

Author Revealed upon completion

Root + Impact

Description

  • Under normal operation, sweepUnclaimedBonus sweeps the unreserved bonus to recoveryAddress when the pool never observed active-risk state (riskWindowStart == 0). Per DESIGN §4, the moderator may correct a mis-flag pre-claim because claimsStarted is a "value-movement finality latch" closed only by the first genuine claim.

  • The issue: sweep moves value out of the pool without setting claimsStarted, so a corrective re-flag from SURVIVED to CORRUPTED, goodFaith, whitehat re-snapshots snapshotTotalBonus against the drained totalBonus, computing bountyEntitlement = snapshotTotalStaked + 0 instead of the "entire pool" DESIGN §12 promises. contributeBonus is gated on outcome == UNRESOLVED, so the drained value is irrecoverable.DESIGN §4 itself defines the invariant that this code violates:

// src/ConfidencePool.sol :: sweepUnclaimedBonus (lines 474-509)
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { // no risk observed => bonus not reserved
reserved += snapshotTotalBonus - claimedBonus;
}
}
// ...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // drained; re-flag will re-snapshot against 0
}
@> // Intentionally does NOT set claimsStarted. // BUG: value left the pool, DESIGN §4 latch not tripped
stakeToken.safeTransfer(recoveryAddress, amount);


Risk

Likelihood:

  • The registry reaches terminal CORRUPTED without any pool interaction observing an intermediate active-risk state, leaving riskWindowStart == 0 — DESIGN §5 explicitly accepts this as the "no-risk-window CORRUPTED race."

  • The moderator flags SURVIVED after judging the breach out-of-scope, then determines scope was in-scope and re-flags CORRUPTED, goodFaith, whitehat — the exact sequence DESIGN §4's correction window exists to accommodate.

  • Any observer of the SURVIVED transaction calls the permissionless sweepUnclaimedBonus() before the corrective re-flag lands. No caller restriction; no timing gate beyond the outcome check.


Impact:

  • The named whitehat receives snapshotTotalStaked only, missing up to 100% of snapshotTotalBonus — the drained bonus is permanently held at recoveryAddress, violating DESIGN §12's "the entire pool is the named attacker's bounty."

  • The loss is irreversible: contributeBonus reverts with OutcomeAlreadySet after any flagOutcome call, so no sponsor can restore the drained bonus without a contract upgrade.

Proof of Concept

  • Save as test/unit/SweepFrontrunsReflagPoC.t.sol and run forge test --match-contract SweepFrontrunsReflagPoC -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract SweepFrontrunsReflagPoC is BaseConfidencePoolTest {
address internal whitehat = makeAddr("whitehat");
address internal permissionlessCaller = makeAddr("permissionlessCaller");
function test_SweepFrontrunsReflag_WhitehatLosesEntireBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(token.balanceOf(address(pool)), 150 * ONE);
// Moderator misjudges scope (DESIGN 4 exists to correct this).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.claimsStarted(), false);
// Permissionless caller front-runs the correction. No collusion required.
vm.prank(permissionlessCaller);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 50 * ONE);
assertEq(pool.claimsStarted(), false);
// Irreversibility: contributeBonus is gated on outcome == UNRESOLVED.
token.mint(carol, 50 * ONE);
vm.startPrank(carol);
token.approve(address(pool), 50 * ONE);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
// Moderator corrects. Re-flag re-snapshots against drained totalBonus.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(pool.snapshotTotalBonus(), 0);
assertEq(pool.bountyEntitlement(), 100 * ONE); // NOT the promised 150
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 100 * ONE);
assertEq(token.balanceOf(recovery), 50 * ONE); // diverted from whitehat
}
}

Recommended Mitigation

Withhold the "no risk observed" bonus release while claimsStarted is false, so a legitimate SURVIVED → good-faith CORRUPTED correction cannot be undermined by a pre-emptive sweep. Post-first-claim, the distribution is locked per §4 and sweep proceeds as before. The 1-wei donation grief protection at lines 503-505 is preserved because sweep still does not touch claimsStarted.

The regression test at test/unit/SweepUnclaimedBonus.t.sol:245 will need updating to assert bountyEntitlement == 150 * ONE after the corrective re-flag, reflecting the reconciled DESIGN §12 invariant.

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
+ if (riskWindowStart != 0 || !claimsStarted) {
reserved += snapshotTotalBonus - claimedBonus;
}
}

Support

FAQs

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

Give us feedback!