feat(zswap-da): show reference rate, offset and sponsorship threshold from /v1/quote and /v1/prices - #915
Conversation
The node is gaining a real reference price per known token (kernel 00005
part A): GET /v1/prices, and GET /v1/quote reporting where each leg's price
came from plus the sponsorship threshold. This adds the client side of that
contract and the pure functions the screens will render.
- api.ts: PricesResponse / AssetPrice / TokenPrice / PriceFeedStatus and
api.getPrices(); Quote gains sponsor_discount, from_source, to_source,
prices_updated_at and the pre-existing top-level source.
These are OPTIONAL on purpose: a node that predates the price service
(preprod today, and midnight-1 until it is redeployed) omits them, and
every consumer must degrade to "no reference known" rather than read
undefined.
- format.ts: isMarketSource, pct1, fmtOffsetPct ("-2.5%" / "+1.2%", U+2212,
null when there is nothing to compare against) and fmtAge ("3 h ago").
- reference.ts: describeQuote() derives the four strings the Swap screen
needs, and referenceRate() derives a pair's rate from two token prices for
the Market header. Both are pure so they are testable without React.
The 422 NOT_SPONSORED path needed no code change - parse() already builds an
ApiError whose message is the node's reason with the numbers on .data, and
createOffer rethrows it untouched. api.test.ts now pins that so it cannot
regress into a generic "Failed to submit offer".
Tests: 35 new (format 12, reference 15, api 8). bunx tsc -b clean.
…eshold
A maker could not tell why an offer was or was not going to be sponsored: the
market rate was hidden in auto mode, "vs market" carried no threshold, and
nothing said where the price came from or how old it was.
- the reference rate line renders in BOTH price modes (renamed from "Market")
- the offset reads "-2.5% vs reference", coloured as before (at or below the
reference is the good side), and disappears entirely when there is no
reference rather than showing a fabricated 0%
- the sponsorship threshold is printed next to it, from the node's own
sponsor_discount - never a hard-coded 2.5%
- a source line says "Reference: CoinGecko - 3 h ago", or for demo-priced
pairs "Demo rate - no market price for TESTTOKENA; test tokens are
sponsored regardless"
- the not-sponsored warning carries the numbers ("This price is only 1.0%
below reference; sponsorship needs 2.5%") and is suppressed for demo-priced
pairs, which the batcher sponsors anyway
- a 422 NOT_SPONSORED from the post shows the node's reason in the error area
with the same "Apply best price" action
All the derived strings come from describeQuote(), so the screen has no
formatting logic of its own to drift.
… offset
The pair header showed last/24h/high/low/volume, all of them derived from the
book itself, so there was no way to tell whether the book was trading near the
real market or nowhere near it.
- new usePrices() hook: GET /v1/prices behind a 60 s module cache with one
shared in-flight request; a node without the route resolves to null rather
than surfacing an error
- "Reference" stat: 1 base = r quote, computed from the two per-base-unit token
prices, with the source and age underneath ("CoinGecko - 3 h ago")
- "Mid vs reference" stat: the book mid's offset from that reference
- both read "no reference" when either token is priced by the demo fallback,
and the stats row now renders even when the chart stats are unavailable
Tests: 176 pass. Build green.
Adds the backport-ledger row required by the Node-1 compatibility contract for the three commits picked from PR #915: - disposition CHERRY_PICK, applied with -x, no conflicts - the resulting maintenance SHAs, and the patch IDs proving each pick is the same patch as its source - the reason it is Node-1-safe: the change is confined to templates/zswap-da/src and every new /v1/quote field is optional, so a node that predates the kernel price service degrades to "no reference known" - evidence that no dependency bump was carried: package.json and bun.lock are the base blobs unchanged, the graph stays on @effectstream/* 0.104.0 over ledger-v8, and there is no ledger-v9 in the tree
The node's price endpoint now REQUIRES ?tokens=<color>[,<color>...] - 1 to 50
lower-case colours - and answers 400 VALIDATION without it (master plan 3a,
Q-11: fetching the whole table is fine for six tokens and wrong for thousands
of mapped or short-lived ones). The frontend was the last unfiltered caller.
- api.getPrices(tokens) builds the query from a lower-cased, de-duplicated
list, and refuses an empty one (or more than 50) client-side with a clear
Error, so the unfiltered form can never be sent by accident
- usePrices(colors, refreshKey) keys its 60 s cache and its shared in-flight
request by the SORTED COLOUR SET, so {a,b} and {b,a} are one entry, another
pair is another entry, and no pair selected fetches nothing at all
- Market passes [baseColor, quoteColor]; nothing else changes on screen
- tokenPriceOf matches a colour case-insensitively, since the query that
produced the rows was lower-cased
A 400 joins 404 as "no reference available": it resolves to null and the header
falls back to "no reference" instead of putting an error in front of a user.
Tests: 189 pass (13 new: query building, the refused empty/oversized list, and
the cache-key contract). tsc -b clean, build green.
Scale amendment applied —
|
| File | Change |
|---|---|
src/services/api.ts |
getPrices(tokens: string[]) builds ?tokens=a,b from a trimmed, lower-cased, de-duplicated list; an empty list (or more than 50) is refused client-side with a clear Error, so the unfiltered form can never be sent by accident. New exported normalizeColors() / MAX_PRICE_TOKENS. Doc comment rewritten: per-pair lookup, unknown colours are silently absent, and a 400 now joins 404 as "no reference available". |
src/state/usePrices.ts |
Cache and shared in-flight request are keyed by the sorted colour set (60 s TTL per key): {a,b} and {b,a} are one entry, a different pair is a different entry that does not evict the first, and an empty key fetches nothing. New pure pricesKey() / loadPrices() carry the whole contract, so it is testable without a renderer. |
src/screens/Market.tsx |
Passes [baseColor, quoteColor]; with no pair selected nothing is fetched. No visual change. |
src/state/reference.ts |
tokenPriceOf matches a colour case-insensitively — the query that produced the rows was lower-cased. |
Gates on 43f0ea36 (from templates/zswap-da): bun test 189 pass / 0 fail / 411 expect() across 13 files (was 176/375/12 — +13: query building, lower-case + dedupe, refused empty list, refused >50, 400 reason, the eight cache-key cases, case-insensitive lookup); bunx tsc -b exit 0; bun run build ✓ 9.85 s incl. the Compact prebuild.
Still not breaking, still no dependency change. The same commit is cherry-picked onto #916 for midnight-1.
Follow-up row for 43f0ea3 (PR #915), picked here as ddc2a8e: the template now asks /v1/prices about the selected pair instead of the whole table, since the kernel route requires ?tokens= and answers 400 without it. Provenance, the matching patch ID, the untouched 8629dda endpoint-rewriting block, the unchanged manifest/lock blobs and the 189-test evidence are in the row.
The keyed cache landed with the response held on its own, so the hook cleared it on every effect run and a Refresh past the 60 s TTL blinked the Market header through "no reference" before the new body arrived - something the unkeyed version never did. Hold the response together with the key it answers, and derive what to show: the held body while its key still matches, that key's cached body otherwise, and null - honestly "no reference" - when a newly selected pair has nothing cached yet. Tests: 189 pass, unchanged. tsc -b clean, build green.
|
Follow-up on the amendment above: Gates on |
What
The Swap and Market screens now show the reference conversion rate, the maker's offset from it, the sponsorship threshold, and where the reference came from and how old it is. A batcher/node refusal is shown as its reason instead of a generic error.
Template-only (
templates/zswap-da), additive, no dependency changes — it cherry-picks ontomidnight-1unchanged.Part C of project 00005 (token price service). Parts A and B add the kernel side in
zswap-offerfiles-kernel:GET /v1/prices, source/threshold fields onGET /v1/quote, and a422 NOT_SPONSOREDonPOST /v1/offers.Why
A maker could not tell why an offer would or would not be sponsored to Celestia:
Screens
Swap (both price modes now):
At 1% below reference:
For a pair the node cannot price (test tokens), the warning is suppressed — the batcher sponsors unpriced tokens — and the line reads:
Market header gains two stats:
Reference 1 WBTC = 32.335 WETH(with the source and age underneath) andMid vs reference −3.5%; both readno referencewhen either token is demo-priced.How
services/api.ts—getPrices(tokens)(per-pair: buildsGET /v1/prices?tokens=a,b, refuses an empty list or more than 50 colours before any request — the node requirestokensand has no unfiltered form) plus the/v1/pricestypes;Quotegainssponsor_discount,from_source,to_source,prices_updated_at.These four are optional on purpose: a node that predates the price service omits them, so this build stays correct against the current preprod API and shows "no reference known" rather than reading
undefined.state/format.ts—isMarketSource,pct1,fmtOffsetPct,fmtAge.state/reference.ts(new) —describeQuote()andreferenceRate(): every string the two screens render is derived here, pure and unit-tested, so the screens hold no formatting logic that can drift.state/usePrices.ts(new) —/v1/prices?tokens=behind a 60 s module cache keyed by the sorted colour set, one shared in-flight request per key; Market asks only for the selected pair and fetches nothing with no pair open; a 400/404 resolves tonull("no reference"). Per-pair lookups landed in43f0ea36+689c326c(Q-11: the price table is unbounded in principle, so no consumer pulls it whole).screens/Swap.tsx,screens/Market.tsx— rendering only.The
422 NOT_SPONSOREDpath needed no code change:parse()already builds anApiErrorwhose message is the node'sreasonwith the numbers on.data, andcreateOfferrethrows it untouched.services/api.test.tsnow pins that so it cannot regress, and the Swap error area gains the same "Apply best price" action for it.Testing
bun test→ 176 pass / 0 fail / 375 expect() across 12 files (35 new:format.test.ts,reference.test.ts,api.test.ts)bunx tsc -bclean,bun run buildgreen (including the compact contract prebuild)Not breaking
Additive. Against a node without the new fields the screens fall back to the pre-existing top-level
sourceand show the rate and offset with no source line and no threshold — never a fabricated number.