Skip to content

built Built. This is a decision record, not documentation.

What is still current: The registry-driven admission rule is still what decides which Morpho markets appear (morpho_market_registry plus the discovery cron, documented in Processes A.7). The route it calls /money-market-rates has been /repo-lending since 2026-08-21.

Landed: migration 041 and scripts/morpho-discovery.ts (v0.3.0)

Header updated 2026-09-14. The body below is frozen history. All plans.

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:

  1. 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 by sync-carries.ts like the other three venues, persisted to carry_registry, approved via --approve, and rendered with full parity (row, chart, capacity, risk, oracle tab, simulator, swap cost, agent API).
  2. Registry-driven Morpho rows on /money-market-rates (Repo markets, Type = Isolated): replace the hardcoded 7-entry MORPHO_MARKETS array 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 by scripts/refreshers/morpho.ts into morpho_market_apy (supply share-rate index, spot IRM borrow APY, sizes, utilization) + one market_collateral_exposure row per market (basis isolated_market). Params locked by a keccak unit test.
  • Carries: carry_registry (migration 035) + sync-carries.ts cover Fluid / Aave v3 / SparkLend only. StrategyConfig has 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-usdt is $9.6M. Both are below any sensible floor. The registry state machine must handle honest delisting.
    • Blue API schema drift: the uniqueKey field is gone (now marketId), whitelisted is now listed. The uniqueKey_in filter 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) ​

#GateSourceRationale
H1Chain = Ethereum mainnet, Morpho Blue singletonchainscope
H2IRM == 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
H3Morpho-listed: Blue API listed = trueAPI (veto only)Morpho's own curation; mechanically excludes the fake/unlisted books
H4No RED warnings from the Blue API (warnings[].level == 'RED')API (veto only)catches oracle-derivation and custom red flags
H5Bad debt: badDebt.usd < 0.1% of supplied USDAPI (veto only)wstUSR/USDC and RLP/USDC style failures
H6Utilization <= 99% at syncchaina ~100%-util book is drained: lenders cannot exit, "borrow APY" is a fiction. AdaptiveCurve targets 90%, so healthy books sit 85-93%
H7Oracle decomposable: the market oracle matches the MorphoChainlinkOracleV2 shape (readable BASE_FEED_1/2, QUOTE_FEED_1/2, BASE_VAULT, SCALE_FACTOR)chainrequired for the ORACLE tab (carries) and honest risk copy; a non-standard oracle -> NEEDS A DECISION, not silent listing
H8Curator diligence: >= 2 distinct supplying MetaMorpho vaultsAPI (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}; OTHER never qualifies (reuse assetClass() from sync-carries verbatim).
  • Collateral yield is modellable: address has a token_yield_apy row, OR is a PT present in pendle_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 count publicAllocatorSharedLiquidity as 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-active market only auto-flips to below_floor when 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 slug morpho-<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 assert keccak256(abi.encode(params)) == id before 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:

MarketLLTVBorrowedFree liqVaultsNote
wstETH/WETH96.5%$78.7M$9.2M9flagship; 94.5% twin ($17.6M) deduped away
sUSDS/USDT96.5%$56.5M$8.1M8sUSDS adapter live
reUSD/USDC91.5%$20.1M$1.9M3reUSD adapter live (PR #252)
weETH/WETH94.5%$14.3M$2.1M4
PT-reUSD-10DEC2026/USDC91.5%$26.0M$2.0M2term 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:

  1. 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/whitelisted fields are gone.)
  2. On-chain verify each surviving candidate: idToMarketParams + keccak, market() for canonical sizes, oracle-shape probe (H7) via the MorphoChainlinkOracleV2 immutable getters.
  3. Classify per 3.1-3.4 against token_yield_apy coverage, pendle_markets, BASE_TOKENS/EXTERNAL_REWARD_TOKENS/assetClass imported from a shared module (extract these from sync-carries.ts into scripts/carry-criteria.ts so 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; prior active stays active; --approve <key> / --reject <key> work unchanged; gone marking learns the morpho- prefix).
  • Persist: carry-track rows into carry_registry with config = { 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 into morpho_market_registry (a market can be both: sUSDS/USDT). PT carries get maturity_ts + short-runway handling exactly like Aave PTs.
  • Approving a carry whose market is not yet snapshotted must also flip the morpho_market_registry row 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: iterate morpho_market_registry rows with status='active' (any track) instead of MORPHO_MARKETS; compute and write borrow_share_rate + realized borrow_apy_24h alongside 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 of MARKETS from morpho_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). Keep marketTypeFor/Type-filter logic untouched — new rows inherit Isolated bucket behaviour (hidden until the "Isolated" type is ticked, per PR #265).
  • morpho-markets.ts keeps the types, morphoMarketId, share-rate helpers and taxonomy; the hardcoded array shrinks to MORPHO_SEED_MARKETS (used by the migration seed, the fallback, and the keccak unit test).
  • Check TokenIcon coverage 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:

  1. carries-table.ts: add the kind to StrategyConfig + lowerConfig; extend getActiveAaveSparkCarries to a protocol-inclusive getActiveRegistryCarries (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_apy for 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), or pendle_market_state.implied_apy for PT collateral;
    • funding leg = realized borrow APY from morpho_market_apy (borrow_share_rate ratio; borrow_apy_24h for the 6M+ views; spot borrow_apy fallback only while realized history is warming).
  2. /carries/page.tsx: display-row generation for morpho-blue registry carries (protocol badge "Morpho", exec link https://app.morpho.org/ethereum/market/<marketId>), debt-utilization tooltip reading morpho_market_apy.utilization.
  3. Capacity: new source: "morpho" branch in the vault-capacity refresher — borrowableUsd = free loan liquidity x price, keyed by strategy_key; the <$100k tight-capacity warning applies unchanged. No caps exist, so no cap-headroom terms; docs note the public allocator as upside optionality.
  4. 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.
  5. Oracle tab (oracles.ts): a MorphoChainlinkOracleV2 resolver — read BASE_VAULT, BASE_VAULT_CONVERSION_SAMPLE, BASE_FEED_1/2, QUOTE_FEED_1/2, SCALE_FACTOR, resolve each feed's description(), 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.
  6. Simulator + swap cost: leveraged-position.ts is venue-agnostic (verified: no kind switches); the swap-cost API takes decimals from config (already the PT pattern). Round-trip quote method unchanged.
  7. 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) ​

  1. Criteria extraction + migration 041 (shared carry-criteria.ts; table + seed + borrow columns + grants). tsc + migrate on staging.
  2. Discovery + sync: morpho-discovery.ts, sync-carries venue fold-in, --dry-run report reviewed by admin BEFORE any persist.
  3. Refresher/backfill: registry-driven iteration, realized borrow leg, backfill existing 7 + candidates; verify realized-vs-spot borrow APY converge on a healthy market.
  4. Repo tab repoint (registry-driven MARKETS + fallback).
  5. Carries parity (4.6 items; largest phase — split PRs if needed: readers/page first, oracle tab second).
  6. 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 --noEmit clean; 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 / --approve keys for the Morpho track. When the numbers in section 3.3 or the gates change, processes.md is 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) ​

  1. Repo floor $25M borrowed / carry floor $1M free liquidity; 50% hysteresis. First sync will delist WETH/USDC and cbBTC/USDT from the repo tab.
  2. 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).
  3. Blue API is veto-only; fail closed for additions, fail open for removals.
  4. Max-lev shown truthfully off LLTV (~28x at 96.5%) with explicit liquidation-line copy.
  5. PT-collateral markets: eligible as term carries, excluded from repo track.
  6. Registry keys carry a marketId hex suffix; repo display slugs stay stable.

Private documentation. creddit.xyz