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