#41365 [SC-Critical] Vested tokens are counted as accumulated revenue
Was this helpful?
Was this helpful?
Submitted on Mar 14th 2025 at 11:42:08 UTC by @armormadeofwoe for
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
accumulatedDeptRewardsYeet
incorrectly counts all vested staking tokens as revenue, allowing them to be swapped for vault shares and distributed as rewards to all stakers.
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:
User started and unstake which vests their token for 10 days
totalSupply
went down, however no token transfers took place
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
Bob unstakes his 60 tokens by calling startUnstake
totalSupply = 100 - 60 = 40
, 10 day vesting period starts for Bob
Admin calls executeRewardDistributionYeet
with input amount == accumulatedDeptRewardsYeet == 60
60 staking tokens are swapped through the Zapper for vault shares
_handleVaultShares
handles reward distribution by updating the reward index.
10 days pass
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.
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.
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
Attaching step-by-step from main body
Let's see a practical example.
Assume there is no revenue, balanceOf(address(StakingV2)) == totalSupply == 100 tokens
Bob unstakes his 60 tokens by calling startUnstake
totalSupply = 100 - 60 = 40
, 10 day vesting period starts for Bob
Admin calls executeRewardDistributionYeet
with input amount == accumulatedDeptRewardsYeet == 60
60 staking tokens are swapped through the Zapper for vault shares
_handleVaultShares
handles reward distribution by updating the reward index.
10 days pass
Bob calls unstake
, however the method reverts since balanceOf(address(StakingV2)) = 40
while unlockedAmount = 60