Normal behavior: expiry and pool scope are sponsor-mutable only up to a defined checkpoint (expiryLocked, scopeLocked), so stakers can rely on both being fixed once risk materializes.
The issue: recoveryAddress — which receives 100% of staker principal + bonus on a bad-faith CORRUPTED resolution — has no equivalent lock, at any point, including after the breach is confirmed.
Likelihood:
The owner keeps unrestricted onlyOwner access to setRecoveryAddress for the pool's entire life — unlike expiry/scope, which the same codebase already freezes once stakers rely on them.
flagOutcome(CORRUPTED, false, address(0)) publicly commits the pool's full balance to the current recoveryAddress, giving the owner an exact on-chain moment to front-run the sweep with one extra call.
Impact:
100% of staked principal + bonus lands in an address of the owner's choosing instead of the address stakers deposited against.
The window never closes — it persists through claimsStarted, covering partial-bounty remainders and later donation sweeps too.
The test below reproduces the full attack in four steps:
Alice and Bob stake 150 total; Carol contributes 20 bonus — 170 tokens are now at stake.
A real attack is observed and confirmed: the registry reports CORRUPTED, and the moderator correctly flags it bad-faith (flagOutcome(CORRUPTED, false, address(0))). At this point corruptedReserve locks in at 170, publicly owed to the current recoveryAddress.
Only after that confirmation, the owner calls setRecoveryAddress(rugWallet) — the call succeeds because nothing gates it on the now-CORRUPTED outcome.
Anyone (the owner or a third party) calls the permissionless claimCorrupted(). It reads recoveryAddress fresh at execution time, so the full 170 tokens pay rugWallet, and the address stakers actually trusted receives zero.
Verified locally: forge test --match-test testRecoveryAddressRugPullAfterBadFaithCorrupted → 1 passed.
outcome is the exact moment corruptedReserve/bountyEntitlement get committed against the current recoveryAddress (see flagOutcome), so gating on it closes the window at its source rather than patching a symptom. It costs nothing functionally: the sponsor keeps full flexibility to update recoveryAddress at any time before an outcome is flagged — which is the only window where staker funds aren't yet at stake — and reuses the existing OutcomeAlreadySet error, so the fix is a single line with no new interface surface or storage.
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.