Summary
The defect: sweepUnclaimedBonus() is a permissionless value-moving exit that omits the claimsStarted finality latch which every claim entrypoint sets on the first value movement.
The invariant: settlement must be monotonic — once value has irreversibly left the pool, the settlement inputs must be immutable (DESIGN §4: "once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting").
The impact: a later documented moderator re-flag re-derives settlement from the now-depleted state, so a good-faith whitehat is mis-settled and the bonus is stranded at recoveryAddress. Medium — this is a settlement-correctness flaw, not a solvency issue; principal is never at risk.
The irreversible, invariant-breaking step is an unprivileged call to sweepUnclaimedBonus(). Settlement in this contract is designed to be monotonic:
docs/DESIGN.md §4 states this promise verbatim:
"Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting."
and the natspec on flagOutcome names claimsStarted "a value-movement finality latch." The invariant is therefore:
After any irreversible value transfer out of the pool, the settlement inputs (
snapshotTotal*, and thecorruptedReserve/bountyEntitlementderived from them) must become immutable.
The contract violates its own monotonicity — the observed transition is:
Stated formally:
sweepUnclaimedBonus() irreversibly transfers bonus out of the pool without setting claimsStarted, so a later flagOutcome re-flag re-snapshots a totalBonus that has already been depleted. This is a state-machine safety defect in the contract's own finality model — not a documentation mismatch.
Scope of impact (stated up front, for accuracy): the protocol never becomes insolvent, no principal is ever at risk (principal is unconditionally reserved in the sweep), and the unprivileged caller gains nothing. The loss is bounded to the bonus pool, and it lands on a specific party — the good-faith whitehat, for whom the contract then derives an incorrect entitlement from the rewritten settlement inputs. This report is about settlement correctness, not solvency: the protocol remains solvent throughout; the defect is that a documented correction workflow derives settlement from state that has already been irreversibly modified.
Normal behavior: When the moderator resolves a pool via flagOutcome, the contract freezes the distribution by copying the live accounting into snapshots (L356-L362). The moderator is permitted to re-flag a pool before any value moves, so a mis-recorded outcome or attacker address can be corrected; that window is closed by the claimsStarted latch, which is set by every claim entrypoint the instant it moves value (L402, L422, L448, L467, L549, L575, L600). A good-faith CORRUPTED outcome entitles a named whitehat (attacker) to a bounty equal to the snapshotted staked + bonus total.
Specific problem: sweepUnclaimedBonus() is a value-moving exit that is callable by anyone once outcome is SURVIVED or EXPIRED. When riskWindowStart == 0 (the risk window elapsed with no pool interaction, so no staker is owed any bonus), it transfers the entire snapshotTotalBonus to recoveryAddress and decrements the live totalBonus (L499-L501) — but deliberately does not set claimsStarted (L503-L505). Because the re-flag gate is only outcome != UNRESOLVED && claimsStarted (L327), the moderator's documented, intended correction to good-faith CORRUPTED is still allowed after the sweep. That re-flag executes snapshotTotalBonus = totalBonus on the now-depleted value (L358) and computes bountyEntitlement from it (L362). The whitehat's entitlement is therefore reduced by the swept amount, and the bonus is stranded at recoveryAddress.
On roles — this is a role-observable, permissionlessly-exploitable state-machine flaw, not role misuse. The unprivileged caller is not exploiting moderator behavior; it is exploiting a documented protocol state:
The protocol explicitly allows SURVIVED to be recorded for a CORRUPTED registry state when the breach is initially believed to be outside the pool's committed scope (SURVIVED branch at L338), and the re-flag mechanism exists precisely to correct that assessment — so neither moderator record is a mistake. The moderator merely exposes the intermediate state; the defect is that the state leaves a permissionless value-moving function callable while settlement is still mutable. The trusted role does not create the inconsistency — the missing finality latch on sweepUnclaimedBonus does.
Sequence:
Likelihood: Medium
The vulnerable sequence is composed entirely of intended protocol operations. It does not rely on violating a trust assumption, manipulating an external dependency, defeating an access control, or exploiting exceptional timing — so its likelihood is governed by how normal each step is, not by attacker difficulty:
riskWindowStart == 0 (no pool interaction observed an active-risk registry state) is a legitimate, documented protocol state (DESIGN §5), not an edge case.
The SURVIVED → good-faith CORRUPTED correction is explicitly designed into the protocol — the pre-claim re-flag window exists precisely for it (DESIGN §4). Both moderator records are individually legal (a CORRUPTED registry is a valid SURVIVED input, L338).
sweepUnclaimedBonus() is permissionless and deterministic — once the correction window is open, any unprivileged account can trigger the state transition with no race complexity, flash loan, or privileged access.
The only non-routine aspect is that all three coincide in one lifecycle; none is individually rare or hard.
Impact: Medium
Framed precisely (not as generic "loss of funds"): this is a logical specification inconsistency with a bounded, real consequence.
No insolvency and no principal at risk — principal is unconditionally reserved in the sweep. The exposure is confined to the bonus pool.
Within that bound, the harm is concrete and permanent. Using the sponsor's own test numbers (test/unit/SweepUnclaimedBonus.t.sol:245, reproduced as PoC test D): a good-faith whitehat entitled under DESIGN §12 to the entire 150e18 pool that existed at resolution is paid only 100e18 — a 33% shortfall, with the remaining 50e18 stranded at recoveryAddress. In the no-staker variant (PoC test B) the effect is total: the contract computes bountyEntitlement = 0, so claimAttackerBounty() yields 0. In both cases the permissionless sweep permanently alters the settlement inputs from which the entitlement is derived; no on-chain path restores it (the only lever, a further re-flag, re-derives from the same depleted snapshot).
The destination also contradicts the spec: the bonus rests at recoveryAddress under a good-faith CORRUPTED outcome, which DESIGN §10 (recovery gets "only excess/dust") and §12 ("the entire pool is the named attacker's bounty") say should route to the whitehat.
Setup (single command, no external config): save the complete file below as test/invariant/SnapshotRefreezePoC.t.sol in a clean clone at commit 58e8ba4, then:
It is a pure local unit test — no fork, no RPC, no mainnet interaction. It extends the contest-provided harness test/helpers/BaseConfidencePoolTest.sol, which supplies the fixtures and helpers used below (no additional setup on your part):
Fixtures: pool (a freshly-initialized ConfidencePool clone), token (a standard MockERC20), attackRegistry (a MockAttackRegistry whose setAgreementState(...) stages the registry enum the pool observes), ONE == 1e18, and the addresses moderator / alice / bob / attacker / recovery.
Helpers: _stake(user, amount) (mint + approve + stake), _contributeBonus(user, amount) (mint + approve + contributeBonus), and _passThroughUnderAttack() (sets the registry to UNDER_ATTACK and calls pokeRiskWindow(), i.e. opens the risk window so riskWindowStart != 0).
Complete PoC file:
Result:
Test A proves the raw mechanic (the snapshot is rewritten after the sweep). Test B proves the harm in the no-staker case (whitehat entitlement drops to 0, bonus stranded at recovery). Test C is the control: the same sequence with an observed risk window keeps the bonus reserved, the sweep reverts, and the snapshot stays stable — isolating the defect to the riskWindowStart == 0 no-reserve path. Test D reproduces the sponsor's own test scenario (a real whitehat with co-stakers): the pool holds 150e18 at resolution, yet the whitehat is paid only 100e18 — a 33% shortfall, with 50e18 stranded at recoveryAddress (see the next section).
The contest test suite already contains a test that runs exactly this sequence and treats the shorted outcome as expected: test/unit/SweepUnclaimedBonus.t.sol:245, testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement:
This is the sponsor's own on-chain demonstration of the finding, and it strengthens the report on three independent points:
The state is anticipated and realistic, not a contrived construction. The developers wrote a first-class unit test for the SURVIVED → sweep → good-faith CORRUPTED re-flag path. This forecloses any "artificial multi-transaction edge case / unreachable" objection: the exact lifecycle is part of the project's own test corpus.
The test asserts only the solvency direction and never the specification direction. Its name (DoesNotOverstateEntitlement) and its assertions verify that the entitlement is not over-stated — i.e., the contract stays solvent and nothing bricks (bountyEntitlement == 100, matching the live balance). It never asserts what DESIGN.md §4/§12 actually promise: that a good-faith whitehat receives the entire pool that existed at resolution (150 = 100 stake + 50 bonus). The 50 bonus is silently routed to recoveryAddress, and the test blesses that as correct. The test encodes the specification violation as the expected result — it optimizes for "internally consistent on the drained snapshot" and never checks "matches the written settlement guarantee."
"The behavior is intended" is not a defense that it is correct**.** The test proves the developers consciously accepted a reduced whitehat entitlement after a sweep. But DESIGN.md §4 guarantees the opposite — "once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting." Here the re-flag is honored after value left, and balance accounting is kept consistent precisely by shrinking what the whitehat is owed. A passing test that asserts the shorted value is expected does not close the gap between the code and its own specification; it documents that the gap was not noticed.
Net: the disagreement between this report and testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement is not about the arithmetic (which is self-consistent) — it is about whether that arithmetic satisfies the protocol's written finality (§4) and payout (§10/§12) guarantees. It does not. Using the sponsor's own numbers, a good-faith whitehat entitled to a 150e18 pool is paid 100e18 and the remaining 50e18 rests permanently at recoveryAddress.
Each of these is grounded in the in-scope source and docs/DESIGN.md at commit 58e8ba4.
1. "This requires the trusted moderator to act, so it is access-control / informational."
The caller does not exploit moderator behavior — it exploits a documented protocol state (outcome recorded → correction still permitted → settlement not finalized) in which a permissionless value-moving function remains callable. Both moderator records are individually legal and documented: flagging SURVIVED on a registry-CORRUPTED agreement is a first-class outcome (L338; DESIGN §5) and the later good-faith CORRUPTED correction is the documented purpose of the re-flag window (DESIGN §4). The moderator never deviates from intended behavior; the irreversible, invariant-breaking step is the unprivileged sweepUnclaimedBonus() call. DESIGN §4 defends the re-flag design by asserting "Finality is correctly tied to value movement" — this finding shows finality is not tied to the sweep's value movement, so §4's own justification does not hold. And if downgraded toward Informational on the "admin-initiated" ground: the pre-claim correction window is a mandatory, documented protocol feature, not an optional path — a permissionless action that corrupts a mandatory feature and disrupts the pool's incentive structure is a functional vulnerability, not an informational note.
2. "This is just the accepted 'no-risk-window CORRUPTED race' in DESIGN §5."
It is not — the two are mechanistically opposite. The §5 race forecloses the correction cleanly: a permissionless claimExpired resolves EXPIRED and latches claimsStarted (L575/L600), after which a re-flag reverts OutcomeAlreadySet and the moderator is properly locked out of an already-final, self-consistent outcome. That is why §5 accepts it. This finding is the inverse: sweepUnclaimedBonus moves value without latching, so the moderator's CORRUPTED re-flag succeeds and operates on a silently drained snapshot — producing exactly the "corrective re-flag [that] cannot be honored without breaking balance accounting" that DESIGN §4 declares impossible. §5 concerns principal resolution (CORRUPTED-whole-pool vs EXPIRED-refund) and never discusses a sweep preceding a re-flag or the resulting bonus-snapshot mutation.
3. "Funds go to the trusted recoveryAddress, so nothing is lost — recoverable off-chain, at most Low."
The decisive point is not where the funds land — it is that the contract itself computes bountyEntitlement = 0. The on-chain accounting concludes the good-faith whitehat is owed nothing; the state is internally "correct" and self-consistent on a drained snapshot, so nothing on-chain flags it as an error to be undone. On top of that, the destination itself contradicts the spec: DESIGN §10 states recoveryAddress receives "only excess/dust under SURVIVED/EXPIRED/good-faith," and DESIGN §12 states "the entire pool is the named attacker's bounty" — the bonus resting at recovery under a good-faith CORRUPTED outcome violates both. There is no on-chain path to restore the entitlement (the only lever, a further re-flag, re-derives from the same drained snapshot). Depending on off-chain goodwill to correct an on-chain accounting break is precisely the class of defect an audit exists to surface.
4. "The moderator should have flagged CORRUPTED first, so the sweep never becomes reachable."
That presumes the moderator's off-chain in-scope judgement is final at the first flag. The re-flag window exists because it is not (DESIGN §4: "fix a typo'd outcome/attacker"). SURVIVED-first is the correct record when the initial off-chain determination is that the breach was out-of-committed-scope; the correction models revised evidence that it was in-scope. Requiring the moderator to be omniscient at the first flag negates the documented correction workflow the contract is built to support.
5. "Someone could claim first to latch finality and self-mitigate."
No entrypoint can set claimsStarted in this state. While outcome == SURVIVED, claimSurvived reverts unless the caller has non-zero eligible stake (L387) — in the riskWindowStart == 0 no-staker scenario there is none — and claimExpired reverts InvalidOutcome on a SURVIVED outcome (L514). The eventual whitehat has no claim path either (claimAttackerBounty requires CORRUPTED, L433). The sweep window is therefore open by construction, not a race the victim can win.
6. "Is the loss material or dust?"
The entire snapshotTotalBonus is misdirected — 1_000e18 in the PoC, and it scales with the bonus pool with no upper bound. Not dust.
7. "This is an artificial multi-transaction construction."
The sequence is natural: resolve → a permissionless post-resolution cleanup sweep (callable by anyone, including automation/keepers) → revise the outcome. The re-flag gate exists specifically to permit post-resolution revision; the sweep is a normal, unprivileged operation on a resolved pool. Decisively: the contest's own test suite already exercises this exact path — test/unit/SweepUnclaimedBonus.t.sol:245 — so it is a first-class anticipated lifecycle, not an invented construction.
8. "The sponsor's own test asserts this outcome is correct, so it is intended behavior — not a bug."
That test (testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement) verifies only that the entitlement is not over-stated (solvency: nothing bricks, bountyEntitlement == 100 matches the live balance). It never asserts the specification guarantee — that a good-faith whitehat receives the entire 150e18 pool present at resolution (DESIGN §12), nor that finality is tied to value movement (DESIGN §4). The test encodes the shorted 100e18 payout as expected, which is exactly the specification violation this report identifies. "A passing test asserts the value" is not evidence the value is correct — it is evidence the code-vs-spec gap went unnoticed. The arithmetic is self-consistent; it simply does not satisfy the protocol's written finality and payout guarantees. See the dedicated section "The sponsor's own test suite corroborates this finding" above.
| Location | Guard that should exist | Present? |
|---|---|---|
sweepUnclaimedBonus() value transfer (L500-L506) |
Latch finality when tracked bonus actually leaves (a dust-donation sweep need not) | No — latch omitted for all sweeps |
flagOutcome re-flag gate (L327) |
Detect that bonus already left (e.g. snapshotTotalBonus/balance consistency) before re-snapshotting |
No — trusts claimsStarted alone |
sweepUnclaimedBonus() entry (L474) |
Block while a corrective re-flag is still possible (e.g. before the corrupted-claim deadline) | No — callable immediately at SURVIVED/EXPIRED |
The root cause is that a value-moving exit does not participate in the finality latch that the re-flag gate depends on:
The existing claimsStarted omission is deliberate and its rationale is legitimate: the comment guards against a post-resolution 1-wei donation (a direct transfer, never counted in totalBonus) letting anyone latch finality and block the moderator's re-flag window. A naive claimsStarted = true on every sweep would reintroduce exactly that grief, so the fix must distinguish a real bonus exit from a dust-donation sweep.
Preferred fix — latch finality only when real, tracked bonus actually leaves. A donation sweep leaves totalBonus unchanged (the min clamps the decrement to 0), so it still will not latch; a genuine bonus removal does:
Alternative A — defer the bonus sweep past the correction window. Gate sweepUnclaimedBonus on the corrupted-claim deadline (mirroring sweepUnclaimedCorrupted), so no legal re-flag can ever follow a sweep. Keeps the sweep latch-free but removes the overlap between "value can leave" and "outcome can still change."
Alternative B — re-derive from live balance on re-flag. In flagOutcome, compute corruptedReserve / bountyEntitlement from the pool's current balance (or min(snapshot, balance)) rather than a snapshot that may predate a sweep, so a prior bonus exit cannot inflate an entitlement the pool can no longer honor.
Any of the three restores the invariant that settlement inputs are immutable once value has irreversibly left the pool, while respecting the developers' donation-grief protection.
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.