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