Plan: Morpho Blue isolated markets in the Repo markets table
SUPERSEDED for membership governance (2026-07-08). Market membership, discovery, and the admission rule are now owned by morpho-markets-expansion-plan.md and documented in processes.md A.7 (
morpho_market_registry+scripts/morpho-discovery.ts). This doc remains the record of the original repo-tab integration + the Type column; the hardcoded top-2 selection and the discovery query here are no longer canonical.
Add the top 2 Morpho Blue markets (by USD borrowed) whose loan token is USDC or USDT to the existing Repo markets table, introduce a market Type column (pooled / pooled liquidity, isolated borrowing / isolated), and lay the foundation for live (event-indexed) Morpho state.
Grounded in the repo's existing patterns: the MARKETS matrix in src/lib/data/money-market-rates.ts, the per-protocol *_reserve_apy tables + getTrailingApy (src/lib/data/apy.ts), the {slug, refresh} registry in scripts/refresh-assets.ts, and the existing creddit-indexer (rindexer) repo.
0. RPC-cost reality check (the dev's assumption is correct, one caveat)
A single Morpho Blue market emits Supply / Withdraw / Borrow / Repay / SupplyCollateral / WithdrawCollateral / Liquidate / AccrueInterest at tens-to-low-hundreds of events per day even for a $100M book — two to three orders of magnitude below a DEX pool's swap stream. Forward indexing is near-free in Alchemy compute units.
Caveat: Morpho is a singleton (0xBBBB…FFCb); every market's events come from one contract. An unscoped subscription would ingest all several-hundred markets. Every Morpho event carries the market id as indexed topic1, so scope getLogs to the two chosen ids (or index the singleton and partition in SQL — volume is still small). The only real spend is the one-time backfill from market creation (~2024), a bounded getLogs range scan comparable to what scripts/refreshers/lending-positions.ts already does for Aave debt-token logs.
Verdict: proceed; cost is not a concern.
1. Phasing — ship the feature without blocking on the indexer
Same end-state data model, two phases that coexist with no rework:
- Phase 1 (ships the 2 markets): a 6h snapshot refresher exactly like
scripts/refreshers/aave-v3.ts/sparklend.ts. ~6 eth_calls per run. - Phase 2 (live state): index the events in
creddit-indexer, derive live market state, and switch the state reads (borrowed / supplied / liquidity, later per-borrower health) from snapshot to indexed.
Trailing APY stays snapshot-based in both phases (an index-ratio over time needs periodic share-rate observations regardless), so the phases do not conflict.
2. Market selection (one-time, on the server)
Discover via the Morpho API (egress is open on the server), then hardcode — the chain stays canonical, the API is discovery-only:
curl -s -X POST https://blue-api.morpho.org/graphql -H 'Content-Type: application/json' \
--data '{"query":"{markets(first:60,orderBy:BorrowAssetsUsd,orderDirection:Desc,where:{chainId_in:[1],loanAssetAddress_in:[\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\"0xdac17f958d2ee523a2206206994597c13d831ec7\"]}){items{uniqueKey lltv loanAsset{symbol} collateralAsset{symbol} oracleAddress irmAddress state{borrowAssetsUsd supplyAssetsUsd}}}}"}' | jqTake the top 2 by borrowAssetsUsd (expect PT-sUSDe/USDC, wstETH/USDC or cbBTC/USDC class). Create a MORPHO_MARKETS registry constant holding each market's full MarketParams {loanToken, collateralToken, oracle, irm, lltv}and its marketId.
Test: assert
keccak256(abi.encode(params)) === marketId(viemencodeAbiParameters+keccak256) so a typo'd param can't silently point at the wrong market.
3. Contract surface (Phase-1 reads)
- Singleton
Morpho = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; AdaptiveCurve IRM0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC(verify both against the deployed registry during implementation). Morpho.market(id)->{totalSupplyAssets, totalSupplyShares, totalBorrowAssets, totalBorrowShares, lastUpdate, fee}(uint128s).- Supply share rate = totalSupplyAssets / totalSupplyShares — the Aave
liquidityIndexanalogue. Trailing APY =annualizeRatio(rate_now/rate_then, elapsed)reusesapy.tsunchanged, so Morpho rows are methodologically identical to every other row (the consistency promise now documented indocs/metrics.md). - Spot APY for display:
borrowRate = IRM.borrowRateView(params, market)(per-second, 1e18 -> compound to annual);supplyApy = borrowApy x utilization x (1 - fee). availableLiquidity = totalSupplyAssets - totalBorrowAssets;utilization = totalBorrow / totalSupply.- The stored totals are stale since
lastUpdate. Either apply Morpho'sexpectedMarketBalancesperiphery math, or accept <= 6h staleness like the other rows. Accept it for Phase 1; document it.
4. Schema — scripts/sql/035-morpho-market-apy.sql (next free number)
Mirror 007-aave-v3-reserve-apy.sql, PK (snapshot_ts, market_id):
market_id text (0x-hex), loan_symbol, collateral_symbol, share_rate numeric (the index), supply_apy, supply_apy_24h, total_supplied, total_borrowed, available_liquidity, utilization, lltv, oracle_address, irm_address, block_number, updated_at.
Additive + idempotent (ADD COLUMN IF NOT EXISTS style) + role GRANTs, per house style. Run before deploy (standard ordering).
5. Phase-1 refresher — scripts/refreshers/morpho.ts
Clone aave-v3.ts's shape:
- anchor block ->
multicall3([market(id), borrowRateView(...)] x 2 markets) - compute share rate, spot APY, and the 24h-trailing APY via the at-or-before-24h anchor pattern from
fluid-ll.ts(NOT the exact-matchtoken-yieldspattern we fixed — that one blanks on a missed cron) - upsert into
morpho_market_apy.
Register {slug: morphoMeta.slug, refresh: refreshMorpho} in scripts/refresh-assets.ts. Failed reads skip the write (previous snapshot stands) — house failure-containment rule. A small backfill script (blockByTimestamp walk, like backfill-aave-v3.ts) can follow if chart history is wanted.
Also write the exposure row: one slice per market into market_collateral_exposure with a new basis 'isolated_market' — collateral symbol, exposure = total borrowed, LLTV, oracle identity. Keep it deliberately thin: one collateral, its LLTV, its oracle is the whole story for an isolated market, which is exactly the "simpler than pooled" expectation. (Posted- collateral total is not on market(); defer the collateral-USD figure to Phase 2 or read it from the collateral token balance at the Morpho address scoped to the market via indexed positions.)
6. Reader — src/lib/data/money-market-rates.ts (+ apy.ts)
MoneyMarketProtocol+='Morpho'.- Extend the
IndexTableunion inapy.tswith'morpho_market_apy'(supply columnshare_rate, no borrow leg — same shape astoken_yield_apy). MARKETS+= two entries, e.g.{ id: 'morpho-wsteth-usdc', protocol: 'Morpho', asset: 'USDC', table: 'morpho_market_apy', token: <marketId> }. Keying nuance: this table keys bymarket_id, not loan-token address; thread that throughgetTrailingApy's$1param (already an opaque string, so no signature change).- New
marketTypefield onMoneyMarketRow:'pooled' | 'pooled_liquidity_isolated_borrowing' | 'isolated'. Aave/Spark ->pooled; Fluid ->pooled_liquidity_isolated_borrowing; Morpho ->isolated. Derived from protocol in the reader; no schema needed. - Morpho row label carries the collateral:
"wstETH -> USDC"(for an isolated market the collateral is the market's identity).
7. UI — MoneyMarketTable.tsx + UnderwrittenCapital.tsx
Type column (small mono chip): Pooled / Isolated / Pooled liq, isolated borrow. One InfoTooltip on the header:
- Pooled: one shared book; all collateral cross-margined; a failure in one collateral can socialise losses to every lender.
- Pooled liquidity, isolated borrowing: lenders share one liquidity layer, but each borrow position lives in an isolated vault with its own collateral, oracle and limits (Fluid). Lender risk spans every vault borrowing the asset; borrower risk is per-vault.
- Isolated: a standalone market against a single collateral with immutable terms (oracle, max LTV fixed at creation); losses are contained to that market; the lender is the sole underwriter of that one collateral.
Expansion for Morpho rows: a slimmed Underwritten-capital panel — single collateral row (100% of book), LLTV, oracle identity, utilization / available, and "Supply cap: none (market-level)". Morpho Blue markets have no caps (caps live in the vault layer), which plugs straight into the max-potential-exposure column (PR #221) as the honest "uncapped" case. Skip the proximity strip and e-mode share (Phase 2 brings per-borrower data).
8. Phase 2 — indexer (creddit-indexer repo)
- Add a rindexer contract entry for the Morpho singleton with the 8 events above, filtered to the two market ids via
topic1(rindexer supports indexed-topic filters; if the installed version doesn't, index unfiltered and constrain in SQL — volume is still fine). - Backfill from each market's
CreateMarketblock (one bounded historicalgetLogsscan; reuse thegetLogsChunkedspan-halving pattern if done app-side). - Derived state: a positions table (per-borrower supplyShares / borrowShares / collateral from event deltas — Morpho events carry exact share/asset amounts), market totals reconstructable and reconciled against
Morpho.market()snapshots each 6h (cheap invariant check: indexed totals vs on-chain totals, alert on drift — same honesty pattern as the Fluid tick reconciliation). - Payoff: live borrowed / supplied / utilization between crons, per-borrower distance-to-liquidation for the proximity strip (needs an oracle price read at evaluation time), and liquidation-event history for the panel.
9. Tests, docs, rollout
Tests
- marketId <-> params keccak check.
- share-rate APY math against a hand-computed fixture.
marketTypemapping locked per protocol.- tooltip copy em-dash lint (house rule).
Docs
docs/metrics.md(Type column + isolated expansion; Morpho share-rate = the same index-ratio method). Wasdocs/money-market-rates.md+docs/methodology.mdbefore the internal-docs rewrite (PR #282).docs/data-pipeline.md(+refreshers/morpho.ts).AGENTS.mddata-layer note.
Rollout order
- migration 035
- deploy (refresher live on the next 6h tick)
- verify two Morpho rows render with the Type column + slimmed expansion
- Phase 2 indexer as a separate PR in
creddit-indexer, then a follow-up app PR switching state reads from snapshot to indexed.
Out of scope here
- Curated funds table (separate track per the pooled-vs-fund classification discussion).
- More than 2 markets (the
MORPHO_MARKETSregistry constant makes adding more a few-line change).
Open decision for product before the dev starts
Confirm the Morpho row id/label carries the collateral (morpho-wsteth-usdc, "wstETH -> USDC"). Recommended yes: for isolated markets the collateral is the market's identity, and it visually reinforces the Type distinction in the table.