#42732 [SC-High] Incomplete token return whena user claim his rewards leads to rewards fund loss
Submitted on Mar 25th 2025 at 13:54:49 UTC by @Le_Rems for Audit Comp | Yeet
Report ID: #42732
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol
Impacts:
Theft of unclaimed yield
Theft of unclaimed royalties
Description
Description
If users claim rewards using claimRewardsInToken0
or claimRewardsInToken1
, the Zapper contract may return unused tokens to the StakeV2
contract instead of to the user who initiated the claim.
The root cause is in the Zapper contract's token handling logic. When redeeming vault shares, the Zapper receives both token0 and token1. It then swaps a portion of one token for the other based on parameters provided by the user. However, any remaining tokens that aren't swapped are sent back to the calling contract (StakeV2
) rather than to the user who initiated the claim.
This behavior is highly likely, as market conditions will change between the time the user calculates the swap inputs off-chain and when the transaction is executed.
This results in users receiving only part of their earned rewards, with the remainder being trapped in the StakeV2
contract where they effectively become part of the pool's general rewards, benefiting all stakers rather than the specific user who earned them.
Recommendations
Approach 1: Modify Zapper to send all tokens to the receiver
Update the zapOutToToken0
and zapOutToToken1
functions in the Zapper contract to send all tokens to the specified receiver rather than sending some back to the caller:
// In Zapper::zapOutToToken0
function zapOutToToken0(
address receiver,
SingleTokenSwap calldata swapData,
KodiakVaultUnstakingParams calldata unstakeParams,
VaultRedeemParams calldata redeemParams
) public nonReentrant onlyWhitelistedKodiakVaults(unstakeParams.kodiakVault) returns (uint256 totalToken0Out) {
(IERC20 token0, IERC20 token1, uint256 token0Debt, uint256 token1Debt) = _yeetOut(redeemParams, unstakeParams);
if (token0Debt == 0 && token1Debt == 0) {
return (0);
}
token0Debt -= swapData.inputAmount;
token1Debt += _verifyTokenAndSwap(swapData, address(token0), address(token1), address(this));
// Send all tokens to the receiver instead of sending token0 back to _msgSender()
_sendERC20Token(token0, receiver, token0Debt);
_sendERC20Token(token1, receiver, token1Debt);
return (token1Debt);
}
Proof of Concept
Proof of Concept
A user has earned rewards and calls
StakeV2::claimRewardsInToken0
to claim themStakeV2
callsZapper::zapOutToToken0
which:Redeems vault shares, receiving both token0 and token1
Swaps a portion of token1 for token0 based on the
swapData
parameterSends token0 to the user (receiver)
Sends any remaining token1 back to
StakeV2
(the_msgSender()
)
The relevant code in
Zapper::zapOutToToken0
:
token0Debt -= swapData.inputAmount;
token1Debt += _verifyTokenAndSwap(swapData, address(token0), address(token1), address(this));
// @audit-issue token remainder will be sent back to StakerV2 --> inflating other users shares and not sending them back to the legitmate user
_sendERC20Token(token0, _msgSender(), token0Debt);
_sendERC20Token(token1, receiver, token1Debt);
The issue is that
token0Debt
(remaining token0) is sent to_msgSender()
(theStakeV2
contract) instead of to the user who initiated the claim.These tokens become "stuck" in the
StakeV2
contract and are counted as part ofaccumulatedDeptRewardsYeet()
, which distributes them to all stakers rather than returning them to the original user.
Was this helpful?