FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: low

`sweepUnclaimedBonus` can permissionlessly drain the bonus pool between a moderator's initial `SURVIVED` flag and a legitimate correction to good-faith `CORRUPTED`, shorting the named attacker's "entire pool" entitlement

Author Revealed upon completion

Root + Impact

Description

Normal behavior: flagOutcome may be re-flagged by the moderator at any time before the first claim (claimsStarted == false), specifically so a mistaken outcome or attacker address can be corrected before any participant locks in a wrong distribution (docs/DESIGN.md §4). For a good-faith CORRUPTED correction, bountyEntitlement is re-snapshotted as snapshotTotalStaked + snapshotTotalBonus the named attacker is meant to receive the entire pool, staked principal plus bonus (docs/DESIGN.md §12).
The specific issue: sweepUnclaimedBonus is callable by anyone whenever outcome == SURVIVED (or EXPIRED), with no check on claimsStarted this is intentional, so a 1-wei donation can't be used to grief the moderator's re-flag window. When riskWindowStart == 0 (which the contract's own §5 "no-risk-window CORRUPTED race" documents as a reachable state even on a pool that is ultimately CORRUPTED), the function reserves none of totalBonus and sweeps the entire bonus to recoveryAddress, also decrementing the live totalBonus state variable. If the moderator's first flag was a mistaken SURVIVED (e.g. an out-of-scope judgement call that later needs correcting), anyone can sweep the whole bonus away in the gap before the moderator re-flags to good-faith CORRUPTED. The subsequent correction re-snapshots snapshotTotalBonus from the now-drained totalBonus, so the correctly-named attacker's "entire pool" entitlement is short by however much was swept — permanently, since the sweep already left the contract.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
// Reserve principal still owed to non-claimers plus any bonus they're entitled to. When
// `riskWindowStart == 0` (no observable risk), `_bonusShare` returns 0 for everyone, so
// the bonus is not owed to any staker and the entire snapshotTotalBonus is sweepable.
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
// @> if (riskWindowStart != 0) {
// @> reserved += snapshotTotalBonus - claimedBonus;
// @> }
}
// aderyn-fp-next-line(reentrancy-state-change)
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
// @> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// Re-flag allowed pre-claim so the moderator can fix a typo'd outcome / attacker before
// any participant locks in the wrong distribution.
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
// @> snapshotTotalBonus = totalBonus; // re-reads the (now-drained) live totalBonus on the correcting flag
...
// @> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
...
}

Risk

Likelihood:

Occurs whenever the moderator's first flagOutcome call names an outcome other than the correct one and the registry-observed risk window never opened locally (riskWindowStart == 0) a state the contract's own documentation (§5) treats as a normal, reachable outcome for a pool that is later confirmed CORRUPTED, not a contrived edge case.
sweepUnclaimedBonus is a permissionless, no-incentive-required "keeper" function by design, any bot, MEV searcher, or even an unrelated party sweeping dust will trigger it the moment SURVIVED is flagged and riskWindowStart == 0, well before the moderator has a chance to review and correct the outcome.

Impact:

The good-faith CORRUPTED attacker, the party the protocol is specifically trying to reward for responsible disclosure, receives less than the documented "entire pool" entitlement, with no way to recover the swept difference; claimAttackerBounty pays min(remaining, freeBalance), and the shortfall is already at recoveryAddress.
Silently breaks the accounting invariant stated in docs/DESIGN.md §12 ("the entire pool is the named attacker's bounty") without reverting or emitting any signal that the entitlement was short-changed.

Proof of Concept

1. Pool has stakers with totalEligibleStake = S, and a bonus pool totalBonus = B (contributed by the sponsor).
2. Registry sits in CORRUPTED (reached without any pool interaction during the prior UNDER_ATTACK phase,
so riskWindowStart == 0 — matches docs/DESIGN.md §5's "no-risk-window CORRUPTED race" scenario).
3. Moderator calls flagOutcome(CORRUPTED, /*goodFaith*/ false, address(0)) — or SURVIVED, if they judge
the breach out of scope — believing (mistakenly) this is the correct call.
- outcome = SURVIVED (or a bad-faith CORRUPTED path that is later revisited), claimsStarted == false.
4. Anyone (bot/MEV/unrelated party) calls sweepUnclaimedBonus():
- reserved = totalEligibleStake only (riskWindowStart == 0 skips the bonus reservation)
- amount = freeBalance - reserved ≈ B (the entire bonus pool)
- totalBonus -= amount (drained to ~0)
- B tokens transferred to recoveryAddress
5. Moderator reviews further evidence, realizes the breach WAS in-scope and identifies the real
whitehat, and calls flagOutcome(CORRUPTED, /*goodFaith*/ true, Bob) a fully legitimate,
documented correction (claimsStarted is still false, so the re-flag is permitted).
- snapshotTotalBonus = totalBonus ≈ 0
- bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus ≈ S + 0 (should have been S + B)
6. Bob calls claimAttackerBounty() and receives only S, never the B tokens that were already
swept to recoveryAddress in step 4.

Recommended Mitigation

Withhold the bonus reservation until either the risk window was genuinely observed, or the re-flag correction window has actually closed (i.e. a claim has occurred) — regardless of whether stakers currently remain, since a good-faith CORRUPTED correction can still name an attacker entitled to the bonus even with zero remaining stakers:

- uint256 reserved;
- if (totalEligibleStake != 0) {
- reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
- }
- }
+ uint256 reserved = totalEligibleStake;
+ // Withhold the "no risk observed" bonus release while the outcome could still be
+ // re-flagged (e.g. SURVIVED -> good-faith CORRUPTED per docs/DESIGN.md §4), so a
+ // legitimate correction's bounty entitlement can't be swept out from under it first.
+ if (riskWindowStart != 0 || !claimsStarted) {
+ reserved += snapshotTotalBonus - claimedBonus;
+ }

This keeps the bonus sweepable for genuinely resolved, uncontested outcomes (once any participant has claimed, the re-flag window is already closed by docs/DESIGN.md §4's own finality rule) while closing the window in which a premature sweep can undercut a pending correction.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!