The leftover tokens after a zapping action is sent from Zapper to the contract StakeV2. These tokens are unhandled by StakeV2, causing the tokens to be permanently stuck in the contract
Vulnerability Details
Users can claim staking rewards in either token0, token1, native token or whitelisted token through functions StakeV2#claimRewardsInNative(), StakeV2#claimRewardsInToken0(), StakeV2#claimRewardsInToken1(), StakeV2#claimRewardsInToken()
In the above functions, the user will spend vault shares and call Zapper contract to handle zap out the position to the wanted token.
For example with the function StakeV2#claimRewardsInToken1(), the contract StakeV2 calls Zapper#zapOutToToken1() to redeem from Vault to receive islandTokens which is then used to remove liquidity from KodiakVault to finally receive pool's token0 and token1. After received token0 and token1, the Zapper contract calls OB SwapRouter to swap token0 --> token1 (here user claims rewards in token1). After swap, all unused token0 are sent back to StakeV2 contract, and all token1 are sent to the user. Here, the function StakeV2#claimRewardsInToken1() does not handle the unused/leftover token0 sent from Zapper contract, which can cause the tokens to stuck in the contract
// StakeV2 contract
function claimRewardsInToken1(
uint256 amountToWithdraw,
IZapper.SingleTokenSwap calldata swapData,
IZapper.KodiakVaultUnstakingParams calldata unstakeParams,
IZapper.VaultRedeemParams calldata redeemParams
) external nonReentrant {
_updateRewards(msg.sender);
IZapper.VaultRedeemParams memory updatedRedeemParams = _verifyAndPrepareClaim(amountToWithdraw, redeemParams);
IERC20(redeemParams.vault).approve(address(zapper), amountToWithdraw);
uint256 receivedAmount = zapper.zapOutToToken1(msg.sender, swapData, unstakeParams, updatedRedeemParams); // <<<<< call Zapper to handle zap out to token1
/// HERE lack the logics to handle the leftover tokens sent from Zapper contract
emit Claimed(msg.sender, receivedAmount);
}
////////////
// Zapper contract
function zapOutToToken1(
address receiver,
SingleTokenSwap calldata swapData,
KodiakVaultUnstakingParams calldata unstakeParams,
VaultRedeemParams calldata redeemParams
) public nonReentrant onlyWhitelistedKodiakVaults(unstakeParams.kodiakVault) returns (uint256 totalToken1Out) {
(IERC20 token0, IERC20 token1, uint256 token0Debt, uint256 token1Debt) = _yeetOut(redeemParams, unstakeParams); // << handle redeem and remove liquidity to get token0, token1
if (token0Debt == 0 && token1Debt == 0) {
return (0);
}
token0Debt -= swapData.inputAmount;
token1Debt += _verifyTokenAndSwap(swapData, address(token0), address(token1), address(this)); // << handle swap token0 to token1
_sendERC20Token(token0, _msgSender(), token0Debt); // << refund token0 to msg.sender, which is Stake contract
_sendERC20Token(token1, receiver, token1Debt); // << send token1 to user as rewards
return (token1Debt);
}
Indeed, if the liquidity pair is BERA-YEET, then the tokens can be used for the next reward distributions. However, these tokens should be entitled to the user, not for the reward distributions because it is redeemed from user shares amount
Impact Details
Users can not get the tokens that should be entitled to receive and the tokens are distributed among other stakers
1.Update the test test_successful_zap_out_yeet at file test/zapper/ZapOut.t.sol
function test_successful_zap_out_yeet() public {
// 1% slippage during LP redeem
IZapper.SingleTokenSwap memory swapInfo = prepareSwapInfo(
amount1,
97842998386015666176, // assuming 2% slippage
95886138418295352852, // assuming 2% slippage
0xDa547d8ce09e23E9e8053dd187B58841B5fB8D5d,
vm.parseBytes(
"0x1740F679325ef3686B2f574e392007A92e4BeD417bC98B68bCBb16cEC81EdDcEa1A3746Fdc5025A401017507c1dc16935B82698e4C63f2746A2fCf994dF802e79e01B6a43bc17680fb67fD8371977d264E047f47c67500Da547d8ce09e23E9e8053dd187B58841B5fB8D5dffff00692bB44820568223798f2577D092BAf0d696dd4701Da547d8ce09e23E9e8053dd187B58841B5fB8D5d000bb801d6D83aF58a19Cd14eF3CF6fe848C9A4d21e5727c01ffff0193439A0E080805b5a147019410B3CeAf12bF4AE900Da547d8ce09e23E9e8053dd187B58841B5fB8D5d01277aaDBd9ea3dB8Fe9eA40eA6E09F6203724BdaE01ffff01EA2e981f185e6A4B53eb1B72792CF02e2EBCbDcB00Da547d8ce09e23E9e8053dd187B58841B5fB8D5d010E4aaF1351de4c0264C5c7056Ef3777b41BD8e030280150027882A7D759355842294A846F039c8e29EC0e3d501Da547d8ce09e23E9e8053dd187B58841B5fB8D5d000bb8ffff01246c12D7F176B93e32015015dAB8329977de981B011E55c4C69acAeb49b2834FF5Bc5D8De5d716B39004f5AFCF50006944d17226978e594D4D25f4f92B40001E55c4C69acAeb49b2834FF5Bc5D8De5d716B39000Da547d8ce09e23E9e8053dd187B58841B5fB8D5d000bb8"
)
);
IZapper.SingleTokenSwap memory noSwap;
(IZapper.VaultRedeemParams memory vaultParams, IZapper.KodiakVaultUnstakingParams memory islandUnstakingParams)
= prepareVaultRedeemAndUnstakeParams(moneyBrinter, vaultShares, 100, amount0, amount1, 100, zapper, zapper);
vm.prank(alice);
address staking = makeAddr('staking');
IERC20(moneyBrinter).transfer(staking, vaultParams.shares);
approveVaultTokens(staking, moneyBrinter, vaultParams.shares);
vm.prank(staking);
uint256 amountOut = contracts.zapper.zapOutToToken0(alice, swapInfo, islandUnstakingParams, vaultParams);
console.log("Token Out: ", amountOut);
verifyMinOutputTokens(
yeet, islandUnstakingParams.amount0Min, islandUnstakingParams.amount1Min, noSwap, swapInfo, amountOut
);
verifyNoBalanceInZapper();
vm.assertEq(IERC20(yeet).balanceOf(staking), 0, "yeet balance in staking is not 0, should belong to alice");
vm.assertEq(IERC20(Wbera).balanceOf(staking), 0, "Wbera balance in staking is not 0, should belong to alice");
}
Run the test and console shows
Failing tests:
Encountered 1 failing test in test/zapper/ZapOut.t.sol:ZapOut
[FAIL: Wbera balance in staking is not 0, should belong to alice: 440897924579083 != 0] test_successful_zap_out_yeet() (gas: 1621201)
The result shows that YEET is sent to Alice successfully, but WBERA is not