feat(prices): seeded asset prices, price-feed service (CoinGecko), /v1/prices, quote sources - #54
Conversation
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.
Correction pushed: USDM is a fetched asset (
|
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.
Q-11 scale amendments — bounded lookups, batched provider requests, no feed in dev
1.
|
| 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> |
200 — assets 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.
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/quoteWBTC→WETH with 0.2153. On this branchthe 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_SPONSOREDpre-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.sqlis edited in place, per this repo'sone-schema-file rule. There is no
ALTERpath and no migration chain: anexisting deployment needs a clean redeploy with a fresh database. That is
the agreed rollout for preprod, which is redeployed from
mainanyway.asset_prices(new)source ∈ seed | feed. Every price is USD (the numeraire); no asset is assumed to be worth one dollar — USDM is fetched like the restprice_feed_status(new)known_tokens+2 columnsdecimals(base units per coin, default 0) and a nullableasset_idtoken_prices+1 columnsource ∈ manual | fallback. The table now holds only operator overrides and the deterministic demo rowsSeed values, captured 2026-09-02 from CoinGecko
last_updated_atbitcoin2026-09-02T20:25:50Z)ethereumusd-coinmidnight-3(NIGHT)usdm-2(USDM — Moneta's Cardano USDM, the asset the VIA Labs bridge carries to Midnight)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-2listing in1b38a67. There is nofixedsource 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_tokensis seeded with the three colours that survive a clean redeploy:NIGHT, a USDC placeholder (
1111…1111), and the VIA Labs bridge'sMidnight-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 $1stablecoin with 6 decimals is
0.000001. The conversion is exact decimal-stringarithmetic — not
toFixed, which would render an 18-decimals token's price as0.00000000, and not doubles, which would make77387/1e8come out as0.0007738700000000001.packages/price-feed— a separate processThe node never calls CoinGecko. A new workspace package refreshes
asset_pricesonce a day and is the only holder of the API key.
Request policy (this is what the CoinGecko bill depends on): asset ids are
batched,
PRICE_FEED_BATCH_SIZE(default 50) persimple/pricerequest,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 itas failed and the other chunks continue.
Failure policy: the first
429stops the cycle where it stands, keepingeverything already written and recording the error in
/v1/prices.feed.last_error; any other per-asset failure is recorded and thecycle 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-keyheader, never as a queryparameter (query strings reach access logs, proxies and error reports), and
never reaches a log line — the startup banner prints
key=presentorkey=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-inbehind
profiles: ["prices"]. Without a key it does nothing: loop mode prints awarning at start and on every tick and idles (a non-zero exit under
restart: unless-stoppedwould be a crash loop);--onceprints the warningand exits 64.
evaluateSponsorship()— one rule, in@zswap-da/offer-guardsponsored ⇔ want_usd ≤ give_usd × (1 − discount)over all legs, with anunpricedverdict when any leg's token has no market price (no row, or only thedemo
fallbackone). Callers decide what unpriced means; the function neverguesses. B and C both call it, and the quote's
sponsoredflag 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, andpackages/node/sponsorship-agreement.test.tsruns 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) —tokensis 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 inprinciple (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_usdis a decimal string. Each entrycarries a
source:feed,seed,manual, orfallback— the last being thedeterministic 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_priceswith prices for colours nobody ever traded.GET /v1/quote— all existing fields and semantics unchanged, plussponsor_discount,from_source,to_source, andprices_updated_at(theolder of the two legs — a quote is only as fresh as its stalest side; null
when either leg is the
$1demo fallback). An unregistered colour still quotes$1without touching the database.POST/GET /v1/known-tokens— accept and returndecimalsandasset_id,both optional. An unknown
asset_idanswers400with the known ids ratherthan a
500from the foreign key.Behavioural changes to call out
fallbackrow. Previously anytoken_pricesrow won, so a token quoted once before it had a name mappingkept its colour-hash price for the life of the database.
manualrows stillwin over everything and are never rewritten.
SPONSOR_DISCOUNT = 0.025is nowSPONSOR_DISCOUNT_BPS(default 250).Basis points, not a fraction: the suggested amount is exact bigint arithmetic
and
0.025as a double is not25/1000. No behaviour change at the default.Environment
COINGECKO_API_KEY--onceexits 64, loop idlesCOINGECKO_BASE_URLhttps://api.coingecko.com/api/v3PRICE_FEED_INTERVAL_MS86400000PRICE_FEED_REQUEST_SPACING_MS1000PRICE_FEED_BATCH_SIZE50simple/pricerequestPRICE_FEED_ASSETSPRICE_FEED_MAPNAME_OR_COLOR=<asset_id>[:decimals],…, merged over the built-in name map. A malformed entry is a startup error, never a silent skipSPONSOR_DISCOUNT_BPS250/v1/prices.sponsor_discountDB_HOST/DB_PORT/DB_USER/DB_PW/DB_NAMEseed-market.tsDocumented in
deploy/.env.example,.env.preview.example,.env.mainnet.example,README.mdandAPI.md.check-env.tsis 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 ninthkernel-image entrypoint, and
deploy/down.shtears the profile down.entrypoint-common.shunsets the five new variables when blank, becauseENV.getString(x, default)treats""as a value and an empty base URL wouldmake 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 typecheckis 0 diagnostics on all three gates (a newtypecheck:price-feedwas added, so the one package nothing imports is not theone package nothing typechecks), and
bun run check:pgtypesis green.Beyond the obvious: the seeds are asserted at their exact values, because they
are a product promise and not a fixture; a
manualrow cannot be clobbered;an
INSERTwithsource 'fixed'is rejected by the CHECK; the 429 stop keeps what it already wrote andnever 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/priceswrites nothing under repeatedpolling 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):
A token registered with
decimals: 8quotes1e8base units atfrom_usd 77387— exactly one bitcoin — and lists at0.00077387. USDM lists at0.000001001(= 1.001 / 1e6).price-feed --oncewith no key exits 64 withits warning. A real
--oncecycle against CoinGecko updated all five assets inone batched request;
usdm-2came back at 1.002.Not run:
bun run test:grand(it needs a full Midnight + Celestia stack andthis 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 iswhere these seeds came from), and the preprod rollout runs
docker compose run --rm price-feed --onceas one of its own exit criteria.Unrelated pre-existing issue found in passing (not fixed here):
REAL_E1_REQUIRED_DATABASE_RELATIONSinpackages/tests/grand-e2e/solver-offerfiles-e2e.tslists
pair_stats, which000-init.sqldoes not create — the table wasdeliberately 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.