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