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