Skip to main content

Public Swaps

This document specifies atomic swaps between one public ERC-20 leg and one encrypted ZKEMT leg. The public leg may be any EIP-3009 token that is not the swap's encrypted token, including another ZKEMT's public side. It complements the two-encrypted-leg EBSwap flow described in Swap SDK and the MM service described in docs/docs/public/specification/market-maker.md.

Scope

The MVP supports both directions:

  • public to encrypted: the taker pays a public token and receives an encrypted token;
  • encrypted to public: the taker pays an encrypted token and receives a public token.

The public amount, token, payer, and recipient are visible on-chain. The encrypted amount remains a ciphertext, although observers can infer it from the public amount and quoted rate. The flow is RFQ-based and gasless for the taker: the MM submits the completed swap through the sponsored SharedAccount.

This exchanges independently funded token contracts rather than using ZKEMT's same-token layer-transfer operations. Same-economic-asset pairs still use MM inventory and RFQ pricing.

Supported public tokens are configured off-chain and must:

  • implement EIP-3009 receiveWithAuthorization with a bytes signature;
  • use six decimals; and
  • move the exact requested amount without fees or rebasing.

The contract itself is permissionless and has no public-token allowlist.

Contract

Public swaps use the fixed, non-upgradeable EBSwap contract and its immutable Hub. The existing private atomicSwap ABI and behavior remain unchanged; the explicit atomicPublicSwap entry point settles one public and one encrypted leg. Both paths use the same contract address, EIP-712 domain (name = "EBSwap", version = "1"), authorization nonce bitmap, and reentrancy guard. The contract exposes no administration or withdrawal path.

Types

struct PublicSwapIntent {
address publicToken;
address encryptedToken;
address publicPayer;
address publicRecipient;
Point encryptedPayerEpk;
Point encryptedRecipientEpk;
uint256 publicAmount;
ElGamalCiphertext encryptedAmountCiphertext;
uint256 expiry;
uint256 intentSalt;
}

struct EIP3009Auth {
uint256 validAfter;
uint256 validBefore;
bytes32 nonce;
bytes signature;
}

The intent is direction-neutral. Its payer and recipient fields fully describe both legs; the contract does not encode taker or maker roles.

Entry point

function atomicPublicSwap(
PublicSwapIntent calldata intent,
EIP3009Auth calldata publicTransferAuth,
Auth calldata publicIntentAuth,
EncryptedTransferParams calldata encryptedParams,
PendingBalanceFlags calldata encryptedFlags,
Auth calldata encryptedIntentAuth,
Auth calldata encryptedTransferAuth
) external nonReentrant;

Anyone may submit a fully authorized swap. The transaction is non-payable.

Intent and authorization hashes

intentHash = keccak256(abi.encode(intent))

publicTransferParamsHash = keccak256(abi.encode(
RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
intent.publicPayer,
address(EBSwap),
intent.publicAmount,
publicTransferAuth.validAfter,
publicTransferAuth.validBefore,
publicTransferAuth.nonce
))

publicIntentAuth = EIP712("SwapIntentAuth",
intentHash, publicTransferParamsHash, nonce, deadline)

encryptedParamsHash = keccak256(abi.encode(encryptedParams))

encryptedIntentAuth = EIP712("SwapIntentAuth",
intentHash, encryptedParamsHash, nonce, deadline)

RECEIVE_WITH_AUTHORIZATION_TYPEHASH is the canonical EIP-3009 hash of ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce). Private and public swaps use the existing primary type exactly: SwapIntentAuth(bytes32 intentHash,bytes32 transferParamsHash,uint256 nonce,uint256 deadline). For a public intent authorization, transferParamsHash contains the EIP-3009 ReceiveWithAuthorization struct hash. For an encrypted intent authorization, it contains keccak256(abi.encode(encryptedParams)).

Both intent authorizations use the EBSwap domain (name = "EBSwap", version = "1") and are verified through AuthorizationChecker:

  • publicIntentAuth signer: intent.publicPayer;
  • encryptedIntentAuth signer: the Hub controller of intent.encryptedPayerEpk.

The contract does not inspect publicPayer.code and accepts arbitrary signature bytes. As an MVP admission policy, the SDK and MM accept only 65-byte signatures. The MM uses ECDSA recovery for code-free payers and ERC-1271 for code-bearing payers; a code-bearing EIP-7702 account is supported only when its delegate accepts the supplied 65-byte signature. Encrypted controllers retain their existing authorization behavior.

The token verifies publicTransferAuth.signature over:

ReceiveWithAuthorization(
from = intent.publicPayer,
to = EBSwap,
value = intent.publicAmount,
validAfter = publicTransferAuth.validAfter,
validBefore = publicTransferAuth.validBefore,
nonce = publicTransferAuth.nonce
)

The SDK reads the public token's EIP-712 name and uses its configured version. Native USDC uses version 2.

encryptedTransferAuth is the existing ZKEMT caller-bound authorization with caller = EBSwap. The encrypted payer chooses its own encryptedFlags; they are bound into the token-domain transfer authorization, so the submitter cannot alter them.

The SDK and MM require all authorization deadlines and EIP-3009 validBefore to equal intent.expiry. The SDK defaults validAfter to zero; the MM requires the latest block timestamp to be greater than validAfter before accepting an order. atomicPublicSwap requires block.timestamp < intent.expiry, matching the strict validBefore boundary of the public token. The private atomicSwap path retains its inclusive block.timestamp <= intent.expiry check, but AuthorizationChecker also requires block.timestamp < deadline; with deadlines set to expiry, both paths reject equality.

The AuthorizationChecker nonce bitmap is shared by both entry points. Intent-authorization nonces must therefore avoid private/public collisions for the same signer.

Validation and execution

Before external token calls, atomicPublicSwap requires:

  • publicToken, encryptedToken, publicPayer, and publicRecipient are nonzero;
  • encryptedToken is a ZKEMT registered under the immutable Hub;
  • publicToken != encryptedToken;
  • publicPayer != publicRecipient, and neither is EBSwap;
  • publicAmount is nonzero;
  • the payer and recipient EPKs are distinct;
  • encryptedParams.senderEpk == intent.encryptedPayerEpk;
  • encryptedParams.recipientEpk == intent.encryptedRecipientEpk;
  • encryptedParams.recipientAmountCt == intent.encryptedAmountCiphertext; and
  • both intent authorizations are valid and unused.

A ZKEMT's public side is a valid public leg: ZKEMT implements EIP-3009, and its public and encrypted balances are independent state. When the public token is a ZKEMT, the payer's EIP-3009 nonce shares that token's authNonces bitmap with its other authorization flows, so SDK and MM nonce selection must avoid collisions. ZKEMT remains responsible for point, registration, freeze, and proof validation on the encrypted call; the settler does not duplicate that policy.

After validating the public intent authorization, atomicPublicSwap reuses _spendLeg to validate the encrypted transfer and intent authorization, then calls encryptedToken.encryptedTransferWithCallerAuth(encryptedParams, encryptedFlags, encryptedTransferAuth). It next executes the public leg: receiveWithAuthorization fixing to = EBSwap, then forwards publicAmount to the recipient with SafeERC20.safeTransfer. The settler does not verify balance movement; exact-transfer semantics are an admission assumption on the public token (see Scope), not an on-chain guarantee. Any failure reverts authorization nonces and both legs.

EBSwap emits:

event PublicSwapped(
bytes32 indexed intentHash,
address indexed publicToken,
address indexed encryptedToken,
address publicPayer,
address publicRecipient,
CompressedPoint encryptedPayerEpk,
CompressedPoint encryptedRecipientEpk,
uint256 publicAmount
);

Tokens sent directly to EBSwap are not recoverable.

Swap flows

Public to encrypted

The public amount is amountIn; the encrypted amount is amountOut.

  1. The client obtains a rate quote and an MM quote containing the MM treasury, maker EPK, expiry, intent salt, and quote HMAC.
  2. The client chooses its registered encrypted recipient, encrypts amountOut to that EPK, and shares the ciphertext randomness with the MM so the MM can reproduce it in its proof.
  3. The client builds the intent with itself as public payer, the MM treasury as public recipient, the MM EPK as encrypted payer, and itself as encrypted recipient.
  4. The client chooses a fresh random uint256 intent-auth nonce, signs publicIntentAuth and EIP-3009 ReceiveWithAuthorization, then submits the order to the MM. It generates no proof.
  5. The MM validates the quote, signatures, unused EIP-3009 nonce, public balance, ciphertext opening, and recipient registration. It reserves encrypted output liquidity.
  6. Immediately before filling, the MM builds the encrypted transfer proof and signs encryptedIntentAuth plus encryptedTransferAuth.
  7. The MM submits atomicPublicSwap through the sponsored SharedAccount.

The taker can invalidate an accepted order by moving its public balance or consuming the EIP-3009 nonce. The fill then reverts and consumes one sponsored attempt.

Encrypted to public

The encrypted amount is amountIn; the public amount is amountOut.

  1. The client obtains a rate quote and an MM quote containing the MM treasury, maker EPK, expiry, intent salt, and quote HMAC.
  2. The client chooses an explicit public recipient and encrypts amountIn to the MM EPK.
  3. The client builds the intent with itself as encrypted payer, the MM as encrypted recipient and public payer, and its chosen address as public recipient.
  4. The client chooses a fresh random uint256 intent-auth nonce, builds the encrypted transfer proof, signs encryptedIntentAuth plus encryptedTransferAuth, then submits the order.
  5. The MM validates the quote, proof parameters, signatures, ciphertext amount, recipient, and available public inventory. It atomically reserves amountOut.
  6. Immediately before filling, the MM signs publicIntentAuth and EIP-3009 ReceiveWithAuthorization from its treasury to EBSwap.
  7. The MM submits atomicPublicSwap through the sponsored SharedAccount.

Recipient EPK registration remains a prior SDK operation; it is never bundled into swap settlement.

SDK

Public-token configuration is separate from the ZKEMT token map:

type PublicTokenConfig = {
address: EvmAddress;
eip712Name: string;
eip712Version: string;
};

The SDK validates decimals() == 6 and verifies the configured EIP-712 domain against the token's DOMAIN_SEPARATOR() before signing.

Direction-specific helpers keep account and proof requirements explicit:

quotePublicToEncrypted({ publicToken, encryptedToken, amountIn | amountOut })
swapPublicToEncrypted({ quote, publicAccount, encryptedRecipient? })

quoteEncryptedToPublic({ encryptedToken, publicToken, amountIn | amountOut })
swapEncryptedToPublic({ quote, publicRecipient })

publicAccount is explicit and may differ from the ZKP account controller. Its MVP signer produces a 65-byte signature; the SDK enforces only that length, and signature acceptance (ECDSA or ERC-1271) is the MM admission policy plus on-chain verification. encryptedRecipient defaults to the ZKP account's registered address; publicRecipient is always explicit. The existing six-decimal rate and fee arithmetic is reused.

The public-swap module adds intent types and hashing, EIP-3009 typed-data construction, Solidity calldata encoding, direction-specific order types, maker transport methods, and status polling. Existing private-swap exports and behavior do not change.

Market maker

Configuration and quote

The MM keeps encrypted tokens and public tokens in distinct registries. A public token entry includes symbol, address, EIP-712 name, and EIP-712 version. Startup requires deployed settler code, a public token not registered as a ZKEMT under the Hub, decimals() == 6, the configured domain, and the EIP-3009 read surface.

Order validation requires each public signature to be 65 bytes. This is an MM policy rather than a settler constraint. The MM uses ECDSA recovery for code-free payers and ERC-1271 for code-bearing payers, so compatible delegated accounts are not rejected merely for having code.

For the MVP, MAKER_CONTROLLER_KEY also owns the public treasury. This is an MM-only exception to ADR-015's key-separation guidance: the maker is intentionally public in this flow, but compromise of that key affects both inventories.

New JSON-RPC methods are separate from private swaps:

mm_quotePublicSwap
mm_submitPublicSwapOrder
mm_publicSwapIntentStatus

Their wire contract is:

type PublicSwapDirection = "PublicToEncrypted" | "EncryptedToPublic";

type PublicSwapQuoteRequest = {
direction: PublicSwapDirection;
publicToken: string; // configured symbol
encryptedToken: string; // configured symbol
amountIn: string;
amountOut: string;
feeToken: string;
rateQuote: RateQuote;
};

type PublicSwapQuoteResponse = {
direction: PublicSwapDirection;
publicToken: string;
encryptedToken: string;
amountIn: string;
amountOut: string;
feeToken: string;
makerTreasury: EvmAddress;
makerEpk: Point;
expiry: string;
intentSalt: string;
quoteHmac: Hex;
};

type SubmitPublicSwapOrderRequest =
| {
direction: "PublicToEncrypted";
intent: PublicSwapIntent;
publicTransferAuth: EIP3009Auth;
publicIntentAuth: Auth;
encryptedAmountRandomness: string;
amountIn: string;
amountOut: string;
quoteHmac: Hex;
}
| {
direction: "EncryptedToPublic";
intent: PublicSwapIntent;
encryptedParams: EncryptedTransferParams;
encryptedIntentAuth: Auth;
encryptedTransferAuth: Auth;
amountIn: string;
amountOut: string;
quoteHmac: Hex;
};

type PublicSwapIntentStatusRequest = { publicSwapIntentHash: Hex };
type PublicSwapOrderResponse = { status: OrderStatus };

All integer quantities use decimal strings. The EIP-3009 nonce is fixed-width hex, while signatures and addresses use ordinary hex encoding; curve and ciphertext fields reuse the private-swap wire format. PublicToEncrypted maps the public amount to amountIn; EncryptedToPublic maps it to amountOut. Submit and status return the existing order-status enum. Public-token metadata is explicit SDK and MM configuration; the MVP adds no discovery endpoint or generated deployment defaults.

The quote endpoint accepts exactly one configured public token and one configured encrypted token. It reuses rate-provider exact matching and fee arithmetic. Its HMAC binds direction, token addresses, amounts, MM treasury, maker EPK, expiry, and intent salt. Taker-selected payer and recipient addresses are chosen after quoting and are instead bound by the final signed intent. A quote remains stateless and is accepted at most once.

Orders and reservations

Public swaps use a separate discriminated Rust order type and database table. Records contain direction, intent and salt, quote authentication, taker-provided authorization or proof material, plaintext amounts, status, attempts, timestamps, and fill transaction hash. Unique constraints on intent hash and intent salt enforce one accepted order per quote. Public fields are not added to private orders.

For encrypted-to-public submission, the MM starts a database transaction, acquires the per-public-token transaction-scoped lock, then reads the chain balance while the lock is held and computes:

available = max(0,
publicToken.balanceOf(makerTreasury)
- sum(publicAmount for Ordered or Filling public-output orders)
)

The MM inserts the order only if available >= publicAmount, then commits the transaction. Moving an order to Filled, Failed, or Expired takes the same lock before the terminal update releases the reservation. This prevents a released reservation from racing a stale pre-lock balanceOf read. A treasury withdrawal after the locked read can still make settlement revert and remains an operator error.

Public-to-encrypted orders reserve encrypted output under the same per-ZKEMT lock as private swaps. Availability remains scoped to (ZKEMT, maker EPK) and subtracts both private-swap output reservations and public-to-encrypted output reservations so the two order tables cannot overcommit one encrypted balance.

There is no public balance mirror or public reconciler. The chain balance is authoritative; incoming public fills are visible to the next balanceOf read. Untracked treasury withdrawals can make a fill fail and are an operator error.

Fill and recovery

The shared fill loop checks the private and public queues sequentially on every poll, so one process does not build competing proofs for the maker balance. Public orders reuse the private worker's claim, attempt, submission, accounting, and requeue model using FOR UPDATE SKIP LOCKED, and expire at the strict intent boundary. It does not add execution leases, heartbeats, or conditional claim ownership.

Public-to-encrypted proof construction uses the same active/pending selection and optional pending-fold strategy as the private worker. Encrypted-to-public settlement receives into the maker's encrypted pending balance and does not require a maker transfer proof. Public payouts use independent EIP-3009 nonces.

Successful public-to-encrypted fills atomically debit encrypted active, apply any completed pending fold, and mark the order Filled with its transaction hash. Successful encrypted-to-public fills atomically credit the encrypted receipt to pending and mark the order Filled. Public inflows and outflows are reflected only by balanceOf; neither is written to maker_balances.

For either direction, the maker uses intentSalt as its EBSwap intent-auth nonce. This nonce shares EBSwap's bitmap with private swaps and must not collide with another intent authorization for the same signer. A public-to-encrypted fill also uses intentSalt for the ZKEMT transfer-auth nonce, whose bitmap is separate. An encrypted-to-public fill encodes it as the EIP-3009 bytes32 nonce. Before retrying a reclaimed fill, the worker checks the maker intent-auth bitmap. If it is consumed, the worker marks the order Filled without resubmitting; the existing periodic encrypted-balance reconciliation corrects any temporary plaintext-mirror drift.

On each worker poll, Ordered rows with expiry <= latestBlockTimestamp become Expired. For a reclaimed Filling row, a consumed maker nonce records Filled; an unconsumed expired row becomes Expired instead of being requeued. These transitions release both public and encrypted reservations.

Overlapping service instances can still produce stale proofs, sponsored reverts, or temporary mirror drift. Cross-instance serialization and recovery hardening are deferred.

Sponsorship and deployment

Sponsored settlement reuses the existing EBSwap paymaster target. The public token is called internally by the settler and is not a direct SharedAccount target. Local tests deploy an unregistered six-decimal EIP-3009 mock and configure its metadata explicitly.

The SDK and MM reuse the existing ebSwap address/configuration and add only explicit public-token configuration; there is no second settler configuration. Because EBSwap is fixed, the changed bytecode requires a fresh deployment and ebSwap address cutover, and existing signatures remain bound to their original contract address. Production deployment, paymaster policy, deployment manifests, and hosted MM rollout are separate operational work.

Failure behavior

The contract uses explicit errors for invalid intent structure, expiry, token overlap, EPK or ciphertext mismatch, and authorization failure. External token and ZKEMT failures revert the complete transaction.

The SDK rejects invalid configuration, unsupported pairs, expired quotes, unregistered recipients, and insufficient public balances before order submission where possible. It generates a fresh random EIP-3009 nonce; the MM and token remain authoritative for nonce use.

The MM rejects invalid quotes, signatures, proof parameters, ciphertext openings, balances, or reservations before persistence. Transient RPC, inclusion, or stale-state failures retry within the existing attempt and expiry bounds. Invalid or expired orders and exhausted retries become terminal failures.

Required verification

  • Contract settlement: private atomicSwap remains unchanged; atomicPublicSwap covers both directions, events from EBSwap, exact payer and recipient deltas, settler balance conservation, a ZKEMT public side as the public leg, pending-flag pass-through, and complete rollback.
  • Contract authorization: field mismatches, replay, strict expiry, an EIP-7702-style ERC-1271 delegate, caller binding, and reentrancy.
  • SDK: Solidity hash parity, USDC domain version, EIP-3009 bytes-signature encoding, random taker nonces, both order builders, and six-decimal validation.
  • MM API: quote HMAC binding, wire variants, signature recovery, validity windows, retries, and status transitions.
  • MM liquidity: concurrent public reservations with balance reads under lock, cross-table encrypted reservations, fill accounting, expiry cleanup, and existing-style recovery.
  • Anvil: both sponsored directions with public balances and decrypted encrypted balances asserted.

Deferred

  • ERC-2612, Permit2, and standing-allowance settlement
  • tokens with decimals other than six
  • fee-on-transfer, rebasing, and other non-exact tokens
  • general contract-wallet signing UX and non-65-byte ERC-1271 signatures
  • bundled EPK registration
  • on-chain public-token registry or allowlist
  • separate MM public-treasury key
  • dedicated indexer support
  • production deployment, paymaster policy, generated deployment metadata, and hosted MM rollout
  • cross-worker execution serialization and private/public recovery hardening