sweepUnclaimedBonus() can transfer protocol-accounted bonus to recoveryAddress without setting claimsStarted. The moderator can then use the documented pre-claim correction window to reflag the same pool from SURVIVED to good-faith CORRUPTED, but the corrected snapshot is taken after totalBonus has already been reduced. The latest outcome says the named attacker is entitled to the good-faith CORRUPTED bounty, yet the bonus portion of that bounty is already held by recoveryAddress.
The issue is not a disagreement with the no-risk-window rule. In a final SURVIVED or EXPIRED outcome with riskWindowStart == 0, stakers are owed principal only and the bonus is sweepable. The break appears because the sweep is allowed while the correction window is still open. This path moves accounted bonus but leaves claimsStarted == false, so later accounting acts as if no final value movement occurred.
Affected function: ConfidencePool.sweepUnclaimedBonus().
Relevant behavior:
flagOutcome(SURVIVED) is allowed while the registry is CORRUPTED when the moderator judges the breach out of scope.
Before any claim, flagOutcome() intentionally permits a correction to good-faith CORRUPTED.
If no active-risk window was observed, _bonusShare() returns zero for SURVIVED/EXPIRED stakers, so sweepUnclaimedBonus() treats the tracked bonus as unreserved.
sweepUnclaimedBonus() transfers that tracked bonus to recoveryAddress, decrements totalBonus, and intentionally does not set claimsStarted.
A later good-faith CORRUPTED reflag snapshots snapshotTotalBonus = totalBonus, which is now zero.
claimAttackerBounty() pays only the remaining principal, even though a direct good-faith CORRUPTED flag over the same original pool pays principal plus bonus.
The sweeper does not need privileged access. The only privileged action is the moderator's documented pre-claim correction reflag, which is part of the intended protocol flow. The exploitable action is that any caller can move accounted bonus to recoveryAddress before that correction finalizes.
The corrected good-faith attacker bounty can be reduced by the full bonus balance. With stake plus bonus, the attacker receives only principal and recoveryAddress keeps the bonus. With a bonus-only pool, the entire good-faith bounty can be swept before correction and the named attacker has no remaining entitlement. A value-moving sweep of accounted protocol bonus must either finalize the current outcome or be impossible while the moderator correction window remains open. Otherwise, the pool can move funds according to one outcome while still allowing accounting to be resnapshotted under a different outcome.
The PoC uses 100e18 stake and 50e18 bonus. Direct good-faith CORRUPTED pays 150e18. The exploit sequence pays the attacker 100e18 and leaves 50e18 with recoveryAddress. The loss scales linearly with totalBonus.
The sequence is realistic during the protocol’s intended correction window. The moderator may initially flag SURVIVED for a registry-level CORRUPTED agreement when the breach appears out of scope, then correct to good-faith CORRUPTED after confirming the breach was in scope. A sponsor-controlled recovery address can watch the temporary SURVIVED flag and call sweepUnclaimedBonus() before the correction.
The path requires riskWindowStart == 0, which is also an intended and documented state: the registry can reach terminal CORRUPTED without the pool locally observing active risk, and moderator CORRUPTED remains allowed in that case.
Mainnet environment validation used BattleChain mainnet block 84. The exploit itself is deterministic against the local Foundry mocks.
PoC output:
Separate donation/dust sweeps from accounted-bonus sweeps while the reflag window is open. A pre-finality sweep may recover untracked donations, but it must not move tracked totalBonus unless the outcome has already been finalized by a claim or the sweep itself deliberately finalizes the outcome.
Minimal patch direction:
Blocking accounted-bonus sweeps before finality preserves the moderator correction window while still allowing donation-only sweeps.
Use the repository root:
Foundry is installed at:
The project .env contains:
The required hostname did not resolve from the validation machine. The exact required command fails before reaching JSON-RPC:
Expected observed output on the validation machine:
The reachable BattleChain mainnet endpoint verifies the expected chain id:
Expected output:
Run the PoC from the repository root while keeping the PoC under my-poc:
The PoC contains three tests. The exploit test performs the following sequence:
Alice stakes 100e18.
Carol contributes 50e18 bonus.
The registry mock is moved directly to CORRUPTED, leaving riskWindowStart == 0.
The moderator temporarily flags SURVIVED.
sweepUnclaimedBonus() is called before any claim.
The sweep transfers 50e18 to recoveryAddress, reduces totalBonus to zero, and leaves claimsStarted == false.
The moderator corrects the outcome to good-faith CORRUPTED and names attacker.
The corrected CORRUPTED snapshot has snapshotTotalBonus == 0.
attacker claims only 100e18.
The control path proves direct good-faith CORRUPTED would have paid 150e18.
Expected output:
The exploit is confirmed if all of the following are true:
pool.riskWindowStart() is zero when the temporary SURVIVED outcome is flagged.
The first snapshot records snapshotTotalBonus == 50e18.
sweepUnclaimedBonus() transfers 50e18 to recoveryAddress.
pool.claimsStarted() remains false immediately after that sweep.
A subsequent flagOutcome(CORRUPTED, true, attacker) succeeds.
The corrected snapshot records snapshotTotalBonus == 0.
pool.bountyEntitlement() is 100e18, not 150e18.
claimAttackerBounty() transfers only 100e18.
The direct-control path transfers 150e18 under good-faith CORRUPTED when the intermediate sweep is skipped.
The bonus-only test validates the maximum version of the same accounting break. With no stake and 50e18 bonus, a direct good-faith CORRUPTED outcome would make the bonus the entire bounty basis. The temporary SURVIVED sweep transfers that full 50e18 to recoveryAddress. The reflag then sets bountyEntitlement == 0, and claimAttackerBounty() reverts with BountyAlreadyClaimed.
Run the non-fork test suite:
Expected summary:
Run fork tests against the reachable mainnet endpoint:
Expected summary:
Apply a patch that prevents sweepUnclaimedBonus() from moving tracked totalBonus while claimsStarted == false, while still allowing donation-only sweeps. After the patch:
The exploit test should revert at sweepUnclaimedBonus() before any tracked bonus leaves.
The direct-good-faith control should keep passing and pay 150e18.
Existing donation-only sweep tests should keep passing.
Existing dust sweep tests after all stakers claim should keep passing.
Existing no-risk principal-claim behavior should keep passing.
The invariant restored by the patch is:
If the implementation instead chooses to set claimsStarted = true when tracked bonus is swept, the exploit test should fail at the later reflag with OutcomeAlreadySet. That also prevents the false corrected outcome, but it lets a sweep finalize a temporary SURVIVED outcome. Blocking tracked-bonus sweeps before finality gives cleaner correction semantics.
| Function | NOT_DEPLOYED | NEW_DEPLOYMENT | ATTACK_REQUESTED | UNDER_ATTACK | PROMOTION_REQUESTED | PRODUCTION | CORRUPTED |
|---|---|---|---|---|---|---|---|
stake |
Allowed before expiry/outcome/pause; locks expiryLocked |
Allowed before expiry/outcome/pause; locks expiryLocked |
Allowed; also locks scope through _observePoolState |
Allowed by design; opens riskWindowStart, disables later withdraw |
Reverts StakingClosed |
Reverts StakingClosed |
Reverts StakingClosed |
contributeBonus |
Allowed before expiry/outcome/pause | Allowed before expiry/outcome/pause | Allowed; locks scope | Allowed; opens riskWindowStart |
Reverts StakingClosed |
Reverts StakingClosed |
Reverts StakingClosed |
withdraw |
Allowed if riskWindowStart == 0 and stake exists |
Allowed if riskWindowStart == 0 and stake exists |
Allowed if riskWindowStart == 0; locks scope |
Reverts WithdrawsDisabled and opens riskWindowStart |
Reverts WithdrawsDisabled and opens riskWindowStart |
Reverts WithdrawsDisabled; seals riskWindowEnd |
Reverts WithdrawsDisabled; seals riskWindowEnd |
flagOutcome(SURVIVED) |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Moderator-only; snapshots SURVIVED | Moderator-only; snapshots SURVIVED for out-of-scope corruption |
flagOutcome(CORRUPTED) |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Reverts InvalidOutcome |
Moderator-only; snapshots CORRUPTED |
claimSurvived |
Ignores live state; requires prior SURVIVED outcome | Same | Same | Same | Same | Same | Same |
claimExpired |
After expiry, unresolved pool resolves EXPIRED | After expiry, unresolved pool resolves EXPIRED | After expiry, unresolved pool resolves EXPIRED | After expiry, unresolved pool resolves EXPIRED and may open/clamp risk start | After expiry, unresolved pool resolves EXPIRED and may open/clamp risk start | After expiry, unresolved pool resolves SURVIVED and seals end | If riskWindowStart != 0, reverts during grace then auto-CORRUPTED after grace; if riskWindowStart == 0, resolves EXPIRED |
claimCorrupted |
Ignores live state; requires CORRUPTED outcome | Same | Same | Same | Same | Same | Same |
claimAttackerBounty |
Ignores live state; requires good-faith CORRUPTED, caller attacker, before deadline | Same | Same | Same | Same | Same | Same |
sweepUnclaimedBonus |
Ignores live state; requires SURVIVED or EXPIRED outcome | Same | Same | Same | Same | Same | Same |
sweepUnclaimedCorrupted |
Ignores live state; requires good-faith CORRUPTED after deadline | Same | Same | Same | Same | Same | Same |
pokeRiskWindow |
Reverts RiskWindowNotReached if unresolved |
Reverts RiskWindowNotReached if unresolved |
Reverts RiskWindowNotReached; revert undoes observed scope lock |
Opens riskWindowStart |
Opens riskWindowStart |
Seals riskWindowEnd |
Seals riskWindowEnd |
setPoolScope |
Owner-only; allowed | Owner-only; allowed | Reverts because _observePoolState would lock scope |
Reverts because _observePoolState would lock scope |
Reverts because _observePoolState would lock scope |
Reverts because _observePoolState would lock scope |
Reverts because _observePoolState would lock scope |
setExpiry |
Owner-only until first stake; live state ignored | Same | Same | Same | Same | Same | Same |
setRecoveryAddress |
Owner-only; live state ignored | Same | Same | Same | Same | Same | Same |
| Latch | Who flips it | Too early? | Can be prevented? | Stale/snapshot risk |
|---|---|---|---|---|
scopeLocked |
Any successful _observePoolState call seeing a state other than NOT_DEPLOYED or NEW_DEPLOYMENT |
Yes for stakers if sponsor expected scope changes in ATTACK_REQUESTED, but documented as post-staging lock | If all observing calls revert, no persistent lock; successful stake/bonus/withdraw in ATTACK_REQUESTED locks | Later flagOutcome assumes the published local scope is final |
expiryLocked |
First successful stake |
Not under current model; first stake creates reliance | Cannot be reset even if all stake withdraws | Protects expiry input to EXPIRED bonus formula |
riskWindowStart |
Any successful observation of UNDER_ATTACK or PROMOTION_REQUESTED while unresolved | Can be observed at/after expiry; capped to expiry and documented | Can be missed if no pool call occurs during active risk | Global sums reset eagerly; per-user sums clamp lazily on next user touch |
riskWindowEnd |
Any successful observation of PRODUCTION or CORRUPTED while unresolved | Can be observed late; capped to expiry | Can be missed until resolution | SURVIVED/CORRUPTED outcomeFlaggedAt uses this first observed terminal timestamp |
outcome |
Moderator flagOutcome or mechanical claimExpired |
Can be set by non-staker claimExpired; documented |
Reflag allowed only before claimsStarted |
Latest outcome must determine later value movement; this is where the confirmed bug breaks |
claimsStarted |
Successful claim paths and CORRUPTED sweeps; not sweepUnclaimedBonus |
Mechanical claimExpired sets it even for non-staker; documented |
Not set by bonus sweep by design | Accounted bonus can leave while reflag remains open, desynchronizing finality from value movement |
outcomeFlaggedAt |
Outcome resolution | Uses riskWindowEnd for SURVIVED/CORRUPTED and expiry for EXPIRED |
No after resolution | Bonus numerator/denominator rely on this being consistent with snapshots |
corruptedClaimDeadline |
First-ever good-faith CORRUPTED flag anchors it; later good-faith re-entry reuses it | Can be stale by design if moderator toggles away and back | Cannot be extended | Old deadline intentionally influences later good-faith re-entry |
_firstGoodFaithCorruptedAt |
First good-faith CORRUPTED flag | Same as deadline | Never reset | Prevents deadline refresh, but can make later corrected attacker inherit old window |
docs/DESIGN.md describes claimsStarted as the value-movement finality latch: once value leaves, a corrective reflag cannot be honored without breaking balance accounting. A value-moving sweep of accounted protocol bonus must either finalize the current outcome or be impossible while the moderator correction window remains open. sweepUnclaimedBonus() violates that rule for accounted bonus. In a no-risk SURVIVED/EXPIRED snapshot, the function can sweep the tracked bonus to recoveryAddress, decrement totalBonus, and leave claimsStarted == false. A subsequent good-faith CORRUPTED reflag snapshots only the remaining principal, so the latest outcome says the attacker bounty is active while the bonus portion has already been moved to recovery.
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.