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