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.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.