Impact: High
Likelihood: Low
flagOutcome lets the moderator set the pool outcome. Its only guard is shown below.
When outcome == SURVIVED and claimsStarted == false, the expression is (true && false) == false, so the call does not revert. This means the moderator can change the outcome again, for example from SURVIVED to CORRUPTED, as long as nobody has claimed yet. This change window is intended as a correction path so the moderator can fix a wrong outcome before any value leaves the pool.
The safety of this correction window rests entirely on claimsStarted. DESIGN.md section 4 describes claimsStarted as a value movement finality latch, and states that changing the outcome again before it is set is safe because no value has moved.
The problem is that flagOutcome re-snapshots almost every state variable on each call, including snapshotTotalBonus which it reads from the live totalBonus. Meanwhile sweepUnclaimedBonus can move the bonus out of the pool and reduce totalBonus without ever setting claimsStarted.
Two facts combine into the bug:
When riskWindowStart == 0, the bonus is not added to reserved, so the whole totalBonus becomes free balance that can be swept to recoveryAddress, and totalBonus is then reduced to 0.
The function never sets claimsStarted, so the outcome stays changeable after the bonus has already left the pool.
This breaks the invariant that the correction window is lossless, because value can move while claimsStarted is still false.
Concrete chain of actions:
Preconditions: the pool has stakes so totalEligibleStake > 0, it has a bonus so totalBonus = B > 0, and riskWindowStart == 0 because the agreement reached CORRUPTED without the pool ever observing an active risk window. Per DESIGN.md section 5 the moderator may still flag on off chain judgement in this case, so this state is reachable.
Flag A, SURVIVED. The moderator first judges the breach to be out of scope and calls flagOutcome(SURVIVED). This sets snapshotTotalBonus = B and leaves claimsStarted == false.
Sweep. Anyone calls sweepUnclaimedBonus. Because riskWindowStart == 0, reserved = totalEligibleStake only, so amount = B, the whole bonus is sent to recoveryAddress, and totalBonus becomes 0. claimsStarted is still false.
Flag B, good faith CORRUPTED. The moderator now corrects the outcome because the breach was actually in scope and a whitehat should be rewarded, calling flagOutcome(CORRUPTED, goodFaith=true, attacker=X). The state is CORRUPTED and claimsStarted == false, so the call proceeds. It re-snapshots snapshotTotalBonus = totalBonus = 0, so bountyEntitlement = snapshotTotalStaked + 0 = totalEligibleStake, which is short by B.
The whitehat claims. claimAttackerBounty pays min(remaining, freeBalance) = totalEligibleStake. The whitehat receives only the principal, and the bonus B stays stranded at recoveryAddress.
Per DESIGN.md section 12 a good faith CORRUPTED whitehat is entitled to the whole pool, which is principal plus bonus. The early sweep permanently moves B away from the whitehat and into recoveryAddress, and the outcome change that section 4 promises is lossless cannot recover it.
This is reachable in practice rather than only theoretical, because sweepUnclaimedBonus is permissionless. Anyone who wants to grief the whitehat, or the sponsor who controls recoveryAddress and wants to keep the bonus, can front run the moderator correction with a sweep. It is a value diversion triggered by an untrusted caller during a window the design assumes is safe.
The bad faith case is not a bug. If Flag B were bad faith CORRUPTED, the whole pool is meant to go to recoveryAddress anyway. The bonus already swept to recovery plus the principal swept through claimCorrupted together equal the whole pool going to recovery, which is correct. The loss is specific to the good faith outcome change.
Secondary instance. A weaker version of the same pattern exists with _firstGoodFaithCorruptedAt, which is set once and never reset.
If the moderator flags good faith CORRUPTED at time t1, which sets _firstGoodFaithCorruptedAt = t1 and the deadline to t1 + 180 days, then changes the outcome to SURVIVED, then flags good faith CORRUPTED again at a later time t2, the value of _firstGoodFaithCorruptedAt stays t1 because it is only set when it is 0. So corruptedClaimDeadline stays anchored at t1 + 180 days rather than the current flag. If t2 > t1 + 180 days, the attacker window is already expired at the time of the new flag, so claimAttackerBounty reverts with ClaimWindowExpired, and the bounty is swept to recovery through sweepUnclaimedCorrupted. This needs a long gap between the two good faith flags, so it is narrower, but it is structurally the same defect where stale storage survives an outcome change.
The root cause for both instances is the same. The safety of flagOutcome depends on claimsStarted being a true signal that value has moved, but sweepUnclaimedBonus moves value without setting it, and one variable is never reset at all.
Make sweepUnclaimedBonus set claimsStarted to true so the outcome can no longer be changed once bonus has left the pool. Alternatively, block sweepUnclaimedBonus until claimsStarted is already true, or keep the bonus reserved while the outcome can still change. Setting claimsStarted is the smallest change and is the most consistent with the stated design model. For the secondary instance, reset _firstGoodFaithCorruptedAt when the outcome is changed away from good faith CORRUPTED, so a later good faith flag anchors its claim deadline to the correct time.
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.