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