paralock
The Rust SDK of Paralock: trustless swaps and payments between ZEC and stablecoins on EVM chains (Tempo, Base, Ethereum, Arbitrum, Robinhood Chain, HyperEVM). There is a lock on each chain and one secret opens both: either both sides settle or both refund. No bridge, custodian or wrapped token.
Embed a market maker (served with axum) or a taker in a Rust server, without a Node process. The engines are
a port of the TypeScript @paralock/sdk (protocol v1.1) and interoperate with it on the wire: a Rust taker swaps with a
TypeScript maker, and a TypeScript taker with a Rust maker, in both modes.
All protocol logic (scripts, transactions, signature hashes, adaptor signatures, escrow ids and digests, wire messages
and every safety check) comes from paralock-core; EVM transactions go
through paralock-evm (alloy, Tempo fee tokens). This crate adds the I/O:
Zcash node clients, durable stores, the maker HTTP API, a price feed, and the persisted swap state machines.
Status: experimental. It went through a security review whose findings are fixed in this release (see
CHANGELOG.md), but it is not formally audited. The Zcash leg is transparent in both modes (shield before and after), and the devnet is not mainnet. Read the trust model, and don't put more money through it than you can afford to lose.
Features
| Feature | Default | What it adds |
|---|---|---|
client |
yes | HttpMakerApi: talk to any Paralock maker over HTTP (responses validated with paralock-core's strict parsers) |
server |
maker_router: an axum Router with exactly the protocol's routes and JSON |
|
lightwalletd |
Lightwalletd: a ZcashNode over lightwalletd / Zaino gRPC (tonic + rustls), no full node needed |
|
price-feed |
CoinGecko: a cached USD/ZEC price with bounded staleness |
|
evm |
paralock-evm clients for both escrows (evm_adapter) and the create_taker / create_maker builders |
|
tempo |
yes | With evm: Tempo transactions paying gas in a chosen TIP-20 (EvmChainOptions::fee_token, via tempo-alloy). Turn default features off to drop it and its dependencies; Tempo then uses plain EIP-1559 transactions |
Always included: the Taker and Maker engines, ZcashRpc (Zebra / Zakura JSON-RPC over rustls), MemoryStore and
FileStore. The minimum Rust version is 1.95, as for paralock-evm.
[]
= { = "0.1", = ["evm", "server", "price-feed"] }
A taker
use Arc;
use *;
use ;
# async
After a crash, taker.resume(&id, ResumeOptions::default()) finishes a swap from the store, and
taker.refund(&id, true) returns the ZEC once the refund height is reached. taker.pending_swaps() lists what is
still open.
An embedded maker (axum)
use Arc;
use *;
use ;
# async
The router serves GET /health, GET /quote, POST /swaps, POST /adaptor/swaps,
POST /adaptor/swaps/{id}/presignature, GET /swaps/{id} (the public view, never the adaptor data) and, only with
expose_swap_list (off by default, and rate limited), GET /swaps. Errors are { "error", "code" } with the code's status; node and internal failures
become generic 503 / 500 bodies, so no internal detail leaks. Bodies over 16 KiB get 413.
Limits. ServerOptions::default() is meant for the open internet:
| Option | Default | What happens |
|---|---|---|
request_timeout |
30 s | 504 timeout. The maker's work runs in its own task and is never cut off midway: a commitment still running when the timeout fires completes, and the client's retry (the POSTs are idempotent) gets its result. |
max_concurrent_requests |
256 | Beyond it, 429 busy right away. |
rate_limit |
120 / min per client, bursts of 30 | On GET /quote, the three POSTs and GET /swaps: 429 busy with retry-after. IPv6 clients are grouped by /64. A taker polling its pre-signature every 1 to 2 s stays well within it. The same client address feeds the maker's per-client reservation limit. |
client_address |
the TCP peer | Behind a reverse proxy, ClientAddress::Header("x-forwarded-for".into()) (the last entry, the one your proxy appended). |
max_connections, idle_timeout |
1 024, 75 s | serve only: further connections wait in the backlog; a connection with no traffic is closed (slow or idle clients). |
ServerOptions::unlimited() turns them all off (tests, or a proxy that enforces its own). serve_with_shutdown stops
gracefully on a signal.
To mount the routes in your own axum app, nest maker_router: the timeout, concurrency and rate limits travel with
it. The rate limit needs the client's address, so serve the app with connect info (or set client_address behind a
proxy); without either, requests are not rate limited and a warning is logged once. Connection limits are then your
server's (or proxy's) job.
# async
For other HTTP frameworks, call Maker::accept_value, propose_adaptor_value and adaptor_pre_signature_value with
the parsed JSON body, and paralock::maker::client_error to map errors to a status and body.
Your own EVM client, node or store
The engines take trait objects: implement EvmClient / AdaptorEvmClient (lock, claim, simulate_claim, refund,
get_lock, state / status, outcome, chain reads) to plug in another library, a custody API or a KMS;
ZcashNode for another Zcash backend; SwapStore for a database. Then build a TakerConfig / MakerConfig directly
and call Taker::new / Maker::new. Ids, commitments and claim digests are always computed by paralock-core, never
by the client.
What the engines guarantee
- Persist before broadcast. Secrets, adaptor secrets, per-swap keys, refund signatures, lock parameters, claims
and refunds are durably stored (
FileStore: temp file, fsync, rename, fsync of the directory, whose failure is an error; directory 0700, files 0600, an exclusive lock per directory) before the transaction they protect is sent. The taker persists its decision to claim, and the claim it sends (hash, nonce, fees), as stageclaiming. - The maker commits first (v1.1). The taker signs and persists its funding, has the maker accept it (htlc) or sign
its refund (private mode, verified with
verify_proposal_accepted, network upgrades included), and only then broadcasts it. A funding a node may still mine is never abandoned. - Verify before revealing. The taker re-runs
paralock-core'sverify_htlc_lock/verify_adaptor_lock(recipient, token, amount, signer, the pre-signature on its own chain's digest, confirmations, claim window) right before claiming, through a second RPC endpoint too when one is configured, and simulates the claim first: a reverted claim would still publish the secret. On Ethereum L1 the claim window defaults to at least 5 minutes and 3 confirmations. - The claim is followed until it is mined. It goes out without a receipt wait and is followed through the
transaction's status and the escrow's state; if it sits unmined it is replaced at the same nonce with higher fees
(
TakerTimeouts::claim_bump_after) until the lock expires. After a restartresumelooks at the claim already sent before it sends anything. A claim by anyone else that paid the recipient (a relayer's duplicate, a front-runner) counts as the success it is. - The maker locks only what it verified. The funding transaction must hash to the declared txid and its output
must pay the lock with the exact (private) or at least the quoted (htlc) amount, with the policy's confirmations,
while the window rule holds with the EVM time actually left (
assert_safe_to_lock, re-checked after the approval, right before the lock goes out). A funding it can't read is treated as not paying the lock. Commitments are serialized, so capacity and inventory checks see each other's reservations; a reservation is released once its funding expires unmined, once the window rule fails (checked before anything about the funding is read, so a node that can't serve it never pins it), or, if someone else locked under its hashlock, by the lock's expiry. - The maker always settles. It rebroadcasts its claim until confirmed (signing an htlc claim again if a network upgrade changed the branch id), claims the ZEC if the taker's claim lands just before the expiry, refunds after it, and marks a swap lost only on a confirmed spend by another transaction.
- One bad record never stops the others. Records are loaded one by one; one that can't be read is moved to the
store's quarantine (
SwapStore::quarantine, aquarantine/directory in aFileStore) with an error log, and every other swap is resumed. Each record carries a formatversionwith a migration hook; a record from a newer SDK is left alone.Maker::newrefuses a configuration whose quotes the wire format would refuse (a token symbol over 32 characters, for example), so the maker never writes records it can't load again. - Bounded work. A maker keeps its unfinished swaps and the 256 most recently finished ones in memory
(
MakerConfig::finished_swaps_in_memory); everything stays in the store. Token balances read for quotes are reused for a few seconds (balance_cache), request work runs under a cap that leaves the nodes to the settlement loops (max_request_work), and one client holds at mostmax_reservations_per_clientunfunded reservations (the router passes the client's address).MakerConfig::on_updatereports every persisted status change. - Logs (
tracing) never contain keys, secrets, preimages or pre-signatures, and swap ids are shortened.Debugof records, configurations andError::SwapFailedredacts every secret, pre-signature, adaptor point and escrow data. - Little to fingerprint. The HTTP clients (
HttpMakerApi,ZcashRpc,CoinGecko) send a genericUser-Agent(DEFAULT_USER_AGENT, the same in every version; configurable in each client's options), and follow redirects only within the original origin (scheme, host and port), so an API key inHttpMakerApiOptions::headersnever reaches another server.HttpMakerApiwarns about a plain-http maker URL on mainnet (except loopback and.onion).
Trust model
The protocol keeps funds safe against the counterparty; each party still trusts its own nodes, so run them (or pick providers you trust) for real funds:
- The taker trusts its EVM RPC endpoint for the maker's lock (its terms and confirmations) and the chain's time
left before revealing its secret. A lying endpoint could make it reveal against a lock that doesn't exist or is
about to expire, and the ZEC would be lost. Set
EvmChainOptions::verify_rpc_url(orTakerChain::verifier) to a second, independent endpoint: the last check before claiming then requires both to report the same lock and both to pass every check. - The maker trusts its Zcash backend for the taker's funding (its outpoint, value and confirmations) before it
locks stablecoins. A full node (Zebra) verifies the chain; a lightwalletd / Zaino server does no proof-of-work
verification of what it serves, and one run by someone else could report a funding that doesn't exist. Use your
own full node, or your own lightwalletd in front of it. Private swaps on mainnet also need a backend that reports
network upgrades (or
allow_unknown_upgrades), since an upgrade before the refund height invalidates the pre-signed refund. - Both trust their EVM endpoints for the escrows' state, and the builders check every escrow address against the
contract code the SDK was built with (
verify_escrow_code) before using it. On mainnet the builders open the store directory restricted to its owner (FileStore::open_private).
Interoperability
-
Wire format. Requests and responses are the TypeScript SDK's JSON, field for field and in the same key order.
-
Conformance.
tests/conformance.rsreplays all 53 recorded exchanges ofspec/vectors/messages.jsonagainstmaker_router, in the recording's environment, and gets identical status codes and bodies, down to the per-quote and signer keys (the test injects the recording's deterministic randomness throughEntropy). The client parses every recorded response. -
Live devnet.
examples/devnet.rsruns onparalock devnet up:PARALOCK_ZCASH_BACKEND=lightwalletdRust taker ↔ TypeScript maker (htlc on Tempo, private on Base, private on Tempo over lightwalletd), TypeScript taker ↔ Rust maker (htlc on Tempo, private on Base, and a private swap the taker never claims: the Rust maker refunds its escrow and the taker refunds its ZEC with the Rust maker's pre-signed refund), Rust ↔ Rust, and taker refunds in both modes all complete on the devnet.
Tests
The engine tests port packages/sdk/test/engines.test.ts (including the slow-block quote-expiry regression, the
refund paths, crash and resume at every stage, maker limits and reservation release) and add Rust-specific cases
(concurrent swaps from one key, double drives, claim simulation, secret redaction). tests/audit.rs turns the security
review's reproductions into regression tests (hashlock squatting, unreadable fundings and records, stuck and front-run
claims, branch-id changes, per-client limits, a second RPC endpoint). tests/live_nodes.rs checks
ZcashRpc and Lightwalletd against a running devnet and skips otherwise.
The lightwalletd client code in src/lightwalletd/proto.rs is generated from the vendored proto/ with
tools/protogen (tonic-prost-build and protox, no protoc).
License
MIT