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