#42548 [SC-High] Remaining token0 and token1 sent from Zapper to StakeV2 will be permanently locked in StakeV2 forever.
Was this helpful?
Was this helpful?
Submitted on Mar 24th 2025 at 15:42:08 UTC by @kaysoft for
Report ID: #42548
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol
Impacts:
Permanent freezing of unclaimed yield
In StakeV2.sol, every function to claim reward integrates the Zapper.sol.
The zapOut(...), zapOutNative(...), zapOutToToken1(...) and zapOutToToken0(...) all returned unused token0Debt and token1Debt to msg.sender but the StakeV2 does not send them to the user.
For example, one of the functions integrated by StakeV2, ZapOutNative(...) function warns in its last comment which I quote:
@dev Bera is sent to the receiver. Any extra token0 and token1 is sent back to the _msgSender().
@dev integrating contracts must handle any returned token0 and token1
Truly this extra token0 and token1 are sent back to the StakeV2.sol but StakeV2.sol does not send this extra token0 and token1 to the user that calls the claimRewardsInNative(...)
function causing the user to lose the extra token0 and token1.
The major issue is that after the following functions in Zapper.sol does all the redeeming, removing Liquidity, and swapping, the _clearUserDebt(...)
sends back the remaining token0Debt
and tokenDebt
to _msgSender()
.
This is because _msgSender()
is passed to the _clearUserDebt(...)
function as the receiver.
the claimRewardsInNative(...)
function of StakeV2.sol calls the zap.zapOutNative(...). So the _clearUserDebt(...)
in zapOutNative(...)
will send the remaining token0Debt
and token1Debt` to StakeV2.sol.
However this remaining token0Debt
and token1Debtsent to the StakeV2.sol are not sent to the user but locked in the StakeV2.sol contract because after calling the
zap.zapOutNative(...)function,
claimRewardsInNative(...)` only emitted an event.
This can even cause the user to lose all their rewards to StakeV2.sol if they pass swap0.inputAmount
and swap1.inputAmount
both as zero.
The zapOut(...), zapOutNative(...), zapOutToToken1(...) and zapOutToToken0(...) all return the remaining tokens to StakeV2.sol but those returned tokens are not sent to the user but locked in StakeV2.sol forever.
Let's look at the zapOut(...) function where token0Debt and token1Debt is sent to msg.sender in this case StakeV2.sol is the msg.sender.
Let's look at another function where remaining token is sent to msg.sender
(StakeV2.sol).
Zapper.sol handles redemption and removing liquidity to get the token0
and token1
and also swap it to the desired output tokens.
During this swap By Zapper.sol, swap0.inputAmount
is deducted from token0Debt
amount and the remaining token0Debt is sent back to the msg.sender
which is the StakeV2.sol. The issue is that the StakeV2 does not send the remaining token0Debt
and token1Debt
to the user.
Loss of token0 and token1 that are not swapped to the StakeV2 contract
The above token0 and token1 that are not swapped will be locked in StakeV2 contract forever.
This loss is highly likely to occur for all users because users are not sure of the exact output amounts of token0 and token1 from the removeLiquidity operation that's why users supply unstakeParams.amount0Min
and unstakeParams.amount0Min
.
Users are allowed to pass in swap0.inputAmount
that is zero and in this case all rewards will be lost because when swap0.inputAmount
is zero, verifyAndSwapToken(...)
returns zero and execution continues with the token0Debt being the full amount of the reward.
Consider sending the remaining token0Debt
and token1Debt
returned to StakeV2.sol from Zapper to the user that is claiming their reward.
Bob deposits to StakeV2.sol
Bob earns reward
Bob calls claimRewardsInToken(...)
function to claim all his reward while passing swap0.inputAmount
and swap1.inputAmount
both as 80 as input to the claimRewardsInToken(...)
function function call.
100 of both token0 and token1 where received by the Zapper during removeLiquidity.
Bob receives 80 token0 and token1 worth of output
token from the swap during the transaction.
The remaining 20 token0 and 20 token1 are sent back to StakeV2.sol
Bob losts 20 token0 and 20 token1 amounts to the StakeV2.sol which is locked in the contract forever.
The Zapper.sol#zapOut(...) function below with _clearDebt(...)
sending the remaining token to StakeV2.sol