setRecoveryAddress() has no check that the pool outcome is still UNRESOLVED. A malicious or compromised pool owner can redirect recoveryAddress to an attacker-controlled wallet after flagOutcome() permanently locks the resolution, then trigger claimCorrupted() or sweepUnclaimedBonus() to drain 100% of staker principal and bonus to the attacker.
recoveryAddress is the sole destination for three sweep sinks:
claimCorrupted() L423 — transfers the entire corruptedReserve\ (stake + bonus)
sweepUnclaimedCorrupted() L468 — transfers expired attacker bounty
sweepUnclaimedBonus() L506 — transfers all unclaimed bonus
None of these sinks re-check that recoveryAddress was set before resolution.
The developer's intent is that recoveryAddress represents the pool sponsor's designated treasury — a pre-committed destination set before stakers join. Once the outcome is sealed by flagOutcome(), the accounting is frozen: corruptedReserve, snapshotTotalBonus, and all downstream claim amounts are computed from that snapshot.
The invariant the code relies on — but never enforces — is: "The recovery address is fixed once any party has acted on the resolution."
flagOutcome() correctly gates re-flagging via claimsStarted (line 327). But it does not protect the destination of the funds. Because setRecoveryAddress has no analogous guard, the owner retains the ability to silently swap the target wallet in the window between resolution and the first sweep call. Any subsequent sweep call (claimCorrupted, sweepUnclaimedBonus, sweepUnclaimedCorrupted) then sends to the new — attacker-controlled — address.
The pool has received staker deposits (totalEligibleStake > 0) and/or bonus contributions (totalBonus > 0).
The outcome has been set: flagOutcome() has been called with CORRUPTED (bad-faith or good-faith), OR the outcome is SURVIVED/EXPIRED with riskWindowStart == 0 (full bonus sweepable).
No sweep function has been called yet (claimCorrupted / sweepUnclaimedBonus not yet triggered).
The pool owner (sponsor) is malicious or their private key is compromised.
No time constraint — the attack can execute in the same block as flagOutcome.
Vector A — Bad-Faith CORRUPTED (staker principal theft):
Alice, Bob, and Carol stake/contribute totalling 190 ONE into the pool.
The underlying agreement is breached; the registry reaches CORRUPTED.
The moderator (DAO) calls flagOutcome(CORRUPTED, false, address(0)) → corruptedReserve = 190 ONE, outcome locked.
Pool owner calls setRecoveryAddress(exploiter) — no revert because line 611 has no outcome guard.
Any caller (attacker or bystander) calls claimCorrupted().
L423: stakeToken.safeTransfer(recoveryAddress, toSweep) → 190 ONE flows to exploiter.
Alice, Bob, and Carol receive zero tokens.
Vector B — SURVIVED / no-risk-window (bonus sponsor theft):
Carol contributes 50 ONE bonus. Alice stakes 100 ONE.
Registry moves to PRODUCTION without ever entering UNDER_ATTACK → riskWindowStart == 0.
Moderator calls flagOutcome(SURVIVED, false, address(0)).
Pool owner calls setRecoveryAddress(exploiter).
Pool owner (or anyone) calls sweepUnclaimedBonus().
Since riskWindowStart == 0, all 50 ONE is free balance → transferred to exploiter.
100% loss of funds held in the pool at resolution time. In the bad-faith CORRUPTED path this includes all staker principal plus all contributed bonus. In the SURVIVED/EXPIRED no-risk-window path this includes all bonus contributions. The attack is atomic (same transaction), permissionless to trigger (anyone can call claimCorrupted), and leaves no on-chain trace distinguishing it from a legitimate sweep.
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.