#41280 [SC-High] Permanent freezing of yield due to incorrect reward handling in `StakeV2` claim functions
Submitted on Mar 13th 2025 at 09:39:03 UTC by @merlinboii for Audit Comp | Yeet
Report ID: #41280
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Permanent freezing of unclaimed yield
Description
Brief/Intro
Users claiming rewards through StakeV2
's claim functions: claimRewardsInNative()
, claimRewardsInToken0()
, claimRewardsInToken1()
and claimRewardsInToken()
, which are designed to handle reward claims in a single output token (either native, token0, token1, or a whitelisted token) can permanently lose access to a portion of their rewards.
This occurs because the Zapper
contract incorrectly sends any remaining token debt to StakeV2
instead of the user who initiated the claim. Since StakeV2
has no mechanism to recover or redistribute these tokens, the lost funds become permanently inaccessible to the user.
Vulnerability Details
This vulnerability stems from a flawed interaction between StakeV2’s claim functions and Zapper’s token return logic when users opt to swap their rewards into a single output token:
In
claimRewardsInNative() -> zapOutNative() -> _swapToWBERA()
: The remainingtoken0Debt
andtoken1Debt
are transferred to_msgSender()
, which is now refer tomsg.sender
(StakeV2
)
function _swapToWBERA(
...
) internal returns (uint256 wBeraDebt) {
if (address(token0) == address(wbera)) {
wBeraDebt += token0Debt;
token0Debt = 0;
} else {
wBeraDebt += _verifyTokenAndSwap(swapData0, address(token0), address(wbera), address(this));
token0Debt -= swapData0.inputAmount;
}
if (address(token1) == address(wbera)) {
wBeraDebt += token1Debt;
token1Debt = 0;
} else {
wBeraDebt += _verifyTokenAndSwap(swapData1, address(token1), address(wbera), address(this));
token1Debt -= swapData1.inputAmount;
}
// log yeetBalance
@> _clearUserDebt(token0, token1, token0Debt, token1Debt, _msgSender());
}
In
claimRewardsInToken0() -> zapOutToToken0()
: The remainingtoken1Debt
is transferred to_msgSender()
, which is now refer tomsg.sender
(StakeV2
)
function zapOutToToken0(
...
) public nonReentrant onlyWhitelistedKodiakVaults(unstakeParams.kodiakVault) returns (uint256 totalToken0Out) {
--- SNIPPED ---
token1Debt -= swapData.inputAmount;
token0Debt += _verifyTokenAndSwap(swapData, address(token1), address(token0), address(this));
_sendERC20Token(token0, receiver, token0Debt);
@> _sendERC20Token(token1, _msgSender(), token1Debt);
return (token0Debt);
}
In
claimRewardsInToken1() -> zapOutToToken1()
: The remainingtoken0Debt
is transferred to_msgSender()
, which is now refer tomsg.sender
(StakeV2
)
function zapOutToToken1(
...
) public nonReentrant onlyWhitelistedKodiakVaults(unstakeParams.kodiakVault) returns (uint256 totalToken1Out) {
--- SNIPPED ---
token0Debt -= swapData.inputAmount;
token1Debt += _verifyTokenAndSwap(swapData, address(token0), address(token1), address(this));
@> _sendERC20Token(token0, _msgSender(), token0Debt);
_sendERC20Token(token1, receiver, token1Debt);
return (token1Debt);
}
In
claimRewardsInToken() -> zapOut()
: The remainingtoken0Debt
andtoken1Debt
are transferred to_msgSender()
, which is now refer tomsg.sender
(StakeV2
)
function zapOut(
...
)
public
override
nonReentrant
onlyWhitelistedKodiakVaults(unstakeParams.kodiakVault)
returns (uint256 totalAmountOut)
{
--- SNIPPED ---
} else {
// sent directly to receiver. What is receiver is zapper?
totalAmountOut += _verifyTokenAndSwap(swap0, address(token0), outputToken, receiver);
token0Debt -= swap0.inputAmount;
totalAmountOut += _verifyTokenAndSwap(swap1, address(token1), outputToken, receiver);
token1Debt -= swap1.inputAmount;
}
@> _clearUserDebt(token0, token1, token0Debt, token1Debt, _msgSender());
}
In the
StakeV2
, there is no logic to handle the remaining token debt, whether after calling the Zapper function or an external function to handle this case.
Impact Details
Users lose access to their full earned rewards when claiming through StakeV2
if they opt to receive a single output token (Native, Token0, Token1, or a whitelisted token
) and do not perfectly specify the swap amount.
If the user specifies
swapDataX.inputAmount < tokenXDebt
(the amount received after removing liquidity from Kodiak), the excesstokenXDebt - swapDataX.inputAmount
is sent toStakeV2
instead of the user and users permanently lose access to a portion of their earned rewards.It is not feasible for users to perfectly predict
swapDataX.inputAmount
as the remove liquidity function only guarantees slippage protection but cannot provide an exact output amount so users cannot reliably specify an inputAmount that exactly matchestokenXDebt
, making some amount of loss inevitable.
function _approveAndUnstakeFromKodiakVault(
IZapper.KodiakVaultUnstakingParams calldata unstakeParams,
uint256 islandTokenDebt
) internal returns (IERC20, IERC20, uint256, uint256) {
--- SNIPPED ---
(uint256 _amount0, uint256 _amount1,) = kodiakStakingRouter.removeLiquidity(
IKodiakVaultV1(unstakeParams.kodiakVault),
islandTokenDebt,
@> unstakeParams.amount0Min,
@> unstakeParams.amount1Min,
unstakeParams.receiver
);
// require(islandTokenDebt == _liqBurned, "Invalid island token burn amount");
return (_token0, _token1, _amount0, _amount1);
}
The severity assessment
This issue leads to Permanent Freezing of Unclaimed Yield
under the intended functionality of StakeV2
’s claiming functions: claimRewardsInNative()
, claimRewardsInToken0()
, claimRewardsInToken1()
and claimRewardsInToken()
. The intended functionality of these functions is to return rewards in a single output token, but due to this bug, users permanently lose access to a portion of their rewards.
Although there is a workaround (users can set unstakeParams.receiver != address(this)
, bypassing the swap and receiving token0
and token1
directly), users may not be aware of this workaround and the default intention behavior encourages using the claim functions, which exposes users to this bug.
A case could be made for Low
Severity (Contract fails to deliver promised returns but doesn’t lose value
), as the lost yield remains within the contract rather than being drained or stolen, However, because the logic directly impacts user funds (yield) and leads to irreversible loss in most cases, I believe a Medium
severity rating is more appropriate.
References
The claim reward functions of
StakeV2
: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol#L327-L394The related Zapper's functions: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol#L249-L353
The related vulnerable lines:
https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol#L262
https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol#L284
https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol#L310
https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol#L622
https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/contracts/Zapper.sol#L352
Proof of Concept
Proof of Concept
Use the case claimRewardsInToken0()
for example, here's how users can permanently lose their remaining debt equivalent to a portion of their rewards:
Prerequisites
User has staked YEET in
StakeV2
and earned500
trifecta vault shares as rewards, which can be redeemed into YEET-WBERA LP tokensCurrent YEET-WBERA Kodiak Island ratio:
1 YEET = 0.00167 BERA
(token0 = YEET
,token1 = WBERA
)Expected rewards after removing LP:
2,000 YEET + 3.34 WBERA
What the user can estimate (but not precisely predict at execution time):
The amount of vault shares available for redemption (
calculateRewardsEarned()
).The amount of
YEET
andWBERA
received from LP removal (from current of Kodiak Island vault state).
Vulnerability Flow
The user intends to claim all rewards as
YEET
by swappingWBERA
toYEET
.
StakeV2.claimRewardsInToken0(
500 shares, // Amount of vault shares to redeem
IZapper.SingleTokenSwap({
inputAmount: 3.30 * 1e18, // User estimates to swap all output WBERA to YEET
outputQuote: 2,000 * 1e18, // Quote amount for YEET
outputMin: 1,995 * 1e18, // Slippage for YEET
executor: address(executor),
path: swapPathData
}),
IZapper.KodiakVaultUnstakingParams({
kodiakVault: address(YEET_BERA_KODIAK_ISLAND),
amount0Min: 1,995 * 1e18, // Slippage for YEET
amount1Min: 3.30 * 1e18, // Slippage for WBERA
receiver: address(zapper), // Opt in swap process
}),
redeemParams // the amount to claim will be prepared in `StakeV2._verifyAndPrepareClaim()`
);
The actual amount from Kodiak remove liquidity:
YEET received: 2,010 YEET
WBERA received: 3.34 WBERA
These amounts include
LP fees
and potential price impact variations.
The swap processes a fixed
inputAmount
for swappingWBERA
toYEET
viaOBRouter
and the givenexecutor
:
Swap
inputAmount
: 3.30 WBERASwap
output
: 1,995 YEETBalance Zapper after swap:
YEET: 2,010 (from LP) + 1,995 (swap output) = 4,005 YEET
WBERA: 3.34 (from LP) - 3.30 (swapped) = 0.04 WBERA
Zapper processes to transfer remaining Debt:
YEET: 4,005 YEET -> sent to user (
receiver
)WBERA: 0.04 WBERA -> sent to
StakeV2
(_msgSender()
)
Final State: User's loss 0.04 WBERA and permanently locked in the StakeV2
StakeV2
Additional Note: If the lost amount is in YEET
, it will be incorrectly redistributed as extra rewards via: StakeV2.executeRewardDistributionYeet()
so this creates unfair rewards distribution, benefiting other claimers.
Was this helpful?