Documentation

Guides

View as Markdown

Privacy

The three privacy levels, the PrivacyOptions that implement level 1, private mode with adaptor signatures, and what an observer can still see.

Paralock removes the middleman, but privacy depends on what the two chains reveal. A swap has two legs: ZEC locked on Zcash and stablecoins locked on an EVM chain. Both locks are public. The goal is that neither your shielded history nor the link between the two legs leaks.

Three levels

Level 1: hygieneLevel 2: adaptor signaturesLevel 3: fully shielded leg
StatusShippedExperimentalIn research
HowPrivacyOptions and shielding in your walletmode: "adaptor"FROST 2-of-2 shielded notes
Swap visible on Zcash?Yes: a transparent HTLCYes, but only as an ordinary 2-of-2 multisigNo: a normal shielded transaction
A shared value links the two chains?Yes: the same sha256 hashlockNoneNone
Linked to your wallet history?No, if you shield before and after and use a fresh recipientNoNo
Amount and timing correlationReduced: standard amounts, jitterReducedReduced

Level 1 and level 2 combine: use private mode and every hygiene option together.

Level 1: hygiene

These measures don't hide that a swap happened, or the link between its two legs. They keep a swap from linking to the rest of your activity.

Shield before and after

The taker spends from one transparent key. Keep your savings in the shielded pool, and before each swap move only the swap amount (plus fees) from your shielded balance to that key's transparent address, taker.zcashAddress. Change and refunds go to zcash.returnAddress (by default the same address); shield them again. The maker does the same with the ZEC it claims, for example in its afterClaim hook.

The SDK doesn't move shielded funds: your wallet does. The devnet shows the pattern: the CLI unshields each swap from ths account 1 into the dedicated swap address (account 2) and reshields change afterwards, and examples/payroll does the same for a treasury.

Fresh recipient

freshRecipient: true pays the stablecoins to a new EVM address created for this swap, instead of an address that already has a history. Its private key is kept in the store and passed to onFreshRecipient; read it back with taker.freshRecipient(id). That key controls the funds, so the taker refuses this option unless the store is durable or you set onFreshRecipient. If you pass a recipient (a payment), it wins over freshRecipient.

Relayed claim

A fresh address has no gas. relayClaim: true lets the relayer account configured on that chain submit the claim and pay its gas. The relayer can't redirect the funds: the escrow always pays the recipient fixed at lock time. It could refuse or fail to relay; then the swap fails with SwapFailedError (the SDK doesn't fall back by itself), and taker.resume(id, { relayClaim: false }) submits the claim from your own EVM account before the lock expires. That account pays the gas and is linked to the claim; the recipient stays the one in the lock.

Standard amounts

standardAmountsOnly: true refuses any amount other than 0.1, 0.5, 1 or 5 ZEC (STANDARD_AMOUNTS_ZAT), so an unusual amount doesn't single out your swap. It applies to the ZEC amount, so use it with exact-in quotes (taker.quote). An exact-out payment almost never lands on a standard amount.

Timing jitter

jitterMs: n waits a random 0 to n milliseconds before claiming, so the claim doesn't line up exactly with the maker's lock. Keep it well inside the EVM lock: the taker re-checks the time left after the jitter, and if fewer than minClaimWindowSeconds remain it refuses to claim, and both sides refund.

All together

TypeScript
import { type Hex, createTaker } from "@paralock/sdk";

const taker = createTaker({
  zcash: { rpcUrl: "http://127.0.0.1:8232", network: "mainnet", key: process.env.ZEC_KEY!, returnAddress: process.env.ZEC_RETURN_ADDRESS },
  evm: {
    base: {
      rpcUrl: "https://mainnet.base.org",
      chainId: 8453,
      htlc: process.env.HTLC as Hex,
      adaptorEscrow: process.env.ESCROW as Hex,
      account: process.env.EVM_KEY as Hex,
      relayer: process.env.RELAYER_KEY as Hex, // pays the claim gas; can't redirect the funds
      fromBlock: 1,
    },
  },
  maker: "https://maker.example",
  store: "./paralock-taker", // fresh-recipient keys are kept here too
  onFreshRecipient: (account, swap) => console.log(`swap ${swap.id} pays ${account.address}; store its key safely`),
});

const quote = await taker.quote(50_000_000n, "base", "adaptor"); // 0.5 ZEC, private mode
const swap = await taker.swap(quote, {
  privacy: {
    freshRecipient: true, // pay a new address created for this swap
    relayClaim: true, // the relayer submits the claim, so that address needs no gas
    standardAmountsOnly: true, // refuse anything but 0.1, 0.5, 1 or 5 ZEC
    jitterMs: 60_000, // wait a random 0 to 60 s before claiming
  },
});
const recipient = await taker.freshRecipient(swap.id); // { address, privateKey }: it controls the stablecoins

The same options on the devnet CLI:

Shell
pnpm paralock swap --zec 0.5 --chain base --private --fresh-recipient --relay --standard-only --jitter 10000

The devnet saves fresh-recipient keys to .paralock/recipients.jsonl.

What level 1 still leaks: the HTLC is transparent, and the same hashlock appears on both chains, so anyone can pair the two legs of a swap.

Level 2: adaptor signatures

Private mode (mode: "adaptor", or --private in the CLI) removes every shared value from the chains:

  • On Zcash, the ZEC sits in a plain 2-of-2 multisig of a fresh taker key and a fresh maker key made for this quote. Spending it looks like any other multisig spend.
  • On the EVM chain, ParalockAdaptorEscrow pays the recipient when given a signature from a one-time signer key over claimDigest(id). The escrow id is a hash of the lock's parameters, including a random salt.
  • The secret is y, the discrete logarithm of an adaptor point Y = y·G that only the two parties ever see. The taker's claim publishes a completed signature. Only the maker, who holds the matching pre-signature, can compute y from it, and then complete the taker's pre-signature on the Zcash claim. The maker's public swap view (GET /swaps/:id) never contains Y, the pre-signatures, the signer's key or the salt: the maker sends them only in answers to the taker's own requests.

How it works walks through the ten steps. The cryptography is in @paralock/adaptor: ECDSA adaptor signatures on secp256k1 with Chaum–Pedersen DLEQ proofs and hedged nonces.

What level 2 still leaks:

  • the amounts and timing of the two legs (use standard amounts and jitter);
  • the fact that a 2-of-2 multisig was used on Zcash;
  • an interaction with ParalockAdaptorEscrow on the EVM chain;
  • to whoever learns the swap id (the quote id): both legs. The maker's public view of a swap lists both chains' transactions and the amounts to anyone who asks with the id, so keep swap ids out of logs and shared links.

An observer who suspects a swap can still correlate amounts and time windows, but has no cryptographic link between the legs.

Level 3: a fully shielded Zcash leg

Status: In research. Level 2 still shows a transparent multisig on Zcash. Removing it needs the shielded pool:

  • FROST 2-of-2 shielded notes. Orchard spend authorization uses RedPallas, which FROST already supports. The taker would pay into a note that both parties control jointly.
  • Schnorr adaptor signatures, which are simpler and better studied than the ECDSA version and extend naturally to FROST.
  • A cross-group DLEQ proof, showing the same y is valid on Pallas (Zcash) and on secp256k1 (EVM), as BTC⇄XMR swaps do for ed25519.
  • A time-locked refund. A v5 transaction's lock_time applies to the whole transaction, so a pre-signed shielded refund can be time-locked too.

The result would be an ordinary shielded transaction on Zcash, with only the EVM leg visible. Open work: a cross-curve proof implementation, Ironwood support in FROST (in progress), and wallet support for jointly controlled notes (PCZT helps).

What observers can still see

Whohtlc modePrivate mode
Anyone watching ZcashThe transparent swap address funding the HTLC, the amount, the hashlock, the claim or refund, and the secret once claimedThe swap address funding a 2-of-2, the amount, and the spend with two ordinary signatures
Anyone watching the EVM chainThe maker's lock, recipient, amount, expiry, the hashlock, and the secret in ClaimedThe maker's lock, recipient, amount, expiry, the one-time signer, and the signature in Claimed
Both chains togetherThe same hashlock pairs the legsOnly amount and timing correlation
The makerBoth legs of your swap, and whatever your connection reveals, such as your IP addressThe same: it is your counterparty
Your lightwalletd serverThe addresses you query and the transactions you sendThe same
The relayerThe claim it submits, which becomes public anywayThe same

Honest limitations

  • The Zcash leg is transparent in both modes. Shielding before and after keeps your history out of it, but the lock, its amount and its spend are public.
  • The maker sees everything about your swap. No mode hides your swap from your counterparty. Reach it over a network path you are comfortable with.
  • Amounts and timing correlate. Standard amounts and jitter reduce this; they don't remove it, especially when few swaps happen.
  • What you do next can re-link. Moving funds from a fresh recipient into an address you already use links them.
  • Light servers learn your addresses. A lightwalletd server sees which transparent addresses you query and what you broadcast. Run your own node or server if that matters.
  • On Tempo, transfers carry a memo: the hashlock (htlc mode) or the escrow id (private mode). In private mode the escrow id doesn't relate to anything on Zcash.