Skip to content

built-with-deviations Built, with deviations. This is a decision record, not documentation; the body is annotated where the build diverged.

What is still current: The Money Market Funds tab, its eligibility gates and the fund registry are live. Six sections no longer describe what ships and are annotated in place where they sit.

Landed: PR #635, migration 086 (v0.44.0)

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

Money Market Funds tab, August 2026: plan ​

Implemented in PR #635 (feat/money-market-funds into staging). Both streams merged and reconciled; the deviations from this plan are recorded in the PR body and in the code that carries them.

Amended in review, round 2. Six sections below no longer describe what ships and are annotated where they sit: the top-3 share is normalised over the whole fund rather than over deployed capital (§4.3), the size column publishes a VALUE in the view's unit in all three views rather than a token count in two of them (§4.5), the bad-debt surface is two figures and two flags rather than one (§4.8, §(d)), there is a NINTH eligibility gate on how far the deposit token sits from its own par (§3), the benchmark's calendar fill lives in the reader alone (§6.3), and rateAsOf is gone from the row contract, having never been rendered.

Amended again in review, round 3. Three more rules changed and are annotated where they sit: par is measured against the deposit token's OWN redemption value rather than against one unit of its ticker, and a token with no tracked redemption path is FLAGGED rather than held out unless its own market price is outside the band (§3, gate 9); both price lookups are quoted under the strict confidence and staleness bounds, and a refused quote keeps the last good price pair for up to a day rather than blanking the size column (§3, §4.5); and a bad-debt response that answers for none of the markets it asked about is a failed reading rather than a clean bill of health (§4.8).

Amended in review, round 4. Two rules: a link to a fund now decides the VIEW the table opens in, and the view a reader switches into is written into the address bar, so the eight non-dollar funds are reachable by the link the app itself writes (§4.5); and gate 9 ABSTAINS on a deposit token whose redemption path this repo tracks but whose rate is missing or over a week old, instead of falling through to the backstop meant for tokens it tracks no path for (§3, gate 9). Also: the vault generation rides on the row's manager line, and the operator counts here and in the docs were corrected (nine gates, ~40 listed).

Amended in review, round 5. Four rules: a view ARRIVED in is written down like one switched into, so closing a deep-linked row cannot leave the address bar describing a different table (§4.5); a per-market config read that comes back undecodable leaves the market on the fund's list, greyed, rather than classing it closed and dropping it (§3.4, §4.7); the share-rate backfill KEEPS a point already on record and --overwrite is the deliberate repair, because that series is what the portfolio values every yield-token holding from (§3.9, which still describes the old --missing-only mechanism below); and a vault name is stored with its whitespace normalised, which moves no row id.

Amended in review, round 6 (final). Three rules. A per-market ceiling that did not READ is now distinguished from one that does not exist: the allocation row carries cap_read, the cap cell says n/a rather than "Uncapped", and the market is left out of the count of markets the manager can fill without asking (§3.1, §4.7). Gate 5 rejects only a fee charged on a DEPOSIT OR A WITHDRAWAL; a 0% performance or management fee is as listable as a 20% one, and the rule is not given a fee level at all (§3, gate 5). And migration 086 seeds TEN approved managers rather than seven, adding Re7 Labs, Block Analitica and B.Protocol, none of which has a mainnet fund above the entry floor today (§3.1).

Adds a new top-level Explore surface Money Market Funds at /money-market-funds: a screener of open-ended, single-asset, lend-only Morpho vaults on Ethereum mainnet (MetaMorpho V1 and Vaults V2), with a per-fund drawer that says what the fund holds, whether a depositor can get out, and who can change either. The old hidden "Money market funds" sub-view on /repo-lending is removed. Ships as ONE PR into staging.

Every universe figure, contract read and selector below was executed against Ethereum mainnet on 2026-08-20 (Morpho GraphQL + raw eth_call via viem against ethereum-rpc.publicnode.com). Selectors were computed with viem's toFunctionSelector, never typed from memory. ABIs were read with the Herd MCP, not guessed.

The work runs as two parallel streams plus a reconciliation pass:

  • Stream A — data/backend. Migration, registry, discovery + approval script, refresher, backfill, readers, formula module, unit tests, docs (data pages).
  • Stream B — UI. Route, nav, SEO, table, drawer, chart card, filters, narratives, icon registries, /repo-lending removal, render tests, e2e, docs (architecture page).
  • Reconciliation. Wires the two together against real data, runs the fixture and the full QA pass, writes the PR body.

Section (d), the interface contract, is what lets the streams run in parallel. Neither stream may change a name in it without telling the other.


1. Scope ​

In scope ​

  1. Route /money-market-funds, fourth in the Explore nav, order: Repo Lending, Carry Trades, Money Market Funds, Multi-Strategy Funds, Asset Profiles.
  2. Morpho mainnet only, both vault generations (MetaMorpho V1 + Vaults V2).
  3. Rule-based eligibility (not an allowlist) plus a one-time manager approval, modelled on scripts/sync-carries.ts / carry_registry.
  4. A registry table with proposal/approval/hysteresis state, a per-fund governance + liquidity state table, and a per-fund-per-market allocation table. All three refreshed on the existing 6h cron.
  5. Realised APY from the existing 6h share-price snapshots (token_yield_apy), net of fee, no reward incentives. History backfill for newly listed funds.
  6. The table (8 columns, 5 filters, sort on every metric, row deep-link) and the drawer (8 sections, in the brief's order) cloned from the /multi-strategy-funds design language.
  7. Removal of MoneyMarketSwitcher's funds branch, CuratorFundsTable, and the Euler half of that sub-view's copy.
  8. Docs in the same PR; fixture rows; unit, render and e2e tests.

Non-goals (explicit) ​

  • Other chains. chain_id is on every key, defaulted to 1, and every query filters chainId_in:[1]. No Base, no Arbitrum.
  • Euler. Euler funds leave the product. src/lib/data/curator-funds.ts, curator-vault-state.ts, src/data/curator-vaults.ts and scripts/refreshers/curator-vault-state.ts stay — they serve the AI assistant tool get_curator_funds and the /portfolio ERC-4626 universe. Only the /repo-lending sub-view and its narratives file go.
  • Rewards. MORPHO and any market-level reward tokens are excluded from every published number. One note line says so.
  • Portfolio taxonomy. No money_market_fund category-key or label change. PORTFOLIO_ERC4626_VAULTS is not widened by this work.
  • Prod. No prod deploy, no prod migration, no prod script run in this PR.
  • Execution. No deposit/withdraw affordance. Contract links only.
  • Instantaneous quoted APY as a headline. It may appear in a tooltip.

2. What the universe actually looks like (measured 2026-08-20) ​

All numbers from blue-api.morpho.org/graphql (identical response to api.morpho.org/graphql; the repo already hardcodes the former in four places, so use blue-api.morpho.org).

Raw counts ​

SetCountTVL
V1 MetaMorpho, mainnet, TVL >= $2.5M38$739.8M
V2 vaults, mainnet, all478 (412 MorphoVault + 66 FeeWrapper)$2,018.4M
V2 vaults, mainnet, TVL >= $2.5M61 (59 MorphoVault + 2 FeeWrapper)$1,977.9M
Combined >= $2.5M99$2,717.7M

Through the gates ​

GateSurvivorsDropped
TVL >= $2.5M99
Deposit asset is USD / ETH / BTC based954: EURCV, tGBP, wARS, LINK
>= 90 days of live history8114 (youngest 16d: Pendle Ecosystem USDC)

Eligible universe: 81 funds, $2,396M, 38 V1 + 43 V2.

By deposit asset: USDC 43, USDT 16, WETH 12, WBTC 2, PYUSD 2, msUSD 1, USDtb 1, msETH 1, RLUSD 1, USDS 1, AUSD 1.

By manager (API curators): Gauntlet 17, unattributed 15, Steakhouse Financial 13, Yearn 3, Clearstar 3, Sentora 3, Sky Money 3, Galaxy Curation 3, KPK 3, Hakutora 2, MEV Capital 2, SparkDAO 2, Armitage by Wintermute 2, RockawayX 2, Api3 2, AlphaPing 2, Waterline 1, Keyrock 1, Flowdesk 1, Hyperithm 1.

What lists on day one ​

The 7 seeded managers are the ones already on the site: Steakhouse Financial (which is also the API's curator for the Smokehouse brand), Gauntlet, Sentora, MEV Capital, Sky Money, SparkDAO, Yearn.

  • Attributed to a seeded manager by the API: 40 funds, $1,753M.
  • Plus 8 more recovered by the name rule (§4.4): Trezor Steakhouse USDC/USDT Prime, Safe x Steakhouse USDC/USDT, Gauntlet USDT Core, Gauntlet USDT Prime, Yearn USDT, Yearn WBTC.
  • Day-one listed: 48. Remaining 33 sit as proposals awaiting a manager decision (26 under 13 named managers, 7 with no readable manager at all, including Adpend USDC $164M and 1337 USDC $87.5M).

Facts that shape the design ​

  • Timelock, V1. Distribution over the 38: 3d ×25, 7d ×9, 10d ×1, 14d ×1, 0 ×2 (1337 USDC, Lulo USDC).
  • Timelock, V2. Per-selector, 18 selectors. Over the 43 eligible V2 funds, after the abdication rule (§5.4): 3d ×26, 7d ×9, "3-7d per action" ×4, 10d ×1, 14d ×1, "none" ×2 (Gauntlet USDT Core, Gauntlet USDT Prime). Without the abdication rule, 41 of 43 would read "none", because setAdapterRegistry is 0 on 40 of them and abdicated on 41. decreaseTimelock is 0 on all 61 by construction and must be excluded (its real delay is the timelock of the function being decreased).
  • Gates (V2 only). 10 of 478 V2 vaults set any gate; 3 of the 61 big ones (Steakhouse Confidential Prime USDC and M1 USDC set sendAssetsGate, OUSD Vault V2 sets receiveSharesGate). All three are deposit-side. No eligible fund gates exit today, which is the distinction the copy must make.
  • Nesting. 76 of 478 V2 vaults hold a MetaMorpho (V1) adapter; 66 hold a MorphoVaultV2 adapter. Among the eligible set: 3 hold V1 funds (Gauntlet USDT Core, Gauntlet USDT Prime, OUSD Vault V2), 2 are FeeWrappers over a V2 fund (Trezor Steakhouse USDC/USDT Prime). This is where TVL double-counts.
  • Public allocator. All 38 V1 vaults have a config row; 28 have at least one non-zero flow cap. Fee is 0 wei on 37 of 38 (Vault Bridge USDC charges 3e14 wei). Caps are frequently set far above reality: Smokehouse USDC's maxOut sums to $1.087 trillion. They MUST be clamped by real liquidity. VaultV2 has no publicAllocatorConfig field and the on-chain PublicAllocator calls IMetaMorpho.reallocate, so the public allocator is a V1-only mechanism. The V2 analogue is forceDeallocate + penalty.
  • Fees. V1 state.fee: 0% ×9, 1% ×1, 5% ×17, 10% ×8, 15% ×2, 50% ×1 (Adpend USDC). V2 performance: 0% ×30, 5% ×10, 7.5% ×1, 8% ×2, 10% ×10, 15% ×8. V2 management: 0 ×58, 0.2% ×1, 0.4% ×1, 1.0% ×1. No Morpho vault of either generation has an entry or exit fee, so gate 5 never rejects anyone; it is asserted, not filtered.
  • Deposits closed. 5 V1 vaults report maxDeposit() == 0 on chain (Adpend USDC, SwissBorg Morpho USDC, Lulo USDC, Metronome msETH, Clearstar Yield USDC). This is a flag, not a gate.
  • Depositor concentration. V1 vaultPositions supports orderBy: Shares. V2 positions does not; max holder count among eligible V2 funds is 1,250 and only 3 of 43 exceed 500.
  • Share-rate history. 23 of the 81 eligible funds already have token_yield_apy rows on staging (some back to 2024-01-05). 58 need a backfill.

The exit-liquidity formula is right ​

Computed fully on chain for Steakhouse USDC (0xBEEF0173…) by walking withdrawQueue, reading Morpho Blue market(id) and position(id, vault):

idle $57  +  Σ min(position, market liquidity) $27,059,227  =  $27,059,284   (36.5% of TVL)
API  Vault.liquidity.usd                                    =  $27.05M       (36.5%)

The brief's definition and Morpho's own liquidity field are the same quantity. That is the cross-check the refresher runs every tick.


3. Data architecture ​

3.1 Migration 086-money-market-funds.sql ​

Latest file on origin/staging is 085-ipor-liquity-carry-token.sql, and no open remote branch claims 086 or above (checked 2026-08-20), so this takes 086. Re-check at implementation time: other feature branches are in flight and ci.yml's first step is a duplicate-migration-number guard, so a collision fails the build rather than corrupting the ledger. One file, four tables, additive, idempotent. It must NOT contain the destructive tag or the no-transaction tag anywhere, including in prose (scripts/ops/migrate.sh greps the whole file, and a prose mention silently skips the migration; that bit migration 055).

Every table needs GRANT SELECT, INSERT, UPDATE, DELETE ... TO onchain_credit — postgres owns the tables, the app and crons connect as onchain_credit.

sql
-- 086-money-market-funds.sql
-- Registry + state for the Money Market Funds tab (/money-market-funds).
-- Morpho mainnet vaults, both generations. Written by
-- scripts/sync-money-market-funds.ts (registry + managers) and
-- scripts/refreshers/money-market-funds.ts (state + allocation, 6h).
--
-- Fund status machine:
--   proposed    passes every gate, manager not yet approved   (not shown)
--   listed      passes every gate under an approved manager   (shown)
--   delisted    was listed, TVL under the exit floor for 7 sustained days
--   ineligible  fails a gate other than the floor             (not shown)
--   rejected    an explicit human no                          (not shown)
--   gone        no longer discovered from the factories
-- Only `listed` renders.

CREATE TABLE IF NOT EXISTS onchain_credit.money_market_manager (
  manager_key         text PRIMARY KEY,          -- slug, lower-cased: 'steakhouse-financial'
  display_name        text NOT NULL,
  morpho_curator_id   text,                      -- API Curator.id, when attributed
  morpho_curator_name text,
  mark_source_url     text,                      -- cdn.morpho.org source, for the mirroring step
  status              text NOT NULL,             -- approved | proposed | rejected
  status_reason       text,
  first_seen_at       timestamptz NOT NULL DEFAULT now(),
  approved_at         timestamptz,
  last_synced_at      timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT mmm_key_chk CHECK (manager_key = lower(manager_key) AND manager_key <> '')
);

CREATE INDEX IF NOT EXISTS money_market_manager_status_idx
  ON onchain_credit.money_market_manager (status);

CREATE TABLE IF NOT EXISTS onchain_credit.money_market_fund_registry (
  chain_id            integer     NOT NULL DEFAULT 1,
  address             text        NOT NULL,      -- vault / share token, lower-cased
  slug                text        NOT NULL,      -- kebab(name) || '-' || substr(address,3,6)
  generation          smallint    NOT NULL,      -- 1 = MetaMorpho V1, 2 = Vaults V2
  vault_kind          text,                      -- morpho_vault | fee_wrapper
  factory_address     text,
  name                text,
  symbol              text,
  manager_key         text,                      -- null when unresolved
  manager_source      text,                      -- api_curator | name_rule | manual
  partner             text,                      -- co-brand: Safe, Grove, Trezor, 3F
  asset_address       text,
  asset_symbol        text,
  asset_decimals      smallint,
  share_decimals      smallint,
  denomination        text,                      -- USD | ETH | BTC
  inception_ts        timestamptz,
  tvl_usd             numeric,
  tvl_native          numeric,
  status              text        NOT NULL,
  status_reason       text,
  below_floor_since   timestamptz,               -- hysteresis clock; NULL while above the exit floor
  gate_report         jsonb,                     -- per-gate pass/fail at last sync, for the report
  first_seen_at       timestamptz NOT NULL DEFAULT now(),
  became_listed_at    timestamptz,
  last_synced_at      timestamptz NOT NULL DEFAULT now(),
  verified_block      bigint,
  PRIMARY KEY (chain_id, address),
  CONSTRAINT mmfr_addr_chk CHECK (address = lower(address) AND address <> ''),
  CONSTRAINT mmfr_gen_chk  CHECK (generation IN (1, 2))
);

CREATE UNIQUE INDEX IF NOT EXISTS money_market_fund_registry_slug_idx
  ON onchain_credit.money_market_fund_registry (chain_id, slug);
CREATE INDEX IF NOT EXISTS money_market_fund_registry_status_idx
  ON onchain_credit.money_market_fund_registry (status);

CREATE TABLE IF NOT EXISTS onchain_credit.money_market_fund_state (
  chain_id                 integer     NOT NULL DEFAULT 1,
  address                  text        NOT NULL,
  block_at_read            bigint,
  total_assets             numeric,               -- HUMAN asset units
  total_assets_usd         numeric,
  share_price              numeric,               -- assets per share, human/human
  idle_assets              numeric,
  withdrawable_now         numeric,               -- exit liquidity, human asset units
  force_deallocatable      numeric,
  force_deallocate_penalty numeric,               -- fraction (WAD/1e18); V2 only
  performance_fee          numeric,               -- fraction
  management_fee           numeric,               -- fraction
  owner_address            text,
  curator_address          text,
  guardian_address         text,                  -- V1 only
  allocators               text[]      NOT NULL DEFAULT '{}',
  sentinels                text[]      NOT NULL DEFAULT '{}',
  timelock_seconds         integer,               -- V1 global timelock
  timelocks                jsonb,                 -- V2: [{selector,functionName,seconds,abdicated}]
  pending_changes          jsonb,                 -- [{functionName,validAt,summary}]
  adapters                 jsonb,                 -- V2: [{address,type,assets,penalty}]
  liquidity_adapter        text,
  caps                     jsonb,                 -- V2: [{id,kind,label,absoluteCap,relativeCap,allocation}]
  gates                    jsonb,                 -- V2: {sendAssets,receiveShares,sendShares,receiveAssets}
  deposits_open            boolean,
  exit_restricted          boolean,
  public_allocator_admin   text,                  -- V1 only
  public_allocator_fee_wei numeric,               -- V1 only
  depositor_count          integer,
  top1_depositor_share     numeric,               -- fraction of TVL
  top10_depositor_share    numeric,
  depositor_exact          boolean,               -- false when the holder list was truncated
  bad_debt_usd             numeric,
  flags                    text[]      NOT NULL DEFAULT '{}',
  api_warnings             jsonb,
  chain_api_delta          jsonb,                 -- fields where the API disagreed with the chain
  updated_at               timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (chain_id, address)
);

CREATE TABLE IF NOT EXISTS onchain_credit.money_market_fund_allocation (
  chain_id            integer     NOT NULL DEFAULT 1,
  fund_address        text        NOT NULL,
  slot_key            text        NOT NULL,       -- market id | 'idle' | 'vault:<address>'
  slot_kind           text        NOT NULL,       -- market | idle | nested_fund
  market_id           text,
  nested_fund_address text,
  collateral_symbol   text,
  collateral_address  text,
  lltv                numeric,                    -- fraction
  oracle_address      text,
  oracle_family       text,
  irm_address         text,
  allocated           numeric,                    -- HUMAN asset units
  allocated_usd       numeric,
  share_of_fund       numeric,                    -- fraction
  supply_cap          numeric,                    -- binding cap, human asset units; NULL = uncapped
  -- AMENDED (round 6): `cap_read boolean NOT NULL DEFAULT true` sits beside it.
  -- A NULL supply_cap means "no ceiling" only when the ceiling was READ; a read
  -- that did not come back is also NULL, and the cap cell renders "Uncapped" on
  -- a NULL, which would tell a reader the fund may lend without limit into a
  -- market nobody could read. On a first-generation vault config(id).cap is a
  -- uint184 and is always a number, so a NULL there can ONLY mean a failed read.
  cap_headroom        numeric,
  market_supply       numeric,
  market_borrow       numeric,
  market_liquidity    numeric,
  market_utilization  numeric,
  pa_max_in           numeric,                    -- V1 public allocator flow caps, human units
  pa_max_out          numeric,
  jit_depth           numeric,
  withdrawable_here   numeric,
  permitted_only      boolean     NOT NULL DEFAULT false,
  bad_debt_usd        numeric,
  block_at_read       bigint,
  updated_at          timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (chain_id, fund_address, slot_key)
);

CREATE INDEX IF NOT EXISTS money_market_fund_allocation_fund_idx
  ON onchain_credit.money_market_fund_allocation (chain_id, fund_address);
CREATE INDEX IF NOT EXISTS money_market_fund_allocation_collateral_idx
  ON onchain_credit.money_market_fund_allocation (collateral_symbol);

GRANT SELECT, INSERT, UPDATE, DELETE ON onchain_credit.money_market_manager       TO onchain_credit;
GRANT SELECT, INSERT, UPDATE, DELETE ON onchain_credit.money_market_fund_registry TO onchain_credit;
GRANT SELECT, INSERT, UPDATE, DELETE ON onchain_credit.money_market_fund_state    TO onchain_credit;
GRANT SELECT, INSERT, UPDATE, DELETE ON onchain_credit.money_market_fund_allocation TO onchain_credit;

Then a seed block in the same file inserting the 7 approved managers with ON CONFLICT (manager_key) DO NOTHING:

sql
INSERT INTO onchain_credit.money_market_manager
  (manager_key, display_name, morpho_curator_name, status, approved_at)
VALUES
  ('steakhouse-financial', 'Steakhouse Financial', 'Steakhouse Financial', 'approved', now()),
  ('gauntlet',             'Gauntlet',             'Gauntlet',             'approved', now()),
  ('sentora',              'Sentora',              'Sentora',              'approved', now()),
  ('mev-capital',          'MEV Capital',          'MEV Capital',          'approved', now()),
  ('sky-money',            'Sky',                  'Sky Money',            'approved', now()),
  ('sparkdao',             'Spark',                'SparkDAO',             'approved', now()),
  ('yearn',                'Yearn',                'Yearn',                'approved', now())
ON CONFLICT (manager_key) DO NOTHING;

AMENDED (round 6): ten managers, not seven. re7-labs ("Re7 Labs"), block-analitica ("Block Analitica") and b-protocol ("B.Protocol") are seeded approved alongside the seven above, each id confirmed against the API's own curator record rather than derived from its display name. None of the three has a mainnet fund anywhere near the entry floor today (largest: Re7 WETH at $0.70M, B.Protocol's Flagship ETH at $0.10M, and Block Analitica is attributed no mainnet vault at all), so approving them lists nothing now and lets a fund of theirs list on its own merits later without a migration. The seed stays ON CONFLICT (manager_key) DO NOTHING, so it is a no-op on a database that already ran the seven.

display_name is what the UI shows. Note the deliberate splits: the API curator "Sky Money" displays as Sky, "SparkDAO" as Spark and "RE7 Labs" as Re7 Labs, matching the existing curator-marks.tsx keys. Smokehouse gets no manager row — the API attributes Smokehouse vaults to Steakhouse Financial, and that is correct: it is Steakhouse's higher-yield brand, not a separate house. The vault name still reads "Smokehouse USDC".

Backward compatibility. Everything is new; no column changes to existing tables in this migration. /money-market-funds is a NEW prerendered route, so it cannot deadlock a deploy the way an added column on an existing page's reader would — but the reader still has to survive the pre-migration build, because deploy-staging.yml runs npm run build BEFORE migrate.sh. See §3.6.

3.2 The refreshers ​

JobFileCadenceWrites
Discovery + eligibility + proposalsscripts/sync-money-market-funds.tsmanual + daily 50 3 * * *money_market_manager, money_market_fund_registry
State + allocationscripts/refreshers/money-market-funds.ts6h, inside refresh-assets.tsmoney_market_fund_state, money_market_fund_allocation
Share-rate snapshotsscripts/refreshers/token-yields.ts (extended)6h, existingtoken_yield_apy
Share-rate historyscripts/backfill-money-market-funds.tsone-offtoken_yield_apy

refresh-assets.ts gets one new entry, appended after curatorVaultStateMeta and before pendleMarketsMeta:

ts
  // Money Market Funds: per-fund governance, liquidity and per-market allocation
  // (Morpho API proposes, chain confirms). Reads the listed set from the registry.
  { slug: moneyMarketFundsMeta.slug, refresh: refreshMoneyMarketFunds },

It returns RefresherStats (not void), so it is covered by the 10% PARTIAL_FAILURE_RATIO floor. One fund whose chain reads fail leaves its previous row in place and increments failed; it is never zeroed.

3.3 API proposes, chain confirms ​

Every number the UI presents as a hard fact is read on chain. The API is used for three things only: discovery (which vaults exist), veto (its warnings, shown as flags), and labels (curator name, mark URL, metadata description). If a chain read fails, the field is null and the row logs into chain_api_delta; it is never filled from the API.

The API is also the cross-check: the refresher compares its own computed withdrawable_now against Vault.liquidity.usd (V1) / VaultV2.liquidityUsd + forceDeallocatableLiquidityUsd (V2) and writes any divergence over 2% into chain_api_delta. That is a log line, not a gate.

GraphQL complexity budget. The endpoint enforces maximumComplexity: 1000000 and returns extensions.complexity on every response. A vaultV2s page of 25 items with adapters + timelocks + gates + caps costs 282,125. Page V2 at 25, V1 at 100, and never request caps and positions in the same query. A Query is too complex error is a hard failure, not a retry.

Reusable helper. There is no shared Morpho GraphQL client in the repo (four independent inline POSTs). Stream A adds one, scripts/morpho-gql.ts, modelled on the retry wrapper already inside scripts/morpho-v2-vaults.ts:

ts
export const MORPHO_API = "https://blue-api.morpho.org/graphql";
export async function morphoGql<T>(
  query: string,
  variables?: Record<string, unknown>,
): Promise<T>;   // 2 retries, [500, 2000]ms; GraphQL `errors` throw immediately (deterministic)

It does not refactor the existing four call sites. That is a separate PR.

3.4 The on-chain read surface (verified, do not guess) ​

MetaMorpho V1 (MetaMorpho, factory 0xa9c3d3a3… v1.0 / 0x1897a899… v1.1):

ReadSelectorNote
timelock() -> uint2560xd33219b4seconds
fee() -> uint960xddca3f43WAD: 5e16 = 5%
curator() -> address0xe66f53b7
owner() -> address0x8da5cb5b
guardian() -> address0x452a9320
feeRecipient() -> address0x46904840
skimRecipient() -> address0x388af5b5
isAllocator(address) -> bool0x4dedf20eallocator set comes from SetIsAllocator logs or the API
pendingTimelock() -> (uint192,uint64)0x7cc4d9a1(value, validAt)
pendingGuardian() -> (address,uint64)0x762c31ba
pendingCap(bytes32) -> (uint192,uint64)0xa31be5d6per market id
config(bytes32) -> (uint184 cap,bool enabled,uint64 removableAt)0xcc718f76cap in RAW asset units
withdrawQueueLength() -> uint2560x33f91ebb
withdrawQueue(uint256) -> bytes320x62518ddfthe full market set, including the idle market
supplyQueueLength() / supplyQueue(uint256)0xa17b3130 / 0xf7d18521deposit ordering
totalAssets() -> uint2560x01e1d114
convertToAssets(uint256) -> uint2560x07a2d13ashare price source
maxDeposit(address) -> uint2560x402d267dreal: 0 means deposits closed

Events for history: SetCap, SubmitCap, RevokePendingCap, SetTimelock, SubmitTimelock, SetCurator, SetGuardian, SetIsAllocator, SetFee, SubmitMarketRemoval, ReallocateSupply, ReallocateWithdraw.

Vaults V2 (VaultV2, factory 0xa1d94f74…):

ReadSelectorNote
owner() 0x8da5cb5b, curator() 0xe66f53b7
isAllocator(address) 0x4dedf20e, isSentinel(address) 0xba03a75fmembership test only; the SET comes from the API or SetIsAllocator/SetIsSentinel logs
timelock(bytes4) -> uint2560xe78ab14eper selector, seconds
abdicated(bytes4) -> bool0xe470b8bcload-bearing, see §5.4
absoluteCap(bytes32) -> uint2560xbc0dd374keyed by cap id, not market id
relativeCap(bytes32) -> uint2560xa68bafa3WAD fraction of totalAssets
allocation(bytes32) -> uint2560xc69507ddkeyed by cap id
adaptersLength() / adapters(uint256)0x5aa22bc8 / 0x4ef501ac
liquidityAdapter()0xad468d110x0 means withdrawals come from idle only
liquidityData()0x2e029228
forceDeallocatePenalty(address) -> uint2560x99e99183WAD fraction
performanceFee() 0x87788782, managementFee() 0xa6f7f5d6WAD
maxRate() -> uint640xece1d6e5per-second WAD rate cap
sendSharesGate() 0x93ab2ab7, receiveSharesGate() 0x7e729ac4, sendAssetsGate() 0x8eede801, receiveAssetsGate() 0x54cde13e0x0 = open
totalAssets() 0x01e1d114, convertToAssets(uint256) 0x07a2d13aboth accrue in the view
executableAt(bytes) -> uint2560x8639fb07pending timelocked call
adapterRegistry()0x50b5c16a

maxDeposit / maxWithdraw / maxMint / maxRedeem on a V2 vault ALWAYS RETURN 0. The contract hardcodes it ("Gross underestimation because being revert-free cannot be guaranteed when calling the gate"). Reading 0 there and concluding "no liquidity" is the single easiest way to ship a wrong number on this page.

MorphoMarketV1AdapterV2 (factory 0x32bb1c0d…) — this is what makes V2 per-market allocation readable without decoding anything:

ReadSelector
marketIdsLength() -> uint2560xace48b45
marketIds(uint256) -> bytes320x779a9683
expectedSupplyAssets(bytes32) -> uint2560x99f7e861
supplyShares(bytes32) -> uint2560x6ead54d8
realAssets() -> uint2560x56c07573
parentVault() -> address0x0fe36536
ids((address,address,address,address,uint256)) -> bytes32[]see below
allocation((address,address,address,address,uint256)) -> uint256
adapterId() -> bytes320x5fb86b01

Morpho Blue singleton 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb:

ReadSelector
market(bytes32) -> (uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee)0x5c60e39a
position(bytes32,address) -> (uint256 supplyShares, uint128 borrowShares, uint128 collateral)0x93c52062
idToMarketParams(bytes32) -> (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)0x2c3c9157

PublicAllocator 0xfd32fA2ca22c76dD6E550706Ad913FC6CE91c75D — confirmed, deployed block 19,375,099 (2024-03-06):

ReadSelector
flowCaps(address vault, bytes32 id) -> (uint128 maxIn, uint128 maxOut)0x9dbcd5b9
fee(address vault) -> uint2560x6fcca69b (wei, paid on reallocateTo)
admin(address vault) -> address0x63a846f8
accruedFee(address vault) -> uint2560x91b114b2

Events: SetFlowCaps (topic0 0x709e1cb4b0ac458eb1c1a9c708e841ee963b229247afbf1437bd39e01ae4aa14), SetFee, SetAdmin, PublicWithdrawal, PublicReallocateTo. Herd's server-side eventName / functionName filters are ignored — filter client-side, or use getLogsChunked with a computed topic0.

3.5 The V2 cap-id trap (write this down) ​

absoluteCap(marketId) returns 0. Verified live on Sentora RLUSD Main. V2 caps are keyed by a cap id, keccak256(idData), and a single Morpho market consumes three of them:

adapter.ids(marketParams) -> [
  0x9944c8c8…   // idData = abi.encode("this", adapter)                → the ADAPTER cap
  0x13ff4704…   // idData = abi.encode("collateralToken", collateral)  → the COLLATERAL cap
  0x1a6e69a6…   // idData = abi.encode("this/marketParams", adapter, loan, coll, oracle, irm, lltv)
]

allocateInternal checks absoluteCap and relativeCap for every id in that array, so:

binding cap for a market = min over its ids of min(absoluteCap, relativeCap/1e18 * totalAssets())

absoluteCap == type(uint128).max (340282366920938463463374607431768211455) means uncapped; relativeCap == 1e18 means 100%. absoluteCap == 0 blocks allocation entirely, so a market with absoluteCap == 0 is not a permitted-but-empty row, it is disabled.

Decoding, if you ever need it without the adapter: MarketV1 idData is abi.decode(idData, (string, address, address, address, address, address, uint256)) = ("this/marketParams", adapter, loanToken, collateralToken, oracle, irm, lltv); Collateral and Adapter idData are abi.decode(idData, (string, address)). marketId = keccak256(abi.encode(loanToken, collateralToken, oracle, irm, lltv)) — verified to round-trip against marketIds(0).

The relative cap is measured against firstTotalAssets (a transient set at the first accrual of a transaction). For a read, totalAssets() is the right denominator.

3.6 The information_schema probe rule ​

deploy-staging.yml runs npm ci && npm run build, and only applies scripts/ops/migrate.sh inside the success branch. /money-market-funds is prerendered (export const revalidate), so its SQL runs during the build, before the migration exists. Postgres resolves column references at parse time, so a COALESCE guard does not save you: the reader must not name a missing table at all.

getMoneyMarketFunds() therefore opens with a probe, the same pattern as hasPerShareColumns in src/lib/data/carries-table.ts:

ts
let hasTables: boolean | null = null;      // cache only the POSITIVE result
async function moneyMarketTablesPresent(): Promise<boolean> {
  if (hasTables === true) return true;      // a cached `false` would keep the page
  const rows = await query<{ n: string }>(  // dark under a long-lived ISR process
    `SELECT count(*)::text AS n FROM information_schema.tables
      WHERE table_schema = 'onchain_credit'
        AND table_name IN ('money_market_fund_registry',
                           'money_market_fund_state',
                           'money_market_fund_allocation')`,
  );
  const present = Number(rows[0]?.n ?? 0) === 3;
  if (present) hasTables = true;
  else console.error(
    "[money-market-funds] registry tables absent; the tab will render empty " +
    "until migration 086 is applied. This is expected only between a deploy " +
    "and its migration.",
  );
  return present;
}

Absent -> return [] and let the page render its empty state. Degrade gracefully, but LOUDLY. Keep the probe until 086 is on prod AND a prod dump has been reseeded to staging (reseed-staging.sh at 03:00 UTC rebuilds creddit_staging from the prod dump, so the tables vanish nightly until then).

3.7 The discovery + proposal + approval script ​

scripts/sync-money-market-funds.ts. CLI, modelled on sync-carries.ts:

scripts/run-cron.sh sync-money-market-funds.ts                     # discover, classify, persist, report
scripts/run-cron.sh sync-money-market-funds.ts --dry-run           # report only, no writes
scripts/run-cron.sh sync-money-market-funds.ts --approve-manager <manager-key>
scripts/run-cron.sh sync-money-market-funds.ts --reject-manager  <manager-key>
scripts/run-cron.sh sync-money-market-funds.ts --bind-manager <fund-slug> <manager-key>
scripts/run-cron.sh sync-money-market-funds.ts --reject-fund <fund-slug> "<reason>"
scripts/run-cron.sh sync-money-market-funds.ts --relist-fund <fund-slug>
scripts/run-cron.sh sync-money-market-funds.ts --allow-shrink       # one run: sweep a genuinely smaller universe

--approve-manager / --reject-manager / --bind-manager / --reject-fund / --relist-fund short-circuit before any discovery, exactly as sync-carries.ts does, and print what changed.

Discovery. V1 from vaults(where:{chainId_in:[1]}) paged at 100 with no TVL floor (so a fund that has fallen below the floor is still seen and can be delisted rather than going gone), cross-checked against metamorpho_vault_registry (the factory-event universe already maintained by refresh-metamorpho-factory.ts) so a vault the API stops listing is not silently lost. V2 from vaultV2s(where:{chainId_in:[1]}) paged at 25.

Gates, evaluated in this order; the first failure is the status_reason:

#GateRuleFails to
1Base assetcollateralCategory(assetSymbol) ∈ {USD, ETH, BTC} (reuse src/lib/collateral.ts, already client-safe)ineligible
2Sizetvl_usd >= 2_500_000 to enter; a listed fund delists only below 2_000_000 sustained 7 days (§3.10)ineligible / delisted
3Track recordnow - inception_ts >= 90 days AND at least one token_yield_apy row older than 90 days OR an API share-price series 90 days deepineligible
4Readabilityfee, allocation set, exit liquidity, owner, curator and timelock all read non-null on chainineligible
5Fee shapeno fee charged on a DEPOSIT or a WITHDRAWAL. The fee LEVEL is not a criterion and the rule is never given one: 0% performance and 0% management lists exactly like 20%, and a charge on what the fund earns or on what it holds never rejects a fund. Asserted, never observed to fail on Morpho (round 6)ineligible
6Shapeopen-ended, single-asset, lend-only. V1: always true. V2: every adapter type ∈ {MorphoMarketV1, MetaMorpho, MorphoVaultV2}, and vault_kind ∈ {morpho_vault, fee_wrapper}ineligible
7Managerresolved AND money_market_manager.status = 'approved'proposed
8Manager confirmedattributed by Morpho's own curator record or by a human, NOT by a match against the fund's own name (round 1, R2-S1)proposed
9Parthe deposit token trades within 5% of what it redeems for to HOLD a listing, within 3% to gain one. A token whose redemption path this repo TRACKS but whose rate is missing or over a week old makes the gate ABSTAIN; only a token with no tracked path at all is flagged and falls to the market-price backstop against one nominal unit. An unreadable price never removes a fund (round 2, SF-5; round 3, NEW-2 / NEW-3; round 4, the tracked/untracked split)ineligible / delisted

A fund passing 1-6 with an unapproved or unresolved manager is proposed. A fund passing all nine is listed. Nothing auto-lists under a new manager; that is the whole point of the approval flow.

Gates 8 and 9 were added in review and the ordering matters for both. Gate 8 closes the other half of the approval door: a name rule reads a string the vault's deployer chose, so a match alone would publish an approved house's brand against a vault they may have nothing to do with, and an administrator confirms it with one --bind-manager. Gate 9 runs LAST because it is the only gate that can flip on a price tick: a fund that also fails a structural gate reports the structural reason, which is the one that would actually have to change. --allow-shrink is a tenth flag on this script, unrelated to the gates: it suspends the discovery guard's registry-count check for one run when the vault universe has genuinely shrunk (round 2, S-A).

AMENDED (round 3, NEW-3): what gate 9 measures against. Par was one dollar or one ether by ticker, which made every yield-bearing deposit token structurally unlistable (a wstETH fund at 1.2043 ether per token recorded a 20.4% break that never happened) and wrote a reason string asserting a depeg. Par is now the token's OWN redemption value, resolved from the tracked-asset registries the rest of the product values wrappers with: one unit for a token that is a dollar or an ether by design, the token's own token_yield_apy share rate (no older than a week) for one that accrues, and NOTHING for a token the repo tracks no redemption path for. The last case flags the row par_unmeasured and the gate abstains: an untracked token is not evidence that anything broke, and the reason strings never claim otherwise. ONE BACKSTOP on that case: the token's MARKET price against one nominal unit of its column, same band and same hysteresis, because a fund taking deposits at $0.73 cannot sit beside true dollar funds while we work out what it redeems for. Its reason states the two facts apart ("market price 26.6% below the dollar; redemption value not tracked") and never names a peg. A fund whose deposit token IS the price reference (WETH, WBTC) is reference: its deviation is zero by construction, so it is not measured and the drawer draws no chip for it.

AMENDED (round 3, NEW-2): the quote both scripts price on. fetchUsdPrices is called with the strict bounds (0.9 confidence floor, six-hour staleness) at both call sites, because this quote is PUBLISHED: it sizes the fund, ranks the column, screens the Min TVL filter and answers the par question, and the par question can remove a fund, the untracked-token backstop included. A refused quote is read as no price, which keeps the fund's prior status and its last good size; the refresher carries the last good price pair for up to a day, flags the row stale_price and names the day it was taken.

Report, three groups, same shape as the carries report:

=========== MONEY MARKET FUNDS COVERAGE REPORT ===========
Floors: TVL >= $2.5M to list | < $2.0M for 7 sustained days to delist | >= 90d track record

---- LISTED / PROPOSED ----
  LISTED    Steakhouse USDC              V1  USDC   $74.1M   Steakhouse Financial
  PROPOSED  Galaxy USDC Quality          V2  USDC   $24.3M   Galaxy Curation (manager not approved)
---- EXCLUDED BY POLICY ----
  Steakhouse Prime EURCV        V2  EURCV  $128.0M  deposit asset is not USD/ETH/BTC based
---- NEEDS A DECISION ----
  MANAGER  Galaxy Curation   4 funds  $60.7M best   -> --approve-manager galaxy-curation
  UNNAMED  Adpend USDC       V1  $164.0M            -> --bind-manager adpend-usdc-55555 <key>
==========================================================

The NEEDS A DECISION manager rows are collapsed per manager, not per fund (one manager approval unlocks all its funds), mirroring how sync-carries.ts collapses Aave/Spark rows per collateral.

3.8 Manager resolution ​

Three sources, in order. The rule never invents a manager.

  1. API curator. Vault.state.curators[] (V1) / VaultV2.curators.items[] (V2). manager_key = slug(curator.name), manager_source = 'api_curator'. Records curator.image (the cdn.morpho.org/v2/assets/images/* URL) into mark_source_url for the mirroring step. 66 of 81 eligible funds resolve here.

  2. Name rule, only when it resolves to an already-approved manager, so it can never create one. Port detectCurator from scripts/sync-curator-vaults.ts:

    /^(.+?)\s+x\s+(Steakhouse|Smokehouse)\b/i   -> steakhouse-financial, partner = group 1
    /\b(Smokehouse|Steakhouse)\b/i              -> steakhouse-financial
    /\bGau?ntlet\b/i                            -> gauntlet          (tolerates the "Gaunlet" typo)
    /\bSentora\b/i                              -> sentora
    /\bMEV Capital\b/i                          -> mev-capital
    /\bsky\.money\b/i                           -> sky-money
    /\bSpark\b/i                                -> sparkdao
    /\bYearn\b/i                                -> yearn

    Recovers 8 funds: Trezor Steakhouse USDC/USDT Prime, Safe x Steakhouse USDC/USDT, Gauntlet USDT Core, Gauntlet USDT Prime, Yearn USDT, Yearn WBTC. partner captures the co-brand (Safe, Trezor, Grove, 3F) for the row's second line.

  3. Manual bind via --bind-manager <fund-slug> <manager-key>, manager_source = 'manual'.

Unresolved -> manager_key IS NULL, status proposed, and the fund appears in the report's UNNAMED bucket. 7 eligible funds land here today.

Gate 4 interaction: a fund with no manager never lists, which satisfies "a fund never lists with a blank core column" for the Fund column's manager line.

3.9 Share-rate history for newly listed funds ​

The realised APY, the total-return curve and the TVL curve all come from onchain_credit.token_yield_apy, which already stores, human-scaled:

share_rate   = convertToAssets(10^18) / 10^rateDivisorPow10,  rateDivisorPow10 = assetDecimals + 18 - shareDecimals
total_supply = totalSupply()          / 10^shareDecimals
TVL(t)       = share_rate(t) * total_supply(t)      in human units of the deposit asset

Verified on both generations: Steakhouse USDC convertToAssets(1e18) = 1_137_000, rateDivisorPow10 = 6 -> 1.137, equal to the API's sharePriceNumber1.137000630. Sentora RLUSD Main (18-dec asset, 18-dec share, divisor 18) -> 1.009841.

Live snapshots. scripts/refreshers/token-yields.ts builds YIELD_TOKENS at module load from a static array. Stream A adds a runtime union so a fund listed by an approval starts snapshotting on the next tick with no deploy:

ts
export async function loadRegistryYieldTokens(pool: Pool): Promise<YieldToken[]>;
// SELECT address, name, asset_decimals, share_decimals
//   FROM onchain_credit.money_market_fund_registry
//  WHERE chain_id = 1 AND status = 'listed'
//    AND asset_decimals IS NOT NULL AND share_decimals IS NOT NULL
// -> { address, symbol: name, kind: "erc4626",
//      rateDivisorPow10: asset_decimals + 18 - share_decimals, shareDecimals: share_decimals }

Unioned by lower-cased address, YIELD_TOKENS winning on collision (23 of the 81 are already there). Wrapped in a try/catch returning [] so a pre-migration tick is a no-op, not a crash. PORTFOLIO_ERC4626_VAULTS is not touched — widening it would change /portfolio's covered universe, an explicit non-goal.

Second-order effect to check. refreshTokenYields returns RefresherStats and feeds the 10% PARTIAL_FAILURE_RATIO alert floor. Today ~83 tokens with 5 known permanent failures (~6%). Adding 58 funds makes it ~141 with the same 5 (~3.5%), which dilutes the existing signal. That is benign, but the reconciliation agent must run one tick after the backfill and confirm the new funds' failure count is near zero; a cluster of new failures could push the combined rate over 10% and start paging.

Backfill. New script scripts/backfill-money-market-funds.ts, a registry-driven sibling of scripts/backfill-curator-vaults.ts (do not edit that one; it iterates PORTFOLIO_ERC4626_VAULTS):

scripts/run-cron.sh backfill-money-market-funds.ts [DAYS] [--only=<addr>,<addr>] [--missing-only]
  • Reads its universe from money_market_fund_registry WHERE status = 'listed'.

  • DAYS positional, default 365, validated to an integer in 1..3650 with the same "did you mean --only=" guard (Number() hex-parses, and a --only 0x9fb7… with a space builds a 3.6e48-entry array and hangs the box).

  • --missing-only skips any fund that already has a row at the tick, which is what makes a re-run cheap.

    AMENDED (round 5): that is now the DEFAULT, and the flag is inert.token_yield_apy is also the series the portfolio values every yield-token holding from, so a rerun over a covered window restated history already published to users on nothing but a second archive read of the same blocks. A point already on record is KEPT and counted, in the statement (DO NOTHING) as well as in the pre-check, and --overwrite is the deliberate repair. --missing-only still parses, so the published runbook command keeps working; it simply names what already happens.

  • Same 6h grid, same blockByTimestamp hoisted once per tick and shared across funds, same permanent-error classification (/0x to a BigInt|no code|reverted|returned no data|empty/i -> null, no retry), same sleep(50) per tick, same ON CONFLICT … supply_apy = COALESCE(…) upsert.

  • Provider: ETHEREUM_ARCHIVE_RPC_URL (default https://eth.drpc.org), the same archive path the existing backfill uses.

Method note. The API's historicalState.sharePriceNumber(interval: DAY) returns the same quantity back to inception (960 daily points for Steakhouse USDC, back to 2024-01-04) and would be a much cheaper backfill. Do not use it. Mixing an API-sourced daily series with chain-sourced 6h rows in the same column gives the chart two provenances and two grids, and the repo's whole yield doctrine is one method per series. The API series is a cross-check only: the backfill logs any point that differs from the archive read by more than 10 bps.

Cost estimate for the run: 58 funds x 1,461 ticks x 2 archive calls ≈ 170k eth_calls plus 1,461 block lookups. Budget 6-9 hours; it is resumable by default (round 5), so a re-run picks up where it stopped without a flag.

3.10 The delist hysteresis ​

This is the first time-based hysteresis in the repo. morpho-rule.ts uses a floor multiplier evaluated per run (HYSTERESIS = 0.5); the brief asks for "below $2.0M sustained 7 days", which needs a clock. That clock is money_market_fund_registry.below_floor_since.

ts
export const MMF_LIST_FLOOR_USD = 2_500_000;
export const MMF_DELIST_FLOOR_USD = 2_000_000;
export const MMF_DELIST_SUSTAIN_MS = 7 * 24 * 3600 * 1000;

export type FloorVerdict =
  | { action: "clear" }                                 // above the exit floor: reset the clock
  | { action: "arm"; since: number }                    // first observation below: start the clock
  | { action: "hold"; since: number }                    // still below, not yet 7 days
  | { action: "delist"; since: number };                 // below for >= 7 days

export function floorVerdict(args: {
  status: string;              // current registry status
  tvlUsd: number | null;
  belowFloorSince: number | null;
  nowMs: number;
}): FloorVerdict;

Rules, all in the pure function:

  • status !== 'listed' -> entry uses the entry floor $2.5M; the clock is irrelevant.
  • tvlUsd === null (a failed read) -> {action:"clear"} is wrong and {action:"arm"} is wrong. A failed read is not an observation: return {action:"hold", since: belowFloorSince} when a clock is running and {action:"clear"} only when there was none. Never delist on a null.
  • tvlUsd >= MMF_DELIST_FLOOR_USD -> clear, below_floor_since = NULL.
  • tvlUsd < MMF_DELIST_FLOOR_USD and no clock -> arm, below_floor_since = now.
  • clock running and now - since >= 7d -> delist.
  • A delisted fund that climbs back over the entry floor $2.5M re-lists automatically (its manager is still approved); crossing back over only $2.0M does not, which is the hysteresis band.

The sync runs daily, so the clock has day granularity. That is intentional: a 7-day rule read at 6h granularity would delist on the 28th consecutive failing tick, which is more fragile, not less.


4. Formulas, each with a unit-test spec ​

All pure functions live in src/lib/data/money-market-fund-math.ts — client-safe, zero imports beyond types, so the client table can import the values. src/lib/data/money-market-funds.ts (which imports pg) re-exports them so server callers keep one import target. This is the same split, for the same reason, as fund-stats.ts / strategies-table.ts.

Tests go in src/lib/data/money-market-fund-math.test.ts, node:test + assert/strict, registered in package.json's explicit test-file list.

4.1 Exit liquidity ​

ts
export type ExitSlot = {
  allocated: number;        // human asset units the fund holds in this market
  marketLiquidity: number;  // market supply minus borrow, human asset units, clamped >= 0
};

export function exitLiquidity(idle: number, slots: readonly ExitSlot[]): number;
// idle + Σ min(allocated, marketLiquidity)

For a V2 fund the same function is used, but the slot list is built differently and the answer is split in two:

  • withdrawableNow = idle + (liquidityAdapter === ZERO ? 0 : exitLiquidity(0, slotsReachableViaLiquidityData)) — because VaultV2.exit() only deallocates from liquidityAdapter, nothing else.
  • forceDeallocatable = exitLiquidity(0, allSlots) minus what is already counted in withdrawableNow, available only through forceDeallocate and therefore quoted with its penalty.

Unit tests:

CaseExpectation
idle only, no marketsreturns idle
one market, position < liquidityidle + position
one market, position > liquidityidle + liquidity (the market, not the position, binds)
Steakhouse USDC replica: idle 0.000057, 11 slots with the measured positions and liquidities27_059_284 +/- 1
a slot with marketLiquidity 0contributes 0, does not throw
a slot with negative marketLiquidity (borrow > supply after an interest tick)clamped to 0
empty slot list, idle 00, not null
V2 with liquidityAdapter === ZEROwithdrawableNow === idle exactly

The magnitude-unique rule applies: no two positions or liquidities in a fixture may share a magnitude, or a swapped-argument bug passes.

4.2 JIT depth via the public allocator (V1 only) ​

The public allocator lets anyone push liquidity into one market by pulling it out of others in the same vault, bounded on both sides by flow caps and by what those other markets actually hold and can release.

ts
export type FlowSlot = {
  marketId: string;
  allocated: number;         // fund position, human units
  marketLiquidity: number;   // unborrowed, human units
  maxIn: number;             // public allocator maxIn for this vault+market
  maxOut: number;            // public allocator maxOut
};

export function jitDepth(target: FlowSlot, others: readonly FlowSlot[]): number;
// min( target.maxIn,
//      Σ_others min(other.maxOut, other.allocated, other.marketLiquidity) )

The triple min is the whole point: maxOut is a permission, allocated is what is there, marketLiquidity is what can actually leave. Measured caps run to $1.087 trillion, so a formula that trusted maxOut alone would print nonsense.

Unit tests:

CaseExpectation
target.maxIn 00 (the market cannot receive)
others empty0
one donor with maxOut huge, allocated smallbounded by allocated
one donor with allocated huge, marketLiquidity smallbounded by marketLiquidity
three donors summing above target.maxInbounded by target.maxIn
donor list includes the target itselfthe target is excluded by the caller; the test asserts the caller's others builder drops it
Smokehouse USDC replica (maxOut 1.087e12, allocations in the low millions)equals the allocation-bound sum, not the cap sum
V2 fundthe caller passes null; the UI column reads "n/a", never 0

jit_depth is stored per allocation row. The drawer's "JIT depth" column shows it, and the tooltip says the figure is per fund, that it is shared with every other vault in the same market, and that it is not additive down the column.

4.3 Top-3 concentration ​

AMENDED IN REVIEW (round 2, B1). The denominator below is deployed capital, which prints top 3 100% on a fund that is 99.998% uninvested: the most concentrated label on the tab, on the least concentrated book on it, one line above its own allocation table saying the opposite. What ships divides by the FUND. Live at block 25,799,307: Vault Bridge WBTC 0.0%, sky.money USDS Flagship 20.0%, Metronome msUSD Vault 37.5%, all three previously 100%.

ts
export function topNShare(
  marketAllocations: readonly (number | null)[],
  totalAssets: number | null,
  n = 3,
): number | null;
// null when totalAssets is unreadable or <= 0, and null when ANY market
// allocation is unreadable; otherwise sum of the n largest market allocations
// / totalAssets, clamped to [0, 1]. Zero is a real answer: the fund lends
// nothing out.

Idle cash is not a candidate (it is not a market, and a fund that has parked its money is not concentrated in cash) and is in the denominator (the reader is asking what share of their deposit rides on three collaterals, and cash rides on none of them). A holding in another listed fund is likewise not a candidate: it is exposure to that fund's whole book. The table shows Markets as <count> · top 3 <pct>.

Unit tests: no markets -> 0; unreadable size -> null; one unreadable allocation -> null; four equal markets with no cash -> 0.75; three markets with no cash -> 1 exactly (not 0.999…); negative entry -> ignored; the Steakhouse USDC replica -> 0.91 +/- 0.005 (measured); and the three cash-heavy funds above, each pinned to the figure measured on chain.

4.4 Timelock summary ​

ts
export type TimelockEntry = { functionName: string; seconds: number; abdicated: boolean };

export const MMF_CORE_TIMELOCK_ACTIONS = [
  "increaseAbsoluteCap",
  "increaseRelativeCap",
  "addAdapter",
  "setAdapterRegistry",
  "setSendSharesGate",
  "setReceiveAssetsGate",
] as const;

export function timelockSummary(
  input: { generation: 1; seconds: number | null } | { generation: 2; entries: readonly TimelockEntry[] },
): { text: string; minSeconds: number | null; maxSeconds: number | null; noTimelock: boolean };

Rules:

  • V1: one global timelock(). 0 -> {text:"none", noTimelock:true} (the cell renders red). null -> {text:"n/a"}. Otherwise the duration formatted as whole days when it divides evenly ("3d", "7d", "14d"), else hours.
  • V2: consider only MMF_CORE_TIMELOCK_ACTIONS, the six selectors that can newly expose a depositor or newly restrict their exit.
    • An abdicated selector can never be called again, so it is excluded from the min/max and counted as permanently delayed.
    • decreaseTimelock is never in the set. It reads 0 on all 61 measured vaults because its effective delay is the timelock of the function whose timelock is being decreased. Including it makes every V2 fund read "none".
    • removeAdapter, increaseTimelock and abdicate are depositor-protective and are shown in the drawer's table but excluded from the one-line summary.
    • Fee setters, setIsAllocator and setForceDeallocatePenalty are shown in the table, excluded from the summary.
    • Every core selector abdicated -> {text:"permanent"}.
    • All live core selectors equal -> that duration ("3d").
    • All live core selectors 0 -> {text:"none", noTimelock:true}.
    • Otherwise "{min}-{max} per action", e.g. "3-7d per action"; noTimelock is true when min === 0.

Unit tests (the abdication case is the one that matters):

CaseExpectation
V1 seconds: 604800"7d", noTimelock false
V1 seconds: 0"none", noTimelock true
V1 seconds: null"n/a", noTimelock false
V2, all six at 259200"3d"
V2, caps at 259200, adapter at 604800"3-7d per action"
V2, setAdapterRegistry 0 abdicated, everything else 259200"3d", noTimelock false
V2, setAdapterRegistry 0 live, everything else 259200"0-3d per action", noTimelock true
V2, entries include decreaseTimelock: 0ignored entirely; result unchanged
V2, all six abdicated"permanent"
V2 Sentora replica (the 18 real entries)"3d", noTimelock false
V2 Gauntlet USDT Core replica"none", noTimelock true

4.5 Realised APY windows ​

Reuse windowApy from src/lib/data/strategies-table.ts verbatim — it already anchors on the newest snapshot at-or-before latest - days and returns null only when unmeasurable, and it is already unit-tested. Do not write a second window function.

The trap that forces a unit test rather than an e2e check: scripts/fixture/seed.sql generates share rates as base_rate * power(1 + apy, i/1460.0), a smooth exponential in which every window annualises to exactly the same number. A 24h/7d/30d selector is therefore indistinguishable from an inert one against the fixture. So Stream A adds, to money-market-fund-math.test.ts, window cases the fixture cannot express, using a local series(days, rateAt) builder against a frozen end timestamp:

CaseExpectation
a frozen rate (no movement) over the windowexactly 0, not null
a fresh step: flat then a jump 12h before the end24h hot, 30d diluted, and 24h > 30d
a series shorter than the windownull
a single snapshotnull
a rate that decreased (management fee on a flat vault)a negative APY, not clamped to 0
latest snapshot rate <= 0null

4.6 TVL denomination by the asset switch ​

AMENDED IN REVIEW (rounds 1 and 2, R2-B1 then SF-1). "Native, never USD-converted" below is not what ships, and it was wrong in every view rather than only the dollar one. A deposit token is not always worth one unit of the denomination it belongs to: 21.5M msUSD is $15.8M and not $21.5M, and 2.03k msETH is 1,490 ether against 1.86k WETH's 1,863. Publishing the token COUNT ranked a $3.45M fund above a $4.31M one in the ether view and let a "2 k ETH" size screen exclude the larger of the two. What ships publishes the fund's VALUE in the view's unit in all three views, from one field (tvlDenominated) that the cell, the sort and the Min TVL screen all read, with the token count and the price it was converted at moved into the size tooltip.

The asset switch has three states: USD, ETH, BTC. It filters rows to that denomination and changes the TVL unit.

ts
export type FundDenomination = "USD" | "ETH" | "BTC";
export function tvlUnit(d: FundDenomination): { label: string; scale: number };
// USD -> { label: "$M",     scale: 1e6 }
// ETH -> { label: "k ETH",  scale: 1e3 }
// BTC -> { label: "BTC",    scale: 1 }
export function fmtTvl(nativeValue: number | null, d: FundDenomination): string;

Native, never USD-converted: an ETH fund's TVL is in ETH, a BTC fund's in BTC, matching how /multi-strategy-funds already treats tvlNative. Min TVL is entered in the switch's own unit and clears when the switch changes (a "10" meaning $10M must not silently become 10k ETH). Rows whose TVL is unreadable are withheld by a Min TVL screen, never passed through.

Unit tests: null -> "n/a" (never "—", never "$0"); 0 -> "$0.0M"; 74_114_800 USD -> "$74.1M"; 1_234 ETH -> "1.2k ETH"; 27.4 BTC -> "27.4 BTC"; a value in the wrong unit is caught by a magnitude-unique fixture.

4.7 "Of which via other listed funds" ​

TVL double-counts when a listed V2 fund holds a listed V1 or V2 fund. The brief is explicit: show it, do not net it.

ts
export function viaListedFunds(
  slots: readonly { slotKind: string; nestedFundAddress: string | null; allocatedUsd: number | null }[],
  listedAddresses: ReadonlySet<string>,
): number;
// Σ allocatedUsd over slots where slotKind === "nested_fund"
//   and nestedFundAddress is in listedAddresses

Computed in the reader, not the refresher, because the listed set changes on every approval. The refresher's job is only to write the nested_fund slots. The table's TVL tooltip shows "$X.XM of which via other listed funds" when the value is > 0, and the panel header adds one line under the total.

Unit tests: no nested slots -> 0; a nested slot pointing at an unlisted fund -> 0; two nested slots both listed -> their sum; a null allocatedUsd -> treated as 0, and the caller marks the total approximate.

4.8 What is not redeemable today ​

ALSO AMENDED (round 2, SF-3 and S-B). The bad-debt surface this section and §(d) describe is one figure and one past_bad_debt flag. What ships is TWO: bad_debt_usd (currently unbacked, a loss that has not landed) with the bad_debt chip, and realized_bad_debt_usd (already written off, and already inside the share price) with past_bad_debt. They are never summed. A failed reading writes neither column and keeps the previous figures, their chips and the date they were taken.

AMENDED AGAIN (round 3, NEW-1). "Failed" now includes a response that came back carrying nothing about the markets it asked about, at both levels: a reading that answered for fewer than half the market ids it requested is discarded whole, and a fund none of whose markets were answered for keeps its own figures even when the reading was otherwise good. Both take the same path as a thrown request, because a 200 with an empty item list and a network failure produce the identical all-null map, and publishing it reads as a clean bill of health across a tab whose subject is risk.

SUPERSEDED IN REVIEW (round 1, R2-B3). This section planned an estimated time to a full exit. It is not shipped and should not be revived. The model counted interest accrual as the refill, and Morpho._accrueInterest adds the same amount to totalBorrowAssets and totalSupplyAssets, so liquidity = supply - borrow is invariant under it: the named mechanism returns no cash at all. The two things that do refill a market, borrower repayments and new deposits, are not forecastable from anything this page holds. On the 51 listed funds the number was also degenerate in practice (22 "not estimable", 26 "over 1 year", 3 "now", no intermediate value anywhere) and contradicted the headline three lines above it.

What ships instead:

ts
export function exitShortfall(
  tvlNative: number | null,
  withdrawableNow: number | null,
): number | null;

max(0, TVL - redeemable now), null when either side is unreadable. The panel publishes the shortfall in the fund's own token and as a share of the fund, and below MMF_SHORTFALL_DUST_SHARE (0.1%) says nothing further about it. Above it, one factual line names what the remainder waits on, and on a Vaults V2 fund the force-deallocatable amount and its penalty, which is a route out that waits on neither. See metrics.md, "What is not redeemable today, and why no wait is quoted".

This is the one number on the page with real model risk. Its tooltip states the assumption in one sentence and never presents it as a guarantee.


5. THE INTERFACE CONTRACT (Stream A <-> Stream B) ​

Stream A implements these functions. Stream B consumes them and may build against the fixture rows in §5.5 before Stream A lands. Neither stream changes a name here unilaterally.

5.1 Module layout ​

ModuleClient-safeContents
src/lib/data/money-market-fund-math.tsyesevery pure formula in §4, FundDenomination, tvlUnit, fmtTvl, the timelock constants
src/lib/data/money-market-funds.tsno (imports pg)all readers, all types below, re-exports everything from the math module
src/data/money-market-fund-narratives.tsyesMANAGER_HOUSE, FUND_MANDATE, moneyMarketFundDescription()

Client components import types from money-market-funds.ts with import type (erased at compile time, does not drag pg in) and values only from money-market-fund-math.ts.

5.2 Types (verbatim; this is the contract) ​

ts
export type FundGeneration = 1 | 2;

export type FundDenomination = "USD" | "ETH" | "BTC";

export type FundFlag =
  | "no_timelock"
  | "permissioned_deposits"
  | "exit_restricted"
  | "pending_cap_raise"
  | "cap_raise_executable"
  | "zero_idle"
  | "deposits_closed"
  // Two disclosures about the FIGURES rather than about the fund (round 3,
  // NEW-2 / NEW-3): the size was converted at the last good price rather than
  // at one taken this cycle, and the deposit token's redemption value is not
  // one the repo tracks, so the par gate abstained.
  | "stale_price"
  | "par_unmeasured"
  // TWO bad-debt flags, not one (round 2, SF-3): `bad_debt` is a loss that has
  // not landed, `past_bad_debt` a loss already inside the share price.
  | "bad_debt"
  | "past_bad_debt"
  | "dominant_depositor";

export type ApyWindow = "24h" | "7d" | "30d";

/** One row of the screener. Every number is in the fund's own asset unit
 *  unless the field name ends in `Usd`. `null` means "not readable", never
 *  "zero": a row with a null in a core column is not listed at all, so a null
 *  here is a live read failure and renders as "n/a". */
export type MoneyMarketFundRow = {
  slug: string;                       // stable row id: kebab(name) + "-" + the LAST six characters of the address
  address: string;                    // lower-cased
  chainId: 1;
  generation: FundGeneration;
  vaultKind: "morpho_vault" | "fee_wrapper";
  name: string;                       // "Steakhouse USDC"
  managerKey: string;                 // "steakhouse-financial" (never null on a listed row)
  managerName: string;                // "Steakhouse Financial"
  partner: string | null;             // "Safe" | "Trezor" | "Grove" | null
  asset: string;                      // "USDC"
  assetAddress: string;
  assetDecimals: number;
  denomination: FundDenomination;
  inceptionIso: string | null;

  tvlNative: number | null;           // human units of `asset`: a COUNT of the deposit token
  tvlUsd: number | null;
  // AMENDED (round 2, SF-1): what the fund is WORTH in its own denomination's
  // unit. This is the size column, the sort and the Min TVL screen, in all
  // three views.
  tvlDenominated: number | null;
  tvlViaListedFundsUsd: number;       // 0 when nothing is nested; never null

  // AMENDED (round 2, SF-5): the price the size was converted at, and how far
  // the deposit token sits from one unit of its denomination.
  // AMENDED AGAIN (round 3, NEW-2 / NEW-3): the distance is measured against
  // what the token REDEEMS for, `parBasis` says how that was established, and
  // `priceAsOf` is the tick the price pair was actually observed at (older than
  // `asOf` when the strict gate refused this tick's quote and the last good
  // pair was carried).
  assetPriceUsd: number | null;
  denominationPriceUsd: number | null;
  parDeviation: number | null;        // signed fraction; -0.2663 is 26.63% below its redemption value
  parBasis: ParBasis;                 // par | redemption_rate | reference | unmeasured
  redemptionRate: number | null;      // denomination units per deposit token; null when unmeasured
  marketPriceInUnit: number | null;   // what one token TRADES at, same unit
  priceAsOf: string | null;           // ISO, when the price pair was observed

  // AMENDED (round 3, N-l): null, not zero, until the fund has a state row. A
  // listed fund between its approval and its first tick lends into nothing we
  // have read, which is not the same as lending into nothing.
  marketCount: number | null;         // markets with a live allocation (idle excluded)
  permittedMarketCount: number | null;// cap > 0 and allocation ~ 0
  topThreeShare: number | null;       // 0..1 of the WHOLE fund; idle is not a candidate

  exitLiquidityNative: number | null;
  exitLiquidityShare: number | null;  // 0..1 of tvlNative
  forceDeallocatableNative: number | null;  // V2 only; null on V1

  performanceFee: number | null;      // fraction, 0.05 = 5%
  managementFee: number | null;       // fraction; 0 is a real value, null is unreadable

  timelockText: string;               // "3d" | "3-7d per action" | "none" | "permanent" | "n/a"
  timelockMinSeconds: number | null;
  timelockMaxSeconds: number | null;

  apy: Record<ApyWindow, number | null>;   // fractional, realised, net of fee, no rewards
  quotedApy: number | null;                // instantaneous protocol-quoted rate; TOOLTIP ONLY

  collateralSymbols: string[];             // current allocation >= 0.5% of TVL, canonicalised
  permittedCollateralSymbols: string[];    // superset: allocation + permitted-but-empty

  flags: FundFlag[];
  depositsOpen: boolean | null;

  asOf: string | null;                // ISO, state read
  // `rateAsOf` was here and is gone (round 2, NIT-8): it was never rendered.
  // A stale rate window is nulled by `apyWindows` instead, which the cell shows
  // as "n/a", and the as-of line above the table carries the state read.
};

export type NamedAddress = {
  address: string;                    // lower-cased
  label: string | null;               // "Steakhouse Financial" | "Safe 1/1" | null
  kind: "manager" | "safe" | "contract" | "eoa" | null;
};

export type TimelockRow = {
  action: string;                     // "increaseAbsoluteCap"
  label: string;                      // "Raise a market cap"  (human copy, no em-dashes)
  seconds: number;
  abdicated: boolean;
  core: boolean;                      // true when it feeds `timelockText`
};

export type PendingChange = {
  action: string;
  label: string;
  executableAtIso: string;
  executableNow: boolean;             // validAt <= now: the change can be applied at will
  summary: string;                    // "Raise the cbBTC / RLUSD cap to 175M"
};

export type AdapterRow = {
  address: string;
  type: "MorphoMarketV1" | "MetaMorpho" | "MorphoVaultV2";
  assetsNative: number | null;
  penalty: number | null;             // fraction; forceDeallocatePenalty
  isLiquidityAdapter: boolean;
};

export type CapRow = {
  id: string;                         // cap id (bytes32)
  kind: "Adapter" | "Collateral" | "MarketV1" | "Unknown";
  label: string;                      // "cbBTC" | "MorphoMarketV1 adapter" | "cbBTC / RLUSD 86%"
  absoluteCapNative: number | null;   // null = uncapped (uint128 max)
  relativeCap: number | null;         // fraction; 1 = 100%
  allocationNative: number | null;
};

export type GateRow = {
  gate: "deposit" | "receiveShares" | "sendShares" | "withdraw";
  address: string;
  effect: string;                     // plain-language, e.g. "Only allowlisted addresses can deposit."
};

export type FundAllocationRow = {
  slotKey: string;                    // market id | "idle" | "vault:0x…"
  slotKind: "market" | "idle" | "nested_fund";
  marketId: string | null;
  nestedFundSlug: string | null;      // set when slotKind === "nested_fund" AND that fund is listed
  collateral: string | null;          // canonicalised symbol; null on idle
  collateralAddress: string | null;
  lltv: number | null;                // fraction, 0.86
  oracleAddress: string | null;
  oracleFamily: string | null;        // "Chainlink" | "Exchange rate" | "Meta oracle" | "Unknown"
  allocatedNative: number | null;
  allocatedUsd: number | null;
  shareOfFund: number | null;         // 0..1
  marketUtilization: number | null;   // 0..1
  marketLiquidityNative: number | null;
  capNative: number | null;           // null = uncapped
  capHeadroomNative: number | null;
  publicAllocatorMaxInNative: number | null;   // V1 only
  publicAllocatorMaxOutNative: number | null;  // V1 only
  jitDepthNative: number | null;               // V1 only; null on V2
  withdrawableHereNative: number | null;
  permittedOnly: boolean;             // cap > 0, allocation ~ 0 -> greyed row
  badDebtUsd: number | null;
};

export type FundExitPanel = {
  withdrawableNowNative: number | null;
  withdrawableNowShare: number | null;      // 0..1
  fromIdleNative: number | null;
  fromMarketsNative: number | null;
  forceDeallocatableNative: number | null;  // V2 only
  forceDeallocatePenalty: number | null;    // V2 only, fraction
  shortfallNative: number | null;   // TVL minus what is redeemable today
  shortfallShare: number | null;
  topTenDepositorShare: number | null;      // 0..1
  largestDepositorShare: number | null;
  depositorCount: number | null;
  depositorConcentrationExact: boolean;     // false when the holder list was truncated
};

export type FundControlPanel = {
  generation: FundGeneration;
  owner: NamedAddress | null;
  curator: NamedAddress | null;
  guardian: NamedAddress | null;            // V1 only
  allocators: NamedAddress[];
  sentinels: NamedAddress[];                // V2 only
  globalTimelockSeconds: number | null;     // V1 only
  timelockedActions: TimelockRow[];         // V2: 18 rows; V1: the delayed-action list
  pendingChanges: PendingChange[];
  adapters: AdapterRow[];                   // V2 only
  caps: CapRow[];                           // V2 only
  gates: GateRow[];                         // V2 only; empty array = open to everyone
  forceDeallocatePenalty: number | null;    // V2 only
  publicAllocator: { admin: NamedAddress | null; feeWei: string | null } | null;  // V1 only
  morphoUrl: string;                        // https://app.morpho.org/ethereum/vault/<addr>
  etherscanUrl: string;
};

export type FundStatsTower = {
  rateType: "Variable";
  trackRecordSinceIso: string | null;
  realised30d: number | null;
  realised90d: number | null;
  realised1y: number | null;
  maxDrawdown: number | null;               // positive fraction
  trackedFromIso: string | null;            // drives the "since <month>" rule
  performanceFee: number | null;
  managementFee: number | null;
  vsBenchmark30d: number | null;            // fraction; fund 30d minus benchmark 30d
  benchmarkLabel: string;                   // "SOFR" | "Aave v3 WETH" | "Aave v3 WBTC"
};

export type MoneyMarketFundDetail = {
  slug: string;
  address: string;
  generation: FundGeneration;
  managerHouse: string;                     // one sentence about the manager
  mandate: string;                          // two sentences about this fund
  stats: FundStatsTower;
  allocations: FundAllocationRow[];         // idle first, then markets by allocation desc, then permitted-only
  exit: FundExitPanel;
  control: FundControlPanel;
};

/** One point of a fund's history. `shareRate` is assets per share in human
 *  units of both; `tvlNative` is shareRate * totalSupply. */
export type FundHistoryPoint = {
  tsIso: string;
  shareRate: number;
  tvlNative: number | null;
};

export type FundBenchmarkSeries = {
  label: string;                            // "SOFR" | "Aave v3 WETH"
  points: { tsIso: string; index: number }[];
};

export type FundHistoryPayload = {
  slug: string;
  denomination: FundDenomination;
  points: FundHistoryPoint[];
  benchmark: FundBenchmarkSeries | null;
};

5.3 Reader functions ​

ts
/** Every listed fund, one row each, ordered by tvlUsd desc.
 *  Returns [] (never throws) when migration 086 has not been applied.
 *  Throws only on a real query failure, so "we could not read" stays
 *  distinguishable from "there are no funds". */
export async function getMoneyMarketFunds(): Promise<MoneyMarketFundRow[]>;

/** Drawer content for every listed fund, keyed by slug. Same emptiness
 *  contract as above. */
export async function getMoneyMarketFundDetails(): Promise<Map<string, MoneyMarketFundDetail>>;

/** Chart series for ONE fund. Called from the route handler, not the page.
 *  `range` trims the series; `points` are 6h up to 3 months and daily closes
 *  beyond, so a payload never exceeds ~1,100 points. Returns null for an
 *  unknown or unlisted slug (the handler turns that into a plain 404). */
export async function getMoneyMarketFundHistory(
  slug: string,
  range: "1m" | "3m" | "6m" | "1y" | "max",
): Promise<FundHistoryPayload | null>;

/** The benchmark index series a fund's chart is compared against.
 *  USD -> SOFR (onchain_credit.sofr_rates.sofr_index, daily)
 *  ETH -> Aave v3 WETH supply_index (onchain_credit.aave_v3_reserve_apy, 6h)
 *  BTC -> Aave v3 WBTC supply_index */
export async function getMoneyMarketBenchmark(
  denomination: FundDenomination,
): Promise<FundBenchmarkSeries | null>;

/** Manager list for the filter, with counts, ordered by fund count desc. */
export async function getMoneyMarketManagers(): Promise<
  { key: string; name: string; count: number }[]
>;

5.4 Null semantics, field by field (the rules Stream B renders against) ​

  • null never means zero. Every numeric field is number | null; null renders as "n/a" and 0 renders as "0" / "0.00%" / "$0.0M". Never "—" (no em-dashes anywhere in user-facing copy).
  • Core columns are never null on a listed row. Gate 4 guarantees fee, allocation, exit liquidity, roles and timelock are readable at listing time. A null appearing later is a live read failure, and the cell says "n/a" while the row stays.
  • tvlViaListedFundsUsd is 0, not null, when nothing is nested. The tooltip line only renders when it is > 0.
  • forceDeallocatableNative, forceDeallocatePenalty, adapters, caps, gates, sentinels are V1-null / V1-empty. Stream B branches on generation, never on emptiness.
  • publicAllocatorMaxInNative, publicAllocatorMaxOutNative, jitDepthNative are V2-null. The V2 allocation table renders those three columns as "n/a" with a tooltip saying the public allocator is a V1 mechanism and that V2 uses force-deallocate instead.
  • quotedApy is tooltip-only. It must never be sorted on, never appear in a column, and never be used when apy["30d"] is null.
  • gates: [] means open to everyone. A non-empty array means at least one restriction; flags carries permissioned_deposits and/or exit_restricted so the row can flag it without inspecting the array.
  • depositorConcentrationExact: false means the holder list was truncated (only possible on V2 funds with more than 500 holders; 3 of 43 today). The UI appends "at least" to the figure and never sorts on it.
  • asOf vs rateAsOf. They come from different jobs and can differ by up to 6h. The panel header stamps asOf; the APY column tooltip stamps rateAsOf.
  • Empty state. getMoneyMarketFunds() returning [] renders "No funds match these filters" only when filters are active; with no filters it renders the pre-migration notice.

5.5 Fixture rows Stream B builds against ​

Stream B does not wait for Stream A. It writes src/lib/data/money-market-funds.fixture.ts (test/dev only, never imported by the page) exporting FIXTURE_FUNDS: MoneyMarketFundRow[] and FIXTURE_DETAILS: Map<string, MoneyMarketFundDetail> with six funds chosen to exercise every branch. All magnitudes are unique.

#sluggenassetdenomWhy it exists
1steakhouse-usdc-2a64cb1USDCUSDthe ordinary case: 7 markets, 36.5% exit, 7d timelock, 5% fee, public allocator with flow caps, 8 collaterals
2gauntlet-weth-prime-1100021WETHETHETH denomination and the ETH benchmark; 1 pending SetCap; 3d timelock
3yearn-wbtc-1100031WBTCBTCBTC denomination; tiny TVL just over the entry floor
4sentora-rlusd-main-80e6bf2RLUSDUSDV2: 3d summary via the abdication rule, 9 markets, 5.1% idle, 8.3% force-deallocatable at 0.01% penalty, no gates, 1 pending cap raise that is executable now
5gauntlet-usdt-core-1100052USDTUSDV2 with a MetaMorpho adapter (nested listed fund -> tvlViaListedFundsUsd > 0) and timelockText: "none" -> no_timelock flag
6confidential-prime-usdc-2200062USDCUSDsendAssetsGate set -> permissioned_deposits; zero_idle; a permittedOnly allocation row; depositorConcentrationExact: false

Distinct magnitudes to keep assertions honest: TVL 74.1M / 8.42M / 3.37M / 317.2M / 4.59M / 40.8M; 30d APY 4.71% / 2.13% / 0.62% / 6.05% / 5.38% / 3.29%; exit share 36.5% / 93.2% / 12.7% / 13.4% / 51.6% / 76.4%; fee 5% / 0% / 10% / 10% + 0% mgmt / 15% / 0%.

Stream A replaces the fixture with real reads at reconciliation; Stream B's render tests keep using it. The fixture file's shape is asserted by money-market-fund-math.test.ts so a contract drift breaks a unit test rather than a page.


6. UI plan (Stream B) ​

6.1 Route, nav, SEO, redirects ​

  • src/app/money-market-funds/page.tsx, export const revalidate = 1800; (30 min ISR; the data moves every 6h). Static metadata object, no generateMetadata, matching /multi-strategy-funds.

  • src/app/money-market-funds/loading.tsx -> RouteLoadingSkeleton.

  • src/lib/seo.ts: new ROUTES.moneyMarketFunds entry:

    ts
    moneyMarketFunds: {
      path: "/money-market-funds",
      title: "Money market funds",
      description:
        "Actively managed onchain money market funds on Morpho: TVL, realised APY versus SOFR, manager, fee, timelock, exit liquidity and the exact lending markets each fund allocates to.",
    },

    and one new line in webApplicationLd().featureList, placed to match nav order.

  • src/components/layout/NavSections.tsx: insert third in EXPLORE, between Carry Trades and Multi-Strategy Funds:

    ts
    {
      label: "Money Market Funds",
      href: "/money-market-funds",
      match: (p) => p.startsWith("/money-market-funds"),
    },

    There is no mobile duplicate; AppSidebar and MobileNav both consume ExploreNav from this one file.

  • src/app/sitemap.ts: add the route at priority 0.9, changeFrequency: "daily", alongside the other four Explore entries.

  • Open Graph: nothing to do. There is exactly one OG render, src/app/opengraph-image.tsx, shared by every route via webPageLd().primaryImageOfPage. Do not add a per-route image.

  • Redirects: none. This is a new path that has never shipped, so there is no legacy URL to preserve. Do not add a redirect from /repo-lending or from any query form of it: the funds sub-view never had its own URL (it was an in-page segmented toggle, and it has been behind SHOW_FUNDS = false). Adding a 308 here would be irreversible for nothing.

  • src/components/home/FeatureSections.tsx:75: the "Money market funds" card currently carries soon: true. Drop the flag and point it at the new route.

6.2 Table ​

Shared terminal-table layer (src/components/ui/terminal-table.tsx: PanelHeader, ColId, ColMetric, Cell, HeadlineMetric, ExpandedPanel, SofrPill), the StrategiesTable grid discipline, the useRowDeepLink and useFrozenScreenerHeader hooks. New file src/components/funds/MoneyMarketFundsTable.tsx ("use client"). It is a fork of StrategiesTable.tsx, not an import: the column set, the filters and the drawer differ enough that parameterising the existing one would make both worse.

Columns, left to right:

#HeaderContentSort
1(chevron)sticky, 28px, left: 0no
2Fundmanager mark + name, with the manager (and partner) on a second line at fontSize: 15, color: FG_DIM; sticky at left: 28no
3Assetcoin mark + tickeryes
4TVLdenominated by the asset switch; tooltip carries the $ figure and the "of which via other listed funds" lineyes
5Markets<count> · top 3 <pct>yes (on count; the caret tooltip says so)
6Exit liquidity<pct> of TVL; tooltip carries the $ figure, and states the figure is per fund and shared with other vaults in the same markets, so it is not additive down the columnyes
7Feeperformance fee, with + <x>%/yr appended when a management fee is non-zeroyes
8TimelocktimelockText; red when no_timelockyes (on timelockMinSeconds, nulls last)
9APY (<window>)amber HeadlineMetric, right edge, driven by the 24h/7d/30d WindowSwitchyes

TABLE_COLS must be handed out only through a tableGrid(part, style, extraClass?) helper with the template spread last and data-screener-grid emitted, exactly as StrategiesTable does.

The arbitrary-grid-class trap, restated because it shipped once already: never declare grid-template-columns as a Tailwind arbitrary utility. The class name ships in the JS chunk, the rule in the CSS chunk; both are hashed separately and cached at the edge for a year, so a deploy that pairs a fresh JS chunk with a stale CSS chunk matches no selector, display: grid still applies and every cell stacks into one full-width column. Inline the template. Tailwind also scans source as raw text, so do not write the arbitrary-value syntax in a comment either.

Proposed floors (the sum must clear the ~987px the shell guarantees at the narrowest width where the sidebar is on screen):

28px  minmax(230px,2fr)  minmax(96px,0.8fr)  minmax(104px,1fr)
minmax(128px,1fr)  minmax(132px,1.1fr)  minmax(104px,0.9fr)
minmax(120px,0.9fr)  minmax(118px,1.1fr)

Sum = 1,060px. That is over budget, so one of two things must happen and Stream B decides with a measurement, not a guess: either Markets and Timelock collapse into a single "Structure" column at narrow widths, or the Fund floor drops to 200 and Exit liquidity to 120 (sum 998, still 11px over). Measure the widest real content and the widest header for each column (a floor must clear its own header; no overflow check catches a wrapped header) and report the numbers in the PR. expectFitsWithoutSideScroll runs at 1360/1140 and is exempt at 900.

Filters (src/components/ui/filter-controls.tsx, all existing):

  1. BoxedSelect Asset: USD / ETH / BTC. Changing it clears Min TVL.
  2. PlatformSelect Manager: seeded to all, with CuratorMark icons and per-manager counts.
  3. Collateral SearchableMultiSelect, grouped by groupCollaterals() from src/lib/collateral.ts, with two extra controls the other screeners do not have:
    • an include / exclude segmented toggle. Include: keep funds touching any selected collateral. Exclude: drop funds touching any selected collateral.
    • a "match permitted collateral too" checkbox. Off (default): match against collateralSymbols (current allocation, dust under 0.5% of TVL already dropped by the reader). On: match against permittedCollateralSymbols. Both states appear in ActiveFiltersLine with explicit text ("Excluding: WBTC, cbBTC", "Incl. permitted"), because a silent exclude filter reads as missing data.
  4. MinField Min TVL, in the asset switch's unit.
  5. BoxedSelect Permissioned: Any / Open only / Permissioned only, driven by the permissioned_deposits and exit_restricted flags.

WindowSwitch for the APY window sits right-aligned via ml-auto, is a metric switch not a filter, never enters ActiveFiltersLine, and never changes the row set.

Sort: onSort toggles direction on the same key, resets to desc on a new key, and nulls always sink in both directions. Default sort is TVL desc.

Row deep-link: useRowDeepLink("fund", (s) => s.toLowerCase()) -> ?fund=<slug>. Rows are native <details>/<summary> with id={row-${slug}} and ROW_SCROLL_MARGIN_CLASS on both the <details> and the <summary>. Because slugs end in -<6 hex>, any spec that parses a row id must tolerate that suffix (this is the exact shape that broke the row-modal spec on the v0.37.0 release tip).

6.3 Drawer ​

AMENDED IN REVIEW (round 2, SF-2 / S-C / S-D). The chart carries nothing forward of its own. SOFR is a business-day series and the page is a calendar-day one, and the fill for that lives in exactly one place, the reader's fillCalendarDays, which fills only between published fixings, never past the newest one, and refuses a gap wider than six days. A second unbounded carry inside the chart repealed that bound with the looser of the two rules and drew a flat benchmark through an outage. The chart aligns by day and leaves every unpublished day null.

ExpandedPanel with the same two-band layout as the funds drawer (grid-cols-1 md:grid-cols-[1fr_400px] for the brief + tower, then a full-width band). Sections in the brief's order:

  1. Header strip — fund name, manager (and partner), a generation chip (V1 / V2), and flag chips. Flag copy, no em-dashes: No timelock (red) · Permissioned deposits · Exit restricted (red) · Cap raise pending · Cap raise executable now · No idle cash · Deposits closed · Bad debt · Past bad debt · Concentrated depositor. A tenth chip, added in round 2 (SF-5), states what one deposit token is worth against its own unit on EVERY fund, amber past one percent off par.

  2. Brief — managerHouse + mandate, two sentences, no risk verdicts. Reuses the narrative voice of curator-vault-narratives.ts.

  3. Chart card — three metric tabs (Total return / APY / TVL) and shared range pills 1M / 3M / 6M / 1Y / MAX. Total return draws the fund against its benchmark with the filled ahead/behind band and the cumulative-gap bracket, ported from StrategyChart.tsx (SPREAD_UP_KEY / SPREAD_DOWN_KEY<Area> pair with [benchmark, fund] range tuples, type="monotone", connectNulls={false}, and the SpreadBracket Recharts child that reads useXAxisScale() / useYAxisScale()). Benchmark: SOFR for USD funds, Aave v3 WETH / WBTC supply index for ETH / BTC funds. The chart is the one thing the page does not ship in its RSC payload. Reason: 81 funds x up to 3 years of 6h points is orders of magnitude more data than the 19-fund page already ships. This is not a new idea in this repo: /carries moved its own series out of the prerendered payload for exactly this reason, and src/app/api/carry-history/route.ts + src/components/carries/CarryChart.tsx are the pattern to copy, both halves of it:

    • Handler src/app/api/money-market-fund-history/route.ts, GET ?slug=<slug>&range=<r>. export const dynamic = "force-dynamic" (the repo's route handlers never use revalidate), per-IP damping via rateLimit("money-market-fund-history", clientIp(req), 30, 60_000) + tooManyRequests(...) from @/lib/rate-limit, a ^[a-z0-9-]{3,80}$ slug regex returning 400, a range allowlist, and a plain 404 for a slug that is not listed. It runs no query shape the page does not already run.
    • Client side, the historyCache / historyInflight per-session map + in-flight dedupe from CarryChart.tsx, keyed by slug, plus its HistoryLoadState = "loading" | "ok" | "error" and its height-reserving placeholder. The chart mounts on the row's hasOpened latch, so it fetches once per session per fund and a remount is free.
  4. Stats tower — FundGlance-shaped rows, exactly two children each: Rate type (Variable) · Max drawdown (amber, the only coloured value) · Track record · Realised 30d / 90d / 1y · Performance fee · Management fee · Vs benchmark (last). The "since <month>" rule from PR #606 is ported verbatim, month granularity, firing in either direction:

    ts
    const windowDiffersFromRecord =
      trackedFromIso !== null &&
      (inceptionIso === null || trackedFromIso.slice(0, 7) !== inceptionIso.slice(0, 7));
  5. Where is the money — an allocation table plus an allocation bar. Columns: Collateral · LLTV · Oracle · Allocated · Share · Market utilization · Cap · Headroom · Max in · Max out · JIT depth. Idle renders as the first row with a dash in every market column. permittedOnly rows render greyed, after the allocated ones, and the section footer says how many there are. Plain columns, no verdict language.

  6. Can I get out — withdrawable now ($ and % of TVL), split idle vs from markets; for V2, a second line for force-deallocatable with its penalty; what is not redeemable today ($ and %, with no estimated wait beside it, see §4.8); top-10 depositor share (prefixed "at least" when depositorConcentrationExact is false) and the largest single depositor.

  7. Who controls it — V1: owner / curator / allocators / guardian, the global timelock and the list of actions it delays, pending changes with execute-after dates, and the public allocator's admin + fee. V2: owner / curator / allocators / sentinels, the adapter list (type + address + assets + penalty), the cap table (absolute + relative, per collateral / adapter / market), the per-action timelock table with abdicated rows marked permanent, and the deposit/exit gates with a plain-language effect line. Addresses render through a NamedAddress chip: manager name or Safe label when known, otherwise the truncated address with a copy button. Links out to the Morpho app and Etherscan.

  8. Note line — "Yields exclude MORPHO incentives." One line, at the bottom.

6.4 Components to reuse verbatim vs fork ​

Reuse verbatimFork
ui/terminal-table.tsx (all of it)strategies/StrategiesTable.tsx -> funds/MoneyMarketFundsTable.tsx
ui/filter-controls.tsx (all of it, SearchableMultiSelect included)strategies/StrategyChart.tsx -> funds/FundPerformanceChart.tsx (three tabs, 5 ranges, fetched series)
hooks/useRowDeepLink.ts, hooks/useFrozenScreenerHeader.tsstrategies/FundBrief.tsx -> funds/FundGlanceTower.tsx (different row set, same shape and the #606 rule)
ui/InfoTooltip, ui/LastUpdatedStamp, lib/use-can-hover
icons/curator-marks.tsx (CuratorMark, hasCuratorMark)
icons/token-marks.tsx (resolveTokenMark, hasTokenMark)
lib/collateral.ts (canonicalCollateral, collateralCategory, groupCollaterals)
lib/filters.ts (activeSubset, parseMinPct, selectionSummary)

New, no precedent to fork: the include/exclude collateral toggle, the allocation table, the control panel, the NamedAddress chip.

6.5 Icon registries ​

creddit keeps six parallel icon registries. This work touches three:

  1. src/components/icons/curator-marks.tsx — manager marks. Keys are lower-cased. Present: steakhouse, smokehouse, gauntlet, sentora, mev capital, sky (image); euler, fluid, yo protocol, yearn, ipor (component). Add lower-cased aliases for the manager display names this page uses (steakhouse financial, sky money -> the existing art) and a mirrored spark.svg. Mirror, do not hotlink: the marks come from cdn.morpho.org/v2/assets/images/… but the repo serves them same-origin from public/curator-logos/, verbatim, downscaled to ~64px, verified by eye composited on panel-dark #16181C. When a manager has no mark, fall back to the existing initial tile in CURATOR_COLOR; do not draw one. Guard on hasCuratorMark(name), never on the truthiness of a rendered <CuratorMark/> (a JSX element is an object and is truthy even when the component returns null).
  2. src/components/icons/token-marks.tsx — deposit-asset and collateral coins. New symbols this page can surface: RLUSD, PYUSD, AUSD, USDS, USDtb, msUSD, msETH, plus whatever collaterals the allocation table lists. Add a mark only for a symbol appearing in at least 1% of listed allocation, sourced from 0xa3k5/web3icons (raw-svgs/tokens/branded/<SYM>.svg) or the official raster into public/token-icons/. Everything else uses the existing fallback. token-marks.test.tsx locks coverage; extend it.
  3. src/components/icons/ProtocolIcons.tsx — the shared MorphoIcon already exists and is what the generation chip uses. No addition needed.

Registries 4-6 (assets/IssuerIcon.tsx, carries/PlatformLink.tsx, carries/AssetMark.tsx) are untouched by this work. Confirm, do not assume: if a new coin lands on /carries or Asset Profiles as a side effect, it goes in theirs too.

6.6 Copy rules ​

No em-dashes anywhere user-visible: tooltips, flag chips, empty states, column headers, the note line. Use a comma, colon, semicolon or period. Placeholders are "n/a" or "0", never "—". Never the CSS help cursor. Tooltips justify a metric economically and never explain the implementation, the data source or why an approach was chosen. TradFi vocabulary leads; DeFi terms go in parentheses or tooltips ("money market fund (vault)", "manager (curator)", "timelock" stays as is, it is the industry word).

Before opening the PR: git diff grepped for the em-dash character, and for cursor: help / cursor-help.

6.7 The /repo-lending removal ​

The funds sub-view is already dark at runtime (SHOW_FUNDS = false in MoneyMarketSwitcher.tsx, feature-flagged off pending issue #102). This is a cleanup, not a behaviour change, which is why it can ride in this PR.

Delete outright (verified zero other importers):

  • src/components/money-market/CuratorFundsTable.tsx
  • src/data/curator-vault-narratives.ts — but move its content first into src/data/money-market-fund-narratives.ts, rekeyed from curator label to manager_key and from vault address to fund slug, with the Euler entries dropped and the newly listed funds written in the same voice.

Edit:

  • src/app/repo-lending/page.tsx — drop the getCuratorFunds / getCuratorFundHistories / CuratorFund / FundReturnPoint / curatorFundDescription imports, the whole funds / fundHistories / fundDescriptions try-block, the three props, and the "plus curator-run lending funds on Morpho and Euler" clause of the sr-only <h1> and of the leading comment.
  • src/components/money-market/MoneyMarketSwitcher.tsx — drop the header comment's Euler sentence, the CuratorFundsTable import, the CuratorFund / FundReturnPoint type imports, SHOW_FUNDS, type View, VIEWS, the three props, hasFunds / view / activeView, the toggle radiogroup block and the funds ternary arm. Keep everything else — this component is the rates view's filter brain.
  • src/lib/seo.ts — ROUTES.repoLending.description: remove "plus curated lending funds (MetaMorpho, Euler V2) that allocate across isolated lending markets."
  • src/components/home/FeatureSections.tsx:75 — unflag and link the card.

Do NOT delete (they serve the assistant tool and the portfolio universe, and touching them is out of scope): src/lib/data/curator-funds.ts, src/lib/data/curator-vault-state.ts, src/data/curator-vaults.ts, scripts/refreshers/curator-vault-state.ts, scripts/sql/021-…, src/lib/agent/tools.ts's get_curator_funds, ProtocolIcons.EulerIcon, curator-marks.tsx's euler entry.

Tests: no e2e spec references the funds view (the fixture never seeded curator_vault_state, so it could never render). No test file leaves package.json's list. UnderwrittenCapital.test.tsx:544 source-greps MoneyMarketSwitcher.tsx for the SOFR import and the absence of .avg30d; both assertions survive, but re-run it after the edit rather than assuming.

The Euler exit is therefore: Euler leaves the money-market surface and its copy, and stays everywhere else it legitimately lives (/multi-strategy-funds rows, /portfolio platform brands, the assistant's curator-funds tool). The PR body says so in one sentence so nobody reads it as a half-done removal.

6.8 Responsive behaviour ​

Structure copied exactly from StrategiesTable:

upper panel (PanelHeader + FilterControls)   border, no overflow
table panel                                   border, NO overflow property
  sticky top-[52px] lg:top-0
    headRef   data-allow-clip overflow-hidden   (mirrored scrollLeft)
    pinnedRef data-allow-clip overflow-hidden   (absolute, top:100%)
  bodyRef  overflow-x-auto terminal-scroll      (the only horizontal scroller)

No overflow on the table-panel wrapper (sticky must resolve against the page). No min-w-[…] on the grid; every data column is minmax(min, fr) so slack distributes proportionally. Both sticky identity cells get opaque backgrounds (BG / #040404), never the translucent band, or scrolled numbers ghost through. The drawer strip is grid-cols-1 md:grid-cols-[1fr_400px]. Page shell max-w-[1560px] mx-auto px-3 py-[18px] md:px-[26px] md:py-[22px] md:pb-6.

tests/e2e/grid-template-provenance.spec.ts's SURFACES array is enforced, not advisory (zoom-sweep.spec.ts diffs it against the page.tsx files under src/app at run time and fails when a route is missing). Add:

ts
{ route: "/money-market-funds", label: "money market funds", expectsPinned: true },

and add the route to tests/e2e/surfaces.ts with a truthful settle predicate and its states, and to tests/e2e/global-setup.ts's pre-compile list.


7. File ownership ​

Stream A owns (Stream B never edits these):

scripts/sql/086-money-market-funds.sql
scripts/sync-money-market-funds.ts
scripts/money-market-rule.ts                     (pure gates + hysteresis)
scripts/money-market-rule.test.ts
scripts/morpho-gql.ts                            (new shared client)
scripts/refreshers/money-market-funds.ts
scripts/backfill-money-market-funds.ts
scripts/refresh-assets.ts                        (one line: the new refresher)
scripts/refreshers/token-yields.ts               (one function + the union)
src/lib/data/money-market-funds.ts
src/lib/data/money-market-fund-math.ts
src/lib/data/money-market-fund-math.test.ts
src/app/api/money-market-fund-history/route.ts
docs/database.md, docs/data-pipeline.md, docs/metrics.md, docs/processes.md,
docs/external-dependencies.md, docs/deployment.md

Stream B owns (Stream A never edits these):

src/app/money-market-funds/page.tsx
src/app/money-market-funds/loading.tsx
src/components/funds/*                            (all new)
src/data/money-market-fund-narratives.ts
src/lib/data/money-market-funds.fixture.ts
src/components/icons/curator-marks.tsx  + .test.tsx
src/components/icons/token-marks.tsx    + .test.tsx
public/curator-logos/*, public/token-icons/*
src/lib/seo.ts
src/components/layout/NavSections.tsx
src/app/sitemap.ts
src/components/home/FeatureSections.tsx
src/app/repo-lending/page.tsx
src/components/money-market/MoneyMarketSwitcher.tsx
src/components/money-market/CuratorFundsTable.tsx   (delete)
src/data/curator-vault-narratives.ts                (delete)
tests/e2e/money-market-funds.spec.ts
tests/e2e/surfaces.ts, tests/e2e/global-setup.ts,
tests/e2e/grid-template-provenance.spec.ts
docs/architecture.md

SHARED — exactly one stream touches each, named here so there is no race:

FileOwnerWhy
package.json "test" listStream Bit lands last and must add every new test file from both streams; Stream A tells B the filenames the moment they exist
scripts/fixture/seed.sqlStream Athe seed generator is data work; B supplies the six fixture funds' magnitudes from §5.5 and A writes the spec_money_market_fund block
docs/.vitepress/config.ts sidebarneitherno new doc page; the surface is documented inside the existing seven
docs/index.mdStream Bone line in the surface list
CHANGELOG / package.json versionneitherthe release PR bumps it, not this one

Overlap is two files (package.json, seed.sql), both single-owner, both touched late. Everything else is disjoint by directory.


8. Test plan ​

8.1 Unit (node --import tsx --test, registered in package.json) ​

  • src/lib/data/money-market-fund-math.test.ts — every case table in §4. Two of them are load-bearing enough to call out: the V2 timelock abdication case (without it 41 of 43 funds mislabel as "none") and the JIT-depth cap-clamp case (without it the page prints trillion-dollar depth).
  • scripts/money-market-rule.test.ts — the nine gates in order, the first-failure reason, and floorVerdict's six branches including "a null TVL never delists".
  • Extend src/components/icons/curator-marks.test.tsx and token-marks.test.tsx for the new keys.
  • A shape test asserting FIXTURE_FUNDS still satisfies MoneyMarketFundRow (this is what makes an interface-contract drift break a unit test rather than a page).

8.2 Render (renderToStaticMarkup, no DOM, no Postgres) ​

src/components/funds/MoneyMarketFundsTable.test.tsx and FundGlanceTower.test.tsx, against FIXTURE_FUNDS / FIXTURE_DETAILS. Every magnitude in the fixture is unique (§5.5) so a regex assertion cannot pass vacuously. Assertions worth having:

  • the timelock cell reads "3d" for the Sentora replica and "none" for the Gauntlet USDT Core replica, and only the second carries the red treatment;
  • the V2 allocation table renders "n/a" in Max in / Max out / JIT depth, and the V1 one renders numbers;
  • a permittedOnly row renders greyed and after every allocated row;
  • the drawdown label names its own window when trackedFromIso's month differs from inceptionIso's, in both directions;
  • tvlViaListedFundsUsd > 0 renders the extra tooltip line and === 0 does not;
  • depositorConcentrationExact: false renders "at least".

Follow the existing convention of decoding SSR entities (&#x27; etc.) before asserting on copy.

8.3 e2e (tests/e2e/money-market-funds.spec.ts) ​

Run isolated, always:

bash
export PATH=$HOME/.nvm/versions/node/v20.20.1/bin:$PATH
FIXTURE_PORT=55441 bash scripts/fixture/build.sh       # per-worktree port
export DATABASE_URL="postgres://postgres@127.0.0.1:55441/creddit_fixture"
# kill this worktree's own `next dev` first: Next 16 refuses a second one
E2E_PORT=3201 npx playwright test tests/e2e/money-market-funds.spec.ts
  • reuseExistingServer: !CI and the default port 3123 mean another worktree's dev server gets reused and the whole run silently tests that tree. Export E2E_PORT. Confirm ownership with ps aux | grep "next dev --port <port>".
  • scripts/fixture/build.sh DROPs and recreates creddit_fixture on whatever instance is on FIXTURE_PORT (default 55432). Use a private port.
  • A fixture older than ~24h mass-fails the suite (the app's own freshness gates emit console.error and the strict-console fixture turns each into a failure). Rebuild it the same day as any full run.
  • Read the summary with grep -E 'passed|failed|skipped|flaky' together; Playwright prints N skipped above the final N passed.

Specs (each must fail when its change is reverted; mutation-test the load-bearing ones rather than reading them):

  1. the table renders its nine canonical columns and at least SEEDED_ROWS rows;
  2. ?fund=<slug> opens that row on load, and opening a row writes ?fund=;
  3. only one row is open at a time;
  4. the Manager filter narrows the row set and every manager option carries a mark;
  5. the Collateral filter's exclude mode removes exactly the funds that include mode kept (assert the two row sets are disjoint and their union is the unfiltered set), and the "permitted too" toggle can only widen the set;
  6. the APY window switch changes the column header and leaves the row set untouched. It cannot assert the numbers change: the fixture's smooth exponential makes every window annualise identically, which is exactly why §4.5 lives in a unit test;
  7. the asset switch changes the TVL unit label and clears Min TVL;
  8. the drawer renders its eight sections in order, and the chart card shows a skeleton then a chart after its fetch resolves (stub the route and wrap the stub in teardownSafe() so a route.fetch() in flight at test end cannot knock the worker's next spec out of the run);
  9. no horizontal overflow at 1360 and 1140 (expectFitsWithoutSideScroll), and expectNoHorizontalOverflow at all three widths;
  10. /repo-lending still renders its rates table and no longer offers a funds toggle.

Gate every interaction on React hydration (__reactFiber$ / __reactProps$ on the row) before clicking: SSR rows toggle <details> natively, so an unhydrated click looks exactly like a passing test.

8.4 Fixture rows ​

Stream A adds a spec_money_market_fund block to scripts/fixture/seed.sql following the file's own three-step pattern: add the four tables to the TRUNCATE list, declare the spec temp table, then CROSS JOIN grid. The six funds are those in §5.5, with token_yield_apy rows generated by the existing base_rate * power(1 + apy, i / 1460.0) formula so trailing windows read back exactly. Bump the spec's SEEDED_ROWS in the same commit.

8.5 Docs ​

cd docs && npm install && npm run build is the dead-link check and must pass. Pages to update, in the same PR:

PageWhat
docs/database.mdthe four new tables, full column lists, the status machine, the hysteresis clock
docs/data-pipeline.mdthe new refresher, its cadence and its place in the 6h orchestrator; the token-yields union; the backfill
docs/metrics.mdevery formula in §4, with its unit and its null semantics; the V2 cap-id rule; the abdication rule
docs/processes.md"Listing a money market fund": the gates, the report, --approve-manager, --bind-manager, the delist rule
docs/external-dependencies.mdblue-api.morpho.org vaultV2s usage and the 1,000,000 complexity budget; correct the stale "POSTed from three places" line (it is five now) and the stale "V1 API does not index Morpho V2 vaults" claim
docs/architecture.mdthe new route, its readers, the history route handler, and the /repo-lending sub-view removal
docs/deployment.mdmigration 086, the new cron line, the backfill runbook
docs/index.mdone line in the surface list

8.6 Browser verification checklist ​

Driven programmatically against the running app after hydration, full page, screenshots at 1360 / 1140 / 900. Never conclude from SSR HTML: the table is a client component, so its headers, tooltips and copy live in the JS bundle and grepping the server HTML returns "not found" for both the old and new strings. If CSS looks stale, rm -rf .next (a plain dev-server restart does not clear a cached Turbopack CSS chunk).

  • [ ] the table fits without horizontal scroll at 1360 and 1140; at 900 it scrolls its own container and nothing overlaps the frozen columns
  • [ ] the frozen header and the pinned open-row clone track horizontal scroll
  • [ ] every column header is fully legible at 1140 (no clipped "Exit liquidit")
  • [ ] each of the five filters narrows the set, and every active state shows in ActiveFiltersLine
  • [ ] the exclude toggle and the "permitted too" checkbox both visibly change the row set
  • [ ] a row opens, writes ?fund=, scrolls to the anchor, and a reload on that URL lands on the same row
  • [ ] the drawer's chart card shows the skeleton, then the chart; all three tabs and all five ranges render; the benchmark band and the gap bracket draw
  • [ ] a V1 fund and a V2 fund both render their control panel correctly, and the V2 one shows adapters, caps, gates and per-action timelocks
  • [ ] flag chips render for at least: no timelock, permissioned deposits, pending cap raise
  • [ ] /repo-lending renders its rates table with no funds toggle
  • [ ] the Explore nav shows five entries in the specified order, and the new one is highlighted on the route
  • [ ] no console errors, at every width

9. Server runbook (goes in the PR body) ​

Staging only. Nothing here runs against prod in this PR.

bash
# 0. Merge to `staging`. deploy-staging.yml builds and, on success, runs migrate.sh.
#    The page's information_schema probe makes the pre-migration build safe.

# 1. Confirm the migration applied (a SKIP looks exactly like success).
sudo -u postgres psql -d creddit_staging -c \
  "SELECT filename, applied_at FROM onchain_credit.schema_migrations \
    WHERE filename = '086-money-market-funds.sql';"
# Empty result -> read the deploy log for `APPLY:` / `SKIP destructive` and fix.

# 2. Seed the registry: discover, classify, propose. ~3-5 min (Morpho API paging
#    at 25 for V2 + one multicall round per candidate).
cd /opt/onchain-credit-staging
scripts/run-cron.sh sync-money-market-funds.ts --dry-run     # read the report first
scripts/run-cron.sh sync-money-market-funds.ts               # persist

# Expected: ~48 listed, ~33 proposed, ~14 ineligible on age, 4 on asset.
sudo -u postgres psql -d creddit_staging -c \
  "SELECT status, count(*), round(sum(tvl_usd)/1e6) AS tvl_musd \
     FROM onchain_credit.money_market_fund_registry GROUP BY 1 ORDER BY 2 DESC;"

# 3. First state read. ~4-8 min.
scripts/run-cron.sh refresh-assets.ts
sudo -u postgres psql -d creddit_staging -c \
  "SELECT count(*) FILTER (WHERE withdrawable_now IS NOT NULL) AS with_exit, \
          count(*) AS total FROM onchain_credit.money_market_fund_state;"
sudo -u postgres psql -d creddit_staging -c \
  "SELECT count(*), count(DISTINCT fund_address) \
     FROM onchain_credit.money_market_fund_allocation;"
# Sanity: no fund should show exit liquidity > TVL.
sudo -u postgres psql -d creddit_staging -c \
  "SELECT r.name, s.withdrawable_now, s.total_assets \
     FROM onchain_credit.money_market_fund_state s \
     JOIN onchain_credit.money_market_fund_registry r USING (chain_id, address) \
    WHERE s.withdrawable_now > s.total_assets * 1.0001;"   -- expect 0 rows

# 4. Share-rate history for the newly listed funds. LONG: budget 6-9 hours.
#    Run it under nohup and check the log; it is resumable.
nohup scripts/run-cron.sh backfill-money-market-funds.ts 365 \
  >/dev/null 2>&1 &
tail -f /tmp/onchain-credit-cron/onchain-credit-staging-backfill-money-market-funds.log

sudo -u postgres psql -d creddit_staging -c \
  "SELECT count(*) FILTER (WHERE y.a IS NOT NULL) AS with_history, count(*) AS listed \
     FROM onchain_credit.money_market_fund_registry r \
     LEFT JOIN (SELECT DISTINCT lower(token_address) a FROM onchain_credit.token_yield_apy) y \
            ON y.a = r.address \
    WHERE r.status = 'listed';"
# Target: with_history = listed.

# 5. One more tick, to confirm the token-yields union picked the new funds up
#    and did NOT push the refresher over the 10% partial-failure floor.
scripts/run-cron.sh refresh-assets.ts
grep -E '\[ok\]|\[partial\]|\[fail\]' \
  /tmp/onchain-credit-cron/onchain-credit-staging-refresh-assets.log | tail -20
# `[partial] token-yields` here is a blocker, not a warning.

# 6. Add the daily discovery cron (root crontab), after the existing 3am block:
#    50 3 * * * /opt/onchain-credit-staging/scripts/run-cron.sh sync-money-market-funds.ts

# 7. Verify the page.
ssh -N -L 3002:127.0.0.1:3002 root@dexhq.io   # staging basic-auth is at nginx; tunnel past it
# then drive http://localhost:3002/money-market-funds in a real browser.

Expected durations: step 2 ~3-5 min · step 3 ~4-8 min · step 4 6-9 hours · step 5 ~4-8 min.

Approvals Fred owns. After step 2 the report's NEEDS A DECISION block lists 13 named managers (26 funds) and 7 unnamed funds. Nothing lists until he runs --approve-manager <key>. That is the whole point; do not pre-approve.

Rollback. The migration is additive, so a code rollback needs no DB change: the four tables sit unread. If the page itself must go, revert the merge and redeploy; the tables stay and the next deploy picks them back up. Do not drop them (a destructive-tagged file is a gated manual step and there is no reason to reach for it). The one irreversible act in this PR is none: no 308 is added, no route is renamed, no column is repurposed.

Note for the eventual prod release PR (not this one): reseed-staging.sh rebuilds creddit_staging from the prod dump at 03:00 UTC, so until 086 is applied to prod, staging loses these tables every night and the page goes empty until the next deploy re-applies the migration. Either touch /root/.reseed-paused while validating, or re-run steps 1-3 after a reseed.


10. Risks and open questions ​

Everything below has a stated default. Only item 1 needs a human.

  1. OPEN, needs Fred: which managers to approve. 13 named managers covering 26 eligible funds ($~350M) and 7 unattributed funds ($~270M, dominated by Adpend USDC at $164M and 1337 USDC at $87.5M) sit as proposals after the sync. The tab ships with 48 funds under the 7 seeded managers regardless, so this does not block the PR. The list arrives with the sync report; the decision is one --approve-manager per name.

Resolved with a default (no decision needed):

  1. Adpend USDC's 50% performance fee. Highest fee in the universe, and the fund is unattributed. Default: it stays proposed because it has no manager, so the fee never gets a chance to be the problem. No fee ceiling is added; the fee is a column, not a gate.
  2. Payload size. 81 rows plus 81 drawers is a large RSC payload. Default: the chart series move behind a route handler (§6.3), and the reconciliation agent measures the remaining payload. Budget: 1.5 MB uncompressed. If the drawer-static content alone breaks it, the control panel moves behind the same handler; the table row data never does.
  3. V2 depositor concentration accuracy. VaultV2.positions cannot be ordered. Default: fetch up to 500 positions (5 pages of 100), sort in process, and set depositorConcentrationExact: false above that. Affects 3 of 43 funds today; the UI says "at least".
  4. token-yields alert-floor dilution. Adding 58 funds moves the denominator from ~83 to ~141 and dilutes the 5 known permanent failures from ~6% to ~3.5%. Default: accept it, and make step 5 of the runbook a hard gate on [partial] token-yields. Retuning PARTIAL_FAILURE_RATIO is a separate PR.
  5. Two funds named "Gauntlet USDT Prime" in V2. Slugs carry a 6-hex address suffix precisely for this. Shipped as kebab(name) + "-" + address.slice(-6), the address TAIL, because several managers mine vanity prefixes and five sets of same-named vaults share their first six characters. Planned as address.slice(2,8), always, even when unambiguous, so the rule never depends on the universe.
  6. Smokehouse is not a manager. The API attributes it to Steakhouse Financial. Default: follow the API; the vault name still reads "Smokehouse USDC" and the manager line reads "Steakhouse Financial". This differs from src/data/curator-vaults.ts, which splits them; that file is not the source of truth here.
  7. Time to full exit is the only modelled number on the page. Default: ship the simple interest-only model in §4.8 with its assumption stated in the tooltip. SETTLED AGAINST in review: the page publishes no such estimate. The modelled refill does not exist on Morpho Blue (§4.8), so the tab states the shortfall and what it waits on and quotes no duration.
  8. money_market_fund in src/lib/portfolio/* is a different thing. It is a live portfolio category key. Default: do not touch it, and do not name any new symbol in a way that could be mistaken for it.

Private documentation. creddit.xyz