42292 sc high zapper wrong convertion of assets in zapout functions leads to partial loss of staking rewards
#42292 [SC-High] Zapper: Wrong Convertion of Assets in zapOut Functions Leads to Partial Loss of Staking Rewards
Submitted on Mar 22nd 2025 at 14:12:16 UTC by @Ace30 for Audit Comp | Yeet
Report ID: #42292
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol
Impacts:
Permanent freezing of unclaimed yield
Description
Brief/Introduction
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:
function claimRewardsInToken0(
uint256 amountToWithdraw,
IZapper.SingleTokenSwap calldata swapData,
IZapper.KodiakVaultUnstakingParams calldata unstakeParams,
IZapper.VaultRedeemParams calldata redeemParams
) external nonReentrant {
--snip--
uint256 receivedAmount = zapper.zapOutToToken0(msg.sender, swapData, unstakeParams, updatedRedeemParams);
}
This function internally calls `zapper.zapOutToToken0()`, which follows these steps:
1- Calls `_yeetOut()` to redeem vault shares and remove liquidity from Kodiak, receiving token0 and token1.
2- However, instead of swapping all received token1 into token0, it only swaps `swapData.inputAmount` of token1.
3- The remaining `token1Debt` is sent to msg.sender (which is StakeV2 - not user), causing users to lose part of their funds.
```solidity
function zapOutToToken0(
address receiver,
SingleTokenSwap calldata swapData,
KodiakVaultUnstakingParams calldata unstakeParams,
VaultRedeemParams calldata redeemParams
) public nonReentrant onlyWhitelistedKodiakVaults(unstakeParams.kodiakVault) returns (uint256 totalToken0Out) {
@1> (IERC20 token0, IERC20 token1, uint256 token0Debt, uint256 token1Debt) = _yeetOut(redeemParams, unstakeParams);
if (token0Debt == 0 && token1Debt == 0) {
return (0);
}
@2> token1Debt -= swapData.inputAmount;
token0Debt += _verifyTokenAndSwap(swapData, address(token1), address(token0), address(this));
_sendERC20Token(token0, receiver, token0Debt);
@3> _sendERC20Token(token1, _msgSender(), token1Debt);
return (token0Debt);
}
At step 2 (@2>
) three possible scenarios arise:
If
swap.inputAmount > token1Debt
then the function will revert because of underflowIf
swap.inputAmount < token1Debt
then the remainingtoken1Debt
is sent toStakeV2
, 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
References
Zapper.zapOutToToken0(): https://github.com/immunefi-team/audit-comp-yeet/blob/da15231cdefd8f385fcdb85c27258b5f0d0cc270/src/contracts/Zapper.sol#L249
Proof of Concept
Proof of Concept (PoC)
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 receivedtoken1
will be converted totoken0
.
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
oftoken1
intotoken0
.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.
Was this helpful?