Skip to main content
Version: 0.5.0

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) and getQuote(B,A) are inverses of each other, and equal-symbol legs cross to 1.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:

CategorySymbolsUSD price is
fiatUSD, EUR, GBPthe fiat's FX rate vs USD (EUR → EUR/USD; USD = 1.0)
cryptoUSDC, USDT, EURCthe token's own market price in USD
zk-stablezkUSD, zkEUR, zkGBP1:1 with the underlying fiatusd(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 (PLN is 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-chain updatedAt as as_of. Two per-chain instances, each enabled only when its RPC URL is configured: Ethereum mainnet for USDC/USDT (no EURC/USD feed exists there), and Base for EURC/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_time as as_of. Covers USDC, 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_of is 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:

  1. 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);
  2. takes the median of the remaining sources' prices (the mean of the middle two when their count is even), stamping the quote's as_of with 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
  3. requires at least min_sources fresh 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/USD and FX.GBP/USD feeds; publish_time as as_of.
  • Kraken (one CEX, HTTP) — the fiat EUR/USD and GBP/USD pairs (returned under Kraken's legacy ZEURZUSD/ZGBPZUSD keys); no timestamp, so as_of is the fetch time.
  • DIA (public quotation REST) — /v1/quotation/{EUR,GBP}; uses DIA's Time as as_of. Keyless.
  • Chainlink (on-chain) — the Ethereum-mainnet EUR/USD and GBP/USD aggregators, added only when RATE_PROVIDER_ETH_RPC_URL is 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.

  • getQuote returns the latest snapshot.
  • getQuoteAt(t) returns the snapshot for the bucket containing t.

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:

  1. getQuote's timestamp is itself a bucket boundary, so getQuoteAt looks up that exact bucket; and
  2. 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 varDefaultMeaning
RATE_PROVIDER_FEE_BPS10Flat fee returned with every quote, in basis points.
RATE_PROVIDER_QUOTE_INTERVAL_SECS15Refresh cadence and getQuoteAt bucket size.
RATE_PROVIDER_HISTORY_RETENTION_SECS3600How long quote history is retained.
HOST / PORT0.0.0.0 / 8550Listen address.
RATE_PROVIDER_FIAT_FEEDliveFiat feed: live (EUR/GBP median + USD anchor) or mock (static table; opt in for local/test).
RATE_PROVIDER_CRYPTO_FEEDliveCrypto 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_URLhttps://hermes.pyth.networkPyth Hermes base URL (both feeds).
RATE_PROVIDER_KRAKEN_BASE_URLhttps://api.kraken.comKraken REST base URL (both feeds).
RATE_PROVIDER_DIA_BASE_URLhttps://api.diadata.orgDIA quotation REST base URL (fiat feed).
RATE_PROVIDER_PRICE_POLL_INTERVAL_SECS10How often each median feed polls each source.
RATE_PROVIDER_PRICE_MAX_STALENESS_SECS60Drop a source whose last fetch is older than this.
RATE_PROVIDER_PRICE_MIN_SOURCES2Minimum fresh sources required to emit a median (shared by both feeds).
RATE_PROVIDER_HTTP_TIMEOUT_SECS5Per-request HTTP timeout for the Pyth/Kraken/DIA client.
RATE_PROVIDER_PRICE_FETCH_TIMEOUT_SECS8Per-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.