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 theonchain_creditPostgres 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
| Layer | Technology | Notes |
|---|---|---|
| Framework | Next.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). |
| Language | TypeScript (strict) | npx tsc --noEmit must be clean. |
| Styling | Tailwind CSS v4 | @theme inline design tokens in src/app/globals.css; @tailwindcss/postcss. |
| UI primitives | Base UI (@base-ui/react), not Radix | Wrapped with project Tailwind classes in src/components/ui/*. |
| Charts | Recharts 3 | Used 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. |
| Database | PostgreSQL via pg | Single typed query() wrapper, app is read-only against the data. |
| On-chain | viem + raw eth_call | Read helpers in src/lib/data/rpc.ts / rpc-batch.ts. |
| Fonts | Geist 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:
- 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). - Those readers run SQL through
query()fromsrc/lib/data/postgres.ts— a thin typed wrapper over a sharedpgPool(maxPG_POOL_MAX, default 12; connection string fromDATABASE_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 onlySELECTprivilege on the data tables (writes are limited to the two email-capture API routes). - 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 Nextfetchcache (next: { revalidate }). - The page renders server HTML. Per-page ISR re-runs the readers on the
revalidateinterval; the prerendered page is otherwise served as-is.
ISR windows by page:
| Route | revalidate |
|---|---|
/ | 1800 (30 min) |
/repo-lending | 1800 (30 min) |
/carries | 3600 (60 min) |
/money-market-funds | 1800 (30 min) |
/multi-strategy-funds | 1800 (30 min) |
/asset-profiles | 3600 (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, defaulthttps://ethereum-rpc.publicnode.com.ETHEREUM_ARCHIVE_RPC_URL— historical/archive, defaulthttps://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
revalidatewindow. 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):
| Schedule | Job | Cadence |
|---|---|---|
0 */6 * * * | run-cron.sh refresh-assets.ts | every 6h |
15 */6 * * * | run-cron.sh refresh-vault-capacity.ts | every 6h |
30 */6 * * * | run-cron.sh refresh-collateral-exposure.ts | every 6h |
45 */6 * * * | run-cron.sh refresh-lending-positions.ts | every 6h |
50 */6 * * * | run-cron.sh refresh-portfolio.ts | every 6h (read-only portfolio spine + flow ledger) |
* * * * * | run-cron.sh drain-portfolio-backfills.ts | minutely (WS5 registration-backfill queue drain; no-op when empty) |
30 3 * * 1 | run-cron.sh refresh-vault-risk.ts | weekly, Mon 03:30 |
30 4 * * 1 | run-cron.sh sync-portfolio-tokens.ts | weekly, Mon 04:30 (portfolio token-registry sync, T6; PROPOSE + WS8 alert only, never mutates on the cron) |
0 5 * * 1 | run-cron.sh refresh-token-market.ts | weekly, 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-5 | run-cron.sh refresh-sofr.ts | weekdays 13:00 |
15 2 * * * | ops/backup-creddit.sh | daily DB backup, 02:15 |
| hourly | disk-usage alert | hourly |
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
| Route | Page | What it is |
|---|---|---|
/ | Home | Orientation: 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. |
/portfolio | Portfolio | Read-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-lending | Repo Lending | USDC / 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. |
/carries | Carry Trades | Cross-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-funds | Money Market Funds | Screener 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-funds | Multi-Strategy Funds | Performance 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-profiles | Asset Profiles | Screener 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 path | New 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.
/carriesis 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 width | Zoom |
|---|---|
| ≤767 | 0.85 |
| 768–1159 | 0.78 |
| 1160–1319 | 0.88 |
| 1320–1535 | 1.00 |
| 1536–1919 | 1.08 |
| ≥1920 | 1.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.
Nav hierarchy
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:
| Cell | Used for | Style |
|---|---|---|
ColId | Identity 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 ⓘ. |
ColMetric | Measured 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
sublines 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 inPortfolioDashboard.tsx, take the signed-in theme'sDIM, which is the same hex. Titles used to be#71767Band 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.#4A4F54survives on separators, arrows and a few resting chevrons, and on nothing interminal-table.tsx.#71767Bstays 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, theutilafter the utilization) move to#8B95A1with the headers: the spec sets those cells' inks explicitly and they are sub-lines of a metric, not asides. The APY column'sCARRY/MAX-LEVkickers 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. ChangingFG_DIMinterminal-table.tsxmoves every data table on the site; the /carries body inks are local toCarriesTable.tsx.Sorting. The active column is marked with an amber ▼ / ▲ caret and carries
aria-sort; inactive sortable columns show nothing (no placeholder·). Theamberprop marks the table's anchor column — the one whose cells render asHeadlineMetric— 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 thesubline 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. Thesubline 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 30dreading 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 (thezoomproperty 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.tsnames that state, and the two others accepted with it, by column, line AND width, and fails on anything else.ⓘ policy. Attach
infoonly 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
Utilizationholds two numbers. The header'ssub="curr / target"line says which is which; the cell renders current in#E7E9EAand/ targetin#71767B, in the same order. Both numbers share one span: as a direct flex child the target's leading space would collapse.
Gotcha.
ColIdre-appliesuppercaseto the sort<button>itself. The UA stylesheet setstext-transform: noneon<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.tsxlocks 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) viahistory.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.tsholds 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 onopenKey— and they raced.setOpencallshistory.replaceState, which re-rendersuseSearchParamsconsumers, 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. lgand up: 72px. The header pins totop: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 realDialogTitle, which is what gives the dialog its accessible name. - A body, the only thing that scrolls. It holds
overscroll-containas 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.
SimulatorCardsspacing andCommandFieldmetrics 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 thesvhbound exists to hold still.Fit to frame. Density is enough only where the ceiling governs. Below 800px of viewport height (720/0.9) the
svhshare 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.CarryRowModaltakes afitToFrameflag (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, nottransform: 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 belowsm, 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, butCarryRowModalrenders (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 reachsvhin 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 whilesvharrives, so a real window drag visibly steps. (3) The body'sResizeObserverschedules 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 unhandledResizeObserver loop completed with undelivered notificationson window (six per simulate/re-simulate cycle, measured), which no Playwrightconsoleorpageerrorhook 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
stalere-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-2with INPUTS spanning both rows means the height ismax(inputs, position + result), and the two sit within a few px of each other).position: sticky; bottom: 0on 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).
CarryRowModaltakes afooterInsetand turns it into the scroller'sscroll-padding-bottom; the measurement lives with the band, asSIMULATOR_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 blackbox-shadowabove it: invisible against the card at rest, a fade the moment there is something behind it.
Gotcha. Never put Tailwind's
relativeon the popup. It beats thefixedBase 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", neverperWidth.perWidthre-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.oncecosts one fetch and loses nothing:perWidthexists 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:
| Question | Resolver | Example |
|---|---|---|
| What coin is this? | components/icons/token-marks | sUSDe → the sUSDe coin |
| Who runs this fund? | components/icons/curator-marks | Sentora PYUSD USDC → Sentora's mark |
| Whose app is it on? | components/icons/ProtocolIcons | that same fund → Euler's mark |
| Who issues this token? | components/assets/IssuerIcon, via carries/AssetMark | sUSDe → 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_FalconXUSDCwears FalconX's coin, as Morpho draws it). - Every coin is a disc, no exceptions.
TokenMarkclips 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.
| Token | Value | Use |
|---|---|---|
--ease-out | cubic-bezier(0.23, 1, 0.32, 1) | Entrances and exits (tooltips, modals, dropdowns, row panels). |
--ease-in-out | cubic-bezier(0.77, 0, 0.175, 1) | Movement across the screen. |
--ease-drawer | cubic-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-styleentrance inglobals.css(.popup-enter) are transitions for this reason..expanded-panel-enteris the deliberate exception: it is a keyframe keyed offdetails[open], not@starting-style.@starting-stylefires 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, orwidth-driven fills: they cost layout and paint. - A popover scales from its trigger, on BOTH axes.
.popup-entersetstransform-origin: top, which is top centre, so it is only half the rule: a panel pinnedright: 0to 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-rightwherever the panel is right-anchored. Carried today by the portfolio's settings and wallets popovers. Known gap: the threeleft-0dropdowns inui/filter-controls.tsxstill 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-90andtranslate-y-pxcompile to the standalonescale,rotateandtranslateproperties, not totransform. An explicit transition list that names onlytransformtherefore fails to animate any of them, silently and with no build error. Name the real property (scale, rotate, translate), or use the plaintransitionutility, whose property list covers all three and still excludes the layout properties thattransition-allwould 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):scrollIntoViewtakes 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>setsisAnimationActive={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 firesmouseenteron tap and never fires the matchingmouseleave, so the highlight sticks. GateonMouseEnteronuseCanHover()(src/lib/use-can-hover.ts) and leaveonMouseLeaveungated. The 2026-07 motion pass gated the highest-traffic sites; ungatedonMouseEnterhandlers 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-cssships noprefers-reduced-motionhandling, so every moving surface carries an explicitmotion-reduce:override or a media query inglobals.css. A programmatic scroll needs the preference read in JS: abehaviorpassed toscrollIntoViewoverrides the CSSscroll-behaviorproperty, so a media query alone cannot quiet it. Route every programmatic scroll throughscrollToTop(src/lib/scroll.ts), which reads the query itself. Note its still branch passes"instant", not"auto": per CSSOM-View"auto"defers to the computedscroll-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
transitionoutranks every normal stylesheet declaration, so the plaintransition: nonein the shared reduced-motion block cannot reach it and the motion plays at full strength underprefers-reduced-motion: reduce. It is not unreachable: per CSS Cascade, an important author declaration beats a normal inline one, sotransition: none !importantdoes win (that is exactly whattransform: none !importantdoes for.popup-enterin the same block). Prefer a class anyway — it keeps the rule and its override in one place instead of scattering!importantthrough 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'stransform: 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!importantguard when next in those files.
API routes (src/app/api/)
All are dynamic = "force-dynamic" (no ISR).
| Route | Method | Purpose |
|---|---|---|
/api/carry-oracle?key=<strategyKey> | GET | Oracle 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> | GET | One 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> | GET | One 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> | GET | One 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-markets | GET | The 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-cost | POST | Real 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>] | GET | Signed 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-capacity | POST | Capacity-alert email capture → capacity_notifications (honeypot + partial-unique-index dedupe; the send pipeline is not wired yet, this only captures). |
/api/newsletter/subscribe | POST | Newsletter 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 | ~rows | Table | ~rows |
|---|---|---|---|
aave_v3_reserve_apy | ~12.3k | morpho_market_apy | ~11.1k |
assets | ~11 | newsletter_subscribers | ~2 |
capacity_notifications | ~1 | schema_migrations | — |
chain_scan_cursors | ~4 | sofr_rates | ~2.1k |
curator_vault_state | ~35 | sparklend_reserve_apy | ~8.2k |
fluid_dex_apy | ~55.2k | token_basis | ~36k |
fluid_ll_apy | ~54.4k | token_yield_apy | ~112k |
carry_registry | ~130 | vault_capacity | ~28 |
lending_borrowers | ~77.5k | vault_risk_params | ~21 |
lending_positions_current | ~2.1k | market_collateral_exposure | ~18.6k |
lending_reserves | ~85 | market_risk_current | ~6 |
pendle_markets | ~55 | pendle_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).
| Concern | Production | Staging |
|---|---|---|
| Repo | /opt/onchain-credit (tracks origin/main) | /opt/onchain-credit-staging |
| Process | pm2 onchain-credit on localhost:3001 | pm2 onchain-credit-staging on :3002 |
| Edge | nginx → https://creddit.xyz (Cloudflare in front) | nginx vhost https://staging.creddit.xyz (Let's Encrypt, HTTP basic-auth .htpasswd-staging, noindex) |
| Database | creddit (role onchain_credit, schema onchain_credit) | separate DB creddit_staging (role onchain_credit_staging) |
| Deploy | CI 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:
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
revalidatewindow.
Known gaps
- Branch protection is advisory, not enforced: a free-plan private repo, so GitHub cannot require PR-only
mainor green checks.ci.ymland thepre-pushguard report but cannot block; the fix is GitHub Pro (see Deployment §2). - Prod migrations stay a gated manual step (
migrate.shby hand after a release); staging auto-applies additive migrations on deploy. - Deploys rebuild in place, over the previous build's output.
deploy.ymlrunsnpm ci && npm run buildon the box with no.nextcleanup and restartspm2only afterwards, so the old process serves while.next/staticis being replaced underneath it. Rebuilding into a dirty.nextwas 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 stalenext/fontmodules. 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.nextbefore building (and, separately,deploymentIdfor 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
| Dependency | Used for | Where |
|---|---|---|
Ethereum RPC (ETHEREUM_RPC_URL) | current on-chain state | src/lib/data/rpc.ts |
Ethereum archive RPC (ETHEREUM_ARCHIVE_RPC_URL) | historical / archive reads | src/lib/data/rpc.ts |
| DefiLlama Coins API | USD prices, block-by-timestamp, basis market price | llama-prices.ts, rpc.ts |
| NY Fed | SOFR rates | refreshers/sofr-rates.ts |
| Dune API | analytics (scarce credits) | refreshers / backfills |
| Morpho Blue GraphQL API | Morpho market + curator data | refreshers/morpho.ts, morpho-markets.ts |
| Euler Goldsky subgraph | curator/market data | refreshers/curator-vault-state.ts |
| KyberSwap aggregator | live swap-cost quotes | api/sim/swap-cost |
Running locally
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 sitenpm 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:
| module | what it gives a test |
|---|---|
allocator.ts | every 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.ts | snapshot 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.ts | flow-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.ts | the expected number, computed by a five-line reducer written on the test side. Expectations are never produced by calling the code under test. |
withholds.ts | the 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.ts | the 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.ts | the 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.ts | generated 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.
| block | cases | what it covers |
|---|---|---|
| A | 31 | every attribution class against every event, with both endpoints read and priced |
| B | 15 | the coverage conditions: read gaps, missing receipts, unpriced marks, uncertified scopes, ambiguous pairings, ghost rows |
| C | 38 | the eighteen named situations — rewards, costs, escrowed exits, credit losses, migrations, cross-wallet movement, router hops, exits into unlisted assets |
| D | 8 | the seizure algebra, plus two guards a number-only case would pass while broken |
| E | 6 | properties over generated histories rather than over one built case |
| anchors | 5 | the 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 reachesfetchor 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.tscomputes with the writers' own valuation. Beside its two reads the gate re-runs that generator with--checkon a facts database of its own, so a writer change that moves a stored value fails the gate untilseed-values.sqlis regenerated, rather than leaving goldens that still match stale values.
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/goldenThe 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, andverifySessionCookie(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 legacycreddit_chatcookie so sessions predating the rename stay authenticated; the write path only ever issuescreddit_session. Secret:SESSION_SECRET, falling back toCHAT_SESSION_SECRET. SIWE domain pin:SIWE_DOMAIN(required in production), falling back toCHAT_SIWE_DOMAIN. - Host allowlist (SIWE domain binding).
expectedDomainresolves the domain a SIWE message is verified against with a fixed precedence: theSIWE_DOMAIN/CHAT_SIWE_DOMAINpin wins (operator override); if unset, the requestHostis accepted only when it is in a built-in allowlist of known creddit hosts (creddit.xyz,staging.creddit.xyz, andlocalhost/127.0.0.1on the dev ports), matched case-insensitively; anything else resolves to null andverifySiwefails closed. The request Host is no longer blindly trusted: the production nginx is a catch-all vhost that forwards whateverHosta 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 pinSIWE_DOMAINin 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/portfoliosign-in prompt drives it through the sharedAccountProvidercontext. - Account creation (
src/lib/auth/accounts.ts, migration042): a successful/verifyupsertsonchain_credit.accounts— insert on first sign-in (created_atis the portfolio "tracked since" anchor,created_block=eth_blockNumberat verify time, best-effort/nullable), bumplast_seen_atthereafter. 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.tskeeps only chat config (getChatConfig: enablement, model gating, daily token budgets) and imports the session secret from the neutral layer. Chat API routes authenticate withverifySessionCookie; 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
/agentroute and itsAgentClient(history rail + full-density answer), the/chatlegacy redirect, the always-mountedAgentDock(minimized bar + expanded sheet,Cmd-K), theNavAgentLaunchertile in the sidebar and mobile drawer, theAgentProvider/ChatEnginehoisted-thread machinery, and theChatMessageanswer renderer — the whole ofsrc/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/chatstream,GET /api/chat/conversations[/id]history), the wholesrc/lib/agent/module set, the prompt sources inprompts/, 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/chatgates onCHAT_ENABLED(and a presentANTHROPIC_API_KEY): with the flag unset it answers 503 before any model call. The twoGET /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 neutralsrc/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.mdpersona + house rules,prompts/assistant-metrics.mdmethodology KB (a condensed derivative ofdocs/metrics.md),prompts/assistant-tools.mdtool guidance; regenerate the full readable snapshot withnode --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.