A Pool can expire while its Agreement is still UNDER_ATTACK, and the moderator's entire protection window can burn down before there is anything to protect.
ConfidencePool holds the permissionless bad-faith CORRUPTED fallback back until expiry + MODERATOR_CORRUPTED_GRACE (180 days). That clock runs from Pool expiry, not from the moment CORRUPTED first exists, and it counts down whether or not the Agreement is corrupted. An Agreement can stay UNDER_ATTACK past Pool expiry and through the full 180 days. Throughout that period the Pool moderator cannot classify the Pool, because flagOutcome() rejects every CORRUPTED-dependent classification while the Agreement is non-terminal — the state that requires moderator judgment does not exist yet.
When markCorrupted() finally lands after the grace, the moderator's first opportunity to classify and the fallback's first opportunity to finalize arrive at the same timestamp. The moderator receives no protected post-CORRUPTED interval. Any caller can invoke claimExpired() at that moment: the fallback is scope-blind and cannot name a good-faith attacker, so it selects bad-faith CORRUPTED, snapshots the full 150e18 corpus into corruptedReserve, and sets claimsStarted = true. That flag closes moderator reclassification permanently, and claimCorrupted() then sends the complete balance to recoveryAddress.
The displaced decisions are the classifications only the moderator can make once CORRUPTED exists: SURVIVED for out-of-scope corruption, good-faith CORRUPTED with a named attacker, or bad-faith CORRUPTED. The selected branch controls the destination of the Pool's entire principal plus bonus.
Two paths coexist after Pool expiry, gated differently.
flagOutcome() is the moderator's path. While the Agreement is non-terminal, flagOutcome(SURVIVED, ...) and flagOutcome(CORRUPTED, ...) both revert with InvalidOutcome, and flagOutcome(EXPIRED, ...) is never valid. The moderator cannot pre-commit a classification for a CORRUPTED state that has not happened. These choices become available only once the Agreement is actually CORRUPTED.
claimExpired() is permissionless and available the whole time. Before the Agreement is corrupted it would resolve the Pool to EXPIRED against the live active-risk state. Its bad-faith CORRUPTED branch is gated only by the absolute threshold expiry + MODERATOR_CORRUPTED_GRACE.
Those two gates are keyed to different things, and that is the defect. The moderator's gate opens when CORRUPTED appears. The fallback's gate opens when a fixed amount of time has passed since expiry. If CORRUPTED first appears after the expiry-based threshold has already elapsed, both gates are open simultaneously. A permissionless claimExpired() can finalize bad-faith CORRUPTED at that same timestamp, and because it sets claimsStarted = true in the same call, the outcome is irreversible before the moderator has any protected interval in which to act.
The affected production contract is src/ConfidencePool.sol:
flagOutcome() — src/ConfidencePool.sol:322-350. Rejects SURVIVED and CORRUPTED while the Agreement is non-terminal, and rejects EXPIRED unconditionally.
flagOutcome() — src/ConfidencePool.sol:330-375. Once the Agreement is CORRUPTED, allows the moderator's scope- and good-faith-specific classifications.
claimExpired() — src/ConfidencePool.sol:512-554. Reads the current Agreement state and applies the absolute expiry + MODERATOR_CORRUPTED_GRACE threshold. The mechanical CORRUPTED branch at lines 529-554 is scope-blind, selects bad-faith CORRUPTED, and sets claimsStarted in the same call.
claimCorrupted() — src/ConfidencePool.sol:408-425. Transfers the Pool's complete live token balance to recoveryAddress.
src/interfaces/IConfidencePool.sol:154-160 documents the first-post-expiry mechanical branch selection and confirms that non-stakers may resolve.
The moderator grace is anchored to Pool expiry instead of to the first observable CORRUPTED state.
The Pool never records when CORRUPTED first became observable, so the only delay before the mechanical branch measures time since Pool expiry and nothing else. The complete grace can elapse while the Agreement is still UNDER_ATTACK. When CORRUPTED later appears, the moderator's first valid opportunity to classify it and the fallback's first valid opportunity to finalize bad-faith CORRUPTED occur at the same time, and no new clock starts.
The decisive branch is src/ConfidencePool.sol:532-554:
The check measures only elapsed time since expiry. It never measures how long the moderator has had a CORRUPTED state to classify.
The grace exists to give the moderator time to classify a corrupted Agreement before the scope-blind fallback takes over (src/ConfidencePool.sol:46-53; docs/DESIGN.md:131-147). That classification is impossible before CORRUPTED exists, because flagOutcome() rejects it while the Agreement is non-terminal (src/ConfidencePool.sol:322-350).
Because the clock is anchored to Pool expiry, its entire duration can run out before the classification question exists. When CORRUPTED finally appears, no new protected clock starts, and claimsStarted lets the fallback result become final in the same call. The moderator receives only the portion of the expiry-anchored grace that remains after CORRUPTED first appears. If CORRUPTED first appears after the deadline, no protected interval remains before the permissionless fallback becomes available.
The repair is to measure the moderator's protected interval from the first observation of CORRUPTED rather than from Pool expiry.
Once the Agreement is CORRUPTED, flagOutcome() offers the Pool moderator three narrowly gated classifications:
SURVIVED — with goodFaith == false and attacker == address(0). For a CORRUPTED Agreement this covers a breach that fell outside the Pool's fixed scope, judged against the published account list (src/ConfidencePool.sol:330-340; docs/DESIGN.md:222-227). Stakers recover stake plus bonus.
Good-faith CORRUPTED — with goodFaith == true and a nonzero attacker named. The named attacker receives the full-corpus bounty, subject to the claim window (src/ConfidencePool.sol:341-375; docs/DESIGN.md:294-307).
Bad-faith CORRUPTED — with goodFaith == false and attacker == address(0). The full live Pool balance sweeps to recoveryAddress through claimCorrupted() (src/ConfidencePool.sol:341-375; src/ConfidencePool.sol:408-425).
The permissionless fallback cannot evaluate Pool-local scope or name a good-faith attacker, so it mechanically selects bad-faith CORRUPTED. Once claimsStarted is set, the other two classifications are gone for good.
Let E be Pool expiry, 90 days after creation, and G be MODERATOR_CORRUPTED_GRACE = 180 days.
One real Agreement backs one Pool. Its commitment covers E, and neither Agreement scope nor Pool scope changes.
A staker deposits 100e18 and the Pool receives a 50e18 bonus, for a 150e18 corpus.
Before E, the Agreement enters UNDER_ATTACK and the Pool observes active risk, setting a nonzero riskWindowStart and locking scope.
At E, the Agreement is still UNDER_ATTACK and the Pool is still UNRESOLVED. From E through E + G it stays that way: claimExpired() is permissionlessly available and would resolve the Pool to EXPIRED against the active-risk state, but no caller invokes it.
Throughout that same period, the moderator's flagOutcome() attempts for SURVIVED and CORRUPTED revert with InvalidOutcome, because no CORRUPTED state exists to classify.
After E + G, the Agreement's attack moderator calls markCorrupted(). CORRUPTED exists for the first time — and the expiry-based grace is already spent.
At the same timestamp, without advancing time, a permissionless non-staker calls claimExpired(). The threshold is already satisfied, so the Pool locks bad-faith CORRUPTED, snapshots the full corpus into corruptedReserve, and sets claimsStarted = true.
The moderator's later flagOutcome() reverts with OutcomeAlreadySet, and claimCorrupted() sweeps all 150e18 to recoveryAddress.
Steps 6 and 7 are the finding. CORRUPTED first appears, no time passes, a non-staker resolves, and the outcome is final at the same instant the moderator's classification first became possible.
The moderator is not barred from submitting a transaction at that point — the issue is that no interval is reserved for it. Both paths open together, so transaction ordering decides which one lands, and the fallback's result is immediately irreversible.
The control uses the same setup and reverses the order of the two decisive calls: claimExpired() runs immediately before the late markCorrupted(). The live Agreement state is still UNDER_ATTACK, so the same unresolved Pool selects EXPIRED and the same 150e18 corpus returns to the staker.
The live state observed at the resolution call selects the full-value branch and its beneficiary.
The auto-CORRUPTED path is a backstop for a permanently unavailable moderator. It is scope-blind and cannot separate in-scope corruption from out-of-scope corruption (docs/DESIGN.md:131-147; protocol-readme.md:53-57).
The Pool's scope is a fixed local commitment, and the moderator's off-chain judgment is the source of truth for whether Agreement-level corruption touched that scope (docs/DESIGN.md:209-227).
Only the moderator can name a good-faith attacker; the mechanical branch always resolves bad-faith CORRUPTED (src/ConfidencePool.sol:341-375; src/ConfidencePool.sol:529-550).
The current delay before the mechanical branch is anchored to Pool expiry (src/ConfidencePool.sol:53; src/ConfidencePool.sol:540).
The documentation defines the moderator's job and the fallback's blindness, and it anchors the only delay to Pool expiry. Those three facts are what allow the fallback to impose a scope-blind branch at the same moment the moderator's judgment first becomes possible.
High. The fallback selects bad-faith CORRUPTED, snapshots the complete corpus into corruptedReserve, and sets claimsStarted, which makes the full live balance transferable to recoveryAddress (src/ConfidencePool.sol:543-554; src/ConfidencePool.sol:408-425). In the reproduced Pool:
The branch is selected before the moderator can determine the Pool-local classification, and claimsStarted makes that selection irreversible. SURVIVED — which would return the corpus to stakers when the corruption fell outside the Pool's fixed scope — and good-faith CORRUPTED — which would reserve the corpus as a named attacker's bounty — both become unavailable.
The PoC quantifies the difference. The late-CORRUPTED-first ordering routes all 150e18 to recovery; the control, under the opposite ordering, routes the same 150e18 to the staker. The lasting impact is the irreversible loss of the moderator's Pool-local classification authority over the Pool's entire balance.
Low. The issue requires the Pool to remain unresolved beyond the 180-day grace, followed by a late CORRUPTED transition and permissionless resolution before the moderator classifies the Pool.
Medium. The impact is high because the race can irreversibly determine the destination of the complete principal-and-bonus corpus. Likelihood is low because the Pool must remain unresolved beyond the 180-day grace before a late CORRUPTED transition. High impact with low likelihood supports Medium severity.
The PoC deploys the real vendored Agreement factory, AttackRegistry, and Safe Harbor registry, creates a real Agreement and Pool, extends the Agreement commitment through Pool expiry, and reaches UNDER_ATTACK before executing a late markCorrupted(). It shows:
the expiry-based grace elapses while the upstream state stays UNDER_ATTACK;
no moderator flagOutcome() classification for the later CORRUPTED state is available during that interval;
a non-staker finalizes bad-faith CORRUPTED at the same timestamp that CORRUPTED first appears;
claimsStarted locks the outcome immediately;
the full 150e18 corpus reaches recovery; and
reversing markCorrupted() and claimExpired() selects a different live-state branch and beneficiary for the same corpus.
The helper's two revert checks, for SURVIVED and CORRUPTED, concern flagOutcome() only. They confirm that the moderator could not apply the CORRUPTED-dependent classifications while the Agreement remained UNDER_ATTACK. They do not imply that the permissionless claimExpired() path was unavailable after expiry.
The local reproduction recorded both tests passing at contest commit 58e8ba4ce3f3277866e4926f3140e597f9554a1e with BattleChain submodule commit fde1b2abe9e5c27175f5b6f7324bcce6afc3b059. No RPC or fork was used. Upstream artifacts compile with Solidity 0.8.34, while the PoC and in-scope project compile with Solidity 0.8.26.
The canonical PoC lives at:
test/poc/late_corruption_grace/LateCorruptionPreemptsModerator.t.sol
Run the following from the contest repository root:
Both tests pass. This run recorded:
The asserted balance delta is:
Primary ordering:
Control:
The moderator-opportunity helper asserts that flagOutcome(SURVIVED) and flagOutcome(CORRUPTED) each revert with InvalidOutcome while the real Agreement remains UNDER_ATTACK. The Pool remains UNRESOLVED because no caller invokes the separately available claimExpired() path.
Record the first observed CORRUPTED in a dedicated timestamp, separate from the bonus-distribution risk-window markers. Any caller may record that observation, but the observation transaction must not also finalize bad-faith CORRUPTED.
Block mechanical finalization until both of the following have elapsed:
the existing expiry-based threshold, retained as a separate minimum; and
an additional bounded interval measured from the first CORRUPTED observation.
This gives the moderator a real window to apply the classifications the protocol already defines — SURVIVED for out-of-scope corruption, good-faith CORRUPTED with a named attacker, or bad-faith CORRUPTED — before the scope-blind fallback becomes available.
Do not reuse riskWindowEnd for this interval. It is capped at Pool expiry (src/ConfidencePool.sol:819-828), so a late first CORRUPTED observation would inherit an already-expired clock. A distinct marker keeps bonus-accrual timing separate from moderator-classification timing.
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.