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

sweepUnclaimedBonus skips claimsStarted latch; bonus sweep between SURVIVED and CORRUPTED re-flag shortchanges whitehat bounty

Author Revealed upon completion

Description

// @audit sweepUnclaimedBonus is the ONLY value-mover that deliberately skips claimsStarted latch
// @audit When riskWindowStart == 0, it sweeps the entire bonus pool with no latch set
// @audit flagOutcome re-reads totalBonus live on re-flag — computes bounty from drained total

sweepUnclaimedBonus (line 474) deliberately does NOT set claimsStarted. The in-code comment justifies this for 1-wei dust donations. But when riskWindowStart == 0, the same code path sweeps the entire bonus pool to recoveryAddress.

// @audit riskWindowStart == 0: bonus NOT reserved, sweeps as "excess"
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus; // skipped when riskWindowStart == 0
}
// @audit Intentionally skips claimsStarted (for dust, but reaches full bonus pool)
stakeToken.safeTransfer(recoveryAddress, amount);
// @audit Re-flag guard: only checks claimsStarted, still false after sweep
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// @audit Re-reads totalBonus live, now drained
snapshotTotalBonus = totalBonus;
bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus; // bonus = 0

Because flagOutcome re-reads totalBonus live on every re-flag (line 358), a SURVIVED to good-faith CORRUPTED correction after the sweep computes bountyEntitlement from the drained total. The whitehat, entitled to the entire pool under §12, is permanently shortchanged by the bonus amount, now in the sponsor-controlled recoveryAddress. The sponsor is directly incentivized: recoveryAddress receives the swept bonus.

Proof of Concept

The PoC below demonstrates the full sequence: SURVIVED flag on a CORRUPTED registry, sweepUnclaimedBonus drains the bonus (claimsStarted stays false), moderator corrects to good-faith CORRUPTED, whitehat claims and receives only principal — 50 short of the 150 owed.

// PoC: test/unit/PocReflagSnapshotBonusDrain.t.sol
vm.prank(alice);
pool.stake(100 ether);
vm.prank(carol);
pool.contributeBonus(50 ether);
mockRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recoveryAddress);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recoveryAddress) - recoveryBefore, 50 ether);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 100 ether); // @audit owed 150 per §12

Risk

The sponsor is directly incentivized: recoveryAddress receives the swept bonus. The sequence is a documented workflow: §4 contemplates re-flag corrections, §8 allows SURVIVED on a CORRUPTED registry. A moderator correcting SURVIVED to CORRUPTED on new evidence is the exact use case §4 protects.

Impact

Whitehat loses the entire bonus portion of their bounty. §12 entitles them to snapshotTotalStaked + snapshotTotalBonus; after sweep + re-flag, they receive only principal. Contradicts §10 and §12.

Recommended Mitigation

Set claimsStarted when the swept amount is material (>0.1% of bonus pool), preserving the dust exemption while protecting the re-flag window.

function sweepUnclaimedBonus() external nonReentrant {
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
+ if (amount > snapshotTotalBonus / 1000) {
+ if (!claimsStarted) claimsStarted = true;
+ }
totalBonus = totalBonus > amount ? totalBonus - amount : 0;
stakeToken.safeTransfer(recoveryAddress, amount);
}

Support

FAQs

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

Give us feedback!