fedimint_cli/
lib.rs

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