FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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).
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!