Skip to main content

ADR-026: Market-Maker Liquidity Management

StatusProposed
Date2026-06-29

Context

The market-maker fills private swaps by spending tokenOut from an encrypted balance it controls and receiving tokenIn in return. EBSwap.atomicSwap runs both legs through the same _spendLeg; the maker leg is an eb_transfer proof the MM generates, proving it knows its tokenOut balance and is sending amountOut to the taker.

Three problems make holding liquidity non-trivial:

  1. Proof staleness. The maker-leg proof's old_balance_ciphertext public input must equal the maker's on-chain encryptedBalanceOf(makerEpk) at execution time. If anything credits that balance between proof generation and on-chain inclusion — including the tokenIn the MM receives from its own concurrent fills, or an external deposit — the ciphertext changes and the fill reverts.
  2. Knowing the spendable balance. The witness needs sender_balance (the plaintext). Recovering it from the on-chain ciphertext is a discrete-log search (the kangaroo crates/solver), too slow to run per fill.
  3. Accounting + rebalancing. tokenOut depletes as the MM fills; tokenIn accumulates. The MM must track what it can spend and, eventually, rebalance.

Proposal

Single maker account

One maker EPK for all tokens. An encrypted balance is held per (token contract, EPK) — each token is its own ZKEMT — so a single EPK holds the MM's balance in every token. It serves as both makerReceiveEpk (when a token is tokenIn) and makerEpk (when it is tokenOut); EBSwap permits makerReceiveEpk == makerEpk because the two legs run on different token contracts and _spendLeg only checks source != dest within a leg. This collapses MakerIdentity's split receive / liquidity maps to a single keypair — one key to fund, track, and flush. (Throughput sharding later adds more EPKs per token; see Out of scope.)

Pending mode, always on

At setup the MM calls ZKEMT.activatePending(epk) for its EPK on every token contract. With pendingActive set, incoming credits land in pendingBalances, never the spendable encryptedBalances (ZKEMT._creditSlot). This is the pending-balance anti-griefing mechanism applied as a maker operating mode rather than an anti-griefing measure. The consequence is the key invariant: no external party can change the maker's active balance. It moves only by the maker's own actions —

  • a fill decrements active tokenOut (by amountOut),
  • a flush increments active (by the accumulated pending).

This makes the active balance interference-free and deterministically trackable, which dissolves problem (1): the proof's old_balance_ct is stable between generation and inclusion, provided the MM serializes its own balance-changing ops per token.

DB accounting, solver as reconciliation

The MM tracks each token's active plaintext balance in its DB: decrement amountOut on each successful fill, and read the witness's sender_balance straight from it — no per-fill discrete-log.

The cheap guard already in prove_maker_transfer (decrypt_to_point(old_balance_ct, sk) == sender_balance·G) catches any drift between the tracked value and the chain before the expensive proof. On a mismatch the MM falls back to the kangaroo solver to recover the true balance from the on-chain ciphertext and corrects the DB, then retries. Tracking is the fast path; the solver is the rare self-healing path.

Flush cycle

ZKEMT._clearPending adds pending into active homomorphically and zeroes pending — no proof, no transfer. Today it is only reachable via _handlePendingFlags on a transfer carrying clearPending=true (and EBSwap's fill leg hardcodes (false, false), so a fill never flushes). We therefore add a standalone ZKEMT.clearPendingBalance(epk) (controller-gated, whenNotPaused, wrapping the existing _clearPending) — completing the activatePending / deactivatePending / clearPending controller family, of which only clearPending was previously a transfer-only side effect.

The alternative considered was teaching EBSwap.atomicSwap to accept the maker leg's pending flags so a fill could flush in the same tx. Rejected: it only flushes the sent token (the tokenOut EPK), so the received tokenIn — a different EPK — still needs a standalone flush; it changes the swap-protocol ABI + the maker transfer-auth flag binding (touching ADR-024); and a flush-via-transfer needs a sender ≠ recipient sink. The standalone function is smaller, more general, and decoupled from swaps.

The MM runs a flush check every ~60s (and may trigger one after a fill): if a token's pendingBalanceOf is non-zero, it calls clearPendingBalance(epk) — a single cheap tx. pendingActive stays set across a flush — only the separate deactivatePending flag clears it — so there is no re-arming. The flushed amount equals the tokenIn the MM received via its own fills since the last flush, known from tracking, so accounting updates without a decrypt.

Concurrency

All active-balance-changing operations on a token (fills and flushes) must be serialized per token — the fill worker's claim/lock. Concurrent fills on the same tokenOut EPK would otherwise build proofs against the same old_balance_ct, and all but one would revert.

Alternatives considered

  • No pending mode — proofs race external credits (and the MM's own received tokenIn); frequent stale-balance reverts. The pending feature exists precisely to prevent this. Rejected.
  • Per-fill discrete-log to read the balance instead of DB tracking — correct, but the kangaroo solver is far too slow for the hot path. Rejected; kept only as the reconciliation fallback.
  • Separate receive / liquidity EPKs (status quo) — more accounts to fund, track, and flush, with no benefit once pending mode protects a unified account. Rejected.

Out of scope (future work)

  • Cross-token rebalancing. tokenOut depletes while tokenIn accumulates; converting accumulated tokenIn back into spendable tokenOut liquidity (a reverse swap, a redeposit, or an external desk) is a separate problem. The first cut assumes externally-funded per-token liquidity.
  • Liquidity sharding across multiple EPKs per token for fill concurrency — one EPK ⇒ serialized fills ⇒ a throughput ceiling under contention.
  • Initial funding / deposit flow and the activatePending bootstrap.

Open questions

  • clearPendingBalance freeze guard + auth variant. Confirm the new fn needs the same freeze guard as the transfer path. A clearPendingBalanceWithAuth(epk, auth) variant (mirroring activatePendingWithAuth) would allow paymaster-relayed flushes; the controller-only form is enough for the MM, which sends its own txs.
  • Maker-authority nonce management for the fill transfers (the per-controller auth-nonce bitmap in AuthorizationChecker).