#41365 [SC-Critical] Vested tokens are counted as accumulated revenue

Submitted on Mar 14th 2025 at 11:42:08 UTC by @armormadeofwoe for Audit Comp | Yeet

  • Report ID: #41365

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol

  • Impacts:

    • Protocol insolvency

    • Theft of unclaimed yield

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

accumulatedDeptRewardsYeet incorrectly counts all vested staking tokens as revenue, allowing them to be swapped for vault shares and distributed as rewards to all stakers.

Vulnerability Details

accumulatedDeptRewardsYeet considers the difference between the live stakingToken.balanceOf(address(this)) and the user-deposited totalSupply as revenue.

Afterwards this amount can be swapped for additional rewards via executeRewardDistributionYeet

The issue here lies within startUnstake, due to it subtracting the unstaked tokens from the totalSupply without transferring or accounting the stakingToken they should receive.

So what happened is:

  1. User started and unstake which vests their token for 10 days

  2. totalSupply went down, however no token transfers took place

  3. Calling accumulatedDeptRewardsYeet will return value equal to the vested amount

Let's see a practical example. Assume there is no revenue, balanceOf(address(StakingV2)) == totalSupply == 100 tokens

  1. Bob unstakes his 60 tokens by calling startUnstake

  2. totalSupply = 100 - 60 = 40, 10 day vesting period starts for Bob

  3. Admin calls executeRewardDistributionYeet with input amount == accumulatedDeptRewardsYeet == 60

  4. 60 staking tokens are swapped through the Zapper for vault shares

  5. _handleVaultShares handles reward distribution by updating the reward index.

  6. 10 days pass

  7. Bob calls unstake, however the method reverts since balanceOf(address(StakingV2)) = 40 while unlockedAmount = 60

NB! This example is severely simplified for illustrative purposes. One might argue that the admin would not perform the call, knowing that there is no current revenue. However under normal working conditions, the protocol will have hundreds/thousands of actors and it is not possible to track which funds are part of the vestings and which are accumulated revenue.

Impact Details

Protocol becomes insolvent since balanceOf(address(StakingV2)) < owed amount to unstaking parties.

Last person to withdraw their funds will be unable to due to insufficient balance.

Rewards had been stolen and distributed to other Yeet participants.

References

https://github.com/immunefi-team/audit-comp-yeet/blob/da15231cdefd8f385fcdb85c27258b5f0d0cc270/src/StakeV2.sol#L148-L150

https://github.com/immunefi-team/audit-comp-yeet/blob/da15231cdefd8f385fcdb85c27258b5f0d0cc270/src/StakeV2.sol#L158-L160

https://github.com/immunefi-team/audit-comp-yeet/blob/da15231cdefd8f385fcdb85c27258b5f0d0cc270/src/StakeV2.sol#L255

Proof of Concept

Proof of Concept

Attaching step-by-step from main body

Let's see a practical example. Assume there is no revenue, balanceOf(address(StakingV2)) == totalSupply == 100 tokens

  1. Bob unstakes his 60 tokens by calling startUnstake

  2. totalSupply = 100 - 60 = 40, 10 day vesting period starts for Bob

  3. Admin calls executeRewardDistributionYeet with input amount == accumulatedDeptRewardsYeet == 60

  4. 60 staking tokens are swapped through the Zapper for vault shares

  5. _handleVaultShares handles reward distribution by updating the reward index.

  6. 10 days pass

  7. Bob calls unstake, however the method reverts since balanceOf(address(StakingV2)) = 40 while unlockedAmount = 60

Was this helpful?