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