Skip to main content

fedimint_cli/
lib.rs

1#![deny(clippy::pedantic, clippy::unwrap_used)]
2#![allow(clippy::doc_markdown)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7#![allow(clippy::ref_option)]
8#![allow(clippy::return_self_not_must_use)]
9#![allow(clippy::too_many_lines)]
10#![allow(clippy::large_futures)]
11
12mod api_request;
13mod cli;
14mod client;
15mod command_encoding;
16mod command_wait;
17mod db;
18pub mod envs;
19mod event_output;
20mod utils;
21mod visualize;
22
23#[cfg(test)]
24mod command_tests;
25
26use core::fmt;
27use std::collections::{BTreeMap, BTreeSet};
28use std::fmt::Debug;
29use std::io::{IsTerminal, Read, Write};
30use std::net::IpAddr;
31use std::path::{Path, PathBuf};
32use std::process::exit;
33use std::str::FromStr;
34use std::sync::Arc;
35use std::time::Duration;
36use std::{fs, result};
37
38use anyhow::{Context, format_err};
39use api_request::ApiRequest;
40use clap::{CommandFactory, Parser};
41use cli::{
42    AdminCmd, Command, DatabaseBackend, DevCmd, Opts, SetupAdminArgs, SetupAdminCmd, VisualizeCmd,
43};
44use envs::SALT_FILE;
45use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
46use fedimint_api_client::api::{DynGlobalApi, FederationApiExt, FederationError};
47use fedimint_api_client::download_from_invite_code;
48use fedimint_bip39::{Bip39RootSecretStrategy, Mnemonic};
49use fedimint_client::db::ApiSecretKey;
50use fedimint_client::module::meta::{FetchKind, LegacyMetaSource, MetaSource};
51use fedimint_client::module::module::init::ClientModuleInit;
52use fedimint_client::module_init::ClientModuleInitRegistry;
53use fedimint_client::secret::RootSecretStrategy;
54use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc, RootSecret};
55use fedimint_connectors::{Connectivity, ConnectorRegistry};
56use fedimint_core::config::FederationId;
57use fedimint_core::core::ModuleInstanceId;
58use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped as _};
59use fedimint_core::encoding::Decodable;
60use fedimint_core::invite_code::InviteCode;
61use fedimint_core::module::registry::ModuleRegistry;
62use fedimint_core::module::{ApiAuth, ApiRequestErased};
63use fedimint_core::setup_code::PeerSetupCode;
64use fedimint_core::transaction::Transaction;
65use fedimint_core::util::{SafeUrl, handle_version_hash_command};
66use fedimint_core::{PeerId, fedimint_build_code_version_env, runtime};
67use fedimint_derive_secret::DerivableSecret;
68use fedimint_eventlog::EventLogTrimableId;
69use fedimint_ln_client::LightningClientInit;
70use fedimint_logging::{LOG_CLIENT, TracingSetup};
71use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
72use fedimint_mint_client::{MintClientInit, MintClientModule};
73use fedimint_wallet_client::WalletClientInit;
74use futures::future::join_all;
75use itertools::Itertools;
76use rand::thread_rng;
77use serde::{Deserialize, Serialize};
78use serde_json::{Value, json};
79use thiserror::Error;
80use tracing::{debug, info, warn};
81
82use crate::client::ClientCmd;
83use crate::db::{StoredAdminCreds, load_admin_creds, store_admin_creds};
84
85/// Type of output the cli produces
86#[derive(Serialize)]
87#[serde(rename_all = "snake_case")]
88#[serde(untagged)]
89enum CliOutput {
90    VersionHash {
91        hash: String,
92    },
93
94    UntypedApiOutput {
95        value: Value,
96    },
97
98    WaitBlockCount {
99        reached: u64,
100    },
101
102    InviteCode {
103        invite_code: InviteCode,
104    },
105
106    DecodeInviteCode {
107        url: SafeUrl,
108        federation_id: FederationId,
109    },
110
111    Join {
112        joined: String,
113    },
114
115    DecodeTransaction {
116        transaction: String,
117    },
118
119    EpochCount {
120        count: u64,
121    },
122
123    ConfigDecrypt,
124
125    ConfigEncrypt,
126
127    SetupCode {
128        setup_code: PeerSetupCode,
129    },
130
131    Raw(serde_json::Value),
132}
133
134impl fmt::Display for CliOutput {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(
137            f,
138            "{}",
139            serde_json::to_string_pretty(self).expect("CliOutput is serializable")
140        )
141    }
142}
143
144#[derive(Debug, Serialize)]
145struct FederationPrivacyReport {
146    federation_id: FederationId,
147    guardians: BTreeMap<String, GuardianPrivacyReport>,
148    summary: FederationPrivacySummary,
149}
150
151#[derive(Debug, Serialize)]
152struct GuardianPrivacyReport {
153    name: String,
154    api_url: SafeUrl,
155    addresses: BTreeMap<String, IpInfoReport>,
156    iroh: Option<IrohConnectionReport>,
157    error: Option<String>,
158}
159
160#[derive(Debug, Serialize)]
161struct IrohConnectionReport {
162    node_id: String,
163    connectivity: &'static str,
164    direct_addr: Option<String>,
165    relay_url: Option<String>,
166}
167
168#[derive(Debug, Clone, Default, Serialize)]
169struct IpInfoReport {
170    address_type: &'static str,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    country: Option<String>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    asn: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    as_name: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    org: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    error: Option<String>,
181}
182
183#[derive(Debug, Serialize)]
184struct FederationPrivacySummary {
185    guardian_count: usize,
186    iroh_guardian_count: usize,
187    resolved_guardian_count: usize,
188    iroh_direct_guardian_count: usize,
189    iroh_relayed_guardian_count: usize,
190    public_ip_count: usize,
191    countries: BTreeMap<String, usize>,
192    autonomous_systems: BTreeMap<String, usize>,
193}
194
195#[derive(Debug, Deserialize)]
196struct IpWhoResponse {
197    success: Option<bool>,
198    message: Option<String>,
199    country_code: Option<String>,
200    country: Option<String>,
201    connection: Option<IpWhoConnection>,
202}
203
204#[derive(Debug, Deserialize)]
205struct IpWhoConnection {
206    asn: Option<u64>,
207    org: Option<String>,
208    isp: Option<String>,
209}
210
211const IP_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
212
213/// `Result` with `CliError` as `Error`
214type CliResult<E> = Result<E, CliError>;
215
216/// `Result` with `CliError` as `Error` and `CliOutput` as `Ok`
217type CliOutputResult = Result<CliOutput, CliError>;
218
219/// Cli error
220#[derive(Serialize, Error)]
221#[serde(tag = "error", rename_all(serialize = "snake_case"))]
222struct CliError {
223    error: String,
224}
225
226/// Extension trait making turning Results/Errors into
227/// [`CliError`]/[`CliOutputResult`] easier
228trait CliResultExt<O, E> {
229    /// Map error into `CliError` wrapping the original error message
230    fn map_err_cli(self) -> Result<O, CliError>;
231    /// Map error into `CliError` using custom error message `msg`
232    fn map_err_cli_msg(self, msg: impl fmt::Display + Send + Sync + 'static)
233    -> Result<O, CliError>;
234}
235
236impl<O, E> CliResultExt<O, E> for result::Result<O, E>
237where
238    E: Into<anyhow::Error>,
239{
240    fn map_err_cli(self) -> Result<O, CliError> {
241        self.map_err(|e| {
242            let e = e.into();
243            CliError {
244                error: format!("{e:#}"),
245            }
246        })
247    }
248
249    fn map_err_cli_msg(
250        self,
251        msg: impl fmt::Display + Send + Sync + 'static,
252    ) -> Result<O, CliError> {
253        self.map_err(|e| Into::<anyhow::Error>::into(e))
254            .context(msg)
255            .map_err(|e| CliError {
256                error: format!("{e:#}"),
257            })
258    }
259}
260
261/// Extension trait to make turning `Option`s into
262/// [`CliError`]/[`CliOutputResult`] easier
263trait CliOptionExt<O> {
264    fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
265}
266
267impl<O> CliOptionExt<O> for Option<O> {
268    fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
269        self.ok_or_else(|| CliError { error: msg.into() })
270    }
271}
272
273// TODO: Refactor federation API errors to just delegate to this
274impl From<FederationError> for CliError {
275    fn from(e: FederationError) -> Self {
276        CliError {
277            error: e.to_string(),
278        }
279    }
280}
281
282impl Debug for CliError {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        f.debug_struct("CliError")
285            .field("error", &self.error)
286            .finish()
287    }
288}
289
290impl fmt::Display for CliError {
291    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
292        let json = serde_json::to_value(self).expect("CliError is valid json");
293        let json_as_string =
294            serde_json::to_string_pretty(&json).expect("valid json is serializable");
295        write!(f, "{json_as_string}")
296    }
297}
298
299impl Opts {
300    fn data_dir(&self) -> CliResult<&PathBuf> {
301        self.data_dir
302            .as_ref()
303            .ok_or_cli_msg("`--data-dir=` argument not set.")
304    }
305
306    /// Get and create if doesn't exist the data dir
307    async fn data_dir_create(&self) -> CliResult<&PathBuf> {
308        let dir = self.data_dir()?;
309
310        tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
311
312        Ok(dir)
313    }
314    fn iroh_enable_dht(&self) -> bool {
315        self.iroh_enable_dht.unwrap_or(true)
316    }
317
318    fn use_tor(&self) -> bool {
319        #[cfg(feature = "tor")]
320        return self.use_tor;
321        #[cfg(not(feature = "tor"))]
322        false
323    }
324
325    async fn admin_client(
326        &self,
327        peer_urls: &BTreeMap<PeerId, SafeUrl>,
328        api_secret: Option<&str>,
329    ) -> CliResult<DynGlobalApi> {
330        self.admin_client_with_db(peer_urls, api_secret, None).await
331    }
332
333    async fn admin_client_with_db(
334        &self,
335        peer_urls: &BTreeMap<PeerId, SafeUrl>,
336        api_secret: Option<&str>,
337        db: Option<&Database>,
338    ) -> CliResult<DynGlobalApi> {
339        // First try CLI argument, then stored credentials
340        let our_id = if let Some(id) = self.our_id {
341            id
342        } else if let Some(db) = db {
343            if let Some(stored_creds) = load_admin_creds(db).await {
344                stored_creds.peer_id
345            } else {
346                return Err(CliError {
347                    error: "Admin client needs our-id set (no stored credentials found)"
348                        .to_string(),
349                });
350            }
351        } else {
352            return Err(CliError {
353                error: "Admin client needs our-id set".to_string(),
354            });
355        };
356
357        Ok(DynGlobalApi::new_admin(
358            self.make_endpoints().await.map_err(|e| CliError {
359                error: e.to_string(),
360            })?,
361            our_id,
362            peer_urls
363                .get(&our_id)
364                .cloned()
365                .context("Our peer URL not found in config")
366                .map_err_cli()?,
367            api_secret,
368        ))
369    }
370
371    async fn make_endpoints(&self) -> Result<ConnectorRegistry, anyhow::Error> {
372        Ok(ConnectorRegistry::build_from_client_defaults()
373            .iroh_pkarr_dht(self.iroh_enable_dht())
374            .ws_force_tor(self.use_tor())
375            .bind()
376            .await)
377    }
378
379    fn auth(&self) -> CliResult<ApiAuth> {
380        let password = self
381            .password
382            .clone()
383            .ok_or_cli_msg("CLI needs password set")?;
384        Ok(ApiAuth::new(password))
385    }
386
387    async fn load_database(&self) -> CliResult<Database> {
388        debug!(target: LOG_CLIENT, "Loading client database");
389        let db_path = self.data_dir_create().await?.join("client.db");
390        match self.db_backend {
391            DatabaseBackend::RocksDb => {
392                debug!(target: LOG_CLIENT, "Using RocksDB database backend");
393                Ok(fedimint_rocksdb::RocksDb::build(db_path)
394                    .open()
395                    .await
396                    .map_err_cli_msg("could not open rocksdb database")?
397                    .into())
398            }
399            DatabaseBackend::CursedRedb => {
400                debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
401                Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
402                    .await
403                    .map_err_cli_msg("could not open cursed redb database")?
404                    .into())
405            }
406        }
407    }
408}
409
410fn decode_federation_secret_hex(federation_secret_hex: &str) -> CliResult<DerivableSecret> {
411    <DerivableSecret as Decodable>::consensus_decode_hex(
412        federation_secret_hex,
413        &ModuleRegistry::default(),
414    )
415    .map_err_cli_msg("invalid federation secret hex")
416}
417
418enum RecoverySecret {
419    Mnemonic(Mnemonic),
420    FederationSecret(DerivableSecret),
421}
422
423fn root_secret_from_mnemonic(mnemonic: &Mnemonic) -> RootSecret {
424    RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(mnemonic))
425}
426
427async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
428    Ok(
429        if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
430            Mnemonic::from_entropy(&entropy).map_err_cli()?
431        } else {
432            debug!(
433                target: LOG_CLIENT,
434                "Generating mnemonic and writing entropy to client storage"
435            );
436            let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
437            Client::store_encodable_client_secret(db, mnemonic.to_entropy())
438                .await
439                .map_err_cli()?;
440            mnemonic
441        },
442    )
443}
444
445async fn query_guardian_addresses(
446    connectors: &ConnectorRegistry,
447    endpoints: &BTreeMap<PeerId, fedimint_core::config::PeerUrl>,
448    path_timeout: Duration,
449) -> Vec<(PeerId, GuardianPrivacyReport)> {
450    join_all(endpoints.iter().map(|(peer_id, peer_url)| async move {
451        let mut report = GuardianPrivacyReport {
452            name: peer_url.name.clone(),
453            api_url: peer_url.url.clone(),
454            addresses: BTreeMap::new(),
455            iroh: None,
456            error: None,
457        };
458
459        if peer_url.url.scheme() == "iroh" {
460            match connectors.iroh_peer_info(&peer_url.url, path_timeout).await {
461                Ok(Some(iroh_info)) => {
462                    let direct_addr = iroh_info.direct_addr;
463                    let known_direct_addrs = iroh_info.known_direct_addrs;
464                    let known_direct_ips = known_direct_addrs
465                        .iter()
466                        .chain(direct_addr.iter())
467                        .map(std::net::SocketAddr::ip)
468                        .collect::<BTreeSet<_>>();
469
470                    report.addresses = ip_info_map_from_ips(known_direct_ips);
471                    report.iroh = Some(IrohConnectionReport {
472                        node_id: iroh_info.node_id,
473                        connectivity: connectivity_name(iroh_info.connectivity),
474                        direct_addr: direct_addr.map(|addr| addr.to_string()),
475                        relay_url: iroh_info.relay_url,
476                    });
477                }
478                Ok(None) => {}
479                Err(error) => {
480                    report.error = Some(error.to_string());
481                }
482            }
483        } else {
484            match resolve_endpoint_ips(&peer_url.url).await {
485                Ok(ips) => {
486                    report.addresses = ip_info_map_from_ips(ips);
487                }
488                Err(error) => {
489                    report.error = Some(error.to_string());
490                }
491            }
492        }
493
494        (*peer_id, report)
495    }))
496    .await
497}
498
499fn ip_info_map_from_ips(ips: BTreeSet<IpAddr>) -> BTreeMap<String, IpInfoReport> {
500    ips.into_iter()
501        .map(|ip| {
502            (
503                ip.to_string(),
504                IpInfoReport {
505                    address_type: ip_address_type(ip),
506                    ..IpInfoReport::default()
507                },
508            )
509        })
510        .collect()
511}
512
513async fn resolve_endpoint_ips(url: &SafeUrl) -> anyhow::Result<BTreeSet<IpAddr>> {
514    let host = url
515        .host_str()
516        .context("endpoint URL does not contain a host")?;
517    if let Ok(ip) = IpAddr::from_str(host) {
518        return Ok(BTreeSet::from([ip]));
519    }
520
521    let port = endpoint_resolution_port(url).ok_or_else(|| {
522        format_err!(
523            "endpoint URL scheme {} does not have a known resolution port",
524            url.scheme()
525        )
526    })?;
527
528    let addresses = tokio::net::lookup_host((host, port))
529        .await
530        .with_context(|| format!("failed to resolve endpoint host {host}"))?
531        .map(|addr| addr.ip())
532        .collect();
533
534    Ok(addresses)
535}
536
537fn endpoint_resolution_port(url: &SafeUrl) -> Option<u16> {
538    url.port_or_known_default().or(match url.scheme() {
539        "http" | "ws" => Some(80),
540        "https" | "wss" => Some(443),
541        _ => None,
542    })
543}
544
545fn all_iroh_guardians_direct(reports: &[(PeerId, GuardianPrivacyReport)]) -> bool {
546    reports
547        .iter()
548        .filter(|(_, report)| report.api_url.scheme() == "iroh")
549        .all(|(_, report)| {
550            report
551                .iroh
552                .as_ref()
553                .is_some_and(|iroh| matches!(iroh.connectivity, "direct" | "mixed"))
554        })
555}
556
557async fn enrich_guardian_ip_info(reports: &mut [(PeerId, GuardianPrivacyReport)]) {
558    let http_client = reqwest::Client::builder()
559        .timeout(IP_LOOKUP_TIMEOUT)
560        .build()
561        .expect("IP lookup HTTP client config is valid");
562    let unique_ips = reports
563        .iter()
564        .flat_map(|(_, report)| {
565            report
566                .addresses
567                .iter()
568                .filter(|(_, ip_info)| ip_info.address_type == "public")
569                .filter_map(|(ip, _)| IpAddr::from_str(ip).ok())
570        })
571        .collect::<BTreeSet<_>>();
572
573    let lookup_cache = join_all(unique_ips.into_iter().map(|ip| {
574        let http_client = http_client.clone();
575        async move { (ip.to_string(), lookup_ip_info(&http_client, ip).await) }
576    }))
577    .await
578    .into_iter()
579    .collect::<BTreeMap<_, _>>();
580
581    for (_, report) in reports {
582        for (ip, ip_info) in &mut report.addresses {
583            if let Some(lookup) = lookup_cache.get(ip) {
584                *ip_info = IpInfoReport {
585                    address_type: ip_info.address_type,
586                    ..lookup.clone()
587                };
588            }
589        }
590    }
591}
592
593async fn lookup_ip_info(http_client: &reqwest::Client, ip: IpAddr) -> IpInfoReport {
594    let url = format!("https://ipwho.is/{ip}");
595    let response = http_client
596        .get(url)
597        .send()
598        .await
599        .and_then(reqwest::Response::error_for_status);
600
601    let response = match response {
602        Ok(response) => response,
603        Err(error) => {
604            return IpInfoReport {
605                address_type: "public",
606                country: None,
607                asn: None,
608                as_name: None,
609                org: None,
610                error: Some(error.to_string()),
611            };
612        }
613    };
614
615    match response.json::<IpWhoResponse>().await {
616        Ok(body) if body.success.unwrap_or(true) => {
617            let connection = body.connection;
618            let asn = connection
619                .as_ref()
620                .and_then(|connection| connection.asn)
621                .map(|asn| format!("AS{asn}"));
622            let org = connection
623                .as_ref()
624                .and_then(|connection| connection.org.clone());
625            let as_name = org
626                .clone()
627                .or_else(|| connection.and_then(|connection| connection.isp));
628
629            IpInfoReport {
630                address_type: "public",
631                country: body.country_code.or(body.country),
632                asn,
633                as_name,
634                org,
635                error: None,
636            }
637        }
638        Ok(body) => IpInfoReport {
639            address_type: "public",
640            country: None,
641            asn: None,
642            as_name: None,
643            org: None,
644            error: Some(
645                body.message
646                    .unwrap_or_else(|| "IP lookup service returned an error".to_owned()),
647            ),
648        },
649        Err(error) => IpInfoReport {
650            address_type: "public",
651            country: None,
652            asn: None,
653            as_name: None,
654            org: None,
655            error: Some(error.to_string()),
656        },
657    }
658}
659
660fn summarize_privacy_report<'a>(
661    reports: impl IntoIterator<Item = &'a GuardianPrivacyReport>,
662) -> FederationPrivacySummary {
663    let mut countries = BTreeMap::new();
664    let mut autonomous_systems = BTreeMap::new();
665    let mut unique_public_ips = BTreeSet::new();
666    let reports = reports.into_iter().collect::<Vec<_>>();
667
668    for report in &reports {
669        let mut guardian_countries = BTreeSet::new();
670        let mut guardian_autonomous_systems = BTreeSet::new();
671
672        unique_public_ips.extend(
673            report
674                .addresses
675                .iter()
676                .filter(|(_, ip_info)| ip_info.address_type == "public")
677                .map(|(ip, _)| ip.clone()),
678        );
679
680        for ip_info in report
681            .addresses
682            .values()
683            .filter(|ip_info| ip_info.address_type == "public")
684        {
685            if let Some(country) = &ip_info.country {
686                guardian_countries.insert(country.clone());
687            }
688            let as_label = match (&ip_info.asn, &ip_info.as_name) {
689                (Some(asn), Some(as_name)) => Some(format!("{asn} {as_name}")),
690                (Some(asn), None) => Some(asn.clone()),
691                (None, Some(as_name)) => Some(as_name.clone()),
692                (None, None) => None,
693            };
694            if let Some(as_label) = as_label {
695                guardian_autonomous_systems.insert(as_label);
696            }
697        }
698
699        for country in guardian_countries {
700            *countries.entry(country).or_insert(0) += 1;
701        }
702        for autonomous_system in guardian_autonomous_systems {
703            *autonomous_systems.entry(autonomous_system).or_insert(0) += 1;
704        }
705    }
706
707    FederationPrivacySummary {
708        guardian_count: reports.len(),
709        iroh_guardian_count: reports
710            .iter()
711            .filter(|report| report.api_url.scheme() == "iroh")
712            .count(),
713        resolved_guardian_count: reports
714            .iter()
715            .filter(|report| !report.addresses.is_empty())
716            .count(),
717        iroh_direct_guardian_count: reports
718            .iter()
719            .filter(|report| {
720                report
721                    .iroh
722                    .as_ref()
723                    .is_some_and(|iroh| matches!(iroh.connectivity, "direct" | "mixed"))
724            })
725            .count(),
726        iroh_relayed_guardian_count: reports
727            .iter()
728            .filter(|report| {
729                report
730                    .iroh
731                    .as_ref()
732                    .is_some_and(|iroh| iroh.connectivity == "relay")
733            })
734            .count(),
735        public_ip_count: unique_public_ips.len(),
736        countries,
737        autonomous_systems,
738    }
739}
740
741fn connectivity_name(connectivity: Connectivity) -> &'static str {
742    match connectivity {
743        Connectivity::Direct => "direct",
744        Connectivity::Relay => "relay",
745        Connectivity::Mixed => "mixed",
746        Connectivity::Tor => "tor",
747        Connectivity::Unknown => "unknown",
748    }
749}
750
751fn ip_address_type(ip: IpAddr) -> &'static str {
752    match ip {
753        IpAddr::V4(ip) => {
754            if ip.is_private() {
755                "private"
756            } else if ip.is_loopback() {
757                "loopback"
758            } else if ip.is_link_local() {
759                "link_local"
760            } else if ip.is_broadcast() {
761                "broadcast"
762            } else if matches!(
763                ip.octets(),
764                [192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]
765            ) {
766                "documentation"
767            } else if matches!(ip.octets(), [100, second, _, _] if (64..=127).contains(&second)) {
768                "shared"
769            } else if matches!(ip.octets(), [198, 18 | 19, _, _]) {
770                "benchmark"
771            } else if ip.is_multicast() {
772                "multicast"
773            } else if ip.is_unspecified() {
774                "unspecified"
775            } else {
776                "public"
777            }
778        }
779        IpAddr::V6(ip) => {
780            if ip.is_loopback() {
781                "loopback"
782            } else if ip.is_unspecified() {
783                "unspecified"
784            } else if ip.is_unique_local() {
785                "unique_local"
786            } else if ip.is_unicast_link_local() {
787                "link_local"
788            } else if ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8 {
789                "documentation"
790            } else if ip.is_multicast() {
791                "multicast"
792            } else {
793                "public"
794            }
795        }
796    }
797}
798
799pub struct FedimintCli {
800    module_inits: ClientModuleInitRegistry,
801    cli_args: Opts,
802}
803
804impl FedimintCli {
805    /// Build a new `fedimintd` with a custom version hash
806    pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
807        assert_eq!(
808            fedimint_build_code_version_env!().len(),
809            version_hash.len(),
810            "version_hash must have an expected length"
811        );
812
813        handle_version_hash_command(version_hash);
814
815        let cli_args = Opts::parse();
816        let base_level = if cli_args.verbose { "debug" } else { "info" };
817        TracingSetup::default()
818            .with_base_level(base_level)
819            .init()
820            .map_err(anyhow::Error::from)
821            .expect("tracing initializes");
822
823        let version = env!("CARGO_PKG_VERSION");
824        debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
825
826        Ok(Self {
827            module_inits: ClientModuleInitRegistry::new(),
828            cli_args,
829        })
830    }
831
832    pub fn with_module<T>(mut self, r#gen: T) -> Self
833    where
834        T: ClientModuleInit + 'static + Send + Sync,
835    {
836        self.module_inits.attach(r#gen);
837        self
838    }
839
840    pub fn with_default_modules(self) -> Self {
841        self.with_module(LightningClientInit::default())
842            .with_module(MintClientInit)
843            .with_module(fedimint_mintv2_client::MintClientInit)
844            .with_module(WalletClientInit::default())
845            .with_module(MetaClientInit)
846            .with_module(fedimint_lnv2_client::LightningClientInit::default())
847            .with_module(fedimint_walletv2_client::WalletClientInit)
848    }
849
850    pub async fn run(&mut self) {
851        match self.handle_command(self.cli_args.clone()).await {
852            Ok(output) => {
853                // ignore if there's anyone reading the stuff we're writing out
854                let _ = writeln!(std::io::stdout(), "{output}");
855            }
856            Err(err) => {
857                debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
858                let _ = writeln!(std::io::stdout(), "{err}");
859                exit(1);
860            }
861        }
862    }
863
864    async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
865        let mut client_builder = Client::builder()
866            .await
867            .with_iroh_enable_dht(cli.iroh_enable_dht());
868        client_builder.with_module_inits(self.module_inits.clone());
869
870        let db = cli.load_database().await?;
871        Ok((client_builder, db))
872    }
873
874    async fn client_join(
875        &mut self,
876        cli: &Opts,
877        invite_code: InviteCode,
878    ) -> CliResult<ClientHandleArc> {
879        let (client_builder, db) = self.make_client_builder(cli).await?;
880
881        let mnemonic = load_or_generate_mnemonic(&db).await?;
882
883        let client = client_builder
884            .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
885            .await
886            .map_err_cli()?
887            .join(db, root_secret_from_mnemonic(&mnemonic))
888            .await
889            .map(Arc::new)
890            .map_err_cli()?;
891
892        print_welcome_message(&client).await;
893        log_expiration_notice(&client).await;
894
895        Ok(client)
896    }
897
898    async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
899        let (mut client_builder, db) = self.make_client_builder(cli).await?;
900
901        // Use CLI args if provided, otherwise try to load stored credentials
902        if let Some(our_id) = cli.our_id {
903            client_builder.set_admin_creds(AdminCreds {
904                peer_id: our_id,
905                auth: cli.auth()?,
906            });
907        } else if let Some(stored_creds) = load_admin_creds(&db).await {
908            debug!(target: LOG_CLIENT, "Using stored admin credentials");
909            client_builder.set_admin_creds(AdminCreds {
910                peer_id: stored_creds.peer_id,
911                auth: ApiAuth::new(stored_creds.auth),
912            });
913        }
914
915        let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
916            .await
917            .map_err_cli()?;
918
919        let root_secret = match (cli.federation_secret_hex.as_deref(), existing_mnemonic) {
920            (Some(_), Some(_)) => {
921                return Err(CliError {
922                    error: "client secret is already set in DB; --federation-secret-hex open requires a client DB without any stored secret".to_owned(),
923                });
924            }
925            (Some(federation_secret_hex), None) => {
926                RootSecret::Custom(decode_federation_secret_hex(federation_secret_hex)?)
927            }
928            (None, Some(entropy)) => {
929                let mnemonic = Mnemonic::from_entropy(&entropy).map_err_cli()?;
930                root_secret_from_mnemonic(&mnemonic)
931            }
932            (None, None) => {
933                return Err(CliError {
934                    error: "Encoded client secret not present in DB".to_owned(),
935                });
936            }
937        };
938
939        let client = client_builder
940            .open(cli.make_endpoints().await.map_err_cli()?, db, root_secret)
941            .await
942            .map(Arc::new)
943            .map_err_cli()?;
944
945        log_expiration_notice(&client).await;
946
947        Ok(client)
948    }
949
950    async fn federation_ip_query(
951        &self,
952        cli: &Opts,
953        invite_code: &InviteCode,
954        path_timeout: Duration,
955        require_direct: bool,
956    ) -> CliResult<FederationPrivacyReport> {
957        let connectors = cli.make_endpoints().await.map_err_cli()?;
958        let (config, _api) = download_from_invite_code(&connectors, invite_code)
959            .await
960            .map_err_cli_msg("failed to download federation config from invite code")?;
961
962        let federation_id = config.calculate_federation_id();
963        let endpoints = config.global.api_endpoints;
964        if require_direct
965            && !endpoints
966                .values()
967                .any(|peer_url| peer_url.url.scheme() == "iroh")
968        {
969            return Err(CliError {
970                error: "--require-direct requires at least one iroh guardian endpoint".to_owned(),
971            });
972        }
973        let report_timeout = if require_direct {
974            Duration::ZERO
975        } else {
976            path_timeout
977        };
978        let mut guardian_reports =
979            query_guardian_addresses(&connectors, &endpoints, report_timeout).await;
980
981        if require_direct {
982            let deadline = fedimint_core::time::now() + path_timeout;
983            while !all_iroh_guardians_direct(&guardian_reports)
984                && fedimint_core::time::now() < deadline
985            {
986                runtime::sleep(Duration::from_millis(250)).await;
987                guardian_reports =
988                    query_guardian_addresses(&connectors, &endpoints, Duration::ZERO).await;
989            }
990
991            if !all_iroh_guardians_direct(&guardian_reports) {
992                let not_direct = guardian_reports
993                    .iter()
994                    .filter(|(_, report)| {
995                        report.api_url.scheme() == "iroh"
996                            && !report
997                                .iroh
998                                .as_ref()
999                                .is_some_and(|iroh| matches!(iroh.connectivity, "direct" | "mixed"))
1000                    })
1001                    .map(|(peer_id, _)| peer_id.to_string())
1002                    .join(", ");
1003                return Err(CliError {
1004                    error: format!(
1005                        "not all iroh guardians reached a direct path within {path_timeout:?}; pending peers: {not_direct}"
1006                    ),
1007                });
1008            }
1009        }
1010
1011        enrich_guardian_ip_info(&mut guardian_reports).await;
1012        let summary = summarize_privacy_report(guardian_reports.iter().map(|(_, report)| report));
1013        let guardians = guardian_reports
1014            .into_iter()
1015            .map(|(peer_id, report)| (peer_id.to_string(), report))
1016            .collect();
1017
1018        Ok(FederationPrivacyReport {
1019            federation_id,
1020            guardians,
1021            summary,
1022        })
1023    }
1024
1025    async fn client_recover(
1026        &mut self,
1027        cli: &Opts,
1028        recovery_secret: RecoverySecret,
1029        invite_code: InviteCode,
1030    ) -> CliResult<ClientHandleArc> {
1031        let (builder, db) = self.make_client_builder(cli).await?;
1032        let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
1033            .await
1034            .map_err_cli()?;
1035
1036        let root_secret = match (recovery_secret, existing_mnemonic) {
1037            (RecoverySecret::Mnemonic(mnemonic), Some(existing)) => {
1038                if existing != mnemonic.to_entropy() {
1039                    Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
1040                }
1041
1042                root_secret_from_mnemonic(&mnemonic)
1043            }
1044            (RecoverySecret::Mnemonic(mnemonic), None) => {
1045                Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
1046                    .await
1047                    .map_err_cli()?;
1048                root_secret_from_mnemonic(&mnemonic)
1049            }
1050            (RecoverySecret::FederationSecret(federation_secret), None) => {
1051                RootSecret::Custom(federation_secret)
1052            }
1053            (RecoverySecret::FederationSecret(_), Some(_)) => {
1054                return Err(CliError {
1055                    error: "client secret is already set in DB; --federation-secret-hex restore requires a client DB without any stored secret".to_owned(),
1056                });
1057            }
1058        };
1059
1060        let preview = builder
1061            .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
1062            .await
1063            .map_err_cli()?;
1064
1065        #[allow(deprecated)]
1066        let backup = preview
1067            .download_backup_from_federation(root_secret.clone())
1068            .await
1069            .map_err_cli()?;
1070
1071        let client = preview
1072            .recover(db, root_secret, backup)
1073            .await
1074            .map(Arc::new)
1075            .map_err_cli()?;
1076
1077        print_welcome_message(&client).await;
1078        log_expiration_notice(&client).await;
1079
1080        Ok(client)
1081    }
1082
1083    async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
1084        if cli.federation_secret_hex.is_some() && matches!(&cli.command, Command::Join { .. }) {
1085            return Err(CliError {
1086                error: "--federation-secret-hex cannot be used with join".to_owned(),
1087            });
1088        }
1089
1090        match cli.command.clone() {
1091            Command::InviteCode { peer } => {
1092                let client = self.client_open(&cli).await?;
1093
1094                let invite_code = client
1095                    .invite_code(peer)
1096                    .await
1097                    .ok_or_cli_msg("peer not found")?;
1098
1099                Ok(CliOutput::InviteCode { invite_code })
1100            }
1101            Command::Join { invite_code } => {
1102                {
1103                    let invite_code: InviteCode = InviteCode::from_str(&invite_code)
1104                        .map_err_cli_msg("invalid invite code")?;
1105
1106                    // Build client and store config in DB
1107                    let _client = self.client_join(&cli, invite_code).await?;
1108                }
1109
1110                Ok(CliOutput::Join {
1111                    joined: invite_code,
1112                })
1113            }
1114            Command::VersionHash => Ok(CliOutput::VersionHash {
1115                hash: fedimint_build_code_version_env!().to_string(),
1116            }),
1117            Command::Client(ClientCmd::Restore {
1118                mnemonic,
1119                invite_code,
1120            }) => {
1121                let invite_code: InviteCode =
1122                    InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
1123                let recovery_secret = match (
1124                    mnemonic.as_deref(),
1125                    cli.federation_secret_hex.as_deref(),
1126                ) {
1127                    (Some(_), Some(_)) => {
1128                        return Err(CliError {
1129                            error: "restore accepts either --mnemonic or --federation-secret-hex, not both".to_owned(),
1130                        });
1131                    }
1132                    (Some(mnemonic), None) => {
1133                        let mnemonic = Mnemonic::from_str(mnemonic).map_err_cli()?;
1134                        RecoverySecret::Mnemonic(mnemonic)
1135                    }
1136                    (None, Some(federation_secret_hex)) => {
1137                        let federation_secret =
1138                            decode_federation_secret_hex(federation_secret_hex)?;
1139                        RecoverySecret::FederationSecret(federation_secret)
1140                    }
1141                    (None, None) => {
1142                        return Err(CliError {
1143                            error: "restore requires either --mnemonic or --federation-secret-hex"
1144                                .to_owned(),
1145                        });
1146                    }
1147                };
1148                let client = self
1149                    .client_recover(&cli, recovery_secret, invite_code)
1150                    .await?;
1151
1152                // TODO: until we implement recovery for other modules we can't really wait
1153                // for more than this one
1154                debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
1155                client.wait_for_all_recoveries().await.map_err_cli()?;
1156
1157                debug!(target: LOG_CLIENT, "Recovery complete");
1158
1159                Ok(CliOutput::Raw(
1160                    serde_json::to_value(()).expect("unit type is serializable"),
1161                ))
1162            }
1163            Command::Client(command) => {
1164                let client = self.client_open(&cli).await?;
1165                Ok(CliOutput::Raw(
1166                    client::handle_command(command, client)
1167                        .await
1168                        .map_err_cli()?,
1169                ))
1170            }
1171            Command::Admin(command) => self.handle_admin_command(cli, command).await,
1172            Command::Dev(command) => self.handle_dev_command(cli, command).await,
1173            Command::Completion { shell } => {
1174                let bin_path = PathBuf::from(
1175                    std::env::args_os()
1176                        .next()
1177                        .expect("Binary name is always provided if we get this far"),
1178                );
1179                let bin_name = bin_path
1180                    .file_name()
1181                    .expect("path has file name")
1182                    .to_string_lossy();
1183                clap_complete::generate(
1184                    shell,
1185                    &mut Opts::command(),
1186                    bin_name.as_ref(),
1187                    &mut std::io::stdout(),
1188                );
1189                // HACK: prints true to stdout which is fine for shells
1190                Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1191            }
1192        }
1193    }
1194
1195    /// Verify a guardian's credentials before storing them in the client
1196    /// database.
1197    async fn handle_admin_auth(
1198        &self,
1199        cli: &Opts,
1200        peer_id: u16,
1201        password: String,
1202        no_verify: bool,
1203        force: bool,
1204    ) -> CliOutputResult {
1205        let db = cli.load_database().await?;
1206        let peer_id = PeerId::from(peer_id);
1207        let auth = ApiAuth::new(password);
1208
1209        // Check if credentials already exist
1210        if !force {
1211            let existing = load_admin_creds(&db).await;
1212            if existing.is_some() {
1213                return Err(CliError {
1214                    error: "Admin credentials already stored. Use --force to overwrite."
1215                        .to_string(),
1216                });
1217            }
1218        }
1219
1220        // Load client config to get peer endpoints
1221        let config = Client::get_config_from_db(&db)
1222            .await
1223            .ok_or_cli_msg("Client not initialized. Please join a federation first.")?;
1224
1225        // Get the endpoint for the specified peer
1226        let peer_url = config
1227            .global
1228            .api_endpoints
1229            .get(&peer_id)
1230            .ok_or_else(|| CliError {
1231                error: format!(
1232                    "Peer ID {} not found in federation. Valid peer IDs are: {:?}",
1233                    peer_id,
1234                    config.global.api_endpoints.keys().collect::<Vec<_>>()
1235                ),
1236            })?;
1237
1238        // Interactive verification unless --no-verify is set
1239        if !no_verify {
1240            // Check if stdin is a terminal for interactive prompts
1241            if !std::io::stdin().is_terminal() {
1242                return Err(CliError {
1243                    error: "Interactive verification requires a terminal. Use --no-verify to skip."
1244                        .to_string(),
1245                });
1246            }
1247
1248            eprintln!("Guardian endpoint for peer {}: {}", peer_id, peer_url.url);
1249            eprint!("Does this look correct? (y/N): ");
1250            std::io::stderr().flush().map_err_cli()?;
1251
1252            let mut input = String::new();
1253            std::io::stdin().read_line(&mut input).map_err_cli()?;
1254            let input = input.trim().to_lowercase();
1255
1256            if input != "y" && input != "yes" {
1257                return Err(CliError {
1258                    error: "Endpoint verification cancelled by user.".to_string(),
1259                });
1260            }
1261        }
1262
1263        // Verify credentials by making an authenticated API call
1264        eprintln!("Verifying credentials...");
1265        let admin_api = DynGlobalApi::new_admin(
1266            cli.make_endpoints().await.map_err_cli()?,
1267            peer_id,
1268            peer_url.url.clone(),
1269            db.begin_transaction_nc()
1270                .await
1271                .get_value(&ApiSecretKey)
1272                .await
1273                .as_deref(),
1274        );
1275
1276        // Use the `auth` endpoint to verify credentials
1277        admin_api.auth(auth.clone()).await.map_err(|e| CliError {
1278            error: format!(
1279                "Failed to verify credentials: {e}. Please check your peer ID and password."
1280            ),
1281        })?;
1282
1283        // Store the credentials in the database
1284        store_admin_creds(
1285            &db,
1286            &StoredAdminCreds {
1287                peer_id,
1288                auth: auth.as_str().to_string(),
1289            },
1290        )
1291        .await;
1292
1293        eprintln!("Admin credentials verified and saved successfully.");
1294        Ok(CliOutput::Raw(json!({
1295            "peer_id": peer_id,
1296            "endpoint": peer_url.url.to_string(),
1297            "status": "saved"
1298        })))
1299    }
1300
1301    /// Dispatch guardian administration without opening a client for setup or
1302    /// auth.
1303    async fn handle_admin_command(&mut self, cli: Opts, command: AdminCmd) -> CliOutputResult {
1304        match command {
1305            AdminCmd::Auth {
1306                peer_id,
1307                password,
1308                no_verify,
1309                force,
1310            } => {
1311                self.handle_admin_auth(&cli, peer_id, password, no_verify, force)
1312                    .await
1313            }
1314            AdminCmd::Audit => {
1315                let client = self.client_open(&cli).await?;
1316
1317                let audit = cli
1318                    .admin_client(
1319                        &client.get_peer_urls().await,
1320                        client.api_secret().as_deref(),
1321                    )
1322                    .await?
1323                    .audit(cli.auth()?)
1324                    .await?;
1325                Ok(CliOutput::Raw(
1326                    serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
1327                ))
1328            }
1329            AdminCmd::Status => {
1330                let client = self.client_open(&cli).await?;
1331
1332                let status = cli
1333                    .admin_client_with_db(
1334                        &client.get_peer_urls().await,
1335                        client.api_secret().as_deref(),
1336                        Some(client.db()),
1337                    )
1338                    .await?
1339                    .status()
1340                    .await?;
1341                Ok(CliOutput::Raw(
1342                    serde_json::to_value(status).map_err_cli_msg("invalid response")?,
1343                ))
1344            }
1345            AdminCmd::GuardianConfigBackup => {
1346                let client = self.client_open(&cli).await?;
1347
1348                let guardian_config_backup = cli
1349                    .admin_client(
1350                        &client.get_peer_urls().await,
1351                        client.api_secret().as_deref(),
1352                    )
1353                    .await?
1354                    .guardian_config_backup(cli.auth()?)
1355                    .await?;
1356                Ok(CliOutput::Raw(
1357                    serde_json::to_value(guardian_config_backup)
1358                        .map_err_cli_msg("invalid response")?,
1359                ))
1360            }
1361            AdminCmd::Setup(dkg_args) => self
1362                .handle_admin_setup_command(cli, dkg_args)
1363                .await
1364                .map(CliOutput::Raw)
1365                .map_err_cli_msg("Config Gen Error"),
1366            AdminCmd::SignApiAnnouncement {
1367                api_url,
1368                override_url,
1369            } => {
1370                let client = self.client_open(&cli).await?;
1371
1372                if !["ws", "wss"].contains(&api_url.scheme()) {
1373                    return Err(CliError {
1374                        error: format!(
1375                            "Unsupported URL scheme {}, use ws:// or wss://",
1376                            api_url.scheme()
1377                        ),
1378                    });
1379                }
1380
1381                let announcement = cli
1382                    .admin_client(
1383                        &override_url
1384                            .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
1385                            .unwrap_or(client.get_peer_urls().await),
1386                        client.api_secret().as_deref(),
1387                    )
1388                    .await?
1389                    .sign_api_announcement(api_url, cli.auth()?)
1390                    .await?;
1391
1392                Ok(CliOutput::Raw(
1393                    serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
1394                ))
1395            }
1396            AdminCmd::SignGuardianMetadata { api_urls, pkarr_id } => {
1397                let client = self.client_open(&cli).await?;
1398
1399                let metadata = fedimint_core::net::guardian_metadata::GuardianMetadata::new(
1400                    api_urls,
1401                    pkarr_id,
1402                    fedimint_core::time::duration_since_epoch().as_secs(),
1403                );
1404
1405                let signed_metadata = cli
1406                    .admin_client(
1407                        &client.get_peer_urls().await,
1408                        client.api_secret().as_deref(),
1409                    )
1410                    .await?
1411                    .sign_guardian_metadata(metadata, cli.auth()?)
1412                    .await?;
1413
1414                Ok(CliOutput::Raw(
1415                    serde_json::to_value(signed_metadata).map_err_cli_msg("invalid response")?,
1416                ))
1417            }
1418            AdminCmd::Shutdown { session_idx } => {
1419                let client = self.client_open(&cli).await?;
1420
1421                cli.admin_client(
1422                    &client.get_peer_urls().await,
1423                    client.api_secret().as_deref(),
1424                )
1425                .await?
1426                .shutdown(Some(session_idx), cli.auth()?)
1427                .await?;
1428
1429                Ok(CliOutput::Raw(json!(null)))
1430            }
1431            AdminCmd::BackupStatistics => {
1432                let client = self.client_open(&cli).await?;
1433
1434                let backup_statistics = cli
1435                    .admin_client(
1436                        &client.get_peer_urls().await,
1437                        client.api_secret().as_deref(),
1438                    )
1439                    .await?
1440                    .backup_statistics(cli.auth()?)
1441                    .await?;
1442
1443                Ok(CliOutput::Raw(
1444                    serde_json::to_value(backup_statistics).expect("Can be encoded"),
1445                ))
1446            }
1447        }
1448    }
1449
1450    /// Submit a raw API request, preserving authenticated and federation-wide
1451    /// routing.
1452    async fn handle_api_request(&mut self, cli: &Opts, request: ApiRequest) -> CliOutputResult {
1453        let ApiRequest {
1454            method,
1455            params,
1456            peer_id,
1457            auth,
1458            module,
1459        } = request;
1460        //Parse params to JSON.
1461        //If fails, convert to JSON string.
1462        let params = serde_json::from_str::<Value>(&params).unwrap_or_else(|err| {
1463            debug!(
1464                target: LOG_CLIENT,
1465                "Failed to serialize params:{}. Converting it to JSON string",
1466                err
1467            );
1468
1469            serde_json::Value::String(params)
1470        });
1471
1472        let mut params = ApiRequestErased::new(params);
1473        if let Some(auth) = auth {
1474            params = params.with_auth(ApiAuth::new(auth));
1475        }
1476        let client = self.client_open(cli).await?;
1477
1478        let api = client.api_clone();
1479
1480        let module_api = match module {
1481            Some(selector) => Some(api.with_module(selector.resolve(&client).map_err_cli()?)),
1482            None => None,
1483        };
1484
1485        let response: Value = match (peer_id, module_api) {
1486            (Some(peer_id), Some(module_api)) => module_api
1487                .request_raw(peer_id.into(), &method, &params)
1488                .await
1489                .map_err_cli()?,
1490            (Some(peer_id), None) => api
1491                .request_raw(peer_id.into(), &method, &params)
1492                .await
1493                .map_err_cli()?,
1494            (None, Some(module_api)) => module_api
1495                .request_current_consensus(method, params)
1496                .await
1497                .map_err_cli()?,
1498            (None, None) => api
1499                .request_current_consensus(method, params)
1500                .await
1501                .map_err_cli()?,
1502        };
1503
1504        Ok(CliOutput::UntypedApiOutput { value: response })
1505    }
1506
1507    /// Submit a transaction using the client's module decoders for both
1508    /// directions.
1509    async fn submit_transaction(&self, cli: &Opts, transaction: &str) -> CliOutputResult {
1510        let client = self.client_open(cli).await?;
1511        let tx = Transaction::consensus_decode_hex(transaction, client.decoders()).map_err_cli()?;
1512        let tx_outcome = client
1513            .api()
1514            .submit_transaction(tx)
1515            .await
1516            .try_into_inner(client.decoders())
1517            .map_err_cli()?;
1518        Ok(CliOutput::Raw(
1519            serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1520        ))
1521    }
1522
1523    /// Render one requested client visualization.
1524    async fn handle_visualize(&self, cli: &Opts, visualize_type: VisualizeCmd) -> CliOutputResult {
1525        let client = self.client_open(cli).await?;
1526        match visualize_type {
1527            VisualizeCmd::Notes { limit } => {
1528                visualize::cmd_notes(&client, limit).await?;
1529            }
1530            VisualizeCmd::Transactions {
1531                operation_id,
1532                limit,
1533            } => {
1534                visualize::cmd_transactions(&client, operation_id, limit).await?;
1535            }
1536            VisualizeCmd::Operations {
1537                operation_id,
1538                limit,
1539            } => {
1540                visualize::cmd_operations(&client, operation_id, limit).await?;
1541            }
1542        }
1543        Ok(CliOutput::Raw(json!({})))
1544    }
1545
1546    /// Fetch metadata using the module source and legacy fallback.
1547    async fn fetch_meta_fields(&self, cli: &Opts) -> CliOutputResult {
1548        let client = self.client_open(cli).await?;
1549        let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1550        let meta_fields = source
1551            .fetch(
1552                &client.config().await,
1553                &client.api_clone(),
1554                FetchKind::Initial,
1555                None,
1556            )
1557            .await
1558            .map_err_cli()?;
1559        Ok(CliOutput::Raw(
1560            serde_json::to_value(meta_fields).expect("Can be encoded"),
1561        ))
1562    }
1563
1564    /// Dispatch developer commands, opening the client only where required.
1565    async fn handle_dev_command(&mut self, cli: Opts, command: DevCmd) -> CliOutputResult {
1566        match command {
1567            DevCmd::Api {
1568                method,
1569                params,
1570                peer_id,
1571                password: auth,
1572                module,
1573            } => {
1574                self.handle_api_request(
1575                    &cli,
1576                    ApiRequest {
1577                        method,
1578                        params,
1579                        peer_id,
1580                        auth,
1581                        module,
1582                    },
1583                )
1584                .await
1585            }
1586            DevCmd::AdvanceNoteIdx { count, amount } => {
1587                let client = self.client_open(&cli).await?;
1588
1589                let mint = client
1590                    .get_first_module::<MintClientModule>()
1591                    .map_err_cli_msg("can't get mint module")?;
1592
1593                for _ in 0..count {
1594                    mint.advance_note_idx(amount).await;
1595                }
1596
1597                Ok(CliOutput::Raw(serde_json::Value::Null))
1598            }
1599            DevCmd::ApiAnnouncements => {
1600                let client = self.client_open(&cli).await?;
1601                let announcements = client.get_peer_url_announcements().await;
1602                Ok(CliOutput::Raw(
1603                    serde_json::to_value(announcements).expect("Can be encoded"),
1604                ))
1605            }
1606            DevCmd::GuardianMetadata => {
1607                let client = self.client_open(&cli).await?;
1608                let metadata = client.get_guardian_metadata().await;
1609                Ok(CliOutput::Raw(
1610                    serde_json::to_value(metadata).expect("Can be encoded"),
1611                ))
1612            }
1613            DevCmd::WaitBlockCount { count: target } => self.wait_block_count(&cli, target).await,
1614
1615            DevCmd::WaitComplete => {
1616                let client = self.client_open(&cli).await?;
1617                client.wait_for_all_active_state_machines().await;
1618                Ok(CliOutput::Raw(serde_json::Value::Null))
1619            }
1620            DevCmd::Wait { seconds } => self.wait_with_networking(&cli, seconds).await,
1621            DevCmd::Decode { decode_type } => self.handle_decode(&cli, decode_type).await,
1622            DevCmd::Encode { encode_type } => command_encoding::handle_encode(encode_type),
1623            DevCmd::SessionCount => {
1624                let client = self.client_open(&cli).await?;
1625                let count = client.api().session_count().await?;
1626                Ok(CliOutput::EpochCount { count })
1627            }
1628            DevCmd::QueryFederationIps {
1629                invite_code,
1630                path_timeout_seconds,
1631                require_direct,
1632            } => {
1633                let report = self
1634                    .federation_ip_query(
1635                        &cli,
1636                        &invite_code,
1637                        Duration::from_secs(path_timeout_seconds),
1638                        require_direct,
1639                    )
1640                    .await?;
1641                Ok(CliOutput::Raw(
1642                    serde_json::to_value(report).expect("privacy report is serializable"),
1643                ))
1644            }
1645            DevCmd::Config => {
1646                let client = self.client_open(&cli).await?;
1647                let config = client.get_config_json().await;
1648                Ok(CliOutput::Raw(
1649                    serde_json::to_value(config).expect("Client config is serializable"),
1650                ))
1651            }
1652            DevCmd::ConfigDecrypt {
1653                in_file,
1654                out_file,
1655                salt_file,
1656                password,
1657            } => decrypt_config(in_file, out_file, salt_file, &password),
1658            DevCmd::ConfigEncrypt {
1659                in_file,
1660                out_file,
1661                salt_file,
1662                password,
1663            } => encrypt_config(in_file, out_file, salt_file, &password),
1664            DevCmd::ListOperationStates { operation_id } => {
1665                #[derive(Serialize)]
1666                struct ReactorLogState {
1667                    active: bool,
1668                    module_instance: ModuleInstanceId,
1669                    creation_time: String,
1670                    #[serde(skip_serializing_if = "Option::is_none")]
1671                    end_time: Option<String>,
1672                    state: String,
1673                }
1674
1675                let client = self.client_open(&cli).await?;
1676
1677                let (active_states, inactive_states) =
1678                    client.executor().get_operation_states(operation_id).await;
1679                let all_states =
1680                    active_states
1681                        .into_iter()
1682                        .map(|(active_state, active_meta)| ReactorLogState {
1683                            active: true,
1684                            module_instance: active_state.module_instance_id(),
1685                            creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1686                            end_time: None,
1687                            state: format!("{active_state:?}",),
1688                        })
1689                        .chain(inactive_states.into_iter().map(
1690                            |(inactive_state, inactive_meta)| ReactorLogState {
1691                                active: false,
1692                                module_instance: inactive_state.module_instance_id(),
1693                                creation_time: crate::client::time_to_iso8601(
1694                                    &inactive_meta.created_at,
1695                                ),
1696                                end_time: Some(crate::client::time_to_iso8601(
1697                                    &inactive_meta.exited_at,
1698                                )),
1699                                state: format!("{inactive_state:?}",),
1700                            },
1701                        ))
1702                        .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1703                        .collect::<Vec<_>>();
1704
1705                Ok(CliOutput::Raw(json!({
1706                    "states": all_states
1707                })))
1708            }
1709            DevCmd::MetaFields => self.fetch_meta_fields(&cli).await,
1710            DevCmd::PeerVersion { peer_id } => {
1711                let client = self.client_open(&cli).await?;
1712                let version = client
1713                    .api()
1714                    .fedimintd_version(peer_id.into())
1715                    .await
1716                    .map_err_cli()?;
1717
1718                Ok(CliOutput::Raw(json!({ "version": version })))
1719            }
1720            DevCmd::ShowEventLog { pos, limit } => {
1721                let client = self.client_open(&cli).await?;
1722
1723                let events: Vec<_> = client
1724                    .get_event_log(pos, limit)
1725                    .await
1726                    .into_iter()
1727                    .map(|v| event_output::event_to_json(v.id(), v.as_raw()))
1728                    .collect();
1729
1730                Ok(CliOutput::Raw(
1731                    serde_json::to_value(events).expect("Can be encoded"),
1732                ))
1733            }
1734            DevCmd::ShowEventLogTrimable { pos, limit } => {
1735                let client = self.client_open(&cli).await?;
1736
1737                let events: Vec<_> = client
1738                    .get_event_log_trimable(
1739                        pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1740                        limit,
1741                    )
1742                    .await
1743                    .into_iter()
1744                    .map(|v| event_output::event_to_json(v.id(), v.as_raw()))
1745                    .collect();
1746
1747                Ok(CliOutput::Raw(
1748                    serde_json::to_value(events).expect("Can be encoded"),
1749                ))
1750            }
1751            DevCmd::NextEventLogId => {
1752                let client = self.client_open(&cli).await?;
1753
1754                let id = client.get_next_event_log_id().await;
1755
1756                Ok(CliOutput::Raw(
1757                    serde_json::to_value(id).expect("Can be encoded"),
1758                ))
1759            }
1760            DevCmd::SubmitTransaction { transaction } => {
1761                self.submit_transaction(&cli, &transaction).await
1762            }
1763            DevCmd::TestEventLogHandling => {
1764                let client = self.client_open(&cli).await?;
1765
1766                client
1767                    .handle_events(
1768                        client.built_in_application_event_log_tracker(),
1769                        move |_dbtx, event| {
1770                            Box::pin(async move {
1771                                info!(target: LOG_CLIENT, "{event:?}");
1772
1773                                Ok::<(), std::convert::Infallible>(())
1774                            })
1775                        },
1776                    )
1777                    .await
1778                    .map_err_cli()?;
1779                unreachable!(
1780                    "handle_events exits only if client shuts down, which we don't do here"
1781                )
1782            }
1783            DevCmd::Panic => {
1784                panic!("This panic is intentional for testing backtrace handling");
1785            }
1786            DevCmd::ChainId => {
1787                let client = self.client_open(&cli).await?;
1788                let chain_id = client
1789                    .db()
1790                    .begin_transaction_nc()
1791                    .await
1792                    .get_value(&fedimint_client::db::ChainIdKey)
1793                    .await
1794                    .ok_or_cli_msg("Chain ID not cached in client database")?;
1795
1796                Ok(CliOutput::Raw(serde_json::json!({
1797                    "chain_id": chain_id.to_string()
1798                })))
1799            }
1800            DevCmd::Visualize { visualize_type } => {
1801                self.handle_visualize(&cli, visualize_type).await
1802            }
1803            DevCmd::RefreshApiVersions => {
1804                let client = self.client_open(&cli).await?;
1805                let versions = client.refresh_api_versions().await.map_err_cli()?;
1806                Ok(CliOutput::Raw(json!({ "versions": versions })))
1807            }
1808        }
1809    }
1810
1811    async fn handle_admin_setup_command(
1812        &self,
1813        cli: Opts,
1814        args: SetupAdminArgs,
1815    ) -> anyhow::Result<Value> {
1816        let client =
1817            DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone());
1818
1819        match &args.subcommand {
1820            SetupAdminCmd::Status => {
1821                let status = client.setup_status(cli.auth()?).await?;
1822
1823                Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1824            }
1825            SetupAdminCmd::SetLocalParams {
1826                name,
1827                federation_name,
1828                federation_size,
1829            } => {
1830                let info = client
1831                    .set_local_params(
1832                        name.clone(),
1833                        federation_name.clone(),
1834                        None,
1835                        None,
1836                        *federation_size,
1837                        cli.auth()?,
1838                    )
1839                    .await?;
1840
1841                Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1842            }
1843            SetupAdminCmd::AddPeer { info } => {
1844                let name = client
1845                    .add_peer_connection_info(info.clone(), cli.auth()?)
1846                    .await?;
1847
1848                Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1849            }
1850            SetupAdminCmd::StartDkg => {
1851                client.start_dkg(cli.auth()?).await?;
1852
1853                Ok(Value::Null)
1854            }
1855        }
1856    }
1857}
1858
1859async fn log_expiration_notice(client: &Client) {
1860    client.get_meta_expiration_timestamp().await;
1861    if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1862        match expiration_time.duration_since(fedimint_core::time::now()) {
1863            Ok(until_expiration) => {
1864                let days = until_expiration.as_secs() / (60 * 60 * 24);
1865
1866                if 90 < days {
1867                    debug!(target: LOG_CLIENT, %days, "This federation will expire");
1868                } else if 30 < days {
1869                    info!(target: LOG_CLIENT, %days, "This federation will expire");
1870                } else {
1871                    warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1872                }
1873            }
1874            Err(_) => {
1875                tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1876            }
1877        }
1878    }
1879}
1880async fn print_welcome_message(client: &Client) {
1881    if let Some(welcome_message) = client
1882        .meta_service()
1883        .get_field::<String>(client.db(), "welcome_message")
1884        .await
1885        .and_then(|v| v.value)
1886    {
1887        eprintln!("{welcome_message}");
1888    }
1889}
1890
1891/// Decrypt a configuration without overwriting an existing output file.
1892fn decrypt_config(
1893    in_file: PathBuf,
1894    out_file: PathBuf,
1895    salt_file: Option<PathBuf>,
1896    password: &str,
1897) -> CliOutputResult {
1898    let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1899    let salt = fs::read_to_string(salt_file).map_err_cli()?;
1900    let key = get_encryption_key(password, &salt).map_err_cli()?;
1901    let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1902    let mut out_file_handle = fs::File::options()
1903        .create_new(true)
1904        .write(true)
1905        .open(out_file)
1906        .expect("Could not create output cfg file");
1907    out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1908    Ok(CliOutput::ConfigDecrypt)
1909}
1910
1911/// Encrypt a configuration using the salt next to the output unless overridden.
1912fn encrypt_config(
1913    in_file: PathBuf,
1914    out_file: PathBuf,
1915    salt_file: Option<PathBuf>,
1916    password: &str,
1917) -> CliOutputResult {
1918    let mut in_file_handle = fs::File::open(in_file).expect("Could not create output cfg file");
1919    let mut plaintext_bytes = vec![];
1920    in_file_handle
1921        .read_to_end(&mut plaintext_bytes)
1922        .expect("Could not read input cfg file");
1923    let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1924    let salt = fs::read_to_string(salt_file).map_err_cli()?;
1925    let key = get_encryption_key(password, &salt).map_err_cli()?;
1926    encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1927    Ok(CliOutput::ConfigEncrypt)
1928}
1929
1930fn salt_from_file_path(file_path: &Path) -> PathBuf {
1931    file_path
1932        .parent()
1933        .expect("File has no parent?!")
1934        .join(SALT_FILE)
1935}
1936
1937/// Convert clap arguments to backup metadata
1938fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1939    let metadata: BTreeMap<String, String> = metadata
1940        .into_iter()
1941        .map(|item| {
1942            match &item
1943                .splitn(2, '=')
1944                .map(ToString::to_string)
1945                .collect::<Vec<String>>()[..]
1946            {
1947                [] => Err(format_err!("Empty metadata argument not allowed")),
1948                [key] => Err(format_err!("Metadata {key} is missing a value")),
1949                [key, val] => Ok((key.clone(), val.clone())),
1950                [..] => unreachable!(),
1951            }
1952        })
1953        .collect::<anyhow::Result<_>>()
1954        .map_err_cli_msg("invalid metadata")?;
1955    Ok(metadata)
1956}
1957
1958#[test]
1959#[allow(clippy::unwrap_used)]
1960fn metadata_from_clap_cli_test() {
1961    for (args, expected) in [
1962        (
1963            vec!["a=b".to_string()],
1964            BTreeMap::from([("a".into(), "b".into())]),
1965        ),
1966        (
1967            vec!["a=b".to_string(), "c=d".to_string()],
1968            BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1969        ),
1970    ] {
1971        assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1972    }
1973}