ADR-024: Private Swaps via Swap Controllers
| Status | Proposed |
| Date | 2026-05-08 |
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. EB is account-based: each (EBEMT, 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. EBEMT should keep seeing "the registered controller authorized this encrypted transfer"; it should not know about quotes, market makers, swap locks, or intent state. ADR-023 (Card-Issuer Vault) gives the missing abstraction: EBEMT accepts token-domain operations called directly by the controller registered for the sender EPK in EBHub, so a controller contract can enforce app-specific policy at its own Solidity call boundary.
Proposal
Use swap controllers around normal EB transfer proofs. The taker uses a fresh temporary EPK per swap, registered to a canonical TakerSwapController for the EBSwap deployment. The maker uses a reusable MakerSwapController for one or more liquidity EPKs. Both legs are executable only through EBSwap, so neither side's transfer package is useful as standalone EBEMT calldata.
The swap app uses the ADR-023 direct-controller path; it does not add a swap circuit or a swap-specific token entry point.
Components
| Component | Role |
|---|---|
TakerSwapController | Canonical controller for taker temporary EPKs. Owns atomic temp EPK setup, stores a write-once policy per temp EPK, and allows either the precommitted swap spend or refund to the user's fixed refund EPK. |
MakerSwapController | Reusable maker controller. Controls maker liquidity EPKs and executes only maker-authorized swap spends through EBSwap. |
EBSwap | External coordinator. Binds both transfers to one intent, authenticates the controllers it calls, and executes both legs atomically. Stores the canonical taker and maker controller addresses for the deployment. |
| SDK and maker service | Handle quote negotiation, temp EPK setup, proof generation, submission, cancellation, and refund UX. |
The swap contracts are deployed as a fixed set. For the MVP, EBSwap stores immutable TakerSwapController and MakerSwapController addresses, and the controllers are bound back to that EBSwap. There is no mutable controller replacement path in this ADR.
The token contracts do not learn swap semantics. They only need the generic ADR-023 extension: token-domain operations called by the sender EPK's registered controller are accepted directly, bypassing EIP-712 signature validation. ADR-024 also requires that direct-controller path for pending-balance activation; otherwise dust protection for the temp EPK needs a separate authorization path.
The MVP requires tokenIn and tokenOut to be EBEMTs registered under the same EBHub. EBSwap enforces this before executing a fill. Cross-Hub swaps are possible later, but they require duplicating the controller-authentication and EPK-registration checks per Hub.
Flow
- User asks the maker for an exact quote off-chain. The accepted quote fixes both encrypted amount ciphertexts: taker-to-maker and maker-to-user. Both parties verify off-chain that the ciphertexts encode the quoted plaintext amounts for their receiving EPKs before accepting the intent.
- User creates a temporary EPK registered to the canonical
TakerSwapController. One setup transaction registers the temp EPK, opens a write-once swap policy, funds it withamountInusing a normal EB transfer proof plus token-domain funding authorization, then activates pending-balance protection throughTakerSwapController. - User gives the maker a taker-side proof package for the quoted spend from the temp EPK.
- Maker generates its own normal EB transfer proof immediately before submission and authorizes
MakerSwapControllerto execute that proof only for this intent. - Before expiry,
EBSwap.atomicSwapcalls the taker controller for the taker leg and the maker controller for the maker leg; it does not call EBEMT directly for either spend. If either side fails, the whole transaction reverts. - If the maker does not fill, the user cancels before expiry or waits for expiry and refunds the temp EPK back to the user's fixed refund EPK.
Intent binding
Amounts remain private on-chain. The maker and taker know exact amountIn and amountOut; observers see token contracts, EPKs, ciphertexts, and intentHash.
The MVP does not try to hide the funding graph. The setup transaction publicly links the funding EPK to the temp EPK; the temporary EPK protects the taker's main balance state during orchestration, not funding-EPK unlinkability.
intentHash commits to the accepted quote and domain, not to mutable account state:
takerToMakerCiphertext = Enc(amountIn, makerReceiveEpk)
makerToUserCiphertext = Enc(amountOut, userReceiveEpk)
takerLeg = (tokenIn, tempEpk, makerReceiveEpk, takerToMakerCiphertext)
makerLeg = (tokenOut, makerLiquidityEpk, userReceiveEpk, makerToUserCiphertext)
intentHash = H("EB_SWAP_INTENT_V1", chainId, EBSwap, takerLeg, makerLeg, quoteNonce, expiry)
The two ciphertexts are the encrypted swap amounts. Each recipient can verify off-chain that its ciphertext matches the quoted plaintext amount.
intentHash deliberately excludes current-balance ciphertexts, new-balance ciphertexts, and proofs. Those depend on current balances and fresh proof randomness. The economic terms are the token pair, participants, domain, expiry, and the two encrypted amount ciphertexts.
EBSwap.atomicSwap recomputes this hash from the submitted intent fields and transfer ciphertexts. A caller cannot provide an arbitrary intentHash disconnected from the economic terms being executed.
Controller model
TakerSwapController is canonical per EBSwap deployment, not per user. It can be the registered controller for many temporary EPKs, but each temp EPK is single-use. The controller stores a write-once policy keyed by temp EPK: refund EPK, token pair, EBSwap, intentHash, expiry, cancel signer, and state.
Opening a policy requires taker authorization, but anyone may relay it. The authorization is validated against the current EBHub controller of the funding EPK and consumes a TakerSwapController nonce keyed by that funding EPK. It is separate from the token-domain authorization used for the funding transfer.
The opening authorization binds the setup to the intended swap:
takerOpenAuth = H(
"EB_SWAP_TAKER_OPEN_V1",
chainId,
TakerSwapController,
EBSwap,
fundingEpk,
tempEpk,
tokenIn,
refundEpk,
intentHash,
expiry,
cancelSigner,
nonce,
deadline
)
This replaces the policy binding that a per-swap controller address would otherwise provide. A relayer or maker cannot pair a valid funding transfer with a different temp EPK policy unless it also has a valid takerOpenAuth.
TakerSwapController does not implement ERC-1271 for token-domain operations. It is the registered controller for swap temp EPKs and calls EBEMT directly under ADR-023's direct-controller rule.
TakerSwapController exposes the safe atomic setup path: register the temp EPK to itself in EBHub, open the policy, execute the funding transfer, then activate pending protection as the temp EPK's registered controller. A policy is fillable only after this setup path completes.
The controller permits only these actions:
- run atomic setup for an unused temp EPK with valid taker authorization;
- execute the taker-side spend, only through
EBSwap, only when it matches the precommitted intent and preserves pending protection; - cancel before expiry, only with the user's signature;
- refund after user cancellation or expiry, only to the user's fixed refund EPK.
The temp EPK lifecycle is unused -> active -> filled or active -> cancelled/expired -> refunded. Expiry is a time guard, not a separate persisted state: fill is allowed only while block.timestamp <= expiry; refund is allowed after cancellation or when block.timestamp > expiry. Terminal temp EPKs cannot be opened again.
Cancellation is a controller-domain authorization, not token-domain ERC-1271 auth. The stored user cancel signer signs over chainId, TakerSwapController, EBSwap, tempEpk, intentHash, a cancel nonce, and a deadline; the controller consumes the nonce before moving the swap to cancelled.
Cancellation only takes effect once mined. A maker that already has the taker proof can still race a public cancellation transaction with a fill transaction before cancellation lands.
The controller is shared across users intentionally. A per-user taker controller would create a stable on-chain link across that user's swaps, add deployment and registry complexity, and not materially improve authorization once open is gated by takerOpenAuth.
MakerSwapController is not per-swap. A maker can register many liquidity EPKs to the same controller, including multiple EPKs for the same token if it wants parallel fill capacity.
ADR-024 does not decide the maker's internal governance model. The maker controller may use an owner key, hot signers, policy modules, or another scheme. That governance should be specified separately.
For swaps, the requirement is narrower: maker authorization must be bound to the exact intentHash, token contract, and maker-side EBEMT call, must be replay-protected, and must be valid only when invoked by EBSwap. The maker authorization is separate from the quote nonce: the quote nonce identifies the economic quote inside intentHash, while the maker authorization nonce prevents replay of a concrete fill authorization.
makerCallHash = H(tokenOut, exact maker-side EBEMT calldata)
makerFillAuth = H("EB_SWAP_MAKER_FILL_V1", chainId, MakerSwapController, EBSwap, intentHash, makerCallHash, authNonce, authDeadline)
makerCallHash covers the proof-bound maker-side transfer fields, including the maker liquidity EPK, user receive EPK, transfer ciphertext, new balance ciphertext, compliance ciphertext, proof, and pending flags.
Maker fill authorizations are controller-domain authorizations, not token-domain ERC-1271 authorizations. MakerSwapController does not expose them as general EBEMT authorization; they are valid only inside MakerSwapController.spend when msg.sender == EBSwap. Like TakerSwapController, the maker controller does not implement ERC-1271 for token-domain transfer authorization in the MVP.
Whoever can authorize maker fills is economically trusted for the maker liquidity controlled by this controller. They cannot submit arbitrary standalone EBEMT transfers, but they can authorize bad swaps. Makers should bound exposure operationally, for example by sharding liquidity across EPKs or controllers.
Briefly:
EBSwap.atomicSwap(intent, takerProof, makerProof, makerAuth)
-> TakerSwapController.spend(intent, takerProof)
-> MakerSwapController.spend(intent, makerProof, makerAuth)
Both controllers check that the submitted leg matches intentHash and that the fill has not expired. EBSwap also authenticates the controllers before calling them; it must not accept arbitrary controller addresses from calldata. For the MVP this means EBSwap verifies:
- both tokens are registered EBEMTs on the same EBHub;
- each controller is the EBHub-registered controller for the EPK it spends from;
- both recipient EPKs are registered on that EBHub;
- the taker controller is the canonical
TakerSwapController, and the temp EPK is active there for thisintentHash; - the maker controller is the configured
MakerSwapControllerfor the MVP deployment.
Without these checks, a fake controller could return success without performing the EBEMT transfer.
Both transfer packages are useless as standalone EBEMT calldata: direct calls from anyone except the registered controller are rejected by EBEMT, and both controllers honor only the EBSwap path. Atomicity comes from EVM transaction semantics: both transfers happen in one atomicSwap transaction or neither happens.
Pre-expiry cancellation is user-only. After cancellation or expiry, anyone may relay a valid user-generated refund proof, but the refund can only go to the fixed refund EPK. Fill is rejected after cancellation or expiry; cancellation and refund are rejected after fill.
Pending and dust
The temp EPK is opened, funded with the taker's amountIn, and pending protection is activated in the same setup transaction after funding. The ordering matters: activating before funding would route the intended funding into pending, while funding without activation leaves a dust race. Later incoming dust lands in pending balance instead of mutating the main ciphertext used by the taker proof.
Swap-fill calls must reject clearPending and deactivatePending on both legs. This keeps pending protection active during fills and prevents a submitter from using direct-controller calls to clear or disable protection. Automatic pending-dust sweeping is out of scope for the MVP; any later cleanup path must be explicit and can only send value to the fixed refund EPK.
Maker liquidity EPKs have the same stale-proof risk as any other EB account. For the MVP, maker liquidity EPKs used by MakerSwapController keep pending protection active. EBSwap also rejects same-token fills where the taker leg credits the same (EBEMT, EPK) that the maker leg spends from. Pending protection may make this safe in some cases, but the MVP keeps the overlap rule conservative.
Deferred
- multi-maker registry
- private slippage inequality (
amountOut >= minAmountOut) inside a circuit - automatic dust sweep
- maker governance
Consequences
Easier:
- No new Noir circuits, verifiers, or public-input layouts.
- No swap-specific state or entry points in EBEMT.
- Swap policy is an external app/controller concern, consistent with ADR-023.
- Taker's main EPK is not locked during maker orchestration.
- Takers do not deploy a controller per swap; one
TakerSwapControllercan serve many single-use temp EPKs. - Maker can parallelize negotiations and only generate its proof immediately before submission.
- Maker does not deploy a contract per swap; one
MakerSwapControllercan serve many liquidity EPKs. - Atomicity is provided by one
EBSwap.atomicSwaptransaction.
Harder:
- Setup is more complex: temp EPK registration, policy opening, funding, and pending activation.
- Swaps require the ADR-023 direct-controller EBEMT extension, including direct-controller pending activation.
- Even without per-swap controller deployment, setup still requires temp EPK registration and funding proofs.
TakerSwapControlleris shared state for all taker swaps on anEBSwapdeployment, so write-once opening, replay protection, and terminal-state handling must be audited carefully.- Maker liquidity EPKs must be onboarded to
MakerSwapController; a bug in that controller affects all EPKs it controls. - ADR-024 leaves maker governance for a separate design; whoever can authorize fills is economically trusted for the liquidity controlled by the
MakerSwapController. - Same-EPK maker fills still serialize on the maker's mutable encrypted balance, so true parallel fills require liquidity sharding across maker EPKs and pending protection for active maker liquidity EPKs.
- Off-chain services must verify that accepted ciphertext commitments match the quoted plaintext amounts; the token contracts only see ciphertexts.
- Cancellation/refund UX needs SDK support.
- Maker service orchestration is part of the feature, not optional polish.
Unchanged:
- Existing
eb-transfercircuit is reused. - Existing EB transfer proof shape and public inputs are reused for the balance transition.
- EBHub/EBEMT do not need to understand swap terms.