Normal behavior: sweepUnclaimedBonus() is the cleanup function. protocol-readme.md:43 says what it recovers: "k=2 rounding dust, non-claimers' forfeited shares, post-resolution donations" — leftovers. It calculates what still belongs to people (reserved) and sends the excess to recoveryAddress.
The bug: it calculates reserved from an outcome that can still change. So it sends the entire bonus pot to recoveryAddress while the moderator is still allowed to correct the outcome. When they do correct it, the money is already gone.
The sequence:
The agreement is hacked, so the registry reads CORRUPTED. But nobody interacted with the pool while it was attackable, so riskWindowStart is still 0. (docs/DESIGN.md §5 documents this exact state - "The no-risk-window CORRUPTED race".)
The moderator judges the hack hit contracts outside this pool's cover and flags SURVIVED. Legal and intended — ConfidencePool.sol:359 accepts a CORRUPTED registry for SURVIVED (§8).
Anyone calls sweepUnclaimedBonus(). The whole bonus pot moves to recoveryAddress. claimsStarted is still false.
The moderator realises they were wrong — the hack was in scope — and re-flags to good-faith CORRUPTED, naming the whitehat. Accepted, because claimsStarted is false (ConfidencePool.sol:348).
The re-snapshot reads snapshotTotalBonus = 0. The whitehat is paid principal only. The bonus is at recoveryAddress forever.
Why flagging SURVIVED does NOT open the risk window (the step that looks like it should save this): flagOutcome calls _observePoolState() first (line 349), which reads the registry. It's natural to assume that observation sets riskWindowStart and puts the bonus back into reserved. It doesn't — the pool has two markers driven by two non-overlapping state lists:
riskWindowStart means "we saw this protocol while it was under attack". CORRUPTED doesn't say that — it says "it's over, and it got hacked". The contract's own comment agrees: "Terminal states do not count as risk observations." So flagging SURVIVED on a CORRUPTED registry seals riskWindowEnd and leaves riskWindowStart at 0. It can never be opened later either, because terminal states are sticky — the registry cannot go back to UNDER_ATTACK. The PoC asserts both directly after flagOutcome runs.
The docs say the opposite, and name the only thing that can block this. protocol-readme.md:45:
"the moderator may still flag it on their off-chain in-scope judgement (sweeping the pool whole, bonus included), provided no
claimExpiredcall has resolved the pool first."
The PoC never calls claimExpired — it meets the team's stated condition in full, and the bonus still vanishes. Their list of blockers is missing one: sweepUnclaimedBonus(). And "sweeping the pool whole, bonus included" is simply not what happens.
The code comment defending this is wrong. ConfidencePool.sol:558-560 explains that the sweep skips claimsStarted so nobody can donate 1 wei to grief the moderator's correction window. Fair for donations. But it ends with "Genuine reliance only comes from claim entrypoints" — false, because this sweep moves snapshotTotalBonus, real accounted money. docs/DESIGN.md §4 states the rule being broken: "Once value has left the contract, a corrective re-flag cannot be honored without breaking balance accounting."
Being upfront: the sweep's maths are correct at the moment it runs — under a finished SURVIVED with no risk window, the bonus does belong to recoveryAddress (§5). The defect is that it computes against an outcome that isn't finished. Nobody reserves for the whitehat, because at that moment the contract doesn't think one exists. The re-flag creates them afterwards, against a pot that's gone.
Likelihood: Low
Needs riskWindowStart == 0 on a CORRUPTED registry (a real state — docs/DESIGN.md §5 has a section on it), and the moderator to correct their own outcome (docs/DESIGN.md §4: the re-flag window exists "so the moderator can fix a typo'd outcome/attacker"). Both are documented as expected, but they must coincide.
The damaging step itself is free, permissionless, unprivileged, and irreversible — any address can call the sweep the moment the first flag lands.
Impact: Medium
The named whitehat loses the entire bonus pot — 100% of what docs/DESIGN.md §12 and protocol-readme.md:39 promise them ("the entire pool").
It lands at recoveryAddress — the sponsor's wallet — the exact opposite of where good-faith CORRUPTED sends funds. The sponsor keeps the reward meant for the person who found the hack.
No recovery path. Nothing can pull it back, and re-flagging just re-reads a balance that is now zero. Staker principal is unaffected.
Paste both functions into test/unit/ConfidencePool.t.sol (anywhere inside the existing contract ConfidencePoolTest block) and run:
No new imports or setup needed — that file already imports IConfidencePool, IAttackRegistry and PoolStates, and extends BaseConfidencePoolTest.
Both functions matter. The first proves the theft. The second is the control: it changes one line (_passThroughUnderAttack()) and the attack becomes impossible — the bonus lands in reserved and the sweep reverts. That proves riskWindowStart == 0 is the sole cause, and rules out "the sweep just always takes the bonus, working as designed". The sweep is meant to protect the bonus; this state slips past the guard.
Result:
The two runs differ by one line and nothing else:
PoC (riskWindowStart == 0) |
Control (window observed) | |
|---|---|---|
reserved |
1000e18 — bonus excluded |
1500e18 — bonus included |
| pool balance | 1500e18 |
1500e18 |
amount = balance - reserved |
500e18 → swept |
0 → reverts NothingToSweep |
recoveryAddress receives |
500e18 |
0 |
| whitehat receives | 1000e18 (short by the bonus) |
full pool |
While the outcome can still change (claimsStarted == false), hold the bonus back unconditionally. Donations and dust stay sweepable, so the "1 wei grief" concern in the existing comment is still handled — but accounted bonus stays in the pool until the outcome is final.
Trade-off, stated upfront: under a moderator-flagged SURVIVED where no staker ever claims, claimsStarted never flips, so the bonus stays reserved instead of sweeping. No funds are lost — it sits alongside the principal, and the first staker claiming unlocks it. If that delay is unacceptable, the alternative is to have sweepUnclaimedBonus set claimsStarted = true whenever the amount it moves includes accounted bonus (rather than only donations/dust) — keeping the sweep immediate while still making value-movement final, which is the rule docs/DESIGN.md §4 says should hold anyway.
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.