Morpho markets expansion: carries + registry-driven Repo markets
Written 2026-07-08 against live Blue API + on-chain data sampled that day. Implementing agent: read this end-to-end, then follow the phase order. Develop on a feature branch -> PR into staging per AGENTS.md; prod promotion is a separate, explicitly-approved step.
1. Goal
Two deliverables, one selection rule:
- Morpho Blue carries on
/carries(Ethereum mainnet): delta-neutral, yield-bearing-collateral markets (wstETH/WETH, weETH/WETH, sUSDS/USDT, reUSD/USDC, PT term carries, ...) discovered and classified bysync-carries.tslike the other three venues, persisted tocarry_registry, approved via--approve, and rendered with full parity (row, chart, capacity, risk, oracle tab, simulator, swap cost, agent API). - Registry-driven Morpho rows on
/money-market-rates(Repo markets, Type = Isolated): replace the hardcoded 7-entryMORPHO_MARKETSarray with a DB registry governed by the same rule, so membership is re-derived by a sync run + admin approval instead of hand-edited code.
Out of scope: non-mainnet chains, Morpho vaults V2 (issue #68), per-borrower position indexing (phase-2 of the original morpho-isolated-markets plan), MetaMorpho vault products (that is the curator-funds feature, already live).
2. Current state (verified 2026-07-08)
- Repo tab: 7 hardcoded markets in
src/lib/data/morpho-markets.ts(MORPHO_MARKETS), snapshotted 6h byscripts/refreshers/morpho.tsintomorpho_market_apy(supply share-rate index, spot IRM borrow APY, sizes, utilization) + onemarket_collateral_exposurerow per market (basisisolated_market). Params locked by a keccak unit test. - Carries:
carry_registry(migration 035) +sync-carries.tscover Fluid / Aave v3 / SparkLend only.StrategyConfighas no Morpho kind. - Live drift found while writing this plan:
morpho-weth-usdc(ETH/USDC) has shrunk to $3.1M borrowed (2 vaults);morpho-cbbtc-usdtis $9.6M. Both are below any sensible floor. The registry state machine must handle honest delisting.- Blue API schema drift: the
uniqueKeyfield is gone (nowmarketId),whitelistedis nowlisted. TheuniqueKey_infilter still exists, so the production refresher's collateral-USD query is unaffected. All new discovery queries must use the current schema; treat the API as drift-prone (see 3.4).
3. THE RULE: Morpho market admission
Morpho Blue is permissionless: thousands of markets, and the raw top of the borrow ranking is exactly the garbage (a $3.5B fake BONDUSD/USR book, drained PAXG/sdeUSD books frozen at 100% utilization, wstUSR with $33M unrealized bad debt). The rule below turns "healthy" from a prose caveat in the old plan into code. It has three layers: hard gates (safety), track gates (fit), and floors (size), plus dedup and hysteresis.
3.1 Hard gates (both tracks; failing any = excluded, never a decision item)
| # | Gate | Source | Rationale |
|---|---|---|---|
| H1 | Chain = Ethereum mainnet, Morpho Blue singleton | chain | scope |
| H2 | IRM == AdaptiveCurve 0x870ac11d48b15db9a138cf899d20f13f79ba00bc and a non-idle market (collateral != 0) | chain (idToMarketParams) | our rate math models exactly this IRM; custom-IRM and idle markets are unmodellable |
| H3 | Morpho-listed: Blue API listed = true | API (veto only) | Morpho's own curation; mechanically excludes the fake/unlisted books |
| H4 | No RED warnings from the Blue API (warnings[].level == 'RED') | API (veto only) | catches oracle-derivation and custom red flags |
| H5 | Bad debt: badDebt.usd < 0.1% of supplied USD | API (veto only) | wstUSR/USDC and RLP/USDC style failures |
| H6 | Utilization <= 99% at sync | chain | a ~100%-util book is drained: lenders cannot exit, "borrow APY" is a fiction. AdaptiveCurve targets 90%, so healthy books sit 85-93% |
| H7 | Oracle decomposable: the market oracle matches the MorphoChainlinkOracleV2 shape (readable BASE_FEED_1/2, QUOTE_FEED_1/2, BASE_VAULT, SCALE_FACTOR) | chain | required for the ORACLE tab (carries) and honest risk copy; a non-standard oracle -> NEEDS A DECISION, not silent listing |
| H8 | Curator diligence: >= 2 distinct supplying MetaMorpho vaults | API (veto only) | separates curator-vetted books (cbBTC/USDC: 16 vaults) from standalone/affiliated books (kBTC/RLUSD, PRIME/PYUSD, msY: 0 vaults). Two independent curators is the "someone else underwrote this" bar |
Notes on H3-H5/H8: the API is advisory/veto only — it can keep a market out, it can never put one in, and every listing-relevant number (sizes, rates, params) stays chain-canonical. If the API is unreachable at sync time, hold candidate PROPOSALS (fail closed for additions) but do not delist existing actives on API silence alone (fail open for removals; only chain-derived gates H2/H6 can auto-delist between API reads).
3.2 Track gates
Repo track (/money-market-rates row):
- Loan asset in {USDC, USDT} (the tab's two assets).
- Collateral is a perpetual asset (no PT-* collateral: the tab has no maturity machinery and a repo book against an expiring instrument needs it).
Carry track (/carries row):
- Loan asset in
BASE_TOKENS(plain, non-yield-bearing funding leg — same honesty rule as the other venues; a yield-bearing loan asset needs the additive wrapper-appreciation funding term and goes to NEEDS A DECISION). - Delta-neutral:
assetClass(collateral) == assetClass(loan), class in {ETH, BTC, USD, GOLD};OTHERnever qualifies (reuseassetClass()from sync-carries verbatim). - Collateral yield is modellable: address has a
token_yield_apyrow, OR is a PT present inpendle_markets(term carry; reuse the whole PR #300/#302 maturity machinery:maturity_ts,config.pendleMarket, read-time filter, simulator clamp,PT_MIN_RUNWAY_DAYS). Yield-bearing but un-adaptered collateral (stcUSD, savUSD today) -> NEEDS A DECISION. - Collateral not in
EXTERNAL_REWARD_TOKENS(USDe, USDtb as collateral -> policy holdout, same as other venues).
3.3 Size floors (and hysteresis)
- Repo track: total borrowed >= $25M to be proposed. This keeps the tab institutional; at 2026-07-08 it admits exactly cbBTC/USDC ($253M), wstETH/USDT ($153M), WBTC/USDC ($108M), sUSDS/USDT ($57M), WBTC/USDT ($51M), wstETH/USDC ($27M).
- Carry track: enterable size >= $1M (the Aave/Spark institutional floor), where enterable = free loan liquidity
(totalSupplyAssets - totalBorrowAssets) * loanPrice. Morpho has no supply/borrow caps, so free liquidity is the only binding bound (do NOT countpublicAllocatorSharedLiquidityas enterable in v1; it is reallocatable only via a public-allocator call and belongs in doc copy, not the number). - Hysteresis (both tracks): an already-
activemarket only auto-flips tobelow_floorwhen it falls under 50% of its track floor ($12.5M / $0.5M). Between 50% and 100% it stays listed and the sync report flags it. Prevents flapping around the threshold. First sync consequence to surface for admin decision: WETH/USDC ($3.1M) and cbBTC/USDT ($9.6M) are below even the 50% line and will delist from the repo tab.
3.4 Dedup and identity
- Market identity is the immutable
marketId(keccak of the 5-tuple params). Registry key:morpho-<colSym>-<loanSym>-<first 8 hex of marketId>(lowercased), e.g.morpho-wsteth-weth-c54d7acf. The hex suffix is required: the same (collateral, loan) pair exists at multiple LLTVs/oracles (two live wstETH/WETH books at 96.5% and 94.5%; two cbBTC/USDT books). A different market is a different key -> a new approval; no silent identity swaps under a stable key. - Per (collateral, loan) pair, only the deepest qualifying market by borrowed USD is proposed per track; the rest classify
duplicate_market(report-only status). Repo-tab display slugs keep the existing 7 (morpho-cbbtc-usdc, ...) for URL/history continuity; new repo rows derive slugmorpho-<col>-<loan>(collision-free given pair-dedup). - On-chain verification at sync time (replaces the hand-verified hardcode): for every candidate, read
Morpho.idToMarketParams(id)and assertkeccak256(abi.encode(params)) == idbefore persisting. A unit test keeps asserting this for the migration seed rows.
3.5 What the rule admits today (2026-07-08 dry run, live data)
Carry track, passing everything:
| Market | LLTV | Borrowed | Free liq | Vaults | Note |
|---|---|---|---|---|---|
| wstETH/WETH | 96.5% | $78.7M | $9.2M | 9 | flagship; 94.5% twin ($17.6M) deduped away |
| sUSDS/USDT | 96.5% | $56.5M | $8.1M | 8 | sUSDS adapter live |
| reUSD/USDC | 91.5% | $20.1M | $1.9M | 3 | reUSD adapter live (PR #252) |
| weETH/WETH | 94.5% | $14.3M | $2.1M | 4 | |
| PT-reUSD-10DEC2026/USDC | 91.5% | $26.0M | $2.0M | 2 | term carry; in pendle_markets |
NEEDS A DECISION queue today: stcUSD/USDT ($1.5M free, 2 vaults) and savUSD/USDC ($1.6M free, 2 vaults) — both blocked_adapter. Excluded by policy: every wstETH/USDx, weETH/USDx, OETH/USDC (directional, cross-class); sUSDe/PYUSD, PT-USD3, wsrUSD, siUSD (single supplying vault); XAUt/USDT (cross-class); syrupUSDC/PYUSD, stUSDS, PST, msY (0 vaults).
Repo track: the six markets in 3.3 = current tab minus WETH/USDC and cbBTC/USDT, plus sUSDS/USDT.
4. Architecture
4.1 Migration 041-morpho-market-registry.sql
New table onchain_credit.morpho_market_registry (agent-grade conventions: chain-scoped key, block-anchored verification, current-state registry):
chain_id smallint NOT NULL DEFAULT 1,
market_id text NOT NULL, -- bytes32, lowercase
strategy_key text NOT NULL UNIQUE, -- morpho-<col>-<loan>-<hex8>
slug text UNIQUE, -- repo-tab display slug (null for carry-only rows)
loan_symbol text, loan_address text, loan_decimals smallint,
collateral_symbol text, collateral_address text, collateral_decimals smallint,
oracle_address text, irm_address text, lltv numeric,
tracks text[] NOT NULL, -- subset of {repo, carry}
status text NOT NULL, -- proposed|active|rejected|below_floor|duplicate_market|blocked_adapter|blocked_oracle|delisted_unhealthy|gone
status_reason text,
borrowed_usd numeric, supplied_usd numeric, free_liquidity_usd numeric,
supplying_vaults smallint, api_listed boolean, bad_debt_usd numeric,
verified_block bigint, -- block of the idToMarketParams+keccak check
first_seen_at/became_active_at/last_synced_at timestamptz,
PRIMARY KEY (chain_id, market_id)Plus: extend morpho_market_apy with the realized borrow leg — borrow_share_rate numeric (borrow index: (totalBorrowAssets + 1) / (totalBorrowShares + 1e6), same SharesMathLib virtual offsets as the supply side) and borrow_apy_24h numeric (realized, at-or-before-24h anchor pattern). The existing spot borrow_apy stays for the repo-tab display. Rationale: the house convention is "every yield is the realised ratio of an on-chain compounding index"; the carry funding leg must be the realized borrow index ratio, not the instantaneous IRM view (matches how Aave carries use the variable borrow index, and the aave-funding-backfill lesson: a funding leg with no history reads 0 and flips carry signs).
Seed: the current 7 MORPHO_MARKETS as tracks={repo}, status='active' (grandfathered; first sync reconciles and will flag WETH/USDC + cbBTC/USDT below floor). GRANT to onchain_credit role (the 035 lesson — without it the registry silently never works). Migration runs via scripts/ops/migrate.sh; prod application is a gated manual step.
4.2 Discovery module scripts/morpho-discovery.ts
Mirror of fluid-discovery.ts: exports discoverMorphoMarkets(db) returning classified candidates for both tracks. Steps:
- Blue API: top ~150 mainnet markets by borrowed USD with fields
marketId, listed, lltv, badDebt{usd}, warnings{type,level}, loanAsset{symbol,address,decimals}, collateralAsset{symbol,address,decimals}, oracle{address}, irmAddress, state{borrowAssetsUsd,supplyAssetsUsd, utilization}, supplyingVaults{name,state{totalAssetsUsd}}. (Current schema — do not copy the old plan doc's query;uniqueKey/whitelistedfields are gone.) - On-chain verify each surviving candidate:
idToMarketParams+ keccak,market()for canonical sizes, oracle-shape probe (H7) via the MorphoChainlinkOracleV2 immutable getters. - Classify per 3.1-3.4 against
token_yield_apycoverage,pendle_markets,BASE_TOKENS/EXTERNAL_REWARD_TOKENS/assetClassimported from a shared module (extract these from sync-carries.ts intoscripts/carry-criteria.tsso the criteria stay literally shared, not copied).
4.3 sync-carries.ts integration
- Add Morpho as the fourth venue: same report sections (LISTED/PROPOSED, EXCLUDED BY POLICY, NEEDS A DECISION, TRANSIENT), same state machine (passing candidates land
proposed; prioractivestays active;--approve <key>/--reject <key>work unchanged;gonemarking learns themorpho-prefix). - Persist: carry-track rows into
carry_registrywithconfig = { kind: "morpho-blue", marketId, loanToken, collateralToken, oracle, irm, lltv, loanDecimals, colDecimals, ...ptFields }(always store both decimals — the swap-cost path then needs no TOKEN_DECIMALS entries),ltv = liquidation_threshold = lltv(Morpho has one number: max borrow IS the liquidation line; see 4.6 copy note),vault_address = MORPHO_BLUE. Repo+carry-track rows intomorpho_market_registry(a market can be both: sUSDS/USDT). PT carries getmaturity_ts+ short-runway handling exactly like Aave PTs. - Approving a carry whose market is not yet snapshotted must also flip the
morpho_market_registryrow active (the refresher follows the registry) and the report must say "run backfill-morpho for<marketId>" — a carry with an empty funding history renders 0/sign-flipped (the emode backfill lesson).
4.4 Refresher + backfill
refreshers/morpho.ts: iteratemorpho_market_registryrows withstatus='active'(any track) instead ofMORPHO_MARKETS; compute and writeborrow_share_rate+ realizedborrow_apy_24halongside the existing columns; keep the all-or-nothing exposure-write rule but scope exposure rows to repo-track markets only (WETH-loan carry markets are not part of the stablecoin repo book).backfill-morpho.ts: accept--market <id>+--days N, source the market list from the registry, and backfill the new borrow columns for the existing 7 markets' full history (2026-06-12 onward) plus ~90d (or since market creation) for newly approved markets. Chain reads only, 6h grid.- No new cron: the refresher already runs under
refresh-assets.ts.
4.5 Repo tab goes registry-driven
money-market-rates.ts: build the Morpho entries ofMARKETSfrommorpho_market_registry(repo track,status='active') at request time, falling back to the seeded constant if the table is missing (house pattern: registry gates visibility, code seed is the safety net). KeepmarketTypeFor/Type-filter logic untouched — new rows inherit Isolated bucket behaviour (hidden until the "Isolated" type is ticked, per PR #265).morpho-markets.tskeeps the types,morphoMarketId, share-rate helpers and taxonomy; the hardcoded array shrinks toMORPHO_SEED_MARKETS(used by the migration seed, the fallback, and the keccak unit test).- Check
TokenIconcoverage for sUSDS (and any future admits) on the tab.
4.6 Carries full parity (kind: "morpho-blue")
Per-surface work items, mirroring what --approve already guarantees for Aave/Spark:
carries-table.ts: add the kind toStrategyConfig+lowerConfig; extendgetActiveAaveSparkCarriesto a protocol-inclusivegetActiveRegistryCarries(keep the old name as a wrapper or update the 3 call sites: carries page, home metrics, agent carry-catalog). History/row readers:- target leg =
token_yield_apy.supply_apyfor the collateral address (collateral in Blue earns nothing at the venue — it is never lent out — so there is no additive venue-supply term; simpler than Aave), orpendle_market_state.implied_apyfor PT collateral; - funding leg = realized borrow APY from
morpho_market_apy(borrow_share_rateratio;borrow_apy_24hfor the 6M+ views; spotborrow_apyfallback only while realized history is warming).
- target leg =
/carries/page.tsx: display-row generation formorpho-blueregistry carries (protocol badge "Morpho", exec linkhttps://app.morpho.org/ethereum/market/<marketId>), debt-utilization tooltip readingmorpho_market_apy.utilization.- Capacity: new
source: "morpho"branch in the vault-capacity refresher —borrowableUsd = free loan liquidity x price, keyed bystrategy_key; the <$100k tight-capacity warning applies unchanged. No caps exist, so no cap-headroom terms; docs note the public allocator as upside optionality. - Risk: LLTV is both max-LTV and liquidation threshold. UI copy must say so explicitly ("borrowing at max LTV on Morpho means liquidation on any adverse tick"); max-lev at 96.5% LLTV is ~28.6x and truthful — do not fudge the number, let the copy carry the warning.
- Oracle tab (
oracles.ts): a MorphoChainlinkOracleV2 resolver — readBASE_VAULT,BASE_VAULT_CONVERSION_SAMPLE,BASE_FEED_1/2,QUOTE_FEED_1/2,SCALE_FACTOR, resolve each feed'sdescription(), and render the price chain (e.g. wstETH exchange-rate feed vs market-price feeds — the pull-oracle LST-impairment semantics memo applies). H7 guarantees the shape; markets that fail it never reached approval. - Simulator + swap cost:
leveraged-position.tsis venue-agnostic (verified: no kind switches); the swap-cost API takes decimals fromconfig(already the PT pattern). Round-trip quote method unchanged. - Agent surface: carry-catalog + tools pick Morpho carries up via the shared registry reader; verify the catalog's venue labels.
4.7 What does NOT change
Curator-funds feature, Aave/Spark/Fluid discovery, the carry math core (annualizeRatio, trailing windows), Type-filter UX, marketTypeFor, existing morpho_market_apy history rows (new columns are additive).
5. Phases (implementation order)
- Criteria extraction + migration 041 (shared
carry-criteria.ts; table + seed + borrow columns + grants). tsc + migrate on staging. - Discovery + sync:
morpho-discovery.ts, sync-carries venue fold-in,--dry-runreport reviewed by admin BEFORE any persist. - Refresher/backfill: registry-driven iteration, realized borrow leg, backfill existing 7 + candidates; verify realized-vs-spot borrow APY converge on a healthy market.
- Repo tab repoint (registry-driven MARKETS + fallback).
- Carries parity (4.6 items; largest phase — split PRs if needed: readers/page first, oracle tab second).
- Docs + tests (see 6/7) — same PRs as the code they document, per the docs-in-same-PR rule.
Rollout runbook (staging): migrate -> deploy -> backfill-morpho -> sync-carries --dry-run (admin reviews report against 3.5) -> full sync -> --approve the agreed initial set (recommend: wstETH/WETH, sUSDS/USDT, weETH/WETH, reUSD/USDC carries; the six 3.3 repo markets) -> validate pages -> release PR. Wait out the Actions deploy before any run-cron (npm-ci/tsx MODULE_NOT_FOUND gotcha). Prod: only with explicit permission.
6. Tests
- Keccak/params test over seed markets (existing test, repointed at the seed).
- Rule classifier unit tests from fixture API payloads: one fixture per gate (unlisted, RED warning, bad debt, 100% util, 1-vault, cross-class, no-adapter, PT short-runway, duplicate pair, below floor, hysteresis band).
- Borrow-index math test (virtual offsets, realized vs spot direction).
- Carries-table reader test for
morpho-blue(target/funding leg composition; PT variant). - Em-dash lint over all new user-facing copy;
npx tsc --noEmitclean; no value-imports of server pg modules into client components.
7. Docs to update (same PRs)
The admission rule (section 3) is itself a required doc deliverable, not just a plan artifact. The canonical home is docs/processes.md A.0, which already documents the "six criteria" for the Fluid/Aave/Spark venues; the Morpho rule extends that same section so a reader learns why any given market is or isn't listed from the living reference, never from this plan file (plans go stale). A.0 must gain, in prose:
- the eight hard gates (H1-H8), each with its one-line rationale, and the ">= 2 independent supplying vaults" curator gate spelled out explicitly;
- both track gates (repo: USDC/USDT loan, perpetual collateral; carry: the reused shared criteria);
- both size floors ($25M borrowed repo / $1M free-liquidity carry) and the 50%-of-floor hysteresis band, with the "Morpho has no caps, so free liquidity is the only enterable bound" note;
- the veto-only Blue API stance (fail closed for additions, fail open for removals) and the report buckets /
--approvekeys for the Morpho track. When the numbers in section 3.3 or the gates change,processes.mdis the doc that must move with the code — treat section 3 of this plan and A.0 as the same rule in two places, and keep them consistent.
Also update: database.md (new table + new columns), data-pipeline.md (discovery/sync/refresher/backfill), metrics.md (Morpho funding-leg formula; collateral earns no venue yield; LLTV semantics), external-dependencies.md (Blue API veto role + 2026-07 schema drift note), the money-market and carries page docs, and mark docs/plans/morpho-isolated-markets-plan.md superseded by this file for membership governance.
8. Decisions taken in this plan (flag to Fred if changing)
- Repo floor $25M borrowed / carry floor $1M free liquidity; 50% hysteresis. First sync will delist WETH/USDC and cbBTC/USDT from the repo tab.
- Curator gate = ">= 2 independent supplying vaults" (NOT "in our 60-vault registry": that list is USD-only and would wrongly veto every WETH-loan carry market).
- Blue API is veto-only; fail closed for additions, fail open for removals.
- Max-lev shown truthfully off LLTV (~28x at 96.5%) with explicit liquidation-line copy.
- PT-collateral markets: eligible as term carries, excluded from repo track.
- Registry keys carry a marketId hex suffix; repo display slugs stay stable.