paralock-evm 0.1.1

EVM layer of Paralock: alloy clients for the ParalockHTLC and ParalockAdaptorEscrow escrows (lock, claim, refund, outcomes), escrow ids and claim digests, Tempo fee-token transactions, a verified network registry with the public testnet deployments, and a local deployment helper.
Documentation

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. EvmHtlcClient for ParalockHTLC (htlc mode) and AdaptorEscrowClient for ParalockAdaptorEscrow (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, ClaimSignature normalization and address_of_public_key, byte-for-byte equal to the contracts and to spec/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_code compares 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) and TESTNET_DEPLOYMENTS (Paralock's public testnet contracts), plus deploy_paralock_contracts for 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

[dependencies]
paralock-evm = "0.1"
tokio = { version = "1", features = ["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:

paralock-evm = { version = "0.1", default-features = 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 alloy::primitives::{B256, U256};
use paralock_evm::{
    EvmConfig, EvmConnection, EvmError, EvmHtlcClient, HtlcSwap, Outcome, ScanConfig, hashlock_of, testnet_deployment,
};

async fn htlc_mode(maker_key: &str) -> Result<(), EvmError> {
    let deployment = testnet_deployment(84532).expect("Base Sepolia");
    let config = EvmConfig::new("https://sepolia.base.org", 84532).with_private_key(maker_key)?;
    let conn = EvmConnection::connect(config).await?; // ChainIdMismatch on the wrong chain
    let htlc = EvmHtlcClient::new(conn.clone(), deployment.htlc, ScanConfig::from_block(deployment.start_block));

    // Maker: approve exactly the amount (if needed) and lock.
    let secret = B256::random();
    let swap = HtlcSwap {
        hashlock: hashlock_of(&secret),
        token: deployment.quote_token_address().expect("USDC"),
        sender: conn.address().expect("a signer"),
        recipient: "0x36e33698492d087ee35db0830ba305faf075d087".parse().expect("address"),
        amount: U256::from(25_000_000u64), // 25 USDC
        expiry: htlc.now().await? + 3_600,
    };
    htlc.approve_and_lock(&swap).await?;

    // Taker: read the lock back from the chain and verify it before revealing anything.
    let lock = htlc.get_lock(swap.hashlock).await?.expect("locked");
    assert_eq!(lock.swap, swap);
    let _confirmations = htlc.block_number().await? - lock.block_number + 1;

    // Anyone may claim (a relayer, too); the funds only ever go to `swap.recipient`.
    htlc.claim(&swap, &secret).await?;

    // Maker: learn the secret from the chain.
    if let Outcome::Claimed { revealed } = htlc.outcome(swap.hashlock).await? {
        assert_eq!(revealed, secret);
    }
    Ok(())
}

adaptor mode

use alloy::signers::{SignerSync, local::PrivateKeySigner};
use paralock_evm::{AdaptorEscrowClient, AdaptorSwap, ClaimSignature, EvmError, Outcome};

async fn adaptor_mode(
    escrow: &AdaptorEscrowClient,
    swap: &AdaptorSwap,
    one_time: &PrivateKeySigner,
) -> Result<(), EvmError> {
    let id = escrow.approve_and_lock(swap).await?.id; // checked against the contract's
    let digest = escrow.claim_digest(id); // bound to this chain id and escrow address
    // In the protocol the taker completes the maker's adaptor pre-signature; here the one-time key signs directly.
    let signature = ClaimSignature::try_from(one_time.sign_hash_sync(&digest).expect("sign"))?;
    escrow.claim(swap, &signature).await?; // normalized to low s; must recover to swap.signer
    if let Outcome::Claimed { revealed } = escrow.outcome(id).await? {
        // The maker extracts the adaptor secret from `revealed` (r, s).
        assert!(revealed.is_canonical());
    }
    Ok(())
}

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 = alloy_signer_aws::AwsSigner::new(client, key_id, Some(chain_id)).await?;
let config = EvmConfig::new(rpc_url, chain_id).with_signer(paralock_evm::EvmSigner::new(kms));

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

  1. The node's chain id must equal the configured one (ChainIdMismatch, nothing sent).
  2. The call is simulated with eth_call from the signer. A revert is EvmError::TransactionReverted { hash: None, revert }, where revert.name() is the contract's custom error (SwapExpired, SwapSettled, HashlockUsed, MalleableSignature, a TIP-20 InsufficientBalance, …) and htlc_error() / adaptor_error() give the typed enum.
  3. 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) == hashlock or the signature recovers to swap.signer.
  4. Gas is eth_estimateGas at latest + 20 %; fees come from eth_feeHistory and are checked against max_fee_per_gas if set.
  5. 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).
  6. The receipt is polled until EvmConfig::receipt_timeout (default 60 s), or a timeout given per call with send_and_confirm_with. A mined revert is TransactionReverted { hash: Some(..) }, named by replaying the call at its block (e.g. SwapSettled when 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 std::time::Duration;
use alloy::primitives::B256;
use paralock_evm::{EvmError, EvmHtlcClient, HtlcSwap, TxResult, TxStatus};

async fn claim_before(htlc: &EvmHtlcClient, swap: &HtlcSwap, preimage: &B256) -> Result<TxResult, EvmError> {
    let conn = htlc.connection();
    let request = htlc.claim_request(swap, preimage)?; // checks the preimage
    match conn.send_and_confirm_with(request, Duration::from_secs(10)).await {
        Err(EvmError::ReceiptTimeout { hash, .. }) => {
            if let TxStatus::Mined(result) = conn.transaction_status(hash).await? {
                return Ok(result);
            }
            // Same call and nonce, both fee fields at least +10 % (or the current estimate, if higher).
            let sent = conn.sent_transaction(hash).expect("sent by this process");
            let bumped = conn.bump(&sent).await?;
            // Catches whichever version is mined: the bump or the original.
            conn.wait_for_sent(&bumped, Some(Duration::from_secs(10))).await
        }
        other => other,
    }
}

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, feature tempo): every transaction is a Tempo transaction (type 0x76) built with tempo-alloy's TempoNetwork and feeToken set, signed by the same alloy signer. The receipt's feeToken (exposed as TxResult::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-20 transfer, the token being transferred, else pathUSD. An account that holds no pathUSD and has no preference cannot even approve ("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 paralock_evm::{EvmChainName, known_network, known_networks, testnet_deployment};

let base = known_network(8453).unwrap();
assert_eq!(base.token("USDC"), base.usdc);
let moderato = testnet_deployment(42431).unwrap();   // htlc, adaptor_escrow, start_block, tokens
assert_eq!(moderato.quote_token, "OUSD");
assert_eq!(known_networks(EvmChainName::Tempo).count(), 2);

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

cargo test -p paralock-evm            # unit, vectors, live anvil tests (own anvils on free ports) and TS interop

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