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, 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;
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_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_DB_BACKEND_ENV, value_enum, default_value = "rocksdb")]
244 db_backend: DatabaseBackend,
245
246 #[arg(short = 'v', long)]
249 verbose: bool,
250
251 #[clap(subcommand)]
252 command: Command,
253}
254
255impl Opts {
256 fn data_dir(&self) -> CliResult<&PathBuf> {
257 self.data_dir
258 .as_ref()
259 .ok_or_cli_msg("`--data-dir=` argument not set.")
260 }
261
262 async fn data_dir_create(&self) -> CliResult<&PathBuf> {
264 let dir = self.data_dir()?;
265
266 tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
267
268 Ok(dir)
269 }
270
271 async fn admin_client(
272 &self,
273 peer_urls: &BTreeMap<PeerId, SafeUrl>,
274 api_secret: &Option<String>,
275 ) -> CliResult<DynGlobalApi> {
276 let our_id = self.our_id.ok_or_cli_msg("Admin client needs our-id set")?;
277
278 DynGlobalApi::new_admin(
279 our_id,
280 peer_urls
281 .get(&our_id)
282 .cloned()
283 .context("Our peer URL not found in config")
284 .map_err_cli()?,
285 api_secret,
286 )
287 .await
288 .map_err(|e| CliError {
289 error: e.to_string(),
290 })
291 }
292
293 fn auth(&self) -> CliResult<ApiAuth> {
294 let password = self
295 .password
296 .clone()
297 .ok_or_cli_msg("CLI needs password set")?;
298 Ok(ApiAuth(password))
299 }
300
301 async fn load_database(&self) -> CliResult<Database> {
302 debug!(target: LOG_CLIENT, "Loading client database");
303 let db_path = self.data_dir_create().await?.join("client.db");
304 match self.db_backend {
305 DatabaseBackend::RocksDb => {
306 debug!(target: LOG_CLIENT, "Using RocksDB database backend");
307 Ok(fedimint_rocksdb::RocksDb::open(db_path)
308 .await
309 .map_err_cli_msg("could not open rocksdb database")?
310 .into())
311 }
312 DatabaseBackend::CursedRedb => {
313 debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
314 Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
315 .await
316 .map_err_cli_msg("could not open cursed redb database")?
317 .into())
318 }
319 }
320 }
321
322 #[allow(clippy::unused_self)]
323 fn connector(&self) -> Connector {
324 #[cfg(feature = "tor")]
325 if self.use_tor {
326 Connector::tor()
327 } else {
328 Connector::default()
329 }
330 #[cfg(not(feature = "tor"))]
331 Connector::default()
332 }
333}
334
335async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
336 Ok(
337 if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
338 Mnemonic::from_entropy(&entropy).map_err_cli()?
339 } else {
340 debug!(
341 target: LOG_CLIENT,
342 "Generating mnemonic and writing entropy to client storage"
343 );
344 let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
345 Client::store_encodable_client_secret(db, mnemonic.to_entropy())
346 .await
347 .map_err_cli()?;
348 mnemonic
349 },
350 )
351}
352
353#[derive(Subcommand, Clone)]
354enum Command {
355 VersionHash,
357
358 #[clap(flatten)]
359 Client(client::ClientCmd),
360
361 #[clap(subcommand)]
362 Admin(AdminCmd),
363
364 #[clap(subcommand)]
365 Dev(DevCmd),
366
367 InviteCode {
369 peer: PeerId,
370 },
371
372 JoinFederation {
374 invite_code: String,
375 },
376
377 Completion {
378 shell: clap_complete::Shell,
379 },
380}
381
382#[allow(clippy::large_enum_variant)]
383#[derive(Debug, Clone, Subcommand)]
384enum AdminCmd {
385 Status,
387
388 Audit,
390
391 GuardianConfigBackup,
393
394 Setup(SetupAdminArgs),
395 SignApiAnnouncement {
398 api_url: SafeUrl,
400 #[clap(long)]
403 override_url: Option<SafeUrl>,
404 },
405 Shutdown {
407 session_idx: u64,
409 },
410 BackupStatistics,
412}
413
414#[derive(Debug, Clone, Args)]
415struct SetupAdminArgs {
416 endpoint: SafeUrl,
417
418 #[clap(subcommand)]
419 subcommand: SetupAdminCmd,
420}
421
422#[derive(Debug, Clone, Subcommand)]
423enum SetupAdminCmd {
424 Status,
425 SetLocalParams {
426 name: String,
427 #[clap(long)]
428 federation_name: Option<String>,
429 },
430 AddPeer {
431 info: String,
432 },
433 StartDkg,
434}
435
436#[derive(Debug, Clone, Subcommand)]
437enum DecodeType {
438 InviteCode { invite_code: InviteCode },
440 #[group(required = true, multiple = false)]
442 Notes {
443 notes: Option<OOBNotes>,
445 #[arg(long)]
447 file: Option<PathBuf>,
448 },
449 Transaction { hex_string: String },
451 SetupCode { setup_code: String },
454}
455
456#[derive(Debug, Clone, Deserialize, Serialize)]
457struct OOBNotesJson {
458 federation_id_prefix: String,
459 notes: TieredMulti<SpendableNote>,
460}
461
462#[derive(Debug, Clone, Subcommand)]
463enum EncodeType {
464 InviteCode {
466 #[clap(long)]
467 url: SafeUrl,
468 #[clap(long = "federation_id")]
469 federation_id: FederationId,
470 #[clap(long = "peer")]
471 peer: PeerId,
472 #[arg(env = FM_API_SECRET_ENV)]
473 api_secret: Option<String>,
474 },
475
476 Notes { notes_json: String },
478}
479
480#[derive(Debug, Clone, Subcommand)]
481enum DevCmd {
482 #[command(after_long_help = r#"
486Examples:
487
488 fedimint-cli dev api --peer-id 0 config '"fed114znk7uk7ppugdjuytr8venqf2tkywd65cqvg3u93um64tu5cw4yr0n3fvn7qmwvm4g48cpndgnm4gqq4waen5te0xyerwt3s9cczuvf6xyurzde597s7crdvsk2vmyarjw9gwyqjdzj"'
489 "#)]
490 Api {
491 method: String,
493 #[clap(default_value = "null")]
498 params: String,
499 #[clap(long = "peer-id")]
501 peer_id: Option<u16>,
502
503 #[clap(long = "module")]
505 module: Option<ModuleSelector>,
506
507 #[clap(long, requires = "peer_id")]
510 password: Option<String>,
511 },
512
513 ApiAnnouncements,
514
515 AdvanceNoteIdx {
517 #[clap(long, default_value = "1")]
518 count: usize,
519
520 #[clap(long)]
521 amount: Amount,
522 },
523
524 WaitBlockCount {
526 count: u64,
527 },
528
529 Wait {
531 seconds: Option<f32>,
533 },
534
535 WaitComplete,
537
538 Decode {
540 #[clap(subcommand)]
541 decode_type: DecodeType,
542 },
543
544 Encode {
546 #[clap(subcommand)]
547 encode_type: EncodeType,
548 },
549
550 SessionCount,
552
553 ConfigDecrypt {
554 #[arg(long = "in-file")]
556 in_file: PathBuf,
557 #[arg(long = "out-file")]
559 out_file: PathBuf,
560 #[arg(long = "salt-file")]
563 salt_file: Option<PathBuf>,
564 #[arg(env = FM_PASSWORD_ENV)]
566 password: String,
567 },
568
569 ConfigEncrypt {
570 #[arg(long = "in-file")]
572 in_file: PathBuf,
573 #[arg(long = "out-file")]
575 out_file: PathBuf,
576 #[arg(long = "salt-file")]
579 salt_file: Option<PathBuf>,
580 #[arg(env = FM_PASSWORD_ENV)]
582 password: String,
583 },
584
585 ListOperationStates {
588 operation_id: OperationId,
589 },
590 MetaFields,
594 PeerVersion {
596 #[clap(long)]
597 peer_id: u16,
598 },
599 ShowEventLog {
601 #[arg(long)]
602 pos: Option<EventLogId>,
603 #[arg(long, default_value = "10")]
604 limit: u64,
605 },
606 SubmitTransaction {
611 transaction: String,
613 },
614}
615
616#[derive(Debug, Serialize, Deserialize)]
617#[serde(rename_all = "snake_case")]
618struct PayRequest {
619 notes: TieredMulti<SpendableNote>,
620 invoice: lightning_invoice::Bolt11Invoice,
621}
622
623pub struct FedimintCli {
624 module_inits: ClientModuleInitRegistry,
625 cli_args: Opts,
626}
627
628impl FedimintCli {
629 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
631 assert_eq!(
632 fedimint_build_code_version_env!().len(),
633 version_hash.len(),
634 "version_hash must have an expected length"
635 );
636
637 handle_version_hash_command(version_hash);
638
639 let cli_args = Opts::parse();
640 let base_level = if cli_args.verbose { "debug" } else { "info" };
641 TracingSetup::default()
642 .with_base_level(base_level)
643 .init()
644 .expect("tracing initializes");
645
646 let version = env!("CARGO_PKG_VERSION");
647 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
648
649 Ok(Self {
650 module_inits: ClientModuleInitRegistry::new(),
651 cli_args,
652 })
653 }
654
655 pub fn with_module<T>(mut self, r#gen: T) -> Self
656 where
657 T: ClientModuleInit + 'static + Send + Sync,
658 {
659 self.module_inits.attach(r#gen);
660 self
661 }
662
663 pub fn with_default_modules(self) -> Self {
664 self.with_module(LightningClientInit::default())
665 .with_module(MintClientInit)
666 .with_module(WalletClientInit::default())
667 .with_module(MetaClientInit)
668 .with_module(fedimint_lnv2_client::LightningClientInit::default())
669 }
670
671 pub async fn run(&mut self) {
672 match self.handle_command(self.cli_args.clone()).await {
673 Ok(output) => {
674 let _ = writeln!(std::io::stdout(), "{output}");
676 }
677 Err(err) => {
678 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
679 let _ = writeln!(std::io::stdout(), "{err}");
680 exit(1);
681 }
682 }
683 }
684
685 async fn make_client_builder(&self, cli: &Opts) -> CliResult<ClientBuilder> {
686 let db = cli.load_database().await?;
687 let mut client_builder = Client::builder(db).await.map_err_cli()?;
688 client_builder.with_module_inits(self.module_inits.clone());
689 client_builder.with_primary_module_kind(fedimint_mint_client::KIND);
690
691 client_builder.with_connector(cli.connector());
692
693 Ok(client_builder)
694 }
695
696 async fn client_join(
697 &mut self,
698 cli: &Opts,
699 invite_code: InviteCode,
700 ) -> CliResult<ClientHandleArc> {
701 let client_builder = self.make_client_builder(cli).await?;
702
703 let mnemonic = load_or_generate_mnemonic(client_builder.db_no_decoders()).await?;
704
705 let client = client_builder
706 .preview(&invite_code)
707 .await
708 .map_err_cli()?
709 .join(RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<
710 12,
711 >::to_root_secret(
712 &mnemonic
713 )))
714 .await
715 .map(Arc::new)
716 .map_err_cli()?;
717
718 print_welcome_message(&client).await;
719 log_expiration_notice(&client).await;
720
721 Ok(client)
722 }
723
724 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
725 let mut client_builder = self.make_client_builder(cli).await?;
726
727 if let Some(our_id) = cli.our_id {
728 client_builder.set_admin_creds(AdminCreds {
729 peer_id: our_id,
730 auth: cli.auth()?,
731 });
732 }
733
734 let mnemonic = Mnemonic::from_entropy(
735 &Client::load_decodable_client_secret::<Vec<u8>>(client_builder.db_no_decoders())
736 .await
737 .map_err_cli()?,
738 )
739 .map_err_cli()?;
740
741 let client = client_builder
742 .open(RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<
743 12,
744 >::to_root_secret(
745 &mnemonic
746 )))
747 .await
748 .map(Arc::new)
749 .map_err_cli()?;
750
751 log_expiration_notice(&client).await;
752
753 Ok(client)
754 }
755
756 async fn client_recover(
757 &mut self,
758 cli: &Opts,
759 mnemonic: Mnemonic,
760 invite_code: InviteCode,
761 ) -> CliResult<ClientHandleArc> {
762 let builder = self.make_client_builder(cli).await?;
763 match Client::load_decodable_client_secret_opt::<Vec<u8>>(builder.db_no_decoders())
764 .await
765 .map_err_cli()?
766 {
767 Some(existing) => {
768 if existing != mnemonic.to_entropy() {
769 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
770 }
771 }
772 None => {
773 Client::store_encodable_client_secret(
774 builder.db_no_decoders(),
775 mnemonic.to_entropy(),
776 )
777 .await
778 .map_err_cli()?;
779 }
780 }
781
782 let root_secret = RootSecret::StandardDoubleDerive(
783 Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
784 );
785 let client = builder
786 .preview(&invite_code)
787 .await
788 .map_err_cli()?
789 .recover(root_secret, None)
790 .await
791 .map(Arc::new)
792 .map_err_cli()?;
793
794 print_welcome_message(&client).await;
795 log_expiration_notice(&client).await;
796
797 Ok(client)
798 }
799
800 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
801 match cli.command.clone() {
802 Command::InviteCode { peer } => {
803 let client = self.client_open(&cli).await?;
804
805 let invite_code = client
806 .invite_code(peer)
807 .await
808 .ok_or_cli_msg("peer not found")?;
809
810 Ok(CliOutput::InviteCode { invite_code })
811 }
812 Command::JoinFederation { invite_code } => {
813 {
814 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
815 .map_err_cli_msg("invalid invite code")?;
816
817 let _client = self.client_join(&cli, invite_code).await?;
819 }
820
821 Ok(CliOutput::JoinFederation {
822 joined: invite_code,
823 })
824 }
825 Command::VersionHash => Ok(CliOutput::VersionHash {
826 hash: fedimint_build_code_version_env!().to_string(),
827 }),
828 Command::Client(ClientCmd::Restore {
829 mnemonic,
830 invite_code,
831 }) => {
832 let invite_code: InviteCode =
833 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
834 let mnemonic = Mnemonic::from_str(&mnemonic).map_err_cli()?;
835 let client = self.client_recover(&cli, mnemonic, invite_code).await?;
836
837 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
840 client.wait_for_all_recoveries().await.map_err_cli()?;
841
842 debug!(target: LOG_CLIENT, "Recovery complete");
843
844 Ok(CliOutput::Raw(serde_json::to_value(()).unwrap()))
845 }
846 Command::Client(command) => {
847 let client = self.client_open(&cli).await?;
848 Ok(CliOutput::Raw(
849 client::handle_command(command, client)
850 .await
851 .map_err_cli()?,
852 ))
853 }
854 Command::Admin(AdminCmd::Audit) => {
855 let client = self.client_open(&cli).await?;
856
857 let audit = cli
858 .admin_client(&client.get_peer_urls().await, client.api_secret())
859 .await?
860 .audit(cli.auth()?)
861 .await?;
862 Ok(CliOutput::Raw(
863 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
864 ))
865 }
866 Command::Admin(AdminCmd::Status) => {
867 let client = self.client_open(&cli).await?;
868
869 let status = cli
870 .admin_client(&client.get_peer_urls().await, client.api_secret())
871 .await?
872 .status()
873 .await?;
874 Ok(CliOutput::Raw(
875 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
876 ))
877 }
878 Command::Admin(AdminCmd::GuardianConfigBackup) => {
879 let client = self.client_open(&cli).await?;
880
881 let guardian_config_backup = cli
882 .admin_client(&client.get_peer_urls().await, client.api_secret())
883 .await?
884 .guardian_config_backup(cli.auth()?)
885 .await?;
886 Ok(CliOutput::Raw(
887 serde_json::to_value(guardian_config_backup)
888 .map_err_cli_msg("invalid response")?,
889 ))
890 }
891 Command::Admin(AdminCmd::Setup(dkg_args)) => self
892 .handle_admin_setup_command(cli, dkg_args)
893 .await
894 .map(CliOutput::Raw)
895 .map_err_cli_msg("Config Gen Error"),
896 Command::Admin(AdminCmd::SignApiAnnouncement {
897 api_url,
898 override_url,
899 }) => {
900 let client = self.client_open(&cli).await?;
901
902 if !["ws", "wss"].contains(&api_url.scheme()) {
903 return Err(CliError {
904 error: format!(
905 "Unsupported URL scheme {}, use ws:// or wss://",
906 api_url.scheme()
907 ),
908 });
909 }
910
911 let announcement = cli
912 .admin_client(
913 &override_url
914 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
915 .unwrap_or(client.get_peer_urls().await),
916 client.api_secret(),
917 )
918 .await?
919 .sign_api_announcement(api_url, cli.auth()?)
920 .await?;
921
922 Ok(CliOutput::Raw(
923 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
924 ))
925 }
926 Command::Admin(AdminCmd::Shutdown { session_idx }) => {
927 let client = self.client_open(&cli).await?;
928
929 cli.admin_client(&client.get_peer_urls().await, client.api_secret())
930 .await?
931 .shutdown(Some(session_idx), cli.auth()?)
932 .await?;
933
934 Ok(CliOutput::Raw(json!(null)))
935 }
936 Command::Admin(AdminCmd::BackupStatistics) => {
937 let client = self.client_open(&cli).await?;
938
939 let backup_statistics = cli
940 .admin_client(&client.get_peer_urls().await, client.api_secret())
941 .await?
942 .backup_statistics(cli.auth()?)
943 .await?;
944
945 Ok(CliOutput::Raw(
946 serde_json::to_value(backup_statistics).expect("Can be encoded"),
947 ))
948 }
949 Command::Dev(DevCmd::Api {
950 method,
951 params,
952 peer_id,
953 password: auth,
954 module,
955 }) => {
956 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
959 debug!(
960 target: LOG_CLIENT,
961 "Failed to serialize params:{}. Converting it to JSON string",
962 err
963 );
964
965 serde_json::Value::String(params)
966 });
967
968 let mut params = ApiRequestErased::new(params);
969 if let Some(auth) = auth {
970 params = params.with_auth(ApiAuth(auth));
971 }
972 let client = self.client_open(&cli).await?;
973
974 let api = client.api_clone();
975
976 let module_api = match module {
977 Some(selector) => {
978 Some(api.with_module(selector.resolve(&client).map_err_cli()?))
979 }
980 None => None,
981 };
982
983 let response: Value = match (peer_id, module_api) {
984 (Some(peer_id), Some(module_api)) => module_api
985 .request_raw(peer_id.into(), &method, ¶ms)
986 .await
987 .map_err_cli()?,
988 (Some(peer_id), None) => api
989 .request_raw(peer_id.into(), &method, ¶ms)
990 .await
991 .map_err_cli()?,
992 (None, Some(module_api)) => module_api
993 .request_current_consensus(method, params)
994 .await
995 .map_err_cli()?,
996 (None, None) => api
997 .request_current_consensus(method, params)
998 .await
999 .map_err_cli()?,
1000 };
1001
1002 Ok(CliOutput::UntypedApiOutput { value: response })
1003 }
1004 Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
1005 let client = self.client_open(&cli).await?;
1006
1007 let mint = client
1008 .get_first_module::<MintClientModule>()
1009 .map_err_cli_msg("can't get mint module")?;
1010
1011 for _ in 0..count {
1012 mint.advance_note_idx(amount)
1013 .await
1014 .map_err_cli_msg("failed to advance the note_idx")?;
1015 }
1016
1017 Ok(CliOutput::Raw(serde_json::Value::Null))
1018 }
1019 Command::Dev(DevCmd::ApiAnnouncements) => {
1020 let client = self.client_open(&cli).await?;
1021 let announcements = client.get_peer_url_announcements().await;
1022 Ok(CliOutput::Raw(
1023 serde_json::to_value(announcements).expect("Can be encoded"),
1024 ))
1025 }
1026 Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1027 "wait_block_count",
1028 backoff_util::custom_backoff(
1029 Duration::from_millis(100),
1030 Duration::from_secs(5),
1031 None,
1032 ),
1033 || async {
1034 let client = self.client_open(&cli).await?;
1035 let wallet = client.get_first_module::<WalletClientModule>()?;
1036 let count = client
1037 .api()
1038 .with_module(wallet.id)
1039 .fetch_consensus_block_count()
1040 .await?;
1041 if count >= target {
1042 Ok(CliOutput::WaitBlockCount { reached: count })
1043 } else {
1044 info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1045 Err(format_err!("target not reached"))
1046 }
1047 },
1048 )
1049 .await
1050 .map_err_cli(),
1051
1052 Command::Dev(DevCmd::WaitComplete) => {
1053 let client = self.client_open(&cli).await?;
1054 client
1055 .wait_for_all_active_state_machines()
1056 .await
1057 .map_err_cli_msg("failed to wait for all active state machines")?;
1058 Ok(CliOutput::Raw(serde_json::Value::Null))
1059 }
1060 Command::Dev(DevCmd::Wait { seconds }) => {
1061 let _client = self.client_open(&cli).await?;
1062 if let Some(secs) = seconds {
1063 runtime::sleep(Duration::from_secs_f32(secs)).await;
1064 } else {
1065 pending::<()>().await;
1066 }
1067 Ok(CliOutput::Raw(serde_json::Value::Null))
1068 }
1069 Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1070 DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1071 url: invite_code.url(),
1072 federation_id: invite_code.federation_id(),
1073 }),
1074 DecodeType::Notes { notes, file } => {
1075 let notes = if let Some(notes) = notes {
1076 notes
1077 } else if let Some(file) = file {
1078 let notes_str =
1079 fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1080 OOBNotes::from_str(¬es_str).map_err_cli_msg("failed to decode notes")?
1081 } else {
1082 unreachable!("Clap enforces either notes or file being set");
1083 };
1084
1085 let notes_json = notes
1086 .notes_json()
1087 .map_err_cli_msg("failed to decode notes")?;
1088 Ok(CliOutput::Raw(notes_json))
1089 }
1090 DecodeType::Transaction { hex_string } => {
1091 let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1092 .map_err_cli_msg("failed to decode transaction")?;
1093
1094 let client = self.client_open(&cli).await?;
1095 let tx = fedimint_core::transaction::Transaction::from_bytes(
1096 &bytes,
1097 client.decoders(),
1098 )
1099 .map_err_cli_msg("failed to decode transaction")?;
1100
1101 Ok(CliOutput::DecodeTransaction {
1102 transaction: (format!("{tx:?}")),
1103 })
1104 }
1105 DecodeType::SetupCode { setup_code } => {
1106 let setup_code = PeerSetupCode::decode_base32(&setup_code)
1107 .map_err_cli_msg("failed to decode setup code")?;
1108
1109 Ok(CliOutput::SetupCode { setup_code })
1110 }
1111 },
1112 Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1113 EncodeType::InviteCode {
1114 url,
1115 federation_id,
1116 peer,
1117 api_secret,
1118 } => Ok(CliOutput::InviteCode {
1119 invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1120 }),
1121 EncodeType::Notes { notes_json } => {
1122 let notes = serde_json::from_str::<OOBNotesJson>(¬es_json)
1123 .map_err_cli_msg("invalid JSON for notes")?;
1124 let prefix =
1125 FederationIdPrefix::from_str(¬es.federation_id_prefix).map_err_cli()?;
1126 let notes = OOBNotes::new(prefix, notes.notes);
1127 Ok(CliOutput::Raw(notes.to_string().into()))
1128 }
1129 },
1130 Command::Dev(DevCmd::SessionCount) => {
1131 let client = self.client_open(&cli).await?;
1132 let count = client.api().session_count().await?;
1133 Ok(CliOutput::EpochCount { count })
1134 }
1135 Command::Dev(DevCmd::ConfigDecrypt {
1136 in_file,
1137 out_file,
1138 salt_file,
1139 password,
1140 }) => {
1141 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1142 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1143 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1144 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1145
1146 let mut out_file_handle = fs::File::options()
1147 .create_new(true)
1148 .write(true)
1149 .open(out_file)
1150 .expect("Could not create output cfg file");
1151 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1152 Ok(CliOutput::ConfigDecrypt)
1153 }
1154 Command::Dev(DevCmd::ConfigEncrypt {
1155 in_file,
1156 out_file,
1157 salt_file,
1158 password,
1159 }) => {
1160 let mut in_file_handle =
1161 fs::File::open(in_file).expect("Could not create output cfg file");
1162 let mut plaintext_bytes = vec![];
1163 in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
1164
1165 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1166 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1167 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1168 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1169 Ok(CliOutput::ConfigEncrypt)
1170 }
1171 Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1172 #[derive(Serialize)]
1173 struct ReactorLogState {
1174 active: bool,
1175 module_instance: ModuleInstanceId,
1176 creation_time: String,
1177 #[serde(skip_serializing_if = "Option::is_none")]
1178 end_time: Option<String>,
1179 state: String,
1180 }
1181
1182 let client = self.client_open(&cli).await?;
1183
1184 let (active_states, inactive_states) =
1185 client.executor().get_operation_states(operation_id).await;
1186 let all_states =
1187 active_states
1188 .into_iter()
1189 .map(|(active_state, active_meta)| ReactorLogState {
1190 active: true,
1191 module_instance: active_state.module_instance_id(),
1192 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1193 end_time: None,
1194 state: format!("{active_state:?}",),
1195 })
1196 .chain(inactive_states.into_iter().map(
1197 |(inactive_state, inactive_meta)| ReactorLogState {
1198 active: false,
1199 module_instance: inactive_state.module_instance_id(),
1200 creation_time: crate::client::time_to_iso8601(
1201 &inactive_meta.created_at,
1202 ),
1203 end_time: Some(crate::client::time_to_iso8601(
1204 &inactive_meta.exited_at,
1205 )),
1206 state: format!("{inactive_state:?}",),
1207 },
1208 ))
1209 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1210 .collect::<Vec<_>>();
1211
1212 Ok(CliOutput::Raw(json!({
1213 "states": all_states
1214 })))
1215 }
1216 Command::Dev(DevCmd::MetaFields) => {
1217 let client = self.client_open(&cli).await?;
1218 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1219
1220 let meta_fields = source
1221 .fetch(
1222 &client.config().await,
1223 &client.api_clone(),
1224 FetchKind::Initial,
1225 None,
1226 )
1227 .await
1228 .map_err_cli()?;
1229
1230 Ok(CliOutput::Raw(
1231 serde_json::to_value(meta_fields).expect("Can be encoded"),
1232 ))
1233 }
1234 Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1235 let client = self.client_open(&cli).await?;
1236 let version = client
1237 .api()
1238 .fedimintd_version(peer_id.into())
1239 .await
1240 .map_err_cli()?;
1241
1242 Ok(CliOutput::Raw(json!({ "version": version })))
1243 }
1244 Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1245 let client = self.client_open(&cli).await?;
1246
1247 let events: Vec<_> = client
1248 .get_event_log(pos, limit)
1249 .await
1250 .into_iter()
1251 .map(|v| {
1252 let module_id = v.module.as_ref().map(|m| m.1);
1253 let module_kind = v.module.map(|m| m.0);
1254 serde_json::json!({
1255 "id": v.event_id,
1256 "kind": v.event_kind,
1257 "module_kind": module_kind,
1258 "module_id": module_id,
1259 "ts": v.timestamp,
1260 "payload": v.value
1261 })
1262 })
1263 .collect();
1264
1265 Ok(CliOutput::Raw(
1266 serde_json::to_value(events).expect("Can be encoded"),
1267 ))
1268 }
1269 Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1270 let client = self.client_open(&cli).await?;
1271 let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1272 .map_err_cli()?;
1273 let tx_outcome = client
1274 .api()
1275 .submit_transaction(tx)
1276 .await
1277 .try_into_inner(client.decoders())
1278 .map_err_cli()?;
1279
1280 Ok(CliOutput::Raw(
1281 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1282 ))
1283 }
1284 Command::Completion { shell } => {
1285 let bin_path = PathBuf::from(
1286 std::env::args_os()
1287 .next()
1288 .expect("Binary name is always provided if we get this far"),
1289 );
1290 let bin_name = bin_path
1291 .file_name()
1292 .expect("path has file name")
1293 .to_string_lossy();
1294 clap_complete::generate(
1295 shell,
1296 &mut Opts::command(),
1297 bin_name.as_ref(),
1298 &mut std::io::stdout(),
1299 );
1300 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1302 }
1303 }
1304 }
1305
1306 async fn handle_admin_setup_command(
1307 &self,
1308 cli: Opts,
1309 args: SetupAdminArgs,
1310 ) -> anyhow::Result<Value> {
1311 let client = DynGlobalApi::from_setup_endpoint(args.endpoint.clone(), &None).await?;
1312
1313 match &args.subcommand {
1314 SetupAdminCmd::Status => {
1315 let status = client.setup_status(cli.auth()?).await?;
1316
1317 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1318 }
1319 SetupAdminCmd::SetLocalParams {
1320 name,
1321 federation_name,
1322 } => {
1323 let info = client
1324 .set_local_params(name.clone(), federation_name.clone(), cli.auth()?)
1325 .await?;
1326
1327 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1328 }
1329 SetupAdminCmd::AddPeer { info } => {
1330 let name = client
1331 .add_peer_connection_info(info.clone(), cli.auth()?)
1332 .await?;
1333
1334 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1335 }
1336 SetupAdminCmd::StartDkg => {
1337 client.start_dkg(cli.auth()?).await?;
1338
1339 Ok(Value::Null)
1340 }
1341 }
1342 }
1343}
1344
1345async fn log_expiration_notice(client: &Client) {
1346 client.get_meta_expiration_timestamp().await;
1347 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1348 match expiration_time.duration_since(fedimint_core::time::now()) {
1349 Ok(until_expiration) => {
1350 let days = until_expiration.as_secs() / (60 * 60 * 24);
1351
1352 if 90 < days {
1353 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1354 } else if 30 < days {
1355 info!(target: LOG_CLIENT, %days, "This federation will expire");
1356 } else {
1357 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1358 }
1359 }
1360 Err(_) => {
1361 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1362 }
1363 }
1364 }
1365}
1366async fn print_welcome_message(client: &Client) {
1367 if let Some(welcome_message) = client
1368 .meta_service()
1369 .get_field::<String>(client.db(), "welcome_message")
1370 .await
1371 .and_then(|v| v.value)
1372 {
1373 eprintln!("{welcome_message}");
1374 }
1375}
1376
1377fn salt_from_file_path(file_path: &Path) -> PathBuf {
1378 file_path
1379 .parent()
1380 .expect("File has no parent?!")
1381 .join(SALT_FILE)
1382}
1383
1384fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1386 let metadata: BTreeMap<String, String> = metadata
1387 .into_iter()
1388 .map(|item| {
1389 match &item
1390 .splitn(2, '=')
1391 .map(ToString::to_string)
1392 .collect::<Vec<String>>()[..]
1393 {
1394 [] => Err(format_err!("Empty metadata argument not allowed")),
1395 [key] => Err(format_err!("Metadata {key} is missing a value")),
1396 [key, val] => Ok((key.clone(), val.clone())),
1397 [..] => unreachable!(),
1398 }
1399 })
1400 .collect::<anyhow::Result<_>>()
1401 .map_err_cli_msg("invalid metadata")?;
1402 Ok(metadata)
1403}
1404
1405#[test]
1406fn metadata_from_clap_cli_test() {
1407 for (args, expected) in [
1408 (
1409 vec!["a=b".to_string()],
1410 BTreeMap::from([("a".into(), "b".into())]),
1411 ),
1412 (
1413 vec!["a=b".to_string(), "c=d".to_string()],
1414 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1415 ),
1416 ] {
1417 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1418 }
1419}