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 | Wrapped in shadcn-style ChartContainer / ChartTooltip; 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, the agent dock) 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) |
/multi-strategy-funds | 1800 (30 min) |
/asset-profiles | 3600 (60 min) |
Route loading states: the four data routes above (/repo-lending, /carries, /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 and /agent are instant static shells (their data loads client-side, post-hydration) and intentionally have 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 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 flow scans 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. Phase B makes the 6h cron CONSUME the ledger behind PORTFOLIO_LEDGER_MODE (off default = today's own scans; shadow = legacy authoritative + an in-memory ledger diff into portfolio_parity_runs; on = ledger-derived flows + dirty re-read/recomposition), flipped after ≥3 zero-diff shadow days and rolled back by the flag. 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). |
/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 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, Sharpe, 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. |
/multi-strategy-funds | Multi-Strategy Funds | Performance tracker for ETH/USD yield funds whose manager runs a broad mandate (share-rate history, TVL, vs-SOFR / vs-wstETH comparison). |
/asset-profiles | Asset Profiles | Screener of yield-bearing collateral: trailing APY, 1M/YTD/1Y returns, per-asset yield-mechanism narrative. |
The four 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. 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).
Nav hierarchy
The left nav (AppSidebar, and its mobile-drawer twin MobileNav, kept in sync via the shared AccountCard + NavSections) leads with an account card (design handoff W2, AccountCard) at the top of the rail that pairs Portfolio with the wallet control. Because Portfolio requires a connected wallet, the two rows swap emphasis: signed out, the wallet is the amber Connect wallet call-to-action and Portfolio is subdued (amber-outline tile, DIM label); signed in, Portfolio lights up as the primary button (filled amber tile, a 2.5px amber bar pinned to the card's left edge) and the wallet drops to a quiet identity line — an amber avatar glyph + truncated address (0x84c2…3f9b) with a trailing sign-out icon (the single disconnect affordance; addendum spec). Clicking the address copies it (brief inline "Copied" feedback). Below the card, all four public research pages (Repo Lending, Carry Trades, 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 254px wide (the content column's left padding in layout.tsx and the .agent-dock left offset in globals.css mirror it). 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. The CredditAI launcher (study G1, a rounded-robot tile — amber mark chip · CredditAI wordmark · Ask chip) sits at the foot of the nav, above a support line + Join the Telegram button (no status row).
The account card reads the shared SIWE session from AccountProvider (a client context mounted in layout.tsx, one /api/auth/me probe) so it stays in sync with the /portfolio sign-in prompt; signing in or out from either updates both. Brand chrome is unchanged (uppercase mono, 1px borders, no gradients); the amber accent is reserved for the account card's active/CTA state and the Explore active row.
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. 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. 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 momentum sign control (Both / Positive / Negative on the carry-trend sign), a searchable multi-select of collateral assets (grouped USD / ETH / BTC, all selected by default), and typed Carry APY ≥ / Max-lev APY ≥ minimums, 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, 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 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 six (Fluid, Mellow, YO, ether.fi, Treehouse, Yearn); 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 a Min TVL field, 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. 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: size leads, and the amber APY anchor holds the right edge under the switch that labels it. Every metric column sorts, APY by whichever window is selected.
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 these funds can legitimately read 0.00%. They 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) — so a 24h or 7d window can span no price update at all. That is a truthful reading of the realised index over the window, not a claim the fund stopped accruing, and the column tooltip says so; the 30-day window is the steadier read. 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, #71767B. 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, #71767B. Optional trailing ⓘ and a faint uppercase sub line. |
Supporting rules:
- Sorting. The active column leads 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. - ⓘ 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 four screeners (Repo lending, Carry trades, 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,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.
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 set scrollMarginTop: ROW_SCROLL_MARGIN (72px, exported by the hook). That 72px is load-bearing on every breakpoint and is not merely the 52px mobile nav: CarriesTable stacks its own ~64px caption + column-header strip above its rows, which pins to top:0 on desktop and leaves the margin only ~8px of slack. Lowering it hides Carries rows under their own column headers.
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. coinsOf()splits a Fluid smart-collateral pair (wstETH/ETH) into legs.
Adding an asset. Prefer the official CoinGecko token image (verbatim, downscaled to 64px). Fall back to web3icons (0xa3k5/web3icons) only when CoinGecko's asset is unusable on this UI, and record the reason next to the entry — the current exceptions are LINK (square brand tile), PAXG (matted on white), XAUt (non-square, so it squashed), PYUSD (CoinGecko's asset is grayscale), crvUSD (matted on a green square), FRAX (the address-keyed art is either a raster disc or the newer "Frax USD legacy" wordmark rather than the brand mark) and GUSD (the same mark, as a vector). 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 falls back to the monogram or a map entry points at a file that isn't in public/; 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, the agent dock sheet. |
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 two@starting-styleentrances inglobals.css(.popup-enter,.dock-sheet-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.ui/progress.tsxtherefore has no transition (Base UI sets its fill as an inlinewidth). - 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, StrategiesTable compare button, filter-controls, CuratorFundsTable, PortfolioClient, CollapsibleSection, 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-enter/.dock-sheet-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: three inline transform transitions are still unguarded and do ignore the preference (home/FeatureSections.tsx,carries/StrategyActions.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/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. |
/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, carry-oracle 30, carry-history 30, 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 60s limiter in src/lib/portfolio/live.ts. 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
multi-strategy-funds/page.tsx # Multi-Strategy Funds
asset-profiles/page.tsx # Asset Profiles
{repo-lending,carries,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, OracleModal, capacity charts
home/ # HomeIndex, FeatureSections
strategies/ # (serves /multi-strategy-funds) StrategiesTable, StrategyChart, ComparisonChart, StrategyFacts
money-market/ # (serves /repo-lending) MoneyMarketTable, MoneyMarketRatesChart, UnderwrittenCapital, CuratorFundsTable
icons/ # ProtocolIcons (venue marks), TokenIcons,
# token-marks (THE token-coin registry),
# curator-marks (THE fund-manager registry)
layout/ # AppSidebar, MobileNav, AccountCard, NavSections, TerminalSectionHeader
ui/ # Base UI wrappers (Button, Card, 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 money-market curator-fund registry
curator-vault-narratives.ts# per-curator / per-vault copy
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.env.local and node_modules are gitignored, so reset --hard preserves secrets and deps. On build failure it rolls back to the previous commit and rebuilds.
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.
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 lib/data + component test files
cd docs && npm install && npm run dev # this docs siteAccounts + 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 both the portfolio and the AI assistant. 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);src/components/chat/siwe.tsre-exports it so chat imports are unchanged. The connect chrome and/portfoliosign-in prompt drive that same flow 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, /agent)
An in-app AI research assistant ("Creddit Agent"): a chat surface that answers questions over creddit's live data via a read-only tool layer, streamed with the Vercel AI SDK and Claude.
- Surfaces (one thread, everywhere): the assistant is a persistent docked surface plus a full route, all driven by a single hoisted
useChatinChatEngine(owned byAgentProvider, mounted once inlayout.tsx, wrapping.app-shell, so the live stream survives every surface transition).ChatEngineis the only client module importing the Vercel AI SDK and is loaded withnext/dynamic({ ssr: false }): the SDK was the single largest chunk in every route's first-load JS, so the provider (context, dock state machine, sign-in gate) stays static while the SDK chunk streams in right after hydration. Until it lands the thread reads as empty/idle and sends are stashed in the provider's pending QUEUE (a queue, not a slot, so a rapid second send in the gap is preserved in order, never overwritten), replayed only AFTER history hydration settles (else the replayed turn would makemessagesnon-empty and the restore would skip, dropping the prior conversation) and once the engine and session are both ready — the same stash that already covered "send before sign-in". Two failure guards: the dynamic import.catches a failed chunk load to an inert null-component (a rejected import with no error boundary would otherwise unmount the whole provider — the dock would vanish), and a 20s watchdog surfaces a refresh prompt if the engine never reports, so a dead chunk degrades to a visible error instead of a silently inert dock.- Nav launcher (
NavAgentLauncher, studyG1— a rounded-robot tile in the left nav): the canonical "deep door", opens the full/agentroute with the composer focused. One clean line — amber robot-mark chip (the sharedAgentMark, G1 Rounded) · CredditAI wordmark · Ask chip — with rest / hover / active (amber border + ring when the route or dock sheet is open) states. Reused byAppSidebarandMobileNav. - Dock (
AgentDock, rendered as a DOM sibling AFTER.app-shellso its fixed positioning escapes the shell's CSSzoom): aminimizedbar and anexpandedsheet (paneldensity, the compact answer) over the current page.Cmd-Kopens/focuses it from anywhere;Esccollapses it. The sheet's Full-view (⤢) control hands the current thread + read position to/agent. Because the dock is mounted inlayout.tsxon every route, itsChatMessagerenderer (which pulls the markdown pipeline plus recharts for tool cards) isnext/dynamic({ ssr: false }), so that chunk loads on first open instead of riding in every page's first-load JS. - Full route
/agent(AgentClient): a ~196px history rail (the user's own conversations) beside thefull-density answer (full table columns, oracleTerminalTabs, taller chart). Switching threads from the rail re-keys the one hoisteduseChat(new id + seeded messages) rather than spinning a second instance, so continuing an old thread appends to it and "+ New thread" creates a fresh conversation row. Empty state = suggested prompts; a runninglist_carriesrenders skeleton rows in the target table; a failed turn shows an inline destructiveBadgewith a non-blocking Retry (regenerate). /chatis the legacy URL and 308-redirects to/agent(query preserved).
- Nav launcher (
- Route + API:
/agent(public shell; sending a message requires wallet sign-in). API:POST /api/chat(stream),GET /api/chat/conversations[/id](history). Sign-in itself goes through the neutral/api/auth/*routes (see Accounts + sign-in), and the chat routes authenticate withverifySessionCookie. The dock's send-gate does not keep its own sign-in store:AgentProviderreads the sharedAccountProvider(useAccount) for signed-in status, so a sign-in or sign-out on any surface (dock, connect chrome, or/portfolio) is reflected everywhere with no reload. It layers only the dock-specific replay of a message the user tried to send before signing in. - 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.