Severity: Medium. The pre-grace principal lock (Impact 2) needs no trust assumption — a single permissionless post-term
poketraps all staker principal until the moderator intervenes. The permanent principal loss (Impact 1) additionally requires the registry to reach CORRUPTED only after the term and the moderator to be absent for the full 180-day grace. Trigger is permissionless in both cases.
A permissionless post-expiry pokeRiskWindow() seals riskWindowStart = expiry (a nonzero value) even when the registry never entered an active-risk state during the pool's term. That nonzero latch is the only condition the auto-CORRUPTED backstop in claimExpired keys on (state == CORRUPTED && riskWindowStart != 0). As a result, a breach that occurs entirely after the pool's coverage term — which docs/DESIGN.md (§2, §5) says must resolve EXPIRED and refund staker principal — is instead flipped into the CORRUPTED path, seizing the entire pool (all staker principal) to recoveryAddress.
_markRiskWindowStart caps the observation timestamp at expiry and writes it unconditionally:
The cap exists to keep the k=2 bonus math sane for a late in-term observation (documented in DESIGN §7). But it has an unintended side effect: a first active-risk observation that happens after expiry still writes a nonzero riskWindowStart (= expiry), as if an in-term risk window had existed.
pokeRiskWindow() is permissionless and only requires outcome == UNRESOLVED; it has no expiry guard, so anyone can trigger the sealing after the term:
The auto-CORRUPTED backstop in claimExpired then keys purely on that latch:
docs/DESIGN.md §6 states the auto-CORRUPTED backstop "by definition cannot apply when no risk window was observed," and §2/§5 state that a pool whose term ends with no in-term risk must resolve EXPIRED. The existing test testCorruptedAutoResolveSkippedWhenRiskWindowNeverOpened encodes exactly this intended EXPIRED-refund behavior. A post-term pokeRiskWindow() subverts it: the latch the design assumes is only set during the term is set after it.
Permanent principal loss (post-grace): After expiry + 180d, claimExpired auto-finalizes bad-faith CORRUPTED and claimCorrupted sweeps the full pool (all staker principal) to recoveryAddress. Stakers who should have received EXPIRED refunds lose 100% of principal, for a breach outside the term they underwrote.
Principal lock with no trust assumption (pre-grace): Even without any moderator-absence assumption, a single permissionless post-term poke makes claimExpired revert AgreementCorruptedAwaitingModerator, trapping all staker principal for up to 180 days until the moderator intervenes. Without the poke, claimExpired refunds principal immediately.
The trigger is permissionless (anyone can poke), and the pool's own recovery path (recoveryAddress) is sponsor-controlled, so a sponsor is directly incentivized to perform the seize.
Add as test/poc/PostTerm.t.sol and run forge test --match-contract PostTermCorruptedPoC -vvv. All three pass. The registry is held in NEW_DEPLOYMENT for the whole term (so riskWindowStart == 0 at expiry, the "no observable risk" precondition); the breach happens only after expiry.
Output:
Do not open the risk window from an observation at or after expiry — a first active-risk observation past the term should leave riskWindowStart == 0 so claimExpired falls through to the staker-favorable EXPIRED refund:
Equivalently, gate the claimExpired auto-CORRUPTED branch on the risk window having opened strictly before expiry.
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.