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