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 osimport requests
MORPHO = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" # same address on Ethereum and BaseMARKET_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 ÷ totalBorrowSharesLive 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 USDCThat’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 ÷ borrowAssetsresp = 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 WATCHLISTValidated 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
- Aave V3 health factor explained: the same risk check, backed by a single
healthFactorstruct field - Compound V3 (Comet): liquidation risk via API: the booleans-instead-of-a-ratio version of this same problem
- ERC-4626 vault share price: the share-to-asset math behind
borrowShares/totalBorrowSharesexplained in a vault context - evmquery for developers: the full API surface, MCP server, and REST reference



