Skip to content

feat(zswap-da): show reference rate, offset and sponsorship threshold from /v1/quote and /v1/prices - #915

Merged
acedward merged 5 commits into
v-nextfrom
00005-token-price-service-c-frontend
Sep 3, 2026
Merged

feat(zswap-da): show reference rate, offset and sponsorship threshold from /v1/quote and /v1/prices#915
acedward merged 5 commits into
v-nextfrom
00005-token-price-service-c-frontend

Conversation

@acedward

@acedward acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 onto midnight-1 unchanged.

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 on GET /v1/quote, and a 422 NOT_SPONSORED on POST /v1/offers.

Why

A maker could not tell why an offer would or would not be sponsored to Celestia:

  • the market rate was hidden whenever "Auto Market Price" was on
  • "−x% vs market" carried no threshold, so 1% and 3% looked equally fine
  • nothing said whether the "market" was a real price or the node's deterministic demo price
  • a refusal from the batcher surfaced as an internal error

Screens

Swap (both price modes now):

Your rate · 1 WBTC = 31.526 WETH                        −2.5% vs reference
Reference · 1 WBTC = 32.3351 WETH   sponsored at ≥ 2.5% below reference
Reference: CoinGecko · 3 h ago
[ Celestia fee sponsored. Good trades get filled fast. ]

At 1% below reference:

! This price is only 1.0% below reference; sponsorship needs ≥ 2.5%.  Apply best price

For a pair the node cannot price (test tokens), the warning is suppressed — the batcher sponsors unpriced tokens — and the line reads:

Demo rate — no market price for TESTTOKENA and TESTTOKENB; test tokens are sponsored regardless

Market header gains two stats: Reference 1 WBTC = 32.335 WETH (with the source and age underneath) and Mid vs reference −3.5%; both read no reference when either token is demo-priced.

How

  • services/api.tsgetPrices(tokens) (per-pair: builds GET /v1/prices?tokens=a,b, refuses an empty list or more than 50 colours before any request — the node requires tokens and has no unfiltered form) plus the /v1/prices types; Quote gains sponsor_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.tsisMarketSource, pct1, fmtOffsetPct, fmtAge.
  • state/reference.ts (new) — describeQuote() and referenceRate(): 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 to null ("no reference"). Per-pair lookups landed in 43f0ea36 + 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_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. services/api.test.ts now pins that so it cannot regress, and the Swap error area gains the same "Apply best price" action for it.

Testing

  • bun test176 pass / 0 fail / 375 expect() across 12 files (35 new: format.test.ts, reference.test.ts, api.test.ts)
  • bunx tsc -b clean, bun run build green (including the compact contract prebuild)
  • Manual walkthrough in the browser against a throwaway stub serving the frozen API fixtures (the kernel routes are not merged yet): auto mode, manual price 1% below, a test-token pair, and both Market cases render exactly the strings above. Posting an offer and seeing the 422 in the UI needs the Lace wallet and is verified on preprod once the kernel side is deployed.

Not breaking

Additive. Against a node without the new fields the screens fall back to the pre-existing top-level source and show the rate and offset with no source line and no threshold — never a fabricated number.

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.
acedward added a commit that referenced this pull request Sep 2, 2026
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.
@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Scale amendment applied — /v1/prices is now a per-pair lookup (43f0ea36)

Follow-up to a review point on this PR: fetching the whole price table is fine for six tokens and wrong for thousands of mapped or short-lived ones. The kernel endpoint (PR effectstream/zswap-offerfiles-kernel#54) now requires ?tokens=<color>[,<color>…] — 1–50 lower-case 64-hex colours — and answers 400 VALIDATION without it. There is no unfiltered form any more, and this branch was the last unfiltered caller.

What changed

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.

acedward added a commit that referenced this pull request Sep 2, 2026
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.
@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the amendment above: 689c326c fixes a display-continuity regression the keyed cache introduced. The response was 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. The response is now held together with the key it answers, and what to show is derived: 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.

Gates on 689c326c unchanged: bun test 189 pass / 0 fail / 411 expect() across 13 files, bunx tsc -b exit 0, bun run build ✓ 9.31 s. Picked onto #916 as 7bb826df (same patch id cd00d137…).

@acedward
acedward merged commit 41b6054 into v-next Sep 3, 2026
6 checks passed
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