Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Valid

Silent Loss of WETH Rewards in VaultRouterBranch stake() Function

Summary

A critical vulnerability exists in the VaultRouterBranch.stake() function where users silently lose their unclaimed WETH rewards when staking additional shares. The vulnerability stems from the reward distribution mechanism's improper handling of lastValuePerShare updates during staking operations. While the unstake() function properly protects users by reverting if unclaimed rewards exist, stake() lacks this protection, leading to permanent loss of rewards.

Vulnerability Details

The Zaros protocol implements a sophisticated reward distribution system where WETH rewards are allocated to vault stakers proportionally to their share holdings. The system uses a cumulative reward tracking mechanism with three key components:

  1. Global Reward Tracking:

    • valuePerShare: A cumulative measure of rewards per share (in WETH)

    • Increases each time new rewards are distributed to the vault

    • Represents total historical rewards per share

  2. User-Specific Tracking:

    • lastValuePerShare: User's checkpoint for reward calculations

    • Updated when user claims rewards or modifies their stake

    • Used as the baseline for calculating unclaimed rewards

  3. Reward Calculation:

    unclaimedRewards = userShares * (valuePerShare - lastValuePerShare)

The vulnerability manifests in the reward accumulation logic, specifically in how Distribution.accumulateActor() is called during staking operations:

// In VaultRouterBranch.stake():
function stake(uint128 vaultId, uint128 shares) external {
// ... other checks ...
// This call leads to reward loss
Distribution.accumulateActor(self, actorId);
// ... staking logic ...
}
// The problematic accumulation function:
function accumulateActor(Data storage self, bytes32 actorId) internal returns (SD59x18 valueChange) {
Actor storage actor = self.actor[actorId];
return _updateLastValuePerShare(self, actor, ud60x18(actor.shares));
}

The core issue lies in how _updateLastValuePerShare() modifies the user's reward checkpoint without ensuring rewards are claimed:

function _updateLastValuePerShare(
Data storage self,
Actor storage actor,
UD60x18 newActorShares
) private returns (SD59x18 valueChange) {
// Calculate unclaimed rewards (but doesn't process them)
valueChange = _getActorValueChange(self, actor);
// Critical Issue: Updates checkpoint without claiming rewards
actor.lastValuePerShare = self.valuePerShare;
// Returns unclaimed amount, but caller (stake) doesn't use it
return valueChange;
}
  • means if the user stake additional shares, the unclaimed rewards will be lost

To illustrate the vulnerability, consider this scenario:

  1. Initial State (t0):

    User State:
    - Staked shares = 100
    - lastValuePerShare = 1.0 WETH/share
    - currentValuePerShare = 1.5 WETH/share
    - Unclaimed rewards = 100 * (1.5 - 1.0) = 50 WETH
  2. User Stakes More (t1):

    Action: User stakes 50 more shares
    System Updates:
    1. accumulateActor() called
    2. lastValuePerShare → 1.5 (updated to current)
    3. total shares → 150
    Result:
    - New claimable = 150 * (1.5 - 1.5) = 0 WETH
    - Previous 50 WETH permanently lost

Note that The unstake() function correctly implements safety checks:

// In unstake():
function unstake(uint128 vaultId, uint256 shares) external {
// Protects users from losing rewards
if (getActorValueChange(actorId) != 0) {
revert Errors.UnclaimedRewards();
}
// ... unstaking logic ...
}
// But stake() lacks this protection:
function stake(uint128 vaultId, uint128 shares) external {
// No reward protection
Distribution.accumulateActor(self, actorId); // Silently erases rewards
// ... staking logic ...
}

Impact

The vulnerability results in direct and permanent economic loss for stakers through:

Tools Used

Manual review

Recommendations

  • Add Protective Revert (like unstake):

function stake(uint128 vaultId, uint128 shares) external {
// Force users to claim before staking
if (getActorValueChange(msg.sender) != 0) {
revert Errors.UnclaimedRewards(
"Claim pending rewards before staking more shares"
);
}
// Proceed with staking
_processStake(vaultId, shares);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Inside VaultRouterBranch if you stake wait some time then stake again makes you lose the rewards.

Support

FAQs

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