Payments and payment links
Pay an exact dollar amount to any address from shielded ZEC, share payment links that open the Pay flow prefilled, and keep receipts.
The stablecoin side of a swap doesn't have to pay the person swapping. It can pay any address: a shop, a contributor, a friend. That is Pay. The recipient receives an exact dollar amount in stablecoins and never deals with ZEC, and settlement is the same atomic swap, so nobody custodies anything in between.
How a payment works
- The recipient shares an EVM address, a dollar amount and a chain, for example in a payment link.
- Your taker asks the maker for an exact-out quote: the dollar amount is fixed, and the ZEC amount is computed from it and rounded up.
- You lock that ZEC on Zcash. The maker locks the stablecoins on the EVM chain with the recipient's address as the payee.
- Your taker verifies the maker's lock (recipient, token, amount, time left) and claims, which pays the recipient.
The recipient address is part of the EVM lock, so the funds can only go there. The recipient doesn't sign anything, run anything or pay gas: the claim is submitted by your EVM account or by a relayer.
Pay with the SDK
Use quoteExactOut instead of quote, then pass recipient (and optionally label) to swap. Here taker comes from createTaker, as in the Quickstart:
import { SwapFailedError } from "@paralock/sdk";
const quote = await taker.quoteExactOut(25_000_000n, "base", "adaptor"); // exactly 25.00 USDC
console.log(`Costs ${Number(quote.zecAmountZat) / 1e8} ZEC at $${quote.rate.toFixed(2)}/ZEC`);
try {
const swap = await taker.swap(quote, {
recipient: "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
label: "Acme Coffee",
privacy: { relayClaim: true },
});
console.log(`Paid ${swap.payment?.label}: claim ${swap.evm.claimTx}`);
} catch (err) {
if (!(err instanceof SwapFailedError)) throw err;
await taker.refund(err.swap.id); // the ZEC comes back after the refund height
}-
usdAmountis in token base units with 6 decimals, so25_000_000nis $25.00. Convert user input without floating point:/** "1850.50" → 1850500000n (6-decimal token units), without floating point. */ function usdUnits(usd: string): bigint { if (!/^\d+(\.\d{1,6})?$/.test(usd)) throw new Error(`not a dollar amount: ${usd}`); const [whole, frac = ""] = usd.split("."); return BigInt(whole!) * 1_000_000n + BigInt(frac.padEnd(6, "0")); } -
chainandmode:"tempo"pays OUSD; the other chains pay USDC."adaptor"(private mode) keeps any shared value off both chains;"htlc"is the default. -
recipientmust be a non-zero0xaddress. It takes precedence overprivacy.freshRecipient. -
labelis stored with the swap asswap.payment.label, for your receipts and UI. It never goes on-chain. -
relayClaimlets the relayer configured on that chain submit the claim, so your own EVM account needs no gas.
The quote checks that matter for payments: quoteExactOut rejects a quote unless it is marked exact-out and its usdAmount equals what you asked for, and the taker refuses to claim unless the maker locked at least that amount for that recipient.
What a payment costs
At a feed price of $1,280 and the default 0.5% spread, the maker's rate is $1,273.60 per ZEC, and paying $25.00 costs 1,972,940 zat (0.0197294 ZEC): 1,962,940 zat at that rate, rounded up, plus 10,000 zat for the maker's own Zcash claim fee. You also pay the Zcash fee of your funding transaction (at least 10,000 zat) and, unless it is relayed, the gas of the claim. How it works shows the formulas.
Pay from the CLI
On the local devnet:
pnpm paralock pay --usd 25 --to 0x90F79bf6EB2c4f870365E785982E1f101E93b906 --chain base --label "Acme Coffee"
pnpm paralock pay --usd 42.50 --to 0x90F79bf6EB2c4f870365E785982E1f101E93b906 --chain tempo --private --relay| Flag | Meaning |
|---|---|
--usd < | Dollars the recipient receives (required) |
--to < | Recipient address (required) |
--chain < | tempo (default), base, ethereum, arbitrum, robinhood or hyperevm |
--label < | A name for the payment |
--private | Adaptor mode |
--relay | The devnet relayer submits the claim |
--no-shield | Don't unshield first or reshield change afterwards |
Payment links
The web app opens the Pay flow prefilled from a link:
/app?pay=<address>&usd=<amount>&chain=<chain>&label=<name>&mode=private| Parameter | Required | Accepted values |
|---|---|---|
pay | Yes | The recipient's 0x address. Without pay the URL isn't a payment link |
usd | No | A dollar amount such as 25, 19. or $1,: up to 6 decimals; a leading $ and commas are ignored |
chain | No | tempo, base, ethereum, arbitrum, robinhood or hyperevm. Anything else is ignored |
label | No | The name shown to the payer, trimmed and cut to 60 characters |
mode | No | private (or adaptor) for private mode; standard (or htlc) for htlc mode |
For example, /app?pay=0x90F79bf6EB2c4f870365E785982E1f101E93b906&usd=4.50&chain=base&label=Acme%20Coffee asks the payer for exactly $4.50 in USDC on Base, labeled "Acme Coffee". When a page opens from a link, the app keeps the chain the link chose instead of silently switching it. /app?flow=pay opens an empty Pay form, and older links on / (/?pay=…) redirect to /app with the same query string.
The app also has a Request a payment section that builds these links: enter your address, the price, the chain, an optional name and whether to use private mode, then copy the link.
Build links in code
The link format is plain query parameters, so any language can produce it:
import type { EvmChain, Hex } from "@paralock/sdk";
interface PaymentRequest {
to: Hex;
usd: string;
chain: EvmChain;
label?: string;
private?: boolean;
}
function paymentLink(origin: string, p: PaymentRequest): string {
const q = new URLSearchParams({ pay: p.to, usd: p.usd, chain: p.chain });
if (p.label) q.set("label", p.label);
if (p.private) q.set("mode", "private");
return `${origin.replace(/\/$/, "")}/app?${q.toString()}`;
}
paymentLink("https://paralock.xyz", { to: "0x90F79bf6EB2c4f870365E785982E1f101E93b906", usd: "4.50", chain: "base", label: "Acme Coffee" });Receipts
swap resolves with the full public record of the payment. It never contains secrets or keys; those stay in the store. Keep the fields you need as a receipt:
import { type Swap, toJson } from "@paralock/sdk";
function receiptOf(swap: Swap) {
return {
swapId: swap.id,
status: swap.status,
mode: swap.mode,
paidTo: swap.evm.recipient,
label: swap.payment?.label,
amount: swap.evm.amount, // token base units (6 decimals)
token: swap.quote.tokenSymbol,
chain: swap.evm.chain,
chainId: swap.evm.chainId,
claimTx: swap.evm.claimTx, // proof of payment on the EVM chain
zecPaidZat: swap.zcash.amountZat,
zcashLockTx: swap.zcash.fundingTxid,
at: swap.events.at(-1)?.at, // Unix ms of the last step
};
}
console.log(toJson(receiptOf(swap))); // toJson writes bigints as decimal stringsclaimTxis the transaction that paid the recipient. On Tempo, the TIP-20 transfer also carries the swap's hashlock or escrow id as its memo.statusiscompletedonce the maker has also claimed the ZEC, orevm_claimedif the maker's claim wasn't seen withintimeouts.followMakerMs(60 seconds by default). Either way, the recipient has been paid.swap.eventsis the full timeline, with a transaction id per step.
The examples/payroll app does all of this in a loop: it pays four contributors exact amounts on four chains from a shielded treasury, in private mode with relayed claims, refunds any failed payment and writes receipts-<timestamp>.json. One run paid $4,665.75 on Tempo, Base, Arbitrum and Ethereum.
pnpm paralock dev --evm all
pnpm --filter @paralock/example-payroll startPrivacy of a payment
The recipient sees an ordinary stablecoin transfer from the escrow contract. In htlc mode, the same hashlock on both chains lets anyone pair your Zcash lock with the payment; private mode removes that link. In both modes the Zcash leg is transparent, so unshield only what the payment needs and shield change and refunds again. See Privacy.