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