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

Bonus can be swept to `recoveryAddress` mid pre-claim reflag window, causing good-faith CORRUPTED `bountyEntitlement` to fall short of the documented full-pool guarantee

Author Revealed upon completion

Root + Impact

Description

Per line 28, a good-faith CORRUPTED outcome entitles the named attacker to the
entire pool (bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus).
flagOutcome is re-flaggable pre-claim (line 322) so the moderator can correct
a mistaken outcome before distribution — this window is gated on claimsStarted,
not on outcome == UNRESOLVED.

The issue: while riskWindowStart == 0, sweepUnclaimedBonus() reserves only
totalEligibleStake, sweeps the full bonus balance to recoveryAddress, and
deliberately does not set claimsStarted. So if the moderator flags SURVIVED
first (valid even when the live registry reads CORRUPTED), anyone can sweep the
bonus before any claim, then the moderator corrects to good-faith CORRUPTED —
the second flagOutcome snapshots totalBonus after it was already reduced.

// sweepUnclaimedBonus() — src/ConfidencePool.sol:474
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus; // bonus unreserved when riskWindowStart == 0
}
}
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted.
stakeToken.safeTransfer(recoveryAddress, amount);
// flagOutcome() — src/ConfidencePool.sol:354
@> snapshotTotalBonus = totalBonus; // reflects the post-sweep, reduced value
...
bountyEntitlement = willBeGoodFaithCorrupted
@> ? snapshotTotalStaked + snapshotTotalBonus // excludes swept bonus
: 0;

claimsStarted protects the distribution, but not the accounting inputs
(totalBonus) feeding the next snapshot — a value-movement event can alter
those inputs inside the window the moderator is meant to still control.

Risk

Likelihood:

  • Occurs whenever a moderator's first flag is SURVIVED on a CORRUPTED registry
    (e.g. believing the breach out-of-scope, or flagging before full review) —
    a normal, expected moderator workflow, not an edge case.

  • Permissionless: any address can trigger the sweep once the pool is in a
    sweep-eligible outcome with riskWindowStart == 0.

  • Widens naturally where riskWindowStart was never latched — a scenario the
    design doc itself acknowledges as real and accepted (§5).

Impact:

  • Breaks the documented "entire pool" invariant for good-faith CORRUPTED
    (§12) — attacker entitlement is silently reduced by whatever bonus swept.

  • Swept bonus routes to recoveryAddress permanently, with no recovery path
    for the moderator or attacker.

  • Undermines the reflag window's purpose (line 322): meant to let the
    moderator fix the outcome before value moves, but value can move without
    tripping the claimsStarted guard meant to be the sole finality signal.

Proof of Concept

This sequence simulates a pool where the registry reports CORRUPTED but the
moderator's first flag is SURVIVED. Between that flag and the corrective
re-flag, an unprivileged caller sweeps the bonus without setting
claimsStarted. When the moderator then corrects to good-faith CORRUPTED, the
new snapshot reflects the already-reduced totalBonus, so the attacker's
bounty comes up short by exactly the swept amount — demonstrating the
entitlement no longer matches the "entire pool" guarantee.

// registry == CORRUPTED, riskWindowStart == 0 (no prior active-risk observation)
pool.flagOutcome(Outcome.SURVIVED, address(0));
// outcome = SURVIVED, claimsStarted = false, totalBonus = B
pool.sweepUnclaimedBonus();
// riskWindowStart == 0 -> bonus not reserved -> B sent to recoveryAddress
// totalBonus = 0, claimsStarted still false
pool.flagOutcome(Outcome.CORRUPTED, attacker);
// snapshotTotalBonus = 0 (post-sweep)
// bountyEntitlement = snapshotTotalStaked + 0
// -> should have included B per §12
pool.claimAttackerBounty();
// receives principal only; B is stuck at recoveryAddress, unrecoverable

Recommended Mitigation

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // Block sweeps while outcome is still correctable, so a reflag can't
+ // snapshot a bonus figure altered mid-window.
+ if (claimsStarted == false && outcome == PoolStates.Outcome.SURVIVED) {
+ revert SweepNotAllowedDuringReflagWindow();
+ }
...
}

Alternative: have sweepUnclaimedBonus() set claimsStarted = true whenever
it moves nonzero bonus value out of the pool — treating the sweep as the same
finality-latching event a claim is, consistent with §4's rule that value
movement, not caller identity, closes the correction window.

Support

FAQs

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

Give us feedback!