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