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

ConfidencePool::sweepUnclaimedBonus moves real bonus without finalizing the outcome, so a pre-correction sweep permanently misdirects the whitehat's bounty

Author Revealed upon completion

Description

  • On the CORRUPTED-path, resolution finality is tied to claimsStarted: flagOutcome may be re-flagged to correct a mistaken outcome, but only until the first value-moving claim latches claimsStarted. The design's stated invariant (docs/DESIGN.md §4) is "Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting."

  • sweepUnclaimedBonus deliberately never sets claimsStarted (to stop a 1-wei donation from slamming the re-flag window shut), yet in the riskWindowStart == 0 / totalEligibleStake == 0 branch it removes the entire real bonus from the pool and zeroes totalBonus. Because the outcome stays re-flaggable, a moderator correction from SURVIVED to good-faith CORRUPTED re-snapshots snapshotTotalBonus from an already-emptied pot, so the named whitehat is paid principal only while the bonus sits at recoveryAddress.

// ConfidencePool.flagOutcome - re-flag window closes on claimsStarted, not on value movement
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ConfidencePool.sweepUnclaimedBonus - real bonus leaves the pool...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// ...but claimsStarted is intentionally NOT set, leaving the outcome re-flaggable:
@> // Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome ...
stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Severity: Medium - Medium Impact x Medium Likelihood.

Likelihood: Medium - requires specific but achievable conditions, none privileged on the exploiting side.

  • Occurs when the registry reaches terminal CORRUPTED without any pool interaction ever observing an active-risk state, so riskWindowStart stays 0 (the documented "no observed risk" path, achievable whenever no one stakes/withdraws/pokes during the active-risk interval)

  • Occurs when the moderator uses the documented pre-claim re-flag window to correct a SURVIVED flag to good-faith CORRUPTED (a supported, non-erroneous reassessment when an initially out-of-scope-looking breach is later judged in-scope), while the sponsor, the recoveryAddress beneficiary calls the permissionless sweepUnclaimedBonus in the interval before the correction lands.

Impact: Medium - indirect fund risk; a bounded amount is misrouted to a protocol-designated address rather than openly drained.

  • The named whitehat receives only the principal; the bonus they are entitled to under good-faith CORRUPTED (whole pool = principal + bonus) is permanently redirected to the sponsor-controlled recoveryAddress. Because a sponsor typically funds a large bonus to attract stakers, the misdirected amount can be a majority of the pool value.

  • The protocol's own coupling of value movement to finality §4 is broken: real bonus leaves the pool under a still-re-flaggable outcome

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract SweepMisdirectionPoC is BaseConfidencePoolTest {
function test_sweepMisdirectsAttackerBonus() public {
// 1. Stake + bonus land while the registry is pre-attack, so riskWindowStart stays 0.
_stake(alice, 1000 * ONE);
_contributeBonus(bob, 500 * ONE);
// 2. Registry jumps straight to terminal CORRUPTED; no active-risk state was ever observed.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
// 3. Moderator flags SURVIVED (breach looked out-of-scope). claimsStarted stays false.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 4. Permissionless sweep drains the full bonus to recovery WITHOUT latching finality.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 500 * ONE);
assertEq(pool.totalBonus(), 0);
// 5. Moderator corrects to good-faith CORRUPTED, re-flag window is still open.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// 6. Whitehat claims the "whole pool", but the bonus is already gone.
vm.prank(attacker);
pool.claimAttackerBounty();
// Attacker receives principal only (1000), not the intended full pool (1500)...
assertEq(token.balanceOf(attacker), 1000 * ONE);
// ...and the 500 bonus was permanently misdirected to the sponsor's recovery address.
assertEq(token.balanceOf(recovery), 500 * ONE);
}
}

Recommended Mitigation

Latch finality precisely when real (non-donation) bonus leaves the pool, keeping the dust-donation exemption intact.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // Real bonus principal is leaving the pool (not donation dust): finalize the outcome
+ // so a later corrective re-flag cannot under-pay the attacker / recovery against a
+ // depleted balance. Pure dust/donation sweeps still skip the latch.
+ if (!claimsStarted) claimsStarted = true;
}

Support

FAQs

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

Give us feedback!