In the Zapper contract, the function zapOutToToken0 calls _yeetOut to convert vault share tokens into token0 and token1.
However, after receiving tokens, it does not swap all amount of the received token1 (token1Debt) into token0. Instead, it only swaps swapData.inputAmount (a user-specified amount) of token1. Additionally, the remaining token1Debt is sent to msg.sender (which is StakeV2) and user does not get this part of his funds.
The same issue occurs in zapOutToToken1, zapOutNative, and zapOut, leading to a partial swap of assets and loss of funds for users.
Vulnerability Details
Users can claim staking rewards in token0 by calling StakeV2.claimRewardsInToken0 as shown below:
At step 2 (@2>) three possible scenarios arise:
If swap.inputAmount > token1Debt then the function will revert because of underflow
If swap.inputAmount < token1Debt then the remaining token1Debt is sent to StakeV2, resulting in a loss for the user.
If swap.inputAmount = token1Debt No issue occurs, but this scenario is very rare since amount of token 1 received (token1Debt) fluctuates due to changes in vault share price and Kodiak liquidity.
Note: Since any action on Kodiak Island affects the received amount of token0 and token1, in the current codebase, users must provide an additional 1-2% inputAmount to account for these fluctuations and prevent reverts.
Note Similar issue happens in other StakeV2 claim functions too: claimRewardsInToken1, claimRewardsInToken, and claimRewardsInNative.
Solution
A better solution is to fully convert the received token1 into token0. To determine the minAmountOut for the swap (to mitigate slippage), the protocol can obtain the user’s acceptable slippage percentage, retrieve the on-chain price from the swapper, and calculate the minAmountOut accordingly.
Impact Details
All users trying to claim their staking rewards will experience either transaction revert or a partial loss of rewards
Step 1: User Initiates Staking Reward Claim
The user calls StakeV2.claimRewardsInToken0() to redeem 100 vault share tokens and receive token0 as a reward.
Based on on-chain data, the user expects to receive:
200 token0
100 token1
The user sets swapData.inputAmount = 100, expecting that all received token1 will be converted to token0.
Step 2: zapOutToToken0() Execution Begins
zapOutToToken0() is called within StakeV2.claimRewardsInToken0().
Internally, it calls _yeetOut() to:
Withdraw from the farm
Redeem vault shares from the Kodiak vault
Remove liquidity from Kodiak Island
Due to on-chain fluctuations (other transactions affecting liquidity), the user actually receives:
210 token0
110 token1
Step 3: Partial Token1 Swap Occurs
The contract executes the following steps inside zapOutToToken0():
Swaps swapData.inputAmount = 100 of token1 into token0.
Assume the swap gives back 200 token0.
Now, the contract holds:
210 (original) + 200 (swap) = 410 token0
110 (original) - 100 (swapped) = 10 token1
Step 4: Incorrect Fund Distribution
After the swap, the contract distributes funds:
User receives:
410 token0
StakeV2 (instead of the user) receives:
10 token1 (remaining token1Debt)
Expected Behavior:
The contract should have converted the full 110 token1 into token0, ensuring the user gets their full expected amount.
Actual Issue:
Only 100 token1 was converted, leaving 10 token1 unconverted.
The unconverted 10 token1 is sent to StakeV2 instead of the user, resulting in a loss of funds.