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

sweepUnclaimedBonus moves accounted bonus to recoveryAddress while the SURVIVED outcome is still correctable, so a legitimate moderator re-flag to good-faith CORRUPTED underfunds the named whitehat by up to the entire bonus

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: on good-faith CORRUPTED the named whitehat is entitled to the pool
    (bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus); per DESIGN.md §10 /
    protocol-readme, recoveryAddress gets "only excess/dust under SURVIVED/EXPIRED/good-faith".
    The moderator may re-flag pre-claim (§4) to correct an initial outcome.

  • Issue: sweepUnclaimedBonus() sends the accounted bonus (totalBonus) to recoveryAddress
    while the SURVIVED outcome is still correctable, with no finality gate. The later legitimate
    correction to good-faith CORRUPTED re-snapshots the now-depleted totalBonus, so the whitehat
    gets principal only. Bonus §10 reserves for the whitehat is redirected to recoveryAddress
    the opposite of "only excess/dust under good-faith". Documented guarantee vs implementation.

// src/ConfidencePool.sol : sweepUnclaimedBonus()
485: if (riskWindowStart != 0) reserved += snapshotTotalBonus - claimedBonus;
// @> when riskWindowStart == 0 (or no eligible stake) the accounted bonus is NOT reserved,
// @> so the entire totalBonus is sweepable below:
492: uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
503: // Intentionally does NOT set claimsStarted.
// @> value (bonus) leaves to recoveryAddress but the SURVIVED outcome stays re-flaggable:
506: stakeToken.safeTransfer(recoveryAddress, amount);
// src/ConfidencePool.sol : flagOutcome() (later good-faith CORRUPTED correction)
358: snapshotTotalBonus = totalBonus; // @> re-snapshots the DEPLETED bonus
362: bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus; // @> principal only; bonus gone

Consistency notes:

  • Every other value-moving exit latches finality; claimExpired latches claimsStarted on all
    branches specifically so a re-flag cannot "redirect the full pool (stake + bonus)"
    (testModeratorCannotOverrideAutoCorrupted). sweepUnclaimedBonus is the sole exit that omits it.

  • The interaction is exercised by testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement,
    but that test only checks the entitlement is not overstated / nothing bricks — it never
    reconciles the whitehat shortfall with §10's "excess/dust only" guarantee.

Risk

Likelihood:

  • The moderator flags SURVIVED on its initial off-chain judgement (breach appears
    out-of-scope) and later corrects to good-faith CORRUPTED once in-scope evidence
    arrives — the exact correction the §4 pre-claim re-flag window exists for.

  • riskWindowStart == 0 (no active-risk was ever observed by the pool) OR the pool
    has no eligible stake, which leaves the accounted bonus unreserved and sweepable.

  • Any address calls the permissionless sweepUnclaimedBonus in the interval between
    the SURVIVED flag and the correction; recoveryAddress/sponsor is directly
    incentivised to, and no claim need have occurred.

Impact:

  • The named good-faith whitehat receives less than the pool §10/§12 assign to them,
    by the swept accounted bonus — up to 100% of the bonus, and the entire pool in a
    no-staker pool; unbounded in absolute size.

  • Bonus funds are redirected to recoveryAddress (sponsor), undermining the
    confidence-pool whitehat incentive the protocol exists to provide.

  • Staker principal and earned bonus are never affected (protected by reserved).

Proof of Concept

This Foundry test reproduces the exact sequence on the unmodified contract: stake 100 principal + 30 bonus, drive the registry to CORRUPTED with no observed risk window (riskWindowStart == 0),
moderator flags SURVIVED, anyone sweeps the unreserved bonus to recovery, then the legitimate
good-faith CORRUPTED correction runs. The whitehat ends up with 100 instead of 130 — the swept 30
bonus is the loss. The control test (no intermediate sweep) pays the full 130, isolating the sweep
as the sole cause.

// forge test --match-test test_H12_SweepBetweenReflagLeaksBonus
// Setup: 100 principal (alice) + 30 bonus (carol). Registry reaches CORRUPTED
// with no active-risk observed => riskWindowStart == 0.
function test_H12_SweepBetweenReflagLeaksBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
// Moderator's first (out-of-scope) judgement: SURVIVED. No claimsStarted latch.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Permissionless sweep: riskWindowStart == 0 => bonus unreserved => swept to recovery.
pool.sweepUnclaimedBonus();
assertFalse(pool.claimsStarted()); // still re-flaggable
// Legitimate correction to good-faith CORRUPTED naming the whitehat.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE); // @> lost the 30 bonus
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE); // whitehat gets 100, not 130
}
// Control (test_H12_NoSweepAttackerGetsFullPool): identical WITHOUT the intermediate
// sweep => bountyEntitlement == 130 and the whitehat receives 130. The sole
// difference (the sweep) is the entire loss. Full suite: H12 7/7 PASS.

Recommended Mitigation

  • Add uint32 public outcomeProposedAt (appended after existing state), set ONCE on the first
    SURVIVED flag in flagOutcome, plus a REFLAG_CORRECTION_WINDOW constant. The sweep still never
    latches claimsStarted, so it cannot foreclose the correction window or be griefed by a 1-wei
    donation. Strictly monotonic vs the current code: the whitehat is never worse off and strictly
    better inside the window (130 vs 100); grief defense and staker protections are unchanged; the
    only cost is delaying the sponsor's recovery of unowed bonus by the window.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != Outcome.SURVIVED && outcome != Outcome.EXPIRED)
revert OutcomeNotEligibleForSweep();
- uint256 reserved;
- if (totalEligibleStake != 0) {
- reserved = totalEligibleStake;
- if (riskWindowStart != 0) reserved += snapshotTotalBonus - claimedBonus;
- }
+ // While the SURVIVED outcome is still correctable, reserve ALL accounted value
+ // (principal + full live bonus) so a later good-faith CORRUPTED correction can
+ // still pay the whitehat the entire pool. Only unaccounted dust stays sweepable.
+ bool correctionWindowOpen = outcome == Outcome.SURVIVED && !claimsStarted
+ && block.timestamp < uint256(outcomeProposedAt) + REFLAG_CORRECTION_WINDOW;
+ uint256 reserved;
+ if (correctionWindowOpen) {
+ reserved = totalEligibleStake + totalBonus;
+ } else if (totalEligibleStake != 0) {
+ reserved = totalEligibleStake;
+ if (riskWindowStart != 0) reserved += snapshotTotalBonus - claimedBonus;
+ }
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;
+ uint256 accountedBonusSwept;
+ if (!correctionWindowOpen && (totalEligibleStake == 0 || riskWindowStart == 0))
+ accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
+ // NOTE: the sweep still never latches claimsStarted (unchanged), so it can
+ // never foreclose the correction window nor be griefed by a 1-wei donation.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
+// plus: `uint32 public outcomeProposedAt;` appended after existing state, set ONCE
+// on the first SURVIVED flag in flagOutcome() (never refreshed on re-flag).

Support

FAQs

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

Give us feedback!