1use 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#[derive(Debug, Clone, Serialize)]
21pub struct Check {
22 pub name: String,
24 pub ok: bool,
26 pub required: bool,
28 pub detail: String,
30 #[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
57async 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(¶lock_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 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
400pub 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}