SECURITY AUDIT REGISTRY

Yieldoor

Leveraged yield-farming vaults — concentrated-liquidity positions on a CLMM DEX, with a lending pool funding the leverage.

SherlockSherlock
RANK 15thpayout $48
[H-01]high

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

In liquidatePosition:

solidity
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).
  • feeRecipient is still address(0).

Impact

Every profitable liquidation reverts, so undercollateralized positions cannot be liquidated and the fee mechanism is dead.

Proof of concept

solidity
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:

solidity
constructor(string memory name_, string memory symbol_, address _lendingPool, address _feeRecipient)
    Ownable(msg.sender) ERC721(name_, symbol_)
{
    lendingPool = _lendingPool;
    feeRecipient = _feeRecipient;
}
[M-01]medium

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

Leverager.sol#L231-L235:

solidity
} 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:

solidity
repayFromWithdraw = (80e6 < 100e6) ? 80e6 : 100e6; // 80e6
// debt -> 20e6, amountOut1 -> 0

Buggy behaviour:

solidity
repayFromWithdraw = (80e6 < 100e6) ? 35e6 : 100e6; // 35e6
// debt -> 65e6, amountOut1 -> 45e6 returned to user

Recommendation

Repay with token1's own collateral:

diff
- uint256 repayFromWithdraw = amount1 < owedAmount ? amountOut0 : owedAmount;
+ uint256 repayFromWithdraw = amount1 < owedAmount ? amountOut1 : owedAmount;
STATUS: AVAILABLE FOR ENGAGEMENTS

Open to audits, contract work, and security writing.