sweepUnclaimedBonus() can send the entire bonus pool to recoveryAddress before a pending moderator correction resolves — permanently under-paying the good-faith whitehat's guaranteed full-pool bounty
sweepUnclaimedBonus() at ConfidencePool.sol:474 is the only payout function in the contract that moves real value without setting claimsStarted = true. Every other payout function — claimSurvived, claimCorrupted, claimAttackerBounty, sweepUnclaimedCorrupted, every branch inside claimExpired — sets this flag on first payout, which closes the moderator's re-flag window. sweepUnclaimedBonus intentionally skips it (line 503-504) to prevent a 1-wei dust donation from blocking a harmless correction.
Separately, when riskWindowStart == 0 the reserved calculation skips the bonus term entirely (reserved = totalEligibleStake, line 31), so the entire bonus pool is treated as sweepable surplus. These two gaps compound: the sweep drains the bonus pool while the re-flag window is still open, and the subsequent bountyEntitlement calculation reads the already-drained totalBonus.
Take a pool with 50,000 staked and a 10,000 bonus contributed by the sponsor.
The registry shows the agreement was corrupted, but nobody happened to interact with the pool while it was actively under attack, so riskWindowStart never got set on-chain.
The moderator, believing the breach fell outside this pool's scope, calls flagOutcome(SURVIVED, ...).
Because riskWindowStart is still 0, sweepUnclaimedBonus() treats the whole 10,000 as unclaimed surplus. It's permissionless — anyone can call it — and the 10,000 moves straight to recoveryAddress.
New information turns up, or the moderator just reconsiders. They correct the flag to CORRUPTED, good faith, attacker named as Alice.
Alice is owed the full pool under the contract's own rules — 60,000. But bountyEntitlement is calculated from the live totalBonus, which is now zero. She gets 50,000.
The 10,000 is already gone by the time the correction happens. It's sitting with recoveryAddress, and nothing in the contract requires it be handed over — whether Alice actually gets made whole comes down to whether the sponsor feels like sending it back voluntarily.
sweepUnclaimedBonus() at ConfidencePool.sol:474:
flagOutcome() at ConfidencePool.sol:322, which is allowed to fire again before any claim happens:
When riskWindowStart == 0, the reserved calculation skips the bonus term entirely, so amount equals the full bonus, not dust. And claimsStarted stays false, so a second flagOutcome call is allowed — but by then totalBonus has been zeroed.
Medium. Real, permanent loss to a specific named party (the good-faith whitehat). No admin key compromise or user mistake required — only a moderator correcting their initial flag, which is normal expected behavior the contract explicitly supports.
A named, good-faith whitehat receives snapshotTotalStaked (50,000 in the PoC) instead of snapshotTotalStaked + snapshotTotalBonus (60,000). The 10,000 shortfall sits permanently in the sponsor-controlled recoveryAddress wallet with no contractual obligation to return it — and with a direct financial incentive to keep it.
riskWindowStart == 0 at the time of the sweep (no on-chain interaction occurred during the active-risk window)
The moderator first flags SURVIVED, then corrects to CORRUPTED (good faith, named attacker)
A bonus pool exists (non-zero totalBonus contributed by the sponsor)
-vvvv output, unedited)Key observations from the trace:
claimsStarted returns false after the first flagOutcome — re-flag window is open
riskWindowStart returns 0 — sweepUnclaimedBonus enters the bonus-draining branch
sweepUnclaimedBonus reads balanceOf(pool) = 60,000, transfers 10,000 to recoveryAddress
totalBonus reads 0 after the sweep — the bonus term was drained
claimsStarted still false after sweep — second flagOutcome succeeds
bountyEntitlement returns 50,000 after the CORRUPTED re-flag — stake only, bonus omitted
claimAttackerBounty transfers 50,000 to attacker; final balanceOf(attacker) = 50,000 instead of the owed 60,000
The obvious fix — just make this function set claimsStarted too — backfires. If a sweep locks the outcome and the moderator later realizes it should've been CORRUPTED, they can no longer fix it at all. The whitehat goes from "underpaid" to "paid nothing."
The better fix uses a distinction the code already makes. This line:
is the only branch that can ever move something as large as the whole bonus pool — the other branch (pure rounding dust or a stray donation) never touches totalBonus and stays genuinely small. So: add a short delay, just on that branch, before the sweep is allowed to fire — long enough for a moderator correction to land first. Leave the dust-sweeping path exactly as it is, instant and permissionless. That keeps the original goal (a 1-wei donation can't hold a correction hostage) without leaving the real hole open. Exact delay length is a call for the team to make.
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.