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