Morpho Blue: Read Health Factor and Liquidation Price via API

Morpho Blue has no healthFactor field. Compute it from position(), market(), and the market's own oracle price and LLTV — one batched API call, no SDK required.

evmquery team··7 min read
Share
Morpho Blue — read health factor and liquidation price via API

Aave V3 gives you one number: healthFactor. Compound V3 gives you two booleans. Morpho Blue gives you neither. There is no risk field anywhere in its ABI — just raw shares, a market’s immutable parameters, and an oracle price you’re expected to combine yourself. For a protocol that passed $10B+ in TVL in 2026, that’s a surprising amount of homework for a number every lending dashboard needs.

TL;DR

Morpho Blue stores positions as supplyShares/borrowShares/collateral, not a health factor. Read position(), market(), and the market’s own oracle price(), then compute healthFactor = (collateral × price ÷ 1e36) × LLTV ÷ borrowAssets yourself. One batched request, no Blue SDK install.

Why Morpho Blue doesn’t expose a health factor

Morpho Blue is a single immutable contract — one address, every market. Aave deploys a pool per deployment and Compound deploys one Comet instance per base asset, but Morpho Blue never deploys a new contract for a new market. Instead, a market is just five parameters — loan token, collateral token, oracle, interest-rate model (IRM), and liquidation LTV (LLTV) — hashed into a bytes32 market Id. Anyone can permissionlessly create a market by picking those five values; the core contract has no opinion on which markets are “real.”

That design is why there’s no healthFactor getter. A single-purpose pool contract (Aave) can afford to compute and cache a portfolio-wide ratio. A singleton serving an unbounded number of permissionless markets keeps its ABI minimal on purpose and pushes the ratio math to callers — the same tradeoff Compound V3’s Comet instances make for a different reason (isolated risk per base asset, not permissionless market creation).

From a market Id to its parameters

Given a market’s Id, idToMarketParams resolves the five underlying values:

curl -s -X POST https://api.evmquery.com/api/v1/query \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_KEY" \
-d '{
"chain": "evm_base",
"schema": { "contracts": { "morpho": { "address": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" } } },
"expression": "morpho.idToMarketParams(hexBytes(\"0x8793cf302b8ffd655ab97bd1c695dbd967807e8367a65cb2f4edaf1380ba1bda\"))"
}'

Run live against Base, that Id resolves to a real, active market:

Field Value
loanToken 0x833589fCD6eDb6E08f4c7C32D4f71b54bda02913 (USDC)
collateralToken 0x4200000000000000000000000000000000000006 (WETH)
oracle 0xFEa2D58cEfCb9fcb597723c6bAe66fFe4193aFE4
irm 0x46415998764C29aB2a25CbeA6254146D50D22687
lltv 860000000000000000 (86%, WAD-scaled)

evmquery can't derive a market Id for you

A market Id is keccak256(abi.encode(marketParams)) — a struct hash, not a value any contract exposes as a plain getter, and evmquery’s CEL surface has no abi.encode/keccak256-of-struct primitive. Get the Id from Morpho’s own market list, the Blue SDK, or their Market ID Generator, then treat it as a known bytes32 constant in your queries — the same pattern this post uses.

This call’s response has meta: null instead of a populated blockNumber/totalCalls — the same meta: null behavior documented for ENS and Permit2’s bytes-argument calls, but one layer more specific than that: it’s not the bytes32 argument that triggers it, it’s returning the raw struct value itself. The health-factor query later in this post calls position() and market() with the exact same bytes32 market Id, but since it returns computed scalars instead of the structs, meta comes back fully populated. Return a struct as-is and you lose meta; derive a value from it and you keep it.

Reading a position: shares, not balances

position(id, user) returns three raw fields: supplyShares, borrowShares, and collateral. Collateral is a plain token amount (wei, 18 decimals for WETH). Supply and borrow are shares, not asset amounts — the same share/asset split the ERC-4626 vault post covers, just applied to a lending position instead of a vault deposit.

import os
import requests
MORPHO = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" # same address on Ethereum and Base
MARKET_ID = "0x8793cf302b8ffd655ab97bd1c695dbd967807e8367a65cb2f4edaf1380ba1bda" # WETH/USDC, 86% LLTV, Base
resp = requests.post(
"https://api.evmquery.com/api/v1/query",
headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
json={
"chain": "evm_base",
"schema": {
"contracts": {"morpho": {"address": MORPHO}},
"context": {"wallet": "sol_address"},
},
"context": {"wallet": "0xYourWalletAddress"},
"expression": f'morpho.position(hexBytes("{MARKET_ID}"), wallet)',
},
timeout=10,
)
print(resp.json()["result"]["value"])

Validated live against a real borrower on this market, this returns:

{ "supplyShares": "0", "borrowShares": "908894105795815882", "collateral": "972272534537228200000" }

972.27 WETH of collateral, zero supply shares (this wallet only borrows here), and a borrow-shares figure that means nothing on its own — it has to be converted to an asset amount using the market’s totals.

Converting shares to assets

market(id) returns the pool-wide totals needed for that conversion: totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, and fee. A position’s actual debt is its share of the pool:

borrowAssets = borrowShares × totalBorrowAssets ÷ totalBorrowShares

Live against the same market, totalBorrowAssets was 92919199535977 (6 decimals) against totalBorrowShares of 81782814152232570441. Plugging in the borrower above:

908894105795815882 × 92919199535977 ÷ 81782814152232570441 ≈ 1,032,658.43 USDC

That’s a real, non-trivial debt — not a demo number — computed entirely from two contract reads and ordinary arithmetic, no library required.

Computing health factor and liquidation price

Combine position(), market(), and the market’s own oracle.price() (scaled 1e36, already denominated in loan-token units per collateral-token unit — no extra decimal correction needed) into Morpho’s own formula:

healthFactor = (collateral × price ÷ 1e36) × LLTV ÷ borrowAssets
resp = requests.post(
"https://api.evmquery.com/api/v1/query",
headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
json={
"chain": "evm_base",
"schema": {
"contracts": {
"morpho": {"address": MORPHO},
"oracle": {"address": "0xFEa2D58cEfCb9fcb597723c6bAe66fFe4193aFE4"},
},
"context": {"wallet": "sol_address"},
},
"context": {"wallet": "0xYourWalletAddress"},
"expression": (
f'cel.bind(id, hexBytes("{MARKET_ID}"), cel.bind(pos, morpho.position(id, wallet), '
'cel.bind(m, morpho.market(id), {'
'"collateral": dyn(formatUnits(pos.collateral, 18)),'
'"borrowAssets": dyn(pos.borrowShares * m.totalBorrowAssets / m.totalBorrowShares),'
'"price": dyn(oracle.price())'
'})))'
),
},
timeout=10,
)

The rest — dividing by 1e36, multiplying by LLTV, dividing by borrowAssets — is arithmetic you do on the returned numbers, same as every other evmquery post that formats a raw result client-side. Run against two real positions on this market:

Wallet Collateral Debt Health factor Liquidation price Price drop to liquidation
Borrower A 972.27 WETH $1,032,658.43 2.06 $1,235.01 51.4%
Borrower B 1.36 WETH $2,815.06 1.056 $2,407.67 5.3%

ETH traded at $2,542.56 on this oracle at query time. Borrower A can absorb a 51% crash before liquidation; Borrower B gets liquidated on a 5.3% dip. Same formula, same market, wildly different risk — which is exactly the number a healthFactor field would have hidden behind “both above 1.0, both fine.”

Liquidation price itself falls out of the same formula, solved for the price where healthFactor = 1:

liquidationPrice = borrowAssets ÷ (collateralTokens × LLTV)

Health factor below 1.0 means liquidatable now

Same threshold as Aave: healthFactor < 1.0 is eligible for liquidation immediately, not “at risk soon.” Borrower B above, at 1.056, is one bad hour away from that line, not one bad week.

The staleness trap: market() totals are as of lastUpdate, not right now

market()’s totalBorrowAssets and totalSupplyAssets are frozen as of the lastUpdate timestamp the same call returns — the last block in which anyone interacted with that specific market. Interest accrues continuously via the market’s IRM between interactions, so a quiet market can report debt that’s a few seconds, or in rare cases longer, behind the real figure. For alerting and dashboards, that lag is negligible — the market used in this post had lastUpdate within the same block as the query. If you need bit-exact parity with Morpho’s own app for every market regardless of activity, replicate the IRM’s accrual (Morpho’s MorphoBalancesLib) or use the Blue SDK’s Position/Market classes, which do this for you in TypeScript. evmquery’s read is exact for the data it reads; it just reads the same frozen totals the contract itself stores.

One singleton, two different addresses

Morpho Blue deploys at the same address on Ethereum and Base — 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb, confirmed live by reading owner() on both and getting an identical result. Polygon and BNB Chain are not the same deployment:

Chain Morpho Blue address
Ethereum 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb
Base 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb
Polygon 0x1bF0c2541F820E775182832f06c0B7Fc27A25f67
BNB Chain 0x01b0Bd309AA75547f7a37Ad7B1219A898E67a83a

All four resolve live on evmquery with an identical ABI shape, but don’t copy the Ethereum/Base address and assume it covers Polygon or BNB Chain too — unlike Permit2 or Multicall3’s fully universal deployments, this one only matches on two of the four chains.

Watching a list of borrowers in one request

Same batching pattern as every other multicall-friendly read on this blog: list<sol_address>.map() applies position() to every wallet in a watchlist in one HTTP round trip.

WATCHLIST = ["0xBorrowerA...", "0xBorrowerB..."]
resp = requests.post(
"https://api.evmquery.com/api/v1/query",
headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
json={
"chain": "evm_base",
"schema": {
"contracts": {"morpho": {"address": MORPHO}},
"context": {"wallets": "list<sol_address>"},
},
"context": {"wallets": WATCHLIST},
"expression": f'cel.bind(id, hexBytes("{MARKET_ID}"), wallets.map(w, morpho.position(id, w)))',
},
timeout=10,
)
positions = resp.json()["result"]["value"] # list, same order as WATCHLIST

Validated live against the two borrowers above, this returns both positions’ raw position() structs in one unit-priced call — convert each with the market() totals and oracle price fetched once, rather than re-querying them per wallet.

Building this into a recurring check instead of a one-off script? evmquery for developers covers the full API surface and REST reference end to end.

Next steps

Share

Check a Morpho position without an SDK

Free tier, no credit card. Run the batched query below against any Morpho Blue market.