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

Unclaimed dust stake can block sweeping of bonus rounding residue

Author Revealed upon completion

Description

After a pool resolves as SURVIVED or EXPIRED, stakers claim their principal plus a floor-rounded bonus share computed via _bonusShare(), and sweepUnclaimedBonus() should transfer only the balance above the remaining staker entitlement reserve to recoveryAddress, including rounding dust. The function reserves outstanding principal plus snapshotTotalBonus - claimedBonus whenever any eligible stake remains, treating all unpaid snapshot bonus as an outstanding liability. This overstates the actual remaining entitlement when Math.mulDiv() rounds down each individual bonus share. An attacker can split stake across many addresses so each computed bonus rounds to zero, claim all but one position to recover principal while leaving claimedBonus at zero, and keep the last minimum-sized stake unclaimed. The contract continues reserving the entire bonus even though the final position's actual entitlement is also zero, blocking the sweep of otherwise unclaimable bonus residue.

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus;
}
}
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;
}
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood:

  • An attacker creates many separate claimant addresses through stake(), each with at least minStake, so the number of positions exceeds the bonus in base units.

  • After the pool resolves as SURVIVED or EXPIRED, each position's _bonusShare() computes as Math.mulDiv(userScore, snapshotTotalBonus, globalScore), flooring to zero when the denominator is large relative to the numerator.

  • The attacker calls claimSurvived() or claimExpired() from all but one address, recovering almost all principal while claimedBonus remains zero.

  • The remaining position also has zero actual bonus entitlement, but totalEligibleStake remains nonzero, causing sweepUnclaimedBonus() to reserve the entire snapshotTotalBonus.

  • This scenario is only economically feasible with low-decimal or coarse-unit ERC20 tokens and a sufficiently low minStake.

Impact:

  • The pool indefinitely retains bonus residue that no remaining staker can receive, delaying the intended sweep to recoveryAddress.

  • The harmed party is the recovery recipient or sponsor-side beneficiary of unowed bonus dust; staker principal and computed bonus claims remain protected.

  • With a coarse-unit token and low minStake, an attacker can lock nearly the entire bonus by leaving one minimum stake unclaimed after recovering the rest of their principal.

  • The locked funds remain in the pool until the last position claims, and the attacker controls the timing of that release.

Recommended Mitigation

Track the remaining bonus-distribution denominator and reserve an aggregate upper bound on actual remaining claims. Snapshot the global score at resolution and initialize remainingScore to that value. When a staker claims, subtract their score from remainingScore. In sweepUnclaimedBonus(), reserve outstanding principal plus Math.mulDiv(remainingScore, snapshotTotalBonus, snapshotGlobalScore) for the k=2 path, and use the analogous remaining-stake ratio for the amount-weighted fallback. If remainingScore is zero, reserve no bonus.

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
+ reserved += Math.mulDiv(remainingScore, snapshotTotalBonus, snapshotGlobalScore);
}
}

Support

FAQs

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

Give us feedback!