Skip to main content

paralock_cli/commands/
doctor.rs

1//! `paralock doctor`: checks everything a swap or a maker needs and prints a fix for each problem.
2
3use std::time::Duration;
4
5use paralock::http::{HttpMakerApi, HttpMakerApiOptions};
6use paralock::{EvmChain, Lightwalletd, LightwalletdOptions, Network, ZcashRpc, ZcashRpcOptions};
7use paralock_core::keys::PrivateKey;
8use paralock_core::zcash::TransparentAddress;
9use serde::Serialize;
10use serde_json::{Value, json};
11
12use super::{PROBE_TIMEOUT, backend_label, price_label, price_source, redact, zcash_node};
13use crate::amount::{usd, zec};
14use crate::cli::DoctorArgs;
15use crate::config::{Backend, Chain, KeySource, Setup};
16use crate::error::{CliResult, Exit};
17use crate::output::Output;
18
19/// One check.
20#[derive(Debug, Clone, Serialize)]
21pub struct Check {
22    /// What was checked.
23    pub name: String,
24    /// Whether it passed.
25    pub ok: bool,
26    /// A failed required check fails `doctor`; an optional one only warns.
27    pub required: bool,
28    /// What was found.
29    pub detail: String,
30    /// How to fix it.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub fix: Option<String>,
33}
34
35impl Check {
36    fn pass(name: impl Into<String>, detail: impl Into<String>) -> Self {
37        Self { name: name.into(), ok: true, required: true, detail: detail.into(), fix: None }
38    }
39
40    fn fail(name: impl Into<String>, detail: impl Into<String>, fix: impl Into<String>) -> Self {
41        Self { name: name.into(), ok: false, required: true, detail: detail.into(), fix: Some(fix.into()) }
42    }
43
44    fn warn(name: impl Into<String>, detail: impl Into<String>, fix: impl Into<String>) -> Self {
45        Self { required: false, ..Self::fail(name, detail, fix) }
46    }
47}
48
49fn client() -> reqwest::Client {
50    reqwest::Client::builder()
51        .user_agent(paralock::DEFAULT_USER_AGENT)
52        .timeout(PROBE_TIMEOUT)
53        .build()
54        .unwrap_or_else(|_| reqwest::Client::new())
55}
56
57/// An EVM JSON-RPC call.
58async fn rpc(client: &reqwest::Client, url: &str, method: &str, params: Value) -> Result<Value, String> {
59    let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params });
60    let response = client
61        .post(url)
62        .header("content-type", "application/json")
63        .body(body.to_string())
64        .send()
65        .await
66        .map_err(|e| e.without_url().to_string())?;
67    let status = response.status();
68    let value: Value = serde_json::from_str(&response.text().await.map_err(|e| e.without_url().to_string())?)
69        .map_err(|_| format!("HTTP {}: not a JSON-RPC answer", status.as_u16()))?;
70    if let Some(error) = value.get("error") {
71        return Err(error.get("message").and_then(Value::as_str).unwrap_or("RPC error").to_owned());
72    }
73    value.get("result").cloned().ok_or_else(|| "no result".to_owned())
74}
75
76fn quantity(value: &Value) -> Option<u128> {
77    u128::from_str_radix(value.as_str()?.trim_start_matches("0x"), 16).ok()
78}
79
80fn evm_address_of(key: &KeySource) -> Result<String, String> {
81    let hex = key.load().map_err(|e| e.message)?;
82    let key = PrivateKey::from_hex(&hex).map_err(|_| "not a private key".to_owned())?;
83    Ok(format!(
84        "0x{}",
85        paralock_core::encoding::to_hex(&paralock_core::evm::EvmAddress::from_public_key(&key.public_key()).0)
86    ))
87}
88
89fn wei(amount: u128) -> String {
90    let milli = amount / 1_000_000_000_000_000;
91    format!("{}.{:03}", milli / 1000, milli % 1000)
92}
93
94async fn token_balance(client: &reqwest::Client, url: &str, token: &str, owner: &str) -> Result<u128, String> {
95    let data = format!("0x70a08231{:0>64}", owner.trim_start_matches("0x").to_ascii_lowercase());
96    let result = rpc(client, url, "eth_call", json!([{ "to": token, "data": data }, "latest"])).await?;
97    quantity(&result).ok_or_else(|| format!("balanceOf answered {result}"))
98}
99
100async fn zcash_checks(setup: &Setup, checks: &mut Vec<Check>) {
101    // Zebra reports its regtest chain as "test" (same address prefixes); lightwalletd may say either.
102    let expected: &[&str] = match setup.network {
103        Network::Mainnet => &["main", "mainnet"],
104        Network::Testnet => &["test", "testnet"],
105        Network::Regtest => &["regtest", "test"],
106    };
107    let name = backend_label(setup);
108    match &setup.zcash {
109        Backend::Rpc { url, auth } => {
110            let options = ZcashRpcOptions { timeout: PROBE_TIMEOUT, retries: 0, ..ZcashRpcOptions::default() };
111            let rpc = match ZcashRpc::with_options(url.clone(), options) {
112                Ok(rpc) => rpc,
113                Err(e) => return checks.push(Check::fail(name, e.to_string(), "fix zcash.rpc in paralock.toml")),
114            };
115            let rpc = match auth {
116                Some((user, secret)) => match secret.load() {
117                    Ok((cookie_user, pass)) => {
118                        rpc.with_auth(cookie_user.unwrap_or_else(|| user.clone()), pass.as_str())
119                    }
120                    Err(e) => return checks.push(Check::fail(name, e.message, "set the RPC password")),
121                },
122                None => rpc,
123            };
124            match rpc.get_blockchain_info().await {
125                Ok(info) if !expected.contains(&info.chain.as_str()) => checks.push(Check::fail(
126                    name,
127                    format!("the node is on {}, the configuration on {}", info.chain, setup.network),
128                    "point zcash.rpc at a node of the configured network",
129                )),
130                Ok(info) => {
131                    let branch = u32::from_str_radix(&info.consensus.nextblock, 16).unwrap_or(0);
132                    let detail = format!(
133                        "{}, height {}, next block's branch id 0x{}",
134                        info.chain, info.blocks, info.consensus.nextblock
135                    );
136                    checks.push(if branch == 0 {
137                        Check::fail(name, detail, "the node reports no consensus branch id; is it synced?")
138                    } else {
139                        Check::pass(name, detail)
140                    });
141                }
142                Err(e) => checks.push(Check::fail(
143                    name,
144                    e.to_string(),
145                    if setup.is_devnet() {
146                        "start the devnet: npx paralock dev"
147                    } else {
148                        "start the node, or fix zcash.rpc"
149                    },
150                )),
151            }
152        }
153        Backend::Lightwalletd { url } => {
154            let options = LightwalletdOptions { timeout: PROBE_TIMEOUT, retries: 0, ..LightwalletdOptions::default() };
155            let info = match Lightwalletd::with_options(url, options) {
156                Ok(client) => client.lightd_info().await.map_err(|e| e.to_string()),
157                Err(e) => Err(e.to_string()),
158            };
159            match info {
160                Ok(info) if !expected.contains(&info.chain_name.as_str()) => checks.push(Check::fail(
161                    name,
162                    format!("the server is on {}, the configuration on {}", info.chain_name, setup.network),
163                    "point zcash.lightwalletd at a server of the configured network",
164                )),
165                Ok(info) => checks.push(Check::pass(
166                    name,
167                    format!(
168                        "{}, height {}, branch id 0x{} ({} {})",
169                        info.chain_name, info.block_height, info.consensus_branch_id, info.vendor, info.version
170                    ),
171                )),
172                Err(e) => checks.push(Check::fail(name, e, "check zcash.lightwalletd and the server")),
173            }
174        }
175    }
176}
177
178async fn key_check(setup: &Setup, key: &KeySource, what: &str, checks: &mut Vec<Check>, wants_funds: bool) {
179    let name = format!("{what} Zcash key");
180    if let KeySource::Generated(path) = key
181        && !path.exists()
182    {
183        return checks.push(Check::pass(name, format!("created on first use at {}", path.display())));
184    }
185    let hex = match key.load() {
186        Ok(hex) => hex,
187        Err(e) => return checks.push(Check::fail(name, e.message, e.hint.unwrap_or_else(|| "set the key".into()))),
188    };
189    let Ok(private) = PrivateKey::from_hex(&hex) else { return };
190    let address = TransparentAddress::p2pkh(&private.public_key(), setup.network).encode();
191    let balance = match zcash_node(setup) {
192        Ok(node) => node.get_address_utxos(std::slice::from_ref(&address)).await.map_err(|e| e.to_string()),
193        Err(e) => Err(e.message),
194    };
195    match balance {
196        Ok(utxos) => {
197            let total: u64 = utxos.iter().map(|u| u.value).sum();
198            let detail =
199                format!("{address} holds {} ZEC in {} UTXOs (from {})", zec(total), utxos.len(), key.describe());
200            checks.push(if total == 0 && wants_funds && !setup.is_devnet() {
201                Check::warn(name, detail, format!("send ZEC to {address} (transparent) before swapping"))
202            } else {
203                Check::pass(name, detail)
204            });
205        }
206        Err(e) => checks.push(Check::pass(name, format!("{address} (balance unknown: {e})"))),
207    }
208}
209
210async fn chain_checks(setup: &Setup, c: &Chain, maker: bool, checks: &mut Vec<Check>) {
211    let http = client();
212    let label = format!("{} ({})", c.chain.display_name(), redact(&c.rpc));
213    let id = match rpc(&http, &c.rpc, "eth_chainId", json!([])).await.map(|v| quantity(&v)) {
214        Ok(Some(id)) => id,
215        Ok(None) => return checks.push(Check::fail(label, "eth_chainId answered nonsense", "check the RPC URL")),
216        Err(e) => {
217            let fix = if setup.is_devnet() { "start the devnet: npx paralock dev" } else { "check evm.<chain>.rpc" };
218            return checks.push(Check::fail(label, e, fix));
219        }
220    };
221    if id != u128::from(c.chain_id) {
222        return checks.push(Check::fail(
223            label,
224            format!("the RPC serves chain id {id}, the configuration says {}", c.chain_id),
225            "point the RPC at the right chain, or fix chain_id",
226        ));
227    }
228    let height = rpc(&http, &c.rpc, "eth_blockNumber", json!([])).await.ok().and_then(|v| quantity(&v)).unwrap_or(0);
229    checks.push(Check::pass(label, format!("chain id {id}, block {height}")));
230    if let Some(verify) = &c.verify_rpc {
231        let name = format!("{}: verifier ({})", c.chain, redact(verify));
232        match rpc(&http, verify, "eth_chainId", json!([])).await.map(|v| quantity(&v)) {
233            Ok(Some(v)) if v == id => checks.push(Check::pass(name, format!("chain id {v}"))),
234            Ok(other) => {
235                checks.push(Check::fail(name, format!("chain id {other:?}"), "point verify_rpc at the same chain"))
236            }
237            Err(e) => checks.push(Check::fail(name, e, "check verify_rpc (claims need both endpoints)")),
238        }
239    }
240
241    let mut contracts = vec![("ParalockHTLC", c.htlc.to_string())];
242    if let Some(escrow) = &c.adaptor_escrow {
243        contracts.push(("ParalockAdaptorEscrow", escrow.to_string()));
244    }
245    for (what, address) in contracts {
246        let name = format!("{}: {what}", c.chain);
247        match rpc(&http, &c.rpc, "eth_getCode", json!([address, "latest"])).await {
248            Ok(code) if code.as_str().is_some_and(|s| s.len() > 2) => checks.push(Check::pass(name, address)),
249            Ok(_) => checks.push(Check::fail(name, format!("no contract at {address}"), "fix the address (or deploy)")),
250            Err(e) => checks.push(Check::fail(name, e, "check the RPC")),
251        }
252    }
253    if c.adaptor_escrow.is_none() {
254        checks.push(Check::warn(
255            format!("{}: private mode", c.chain),
256            "no adaptor_escrow configured",
257            "set adaptor_escrow to enable --private",
258        ));
259    }
260
261    let account = match evm_address_of(&c.key) {
262        Ok(account) => account,
263        Err(e) => {
264            return checks.push(Check::fail(
265                format!("{}: account", c.chain),
266                e,
267                format!("set key_env or key_file in [evm.{}]", c.chain),
268            ));
269        }
270    };
271    let native = rpc(&http, &c.rpc, "eth_getBalance", json!([account, "latest"])).await.ok().and_then(|v| quantity(&v));
272    let mut detail = format!("{account} ({})", c.key.describe());
273    if c.chain != EvmChain::Tempo
274        && let Some(native) = native
275    {
276        detail.push_str(&format!(", {} native for gas", wei(native)));
277    }
278    let tokens: Vec<(String, String, u32)> = match (&c.token, maker) {
279        (Some(t), true) => vec![(t.address.to_string(), t.symbol.clone(), t.decimals)],
280        _ if !c.tokens.is_empty() => c
281            .tokens
282            .iter()
283            .map(|t| (t.to_string(), c.token.as_ref().map_or("token".into(), |q| q.symbol.clone()), 6))
284            .collect(),
285        _ => Vec::new(),
286    };
287    let mut inventory = None;
288    for (token, symbol, decimals) in &tokens {
289        match token_balance(&http, &c.rpc, token, &account).await {
290            Ok(balance) => {
291                detail.push_str(&format!(", {} {symbol}", usd(balance, *decimals)));
292                inventory = Some(inventory.unwrap_or(0) + balance);
293            }
294            Err(e) => checks.push(Check::fail(
295                format!("{}: token {token}", c.chain),
296                format!("balanceOf failed: {e}"),
297                "check the token address",
298            )),
299        }
300    }
301    let no_gas = c.chain != EvmChain::Tempo && native == Some(0);
302    checks.push(if maker && inventory == Some(0) {
303        Check::warn(format!("{}: maker account", c.chain), detail, "fund it with the stablecoin it quotes")
304    } else if no_gas {
305        Check::warn(format!("{}: account", c.chain), detail, "it needs native gas to claim (or use --relay)")
306    } else {
307        Check::pass(format!("{}: {} account", c.chain, if maker { "maker" } else { "taker" }), detail)
308    });
309    if !maker && let Some(relayer) = &c.relayer {
310        checks.push(match evm_address_of(relayer) {
311            Ok(address) => Check::pass(format!("{}: relayer", c.chain), format!("{address} ({})", relayer.describe())),
312            Err(e) => Check::warn(format!("{}: relayer", c.chain), e, "fix the relayer key (needed for --relay)"),
313        });
314    }
315}
316
317async fn maker_url_check(setup: &Setup, checks: &mut Vec<Check>) {
318    let Some(url) = &setup.maker_url else {
319        return checks.push(Check::warn("maker", "no maker_url configured", "set maker_url or pass --maker-url"));
320    };
321    let name = format!("maker {}", redact(url));
322    let options = HttpMakerApiOptions { timeout: PROBE_TIMEOUT, retries: 0, ..HttpMakerApiOptions::default() };
323    let health = match HttpMakerApi::with_options(url.clone(), setup.network, options) {
324        Ok(api) => api.health().await,
325        Err(e) => Err(e),
326    };
327    match health {
328        Ok(health) if health.ok => {
329            let served: Vec<&str> = health.chains.iter().map(|c| c.as_str()).collect();
330            let missing: Vec<&str> =
331                setup.chains.keys().filter(|c| !health.chains.contains(c)).map(|c| c.as_str()).collect();
332            let detail =
333                format!("up, serves {}", if served.is_empty() { "no chain".into() } else { served.join(", ") });
334            checks.push(if missing.is_empty() {
335                Check::pass(name, detail)
336            } else {
337                Check::warn(name, format!("{detail}; not {}", missing.join(", ")), "swap on a chain it serves")
338            });
339        }
340        Ok(_) => checks.push(Check::fail(name, "answered ok: false", "the maker is not ready")),
341        Err(e) => checks.push(Check::fail(
342            name,
343            e.to_string(),
344            if setup.is_devnet() { "start the devnet's maker: npx paralock dev" } else { "check maker_url" },
345        )),
346    }
347}
348
349async fn price_check(setup: &Setup, checks: &mut Vec<Check>) {
350    let name = format!("price ({})", price_label(setup));
351    let price = match price_source(setup) {
352        Ok(source) => tokio::time::timeout(Duration::from_secs(8), source.usd_per_zec())
353            .await
354            .map_err(|_| "timed out".to_owned())
355            .and_then(|r| r.map_err(|e| e.to_string())),
356        Err(e) => Err(e.message),
357    };
358    match price {
359        Ok(price) => checks.push(Check::pass(name, format!("${price:.2}/ZEC"))),
360        Err(e) => checks.push(Check::fail(name, e, "quotes are checked against this price: make it reachable")),
361    }
362}
363
364async fn listen_check(setup: &Setup, checks: &mut Vec<Check>) {
365    let listen = setup.maker.listen;
366    let name = format!("listen {listen}");
367    match tokio::net::TcpListener::bind(listen).await {
368        Ok(_) => checks.push(Check::pass(name, "free")),
369        Err(e) => {
370            let paralock =
371                client().get(format!("http://{listen}/health")).send().await.is_ok_and(|r| r.status().is_success());
372            let detail = if paralock { "in use by a running Paralock maker".to_owned() } else { e.to_string() };
373            checks.push(Check::warn(name, detail, "stop it, or pass --port / set maker.listen"));
374        }
375    }
376}
377
378fn store_check(setup: &Setup, maker: bool, checks: &mut Vec<Check>) {
379    let dir = if maker { setup.maker_store() } else { setup.taker_store() };
380    let name = "swap store";
381    if !dir.exists() {
382        return checks.push(Check::pass(name, format!("{} (created on first use)", dir.display())));
383    }
384    #[cfg(unix)]
385    {
386        use std::os::unix::fs::PermissionsExt;
387        if let Ok(meta) = std::fs::metadata(&dir)
388            && meta.permissions().mode() & 0o077 != 0
389        {
390            return checks.push(Check::warn(
391                name,
392                format!("{} is accessible by other users (it holds keys)", dir.display()),
393                format!("chmod 700 {}", dir.display()),
394            ));
395        }
396    }
397    checks.push(Check::pass(name, dir.display().to_string()));
398}
399
400/// `paralock doctor`.
401pub async fn doctor(setup: &Setup, out: Output, args: DoctorArgs) -> CliResult<Exit> {
402    let mut checks = Vec::new();
403    zcash_checks(setup, &mut checks).await;
404    if args.maker {
405        key_check(setup, &setup.maker.zcash_key, "maker", &mut checks, false).await;
406    } else {
407        key_check(setup, &setup.zcash_key, "taker", &mut checks, true).await;
408    }
409    for c in setup.chains.values() {
410        chain_checks(setup, c, args.maker, &mut checks).await;
411    }
412    if args.maker {
413        listen_check(setup, &mut checks).await;
414    } else {
415        maker_url_check(setup, &mut checks).await;
416    }
417    price_check(setup, &mut checks).await;
418    store_check(setup, args.maker, &mut checks);
419
420    let failed = checks.iter().filter(|c| !c.ok && c.required).count();
421    let warned = checks.iter().filter(|c| !c.ok && !c.required).count();
422    if out.json {
423        crate::output::print_document(&json!({
424            "ok": failed == 0,
425            "network": setup.network.as_str(),
426            "source": setup.source,
427            "devnet": setup.is_devnet(),
428            "side": if args.maker { "maker" } else { "taker" },
429            "checks": checks,
430        }));
431    } else {
432        println!("Paralock doctor ({} side)", if args.maker { "maker" } else { "taker" });
433        let origin = if setup.is_devnet() { "devnet" } else { "configuration" };
434        println!("  {origin}: {} ({})", setup.source.display(), setup.network);
435        for c in &checks {
436            let mark = if c.ok {
437                "✓"
438            } else if c.required {
439                "✗"
440            } else {
441                "!"
442            };
443            println!("  {mark} {:<36} {}", c.name, c.detail);
444            if let (false, Some(fix)) = (c.ok, &c.fix) {
445                println!("      fix: {fix}");
446            }
447        }
448        if failed > 0 {
449            println!("\n{failed} required check(s) failed: fix them and run `paralock doctor` again.");
450        } else {
451            let warnings = if warned > 0 { format!(" ({warned} warning(s))") } else { String::new() };
452            println!("\nAll required checks passed{warnings}.");
453        }
454    }
455    Ok(if failed == 0 { Exit::Ok } else { Exit::Failed })
456}