FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Support

FAQs

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

Give us feedback!