sweepUnclaimedBonus() can move funds before the outcome is final while leaving claimsStarted == false. The root cause is that a later flagOutcome() re-snapshots from the mutated live accounting, so a pre-finality sweep can erase value from a later CORRUPTED reserve or let the wrong branch become permanent after value already moved.
The protocol explicitly says the moderator may re-flag a typo'd outcome or attacker before any participant locks in the wrong distribution. That correction window is supposed to remain safe only until value leaves the contract. But the SURVIVED branch is still reachable even while the registry is terminal CORRUPTED, and flagOutcome() still allows a later pre-claim re-flag to CORRUPTED.
On the no-risk-window path, sweepUnclaimedBonus() treats the entire bonus as unreserved, transfers it to recoveryAddress, decrements totalBonus, and deliberately does not set claimsStarted. This is the accounting break: live economic state changes, but the contract still presents the outcome as safely re-flaggable.
This is the opposite of how the protocol is described externally. README.md#L71-L79 says the moderator's correction window closes on the first claim because finality is value-movement, and protocol-readme.md#L43-L45 describes sweepUnclaimedBonus() as recovering only excess over the remaining entitlement reserve. In this branch the sweep itself is the first meaningful value movement, and it does not just recover dust: it changes totalBonus before the outcome is final.
Any later re-flag then snapshots from the already-mutated live totals. flagOutcome() copies totalEligibleStake and totalBonus straight into snapshotTotalStaked, snapshotTotalBonus, corruptedReserve, and bountyEntitlement, so the future CORRUPTED path inherits the reduced reserve instead of the pre-sweep economic obligation.
There are then two distinct loss branches from the same root cause.
If stakers still exist, a staker can claim principal after the sweep and only then lock finality. claimSurvived() flips claimsStarted only when the claim itself executes, so the sweep creates a window where bonus is already gone but principal is still claimable through the wrong SURVIVED branch.
If no stakers remain, the same bug erases the future good-faith bounty entirely: the bonus is swept away while claimsStarted == false, and a later typo correction or scope correction to good-faith CORRUPTED snapshots snapshotTotalBonus == 0, even though the docs say the entire pool is the named attacker's bounty in the normal good-faith case.
This is stronger than the already-known no-risk-window claimExpired() race in docs/DESIGN.md#L111-L127. That accepted tradeoff is about a conservative EXPIRED backstop resolving before moderator action. Here the contract actively moves funds out pre-finality, then allows later re-snapshotting or a later staker claim to make the wrong distribution permanent.
The test suite also encodes the unsafe assumption instead of checking the safety claim. test_sweepUnclaimedBonus_doesNotLockReflag explicitly asserts that a successful sweep must not flip claimsStarted, and testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement treats the later reduced bountyEntitlement as the expected result. Those tests prove reachability of the reserve loss; they do not prove that the resulting payout is safe.
This bug silently destroys funds that a later CORRUPTED settlement should still reserve. In the good-faith path, the sponsor-controlled recoveryAddress can receive bonus that the named whitehat attacker should have received. In the bad-faith or corrected-CORRUPTED path, a staker can recover principal that should have remained sweepable. The contract therefore routes real pool value to the wrong beneficiary and underfunds the economically correct CORRUPTED outcome.
Make any value-moving sweepUnclaimedBonus() call a finality event, or forbid bonus sweeps entirely while the moderator can still re-flag into a different economic branch.
I validated the two strongest branches locally with a temporary standalone Foundry test file.
Save the following as test/TempSweepReflagAccounting.t.sol:
Run:
Observed output:
These PoCs show the same root cause producing both major failure modes:
the sweep can erase bonus that a later good-faith CORRUPTED path should still reserve for the named attacker
the sweep can happen before finality, after which a staker can still claim principal and permanently block correction into CORRUPTED
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.