Skip to content

feat(prices): seeded asset prices, price-feed service (CoinGecko), /v1/prices, quote sources - #54

Merged
acedward merged 7 commits into
mainfrom
00005-token-price-service-a-feed
Sep 3, 2026
Merged

feat(prices): seeded asset prices, price-feed service (CoinGecko), /v1/prices, quote sources#54
acedward merged 7 commits into
mainfrom
00005-token-price-service-a-feed

Conversation

@acedward

@acedward acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Part A of the token price service (project 00005). Quotes stop coming from a
hash of the token colour and start coming from real reference prices.

Preprod today answers GET /v1/quote WBTC→WETH with 0.2153. On this branch
the same pair answers 32.335, and it does so on a database nobody has
touched — the prices are seeded.

Parts B (the batcher's fee-sponsorship gate + the node's 422 NOT_SPONSORED
pre-check) and C (the frontend) are separate PRs. This one adds the data, the
service that refreshes it, the shared rule B will call, and the endpoints C
will read.


Schema — breaking for an existing database

packages/database/migrations/000-init.sql is edited in place, per this repo's
one-schema-file rule. There is no ALTER path and no migration chain: an
existing deployment needs a clean redeploy with a fresh database. That is
the agreed rollout for preprod, which is redeployed from main anyway.

Change
asset_prices (new) USD per coin for a tradable asset, keyed by CoinGecko id. source ∈ seed | feed. Every price is USD (the numeraire); no asset is assumed to be worth one dollar — USDM is fetched like the rest
price_feed_status (new) one row, written by the refresh service. Deliberately not seeded, so "never ran here" stays distinguishable
known_tokens +2 columns decimals (base units per coin, default 0) and a nullable asset_id
token_prices +1 column source ∈ manual | fallback. The table now holds only operator overrides and the deterministic demo rows

Seed values, captured 2026-09-02 from CoinGecko

asset usd provider last_updated_at
bitcoin 77387 1788380750 (2026-09-02T20:25:50Z)
ethereum 2393.28 1788380750
usd-coin 0.999818 1788380750
midnight-3 (NIGHT) 0.01918181 1788380780
usdm-2 (USDM — Moneta's Cardano USDM, the asset the VIA Labs bridge carries to Midnight) 1.001 1788388850 (2026-09-02T22:40:50Z)

USDM was first modelled as a fixed $1 peg; that was wrong (a stablecoin drifts and can depeg — it fetched at 1.002 nine minutes after being seeded at 1.001) and was replaced by the usdm-2 listing in 1b38a67. There is no fixed source any more.

Seeding is the point, not a convenience: a dev stack, an e2e run and a fresh
preprod all quote real ratios with the refresh service switched off. Written as
to_timestamp(1788380750) so the epoch in the plan is the literal in the SQL.

known_tokens is seeded with the three colours that survive a clean redeploy:
NIGHT, a USDC placeholder (1111…1111), and the VIA Labs bridge's
Midnight-preview USDM token type (003bacd9…7d73, unshielded, 6 decimals;
the mainnet type is in a comment). Faucet-minted colours are not seeded —
they derive from the contract address and change on every redeploy, so tokens
map to assets by name.

Prices are per BASE UNIT

Amounts everywhere in this API are integer base units and carry no decimals
metadata, so a token's price is its asset's per-coin price ÷ 10^decimals. A $1
stablecoin with 6 decimals is 0.000001. The conversion is exact decimal-string
arithmetic — not toFixed, which would render an 18-decimals token's price as
0.00000000, and not doubles, which would make 77387/1e8 come out as
0.0007738700000000001.

packages/price-feed — a separate process

The node never calls CoinGecko. A new workspace package refreshes asset_prices
once a day and is the only holder of the API key.

bun run --filter @zswap-da/price-feed once    # one cycle; exit 0 = all, 2 = partial
bun run --filter @zswap-da/price-feed start   # loop, one cycle a day
docker compose run --rm price-feed --once     # same, in deploy/ (profile: prices)

Request policy (this is what the CoinGecko bill depends on): asset ids are
batched, PRICE_FEED_BATCH_SIZE (default 50) per simple/price request,
requests at least PRICE_FEED_REQUEST_SPACING_MS (1 s) apart, one cycle a day —
today's five assets are one call a day against the demo plan's 10 000
credits a month and 100 req/min, and thousands of mapped tokens still collapse
to ceil(assets / 50) calls (e2c7a63). A failed chunk records every id in it
as failed and the other chunks continue.

Failure policy: the first 429 stops the cycle where it stands, keeping
everything already written and recording the error in
/v1/prices.feed.last_error; any other per-asset failure is recorded and the
cycle moves on. Nothing is ever deleted, and a row is only overwritten by a
successful fetch.

The key travels as the x-cg-demo-api-key header, never as a query
parameter (query strings reach access logs, proxies and error reports), and
never reaches a log line — the startup banner prints key=present or
key=ABSENT. Both are asserted by tests. Nothing secret is in this diff.

Not a development component: the service is not registered in
start.dev.ts
— development runs on the seeds. The compose service is opt-in
behind profiles: ["prices"]. Without a key it does nothing: loop mode prints a
warning at start and on every tick and idles (a non-zero exit under
restart: unless-stopped would be a crash loop); --once prints the warning
and exits 64.

evaluateSponsorship() — one rule, in @zswap-da/offer-guard

sponsored ⇔ want_usd ≤ give_usd × (1 − discount) over all legs, with an
unpriced verdict when any leg's token has no market price (no row, or only the
demo fallback one). Callers decide what unpriced means; the function never
guesses. B and C both call it, and the quote's sponsored flag already does.

The doc comment carries the algebra showing that for a single-leg pair this is
exactly the quote's existing discount ≥ SPONSOR_DISCOUNT, and
packages/node/sponsorship-agreement.test.ts runs 2000 randomised pairs —
probed at the auto-suggested amount and its neighbours, so the boundary is
exercised — asserting the two agree rather than trusting the paragraph.

USD sums are doubles on purpose and it says so: this is a policy threshold, not
a settlement amount. Settled amounts stay in bigint.

API

GET /v1/prices?tokens=<color>[,<color>…] (new) — tokens is required
(1–50 lower-case 64-hex colours); without it, or malformed, the route answers
400 VALIDATION. There is no unfiltered form: the price table is unbounded in
principle (thousands of mapped or short-lived tokens), so every consumer asks
for the colours it is looking at — the UI its pair, the batcher an offer's legs.
The body carries the sponsorship threshold, the feed's status, the assets those
tokens reference and the requested tokens that resolve to a price (unknown
colours are silently absent). price_usd is a decimal string. Each entry
carries a source: feed, seed, manual, or fallback — the last being the
deterministic demo price, not market data, which the sponsorship gate treats
as unpriced and a UI should label. Read-only: an endpoint that wrote demo rows
on read would fill token_prices with prices for colours nobody ever traded.

GET /v1/quote — all existing fields and semantics unchanged, plus
sponsor_discount, from_source, to_source, and prices_updated_at (the
older of the two legs — a quote is only as fresh as its stalest side; null
when either leg is the $1 demo fallback). An unregistered colour still quotes
$1 without touching the database.

POST/GET /v1/known-tokens — accept and return decimals and asset_id,
both optional. An unknown asset_id answers 400 with the known ids rather
than a 500 from the foreign key.

Behavioural changes to call out

  1. An asset price now beats an existing fallback row. Previously any
    token_prices row won, so a token quoted once before it had a name mapping
    kept its colour-hash price for the life of the database. manual rows still
    win over everything and are never rewritten.
  2. SPONSOR_DISCOUNT = 0.025 is now SPONSOR_DISCOUNT_BPS (default 250).
    Basis points, not a fraction: the suggested amount is exact bigint arithmetic
    and 0.025 as a double is not 25/1000. No behaviour change at the default.
  3. Quotes for mapped tokens change value — that is the point of the PR.

Environment

Variable Default Read by Meaning
COINGECKO_API_KEY price-feed Required to fetch. Header only. Absent ⇒ --once exits 64, loop idles
COINGECKO_BASE_URL https://api.coingecko.com/api/v3 price-feed Point at a stub in tests
PRICE_FEED_INTERVAL_MS 86400000 price-feed Loop period
PRICE_FEED_REQUEST_SPACING_MS 1000 price-feed Minimum gap between requests
PRICE_FEED_BATCH_SIZE 50 price-feed Asset ids per simple/price request
PRICE_FEED_ASSETS the five seeded ids price-feed Comma-separated CoinGecko ids
PRICE_FEED_MAP node + price-feed NAME_OR_COLOR=<asset_id>[:decimals],…, merged over the built-in name map. A malformed entry is a startup error, never a silent skip
SPONSOR_DISCOUNT_BPS 250 node Published in /v1/prices.sponsor_discount
DB_HOST/DB_PORT/DB_USER/DB_PW/DB_NAME as seed-market.ts price-feed Where the feed writes

Documented in deploy/.env.example, .env.preview.example,
.env.mainnet.example, README.md and API.md. check-env.ts is unchanged —
every one of these is optional.

Deploy

New opt-in compose service price-feed (profiles: ["prices"],
restart: unless-stopped, waits on the database, forwards --once), a ninth
kernel-image entrypoint, and deploy/down.sh tears the profile down.
entrypoint-common.sh unsets the five new variables when blank, because
ENV.getString(x, default) treats "" as a value and an empty base URL would
make every request a relative path.

Testing

474 pass / 2 skip / 0 fail across database, node, offer-guard, price-feed,
batcher and validator — up from 378 / 2 / 0 on main (+96 tests).
bun run typecheck is 0 diagnostics on all three gates (a new
typecheck:price-feed was added, so the one package nothing imports is not the
one package nothing typechecks), and bun run check:pgtypes is green.

Beyond the obvious: the seeds are asserted at their exact values, because they
are a product promise and not a fixture; a manual row cannot be clobbered;
an INSERT with source 'fixed' is rejected by the CHECK; the 429 stop keeps what it already wrote and
never blanks the assets it did not reach; the retry ladder is bounded and resets
on success; a database that is down does not kill the loop; a pre-00005 database
exits 64 naming 000-init.sql; /v1/prices writes nothing under repeated
polling and answers 400 without tokens.

Live check — the real components as separate processes over real sockets on
random high ports, torn down afterwards. On a fresh database, with WBTC and WETH
registered under randomly generated colours (proving the name path, since
nothing is keyed on a seeded colour):

GET /v1/quote?from_token=WBTC&to_token=WETH&from_amount=1000
  market_rate        32.33512167402059      (= 77387 / 2393.28; preprod today: 0.2153)
  from_usd           77387000
  suggested_to_amount 31526      discount 0.02502…   sponsored true
  from_source "seed"  to_source "seed"  sponsor_discount 0.025

A token registered with decimals: 8 quotes 1e8 base units at from_usd 77387 — exactly one bitcoin — and lists at 0.00077387. USDM lists at
0.000001001 (= 1.001 / 1e6). price-feed --once with no key exits 64 with
its warning. A real --once cycle against CoinGecko updated all five assets in
one batched request; usdm-2 came back at 1.002.

Not run: bun run test:grand (it needs a full Midnight + Celestia stack and
this host runs one at a time). The three static schema lists it checks were
updated and diffed against the schema by hand. Also outstanding: one live cycle
against the real CoinGecko endpoint — the key is not on the build host. The
parser is pinned to the documented response shape by 12 unit tests against a
stubbed fetch, the shape itself was verified by hand on 2026-09-02 (that is
where these seeds came from), and the preprod rollout runs
docker compose run --rm price-feed --once as one of its own exit criteria.

Unrelated pre-existing issue found in passing (not fixed here):
REAL_E1_REQUIRED_DATABASE_RELATIONS in packages/tests/grand-e2e/solver-offerfiles-e2e.ts
lists pair_stats, which 000-init.sql does not create — the table was
deliberately dropped and the frozen list was not updated. Since the assertion is
an exact match it cannot currently be satisfied. Left alone rather than mixed
into this PR.

Adds the schema half of the token price service.

asset_prices holds USD per COIN for a tradable asset, keyed by its CoinGecko
id, and ships seeded with the values captured on 2026-09-02 (bitcoin 77387,
ethereum 2393.28, usd-coin 0.999818, midnight-3 0.01918181) plus the fixed
usdm = 1 peg. Seeding is the point: a fresh stack quotes real ratios — WBTC to
WETH is 32.3, not the 0.215 the colour-hash demo price produced — with the
price-feed service switched off.

known_tokens gains decimals (base units per coin; the API prices per BASE
UNIT because amounts carry no metadata) and a nullable asset_id, and is seeded
with the three colours that survive a clean redeploy: NIGHT, the USDC
placeholder, and the VIA Labs bridge's Midnight-preview USDM token type.
Faucet-minted colours change with the contract address, so they are mapped by
NAME instead, through the new packages/database/price-map.ts.

token_prices keeps only what asset_prices cannot express: operator overrides
('manual', never rewritten by anything) and the deterministic demo rows
('fallback', written once on first quote and labelled as not-a-market-price
everywhere).

price_feed_status is the one-row table the service writes after each cycle. It
is deliberately not seeded, so "never ran here" stays distinguishable.
…tcher

evaluateSponsorship() in @zswap-da/offer-guard decides "is this offer a good
enough trade that we pay the Celestia fee for it?" once, for the three places
that ask: the quote's `sponsored` flag, the node's POST /v1/offers pre-check,
and the batcher's validateInput. Two implementations of that question would
drift silently — the UI promising sponsorship the batcher then refuses.

The rule is want_usd <= give_usd * (1 - discount) over all legs, with an
`unpriced` verdict when any leg's token has no market price (no row, or only
the deterministic demo `fallback` one). Callers decide what unpriced means;
the function never guesses.

The doc comment carries the algebra showing that for a single-leg pair this is
exactly the quote's existing `discount >= SPONSOR_DISCOUNT`, and
packages/node/sponsorship-agreement.test.ts holds 2000 randomised pairs —
probed at the auto-suggested amount and its neighbours, so the boundary is
exercised — asserting the two agree rather than trusting the paragraph.

SPONSOR_DISCOUNT's hard-coded 0.025 is replaced by SPONSOR_DISCOUNT_BPS
(default 250) in env.ts. Basis points, not a fraction: the suggested amount is
exact bigint arithmetic and 0.025 as a double is not 25/1000.
A new workspace package, @zswap-da/price-feed, and a compose service behind
the opt-in `prices` profile. It is the only process in the stack that talks to
a third party on purpose and the only holder of COINGECKO_API_KEY.

Optional by construction. The schema seeds real reference prices, so a stack
that never starts this process still quotes real ratios; without a key `--once`
exits 64 and loop mode logs one line and idles rather than crash-looping under
`restart: unless-stopped`.

Request policy, which is what the CoinGecko bill depends on: ONE asset per
request, at least PRICE_FEED_REQUEST_SPACING_MS apart, four per cycle, one
cycle a day — about 5 calls a day against a 10 000/month budget. `simple/price`
would take a comma list; it is not used, because a batched call is
all-or-nothing and one bad id would cost every asset its refresh. The first 429
stops the cycle where it stands and keeps what was already written; any other
per-asset failure is recorded and the cycle continues. `usdm` is a fixed peg
and is never requested, with the ON CONFLICT guard as the backstop.

The key travels as the x-cg-demo-api-key header and never as a query
parameter, and never reaches a log line — the startup banner says
key=present/ABSENT.

Loop schedule, exit codes, spacing and the 429 stop are covered by injected
clock/network fakes; the DB half runs against PGlite with the real migrations.
Adds scripts/price-feed-typecheck.ts so the one package nothing imports is not
the one package nothing typechecks.
The node now serves the reference prices it quotes from, and every quote says
where each side's price came from and how old it is.

GET /v1/prices returns the sponsorship threshold, the feed's status, every
asset and every priced known token. It is read-only on purpose: the batcher
polls it every ten minutes, and an endpoint that wrote demo rows on read would
fill token_prices with prices for colours nobody ever traded.

GET /v1/quote gains sponsor_discount, from_source, to_source and
prices_updated_at (the OLDER of the two sides — a quote is only as fresh as its
stalest leg; null when either side is the $1 demo fallback). Existing fields and
the top-level `source` are unchanged, and an unregistered colour still quotes
$1 without touching the database.

packages/node/prices.ts is the one place the resolution order lives: a manual
override, else the token's reference asset divided by 10^decimals, else the
deterministic demo price written once. Asset-before-existing-fallback-row is a
real behaviour change: previously ANY token_prices row won, so a token quoted
once before it had a mapping would keep its colour-hash price for the life of
the database.

Also: the grand-e2e schema lists gain asset_prices and price_feed_status, both
excluded from the determinism diff (externally sourced and per-node
respectively). API.md, FRONTEND-API-HANDOFF.md and README.md document the
endpoint, the per-base-unit rule, the name map and the price-feed service; the
"step 5 needs a price oracle" paragraph now points at it.
Every price this service serves is a USD price, and USD is the numeraire:
no asset is assumed to be worth one dollar. USDM on Midnight is Moneta's
Cardano USDM carried by the VIA Labs bridge, listed on CoinGecko as
`usdm-2` (platform cardano, homepage moneta.global) — it trades AROUND a
dollar and drifts, so it is observed like bitcoin rather than pinned.

That removes the only user of the `fixed` source, so `fixed` is deleted
outright rather than left as a dead branch:

- `000-init.sql`: `asset_prices.source` CHECK is now `IN ('seed','feed')`;
  the `('usdm', 1, 'fixed')` row becomes `('usdm-2', 1.001, 'seed',
  to_timestamp(1788388850))` — a LIVE capture taken today at 22:40:50Z,
  1.001 and not 1, which is the point. USDM's `known_tokens` row keeps its
  colour, kind and 6 decimals and now carries `asset_id = 'usdm-2'`.
- `upsertAssetPriceFeed` loses its `WHERE source <> 'fixed'` guard. It still
  RETURNs the id it wrote, and the feed now reports a zero-row write as a
  failure: with no rule left that can refuse one, that can only mean the
  schema has drifted from the code.
- `price-map.ts`: `USDM -> usdm-2`; `SEEDED_ASSET_IDS` is the five fetched
  ids. `FIXED_ASSET_IDS`/`FEED_ASSET_IDS` are gone — with nothing exempt,
  "seeded" and "requested" are one list, and two names for it could only
  drift.
- `price-feed`: no skip filter, no `skipped` result field, and `--once`
  counts every configured asset. A cycle is 5 requests a day (~150/month
  against the demo plan's 10 000 credits), not 4.
- `TokenPriceSource` / `PriceSource` drop `fixed`.
- Docs (API.md, FRONTEND-API-HANDOFF.md, README, deploy/README, the three
  env examples) say USD, five assets, and no peg.

Tests: the fixed-asset cases become usdm-2 feed cases, and the schema now
pins the removal — an `INSERT ... source 'fixed'` is rejected by the CHECK,
and a 0.94 write on the stablecoin lands as `feed` instead of being refused.
USDM's per-base-unit price is `0.000001001` (1.001/1e6), not `0.000001`.

Verified live against the real CoinGecko endpoint on a real PGlite server
(random free port, torn down): five assets requested, five updated, exit 0,
`usdm-2` fetched at 1.002 nine minutes after being seeded at 1.001.
@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Correction pushed: USDM is a fetched asset (coingecko:usdm-2), not a $1 peg — fixed removed

1b38a67 + d35f864 (a two-line doc follow-up), on top of a211563. Branch tip: d35f864. Ruled by @acedward while reviewing the seed comment "USD per coin … (or usdm, a $1 peg with no listing)".

The rule. Every price this service serves is a USD price and USD is the numeraire — no asset is assumed to be worth one dollar, stablecoins included. USDM on Midnight is Moneta's Cardano USDM carried by the VIA Labs bridge, and CoinGecko lists it as usdm-2 (platform cardano, homepage moneta.global). It trades around a dollar and drifts, so it is observed like bitcoin.

That leaves the fixed source with no users at all, so it is deleted rather than kept as a dead branch.

Schema

  • asset_prices.source CHECK is now IN ('seed', 'feed'). There is no third kind of price.
  • The ('usdm', 1, 'fixed', NULL) seed becomes ('usdm-2', 1.001, 'seed', to_timestamp(1788388850)) — a live capture taken at correction time, 2026-09-02T22:40:50Z. It is 1.001, not 1, which is the whole point.
  • USDM's known_tokens row is unchanged in colour, kind and decimals 6; only asset_id moves to usdm-2.
  • upsertAssetPriceFeed loses its WHERE asset_prices.source <> 'fixed' guard. It still RETURNING asset_id, and the feed now reports a zero-row write as a failure (kind: "not_written"): with no rule left that can refuse a write, that can only mean the schema has drifted from the code.

Code

  • price-map.ts: USDM → usdm-2; SEEDED_ASSET_IDS is the five fetched ids. FIXED_ASSET_IDS and FEED_ASSET_IDS are gone — with nothing exempt, "the seeded list" and "the requested list" are one list, and two names for it could only drift apart.
  • price-feed: no skip filter, no skipped field on CycleResult, and --once counts every configured asset. A cycle is 5 requests a day now, not 4 (~150/month against the demo plan's 10 000 credits) — this restores the original estimate.
  • TokenPriceSource (node) and PriceSource (offer-guard) drop fixed.
  • Docs: API.md, FRONTEND-API-HANDOFF.md, README.md, deploy/README.md and the three env examples now say USD, five assets, and no peg. Frontend note: the source union a client may see is feed | seed | manual | fallback (+ demo-fallback on a quote) — fixed never ships.

Tests

Fixed-asset cases became usdm-2 feed cases, and the removal is now pinned by the schema rather than by absence:

  • an INSERT INTO asset_prices … source 'fixed' is rejected by the CHECK;
  • a 0.94 write on the stablecoin lands as feed (a depeg reaches the database instead of being clamped);
  • USDM's per-base-unit price is 0.000001001 (= 1.001 / 10^6), not 0.000001.

bun test packages/database packages/node packages/offer-guard packages/price-feed packages/batcher packages/validator475 pass / 2 skip / 0 fail (was 474/2/0; +1 is the new CHECK test, everything else replaced a case). bun run typecheck 0 diagnostics on all three gates. bun run check:pgtypes green (no queries.sql change — upsertAssetPriceFeed is a hand-written prepared<>()).

Live re-run

Real start-pglite on a random free port, real migrations, then the real binary bun run --filter @zswap-da/price-feed once against the real api.coingecko.com. Torn down after.

[price-feed] provider=coingecko base=https://api.coingecko.com/api/v3 assets=bitcoin,ethereum,usd-coin,midnight-3,usdm-2 spacing=1000ms interval=86400000ms db=127.0.0.1:51551/postgres key=present
[price-feed] bitcoin usd=77039 provider_updated_at=2026-09-02T22:48:40.000Z
[price-feed] ethereum usd=2380.33 provider_updated_at=2026-09-02T22:49:00.000Z
[price-feed] usd-coin usd=0.999838 provider_updated_at=2026-09-02T22:48:10.000Z
[price-feed] midnight-3 usd=0.01946172 provider_updated_at=2026-09-02T22:49:20.000Z
[price-feed] usdm-2 usd=1.002 provider_updated_at=2026-09-02T22:49:20.000Z
[price-feed] cycle done: 5 updated, 0 failed
Exited with code 0

All five rows flipped seed → feed. usdm-2 was seeded at 1.001 and fetched at 1.002 nine minutes later — a 0.1% move on a "dollar" inside ten minutes, i.e. precisely the drift this change exists to keep visible. Still no x-ratelimit-* headers from the demo plan (unchanged), so credit usage is only readable from the CoinGecko dashboard.

The PR body's breaking-change note is unaffected: this is still an in-place 000-init.sql edit assuming a clean redeploy.

Two lines the previous commit missed: README's PRICE_FEED_ASSETS default
and the comment above the spacing variables in deploy/compose.yml both
still described the four-asset cycle from before usdm-2 was fetched.
…ed in dev

Q-11: the price surfaces were sized for six tokens. With thousands of mapped
or short-lived tokens, `GET /v1/prices` returned the whole registry and the
feed spent one CoinGecko call per asset per day. Three amendments.

1. `GET /v1/prices` REQUIRES `?tokens=<color>[,<color>...]` — 1-50 colours,
   comma-separated. Missing, empty, malformed or over-long is
   `400 VALIDATION` with a reason; there is no unfiltered form. A colour this
   node cannot price is silently absent rather than an error, because the
   batcher asks about whatever colours an offer's legs carry.

   The bound reaches the SQL too: `getTokenPriceRows` takes `token_colors`
   and a new `getKnownTokensByColors` replaces the full-registry read on the
   request path, so no query on this route can scan a table that grows with
   the chain. `assets` now carries only the assets that actually backed a
   returned price — a `manual` override beats the asset, so it contributes
   none — which makes every row in `assets` explain a row in `tokens`.

   Colours are lower-cased before validating, as `/v1/quote` already does for
   `from_token`/`to_token`: two routes in one API disagreeing about the case
   of the same 64 hex characters would be a trap.

2. The feed batches: `PRICE_FEED_BATCH_SIZE` (default 50) ids per
   `simple/price` call, chunks spaced by the existing spacing variable.
   Today's five assets are ONE call a day instead of five, and credits scale
   with ceil(assets / 50).

   Batching used to be rejected as all-or-nothing. It is not here: a 2xx body
   is parsed PER ID, so one delisted or malformed entry fails only that id.
   Only a failure of the request itself takes a chunk down, and then every id
   it carried is recorded — blaming one would be a guess. A 429 still stops
   the cycle where it stands.

3. The service is no longer registered in `start.dev.ts` at all. Development
   runs on the seeded prices, which is why they exist; starting the feed on
   every `bun run dev` spent a shared metered budget to replace correct
   numbers with slightly newer ones. Without a key it now only WARNS —
   `--once` exits 64, loop mode warns at start and on every tick and runs
   nothing, rather than idling silently forever. The compose service stays
   opt-in under `profiles: ["prices"]`.

Fallback rows are deliberately UNCHANGED: the quote path still persists the
deterministic demo price so an operator can inspect and override it.

Verified live: one batched cycle against the real CoinGecko endpoint
(`requests/cycle=1`, `cycle done: 5 updated, 0 failed in 1 request(s)`, exit
0) and the real apiRouter over HTTP for every `?tokens=` branch, both on
random free ports and torn down.
@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Q-11 scale amendments — bounded lookups, batched provider requests, no feed in dev

e2c7a63, on top of d35f864. Ruled by @acedward reviewing api.getPrices(): the price surfaces were sized for six tokens and would not survive thousands of mapped or short-lived ones.

1. GET /v1/prices requires ?tokens=

1-50 64-hex colours, comma-separated. Missing, empty, malformed or over-long is 400 { "error": "VALIDATION", "reason": … }. There is no unfiltered form — the batcher asks per offer, the UI asks for the pair on screen, and an endpoint whose cost grew with the registry served neither.

The bound reaches the SQL, not just the handler: getTokenPriceRows now takes token_colors and a new getKnownTokensByColors replaces the full-registry read on the request path, so no query on this route can scan a table that grows with the chain.

Two smaller decisions worth a reviewer's eye:

  • assets now carries only the assets that actually backed a returned price. A manual override beats the asset, so it contributes none — every row in assets explains a row in tokens.
  • Colours are lower-cased before validating, exactly as /v1/quote already treats from_token/to_token. §3a says "lower-case"; two routes in one API disagreeing about the case of the same 64 hex characters would be a trap rather than a contract. Duplicates collapse. Responses always spell them lower case.

An unknown colour is a 200 with the colour simply absent, not a 400: the batcher asks about whatever colours an offer's legs carry, so "I do not price this" is an answer.

2. The feed batches provider requests

PRICE_FEED_BATCH_SIZE (default 50) ids per simple/price call, chunks spaced by the existing PRICE_FEED_REQUEST_SPACING_MS. Today's five assets are one call a day instead of five, and credits scale with ceil(assets / 50) (SC-004).

Batching was originally rejected in this branch as all-or-nothing. It is not, and that is the point of the rewrite: a 2xx body is parsed per id, so one delisted or malformed entry fails only that id. Only a failure of the request takes a chunk down — and then every id it carried is recorded, because blaming one would be a guess. A 429 still stops the cycle where it stands.

3. The service is not part of development

start.dev.ts no longer registers price-feed at all (it used to, when a key was present). Development runs on the seeded prices — which is why they exist; starting the feed on every bun run dev spent a shared metered budget to replace correct numbers with slightly newer ones. Without a key the service now only warns: --once exits 64, loop mode warns at start and on every tick and runs nothing, rather than idling silently forever. Compose stays opt-in under profiles: ["prices"].

4. Fallback rows: unchanged, deliberately

The quote path still persists the deterministic demo price so an operator can inspect and override it in token_prices (Q-11 option B). The test that pins this now says it is a ruled decision, not an oversight.

Gates

Check d35f864 e2c7a63
bun test packages/database packages/node packages/offer-guard packages/price-feed packages/batcher packages/validator 475 pass / 2 skip / 0 fail 490 pass / 2 skip / 0 fail, 3699 expects
bun run typecheck (3 gates) 0 / 0 / 0 0 / 0 / 0
bun run check:pgtypes green green (both new queries are hand-written prepared<>())

Live checks

The feed — real start-pglite on a random free port, real migrations, the real binary against the real api.coingecko.com:

[price-feed] provider=coingecko … assets=bitcoin,ethereum,usd-coin,midnight-3,usdm-2 batch=50 requests/cycle=1 spacing=1000ms … key=present
[price-feed] bitcoin usd=77191 provider_updated_at=2026-09-02T23:15:10.000Z
[price-feed] ethereum usd=2385.92 …
[price-feed] usd-coin usd=0.999822 …
[price-feed] midnight-3 usd=0.0195956 …
[price-feed] usdm-2 usd=1.002 …
[price-feed] cycle done: 5 updated, 0 failed in 1 request(s)
Exited with code 0

requests/cycle=1 and in 1 request(s) are the SC-004 evidence. The no-key path on the same binary prints [price-feed] WARNING: COINGECKO_API_KEY is not set… and exits 64.

The endpoint — the real apiRouter on fastify over real HTTP:

Request Answer
GET /v1/prices 400 … "tokens is required: 1-50 comma-separated 64-hex token colors"
?tokens= the same 400
?tokens=1111…,nothex 400 … "tokens entry \"nothex\" is not a 64-hex token color"
51 colours 400 … "tokens accepts at most 50 colors, got 51"
?tokens=<USDC>,<USDM>,<unknown> 200assets exactly [usd-coin, usdm-2], tokens exactly [USDC, USDM], the unknown colour absent
?tokens=<NIGHT upper-case>,<NIGHT> 200 — one row, lower case, assets exactly [midnight-3]
repeated ?tokens=…&tokens=… 400 … "single comma-separated string"

bitcoin and ethereum are seeded but appear in none of those responses — the payload is now the caller's question, not the registry. Read-only confirmed: token_prices count unchanged across the unknown-colour requests. Both harnesses ran on random free ports and were torn down.

Breaking for clients: any caller of GET /v1/prices without ?tokens= now gets a 400. The frontend was updated in the same ruling (#915, #916) and the batcher in #55.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant