paralock-evm
The EVM layer of Paralock: trustless swaps between shielded ZEC and stablecoins on EVM chains
(Tempo, Base, Ethereum, Arbitrum, Robinhood Chain, HyperEVM). It is the Rust counterpart of the TypeScript package
@paralock/evm, built on alloy 2.x.
- Escrow clients.
EvmHtlcClientforParalockHTLC(htlc mode) andAdaptorEscrowClientforParalockAdaptorEscrow(adaptor mode): exact approvals, lock, claim, refund, lock lookups with block and transaction, outcomes (Empty/Active/Claimed { preimage | signature }/Refunded), EVM time and balances. - Protocol math.
hashlock_of,HtlcSwap::commitment,AdaptorSwap::id,claim_digest,ClaimSignaturenormalization andaddress_of_public_key, byte-for-byte equal to the contracts and tospec/vectors/evm.json. - A hardened send pipeline. Chain-id verification before every transaction, simulation with typed revert decoding, gas and fee estimation with an optional fee cap, process-wide nonce management, receipt waits with per-call timeouts, fee bumps (same nonce, +10 % or more), transaction status lookups, bounded retries with exponential backoff on transport errors and HTTP 429/5xx.
- Escrow address checks.
verify_escrow_codecompares an address's runtime code with the embedded build. - Tempo. Transactions that pay gas in a TIP-20 fee token, through Tempo's official
tempo-alloy. - Networks.
KNOWN_NETWORKS(verified chain ids, RPCs, explorers and stablecoins) andTESTNET_DEPLOYMENTS(Paralock's public testnet contracts), plusdeploy_paralock_contractsfor local chains.
The code is experimental and has not been audited. The devnet is not mainnet. Anvil's dev keys are public: never use them on a real network.
Install
[]
= "0.1"
= { = "1", = ["rt-multi-thread", "macros"] }
Features: tempo (default) enables Tempo 0x76 transactions with a fee token, through tempo-alloy, which brings
about a hundred more crates (Tempo's primitives and a second crypto stack). If you don't need fee tokens, opt out:
= { = "0.1", = false }
Everything else works the same without it, Tempo chains included: they get plain EIP-1559 transactions and the
protocol picks the fee token (see Tempo fees); only EvmConfig::with_fee_token is refused. MSRV 1.95
(alloy 2.x needs 1.94.1, tempo-alloy 1.95). The clients need a Tokio runtime.
Units
Amounts are token base units (U256); every supported stablecoin has 6 decimals (1 USD = 1,000,000). Expiries are
Unix seconds of EVM block time (now() is the latest block's timestamp), never local time and never
milliseconds: lock refuses an expiry more than max_lock_seconds (default 10,000,000 s) ahead.
htlc mode
use ;
use ;
async
adaptor mode
use ;
use ;
async
Signers and KMS
EvmConfig::with_private_key and with_signer(PrivateKeySigner) cover local keys. Anything that implements alloy's
TxSigner<Signature> plugs in with EvmSigner::new, e.g. a KMS:
let kms = new.await?;
let config = new.with_signer;
Keys never appear in Debug output or errors, and RPC URLs (which often embed an API key) are redacted from error
messages and EvmConfig's Debug.
Sending: what happens before a transaction is signed
- The node's chain id must equal the configured one (
ChainIdMismatch, nothing sent). - The call is simulated with
eth_callfrom the signer. A revert isEvmError::TransactionReverted { hash: None, revert }, whererevert.name()is the contract's custom error (SwapExpired,SwapSettled,HashlockUsed,MalleableSignature, a TIP-20InsufficientBalance, …) andhtlc_error()/adaptor_error()give the typed enum. - Client-side checks: the swap's sender is this account, amount and recipient non-zero,
now < expiry ≤ now + max_lock_seconds; for claims,sha256(preimage) == hashlockor the signature recovers toswap.signer. - Gas is
eth_estimateGasatlatest+ 20 %; fees come frometh_feeHistoryand are checked againstmax_fee_per_gasif set. - The nonce is assigned under a process-wide lock per (RPC endpoint, chain id, account), so concurrent sends never collide; a transient failure to read the pending nonce is an error rather than a guess; the broadcast is idempotent (a lost response is detected by looking the transaction up by hash).
- The receipt is polled until
EvmConfig::receipt_timeout(default 60 s), or a timeout given per call withsend_and_confirm_with. A mined revert isTransactionReverted { hash: Some(..) }, named by replaying the call at its block (e.g.SwapSettledwhen a competing claim landed first).
Deadlines, stuck transactions and fee bumps
A claim must be mined before the swap's expiry, so don't let a stuck transaction eat the window: wait for a part of
the time left, and replace the transaction with higher fees if it is not mined by then. Every error raised after a
transaction was signed carries its hash (EvmError::tx_hash()) and, where known, its nonce (EvmError::nonce()):
ReceiptTimeout { hash, nonce, .. }, BroadcastUnknown { hash, nonce, .. } (the broadcast failed but the node may
have the transaction), TransactionReverted { hash: Some(..), .. }, NonceUsed. Keep the hash when you convert the
error, and wait for, look up or bump that transaction instead of sending the operation again.
use Duration;
use B256;
use ;
async
submit broadcasts without waiting and returns the SentTx record (hash, nonce, gas, fees, every replaced
version); it is serializable, so a caller can persist it and still wait for or bump the transaction after a restart.
sent_transaction(hash) finds the latest version of one this process sent; bump refuses a nonce that a mined
transaction already used (NonceUsed: wait for it with wait_for_sent). transaction_status(hash) is
Pending, Mined(receipt), Reverted(receipt) or Unknown (never received, dropped, or replaced). The escrow
clients' claim_request / refund_request build the calls (with the same client-side checks as claim / refund)
for these connection methods.
approve_and_lock checks the swap first (nothing is approved for a lock that would be refused), then serializes
approve → lock per (account, token, escrow), so exact approvals are never overwritten by a concurrent lock. Use one
EvmConnection per chain; separate processes sending from the same key are not coordinated: give each its own key.
Reading the chain
get_lock checks the node's chain id first (it is what a taker verifies before revealing). get_lock / outcome
pin the latest block, read the on-chain state at that block, and scan logs newest-first from
ScanConfig::from_block (the contract's deployment block) in windows of max_block_range blocks. A window the node
refuses is halved, down to one block: a JSON-RPC error, HTTP 400 or 413 (providers that cap the range at the
gateway), or a message about the range or result size ("block range", "query returned more than", "range too
large", …). Once a narrower window works, the client keeps it for later scans (scan_window()); a refusal that
persists down to one block is returned as the error. Logs are checked against the filter, the HTLC commitment must
equal the stored one while active, an escrow's idOf(swap) must equal its id, a revealed preimage must hash to the
hashlock, and a published claim signature must be canonical and recover to the swap's signer. A settled entry whose
events are not served yet is SettlementNotIndexed (retry); a lock older than from_block is LockEventMissing.
Checking escrow addresses
Before trusting a configured escrow address, check that it holds this build's contract: verify_escrow_code() on
either client (or EvmConnection::verify_escrow_code(address, ParalockContract::…)) compares the runtime code on the
configured chain with the embedded artifact's deployedBytecode, byte for byte, and fails with CodeMismatch
otherwise (no code, swapped addresses, another contract or another build). The public testnet deployments in
TESTNET_DEPLOYMENTS match.
Tempo fees
Tempo has no native gas token; fees are paid in a TIP-20 USD stablecoin. Verified on anvil --network tempo
(Foundry 1.8.5):
- With a fee token (
EvmConfig::with_fee_token, featuretempo): every transaction is a Tempo transaction (type0x76) built withtempo-alloy'sTempoNetworkandfeeTokenset, signed by the same alloy signer. The receipt'sfeeToken(exposed asTxResult::fee_token) is that token. An account holding only BetaUSD pays in BetaUSD. - Without one: plain EIP-1559 transactions work, and the protocol picks the token: the account's FeeManager
preference (
tempo_fee_token_preference; anvil's dev accounts are preset to AlphaUSD), else, for a TIP-20transfer, the token being transferred, else pathUSD. An account that holds no pathUSD and has no preference cannot evenapprove("insufficient fee token balance").
Set a fee token in production so fees come from the stablecoin you hold. Fees are paid on top of the swap amount: an
account locking OUSD and paying fees in OUSD needs amount + fees.
Networks and deployments
use ;
let base = known_network.unwrap;
assert_eq!;
let moderato = testnet_deployment.unwrap; // htlc, adaptor_escrow, start_block, tokens
assert_eq!;
assert_eq!;
deploy_paralock_contracts deploys ParalockHTLC, ParalockAdaptorEscrow and a local stablecoin (a MockUSD
"USDC", or on Tempo an "OUSD" stand-in created through the TIP-20 factory) from the embedded artifacts, reusing
contracts that are still current. It refuses every known public network; use contracts/script/Deploy.s.sol there.
For the swap engines
The SDK's EvmClient / AdaptorEvmClient map onto the clients one to one: lock = approve_and_lock, claim,
refund, get_locked_swap, get_lock (LockInfo { swap, block_number, tx_hash, … }), outcome, now,
block_number, rpc_chain_id, token_balance, id_of, claim_digest. Errors say whether retrying may help
(EvmError::is_transient) and which transaction is already out (EvmError::tx_hash, EvmError::nonce).
Tests
The live tests start private anvils (~/.foundry/bin/anvil, ANVIL_BIN or PATH), including
anvil --network tempo, and are skipped without one. The interop test also needs Node and the built TypeScript
packages (pnpm build).
License
MIT