Skip to content

External dependencies ​

Everything the app and its refreshers reach outside the box: third-party HTTP services, the on-chain RPC endpoints, and the npm runtime. For where these are called from in the pipeline see Data pipeline; for the table shapes they write into see Database & schema; for the server/deploy mechanics see Deployment.

Three ground rules that hold across every dependency here:

  • The app reads only its own Postgres at request time. Off-chain services are touched by the refreshers (scripts/refreshers/, run via cron) and by one-shot backfills, not on the request path. The exceptions are the on-chain RPC (used by per-asset adapters during ISR revalidation), DefiLlama current-prices (used by the carry simulator API, and as the backup vendor behind every live price), CoinGecko (the live price of every market-priced asset, on a signed-in page load and on Synchronize), the KyberSwap aggregator (the simulator's swap-cost quote route), and Google Fonts (fetched by the OG-image route). Everything else is snapshotted into Postgres ahead of time.
  • No external read failure is allowed to write a wrong value. Refreshers retry with backoff, then either skip the row (leaving the prior snapshot standing) or write null. A failed read is never persisted as 0.
  • Every outbound HTTP call on the request path carries an explicit timeout (AbortSignal.timeout). A wedged upstream socket must not hang a bounded API route or an ISR page render, nor keep a live portfolio refresh running past its route deadline while holding a DB client. Budgets: on-chain reads 15s (archive-block eth_call 20s), the Kyber and Pendle swap quotes 10s per attempt, the simulator's Llama current-price read 10s, the live CoinGecko price read 10s, the yoETH Base-chain TVL read 15s; the batched RPC helper the refreshers share (rpc-batch.ts) uses 60s. The live price asks CoinGecko and DefiLlama in parallel, so the pair adds at most one 10s budget rather than two — which still has to fit inside the 30s a per-wallet live refresh gets while it is also doing RPC work, and does so by degrading: a vendor that times out is an absent entry and the stored bar stands. A timeout rejects the fetch the same way a non-2xx does, so the existing handler degrades it (skip / null / retry), never a wrong value.

Service inventory ​

ServiceUsed forEndpoint (default)AuthCalled from
publicnode RPCCurrent-state eth_call (ERC-20 / ERC-4626 reads); the event ingester's native-ether block feed, at most two calls per block (below)https://ethereum-rpc.publicnode.com (ETHEREUM_RPC_URL)nonesrc/lib/data/rpc.ts → per-asset adapters; api/sim/swap-cost (request time: SY getTokensOut + decimals, cached per process, for the PT redeem-and-swap exit); src/lib/portfolio/native-feed.ts → the ingester
dRPC archive RPCHistorical eth_call / eth_getStorageAt / eth_getBlockByNumber at past blocks; the reading audit's eth_getLogs explanation of a break, and a native-ether break's transfer listing (alchemy_getAssetTransfers, an Alchemy method: production's endpoint) and receipts (below)https://eth.drpc.org (ETHEREUM_ARCHIVE_RPC_URL)nonesrc/lib/data/rpc.ts → refreshers + backfills; src/lib/portfolio/adapters/log-fetcher.ts and src/lib/portfolio/audit/production.ts → the ledger worker's audit jobs; scripts/ops/backfill-native-eth.ts
DefiLlama Coins APIUSD prices (current + historical), token decimals, block-by-timestamp. Not a mark source for the portfolio — portfolio + token_basis MARKET marks come from the Dune mirror (Milestone C); the other historical-price uses are Fluid DEX fee/TVL and the vintage marks on backfilled collateral-exposure rowshttps://coins.llama.finoneprices.ts (Fluid DEX fee/TVL), llama-prices.ts (simulator current prices, the USD marks the exposure writers publish, and the vintage marks a Morpho exposure backfill publishes), rpc.ts (block-by-timestamp), the DEPRECATED backfill-token-basis.ts
CoinGeckoThe LIVE price of every market-priced asset (page load + Synchronize), the hourly history that fills a hole the tape never printed, and the weekly REPORTED daily volume the pricing-category test's volume bar is judged onhttps://api.coingecko.com/api/v3COINGECKO_API_KEY (Demo key, header x-cg-demo-api-key; keyless fallback works at a much lower rate limit)src/lib/data/coingecko.ts → live-price.ts (the valuation path and the 6h check), history-fill.ts (the six-hourly hole fill) and the weekly refresh-token-market.ts
DefiLlama Stablecoins APITop-20 mainnet stablecoins by Ethereum circulating (ranking) + per-stablecoin mainnet address (--approve only)https://stablecoins.llama.finonescripts/sync-portfolio-tokens.ts (weekly cron in PROPOSE mode + the manual --approve apply)
NY Fed reference ratesSOFR daily rate + compounded averages + SOFR Indexhttps://markets.newyorkfed.org/api/rates/securednonescripts/refreshers/sofr-rates.ts
Morpho Blue GraphQLMorpho market posted-collateral USD; curator-vault fee / netApy / allocation; money market fund discovery (BOTH vault generations) + the pending-call, holder, warning and per-market bad-debt lookups the fund refresher useshttps://blue-api.morpho.org/graphqlnonescripts/refreshers/morpho.ts, curator-vault-state.ts, morpho-discovery.ts, sync-money-market-funds.ts, refreshers/money-market-funds.ts (the last two through the shared scripts/morpho-gql.ts)
Euler (Goldsky subgraph)Euler curator-vault interestFee + accepted-collateral sethttps://api.goldsky.com/api/public/project_cm4iagnemt1wp01xn4gh1agft/subgraphs/euler-v2-mainnet/latest/gnnonescripts/refreshers/curator-vault-state.ts
KyberSwap aggregatorRound-trip swap quotes behind the simulator's execution-cost card (non-PT legs)https://aggregator-api.kyberswap.com/ethereum/api/v1/routesnone (x-client-id header)src/app/api/sim/swap-cost/route.ts (request time)
Fluid public APICarry-registry vault candidates; token symbols, decimals and prices for the Fluid exposure slices (not their vault universe)https://api.fluid.instadapp.io/v2/1/vaultsnonescripts/fluid-discovery.ts, scripts/refreshers/collateral-exposure.ts
vaults.fyiCurator-fund vault discovery only (registry regen)https://api.vaults.fyi/v2VAULTS_FYI_API_KEYscripts/sync-curator-vaults.ts (manual tooling, never the app or a cron)
Google FontsTTF fetch for the OG-image renderer (Satori)https://fonts.googleapis.com/css2nonesrc/app/opengraph-image.tsx (request time)
Pendle hosted APIActive-market registry (new-market discovery, on-chain verified before insert), the expired-market catalogue that lets the registry catch up on a market which matured before this deployment's first sync (same verification, narrowed to PTs the portfolio has recorded), liquidity_usd display aid, per-market daily history backfill (underlying_apy, plus the redemption-index factor read on chain) — run by hand for the backlog and automatically by the refresher for a market it has just inserted, capped at two markets a tick, and the simulator's PT-leg swap quotes (v2 SDK /swap — Kyber has no real PT route)https://api-v2.pendle.finance/core (v1 active-list, v1 paginated markets?is_expired=true — server-capped at limit=100, so it is read page by page — and v2 history/SDK)nonescripts/refreshers/pendle-markets.ts, scripts/backfill-pendle-history.ts, src/app/api/sim/swap-cost/route.ts (request time)
Dune AnalyticsThe token_price_bars mirror: hourly USD bars (prices.hour) as the historical market-price source for portfolio marks + token_basis, plus a per-token dex.trades ratio feed for assets the aggregated channel quotes too coarsely (sUSDe); plus ad-hoc analytics / reconciliationhttps://api.dune.com/api/v1 (DUNE_API_KEY, DUNE_QUERY_ID_HOURLY, DUNE_QUERY_ID_SUSDE)API key (scarce credits)src/lib/data/dune.ts → the 6h token-basis refresher (incremental sync) + scripts/backfill-token-price-bars.ts (bulk load) + fetch-through on a read miss
GeckoTerminal / CoinGecko /onchainPool reserves for the pricing categories' market limb: every pool holding a tracked token, from which the weekly job takes the deepest one whose other side is an asset of the token's own book or a recognised counter of that book, skipping any side that is the pool's own share tokenhttps://api.coingecko.com/api/v3/onchain with a key, else https://api.geckoterminal.com/api/v2COINGECKO_API_KEY (optional; the keyless door works and is seven times slower)src/lib/data/dex-market.ts → the weekly refresh-token-market.ts
CloudflareDNS + CDN + TLS in front of creddit.xyz (and Cloudflare Pages + Access for docs.creddit.xyz)——Infrastructure (no app code)

All defaults are public, keyless endpoints, so the app boots with only DATABASE_URL set. The two RPC env vars only need setting to override the public defaults (e.g. to a paid endpoint with higher limits). vaults.fyi is manual registry tooling — neither the app nor any cron calls it. The pool endpoint takes a CoinGecko key where one is set and falls back to the keyless door where it is not; the fallback is a working configuration, seven times slower, and the run says which door it used.


Ethereum RPC ​

Defined in src/lib/data/rpc.ts — a zero-dependency raw JSON-RPC helper, split into two endpoints by access pattern:

  • ETHEREUM_RPC_URL (default publicnode) — current state only, low latency. Used by ethCall / ethCallRaw against the latest block, which back the per-asset on-chain adapters during page ISR revalidation.
  • ETHEREUM_ARCHIVE_RPC_URL (default dRPC) — historical / archive. Used by ethCallAt, ethCallAtRaw, ethGetStorageAt, and getBlockTimestamp, which read state at a specific historical block. Also where block-by-timestamp resolution VERIFIES its answer: the block at or after the second, and the block below it, are both read here rather than taken on a third party's word. What it is not is the ledger's chain-identity endpoint — a stored block hash is a claim about which chain served the logs beside it, so the live scans read their headers from ETHEREUM_RPC_URL (which served those logs) and the reorg sweep verifies against that same endpoint. The archive endpoint answers the transfers catch-up's interpolation timestamps, where no hash is stored or compared. This is the endpoint the refreshers and backfills hit, since the canonical APY (annualizeRatio in src/lib/data/apy.ts) needs the compounding index at two past blocks.

ethGetStorageAt reads packed storage slots directly (e.g. Fluid DEX dexVariables2, which has no clean resolver getter).

Failure behaviour. Every helper throws on a non-2xx response (RPC <status>), a JSON-RPC error, or an empty result. The caller (an adapter or refresher) catches and skips that asset/snapshot; it never writes a partial or zeroed row.

Timeouts. Every fetch here carries an explicit per-request timeout so a wedged socket cannot hang an ISR render or a bounded route: 15s for the latest eth_call, a storage slot, a block number / timestamp, and the Llama block-by-timestamp lookup; 20s for an archive-block eth_call (ethCallAt), which executes contract code at a historical block. A timeout rejects the fetch just like a non-2xx, so the same catch skips the read (and the block-by-timestamp ladder counts it as one transient attempt and retries).

publicnode getLogs size limit. publicnode rejects oversized getLogs ranges with HTTP 400, which is the signal used in the position-attribution scans (lending-positions) to back off / narrow the block window. Treat an HTTP 400 from publicnode on a log query as "range too large," not as a hard error. (Note: rpc.ts itself only does eth_call-class methods; the log scanning lives in the lending-positions pipeline.)

The reading audit's explanation: eth_getLogs on the archive endpoint. When a stored reading disagrees with the movement ledger (a break), the audit job the ledger worker runs asks the chain what moved before it books any correction (the explanation ladder). It asks ETHEREUM_ARCHIVE_RPC_URL (src/lib/portfolio/adapters/log-fetcher.ts), because a break's range starts where the ledger and a reading last agreed, anywhere inside the history window, and the live default refuses wide archive log queries. Its budget:

  • Nothing on a clean audit. A reading that agrees with the ledger asks nothing, and neither does an escrow claim or a native-ether leg (there is no log to ask for; its break asks the provider's transfer listing instead, below). The steady state is zero requests.
  • One to five questions per break, by venue (a wallet token 2, Aave or SparkLend 4 or 5, Morpho Blue 2, Fluid 3, an ERC-4626 vault 3, a Pendle PT 2 or 3: the table), all asked in one round.
  • One request per 50,000 blocks of the break's range, per question, halving a window the provider refuses (down to 500 blocks) and growing it back after. A 6h reading interval (~1,800 blocks) is one request per question; a range as wide as the 30-day history window (~216,000 blocks) is about five.
  • A found log no stream holds yet is landed, at one eth_getBlockByNumber per distinct block whose hash or time the log did not carry.
  • A failed request fails the explanation, and never reads as "nothing moved": the chunker retries a transient error at the same span and then throws, and the audit job retries under its three-attempt budget (the ingester alarm's derive-lag arm pages a job that stays failed).

So a break costs its leg's questions times the windows its range spans, a few requests at most. The case that multiplies it is a movement no decoder books (a new event shape on a tracked venue): every holding it touched breaks at its next reading, and each break asks once.

The native-ether block feed: at most two calls per BLOCK, never per wallet (issue #966; how). Native ether emits no log, so the event ingester reads its movements from the blocks themselves, on the live endpoint (ETHEREUM_RPC_URL): for every block it scans, eth_getBlockByNumber(block, true), and the receipts of the transactions a tracked wallet needs, asked for by what they cost (none needed: no call; one: eth_getTransactionReceipt; two or more: eth_getBlockReceipts(block), one call for all), filtered in memory against the tracked wallets. At about 7,200 blocks a day that is at most about 14,400 calls a day (10 a minute), whatever the number of tracked wallets: 5 or 50,000 cost the same, which a unit test holds (native-feed.test.ts, a stub provider counting calls per cycle), and a block none of the tracked wallets took part in costs one call. Against Alchemy's published compute-unit table (checked 2026-09-28: 20 CU for each of the three methods) that is at most 40 CU a block, about 288,000 CU a day and 8.6 million a month, under a third of the free tier's 30 million and about $4.50 a month at the pay-as-you-go rate ($0.525 per million). With a small population most blocks cost the body alone, about half that.

Throughput, which is billed apart from compute units. A plan caps compute units per SECOND, and Alchemy counts eth_getBlockReceipts toward that cap as 500 throughput CU, not 20 (the other two methods count 20): the free tier caps at 300 to 500 CU/s (its documentation and its pricing page state different figures, over a rolling window of about ten seconds), pay-as-you-go at 10,000. An answer over the cap is an HTTP 429, which rpcRequest retries twice (after 1 s and 4 s) and then fails. So the feed's reads spend a stated throughput budget, a token bucket of throughput CU per second held for the life of the process (INGEST_NATIVE_CU_PER_SEC, 2,000 by default: a fifth of pay-as-you-go's cap, the rest left to the other log streams and processes on the same key). At the tip it never binds (five blocks a minute, at most 520 throughput CU each, 2,600 a minute); a 64-block catch-up (33,280 at most) spreads over some sixteen seconds instead of a burst. The one-time history run (scripts/ops/backfill-native-eth.ts, a release step) reads the release window's blocks the same way on the archive endpoint, with its own budget (--cu-per-sec, 3,000 by default): about 216,000 blocks for 30 days, at most 432,000 calls and 8.6 million CU once, and at most 112 million throughput CU, which at 3,000 CU/s is about ten hours when every block needs its block receipts (a population of 50,000) and well under one hour for today's few dozen wallets (most blocks then need no receipt at all). The defaults are sized for pay-as-you-go, the plan production needs anyway: the ingester's thirteen log streams alone ask at least thirteen eth_getLogs a minute (one per stream per cycle, more for a stream with several filters) at 60 CU each, over 33 million CU a month, past the free tier's 30 million. On a lower plan set the two budgets so that, together with the ingester's other calls while both run, they stay under that plan's cap (on a 300 CU/s cap, for instance, 150 for the live feed and 100 for the history run); 0 turns a pacer off. Concurrency (INGEST_NATIVE_CONCURRENCY, --concurrency, 4) then only keeps enough calls in flight to use the budget. An endpoint that refuses eth_getBlockReceipts (the method, not a transient error) makes the process read every block's needed receipts one by one (20 CU and 20 throughput CU each): its cost follows the tracked wallets' activity, still not their number. Measure it on staging before the release (the release entry): the ingester's [ingester/native-eth] line states each step's calls, CU and throughput CU.

The native-ether explanation: the provider's transfer listing, per break. Ether a contract pays a wallet inside a transaction (a swap into ether, a withdrawal paid out in ether) appears in no block without traces, and traces per block are the one cost that does not scale. So the reading audit asks for it only where a reading disagrees with the ledger on the ether leg: ONE alchemy_getAssetTransfers per side of the break (the wallet as sender, as receiver; categories external and internal; paged at 1,000 transfers, at most 20 pages), over the break's own range, on the archive endpoint (ETHEREUM_ARCHIVE_RPC_URL, Alchemy in production). About 240 CU a break (120 CU a listing). For the part of a range the feed never read (history before a wallet was followed) it also reads one receipt per transaction the listing names (the earliest 500; the rest are named as a missing source), and for an internal transfer's block with no stored header, that block's header (20 CU). It is the one provider-specific call in the design: an endpoint without it answers "method not found", which the explanation reads as an unavailable source (the residue is then the accepted native-eth-transfer cause and never pages for the outage alone), never as "nothing moved". Once the feed follows a wallet, its cost follows the breaks, never the population: a wallet whose readings agree asks nothing.

The one term that follows enrolments: a new wallet's history before the feed followed it (PR #967 review round 2, S1). The feed reads a wallet from the block it enrolled at, so the 30 days of history the registration replay reads before that have no feed rows, and the replay's audit compares every six-hourly reading of that window (about 120): each one whose ether moved (any gas paid, any transfer) is a native-ether break, explained with the two listings over its own six hours (240 CU), a receipt per transaction the listing names there (20 CU each) and a header per block of a listed internal transfer (20 CU). About 260 CU for a window with one transaction, about 360 for one with five, so, once per wallet, at enrolment:

a new wallet's ether in its 30-day windowlistingsabout
never moved (a Safe holding only tokens)none0 CU
moved in a few windows a week (a typical holder: 10 to 15 windows)20 to 303,000 to 4,000 CU
moved in every window (a trader: 120 windows, 5 transactions each)240about 43,000 CU

At 50,000 enrolments that is at most about 2.2 billion CU once (every wallet a trader; about $1,100 at the pay-as-you-go rate above), and some 0.15 to 0.2 billion (about $80 to $105) for wallets that move ether a few times a week. It is paid when a wallet is added, never again: after enrolment the feed covers the wallet and the steady state above holds. What it buys is proper lines for that history ("Transfer received" and "Transfer sent" in ETH, the fees under their transactions) where v0.71.0 folded every ether change there into "balance adjustments, sources not tracked" (a wrap or an unwrap there needs no listing: its WETH9 event books both legs); a failed transaction's fee the listing does not name is left to the accepted cause (the feed never read those blocks, so the sources are incomplete there by construction, and the residue never pages). Skipping the listing wherever the feed covered none of a break's range would make enrolment free and fold that history as v0.71.0 did; the decision is recorded on PR #967.


DefiLlama Coins API ​

Free and keyless; used in five places. Their rate limit is aggressive enough to break long backfills, so size matters.

  • Current prices — prices/current/{coins} in src/lib/data/llama-prices.ts. Two classes of caller now: the carry simulator's live position-size readout (during the /carries ISR render), and every writer that has to publish a USD figure — the Aave/Spark position writer, the Morpho exposure writer, the carry-registry sync, Morpho discovery and vault capacity. Dedupes + validates addresses, revalidate: 300, a 10s request timeout, and returns {} on any failure, a timeout included (the UI degrades gracefully rather than erroring).

    The exposure writers judge every quote before using it (a strict mode those two callers opt into; other consumers apply only the basic sanity bound, a finite positive price). Llama publishes a timestamp (when the price was last observed) and a confidence (0..1) alongside each price. Under the strict mode a quote is accepted only if it is at most 6h old — in either direction, since a future stamp is a clock fault rather than a fresher price — and its confidence is at least 0.9. One that fails either bound is simply ABSENT from the result, indistinguishable from an address the API has never heard of, and the caller's existing no-price path takes over: skip the write and leave the previous snapshot standing, or drop the leg from an attribution. A refused quote is never a zero and never a silent par assumption; a stablecoin trading away from par is exactly when a par assumption misprices a book.

    The confidence floor is INCLUSIVE at 0.90 deliberately: every Pendle PT reserve and sFRAX quotes at exactly that value today, so tightening it by a hair would unprice Aave's largest e-mode collateral class in one step. Every refusal is named on stderr with its reason (stale / low-confidence / invalid) plus an N/M quotes rejected count, so a vendor-wide confidence move is distinguishable from one unpriceable token.

  • Vintage marks — prices/historical/{ts}/{coins} in src/lib/data/llama-prices.ts (fetchUsdPricesAt). The same feed asked about a MOMENT instead of about now, and the same payload shape (price, timestamp, confidence, plus decimals/symbol the callers ignore; an address with no observation near that moment is simply absent, exactly as an unknown address is on the current endpoint). It exists so a collateral-exposure backfill can mark a window months old at the price that window actually had, instead of skipping it: see Data pipeline.

    The freshness bound is redefined, not reused. "Within 6h of now" says nothing about a window in 2025. What is bounded instead is the distance between the moment asked about and the moment the returned quote was OBSERVED, and it is 3h, half a 6h window, so an accepted quote is by construction nearer to its own window than to either neighbour and a window can never be marked with its successor's price. The confidence floor (0.9) is unchanged and always applies here: unlike the current path there is no lenient mode, because every caller of a vintage mark is a caller that writes it.

    Confidence carries no vintage information, which is why the timestamp bound is the load-bearing one. Asked for USDS on 2024-08-01 with a 90-day search width, the API returns a quote observed 2024-09-24 at confidence 0.99. So the request sends the vendor's own searchWidth (in bare seconds, which the endpoint accepts) set to the same 3h AND re-checks the timestamp it gets back: the search width narrows what the API looks at, the returned timestamp is the only thing that proves what it found. A refusal is named on stderr as off-vintage rather than stale — a different fault with a different fix — and the address is simply absent from the result, so the caller's existing no-price path skips that row.

    A 429 or 5xx is retried twice (1s, 2s) before the window gives up; the current path deliberately does not retry, since it runs inside a page render.

  • Historical prices + decimals — prices/historical/{ts}/{coins} in src/lib/data/prices.ts (getHistoricalPrices). Batches up to ~50 coins per request (URL-length bound), revalidate: 86400. Throws on non-2xx so the caller skips that snapshot. Maps the native-ETH sentinel 0xEee...EEeE → WETH for lookups. Since Milestone C this is out of the mark pipeline — its only live callers are the Fluid DEX fee/TVL enrichment (fluid-dex.ts, backfill-fluid-core.ts, display/TVL, not a portfolio or token_basis mark). prices.ts carries a header forbidding new mark-pipeline use.

  • Block-by-timestamp — block/ethereum/{ts} in rpc.ts (blockByTimestamp), used to resolve the two window-endpoint blocks for every 6h APY snapshot. Retries up to 5x with exponential backoff (1/2/4/8s) on 429 / 5xx; fails fast on other 4xx; throws after the last attempt. Every snapshot window END — live cron and backfill alike — asks for the at-or-before anchor (see Data Pipeline), which still makes exactly one Llama request and then verifies the answer against the chain. For a boundary that is comfortably in the past that costs ~2 extra eth_getBlockByNumber calls, ~300 ms (measured over six boundaries), on the archive RPC and not on Llama. For the newest boundary in a run it costs more: Llama's index has not passed the second, so its answer cannot locate the boundary and the resolution re-resolves with a full bisect — measured 1 Llama + 30 eth_getBlockByNumber + 1 eth_blockNumber, 2.6 s against 138 ms for the unanchored lookup. A live tick pays that once per refresher (five of them, so ~150 archive reads and ~13 s a tick, against the thousands of reads the tick already makes); backfill-recent-gap.ts pays it five times per recent boundary, and its documented trigger is the archive-RPC quota being exhausted. The same path is what makes the anchor trustworthy rather than merely cheap: it is how a stale Llama answer is corrected against the chain instead of believed.

  • Token basis — no longer a DefiLlama consumer (Milestone C). The 6h token-basis refresher now sources its market + ETH/BTC numeraire quotes from the Dune price mirror (getBarSeriesAt over token_price_bars, same-bar division), not getHistoricalPrices + a coingecko:bitcoin call. Only the DEPRECATED legacy scripts/backfill-token-basis.ts still uses the DefiLlama path, and it refuses to run without --i-know-deprecated (it would write cross-vintage rows into the mirror-derived served table). See Data pipeline → "Token price bars (Dune mirror)".

batchHistorical (avoid the 429) ​

This describes the deprecated legacy token_basis backfill, retained only to reconstruct pre-mirror history (see the Token-basis note above); the live pipeline no longer uses DefiLlama for marks.

Requesting thousands of individual historical timestamps gets hard rate-limited (429). The legacy backfill therefore uses the batch endpoint coins.llama.fi/batchHistorical?coins={...} (scripts/backfill-token-basis.ts), which encodes {coin: [ts, ...]} and prefetches everything in ~90 batched calls instead of thousands of singles. The URL grows with coins x timestamps, so it is capped at 40 timestamps x 6 coins per call — past roughly 12 coins x 40 ts DefiLlama returns 414 (URI Too Long). Each batch retries up to 5x on failure. Rule of thumb: any DefiLlama backfill at scale must use batchHistorical, never per-timestamp loops.

The six-hourly hole fill and the one-off re-judge repair ask the same endpoint through fetchUsdPriceHours, ONE address a request with the hours listed: the pair budget is 336 (addresses x hours), measured, because the vendor answers 414 long before any documented limit. The repair walks every hour of a token's series that way, about 19 requests a token for January to September, paced 250 ms and retried three times, and a request that still fails marks its hours as never asked rather than as unpriced.

The one sanctioned exception is the Morpho exposure backfill, which batches by COIN within a window rather than by timestamp across coins: one request per 6h window (~1,800 for a full run) arriving at the pace of an archive-RPC-bound walk, on a job that already resolves two blocks per window through the same host. The reasoning, and the escape hatch if that walk ever gets faster, are in Data pipeline.


NY Fed SOFR ​

scripts/refreshers/sofr-rates.ts, run weekdays 13:00 UTC (~09:00 ET, after the ~08:00 ET NY Fed publish). Two endpoints under https://markets.newyorkfed.org/api/rates/secured, joined by date:

  • /sofr/... — daily overnight rate (ACT/360, stored in %).
  • /sofrai/... — compounded 30d / 90d / 180d averages + the canonical SOFR Index (the compounding accrual factor used as the risk-free benchmark on the USD-denominated charts).

Daily mode pulls the last ~30 business days (.../last/30.json) for a generous revision-overlap window; backfill mode ({ since }) walks the range in 1-year chunks (/search.json?startDate=&endDate=) back to the SOFR genesis 2018-04-03. Writes upsert into onchain_credit.sofr_rates keyed by rate_date.

Each date is written only in the columns whose feed covered it. The two endpoints answer for different spans and fail independently, so a failed /sofrai leaves the stored 30d / 90d / 180d averages and the compounding index untouched, a failed /sofr leaves the stored daily rate untouched, and both down writes nothing at all — each case says so on stderr rather than passing as a quiet run. This is not only a failure rule: on a perfectly healthy day the two windows do not coincide (they publish on their own schedules, so each pull's oldest and newest dates differ by a day or two between the feeds), and a merge that wrote every column for every date in the union would blank the averages on the dates only the daily feed reached, then keep that blank permanently once the window rolled past them. A date a feed DID cover but skipped is still written null, because that is a genuine gap in what the NY Fed published and the reader has to be able to tell the two apart.

Dates already stored with a null average where the NY Fed did publish one are not repaired by the write rule; one refresh-sofr.ts --since=<date> run refills them, since the search endpoint returns coincident spans for both feeds.

Reader side (src/lib/data/sofr.ts) returns [] if the DB read fails. A null average therefore means the NY Fed published none for that date, which is what makes the reader's walk back to the latest populated average — and the staleness ceiling on that walk — meaningful. See Metrics → vs-SOFR spread.


Morpho Blue GraphQL API ​

https://blue-api.morpho.org/graphql, POSTed from five places. Keyless.

Four of the five carry their own inline fetch and their own retry rules, for historical reasons. scripts/morpho-gql.ts is the shared client going forward and the Money Market Funds jobs use it; folding the older four in is its own change, so a bug in the shared client cannot take down the market-admission rule while something else is landing.

  1. Market discovery — veto only (scripts/morpho-discovery.ts, fetchApiMarkets). Pulls the top mainnet markets by borrowed USD for the admission rule (processes.md A.7). The API is advisory / veto-only: it supplies the veto signals (listed, RED warnings, badDebt, supplyingVaults count) and can keep a market out, but every listing number (sizes, params, oracle) is re-read on-chain — the chain stays canonical. Fail closed for additions (API down → hold new proposals), fail open for removals.
  2. Market collateral USD (scripts/refreshers/morpho.ts, fetchCollateralUsdByMarket). The on-chain market() struct carries no collateral aggregate (it is per-position state), so total posted-collateral USD for the "Underwritten capital" overcollateralization figure comes from Morpho's own indexer (state.collateralAssetsUsd). Best-effort on live runs only; the historical backfill passes fetchCollateralUsd:false because the API exposes current state only. On any failure the map comes back empty and collateral_usd is written null (the overcoll column simply hides rather than lying).
  3. Curator-vault fee / netApy / allocation (scripts/refreshers/curator-vault-state.ts, fetchMorpho). Chunked at 20 vaults per query, 4 retries with backoff, 300ms between chunks. A vault whose state doesn't come back is counted failed and skipped (prior snapshot stands).
  4. Money market fund discovery (scripts/sync-money-market-funds.ts). Walks the whole mainnet universe of BOTH generations, vaults for MetaMorpho and vaultV2s for Vaults V2, for the addresses, names, inception stamps, curator attribution and adapter shapes the listing rule needs. Discovery only: the size, fee, roles and notice period the rule actually judges are re-read on chain, and the API's own size figure is used only to decide which vaults are worth a chain read.
  5. Money market fund state (scripts/refreshers/money-market-funds.ts). Four narrow uses, and nothing else: WHICH pending timelocked calls are in flight (each then confirmed against the vault's own executableAt), WHICH addresses hold the largest positions (each balance then read on chain), the API's own warnings, surfaced as flags, and per-market BAD DEBT, which is the one published figure on that tab the chain cannot confirm: whether a loan is backed is a statement about a market's whole position set, and enumerating those needs an indexer. It is read once per tick as markets(where:{uniqueKey_in:[...]}){items{uniqueKey badDebt{usd}}} in pages of 100, keyed by MARKET so it covers both vault generations, and a market the response does not carry is stored NULL rather than zero. It also reads Morpho's published liquidity / forceDeallocatableLiquidity purely as a cross-check against our own computed figure. scripts/backfill-money-market-funds.ts uses the same client for one more cross-check, the published daily share-price series.

Gotchas ​

  • Address filters need checksummed casing. The GraphQL address-type filters only match checksummed-case addresses. The market query sidesteps this by filtering on uniqueKey_in (the lowercase bytes32 market ids, matched verbatim) instead of address. If you add an address filter, checksum it first.
  • Schema drifts (2026-07). On the Market type the uniqueKey field was renamed marketId and whitelisted became listed. The uniqueKey_infilter still exists (so the collateral-USD query is unaffected), but the discovery query uses the current field names — treat the API as drift-prone and re-check fields ({ __type(name:"Market"){ fields{ name } } }) if a query starts erroring.
  • Vaults V2 IS indexed now (verified 2026-08-20), under its own root fields: vaultV2s and vaultV2ByAddress, with performanceFee, managementFee, timelocks, caps, adapters, pendingConfigs, gatesConfig, liquidity, forceDeallocatableLiquidity, curators and warnings. This supersedes the long-standing note that V2 vaults were absent from this API; the /money-market-funds tab discovers both generations through it. The V1 vaults root still returns MetaMorpho only, so the two have to be walked separately.
  • THE COMPLEXITY BUDGET IS REAL AND IT BINDS. The endpoint enforces a maximum query complexity of 1,000,000 and reports what each response cost under extensions.complexity. A page of 25 Vaults V2 with adapters, timelocks, gates and caps costs about 282,000; a page of 100 V1 vaults with their allocation costs about 68,500. So: page V2 at 25, page V1 at 100, and never ask for caps and positions in one query. Query is too complex is a hard failure, not something to retry.
  • A errors payload is not always OUR bug. The usual rule is that a GraphQL-level error is deterministic and must not be retried, and that is right for a validation failure or a complexity rejection. But this endpoint answers a perfectly valid full-universe walk with a body full of INTERNAL_SERVER_ERROR entries when one of its own resolvers falls over, and the identical query succeeds seconds later. scripts/morpho-gql.ts retries that specific shape and nothing else (isTransientGqlErrors). Two concurrent full-universe walks make it far likelier, so the discovery script walks the two generations one after the other.
  • VaultV2.positions cannot be ordered. MetaMorpho positions take orderBy: Shares, so a top-ten holder list is exact. V2 positions take only first / skip / where, so the concentration figure is built from up to five pages sorted in process and marked inexact past that; the drawer says "at least".
  • timelocks.functionName and pendingConfigs.functionName use DIFFERENT casing on the same actions (increaseAbsoluteCap versus IncreaseAbsoluteCap). Matching one against the other silently finds nothing.

Euler (Goldsky subgraph) ​

scripts/refreshers/curator-vault-state.ts, fetchEuler / eulerGql. POSTs GraphQL to the public Goldsky subgraph euler-v2-mainnet. Euler is an isolated single-pool model, so there is no per-collateral $ allocation: the refresher exposes only the interestFee (scaled by 1e4, e.g. 1000 = 10%) and the accepted-collateral set (collateral vault ids resolved to asset symbols, e.g. eWETH-1 → WETH, batched 50 at a time). 4 retries with backoff; on persistent failure eulerGql returns null and the vault is skipped. Stored as allocation entries with supplyUsd = 0, which the UI renders as an unweighted set rather than a weighted bar.


CoinGecko ​

https://api.coingecko.com/api/v3, the live price source for every market-priced asset, the first vendor asked for an hour the tape never printed, and — once a week — the source of the REPORTED daily volume the pricing-category test's volume bar is judged on (metrics). Client: src/lib/data/coingecko.ts; the rule that decides whether a level is served at all is src/lib/data/live-price.ts (see Live prices).

The volume read is the same endpoint as the hole fill, asked a different question.market_chart/range answers prices and total_volumes together; the fill reads the first, the weekly measurement the second. On this plan the granularity is automatic and a 30-day range answers hourly (measured 2026-09-23), each total_volumes point being the trailing 24 hours as of its stamp — so the measurement folds it to one value a day, the last point inside each UTC day, and takes the median of those days. A 404 (coin not found, which sGHO's contract answers) is an ANSWER: that row's volume bar reads the on-chain median instead. It is exchange volume AND DEX volume together, which is the point of asking a price vendor rather than the trade data — the bar asks whether a traded price exists, and a venue that is not a pool still makes one.

Plan and key. The Demo plan: COINGECKO_API_KEY rides as the header x-cg-demo-api-key. With the variable unset the same host answers keyless at a much lower rate limit, and the client says so once per process — a box running keyless by accident looks exactly like a vendor having a bad day, so the line is what tells the two apart. The key lives only in each server's .env.local.

Budget, which is the shape of the client, and the arithmetic rather than an assertion. The Demo plan allows about 10k calls a month, which is ~13.9 calls an hour. The four spenders, priced:

spendercallsper month
the live batch on a signed-in page load / Synchronize1 per refresh (one /simple/token_price), 2 when a held row carries a listing id (BTC.b today), and the cache allows at most 12 refreshes an hour per app process8.6k–17.3k under continuously loaded traffic; nothing at all in an hour nobody signs in
the six-hourly live check3 a tick over the 61-asset set (2 address chunks + 1 id chunk)~360
the hole fill, healthy tape1 per token per tick, and only for a token with a hole to fill or a suspect bar to corroborate0: a whole tape with no holes asks nothing
the hole fill, tape-wide outageevery Dune row (the tape rows and sUSDe's routed one) has holes, so every row would ask — 60 rows x 4 ticks a day is 7.2k a month, which is why the leg carries a ceiling of 30 metered calls a pass≤3.6k, and 0 again the moment the tape prints
the weekly reported-volume read1 per measured row per week: 23 tokens, minus the rows the registry already records as unlisted~100
the one-off re-judge repaironce, never on a schedule: 1 per 88 days of history holding a suspect bar or a gap, per token, for the nine months to September; an execution run over the dry run's vendor cache asks nothing again~100–150, once

Two rules produce the fill's numbers, and the weekly volume read obeys the first of them too. A row the vendor has no entry for at all is never asked (COINGECKO_UNLISTED in the registry; Ethereum PST today, whose two catalogue entries are both Solana tokens): holes are that row's steady state, so without the rule it would buy a guaranteed-empty call every tick for ever. And one pass spends at most 30 metered calls, rotating across the token list so the rows past the ceiling lead the next tick. A listed row past the ceiling writes nothing that tick — filling it from DefiLlama alone would make a permanent bar out of one vendor's word when the second opinion is one rotation away for free, and the hour stays fillable for another 42 hours — so the ceiling costs latency rather than coverage, and what it buys is that a background leg cannot spend the plan the PAGE is served from.

So the fit depends on a traffic assumption, and it is worth stating plainly: the design fits inside the plan while the signed-in pages are not loaded continuously, around the clock, in more than one process. At the ceiling — someone holding a page open in every hour of the month — the live batch alone can reach ~17k. The dial is the cache window (LIVE_CACHE_MS, five minutes today, matched to PORTFOLIO_LIVE_COOLDOWN_MS): ten minutes halves the worst case, at the cost of serving a Synchronize press a price up to ten minutes old, which is the staleness this whole path exists to remove. It is therefore held at five until the alert below says it has to move.

A spent plan is an ALERT, not a log line, because the degradation is otherwise perfect: every call is refused, every market-priced asset is served from DefiLlama, no dash appears and no number is wrong. What has actually stopped is the corroboration — R6 judges a quote by asking a second vendor, and with the primary shut there is only ever one. So the six-hourly check prints [fail] the primary vendor answered for NONE of N asset(s) and ends the run non-zero, on every tick it is true, whenever the set has fallen to the backup: zero of about sixty rows served by CoinGecko while the backup served at least one. One row on the backup is a feed degrading and stays a quiet line; the whole set is a shut door — a spent plan, a refused key or a vendor-wide outage — and each of those is something an operator has to act on. scripts/refreshers/token-basis.ts carries it, beside the dark-feed and disagreement legs.

What running out looks like to an operator, because the degradation is graceful enough to be invisible. Every call 429s, the client opens its 60-second cool-off and answers empty, and every market-priced asset is served from DefiLlama instead — the backup path working as designed, with no dash, no error and no alert. The three traces are the [coingecko] HTTP 429 … pausing requests log line, the six-hourly check's [live-price] N asset(s) served from the backup vendor this tick line, and, if the two vendors then disagree about something, a [fail] price disagreement. A month spent at the backup is not a wrong number, but it is the product running on its second vendor without anybody deciding to, so the 429 line is worth watching for.

The mitigations that produce the numbers above:

  • the live batch is cached in process for five minutes, the same window as PORTFOLIO_LIVE_COOLDOWN_MS, so a signed-in page refreshing as often as it is allowed to costs one call and a burst of concurrent loads costs none beyond the first;
  • every request is batched: one /simple/token_price/ethereum for the rows the vendor lists by contract and one /simple/price for the rows that carry a listing id, whatever the list length (chunked only to keep a URL sane);
  • the history path is asked only about hours that are actually missing, is never asked at all about a row the vendor does not list, and spends at most 30 calls in a pass, so a tick over a healthy tape spends nothing and a tape-wide outage spends a bounded amount;
  • a 429 opens a 60-second cool-off during which the client answers empty without asking again, so a vendor that is rate-limiting us is not hammered by every page load.

Endpoints.

pathused for
/simple/token_price/ethereum?contract_addresses=…&include_last_updated_at=truethe live level of every row the vendor lists by contract
/simple/price?ids=…&include_last_updated_at=truethe live level of a row it does not (coingecko_id on the registry row; BTC.b is the only one today)
/coins/ethereum/contract/{address}/market_chart/rangehourly history for a hole (the six-hourly fill, and the one-off re-judge's judge and fill), by contract; and the weekly reported-volume series (total_volumes) for the pricing-category test
/coins/{id}/market_chart/rangethe same, by listing id

include_last_updated_at is load-bearing rather than decorative: the ETH-pair guard accepts a wrapper/ETH ratio only when the two observation stamps are within 120 seconds of each other, and without the stamps there is no way to know whether ETH's own move cancelled.

Granularity is automatic on this plan — a range under a day answers every five minutes, a range of days answers hourly — so every point is matched to its NEAREST hour and the closest one wins, which yields the same grid from either shape. A range of 90 days or more answers DAILY (measured 2026-09-24 on USDC from 2026-01-01: 85 and 89 days came back at 3,600-second spacing, 90 and 91 at 86,400), which folded onto the hourly grid would read as a vendor with 23 holes a day; the repair that walks months of history therefore asks in spans of at most 88 days (HOURLY_RANGE_MAX_SEC, with fetchHourlyPriceRange, which also keeps a refused request apart from an unlisted coin). The explicit interval=hourly parameter is Enterprise-only and is off by default.

Every failure is an absent entry, never a throw. This client sits in front of a valuation: a 429, a wedged socket, a parse error or a listing the vendor dropped degrades to the backup vendor and then to the stored bar, in that order. The one throw is a caller's mistake rather than a vendor's failure: fetchHourlyPriceRange refuses a span wider than 88 days, which the vendor would answer at daily granularity.

KyberSwap aggregator ​

https://aggregator-api.kyberswap.com/ethereum/api/v1/routes, called at request time from src/app/api/sim/swap-cost/route.ts (force-dynamic POST, invoked by the carry simulator's execution-cost card). Keyless; sends an x-client-id header (KYBER_CLIENT_ID, default creddit).

The route quotes each leg as a reciprocal round trip (A→B then B→A at the quoted output) and reports the round-trip loss as the execution cost — one round trip for a single-token leg, two for T2/T3 pool legs, zero for mirrored T4 legs. A non-positive round-trip loss (cross-venue spread noise) returns {ok:false} ("Quote not available") — there is deliberately no estimated-cost fallback anywhere in the simulator. Kyber was chosen over Enso after evaluation: Enso's priceImpact is unstable and size-invariant, while its amountOut is a Kyber drop-in, so quoting Kyber directly is strictly simpler. Each attempt carries a 10s timeout (AbortSignal.timeout); a timed-out attempt is treated as transient and retried within the 3-attempt loop, exactly like a 429 / 5xx, and returns null if all three fail (no fabricated quote).

Pendle PT legs quote on Pendle, not Kyber. Kyber has no real route to a Pendle AMM (verified: quoting 100 PT-srUSDe, worth roughly $98, it returned a dust path paying ~22 USDC, with its own USD estimate off by three orders of magnitude — the round-trip check correctly refused it, which is why PT rows showed "Quote not available"). When a leg's token spec carries a pendleMarket tag (wired from the carry registry config), venuePrice dispatches that leg to Pendle's hosted SDK (/core/v2/sdk/1/markets/{market}/swap, enableAggregator=true so a USDC/USDT side routes through the underlying inside the quote) and uses its amountOut with the identical round-trip method. Same retry policy (3 attempts, each with a 10s timeout), same no-fallback rule. The client-supplied market is validated against pendle_markets (10-minute per-process cache) before any outbound call, so the unauthenticated endpoint cannot be used as a quote proxy for arbitrary markets — Pendle rate-limits per IP, shared with our own refresher.

Per-IP rate limited (protects the quota). The swap-cost route is now wrapped in a per-IP in-memory limiter (10 requests/min/IP, src/lib/rate-limit.ts), so a single caller cannot burn Kyber (or Pendle) quote quota on this unauthenticated, outbound-heavy endpoint. Over-limit returns HTTP 429 with Retry-After. See Architecture → API routes for the full per-endpoint budget table and the single-process caveat.


Fluid public API ​

https://api.fluid.instadapp.io/v2/1/vaults, keyless. Two consumers:

  • scripts/fluid-discovery.ts (via the manual sync-carries.ts flow) — lists Fluid vaults as carry-registry candidates, with TVL from the API's own embedded token prices. Partial-response guard: if the API returns fewer than MIN_EXPECTED_VAULTS (80) vaults, discovery refuses to classify — otherwise the sync's gone-marking would mass-mark live vaults as gone and strip /carries. The guard is discovery's alone; it is a floor against a truncated response, not evidence that the list is complete.
  • scripts/refreshers/collateral-exposure.ts (6h cron) — token symbols, decimals and USD prices for the Fluid underwritten-capital slices, plus the per-vault borrow-limit fields the wind-down test reads. It is not the vault universe. The list endpoint FILTERS rather than truncates — 119 of the 181 live vaults today, scattered ids, no pagination parameter and no total in the payload — and the vaults it drops carry about a third of the direct stablecoin debt, so no "did we get N items" guard can detect the gap. The universe is the on-chain vault census instead (one resolver call returns every vault's state at the snapshot block), and a vault missing from the list is fetched by id (/v2/1/vaults/{id}, the same object shape). A vault with live debt that neither source can describe is published in the explicit Unattributed remainder rather than silently dropped. The risk layer reads on-chain storage, not the API.

vaults.fyi ​

https://api.vaults.fyi/v2, requires VAULTS_FYI_API_KEY. Discovery only: scripts/sync-curator-vaults.ts (manual, --write to regen src/data/curator-vaults.ts) lists USD curator vaults to propose registry candidates. The app and crons never call it; once a vault is in the registry, everything comes from on-chain reads + the Morpho/Euler APIs.

Gotchas: rate-limits aggressively (429 and 403 both mean back off — the script retries 5x with pacing); the list endpoint carries corrupted/phantom duplicate entries (filter isCorrupted !== true && holders > 0) and low-holder feeder vaults next to the real "Main" vault.


Google Fonts (OG image) ​

src/app/opengraph-image.tsx fetches the display font at request time (fonts.googleapis.com/css2 → font file) for the Satori-rendered social card. Two hard-won rules, documented in the file itself:

  • Do not send a browser User-Agent on the CSS request — Google then serves WOFF2, which Satori cannot parse (this broke a deploy once). The default fetch UA gets TTF.
  • The font URL is regex-pinned to a truetype/opentype src block as a second guard against format drift.

Dune Analytics ​

A runtime dependency of the 6h token-basis refresher + the mirror backfill tooling (dune-price-mirror-plan.md Phase A), plus the older out-of-band analytics use. The client is src/lib/data/dune.ts — a plain HTTP call against the Dune API (no SDK): execute a saved parameterized query → poll its execution (reading the execution_cost_credits off the status, free) → page the results (32k rows/page) → idempotent UPSERT into onchain_credit.token_price_bars.

Three saved queries (buying a month off Dune's finest-grained price table scans its whole time partition, ~110-170 credits/month — ~100x the estimate — so no window goes near it):

  • HOURLY — prices.hour, DUNE_QUERY_ID_HOURLY (query 8033816), params tokens CSV / from_iso / to_iso, bars on the hour. Cost is SUBLINEAR in span: 1y × ~40 tokens ≈ 16 credits in ONE execution, 1 month ≈ 1.5, a 6h window ≈ 0.25 (floor ~0.22). Every window path — sync, preload, fetch-through — uses it.

  • DEX-RATIO — dex.trades, one saved query PER ROUTED TOKEN (dexRatioSources() in dune.ts; today only sUSDe → DUNE_QUERY_ID_SUSDE, query 8283549), with the same params and output columns as the hourly query so the mirror ingests its rows unchanged. It exists because the aggregated-exchange channel behind prices.hour is not uniformly precise: measured on Dune (ethereum, 2026-08-05, 24 hourly bars), sUSDe had ONE distinct price all day (1.24) with 24/24 bars exactly cent-aligned, while USDe, USDC, USDT and DAI each had 24 distinct prices and none cent-aligned. A cent is ~0.8% of sUSDe's price and its redemption value accrues daily, so the basis built on that quote was a quantisation sawtooth rather than a market fact. The replacement prices the token from realised DEX trades: per-trade ratio against a clean counter asset (USDe/USDC/USDT/DAI), hourly volume-weighted, MULTIPLIED by that counter's own full-precision prices.hour quote at the same hour — a product, not a cross-vendor ratio. Coverage with all guards: 564 bars over July 2026's 744 hours (76%; hours failing a guard are dropped rather than guessed at, and the reader walks back). The SQL is vendored at scripts/dune/susde-dex-ratio.sql so its guards are reviewable in a PR. Routing is EXCLUSIVE — syncBars splits its token list so a routed token is never in the standard CSV, and upsertBars refuses any bar for a routed token that does not carry its route's source tag, so the invariant survives a future writer rather than depending on each one remembering. With ON CONFLICT DO NOTHING a single leaked bar would be permanent. The query's guards are shaped by its consumer being an EXTREMUM (MIN(basis) over 365 days), which one hour can move in either direction: complete hours only; a token-quantity liquidity floor rather than a USD one (a USD floor tightens during a depeg, blanking the hour and serving a pre-crash price — understating risk); a measured venue allowlist; a >= 2 distinct-taker floor; the dust guard and the median band. Cost is FLAT in the window, which is the surprising part and the reason for the cooldown below. Measured (small engine, 2026-08-10): 1 calendar month = 1.252 credits, 6 hours = 1.907 — the shorter window cost MORE, because dex.trades prunes on block_month and a block_time predicate does not prune inside a partition. So the bill is "one month partition scanned" regardless of slice, and unlike the hourly query a fetch-through here is a ~2-credit execution rather than a ~0.25-credit rounding error. DexRatioSource.minResyncSeconds (default 12h, overridable per environment by DUNE_DEX_RATIO_MIN_RESYNC_SECONDS) makes a short-window caller skip while the ROUTED TOKEN'S OWN newest bar is inside the cooldown — its own bar, not anything derived from the caller's window, because the refresher's from is the TABLE-WIDE MAX(bar_ts) that every other token advances and which therefore says nothing about this one. When it does fire it starts from that own bar, so the ticks it skipped are still covered. Bypassed by allowLongWindow so the bulk load and the re-source repair are never short-circuited. Standing cost: ~115 credits/month at 12h (~230 at 6h, ~57 at 24h). Above 6h the skipped ticks serve a bar past BAR_STALE_SOFT, so ops sees the staleness notice routinely — a deliberate trade, not a lagging mirror. It is a standing feed: the routed leg is bought whether or not anyone looks at sUSDe.

  • DEX MARKET MEASURE — dex.trades, DUNE_QUERY_ID_DEX_MARKET (query 8808795, v2), params tokens CSV / usd_counters CSV / from_iso / to_iso, ONE row per token. It answers the market limb of the pricing-category test (metrics): over a 30-day window, how many UTC days the token traded on a DEX and the MEDIAN DAILY volume across all thirty of them, with a day that did not trade counting as zero. Cheap and weekly — four tokens over 30 days cost 0.038 credits — because it aggregates rather than emitting a row per hour.

    The median is over the calendar, not over the days that traded, and that is the judgement the SQL encodes: a median over only the trading days flatters precisely the asset the test is looking for, one that trades three days a month at $1M. The only filter is a dust bound on both legs; there is deliberately NO venue allowlist and no taker floor, because this query measures WHETHER a market exists rather than pricing off it, and a filter that removed a venue would remove evidence in the direction that changes how an asset is valued.

    A trade Dune cannot price is valued on its dollar counter leg, not dropped (v2, 2026-09-22). Dune's own amount_usd is preferred wherever it has one; where it does not and the other leg is one of the recognised dollar stables the run passes in (usd_counters, the dollar half of the same list the pool rule reads), that leg's TOKEN AMOUNT is the notional to within its own peg. A trade that is neither is still dropped from the volume AND the day count, because an unpriced trade against an unknown asset is not evidence of a dollar market. USD3 is why: Dune decodes its Curve pool against frxUSD and carries all 1,516 of its trades over the 30 days to 2026-09-22, on all 30 days, with amount_usd NULL on every one — so under v1 a pair trading a median $318k a day read as having never traded. Vendored at scripts/dune/dex-market-measure.sql; read by the weekly refresh-token-market.ts, never by the app.

Call sites:

  • Incremental sync — the token-basis refresher prepends syncBars(tracked, MAX(bar_ts), now) on its existing 6h grid (HOURLY), so the newest window lands for ~0.25 credits/run (~30/month steady state); no crontab change.
  • Bulk load — scripts/backfill-token-price-bars.ts seeds ~1 year of hourly bars in ONE execution (~16 credits; --halves splits into two if it pages awkwardly), idempotent + resumable, with a hard abort if a chunk returns zero rows for all tokens (a query/param bug) or the credit guard stops it.
  • Fetch-through — a read miss beyond the 6h walk-back triggers ONE HOURLY syncBars for a ±1h window (cheap floor), then re-reads; a second miss returns null (M9 — never a fabricated mark). Fetch-throughs are logged; alert if > 20/day (a coverage gap — fix the tracked set, not the budget).

Guard rails, because credits are scarce (~2,500/month on the mirror plan; steady state ~30-60/month for the standard queries, plus ~115/month for sUSDe's routed dex-ratio leg at the default 12h cadence):

  • Window clamp — syncBars refuses a window > 48h (a [fail]-style log) and clamps to the most recent 48h unless allowLongWindow (bulk load only): a stale mirror is caught up by the bulk tool, never by one giant cron execution.
  • Credit guard — a per-process accumulator (DUNE_MAX_CREDITS_PER_RUN, default 50) stops issuing further executions once reached, returning partial with a [fail] line; the per-execution credit cost is logged always. It is checked twice — before queueing and again after the execution gate hands over a slot — so a call that waited while a sibling spent the budget does not issue past the ceiling.
  • Burst control (the execution gate) — Dune bills per execution but rate-limits per REQUEST, so a burst of concurrent reads can trip the per-minute cap and degrade marks to older bars on 429. At most DUNE_MAX_CONCURRENT (default 2) executions are in flight, with starts spaced DUNE_EXECUTION_SPACING_MS (default 1000) apart, so a burst queues instead of 429ing. It changes no outcome: the gate only delays a start, and a call the credit guard has already refused never occupies a slot.
  • Rate-limit breaker — the layers above bound how FAST a process issues; they cannot tell it that issuing is currently pointless. A wallet backfill values thousands of points and each unmirrored bar triggers one fetch-through, so once Dune starts answering 429 the process keeps asking: one prod drain (2026-08-06) logged 8,747 fetch-through … dune 429 lines in a single run, alongside 1,526 marks served from bars past the 6h soft-stale threshold. Every one of those requests degraded to the standing bar anyway. So the FIRST 429 on any request shape (execute, status poll, results page) opens a per-process pause for DUNE_RATE_LIMIT_COOLOFF_MS (default 60s, Dune meters per minute; 0 disables), and every execution the process would have issued inside it is refused locally, before the socket. Nothing served changes: a refused execution takes the same path a spent credit budget takes — partial, re-read, the standing bar, or an honest null (M9) — which is what the 429 delivered. The pause logs once when it opens and once when it closes, with the count it absorbed, instead of one line per suppressed request. SyncResult.guard distinguishes the two stops ("credits" = a budget to raise, "rate-limit" = a minute to wait), which is what the bulk loader prints.
  • Both layers are PER PROCESS, which decides where they bind. They bound a valuation pass's 24-way parallel mark loading and the long-lived Next server's concurrent JIT reads. They do NOT bound the backfill drain: scripts/drain-portfolio-backfills.ts runs BACKFILL_WORKERS wallets at once and each is a spawned CHILD PROCESS with its own gate and its own ceiling. The drain therefore DIVIDES the budget before spawning (duneChildBudget), setting DUNE_MAX_CONCURRENT / DUNE_MAX_CREDITS_PER_RUN per child so the aggregate is what these settings describe. Raising BACKFILL_WORKERS shrinks each child's share rather than multiplying the total.
  • Result reads are not free either. The credit guard accounts execution_cost_credits only, but Dune's pricing FAQ states that the datapoints returned by every /results request also accrue against the quota. Nothing observed in this account's usage suggests it dominates at our result sizes, but it is a real axis the guard cannot see — which is why every read asks for one token list over a BOUNDED window rather than a token's whole history: syncBars clamps to the most recent 48h for every caller but the bulk load, and a read miss asks for ±1h, so the datapoints returned are the list times the hours asked for.
  • Reads never overwrite a stored bar (ON CONFLICT DO NOTHING), so a Dune history restatement cannot move a frozen bar — a stable audit trail.
  • Keyless is graceful: with DUNE_API_KEY / the relevant DUNE_QUERY_ID_* unset every network path degrades to a logged skip + DB-only reads, so local dev / CI / a de-provisioned box still valuate from stored bars.
  • For the surviving ad-hoc analytics (e.g. Fluid NFT-PnL): inspect a query and its cached results before executing, partition-prune before windowing, reuse cached results.

ToS note. The credit model here is built on RETAINING query results in our own DB (the mirror is a persisted copy of Dune bars). Confirm the Dune API terms permit that use before the prod bulk load — a one-time check (Dune API terms: https://dune.com/terms).


GeckoTerminal / CoinGecko /onchain ​

One dataset behind two doors, read once a week by refresh-token-market.ts for the third fact the pricing-category test needs: the reserves of every pool holding a tracked token, from which the job takes the deepest one whose OTHER side is an asset of the token's own book or a recognised counter of that book.

DoorEndpointRate
Keyed (COINGECKO_API_KEY set)https://api.coingecko.com/api/v3/onchain/networks/eth/tokens/{address}/pools with an x-cg-demo-api-key header30 a minute, and it holds
Keyless (fallback)https://api.geckoterminal.com/api/v2/networks/eth/tokens/{address}/poolspublished 30 a minute, measured five

Both answer the same envelope, so one parser reads either.

The keyless limit is measured, not read off the documentation, and the two disagree. Against the live vendor on 2026-09-22: five requests answered 200, then every request for about forty seconds came back 429 in roughly 12 milliseconds, whatever the spacing. A 23-token pass paced off the published figure collected seven readings. That is the worst shape a measurement can fail in, because the sixteen rows it dropped look exactly like sixteen rows nobody has measured yet: no market limb, no category candidate, no loopable flag, week after week, with nothing in the run that looks wrong. So the keyless door is paced at four a minute — under the allowance rather than under the advertisement — and a 429 is waited out and retried twice (20s, then 45s, or the vendor's own Retry-After when it sends one) before the token is given up for the week.

And the keyless door is the fallback in both directions. An unset key picks it at the start of the run; a key the vendor REFUSES picks it mid-run. An expired, mistyped or wrong-tier key answers 401 or 403 to every request (measured: a nonsense key returns 401 "API Key Missing"), which with one door would take the whole pool limb dark and lose the week's readings — so the first of those swaps the door for the rest of the pass and prints a [fail] line naming the variable. A 404 does NOT: both endpoints answer 404 for an address they carry no pools for, which is about the token rather than the door. The pass then runs seven times slower and finishes. It is swapped once and never back: if the keyless door refuses us too, there is nowhere else to go and the limb is reported dark.

Reserves, not a quote, and that distinction is the point. The test's market limb must exclude a token's native mint and redeem, because an aggregator routes through exactly that route wherever it is atomic and would report a deep, tight market for an asset with no secondary market at all. Pool reserves cannot do that: they are a balance sheet.

The superlative is WALKED, and the reading says how far it got. A page holds at most twenty pools and the vendor will not order them by reserves: sort=reserve_in_usd_desc answers 400, "Invalid sort option. Allowed values: h24_volume_usd_desc, h24_tx_count_desc, h24_volume_usd_liquidity_desc" (measured 2026-09-22), every one of them a volume ordering. Page 1 is therefore the twenty busiest pools rather than the twenty deepest, and the gap is real: sUSDe's page 1 ended on a $558 pool that day while a $443k pool sat on page 2. So the pass reads up to three pages per token and stops the moment it has its answer — a pool clearing the $1M bar, or a short page, which means the vendor's list has run out. pagesRead and exhausted ride in the stored detail beside the number, and exhausted: false is what tells the verdict the figure is a FLOOR under the depth rather than the deepest pool: the bar is then withheld rather than failed, because understating depth is what pushes a row toward redemption-priced.

What the counter rule excludes is recorded beside what it counts. A pool counts when the other side of it is an asset of the token's own book or a recognised counter of that book — a dollar stable at par, or ether and its liquid wrappers — carried as a row in this registry or not, which is why USD3's $2.1M pool against frxUSD is its QUALIFYING pool. A pool against a token on neither path does not qualify however deep it is, and sUSDe is the live example: $3,729,080 counted against sDAI beside $62,534,776 against DOLA. So the reading stores the qualifying pool AND the deepest pool overall, and a reader looking at a "no real market" verdict can see the pool the rule did not count rather than reading it as a fact about the market.

A pool's own share token is not a side at all, and that is a different rule. Balancer's stable pools list their own share token as a member, and this endpoint reports the pool's balance of it as reserve_in_usd: ETHx's deepest listed "pool" is $25,788,306 whose base token is 0x4cbde5c4…6163, which IS that pool's address (read live, 2026-09-23). That is the pool's unminted supply rather than depth against anything a holder can sell into, so the pool is SKIPPED — neither counted as depth nor recorded as an exclusion the counter rule made, because the counter rule is not what left it out. The reading carries the count and the deepest skipped figure so the drop stays visible. Two tests: the side's address being the pool's own, and the symbol naming a Balancer pool token (-BPT, the bb-a- family, a slash inside a ticker), read off the pool NAME since this response carries no symbols of its own.

Failure is an absent reading: a non-2xx, a timeout, or a 200 whose body is not a pool list stores nothing for that token and the run continues, because a missing third fact leaves the verdict null rather than turning it into a wrong answer. The last of those is the case worth naming: a renamed envelope, an error object served with a 200, or a null data under load all shape to "no pools", and stored as a measured zero they would read as "nobody trades this, and we looked" for every token in the same pass. A well-formed list holding no qualifying pool IS a measured zero, and the two are kept apart in code.

The pass covers the rows the category test applies to, which excludes the three no-base tokens and the two derived ones (stETH, eETH): the rule the pool reading serves names an asset of the token's own book or a recognised counter of that book, and both limbs are measured relative to a book a no-base token does not have, so it cannot clear the bar at any depth, and a derived row is priced through its wrapper's bar rather than off a market of its own. Measuring either would record a false fact about a market that may be perfectly real.

A LIMB IS THE UNIT OF FAILURE. The job writes four readings from three vendors and the verdict needs three facts, but a row counts as measured on any one of them — so the Dune half can be unconfigured for months while every row still stores its pool depth and the run exits zero. A limb that answered for NOTHING is therefore a failure in its own right: its own [fail] line, and a non-zero exit, which are the two halves the cron alert needs.


Cloudflare ​

Fronts the production app: creddit.xyz resolves to Cloudflare, which proxies to nginx on the Hetzner box, which proxies to the pm2 onchain-credit process on localhost:3001. The docs site docs.creddit.xyz is served from Cloudflare Pages and gated by a Cloudflare Access policy while private. No application code touches Cloudflare; it is pure edge infrastructure (DNS, CDN, TLS). See Deployment.


npm runtime dependencies ​

From package.json (Node + tsx for the TypeScript crons):

PackageVersionRole
next16.2.5App Router framework, Server Components, per-page ISR
react / react-dom19.2.4React 19
tailwindcss + @tailwindcss/postcssv4Styling (@theme inline in globals.css)
@base-ui/react1.4.xUI primitives (Base UI, not Radix), wrapped in src/components/ui/*
recharts3.xCharts
viem2.xABI encode/decode for on-chain reads (used alongside the raw rpc.ts helpers)
pg8.xPostgres client (src/lib/data/postgres.ts)
tsx (dev)4.xRuns the .ts refreshers / backfills directly under the cron wrapper
lucide-react, clsx, class-variance-authority, tailwind-merge, tw-animate-css—UI utilities
typescript (dev)5.xStrict type checking

node_modules is gitignored, so the deploy runs npm ci on the server. A transient gotcha: during npm ci tsx is briefly absent, so a refresher launched mid-deploy can fail with MODULE_NOT_FOUND — wait out the deploy before running a cron by hand.


Environment variables ​

VarRequiredDefaultPurpose
DATABASE_URLyes—Postgres connection string. App throws DATABASE_URL not set without it. Prod points at role onchain_credit on db creddit; staging at onchain_credit_staging on creddit_staging.
ETHEREUM_RPC_URLnohttps://ethereum-rpc.publicnode.comCurrent-state RPC override
ETHEREUM_ARCHIVE_RPC_URLnohttps://eth.drpc.orgArchive RPC override
LEDGER_VERIFY_RPC_URLno—A second archive-capable provider for the event ledger's scan-receipt cross-check. Set ⇒ the ingester re-scans every live range against it and compares log digests before writing the receipt (match stamps verified_by; a mismatch holds the cursor and logs loudly). A digest difference is escalated only once the mirror's own head is confirmed to be past the scanned range: a provider that has not reached the range never served it, so its short answer is an absence, not a disagreement, and treating the two alike would let an ordinary replica lag wedge every cursor indefinitely. Unset ⇒ receipts are written unverified and the digest only proves the ledger is self-consistent, which does not catch a truncated eth_getLogs that returned HTTP 200 with half the range. Logged once per process at startup either way.
INGEST_NATIVE_FEEDnoon0 turns off the ingester's native-ether block feed (how). Off, no native-eth row is written, the stream's cursor stops (the hourly alarm's feed-lag arm pages on it), and a wallet the feed covers waits in its reading audit until the feed is back (then gives up at the six-hour horizon, as any deferred audit does). An emergency switch, not a configuration: once the stream's rollout marker exists, the ingested tip every wallet's derivation and "Updated" stamp follow waits for the feed's cursor, so turning the feed off holds them all. Delete the native-eth '*' marker first (the tip then ignores the stream), and stamp it again once the feed is back and caught up.
INGEST_NATIVE_MAX_BLOCKSno64Blocks one native-ether feed step reads at most, so an ingester that was down catches up over a few cycles instead of stalling one. At the tip a step reads about five.
INGEST_NATIVE_CONCURRENCYno4Blocks the native-ether feed reads in parallel (at most two calls each).
INGEST_NATIVE_CU_PER_SECno2000The native-ether feed's throughput budget, in the provider's throughput compute units per second (Alchemy weighs eth_getBlockReceipts 500, the feed's other two methods 20; the sizing). A token bucket for the life of the process: at the tip it never binds, and a catch-up spreads over seconds instead of bursting past the plan's per-second cap. Size it under the plan's cap with the other streams' share left over (pay-as-you-go 10,000, which the default is sized for; the free tier 300 to 500). 0 turns the pacer off.
INGEST_REORG_SWEEP_MAXno256How many unfinalized block headers the ingester's reorg sweep re-reads per cycle. The real working set is the blocks between the finalized head and the scan tip (tens); the cap bounds the abnormal case (a long outage, a provider whose finalized tag stopped advancing). The remainder is swept next cycle, lowest height first.
INGEST_IMPAIRMENT_WINDOWno250000How many blocks the ingester's Morpho market-impairment pass derives per cycle. Bounded for the same reason the catch-up is: the first pass on a fresh box faces the whole history at once and each bad-debt event it finds costs two archive market(id) reads, so a window lets it drain over a handful of cycles instead of stalling one for minutes. At the tip the window is never the binding constraint — the morpho stream's own cursor and coverage certificate are. See the impairment pass for the pass, its refusal behaviour and the line to grep for.
JIT_LEDGER_LAG_BLOCKSno40How far the event ledger's tip may trail head for the portfolio page's JIT positions fast path (recompose a wallet's positions from its stored legs instead of a full read). The default keeps the path OFF, because the ingester's tip always trails head by at least 64 blocks. Production sets 200 (on); staging should match production, though there the path can only engage right after an attended ingester hand-run, because staging runs no ingester process and its ledger tip otherwise trails head by far more than 200 blocks. Even when on, a wallet holding any bare-token or native balance, a Fluid position, or any position change since its last snapshot still full-reads, and the un-ingested tail is chain-scanned first. See JIT positions fast path.
PORTFOLIO_LIVE_COOLDOWN_MSno300000The per-wallet cooldown of the portfolio page's live refresh, page load and the Synchronize button alike. Inside the window the server answers refreshed:false with cooldownUntil, the button shows when it is available again, and the page keeps serving the wallet's stored live tip. Read at call time, like the other portfolio knobs. See Portfolio.
MARK_READ_CONCURRENCYno24How many per-key mark reads one valuation pass issues at once: the derive mark resolver's mirror bars, redemption rates, decimals() and PT rates (src/lib/portfolio/derive/marks.ts), and the liquidation-numeraire bar reads. Bounds archive fan-out at whale scale; the results are identical at any setting. Named VALUE_FLOWS_RPC_CONCURRENCY before #909 (neither environment set it).
COINGECKO_API_KEYlive prices—CoinGecko Demo key, sent as x-cg-demo-api-key. Drives the live price of every market-priced asset (page load + Synchronize + the 6h check), the hourly history that fills a hole the tape never printed, and the weekly reported-volume reading the category test's volume bar is judged on (one call per measured row, about a hundred a month). Unset ⇒ the same host is called keyless at a much lower rate limit, logged once per process; a rate-limited or failed call degrades to DefiLlama and then to the stored bar, never to an error. Never committed: it lives only in each server's .env.local.
KYBER_CLIENT_IDnocredditx-client-id header on KyberSwap quote requests
VAULTS_FYI_API_KEYtooling only—scripts/sync-curator-vaults.ts discovery; not read by the app or any cron
COINGECKO_API_KEYno—Opens the keyed door to the pool-reserves read behind the pricing categories' market limb (x-cg-demo-api-key on api.coingecko.com/api/v3/onchain), at 30 requests a minute. Unset OR refused, the weekly job falls back to the keyless GeckoTerminal endpoint at four a minute, which is what that door measurably allows — the pass still completes, and it is paced ONE REQUEST PER PAGE rather than per token: about six minutes when all 23 rows answer on page 1, about nine on the 2026-09-22 shape (eleven rows needing a second page), and about seventeen at the three-page ceiling of 69 requests, against about one minute keyed. The weekly volume read rides on the same paced stream, one call per measured row, so the pass is about twice those figures. Both together cost about two hundred calls a month against the Demo plan's ten thousand.
DUNE_API_KEYmirror—Dune API key for the token_price_bars mirror (6h token-basis sync + backfill + fetch-through). Unset ⇒ the mirror degrades to DB-only reads (no error).
DUNE_QUERY_ID_HOURLYmirror—Saved Dune prices.hour query id (query 8033816; params tokens CSV, from_iso, to_iso). The WINDOW path: sync / preload / fetch-through. Unset degrades to DB-only reads.
DUNE_QUERY_ID_SUSDEmirror—Saved Dune dex-ratio query id for sUSDe (query 8283549; same params as the hourly query: tokens CSV, from_iso, to_iso). sUSDe's aggregated-exchange quote is rounded to the whole cent, so dexRatioSources() routes it here instead. Unset ⇒ sUSDe is skipped with a log, never folded back onto the coarse query (no bar beats a known-wrong bar).
DUNE_DEX_RATIO_MIN_RESYNC_SECONDSno43200 (12h)Re-execution cooldown for every routed dex-ratio token. Because that query's cost is FLAT in the window (one dex.trades month partition either way), this is purely a freshness/spend dial: 6h ⇒ ~230 credits/month, 12h ⇒ ~115, 24h ⇒ ~57, at ~0.35 / 0.7 / 1.4bps of extra basis drift. 12h is the widest setting still at BAR_STALE_SOFT. 0 disables the cooldown. Window-scoped, and bypassed by allowLongWindow, so it can never mask a real gap or short-circuit the bulk load / re-source repair.
DUNE_MAX_CREDITS_PER_RUNno50Per-process Dune credit ceiling; the client stops issuing further executions once a run reaches it (returns partial).
DUNE_MAX_CONCURRENTno2Max Dune executions in flight at once, process-wide. A burst beyond it queues instead of 429ing.
DUNE_RATE_LIMIT_COOLOFF_MSno60000How long a 429 pauses this process's Dune executions (the rate-limit breaker). 0 disables it and every execution is issued, 429s and all.
DUNE_EXECUTION_SPACING_MSno1000Minimum gap between consecutive Dune execution STARTS, so even a serial burst is paced rather than rapid-fired. 0 disables the spacing.

DefiLlama, NY Fed, Morpho Blue, Euler/Goldsky, Kyber, the Fluid API, and Google Fonts are all keyless, so they need no env vars. CoinGecko is the one price vendor that takes a key, and it degrades rather than fails without one. DUNE_API_KEY + DUNE_QUERY_ID_HOURLY / DUNE_QUERY_ID_SUSDE drive the token_price_bars mirror (the token-basis refresher + the bulk load); with the key or the relevant query id unset the mirror degrades to DB-only reads rather than erroring. On the server these live in .env.local (gitignored, so a deploy git reset --hard preserves them); the cron wrapper scripts/run-cron.sh loads the same env before invoking a refresher.


Deploy / verification caveat ​

The CI deploy is code-only (git reset --hard + npm ci + npm run build

  • pm2 restart); it does not run migrations, backfills, or refreshers. Pages are prerendered at build with ISR revalidate of 1800/3600s, so if a refresher runs after a deploy build, re-run the deploy to re-prerender or data stays stale up to the revalidate window. See Data pipeline and Deployment.

Two things to know when verifying a live deploy:

  • Egress firewall. This dev environment's egress sits behind an SSL- inspecting firewall that blocks creddit.xyz (you get TLS errors). You cannot curl / WebFetch prod from here; rely on the green deploy workflow + local route validation, and ask a human to confirm the live URL.
  • Early-access gate. src/components/EarlyAccessGate.tsx overlays the whole app until a code is entered. It is a soft gate (friction + early-access signal, code ships in the client bundle); the page content is still server-rendered underneath so SEO/structured data is unaffected. It blocks human visual access, not crawlers or the data path, so it does not affect any external dependency or refresher.

Anthropic API (AI assistant) ​

The Creddit Agent assistant calls the Anthropic Messages API through the Vercel AI SDK (ai + @ai-sdk/anthropic), server-side in src/app/api/chat/route.ts. The assistant has no UI: its release is deferred and every surface was removed (see architecture), leaving the API route reachable only by a hand-rolled request. Nothing in the app calls it, so this dependency is dormant wherever CHAT_ENABLED is left unset.

Used forStreaming the tool-using chat response over the creddit intelligence layer.
ConfigANTHROPIC_API_KEY (secret), CHAT_MODEL (default claude-sonnet-5), CHAT_ENABLED (kill switch), plus CHAT_SESSION_SECRET and the CHAT_*_TOKEN_BUDGET caps. See deployment.
Failure modeThe route returns 503 if CHAT_ENABLED is off or the key is missing; the rest of the app is unaffected (it does not depend on this key). Model errors surface as an error payload on the stream, not a 500.
Cost controlPer-user + whole-app daily token budgets (chat_usage, cost-weighted), a stepCountIs(5) tool-loop cap, prompt caching on the static system prompt (1h TTL, shared across users) + a rolling 5m breakpoint in the tool loop, and a CHAT_EFFORT thinking-depth ceiling (default medium; adaptive thinking still decides per step whether to think at all; model-default omits the param for models that reject effort).
NotesThe methodology system prompt is prompts/assistant-metrics.md (a condensed derivative of docs/metrics.md), cached. Model choice is env-driven so it can change without a code deploy. Anthropic pricing: https://www.anthropic.com/pricing.

Private documentation. creddit.xyz