Skip to main content

fedimint_cli/
lib.rs

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