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 | 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 | 1:1 with the underlying fiat — usd(zkEUR) = usd(EUR) |
PLN/zkPLN are not quotable — the rate provider serves only the fiat symbols above.
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(PLNis unsupported); 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. A PriceFeed::usd_price returns a
{ price, as_of } quote: the price plus the upstream instant it was valid as of, so an
aggregator can reason about staleness rather than trusting every successful read equally.
Each feed has two implementations, selected by config (RATE_PROVIDER_FIAT_FEED /
RATE_PROVIDER_CRYPTO_FEED): a mock in-memory table (opt in, no network), or the live median
feed described below — the default for both. Swapping one for the other is a one-line change at
startup; nothing else in the service depends on the concrete feed. Both medians share the same
polling/aggregation machinery and the same tuning knobs (RATE_PROVIDER_PRICE_*).
Crypto median feed
The live crypto feed medians several independent sources, each itself a PriceFeed covering the
stablecoins it can serve:
- Chainlink (on-chain) — reads
latestRoundData()and uses the on-chainupdatedAtasas_of. Two per-chain instances, each enabled only when its RPC URL is configured: Ethereum mainnet forUSDC/USDT(no EURC/USD feed exists there), and Base forEURC/USD (which exists on Base, not mainnet). EURC's USD market price is global, so the cross-chain read is valid. - Pyth (Hermes HTTP) — uses the feed's
publish_timeasas_of. CoversUSDC,USDT,EURC. - Kraken (one CEX, HTTP) — chosen because it lists all three directly against fiat USD (not
USDT, which drifts from USD and would bias the cross). The Ticker has no timestamp, so
as_ofis the fetch time.
With both Chainlink legs configured, every symbol has three independent sources — Pyth, Kraken, and a Chainlink reading — so the median outvotes any single compromised source rather than being dragged toward it (which a two-source mean cannot resist).
The median feed polls every source in the background and caches each source's latest quote with the time it was fetched; quotes are served from the cache, never by querying a source on the request path. Within a round the sources are polled concurrently, and each poll is bounded by a per-source fetch timeout (which notably caps the on-chain Chainlink RPC read, otherwise unbounded) — so one slow or rate-limited endpoint fails only its own leg (which then ages out) rather than stalling the round. On each request it:
- drops stale sources — any whose last successful fetch is older than
max_staleness(this is how a dead or erroring provider is ignored: its cache entry stops advancing and ages out); - takes the median of the remaining sources' prices (the mean of the middle two when their
count is even), stamping the quote's
as_ofwith the oldest contributing source's last successful fetch time — so a cross is only as fresh as its most-recently-confirmed stalest leg, not an upstream publish time (a slow-heartbeat on-chain feed can report a value that is valid yet stamped hours ago); and - requires at least
min_sourcesfresh quotes, else the symbol has no price for that refresh (the last good snapshot keeps serving) rather than publishing a thinly-sourced number.
With min_sources at its default of 2, a symbol quotes only when at least two sources corroborate
each other — a single surviving source is never published alone. Every symbol has at least two
sources (Pyth + Kraken) without any Chainlink leg configured, so the default is satisfiable
out of the box; the trade-off is that a symbol stops quoting (the last good snapshot keeps serving)
once it is down to one fresh source. Lower it to 1 to favour availability over corroboration.
Fiat median feed
The fiat feed uses the same median machinery over its own FX sources, covering EUR and GBP
(both quoted USD-per-unit, so no inversion):
- Pyth (Hermes HTTP) — the
FX.EUR/USDandFX.GBP/USDfeeds;publish_timeasas_of. - Kraken (one CEX, HTTP) — the fiat
EUR/USDandGBP/USDpairs (returned under Kraken's legacyZEURZUSD/ZGBPZUSDkeys); no timestamp, soas_ofis the fetch time. - DIA (public quotation REST) —
/v1/quotation/{EUR,GBP}; uses DIA'sTimeasas_of. Keyless. - Chainlink (on-chain) — the Ethereum-mainnet
EUR/USDandGBP/USDaggregators, added only whenRATE_PROVIDER_ETH_RPC_URLis set (the same RPC as the crypto USDC/USDT leg).
Without the Chainlink leg every symbol still has three independent sources (Pyth + Kraken + DIA),
so the default min_sources = 2 is satisfiable out of the box; with it, four. USD is not fetched —
it is mounted as the structural 1.0 anchor (the numéraire), which also backs zkUSD. PLN has no
free real-time source and is unsupported; USD/PLN-style feeds on Pyth/Chainlink either don't
exist on mainnet or are unpopulated, so it is omitted rather than sourced from a daily reference rate.
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 / 8550 | Listen address. |
RATE_PROVIDER_FIAT_FEED | live | Fiat feed: live (EUR/GBP median + USD anchor) or mock (static table; opt in for local/test). |
RATE_PROVIDER_CRYPTO_FEED | live | Crypto feed: live (median) or mock (static table; opt in for local/test). |
RATE_PROVIDER_ETH_RPC_URL | (unset) | Ethereum RPC for the Chainlink legs — crypto USDC/USDT and fiat EUR/GBP; unset disables both. |
RATE_PROVIDER_BASE_RPC_URL | (unset) | Base RPC for Chainlink EURC/USD (EURC's 3rd source); unset disables it. |
RATE_PROVIDER_PYTH_BASE_URL | https://hermes.pyth.network | Pyth Hermes base URL (both feeds). |
RATE_PROVIDER_KRAKEN_BASE_URL | https://api.kraken.com | Kraken REST base URL (both feeds). |
RATE_PROVIDER_DIA_BASE_URL | https://api.diadata.org | DIA quotation REST base URL (fiat feed). |
RATE_PROVIDER_PRICE_POLL_INTERVAL_SECS | 10 | How often each median feed polls each source. |
RATE_PROVIDER_PRICE_MAX_STALENESS_SECS | 60 | Drop a source whose last fetch is older than this. |
RATE_PROVIDER_PRICE_MIN_SOURCES | 2 | Minimum fresh sources required to emit a median (shared by both feeds). |
RATE_PROVIDER_HTTP_TIMEOUT_SECS | 5 | Per-request HTTP timeout for the Pyth/Kraken/DIA client. |
RATE_PROVIDER_PRICE_FETCH_TIMEOUT_SECS | 8 | Per-source poll timeout; bounds every source (incl. the Chainlink RPC read). |
The source/median rows apply only to whichever feed(s) run live; a feed set to mock ignores them.