The liquidation floor is sized from the wrong maximum leverage
| Title | Base calculation in Leverager::isLiquidateable() is incorrect as the max leverage may be smaller |
|---|---|
| Reward | $1009, 3 finders |
| Contest | Yieldoor - 24 February 2025 on Sherlock |
| Author | 0x73696d616f (0xSimao) |
| Context | Liquidation 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:
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:
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.maxTimesLeverage | maxLevTimes | Real ceiling | Divisor used | Divisor that applies | base is |
|---|---|---|---|---|---|
| 5e18 | 5e18 | 5x | 4e18 | 4e18 | correct |
| 5e18 | 3e18 | 3x | 4e18 | 2e18 | half of it |
| 5e18 | 2e18 | 2x | 4e18 | 1e18 | a quarter of it |
| 10e18 | 2e18 | 2x | 9e18 | 1e18 | a 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.