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

High: sweepUnclaimedBonus incorrectly transfers all unclaimed bonus funds to recoveryAddress when riskWindowStart == 0, leading to permanent user fund loss

Author Revealed upon completion

Root + Impact

Description

  • Root Cause & Impact Summary

    The sweepUnclaimedBonus() function is implemented to sweep only excess leftover donation/dust funds to recoveryAddress after a pool settles to SURVIVED or EXPIRED. Per protocol design, funds reserved for users must cover staker principal + all unclaimed bonus rewards; only balance above this reserved amount can be swept.

    Expected Behavior

    When calculating reserved funds for the pool, the contract must always account for all unclaimed bonus (snapshotTotalBonus - claimedBonus) on top of totalEligibleStake. This prevents user bonus rewards from being classified as excess sweepable funds.

    Actual Vulnerable Behavior

    The logic that adds unclaimed bonus to reserved is wrapped inside an if (riskWindowStart != 0) conditional block. When riskWindowStart == 0 (the pool never enters an attack risk window, a common normal pool lifecycle), this entire branch is skipped.

    • reserved only equals totalEligibleStake

    • All unclaimed bonus is excluded from reserved fund calculation

    • The full unclaimed bonus pool is falsely marked as excess funds and permanently transferred to the sponsor-controlled recoveryAddress

    Vulnerable Code Snippet

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
// Bug: Bonus addition gated by riskWindowStart != 0
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}

When riskWindowStart == 0, the bonus addition logic never runs, omitting all user bonus from reserved funds.


Risk

Likelihood:

  1. Trigger condition riskWindowStart == 0 occurs for any pool that never detects an attack during its lifecycle (the majority of normal pool deployments).

  2. Exploitable immediately after the pool settles to EXPIRED or SURVIVED.

  3. Zero access control: any external address may call sweepUnclaimedBonus() with no permissions required.

Impact:

  • The full unclaimed bonus reward pool is permanently transferred to the sponsor-controlled recoveryAddress.

  • Bonus contributors and eligible stakers lose all entitled bonus rewards against the protocol’s intended logic.

  • Fund misallocation is irreversible; there is no built-in mechanism to recover swept bonus funds for affected userse

Proof of Concept

Test Explanation

  1. Staker deposits 10 ETH eligible stake principal

  2. Bonus contributor adds 5 ETH to the bonus reward pool

  3. Validate pool state riskWindowStart = 0 (no attack window triggered)

  4. Fast-forward block timestamp past pool expiry period

  5. Staker withdraws principal via claimExpired()

  6. Random unpermissioned external address calls sweepUnclaimedBonus()

  7. Assert confirms full 5 ETH bonus is sent to recoveryAddress

function testSweepUnclaimedBonusWhenNoRiskWindow() public {
vm.startPrank(STAKER);
token.approve(address(pool), 10 ether);
pool.stake(10 ether);
vm.stopPrank();
vm.startPrank(BONUS_CONTRIBUTOR);
token.approve(address(pool), 10 ether);
pool.contributeBonus(5 ether);
vm.stopPrank();
assertEq(pool.riskWindowStart(), 0);
vm.warp(block.timestamp + 32 days);
vm.prank(STAKER);
pool.claimExpired();
vm.prank(address(0x999));
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(RECOVERY), 5 ether);
}

Recommended Mitigation

Fix Explanation

The core vulnerability stems from a misplaced conditional gate that limits unclaimed bonus calculation to pools with a non-zero riskWindowStart value. When a pool never triggers an attack window (riskWindowStart = 0), unclaimed bonus funds are excluded from the reserved fund tally, which incorrectly classifies all user bonus as excess sweepable funds sent to the sponsor-controlled recoveryAddress.

This fix removes the riskWindowStart != 0 restriction entirely. We replace it with a safe arithmetic check snapshotTotalBonus > claimedBonus to ensure we only add positive unclaimed bonus values to the reserved pool. This check also prevents uint underflow reverts that would occur if claimedBonus exceeds snapshotTotalBonus. After the fix, all user-owed bonus rewards are permanently included in reserved funds for every pool lifecycle state, eliminating the unauthorized bonus sweep vulnerability.

Key Code Changes Summary

  1. Remove the original if (riskWindowStart != 0) conditional wrapper that blocked bonus calculation for normal pools with no attack window.

  2. Implement a safe positive balance check before adding unclaimed bonus to the reserved fund count.

  3. Ensure reserved funds always sum staker principal + all outstanding unclaimed bonus, regardless of pool risk window status.

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
- if (riskWindowStart != 0) {
- reserved += snapshotTotalBonus - claimedBonus;
- }
+ if (snapshotTotalBonus > claimedBonus) {
+ reserved += snapshotTotalBonus - claimedBonus;
+ }
}

Support

FAQs

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

Give us feedback!