Skip to content
Request an audit

The liquidation floor is sized from the wrong maximum leverage

TitleBase calculation in Leverager::isLiquidateable() is incorrect as the max leverage may be smaller
Reward$1009, 3 finders
ContestYieldoor - 24 February 2025 on Sherlock
Author0x73696d616f (0xSimao)
ContextLiquidation threshold

Yieldoor's Leverager lets you borrow from a lending pool and put the borrowed funds into a concentrated liquidity vault. Whether that position can be liquidated comes down to one comparison, and the right hand side of it is derived from the maximum leverage the protocol allows.

There are two maximum leverages. The check reads one of them.

Both ceilings apply when the position opens

_checkWithinlimits runs on every open, and reads a per vault limit alongside a per asset limit from the lending pool:

solidity
459function _checkWithinlimits(Position memory up) internal {460    VaultParams memory vp = vaultParams[up.vault];461@>    (uint256 maxIndividualBorrow, uint256 maxLevTimes) =462        ILendingPool(lendingPool).getLeverageParams(up.denomination);463464    uint256 positionLeverage = (up.initCollateralValue + up.borrowedAmount) * 1e18 / up.initCollateralValue;465466    require(up.initBorrowedUsd >= minBorrow, "position must be at least minBorrow amount");467@>    require(positionLeverage <= vp.maxTimesLeverage && positionLeverage <= maxLevTimes, "too high x leverage");468    require(up.initBorrowedUsd <= vp.maxUsdLeverage, "too high borrow usd amount");469    require(up.borrowedAmount <= maxIndividualBorrow, "too high borrow for the vault");

vp.maxTimesLeverage is the vault's own limit. maxLevTimes comes from the lending pool and is set per borrowed asset, so the same vault has a different ceiling depending on what you borrow against it. Both are enforced with a single &&, which means the real ceiling is whichever of the two is smaller.

Only one of them applies when it is checked

The tail of isLiquidateable:

solidity
403    uint256 bIndex = ILendingPool(lendingPool).getCurrentBorrowingIndex(pos.denomination);404    uint256 owedAmount = pos.borrowedAmount * bIndex / pos.borrowedIndex;405406    /// here we make a calculation what would be the necessary collateral407    /// if we had the same borrowed amount, but at max leverage. Check docs for better explanation why.408@>    uint256 base = owedAmount * 1e18 / (vp.maxTimesLeverage - 1e18);409    base = base < pos.initCollateralValue ? base : pos.initCollateralValue;410411    if (owedAmount > totalDenom || totalDenom - owedAmount < vp.minCollateralPct * base / 1e18) return true;412    else return false;413}

The comment on line 406 says what base is meant to be: the collateral a position would have needed to borrow this much at maximum leverage. Leverage here is (collateral + borrowed) / collateral, so inverting it for the collateral gives owed / (maxLeverage - 1), which is line 408. The liquidation floor is then minCollateralPct of that.

Line 408 only knows about vp.maxTimesLeverage. maxLevTimes is never read in this function.

The floor lands too low

base divides by maxLeverage - 1e18, so a larger divisor gives a smaller base and a smaller floor. Using the vault ceiling when the pool ceiling is tighter is exactly the case where the divisor is too large.

vp.maxTimesLeveragemaxLevTimesReal ceilingDivisor usedDivisor that appliesbase is
5e185e185x4e184e18correct
5e183e183x4e182e18half of it
5e182e182x4e181e18a quarter of it
10e182e182x9e181e18a ninth of it

The gap opens precisely when the lending pool tightens its limit below the vault's, which is what a pool does when the borrowed asset gets riskier.

Impact

isLiquidateable returns false while the position sits below the collateral floor the configuration implies, so no liquidator calls it and the collateral keeps falling. The liquidation that eventually happens starts from a smaller buffer, and if the collateral falls past the debt first the shortfall is bad debt on the lending pool.

Alpha: when a value is bounded in two places, find every consumer and check each one applies the same bound. Opening took the minimum of two ceilings through an &&. Liquidating took one of the two terms. A limit written as a compound require at the entry point and as a single variable everywhere else is the shape to look for. getLeverageParams returns two values here and only one of them is read a second time.

Conclusion

The fix is to take the minimum of vp.maxTimesLeverage and maxLevTimes before subtracting 1e18. It paid $1009, split three ways. Nothing about it needs deep protocol knowledge: the require that gates the open has two conditions and the check that gates the liquidation has one.

See more of the Yieldoor audit contest

Message on Telegram All 66 posts