Skip to main content

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    Join {
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    #[clap(alias = "join-federation")]
425    Join {
426        invite_code: String,
427    },
428
429    Completion {
430        shell: clap_complete::Shell,
431    },
432}
433
434#[allow(clippy::large_enum_variant)]
435#[derive(Debug, Clone, Subcommand)]
436enum AdminCmd {
437    /// Store admin credentials (peer_id and password) in the client database.
438    ///
439    /// This allows subsequent admin commands to be run without specifying
440    /// `--our-id` and `--password` each time.
441    ///
442    /// The command will verify the credentials by making an authenticated
443    /// API call before storing them.
444    Auth {
445        /// Guardian's peer ID
446        #[arg(long, env = FM_OUR_ID_ENV)]
447        peer_id: u16,
448        /// Guardian password for authentication
449        #[arg(long, env = FM_PASSWORD_ENV)]
450        password: String,
451        /// Skip interactive endpoint verification
452        #[arg(long)]
453        no_verify: bool,
454        /// Force overwrite existing stored credentials
455        #[arg(long)]
456        force: bool,
457    },
458
459    /// Show the status according to the `status` endpoint
460    Status,
461
462    /// Show an audit across all modules
463    Audit,
464
465    /// Download guardian config to back it up
466    GuardianConfigBackup,
467
468    Setup(SetupAdminArgs),
469    /// Sign and announce a new API endpoint. The previous one will be
470    /// invalidated
471    SignApiAnnouncement {
472        /// New API URL to announce
473        api_url: SafeUrl,
474        /// Provide the API url for the guardian directly in case the old one
475        /// isn't reachable anymore
476        #[clap(long)]
477        override_url: Option<SafeUrl>,
478    },
479    /// Sign guardian metadata
480    SignGuardianMetadata {
481        /// API URLs (can be specified multiple times or comma-separated)
482        #[clap(long, value_delimiter = ',')]
483        api_urls: Vec<SafeUrl>,
484        /// Pkarr ID (z32 format)
485        #[clap(long)]
486        pkarr_id: String,
487    },
488    /// Stop fedimintd after the specified session to do a coordinated upgrade
489    Shutdown {
490        /// Session index to stop after
491        session_idx: u64,
492    },
493    /// Show statistics about client backups stored by the federation
494    BackupStatistics,
495    /// Change guardian password, will shut down fedimintd and require manual
496    /// restart
497    ChangePassword {
498        /// New password to set
499        new_password: String,
500    },
501}
502
503#[derive(Debug, Clone, Args)]
504struct SetupAdminArgs {
505    endpoint: SafeUrl,
506
507    #[clap(subcommand)]
508    subcommand: SetupAdminCmd,
509}
510
511#[derive(Debug, Clone, Subcommand)]
512enum SetupAdminCmd {
513    Status,
514    SetLocalParams {
515        name: String,
516        #[clap(long)]
517        federation_name: Option<String>,
518        #[clap(long)]
519        federation_size: Option<u32>,
520    },
521    AddPeer {
522        info: String,
523    },
524    StartDkg,
525}
526
527#[derive(Debug, Clone, Subcommand)]
528enum DecodeType {
529    /// Decode an invite code string into a JSON representation
530    InviteCode { invite_code: InviteCode },
531    /// Decode a string of ecash notes into a JSON representation
532    #[group(required = true, multiple = false)]
533    Notes {
534        /// Base64 e-cash notes to be decoded
535        notes: Option<OOBNotes>,
536        /// File containing base64 e-cash notes to be decoded
537        #[arg(long)]
538        file: Option<PathBuf>,
539    },
540    /// Decode a transaction hex string and print it to stdout
541    Transaction { hex_string: String },
542    /// Decode a setup code (as shared during a federation setup ceremony)
543    /// string into a JSON representation
544    SetupCode { setup_code: String },
545}
546
547#[derive(Debug, Clone, Deserialize, Serialize)]
548struct OOBNotesJson {
549    federation_id_prefix: String,
550    notes: TieredMulti<SpendableNote>,
551}
552
553#[derive(Debug, Clone, Subcommand)]
554enum EncodeType {
555    /// Encode connection info from its constituent parts
556    InviteCode {
557        #[clap(long)]
558        url: SafeUrl,
559        #[clap(long = "federation_id")]
560        federation_id: FederationId,
561        #[clap(long = "peer")]
562        peer: PeerId,
563        #[arg(env = FM_API_SECRET_ENV)]
564        api_secret: Option<String>,
565    },
566
567    /// Encode a JSON string of notes to an ecash string
568    Notes { notes_json: String },
569}
570
571#[derive(Debug, Clone, Subcommand)]
572enum DevCmd {
573    /// Send direct method call to the API. If you specify --peer-id, it will
574    /// just ask one server, otherwise it will try to get consensus from all
575    /// servers.
576    #[command(after_long_help = r#"
577Examples:
578
579  fedimint-cli dev api --peer-id 0 config '"fed114znk7uk7ppugdjuytr8venqf2tkywd65cqvg3u93um64tu5cw4yr0n3fvn7qmwvm4g48cpndgnm4gqq4waen5te0xyerwt3s9cczuvf6xyurzde597s7crdvsk2vmyarjw9gwyqjdzj"'
580    "#)]
581    Api {
582        /// JSON-RPC method to call
583        method: String,
584        /// JSON-RPC parameters for the request
585        ///
586        /// Note: single jsonrpc argument params string, which might require
587        /// double-quotes (see example above).
588        #[clap(default_value = "null")]
589        params: String,
590        /// Which server to send request to
591        #[clap(long = "peer-id")]
592        peer_id: Option<u16>,
593
594        /// Module selector (either module id or module kind)
595        #[clap(long = "module")]
596        module: Option<ModuleSelector>,
597
598        /// Guardian password in case authenticated API endpoints are being
599        /// called. Only use together with --peer-id.
600        #[clap(long, requires = "peer_id")]
601        password: Option<String>,
602    },
603
604    ApiAnnouncements,
605
606    GuardianMetadata,
607
608    /// Advance the note_idx
609    AdvanceNoteIdx {
610        #[clap(long, default_value = "1")]
611        count: usize,
612
613        #[clap(long)]
614        amount: Amount,
615    },
616
617    /// Wait for the fed to reach a consensus block count
618    WaitBlockCount {
619        count: u64,
620    },
621
622    /// Just start the `Client` and wait
623    Wait {
624        /// Limit the wait time
625        seconds: Option<f32>,
626    },
627
628    /// Wait for all state machines to complete
629    WaitComplete,
630
631    /// Decode invite code or ecash notes string into a JSON representation
632    Decode {
633        #[clap(subcommand)]
634        decode_type: DecodeType,
635    },
636
637    /// Encode an invite code or ecash notes into binary
638    Encode {
639        #[clap(subcommand)]
640        encode_type: EncodeType,
641    },
642
643    /// Gets the current fedimint AlephBFT block count
644    SessionCount,
645
646    /// Returns the client config
647    Config,
648
649    ConfigDecrypt {
650        /// Encrypted config file
651        #[arg(long = "in-file")]
652        in_file: PathBuf,
653        /// Plaintext config file output
654        #[arg(long = "out-file")]
655        out_file: PathBuf,
656        /// Encryption salt file, otherwise defaults to the salt file from the
657        /// `in_file` directory
658        #[arg(long = "salt-file")]
659        salt_file: Option<PathBuf>,
660        /// The password that encrypts the configs
661        #[arg(env = FM_PASSWORD_ENV)]
662        password: String,
663    },
664
665    ConfigEncrypt {
666        /// Plaintext config file
667        #[arg(long = "in-file")]
668        in_file: PathBuf,
669        /// Encrypted config file output
670        #[arg(long = "out-file")]
671        out_file: PathBuf,
672        /// Encryption salt file, otherwise defaults to the salt file from the
673        /// `out_file` directory
674        #[arg(long = "salt-file")]
675        salt_file: Option<PathBuf>,
676        /// The password that encrypts the configs
677        #[arg(env = FM_PASSWORD_ENV)]
678        password: String,
679    },
680
681    /// Lists active and inactive state machine states of the operation
682    /// chronologically
683    ListOperationStates {
684        operation_id: OperationId,
685    },
686    /// Returns the federation's meta fields. If they are set correctly via the
687    /// meta module these are returned, otherwise the legacy mechanism
688    /// (config+override file) is used.
689    MetaFields,
690    /// Gets the tagged fedimintd version for a peer
691    PeerVersion {
692        #[clap(long)]
693        peer_id: u16,
694    },
695    /// Dump Client's Event Log
696    ShowEventLog {
697        #[arg(long)]
698        pos: Option<EventLogId>,
699        #[arg(long, default_value = "10")]
700        limit: u64,
701    },
702    /// Dump Client's Trimable Event Log
703    ShowEventLogTrimable {
704        #[arg(long)]
705        pos: Option<EventLogId>,
706        #[arg(long, default_value = "10")]
707        limit: u64,
708    },
709    /// Test the built-in event handling and tracking by printing events to
710    /// console
711    TestEventLogHandling,
712    /// Manually submit a fedimint transaction to guardians
713    ///
714    /// This can be useful to check why a transaction may have been rejected
715    /// when debugging client issues.
716    SubmitTransaction {
717        /// Hex-encoded fedimint transaction
718        transaction: String,
719    },
720    /// Show the chain ID (bitcoin block hash at height 1) cached in the client
721    /// database
722    ChainId,
723}
724
725pub struct FedimintCli {
726    module_inits: ClientModuleInitRegistry,
727    cli_args: Opts,
728}
729
730impl FedimintCli {
731    /// Build a new `fedimintd` with a custom version hash
732    pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
733        assert_eq!(
734            fedimint_build_code_version_env!().len(),
735            version_hash.len(),
736            "version_hash must have an expected length"
737        );
738
739        handle_version_hash_command(version_hash);
740
741        let cli_args = Opts::parse();
742        let base_level = if cli_args.verbose { "debug" } else { "info" };
743        TracingSetup::default()
744            .with_base_level(base_level)
745            .init()
746            .expect("tracing initializes");
747
748        let version = env!("CARGO_PKG_VERSION");
749        debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
750
751        Ok(Self {
752            module_inits: ClientModuleInitRegistry::new(),
753            cli_args,
754        })
755    }
756
757    pub fn with_module<T>(mut self, r#gen: T) -> Self
758    where
759        T: ClientModuleInit + 'static + Send + Sync,
760    {
761        self.module_inits.attach(r#gen);
762        self
763    }
764
765    pub fn with_default_modules(self) -> Self {
766        self.with_module(LightningClientInit::default())
767            .with_module(MintClientInit)
768            .with_module(WalletClientInit::default())
769            .with_module(MetaClientInit)
770            .with_module(fedimint_lnv2_client::LightningClientInit::default())
771            .with_module(fedimint_walletv2_client::WalletClientInit)
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::Join { 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::Join {
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::Config) => {
1430                let client = self.client_open(&cli).await?;
1431                let config = client.get_config_json().await;
1432                Ok(CliOutput::Raw(
1433                    serde_json::to_value(config).expect("Client config is serializable"),
1434                ))
1435            }
1436            Command::Dev(DevCmd::ConfigDecrypt {
1437                in_file,
1438                out_file,
1439                salt_file,
1440                password,
1441            }) => {
1442                let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1443                let salt = fs::read_to_string(salt_file).map_err_cli()?;
1444                let key = get_encryption_key(&password, &salt).map_err_cli()?;
1445                let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1446
1447                let mut out_file_handle = fs::File::options()
1448                    .create_new(true)
1449                    .write(true)
1450                    .open(out_file)
1451                    .expect("Could not create output cfg file");
1452                out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1453                Ok(CliOutput::ConfigDecrypt)
1454            }
1455            Command::Dev(DevCmd::ConfigEncrypt {
1456                in_file,
1457                out_file,
1458                salt_file,
1459                password,
1460            }) => {
1461                let mut in_file_handle =
1462                    fs::File::open(in_file).expect("Could not create output cfg file");
1463                let mut plaintext_bytes = vec![];
1464                in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
1465
1466                let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1467                let salt = fs::read_to_string(salt_file).map_err_cli()?;
1468                let key = get_encryption_key(&password, &salt).map_err_cli()?;
1469                encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1470                Ok(CliOutput::ConfigEncrypt)
1471            }
1472            Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1473                #[derive(Serialize)]
1474                struct ReactorLogState {
1475                    active: bool,
1476                    module_instance: ModuleInstanceId,
1477                    creation_time: String,
1478                    #[serde(skip_serializing_if = "Option::is_none")]
1479                    end_time: Option<String>,
1480                    state: String,
1481                }
1482
1483                let client = self.client_open(&cli).await?;
1484
1485                let (active_states, inactive_states) =
1486                    client.executor().get_operation_states(operation_id).await;
1487                let all_states =
1488                    active_states
1489                        .into_iter()
1490                        .map(|(active_state, active_meta)| ReactorLogState {
1491                            active: true,
1492                            module_instance: active_state.module_instance_id(),
1493                            creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1494                            end_time: None,
1495                            state: format!("{active_state:?}",),
1496                        })
1497                        .chain(inactive_states.into_iter().map(
1498                            |(inactive_state, inactive_meta)| ReactorLogState {
1499                                active: false,
1500                                module_instance: inactive_state.module_instance_id(),
1501                                creation_time: crate::client::time_to_iso8601(
1502                                    &inactive_meta.created_at,
1503                                ),
1504                                end_time: Some(crate::client::time_to_iso8601(
1505                                    &inactive_meta.exited_at,
1506                                )),
1507                                state: format!("{inactive_state:?}",),
1508                            },
1509                        ))
1510                        .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1511                        .collect::<Vec<_>>();
1512
1513                Ok(CliOutput::Raw(json!({
1514                    "states": all_states
1515                })))
1516            }
1517            Command::Dev(DevCmd::MetaFields) => {
1518                let client = self.client_open(&cli).await?;
1519                let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1520
1521                let meta_fields = source
1522                    .fetch(
1523                        &client.config().await,
1524                        &client.api_clone(),
1525                        FetchKind::Initial,
1526                        None,
1527                    )
1528                    .await
1529                    .map_err_cli()?;
1530
1531                Ok(CliOutput::Raw(
1532                    serde_json::to_value(meta_fields).expect("Can be encoded"),
1533                ))
1534            }
1535            Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1536                let client = self.client_open(&cli).await?;
1537                let version = client
1538                    .api()
1539                    .fedimintd_version(peer_id.into())
1540                    .await
1541                    .map_err_cli()?;
1542
1543                Ok(CliOutput::Raw(json!({ "version": version })))
1544            }
1545            Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1546                let client = self.client_open(&cli).await?;
1547
1548                let events: Vec<_> = client
1549                    .get_event_log(pos, limit)
1550                    .await
1551                    .into_iter()
1552                    .map(|v| {
1553                        let id = v.id();
1554                        let v = v.as_raw();
1555                        let module_id = v.module.as_ref().map(|m| m.1);
1556                        let module_kind = v.module.as_ref().map(|m| m.0.clone());
1557                        serde_json::json!({
1558                            "id": id,
1559                            "kind": v.kind,
1560                            "module_kind": module_kind,
1561                            "module_id": module_id,
1562                            "ts": v.ts_usecs,
1563                            "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1564                        })
1565                    })
1566                    .collect();
1567
1568                Ok(CliOutput::Raw(
1569                    serde_json::to_value(events).expect("Can be encoded"),
1570                ))
1571            }
1572            Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1573                let client = self.client_open(&cli).await?;
1574
1575                let events: Vec<_> = client
1576                    .get_event_log_trimable(
1577                        pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1578                        limit,
1579                    )
1580                    .await
1581                    .into_iter()
1582                    .map(|v| {
1583                        let id = v.id();
1584                        let v = v.as_raw();
1585                        let module_id = v.module.as_ref().map(|m| m.1);
1586                        let module_kind = v.module.as_ref().map(|m| m.0.clone());
1587                        serde_json::json!({
1588                            "id": id,
1589                            "kind": v.kind,
1590                            "module_kind": module_kind,
1591                            "module_id": module_id,
1592                            "ts": v.ts_usecs,
1593                            "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1594                        })
1595                    })
1596                    .collect();
1597
1598                Ok(CliOutput::Raw(
1599                    serde_json::to_value(events).expect("Can be encoded"),
1600                ))
1601            }
1602            Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1603                let client = self.client_open(&cli).await?;
1604                let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1605                    .map_err_cli()?;
1606                let tx_outcome = client
1607                    .api()
1608                    .submit_transaction(tx)
1609                    .await
1610                    .try_into_inner(client.decoders())
1611                    .map_err_cli()?;
1612
1613                Ok(CliOutput::Raw(
1614                    serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1615                ))
1616            }
1617            Command::Dev(DevCmd::TestEventLogHandling) => {
1618                let client = self.client_open(&cli).await?;
1619
1620                client
1621                    .handle_events(
1622                        client.built_in_application_event_log_tracker(),
1623                        move |_dbtx, event| {
1624                            Box::pin(async move {
1625                                info!(target: LOG_CLIENT, "{event:?}");
1626
1627                                Ok(())
1628                            })
1629                        },
1630                    )
1631                    .await
1632                    .map_err_cli()?;
1633                unreachable!(
1634                    "handle_events exits only if client shuts down, which we don't do here"
1635                )
1636            }
1637            Command::Dev(DevCmd::ChainId) => {
1638                let client = self.client_open(&cli).await?;
1639                let chain_id = client
1640                    .db()
1641                    .begin_transaction_nc()
1642                    .await
1643                    .get_value(&fedimint_client::db::ChainIdKey)
1644                    .await
1645                    .ok_or_cli_msg("Chain ID not cached in client database")?;
1646
1647                Ok(CliOutput::Raw(serde_json::json!({
1648                    "chain_id": chain_id.to_string()
1649                })))
1650            }
1651            Command::Completion { shell } => {
1652                let bin_path = PathBuf::from(
1653                    std::env::args_os()
1654                        .next()
1655                        .expect("Binary name is always provided if we get this far"),
1656                );
1657                let bin_name = bin_path
1658                    .file_name()
1659                    .expect("path has file name")
1660                    .to_string_lossy();
1661                clap_complete::generate(
1662                    shell,
1663                    &mut Opts::command(),
1664                    bin_name.as_ref(),
1665                    &mut std::io::stdout(),
1666                );
1667                // HACK: prints true to stdout which is fine for shells
1668                Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1669            }
1670        }
1671    }
1672
1673    async fn handle_admin_setup_command(
1674        &self,
1675        cli: Opts,
1676        args: SetupAdminArgs,
1677    ) -> anyhow::Result<Value> {
1678        let client =
1679            DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone())?;
1680
1681        match &args.subcommand {
1682            SetupAdminCmd::Status => {
1683                let status = client.setup_status(cli.auth()?).await?;
1684
1685                Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1686            }
1687            SetupAdminCmd::SetLocalParams {
1688                name,
1689                federation_name,
1690                federation_size,
1691            } => {
1692                let info = client
1693                    .set_local_params(
1694                        name.clone(),
1695                        federation_name.clone(),
1696                        None,
1697                        None,
1698                        *federation_size,
1699                        cli.auth()?,
1700                    )
1701                    .await?;
1702
1703                Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1704            }
1705            SetupAdminCmd::AddPeer { info } => {
1706                let name = client
1707                    .add_peer_connection_info(info.clone(), cli.auth()?)
1708                    .await?;
1709
1710                Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1711            }
1712            SetupAdminCmd::StartDkg => {
1713                client.start_dkg(cli.auth()?).await?;
1714
1715                Ok(Value::Null)
1716            }
1717        }
1718    }
1719}
1720
1721async fn log_expiration_notice(client: &Client) {
1722    client.get_meta_expiration_timestamp().await;
1723    if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1724        match expiration_time.duration_since(fedimint_core::time::now()) {
1725            Ok(until_expiration) => {
1726                let days = until_expiration.as_secs() / (60 * 60 * 24);
1727
1728                if 90 < days {
1729                    debug!(target: LOG_CLIENT, %days, "This federation will expire");
1730                } else if 30 < days {
1731                    info!(target: LOG_CLIENT, %days, "This federation will expire");
1732                } else {
1733                    warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1734                }
1735            }
1736            Err(_) => {
1737                tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1738            }
1739        }
1740    }
1741}
1742async fn print_welcome_message(client: &Client) {
1743    if let Some(welcome_message) = client
1744        .meta_service()
1745        .get_field::<String>(client.db(), "welcome_message")
1746        .await
1747        .and_then(|v| v.value)
1748    {
1749        eprintln!("{welcome_message}");
1750    }
1751}
1752
1753fn salt_from_file_path(file_path: &Path) -> PathBuf {
1754    file_path
1755        .parent()
1756        .expect("File has no parent?!")
1757        .join(SALT_FILE)
1758}
1759
1760/// Convert clap arguments to backup metadata
1761fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1762    let metadata: BTreeMap<String, String> = metadata
1763        .into_iter()
1764        .map(|item| {
1765            match &item
1766                .splitn(2, '=')
1767                .map(ToString::to_string)
1768                .collect::<Vec<String>>()[..]
1769            {
1770                [] => Err(format_err!("Empty metadata argument not allowed")),
1771                [key] => Err(format_err!("Metadata {key} is missing a value")),
1772                [key, val] => Ok((key.clone(), val.clone())),
1773                [..] => unreachable!(),
1774            }
1775        })
1776        .collect::<anyhow::Result<_>>()
1777        .map_err_cli_msg("invalid metadata")?;
1778    Ok(metadata)
1779}
1780
1781#[test]
1782fn metadata_from_clap_cli_test() {
1783    for (args, expected) in [
1784        (
1785            vec!["a=b".to_string()],
1786            BTreeMap::from([("a".into(), "b".into())]),
1787        ),
1788        (
1789            vec!["a=b".to_string(), "c=d".to_string()],
1790            BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1791        ),
1792    ] {
1793        assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1794    }
1795}