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