sweepUnclaimedBonus() can permanently siphon funds that belong to a good faith attacker's bounty, undermining the documented safe re‑flag guarantee. This directly contradicts the invariant in no. 4 states(DESIGN.md): ‘claimsStarted’ is a value-movement finality latch. sweepUnclaimedBonus is a value movement function that doesn't honor that latch.
The invariant the contract tries to maintain: no. 4 of the DESIGN.md states the re-flag window (!claimsStarted) exists precisely so the moderator can correct a mistaken outcome "before any participant locks in the wrong distribution," and every value-moving function in the contract is carefully coupled to claimsStarted (claimSurvived, claimExpired, claimCorrupted, claimAttackerBounty when payout>0, sweepUnclaimedCorrupted all set it). sweepUnclaimedBonus() is the one exception, it moves real tokens to recoveryAddress but deliberately does not set claimsStarted.
sweepUnclaimedBonus() is callable while outcome == SURVIVED. If riskWindowStart == 0 at that point (the pool never happened to observe the registry mid‑UNDER_ATTACK/PROMOTION_REQUESTED, plausible for a quiet pool, or when the registry jumps straight to a terminal state), _bonusShare pays every staker 0, so sweepUnclaimedBonus() sweeps the entire totalBonus to recoveryAddress and decrements the live totalBonus variable accordingly:
But flagOutcome(SURVIVED, ...) is explicitly valid even when the registry reads CORRUPTED (no. 8: "out of this pool's scope" case). And crucially, bountyEntitlement for a good-faith CORRUPTED is computed from the live totalBonus at the moment of flagging, with no dependency on riskWindowStart:
So the following sequence is possible, entirely within the "safe" pre-claim window (claimsStarted still false throughout):
Registry reads CORRUPTED, but this pool's riskWindowStart == 0 (it never observed the intermediate attack state).
Moderator initially judges the breach out-of-scope and calls flagOutcome(SURVIVED, false, address(0)), valid per no. 8.
Anyone (permissionless, no profit needed, could even be the moderator's own tx, or a bystander) calls sweepUnclaimedBonus(). Since riskWindowStart == 0, this sweeps 100% of totalBonus to recoveryAddress and zeroes totalBonus. claimsStarted stays false.
Moderator reconsiders (new evidence, correction of a scope misjudgment, exactly the case no. 4 says the re-flag window exists for) and calls flagOutcome(CORRUPTED, true, attackerAddr). This succeeds because claimsStarted is still false.
bountyEntitlement is now computed from the already-drained totalBonus (0, or whatever's left), so the named whitehat's bounty is short by exactly the amount that was swept in step 3 permanently, since those tokens already left the contract for recoveryAddress.
For a bad-faith CORRUPTED re-flag this is harmless (both paths route to recoveryAddress anyway), but for a good faith re-flag it silently transfers value that should go to the named attacker/whitehat over to recoveryAddress instead, with no way to reverse it on-chain. This directly contradicts the stated purpose of the claimsStarted gate: a corrective re-flag has let value move on the old (wrong) distribution before the correction landed, just not through a function the code classifies as a "claim."
Likelihood: high
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.