Skip to main content
Version: 0.5.0

Swaps

Swap between encrypted-balance tokens through a market maker. Three parties are involved: the rate provider supplies the canonical price and fee; the market maker validates every quote against that same rate provider (it cannot fill at other terms), supplies the counter-liquidity, and submits the fill; the EBSwap contract settles both transfers atomically - both legs execute or neither does. See the market maker specification for the full protocol.

From the client's side that is three steps: quote, swap, wait for the fill. Amounts stay encrypted on-chain, and the swap is gasless for you - the maker submits the settlement.

Setup

import { createSwapClient } from '@cardinal-cryptography/core'

const client = createSwapClient({ chain, zkpAccount })

There is one canonical deployment per network: the maker and rate-provider URLs and the EBSwap address default from the chain's deployment manifest. For a local or custom setup, pass maker, rates, and ebSwap explicitly - or extend an existing client with the same options:

import {
createDecryptClient,
createMakerClient,
createRateProviderClient,
zkpSwapActions,
} from '@cardinal-cryptography/core'

const client = createDecryptClient({ chain, transport, zkpAccount }).extend(
zkpSwapActions({
zkpAccount,
maker: createMakerClient(makerUrl),
rates: createRateProviderClient(ratesUrl),
ebSwap, // EBSwap contract address
}),
)

Get a quote

const quote = await client.quoteSwap({
tokenIn: 'zkUSD',
tokenOut: 'zkEUR',
amountIn: 100_000_000n,
})

Pass exactly one of amountIn (fix what you send) or amountOut (fix what you receive); the other side is derived from the current rate with the maker fee folded in. The returned quote is self-contained and valid until quote.accepted.expiry (unix seconds).

Instant estimation

The full UI flow is four steps - the first two are local and drive the form, the last two execute:

  1. getSwapRate - refetch on an interval so the display stays fresh:

    let rate = await client.getSwapRate({ tokenIn: 'zkUSD', tokenOut: 'zkEUR' })
    const timer = setInterval(async () => {
    rate = await client.getSwapRate({ tokenIn: 'zkUSD', tokenOut: 'zkEUR' })
    }, 15_000) // the rate provider emits a new quote every 15s
  2. calculateSwapAmounts - synchronous, call it per keystroke:

    const { amountOut, feeAmount } = client.calculateSwapAmounts({ rateQuote: rate, amountIn })

    It uses the same arithmetic the maker accepts; feeAmount is the fee already folded into the derived amount, denominated in the derived-side token.

  3. quoteSwap - the binding, maker-accepted quote (below).

  4. swap - submit the order (below).

In React, steps 1-2 collapse to the useSwapRate hook plus the same calculateSwapAmounts call.

A stale displayed rate is a UX matter only, never a correctness one: quoteSwap refetches the rate internally, and the maker re-checks every order against the rate provider's freshness window server-side. The refetch interval just controls how closely the on-screen estimate tracks the final quote.

Execute the swap

const { intentHash } = await client.swap({ quote })

This proves and signs locally, then submits the order to the maker. It throws if the quote has expired - fetch a fresh one and retry. onProgress reports the usual proving/signing stages.

Wait for the fill

await client.waitForSwap({
intentHash,
onStatus: (status) => console.log(status), // Ordered -> Filling -> Filled
})

Resolves on Filled; throws if the order reaches Failed or Expired, or after timeoutMs (default 120s, polling every 3s). Transient network errors during polling are tolerated until the timeout.

Errors

Service-side rejections throw SwapRpcError with the JSON-RPC code and method - notably insufficient maker liquidity at submit (the maker reserves liquidity only when the order is accepted, so a successful quote does not guarantee the fill). Local validation failures (expired quote, malformed amounts) throw plain Errors.