Skip to main content

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

  1. Alice posts a maker order. She specifies "I'll send 0.005 ETH for 50 USDC."
  2. Bob accepts. He picks the order from the order book.
  3. 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.
  4. Both POST the prepared relayOptions[0] to the coordinator. The coordinator forwards each payload to the relayer in sequence.
  5. 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, and complete.
  • 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_settled state. 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 the processor, which lets the msg.sender == processor check 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.