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;
21use std::fmt::Debug;
22use std::io::{IsTerminal, Read, Write};
23use std::path::{Path, PathBuf};
24use std::process::exit;
25use std::str::FromStr;
26use std::sync::Arc;
27use std::time::Duration;
28use std::{fs, result};
29
30use anyhow::{Context, format_err};
31use clap::{CommandFactory, Parser};
32use cli::{
33    AdminCmd, Command, DatabaseBackend, DecodeType, DevCmd, EncodeType, OOBNotesJson, Opts,
34    SetupAdminArgs, SetupAdminCmd, VisualizeCmd,
35};
36use envs::SALT_FILE;
37use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
38use fedimint_api_client::api::{DynGlobalApi, FederationApiExt, FederationError};
39use fedimint_bip39::{Bip39RootSecretStrategy, Mnemonic};
40use fedimint_client::db::ApiSecretKey;
41use fedimint_client::module::meta::{FetchKind, LegacyMetaSource, MetaSource};
42use fedimint_client::module::module::init::ClientModuleInit;
43use fedimint_client::module_init::ClientModuleInitRegistry;
44use fedimint_client::secret::RootSecretStrategy;
45use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc, RootSecret};
46use fedimint_connectors::ConnectorRegistry;
47use fedimint_core::base32::FEDIMINT_PREFIX;
48use fedimint_core::config::{FederationId, FederationIdPrefix};
49use fedimint_core::core::ModuleInstanceId;
50use fedimint_core::db::{Database, DatabaseValue, IDatabaseTransactionOpsCoreTyped as _};
51use fedimint_core::encoding::Decodable;
52use fedimint_core::invite_code::InviteCode;
53use fedimint_core::module::registry::ModuleRegistry;
54use fedimint_core::module::{ApiAuth, ApiRequestErased};
55use fedimint_core::setup_code::PeerSetupCode;
56use fedimint_core::transaction::Transaction;
57use fedimint_core::util::{SafeUrl, backoff_util, handle_version_hash_command, retry};
58use fedimint_core::{PeerId, base32, fedimint_build_code_version_env, runtime};
59use fedimint_derive_secret::DerivableSecret;
60use fedimint_eventlog::EventLogTrimableId;
61use fedimint_ln_client::LightningClientInit;
62use fedimint_logging::{LOG_CLIENT, TracingSetup};
63use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
64use fedimint_mint_client::{MintClientInit, MintClientModule, OOBNotes};
65use fedimint_wallet_client::api::WalletFederationApi;
66use fedimint_wallet_client::{WalletClientInit, WalletClientModule};
67use futures::future::pending;
68use itertools::Itertools;
69use rand::thread_rng;
70use serde::Serialize;
71use serde_json::{Value, json};
72use thiserror::Error;
73use tracing::{debug, info, warn};
74
75use crate::client::ClientCmd;
76use crate::db::{StoredAdminCreds, load_admin_creds, store_admin_creds};
77
78/// Type of output the cli produces
79#[derive(Serialize)]
80#[serde(rename_all = "snake_case")]
81#[serde(untagged)]
82enum CliOutput {
83    VersionHash {
84        hash: String,
85    },
86
87    UntypedApiOutput {
88        value: Value,
89    },
90
91    WaitBlockCount {
92        reached: u64,
93    },
94
95    InviteCode {
96        invite_code: InviteCode,
97    },
98
99    DecodeInviteCode {
100        url: SafeUrl,
101        federation_id: FederationId,
102    },
103
104    Join {
105        joined: String,
106    },
107
108    DecodeTransaction {
109        transaction: String,
110    },
111
112    EpochCount {
113        count: u64,
114    },
115
116    ConfigDecrypt,
117
118    ConfigEncrypt,
119
120    SetupCode {
121        setup_code: PeerSetupCode,
122    },
123
124    Raw(serde_json::Value),
125}
126
127impl fmt::Display for CliOutput {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(
130            f,
131            "{}",
132            serde_json::to_string_pretty(self).expect("CliOutput is serializable")
133        )
134    }
135}
136
137/// `Result` with `CliError` as `Error`
138type CliResult<E> = Result<E, CliError>;
139
140/// `Result` with `CliError` as `Error` and `CliOutput` as `Ok`
141type CliOutputResult = Result<CliOutput, CliError>;
142
143/// Cli error
144#[derive(Serialize, Error)]
145#[serde(tag = "error", rename_all(serialize = "snake_case"))]
146struct CliError {
147    error: String,
148}
149
150/// Extension trait making turning Results/Errors into
151/// [`CliError`]/[`CliOutputResult`] easier
152trait CliResultExt<O, E> {
153    /// Map error into `CliError` wrapping the original error message
154    fn map_err_cli(self) -> Result<O, CliError>;
155    /// Map error into `CliError` using custom error message `msg`
156    fn map_err_cli_msg(self, msg: impl fmt::Display + Send + Sync + 'static)
157    -> Result<O, CliError>;
158}
159
160impl<O, E> CliResultExt<O, E> for result::Result<O, E>
161where
162    E: Into<anyhow::Error>,
163{
164    fn map_err_cli(self) -> Result<O, CliError> {
165        self.map_err(|e| {
166            let e = e.into();
167            CliError {
168                error: format!("{e:#}"),
169            }
170        })
171    }
172
173    fn map_err_cli_msg(
174        self,
175        msg: impl fmt::Display + Send + Sync + 'static,
176    ) -> Result<O, CliError> {
177        self.map_err(|e| Into::<anyhow::Error>::into(e))
178            .context(msg)
179            .map_err(|e| CliError {
180                error: format!("{e:#}"),
181            })
182    }
183}
184
185/// Extension trait to make turning `Option`s into
186/// [`CliError`]/[`CliOutputResult`] easier
187trait CliOptionExt<O> {
188    fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
189}
190
191impl<O> CliOptionExt<O> for Option<O> {
192    fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
193        self.ok_or_else(|| CliError { error: msg.into() })
194    }
195}
196
197// TODO: Refactor federation API errors to just delegate to this
198impl From<FederationError> for CliError {
199    fn from(e: FederationError) -> Self {
200        CliError {
201            error: e.to_string(),
202        }
203    }
204}
205
206impl Debug for CliError {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        f.debug_struct("CliError")
209            .field("error", &self.error)
210            .finish()
211    }
212}
213
214impl fmt::Display for CliError {
215    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
216        let json = serde_json::to_value(self).expect("CliError is valid json");
217        let json_as_string =
218            serde_json::to_string_pretty(&json).expect("valid json is serializable");
219        write!(f, "{json_as_string}")
220    }
221}
222
223impl Opts {
224    fn data_dir(&self) -> CliResult<&PathBuf> {
225        self.data_dir
226            .as_ref()
227            .ok_or_cli_msg("`--data-dir=` argument not set.")
228    }
229
230    /// Get and create if doesn't exist the data dir
231    async fn data_dir_create(&self) -> CliResult<&PathBuf> {
232        let dir = self.data_dir()?;
233
234        tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
235
236        Ok(dir)
237    }
238    fn iroh_enable_dht(&self) -> bool {
239        self.iroh_enable_dht.unwrap_or(true)
240    }
241
242    fn iroh_enable_next(&self) -> bool {
243        self.iroh_enable_next.unwrap_or(true)
244    }
245
246    fn use_tor(&self) -> bool {
247        #[cfg(feature = "tor")]
248        return self.use_tor;
249        #[cfg(not(feature = "tor"))]
250        false
251    }
252
253    async fn admin_client(
254        &self,
255        peer_urls: &BTreeMap<PeerId, SafeUrl>,
256        api_secret: Option<&str>,
257    ) -> CliResult<DynGlobalApi> {
258        self.admin_client_with_db(peer_urls, api_secret, None).await
259    }
260
261    async fn admin_client_with_db(
262        &self,
263        peer_urls: &BTreeMap<PeerId, SafeUrl>,
264        api_secret: Option<&str>,
265        db: Option<&Database>,
266    ) -> CliResult<DynGlobalApi> {
267        // First try CLI argument, then stored credentials
268        let our_id = if let Some(id) = self.our_id {
269            id
270        } else if let Some(db) = db {
271            if let Some(stored_creds) = load_admin_creds(db).await {
272                stored_creds.peer_id
273            } else {
274                return Err(CliError {
275                    error: "Admin client needs our-id set (no stored credentials found)"
276                        .to_string(),
277                });
278            }
279        } else {
280            return Err(CliError {
281                error: "Admin client needs our-id set".to_string(),
282            });
283        };
284
285        DynGlobalApi::new_admin(
286            self.make_endpoints().await.map_err(|e| CliError {
287                error: e.to_string(),
288            })?,
289            our_id,
290            peer_urls
291                .get(&our_id)
292                .cloned()
293                .context("Our peer URL not found in config")
294                .map_err_cli()?,
295            api_secret,
296        )
297        .map_err_cli()
298    }
299
300    async fn make_endpoints(&self) -> Result<ConnectorRegistry, anyhow::Error> {
301        ConnectorRegistry::build_from_client_defaults()
302            .iroh_next(self.iroh_enable_next())
303            .iroh_pkarr_dht(self.iroh_enable_dht())
304            .ws_force_tor(self.use_tor())
305            .bind()
306            .await
307    }
308
309    fn auth(&self) -> CliResult<ApiAuth> {
310        let password = self
311            .password
312            .clone()
313            .ok_or_cli_msg("CLI needs password set")?;
314        Ok(ApiAuth::new(password))
315    }
316
317    async fn load_database(&self) -> CliResult<Database> {
318        debug!(target: LOG_CLIENT, "Loading client database");
319        let db_path = self.data_dir_create().await?.join("client.db");
320        match self.db_backend {
321            DatabaseBackend::RocksDb => {
322                debug!(target: LOG_CLIENT, "Using RocksDB database backend");
323                Ok(fedimint_rocksdb::RocksDb::build(db_path)
324                    .open()
325                    .await
326                    .map_err_cli_msg("could not open rocksdb database")?
327                    .into())
328            }
329            DatabaseBackend::CursedRedb => {
330                debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
331                Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
332                    .await
333                    .map_err_cli_msg("could not open cursed redb database")?
334                    .into())
335            }
336        }
337    }
338}
339
340fn decode_federation_secret_hex(federation_secret_hex: &str) -> CliResult<DerivableSecret> {
341    <DerivableSecret as Decodable>::consensus_decode_hex(
342        federation_secret_hex,
343        &ModuleRegistry::default(),
344    )
345    .map_err_cli_msg("invalid federation secret hex")
346}
347
348enum RecoverySecret {
349    Mnemonic(Mnemonic),
350    FederationSecret(DerivableSecret),
351}
352
353fn root_secret_from_mnemonic(mnemonic: &Mnemonic) -> RootSecret {
354    RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(mnemonic))
355}
356
357async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
358    Ok(
359        if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
360            Mnemonic::from_entropy(&entropy).map_err_cli()?
361        } else {
362            debug!(
363                target: LOG_CLIENT,
364                "Generating mnemonic and writing entropy to client storage"
365            );
366            let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
367            Client::store_encodable_client_secret(db, mnemonic.to_entropy())
368                .await
369                .map_err_cli()?;
370            mnemonic
371        },
372    )
373}
374
375pub struct FedimintCli {
376    module_inits: ClientModuleInitRegistry,
377    cli_args: Opts,
378}
379
380impl FedimintCli {
381    /// Build a new `fedimintd` with a custom version hash
382    pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
383        assert_eq!(
384            fedimint_build_code_version_env!().len(),
385            version_hash.len(),
386            "version_hash must have an expected length"
387        );
388
389        handle_version_hash_command(version_hash);
390
391        let cli_args = Opts::parse();
392        let base_level = if cli_args.verbose { "debug" } else { "info" };
393        TracingSetup::default()
394            .with_base_level(base_level)
395            .init()
396            .expect("tracing initializes");
397
398        let version = env!("CARGO_PKG_VERSION");
399        debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
400
401        Ok(Self {
402            module_inits: ClientModuleInitRegistry::new(),
403            cli_args,
404        })
405    }
406
407    pub fn with_module<T>(mut self, r#gen: T) -> Self
408    where
409        T: ClientModuleInit + 'static + Send + Sync,
410    {
411        self.module_inits.attach(r#gen);
412        self
413    }
414
415    pub fn with_default_modules(self) -> Self {
416        self.with_module(LightningClientInit::default())
417            .with_module(MintClientInit)
418            .with_module(fedimint_mintv2_client::MintClientInit)
419            .with_module(WalletClientInit::default())
420            .with_module(MetaClientInit)
421            .with_module(fedimint_lnv2_client::LightningClientInit::default())
422            .with_module(fedimint_walletv2_client::WalletClientInit)
423    }
424
425    pub async fn run(&mut self) {
426        match self.handle_command(self.cli_args.clone()).await {
427            Ok(output) => {
428                // ignore if there's anyone reading the stuff we're writing out
429                let _ = writeln!(std::io::stdout(), "{output}");
430            }
431            Err(err) => {
432                debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
433                let _ = writeln!(std::io::stdout(), "{err}");
434                exit(1);
435            }
436        }
437    }
438
439    async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
440        let mut client_builder = Client::builder()
441            .await
442            .map_err_cli()?
443            .with_iroh_enable_dht(cli.iroh_enable_dht())
444            .with_iroh_enable_next(cli.iroh_enable_next());
445        client_builder.with_module_inits(self.module_inits.clone());
446
447        let db = cli.load_database().await?;
448        Ok((client_builder, db))
449    }
450
451    async fn client_join(
452        &mut self,
453        cli: &Opts,
454        invite_code: InviteCode,
455    ) -> CliResult<ClientHandleArc> {
456        let (client_builder, db) = self.make_client_builder(cli).await?;
457
458        let mnemonic = load_or_generate_mnemonic(&db).await?;
459
460        let client = client_builder
461            .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
462            .await
463            .map_err_cli()?
464            .join(db, root_secret_from_mnemonic(&mnemonic))
465            .await
466            .map(Arc::new)
467            .map_err_cli()?;
468
469        print_welcome_message(&client).await;
470        log_expiration_notice(&client).await;
471
472        Ok(client)
473    }
474
475    async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
476        let (mut client_builder, db) = self.make_client_builder(cli).await?;
477
478        // Use CLI args if provided, otherwise try to load stored credentials
479        if let Some(our_id) = cli.our_id {
480            client_builder.set_admin_creds(AdminCreds {
481                peer_id: our_id,
482                auth: cli.auth()?,
483            });
484        } else if let Some(stored_creds) = load_admin_creds(&db).await {
485            debug!(target: LOG_CLIENT, "Using stored admin credentials");
486            client_builder.set_admin_creds(AdminCreds {
487                peer_id: stored_creds.peer_id,
488                auth: ApiAuth::new(stored_creds.auth),
489            });
490        }
491
492        let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
493            .await
494            .map_err_cli()?;
495
496        let root_secret = match (cli.federation_secret_hex.as_deref(), existing_mnemonic) {
497            (Some(_), Some(_)) => {
498                return Err(CliError {
499                    error: "client secret is already set in DB; --federation-secret-hex open requires a client DB without any stored secret".to_owned(),
500                });
501            }
502            (Some(federation_secret_hex), None) => {
503                RootSecret::Custom(decode_federation_secret_hex(federation_secret_hex)?)
504            }
505            (None, Some(entropy)) => {
506                let mnemonic = Mnemonic::from_entropy(&entropy).map_err_cli()?;
507                root_secret_from_mnemonic(&mnemonic)
508            }
509            (None, None) => {
510                return Err(CliError {
511                    error: "Encoded client secret not present in DB".to_owned(),
512                });
513            }
514        };
515
516        let client = client_builder
517            .open(cli.make_endpoints().await.map_err_cli()?, db, root_secret)
518            .await
519            .map(Arc::new)
520            .map_err_cli()?;
521
522        log_expiration_notice(&client).await;
523
524        Ok(client)
525    }
526
527    async fn client_recover(
528        &mut self,
529        cli: &Opts,
530        recovery_secret: RecoverySecret,
531        invite_code: InviteCode,
532    ) -> CliResult<ClientHandleArc> {
533        let (builder, db) = self.make_client_builder(cli).await?;
534        let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
535            .await
536            .map_err_cli()?;
537
538        let root_secret = match (recovery_secret, existing_mnemonic) {
539            (RecoverySecret::Mnemonic(mnemonic), Some(existing)) => {
540                if existing != mnemonic.to_entropy() {
541                    Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
542                }
543
544                root_secret_from_mnemonic(&mnemonic)
545            }
546            (RecoverySecret::Mnemonic(mnemonic), None) => {
547                Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
548                    .await
549                    .map_err_cli()?;
550                root_secret_from_mnemonic(&mnemonic)
551            }
552            (RecoverySecret::FederationSecret(federation_secret), None) => {
553                RootSecret::Custom(federation_secret)
554            }
555            (RecoverySecret::FederationSecret(_), Some(_)) => {
556                return Err(CliError {
557                    error: "client secret is already set in DB; --federation-secret-hex restore requires a client DB without any stored secret".to_owned(),
558                });
559            }
560        };
561
562        let preview = builder
563            .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
564            .await
565            .map_err_cli()?;
566
567        #[allow(deprecated)]
568        let backup = preview
569            .download_backup_from_federation(root_secret.clone())
570            .await
571            .map_err_cli()?;
572
573        let client = preview
574            .recover(db, root_secret, backup)
575            .await
576            .map(Arc::new)
577            .map_err_cli()?;
578
579        print_welcome_message(&client).await;
580        log_expiration_notice(&client).await;
581
582        Ok(client)
583    }
584
585    async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
586        if cli.federation_secret_hex.is_some() && matches!(&cli.command, Command::Join { .. }) {
587            return Err(CliError {
588                error: "--federation-secret-hex cannot be used with join".to_owned(),
589            });
590        }
591
592        match cli.command.clone() {
593            Command::InviteCode { peer } => {
594                let client = self.client_open(&cli).await?;
595
596                let invite_code = client
597                    .invite_code(peer)
598                    .await
599                    .ok_or_cli_msg("peer not found")?;
600
601                Ok(CliOutput::InviteCode { invite_code })
602            }
603            Command::Join { invite_code } => {
604                {
605                    let invite_code: InviteCode = InviteCode::from_str(&invite_code)
606                        .map_err_cli_msg("invalid invite code")?;
607
608                    // Build client and store config in DB
609                    let _client = self.client_join(&cli, invite_code).await?;
610                }
611
612                Ok(CliOutput::Join {
613                    joined: invite_code,
614                })
615            }
616            Command::VersionHash => Ok(CliOutput::VersionHash {
617                hash: fedimint_build_code_version_env!().to_string(),
618            }),
619            Command::Client(ClientCmd::Restore {
620                mnemonic,
621                invite_code,
622            }) => {
623                let invite_code: InviteCode =
624                    InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
625                let recovery_secret = match (
626                    mnemonic.as_deref(),
627                    cli.federation_secret_hex.as_deref(),
628                ) {
629                    (Some(_), Some(_)) => {
630                        return Err(CliError {
631                            error: "restore accepts either --mnemonic or --federation-secret-hex, not both".to_owned(),
632                        });
633                    }
634                    (Some(mnemonic), None) => {
635                        let mnemonic = Mnemonic::from_str(mnemonic).map_err_cli()?;
636                        RecoverySecret::Mnemonic(mnemonic)
637                    }
638                    (None, Some(federation_secret_hex)) => {
639                        let federation_secret =
640                            decode_federation_secret_hex(federation_secret_hex)?;
641                        RecoverySecret::FederationSecret(federation_secret)
642                    }
643                    (None, None) => {
644                        return Err(CliError {
645                            error: "restore requires either --mnemonic or --federation-secret-hex"
646                                .to_owned(),
647                        });
648                    }
649                };
650                let client = self
651                    .client_recover(&cli, recovery_secret, invite_code)
652                    .await?;
653
654                // TODO: until we implement recovery for other modules we can't really wait
655                // for more than this one
656                debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
657                client.wait_for_all_recoveries().await.map_err_cli()?;
658
659                debug!(target: LOG_CLIENT, "Recovery complete");
660
661                Ok(CliOutput::Raw(
662                    serde_json::to_value(()).expect("unit type is serializable"),
663                ))
664            }
665            Command::Client(command) => {
666                let client = self.client_open(&cli).await?;
667                Ok(CliOutput::Raw(
668                    client::handle_command(command, client)
669                        .await
670                        .map_err_cli()?,
671                ))
672            }
673            Command::Admin(AdminCmd::Auth {
674                peer_id,
675                password,
676                no_verify,
677                force,
678            }) => {
679                let db = cli.load_database().await?;
680                let peer_id = PeerId::from(peer_id);
681                let auth = ApiAuth::new(password);
682
683                // Check if credentials already exist
684                if !force {
685                    let existing = load_admin_creds(&db).await;
686                    if existing.is_some() {
687                        return Err(CliError {
688                            error: "Admin credentials already stored. Use --force to overwrite."
689                                .to_string(),
690                        });
691                    }
692                }
693
694                // Load client config to get peer endpoints
695                let config = Client::get_config_from_db(&db)
696                    .await
697                    .ok_or_cli_msg("Client not initialized. Please join a federation first.")?;
698
699                // Get the endpoint for the specified peer
700                let peer_url =
701                    config
702                        .global
703                        .api_endpoints
704                        .get(&peer_id)
705                        .ok_or_else(|| CliError {
706                            error: format!(
707                                "Peer ID {} not found in federation. Valid peer IDs are: {:?}",
708                                peer_id,
709                                config.global.api_endpoints.keys().collect::<Vec<_>>()
710                            ),
711                        })?;
712
713                // Interactive verification unless --no-verify is set
714                if !no_verify {
715                    // Check if stdin is a terminal for interactive prompts
716                    if !std::io::stdin().is_terminal() {
717                        return Err(CliError {
718                            error: "Interactive verification requires a terminal. Use --no-verify to skip.".to_string(),
719                        });
720                    }
721
722                    eprintln!("Guardian endpoint for peer {}: {}", peer_id, peer_url.url);
723                    eprint!("Does this look correct? (y/N): ");
724                    std::io::stderr().flush().map_err_cli()?;
725
726                    let mut input = String::new();
727                    std::io::stdin().read_line(&mut input).map_err_cli()?;
728                    let input = input.trim().to_lowercase();
729
730                    if input != "y" && input != "yes" {
731                        return Err(CliError {
732                            error: "Endpoint verification cancelled by user.".to_string(),
733                        });
734                    }
735                }
736
737                // Verify credentials by making an authenticated API call
738                eprintln!("Verifying credentials...");
739                let admin_api = DynGlobalApi::new_admin(
740                    cli.make_endpoints().await.map_err_cli()?,
741                    peer_id,
742                    peer_url.url.clone(),
743                    db.begin_transaction_nc()
744                        .await
745                        .get_value(&ApiSecretKey)
746                        .await
747                        .as_deref(),
748                )
749                .map_err_cli()?;
750
751                // Use the `auth` endpoint to verify credentials
752                admin_api.auth(auth.clone()).await.map_err(|e| CliError {
753                    error: format!(
754                        "Failed to verify credentials: {e}. Please check your peer ID and password."
755                    ),
756                })?;
757
758                // Store the credentials in the database
759                store_admin_creds(
760                    &db,
761                    &StoredAdminCreds {
762                        peer_id,
763                        auth: auth.as_str().to_string(),
764                    },
765                )
766                .await;
767
768                eprintln!("Admin credentials verified and saved successfully.");
769                Ok(CliOutput::Raw(json!({
770                    "peer_id": peer_id,
771                    "endpoint": peer_url.url.to_string(),
772                    "status": "saved"
773                })))
774            }
775            Command::Admin(AdminCmd::Audit) => {
776                let client = self.client_open(&cli).await?;
777
778                let audit = cli
779                    .admin_client(
780                        &client.get_peer_urls().await,
781                        client.api_secret().as_deref(),
782                    )
783                    .await?
784                    .audit(cli.auth()?)
785                    .await?;
786                Ok(CliOutput::Raw(
787                    serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
788                ))
789            }
790            Command::Admin(AdminCmd::Status) => {
791                let client = self.client_open(&cli).await?;
792
793                let status = cli
794                    .admin_client_with_db(
795                        &client.get_peer_urls().await,
796                        client.api_secret().as_deref(),
797                        Some(client.db()),
798                    )
799                    .await?
800                    .status()
801                    .await?;
802                Ok(CliOutput::Raw(
803                    serde_json::to_value(status).map_err_cli_msg("invalid response")?,
804                ))
805            }
806            Command::Admin(AdminCmd::GuardianConfigBackup) => {
807                let client = self.client_open(&cli).await?;
808
809                let guardian_config_backup = cli
810                    .admin_client(
811                        &client.get_peer_urls().await,
812                        client.api_secret().as_deref(),
813                    )
814                    .await?
815                    .guardian_config_backup(cli.auth()?)
816                    .await?;
817                Ok(CliOutput::Raw(
818                    serde_json::to_value(guardian_config_backup)
819                        .map_err_cli_msg("invalid response")?,
820                ))
821            }
822            Command::Admin(AdminCmd::Setup(dkg_args)) => self
823                .handle_admin_setup_command(cli, dkg_args)
824                .await
825                .map(CliOutput::Raw)
826                .map_err_cli_msg("Config Gen Error"),
827            Command::Admin(AdminCmd::SignApiAnnouncement {
828                api_url,
829                override_url,
830            }) => {
831                let client = self.client_open(&cli).await?;
832
833                if !["ws", "wss"].contains(&api_url.scheme()) {
834                    return Err(CliError {
835                        error: format!(
836                            "Unsupported URL scheme {}, use ws:// or wss://",
837                            api_url.scheme()
838                        ),
839                    });
840                }
841
842                let announcement = cli
843                    .admin_client(
844                        &override_url
845                            .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
846                            .unwrap_or(client.get_peer_urls().await),
847                        client.api_secret().as_deref(),
848                    )
849                    .await?
850                    .sign_api_announcement(api_url, cli.auth()?)
851                    .await?;
852
853                Ok(CliOutput::Raw(
854                    serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
855                ))
856            }
857            Command::Admin(AdminCmd::SignGuardianMetadata { api_urls, pkarr_id }) => {
858                let client = self.client_open(&cli).await?;
859
860                let metadata = fedimint_core::net::guardian_metadata::GuardianMetadata {
861                    api_urls,
862                    pkarr_id_z32: pkarr_id,
863                    timestamp_secs: fedimint_core::time::duration_since_epoch().as_secs(),
864                };
865
866                let signed_metadata = cli
867                    .admin_client(
868                        &client.get_peer_urls().await,
869                        client.api_secret().as_deref(),
870                    )
871                    .await?
872                    .sign_guardian_metadata(metadata, cli.auth()?)
873                    .await?;
874
875                Ok(CliOutput::Raw(
876                    serde_json::to_value(signed_metadata).map_err_cli_msg("invalid response")?,
877                ))
878            }
879            Command::Admin(AdminCmd::Shutdown { session_idx }) => {
880                let client = self.client_open(&cli).await?;
881
882                cli.admin_client(
883                    &client.get_peer_urls().await,
884                    client.api_secret().as_deref(),
885                )
886                .await?
887                .shutdown(Some(session_idx), cli.auth()?)
888                .await?;
889
890                Ok(CliOutput::Raw(json!(null)))
891            }
892            Command::Admin(AdminCmd::BackupStatistics) => {
893                let client = self.client_open(&cli).await?;
894
895                let backup_statistics = cli
896                    .admin_client(
897                        &client.get_peer_urls().await,
898                        client.api_secret().as_deref(),
899                    )
900                    .await?
901                    .backup_statistics(cli.auth()?)
902                    .await?;
903
904                Ok(CliOutput::Raw(
905                    serde_json::to_value(backup_statistics).expect("Can be encoded"),
906                ))
907            }
908            Command::Admin(AdminCmd::ChangePassword { new_password }) => {
909                let client = self.client_open(&cli).await?;
910
911                cli.admin_client(
912                    &client.get_peer_urls().await,
913                    client.api_secret().as_deref(),
914                )
915                .await?
916                .change_password(cli.auth()?, &new_password)
917                .await?;
918
919                warn!(target: LOG_CLIENT, "Password changed, please restart fedimintd manually");
920
921                Ok(CliOutput::Raw(json!(null)))
922            }
923            Command::Dev(DevCmd::Api {
924                method,
925                params,
926                peer_id,
927                password: auth,
928                module,
929            }) => {
930                //Parse params to JSON.
931                //If fails, convert to JSON string.
932                let params = serde_json::from_str::<Value>(&params).unwrap_or_else(|err| {
933                    debug!(
934                        target: LOG_CLIENT,
935                        "Failed to serialize params:{}. Converting it to JSON string",
936                        err
937                    );
938
939                    serde_json::Value::String(params)
940                });
941
942                let mut params = ApiRequestErased::new(params);
943                if let Some(auth) = auth {
944                    params = params.with_auth(ApiAuth::new(auth));
945                }
946                let client = self.client_open(&cli).await?;
947
948                let api = client.api_clone();
949
950                let module_api = match module {
951                    Some(selector) => {
952                        Some(api.with_module(selector.resolve(&client).map_err_cli()?))
953                    }
954                    None => None,
955                };
956
957                let response: Value = match (peer_id, module_api) {
958                    (Some(peer_id), Some(module_api)) => module_api
959                        .request_raw(peer_id.into(), &method, &params)
960                        .await
961                        .map_err_cli()?,
962                    (Some(peer_id), None) => api
963                        .request_raw(peer_id.into(), &method, &params)
964                        .await
965                        .map_err_cli()?,
966                    (None, Some(module_api)) => module_api
967                        .request_current_consensus(method, params)
968                        .await
969                        .map_err_cli()?,
970                    (None, None) => api
971                        .request_current_consensus(method, params)
972                        .await
973                        .map_err_cli()?,
974                };
975
976                Ok(CliOutput::UntypedApiOutput { value: response })
977            }
978            Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
979                let client = self.client_open(&cli).await?;
980
981                let mint = client
982                    .get_first_module::<MintClientModule>()
983                    .map_err_cli_msg("can't get mint module")?;
984
985                for _ in 0..count {
986                    mint.advance_note_idx(amount)
987                        .await
988                        .map_err_cli_msg("failed to advance the note_idx")?;
989                }
990
991                Ok(CliOutput::Raw(serde_json::Value::Null))
992            }
993            Command::Dev(DevCmd::ApiAnnouncements) => {
994                let client = self.client_open(&cli).await?;
995                let announcements = client.get_peer_url_announcements().await;
996                Ok(CliOutput::Raw(
997                    serde_json::to_value(announcements).expect("Can be encoded"),
998                ))
999            }
1000            Command::Dev(DevCmd::GuardianMetadata) => {
1001                let client = self.client_open(&cli).await?;
1002                let metadata = client.get_guardian_metadata().await;
1003                Ok(CliOutput::Raw(
1004                    serde_json::to_value(metadata).expect("Can be encoded"),
1005                ))
1006            }
1007            Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1008                "wait_block_count",
1009                backoff_util::custom_backoff(
1010                    Duration::from_millis(100),
1011                    Duration::from_secs(5),
1012                    None,
1013                ),
1014                || async {
1015                    let client = self.client_open(&cli).await?;
1016                    let wallet = client.get_first_module::<WalletClientModule>()?;
1017                    let count = client
1018                        .api()
1019                        .with_module(wallet.id)
1020                        .fetch_consensus_block_count()
1021                        .await?;
1022                    if count >= target {
1023                        Ok(CliOutput::WaitBlockCount { reached: count })
1024                    } else {
1025                        info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1026                        Err(format_err!("target not reached"))
1027                    }
1028                },
1029            )
1030            .await
1031            .map_err_cli(),
1032
1033            Command::Dev(DevCmd::WaitComplete) => {
1034                let client = self.client_open(&cli).await?;
1035                client
1036                    .wait_for_all_active_state_machines()
1037                    .await
1038                    .map_err_cli_msg("failed to wait for all active state machines")?;
1039                Ok(CliOutput::Raw(serde_json::Value::Null))
1040            }
1041            Command::Dev(DevCmd::Wait { seconds }) => {
1042                let client = self.client_open(&cli).await?;
1043                // Since most callers are `wait`ing for something to happen,
1044                // let's trigger a network call, so any background threads
1045                // waiting for it starts doing their job.
1046                client
1047                    .task_group()
1048                    .spawn_cancellable("fedimint-cli dev wait: init networking", {
1049                        let client = client.clone();
1050                        async move {
1051                            let _ = client.api().session_count().await;
1052                        }
1053                    });
1054
1055                if let Some(secs) = seconds {
1056                    runtime::sleep(Duration::from_secs_f32(secs)).await;
1057                } else {
1058                    pending::<()>().await;
1059                }
1060                Ok(CliOutput::Raw(serde_json::Value::Null))
1061            }
1062            Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1063                DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1064                    url: invite_code.url(),
1065                    federation_id: invite_code.federation_id(),
1066                }),
1067                DecodeType::Notes { notes, file } => {
1068                    let notes = if let Some(notes) = notes {
1069                        notes
1070                    } else if let Some(file) = file {
1071                        let notes_str =
1072                            fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1073                        OOBNotes::from_str(&notes_str).map_err_cli_msg("failed to decode notes")?
1074                    } else {
1075                        unreachable!("Clap enforces either notes or file being set");
1076                    };
1077
1078                    let notes_json = notes
1079                        .notes_json()
1080                        .map_err_cli_msg("failed to decode notes")?;
1081                    Ok(CliOutput::Raw(notes_json))
1082                }
1083                DecodeType::Transaction { hex_string } => {
1084                    let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1085                        .map_err_cli_msg("failed to decode transaction")?;
1086
1087                    let client = self.client_open(&cli).await?;
1088                    let tx = fedimint_core::transaction::Transaction::from_bytes(
1089                        &bytes,
1090                        client.decoders(),
1091                    )
1092                    .map_err_cli_msg("failed to decode transaction")?;
1093
1094                    Ok(CliOutput::DecodeTransaction {
1095                        transaction: (format!("{tx:?}")),
1096                    })
1097                }
1098                DecodeType::SetupCode { setup_code } => {
1099                    let setup_code = base32::decode_prefixed(FEDIMINT_PREFIX, &setup_code)
1100                        .map_err_cli_msg("failed to decode setup code")?;
1101
1102                    Ok(CliOutput::SetupCode { setup_code })
1103                }
1104            },
1105            Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1106                EncodeType::InviteCode {
1107                    url,
1108                    federation_id,
1109                    peer,
1110                    api_secret,
1111                } => Ok(CliOutput::InviteCode {
1112                    invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1113                }),
1114                EncodeType::Notes { notes_json } => {
1115                    let notes = serde_json::from_str::<OOBNotesJson>(&notes_json)
1116                        .map_err_cli_msg("invalid JSON for notes")?;
1117                    let prefix =
1118                        FederationIdPrefix::from_str(&notes.federation_id_prefix).map_err_cli()?;
1119                    let notes = OOBNotes::new(prefix, notes.notes);
1120                    Ok(CliOutput::Raw(notes.to_string().into()))
1121                }
1122            },
1123            Command::Dev(DevCmd::SessionCount) => {
1124                let client = self.client_open(&cli).await?;
1125                let count = client.api().session_count().await?;
1126                Ok(CliOutput::EpochCount { count })
1127            }
1128            Command::Dev(DevCmd::Config) => {
1129                let client = self.client_open(&cli).await?;
1130                let config = client.get_config_json().await;
1131                Ok(CliOutput::Raw(
1132                    serde_json::to_value(config).expect("Client config is serializable"),
1133                ))
1134            }
1135            Command::Dev(DevCmd::ConfigDecrypt {
1136                in_file,
1137                out_file,
1138                salt_file,
1139                password,
1140            }) => {
1141                let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1142                let salt = fs::read_to_string(salt_file).map_err_cli()?;
1143                let key = get_encryption_key(&password, &salt).map_err_cli()?;
1144                let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1145
1146                let mut out_file_handle = fs::File::options()
1147                    .create_new(true)
1148                    .write(true)
1149                    .open(out_file)
1150                    .expect("Could not create output cfg file");
1151                out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1152                Ok(CliOutput::ConfigDecrypt)
1153            }
1154            Command::Dev(DevCmd::ConfigEncrypt {
1155                in_file,
1156                out_file,
1157                salt_file,
1158                password,
1159            }) => {
1160                let mut in_file_handle =
1161                    fs::File::open(in_file).expect("Could not create output cfg file");
1162                let mut plaintext_bytes = vec![];
1163                in_file_handle
1164                    .read_to_end(&mut plaintext_bytes)
1165                    .expect("Could not read input cfg file");
1166
1167                let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1168                let salt = fs::read_to_string(salt_file).map_err_cli()?;
1169                let key = get_encryption_key(&password, &salt).map_err_cli()?;
1170                encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1171                Ok(CliOutput::ConfigEncrypt)
1172            }
1173            Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1174                #[derive(Serialize)]
1175                struct ReactorLogState {
1176                    active: bool,
1177                    module_instance: ModuleInstanceId,
1178                    creation_time: String,
1179                    #[serde(skip_serializing_if = "Option::is_none")]
1180                    end_time: Option<String>,
1181                    state: String,
1182                }
1183
1184                let client = self.client_open(&cli).await?;
1185
1186                let (active_states, inactive_states) =
1187                    client.executor().get_operation_states(operation_id).await;
1188                let all_states =
1189                    active_states
1190                        .into_iter()
1191                        .map(|(active_state, active_meta)| ReactorLogState {
1192                            active: true,
1193                            module_instance: active_state.module_instance_id(),
1194                            creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1195                            end_time: None,
1196                            state: format!("{active_state:?}",),
1197                        })
1198                        .chain(inactive_states.into_iter().map(
1199                            |(inactive_state, inactive_meta)| ReactorLogState {
1200                                active: false,
1201                                module_instance: inactive_state.module_instance_id(),
1202                                creation_time: crate::client::time_to_iso8601(
1203                                    &inactive_meta.created_at,
1204                                ),
1205                                end_time: Some(crate::client::time_to_iso8601(
1206                                    &inactive_meta.exited_at,
1207                                )),
1208                                state: format!("{inactive_state:?}",),
1209                            },
1210                        ))
1211                        .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1212                        .collect::<Vec<_>>();
1213
1214                Ok(CliOutput::Raw(json!({
1215                    "states": all_states
1216                })))
1217            }
1218            Command::Dev(DevCmd::MetaFields) => {
1219                let client = self.client_open(&cli).await?;
1220                let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1221
1222                let meta_fields = source
1223                    .fetch(
1224                        &client.config().await,
1225                        &client.api_clone(),
1226                        FetchKind::Initial,
1227                        None,
1228                    )
1229                    .await
1230                    .map_err_cli()?;
1231
1232                Ok(CliOutput::Raw(
1233                    serde_json::to_value(meta_fields).expect("Can be encoded"),
1234                ))
1235            }
1236            Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1237                let client = self.client_open(&cli).await?;
1238                let version = client
1239                    .api()
1240                    .fedimintd_version(peer_id.into())
1241                    .await
1242                    .map_err_cli()?;
1243
1244                Ok(CliOutput::Raw(json!({ "version": version })))
1245            }
1246            Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1247                let client = self.client_open(&cli).await?;
1248
1249                let events: Vec<_> = client
1250                    .get_event_log(pos, limit)
1251                    .await
1252                    .into_iter()
1253                    .map(|v| {
1254                        let id = v.id();
1255                        let v = v.as_raw();
1256                        let module_id = v.module.as_ref().map(|m| m.1);
1257                        let module_kind = v.module.as_ref().map(|m| m.0.clone());
1258                        serde_json::json!({
1259                            "id": id,
1260                            "kind": v.kind,
1261                            "module_kind": module_kind,
1262                            "module_id": module_id,
1263                            "ts": v.ts_usecs,
1264                            "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1265                        })
1266                    })
1267                    .collect();
1268
1269                Ok(CliOutput::Raw(
1270                    serde_json::to_value(events).expect("Can be encoded"),
1271                ))
1272            }
1273            Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1274                let client = self.client_open(&cli).await?;
1275
1276                let events: Vec<_> = client
1277                    .get_event_log_trimable(
1278                        pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1279                        limit,
1280                    )
1281                    .await
1282                    .into_iter()
1283                    .map(|v| {
1284                        let id = v.id();
1285                        let v = v.as_raw();
1286                        let module_id = v.module.as_ref().map(|m| m.1);
1287                        let module_kind = v.module.as_ref().map(|m| m.0.clone());
1288                        serde_json::json!({
1289                            "id": id,
1290                            "kind": v.kind,
1291                            "module_kind": module_kind,
1292                            "module_id": module_id,
1293                            "ts": v.ts_usecs,
1294                            "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1295                        })
1296                    })
1297                    .collect();
1298
1299                Ok(CliOutput::Raw(
1300                    serde_json::to_value(events).expect("Can be encoded"),
1301                ))
1302            }
1303            Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1304                let client = self.client_open(&cli).await?;
1305                let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1306                    .map_err_cli()?;
1307                let tx_outcome = client
1308                    .api()
1309                    .submit_transaction(tx)
1310                    .await
1311                    .try_into_inner(client.decoders())
1312                    .map_err_cli()?;
1313
1314                Ok(CliOutput::Raw(
1315                    serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1316                ))
1317            }
1318            Command::Dev(DevCmd::TestEventLogHandling) => {
1319                let client = self.client_open(&cli).await?;
1320
1321                client
1322                    .handle_events(
1323                        client.built_in_application_event_log_tracker(),
1324                        move |_dbtx, event| {
1325                            Box::pin(async move {
1326                                info!(target: LOG_CLIENT, "{event:?}");
1327
1328                                Ok(())
1329                            })
1330                        },
1331                    )
1332                    .await
1333                    .map_err_cli()?;
1334                unreachable!(
1335                    "handle_events exits only if client shuts down, which we don't do here"
1336                )
1337            }
1338            Command::Dev(DevCmd::Panic) => {
1339                panic!("This panic is intentional for testing backtrace handling");
1340            }
1341            Command::Dev(DevCmd::ChainId) => {
1342                let client = self.client_open(&cli).await?;
1343                let chain_id = client
1344                    .db()
1345                    .begin_transaction_nc()
1346                    .await
1347                    .get_value(&fedimint_client::db::ChainIdKey)
1348                    .await
1349                    .ok_or_cli_msg("Chain ID not cached in client database")?;
1350
1351                Ok(CliOutput::Raw(serde_json::json!({
1352                    "chain_id": chain_id.to_string()
1353                })))
1354            }
1355            Command::Dev(DevCmd::Visualize { visualize_type }) => {
1356                let client = self.client_open(&cli).await?;
1357
1358                match visualize_type {
1359                    VisualizeCmd::Notes { limit } => {
1360                        visualize::cmd_notes(&client, limit).await?;
1361                    }
1362                    VisualizeCmd::Transactions {
1363                        operation_id,
1364                        limit,
1365                    } => {
1366                        visualize::cmd_transactions(&client, operation_id, limit).await?;
1367                    }
1368                    VisualizeCmd::Operations {
1369                        operation_id,
1370                        limit,
1371                    } => {
1372                        visualize::cmd_operations(&client, operation_id, limit).await?;
1373                    }
1374                }
1375                Ok(CliOutput::Raw(json!({})))
1376            }
1377            Command::Completion { shell } => {
1378                let bin_path = PathBuf::from(
1379                    std::env::args_os()
1380                        .next()
1381                        .expect("Binary name is always provided if we get this far"),
1382                );
1383                let bin_name = bin_path
1384                    .file_name()
1385                    .expect("path has file name")
1386                    .to_string_lossy();
1387                clap_complete::generate(
1388                    shell,
1389                    &mut Opts::command(),
1390                    bin_name.as_ref(),
1391                    &mut std::io::stdout(),
1392                );
1393                // HACK: prints true to stdout which is fine for shells
1394                Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1395            }
1396        }
1397    }
1398
1399    async fn handle_admin_setup_command(
1400        &self,
1401        cli: Opts,
1402        args: SetupAdminArgs,
1403    ) -> anyhow::Result<Value> {
1404        let client =
1405            DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone())?;
1406
1407        match &args.subcommand {
1408            SetupAdminCmd::Status => {
1409                let status = client.setup_status(cli.auth()?).await?;
1410
1411                Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1412            }
1413            SetupAdminCmd::SetLocalParams {
1414                name,
1415                federation_name,
1416                federation_size,
1417            } => {
1418                let info = client
1419                    .set_local_params(
1420                        name.clone(),
1421                        federation_name.clone(),
1422                        None,
1423                        None,
1424                        *federation_size,
1425                        cli.auth()?,
1426                    )
1427                    .await?;
1428
1429                Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1430            }
1431            SetupAdminCmd::AddPeer { info } => {
1432                let name = client
1433                    .add_peer_connection_info(info.clone(), cli.auth()?)
1434                    .await?;
1435
1436                Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1437            }
1438            SetupAdminCmd::StartDkg => {
1439                client.start_dkg(cli.auth()?).await?;
1440
1441                Ok(Value::Null)
1442            }
1443        }
1444    }
1445}
1446
1447async fn log_expiration_notice(client: &Client) {
1448    client.get_meta_expiration_timestamp().await;
1449    if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1450        match expiration_time.duration_since(fedimint_core::time::now()) {
1451            Ok(until_expiration) => {
1452                let days = until_expiration.as_secs() / (60 * 60 * 24);
1453
1454                if 90 < days {
1455                    debug!(target: LOG_CLIENT, %days, "This federation will expire");
1456                } else if 30 < days {
1457                    info!(target: LOG_CLIENT, %days, "This federation will expire");
1458                } else {
1459                    warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1460                }
1461            }
1462            Err(_) => {
1463                tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1464            }
1465        }
1466    }
1467}
1468async fn print_welcome_message(client: &Client) {
1469    if let Some(welcome_message) = client
1470        .meta_service()
1471        .get_field::<String>(client.db(), "welcome_message")
1472        .await
1473        .and_then(|v| v.value)
1474    {
1475        eprintln!("{welcome_message}");
1476    }
1477}
1478
1479fn salt_from_file_path(file_path: &Path) -> PathBuf {
1480    file_path
1481        .parent()
1482        .expect("File has no parent?!")
1483        .join(SALT_FILE)
1484}
1485
1486/// Convert clap arguments to backup metadata
1487fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1488    let metadata: BTreeMap<String, String> = metadata
1489        .into_iter()
1490        .map(|item| {
1491            match &item
1492                .splitn(2, '=')
1493                .map(ToString::to_string)
1494                .collect::<Vec<String>>()[..]
1495            {
1496                [] => Err(format_err!("Empty metadata argument not allowed")),
1497                [key] => Err(format_err!("Metadata {key} is missing a value")),
1498                [key, val] => Ok((key.clone(), val.clone())),
1499                [..] => unreachable!(),
1500            }
1501        })
1502        .collect::<anyhow::Result<_>>()
1503        .map_err_cli_msg("invalid metadata")?;
1504    Ok(metadata)
1505}
1506
1507#[test]
1508#[allow(clippy::unwrap_used)]
1509fn metadata_from_clap_cli_test() {
1510    for (args, expected) in [
1511        (
1512            vec!["a=b".to_string()],
1513            BTreeMap::from([("a".into(), "b".into())]),
1514        ),
1515        (
1516            vec!["a=b".to_string(), "c=d".to_string()],
1517            BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1518        ),
1519    ] {
1520        assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1521    }
1522}