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 leg —
amountInoftokenInmoves from the taker's source EPK to the maker's receive EPK; - maker leg —
amountOutoftokenOutmoves 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, sorInstays with the client. The MM verifiesamountInby decryptingamountInCiphertext(it is encrypted tomakerReceiveEpk, which the MM controls), so it never needsrIn.amountOutCiphertext = Enc(amountOut, takerReceiveEpk, rOut)— the MM must reproduce this in its maker-leg proof, so the client sharesrOut(andamountOut) with the order. That leaks nothing — the MM is the counterparty:amountOutis sent in plain, andrOutlets the MM both checkamountOutCiphertextencodes it and reproduce it for the proof. (The client, conversely, ownstakerReceiveEpkand 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 leg — atomicSwap 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.intentSaltis unused (fast-path; theUNIQUEinsert below is authoritative) andintent.expiryis in the future;HMAC(QUOTE_HMAC_SECRET, terms + intentSalt)over the providedamountIn/amountOutequalsquoteHmac— 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(undermakerReceiveEpk) and compare toamountIn·G; recomputeEnc(amountOut, takerReceiveEpk, rOut)and compare toamountOutCiphertext— a few scalar mults each, not a recovery; takerParamsmatches the intent (sender =takerEpk, recipient =makerReceiveEpk,recipientAmountCt == amountInCiphertext) and both taker auths recover to the controller oftakerEpk.
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.
- Filled —
Swapped(intentHash, …)observed. - Failed / Expired — terminal: the order could not be filled, or its
expirypassed.
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:
| Field | Notes |
|---|---|
intent_hash | PK = hashIntent(intent) |
| intent fields | tokenIn, tokenOut, four EPKs, both ciphertexts, expiry, intentSalt (random; UNIQUE — one order per quote) |
quote_hmac | the MM's HMAC over the quote terms + intentSalt |
amount_in, amount_out | recovered plaintext amounts |
rate_timestamp | the 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_out | client-supplied opening of amountOutCiphertext, for the maker-leg proof |
taker_order | taker EncryptedTransferParams + takerIntentAuth + takerTransferAuth |
fill_tx_hash | submitted atomicSwap tx |
attempts | fill attempt counter |
status | see lifecycle |
created_at, updated_at | timestamps |
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
ZKEMT—EBSwaphas no custody or withdraw path. Selection and the pending-mode operating model are covered under Liquidity model. - No hard reservation; check at fill.
mm_quoteis 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-clientmm_quoterate limits, shortexpiry, 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; default100bps, i.e. ±1%). It is a sanity band, not the exact-match check atmm_quote, so it does not break the price-lock — it caps the blast radius of a stale or forged quote (or a leakedQUOTE_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(aneb-transferproof) 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 acceptedintentHash) andmakerTransferAuth(ZKEMT EIP-712 domain, caller-bound toEBSwap, authorizes the maker transfer viaencryptedTransferWithCallerAuth).EBSwapconsumes the intent-auth nonce andZKEMTthe transfer-auth nonce — independent nonce spaces, both per the MM key. A relayer may submitatomicSwap; 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.expiryis set bymm_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'stimestamp(price-quote time, age-bounded by the MM) is a separate, earlier clock. - Inventory & rebalancing — partially deferred. The pending-mode operating model and the
clearPendingBalanceflush (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 accruedtokenInback into spendabletokenOutliquidity (a reverse swap, redeposit, or external desk). The MVP assumes the maker EPK is pre-funded in each token.