Skip to main content

paralock_cli/commands/
taker.rs

1//! The taker's commands: `quote`, `swap`, `pay`, `swaps`, `resume` and `refund`.
2
3use std::io::{IsTerminal as _, Write as _};
4use std::path::Path;
5use std::sync::Arc;
6use std::time::Duration;
7
8use offline::OfflineEvm;
9use paralock::evm::{RelayerClients, VerifierClients};
10use paralock::http::{HttpMakerApi, HttpMakerApiOptions};
11use paralock::taker::FreshRecipient;
12use paralock::{
13    EvmChain, FileStore, Hex, Network, PrivacyOptions, Quote, ResumeOptions, Swap, SwapMode, SwapOptions, SwapStatus,
14    Taker, TakerConfig, TakerPriceCheck, TakerSwapRecord, ZcashNode,
15};
16use paralock_core::keys::{KeyPair, PrivateKey};
17use paralock_core::protocol::is_standard_amount;
18use paralock_core::zcash::TransparentAddress;
19use serde_json::{Value, json};
20
21use super::{connect, pick_chain, price_source, zcash_node};
22use crate::amount::{self, parse_usd, parse_zec, usd, zec};
23use crate::cli::{PayArgs, QuoteArgs, RefundArgs, ResumeArgs, SwapArgs, SwapCommon, SwapsArgs};
24use crate::config::Setup;
25use crate::devnet::{Faucet, Miner};
26use crate::error::{CliError, CliResult, Exit};
27use crate::output::{Output, status_str, warn};
28
29/// A connected taker.
30pub struct TakerHandle {
31    /// The engine.
32    pub taker: Taker,
33    /// Its Zcash backend.
34    pub node: Arc<dyn ZcashNode>,
35    /// The transparent address it funds swaps from.
36    pub address: String,
37}
38
39/// What `build` connects.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Connect {
42    /// The chain's RPC must answer (quote, swap, pay, resume).
43    Online,
44    /// Refunds only need Zcash: an unreachable EVM RPC is tolerated.
45    ZcashOnly,
46}
47
48/// Builds the taker of `setup` for `chains`. `relay` requires a relayer key.
49pub async fn build(
50    setup: &Setup,
51    out: Output,
52    chains: &[EvmChain],
53    relay: bool,
54    connect_mode: Connect,
55) -> CliResult<TakerHandle> {
56    let network = setup.network;
57    let key = setup.zcash_key.load()?;
58    let key = KeyPair::from_private(
59        PrivateKey::from_hex(&key).map_err(|_| CliError::usage("the Zcash key is not a private key"))?,
60    );
61    let address = TransparentAddress::p2pkh(&key.public_key(), network).encode();
62    let node = zcash_node(setup)?;
63    let maker_url = setup.maker_url.clone().ok_or_else(|| {
64        CliError::usage("no maker to trade with").hint("set maker_url in paralock.toml or pass --maker-url <url>")
65    })?;
66    let maker = HttpMakerApi::with_options(maker_url, network, HttpMakerApiOptions::default())?;
67    let price = TakerPriceCheck::Source(price_source(setup)?);
68    let mut cfg = TakerConfig::new(network, node.clone(), key, Arc::new(maker), price);
69    cfg.return_address = setup.return_address.clone();
70    cfg.store = Arc::new(FileStore::open(setup.taker_store())?);
71    cfg.limits = setup.taker_limits;
72    cfg.timeouts = setup.taker_timeouts;
73    cfg.on_update = Some(out.progress(false));
74    let recipients = setup.store_dir.join("recipients.jsonl");
75    cfg.on_fresh_recipient =
76        Some(Arc::new(move |fresh: &FreshRecipient, swap: &Swap| match save_recipient(&recipients, fresh, swap) {
77            Ok(()) => out.say(format!(
78                "  fresh recipient {} (its key is saved in the swap store and {})",
79                fresh.address,
80                recipients.display()
81            )),
82            Err(e) => warn(&format!("could not save the fresh recipient's key to {}: {e}", recipients.display())),
83        }));
84    if let Some(devnet) = &setup.devnet {
85        let miner = Arc::new(Miner::new(&devnet.zcash.rpc)?);
86        cfg.mine_zcash = Some(Arc::new(move |_blocks| {
87            let miner = Arc::clone(&miner);
88            Box::pin(async move {
89                miner.tick().await;
90            })
91        }));
92    }
93    for chain in chains {
94        let c = setup.chain(*chain)?;
95        if relay && c.relayer.is_none() {
96            return Err(CliError::usage(format!("--relay needs a relayer on {chain}"))
97                .hint(format!("set relayer_key_env or relayer_key_file in [evm.{chain}]")));
98        }
99        let tokens = if c.tokens.is_empty() {
100            paralock_evm::known_network(c.chain_id)
101                .map(|n| n.tokens.iter().map(|(_, a)| Hex::from_bytes(a.as_slice())).collect::<Vec<_>>())
102                .unwrap_or_default()
103        } else {
104            c.tokens.clone()
105        };
106        if tokens.is_empty() {
107            return Err(CliError::usage(format!(
108                "[evm.{chain}]: list the stablecoins you accept in tokens (none are known for chain id {})",
109                c.chain_id
110            )));
111        }
112        // Receipts are never awaited longer than half the claim window (as `create_taker` does).
113        let window = setup.taker_limits.claim.min_claim_window_seconds();
114        let receipt_timeout = Some(Duration::from_secs((window / 2).clamp(5, 60)));
115        let connected = match connect(c, &c.key, None, receipt_timeout).await {
116            Ok(connected) => connected,
117            Err(e) if connect_mode == Connect::ZcashOnly => {
118                warn(&format!("{}; refunding with Zcash only", e.message));
119                let account = Hex::from_bytes(
120                    &paralock_core::evm::EvmAddress::from_public_key(
121                        &PrivateKey::from_hex(&c.key.load()?).map_err(|_| CliError::usage("bad EVM key"))?.public_key(),
122                    )
123                    .0,
124                );
125                (Arc::new(OfflineEvm::new(c, account)) as Arc<dyn paralock::EvmClient>, None)
126            }
127            Err(e) => return Err(e),
128        };
129        let mut taker_chain = paralock::TakerChain::new(connected.0, tokens);
130        taker_chain.adaptor = connected.1;
131        taker_chain.token_decimals = Some(c.token.as_ref().map_or(6, |t| t.decimals));
132        if let (Some(relayer), Connect::Online) = (&c.relayer, connect_mode) {
133            // Needed for --relay (and to resume a swap started with it); otherwise its absence is only a warning.
134            match connect(c, relayer, None, receipt_timeout).await {
135                Ok((htlc, adaptor)) => taker_chain.relayer = Some(RelayerClients { htlc, adaptor }),
136                Err(e) if relay => return Err(e.context("the relayer")),
137                Err(e) => {
138                    warn(&format!("the {chain} relayer is unavailable ({}); claims use your own account", e.message))
139                }
140            }
141        }
142        if let (Some(url), Connect::Online) = (&c.verify_rpc, connect_mode) {
143            // Read-only: the final lock check before claiming reads through both endpoints.
144            let (htlc, adaptor) = connect(c, &c.key, Some(url), receipt_timeout).await?;
145            taker_chain.verifier = Some(VerifierClients { htlc, adaptor });
146        }
147        cfg.chains.insert(*chain, taker_chain);
148    }
149    let taker = Taker::new(cfg)?;
150    Ok(TakerHandle { taker, node, address })
151}
152
153/// Appends a fresh recipient's key to `recipients.jsonl` (mode 0600), like the npm CLI.
154fn save_recipient(path: &Path, fresh: &FreshRecipient, swap: &Swap) -> std::io::Result<()> {
155    let mut options = std::fs::OpenOptions::new();
156    options.append(true).create(true);
157    #[cfg(unix)]
158    {
159        use std::os::unix::fs::OpenOptionsExt;
160        options.mode(0o600);
161    }
162    let line = json!({
163        "address": fresh.address,
164        "privateKey": fresh.private_key.expose(),
165        "swapId": swap.id,
166        "chain": swap.evm.chain,
167    });
168    let mut file = options.open(path)?;
169    writeln!(file, "{line}")?;
170    file.sync_all()
171}
172
173fn mode_of(private: bool) -> SwapMode {
174    if private { SwapMode::Adaptor } else { SwapMode::Htlc }
175}
176
177fn decimals(setup: &Setup, chain: EvmChain) -> u32 {
178    setup.chains.get(&chain).and_then(|c| c.token.as_ref()).map_or(6, |t| t.decimals)
179}
180
181fn mode_line(mode: SwapMode) -> &'static str {
182    match mode {
183        SwapMode::Adaptor => "private (adaptor signatures: no hash on either chain)",
184        SwapMode::Htlc => "htlc (a shared sha256 hashlock on both chains)",
185    }
186}
187
188fn print_quote(out: Output, setup: &Setup, quote: &Quote, pay: Option<(&str, Option<&str>)>) {
189    let d = decimals(setup, quote.chain);
190    let dollars = usd(quote.usd_amount, d);
191    match pay {
192        Some((to, label)) => {
193            let label = label.map(|l| format!(" ({l})")).unwrap_or_default();
194            out.say(format!("Pay {dollars} {} on {} to {to}{label}", quote.token_symbol, quote.chain));
195            out.say(format!("  costs {} ZEC @ ${:.2}/ZEC", zec(quote.zec_amount_zat), quote.rate));
196        }
197        None => out.say(format!(
198            "Quote: {} ZEC → {dollars} {} on {} @ ${:.2}/ZEC",
199            zec(quote.zec_amount_zat),
200            quote.token_symbol,
201            quote.chain,
202            quote.rate
203        )),
204    }
205    out.say(format!("  Mode {}", mode_line(quote.mode)));
206    out.say(format!(
207        "  Zcash refund window {} blocks · {} lock {}s · quote {}",
208        quote.zcash_lock_blocks, quote.chain, quote.evm_lock_seconds, quote.id
209    ));
210}
211
212fn quote_json(quote: &Quote, decimals: u32) -> Value {
213    json!({
214        "quote": quote,
215        "zec": zec(quote.zec_amount_zat),
216        "usd": amount::exact(quote.usd_amount, decimals),
217    })
218}
219
220/// `paralock quote`.
221pub async fn quote(setup: &Setup, out: Output, args: QuoteArgs) -> CliResult<Exit> {
222    let chain = pick_chain(setup, args.chain)?;
223    let h = build(setup, out, &[chain], false, Connect::Online).await?;
224    let mode = mode_of(args.private);
225    let quote = match (&args.zec, &args.usd) {
226        (Some(z), _) => h.taker.quote(parse_zec(z)?, chain, mode).await?,
227        (None, Some(u)) => h.taker.quote_exact_out(parse_usd(u, decimals(setup, chain))?, chain, mode).await?,
228        (None, None) => return Err(CliError::usage("give --zec or --usd")),
229    };
230    print_quote(out, setup, &quote, None);
231    out.say("  The quote passed every check (keys, chain id, token, windows, price). Nothing was locked.");
232    out.result(quote_json(&quote, decimals(setup, chain)));
233    Ok(Exit::Ok)
234}
235
236/// `paralock swap`.
237pub async fn swap(setup: &Setup, out: Output, args: SwapArgs) -> CliResult<Exit> {
238    let zat = parse_zec(&args.zec)?;
239    if args.standard_only && !is_standard_amount(zat) {
240        return Err(CliError::usage("--standard-only allows 0.1, 0.5, 1 or 5 ZEC"));
241    }
242    let chain = pick_chain(setup, args.common.chain)?;
243    let h = build(setup, out, &[chain], args.common.relay, Connect::Online).await?;
244    let quote = h.taker.quote(zat, chain, mode_of(args.common.private)).await?;
245    print_quote(out, setup, &quote, None);
246    let privacy = PrivacyOptions {
247        fresh_recipient: args.fresh_recipient.then_some(true),
248        relay_claim: args.common.relay.then_some(true),
249        standard_amounts_only: args.standard_only.then_some(true),
250        jitter_ms: args.jitter,
251    };
252    let options = SwapOptions { no_claim: false, privacy, recipient: None, label: None };
253    run(setup, out, &h, &quote, options, &args.common, args.refund_demo).await
254}
255
256/// `paralock pay`.
257pub async fn pay(setup: &Setup, out: Output, args: PayArgs) -> CliResult<Exit> {
258    let to = paralock_core::evm::EvmAddress::parse(args.to.trim()).map_err(|_| {
259        CliError::usage(format!("--to must be an EVM address (0x + 40 hex digits), got \"{}\"", args.to))
260    })?;
261    let to = Hex::from_bytes(&to.0);
262    let chain = pick_chain(setup, args.common.chain)?;
263    let units = parse_usd(&args.usd, decimals(setup, chain))?;
264    let h = build(setup, out, &[chain], args.common.relay, Connect::Online).await?;
265    let quote = h.taker.quote_exact_out(units, chain, mode_of(args.common.private)).await?;
266    print_quote(out, setup, &quote, Some((to.as_str(), args.label.as_deref())));
267    let privacy = PrivacyOptions { relay_claim: args.common.relay.then_some(true), ..PrivacyOptions::default() };
268    let options = SwapOptions { no_claim: false, privacy, recipient: Some(to), label: args.label.clone() };
269    run(setup, out, &h, &quote, options, &args.common, false).await
270}
271
272/// Asks before a mainnet swap, unless `--yes`.
273async fn confirm(setup: &Setup, out: Output, yes: bool) -> CliResult<()> {
274    if setup.network != Network::Mainnet || yes {
275        return Ok(());
276    }
277    if out.json || !std::io::stdin().is_terminal() {
278        return Err(
279            CliError::usage("a mainnet swap needs confirmation").hint("add --yes to confirm it non-interactively")
280        );
281    }
282    out.say("This locks real ZEC on mainnet. Paralock is experimental and not audited.");
283    let answer = tokio::task::spawn_blocking(|| {
284        let mut stdout = std::io::stdout();
285        let _ = write!(stdout, "Proceed? [y/N] ");
286        let _ = stdout.flush();
287        let mut line = String::new();
288        std::io::stdin().read_line(&mut line).map(|_| line)
289    })
290    .await
291    .map_err(|e| CliError::failed(e.to_string()))??;
292    if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
293        Ok(())
294    } else {
295        Err(CliError::failed("cancelled; nothing was locked"))
296    }
297}
298
299/// The transparent balance of the taker's address, in zatoshi.
300async fn balance(h: &TakerHandle) -> CliResult<u64> {
301    let utxos = h.node.get_address_utxos(std::slice::from_ref(&h.address)).await.map_err(paralock::Error::from)?;
302    Ok(utxos.iter().map(|u| u.value).sum())
303}
304
305/// Makes sure the swap address can fund `zat` plus fees: from the faucet on the devnet, otherwise by checking.
306async fn ensure_funds(setup: &Setup, out: Output, h: &TakerHandle, zat: u64) -> CliResult<()> {
307    let have = balance(h).await?;
308    let need = zat.saturating_add(50_000);
309    if have >= need {
310        return Ok(());
311    }
312    let Some(devnet) = &setup.devnet else {
313        return Err(CliError::failed(format!(
314            "the swap address {} holds {} ZEC; this swap needs {} ZEC plus the network fee",
315            h.address,
316            zec(have),
317            zec(zat)
318        ))
319        .hint(format!("send ZEC to {} (a transparent address), then run the command again", h.address)));
320    };
321    let missing = need - have;
322    out.say(format!("Funding the swap address {} with {} ZEC from the devnet faucet…", h.address, zec(missing)));
323    Faucet::new(&devnet.zcash.dashboard)?.pay(&h.address, missing).await?;
324    let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
325    while balance(h).await? < need {
326        if tokio::time::Instant::now() >= deadline {
327            return Err(CliError::failed("the faucet's ZEC did not arrive within 2 minutes"));
328        }
329        tokio::time::sleep(Duration::from_secs(1)).await;
330    }
331    Ok(())
332}
333
334fn summary_lines(out: Output, swap: &Swap) {
335    let rows = [
336        ("Zcash lock", Some(swap.zcash.htlc_address.clone())),
337        ("Zcash funding", swap.zcash.funding_txid.clone()),
338        ("EVM lock", swap.evm.lock_tx.as_ref().map(ToString::to_string)),
339        ("EVM claim", swap.evm.claim_tx.as_ref().map(ToString::to_string)),
340        ("Zcash claim", swap.zcash.claim_txid.clone()),
341        ("Zcash refund", swap.zcash.refund_txid.clone()),
342        ("EVM refund", swap.evm.refund_tx.as_ref().map(ToString::to_string)),
343    ];
344    for (what, value) in rows {
345        if let Some(value) = value {
346            out.say(format!("  {what:<14} {value}"));
347        }
348    }
349}
350
351async fn run(
352    setup: &Setup,
353    out: Output,
354    h: &TakerHandle,
355    quote: &Quote,
356    options: SwapOptions,
357    common: &SwapCommon,
358    refund_demo: bool,
359) -> CliResult<Exit> {
360    confirm(setup, out, common.yes).await?;
361    ensure_funds(setup, out, h, quote.zec_amount_zat).await?;
362    let d = decimals(setup, quote.chain);
363    if refund_demo {
364        let swap = h.taker.lock_only(quote, options.recipient.clone(), options.label.clone()).await?;
365        out.say(format!(
366            "Refund demo: the maker never locks. Waiting for the Zcash refund height {}…",
367            swap.zcash.refund_height
368        ));
369        let refunded = h.taker.refund(&swap.id, true).await?;
370        out.say(format!("Refunded {} ZEC to the taker. No funds were lost.", zec(refunded.zcash.amount_zat)));
371        summary_lines(out, &refunded);
372        out.result(json!({ "swap": refunded }));
373        return Ok(Exit::Ok);
374    }
375    out.say("Swapping:");
376    let pay = options.recipient.is_some();
377    match h.taker.swap(quote, options).await {
378        Ok(swap) => {
379            let what = format!("{} {} on {}", usd(swap.evm.amount, d), quote.token_symbol, quote.chain);
380            if pay {
381                out.say(format!("\nPaid: {} received {what}; status {}.", swap.evm.recipient, status_str(swap.status)));
382            } else {
383                out.say(format!(
384                    "\nDone: received {what} at {}; status {}.",
385                    swap.evm.recipient,
386                    status_str(swap.status)
387                ));
388            }
389            summary_lines(out, &swap);
390            if swap.mode == SwapMode::Adaptor {
391                out.say("No hashlock appeared on either chain; the secret traveled inside the signatures.");
392            }
393            out.result(json!({ "swap": swap }));
394            Ok(Exit::Ok)
395        }
396        Err(e) => {
397            let Some(failed) = e.failed_swap().cloned() else { return Err(e.into()) };
398            let refund_hint = format!(
399                "refund it once block {} is reached: paralock refund {}",
400                failed.zcash.refund_height, failed.id
401            );
402            if common.no_refund {
403                return Err(CliError::from(e).hint(refund_hint));
404            }
405            out.say(format!(
406                "Swap failed after locking ZEC ({e}). Refunding once the Zcash refund height {} is reached…",
407                failed.zcash.refund_height
408            ));
409            out.say(format!("  (the swap is saved; `paralock refund {}` also works after a restart)", failed.id));
410            match h.taker.refund(&failed.id, true).await {
411                Ok(refunded) => {
412                    out.say(format!("ZEC refunded to the taker ({} ZEC).", zec(refunded.zcash.amount_zat)));
413                    summary_lines(out, &refunded);
414                    if out.json {
415                        let error = CliError::from(e);
416                        crate::output::print_document(&json!({
417                            "ok": false,
418                            "refunded": true,
419                            "error": { "code": error.code.map(|c| c.as_str()), "message": error.message },
420                            "swap": refunded,
421                        }));
422                    }
423                    Ok(Exit::SwapFailed)
424                }
425                Err(r) => {
426                    let mut error = CliError::from(e);
427                    error.message = format!("{}; the refund failed too: {r}", error.message);
428                    error.exit = Exit::SwapFailed;
429                    error.swap = Some(Box::new(failed));
430                    Err(error.hint(refund_hint))
431                }
432            }
433        }
434    }
435}
436
437/// A swap id as the stores name them: `0x` + 64 hex digits (htlc) or a UUID (private mode).
438fn check_id(id: &str) -> CliResult<String> {
439    let id = id.trim().to_ascii_lowercase();
440    let hash = id.strip_prefix("0x").is_some_and(|d| d.len() == 64 && d.bytes().all(|b| b.is_ascii_hexdigit()));
441    if hash || paralock_core::protocol::is_quote_id(&id) {
442        Ok(id)
443    } else {
444        Err(CliError::usage(format!("\"{id}\" is not a swap id (0x + 64 hex digits, or a UUID)")))
445    }
446}
447
448/// Reads the taker's records straight from its store directory (read-only, without taking the store's lock, so it
449/// works while a swap is running).
450pub fn read_records(dir: &Path) -> CliResult<Vec<TakerSwapRecord>> {
451    let Ok(entries) = std::fs::read_dir(dir) else { return Ok(Vec::new()) };
452    let mut records = Vec::new();
453    for entry in entries.flatten() {
454        let name = entry.file_name().to_string_lossy().into_owned();
455        if !(name.starts_with("taker~") && name.ends_with(".json")) {
456            continue;
457        }
458        let text = zeroize::Zeroizing::new(std::fs::read_to_string(entry.path())?);
459        match serde_json::from_str::<TakerSwapRecord>(&text) {
460            Ok(record) => records.push(record),
461            Err(e) => warn(&format!("skipping {}: {e}", entry.path().display())),
462        }
463    }
464    records.sort_by_key(|r| r.updated_at);
465    Ok(records)
466}
467
468fn read_record(setup: &Setup, id: &str) -> CliResult<TakerSwapRecord> {
469    let id = check_id(id)?;
470    let path = setup.taker_store().join(format!("taker~{id}.json"));
471    let text = zeroize::Zeroizing::new(std::fs::read_to_string(&path).map_err(|_| {
472        CliError::usage(format!("no swap {id} in {}", setup.taker_store().display()))
473            .hint("`paralock swaps --all` lists the saved swaps")
474    })?);
475    serde_json::from_str(&text).map_err(|e| CliError::failed(format!("{} is unreadable: {e}", path.display())))
476}
477
478fn stage_str(record: &TakerSwapRecord) -> String {
479    serde_json::to_value(record.stage).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default()
480}
481
482/// `paralock swaps`.
483pub fn swaps(setup: &Setup, out: Output, args: SwapsArgs) -> CliResult<Exit> {
484    let records: Vec<TakerSwapRecord> =
485        read_records(&setup.taker_store())?.into_iter().filter(|r| args.all || !r.stage.is_done()).collect();
486    if records.is_empty() {
487        out.say(if args.all { "No saved swaps." } else { "No unfinished swaps." });
488    }
489    for r in &records {
490        let s = &r.swap;
491        out.say(format!(
492            "{}  {:<7} {:<14} {:<10} {} ZEC → {}, refund at block {}",
493            s.id,
494            s.mode.as_str(),
495            status_str(s.status),
496            stage_str(r),
497            zec(s.zcash.amount_zat),
498            s.evm.chain,
499            s.zcash.refund_height
500        ));
501    }
502    let list: Vec<Value> = records
503        .iter()
504        .map(|r| json!({ "stage": stage_str(r), "updatedAt": r.updated_at, "error": r.error, "swap": r.swap }))
505        .collect();
506    out.result(json!({ "store": setup.taker_store(), "swaps": list }));
507    Ok(Exit::Ok)
508}
509
510/// `paralock resume`.
511pub async fn resume(setup: &Setup, out: Output, args: ResumeArgs) -> CliResult<Exit> {
512    let record = read_record(setup, &args.id)?;
513    let id = record.id.clone();
514    let h = build(setup, out, &[record.swap.quote.chain], false, Connect::Online).await?;
515    out.say(format!("Resuming swap {id} ({}, {})…", record.swap.mode.as_str(), stage_str(&record)));
516    let options = ResumeOptions { claim: Some(true), relay_claim: args.no_relay.then_some(false) };
517    let swap = h.taker.resume(&id, options).await?;
518    out.say(format!("Swap {}: {}", swap.id, status_str(swap.status)));
519    summary_lines(out, &swap);
520    out.result(json!({ "swap": swap }));
521    Ok(if swap.status == SwapStatus::Failed { Exit::Failed } else { Exit::Ok })
522}
523
524/// `paralock refund`.
525pub async fn refund(setup: &Setup, out: Output, args: RefundArgs) -> CliResult<Exit> {
526    let record = read_record(setup, &args.id)?;
527    let id = record.id.clone();
528    let h = build(setup, out, &[record.swap.quote.chain], false, Connect::ZcashOnly).await?;
529    out.say(format!(
530        "Refunding swap {id}: {} ZEC from {} once block {} is reached…",
531        zec(record.swap.zcash.amount_zat),
532        record.swap.zcash.htlc_address,
533        record.swap.zcash.refund_height
534    ));
535    let swap = h.taker.refund(&id, !args.no_wait).await?;
536    out.say(format!(
537        "Refunded {} ZEC in {}.",
538        zec(swap.zcash.amount_zat),
539        swap.zcash.refund_txid.as_deref().unwrap_or("(already refunded)")
540    ));
541    out.result(json!({ "swap": swap }));
542    Ok(Exit::Ok)
543}
544
545/// An EVM client for a chain whose RPC is unreachable: every call fails. Refunds only need Zcash.
546mod offline {
547    use paralock::evm::{EvmLock, HtlcOutcome, TxHash};
548    use paralock::{ChainInfo, EvmChain, EvmClient, EvmError, Hex};
549    use paralock_core::evm::{EvmAddress, HtlcSwap, U256};
550    use paralock_core::keys::Secret;
551
552    use crate::config::Chain;
553
554    pub struct OfflineEvm {
555        chain: EvmChain,
556        chain_id: u64,
557        contract: Hex,
558        account: Hex,
559    }
560
561    impl OfflineEvm {
562        pub fn new(chain: &Chain, account: Hex) -> Self {
563            Self { chain: chain.chain, chain_id: chain.chain_id, contract: chain.htlc.clone(), account }
564        }
565    }
566
567    fn offline() -> EvmError {
568        EvmError::transient("the EVM RPC is unreachable")
569    }
570
571    #[async_trait::async_trait]
572    impl ChainInfo for OfflineEvm {
573        fn chain(&self) -> EvmChain {
574            self.chain
575        }
576        fn chain_id(&self) -> u64 {
577            self.chain_id
578        }
579        fn contract(&self) -> Hex {
580            self.contract.clone()
581        }
582        fn address(&self) -> Hex {
583            self.account.clone()
584        }
585        async fn now(&self) -> Result<u64, EvmError> {
586            Err(offline())
587        }
588        async fn block_number(&self) -> Result<u64, EvmError> {
589            Err(offline())
590        }
591        async fn rpc_chain_id(&self) -> Result<u64, EvmError> {
592            Err(offline())
593        }
594        async fn token_balance(&self, _: &EvmAddress, _: Option<&EvmAddress>) -> Result<U256, EvmError> {
595            Err(offline())
596        }
597    }
598
599    #[async_trait::async_trait]
600    impl EvmClient for OfflineEvm {
601        async fn lock(&self, _: &HtlcSwap) -> Result<TxHash, EvmError> {
602            Err(offline())
603        }
604        async fn claim(&self, _: &HtlcSwap, _: &Secret) -> Result<TxHash, EvmError> {
605            Err(offline())
606        }
607        async fn simulate_claim(&self, _: &HtlcSwap, _: &Secret) -> Result<(), EvmError> {
608            Err(offline())
609        }
610        async fn refund(&self, _: &HtlcSwap) -> Result<TxHash, EvmError> {
611            Err(offline())
612        }
613        async fn get_lock(&self, _: &[u8; 32]) -> Result<Option<EvmLock<HtlcSwap>>, EvmError> {
614            Err(offline())
615        }
616        async fn state(&self, _: &[u8; 32]) -> Result<[u8; 32], EvmError> {
617            Err(offline())
618        }
619        async fn outcome(&self, _: &[u8; 32]) -> Result<HtlcOutcome, EvmError> {
620            Err(offline())
621        }
622    }
623}