Normal behavior: withdraw() lets a staker exit while the protocol is not yet dangerous. The docs promise this as a fairness guarantee — docs/DESIGN.md §9: "a staker only forfeits the exit option once risk has actually materialized, which is exactly when they begin earning the risk premium. A sponsor cannot grief stakers by keeping the agreement out of attackable mode — stakers can freely exit until risk materializes." The deal is a fair trade: you lose the exit EXACTLY when you start earning the premium.
The bug: the exit can close without risk ever materializing. When it does, the staker is locked in and earns zero bonus — both halves of the trade fail at once.
Why: withdraw() allows the exit only in three registry states — NOT_DEPLOYED, NEW_DEPLOYMENT, ATTACK_REQUESTED. Everything else, including PRODUCTION, permanently disables it. The docs describe this as "disabled from UNDER_ATTACK onward" (§9), which assumes the registry always walks ... → UNDER_ATTACK → ... → PRODUCTION. It does not. The registry can reach PRODUCTION while completely skipping UNDER_ATTACK, and when it does riskWindowStart stays 0, so _bonusShare pays nothing.
The team already knows the registry can skip the active-risk states — protocol-readme.md:31: "If the registry transitions straight from NEW_DEPLOYMENT to a terminal state without anyone observing an active-risk state, riskWindowStart never seals." But they only ever connect that skip-path to the bonus ("no observed risk"). They never connect it to withdraw closing. §9 asserts the opposite. The two statements cannot both hold.
Two ways the registry reaches PRODUCTION without UNDER_ATTACK (both verified in AttackRegistry.sol, the real dependency):
Route A — the clock, no transaction, no event. AttackRegistry.sol:872:
_registerAgreement sets deadlineTimestamp = block.timestamp + PROMOTION_WINDOW (14 days) with attackApproved: false. The check sits after the attackApproved branch, so it fires only for a request the DAO never approved. The sponsor requests the attack phase → the DAO does not approve within 14 days → the state becomes PRODUCTION by the passage of time alone. No transaction, no event to observe.
Route B — sponsor, one atomic tx. AttackRegistry.goToProduction takes NOT_DEPLOYED → PRODUCTION in a single call. It is owner-gated (_validateAndPrepareAgreement, AttackRegistry.sol:752-755: msg.sender != agreementOwner reverts). ConfidencePoolFactory.createPool already enforces IAgreement(agreement).owner() == msg.sender, so the pool sponsor is always the agreement owner — always authorized to call it. Stakers cannot front-run a single atomic tx.
This is structural, not an edge case. _MIN_EXPIRY_LEAD is 30 days; PROMOTION_WINDOW is 14 days. So the auto-promote deadline (Route A) always lands at least 16 days before the earliest legal expiry. Every pool whose agreement sits in ATTACK_REQUESTED without DAO approval has a mandatory lock-in window built into the constants.
Likelihood: Medium
Route A requires no malicious actor at all — only DAO inaction for 14 days on an attack request that was made in good faith. The sponsor need do nothing. This clears the "admin acting against documented intent" bar, because nobody acts against intent.
Route B is a deliberate sponsor action, free and unfront-runnable, that directly realizes the "grief" §9 says is impossible.
The staker's only defense (pokeRiskWindow / poll deadlineTimestamp) requires knowing to watch a timestamp on an external contract; §6's stated defense ("sealable by anyone via permissionless pokeRiskWindow() during the active-risk interval") is inert here, because on both routes there is no active-risk interval — not one block of it.
Impact: Medium
Staker capital is locked from the PRODUCTION transition until the moderator flags SURVIVED or expiry arrives (sponsor-chosen pre-stake, bounded only by uint32 → year 2106), earning zero bonus the whole time.
On Route B the sponsor can then call the permissionless sweepUnclaimedBonus() and reclaim the entire bonus pot to recoveryAddress — funding the pool costs them nothing net, while stakers are locked with no yield and no risk underwritten.
Principal is ultimately recoverable (it is not stolen), which is what caps this at Medium rather than High — the harm is locked capital + denied premium, not lost principal.
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.
Scope note (important): the pool's behavior is proven directly — given a registry reporting PRODUCTION with no prior active-risk observation, withdraw reverts permanently and the bonus is zero. How the registry reaches PRODUCTION without UNDER_ATTACK is an out-of-scope dependency fact, cited from AttackRegistry.sol above (Routes A/B), not executed here — the real AttackRegistry pins pragma solidity 0.8.34 while this project pins 0.8.26, so it cannot be compiled into this suite (which is why the repo vendors only its interfaces). The test simulates the transition via MockAttackRegistry, which faithfully represents "registry reports PRODUCTION" from the pool's view.
Both functions matter. The first proves the harm. The second is the control: when risk genuinely materializes, the exit closes and the premium is paid (STAKE + BONUS). So "the exit closes" is not the bug — exit-closed-without-premium is.
Result:
Allow the exit when the registry is PRODUCTION and no risk window was ever observed — a terminal survival with no risk borne has nothing to settle. This does not re-open the CORRUPTED escape that DESIGN.md §5 / PR#63 closed, because that path is gated on state == CORRUPTED, which stays excluded.
Note the riskWindowStart != 0 latch remains the primary guard: once any risk window was observed, the exit stays closed regardless of state, so this only re-opens the exit in the exact no-risk-ever case the bug is about.
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.