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, EventLogTrimableId};
53use fedimint_ln_client::LightningClientInit;
54use fedimint_logging::{LOG_CLIENT, TracingSetup};
55use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
56use fedimint_mint_client::{MintClientInit, MintClientModule, OOBNotes, SpendableNote};
57use fedimint_wallet_client::api::WalletFederationApi;
58use fedimint_wallet_client::{WalletClientInit, WalletClientModule};
59use futures::future::pending;
60use itertools::Itertools;
61use rand::thread_rng;
62use serde::{Deserialize, Serialize};
63use serde_json::{Value, json};
64use thiserror::Error;
65use tracing::{debug, info, warn};
66use utils::parse_peer_id;
67
68use crate::client::ClientCmd;
69use crate::envs::{FM_CLIENT_DIR_ENV, FM_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 ShowEventLogTrimable {
608 #[arg(long)]
609 pos: Option<EventLogId>,
610 #[arg(long, default_value = "10")]
611 limit: u64,
612 },
613 SubmitTransaction {
618 transaction: String,
620 },
621}
622
623#[derive(Debug, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625struct PayRequest {
626 notes: TieredMulti<SpendableNote>,
627 invoice: lightning_invoice::Bolt11Invoice,
628}
629
630pub struct FedimintCli {
631 module_inits: ClientModuleInitRegistry,
632 cli_args: Opts,
633}
634
635impl FedimintCli {
636 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
638 assert_eq!(
639 fedimint_build_code_version_env!().len(),
640 version_hash.len(),
641 "version_hash must have an expected length"
642 );
643
644 handle_version_hash_command(version_hash);
645
646 let cli_args = Opts::parse();
647 let base_level = if cli_args.verbose { "debug" } else { "info" };
648 TracingSetup::default()
649 .with_base_level(base_level)
650 .init()
651 .expect("tracing initializes");
652
653 let version = env!("CARGO_PKG_VERSION");
654 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
655
656 Ok(Self {
657 module_inits: ClientModuleInitRegistry::new(),
658 cli_args,
659 })
660 }
661
662 pub fn with_module<T>(mut self, r#gen: T) -> Self
663 where
664 T: ClientModuleInit + 'static + Send + Sync,
665 {
666 self.module_inits.attach(r#gen);
667 self
668 }
669
670 pub fn with_default_modules(self) -> Self {
671 self.with_module(LightningClientInit::default())
672 .with_module(MintClientInit)
673 .with_module(WalletClientInit::default())
674 .with_module(MetaClientInit)
675 .with_module(fedimint_lnv2_client::LightningClientInit::default())
676 }
677
678 pub async fn run(&mut self) {
679 match self.handle_command(self.cli_args.clone()).await {
680 Ok(output) => {
681 let _ = writeln!(std::io::stdout(), "{output}");
683 }
684 Err(err) => {
685 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
686 let _ = writeln!(std::io::stdout(), "{err}");
687 exit(1);
688 }
689 }
690 }
691
692 async fn make_client_builder(&self, cli: &Opts) -> CliResult<ClientBuilder> {
693 let db = cli.load_database().await?;
694 let mut client_builder = Client::builder(db).await.map_err_cli()?;
695 client_builder.with_module_inits(self.module_inits.clone());
696 client_builder.with_primary_module_kind(fedimint_mint_client::KIND);
697
698 client_builder.with_connector(cli.connector());
699
700 Ok(client_builder)
701 }
702
703 async fn client_join(
704 &mut self,
705 cli: &Opts,
706 invite_code: InviteCode,
707 ) -> CliResult<ClientHandleArc> {
708 let client_builder = self.make_client_builder(cli).await?;
709
710 let mnemonic = load_or_generate_mnemonic(client_builder.db_no_decoders()).await?;
711
712 let client = client_builder
713 .preview(&invite_code)
714 .await
715 .map_err_cli()?
716 .join(RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<
717 12,
718 >::to_root_secret(
719 &mnemonic
720 )))
721 .await
722 .map(Arc::new)
723 .map_err_cli()?;
724
725 print_welcome_message(&client).await;
726 log_expiration_notice(&client).await;
727
728 Ok(client)
729 }
730
731 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
732 let mut client_builder = self.make_client_builder(cli).await?;
733
734 if let Some(our_id) = cli.our_id {
735 client_builder.set_admin_creds(AdminCreds {
736 peer_id: our_id,
737 auth: cli.auth()?,
738 });
739 }
740
741 let mnemonic = Mnemonic::from_entropy(
742 &Client::load_decodable_client_secret::<Vec<u8>>(client_builder.db_no_decoders())
743 .await
744 .map_err_cli()?,
745 )
746 .map_err_cli()?;
747
748 let client = client_builder
749 .open(RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<
750 12,
751 >::to_root_secret(
752 &mnemonic
753 )))
754 .await
755 .map(Arc::new)
756 .map_err_cli()?;
757
758 log_expiration_notice(&client).await;
759
760 Ok(client)
761 }
762
763 async fn client_recover(
764 &mut self,
765 cli: &Opts,
766 mnemonic: Mnemonic,
767 invite_code: InviteCode,
768 ) -> CliResult<ClientHandleArc> {
769 let builder = self.make_client_builder(cli).await?;
770 match Client::load_decodable_client_secret_opt::<Vec<u8>>(builder.db_no_decoders())
771 .await
772 .map_err_cli()?
773 {
774 Some(existing) => {
775 if existing != mnemonic.to_entropy() {
776 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
777 }
778 }
779 None => {
780 Client::store_encodable_client_secret(
781 builder.db_no_decoders(),
782 mnemonic.to_entropy(),
783 )
784 .await
785 .map_err_cli()?;
786 }
787 }
788
789 let root_secret = RootSecret::StandardDoubleDerive(
790 Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
791 );
792 let client = builder
793 .preview(&invite_code)
794 .await
795 .map_err_cli()?
796 .recover(root_secret, None)
797 .await
798 .map(Arc::new)
799 .map_err_cli()?;
800
801 print_welcome_message(&client).await;
802 log_expiration_notice(&client).await;
803
804 Ok(client)
805 }
806
807 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
808 match cli.command.clone() {
809 Command::InviteCode { peer } => {
810 let client = self.client_open(&cli).await?;
811
812 let invite_code = client
813 .invite_code(peer)
814 .await
815 .ok_or_cli_msg("peer not found")?;
816
817 Ok(CliOutput::InviteCode { invite_code })
818 }
819 Command::JoinFederation { invite_code } => {
820 {
821 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
822 .map_err_cli_msg("invalid invite code")?;
823
824 let _client = self.client_join(&cli, invite_code).await?;
826 }
827
828 Ok(CliOutput::JoinFederation {
829 joined: invite_code,
830 })
831 }
832 Command::VersionHash => Ok(CliOutput::VersionHash {
833 hash: fedimint_build_code_version_env!().to_string(),
834 }),
835 Command::Client(ClientCmd::Restore {
836 mnemonic,
837 invite_code,
838 }) => {
839 let invite_code: InviteCode =
840 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
841 let mnemonic = Mnemonic::from_str(&mnemonic).map_err_cli()?;
842 let client = self.client_recover(&cli, mnemonic, invite_code).await?;
843
844 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
847 client.wait_for_all_recoveries().await.map_err_cli()?;
848
849 debug!(target: LOG_CLIENT, "Recovery complete");
850
851 Ok(CliOutput::Raw(serde_json::to_value(()).unwrap()))
852 }
853 Command::Client(command) => {
854 let client = self.client_open(&cli).await?;
855 Ok(CliOutput::Raw(
856 client::handle_command(command, client)
857 .await
858 .map_err_cli()?,
859 ))
860 }
861 Command::Admin(AdminCmd::Audit) => {
862 let client = self.client_open(&cli).await?;
863
864 let audit = cli
865 .admin_client(&client.get_peer_urls().await, client.api_secret())
866 .await?
867 .audit(cli.auth()?)
868 .await?;
869 Ok(CliOutput::Raw(
870 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
871 ))
872 }
873 Command::Admin(AdminCmd::Status) => {
874 let client = self.client_open(&cli).await?;
875
876 let status = cli
877 .admin_client(&client.get_peer_urls().await, client.api_secret())
878 .await?
879 .status()
880 .await?;
881 Ok(CliOutput::Raw(
882 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
883 ))
884 }
885 Command::Admin(AdminCmd::GuardianConfigBackup) => {
886 let client = self.client_open(&cli).await?;
887
888 let guardian_config_backup = cli
889 .admin_client(&client.get_peer_urls().await, client.api_secret())
890 .await?
891 .guardian_config_backup(cli.auth()?)
892 .await?;
893 Ok(CliOutput::Raw(
894 serde_json::to_value(guardian_config_backup)
895 .map_err_cli_msg("invalid response")?,
896 ))
897 }
898 Command::Admin(AdminCmd::Setup(dkg_args)) => self
899 .handle_admin_setup_command(cli, dkg_args)
900 .await
901 .map(CliOutput::Raw)
902 .map_err_cli_msg("Config Gen Error"),
903 Command::Admin(AdminCmd::SignApiAnnouncement {
904 api_url,
905 override_url,
906 }) => {
907 let client = self.client_open(&cli).await?;
908
909 if !["ws", "wss"].contains(&api_url.scheme()) {
910 return Err(CliError {
911 error: format!(
912 "Unsupported URL scheme {}, use ws:// or wss://",
913 api_url.scheme()
914 ),
915 });
916 }
917
918 let announcement = cli
919 .admin_client(
920 &override_url
921 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
922 .unwrap_or(client.get_peer_urls().await),
923 client.api_secret(),
924 )
925 .await?
926 .sign_api_announcement(api_url, cli.auth()?)
927 .await?;
928
929 Ok(CliOutput::Raw(
930 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
931 ))
932 }
933 Command::Admin(AdminCmd::Shutdown { session_idx }) => {
934 let client = self.client_open(&cli).await?;
935
936 cli.admin_client(&client.get_peer_urls().await, client.api_secret())
937 .await?
938 .shutdown(Some(session_idx), cli.auth()?)
939 .await?;
940
941 Ok(CliOutput::Raw(json!(null)))
942 }
943 Command::Admin(AdminCmd::BackupStatistics) => {
944 let client = self.client_open(&cli).await?;
945
946 let backup_statistics = cli
947 .admin_client(&client.get_peer_urls().await, client.api_secret())
948 .await?
949 .backup_statistics(cli.auth()?)
950 .await?;
951
952 Ok(CliOutput::Raw(
953 serde_json::to_value(backup_statistics).expect("Can be encoded"),
954 ))
955 }
956 Command::Dev(DevCmd::Api {
957 method,
958 params,
959 peer_id,
960 password: auth,
961 module,
962 }) => {
963 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
966 debug!(
967 target: LOG_CLIENT,
968 "Failed to serialize params:{}. Converting it to JSON string",
969 err
970 );
971
972 serde_json::Value::String(params)
973 });
974
975 let mut params = ApiRequestErased::new(params);
976 if let Some(auth) = auth {
977 params = params.with_auth(ApiAuth(auth));
978 }
979 let client = self.client_open(&cli).await?;
980
981 let api = client.api_clone();
982
983 let module_api = match module {
984 Some(selector) => {
985 Some(api.with_module(selector.resolve(&client).map_err_cli()?))
986 }
987 None => None,
988 };
989
990 let response: Value = match (peer_id, module_api) {
991 (Some(peer_id), Some(module_api)) => module_api
992 .request_raw(peer_id.into(), &method, ¶ms)
993 .await
994 .map_err_cli()?,
995 (Some(peer_id), None) => api
996 .request_raw(peer_id.into(), &method, ¶ms)
997 .await
998 .map_err_cli()?,
999 (None, Some(module_api)) => module_api
1000 .request_current_consensus(method, params)
1001 .await
1002 .map_err_cli()?,
1003 (None, None) => api
1004 .request_current_consensus(method, params)
1005 .await
1006 .map_err_cli()?,
1007 };
1008
1009 Ok(CliOutput::UntypedApiOutput { value: response })
1010 }
1011 Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
1012 let client = self.client_open(&cli).await?;
1013
1014 let mint = client
1015 .get_first_module::<MintClientModule>()
1016 .map_err_cli_msg("can't get mint module")?;
1017
1018 for _ in 0..count {
1019 mint.advance_note_idx(amount)
1020 .await
1021 .map_err_cli_msg("failed to advance the note_idx")?;
1022 }
1023
1024 Ok(CliOutput::Raw(serde_json::Value::Null))
1025 }
1026 Command::Dev(DevCmd::ApiAnnouncements) => {
1027 let client = self.client_open(&cli).await?;
1028 let announcements = client.get_peer_url_announcements().await;
1029 Ok(CliOutput::Raw(
1030 serde_json::to_value(announcements).expect("Can be encoded"),
1031 ))
1032 }
1033 Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1034 "wait_block_count",
1035 backoff_util::custom_backoff(
1036 Duration::from_millis(100),
1037 Duration::from_secs(5),
1038 None,
1039 ),
1040 || async {
1041 let client = self.client_open(&cli).await?;
1042 let wallet = client.get_first_module::<WalletClientModule>()?;
1043 let count = client
1044 .api()
1045 .with_module(wallet.id)
1046 .fetch_consensus_block_count()
1047 .await?;
1048 if count >= target {
1049 Ok(CliOutput::WaitBlockCount { reached: count })
1050 } else {
1051 info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1052 Err(format_err!("target not reached"))
1053 }
1054 },
1055 )
1056 .await
1057 .map_err_cli(),
1058
1059 Command::Dev(DevCmd::WaitComplete) => {
1060 let client = self.client_open(&cli).await?;
1061 client
1062 .wait_for_all_active_state_machines()
1063 .await
1064 .map_err_cli_msg("failed to wait for all active state machines")?;
1065 Ok(CliOutput::Raw(serde_json::Value::Null))
1066 }
1067 Command::Dev(DevCmd::Wait { seconds }) => {
1068 let _client = self.client_open(&cli).await?;
1069 if let Some(secs) = seconds {
1070 runtime::sleep(Duration::from_secs_f32(secs)).await;
1071 } else {
1072 pending::<()>().await;
1073 }
1074 Ok(CliOutput::Raw(serde_json::Value::Null))
1075 }
1076 Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1077 DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1078 url: invite_code.url(),
1079 federation_id: invite_code.federation_id(),
1080 }),
1081 DecodeType::Notes { notes, file } => {
1082 let notes = if let Some(notes) = notes {
1083 notes
1084 } else if let Some(file) = file {
1085 let notes_str =
1086 fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1087 OOBNotes::from_str(¬es_str).map_err_cli_msg("failed to decode notes")?
1088 } else {
1089 unreachable!("Clap enforces either notes or file being set");
1090 };
1091
1092 let notes_json = notes
1093 .notes_json()
1094 .map_err_cli_msg("failed to decode notes")?;
1095 Ok(CliOutput::Raw(notes_json))
1096 }
1097 DecodeType::Transaction { hex_string } => {
1098 let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1099 .map_err_cli_msg("failed to decode transaction")?;
1100
1101 let client = self.client_open(&cli).await?;
1102 let tx = fedimint_core::transaction::Transaction::from_bytes(
1103 &bytes,
1104 client.decoders(),
1105 )
1106 .map_err_cli_msg("failed to decode transaction")?;
1107
1108 Ok(CliOutput::DecodeTransaction {
1109 transaction: (format!("{tx:?}")),
1110 })
1111 }
1112 DecodeType::SetupCode { setup_code } => {
1113 let setup_code = PeerSetupCode::decode_base32(&setup_code)
1114 .map_err_cli_msg("failed to decode setup code")?;
1115
1116 Ok(CliOutput::SetupCode { setup_code })
1117 }
1118 },
1119 Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1120 EncodeType::InviteCode {
1121 url,
1122 federation_id,
1123 peer,
1124 api_secret,
1125 } => Ok(CliOutput::InviteCode {
1126 invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1127 }),
1128 EncodeType::Notes { notes_json } => {
1129 let notes = serde_json::from_str::<OOBNotesJson>(¬es_json)
1130 .map_err_cli_msg("invalid JSON for notes")?;
1131 let prefix =
1132 FederationIdPrefix::from_str(¬es.federation_id_prefix).map_err_cli()?;
1133 let notes = OOBNotes::new(prefix, notes.notes);
1134 Ok(CliOutput::Raw(notes.to_string().into()))
1135 }
1136 },
1137 Command::Dev(DevCmd::SessionCount) => {
1138 let client = self.client_open(&cli).await?;
1139 let count = client.api().session_count().await?;
1140 Ok(CliOutput::EpochCount { count })
1141 }
1142 Command::Dev(DevCmd::ConfigDecrypt {
1143 in_file,
1144 out_file,
1145 salt_file,
1146 password,
1147 }) => {
1148 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1149 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1150 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1151 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1152
1153 let mut out_file_handle = fs::File::options()
1154 .create_new(true)
1155 .write(true)
1156 .open(out_file)
1157 .expect("Could not create output cfg file");
1158 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1159 Ok(CliOutput::ConfigDecrypt)
1160 }
1161 Command::Dev(DevCmd::ConfigEncrypt {
1162 in_file,
1163 out_file,
1164 salt_file,
1165 password,
1166 }) => {
1167 let mut in_file_handle =
1168 fs::File::open(in_file).expect("Could not create output cfg file");
1169 let mut plaintext_bytes = vec![];
1170 in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
1171
1172 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1173 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1174 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1175 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1176 Ok(CliOutput::ConfigEncrypt)
1177 }
1178 Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1179 #[derive(Serialize)]
1180 struct ReactorLogState {
1181 active: bool,
1182 module_instance: ModuleInstanceId,
1183 creation_time: String,
1184 #[serde(skip_serializing_if = "Option::is_none")]
1185 end_time: Option<String>,
1186 state: String,
1187 }
1188
1189 let client = self.client_open(&cli).await?;
1190
1191 let (active_states, inactive_states) =
1192 client.executor().get_operation_states(operation_id).await;
1193 let all_states =
1194 active_states
1195 .into_iter()
1196 .map(|(active_state, active_meta)| ReactorLogState {
1197 active: true,
1198 module_instance: active_state.module_instance_id(),
1199 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1200 end_time: None,
1201 state: format!("{active_state:?}",),
1202 })
1203 .chain(inactive_states.into_iter().map(
1204 |(inactive_state, inactive_meta)| ReactorLogState {
1205 active: false,
1206 module_instance: inactive_state.module_instance_id(),
1207 creation_time: crate::client::time_to_iso8601(
1208 &inactive_meta.created_at,
1209 ),
1210 end_time: Some(crate::client::time_to_iso8601(
1211 &inactive_meta.exited_at,
1212 )),
1213 state: format!("{inactive_state:?}",),
1214 },
1215 ))
1216 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1217 .collect::<Vec<_>>();
1218
1219 Ok(CliOutput::Raw(json!({
1220 "states": all_states
1221 })))
1222 }
1223 Command::Dev(DevCmd::MetaFields) => {
1224 let client = self.client_open(&cli).await?;
1225 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1226
1227 let meta_fields = source
1228 .fetch(
1229 &client.config().await,
1230 &client.api_clone(),
1231 FetchKind::Initial,
1232 None,
1233 )
1234 .await
1235 .map_err_cli()?;
1236
1237 Ok(CliOutput::Raw(
1238 serde_json::to_value(meta_fields).expect("Can be encoded"),
1239 ))
1240 }
1241 Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1242 let client = self.client_open(&cli).await?;
1243 let version = client
1244 .api()
1245 .fedimintd_version(peer_id.into())
1246 .await
1247 .map_err_cli()?;
1248
1249 Ok(CliOutput::Raw(json!({ "version": version })))
1250 }
1251 Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1252 let client = self.client_open(&cli).await?;
1253
1254 let events: Vec<_> = client
1255 .get_event_log(pos, limit)
1256 .await
1257 .into_iter()
1258 .map(|v| {
1259 let module_id = v.module.as_ref().map(|m| m.1);
1260 let module_kind = v.module.map(|m| m.0);
1261 serde_json::json!({
1262 "id": v.event_id,
1263 "kind": v.event_kind,
1264 "module_kind": module_kind,
1265 "module_id": module_id,
1266 "ts": v.timestamp,
1267 "payload": v.value
1268 })
1269 })
1270 .collect();
1271
1272 Ok(CliOutput::Raw(
1273 serde_json::to_value(events).expect("Can be encoded"),
1274 ))
1275 }
1276 Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1277 let client = self.client_open(&cli).await?;
1278
1279 let events: Vec<_> = client
1280 .get_event_log_trimable(
1281 pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1282 limit,
1283 )
1284 .await
1285 .into_iter()
1286 .map(|v| {
1287 let module_id = v.module.as_ref().map(|m| m.1);
1288 let module_kind = v.module.map(|m| m.0);
1289 serde_json::json!({
1290 "id": v.event_id,
1291 "kind": v.event_kind,
1292 "module_kind": module_kind,
1293 "module_id": module_id,
1294 "ts": v.timestamp,
1295 "payload": v.value
1296 })
1297 })
1298 .collect();
1299
1300 Ok(CliOutput::Raw(
1301 serde_json::to_value(events).expect("Can be encoded"),
1302 ))
1303 }
1304 Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1305 let client = self.client_open(&cli).await?;
1306 let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1307 .map_err_cli()?;
1308 let tx_outcome = client
1309 .api()
1310 .submit_transaction(tx)
1311 .await
1312 .try_into_inner(client.decoders())
1313 .map_err_cli()?;
1314
1315 Ok(CliOutput::Raw(
1316 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1317 ))
1318 }
1319 Command::Completion { shell } => {
1320 let bin_path = PathBuf::from(
1321 std::env::args_os()
1322 .next()
1323 .expect("Binary name is always provided if we get this far"),
1324 );
1325 let bin_name = bin_path
1326 .file_name()
1327 .expect("path has file name")
1328 .to_string_lossy();
1329 clap_complete::generate(
1330 shell,
1331 &mut Opts::command(),
1332 bin_name.as_ref(),
1333 &mut std::io::stdout(),
1334 );
1335 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1337 }
1338 }
1339 }
1340
1341 async fn handle_admin_setup_command(
1342 &self,
1343 cli: Opts,
1344 args: SetupAdminArgs,
1345 ) -> anyhow::Result<Value> {
1346 let client = DynGlobalApi::from_setup_endpoint(args.endpoint.clone(), &None).await?;
1347
1348 match &args.subcommand {
1349 SetupAdminCmd::Status => {
1350 let status = client.setup_status(cli.auth()?).await?;
1351
1352 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1353 }
1354 SetupAdminCmd::SetLocalParams {
1355 name,
1356 federation_name,
1357 } => {
1358 let info = client
1359 .set_local_params(name.clone(), federation_name.clone(), cli.auth()?)
1360 .await?;
1361
1362 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1363 }
1364 SetupAdminCmd::AddPeer { info } => {
1365 let name = client
1366 .add_peer_connection_info(info.clone(), cli.auth()?)
1367 .await?;
1368
1369 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1370 }
1371 SetupAdminCmd::StartDkg => {
1372 client.start_dkg(cli.auth()?).await?;
1373
1374 Ok(Value::Null)
1375 }
1376 }
1377 }
1378}
1379
1380async fn log_expiration_notice(client: &Client) {
1381 client.get_meta_expiration_timestamp().await;
1382 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1383 match expiration_time.duration_since(fedimint_core::time::now()) {
1384 Ok(until_expiration) => {
1385 let days = until_expiration.as_secs() / (60 * 60 * 24);
1386
1387 if 90 < days {
1388 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1389 } else if 30 < days {
1390 info!(target: LOG_CLIENT, %days, "This federation will expire");
1391 } else {
1392 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1393 }
1394 }
1395 Err(_) => {
1396 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1397 }
1398 }
1399 }
1400}
1401async fn print_welcome_message(client: &Client) {
1402 if let Some(welcome_message) = client
1403 .meta_service()
1404 .get_field::<String>(client.db(), "welcome_message")
1405 .await
1406 .and_then(|v| v.value)
1407 {
1408 eprintln!("{welcome_message}");
1409 }
1410}
1411
1412fn salt_from_file_path(file_path: &Path) -> PathBuf {
1413 file_path
1414 .parent()
1415 .expect("File has no parent?!")
1416 .join(SALT_FILE)
1417}
1418
1419fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1421 let metadata: BTreeMap<String, String> = metadata
1422 .into_iter()
1423 .map(|item| {
1424 match &item
1425 .splitn(2, '=')
1426 .map(ToString::to_string)
1427 .collect::<Vec<String>>()[..]
1428 {
1429 [] => Err(format_err!("Empty metadata argument not allowed")),
1430 [key] => Err(format_err!("Metadata {key} is missing a value")),
1431 [key, val] => Ok((key.clone(), val.clone())),
1432 [..] => unreachable!(),
1433 }
1434 })
1435 .collect::<anyhow::Result<_>>()
1436 .map_err_cli_msg("invalid metadata")?;
1437 Ok(metadata)
1438}
1439
1440#[test]
1441fn metadata_from_clap_cli_test() {
1442 for (args, expected) in [
1443 (
1444 vec!["a=b".to_string()],
1445 BTreeMap::from([("a".into(), "b".into())]),
1446 ),
1447 (
1448 vec!["a=b".to_string(), "c=d".to_string()],
1449 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1450 ),
1451 ] {
1452 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1453 }
1454}