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 Join {
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 #[clap(alias = "join-federation")]
397 Join {
398 invite_code: String,
399 },
400
401 Completion {
402 shell: clap_complete::Shell,
403 },
404}
405
406#[allow(clippy::large_enum_variant)]
407#[derive(Debug, Clone, Subcommand)]
408enum AdminCmd {
409 Status,
411
412 Audit,
414
415 GuardianConfigBackup,
417
418 Setup(SetupAdminArgs),
419 SignApiAnnouncement {
422 api_url: SafeUrl,
424 #[clap(long)]
427 override_url: Option<SafeUrl>,
428 },
429 Shutdown {
431 session_idx: u64,
433 },
434 BackupStatistics,
436 ChangePassword {
439 new_password: String,
441 },
442}
443
444#[derive(Debug, Clone, Args)]
445struct SetupAdminArgs {
446 endpoint: SafeUrl,
447
448 #[clap(subcommand)]
449 subcommand: SetupAdminCmd,
450}
451
452#[derive(Debug, Clone, Subcommand)]
453enum SetupAdminCmd {
454 Status,
455 SetLocalParams {
456 name: String,
457 #[clap(long)]
458 federation_name: Option<String>,
459 },
460 AddPeer {
461 info: String,
462 },
463 StartDkg,
464}
465
466#[derive(Debug, Clone, Subcommand)]
467enum DecodeType {
468 InviteCode { invite_code: InviteCode },
470 #[group(required = true, multiple = false)]
472 Notes {
473 notes: Option<OOBNotes>,
475 #[arg(long)]
477 file: Option<PathBuf>,
478 },
479 Transaction { hex_string: String },
481 SetupCode { setup_code: String },
484}
485
486#[derive(Debug, Clone, Deserialize, Serialize)]
487struct OOBNotesJson {
488 federation_id_prefix: String,
489 notes: TieredMulti<SpendableNote>,
490}
491
492#[derive(Debug, Clone, Subcommand)]
493enum EncodeType {
494 InviteCode {
496 #[clap(long)]
497 url: SafeUrl,
498 #[clap(long = "federation_id")]
499 federation_id: FederationId,
500 #[clap(long = "peer")]
501 peer: PeerId,
502 #[arg(env = FM_API_SECRET_ENV)]
503 api_secret: Option<String>,
504 },
505
506 Notes { notes_json: String },
508}
509
510#[derive(Debug, Clone, Subcommand)]
511enum DevCmd {
512 #[command(after_long_help = r#"
516Examples:
517
518 fedimint-cli dev api --peer-id 0 config '"fed114znk7uk7ppugdjuytr8venqf2tkywd65cqvg3u93um64tu5cw4yr0n3fvn7qmwvm4g48cpndgnm4gqq4waen5te0xyerwt3s9cczuvf6xyurzde597s7crdvsk2vmyarjw9gwyqjdzj"'
519 "#)]
520 Api {
521 method: String,
523 #[clap(default_value = "null")]
528 params: String,
529 #[clap(long = "peer-id")]
531 peer_id: Option<u16>,
532
533 #[clap(long = "module")]
535 module: Option<ModuleSelector>,
536
537 #[clap(long, requires = "peer_id")]
540 password: Option<String>,
541 },
542
543 ApiAnnouncements,
544
545 AdvanceNoteIdx {
547 #[clap(long, default_value = "1")]
548 count: usize,
549
550 #[clap(long)]
551 amount: Amount,
552 },
553
554 WaitBlockCount {
556 count: u64,
557 },
558
559 Wait {
561 seconds: Option<f32>,
563 },
564
565 WaitComplete,
567
568 Decode {
570 #[clap(subcommand)]
571 decode_type: DecodeType,
572 },
573
574 Encode {
576 #[clap(subcommand)]
577 encode_type: EncodeType,
578 },
579
580 SessionCount,
582
583 ConfigDecrypt {
584 #[arg(long = "in-file")]
586 in_file: PathBuf,
587 #[arg(long = "out-file")]
589 out_file: PathBuf,
590 #[arg(long = "salt-file")]
593 salt_file: Option<PathBuf>,
594 #[arg(env = FM_PASSWORD_ENV)]
596 password: String,
597 },
598
599 ConfigEncrypt {
600 #[arg(long = "in-file")]
602 in_file: PathBuf,
603 #[arg(long = "out-file")]
605 out_file: PathBuf,
606 #[arg(long = "salt-file")]
609 salt_file: Option<PathBuf>,
610 #[arg(env = FM_PASSWORD_ENV)]
612 password: String,
613 },
614
615 ListOperationStates {
618 operation_id: OperationId,
619 },
620 MetaFields,
624 PeerVersion {
626 #[clap(long)]
627 peer_id: u16,
628 },
629 ShowEventLog {
631 #[arg(long)]
632 pos: Option<EventLogId>,
633 #[arg(long, default_value = "10")]
634 limit: u64,
635 },
636 ShowEventLogTrimable {
638 #[arg(long)]
639 pos: Option<EventLogId>,
640 #[arg(long, default_value = "10")]
641 limit: u64,
642 },
643 TestEventLogHandling,
646 SubmitTransaction {
651 transaction: String,
653 },
654}
655
656#[derive(Debug, Serialize, Deserialize)]
657#[serde(rename_all = "snake_case")]
658struct PayRequest {
659 notes: TieredMulti<SpendableNote>,
660 invoice: lightning_invoice::Bolt11Invoice,
661}
662
663pub struct FedimintCli {
664 module_inits: ClientModuleInitRegistry,
665 cli_args: Opts,
666}
667
668impl FedimintCli {
669 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
671 assert_eq!(
672 fedimint_build_code_version_env!().len(),
673 version_hash.len(),
674 "version_hash must have an expected length"
675 );
676
677 handle_version_hash_command(version_hash);
678
679 let cli_args = Opts::parse();
680 let base_level = if cli_args.verbose { "debug" } else { "info" };
681 TracingSetup::default()
682 .with_base_level(base_level)
683 .init()
684 .expect("tracing initializes");
685
686 let version = env!("CARGO_PKG_VERSION");
687 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
688
689 Ok(Self {
690 module_inits: ClientModuleInitRegistry::new(),
691 cli_args,
692 })
693 }
694
695 pub fn with_module<T>(mut self, r#gen: T) -> Self
696 where
697 T: ClientModuleInit + 'static + Send + Sync,
698 {
699 self.module_inits.attach(r#gen);
700 self
701 }
702
703 pub fn with_default_modules(self) -> Self {
704 self.with_module(LightningClientInit::default())
705 .with_module(MintClientInit)
706 .with_module(WalletClientInit::default())
707 .with_module(MetaClientInit)
708 .with_module(fedimint_lnv2_client::LightningClientInit::default())
709 }
710
711 pub async fn run(&mut self) {
712 match self.handle_command(self.cli_args.clone()).await {
713 Ok(output) => {
714 let _ = writeln!(std::io::stdout(), "{output}");
716 }
717 Err(err) => {
718 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
719 let _ = writeln!(std::io::stdout(), "{err}");
720 exit(1);
721 }
722 }
723 }
724
725 async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
726 let mut client_builder = Client::builder()
727 .await
728 .map_err_cli()?
729 .with_iroh_enable_dht(cli.iroh_enable_dht())
730 .with_iroh_enable_next(cli.iroh_enable_next());
731 client_builder.with_module_inits(self.module_inits.clone());
732
733 let db = cli.load_database().await?;
734 Ok((client_builder, db))
735 }
736
737 async fn client_join(
738 &mut self,
739 cli: &Opts,
740 invite_code: InviteCode,
741 ) -> CliResult<ClientHandleArc> {
742 let (client_builder, db) = self.make_client_builder(cli).await?;
743
744 let mnemonic = load_or_generate_mnemonic(&db).await?;
745
746 let client = client_builder
747 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
748 .await
749 .map_err_cli()?
750 .join(
751 db,
752 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(
753 &mnemonic,
754 )),
755 )
756 .await
757 .map(Arc::new)
758 .map_err_cli()?;
759
760 print_welcome_message(&client).await;
761 log_expiration_notice(&client).await;
762
763 Ok(client)
764 }
765
766 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
767 let (mut client_builder, db) = self.make_client_builder(cli).await?;
768
769 if let Some(our_id) = cli.our_id {
770 client_builder.set_admin_creds(AdminCreds {
771 peer_id: our_id,
772 auth: cli.auth()?,
773 });
774 }
775
776 let mnemonic = Mnemonic::from_entropy(
777 &Client::load_decodable_client_secret::<Vec<u8>>(&db)
778 .await
779 .map_err_cli()?,
780 )
781 .map_err_cli()?;
782
783 let client = client_builder
784 .open(
785 cli.make_endpoints().await.map_err_cli()?,
786 db,
787 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(
788 &mnemonic,
789 )),
790 )
791 .await
792 .map(Arc::new)
793 .map_err_cli()?;
794
795 log_expiration_notice(&client).await;
796
797 Ok(client)
798 }
799
800 async fn client_recover(
801 &mut self,
802 cli: &Opts,
803 mnemonic: Mnemonic,
804 invite_code: InviteCode,
805 ) -> CliResult<ClientHandleArc> {
806 let (builder, db) = self.make_client_builder(cli).await?;
807 match Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
808 .await
809 .map_err_cli()?
810 {
811 Some(existing) => {
812 if existing != mnemonic.to_entropy() {
813 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
814 }
815 }
816 None => {
817 Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
818 .await
819 .map_err_cli()?;
820 }
821 }
822
823 let root_secret = RootSecret::StandardDoubleDerive(
824 Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
825 );
826
827 let preview = builder
828 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
829 .await
830 .map_err_cli()?;
831
832 let backup = preview
833 .download_backup_from_federation(root_secret.clone())
834 .await
835 .map_err_cli()?;
836
837 let client = preview
838 .recover(db, root_secret, backup)
839 .await
840 .map(Arc::new)
841 .map_err_cli()?;
842
843 print_welcome_message(&client).await;
844 log_expiration_notice(&client).await;
845
846 Ok(client)
847 }
848
849 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
850 match cli.command.clone() {
851 Command::InviteCode { peer } => {
852 let client = self.client_open(&cli).await?;
853
854 let invite_code = client
855 .invite_code(peer)
856 .await
857 .ok_or_cli_msg("peer not found")?;
858
859 Ok(CliOutput::InviteCode { invite_code })
860 }
861 Command::Join { invite_code } => {
862 {
863 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
864 .map_err_cli_msg("invalid invite code")?;
865
866 let _client = self.client_join(&cli, invite_code).await?;
868 }
869
870 Ok(CliOutput::Join {
871 joined: invite_code,
872 })
873 }
874 Command::VersionHash => Ok(CliOutput::VersionHash {
875 hash: fedimint_build_code_version_env!().to_string(),
876 }),
877 Command::Client(ClientCmd::Restore {
878 mnemonic,
879 invite_code,
880 }) => {
881 let invite_code: InviteCode =
882 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
883 let mnemonic = Mnemonic::from_str(&mnemonic).map_err_cli()?;
884 let client = self.client_recover(&cli, mnemonic, invite_code).await?;
885
886 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
889 client.wait_for_all_recoveries().await.map_err_cli()?;
890
891 debug!(target: LOG_CLIENT, "Recovery complete");
892
893 Ok(CliOutput::Raw(serde_json::to_value(()).unwrap()))
894 }
895 Command::Client(command) => {
896 let client = self.client_open(&cli).await?;
897 Ok(CliOutput::Raw(
898 client::handle_command(command, client)
899 .await
900 .map_err_cli()?,
901 ))
902 }
903 Command::Admin(AdminCmd::Audit) => {
904 let client = self.client_open(&cli).await?;
905
906 let audit = cli
907 .admin_client(
908 &client.get_peer_urls().await,
909 client.api_secret().as_deref(),
910 )
911 .await?
912 .audit(cli.auth()?)
913 .await?;
914 Ok(CliOutput::Raw(
915 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
916 ))
917 }
918 Command::Admin(AdminCmd::Status) => {
919 let client = self.client_open(&cli).await?;
920
921 let status = cli
922 .admin_client(
923 &client.get_peer_urls().await,
924 client.api_secret().as_deref(),
925 )
926 .await?
927 .status()
928 .await?;
929 Ok(CliOutput::Raw(
930 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
931 ))
932 }
933 Command::Admin(AdminCmd::GuardianConfigBackup) => {
934 let client = self.client_open(&cli).await?;
935
936 let guardian_config_backup = cli
937 .admin_client(
938 &client.get_peer_urls().await,
939 client.api_secret().as_deref(),
940 )
941 .await?
942 .guardian_config_backup(cli.auth()?)
943 .await?;
944 Ok(CliOutput::Raw(
945 serde_json::to_value(guardian_config_backup)
946 .map_err_cli_msg("invalid response")?,
947 ))
948 }
949 Command::Admin(AdminCmd::Setup(dkg_args)) => self
950 .handle_admin_setup_command(cli, dkg_args)
951 .await
952 .map(CliOutput::Raw)
953 .map_err_cli_msg("Config Gen Error"),
954 Command::Admin(AdminCmd::SignApiAnnouncement {
955 api_url,
956 override_url,
957 }) => {
958 let client = self.client_open(&cli).await?;
959
960 if !["ws", "wss"].contains(&api_url.scheme()) {
961 return Err(CliError {
962 error: format!(
963 "Unsupported URL scheme {}, use ws:// or wss://",
964 api_url.scheme()
965 ),
966 });
967 }
968
969 let announcement = cli
970 .admin_client(
971 &override_url
972 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
973 .unwrap_or(client.get_peer_urls().await),
974 client.api_secret().as_deref(),
975 )
976 .await?
977 .sign_api_announcement(api_url, cli.auth()?)
978 .await?;
979
980 Ok(CliOutput::Raw(
981 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
982 ))
983 }
984 Command::Admin(AdminCmd::Shutdown { session_idx }) => {
985 let client = self.client_open(&cli).await?;
986
987 cli.admin_client(
988 &client.get_peer_urls().await,
989 client.api_secret().as_deref(),
990 )
991 .await?
992 .shutdown(Some(session_idx), cli.auth()?)
993 .await?;
994
995 Ok(CliOutput::Raw(json!(null)))
996 }
997 Command::Admin(AdminCmd::BackupStatistics) => {
998 let client = self.client_open(&cli).await?;
999
1000 let backup_statistics = cli
1001 .admin_client(
1002 &client.get_peer_urls().await,
1003 client.api_secret().as_deref(),
1004 )
1005 .await?
1006 .backup_statistics(cli.auth()?)
1007 .await?;
1008
1009 Ok(CliOutput::Raw(
1010 serde_json::to_value(backup_statistics).expect("Can be encoded"),
1011 ))
1012 }
1013 Command::Admin(AdminCmd::ChangePassword { new_password }) => {
1014 let client = self.client_open(&cli).await?;
1015
1016 cli.admin_client(
1017 &client.get_peer_urls().await,
1018 client.api_secret().as_deref(),
1019 )
1020 .await?
1021 .change_password(cli.auth()?, &new_password)
1022 .await?;
1023
1024 warn!(target: LOG_CLIENT, "Password changed, please restart fedimintd manually");
1025
1026 Ok(CliOutput::Raw(json!(null)))
1027 }
1028 Command::Dev(DevCmd::Api {
1029 method,
1030 params,
1031 peer_id,
1032 password: auth,
1033 module,
1034 }) => {
1035 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
1038 debug!(
1039 target: LOG_CLIENT,
1040 "Failed to serialize params:{}. Converting it to JSON string",
1041 err
1042 );
1043
1044 serde_json::Value::String(params)
1045 });
1046
1047 let mut params = ApiRequestErased::new(params);
1048 if let Some(auth) = auth {
1049 params = params.with_auth(ApiAuth(auth));
1050 }
1051 let client = self.client_open(&cli).await?;
1052
1053 let api = client.api_clone();
1054
1055 let module_api = match module {
1056 Some(selector) => {
1057 Some(api.with_module(selector.resolve(&client).map_err_cli()?))
1058 }
1059 None => None,
1060 };
1061
1062 let response: Value = match (peer_id, module_api) {
1063 (Some(peer_id), Some(module_api)) => module_api
1064 .request_raw(peer_id.into(), &method, ¶ms)
1065 .await
1066 .map_err_cli()?,
1067 (Some(peer_id), None) => api
1068 .request_raw(peer_id.into(), &method, ¶ms)
1069 .await
1070 .map_err_cli()?,
1071 (None, Some(module_api)) => module_api
1072 .request_current_consensus(method, params)
1073 .await
1074 .map_err_cli()?,
1075 (None, None) => api
1076 .request_current_consensus(method, params)
1077 .await
1078 .map_err_cli()?,
1079 };
1080
1081 Ok(CliOutput::UntypedApiOutput { value: response })
1082 }
1083 Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
1084 let client = self.client_open(&cli).await?;
1085
1086 let mint = client
1087 .get_first_module::<MintClientModule>()
1088 .map_err_cli_msg("can't get mint module")?;
1089
1090 for _ in 0..count {
1091 mint.advance_note_idx(amount)
1092 .await
1093 .map_err_cli_msg("failed to advance the note_idx")?;
1094 }
1095
1096 Ok(CliOutput::Raw(serde_json::Value::Null))
1097 }
1098 Command::Dev(DevCmd::ApiAnnouncements) => {
1099 let client = self.client_open(&cli).await?;
1100 let announcements = client.get_peer_url_announcements().await;
1101 Ok(CliOutput::Raw(
1102 serde_json::to_value(announcements).expect("Can be encoded"),
1103 ))
1104 }
1105 Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1106 "wait_block_count",
1107 backoff_util::custom_backoff(
1108 Duration::from_millis(100),
1109 Duration::from_secs(5),
1110 None,
1111 ),
1112 || async {
1113 let client = self.client_open(&cli).await?;
1114 let wallet = client.get_first_module::<WalletClientModule>()?;
1115 let count = client
1116 .api()
1117 .with_module(wallet.id)
1118 .fetch_consensus_block_count()
1119 .await?;
1120 if count >= target {
1121 Ok(CliOutput::WaitBlockCount { reached: count })
1122 } else {
1123 info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1124 Err(format_err!("target not reached"))
1125 }
1126 },
1127 )
1128 .await
1129 .map_err_cli(),
1130
1131 Command::Dev(DevCmd::WaitComplete) => {
1132 let client = self.client_open(&cli).await?;
1133 client
1134 .wait_for_all_active_state_machines()
1135 .await
1136 .map_err_cli_msg("failed to wait for all active state machines")?;
1137 Ok(CliOutput::Raw(serde_json::Value::Null))
1138 }
1139 Command::Dev(DevCmd::Wait { seconds }) => {
1140 let client = self.client_open(&cli).await?;
1141 client
1145 .task_group()
1146 .spawn_cancellable("fedimint-cli dev wait: init networking", {
1147 let client = client.clone();
1148 async move {
1149 let _ = client.api().session_count().await;
1150 }
1151 });
1152
1153 if let Some(secs) = seconds {
1154 runtime::sleep(Duration::from_secs_f32(secs)).await;
1155 } else {
1156 pending::<()>().await;
1157 }
1158 Ok(CliOutput::Raw(serde_json::Value::Null))
1159 }
1160 Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1161 DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1162 url: invite_code.url(),
1163 federation_id: invite_code.federation_id(),
1164 }),
1165 DecodeType::Notes { notes, file } => {
1166 let notes = if let Some(notes) = notes {
1167 notes
1168 } else if let Some(file) = file {
1169 let notes_str =
1170 fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1171 OOBNotes::from_str(¬es_str).map_err_cli_msg("failed to decode notes")?
1172 } else {
1173 unreachable!("Clap enforces either notes or file being set");
1174 };
1175
1176 let notes_json = notes
1177 .notes_json()
1178 .map_err_cli_msg("failed to decode notes")?;
1179 Ok(CliOutput::Raw(notes_json))
1180 }
1181 DecodeType::Transaction { hex_string } => {
1182 let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1183 .map_err_cli_msg("failed to decode transaction")?;
1184
1185 let client = self.client_open(&cli).await?;
1186 let tx = fedimint_core::transaction::Transaction::from_bytes(
1187 &bytes,
1188 client.decoders(),
1189 )
1190 .map_err_cli_msg("failed to decode transaction")?;
1191
1192 Ok(CliOutput::DecodeTransaction {
1193 transaction: (format!("{tx:?}")),
1194 })
1195 }
1196 DecodeType::SetupCode { setup_code } => {
1197 let setup_code = base32::decode_prefixed(FEDIMINT_PREFIX, &setup_code)
1198 .map_err_cli_msg("failed to decode setup code")?;
1199
1200 Ok(CliOutput::SetupCode { setup_code })
1201 }
1202 },
1203 Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1204 EncodeType::InviteCode {
1205 url,
1206 federation_id,
1207 peer,
1208 api_secret,
1209 } => Ok(CliOutput::InviteCode {
1210 invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1211 }),
1212 EncodeType::Notes { notes_json } => {
1213 let notes = serde_json::from_str::<OOBNotesJson>(¬es_json)
1214 .map_err_cli_msg("invalid JSON for notes")?;
1215 let prefix =
1216 FederationIdPrefix::from_str(¬es.federation_id_prefix).map_err_cli()?;
1217 let notes = OOBNotes::new(prefix, notes.notes);
1218 Ok(CliOutput::Raw(notes.to_string().into()))
1219 }
1220 },
1221 Command::Dev(DevCmd::SessionCount) => {
1222 let client = self.client_open(&cli).await?;
1223 let count = client.api().session_count().await?;
1224 Ok(CliOutput::EpochCount { count })
1225 }
1226 Command::Dev(DevCmd::ConfigDecrypt {
1227 in_file,
1228 out_file,
1229 salt_file,
1230 password,
1231 }) => {
1232 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1233 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1234 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1235 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1236
1237 let mut out_file_handle = fs::File::options()
1238 .create_new(true)
1239 .write(true)
1240 .open(out_file)
1241 .expect("Could not create output cfg file");
1242 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1243 Ok(CliOutput::ConfigDecrypt)
1244 }
1245 Command::Dev(DevCmd::ConfigEncrypt {
1246 in_file,
1247 out_file,
1248 salt_file,
1249 password,
1250 }) => {
1251 let mut in_file_handle =
1252 fs::File::open(in_file).expect("Could not create output cfg file");
1253 let mut plaintext_bytes = vec![];
1254 in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
1255
1256 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1257 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1258 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1259 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1260 Ok(CliOutput::ConfigEncrypt)
1261 }
1262 Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1263 #[derive(Serialize)]
1264 struct ReactorLogState {
1265 active: bool,
1266 module_instance: ModuleInstanceId,
1267 creation_time: String,
1268 #[serde(skip_serializing_if = "Option::is_none")]
1269 end_time: Option<String>,
1270 state: String,
1271 }
1272
1273 let client = self.client_open(&cli).await?;
1274
1275 let (active_states, inactive_states) =
1276 client.executor().get_operation_states(operation_id).await;
1277 let all_states =
1278 active_states
1279 .into_iter()
1280 .map(|(active_state, active_meta)| ReactorLogState {
1281 active: true,
1282 module_instance: active_state.module_instance_id(),
1283 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1284 end_time: None,
1285 state: format!("{active_state:?}",),
1286 })
1287 .chain(inactive_states.into_iter().map(
1288 |(inactive_state, inactive_meta)| ReactorLogState {
1289 active: false,
1290 module_instance: inactive_state.module_instance_id(),
1291 creation_time: crate::client::time_to_iso8601(
1292 &inactive_meta.created_at,
1293 ),
1294 end_time: Some(crate::client::time_to_iso8601(
1295 &inactive_meta.exited_at,
1296 )),
1297 state: format!("{inactive_state:?}",),
1298 },
1299 ))
1300 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1301 .collect::<Vec<_>>();
1302
1303 Ok(CliOutput::Raw(json!({
1304 "states": all_states
1305 })))
1306 }
1307 Command::Dev(DevCmd::MetaFields) => {
1308 let client = self.client_open(&cli).await?;
1309 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1310
1311 let meta_fields = source
1312 .fetch(
1313 &client.config().await,
1314 &client.api_clone(),
1315 FetchKind::Initial,
1316 None,
1317 )
1318 .await
1319 .map_err_cli()?;
1320
1321 Ok(CliOutput::Raw(
1322 serde_json::to_value(meta_fields).expect("Can be encoded"),
1323 ))
1324 }
1325 Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1326 let client = self.client_open(&cli).await?;
1327 let version = client
1328 .api()
1329 .fedimintd_version(peer_id.into())
1330 .await
1331 .map_err_cli()?;
1332
1333 Ok(CliOutput::Raw(json!({ "version": version })))
1334 }
1335 Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1336 let client = self.client_open(&cli).await?;
1337
1338 let events: Vec<_> = client
1339 .get_event_log(pos, limit)
1340 .await
1341 .into_iter()
1342 .map(|v| {
1343 let id = v.id();
1344 let v = v.as_raw();
1345 let module_id = v.module.as_ref().map(|m| m.1);
1346 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1347 serde_json::json!({
1348 "id": id,
1349 "kind": v.kind,
1350 "module_kind": module_kind,
1351 "module_id": module_id,
1352 "ts": v.ts_usecs,
1353 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1354 })
1355 })
1356 .collect();
1357
1358 Ok(CliOutput::Raw(
1359 serde_json::to_value(events).expect("Can be encoded"),
1360 ))
1361 }
1362 Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1363 let client = self.client_open(&cli).await?;
1364
1365 let events: Vec<_> = client
1366 .get_event_log_trimable(
1367 pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1368 limit,
1369 )
1370 .await
1371 .into_iter()
1372 .map(|v| {
1373 let id = v.id();
1374 let v = v.as_raw();
1375 let module_id = v.module.as_ref().map(|m| m.1);
1376 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1377 serde_json::json!({
1378 "id": id,
1379 "kind": v.kind,
1380 "module_kind": module_kind,
1381 "module_id": module_id,
1382 "ts": v.ts_usecs,
1383 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1384 })
1385 })
1386 .collect();
1387
1388 Ok(CliOutput::Raw(
1389 serde_json::to_value(events).expect("Can be encoded"),
1390 ))
1391 }
1392 Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1393 let client = self.client_open(&cli).await?;
1394 let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1395 .map_err_cli()?;
1396 let tx_outcome = client
1397 .api()
1398 .submit_transaction(tx)
1399 .await
1400 .try_into_inner(client.decoders())
1401 .map_err_cli()?;
1402
1403 Ok(CliOutput::Raw(
1404 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1405 ))
1406 }
1407 Command::Dev(DevCmd::TestEventLogHandling) => {
1408 let client = self.client_open(&cli).await?;
1409
1410 client
1411 .handle_events(
1412 client.built_in_application_event_log_tracker(),
1413 move |_dbtx, event| {
1414 Box::pin(async move {
1415 info!(target: LOG_CLIENT, "{event:?}");
1416
1417 Ok(())
1418 })
1419 },
1420 )
1421 .await
1422 .map_err_cli()?;
1423 unreachable!(
1424 "handle_events exits only if client shuts down, which we don't do here"
1425 )
1426 }
1427 Command::Completion { shell } => {
1428 let bin_path = PathBuf::from(
1429 std::env::args_os()
1430 .next()
1431 .expect("Binary name is always provided if we get this far"),
1432 );
1433 let bin_name = bin_path
1434 .file_name()
1435 .expect("path has file name")
1436 .to_string_lossy();
1437 clap_complete::generate(
1438 shell,
1439 &mut Opts::command(),
1440 bin_name.as_ref(),
1441 &mut std::io::stdout(),
1442 );
1443 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1445 }
1446 }
1447 }
1448
1449 async fn handle_admin_setup_command(
1450 &self,
1451 cli: Opts,
1452 args: SetupAdminArgs,
1453 ) -> anyhow::Result<Value> {
1454 let client =
1455 DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone())?;
1456
1457 match &args.subcommand {
1458 SetupAdminCmd::Status => {
1459 let status = client.setup_status(cli.auth()?).await?;
1460
1461 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1462 }
1463 SetupAdminCmd::SetLocalParams {
1464 name,
1465 federation_name,
1466 } => {
1467 let info = client
1468 .set_local_params(
1469 name.clone(),
1470 federation_name.clone(),
1471 None,
1472 None,
1473 cli.auth()?,
1474 )
1475 .await?;
1476
1477 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1478 }
1479 SetupAdminCmd::AddPeer { info } => {
1480 let name = client
1481 .add_peer_connection_info(info.clone(), cli.auth()?)
1482 .await?;
1483
1484 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1485 }
1486 SetupAdminCmd::StartDkg => {
1487 client.start_dkg(cli.auth()?).await?;
1488
1489 Ok(Value::Null)
1490 }
1491 }
1492 }
1493}
1494
1495async fn log_expiration_notice(client: &Client) {
1496 client.get_meta_expiration_timestamp().await;
1497 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1498 match expiration_time.duration_since(fedimint_core::time::now()) {
1499 Ok(until_expiration) => {
1500 let days = until_expiration.as_secs() / (60 * 60 * 24);
1501
1502 if 90 < days {
1503 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1504 } else if 30 < days {
1505 info!(target: LOG_CLIENT, %days, "This federation will expire");
1506 } else {
1507 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1508 }
1509 }
1510 Err(_) => {
1511 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1512 }
1513 }
1514 }
1515}
1516async fn print_welcome_message(client: &Client) {
1517 if let Some(welcome_message) = client
1518 .meta_service()
1519 .get_field::<String>(client.db(), "welcome_message")
1520 .await
1521 .and_then(|v| v.value)
1522 {
1523 eprintln!("{welcome_message}");
1524 }
1525}
1526
1527fn salt_from_file_path(file_path: &Path) -> PathBuf {
1528 file_path
1529 .parent()
1530 .expect("File has no parent?!")
1531 .join(SALT_FILE)
1532}
1533
1534fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1536 let metadata: BTreeMap<String, String> = metadata
1537 .into_iter()
1538 .map(|item| {
1539 match &item
1540 .splitn(2, '=')
1541 .map(ToString::to_string)
1542 .collect::<Vec<String>>()[..]
1543 {
1544 [] => Err(format_err!("Empty metadata argument not allowed")),
1545 [key] => Err(format_err!("Metadata {key} is missing a value")),
1546 [key, val] => Ok((key.clone(), val.clone())),
1547 [..] => unreachable!(),
1548 }
1549 })
1550 .collect::<anyhow::Result<_>>()
1551 .map_err_cli_msg("invalid metadata")?;
1552 Ok(metadata)
1553}
1554
1555#[test]
1556fn metadata_from_clap_cli_test() {
1557 for (args, expected) in [
1558 (
1559 vec!["a=b".to_string()],
1560 BTreeMap::from([("a".into(), "b".into())]),
1561 ),
1562 (
1563 vec!["a=b".to_string(), "c=d".to_string()],
1564 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1565 ),
1566 ] {
1567 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1568 }
1569}