OTC private swap
Two parties want to swap shielded assets: Alice sends ETH-in-pool to Bob, and Bob sends USDC-in-pool to Alice. This guide follows the patterns established by the OTC PoC repo.
Architecture overview
Each party generates their leg of the swap as a private transfer. A coordinator (the OTC server, or a paired browser session) sequences the submissions so they land back-to-back.
The flow
- Alice posts a maker order. She specifies "I'll send 0.005 ETH for 50 USDC."
- Bob accepts. He picks the order from the order book.
- Both prepare their legs in their own browsers. Alice's leg spends an ETH note with Bob as the recipient, and Bob's leg spends a USDC note with Alice as the recipient. Each side generates its own proof client-side.
- Both POST the prepared
relayOptions[0]to the coordinator. The coordinator forwards each payload to the relayer in sequence. - Both txs land back-to-back. There is no atomicity, so one leg can land even if the other reverts.
Code sketch (Alice's side)
const toHex = (value: bigint): `0x${string}` => `0x${value.toString(16)}`;
const prepared = await session.prepareTransfer({
inputCommitments: [aliceEthNote.commitment],
amount: toHex(parseEther("0.005")),
tokenId: NATIVE_ETH,
recipientDiscoveryData: { evmAddress: bobAddress },
});
// Capture the recipient note for a receipt back to Bob.
const recipientNote = prepared.executeOptions.recipientPendingNotes[0];
// Hand the prepared payload to the OTC coordinator.
await api.post(`/api/orders/${order.id}/submit-maker`, {
relayParams: prepared.relayOptions[0],
recipientCommitment: recipientNote.commitment,
recipientNoteSecret: recipientNote.noteSecret,
});
Coordinator's role
The coordinator's role is narrow:
- It owns the order book: it creates orders, lists them, and updates their status as they move through
open,maker_ready,settling, andcomplete. - It forwards each prepared payload to the relayer's
/v1/relay/...endpoint, in sequence. - It polls for both receipts and updates the order status.
- It never holds any user keys.
Partial-landing window
The maker's leg can confirm while the taker's reverts, because the protocol doesn't provide atomicity across separate transact calls. There are a few ways to manage that window:
- Show a UI banner: "Sequential, same-block target. Manual unwind required if taker reverts after maker lands."
- If the taker's leg reverts, surface a
partial_settledstate. The maker still holds a recipient note they don't owe back, so the broken swap settles in their favor. - For an atomic alternative, wrap both transactions in
Multicall3.aggregate3(requireSuccess=true). This requires building both proofs with the Multicall3 contract as theprocessor, which lets themsg.sender == processorcheck pass.
Auto-import after settlement
After settlement, a client can call importReceivedNote for the note it received, so the new shielded balance appears immediately instead of waiting for the next discovery sync. The OTC PoC is a single-wallet demo where one session plays both sides, so its coordinator does this for the maker-leg and taker-leg notes at once.