#41831 [SC-Critical] Miscalculation of excess rewards via external token transfers leads to contract insolvency and incomplete withdrawals
Was this helpful?
Was this helpful?
Submitted on Mar 18th 2025 at 18:24:43 UTC by @vladi319 for
Report ID: #41831
Report Type: Smart Contract
Report severity: Critical
Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol
Impacts:
Protocol insolvency
Smart contract unable to operate due to lack of token funds
The StakeV2 contract miscalculates excess rewards by using the difference between the contract’s token balance and the total staked tokens. External transfers to the contract can inflate this balance, causing erroneous reward distributions that deplete the tokens reserved for user withdrawals, potentially locking user funds and rendering the contract insolvent.
The vulnerability lies in the function accumulatedDeptRewardsYeet()
, which computes excess rewards using the formula:
This calculation assumes that all tokens held by the contract are a direct result of user stakes. However, if tokens are directly transferred to the contract—outside the standard staking process—the balance becomes artificially inflated. In the provided PoC, an external transfer of 100 ether worth of tokens is made to the contract, leading it to misinterpret this as an excess reward. When the executeRewardDistributionYeet
function is subsequently called, it processes these “excess” tokens through the reward distribution mechanism. As a result, tokens that are not part of the users’ staked balances are used up, leaving the contract with insufficient tokens to cover legitimate withdrawal requests.
Exploitation of this vulnerability can lead to significant financial losses:
Insolvency: The contract becomes insolvent, as the reward distribution mechanism depletes tokens required for full user withdrawals.
User Funds Locked: Users may are unable to withdraw the full amount of their staked tokens, effectively locking their funds within the contract.
Link to the code: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol?utm_source=immunefi#L148-L150
Setup: Staking and Accounting
Owner stakes: 100 tokens
User stakes: 200 tokens
Total Supply: 100 + 200 = 300 tokens
Contract’s Token Balance: 300 tokens (all coming from proper stake calls)
Accumulation of “Excess Rewards”
Under normal operation, the contract is expected to receive rewards via methods such as depositReward()
. In addition, due to swap inefficiencies or operational residues, extra tokens may be added indirectly.
Suppose that, as a result of these inefficiencies, the contract ends up with an additional 100 tokens.
New Token Balance: 300 (from stakes) + 100 (from inefficiencies/rewards) = 400 tokens
The function accumulatedDeptRewardsYeet()
then computes the “excess” as:
(Note: Depending on timing, this difference may be larger if unstaking has already reduced totalSupply
.)
Reward Distribution and Balance Reduction
A manager calls executeRewardDistributionYeet()
. In this function:
The excess rewards amount is determined by calling accumulatedDeptRewardsYeet()
.
The code then executes:
This approves the external zapper contract to spend the calculated excess tokens.
Next, one of the following external calls is made:
Crucial Point: The StakeV2 contract does not itself subtract these tokens from its balance. Instead, the approved tokens are transferred out when the external zapper function executes. In other words, the reduction of the StakeV2 contract’s token balance happens indirectly within the zapper’s zapInToken0
or zapInToken1
functions.
Unstaking and the Insolvency Scenario
Suppose after this reward distribution the following occurs:
The recorded totalSupply
in the StakeV2 bookkeeping is adjusted only by user actions (for example, via startUnstake
), but the actual token balance in the contract has been reduced by the external zapper call.
Example Scenario with Numbers:
Before unstaking, assume that due to an unstake action the totalSupply
has been reduced to 200 tokens (because a user started unstaking 100 tokens), but the token balance was still 400 tokens.
When executeRewardDistributionYeet()
is called at that point, it computes:
These 200 tokens are then approved and passed to the zapper, which transfers them out.
After reward distribution: The actual token balance in the contract is now 400 - 200 = 200 tokens.
Now, if a user (with a remaining recorded stake of, say, 200 tokens) tries to fully withdraw via a call to rageQuit()
, the contract may not have enough tokens available due to the external zapper call having removed a significant portion of the tokens.
The result is that although the user’s balance (in the contract’s bookkeeping) indicates 200 tokens, the contract’s token balance is insufficient to honor the full withdrawal—effectively locking part of the user’s funds.
Balance Reduction Location: The StakeV2 contract itself does not contain a direct line of code that subtracts tokens from its balance during reward distribution. Instead, the tokens are moved out when the external zapper contract is called via:
or
These external functions are responsible for transferring the approved excess tokens away from the StakeV2 contract.
Validity of the Issue:
Because the reward distribution mechanism relies on the difference between the contract’s token balance and totalSupply
, and since an external call removes tokens (reducing the actual balance) without corresponding adjustments in totalSupply
, there is a risk that users will be unable to withdraw the full amount they are entitled to. This misalignment can lead to a situation where the contract appears solvent on paper but lacks sufficient tokens to satisfy withdrawal requests, thereby locking user funds.
Setup:
Owner stakes 150 tokens and user stakes 250 tokens, so the recorded total supply is 400 tokens.
The contract’s token balance is initially 400 tokens.
Accumulation of Excess Rewards:
Extra 150 tokens are added (to simulate inefficiencies), making the balance 550 tokens.
The excess rewards calculated become 550 – 400 = 150 tokens (though later, after unstake, the excess becomes 550 – 250 = 300 tokens).
Reward Distribution:
When executeRewardDistributionYeet()
is called, 300 tokens are approved for the external zapper call.
The external zapper (via zapInToken0
or zapInToken1
) transfers out these tokens, reducing the contract’s balance indirectly.
Unstaking and Insolvency:
With the bookkeeping still showing higher stakes but the actual token balance reduced by the external call, a user trying to withdraw their full recorded amount will find insufficient tokens available, effectively locking their funds.