Yieldoor
Leveraged yield-farming vaults — concentrated-liquidity positions on a CLMM DEX, with a lending pool funding the leverage.
Liquidation reverts because feeRecipient is never initialized in Leverager.sol
Summary
feeRecipient in Leverager.sol is never set to a non-zero address. When a liquidation is profitable (collateral value exceeds borrowed value), the protocol tries to transfer protocol fees to feeRecipient. Because it is address(0), SafeERC20's safeTransfer reverts and the whole liquidation fails.
Root cause
if (totalValueUSD > borrowedValue) {
uint256 protocolFeePct = 1e18 * liquidationFee * (totalValueUSD - borrowedValue) / (totalValueUSD * 10_000);
uint256 pf0 = protocolFeePct * amount0 / 1e18;
uint256 pf1 = protocolFeePct * amount1 / 1e18;
if (pf0 > 0) IERC20(up.token0).safeTransfer(feeRecipient, pf0);
if (pf1 > 0) IERC20(up.token1).safeTransfer(feeRecipient, pf1);
amount0 -= pf0;
amount1 -= pf1;
}
feeRecipient is never assigned in the constructor or an initializer, so it defaults to address(0), and ERC20 transfers to the zero address revert.
Pre-conditions
- ›A leveraged position exists that meets the liquidation condition (
totalValueUSD > borrowedValue). - ›
feeRecipientis stilladdress(0).
Impact
Every profitable liquidation reverts, so undercollateralized positions cannot be liquidated and the fee mechanism is dead.
Proof of concept
function test_liquidationFailsWhenFeeRecipientIsZero() public {
address fee = ILeverager(leverager).feeRecipient();
assertEq(fee, address(0), "feeRecipient must be zero for this test");
vm.startPrank(depositor);
ILeverager.LeverageParams memory lp;
lp.amount0In = 0.5e8; // 0.5 WBTC
lp.amount1In = 50_000e6; // 50,000 USDC
lp.vault0In = 1e8;
lp.vault1In = 100_000e6;
lp.vault = vault;
lp.maxBorrowAmount = 200e18;
lp.denomination = address(weth);
IMainnetRouter.ExactOutputParams memory ep;
ep.path = abi.encodePacked(address(wbtc), uint24(3000), address(weth));
ep.deadline = block.timestamp;
ep.amountInMaximum = type(uint256).max;
ep.recipient = leverager;
lp.swapParams1 = abi.encode(ep);
ep.path = abi.encodePacked(address(usdc), uint24(3000), address(weth));
lp.swapParams2 = abi.encode(ep);
deal(address(wbtc), depositor, 0.5e8);
deal(address(usdc), depositor, 50_000e6);
wbtc.approve(leverager, type(uint256).max);
usdc.approve(leverager, type(uint256).max);
uint256 posId = ILeverager(leverager).openLeveragedPosition(lp);
vm.stopPrank();
// Push the denomination price so totalValueUSD > borrowedValue.
MockOracle(wethOracle).setPrice(6000e18);
assertTrue(ILeverager(leverager).isLiquidateable(posId));
vm.startPrank(liquidator);
ILeverager.LiquidateParams memory liqParams;
liqParams.id = posId;
liqParams.hasToSwap = false;
weth.approve(leverager, type(uint256).max);
vm.expectRevert(); // safeTransfer to address(0) reverts
ILeverager(leverager).liquidatePosition(liqParams);
vm.stopPrank();
}
Recommendation
Set feeRecipient to a valid non-zero address at deployment or via a protected setter:
constructor(string memory name_, string memory symbol_, address _lendingPool, address _feeRecipient)
Ownable(msg.sender) ERC721(name_, symbol_)
{
lendingPool = _lendingPool;
feeRecipient = _feeRecipient;
}
Withdraw under-repays debt for token1-borrowed positions, leaving them undercollateralized
Summary
When a user withdraws from a leveraged position where token1 (e.g. USDC) is the borrowed asset, the contract should repay debt with the withdrawn token1 collateral. A coding error makes it use the withdrawn token0 amount instead. Because the two tokens have different decimals and value, the repayment is under-calculated and the user walks away with collateral that should have covered the debt.
Root cause
} else if (up.denomination == up.token1) {
uint256 repayFromWithdraw = amount1 < owedAmount ? amountOut0 : owedAmount;
owedAmount -= repayFromWithdraw;
amountOut1 -= repayFromWithdraw;
}
The branch compares against the withdrawn token1 amount (amount1 / amountOut1) but then repays amountOut0 — the token0 amount. WBTC (8 decimals) and USDC (6 decimals) are not interchangeable, so the repayment is wrong.
Impact
The debt repayment is under-calculated. Example: instead of repaying 80 USDC and cutting debt to 20 USDC, it repays only ~35 USDC (the token0-derived figure), leaving 65 USDC of debt and returning ~45 USDC of collateral that should have been used for repayment. Repeated withdrawals let a user extract more collateral than intended, leaving positions undercollateralized.
Proof of concept
Position with 100 USDC debt for the withdrawn portion. On withdrawal the vault returns 80 USDC (amountOut1) and 0.0007 WBTC (amountOut0, ≈ 35 USDC).
Correct behaviour:
repayFromWithdraw = (80e6 < 100e6) ? 80e6 : 100e6; // 80e6
// debt -> 20e6, amountOut1 -> 0
Buggy behaviour:
repayFromWithdraw = (80e6 < 100e6) ? 35e6 : 100e6; // 35e6
// debt -> 65e6, amountOut1 -> 45e6 returned to user
Recommendation
Repay with token1's own collateral:
- uint256 repayFromWithdraw = amount1 < owedAmount ? amountOut0 : owedAmount;
+ uint256 repayFromWithdraw = amount1 < owedAmount ? amountOut1 : owedAmount;