Rate Provider
Overview
The zkStables Rate Provider is the price + fee authority for EBSwap swaps.
It is a small stateless HTTP service (crates/rate-provider)
that answers two questions about a symbol pair: what is the rate right now (getQuote, used by
a client to price a swap) and what was the canonical rate at a past instant (getQuoteAt, used by
the Market Maker — and any auditor — to cross-check a submitted quote).
Everything is keyed by asset symbol (EUR, USDC, zkEUR), never by contract address. Token
addresses are not canonical — they differ per chain and per deployment — so mapping an on-chain
token to its symbol is the caller's job (the Market Maker knows its own tokens); the rate
provider is chain-agnostic and address-free.
Quotes are not signed. Both endpoints serve from one constant HTTPS URL, and getQuoteAt
lets any verifier read the canonical value directly, so transport integrity is sufficient and
there is no signature to manage. The Market Maker exact-matches a client's quote against
getQuoteAt — there is no slippage band on the authenticity check, because the value is canonical.
Pricing model
The rate provider tracks the USD spot price of every quotable symbol and derives a pair rate by cross:
price(symbolIn → symbolOut) = usd(symbolIn) / usd(symbolOut) // symbolOut per symbolIn
USD as the common reference (numéraire)
Every symbol is priced as USD per 1 unit of that symbol — its rate against USD. So usd("EUR")
is the EUR/USD spot rate (≈ 1.08 USD per EUR), and usd("USD") = 1.0 by definition. There is no
direct per-pair feed: a pair rate is always derived by dividing the two USD prices.
This single-reference design is deliberate:
- No N² feeds. Any pair is a cross of two USD prices, so adding a symbol needs only its USD price — not a quote against every other symbol.
- Asset classes compose. A fiat leg (
EUR, from the fiat feed) and a crypto leg (EURC, from the crypto feed) are both expressed in USD, so they cross cleanly even though different feeds produce them. - Direction is just reciprocal.
getQuote(A,B)andgetQuote(B,A)are inverses of each other, and equal-symbol legs cross to1.0.
The contract every feed must honour: usd_price(symbol) returns USD per 1 unit, normalized to
that convention. FX "EUR/USD" already means USD-per-EUR and maps directly, but a feed whose upstream
returns the inverse (units-per-USD) or quotes against a non-USD base must convert before
returning — otherwise the cross silently inverts. This normalization is the feed's responsibility,
not the cross's.
feeBps is a flat configured fee returned alongside the price; the Market Maker folds it into one
leg (see Market Maker › Pricing & fee). The rate provider itself does
no amount math — it only publishes { price, feeBps, timestamp }.
Symbol categories
Quotable symbols fall into three kinds, which determine how the USD price is sourced:
| Category | Symbols | USD price is |
|---|---|---|
| fiat | USD, EUR, GBP, PLN | the fiat's FX rate vs USD (EUR → EUR/USD; USD = 1.0) |
| crypto | USDC, USDT, EURC | the token's own market price in USD |
| zk-stable | zkUSD, zkEUR, zkGBP, zkPLN | 1:1 with the underlying fiat — usd(zkEUR) = usd(EUR) |
A zk-stable is valued as the fiat itself: zk<FIAT> pegs to <FIAT> at exactly 1.0, so
getQuote(zkEUR, EUR) is 1 and getQuote(zkEUR, zkUSD) equals EUR/USD. A crypto stablecoin
is not assumed equal to its peg: EURC is valued at its own market price, so if it depegs,
getQuote(EUR, EURC) reflects it (the two legs come from different feeds).
Price feeds (fiat and crypto)
USD prices come from two feeds behind a shared PriceFeed trait, one per priced asset class:
- a fiat feed — FX rates for
USD,EUR,GBP,PLN; and - a crypto feed — market prices for the on-chain stablecoins in use (
USDC,USDT,EURC), not volatile assets like BTC/ETH.
There is no zk-stable feed: for every fiat symbol F the fiat feed prices, the provider
publishes a zk<F> symbol at the same USD price. So zk-stables are derived, never fetched, and the
1:1 peg is structural rather than something a feed has to report.
Keeping the two feeds separate is what lets a pair cross asset classes — e.g. EUR (fiat feed)
against EURC (crypto feed) — since both legs reduce to USD. Only mock feeds ship today: static
in-memory price tables for tests and local/stage runs. The real HTTP-backed fiat and crypto feed
clients land in a later PR; swapping a mock for a real feed is a one-line change at startup and
nothing else in the service depends on the concrete feed.
Quote history (getQuoteAt)
History is an in-memory ring buffer. On a fixed cadence the service fetches a full price snapshot and records it stamped at a bucket boundary — a multiple of the quote interval.
getQuotereturns the latest snapshot.getQuoteAt(t)returns the snapshot for the bucket containingt.
Round-trip guarantee. Whenever getQuote hands a caller a { price, feeBps, timestamp },
getQuoteAt(timestamp) returns the identical quote to that caller and to anyone else — the
canonical / exact-match property the Market Maker and auditors rely on. This holds because:
getQuote'stimestampis itself a bucket boundary, sogetQuoteAtlooks up that exact bucket; and- a published bucket is immutable — once recorded it is never overwritten (first write wins), so the answer cannot drift even if the refresh cadence differs from the bucket size.
The guarantee is bounded only by retention: the buffer is trimmed to a window (which must cover
the MM's freshness window, longer for the audit use case), and a lookup for an evicted bucket
returns a no quote error. History is not persisted — it is lost on restart, after which
getQuoteAt can only answer for buckets observed since. Reproducibility beyond the retention window
(or across restarts) would require the persistent store, which is deferred.
Endpoints
Transport is JSON-RPC 2.0 over a single POST /, plus GET /api/health. Symbols are
case-insensitive strings (upper-cased internally); feeBps is an integer; other integer fields are
decimal strings, matching the SDK serialization. All timestamp fields are Unix time in
seconds (never milliseconds) — both the getQuoteAt parameter and the timestamp in every
RateQuote.
getQuote
Params { symbolIn: string, symbolOut: string }
Result RateQuote = { price: string, feeBps: number, timestamp: string }
— the current quote; price is symbolOut per symbolIn, timestamp is the bucket time.
getQuoteAt
Params { symbolIn: string, symbolOut: string, timestamp: string }
Result RateQuote — the canonical quote for the bucket containing timestamp (equal to what
getQuote returned at that instant). Errors if the bucket is outside the retained history window.
health (GET /api/health)
Result { status: "ok", symbols: string[] } — every quotable symbol (sorted).
Errors
JSON-RPC error codes: -32001 unsupported pair (a symbol is not quotable, or the two symbols are
equal), -32002 no quote available (still warming up, or timestamp outside history), -32600 bad
request (malformed params / unknown method), -32000 internal error.
Configuration
Read from the environment at startup (fail-fast: at least one feed must provide a symbol):
| Env var | Default | Meaning |
|---|---|---|
RATE_PROVIDER_FEE_BPS | 10 | Flat fee returned with every quote, in basis points. |
RATE_PROVIDER_QUOTE_INTERVAL_SECS | 15 | Refresh cadence and getQuoteAt bucket size. |
RATE_PROVIDER_HISTORY_RETENTION_SECS | 3600 | How long quote history is retained. |
HOST / PORT | 0.0.0.0 / 3002 | Listen address. |