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.
Each leg also carries its source's own PendingBalanceFlags ({clear, enabled}) —
pending-balance management applied to that leg's sender after the transfer (see
Encrypted Balances → Pending Balance). The flags are
bound into that leg's transferAuth (the ZKEMT paramsHash), so the counterparty and the
submitting relayer cannot alter them — a mismatch reverts the whole swap. Each side chooses its
own: a taker typically passes the no-op {clear: false, enabled: false}, while a maker
consolidating received liquidity may clear (fold pending into spendable) and keep pending
armed with enabled: true.
Recommendation: both source EPKs should arm pending protection before swapping. Each leg's
proof commits to its source's current encryptedBalances; an attacker who front-runs the swap
with any incoming transfer to a source shifts that balance and invalidates the leg's proof,
reverting the swap. With pending enabled, incoming funds route to pendingBalances and the
committed balance stays stable. Arming must happen in a prior transaction — the per-leg flags
above apply only after the transfer, so they cannot protect their own swap.
Use a dedicated swap EPK. Fund a fresh, swap-purpose EPK with just the amount being swapped
rather than sourcing from a main-balance EPK. A committed source EPK is effectively tied up for
the swap window — its encryptedBalances must not change until settlement, and arming its pending
reroutes all its incoming funds — so isolating that to a throwaway EPK leaves the main account
fully spendable in the meantime, caps exposure at the swap amount, and composes with the
fresh-controller-key deniability model.
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, the rate quote timestamp, rOut, nonces, deadlines) are decimal strings; amount fields
(amountIn, amountOut) must be in 1..=2^64-1 (the transfer-circuit domain); 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: symbol, tokenOut: symbol, amountIn: string, amountOut: string, feeToken: symbol, rateQuote: RateQuote }
— feeToken is tokenIn or tokenOut (the fee side; see Pricing & fee).
Result { tokenIn: symbol, tokenOut: symbol, amountIn: string, amountOut: string, feeToken: symbol, makerReceiveEpk: Point, makerEpk: Point, expiry: string, intentSalt: string, quoteHmac: string }
The request token fields are rate-provider symbols; the MM resolves them to on-chain addresses
and binds the addresses in the quoteHmac (they are what the SwapIntent carries).
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 or reserve liquidity — the liquidity check here is advisory (there
is nothing to lock; the quote is a stateless signed salt). The binding reservation happens at
mm_submitTakerOrder. See Maker operational requirements for the
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, takerFlags: PendingBalanceFlags, 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, and binds takerFlags via its paramsHash
(so the MM must submit the same flags the client signed). 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)(rOutmust be a canonical nonzero scalar, below the BN254 scalar-field modulus (the Noir Field order), since it is stored raw for the maker-leg proof) 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.
If those pass, the MM re-prices against the live rate provider — the economic sanity band
(see Maker operational requirements) — and then atomically
reserves liquidity and inserts the order (Ordered, rOut included for the maker-leg proof).
Under a per-tokenOut lock it checks the maker can still cover amountOut given active + pending
minus what open (Ordered/Filling) orders already reserve, and rejects with an
insufficient-liquidity error if not — so an over-committed quote is refused here, not silently at
fill (see Liquidity model). The UNIQUE(intentSalt) insert rejects a quote
reused for a second order (and the race the fast-path misses).
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
└── requeue (transient failure, bounded by attempts)
- Ordered — client-built, signed order received and validated via
mm_submitTakerOrder; waiting for, or between, fill attempts. - Filling — a fill worker has claimed the order and is attempting to settle it.
- Filled — the
atomicSwapsettled (Swapped(intentHash, …)observed). - Failed — terminal: attempts were exhausted, or the maker cannot cover the order.
- Expired — terminal:
expirypassed with the order unfilled.
A transient fill failure returns the order to Ordered for another attempt (bounded by a max-attempt count); a Filling order left stale by a crashed worker is reclaimed the same way — see Fill loop. 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 — 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 live rate the sanity band priced against at submit |
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 |
Durable (PostgreSQL): maker_balances — the plaintext balance mirror, keyed by (token, epk).
The chain holds only ciphertexts, so the MM tracks each maker EPK's spendable active and
credited-but-unfolded pending balance per token as NUMERIC(78,0) (a full uint256). A fill
debits/credits these, folding moves pending → active, and the reconciler re-baselines them to
chain truth (see Liquidity model). It is also the reservation denominator:
active + pending minus open-order amountOut is what mm_submitTakerOrder gates on.
Liquidity model
The MM runs a single maker EPK for all tokens, in pending mode on each (armed once per token
contract via ZKEMT.managePendingBalance(epk, {clear: false, enabled: true})). 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, and folding credited
pending into spendable active (active += pending, homomorphic, no proof) is fill-driven, not
periodic: the fill loop folds a token's pending as part of a fill — the maker swap leg
folds in-swap (makerFlags = {clear: true, enabled: true}) when active already covers, or a
standalone managePendingBalance fold runs first when it doesn't. The maker stays armed
(enabled: true) across the fold, 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.
Tracked balances & reconciliation. The maker-leg proof needs the spendable amount as a witness,
but the chain stores only ciphertexts, so the MM keeps a plaintext mirror of each maker EPK's
active and pending per token (the maker_balances table). The mirror is bootstrapped from
chain at startup and updated by each fill; a background reconciler then periodically
re-baselines it to chain truth — reading the active + pending ciphertexts, decrypting them with the
maker key, and recovering the plaintext by discrete-log search. Both the bootstrap and the periodic
pass are the same read-decrypt-recover step, so no balance is ever configured by hand; it also
corrects drift (an external deposit to the EPK, or a fill that settled on-chain but crashed before
recording). The on-chain proof is the hard guard, so a transient mirror/chain mismatch self-corrects
on the next cycle and never causes an over-spend.
Fill loop
Filling is a background worker, decoupled from the mm_submitTakerOrder response: submit only
validates, reserves, and persists the order (Ordered); a separate loop settles it. The worker
claims one order at a time, assembles and submits its fill, and records the outcome. Every step is
driven off the database, so the loop is safe to run across multiple instances and survives a crash
mid-fill.
Claiming. Each iteration atomically claims the oldest fillable order (Ordered → Filling,
bumping attempts) while skipping rows another worker holds (SELECT … FOR UPDATE SKIP LOCKED). An
order left in Filling past a reclaim window — a worker that crashed mid-fill — is treated as stale
and re-claimed, so no order is stranded. When the queue is empty the worker idles briefly, then
polls again.
Balance strategy. The maker leg spends tokenOut from the maker EPK's active balance (the
tracked mirror from the Liquidity model). Against the order's amountOut, the
worker takes one of three paths:
activecovers → build the maker-leg proof againstactiveand submit the swap. The maker leg always carriesmakerFlags = {clear: true, enabled: true}, so it foldstokenOut's pending intoactivein the swap itself — no separate op. The fold happens after the leg's transfer, so the proof still commits to the pre-foldactive, which a stranger creditingpendingcannot invalidate;enabled: truekeeps the EPK armed.activeshort,active + pendingcovers → fold first: submit a standalonemanagePendingBalance(tokenOut, {clear: true, enabled: true})op (active += pending, homomorphic, no proof), fold the mirror, then build the maker-leg proof against the settled (folded) balance and fill.- neither covers → the order cannot be filled now →
Failed.
Settlement. A fill is a single sponsored atomicSwap — submitted as an ERC-4337 UserOp
through a bundler with a paymaster covering gas, so the fill is gasless for the MM as well as the
client. The maker-leg proof and both maker authorizations are generated immediately before
submission, against the current tracked balance, to minimise staleness. On a successful,
non-reverted receipt the worker records the fill atomically — in one DB transaction it debits
amountOut from tokenOut active, credits the received amountIn of tokenIn to pending,
folds tokenOut's pending if the maker leg folded (there was pending), and marks the order Filled
(with the tx hash).
Committing the balance moves and the status together is what makes recording exactly-once: a crash
mid-record rolls the whole thing back, leaving the order Filling with the mirror untouched, so the
retry re-records it once instead of double-applying.
Retries. A transient failure — a submit error, or an included-but-reverted op (e.g. a same-token
spend that lost the race and staled the maker proof) — requeues the order (Filling → Ordered) for
another attempt, bounded by a max-attempt count, after which it goes Failed. Because the proof is
rebuilt each attempt against the then-current balance, a stale-balance revert simply retries clean.
Already-settled guard. A fill can land on-chain but crash before it is recorded, so a re-attempt
first checks whether this order's atomicSwap already committed: the maker's intent-auth nonce is
consumed in EBSwap's authorization bitmap iff the swap executed. If it is, the worker just
records Filled instead of re-submitting into a guaranteed revert — it can't reconstruct the prior
attempt's exact balance deltas here, so it leaves the mirror to the reconciler, which re-baselines
it from chain (the balance authority).
Nonces. Both maker authorizations for a fill use the order's intentSalt as their nonce.
EBSwap's intent-auth and ZKEMT's transfer-auth nonces are independent unordered bitmaps (any
unused value works — not a sequence), so the per-order-unique salt is a safe nonce for both legs and
doubles as the settled-check key above.
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. - Advisory at quote, binding reservation at submit.
mm_quoteis free and unauthenticated, so it must not lock inventory — trivially weaponized into a book-exhaustion DoS, and there is nothing to lock (the quote is a stateless signed salt). The quote does only an advisory liquidity check; the binding reservation is atmm_submitTakerOrder, which under a per-tokenOutlock counts open (Ordered/Filling) orders'amountOutagainstactive + pendingand refuses an order it can no longer cover. So concurrent submits can't jointly over-accept, and an over-committed quote fails at submit rather than silently at fill. Still defend the quote surface with per-client rate limits and a shortexpiry, and shard liquidity across EPKs for spend throughput (concurrent spends of one token serialize on its single active balance). Refusals under contention are 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. - Fill mechanics. How the worker claims orders, picks a balance path, folds pending, submits the
sponsored
atomicSwap, and retries — plus the proof being generated immediately before submission to minimise staleness — is the Fill loop. Fills that spend one token still serialize on its single active balance, so throughput comes from sharding that token across EPKs. Three knobs are operator-configurable, defaulting to values suited to fast chains: the per-fill auth deadline (how long a submitted fill stays valid; default 300 s), the stale-Fillingreclaim window (must exceed the worst-case fill time; default 300 s), and the max attempt count before an order is failed (default 3). - Authorization. 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 viaencryptedTransferWithCallerAuthand binds the maker's ownmakerFlags). The two nonce spaces are independent, both per the MM key, and reuse the order'sintentSalt(see Fill loop). 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 its
fill-driven
managePendingBalancefold (see Liquidity model) are the baseline and in scope — the fold mutates the active ciphertext, and rides with the fill that triggers it, so it never races that EPK's own in-flight fill. 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.