sweepUnclaimedBonus() is the only value-moving path in ConfidencePool that mutates a resolution snapshot input (totalBonus) without setting the claimsStarted finality latch that every sibling value-moving path sets. Because the moderator's pre-claim re-flag is permitted while claimsStarted is false, a sweep that zeroes totalBonus and then a good-faith-CORRUPTED re-flag re-snapshots the bounty basis from the now-empty bonus, under-entitling the whitehat by the whole B that already left the pool.
Attack, step by step:
A staker stakes principal P; a contributor funds the bonus through contributeBonus, so totalBonus == B. The pool custodies P + B.
The registry reaches terminal CORRUPTED while the pool never observed a risk window, so riskWindowStart == 0.
The moderator calls flagOutcome(SURVIVED, ...) — a legitimate out-of-scope judgement. snapshotTotalBonus captures B; claimsStarted stays false.
Any address calls sweepUnclaimedBonus(). Since riskWindowStart == 0, no staker is owed any bonus, so the whole B is unreserved and swept to recoveryAddress, totalBonus is zeroed, and claimsStarted is deliberately left unset.
The moderator uses the still-open pre-claim window to correct the outcome to good-faith CORRUPTED, naming the whitehat. flagOutcome re-snapshots snapshotTotalBonus from the now-zero totalBonus, so bountyEntitlement == snapshotTotalStaked + 0 == P — short by B.
The whitehat calls claimAttackerBounty() and receives only P. The bonus B is already at the recovery address, captured by the sponsor.
Root cause — sweepUnclaimedBonus() removes the accounted bonus but never latches finality:
Consumed by the subsequent re-flag in flagOutcome(), which re-snapshots the emptied bonus:
When a pool holds a funded bonus B at resolution and the registry reaches terminal CORRUPTED while no risk window was ever observed (riskWindowStart == 0), the entire bonus becomes unreserved and sweepable.
When the moderator flags the pool SURVIVED and any address calls the permissionless sweepUnclaimedBonus() before the moderator re-flags to good-faith CORRUPTED, the sweep zeroes totalBonus and leaves claimsStarted unset, so the re-flag re-snapshots the reduced basis.
When zero eligible stakers remain alongside a funded bonus (totalEligibleStake == 0), no claimSurvived() call exists to latch claimsStarted first, so the forfeiture occurs unconditionally.
The moderator-named good-faith whitehat forfeits the entire bonus pool B — receiving P instead of P + B, or 0 instead of B in the zero-eligible-staker frame.
The forfeited B is misrouted to and captured by the sponsor-controlled recovery address, which the sponsor can point at itself.
The loss is unrecoverable: no external entrypoint returns B to the whitehat, and the pool clone is non-upgradeable.
The protocol's core economic promise — the whole pool to a good-faith whitehat — is broken.
The PoC is two Foundry tests over identical funding (P = 1000 ether, B = 500 ether), isolating the value delta:
test_control_directGoodFaithCorrupted_paysWholePool — the intended outcome. A pool taken straight to good-faith CORRUPTED pays the whitehat the whole pool P + B (1500 ether). This fixes what the whitehat is owed.
test_bug_sweepThenReflag_whitehatForfeitsEntireBonus_sponsorCaptures — the bug. Inserting the permissionless sweep between the SURVIVED flag and the CORRUPTED re-flag pays the whitehat only P (1000 ether); it asserts the realized loss equals the whole bonus B (500 ether) and that the 500 ether is captured by the sponsor.
Run:
Expected output (both pass):
test/unit/RB001ImpactPoC.t.solLatch finality inside sweepUnclaimedBonus() whenever the sweep removes accounted bonus, mirroring every other value-moving path. Guard the latch on bonusRemoved != 0 so a pure-donation/dust sweep still does not latch — preserving the developer's defense against a 1-wei donation being used to slam the re-flag window shut.
With the fix, the sweep removes B > 0 in both frames, so claimsStarted is set. The subsequent good-faith-CORRUPTED re-flag then hits outcome != UNRESOLVED && claimsStarted in flagOutcome and reverts OutcomeAlreadySet, so the reduced-basis re-snapshot never happens. Legitimate claim paths (claimSurvived/claimExpired) gate on outcome/hasClaimed rather than claimsStarted, so they are unaffected, and no external call or reentrancy window is added.
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.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.