FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

Permissionless bonus sweep breaks settlement finality: a documented re-flag re-derives the good-faith whitehat's bounty from already-swept state

Author Revealed upon completion

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.


Broken Invariant (read this first)

The irreversible, invariant-breaking step is an unprivileged call to sweepUnclaimedBonus(). Settlement in this contract is designed to be monotonic:

UNRESOLVED ──flagOutcome──▶ RESOLVED ──value moves──▶ FINAL (settlement inputs immutable)

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 the corruptedReserve / bountyEntitlement derived from them) must become immutable.

The contract violates its own monotonicity — the observed transition is:

RESOLVED ──value moves (sweep, no latch)──▶ RESOLVED AGAIN (settlement inputs re-derived from depleted state)

Stated formally:

Invariant (settlement monotonicity):
value has irreversibly left the pool ⇒ settlement inputs are immutable
Violation:
totalBonus(after sweep) < snapshotTotalBonus(at first resolution) // value left
yet a later re-flag executes snapshotTotalBonus := totalBonus (depleted) // inputs mutated
⇒ bountyEntitlement is re-derived from state that no longer exists in the pool

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.


Description

  • 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:

outcome recorded ──▶ correction still permitted ──▶ settlement NOT finalized

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:

moderator: flagOutcome(SURVIVED) ──▶ snapshotTotalBonus = 1000e18 (frozen)
claimsStarted = false
anyone: sweepUnclaimedBonus() ──▶ totalBonus = 0 (bonus → recoveryAddress)
claimsStarted = false ◀── no finality latch
moderator: flagOutcome(CORRUPTED,gf) ──▶ snapshotTotalBonus = 0 (re-read of depleted totalBonus)
bountyEntitlement = 0 ◀── whitehat owed nothing
whitehat: claimAttackerBounty() ──▶ reverts / pays 0 (bonus stranded at recovery)

Risk

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.


Proof of Concept

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:

git clone https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools.git
cd 2026-07-bc-confidence-pools
forge install && forge build
# save the file below to test/invariant/SnapshotRefreezePoC.t.sol
forge test --match-contract SnapshotRefreezePoC -vvv

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:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
/// @notice Confirms the settlement-monotonicity break: sweepUnclaimedBonus moves value out WITHOUT
/// setting the claimsStarted finality latch, so a subsequent moderator re-flag re-snapshots a
/// now-depleted totalBonus — contradicting DESIGN.md §4 ("once value has left the contract a
/// corrective re-flag cannot be honored without breaking balance accounting").
contract SnapshotRefreezePoC is BaseConfidencePoolTest {
// --- A) the raw mechanic: SURVIVED -> sweep -> re-flag SURVIVED re-snapshots bonus to 0 ---
function test_snapshot_bonus_not_frozen_after_sweep() public {
_contributeBonus(alice, 1_000 * ONE);
// registry jumps straight to a terminal state (no observed risk window => riskWindowStart==0)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), 1_000 * ONE, "frozen bonus should be 1000e18");
assertEq(pool.claimsStarted(), false, "no claim yet");
// anyone sweeps the (unowed, riskWindowStart==0) bonus to recovery
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0, "live bonus swept out");
assertEq(pool.claimsStarted(), false, "sweep did NOT latch finality"); // <-- the crux
// moderator re-flags (still permitted): snapshot is silently rewritten
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), 0, "SNAPSHOT WAS NOT FROZEN: rewritten to 0");
}
// --- B) the harmful instantiation: whitehat bounty shorted the swept bonus ---
// Moderator first records SURVIVED (believes breach out-of-committed-scope), an unprivileged
// caller sweeps the bonus to recovery, then the moderator corrects to good-faith CORRUPTED
// naming a whitehat. The whitehat's entitlement is re-snapshotted WITHOUT the swept bonus.
function test_whitehat_bounty_shorted_by_presweep() public {
_contributeBonus(alice, 1_000 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // in-scope breach, riskWindowStart==0
uint256 recoveryBefore = token.balanceOf(recovery);
// 1) moderator records SURVIVED (a CORRUPTED registry is a legal SURVIVED input, L338)
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 2) unprivileged caller sweeps the bonus to recovery (riskWindowStart==0 => unreserved)
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 1_000 * ONE, "bonus parked at recovery");
// 3) moderator corrects to good-faith CORRUPTED naming the whitehat
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// whitehat's entitlement excludes the already-swept bonus
assertEq(pool.bountyEntitlement(), 0, "entitlement re-snapshotted WITHOUT the 1000e18 bonus");
// whitehat claims: gets nothing (pool balance already 0), bonus stuck at recovery
vm.prank(attacker);
vm.expectRevert(); // no balance -> bounty path yields 0
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 0, "whitehat received nothing; bonus misdirected to recovery");
}
// --- C) control: with an OBSERVED risk window + stakers, the bonus is RESERVED, the sweep
// reverts, and the snapshot is stable across a re-flag -> isolates the defect to the
// riskWindowStart == 0 no-reserve path. ---
function test_control_observed_window_bonus_reserved() public {
_stake(alice, 100 * ONE);
_contributeBonus(bob, 1_000 * ONE);
_passThroughUnderAttack(); // riskWindowStart != 0
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 snapBonus = pool.snapshotTotalBonus();
assertEq(snapBonus, 1_000 * ONE, "bonus frozen");
// sweep can only take donations; bonus is reserved for the staker
vm.expectRevert(); // NothingToSweep (freeBalance == reserved)
pool.sweepUnclaimedBonus();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.snapshotTotalBonus(), snapBonus, "snapshot stable when bonus is reserved");
}
// --- D) sponsor-scenario mirror (with co-stakers): the named whitehat is shorted a THIRD of the
// pool. Reproduces test/unit/SweepUnclaimedBonus.t.sol:245
// (testReflagToCorruptedAfterBonusSweepDoesNotOverstateEntitlement) and reads its silently-accepted
// result as the harm. At the first (correct) resolution the pool held 150e18 (100 stake + 50 bonus);
// per DESIGN §12 a good-faith whitehat is entitled to "the entire pool", yet a pre-emptive
// unprivileged sweep removes the 50 bonus to recovery and the whitehat is paid only 100e18.
function test_whitehat_shorted_a_third_with_costakers() public {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// in-scope breach, but no active-risk ever observed => riskWindowStart == 0
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// pool value at the first (correct) resolution moment = 100 stake + 50 bonus = 150e18
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 poolValueAtResolution = token.balanceOf(address(pool));
assertEq(poolValueAtResolution, 150 * ONE, "pool held 150e18 at first resolution");
// unprivileged pre-emptive sweep: 50 bonus leaves to recovery, no finality latch set
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "bonus (a third) parked at recovery");
assertEq(pool.claimsStarted(), false, "sweep did NOT latch finality");
// documented correction to good-faith CORRUPTED naming the whitehat
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// DESIGN §12: "the entire pool is the named attacker's bounty" -- but entitlement is 100, not 150
assertEq(pool.bountyEntitlement(), 100 * ONE, "whitehat entitlement re-derived WITHOUT swept bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
// the whitehat receives 100e18 of the 150e18 pool that existed at resolution: a 33% shortfall,
// the 50e18 remainder permanently at recovery under a good-faith CORRUPTED outcome (§10/§12 violated)
assertEq(token.balanceOf(attacker), 100 * ONE, "whitehat paid only 2/3 of the resolution-time pool");
uint256 shortfall = poolValueAtResolution - token.balanceOf(attacker);
assertEq(shortfall, 50 * ONE, "whitehat shorted 50e18 (a third), stranded at recovery");
}
}

Result:

Ran 4 tests for test/invariant/SnapshotRefreezePoC.t.sol:SnapshotRefreezePoC
[PASS] test_control_observed_window_bonus_reserved() (gas: 518670)
[PASS] test_snapshot_bonus_not_frozen_after_sweep() (gas: 288828)
[PASS] test_whitehat_bounty_shorted_by_presweep() (gas: 311396)
[PASS] test_whitehat_shorted_a_third_with_costakers() (gas: 588944)
Suite result: ok. 4 passed; 0 failed; 0 skipped

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 sponsor's own test suite corroborates this finding

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:

_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// Registry CORRUPTED with no active-risk ever observed → riskWindowStart == 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Sweep the unreserved bonus (50) to recovery; principal (100) stays reserved.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), 100 * ONE, "only principal remains");
// Moderator changes their mind: re-flag good-faith CORRUPTED naming an attacker.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Entitlement must be 100 (stake only) — the 50 bonus is gone and must not be counted.
assertEq(pool.bountyEntitlement(), 100 * ONE, "entitlement excludes swept bonus");
assertEq(pool.snapshotTotalBonus(), 0, "bonus dropped from snapshot after sweep");

This is the sponsor's own on-chain demonstration of the finding, and it strengthens the report on three independent points:

  1. 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.

  2. 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."

  3. "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.


Anticipated Objections & Rebuttals

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.

Guard-absence table

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

Recommended Mitigation

The root cause is that a value-moving exit does not participate in the finality latch that the re-flag gate depends on:

// src/ConfidencePool.sol : sweepUnclaimedBonus()
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // value irreversibly removed
}
// Intentionally does NOT set claimsStarted. ...
@> stakeToken.safeTransfer(recoveryAddress, amount); // ... yet finality is not latched
// src/ConfidencePool.sol : flagOutcome() — the re-flag gate that trusts the (unset) latch
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

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:

function sweepUnclaimedBonus() external nonReentrant {
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 bonusRemoved = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= bonusRemoved;
+ // A real bonus balance (tracked in `totalBonus`) is leaving the pool — this is the
+ // value movement the finality latch must respect, so a later corrective re-flag cannot
+ // re-snapshot an already-depleted `totalBonus`. A pure donation sweep (bonusRemoved == 0,
+ // never counted in `totalBonus`) still does NOT latch, preserving the intended protection
+ // against a 1-wei donation blocking the moderator's re-flag window.
+ if (bonusRemoved != 0 && !claimsStarted) claimsStarted = true;
}
- // Intentionally does NOT set claimsStarted. ...
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

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.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!