claimsStarted is the value-movement finality latch. the moderator may re-flag a pool's outcome up until the first participant acts on it, so the outcome and named attacker stay adjustable across the entire pre-claim window (flagOutcome re-flag gate, ConfidencePool.sol#L322-L327):
every value-moving exit latches claimsStarted the moment value leaves, which freezes the outcome, because a corrective re-flag re-snapshots the accounting inputs and would otherwise hand out funds the pool no longer holds. the docs state this invariant directly (DESIGN.md#L75-L90): "Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting", and "claimsStarted is a value-movement finality latch".
sweepUnclaimedBonus is the one value-moving exit that deliberately does not latch (ConfidencePool.sol#L474-L509). its comment justifies that by asserting only dust and donations ever leave through it: "Genuine reliance only comes from claim entrypoints". that assertion breaks in the riskWindowStart == 0 mode, which the same function handles two lines earlier. with riskWindowStart == 0, _bonusShare pays every staker zero, so the reserve withholds nothing for the bonus:
the entire genuine bonus B moves to recoveryAddress and live totalBonus is zeroed, yet claimsStarted stays false. real value has left the pool and the re-flag window is still open over a pool already drained of its bonus.
a subsequent re-flag to good-faith CORRUPTED then re-snapshots the depleted totalBonus (ConfidencePool.sol#L354-L362):
the named whitehat's entitlement is now S, short by the whole bonus B that already sits at recoveryAddress. claimAttackerBounty pays min(remaining, freeBalance) == S (ConfidencePool.sol#L432-L442), and contributeBonus is blocked once outcome != UNRESOLVED, so the depletion is permanent. there is no path to restore totalBonus, and even a corrective bad-faith re-flag cannot pull B back out of recoveryAddress.
this is exactly the balance-accounting break section 4 states cannot happen: the latch exists so that "value has left" implies "outcome frozen", and the bonus sweep is the single exit that severs the implication. the docs are on this side of the argument. the one no-risk-window race they already accept (DESIGN.md#L93-L127) is a different mechanism, a staker calling claimExpired to foreclose CORRUPTED with EXPIRED, which turns on claimExpired latching finality. this path turns on sweepUnclaimedBonus not latching it, and it strands the bonus under a corrected CORRUPTED outcome the docs never discuss. the same section only describes the SURVIVED sweep that sends the bonus to recoveryAddress at riskWindowStart == 0; it never addresses the corrected CORRUPTED outcome, where that same bonus is owed to the attacker but has already left the pool.
Likelihood:
the registry reaches terminal CORRUPTED with, in the design's own words, "no pool interaction observed the registry in an active-risk state before it reached a terminal one", leaving riskWindowStart == 0.
the moderator resolves an initial out-of-scope reading as SURVIVED and later corrects it to good-faith CORRUPTED, the exact pre-claim correction the re-flag window is built to allow. SURVIVED is accepted on a terminal-CORRUPTED registry, so both flags are valid.
the permissionless bonus sweep runs between the two flags, ahead of any claim latching finality. the sequence needs no malicious actor: sweeping unclaimed bonus is the routine post-SURVIVED cleanup any keeper performs. and the sponsor who controls recoveryAddress is directly paid by that sweep, so a motivated party exists to guarantee the timing.
Impact:
the entire sponsor bonus B, which the good-faith CORRUPTED path routes to the named whitehat as their bounty, is permanently captured by recoveryAddress instead. the whitehat receives S where a direct CORRUPTED resolution of the identical state pays S + B.
the shortfall is unrecoverable. contributeBonus is gated on UNRESOLVED so totalBonus can never be topped back up, and the moderator has no corrective flag that pulls B back out of recoveryAddress once the sweep has fired.
the finality invariant the claimsStarted latch enforces is broken: value has left the pool while the outcome was left unfrozen, the exact balance-accounting break the design states is impossible.
add test/poc/SweepReflagAttackerShortfall.t.sol. it deploys a pool through the in-repo BaseConfidencePoolTest helper and the existing registry/agreement mocks, no fork. the test walks the SURVIVED -> sweep -> good-faith CORRUPTED sequence and asserts the named whitehat receives only S while the whole bonus B sits at recoveryAddress.
run:
output:
the test shows the named whitehat receiving 100_000e18 (principal only) instead of the 140_000e18 a good-faith CORRUPTED resolution entitles them to, with the 40_000e18 bonus permanently held by recoveryAddress. the shortfall is exactly the swept bonus B.
treat the bonus sweep as a finality event on the branch where it moves genuine bonus rather than dust. the 1-wei-donation anti-grief concern the current comment cites only applies to the reserved case, where the swept amount is pure excess. in the riskWindowStart == 0 || totalEligibleStake == 0 branch the swept amount is the real bonus and must freeze the outcome:
alternatively, gate flagOutcome's re-flag on a dedicated bonusSwept latch set whenever a non-dust bonus sweep occurs, so a correction cannot re-snapshot bountyEntitlement / corruptedReserve after the bonus has already been moved out.
both options bring the bonus sweep in line with the one-way finality the design already relies on: the riskWindowStart != 0 latch that keeps a benign upstream rewind from re-opening withdraw (DESIGN.md#L289-L290), the expiryLocked latch on expiry, and claimsStarted on every claim path. value that has left the pool should not be re-openable by a later re-flag, which is the invariant the design already upholds for every other latched position.
Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.