Skip to content

Architecture ​

This is the orientation doc. Read it first, then follow the links out to the deeper references: Database & schema, Data pipeline & refreshers, Metrics: how & why (every formula and the reasoning), Deployment & server ops, Operational processes, and External dependencies. The concise top-level handoff is AGENTS.md at the repo root (CLAUDE.md just re-exports it).

What creddit is ​

creddit is an institutional analytics terminal for yield-bearing collateral in onchain lending protocols. It translates DeFi risk and yield mechanics into the vocabulary a TradFi credit analyst already uses — carry spreads, Sharpe, SOFR comparison, named comparable issuers — so a credit officer at a bank, fund, or RWA desk can underwrite a DeFi position without first learning DeFi.

It is a live, data-driven product, not a mock-up. There is no rating or risk-grading framework in the surface today, and no forward references to unbuilt memo-style content. The terminal aesthetic is deliberate: tabular numerals, 1px borders, color only for meaning (gains/losses, chart series, the single amber accent), top-to-bottom memo reading over panel-jumping. All monetary and percent values pass through the formatters in src/lib/format.ts.

The end-to-end system ​

                          WRITE PATH (off-chain, cron)                          READ PATH (request-time)
  ┌────────────┐   ┌──────────────┐   ┌─────────────┐         ┌────────────────────┐   ┌──────────────┐
  │ Ethereum   │   │ DefiLlama /  │   │  Dune /     │         │ Next.js page       │   │ Browser      │
  │ RPC + arch │──▶│ NY Fed SOFR /│──▶│  Morpho /   │         │ (Server Component, │   │ (client      │
  │ (eth_call, │   │ Llama prices │   │  Euler subg │         │  per-page ISR)     │   │  components)  │
  │  storage)  │   └──────┬───────┘   └──────┬──────┘         └─────────┬──────────┘   └──────┬───────┘
  └─────┬──────┘          │                  │                          │                     │
        ▼                 ▼                  ▼                          │ query() (read-only) │ fetch()
  ┌──────────────────────────────────────────────┐                     ▼                     ▼
  │ scripts/refreshers/*  (run-cron.sh, 6h/d/wk)  │              ┌──────────────┐      ┌──────────────┐
  │  upsert into onchain_credit.* (30+ tables)    │─────────────▶│  PostgreSQL  │◀─────│ api/* routes │
  └──────────────────────────────────────────────┘              │  schema      │      │ (notify /    │
                                                                 │ onchain_credit│      │  newsletter) │
                                                                 └──────────────┘      └──────────────┘
  • Write path (off-chain, scheduled): scripts/refreshers/* read on-chain state plus a few external APIs, annualise every yield with one shared convention, and upsert snapshots into the onchain_credit Postgres schema. The app never writes time-series data. Full detail in Data pipeline.
  • Read path (request-time): pages are Server Components with per-page ISR. They read Postgres read-only through the typed query() wrapper, do any live "now" reads via RPC, and render server HTML. Client components hydrate only for interactivity (sorting, chart range, the simulator, the oracle modal).
  • Browser → API: the few interactive surfaces that need server work at request time hit src/app/api/* routes (oracle report, carry-history series, swap-cost quote, email captures).

Tech stack ​

LayerTechnologyNotes
FrameworkNext.js 16 (App Router, Turbopack)Server Components by default; "use client" only where the browser is genuinely needed. Per-page ISR (revalidate 1800 or 3600).
LanguageTypeScript (strict)npx tsc --noEmit must be clean.
StylingTailwind CSS v4@theme inline design tokens in src/app/globals.css; @tailwindcss/postcss.
UI primitivesBase UI (@base-ui/react), not RadixWrapped with project Tailwind classes in src/components/ui/*.
ChartsRecharts 3Used directly by each chart component (no shared wrapper); client-only, and next/dynamic({ ssr: false }) with a height-reserving placeholder wherever the chart is behind an interaction (a row expansion, the strategies Compare, the portfolio dashboard) so recharts stays out of that surface's first-load JS.
DatabasePostgreSQL via pgSingle typed query() wrapper, app is read-only against the data.
On-chainviem + raw eth_callRead helpers in src/lib/data/rpc.ts / rpc-batch.ts.
FontsGeist Sans (prose) + Geist Mono (numerics) + JetBrains Mono (logo)All loaded via next/font/google in src/app/layout.tsx, self-hosted.

Security response headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and a Report-Only CSP) and poweredByHeader: false are set centrally in next.config.ts via an async headers() block over /:path*; there is no middleware.ts. See Deployment → Security response headers for the header table, the CSP rollout (Report-Only first, then flip to enforcing), and verification.

The read path in detail ​

Every section page (src/app/<section>/page.tsx) is an async Server Component:

  1. It calls one or more readers in src/lib/data/* (e.g. getHomeMetrics, carries-table.ts, money-market-rates.ts, strategies-table.ts, assets-table.ts).
  2. Those readers run SQL through query() from src/lib/data/postgres.ts — a thin typed wrapper over a shared pg Pool (max PG_POOL_MAX, default 12; connection string from DATABASE_URL). The pool size is env-tunable because the deploy build prerenders across worker processes that each open their own pool: the deploy workflows lower it to 4 for the build so the workers do not collectively exhaust the role's connection cap (see Deployment §2.1). The app holds only SELECT privilege on the data tables (writes are limited to the two email-capture API routes).
  3. Where a page needs a live "now" value (e.g. a current on-chain rate the 6h snapshot cadence wouldn't show), it reads it just-in-time via RPC through src/lib/data/rpc.ts: ethCall (current-state, latest), ethCallAt / ethGetStorageAt (archive, historical block), blockByTimestamp / getBlockTimestamp. These reads are cached via the Next fetch cache (next: { revalidate }).
  4. The page renders server HTML. Per-page ISR re-runs the readers on the revalidate interval; the prerendered page is otherwise served as-is.

ISR windows by page:

Routerevalidate
/1800 (30 min)
/repo-lending1800 (30 min)
/carries3600 (60 min)
/money-market-funds1800 (30 min)
/multi-strategy-funds1800 (30 min)
/asset-profiles3600 (60 min)

Route loading states: the five data routes above (/repo-lending, /carries, /money-market-funds, /multi-strategy-funds, /asset-profiles) each ship a loading.tsx that renders a table-shaped skeleton (RouteLoadingSkeleton) while the Server Component awaits its readers, so a client-side navigation paints the page chrome immediately instead of a blank frame. It is a visual fallback only, with no effect on metadata/SEO. /portfolio is an instant static shell (its data loads client-side, post-hydration) and intentionally has none.

Two RPC env vars back the on-chain reads (defaults are unset-env fallbacks; prod points both at a paid provider):

  • ETHEREUM_RPC_URL — current state, default https://ethereum-rpc.publicnode.com.
  • ETHEREUM_ARCHIVE_RPC_URL — historical/archive, default https://eth.drpc.org.

ISR + prerender gotcha: pages are prerendered at build time. If a refresher writes new data after a deploy build, the page can serve stale values for up to its revalidate window. Re-running the deploy re-prerenders.

The write path at a glance ​

Off-chain refreshers populate Postgres on a cron cadence; the app only reads. Each job is invoked through scripts/run-cron.sh <script>.ts, which sources .env.local, runs the script with tsx, and logs to /tmp/onchain-credit-cron/. The canonical entry points live at scripts/refresh-*.ts and delegate into scripts/refreshers/*. Server crontab (all UTC):

ScheduleJobCadence
0 */6 * * *run-cron.sh refresh-assets.tsevery 6h
15 */6 * * *run-cron.sh refresh-vault-capacity.tsevery 6h
30 */6 * * *run-cron.sh refresh-collateral-exposure.tsevery 6h
45 */6 * * *run-cron.sh refresh-lending-positions.tsevery 6h
50 */6 * * *run-cron.sh refresh-portfolio.tsevery 6h (read-only portfolio spine + flow ledger)
* * * * *run-cron.sh drain-portfolio-backfills.tsminutely (WS5 registration-backfill queue drain; no-op when empty)
30 3 * * 1run-cron.sh refresh-vault-risk.tsweekly, Mon 03:30
30 4 * * 1run-cron.sh sync-portfolio-tokens.tsweekly, Mon 04:30 (portfolio token-registry sync, T6; PROPOSE + WS8 alert only, never mutates on the cron)
0 5 * * 1run-cron.sh refresh-token-market.tsweekly, Mon 05:00 (the pricing categories' market limb: trading days on chain, the median daily volume the price vendor reports across exchanges and DEXes (with the on-chain median stored beside it), and the deepest single pool holding the token against an asset of its own book or against a recognised counter of that book (a dollar stable at par, or ether and its liquid wrappers); appends readings, proposes nothing)
0 13 * * 1-5run-cron.sh refresh-sofr.tsweekdays 13:00
15 2 * * *ops/backup-creddit.shdaily DB backup, 02:15
hourlydisk-usage alerthourly

backfill-*.ts and the ad-hoc sync-*.ts scripts (sync-carries.ts, sync-curator-vaults.ts, backfill-fluid-core.ts) are manual, not crons — the one exception is sync-portfolio-tokens.ts above, a weekly cron whose PROPOSE run powers the WS8 registry alerts (its --approve apply stays a manual step). The full job-by-job breakdown — what each reads, what it writes, the formulas — is in Data pipeline.

One APY convention ​

Every trailing APY in the app is annualizeRatio from src/lib/data/apy.ts: the realised ratio of an on-chain compounding index between two blocks, annualised by the actual elapsed time between their timestamps. Never average per-snapshot annualised rates (that overstates the compounded return). The same convention is applied across every venue so /carries compares like-for-like. See Metrics: how & why.

Event ledger (portfolio 100k scale) ​

Alongside the crons runs one always-on process, the event-ledger ingester (scripts/ingester/ingest-events.ts, PM2 creddit-event-ingester) — the wallet-count-independent spine that lets the portfolio serve up to 100k registered wallets. Where the per-wallet chain scans it replaced put every registered wallet into an eth_getLogs topic (which hard-fails at ~1-2k wallets), the ingester scans the tracked contracts + the Fluid chain-wide streams by contract address + event topic0 only, so nothing about it scales with wallet count. It appends every such log into onchain_credit.raw_events (range-partitioned by block) and maintains a per-contract coverage certificate in event_coverage. The scan surface is derived each cycle from the SAME registries the venue readers use (trackedContracts(reg, vaults)), so the reader universe and the event universe cannot drift. 100k-scale plan: the ingester writes the ledger; the venue readers stay the balance-of-record. The 6h cron CONSUMES that ledger and nothing else: it derives every wallet's receipts from the event store and re-reads only the wallets the store says moved, recomposing the rest. The per-venue chain scanners it replaced, the shadow-mode comparison that proved the two equivalent and the flag that switched between them are all deleted. Ops: Data pipeline → event ledger ingester

Routes ​

Pages ​

RoutePageWhat it is
/HomeOrientation: typed tagline, live rate tape, feature showcase linking to the four sections (HomeIndex, live metrics from getHomeMetrics). When the reader returns nothing the strip carries no figures at all — it names what the site tracks (SOFR, repo lending, carry trades, multi-strategy funds, asset profiles) and its marker reads "Rates" instead of "Live", so the page never presents a placeholder as a current rate.
/portfolioPortfolioRead-only position monitor gated by wallet sign-in. Prerendered PUBLIC shell (route metadata + JSON-LD, indexable); a "use client" PortfolioClient probes /api/auth/me and renders either the signed-out landing (PortfolioLanding: hero + Connect CTA, a SAMPLE cumulative-yield chart, value props) or the signed-in portfolio view. Per-user data flows only through authenticated APIs, never the shell.
/repo-lendingRepo LendingUSDC / USDT / USDS / GHO supply rates across Fluid, SparkLend, Aave v3, plus the largest healthy Morpho Blue isolated markets, with a market-structure Type column. The deposit-asset set is MONEY_MARKET_ASSETS in money-market-rates.ts; the venue matrix is ragged (SparkLend has no GHO reserve, and Aave's GHO reserve is deliberately excluded because it compensates no third-party supplier and prices off a flat governance-set curve) and a venue whose book is empty is dropped rather than shown as a row of dashes.
/carriesCarry TradesCross-protocol carry-trade screener (Fluid T1–T4, Aave v3 / SparkLend e-mode, incl. Pendle-PT term carries with maturity-clamped simulation): collateral vs funding APY, net carry, vol, max-lev carry; expandable three-panel chart with a leverage slider; a forward-projection trade simulator; per-strategy oracle transparency; auto-discovery of ≥$100k Fluid vaults from a registry.
/money-market-fundsMoney Market FundsScreener of open-ended, single-asset, lend-only Morpho vaults on Ethereum mainnet, both vault generations (MetaMorpho V1 and Vaults V2). Eight columns: fund (the manager with its brand mark, over the fund's own name with the manager's words dropped and its vault generation kept), deposit asset, TVL in the asset switch's own unit, exposure (the three collaterals the fund lends most against as coin marks and a +N for the rest; hovering the coins opens a breakdown of the five largest, each with its share of the whole fund), liquidity as a share of the fund, the fee, the timelock summary, and realised APY on a 24h/7d/30d switch. Five filters (manager, asset, collateral with an include/exclude mode and a match-permitted-too switch, minimum TVL, deposit permissioning). Each row expands to the three questions a yield figure cannot answer: what the fund holds (a per-market allocation table with caps, headroom, market utilization and, on V1, public-allocator flow caps and JIT depth), whether a depositor can get out (withdrawable now split into cash and markets, what is not redeemable today, depositor concentration), and who can change either (roles, per-action notice periods, submitted changes, adapters, caps and deposit gates, branched on vault generation). Above them a chart card with three metric tabs (total return against SOFR or the Aave v3 supply index, APY, TVL) on five range pills.
/multi-strategy-fundsMulti-Strategy FundsPerformance tracker for ETH/USD yield funds whose manager runs a broad mandate (share-rate history, TVL, per-denomination benchmark spread against SOFR or wstETH), over an always-on peer chart comparing every visible fund's realised return from a common start. Each row expands to a detail zone over a chart card: a short strategy brief with its yield-source categories beside a statistics tower of rate type, drawdown, track record, fees, redemption terms and the vault platform the fund runs on, above a per-fund chart that fills and measures the gap to the benchmark.
/asset-profilesAsset ProfilesScreener of yield-bearing collateral: trailing APY, 1M/YTD/1Y returns, per-asset yield-mechanism narrative.

The five section rows are listed in Explore nav order, which is also the sitemap order and the JSON-LD featureList order (webApplicationLd in src/lib/seo.ts). ROUTES in src/lib/seo.ts is the single source of truth for each path and title; the sitemap, the breadcrumb JSON-LD and each page's <head> all read from it.

Route renames and the 308s ​

Three section paths were renamed in 2026-07 so the URL matches what the surface is actually called:

Old pathNew path
/asset-coverage/asset-profiles
/money-market-rates/repo-lending
/strategies/multi-strategy-funds

Each old path is a permanent (308) redirect declared in the redirects() block of next.config.ts. The destinations declare no query string, so Next carries the incoming one through: row deep-links (?asset=, ?market=, ?strategy=) survive the hop, and so does the legacy /?asset=<ticker> home-page redirect (now pointed at /asset-profiles).

A 308 is one-way. Browsers cache a permanent redirect indefinitely, so a path that has shipped here cannot be reversed: the reverse rule would put every client that already cached the first hop into a redirect loop. /carries is exactly that situation already (/carry-trades → /carries), which is why it was deliberately left un-renamed in this pass even though its nav label is "Carry Trades". Rename a surface again only by adding a NEW path, never by pointing an existing destination back the way it came.

The nav matchers in NavSections.tsx also recognise each surface's pre-rename path. The 308 rewrites the URL before the nav renders, so the legacy arms only matter for a client-side navigation that beats the redirect, but they keep the active row correct either way.

"Multi-strategy funds", not "managed strategies" ​

The category was renamed along with the route. "Managed" did not distinguish it from money market funds, whose Morpho/Euler curators are also managers. The real separator is mandate breadth: a multi-strategy fund's manager may take leverage and directional exposure, while a money market fund's curator only allocates across lending markets. Since 2026-08 that second category has its own surface, /money-market-funds, which is where the separator is visible: every fund there is single-asset, lend-only and unlevered by definition. In the portfolio taxonomy the rename is label-only: the category key managed_strategy_fund is the /api/portfolio wire format and is deliberately unchanged, as is the erc4626 role: 'managed' behind it. Only CATEGORY_LABEL moved (see Portfolio and Metrics).

Shell scale (the per-width zoom tiers) ​

The whole layout shell renders through a CSS zoom that steps with the viewport (.app-shell in globals.css). Narrow windows zoom down so the wide screeners fit without horizontal scroll; large monitors zoom up so text stays proportional. A reader never sets this: it is a property of their window width, and browser zoom reaches it the same way, since zooming to Z% in a window W wide lays the page out at an effective viewport of W/Z.

Effective widthZoom
≤7670.85
768–11590.78
1160–13190.88
1320–15351.00
1536–19191.08
≥19201.15

From the lg breakpoint up, a tier's zoom has to be affordable at the tier's narrowest width. Usable content width is viewport / zoom − chrome, where chrome is the 234px sidebar plus a 26px page gutter on each side (286px, the wrapper the four screener pages share; /portfolio uses 24px under a 1300px cap and is not the binding surface). Because the zoom is constant across a tier while the viewport is not, the bottom of each tier is its tightest point, so a tier whose zoom is chosen for its top starves its bottom. That is what the boundaries above encode, and it is why they are not round numbers: each one is the width at which the next zoom up first leaves the widest screener enough room — /carries, whose seven column floors sum to 996px plus a focus rail that resolves to 2.3–3.0px depending on the zoom, so 999 in practice (the boundaries were derived against 1007, the budget before the caret spacer gave 8px back to cover the scrollbar case below; the difference is left in place as margin). Getting it wrong is not subtle in effect but is easy to miss in review: before these boundaries moved, widening a window from 1279px to 1280px reduced the table's space from 1145px to 972px and pushed it into horizontal scroll.

Below lg the rule does not apply. The mobile shell drops the sidebar and accepts sideways scroll inside a wide table, which is why the 768 tier is not derived this way and why the sweep's scroller check starts at 1024.

Two costs are accepted rather than solved. 1024 is the tightest width and no boundary can move it, because that is where the sidebar itself appears; it is where a classic 15–17px scrollbar costs the most, because its gutter comes off the layout width before the zoom divides it (~19–22 content px at that tier). It is the width to re-measure after any change to the chrome or the column floors, and it has to be measured with a scrollbar gutter — headless browsers and macOS use overlay scrollbars and will show it fitting when it does not. And because effective scale is browser zoom × shell zoom, zooming in one step can now render the page slightly smaller (on a 1440px window, 110% and 125% land in a tier one step down, for a net ≈0.98). The alternative was a table that scrolls sideways at those widths. A continuous clamp() scale would make the response monotonic by construction and is the natural way out if this becomes a problem.

The boundaries are deliberately not the same as the lg layout breakpoint (1024), which is where the fixed sidebar swaps for the mobile drawer and where the one-screen home column switches on. Those track the 3-column layout; these track scale. They shared a number until the tiers moved, and conflating them again would silently retire either rule.

The zoom sweep (docs/processes.md E.6) checks both sides of every boundary above on every registered surface, reading the tiers out of globals.css rather than from a copy, so a boundary change re-points the tests automatically.

Where a screener's column template lives ​

Each of the four screener tables hands out its grid as one call (tableGrid) that returns the class and an inline gridTemplateColumns together. The template is never a Tailwind arbitrary-value utility, and that is a correctness requirement rather than a matter of taste.

An arbitrary value puts the track widths in the selector name, so retuning any column renames the class — the caret spacer's 28 → 20 did exactly that. These grids are client-rendered (the routes server-render only their loading skeleton), so that name ships in the JS chunk while the matching rule ships in the CSS chunk: two separately hashed files, both served immutable for a year and cached at the edge. Pair a stale half with a fresh one and the selector matches nothing. display: grid still applies, the template does not, and all seven or eight cells stack into a single full-width column.

What is demonstrated, and what is inferred. The mechanism was reproduced exactly: serving the built app a complete, valid stylesheet in which only that one class name differed renders the reported screenshot, table collapsed and the rest of the page intact. Which cache serves the stale half in production — the edge, the browser's own immutable copy, or a build artifact — was not pinned down, and the note below on in-place rebuilds is the demonstrated delivery path rather than the only possible one.

The reason the rest of the page survives is narrower than "class names are stable": roughly 90 arbitrary-value selectors ship, so any of them whose value changed in the same release breaks the same way. What holds is that a release usually retunes few of them, and the screener template is the one whose loss is catastrophic rather than cosmetic — every other one degrades a width or a max-width, while this one collapses the table.

Returned as a single call so the class cannot be applied without the template: a bare class constant beside a style constant invites className={TABLE_GRID} on its own, which is a silently collapsed table and the exact defect this replaced.

tests/e2e/grid-template-provenance.spec.ts holds the invariant. It strips every grid-template-columns declaration out of the loaded stylesheets and asserts that every screener grid currently laid out resolves to the tracks it had before. It finds them by the data-screener-grid attribute each tableGrid() emits, and asserts it actually saw a header and a row, so the check cannot pass by measuring nothing.

Each surface declares whether it has a pinned open-row strip, and one that declares it must produce it or fail — no conditional is allowed to swallow its own timeout, because a skipped check reports the same green as a real pass, and that is the failure mode this spec has already hit twice. Revealing the strip walks the page down in small steps and waits on the observable outcome (the strip taking a layout box) rather than restating the component's visibility predicate, and it stops with a diagnosis of its own if the page runs out of scroll: on a viewport taller than the table, or a fixture with too few rows, the open row can never straddle the freeze line, and that is a short surface rather than a broken strip. The no-data row needs a degraded fixture and is documented as not exercised rather than claimed.

Its positive control is installed rather than found: the spec adds its own stylesheet-driven grid and requires the strip to destroy it. Searching the page for one does not work, because these tables were the last stylesheet-driven grids on these surfaces — a search finds nothing and silently disables the control, which is what an earlier version of this spec did in every run.

It fails the moment a table goes back to declaring its template in a class name.

One trap when editing this page: Tailwind scans docs/ as raw text, so writing a literal arbitrary-value class candidate in prose here mints a real rule in the production stylesheet. Describe the syntax, do not spell it.

The left nav (AppSidebar, and its mobile-drawer twin MobileNav, kept in sync via the shared NavSections) leads with Portfolio as a plain nav row (design handoff "Portfolio nav row" V1, PortfolioNav) above the Explore section — no kicker, no card. The row shares the Explore rows' box model but rests brighter: an amber allocation-bars glyph and an FG label (vs the Explore rows' DIM rest) are what keep it reading as the primary destination without a box. The rail carries no wallet state — no connect call-to-action, no address, no sign-out; connecting lives on the /portfolio page itself (its signed-out landing → connect flow) and disconnecting in the signed-in dashboard's settings popover (its session row: connected address + Sign out). Below the row, all five public research pages (Repo Lending, Carry Trades, Money Market Funds, Multi-Strategy Funds, Asset Profiles, in that order) are grouped under an Explore label with a trailing hairline rule, each a single-line iconless chevron row (handoff E1) with no subtitle; default / hover / active states colour the label + trailing chevron (active carries a 2px amber left edge and a faint amber tint), and a 2px left-border gutter is reserved in every state so labels never shift on activate. The rail is 234px wide (the content column's left padding in layout.tsx mirrors it, and the mobile drawer matches). It is sized from the longest label rather than chosen: the widest nav row lays out at 176px, leaving a deliberate ~13px gap before its chevron. The 20px this gave back over the earlier 254 went to the chrome above, and from there to two /carries columns whose floors rose by the same 20px, which is why the zoom-tier boundaries are unchanged by the pair. Multi-Strategy Funds used to be withheld — out of the nav, out of the sitemap, out of the JSON-LD featureList, and a header-only "Coming soon" row on the home page. It is now promoted on all four: an Explore row, a sitemap entry, a featureList line, and a full home-page slide. Keep those four in sync when a surface is added or withheld, plus public/llms.txt, which publishes its own per-page list to AI crawlers and is easy to forget because nothing in the app reads it. A support line + Join the Telegram button sits at the foot of the nav (no status row); it used to sit under the CredditAI launcher, which went with the assistant's UI.

The shared SIWE session still lives in AccountProvider (a client context mounted in layout.tsx, one /api/auth/me probe), consumed by the /portfolio sign-in prompt only — the rail does not read it. Brand chrome is unchanged (uppercase mono, 1px borders, no gradients); in the rail the amber accent is reserved for the Portfolio row's glyph and the active row's edge, tint and chevron.

Screener filter bars ​

All four screeners share one control vocabulary, defined once in src/components/ui/filter-controls.tsx: PlatformSelect (the brand-marked multi-select dropdown, all selected by default; its option list is passed in by each surface, so the count differs per screener, and so is its wording — it is the Platform filter on carries and money markets and the Manager filter on multi-strategy funds, via the label / dialogLabel / noun props), Segmented (single-select), BoxedSelect (single-select dropdown), WindowSwitch (a metric switch, solid amber, never a filter), MinField, SearchableMultiSelect (grouped checklist with a draft that only commits on Apply) and ActiveFiltersLine. The controls are presentational; applied filter state lives in the table that owns it, because that state also drives the rows, the URL, and (on repo) the chart. Shared pure helpers live in src/lib/filters.ts (activeSubset, parseMinPct, selectionSummary, constraintSummary). The signed-in Portfolio deliberately does NOT draw from this file. Its MinValueField is a local control in PortfolioDashboard.tsx for three concrete reasons, so nobody "unifies" the two later: the dashboard is styled entirely from signed-in-theme.ts inline tokens and imports no Tailwind-class control vocabulary; its header chrome is uniformly square where filter-controls is 6px-rounded, and its input has to hold 0.0001 where MinField's is fixed at a narrower width; and the semantics are inverted, since a screener filter is off until you type while the portfolio floor ships already set. (Two screener exceptions, both on carries. The Borrowable min ships set at $100k since the 2026-08-03 floor loosening: the listing floors admit real-but-tight markets and the default screen filters them instead. The Borrowed min ships set at $1M, so the default screen stays on markets with real borrowing behind them, and it is the one min-field carrying an explainer, since the size figure it screens on covers the whole debt reserve on the pooled venues. Clearing either writes an explicit liqmin=0 / bormin=0 so the choice survives reload.) The strip above a filter bar is a shared PanelHeader (terminal-table.tsx): amber ▮ marker, surface title, live result count, and the right-aligned stamp, with an optional ⓘ passed as children (multi-strategy funds passes one defining what a multi-strategy fund is, the way repo lending defines a repo market). Only /multi-strategy-funds consumes it today. Carries and Asset profiles still define local PanelHeader functions and Repo lending inlines the markup, so restyling the shared one changes ONE surface, not four; absorbing the others also needs the hardcoded results noun to become a prop (repo counts "markets"). Migrate them deliberately, not by assuming it is done. Every screener's page header carries a shared LastUpdatedStamp (src/components/ui/LastUpdatedStamp.tsx) — LAST UPDATED <YYYY-MM-DD HH:MM UTC> at minute precision — which replaced a pulsing green "LIVE" pill that read as real-time streaming (the feed refreshes on a ~6h cadence) and spent the gains colour on a status indicator.

Carry trades. /carries (CarriesTable) has a three-row bar: a Platform multi-select dropdown (all four platforms selected by default), a searchable multi-select of collateral assets (grouped USD / ETH / BTC, all selected by default), and four typed minimums: Borrowable ≥ and Borrowed ≥ in millions of USD (the two that ship already set, per the note above) and Carry ≥ / Max-lev ≥ in percent, plus an active-filters summary with Clear all. The applied set is mirrored to shareable URL query params (plat platform slugs, mom, col collateral tickers, liqmin, bormin, carrymin, levmin) which coexist with the row deep-link's carry param (useRowDeepLink), so a filtered screen survives reload and is copy-pasteable; all-selected collapses to "no constraint" and the param is omitted, while a deliberately-emptied selection round-trips as the plat=none sentinel. The two defaulted fields invert that presence rule: an absent param is the shipped default and the explicit 0 sentinel is the cleared state. bormin goes one step further and is written whenever a user sets that floor, even at the shipped value, because presence is what marks the threshold as asserted rather than inherited, and the two differ on a market whose size cannot be read (the default lists it, a floor somebody chose does not). The predicate is a pure, unit-tested function (itemPassesFilters in CarriesTable.tsx).

Asset profiles. /asset-profiles (AssetsTable) has one control: an Underlying asset segmented single-select, plus a live result count. The segments are derived from the rows (USD / ETH today; a BTC segment appears by itself the day a BTC-denominated asset enters the registry) so no segment ever leads to a guaranteed-empty table.

Repo lending. /repo-lending (MoneyMarketTable) has a two-row bar. Row 1 groups the Asset boxed select (single-select — repo is browsed one deposit asset at a time, so the asset is the table's axis, not a filter, and never enters the filter count) with the Collateral Exposure searchable multi-select, then a divider, then a Platform dropdown whose counts are scoped to the selected asset, a Type segment (All / Pooled / Isolated, defaulting to Pooled so the Morpho isolated markets stay an opt-in), and a right-aligned APY window switch (24h / 7 day / 30 day — a metric switch that selects which trailing window the APY column and the vs-SOFR spread report, so it never narrows the rows). Row 2 is the active-filters summary. Because Pooled is the default, a fresh load honestly reads 1 filter · Type: Pooled; Clear all returns every dimension to unconstrained (Type lands on All), not to the initial defaults. Filter state is owned by MoneyMarketSwitcher so the rates chart beneath the table renders exactly the visible markets; both sides call one pure, unit-tested predicate (marketPassesFilters in src/lib/repo-filters.ts).

Multi-strategy funds. /multi-strategy-funds (StrategiesTable) has a two-row bar. Row 1 is the Asset boxed select (ETH / USD — the table's axis for the same reason repo's is: the two denominations never share a comparison chart, so it never enters the filter count), a divider, then a Manager dropdown over the house that runs each fund, all selected by default. The option list is derived from the funds the page actually renders, not from the StrategyManager union, so it currently holds seven (Fluid, Mellow, YO, ether.fi, Treehouse, Yearn, IPOR); Morpho, Euler and Falcon are legal values of the type whose vaults are surfaced elsewhere, so they do not appear. Every manager carries a brand mark from ProtocolIcons, which MANAGER_ICON enforces at compile time (a total Record, not a Partial) and the e2e spec re-checks in the rendered control. Then the MIN kicker over a TVL field — the same grouping /carries gives its family of floors — and right-aligned over the column it governs, the APY window switch (24h / 7 day / 30 day, from the same TRAILING_WINDOW_CELLS constant /carries uses). The window switch is a metric switch, not a filter: it changes what the APY column reports and never which rows are in the table, so like the Asset axis it stays out of the filter count. The Asset select takes BoxedSelect's own 108px default width rather than a local override, so it renders at the same size as repo lending's deposit-asset select sitting in the same slot of the same bar. Row 2 is the active-filters summary. The manager option list is deliberately NOT scoped to the selected asset — the applied selection has to keep its meaning when the asset switches, so only the per-option counts follow the asset (an ETH-only manager reads 0 while browsing USD). Filter state is not mirrored to the URL here; the only query param this page owns is the row deep link (?strategy=).

Columns run FUND · TVL · 1M Return · YTD Return · 1Y Return · APY · Vs benchmark: size leads, the amber APY anchor sits under the switch that labels it, and the benchmark spread closes the row in the same right-edge slot repo lending gives its Vs SOFR pill. Every metric column sorts, APY by whichever window is selected; the spread does not, since it is the APY column minus one number shared by every row in the view and would reproduce the APY sort exactly.

The benchmark follows the Asset switch, reading Vs SOFR in the USD view and Vs wstETH in the ETH view, off the same baselineByDenom pair the comparison chart benchmarks against. An ether-denominated return minus a dollar cash rate is not a spread, so the column would be meaningless fixed to SOFR; what an ether allocator is choosing between is the fund and passive staking. Both sides are annualised by windowApy over a realised-return index, measured server-side in page.tsx and handed down as benchmarkApy — windowApy lives beside the Postgres readers, so value-importing it into this client component would drag the driver into the browser bundle. (The same rule put maxDrawdown on a client-safe leaf, src/lib/data/fund-stats.ts.)

The column header row is pinned to 44px, the height /carries pins. Left to content it measured 31px, because this head carries no two-line sub-label to push it out, and it sat visibly shallower than every other table on the site.

The expanded row is a detail zone over a chart card. A 1fr / 400px strip on its own near-black fill: on the left a ▮ STRATEGY kicker and one short paragraph (two sentences, 20 to 70 words, prefixed //) over a plain run of yield-source categories separated by amber middots; on the right a ▮ AT A GLANCE tower. StrategyRow therefore carries two nodes, expansion (narrative) and an optional glance (tower), rather than one pre-composed node: the table owns the strip's grid, so it decides the split, and a row with no tower renders its narrative full width.

The tower's rows are drawn in FundBrief.tsx and are deliberately not the shared KpiHero / KpiRow pair /carries uses. Rate type now leads the tower, and a hero-sized drawdown under the row that frames it would out-shout it, so the funds' tower is a uniform eight-row rhythm and the shared primitive stays /carries'. Its rows are the terms and measurements of the holding — rate type, max drawdown, track record, performance fee, management fee, exit fee, redemption, vault infrastructure — not a description of the fund: who runs it is already the row's second identity line and what it does is the paragraph beside it. Per-fund qualifications (a fee charged on one leg of the return only, a headline "None" that hides the manager keeping the upside) ride as optional *Note fields appended to the standard tooltip rather than replacing it.

Vault infrastructure closes the tower, and it is the one row that is not a figure. It names the platform whose vault contracts hold the assets and enforce the mandate, a second counterparty the depositor takes on top of the manager and frequently a different house from the one whose name is on the fund: ether.fi Liquid ETH runs on Veda, with Seven Seas as its strategist, which is a third role again. Per-fund editorial like every other term, out of a closed set defined beside the type in FundBrief.tsx and imported by both construction sites, so the proprietary case has exactly one definition and the platform arm's name is a literal union rather than a string: naming a platform the file has not been taught is a type error, and adding one is a deliberate edit beside the type, the same closed-vocabulary rule FundRateType and the yield-source categories run on. FundInfrastructure is a discriminated union rather than a name with an optional url: a third-party platform always has both a name and a site to send the reader to, a manager on its own stack has neither, and the fourth state would render a platform the reader is told about and cannot look up. Required on FundStats, so a new fund cannot ship without an answer. A platform with a site renders as a link out marked with the app's ↗; a manager on its own stack reads Proprietary as plain text, with no affordance, because there is nowhere to send the reader.

Its label is the tower's one DeFi-leading term, against the house rule that TradFi vocabulary leads, and deliberately so: administrator, custodian and transfer agent each name a slice of what the platform does and misdescribe the rest, so the label stays the accurate one and the tooltip carries the traditional-fund analogy.

A peer chart sits below the table, always on, the way the rates chart sits below the repo-lending rows. It plots one line per VISIBLE fund (it is handed the filtered rows, so it cannot disagree with the table), each the realised return of a deposit made at the window start, every line indexed to 0% on the same date, against the denomination's dashed baseline. Timeframe pills are 3M (default) / 6M / 1Y / ALL, anchored on the newest reading rather than the wall clock. A fund whose record does not reach back to the window start is not drawn, and is named in the legend rather than vanishing. ALL is the second-earliest first reading among the funds on screen — the longest window on which a comparison is still possible, and the only reading of ALL under which the pill is never narrower than the one above it. The full rule, and why the two obvious alternatives are worse, is in Metrics.

It replaced a per-row Benchmark vs peers toggle that revealed a head-to-head panel for one fund at a time (ComparisonChart, deleted). Comparing these funds is the reason to open the page, so it is not something to click into; and that panel's window was dictated by the compared fund's peer set rather than chosen by the reader. StrategyRow.inceptionMs, which only ever fed that peer set, went with it.

Yield sources are categories, never holdings. A closed vocabulary in FundBrief.tsx (Staking, Lending, Leveraged carry, Liquidity provision, Fixed rate, Basis & funding, Incentives). The field used to list assets ("sUSDe, syrupUSDC, syrupUSDT, sUSDai"), which dates on the next rebalance under an actively managed mandate and invites the reader to diligence positions the manager is free to replace. The union makes a stray ticker a type error, and an e2e spec walks both denominations checking every rendered category against the set.

Which level a category is named at is the part worth stating, because the answer is not "the fund's own contracts" and it is not "every trade running beneath it". The line is what the mandate FIXES versus what it ROTATES. A base asset a fund is defined by earns its category: iETHv2 exists to hold staked ether, so Staking is the fund rather than a detail of it. A collateral basket the manager reshuffles does not: Fluid Lite USD buys whichever yield-bearing dollar instruments look best, pledges them and borrows against them, and what it is paid for is the spread over its borrowing cost, so it reads Lending · Leveraged carry. One of its four current instruments is a staked synthetic dollar whose own yield comes from perpetual funding; that is the issuer's trade on a line the manager can replace, and Basis & funding is reserved for a fund running the delta-neutral position itself. Tagged the other way the vocabulary stops separating anything: every fund holding an LST becomes a staking fund and every fund holding a synthetic dollar a basis fund, whatever either is doing. The same test settles the allocators: a category has to be something the mandate is built on rather than a position that happens to be open, so earnETH carries all four of the things its sub-funds do as a matter of mandate, while a single rotating line inside a collateral basket earns no category at all. Its dollar sibling Lido Earn USD is the same test applied to a different mandate and lands somewhere else: its sleeves supply money markets, borrow against those holdings and buy principal tokens held to a maturity date, so it reads Lending · Leveraged carry · Fixed rate — three chips, each of them traceable to a clause of the paragraph the reader sees. It does NOT read Liquidity provision, though the conservative sleeve is whitelisted for Balancer v3 and Aura: a whitelist is a permission, which is weaker than a position that happens to be open, which is itself below the bar this paragraph sets. And pointedly not Basis & funding either, because what it levers is a staked synthetic dollar whose funding trade belongs to its issuer, the same line Fluid Lite USD already settles. Members can sit unused (Basis & funding, Incentives today) without being dead: the set is the vocabulary, not the current inventory.

The strategy paragraph states no verdict on risk. Each fund's brief used to close by naming the exposure that was "decisive" or "distinguishing", which is the page ranking one risk above the others on the reader's behalf. The paragraph says what the fund is and what sits behind the rate it pays; the yield-source categories under it say what the money is doing, and the reader concludes. An e2e spec matches the shape of a verdict rather than the word "risk", so a manager's own screening can still be described.

TVL is denominated by the Asset switch — dollars in the USD view, ETH-equivalent in the ETH view — because that is the unit the underlying series carries (tvlDenom), and converting ETH history to dollars would need a historical eth/usd overlay that does not exist. One denomination is on screen at a time, so a column never mixes units. tvlNative on the series resolves it: the live on-chain read where the node answered, and otherwise the newest persisted total_supply x share_rate point provided that point is recent (recentSeriesTvl, ceilinged at 48h against the series' own newest entry, the same shape as dune.ts's BAR_STALE_HARD). So a brief RPC outage still shows a size rather than blanking every row, while a fund whose supply column stopped being written reads "-" instead of printing a dead number beside live returns — which matters twice over, because that number also decides whether the fund clears a Min TVL floor. The ceiling is measured against the series rather than the wall clock: what misleads is a stale size next to fresh returns, whereas a whole-pipeline stall is already declared by the panel's "last updated" stamp. The Min TVL field is typed in that same unit ($M / k ETH) and switching denomination clears it rather than reinterpreting 10 from $10M to 10k ETH. A fund whose TVL could not be read is withheld from a Min TVL screen, never passed through.

Short APY windows on oracle-priced funds swing in BOTH directions. These funds are priced by oracles that update on their own schedule, not per block — the Mellow oracle behind earnETH can hold still for ~27 days (what flatPeriods measures), and the one behind Lido Earn USD publishes about once a day, so on the 6h grid three of every four of its snapshots repeat. A short window therefore contains a WHOLE NUMBER of price updates, and which number depends on where the publication clock happens to sit relative to the snapshot boundary:

  • zero updates → 0.00%. The window spans no new report.
  • two updates in a 24h window → roughly double the fund's actual rate. Two days of accrual annualised over one. Measured on Lido Earn USD's backfilled series, 9 of 661 stored 24h readings sit above 15% against a fund earning ~7%, topping out at 22.77%, and each one is adjacent to a 0.00% reading.

Both readings are truthful arithmetic on truthful inputs — the realised index ratio over the window, annualised once — and neither is a claim about what the fund earned. The 30-day window is the steadier read, and it is the default for that reason. The column tooltip states the zero case; a reader who wants a rate rather than an artifact should not take a single short-window print off an oracle-reported fund. windowApy in strategies-table.ts is exported and unit-tested for exactly this (strategies-table.test.ts), because the fixture database's share rates are a smooth exponential on which every window annualises identically — a browser test cannot tell a working window switch from an inert one.

Screener column headers ​

Every screener header cell comes from one pair of primitives in src/components/ui/terminal-table.tsx, so the convention lives in a single place rather than being re-hand-rolled per table:

CellUsed forStyle
ColIdIdentity columns — what the row is. PLATFORM, COLLATERAL → DEBT, ASSET, MARKET, TICKER, ISSUER, FUND.UPPERCASE Geist Sans kicker, 10.5px, letter-spacing: 0.14em, #8B95A1. No icon, no ⓘ.
ColMetricMeasured columns — a computed value. Carry APY, Total Deposited, Utilization, Vs SOFR, APY (30d), 1M Return.Title Case Geist Mono, 11px, letter-spacing: 0.04em, #8B95A1. Optional trailing ⓘ and an uppercase sub line, 8.5px, in the same ink.

Supporting rules:

  • One header ink, app-wide. Column titles and their sub lines are both #8B95A1. "App-wide" includes the two tables that do not come from these primitives: /portfolio's holdings card (HHead) and the position-details panel inside an expanded holding, both in PortfolioDashboard.tsx, take the signed-in theme's DIM, which is the same hex. Titles used to be #71767B and sub-lines #4A4F54, and the pair read as chrome rather than as the labels for the figures underneath; the hierarchy between the two lines is now size and uppercase tracking alone. #4A4F54 survives on separators, arrows and a few resting chevrons, and on nothing in terminal-table.tsx. #71767B stays on body captions and units everywhere EXCEPT the three measured columns of the /carries row, whose own units and second lines (bp, +$5.1M borrow, the % and the en dash of the carry band, the util after the utilization) move to #8B95A1 with the headers: the spec sets those cells' inks explicitly and they are sub-lines of a metric, not asides. The APY column's CARRY / MAX-LEV kickers in the same row are still #71767B, so that row carries two caption greys — deliberate, because the spec addresses columns 4 to 6 and leaves column 7 alone, but worth knowing before a third one is added. Changing FG_DIM in terminal-table.tsx moves every data table on the site; the /carries body inks are local to CarriesTable.tsx.

  • Sorting. The active column is marked with an amber ▼ / ▲ caret and carries aria-sort; inactive sortable columns show nothing (no placeholder ·). The amber prop marks the table's anchor column — the one whose cells render as HeadlineMetric — and stays amber under any sort, so the header keeps matching its own column. Only the caret moves.

  • Where the caret sits. Beside the title in the ordinary form. In the fixed-stack form (labelWidth, which /carries uses to line its three ⓘ marks up at one offset) it rides the sub line instead, and the whole two-line stack becomes the sort control — one tab stop, both lines clickable. The title line there has to seat the longest column name, a 6px gap and the ⓘ inside a single metric track, and the caret plus its own gap is another 12px on top. The sub line is set 2.5px smaller and is the shorter of the two in every column, so the caret is far cheaper there.

    On /carries that 12px is the difference between Carry range 30d reading in full at four of the six widths the table is measured at and at none of them. It still does not read in full at all six: at 11px that string measures anywhere from 102.8 to 111.6 CSS px depending on which zoom step the shell is on (the zoom property re-rasterises the text and its advances do not scale back cleanly), and ~5px narrower again in a full Chromium than in the headless shell CI runs, against the 108px its track can give a label stack where the three tracks sit on their floors. It ellipsises at 1320 and nowhere else — 1360 is the same zoom step with 4px more track and shows it in full — which the product owner accepted with the ⓘ carrying the definition. tests/e2e/carries.spec.ts names that state, and the two others accepted with it, by column, line AND width, and fails on anything else.

  • ⓘ policy. Attach info only where a definition helps: an ambiguous or computed metric (Carry APY, Utilization, Vs SOFR, Type). Self-explanatory metrics omit it, which keeps the ⓘ meaningful rather than decorative. Asset profiles therefore carries none.

  • Alignment. Identity columns left; numeric metric columns right. Each header matches its column's value alignment.

  • APY naming. APY (Nd). On repo lending the window in parentheses tracks the 24H / 7 DAY / 30 DAY switch (APY (24h) / APY (7d) / APY (30d)).

  • Repo Utilization holds two numbers. The header's sub="curr / target" line says which is which; the cell renders current in #E7E9EA and / target in #71767B, in the same order. Both numbers share one span: as a direct flex child the target's leading space would collapse.

Gotcha. ColId re-applies uppercase to the sort <button> itself. The UA stylesheet sets text-transform: none on <button> and Tailwind's preflight does not restore it, so an identity kicker that happens to be sortable silently renders Title Case otherwise. terminal-table.test.tsx locks this.

Expandable rows ​

All five screeners (Repo lending, Carry trades, Money market funds, Multi-strategy funds, Asset profiles) are single-open accordions driven by one hook, src/components/hooks/useRowDeepLink.ts. It owns three behaviours so the tables cannot drift apart:

  • One open row, its key mirrored to a query param (asset, carry, fund, strategy, market) via history.replaceState — shareable, no RSC refetch.
  • The open row's header is scrolled to the top of the viewport, whether the row was clicked or seeded from an inbound deep link. Opening a row collapses the previously-open one, which shifts the layout upward; without the scroll the reader lands mid-panel on a row they did not open. A click defers one frame (long enough for that collapse); the deep link waits 80ms, because on first paint the whole page is still settling.
  • Scroll anchoring is suppressed while a row is open, and restored the moment it closes. Chrome anchors the scroll position to a node in view and compensates for anything inserted above it, which is right for a page filling itself in around a reader and wrong for a table that has just told the page where to sit. Money market funds fetches its drawer on first expand, so the content lands after the scroll: measured at 1140, opening a second row moved the page 1,523px, exactly the height the arriving content added, leaving the row the reader had clicked 1,453px above the top of the window and the page parked at the end of the drawer. The four screeners whose drawers render from data already on the page never showed it, because nothing arrives late to compensate for. tests/e2e/screener-freeze.spec.ts holds it.

Gotcha. That scroll must stay one effect keyed on openKey. It was briefly two — an inbound-deep-link effect keyed on the URL-derived key, and an open-scroll effect keyed on openKey — and they raced. setOpen calls history.replaceState, which re-renders useSearchParams consumers, so opening a row also flips the URL-derived key and re-fired the deep-link effect 80ms behind the other's rAF, restarting the scroll easing mid-glide. It hit only the first expand of a session (the guard armed afterwards), which is exactly the kind of thing that reads as "the first click is janky" and never gets reported.

Two contracts bind a table into this: rows must render id={`row-${key}`} with the same normalized key the hook stores, and must carry ROW_SCROLL_MARGIN_CLASS (exported by the hook, and resolving to the single class .screener-row-anchor in globals.css). That class carries two values, because the freeze stacks differently per breakpoint and a single px figure cannot serve both:

  • Below lg: 112px. The 52px mobile nav plus the frozen column-header strip plus slack. 72px here lands the row under the headers.
  • lg and up: 72px. The header pins to top:0, so only that strip has to be cleared; this is the long-standing desktop figure.

The strip is sized per table: Carry trades fixes its header at 44px, the others are content-sized and come out shorter, so 44 is the height both values are built to clear and the rest over-clear harmlessly. Put the class on the row's <summary> as well as the <details>: scroll-margin does not inherit, and sequential focus navigation scrolls the summary. That gives the browser the offset to use; it is not a guarantee it will, since focus scrolling goes through the same scrollIntoView path src/lib/scroll.ts had to work around.

Both values live in one named rule with the media query inside it, deliberately not in a scroll-mt-[112px] lg:scroll-mt-[72px] pair of Tailwind arbitrary utilities. An arbitrary value puts the number in the selector, so retuning it renames the class, and the deploy window that serves a fresh JS chunk against a stale CSS one then matches no rule and drops the offset entirely — the same stale-pairing failure the grid-template provenance spec below exists to prevent. A stable name degrades to the previous offset instead of to none. Do not replace it with an inline scrollMarginTop either (that cannot carry the breakpoint). Lowering the desktop value hides rows under their own column headers.

Frozen column headers (and the open row) ​

Carry trades, Asset profiles, Multi-Strategy Funds and Repo lending keep a unified page scroll: the column-header strip is position:sticky in the page flow (not inside a table scroller), and the body is the only horizontal scroller. The body's scrollLeft is mirrored onto the header so the sticky-left identity columns stay aligned sideways. The shared hook is useFrozenScreenerHeader in src/components/hooks/useFrozenScreenerHeader.ts.

While an open row's expansion straddles the freeze line (the summary has scrolled up past the headers, but the expansion bottom is still below them), a pinned clone of that row sits flush under the headers so the row's identity stays visible. The clone is an absolute overlay, not in flow, so showing it never shifts the body (a layout shift here would feed back into the visibility math and flicker), and it is mounted only while it shows rather than kept in the DOM at display: none — a hidden clone is a second copy of the open row's text sitting earlier in the document than the live row, and anything reading the first match for a ticker or market name gets the invisible one. Activating it jumps back to the live row: it is a real control (pinnedRowHandle, exported by the same hook — role="button", focusable, Enter/Space, aria-label), not a div with an onClick, so a reader who opened the row from the keyboard has a way back to it.

Visibility is re-measured after every paint, not only on scroll: a filter that unmounts the live row without changing the open key must not leave the pin stuck on. The same after-every-paint pass re-mirrors the body's scrollLeft onto both strips, which is what covers the horizontal positions no scroll event announces — a mount, a bfcache restore or a back-navigation hands the scroller back where it was, and a header left at 0 is then visibly a column out of step.

The table-panel wrapper must not be an overflow container: sticky resolves against the nearest scroll ancestor, and an overflow: hidden on that wrapper is what used to let a header and open row scroll away.

tests/e2e/grid-template-provenance.spec.ts declares which surfaces have the pinned strip (expectsPinned) and must actually produce it; a surface that grows one cannot leave that flag false.

Carry-row modals ​

An expanded Carry trades row carries a "Before you size" tab strip, and each of its three steps opens a dialog: the trade calculator, oracle transparency, and market depth. All three are one frame, CarryRowModal (src/components/carries/CarryRowModal.tsx); the panels inside it (TradeSimulator, OraclePanel, MarketDepthPanel) render a body only, with no border and no title bar of their own.

The frame is two boxes, and which is which is the whole point:

  • A fixed header, holding the dialog's title, the pair it belongs to (sUSDe / USDT; the oracle dialog upgrades this to both vault legs and the venue once its report lands) and the close ×. It is a sibling of the scroller, never its first child, so all three stay on screen for the entire scroll. The title is a real DialogTitle, which is what gives the dialog its accessible name.
  • A body, the only thing that scrolls. It holds overscroll-contain as belt-and-braces rather than as a fix for anything seen on desktop: Base UI already locks the page (body { overflow: hidden }) while a dialog is open, so a scroll running off either end has nothing to chain to there. It earns its place on iOS, where the page rubber-bands behind a modal regardless.

The frame's height is bounded in svh, not vh or dvh. svh is the small viewport height, so it does not change when mobile browser chrome collapses mid-scroll — and because the popup is centred, a box that resizes moves its top and bottom edges at once, which reads as the modal jumping under the cursor.

Both bounds are a min() of a viewport share and an absolute ceiling — min(90svh, 720px) tall, 720px wide — and the ceiling is the half that matters. Sized in viewport units alone a dialog keeps growing with the screen, so the same panel that sits politely on a laptop covers a large monitor end to end and stops reading as something laid over the page: it reads as the page, zoomed. The share governs a small screen, where the ceiling would overflow; the ceiling governs everything from a laptop up, and past it the extra pixels go to the backdrop rather than to the panel. The strategy behind the modal stays visible, and so does the fact that clicking it dismisses.

720px is set by the widest thing the three panels have to seat, which is the oracle memo's pricing boxes rather than its prose. The arithmetic runs inside the scroller, not inside the frame, and the difference is the scrollbar: a 720px popup is 718px of scroller (2px border) and 708px of client width once the memo's own 10px scrollbar is taken out. Section 02 is a two-column grid of Collateral and Debt, each seating a token, its mark and a source label; each box gets 288px of content plus 32px of box padding, so 2 × (288 + 32) + 16px column gap + 48px px-6 gutters = 704, and the remaining 16 is the scrollbar plus the border. The prose measure sits inside that with room to spare but less than the frame width suggests: 78ch at the panel's 13px mono is 608px against the panel's 660px content box, so 52px of slack, not the ~110 a naive 718 − 608 gives. Below a popup width of ~668 the prose becomes the binding constraint instead. What the previous 920px frame had was neither constraint binding: ~260px of gutter beside every paragraph and boxes wider than their content needed.

The trade calculator is the one panel this bound is tight on — it is operated rather than read, so inputs, position, result and the Simulate action all want to be on screen together. Three separate mechanisms carry that:

  • Density. SimulatorCards spacing and CommandField metrics are load-bearing rather than cosmetic; together they land the DEFAULT card ~12px inside the ceiling at a laptop height. One exception is a floor rather than a budget: the command field's input is 16px because iOS Safari zooms the viewport on focus for anything smaller, which would move the modal the svh bound exists to hold still.

  • Fit to frame. Density is enough only where the ceiling governs. Below 800px of viewport height (720/0.9) the svh share governs instead; from about 785 down the frame is smaller than the panel, and the reader was left scrolling a panel they are supposed to be driving. CarryRowModal takes a fitToFrame flag (the calculator passes it; the two panels that are READ do not, because prose belongs at its designed size and scrolling is how anyone expects to get through it) and zooms the body out by exactly the shortfall: at 720px tall that is ~0.89, at 1360×900 it is 1 and nothing is touched. The frame's size never changes — the extra pixels stay with the backdrop, as above.

    Five details are load-bearing. It is CSS zoom, not transform: scale(), because zoom is part of layout: the panel still fills the frame's width at the smaller size, and the scroller sees the height it actually occupies rather than keeping a scrollbar for content that visibly fits. The available height comes from the popup's max-height rather than the scroller's current height, which is a consequence of the zoom and would ratchet the fit down a step per pass. The shrink loop re-measures each pass instead of dividing once, since zoom hands the body more layout width as it shrinks. A change in the PANEL's own height may only shrink the fit, never grow it back (see the gotcha below). And the fit is floored at 0.75 (MIN_FIT), below which the 9px card labels stop being readable and the scroller takes over again — the floor first binds at ~635px of viewport height and real overflow starts at ~632 — and below sm, where the cards stack, no fit is applied at all. The command field divides its 16px by the fit (--fit-scale), so the one control the reader types into renders at 16px whatever the panel was scaled to, keeping the iOS guard above intact.

    The floor is also what bounds this against a reader who has zoomed their BROWSER, and the tradeoff is worth stating rather than leaving in a comment. Browser zoom reaches the page as a smaller viewport, so zooming in asks this panel to shrink: at 200% of a 1360×900 window the page sees 680×450, the fit floors at 0.75, and the reader nets 150% of the 200% they asked for. Nothing is clipped and nothing side-scrolls at that size (verified), so this is not a loss of content under WCAG 1.4.4 — but in the 640–853px effective-width band the panel does give back up to a quarter of the reader's own zoom. Past ~267% the viewport drops under 640, the cards stack and the full scale is restored.

    Gotcha. Three things this measurement cannot do naively. (1) The nodes are held in state via callback refs, not useRef: Base UI mounts a dialog's popup only while it is open, but CarryRowModal renders (and runs its layout effect) with the dialog closed too, so refs read null on the only pass the effect would ever make. (2) A viewport change does not reach svh in the tick that announces it, so the measurement keeps looking a frame at a time until two passes agree on the same available height. Without that, a window dragged TALLER kept the zoom of a height it no longer had — a stale reading self-corrects on the way down (the panel's own height moves, which re-measures) and not on the way up. One measurement settles in two passes, but a single viewport step still paints four to six sizes over as many frames while svh arrives, so a real window drag visibly steps. (3) The body's ResizeObserver schedules the next pass, it never measures inline. An observer reports its element's box in that element's own post-zoom space, so writing the zoom from inside its own callback is a resize during resize delivery: Chrome drops a notification and raises an unhandled ResizeObserver loop completed with undelivered notifications on window (six per simulate/re-simulate cycle, measured), which no Playwright console or pageerror hook can see.

  • A sticky CTA. Neither of the two above is enough on its own, because the card is not one fixed height, the fit may not grow back when that height changes, and it has a floor. Four shipping states are taller than the default: the stale re-simulate banner (which is the calculator's own loop, not an edge case), a term carry's maturity captions, the smart-leg projection note, and a position with enough legs that the RIGHT column, not the inputs column, sets the grid height (grid-cols-2 with INPUTS spanning both rows means the height is max(inputs, position + result), and the two sit within a few px of each other). position: sticky; bottom: 0 on the CTA container costs no vertical budget and keeps the primary action whole in all of them.

    A sticky band buys two obligations, and both are paid in code rather than left to chance. It is opaque and it sits at the bottom edge of the scrollport, which is precisely where a browser parks a control it has just scrolled into view on focus — so tabbing through the inputs would otherwise leave the focused chip completely hidden behind it, which is WCAG 2.2 SC 2.4.11 (Focus Not Obscured, AA). CarryRowModal takes a footerInset and turns it into the scroller's scroll-padding-bottom; the measurement lives with the band, as SIMULATOR_FOOTER_INSET. Size it against the band's TALLEST state, which is not the obvious one: the re-simulate banner takes it from 47px to 81, and at 320px wide that banner's copy wraps to a third line and takes it to 94. And its top edge would otherwise be a hard horizontal cut through whichever line of text is under it, which reads as a rendering fault rather than as a pinned bar, so it carries a black box-shadow above it: invisible against the card at rest, a fade the moment there is something behind it.

Gotcha. Never put Tailwind's relative on the popup. It beats the fixed Base UI centres it with, and the modal drops into page flow at the bottom of the document.

Below sm the frame is calc(100% - 2rem), i.e. a 16px margin a side.

Covered by tests/e2e/carries.spec.ts:

  • the chrome holds its viewport position while the body scrolls;
  • the frame honours both px ceilings on a 1200px-tall viewport;
  • the default calculator fits unscrolled and unzoomed at 800px of viewport height;
  • at 720px it fits by zooming out instead of scrolling, with the applied zoom matching the fit the frame decided on and the typed field still rendering at 16px;
  • that fit follows the viewport back up as well as down;
  • operating the panel never grows it back: simulating, dirtying an input and re-simulating leaves the fit monotonically non-increasing and the panel unscrolled at every step;
  • and at 560px, where the card cannot fit however dense it gets or however far it is zoomed, its CTA stays whole at both ends of the scroll and a full Tab walk parks no control behind the band.

Plus the zoom sweep's "calculator modal open" and "oracle modal open" surface states. Those tests cover the mechanisms the tall states depend on rather than the states themselves: the fixture seeds no maturity_ts row and only t1-single / aave-v3-emode / sparklend-emode / morpho-blue kinds, so a term carry and a smart-leg position cannot be rendered from it.

Gotcha. Register a dialog that FETCHES as mode: "once", never perWidth. perWidth re-navigates and refetches at each of the ~26 sweep widths, and both the oracle and market-depth panels read /api/carry-oracle, which is budgeted at 30 requests per IP per minute. The sweep trips that limiter partway through and the panel renders its "Oracle data unavailable right now" state, so the run fails on a rate limit dressed as a layout defect. once costs one fetch and loses nothing: perWidth exists for popups that a resize dismisses, and a Base UI dialog is not one.

Token coins vs curator marks vs issuer marks ​

Four different questions, four different resolvers. Mixing them up is the usual bug:

QuestionResolverExample
What coin is this?components/icons/token-markssUSDe → the sUSDe coin
Who runs this fund?components/icons/curator-marksSentora PYUSD USDC → Sentora's mark
Whose app is it on?components/icons/ProtocolIconsthat same fund → Euler's mark
Who issues this token?components/assets/IssuerIcon, via carries/AssetMarksUSDe → Ethena's logo

A curator fund is three of those at once — a Sentora-run vault holding PYUSD, deployed on Euler — so which mark leads depends on what the cell NAMES. The /portfolio holdings row and the /money-market Fund column both name the fund, so both lead with the curator's mark from curator-marks; the Platform / Execution platform cell beside it names the venue and keeps the protocol mark. Euler's own Prime / Yield vaults are the one case where curator and venue are the same firm. curator-marks.test.tsx fails if a manager in a live vault registry has no mark, or if a curator fund on Euler resolves to Euler's mark.

token-marks is the token-coin registry: one map, every surface (Asset Profiles' TokenIcon, the carries Position column, both Collateral filter dropdowns). It used to be three parallel maps, which is why a newly-listed asset would show its coin on one page and a grey monogram on the others. Add a coin once, here, and every surface lights up. AssetMark stays the issuer resolver and is used only where the issuer is the point (the oracle + simulator panels).

Rules the registry encodes, so callers don't re-implement them:

  • Keys are UPPER-CASE — registry labels drift in casing.
  • A Pendle PT wears its underlying's coin (PT-srUSDe-22OCT2026 → srUSDe), so a maturity roll needs no code change.
  • Wrappers alias their underlying (stETH → wstETH, eETH → weETH). WETH is the exception: it is the one wrapper with a registered coin of its own (CoinGecko 2518 / CMC 2396, the pink-ring wordmark), so it keeps that mark rather than borrowing the ETH diamond — a holdings row or a wstETH/WETH loop has to tell the two apart.
  • Idle CDO tranches match by PREFIX (AA_ / BB_) and wear Idle's issuer mark: the token is minted per borrower and has no coin of its own. This is the documented last resort before a monogram, not a licence to use issuer marks generally. A tranche with a coin of its own is keyed exactly and wins over the prefix (AA_FalconXUSDC wears FalconX's coin, as Morpho draws it).
  • Every coin is a disc, no exceptions. TokenMark clips whatever it draws to a circle and paints a faint rim over it (an outline, the one decoration drawn above an image), so a square tile renders round and a black coin on a black row still reads as a disc. A clip cannot give a bare glyph a disc to sit on, so a file must be a disc or a full-bleed tile; bare glyphs are not accepted.
  • coinsOf() splits a Fluid smart-collateral pair (wstETH/ETH) into legs.

Adding an asset. Prefer the official CoinGecko token image (verbatim, downscaled to 64px). For a collateral a Money Market Fund lends against, take the logoURI the Morpho Blue API returns for that exact contract address (cdn.morpho.org, the coin Morpho's own app draws; an SVG that only wraps an embedded raster, or a very heavy vector, is rendered to a 64px PNG), and where Morpho carries none, the icon on Pendle's asset registry entry for the same token, or the underlying's coin for a wrapper or tranche. Whatever the source, the file must survive the disc clip whole: a badge on the rim gets cut. The icons that used to be bare glyphs or a square tile (wstETH, tETH, osETH, XAUt, LINK, PYUSD, FRAX, crvUSD, USDT) now ship as the issuer's round coin from those sources. Fall back to web3icons (0xa3k5/web3icons) only when none of these is usable, and record the reason next to the entry. For the idle-stablecoin set, prefer an address-keyed registry — Trust Wallet (trustwallet/assets) or Curve (curvefi/curve-assets) — because tickers collide: several live tokens call themselves eUSD or rUSD, and only the contract tells them apart. Never redraw a brand mark by hand; if no registry carries the coin, leave the monogram and say so next to the entry (rUSD is the one such gap today). token-marks.test.tsx fails if a live collateral symbol (including every collateral and deposit asset of a listed Money Market Fund) falls back to the monogram, if a map entry points at a file that isn't in public/, or if a coin renders without its circular clip and rim; symbols.test.ts fails if an asset gains a book without gaining a ticker, which would print a bare 0x1234…cdef in the holdings table.

Motion ​

The brief is minimal animation: motion exists to prevent a jarring change or to show where something came from, never for decoration. A terminal is scanned, so anything the analyst touches dozens of times an hour stays instant.

Tokens. Three easing curves live in @theme in src/app/globals.css. Defining --ease-out there deliberately overrides Tailwind's built-in curve for the ease-out utility, which is too soft for deliberate motion.

TokenValueUse
--ease-outcubic-bezier(0.23, 1, 0.32, 1)Entrances and exits (tooltips, modals, dropdowns, row panels).
--ease-in-outcubic-bezier(0.77, 0, 0.175, 1)Movement across the screen.
--ease-drawercubic-bezier(0.32, 0.72, 0, 1)Sliding panels: the mobile drawer.

Hover and color changes keep the plain ease keyword at 150ms, the app's one hover duration.

Rules the code follows.

  • Transitions, not keyframes, for anything reversible. A transition retargets from wherever the motion currently is; a keyframe restarts from zero. Tooltips (ui/tooltip.tsx) and the @starting-style entrance in globals.css (.popup-enter) are transitions for this reason. .expanded-panel-enter is the deliberate exception: it is a keyframe keyed off details[open], not @starting-style. @starting-style fires only when an element is first inserted, and three of the screener tables latch their panel mounted after the first expand (hasOpened, so the heavy charts are not rebuilt on every toggle), so the entrance ran exactly once per row and then silently stopped playing. Matching the selector replays it on every open. A one-shot entrance has nothing to interrupt, so a keyframe is safe there.
  • Transform and opacity only. Never animate height, width, or width-driven fills: they cost layout and paint.
  • A popover scales from its trigger, on BOTH axes. .popup-enter sets transform-origin: top, which is top centre, so it is only half the rule: a panel pinned right: 0 to its button grows from a point half its own width away from that button, and the one edge the reader is watching (the edge touching the trigger) is the one that drifts. Add .popup-enter-right wherever the panel is right-anchored. Carried today by the portfolio's settings and wallets popovers. Known gap: the three left-0 dropdowns in ui/filter-controls.tsx still run on the bare class and have the same defect mirrored, drifting by half their scale-up on the left edge; they want a matching .popup-enter-left.
  • Name the property Tailwind v4 actually emits. scale-95, rotate-90 and translate-y-px compile to the standalone scale, rotate and translate properties, not to transform. An explicit transition list that names only transform therefore fails to animate any of them, silently and with no build error. Name the real property (scale, rotate, translate), or use the plain transition utility, whose property list covers all three and still excludes the layout properties that transition-all would drag in.
  • Exits are faster than entrances. Opening is the deliberate act; dismissing is the system responding. Modals run 200ms in / 120ms out; the mobile drawer 250ms / 180ms; the row-expansion and dropdown entrances have no exit at all.
  • Durations. Tooltips 150ms, dropdowns and row panels 150-180ms, modals and drawers 200-280ms. UI motion stays under 300ms. The one exception is the screener row-expand scroll (scrollToTop): scrollIntoView takes no duration, and the browser scales its own animation by distance, so opening a row far down a long table runs past 300ms. The alternative is a hand-written scroll tween, which is more moving parts than the jump is worth.
  • Charts never animate. Every Recharts series and <Tooltip> sets isAnimationActive={false} — the default 400ms tooltip tween makes the readout trail the crosshair, and a re-drawing line on every filter change is noise.
  • JS hover must be touch-gated. Tailwind's hover: utilities compile inside @media (hover: hover) and are safe. JS hover state is not: a touch browser fires mouseenter on tap and never fires the matching mouseleave, so the highlight sticks. Gate onMouseEnter on useCanHover() (src/lib/use-can-hover.ts) and leave onMouseLeave ungated. The 2026-07 motion pass gated the highest-traffic sites; ungated onMouseEnter handlers remain (CarryDiligence CTA, filter-controls, PortfolioClient, CapacityTightPanel, AppSidebar) — apply the gate when touching those files.
  • Reduced motion keeps the fades, drops the movement. tw-animate-css ships no prefers-reduced-motion handling, so every moving surface carries an explicit motion-reduce: override or a media query in globals.css. A programmatic scroll needs the preference read in JS: a behavior passed to scrollIntoView overrides the CSS scroll-behavior property, so a media query alone cannot quiet it. Route every programmatic scroll through scrollToTop (src/lib/scroll.ts), which reads the query itself. Note its still branch passes "instant", not "auto": per CSSOM-View "auto" defers to the computed scroll-behavior, so it would start gliding again the day anything sets that property.
  • A transition that must be quietable does not belong inline. An inline transition outranks every normal stylesheet declaration, so the plain transition: none in the shared reduced-motion block cannot reach it and the motion plays at full strength under prefers-reduced-motion: reduce. It is not unreachable: per CSS Cascade, an important author declaration beats a normal inline one, so transition: none !important does win (that is exactly what transform: none !important does for .popup-enter in the same block). Prefer a class anyway — it keeps the rule and its override in one place instead of scattering !important through the sheet. Two exist for this reason: .mobile-scrim (nav scrim fade) and .disclosure-chevron (portfolio wallet-card + position-row chevrons). The state stays inline in both — the chevron's transform: rotate(...) is state, not motion. Known gap: two inline transform transitions are still unguarded and do ignore the preference (home/FeatureSections.tsx, carries/CarryDiligence.tsx). Fix them with a class or an !important guard when next in those files.

API routes (src/app/api/) ​

All are dynamic = "force-dynamic" (no ISR).

RouteMethodPurpose
/api/carry-oracle?key=<strategyKey>GETOracle transparency report for one carry strategy: curated mechanism + per-token explanation + risk notes (src/lib/data/oracles.ts) merged with live on-chain reads and secondary-market basis (src/lib/data/basis.ts). Fetched lazily by the ORACLE tab so the carries page never pays for these reads.
/api/carry-history?key=<strategyKey>GETOne carry strategy's full 6h-cadence CarryHistoryPoint[] series (the array getCarryHistory produces). Fetched lazily by the row's expansion chart (CarryChart) on first expand, so the prerendered /carries RSC payload no longer ships every strategy's series up front. The key is resolved through resolveShownStrategies (src/lib/data/carry-strategies.ts) — the same resolver /carries uses to build its rows — so it can only resolve the exact StrategyCore the row was built from; a key that is not shown is a 404. Cached s-maxage=1800, stale-while-revalidate=3600 (mirrors carry-oracle; the series only moves on the 6h cron).
/api/money-market-fund-history?slug=<slug>&range=<1m|3m|6m|1y|max>GETOne money market fund's share-rate, TVL and benchmark series over the requested window. Fetched lazily by the expanded row's chart card (FundPerformanceChart) on first expand, so the prerendered /money-market-funds payload ships its rows and nothing else. Same shape and the same reasons as carry-history above: the slug resolves through the reader the page builds its rows from, so a fund the tab does not list is a plain 404; a malformed slug or an unknown range is a 400 before any query runs; per-IP damping at 30 requests a minute. Cached s-maxage=1800, stale-while-revalidate=3600 (the series only moves on the 6h cron).
/api/money-market-fund-detail?slug=<slug>GETOne money market fund's drawer: its brief, statistics tower, per-market allocation table, exit panel and control panel. Fetched by the row on first expand, for the same reason the chart's series is: measured on the fixture, a drawer is roughly seven times the weight of the row it sits under, so prerendering all of them tied the page's transfer to how many funds an administrator has approved rather than to what a reader opens. Same guards as the history route beside it: an anchored slug pattern, a plain 404 for a fund the tab does not list, per-IP damping at 30 requests a minute, s-maxage=1800, stale-while-revalidate=3600.
/api/repo-marketsGETThe repo-lending market universe with the two facts a LENDER's position row cannot carry about the book it supplies: the collateral underwriting that book (largest exposures first, plus the total count) and its current utilization. Same figures /repo-lending publishes, read market-side by src/lib/data/repo-market-context.ts — one latest row per market, deliberately not getMoneyMarketRates(), whose per-market history scans answer a different question. Public and account-free by construction (every wallet supplying USDC on Aave v3 lends into the one market), which is what makes it edge-cacheable and usable by an agent querying the same analytics. Fetched once per /portfolio dashboard mount for the Repo lending row group; cached s-maxage=1800, stale-while-revalidate=3600.
/api/sim/swap-costPOSTReal execution-cost quote for the leveraged-carry simulator via the KyberSwap aggregator. Round-trip / no-phantom-loss method; returns { ok: false } rather than an estimated-bps fallback when a leg is unquotable. Both legs are sized at the entry borrow (roundTripNotionals), so the figure is the price of the round trip at today's depth and does not drift with the holding period — see Execution costs for what that knowingly omits. Also returns maturityExit: true when a PT exit was priced as redeem-at-par plus the proceeds swap rather than as a market sale. No UI in this app reads that flag; it is part of the response for API consumers pricing their own exits, and it reports what was priced rather than which branch was attempted.
/api/portfolio/pt-exit-cost?position=pendle:pt:<pt>[&wallets=<a>,<b>]GETSigned in. What selling a directly held PT would cost right now, for the expanded PT row's "Cost to sell now" (src/lib/portfolio/pt-exit-cost.ts): ONE sell quote for the account's whole holding (summed over the named wallets, each tracked by the session's account, 403 otherwise; the size is read server-side, never taken from the caller) through Pendle's hosted SDK (src/lib/data/pendle-swap.ts, the calculator's own integration), against the position's value at the moment of the quote (the PT rate by the one rate method at the chain's current block, carried into the row's book by the row's own converter; the row's reading stands in only if under ten minutes old). Returns { ok, cost, costPct, proceeds, reference, referenceSource, quotedAt, book }, the cost SIGNED (negative when the quote is above the current price), or { ok: false, reason }. no-store; one quote per market and size per minute per process.
/api/notify-capacityPOSTCapacity-alert email capture → capacity_notifications (honeypot + partial-unique-index dedupe; the send pipeline is not wired yet, this only captures).
/api/newsletter/subscribePOSTNewsletter signup → newsletter_subscribers (ON CONFLICT DO NOTHING).

Abuse damping (per-IP rate limits). The public, unauthenticated routes carry a small in-memory per-IP fixed-window limiter (src/lib/rate-limit.ts) so one caller cannot burn external quote quota (Kyber/Pendle) or hammer the on-chain/DB writers. Budgets, per IP per minute: sim/swap-cost 10, portfolio/pt-exit-cost 10 (signed in, but it spends the same Pendle quote quota), carry-oracle 30, carry-history 30, repo-markets 60, auth/verify 10, auth/nonce 30, newsletter/subscribe 5, notify-capacity 5 (on notify-capacity the honeypot check runs first, so a bot still gets the lying 200, never a 429). Over-limit returns HTTP 429 with a Retry-After header. The client key is the visitor IP from cf-connecting-ip first (Cloudflare fronts the origin and nginx sets X-Real-IP to $remote_addr, which is the Cloudflare edge IP, not the visitor, so keying on it would collapse many visitors into one bucket), falling back to x-real-ip then the first x-forwarded-for hop; this is best-effort damping, not a security boundary; the real guards stay with each feature (the SIWE session, the chat token budget). health and the session-gated portfolio/chat routes are never IP-limited: chat has its own per-user token budget, and the live portfolio read has its own per-wallet cooldown in src/lib/portfolio/live.ts (five minutes, PORTFOLIO_LIVE_COOLDOWN_MS), which a page load and the Synchronize button are answered by alike. State is a single per-process Map (the app runs as one PM2 process); a restart only resets windows, which forgives, and an nginx-level limit_req remains a recommended second layer for launch.

Repo map ​

src/
  app/
    page.tsx                    # Home
    portfolio/page.tsx          # Portfolio (prerendered public shell + PortfolioClient)
    repo-lending/page.tsx       # Repo Lending
    carries/page.tsx            # Carry Trades
    money-market-funds/page.tsx # Money Market Funds
    multi-strategy-funds/page.tsx # Multi-Strategy Funds
    asset-profiles/page.tsx     # Asset Profiles
    {repo-lending,carries,money-market-funds,multi-strategy-funds,asset-profiles}/loading.tsx # route loading skeletons
    api/                        # auth/*, carry-oracle, carry-history, sim/swap-cost, notify-capacity, newsletter/subscribe
    layout.tsx, globals.css     # root layout (fonts, AccountProvider), Tailwind v4 tokens
    sitemap.ts, robots.ts, manifest.ts, opengraph-image.tsx, icon.svg
  components/
    assets/                     # AssetsTable, AssetYieldChart, TokenIcon, IssuerIcon
    auth/                       # AccountProvider (shared SIWE session context)
    portfolio/                  # PortfolioClient (auth-gated /portfolio region)
    carries/                    # CarriesTable, CarryChart, OraclePanel, CarryRowModal (the shared
                                #   frame for the calculator / oracle / market-depth dialogs), capacity charts
    home/                       # HomeIndex, FeatureSections
    strategies/                # (serves /multi-strategy-funds) StrategiesTable, PeerReturnsChart, StrategyChart, FundBrief, UsdVaultRows
    funds/                     # (serves /money-market-funds) MoneyMarketFundsTable, FundDrawer, FundGlanceTower,
                               #   FundAllocationPanel, FundExitSection, FundControlSection, FundPerformanceChart
    money-market/              # (serves /repo-lending) MoneyMarketTable, MoneyMarketRatesChart, UnderwrittenCapital
    icons/                     # ProtocolIcons (venue marks), TokenIcons,
                               #   token-marks (THE token-coin registry),
                               #   curator-marks (THE fund-manager registry)
    layout/                    # AppSidebar, MobileNav, NavSections
    ui/                        # Base UI wrappers (Button, Dialog, Tooltip, ...)
                               # + terminal-table.tsx (shared table primitives:
                               #   PanelHeader, ColId / ColMetric headers,
                               #   TableRow, Cell, HeadlineMetric,
                               #   ExpandedPanel, SofrPill)
    hooks/                     # useRowDeepLink
    loading/                    # RouteLoadingSkeleton (route-level loading.tsx fallback)
    seo/                       # JsonLd
  lib/
    data/
      apy.ts                   # canonical annualisation + index-ratio math
      postgres.ts              # pg Pool + typed query()
      rpc.ts / rpc-batch.ts    # eth_call / archive / storage / block-by-ts; batched Multicall3 + getLogs
      adapters/                # fluid-ll.ts, fluid-dex.ts (per-venue on-chain decode)
      carries-table.ts         # carry strategy readers + distributional stats
      strategies-table.ts      # multi-strategy fund share-rate readers
      assets-table.ts          # asset profiles table reader
      money-market-rates.ts    # repo-lending supply-rate reader
      morpho-markets.ts        # Morpho Blue isolated-market registry + reader
      home-metrics.ts          # live home-page rate tape
      oracles.ts               # per-strategy oracle config + live reads
      basis.ts                 # secondary-market basis legs (token_basis)
      vault-capacity.ts        # Fluid / Aave borrow-cap reader
      vault-risk.ts            # max-LTV / liquidation-threshold reader
      cap-exposure.ts          # max-potential-exposure / underwritten capital
      sofr.ts                  # SOFR index reader
      prices.ts / llama-prices.ts  # DefiLlama USD price fetchers
    sim/
      leveraged-position.ts    # buy-and-hold leveraged carry simulator model
    auth/                      # session.ts (server SIWE machinery), accounts.ts (accounts writer),
                               #   siwe-client.ts (shared injected-wallet client flow), address.ts (truncate)
    format.ts                  # fmtUsd / fmtPct / fmtBps / fmtNumber
    seo.ts                     # canonical URLs, structured data
  data/
    asset-narratives.ts        # per-asset yield-mechanism copy
    curator-vaults.ts          # auto-generated curator-fund registry (portfolio + assistant)
    money-market-fund-narratives.ts # per-manager house line + per-fund mandate (/money-market-funds)
scripts/
  refresh-*.ts                 # cron entry points (assets, vault-capacity, collateral-exposure, ...)
  refreshers/                  # the actual ingestion jobs + shared.ts
  backfill-*.ts                # one-off history backfills
  sync-carries.ts, fluid-discovery.ts, sync-curator-vaults.ts, resolve-oracles.ts  # ad-hoc
  run-cron.sh                  # cron wrapper (sources .env.local, runs via tsx)
  sql/                         # 001..043-*.sql schema migrations (DDL for every table)
docs/                          # this VitePress site (also served at docs.creddit.xyz)

Database ​

PostgreSQL, schema onchain_credit. Time-series tables are written only by the refreshers; the app reads them and writes only the two email-capture tables. The DDL for every table lives in scripts/sql/001..043-*.sql — read those for exact columns. The market-data core is these ~23 tables; the neutral accounts, the chat-assistant tables, and the three read-only-portfolio tables (migrations 042/043) are covered in Database & schema:

Table~rowsTable~rows
aave_v3_reserve_apy~12.3kmorpho_market_apy~11.1k
assets~11newsletter_subscribers~2
capacity_notifications~1schema_migrations—
chain_scan_cursors~4sofr_rates~2.1k
curator_vault_state~35sparklend_reserve_apy~8.2k
fluid_dex_apy~55.2ktoken_basis~36k
fluid_ll_apy~54.4ktoken_yield_apy~112k
carry_registry~130vault_capacity~28
lending_borrowers~77.5kvault_risk_params~21
lending_positions_current~2.1kmarket_collateral_exposure~18.6k
lending_reserves~85market_risk_current~6
pendle_markets~55pendle_market_state~4.7k

New tables follow agent-grade conventions (canonical chain_id + lower-cased address keys, block-anchored rows, *_current split from snapshot history, an explicit basis column on derived rows). See Database & schema.

Deployment topology ​

The app runs on a Hetzner box (ssh root@dexhq.io, key ~/.ssh/hetzner_ed25519).

ConcernProductionStaging
Repo/opt/onchain-credit (tracks origin/main)/opt/onchain-credit-staging
Processpm2 onchain-credit on localhost:3001pm2 onchain-credit-staging on :3002
Edgenginx → https://creddit.xyz (Cloudflare in front)nginx vhost https://staging.creddit.xyz (Let's Encrypt, HTTP basic-auth .htpasswd-staging, noindex)
Databasecreddit (role onchain_credit, schema onchain_credit)separate DB creddit_staging (role onchain_credit_staging)
DeployCI on every push to main (deploy.yml, see below)CI on every push to staging (deploy-staging.yml); data is a nightly PII-scrubbed reseed of prod — see Deployment §3

Other pm2 processes co-located on the box: rindexer, dexhq, creddit-indexer. The prod DB is backed up daily at 02:15 UTC via scripts/ops/backup-creddit.sh.

Production deploy (.github/workflows/deploy.yml) ​

Triggers on every push to main (concurrency group deploy-production, one at a time). A GitHub runner SSHes to root@dexhq.io (pinned host key; key from the DEPLOY_SSH_KEY secret) and runs:

bash
git fetch <https + ephemeral GITHUB_TOKEN> && git reset --hard FETCH_HEAD
npm ci
npm run build
pm2 restart onchain-credit --update-env
bash scripts/ops/restart-ingester.sh   # the event ingester, then the ledger worker; leaves a stopped one alone

.env.local and node_modules are gitignored, so reset --hard preserves secrets and deps. On build failure it rolls back to the previous commit, rebuilds, and restarts the ingester and the worker onto that build.

The deploy ships code only. It does not run DB migrations, backfills, or data refreshers — those are manual server steps after a deploy. And because pages are prerendered at build time, if a refresher ran after the build, re-run the deploy to re-prerender or the data stays stale up to the revalidate window.

Known gaps ​

  • Branch protection is advisory, not enforced: a free-plan private repo, so GitHub cannot require PR-only main or green checks. ci.yml and the pre-push guard report but cannot block; the fix is GitHub Pro (see Deployment §2).
  • Prod migrations stay a gated manual step (migrate.sh by hand after a release); staging auto-applies additive migrations on deploy.
  • Deploys rebuild in place, over the previous build's output. deploy.yml runs npm ci && npm run build on the box with no .next cleanup and restarts pm2 only afterwards, so the old process serves while .next/static is being replaced underneath it. Rebuilding into a dirty .next was observed to produce two distinct failures: page HTML referencing the previous build's CSS chunk, which then 404s into a completely unstyled page, and an outright build failure on stale next/font modules. A clean build produced neither. This is the demonstrated delivery path for the stale-stylesheet collapse described under Where a screener's column template lives; the fix is to clear .next before building (and, separately, deploymentId for real skew protection).

The full staging → prod pipeline (auto-deploy on push to staging, the staging → main release PR, the nightly reseed) is live and documented in Deployment.

External dependencies ​

DependencyUsed forWhere
Ethereum RPC (ETHEREUM_RPC_URL)current on-chain statesrc/lib/data/rpc.ts
Ethereum archive RPC (ETHEREUM_ARCHIVE_RPC_URL)historical / archive readssrc/lib/data/rpc.ts
DefiLlama Coins APIUSD prices, block-by-timestamp, basis market pricellama-prices.ts, rpc.ts
NY FedSOFR ratesrefreshers/sofr-rates.ts
Dune APIanalytics (scarce credits)refreshers / backfills
Morpho Blue GraphQL APIMorpho market + curator datarefreshers/morpho.ts, morpho-markets.ts
Euler Goldsky subgraphcurator/market datarefreshers/curator-vault-state.ts
KyberSwap aggregatorlive swap-cost quotesapi/sim/swap-cost

Running locally ​

bash
npm install
npm run dev        # http://localhost:3000
npx tsc --noEmit   # type check (must be clean)
npm run build      # production build
npm test           # node --test over the listed test files; the golden gate needs Postgres 16

cd docs && npm install && npm run dev   # this docs site

npm test is an explicit file list in package.json, not a glob. A *.test.ts added but not listed never executes and never fails, so every change that adds a test file edits that list in the same commit. CI runs the list as two jobs (Processes, E.5): npm run test:fixture is the suites that build a fixture database, a second list that a new such suite joins as well, and npm run test:unit is the rest, computed by scripts/ci/test-halves.mjs.

Engine-v2 scenario harness ​

src/lib/portfolio/v2/fixtures/ holds the deterministic fixture harness the portfolio engine rebuild is specified against, and src/lib/portfolio/v2/scenarios/ holds the cells that use it. It exists because two failure modes are already recorded against this repo's fixtures and both are invisible in a green suite: fixture rates that are perfectly exponential make windowing bugs impossible to see, and assertions against non-unique magnitudes pass without matching their own case.

The pieces, and the discipline each one enforces:

modulewhat it gives a test
allocator.tsevery principal, receipt, growth factor and price wedge, derived from one ordinal so any number in a failing assertion traces back to the case that owns it. Growth paths are irregular and non-monotone, with one flat interval and one negative one. ETH cases are scaled so they never collide with USD magnitudes.
grid.tssnapshot points and block-ranged intervals: a receipt belongs to (b0, b1] by its own block, never by wall clock. Carries a 24h interval, a 6h interval, a missed cron window and a page-load read at a non-aligned second.
rows.tsflow-ledger rows with every CHECK constraint from migration 082 mirrored in TypeScript, so a case cannot assert behaviour over a row the database would reject. The enumerations are cross-checked against the migration file itself. It also carries the identities the data contract states across a leg's rows rather than within one — the running-balance recurrence and the quantity self-audit, both with no tolerance — because a corpus that breaks one is a corpus no real ledger could hold.
reference.tsthe expected number, computed by a five-line reducer written on the test side. Expectations are never produced by calling the code under test.
withholds.tsthe assertion that a case books the right number for the right reason: the withheld magnitudes, the alarm list and their counts, not only the figure. A case that books correctly by suppressing an alarm is still a defect.
mutation.tsthe tolerances, and the rig that reverts the rule a case exists for and requires the number to move by more than ten times that tolerance. A case whose expectation survives its own mutation is not a spec.
matrix.tsthe registry of every case, each declaring whether its inputs are a tracked wallet's own history, a decoded public transaction with no tracked wallet in it, or constructed. That declaration is what tells a reader what a passing case actually proves.
history.tsgenerated histories for the property tests, each with positions that close and sit out an absence — most reopening at a different size, one in five closing for good, because a full close that never returns is the worst incident the matrix records and a population without one lets a property about it pass over an empty set.

scenarios/harness-worked-example.test.ts is the end-to-end demonstration: it reverts to the pre-fix wall-clock convention and shows the booked figure moving by the whole receipt per interval while the total over the whole history is unchanged, which is why that defect survived in a shipped engine.

scenarios/prod.ts is the counterpart for the cases whose inputs are a tracked wallet's own history: it turns transcribed snapshot rows and ledger rows into the engine's input shape, and it may not generate a magnitude. The two columns the retired ledger did not carry — the quantity a movement left behind, and whether it emptied the position — are supplied only where snapshot presence itself is the witness, and that rule is written at the top of the file so a reader can tell a transcription from an assumption.

The matrix, and what "complete" means ​

matrix.ts registers 103 cases across five blocks, and every one of them is now written. The registry and the cases check each other in both directions: a case marked written with no test naming it fails, and a test naming a case still marked unwritten fails too, so the registry cannot drift from what the suite actually covers.

blockcaseswhat it covers
A31every attribution class against every event, with both endpoints read and priced
B15the coverage conditions: read gaps, missing receipts, unpriced marks, uncertified scopes, ambiguous pairings, ghost rows
C38the eighteen named situations — rewards, costs, escrowed exits, credit losses, migrations, cross-wallet movement, router hops, exits into unlisted assets
D8the seizure algebra, plus two guards a number-only case would pass while broken
E6properties over generated histories rather than over one built case
anchors5the five verified production incidents, each asserted as a defective-versus-corrected pair

The five anchors are the ones worth knowing about: +36.87, +23.17, +51.76 and +57.14 of return booked across stretches of time the positions were not held at all, and a −0.113 ETH closing settlement reported as roughly +0.001. Every one is reproduced from the wallet's own stored rows by the formula that produced it, so the corrected figure is measured against the incident rather than against a description of it.

Block E's six properties are the ones a case-by-case suite cannot make. Three matter most: no interval ever spans an absence, with a provable zero in between; an exit and a re-entry whose amounts are identical on both sides still books zero, which is the case any size-based rule passes while getting it wrong; and a scan that fails if any of the deleted size thresholds reappears anywhere under src/. It cannot pass vacuously: it asserts it found sources to scan and that reverting the rule turns it red, and it reads code rather than prose, so naming a threshold in a comment does not trip it.

Golden portfolio harness ​

scripts/ops/golden-portfolio.ts freezes what the portfolio API serves over the fixture database into tests/golden/, one JSON file per wallet and surface, so a change that is meant to move no served number can show that it moved none. scripts/ops/golden-portfolio.test.ts is its gate, and it runs inside npm test.

What it captures. For every tracked wallet in scripts/fixture/seed.sql: the summary, the positions (live and at two past days), the history (every view, both grids), the activity statement (live and dated, with and without the held-back costs, plus one filtered read per position, venue and action the statement offers), the events ledger and the coverage notes. For every account that tracks more than one wallet: the ?wallet=all summary, positions, history and events. It calls the served builders directly, with the arguments their routes pass. /risk, /prices and /pt-exit-cost are live reads with nothing stored behind them and are not captured.

How it stays deterministic.

  • The clock. The process clock and the fixture's build clock are both pinned to 2026-07-01 00:00 UTC, and a served statement that reads the database's clock fails the run.
  • The network. Nothing leaves the process. The two on-chain reads the fixture's served GETs make have test seams: a block-pinned rate read answers "unreadable" and must not happen at all over the fixture, and the Fluid vault-to-pool read answers from pools the harness declares. Each seam's call count is pinned. Any other on-chain or network read (a PT's rate or redemption factor, a token's decimals(), a vendor price) has no seam: it reaches fetch or a socket, and fails the run. The guard is in place for the whole process, from before any served module loads: it is the harness's first import, and installs itself when the harness is the process's entry point.
  • The database. A served statement that writes, reads a volatile source, or fails (even when the served code catches the failure) fails the run. The fixture server must be Postgres 16 collating as C.
  • The query plan. The gate reads the fixture twice, the second time from a copy that has been ANALYZEd and has index scans and hash aggregation turned off, so a served order that depends on the plan fails the gate instead of flaking later.
  • The stored values. The goldens are read from the fixture's stored portfolio values, which scripts/fixture/generate-values.ts computes with the writers' own valuation. Beside its two reads the gate re-runs that generator with --check on a facts database of its own, so a writer change that moves a stored value fails the gate until seed-values.sql is regenerated, rather than leaving goldens that still match stale values.
bash
npm test                                                        # includes the gate
node --import tsx --test scripts/ops/golden-portfolio.test.ts   # the gate alone, about five minutes
npx tsx scripts/ops/golden-portfolio.ts --check                 # build, generate, diff; exit 1 on any difference
npx tsx scripts/ops/golden-portfolio.ts                         # regenerate tests/golden

The gate is bounded (ten minutes on CI, an hour elsewhere, GOLDEN_GATE_TIMEOUT_MS to set it), and kills every process it started when it runs out, so a hung child fails the gate by name rather than holding the run. Every golden fixture build (buildGoldenFixture, not a plain build.sh build) first drops the databases a killed run left behind on the shared fixture server: those named for a process (creddit_<family>_<pid>, and their _<suffix> copies) whose process no longer exists and that nobody is connected to. It goes by the name alone, so a hand-made clone takes a name under creddit_fixture_* (creddit_pr_960 would read as process 960's).

A PR that moves a served number regenerates tests/golden, and the golden diff states what moved. A PR that changes the fixture's inputs (the seed and its generated value file seed-values.sql, a migration, bootstrap.sql, build.sh) regenerates too: manifest.json records the hash of each, and if it is the only file that changes, no served number moved.

Another database (the ledger-first comparator). To snapshot another database, such as staging, pass --db-url with --wallets (the instant defaults to the capture hour and the days to the two full UTC days before it; --now and --asof override them) and --out: a capture always writes a tree of its own, never tests/golden, and into a directory that is new, empty or a previous capture's tree, because writing a tree deletes every JSON file there that the new tree does not hold. The sessions are read-only. The manifest records each wallet's newest reading and derive cursor, the ingested tip and the input marks: the price bars, share rates, fund states and PT factors, the token registry, the held funds' registry rows, and per wallet how many readings and ledger rows it has, with an md5 over each set (every column, in primary-key order, read in UTC), so a row rewritten in place shows. A second capture passes --pin-from with the first one's manifest.json: it reads at the same instant, days, wallets and surfaces, refuses when a wallet's newest reading moved (--override captures anyway and records it), and reports every mark that moved. It digests the rows over the columns the first capture digested, so a column added since is reported by name and moves no digest. --perturb-plan reads under the gate's planner settings, so a second capture that comes out byte-identical shows the served output does not depend on those plan choices.

Accounts + sign-in (SIWE) ​

Identity is Sign-In with Ethereum (SIWE, EIP-4361): the user signs a nonce-bound message with an injected wallet, the server verifies it, and issues a short HMAC-signed httpOnly session cookie carrying the lowercased wallet address. That wallet address IS the one creddit account (uid), and the same account gates every signed-in surface: the portfolio, and the assistant API still wired up behind its removed UI. The auth layer is deliberately account-neutral; feature config (chat model gating, budgets) lives next to each feature.

  • Neutral machinery (src/lib/auth/session.ts): issueNonce / consumeNonce (single-use, in-memory, 5-min TTL), verifySiwe (nonce + domain binding; a mainnet public client so ERC-1271 / ERC-6492 smart-wallet signatures verify too, EOAs verify offline), cookieValueFor / cookieOptions / expectedDomain, and verifySessionCookie (constant-time HMAC; the address is only ever read from this verified cookie, never from a request body). Server-only (node:crypto + viem); never imported from a client component.
  • Cookie: creddit_session. The read path also accepts the legacy creddit_chat cookie so sessions predating the rename stay authenticated; the write path only ever issues creddit_session. Secret: SESSION_SECRET, falling back to CHAT_SESSION_SECRET. SIWE domain pin: SIWE_DOMAIN (required in production), falling back to CHAT_SIWE_DOMAIN.
  • Host allowlist (SIWE domain binding). expectedDomain resolves the domain a SIWE message is verified against with a fixed precedence: the SIWE_DOMAIN / CHAT_SIWE_DOMAIN pin wins (operator override); if unset, the request Host is accepted only when it is in a built-in allowlist of known creddit hosts (creddit.xyz, staging.creddit.xyz, and localhost / 127.0.0.1 on the dev ports), matched case-insensitively; anything else resolves to null and verifySiwe fails closed. The request Host is no longer blindly trusted: the production nginx is a catch-all vhost that forwards whatever Host a client sends (proxy_set_header Host $host), so echoing it into the SIWE domain would let a phishing relay bind a victim's signature to an attacker origin and mint a session for the victim's address. Operators must still pin SIWE_DOMAIN in prod; the allowlist is a safety net, not the primary control. See the Origin & SIWE hardening runbook.
  • Routes (src/app/api/auth/): GET /nonce (mint a nonce), POST /verify (verify the signed message, set the cookie, upsert the account), GET /me ({ address } from the cookie, else 401), POST /signout (expire both cookie names). The injected-wallet client flow lives in the neutral auth layer (src/lib/auth/siwe-client.ts); the /portfolio sign-in prompt drives it through the shared AccountProvider context.
  • Account creation (src/lib/auth/accounts.ts, migration 042): a successful /verify upserts onchain_credit.accounts — insert on first sign-in (created_at is the portfolio "tracked since" anchor, created_block = eth_blockNumber at verify time, best-effort/nullable), bump last_seen_at thereafter. The upsert is best-effort: a DB or RPC failure is logged and never blocks issuing the session cookie.
  • Feature config stays with its feature. src/lib/agent/session.ts keeps only chat config (getChatConfig: enablement, model gating, daily token budgets) and imports the session secret from the neutral layer. Chat API routes authenticate with verifySessionCookie; there is no chat-specific session route.

AI assistant (Creddit Agent) — held back, no UI ​

An in-app AI research assistant ("Creddit Agent") that answers questions over creddit's live data via a read-only tool layer, streamed with the Vercel AI SDK and Claude. Its release is deferred: every UI surface has been removed and no route, nav entry or dock renders it. The server side is intact so it can be picked up later — restoring the interface is a revert of the removal commit, not a rebuild.

  • What was removed. The /agent route and its AgentClient (history rail + full-density answer), the /chat legacy redirect, the always-mounted AgentDock (minimized bar + expanded sheet, Cmd-K), the NavAgentLauncher tile in the sidebar and mobile drawer, the AgentProvider / ChatEngine hoisted-thread machinery, and the ChatMessage answer renderer — the whole of src/components/chat/ — plus the CSS those surfaces owned and the client-only dependencies they pulled (@ai-sdk/react, react-markdown, remark-gfm).
  • What stayed. The API (POST /api/chat stream, GET /api/chat/conversations[/id] history), the whole src/lib/agent/ module set, the prompt sources in prompts/, the conversation/usage tables, and the retention cron. Nothing in the app links to any of it.
  • Keeping it dark in a deployment. POST /api/chat gates on CHAT_ENABLED (and a present ANTHROPIC_API_KEY): with the flag unset it answers 503 before any model call. The two GET /api/chat/conversations* routes do not read the flag; they are session-gated (401 signed out) and only ever return the caller's own stored history, so they spend nothing. Leave the flag unset wherever the assistant is not meant to be reachable — with the UI gone it is the only thing standing between a hand-rolled POST and real token spend.
  • Agent module map (src/lib/agent/): session.ts (chat config only: enablement, model gating, daily token budgets; the SIWE + session-cookie machinery is the neutral src/lib/auth/session.ts), budget.ts (per-user + global daily token caps), carry-catalog.ts (assembles the same registry -> getCarryRow -> stats the /carries page does), tools.ts (the read-only tool set, a factory over the signed-in address), system-prompt.ts (assembles the cached prompt from three editable markdown sources: prompts/assistant-rules.md persona + house rules, prompts/assistant-metrics.md methodology KB (a condensed derivative of docs/metrics.md), prompts/assistant-tools.md tool guidance; regenerate the full readable snapshot with node --import tsx scripts/dump-assistant-context.ts -> prompts/assistant-context.md), persistence.ts (conversations/messages), profile.ts (stage-2 intake), wallet-positions.ts (live Aave v3 read for the signed-in wallet).
  • Tools wrap existing readers (carries-table, vault-capacity, vault-risk, oracles, basis, money-market-rates, curator-funds, assets-table, sofr, leveraged-position) so the assistant's numbers can never disagree with the pages. Every tool is strictly read-only; the only write is a user's own profile.
  • Grounding: the system prompt requires every number to come from a tool result, cited with its metric + as-of; the model never states a figure from memory. Not financial advice; no transaction construction or execution.
  • Data model + env + ops: see database and deployment §6.

Private documentation. creddit.xyz