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