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.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.