Skip to main content

ADR-024: Private Swaps via EBSwap

StatusAccepted
Date2026-05-08
Revised2026-06-16 — EBSwap is now a stateless settler: no escrow, no swap EPK, no on-chain setup, no policy/refund/cancel, no owner or maker allowlist. Both sides settle a signed RFQ order in one atomic transaction.

This ADR specifies the private EBSwap.atomicSwap path. ADR-027 later adds atomicPublicSwap; the private ABI, EBSwap domain, SWAP_INTENT_AUTH_TYPEHASH, and no-custody semantics remain unchanged. The contract-level authorization nonce bitmap and reentrancy guard are shared by both entry points.

Context

We need encrypted-balance swaps between token instances (for example zkUSD to zkEUR) without revealing amounts on-chain. The closest existing design is the zkprivacy-utxo swap implementation: a taker creates an intent, a market maker fills it, both sides are bound to the same intent hash, and the contract executes both sides atomically.

That design cannot be copied literally. zkprivacy-utxo is UTXO-based: notes and nullifiers isolate each spend, so concurrent work naturally targets distinct notes. Encrypted balances are account-based: each (ZKEMT, EPK) pair has one mutable encrypted balance ciphertext. A normal outgoing proof is a state transition from the current ciphertext to a new ciphertext. If that ciphertext changes between proof generation and submission, the proof becomes stale.

We also want swaps to remain external to token logic. ZKEMT should keep seeing "a controller-authorized encrypted transfer"; it should not know about quotes, market makers, or swap state. ZKEMT's caller-bound transfer authorization (encryptedTransferWithCallerAuth) supplies the missing primitive: the EPK's controller signs a transfer that is only valid when a specific caller relays it. A coordinator can therefore require both sides' transfers to be authorized to itself and execute them together, without ever controlling the EPKs or holding the funds.

Proposal

EBSwap.atomicSwap is a stateless atomic settlement path. It holds no funds, controls no EPKs, and creates no order or escrow state. A private swap is one transaction carrying two normal encrypted-balance transfers:

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

Each side authorizes its own leg with two signatures, both verified against the controller of that leg's source EPK:

  1. an intent authorization (EBSwap EIP-712 domain) binding the accepted intentHash;
  2. a caller-bound transfer authorization (ZKEMT encryptedTransferWithCallerAuth, caller = EBSwap) authorizing the actual transfer.

EBSwap recomputes intentHash, checks both sides signed it, checks each transfer matches the committed intent (source/recipient EPKs and amount ciphertext), and relays the two transfers. Both execute or neither does, by EVM transaction atomicity. No swap circuit, no swap-specific token entry point, no new ZKEMT state.

This is an RFQ / signed-order model: the client signs its order off-chain and the maker submits the transaction. There is no on-chain setup, no escrow, no policy, and no refund or cancel — the swap is gasless for the client, and an unfilled order simply expires.

The protocol-first MVP uses one fixed, non-upgradeable EBSwap. Replacement is a fresh deployment.

Why no escrow — the account-based staleness question

The Context's stale-proof problem is handled by keeping the swap window short, not by escrowing funds. The taker leg spends from an EPK the taker controls; if its balance changes between signing and the maker's atomicSwap landing, the proof is stale and the transaction reverts — a retry, never a loss. Nothing is locked, so the client "cancels" by letting the order expire (or by moving the funds).

A taker who wants a longer, asynchronous fill window can isolate amountIn in a fresh, self-controlled EPK and keep it pending-active, so unrelated main-balance activity doesn't invalidate the order. That isolation EPK is a user-side convenience, controlled exactly like any other user EPK — not a protocol object and never controlled by EBSwap. The earlier escrow design (an EBSwap-controlled swap EPK, on-chain setup, policy, refund/cancel) was only ever needed to give the maker a hard on-chain commitment; an RFQ maker does not need it (it controls submission timing and risks only gas), so it is removed.

Components

ComponentRole
EBSwap.atomicSwapStateless private settlement path. Verifies both sides' intent + transfer authorizations against the source EPKs' controllers, checks each transfer matches the committed intent, and relays both legs atomically. Controls no EPKs, holds no funds, and creates no order or escrow state.
SDK and maker serviceQuote negotiation, off-chain order signing (client), maker-leg proof and signatures, transaction submission (maker).

The token contracts do not learn swap semantics. They only need ZKEMT's caller-bound transfer authorization, which already exists. The MVP requires tokenIn and tokenOut to be distinct ZKEMTs registered under the same ZKHub; EBSwap checks both are ZKEMTs and that they differ. Cross-Hub swaps are deferred.

Flow

  1. Client asks the maker for an exact quote off-chain. The accepted quote fixes both encrypted amount ciphertexts. Both parties verify off-chain that the ciphertexts encode the quoted plaintext amounts for their receiving EPKs.
  2. Client builds its taker-leg transfer (spending amountIn from its source EPK to the maker's receive EPK) and signs the two taker authorizations — intent (binds intentHash) and caller-bound transfer (caller = EBSwap). It sends the signed order to the maker. No on-chain step.
  3. Maker generates its maker-leg proof immediately before submission, signs its two authorizations, and submits EBSwap.atomicSwap before expiry.
  4. EBSwap verifies everything and relays both legs atomically. If either proof is stale or any authorization is invalid, the whole transaction reverts; the client retries or the order expires.

Intent binding

Amounts remain private on-chain. Maker and taker know exact amountIn and amountOut; observers see token contracts, EPKs, ciphertexts, and intentHash. The MVP does not hide the funding graph — the taker leg's source EPK is visible as the swap source.

intentHash commits to the accepted quote, not to mutable account state:

amountInCiphertext = Enc(amountIn, makerReceiveEpk)
amountOutCiphertext = Enc(amountOut, takerReceiveEpk)

takerLeg = (tokenIn, takerEpk, makerReceiveEpk, amountInCiphertext)
makerLeg = (tokenOut, makerEpk, takerReceiveEpk, amountOutCiphertext)

intentHash = H(takerLeg, makerLeg, intentSalt, expiry)

intentHash carries no chain or contract domain of its own; it is a pure identifier of the economic terms. Chain and contract binding comes from the EIP-712 domain separator of every authorization that carries intentHash (takerIntentAuth, makerIntentAuth), so an intentHash cannot be replayed across chains or EBSwap deployments.

Both transfer ciphertexts are fixed at quote acceptance. The taker and maker proofs are generated later with the corresponding transfer randomness, and the prover layer checks that the proof public input matches the accepted ciphertext. Each recipient can verify off-chain that its ciphertext matches the quoted plaintext amount when the quote package includes the encryption randomness (or it decrypts with its own receiving key).

intentHash deliberately excludes current-balance ciphertexts, new-balance ciphertexts, and proofs — those depend on live balances and fresh randomness. EBSwap.atomicSwap recomputes intentHash from the submitted intent, so a caller cannot present an intentHash disconnected from the terms being executed.

Authorization model

The two legs are fully symmetric — the same code path runs for taker and maker. For each leg, with signer = controllers(sourceEpk):

transferParamsHash = H(EncryptedTransferParams)
intentAuth = H("SwapIntentAuth", intentHash, transferParamsHash, nonce, deadline) // EBSwap domain, verified by EBSwap
transferAuth = ZKEMT.encryptedTransferWithCallerAuth digest, caller = EBSwap // ZKEMT domain, verified by ZKEMT

Each side needs both signatures, and they cannot collapse to one:

  • the transfer auth authorizes the actual spend, but its ZKEMT digest is fixed and cannot carry intentHash, so it does not commit the signer to what they receive (the counter-leg);
  • the intent auth commits the signer to the full intentHash (both legs / the price). Without it, a counterparty could pair a valid leg with an under-priced counter-leg and drain the signer.

transferParamsHash pins the exact transfer parameters; tokenOut/tokenIn are not repeated because they are committed by intentHash. The two nonces live in separate spaces (EBSwap and ZKEMT), both keyed by the source EPK's controller. Each leg is useless as standalone ZKEMT calldata: the transfer auth is bound to caller = EBSwap, so it executes only inside atomicSwap. EBSwap does not implement ERC-1271 and is not the controller of any swap EPK — both legs are caller-bound token-domain transfers signed by the parties' own controllers.

Anyone may submit atomicSwap (it is gated entirely by the four signatures), but in practice the maker submits, since it is the party that has both sides' authorizations. Whoever can sign for a source EPK's controller is trusted only for that EPK's own funds; the private path custodies nothing, so it cannot move funds beyond a swap both parties signed.

Briefly:

EBSwap.atomicSwap(intent,
takerParams, takerIntentAuth, takerTransferAuth,
makerParams, makerIntentAuth, makerTransferAuth)
-> check expiry, both tokens are distinct same-Hub ZKEMTs
-> for each leg: check the transfer matches the intent, verify the intent auth against
the source EPK's controller, then execute the caller-bound transfer

Pending and dust

Each party manages the pending-balance protection of its own source EPK directly through ZKEMT; EBSwap supplies fixed non-clearing pending flags on the transfers it relays and never manages pending state. A taker isolating amountIn in a fresh EPK keeps that EPK pending-active so incoming dust lands in pending instead of mutating the main ciphertext its proof depends on. Same-token swaps are out of scope.

Deferred

  • private slippage inequality (amountOut >= minAmountOut) inside a circuit
  • cross-Hub swaps

Consequences

Easier:

  • No new Noir circuits, verifiers, or public-input layouts.
  • No swap-specific state or entry points in ZKEMT; reuses the existing caller-bound transfer authorization.
  • The private settlement path is stateless — no escrow, setup, policy, refund, or cancel code — so its audit surface is small.
  • No escrow: the client's funds are never locked, and the swap is gasless for the client (the maker submits).
  • The two legs are symmetric — one settlement path, no taker/maker special-casing.
  • The private path custodies nothing, so it cannot move funds beyond a swap both parties signed.
  • Multi-maker and concurrent swaps are inherent: each leg is authorized by its own source-EPK controller; no registry, no shared policy state.
  • Atomicity is provided by one EBSwap.atomicSwap transaction.

Harder:

  • Fills are effectively synchronous: the taker leg spends from a live EPK, so a long gap between signing and fill risks a stale proof (a retry, never a loss). A taker wanting an async window must isolate amountIn in a fresh self-controlled EPK.
  • The maker bears a griefing risk — the client can invalidate an order by moving funds before the maker's transaction lands — bounded to wasted gas, never fund loss; mitigated by short deadlines and prompt fills.
  • Four signatures per swap (an intent auth and a transfer auth per side).
  • Off-chain services must verify that accepted ciphertext commitments match the quoted plaintext amounts; the token contracts only see ciphertexts.
  • Maker service orchestration is part of the feature, not optional polish.

Unchanged:

  • Existing eb-transfer circuit is reused.
  • Existing encrypted-balance transfer proof shape and public inputs are reused for the balance transition.
  • ZKHub/ZKEMT do not need to understand swap terms.