Skip to main content
Version: 0.4.7

Market Maker

Overview

The Market Maker (MM) is an off-chain service that prices and fills EBSwap swaps — encrypted-balance swaps between two token instances under the same Hub, where the swapped amounts stay private on-chain. The MM controls its own encrypted liquidity EPKs (registered to the MM's own key in the Hub, not to EBSwap): it validates a client's swap quote against the zkStables Rate Provider, hands back the maker EPKs and a stateless signed quote (a random salt plus an HMAC the MM re-checks at submit), and fills the client's signed orders atomically. The client prices the swap at the rate provider, computes the amounts, and assembles and signs the swap intent; the MM validates it and submits the fill.

EBSwap is a stateless settler: it holds no funds, controls no EPKs, and stores no state. A swap is a single EBSwap.atomicSwap transaction with two symmetric legs, each a normal encrypted-balance transfer that its owner authorized to EBSwap:

  • taker legamountIn of tokenIn moves from the taker's source EPK to the maker's receive EPK;
  • maker legamountOut of tokenOut moves from the maker's liquidity EPK to the taker's receive EPK.

Both amounts are committed as ElGamal ciphertexts at quote time and bound into a single intentHash that both parties sign. Either both legs execute or the transaction reverts. The client signs its order off-chain and the MM submits the transaction, so the swap is gasless for the client and there is no on-chain setup step.

Trust model. EBSwap custodies nothing and has no owner and no maker allowlist. Each leg is authorized by the key controlling its source EPK, so neither side can be made to pay without receiving the committed counter-amount, and an EBSwap bug cannot move funds beyond a swap both parties signed. Pricing is anchored to the zkStables Rate Provider: the client sources its price there and the MM cross-checks every order against the same rate provider, so the MM cannot fill at terms other than the rate provider's. A taker is exposed only to the MM declining to fill; since nothing is escrowed, the taker "cancels" simply by letting the order expire.

zkStables Rate Provider. The price + fee source is the zkStables Rate Provider, a separate service in this repo (Rate Provider) returning { price, feeBps, timestamp }. The client queries it (getQuote) to price the swap; the MM verifies the exact quote after the fact via getQuoteAt to cross-check the order. It is queried by asset symbol (zkEUR, EURC), not token address, so the client and MM each map their swap tokens to symbols before calling. Its interface is documented under External dependencies.

Swap flow

Encryption & randomness

Both legs must reproduce, bit-for-bit, the ciphertexts committed in the intent — EBSwap rejects a mismatch. The client builds both ciphertexts (it knows both plaintext amounts: it computed amountIn and amountOut from the rate quote, and the MM validated them), choosing its own randomness:

  • amountInCiphertext = Enc(amountIn, makerReceiveEpk, rIn) — used as the recipient amount in the client's taker-leg proof, so rIn stays with the client. The MM verifies amountIn by decrypting amountInCiphertext (it is encrypted to makerReceiveEpk, which the MM controls), so it never needs rIn.
  • amountOutCiphertext = Enc(amountOut, takerReceiveEpk, rOut) — the MM must reproduce this in its maker-leg proof, so the client shares rOut (and amountOut) with the order. That leaks nothing — the MM is the counterparty: amountOut is sent in plain, and rOut lets the MM both check amountOutCiphertext encodes it and reproduce it for the proof. (The client, conversely, owns takerReceiveEpk and can decrypt to confirm its own output.)

intentHash = keccak256(abi.encode(SwapIntent)) binds the token pair, all four EPKs, both ciphertexts, expiry, and intentSalt, and both parties sign it (takerIntentAuth, makerIntentAuth). Chain and contract binding come from the EIP-712 domain separator of those authorizations, so an intent cannot be replayed across chains or EBSwap deployments.

Pricing & fee

The zkStables Rate Provider returns price (decimal, tokenOut per tokenIn) and feeBps (integer basis points). There is no separate fee legatomicSwap has exactly two legs — so the fee is folded into one of the two amounts. feeToken (one of tokenIn / tokenOut, chosen by the client) selects which side carries it. Given the rate provider price:

feeToken = tokenIn (fee on the input side): amountIn = ceil( (amountOut / price) * (1 + feeBps / 10_000) )
feeToken = tokenOut (fee on the output side): amountOut = floor( (amountIn * price) * (1 - feeBps / 10_000) )

The client fixes whichever amount it cares about and solves for the other from the relation for its feeToken; rounding goes toward the maker (amountIn up, amountOut down), so the maker never loses to rounding. Both amountIn and amountOut are plaintext known to both parties. feeToken is an off-chain quote attribute only — it is not part of SwapIntent; on-chain the two legs just move the final amountIn and amountOut.

The MM does not set the price; it cross-checks, exactly. On mm_quote it calls getQuoteAt(symbolIn, symbolOut, rateQuote.timestamp) itself (over HTTPS from the constant rate provider URL) and requires the returned quote to equal the client's submitted rateQuote — no slippage band, because the value is canonical, which is also why no quote signature is needed. It then enforces freshness (timestamp within a max age) and — using the client's feeToken to pick the relation above — checks (amountIn, amountOut) are consistent with that quote's price/feeBps. It needs feeToken because the amountIn ↔ amountOut relation differs by side. A slippage band, if the MM wants one, is now only an optional risk control against price drift since timestamp, not an authenticity check. So the MM cannot fill at terms other than the rate provider's, and both sides hold the same (amountIn, amountOut) pair before any ciphertext is built.

Quote authentication (stateless)

mm_quote persists nothing. It mints a random intentSalt and authenticates the quote with an HMAC over the agreed terms and that salt:

intentSalt = random 32 bytes
quoteHmac = HMAC(QUOTE_HMAC_SECRET, { tokenIn, tokenOut, amountIn, amountOut, makerReceiveEpk, makerEpk, expiry, intentSalt })

The MM returns both; the client puts intentSalt in the intent and signs as usual, and at submit forwards quoteHmac plus the plaintext amountIn/amountOut (off-chain — the MM is the counterparty and learns them anyway). At mm_submitTakerOrder the MM re-derives the HMAC (no lookup) from the intent's tokens / EPKs / expiry / intentSalt and the provided amounts, and requires it to equal quoteHmac. A match proves the MM issued this exact quote — amounts included, so a lie about them fails the HMAC — and that makerEpk is one of its own. It then verifies (not recovers) that the ciphertexts encode those amounts — cheap scalar-mult checks, no discrete-log.

Single-use. A quote redeems into at most one order: orders.intentSalt carries a UNIQUE constraint, so a second order reusing the salt is rejected. This keeps a quote a one-swap offer — N fills need N quotes, each re-priced against the live rate provider, so a drifted price can't be reused. On a failed/expired order the client simply re-quotes (fresh salt, fresh price).

QUOTE_HMAC_SECRET is the MM's symmetric key — a random startup value if unset (restart → re-quote, fine), or set in env to share across instances. Keep it secret: forging it mints arbitrary terms, bounded by the fill-time economic sanity check.

Endpoints

Transport is JSON-RPC 2.0 over a single POST / endpoint (method names below), except health which is GET /health. All integer fields (amountIn, amountOut, expiry, intentSalt, nonces, deadlines) are decimal strings; addresses, proofs, signatures, and quoteHmac are 0x-hex; a Point is { x, y } with 32-byte 0x-hex coordinates; an ElGamalCiphertext is { R: Point, C: Point } (compatible with the SDK's 256-hex serialized form). Field names mirror the on-chain SwapIntent.

mm_quote

The client submits its own rate-provider-priced amounts plus the rate quote it used; the MM cross-checks, assigns the maker EPKs, and returns a stateless signed quote — a random intentSalt plus a quoteHmac (see Quote authentication). It stores nothing and does not lock liquidity.

Params { tokenIn: address, tokenOut: address, amountIn: string, amountOut: string, feeToken: address, rateQuote: RateQuote }feeToken is tokenIn or tokenOut (the fee side; see Pricing & fee). Result { tokenIn: address, tokenOut: address, amountIn: string, amountOut: string, feeToken: address, makerReceiveEpk: Point, makerEpk: Point, expiry: string, intentSalt: string, quoteHmac: string }

The MM calls getQuoteAt(symbolIn, symbolOut, rateQuote.timestamp) and requires it to equal the submitted rateQuote (exact match), enforces timestamp freshness, checks (amountIn, amountOut) are consistent with the quote's price/feeBps and feeToken (which selects the amountIn ↔ amountOut relation), and confirms it currently holds ≥ amountOut of tokenOut (advisory — see below). If all pass it assigns makerReceiveEpk (taker-leg destination) and makerEpk (maker-leg source, with balance ≥ amountOut), sets an expiry, mints a random intentSalt, and computes quoteHmac = HMAC(QUOTE_HMAC_SECRET, terms + intentSalt) — persisting nothing. expiry is the deadline the client puts in the intent and until which the MM intends to fill.

mm_quote does not lock liquidity (there is nothing to lock — the quote is a stateless signed salt); it is consumed at fill, first valid order wins. See Maker operational requirements for the no-reservation rationale and defenses.

mm_submitTakerOrder

Delivers the client-built, signed swap order. No on-chain step precedes this — the client signs off-chain and the MM submits the swap.

Params { intent: SwapIntent, takerParams: EncryptedTransferParams, takerIntentAuth: Auth, takerTransferAuth: Auth, amountIn: string, amountOut: string, rOut: string, quoteHmac: string } Result { status: string }

takerIntentAuth is the EBSwap-domain signature binding hashIntent(intent) (the client's agreement to the full terms); takerTransferAuth is the ZKEMT caller-bound signature (caller = EBSwap) authorizing the taker transfer. The client supplies the plaintext amountIn / amountOut, so the MM validates with no quote lookup and no discrete-log, cheapest checks first (see Quote authentication):

  • intent.intentSalt is unused (fast-path; the UNIQUE insert below is authoritative) and intent.expiry is in the future;
  • HMAC(QUOTE_HMAC_SECRET, terms + intentSalt) over the provided amountIn/amountOut equals quoteHmac — authenticating the order and pinning the amounts (a lie fails the HMAC), so forgeries fall before any EC work;
  • the ciphertexts encode those amounts: decrypt amountInCiphertext (under makerReceiveEpk) and compare to amountIn·G; recompute Enc(amountOut, takerReceiveEpk, rOut) and compare to amountOutCiphertext — a few scalar mults each, not a recovery;
  • takerParams matches the intent (sender = takerEpk, recipient = makerReceiveEpk, recipientAmountCt == amountInCiphertext) and both taker auths recover to the controller of takerEpk.

It then stores the order (rOut included, for its maker-leg proof) and marks it fill-eligible — the UNIQUE(intentSalt) insert rejects a quote reused for a second order (and the race the fast-path misses). The economic sanity check vs the live rate provider runs at fill time, not here (see Maker operational requirements).

mm_supportedTokens

Result Token[] — tokens the MM currently makes markets for (with the Hub they belong to).

mm_swapIntentStatus

Params { swapIntentHash: "0x…" } Result { status: string } — one of the lifecycle states (see Status lifecycle below).

health (GET /health)

Result { status: string }.

External dependencies

zkStables Rate Provider

The price + fee authority — a separate service in this repo; see Rate Provider for its full design. The client prices a swap with getQuote; the MM (and anyone) verify a quote after the fact with getQuoteAt. Both live at a constant HTTPS URL, so transport integrity is enough — quotes are not signed, because getQuoteAt lets a verifier read the canonical value directly. The contract the MM depends on:

getQuote — Params { symbolIn: string, symbolOut: string } Result RateQuote = { price: string, feeBps: number, timestamp: string } — the current quote, for pricing; timestamp is the quote time; price is symbolOut per symbolIn.

getQuoteAt — Params { symbolIn: string, symbolOut: string, timestamp: string } Result RateQuote — the canonical quote as of timestamp (must equal what getQuote returned at that instant). The MM exact-matches a client's rateQuote against it; auditors / third parties use it to verify a settled swap priced fairly for its timestamp. The rate provider must retain history at least as long as the MM's freshness window (longer for the audit use case).

Status lifecycle

The MM tracks an internal status per order (there is no on-chain state to mirror — EBSwap is stateless).

Ordered ──▶ Filling ──▶ Filled
├─────▶ Failed
└─────▶ Expired
  • Ordered — client-built, signed order received and validated via mm_submitTakerOrder.
  • Filling — fill attempts in progress.
  • FilledSwapped(intentHash, …) observed.
  • Failed / Expired — terminal: the order could not be filled, or its expiry passed.

There is no Cancelled/Refunded state: nothing is escrowed, so an unfilled order simply expires.

Persistence

Quotes are stateless (signed salts, never stored), so the durable surface is small.

In-memory: rate-quote cache. A per-(tokenIn, tokenOut) ring of recent rate quotes, looked up by timestamp and retained for the freshness window, so getQuoteAt verification hits cache instead of the rate provider on the hot path; the head is the current price. Ephemeral, per instance — not in the database.

Durable (PostgreSQL): orders — the only table, created by mm_submitTakerOrder, keyed by intentHash:

FieldNotes
intent_hashPK = hashIntent(intent)
intent fieldstokenIn, tokenOut, four EPKs, both ciphertexts, expiry, intentSalt (random; UNIQUE — one order per quote)
quote_hmacthe MM's HMAC over the quote terms + intentSalt
amount_in, amount_outrecovered plaintext amounts
rate_timestampthe rate quote used, referenced by (symbolIn, symbolOut, timestamp)price/feeBps are re-derivable via getQuoteAt (snapshot them too only if audit must outlive the rate provider's retention)
r_outclient-supplied opening of amountOutCiphertext, for the maker-leg proof
taker_ordertaker EncryptedTransferParams + takerIntentAuth + takerTransferAuth
fill_tx_hashsubmitted atomicSwap tx
attemptsfill attempt counter
statussee lifecycle
created_at, updated_attimestamps

Liquidity model

The MM runs a single maker EPK for all tokens, in pending mode on each (ZKEMT.activatePending, once per token contract). An encrypted balance lives per token contract, so one EPK holds the MM's balance in every token; with pending on, incoming transfers land in that token's pending balance, never its spendable active one. A token's active balance therefore moves only by the MM's own actions — a fill spends from it; a periodic clearPendingBalance flush (active += pending, homomorphic, no proof) folds in funds received since the last flush. pendingActive stays set across a flush, so the protection is continuous. The one EPK is both makerReceiveEpk (when a token is tokenIn) and makerEpk (when it is tokenOut) — the two legs are on different token contracts, so EBSwap allows them to be the same EPK.

This is what lets the MM run swaps on a token concurrently: a swap that receives the token (as tokenIn) accrues to pending and so cannot stale-invalidate an in-flight maker proof that spends that token (as tokenOut). Concurrent spends of one token still serialize on its single active balance, so for throughput the MM shards a token across several EPKs.

Maker operational requirements

  • Liquidity selection & management. Each maker EPK is registered to the MM's own key (its Hub controller) and funded with that token directly through ZKEMTEBSwap has no custody or withdraw path. Selection and the pending-mode operating model are covered under Liquidity model.
  • No hard reservation; check at fill. mm_quote is free and unauthenticated, so it must not lock inventory — that is trivially weaponized into a book-exhaustion DoS, and there is nothing to lock since the quote is a stateless signed salt. Liquidity is consumed at fill (first valid order wins); a short EPK just reverts the fill (the client re-quotes, no loss). Defend with: per-client mm_quote rate limits, short expiry, and liquidity sharded across EPKs (e.g. round-robin / least-loaded assignment over in-flight orders) so concurrent orders don't collide. Under contention some fills fail — that is a liveness cost, never a safety one.
  • Economic sanity band (at submit). When an order arrives — before persisting it — re-price it against the live rate provider and refuse anything wildly off (a configurable band in basis points, 0..=10000; default 100 bps, i.e. ±1%). It is a sanity band, not the exact-match check at mm_quote, so it does not break the price-lock — it caps the blast radius of a stale or forged quote (or a leaked QUOTE_HMAC_SECRET). Checking at submit fails fast (nothing is stored); a maker may additionally re-check immediately before signing the maker leg for the tightest bound against rate drift between submit and fill.
  • Proof generation. The maker-leg EncryptedTransferParams (an eb-transfer proof) is generated immediately before submission, against the liquidity EPK's current balance, to minimise staleness.
  • Fill serialization & retries. Fills against the same maker EPK serialize on its mutable encrypted balance; a stale-balance revert triggers regeneration of the maker proof and a retry, up to a bounded attempt count, before expiry. Use a per-maker-EPK lock.
  • Authorization & nonces. The maker signs two authorizations per fill, symmetric to the taker's, both verified against the liquidity EPK's controller (the MM's key): makerIntentAuth (EBSwap EIP-712 domain, binds the accepted intentHash) and makerTransferAuth (ZKEMT EIP-712 domain, caller-bound to EBSwap, authorizes the maker transfer via encryptedTransferWithCallerAuth). EBSwap consumes the intent-auth nonce and ZKEMT the transfer-auth nonce — independent nonce spaces, both per the MM key. A relayer may submit atomicSwap; the MM key need not be the tx sender.
  • Key custody. One MM key both controls the liquidity EPKs (Hub controller) and signs the two fill authorizations; it starts from an env/KMS-held private key, with a path to a hardware/MPC controller (Fireblocks) later without changing the protocol surface.
  • Expiry budget. intent.expiry is set by mm_quote; it must cover the maker's fill window after the order arrives. Keep it short — until the order is filled, the taker leg spends from the client's live source EPK, so a long window raises the chance the client's balance shifts and the proof goes stale (worst case a retry, never a loss). It also bounds the price the MM is on the hook for. The rate quote's timestamp (price-quote time, age-bounded by the MM) is a separate, earlier clock.
  • Inventory & rebalancing — partially deferred. The pending-mode operating model and the clearPendingBalance flush (see Liquidity model) are the baseline and in scope. A flush mutates the active ciphertext, so — like a fill — it serializes against in-flight fills on that EPK. What the MVP still defers is the automated inventory manager: sharding a token across a pool of EPKs (to get past same-EPK fill serialization), and cross-token rebalancing — converting accrued tokenIn back into spendable tokenOut liquidity (a reverse swap, redeposit, or external desk). The MVP assumes the maker EPK is pre-funded in each token.