fedimint_cli/
lib.rs

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