Swap on public testnets
Run a real swap between Zcash testnet (TAZ) and OUSD on Tempo Moderato with examples/testnet, through a public lightwalletd server.
The examples/testnet app runs a real Paralock swap on public test networks: Zcash testnet coins (TAZ) for OUSD on Tempo Moderato. Every step is a real transaction you can open in a block explorer, and testnet coins have no value.
| Side | Network | How the example reaches it |
|---|---|---|
| Zcash | Zcash testnet | The public lightwalletd server testnet., through @paralock/, so you don't run a node |
| EVM | Tempo Moderato (chain id 42431) | https:/, with the escrows from contracts/ |
| Maker | In the same process | create runs next to the taker, so the demo is one command |
In production the maker is a separate service behind makerHandler, and the taker passes its URL instead of the Maker object. See Run a maker. Where Paralock works lists the other networks, Zcash backends and wallets you can combine.
What you need
| Variable | What it is |
|---|---|
ZEC_TAKER_KEY | A transparent Zcash testnet private key (32 bytes of hex) whose tm… address holds the TAZ you will swap |
ZEC_MAKER_KEY | The maker's transparent Zcash key. The TAZ the maker claims goes to its address; it needs no funds |
EVM_MAKER_KEY | A Moderato account holding OUSD for the maker to lock, plus pathUSD for transaction fees |
EVM_RELAYER_KEY | Optional. An account with pathUSD that pays the claim fee, so the recipient needs nothing. It can't redirect the OUSD |
LIGHTWALLETD_URL | Optional. Default testnet. |
TEMPO_RPC_URL | Optional. Default https:/ |
Inside a Paralock checkout, any key variable you don't set falls back to a file in .paralock/: testnet-zcash.json (taker), testnet-maker-zcash.json, testnet-deployer.json (maker EVM) and testnet-relayer.json, each shaped like { "privateKey": "…" }. The taker's own EVM key is created on the first run in .paralock/testnet-taker-evm.json. That directory is gitignored.
Make the Zcash keys
Any random 32 bytes is a valid key. Generate one for the taker and one for the maker, and print the taker's testnet address (run the last command from examples/testnet, where @paralock/zcash is installed):
export ZEC_TAKER_KEY=$(openssl rand -hex 32)
export ZEC_MAKER_KEY=$(openssl rand -hex 32)
node --input-type=module -e "import { keyAddress, keyPairFromPrivate } from '@paralock/zcash'; console.log(keyAddress(keyPairFromPrivate(process.env.ZEC_TAKER_KEY), 'testnet'))"Keep both keys. The taker's key signs htlc-mode refunds, so losing it can strand a locked swap.
Get TAZ into the transparent address
Paralock spends from a transparent address (tm…), but testnet faucets often pay only shielded addresses. Get TAZ into a shielded testnet wallet first, for example with zcash-devtool, connected to the same side of the testnet split as the example (see the note below), then send it from that wallet to the taker's tm… address. Wait for one confirmation (about 75 seconds).
Run it
From the repository root, after pnpm install && pnpm build:
mkdir -p .paralock # the example writes its taker key here
pnpm --filter @paralock/example-testnet start -- --zec 0.2 # swap 0.2 TAZ (htlc mode)
pnpm --filter @paralock/example-testnet start -- --zec 0.2 --private # adaptor mode: nothing on-chain links the legs
pnpm --filter @paralock/example-testnet start -- --pay 20 --to 0x… # pay exactly 20 OUSD to someone else
pnpm --filter @paralock/example-testnet start -- --zec 0.2 --fresh # receive at a fresh address made for this swapWithout --zec or --pay, it swaps 0.1 TAZ. The maker uses its default limits, so a swap must be between 0.01 and 10 ZEC. A payment therefore has to be worth at least 0.01 ZEC at the current price (about $12.70 at $1,270 per ZEC); smaller amounts fail with limit_exceeded.
The example first prints the taker's address and its confirmed TAZ balance, and stops if it is empty. Then it quotes from the live ZEC price (TAZ is priced as if it were ZEC), runs the swap, prints one line per step with an explorer link, and ends with the transactions: the Zcash lock, the Tempo lock, the Tempo claim and, once it is seen, the maker's Zcash claim. If a Zcash explorer link doesn't show your transaction, that explorer follows the other side of the testnet split.
The core of it
const zcash = new LightwalletdClient("testnet.zec.rocks:443");
const maker = createMaker({ zcash: { network: "testnet", node: zcash, key }, evm: { tempo: { …, token: OUSD } }, price: zecUsdPrice(), policy });
const taker = createTaker({ zcash: { network: "testnet", node: zcash, key }, evm: { tempo: { …, tokens: [OUSD] } }, maker });
const quote = await taker.quote(20_000_000n, "tempo", "adaptor");
const swap = await taker.swap(quote);Both parties share one LightwalletdClient, which implements the same ZcashNode interface as a Zebra RPC client. On Tempo, both set feeToken to pathUSD. Full source: examples/testnet/src/index.ts.
Timing
Zcash testnet blocks come about every 75 seconds, so a swap takes a few minutes, most of it spent waiting for Zcash blocks:
- The taker's funding needs one block to confirm.
- The maker polls every 15 seconds, sees the confirmed funding and locks OUSD. Tempo confirms in under a second.
- The taker verifies the lock and claims on Tempo.
- The maker's Zcash claim needs one more block.
lightwalletd only reports mined transactions, so neither side sees a transaction until it is in a block.
The example raises the taker's waits for the slower chain: up to 60 minutes for the funding to confirm (fundingConfirmationMs) and for the maker's lock (makerLockMs), and up to 20 minutes to watch the maker's ZEC claim (followMakerMs). It also requires 120 seconds of EVM lock left before claiming (minClaimWindowSeconds), instead of the default 30.
The testnet policy
The example passes its own timeout policy to the maker:
const TESTNET_POLICY: TimeoutPolicy = {
zcashLockBlocks: 96, // refund height N = tip + 96 blocks: about 2 hours at 75 s
evmLockSeconds: 1800, // expiry T = 30 minutes after the maker locks
zcashBlockSeconds: 75, // used to compare the two clocks
minZcashConfirmations: 1, // the maker waits for one confirmation of the funding and of its own claim
safetyFactor: 2, // the Zcash time left must be at least 2× the EVM lock
};When the maker locks, at least 2 × 1,800 = 3,600 seconds (48 blocks) must remain before N. N starts 96 blocks out, so the maker has about 48 blocks (an hour) after the taker builds its lock to lock the OUSD, or it abandons the swap. Whoever learns the secret always has time to use it. How it works explains the rule; MAINNET_POLICY uses 144 blocks and a one-hour EVM lock.
If a swap stalls
Both parties persist everything before each step: the maker in .paralock/testnet-swaps/maker, the taker in .paralock/testnet-swaps/taker. Every run of the example calls maker.start(), which resumes the maker's unfinished swaps for as long as that run lasts: it claims the TAZ if the taker claimed, or refunds the OUSD after T. A real maker runs as a long-lived service, so this happens on every restart.
If the taker's TAZ is locked and the swap can't finish, refund it after the refund height with a short script that opens the same store:
import { LightwalletdClient } from "@paralock/lightwalletd";
import { type Hex, createTaker } from "@paralock/sdk";
const zcash = new LightwalletdClient("testnet.zec.rocks:443");
const taker = createTaker({
zcash: { network: "testnet", node: zcash, key: process.env.ZEC_TAKER_KEY! },
evm: {
tempo: {
rpcUrl: "https://rpc.moderato.tempo.xyz",
chainId: 42431,
htlc: "0xD9B278625B5946B8CD15dad6c0c776095B8D0513",
adaptorEscrow: "0xd2f352Fa313d23b3536dAA3d783263C03CED2673",
fromBlock: 39235852,
account: process.env.EVM_TAKER_KEY as Hex, // the key in .paralock/testnet-taker-evm.json
tokens: ["0x20c0000000000000000000006a37DA5C996874BE"], // OUSD
},
},
maker: "http://127.0.0.1:7420", // refund() never contacts the maker
store: ".paralock/testnet-swaps/taker", // the example's taker store, from the repo root
});
for (const swap of await taker.pendingSwaps()) {
console.log(`${swap.id}: ${swap.status}, refund opens at block ${swap.zcash.refundHeight}`);
await taker.refund(swap.id); // waits for the refund height (about 75 s per block), then broadcasts
}
zcash.close();In htlc mode the refund is signed with ZEC_TAKER_KEY; in private mode it uses the refund the maker pre-signed, which is in the store. See Persistence and recovery.
Other testnets
The escrows are also deployed on Base Sepolia, Sepolia, Arbitrum Sepolia, Robinhood Chain Testnet and HyperEVM Testnet. Each contracts/deployments/<chainId>.json lists htlc, adaptorEscrow, the tokens and startBlock (pass it as fromBlock). Testnet USDC comes from faucet.circle.com; Robinhood Chain Testnet has no USDC, so a 6-decimal MockUSD test token stands in. The contracts page has every address. The testnet example is wired for Tempo Moderato; for another chain, change the evm entry and the token.