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

Good-faith CORRUPTED bounty can be underpaid after bonus is swept during the re-flag window

Author Revealed upon completion

Root + Impact

Description

Intended behavior: before any claim starts, the moderator can re-flag an outcome to correct a mistaken SURVIVED / attacker / good-faith decision. For good-faith CORRUPTED, the named attacker should receive the full tracked pool: snapshotTotalStaked + snapshotTotalBonus.

Issue: sweepUnclaimedBonus() can move tracked bonus to recoveryAddress without setting claimsStarted. In the no-observed-risk case, this also decrements totalBonus. A later correction to good-faith CORRUPTED is still allowed, but the attacker’s bountyEntitlement is recomputed from the reduced totalBonus, so the attacker loses the swept bonus.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED
? snapshotTotalStaked + snapshotTotalBonus
: 0;
@> bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus
: 0;
}
function sweepUnclaimedBonus() external nonReentrant {
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
@> // Does not set claimsStarted
@> stakeToken.safeTransfer(recoveryAddress, amount);
}

Risk

Likelihood:

  • Reason 1: This occurs during the documented pre-claim correction window when the moderator first flags SURVIVED for a terminal CORRUPTED registry and later corrects to good-faith CORRUPTED.

  • Reason 2: The sweep is permissionless, and in the riskWindowStart == 0 path it can be executed immediately before any staker claim finalizes the outcome.

Impact:

  • Impact 1: The named good-faith attacker receives less than the intended full-pool bounty.

  • Impact 2: Up to the entire tracked bonus pool is redirected to recoveryAddress; in a bonus-only pool, the attacker can lose the whole intended bounty.

Proof of Concept

Below is the textual PoC:

// 1. Pool has 100 stake + 50 bonus. riskWindowStart == 0.
// 2. Registry is CORRUPTED.
// 3. Moderator flags SURVIVED.
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 4. Anyone sweeps unreserved bonus to recoveryAddress.
// totalBonus becomes 0, claimsStarted remains false.
pool.sweepUnclaimedBonus();
// 5. Moderator corrects to good-faith CORRUPTED.
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// 6. Attacker entitlement is now only 100, not 150.
pool.claimAttackerBounty();

Recommended Mitigation

Prevent tracked bonus from being swept while the re-flag window is still open. Pure donations can still be swept, but accounted totalBonus should remain until a real finality event occurs.

+ error BonusSweepBeforeFinality();
function sweepUnclaimedBonus() external nonReentrant {
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
+ uint256 accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ if (!claimsStarted && accountedBonusSwept != 0) {
+ revert BonusSweepBeforeFinality();
+ }
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= accountedBonusSwept;
}
...
}

Support

FAQs

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

Give us feedback!