FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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;
}
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!