github.com/morpho-org/morpho-blue @ HEAD 1478e9cfe1b4d514f80682b3b60e4e12ff3ee45asrc/Morpho.sol, libraries/{SharesMathLib,MathLib,ConstantsLib,UtilsLib,MarketParamsLib}.sol, interfaces/IOracle.solMorpho Blue is the minimal canonical modern lending primitive: immutable, no admin pause, oracle/IRM/LLTV all integrator-chosen per market. That makes it the ideal compact reference for the liquidation/lending audit lens because every guard is visible in ~550 lines and the trust boundaries are explicit.
borrowed > maxBorrow, where
borrowed = borrowShares.toAssetsUp(totalBorrowAssets, totalBorrowShares) and
maxBorrow = collateral.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE).wMulDown(lltv).
Healthy iff maxBorrow >= borrowed (boundary is healthy-at-equality; liquidatable is strict <).toAssetsUp), collateral value rounds DOWN twice (mulDivDown then wMulDown). Both directions make the position look worse than reality, so the protocol never under-counts debt or over-counts collateral. borrowShares == 0 short-circuits to healthy. ORACLE_PRICE_SCALE = 1e36 and the oracle returns price scaled 36 + loanDecimals - collateralDecimals, so collateral-decimals and loan-decimals cancel inside the formula.<= vs < flip is a 1-wei liquidate-too-early / borrow-1-unit-too-much bug. Confirm the zero-debt short-circuit cannot be skipped to read a stale/zero oracle.LIF = min(MAX_LIF=1.15e18, WAD / (WAD - cursor*(WAD-lltv))), cursor=0.3e18.
Given repaidShares, seized collateral value = repaidAssets * LIF. Given seizedAssets, debt repaid = seizedAssetsQuoted / LIF.lltv (no caller input). As lltv -> WAD, (WAD-lltv) -> 0, so the denominator -> WAD and LIF -> 1.0 (degrades gracefully to no bonus, never explodes). Because LLTV is forced < WAD at enableLltv, the denominator is always > 0 (no div-by-zero). Direction: seized-from-repaid rounds collateral value UP via mulDivUp/wDivUp then toSharesUp (charges the liquidator more debt per unit seized = protocol-favoring); repaid-from-seized rounds DOWN (gives the liquidator less seizure = protocol-favoring).collateral == 0 and debt remains, the residual borrowShares are written off: totalBorrowAssets -= badDebt, totalSupplyAssets -= badDebt, totalBorrowShares -= badDebtShares, and the borrower's borrowShares = 0.totalSupplyAssets (lowers every supplier's asset-per-share) WITHOUT touching totalSupplyShares (so no supplier's share count changes). badDebtAssets = min(totalBorrowAssets, badDebtShares.toAssetsUp(...)) clamps so the subtraction cannot underflow. The write-off only triggers in the collateral == 0 branch (a partial liquidation that leaves dust collateral does NOT realize bad debt - it stays as an under-collateralized position).min() clamp exists so socialization never underflows totals.seizedAssets OR repaidShares (exactly-one-zero), arbitrary magnitude. No close factor, no minimum repay, no leftover-health requirement.borrowShares/collateral decremented; if collateral hits 0, bad debt clears it. Over-seizing past collateral reverts via uint128 underflow on position.collateral -= seizedAssets. Repaying more than debt reverts on borrowShares -=. Self-liquidation is permitted but unprofitable (you pay the LIF spread to yourself net-zero minus gas; you cannot extract because seize value <= repaid value * 1.15 and you fund the repay)._accrueInterest(marketParams, id) updates totalBorrowAssets/totalSupplyAssets from the IRM + elapsed time before any solvency-relevant read._accrueInterest is the FIRST state-changing call in EVERY entrypoint that touches solvency: supply, withdraw, borrow, repay, withdrawCollateral, liquidate, setFee. So the health check and the LIF math always run on a fresh index. supplyCollateral deliberately skips accrual (safe: adding collateral only improves health, never enables a wrongful action). elapsed == 0 early-returns (idempotent within a block).liquidate reads collateralPrice = IOracle(marketParams.oracle).price() ONCE, then uses it for both the health gate and the seize math.flash-loan -> move price -> self-liquidate at favorable price -> repay flash loan is the canonical attack and it lives in the oracle, not the core. If the protocol owns the oracle, demand TWAP/staleness/deviation guards. Always confirm the liquidation reads price once (not twice with a gap an attacker can straddle).liquidate has an optional onMorphoLiquidate(repaidAssets, data) callback to the liquidator (msg.sender) so they can source the repay (e.g. swap seized collateral) before the final safeTransferFrom.loanToken.safeTransferFrom(msg.sender, ...) pulls repayment. The callback target is msg.sender only (actor-bound, no arbitrary address). Re-entering liquidate/borrow during the callback sees already-updated state (position already debited) and a fresh _accrueInterest early-returns same-block, so no double-spend window. No reentrancy guard is needed because state is settled pre-callback.VIRTUAL_SHARES=1e6, VIRTUAL_ASSETS=1) with four converters: toShares{Up,Down}, toAssets{Up,Down}.ROUNDING-DIRECTION + ORACLE-SCALE errors in the health/seize math (primitives 1, 2, 8) are the highest-yield class. Rationale:
- They are silent (no revert, no event anomaly) and compound per-transaction into protocol drain or wrongful liquidation.
- They are the MOST FORKED-AND-MUTATED surface: every Morpho/Aave/Compound fork re-implements the LLTVpricecollateral formula and the seize<->repay conversion, and forks routinely flip a single Up/Down or mis-scale the oracle when they swap in a different price feed or token-decimal assumption (the 36 + loanDec - collDec scaling is a classic fork footgun).
- Concretely the apex sub-class is "liquidation seize/repay rounding that over-credits the liquidator" combined with "oracle scale mismatch on a forked market," yielding either free collateral extraction or mass wrongful liquidation.
Second-highest: bad-debt realization desync (primitive 3) - the collateral == 0 gating is subtle and forks frequently get the socialization side wrong (reducing shares instead of assets, or forgetting the min() clamp -> underflow/DoS).
Primitives 1 (health check), 2 (LIF seize<->repay conversion), 3 (bad-debt min-clamp + asset-side write-off), and 8 (SharesMathLib four-way matrix) are the rounding-direction-critical ones. 5 (accrual ordering) is sequencing-critical; 6 (oracle) and 7 (callback/CEI) are trust-boundary / ordering-critical rather than rounding.
What WORKED: deriving each guard from source and inverting it into a transferable "check X by doing Y" rule produced a clean 8-primitive lens reusable on any lending/CDP/perp liquidation engine, expanding coverage beyond the Liquity-fork-specific C220 Mezo work. What was UNEXPECTED: Morpho's deliberate NON-guards (no close factor, no post-partial health re-check, delegated oracle) are themselves lens entries - "the absence of a guard is safe ONLY because of property Z" is a higher-order audit question than "is the guard correct." Banked the lens to lessons-library + INDEX. Highest-yield class (rounding-direction/oracle-scale in fork mutations) flagged for Rule-38 fork-regression hunting on Morpho/Aave/Compound forks.