Persistence and recovery
How the engines persist secrets and keys before every irreversible step, and how to resume or refund a swap after a failure, a crash or a restart.
A swap spans minutes to hours on two chains. A crash in the middle must never strand funds, so both engines write everything they need to a store before each irreversible step: broadcasting a funding transaction, locking stablecoins, handing out a pre-signature. After a restart, the store holds enough to finish the swap or refund it.
Stores
A store is any implementation of the SwapStore interface: get, put, delete, list(prefix), and a durable flag. put must resolve only once the record would survive a crash or power loss.
| Store | Durable | Use it for |
|---|---|---|
MemorySwapStore | No | Tests and throwaway sessions. Everything is lost on exit |
FileSwapStore( | Yes | A single process on one machine. One JSON file per record |
Your own SwapStore | You decide | A database, replicated storage, a KMS-backed vault |
Pass store to createTaker or createMaker as a directory path (which creates a FileSwapStore) or as a SwapStore object. Without it, both default to a MemorySwapStore, and both factories refuse a non-durable store on mainnet. The taker also refuses freshRecipient with a non-durable store unless you set onFreshRecipient, because the fresh address's key would be lost.
FileSwapStore creates its directory with mode 0700 and each file with mode 0600. Every put writes a temporary file, fsyncs it, renames it over the old one and fsyncs the directory, so a record is always either the old version or the new one, never a torn write. Use one store directory per process.
For a database, serialize records with encodeRecord and decodeRecord, which round-trip bigints exactly:
import { type StoredRecord, type SwapStore, decodeRecord, encodeRecord } from "@paralock/sdk";
/** Any key-value database with durable writes. */
interface Kv {
get(key: string): Promise<string | undefined>;
set(key: string, value: string): Promise<void>; // resolves only once the write is durable
delete(key: string): Promise<void>;
keysWithPrefix(prefix: string): Promise<string[]>;
}
class KvSwapStore implements SwapStore {
readonly durable = true;
constructor(private readonly kv: Kv) {}
async get<T extends StoredRecord = Record<string, unknown>>(key: string): Promise<T | undefined> {
const json = await this.kv.get(key);
return json === undefined ? undefined : decodeRecord<T>(json);
}
put(key: string, record: StoredRecord): Promise<void> {
return this.kv.set(key, encodeRecord(record)); // bigints round-trip exactly
}
delete(key: string): Promise<void> {
return this.kv.delete(key);
}
async list<T extends StoredRecord = Record<string, unknown>>(prefix: string): Promise<{ key: string; record: T }[]> {
const keys = (await this.kv.keysWithPrefix(prefix)).sort();
const out: { key: string; record: T }[] = [];
for (const key of keys) {
const record = await this.get<T>(key);
if (record) out.push({ key, record });
}
return out;
}
}Keys are short ASCII paths of at most 200 characters ([A-Za-z0-9._:/-]), and writes to the same key may arrive concurrently: the last one must win.
Persist before broadcast
| Party | Written to the store | Before |
|---|---|---|
| Taker (htlc) | The secret and the signed funding transaction | Asking the maker to accept the HTLC, and so before broadcasting the funding |
| Taker (private) | The adaptor secret y, the per-swap multisig key and the signed funding | Sending the proposal to the maker |
| Taker (private) | The maker's signature on the time-locked refund | Broadcasting the funding |
| Taker (private) | Its pre-signature on the maker's claim | Sending it to the maker |
| Taker | A fresh recipient's private key | Anything is locked |
| Taker | The signed refund transaction | Broadcasting the refund |
| Maker | Each quote, with its per-quote Zcash key in private mode | Returning the quote |
| Maker (private) | The one-time EVM signer key and the accepted proposal | Answering the proposal |
| Maker | The EVM lock parameters (what a refund needs) | Locking |
| Maker | Its ZEC claim transaction | Broadcasting the claim |
What's stored
| Key | Contents |
|---|---|
taker/ | The public Swap, its stage and options, the signed funding, the return address, and the secrets: the htlc secret, or the adaptor secret, multisig key, the maker's refund signature and both pre-signatures. Also the fresh recipient's key and the signed refund, if any |
maker/ | An open quote, and its per-quote Zcash key in private mode. Deleted when the quote is used or expires |
maker/ | The public Swap, its stage, the per-swap Zcash key and one-time signer key (private mode), the proposal, the pre-signatures, the EVM lock parameters and the claim transaction |
FileSwapStore turns / into ~ in file names, so taker/<id> is taker~<id>.json. The swap id is the hashlock in htlc mode and the quote id in private mode.
When a swap fails
taker.swap() resolves when the stablecoins are claimed. If something fails after the ZEC was locked, it throws SwapFailedError, which carries the swap (err.swap) and the code of the underlying error (err.code, for example lock_mismatch or timeout). The swap stays in the store, so you can refund it now or after a restart:
import { SwapFailedError } from "@paralock/sdk";
try {
await taker.swap(quote);
} catch (err) {
if (err instanceof SwapFailedError) {
console.log(`swap ${err.swap.id} failed (${err.code}): ${err.message}`);
await taker.refund(err.swap.id); // waits for the refund height, then returns the ZEC
} else {
throw err; // nothing was locked
}
}If it fails before anything was broadcast, swap() rethrows the original error, and the record is marked abandoned: no funds moved. That includes the maker refusing to commit (for example quote_expired or insufficient_liquidity), because the taker broadcasts its funding only after the maker accepted it. If the funding broadcast itself fails and the node doesn't report the transaction, the swap is not abandoned, since a node may have it anyway: swap() throws SwapFailedError and the swap stays pending until resume settles it.
After a crash or restart
taker.pendingSwaps() returns every persisted swap that isn't finished, meaning its ZEC may still be locked. A recovery loop at startup:
for (const swap of await taker.pendingSwaps()) {
try {
await taker.resume(swap.id); // claims if the maker's lock is there and still safe to claim
} catch {
await taker.refund(swap.id); // waits for the refund height, then returns the ZEC
}
}Taker stages
| Stage | Meaning | Finished? |
|---|---|---|
prepared | Funding signed, not broadcast; the maker hasn't committed | No |
reserved | htlc: the maker accepted the HTLC and reserved its stablecoins; the funding is broadcast next | No |
proposed | Private mode: the maker's refund signature is held and the maker reserved its stablecoins; the funding is broadcast next | No |
funded | Funding broadcast | No |
accepted | The maker has all it needs to lock (htlc: the funding confirmed; private: the pre-signature was sent) | No |
locked | The maker's lock was verified | No |
claimed | Stablecoins claimed; the secret is public | Yes |
completed | The maker claimed the ZEC | Yes |
refunded | The ZEC came back | Yes |
abandoned | Nothing was locked: the funding was never broadcast, or it expired without being mined | Yes |
resume(id, { claim? })
resume continues the state machine from the stored stage:
- A finished swap is returned as it is.
- A swap whose funding already reached the network continues from
funded, whatever stage it was saved at. - A swap stuck at
prepared(the maker never committed) is markedabandoned, and its funding is not broadcast. - A swap stuck at
reservedorproposed(the maker committed, the funding wasn't broadcast) is broadcast late only if all of these hold: the tip is at least 5 blocks below the funding's expiry height, at leastminZcashLockBlocksof the refund window remain, and the maker still holds the swap (getSwap). The maker re-checks its timeout policy on every poll and drops a reservation it can no longer lock safely, so that last check covers the windows. Otherwise the swap is markedabandoned. If the taker had already handed the funding to a node (a broadcast that failed ambiguously), it is never abandoned while that funding could still be mined:resumethrowsconflictuntil 2 blocks after the funding's expiry height. - Otherwise it waits for the funding to confirm, verifies the maker's lock, and claims if the lock is still safe to claim. If the funding expires without being mined, the swap is marked
abandoned. If the claim already landed before the crash, it finds it on-chain instead of claiming twice.
resume(id, { claim: true }) claims a swap that was started with swap(quote, { claim: false }), and resume(id, { relayClaim: false }) submits the claim from your own EVM account after a relayer refused. If the maker's lock never appears, was refunded, or expires too soon, resume throws, and you refund.
refund(id, { wait? })
refund takes the ZEC back from a persisted swap:
- It checks that the lock is still unspent. If the maker already claimed it, or the funding never confirmed, it throws a
conflicterror. - It waits until the chain reaches the refund height, polling every 15 seconds. With
{ wait: false }it throwsconflict("the refund opens at block N") instead of waiting. - It builds the refund. In htlc mode it signs with the taker's Zcash key from your configuration; in private mode it completes the refund the maker pre-signed, using the multisig key from the store.
- It stores the signed refund, broadcasts it (rebroadcasting the stored one if you call
refundagain), and waits for it to confirm.
The refund pays the return address recorded when the swap started. refund accepts an id or a Swap, and works from a fresh process: everything it needs is in the store and your configuration. It also works on a swap marked abandoned if its funding reached the chain after all.
taker.getSwap(id) returns the public view of any stored swap, and taker.freshRecipient(id) the fresh recipient's key, if one was used.
From the CLI
On the devnet, the CLI and the web app share the taker store .paralock/taker, so these commands work for swaps started from either:
pnpm paralock swaps # unfinished swaps: id, mode, status, amount, chain, refund height
pnpm paralock resume <id> # resume(id, { claim: true }) and print progress
pnpm paralock refund <id> # refund once the refund height is reached, then reshield
pnpm paralock refund <id> --no-shield # refund without reshieldingWhen a CLI swap fails after locking, the CLI refunds it automatically and prints the paralock refund <id> command in case the process is interrupted. The devnet maker keeps its state in .paralock/maker, so restarting pnpm paralock maker resumes its swaps.
Maker restarts
await maker.start() loads the store, deletes expired quotes and resumes every unfinished swap: it waits for the taker's funding, locks, watches for the taker's claim, claims the ZEC (rebroadcasting until it confirms) or refunds after the EVM expiry. Locks are idempotent on-chain, so resending one after a crash is safe. The HTTP handler calls start() on the first request too. Run a maker lists the maker's stages.
Backups
- Back up the store continuously, not once a day: a record written seconds before a crash can be the only copy of a key that claims or refunds funds.
- Encrypt backups. They contain secrets and private keys.
- Keep the Zcash signing key alongside the store (htlc-mode refunds need it), and the fresh-recipient keys, which control swapped stablecoins.
- Restore a store to exactly one process. Two processes driving the same swaps can race each other.
- Keep records of finished swaps as long as you need receipts; nothing deletes them automatically.