#41280 [SC-High] Permanent freezing of yield due to incorrect reward handling in `StakeV2` claim functions
Was this helpful?
Was this helpful?
Submitted on Mar 13th 2025 at 09:39:03 UTC by @merlinboii for
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
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.
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 remaining token0Debt
and token1Debt
are transferred to _msgSender()
, which is now refer to msg.sender
(StakeV2
)
In claimRewardsInToken0() -> zapOutToToken0()
: The remaining token1Debt
is transferred to _msgSender()
, which is now refer to msg.sender
(StakeV2
)
In claimRewardsInToken1() -> zapOutToToken1()
: The remaining token0Debt
is transferred to _msgSender()
, which is now refer to msg.sender
(StakeV2
)
In claimRewardsInToken() -> zapOut()
: The remaining token0Debt
and token1Debt
are transferred to _msgSender()
, which is now refer to msg.sender
(StakeV2
)
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.
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 excess tokenXDebt - swapDataX.inputAmount
is sent to StakeV2
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 matches tokenXDebt
, making some amount of loss inevitable.
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.
The claim reward functions of StakeV2
: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol#L327-L394
The 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
Use the case claimRewardsInToken0()
for example, here's how users can permanently lose their remaining debt equivalent to a portion of their rewards:
User has staked YEET in StakeV2
and earned 500
trifecta vault shares as rewards, which can be redeemed into YEET-WBERA LP tokens
Current 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
and WBERA
received from LP removal (from current of Kodiak Island vault state).
The user intends to claim all rewards as YEET
by swapping WBERA
to YEET
.
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 swapping WBERA
to YEET
via OBRouter
and the given executor
:
Swap inputAmount
: 3.30 WBERA
Swap output
: 1,995 YEET
Balance 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()
)
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.