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

sweepUnclaimedBonus sweeps the stakers' reserved bonus instead of the excess balance, stealing yields and permanently bricking claims

Author Revealed upon completion

Root + Impact

Description

  • When a pool resolves to SURVIVED or EXPIRED, stakers are entitled to claim their principal plus their share of the bonus. The sweepUnclaimedBonus function is intended to allow the sponsor to sweep "excess" tokens (like direct donations or dust) while strictly reserving the snapshotTotalBonus - claimedBonus amount for the stakers.

  • Due to a catastrophic logic error, sweepUnclaimedBonus calculates the exact amount of bonus owed to the stakers, and then transfers that exact amount to the sponsor's recoveryAddress. This not only steals the stakers' yields, but because the contract balance is depleted, all subsequent claimSurvived or claimExpired calls will revert due to insufficient funds, permanently locking the stakers' principal.

function sweepUnclaimedBonus() external nonReentrant {
// ...
// @> Root cause: Calculates the reserved staker entitlement
uint256 amount = snapshotTotalBonus - claimedBonus;
uint256 freeBalance = stakeToken.balanceOf(address(this));
if (amount > freeBalance) amount = freeBalance;
// @> Sweeps the staker entitlement to the sponsor instead of sweeping the excess!
stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Likelihood:

  • Any caller can trigger this function immediately after flagOutcome(SURVIVED) or claimExpired() resolves the pool. It requires no malicious admin, just a standard protocol state transition.

Impact:

  1. The sponsor instantly receives 100% of the stakers' bonus yields.

  2. The contract is drained below its liabilities. When stakers attempt to claim, stakeToken.safeTransfer(msg.sender, payout) reverts, permanently trapping their principal in the contract.

Proof of Concept

// t0: Pool resolves to SURVIVED.
// Total Stake = 100. Total Bonus = 50. Contract Balance = 150.
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// t1: Sponsor (or anyone) calls sweepUnclaimedBonus()
// amount = snapshotTotalBonus (50) - claimedBonus (0) = 50.
pool.sweepUnclaimedBonus();
// The 50 bonus tokens are transferred to the sponsor. Contract balance drops to 100.
// t2: A staker tries to claim their 100 principal + 50 bonus
pool.claimSurvived();
// Reverts! The contract attempts to transfer 150 tokens, but only holds 100.
// The staker loses their principal forever.

Recommended Mitigation

Rewrite sweepUnclaimedBonus to calculate the total reserved liabilities (unclaimed principal + unclaimed bonus) and only sweep the balance that exceeds those liabilities.

- remove this code
+ add this code
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
- uint256 amount = snapshotTotalBonus - claimedBonus;
uint256 freeBalance = stakeToken.balanceOf(address(this));
+ uint256 reservedBonus = snapshotTotalBonus - claimedBonus;
+ uint256 reservedStake = totalEligibleStake;
+ uint256 totalReserved = reservedBonus + reservedStake;
+
+ uint256 amount = freeBalance > totalReserved ? freeBalance - totalReserved : 0;
- if (amount > freeBalance) amount = freeBalance;
-
- if (amount > 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
- }
-
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Support

FAQs

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

Give us feedback!