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;
13mod db;
14pub mod envs;
15mod utils;
16
17use core::fmt;
18use std::collections::BTreeMap;
19use std::fmt::Debug;
20use std::io::{IsTerminal, Read, Write};
21use std::path::{Path, PathBuf};
22use std::process::exit;
23use std::str::FromStr;
24use std::sync::Arc;
25use std::time::Duration;
26use std::{fs, result};
27
28use anyhow::{Context, format_err};
29use clap::{Args, CommandFactory, Parser, Subcommand};
30use client::ModuleSelector;
31#[cfg(feature = "tor")]
32use envs::FM_USE_TOR_ENV;
33use envs::{FM_API_SECRET_ENV, FM_DB_BACKEND_ENV, FM_IROH_ENABLE_DHT_ENV, SALT_FILE};
34use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
35use fedimint_api_client::api::{DynGlobalApi, FederationApiExt, FederationError};
36use fedimint_bip39::{Bip39RootSecretStrategy, Mnemonic};
37use fedimint_client::db::ApiSecretKey;
38use fedimint_client::module::meta::{FetchKind, LegacyMetaSource, MetaSource};
39use fedimint_client::module::module::init::ClientModuleInit;
40use fedimint_client::module_init::ClientModuleInitRegistry;
41use fedimint_client::secret::RootSecretStrategy;
42use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc, RootSecret};
43use fedimint_connectors::ConnectorRegistry;
44use fedimint_core::base32::FEDIMINT_PREFIX;
45use fedimint_core::config::{FederationId, FederationIdPrefix};
46use fedimint_core::core::{ModuleInstanceId, OperationId};
47use fedimint_core::db::{Database, DatabaseValue, IDatabaseTransactionOpsCoreTyped as _};
48use fedimint_core::encoding::Decodable;
49use fedimint_core::invite_code::InviteCode;
50use fedimint_core::module::{ApiAuth, ApiRequestErased};
51use fedimint_core::setup_code::PeerSetupCode;
52use fedimint_core::transaction::Transaction;
53use fedimint_core::util::{SafeUrl, backoff_util, handle_version_hash_command, retry};
54use fedimint_core::{
55 Amount, PeerId, TieredMulti, base32, fedimint_build_code_version_env, runtime,
56};
57use fedimint_eventlog::{EventLogId, EventLogTrimableId};
58use fedimint_ln_client::LightningClientInit;
59use fedimint_logging::{LOG_CLIENT, TracingSetup};
60use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
61use fedimint_mint_client::{MintClientInit, MintClientModule, OOBNotes, SpendableNote};
62use fedimint_wallet_client::api::WalletFederationApi;
63use fedimint_wallet_client::{WalletClientInit, WalletClientModule};
64use futures::future::pending;
65use itertools::Itertools;
66use rand::thread_rng;
67use serde::{Deserialize, Serialize};
68use serde_json::{Value, json};
69use thiserror::Error;
70use tracing::{debug, info, warn};
71use utils::parse_peer_id;
72
73use crate::client::ClientCmd;
74use crate::db::{StoredAdminCreds, load_admin_creds, store_admin_creds};
75use crate::envs::{FM_CLIENT_DIR_ENV, FM_IROH_ENABLE_NEXT_ENV, FM_OUR_ID_ENV, FM_PASSWORD_ENV};
76
77#[derive(Serialize)]
79#[serde(rename_all = "snake_case")]
80#[serde(untagged)]
81enum CliOutput {
82 VersionHash {
83 hash: String,
84 },
85
86 UntypedApiOutput {
87 value: Value,
88 },
89
90 WaitBlockCount {
91 reached: u64,
92 },
93
94 InviteCode {
95 invite_code: InviteCode,
96 },
97
98 DecodeInviteCode {
99 url: SafeUrl,
100 federation_id: FederationId,
101 },
102
103 JoinFederation {
104 joined: String,
105 },
106
107 DecodeTransaction {
108 transaction: String,
109 },
110
111 EpochCount {
112 count: u64,
113 },
114
115 ConfigDecrypt,
116
117 ConfigEncrypt,
118
119 SetupCode {
120 setup_code: PeerSetupCode,
121 },
122
123 Raw(serde_json::Value),
124}
125
126impl fmt::Display for CliOutput {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 write!(f, "{}", serde_json::to_string_pretty(self).unwrap())
129 }
130}
131
132type CliResult<E> = Result<E, CliError>;
134
135type CliOutputResult = Result<CliOutput, CliError>;
137
138#[derive(Serialize, Error)]
140#[serde(tag = "error", rename_all(serialize = "snake_case"))]
141struct CliError {
142 error: String,
143}
144
145trait CliResultExt<O, E> {
148 fn map_err_cli(self) -> Result<O, CliError>;
150 fn map_err_cli_msg(self, msg: impl fmt::Display + Send + Sync + 'static)
152 -> Result<O, CliError>;
153}
154
155impl<O, E> CliResultExt<O, E> for result::Result<O, E>
156where
157 E: Into<anyhow::Error>,
158{
159 fn map_err_cli(self) -> Result<O, CliError> {
160 self.map_err(|e| {
161 let e = e.into();
162 CliError {
163 error: format!("{e:#}"),
164 }
165 })
166 }
167
168 fn map_err_cli_msg(
169 self,
170 msg: impl fmt::Display + Send + Sync + 'static,
171 ) -> Result<O, CliError> {
172 self.map_err(|e| Into::<anyhow::Error>::into(e))
173 .context(msg)
174 .map_err(|e| CliError {
175 error: format!("{e:#}"),
176 })
177 }
178}
179
180trait CliOptionExt<O> {
183 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
184}
185
186impl<O> CliOptionExt<O> for Option<O> {
187 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
188 self.ok_or_else(|| CliError { error: msg.into() })
189 }
190}
191
192impl From<FederationError> for CliError {
194 fn from(e: FederationError) -> Self {
195 CliError {
196 error: e.to_string(),
197 }
198 }
199}
200
201impl Debug for CliError {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.debug_struct("CliError")
204 .field("error", &self.error)
205 .finish()
206 }
207}
208
209impl fmt::Display for CliError {
210 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
211 let json = serde_json::to_value(self).expect("CliError is valid json");
212 let json_as_string =
213 serde_json::to_string_pretty(&json).expect("valid json is serializable");
214 write!(f, "{json_as_string}")
215 }
216}
217
218#[derive(Debug, Clone, Copy, clap::ValueEnum)]
219enum DatabaseBackend {
220 #[value(name = "rocksdb")]
222 RocksDb,
223 #[value(name = "cursed-redb")]
225 CursedRedb,
226}
227
228#[derive(Parser, Clone)]
229#[command(version)]
230struct Opts {
231 #[arg(long = "data-dir", env = FM_CLIENT_DIR_ENV)]
233 data_dir: Option<PathBuf>,
234
235 #[arg(env = FM_OUR_ID_ENV, long, value_parser = parse_peer_id)]
237 our_id: Option<PeerId>,
238
239 #[arg(long, env = FM_PASSWORD_ENV)]
241 password: Option<String>,
242
243 #[cfg(feature = "tor")]
244 #[arg(long, env = FM_USE_TOR_ENV)]
246 use_tor: bool,
247
248 #[arg(long, env = FM_IROH_ENABLE_DHT_ENV)]
250 iroh_enable_dht: Option<bool>,
251
252 #[arg(long, env = FM_IROH_ENABLE_NEXT_ENV)]
254 iroh_enable_next: Option<bool>,
255
256 #[arg(long, env = FM_DB_BACKEND_ENV, value_enum, default_value = "rocksdb")]
258 db_backend: DatabaseBackend,
259
260 #[arg(short = 'v', long)]
263 verbose: bool,
264
265 #[clap(subcommand)]
266 command: Command,
267}
268
269impl Opts {
270 fn data_dir(&self) -> CliResult<&PathBuf> {
271 self.data_dir
272 .as_ref()
273 .ok_or_cli_msg("`--data-dir=` argument not set.")
274 }
275
276 async fn data_dir_create(&self) -> CliResult<&PathBuf> {
278 let dir = self.data_dir()?;
279
280 tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
281
282 Ok(dir)
283 }
284 fn iroh_enable_dht(&self) -> bool {
285 self.iroh_enable_dht.unwrap_or(true)
286 }
287
288 fn iroh_enable_next(&self) -> bool {
289 self.iroh_enable_next.unwrap_or(true)
290 }
291
292 fn use_tor(&self) -> bool {
293 #[cfg(feature = "tor")]
294 return self.use_tor;
295 #[cfg(not(feature = "tor"))]
296 false
297 }
298
299 async fn admin_client(
300 &self,
301 peer_urls: &BTreeMap<PeerId, SafeUrl>,
302 api_secret: Option<&str>,
303 ) -> CliResult<DynGlobalApi> {
304 self.admin_client_with_db(peer_urls, api_secret, None).await
305 }
306
307 async fn admin_client_with_db(
308 &self,
309 peer_urls: &BTreeMap<PeerId, SafeUrl>,
310 api_secret: Option<&str>,
311 db: Option<&Database>,
312 ) -> CliResult<DynGlobalApi> {
313 let our_id = if let Some(id) = self.our_id {
315 id
316 } else if let Some(db) = db {
317 if let Some(stored_creds) = load_admin_creds(db).await {
318 stored_creds.peer_id
319 } else {
320 return Err(CliError {
321 error: "Admin client needs our-id set (no stored credentials found)"
322 .to_string(),
323 });
324 }
325 } else {
326 return Err(CliError {
327 error: "Admin client needs our-id set".to_string(),
328 });
329 };
330
331 DynGlobalApi::new_admin(
332 self.make_endpoints().await.map_err(|e| CliError {
333 error: e.to_string(),
334 })?,
335 our_id,
336 peer_urls
337 .get(&our_id)
338 .cloned()
339 .context("Our peer URL not found in config")
340 .map_err_cli()?,
341 api_secret,
342 )
343 .map_err_cli()
344 }
345
346 async fn make_endpoints(&self) -> Result<ConnectorRegistry, anyhow::Error> {
347 ConnectorRegistry::build_from_client_defaults()
348 .iroh_next(self.iroh_enable_next())
349 .iroh_pkarr_dht(self.iroh_enable_dht())
350 .ws_force_tor(self.use_tor())
351 .bind()
352 .await
353 }
354
355 fn auth(&self) -> CliResult<ApiAuth> {
356 let password = self
357 .password
358 .clone()
359 .ok_or_cli_msg("CLI needs password set")?;
360 Ok(ApiAuth(password))
361 }
362
363 async fn load_database(&self) -> CliResult<Database> {
364 debug!(target: LOG_CLIENT, "Loading client database");
365 let db_path = self.data_dir_create().await?.join("client.db");
366 match self.db_backend {
367 DatabaseBackend::RocksDb => {
368 debug!(target: LOG_CLIENT, "Using RocksDB database backend");
369 Ok(fedimint_rocksdb::RocksDb::build(db_path)
370 .open()
371 .await
372 .map_err_cli_msg("could not open rocksdb database")?
373 .into())
374 }
375 DatabaseBackend::CursedRedb => {
376 debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
377 Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
378 .await
379 .map_err_cli_msg("could not open cursed redb database")?
380 .into())
381 }
382 }
383 }
384}
385
386async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
387 Ok(
388 if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
389 Mnemonic::from_entropy(&entropy).map_err_cli()?
390 } else {
391 debug!(
392 target: LOG_CLIENT,
393 "Generating mnemonic and writing entropy to client storage"
394 );
395 let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
396 Client::store_encodable_client_secret(db, mnemonic.to_entropy())
397 .await
398 .map_err_cli()?;
399 mnemonic
400 },
401 )
402}
403
404#[derive(Subcommand, Clone)]
405enum Command {
406 VersionHash,
408
409 #[clap(flatten)]
410 Client(client::ClientCmd),
411
412 #[clap(subcommand)]
413 Admin(AdminCmd),
414
415 #[clap(subcommand)]
416 Dev(DevCmd),
417
418 InviteCode {
420 peer: PeerId,
421 },
422
423 JoinFederation {
425 invite_code: String,
426 },
427
428 Completion {
429 shell: clap_complete::Shell,
430 },
431}
432
433#[allow(clippy::large_enum_variant)]
434#[derive(Debug, Clone, Subcommand)]
435enum AdminCmd {
436 Auth {
444 #[arg(long, env = FM_OUR_ID_ENV)]
446 peer_id: u16,
447 #[arg(long, env = FM_PASSWORD_ENV)]
449 password: String,
450 #[arg(long)]
452 no_verify: bool,
453 #[arg(long)]
455 force: bool,
456 },
457
458 Status,
460
461 Audit,
463
464 GuardianConfigBackup,
466
467 Setup(SetupAdminArgs),
468 SignApiAnnouncement {
471 api_url: SafeUrl,
473 #[clap(long)]
476 override_url: Option<SafeUrl>,
477 },
478 SignGuardianMetadata {
480 #[clap(long, value_delimiter = ',')]
482 api_urls: Vec<SafeUrl>,
483 #[clap(long)]
485 pkarr_id: String,
486 },
487 Shutdown {
489 session_idx: u64,
491 },
492 BackupStatistics,
494 ChangePassword {
497 new_password: String,
499 },
500}
501
502#[derive(Debug, Clone, Args)]
503struct SetupAdminArgs {
504 endpoint: SafeUrl,
505
506 #[clap(subcommand)]
507 subcommand: SetupAdminCmd,
508}
509
510#[derive(Debug, Clone, Subcommand)]
511enum SetupAdminCmd {
512 Status,
513 SetLocalParams {
514 name: String,
515 #[clap(long)]
516 federation_name: Option<String>,
517 },
518 AddPeer {
519 info: String,
520 },
521 StartDkg,
522}
523
524#[derive(Debug, Clone, Subcommand)]
525enum DecodeType {
526 InviteCode { invite_code: InviteCode },
528 #[group(required = true, multiple = false)]
530 Notes {
531 notes: Option<OOBNotes>,
533 #[arg(long)]
535 file: Option<PathBuf>,
536 },
537 Transaction { hex_string: String },
539 SetupCode { setup_code: String },
542}
543
544#[derive(Debug, Clone, Deserialize, Serialize)]
545struct OOBNotesJson {
546 federation_id_prefix: String,
547 notes: TieredMulti<SpendableNote>,
548}
549
550#[derive(Debug, Clone, Subcommand)]
551enum EncodeType {
552 InviteCode {
554 #[clap(long)]
555 url: SafeUrl,
556 #[clap(long = "federation_id")]
557 federation_id: FederationId,
558 #[clap(long = "peer")]
559 peer: PeerId,
560 #[arg(env = FM_API_SECRET_ENV)]
561 api_secret: Option<String>,
562 },
563
564 Notes { notes_json: String },
566}
567
568#[derive(Debug, Clone, Subcommand)]
569enum DevCmd {
570 #[command(after_long_help = r#"
574Examples:
575
576 fedimint-cli dev api --peer-id 0 config '"fed114znk7uk7ppugdjuytr8venqf2tkywd65cqvg3u93um64tu5cw4yr0n3fvn7qmwvm4g48cpndgnm4gqq4waen5te0xyerwt3s9cczuvf6xyurzde597s7crdvsk2vmyarjw9gwyqjdzj"'
577 "#)]
578 Api {
579 method: String,
581 #[clap(default_value = "null")]
586 params: String,
587 #[clap(long = "peer-id")]
589 peer_id: Option<u16>,
590
591 #[clap(long = "module")]
593 module: Option<ModuleSelector>,
594
595 #[clap(long, requires = "peer_id")]
598 password: Option<String>,
599 },
600
601 ApiAnnouncements,
602
603 GuardianMetadata,
604
605 AdvanceNoteIdx {
607 #[clap(long, default_value = "1")]
608 count: usize,
609
610 #[clap(long)]
611 amount: Amount,
612 },
613
614 WaitBlockCount {
616 count: u64,
617 },
618
619 Wait {
621 seconds: Option<f32>,
623 },
624
625 WaitComplete,
627
628 Decode {
630 #[clap(subcommand)]
631 decode_type: DecodeType,
632 },
633
634 Encode {
636 #[clap(subcommand)]
637 encode_type: EncodeType,
638 },
639
640 SessionCount,
642
643 ConfigDecrypt {
644 #[arg(long = "in-file")]
646 in_file: PathBuf,
647 #[arg(long = "out-file")]
649 out_file: PathBuf,
650 #[arg(long = "salt-file")]
653 salt_file: Option<PathBuf>,
654 #[arg(env = FM_PASSWORD_ENV)]
656 password: String,
657 },
658
659 ConfigEncrypt {
660 #[arg(long = "in-file")]
662 in_file: PathBuf,
663 #[arg(long = "out-file")]
665 out_file: PathBuf,
666 #[arg(long = "salt-file")]
669 salt_file: Option<PathBuf>,
670 #[arg(env = FM_PASSWORD_ENV)]
672 password: String,
673 },
674
675 ListOperationStates {
678 operation_id: OperationId,
679 },
680 MetaFields,
684 PeerVersion {
686 #[clap(long)]
687 peer_id: u16,
688 },
689 ShowEventLog {
691 #[arg(long)]
692 pos: Option<EventLogId>,
693 #[arg(long, default_value = "10")]
694 limit: u64,
695 },
696 ShowEventLogTrimable {
698 #[arg(long)]
699 pos: Option<EventLogId>,
700 #[arg(long, default_value = "10")]
701 limit: u64,
702 },
703 TestEventLogHandling,
706 SubmitTransaction {
711 transaction: String,
713 },
714 ChainId,
717}
718
719#[derive(Debug, Serialize, Deserialize)]
720#[serde(rename_all = "snake_case")]
721struct PayRequest {
722 notes: TieredMulti<SpendableNote>,
723 invoice: lightning_invoice::Bolt11Invoice,
724}
725
726pub struct FedimintCli {
727 module_inits: ClientModuleInitRegistry,
728 cli_args: Opts,
729}
730
731impl FedimintCli {
732 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
734 assert_eq!(
735 fedimint_build_code_version_env!().len(),
736 version_hash.len(),
737 "version_hash must have an expected length"
738 );
739
740 handle_version_hash_command(version_hash);
741
742 let cli_args = Opts::parse();
743 let base_level = if cli_args.verbose { "debug" } else { "info" };
744 TracingSetup::default()
745 .with_base_level(base_level)
746 .init()
747 .expect("tracing initializes");
748
749 let version = env!("CARGO_PKG_VERSION");
750 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
751
752 Ok(Self {
753 module_inits: ClientModuleInitRegistry::new(),
754 cli_args,
755 })
756 }
757
758 pub fn with_module<T>(mut self, r#gen: T) -> Self
759 where
760 T: ClientModuleInit + 'static + Send + Sync,
761 {
762 self.module_inits.attach(r#gen);
763 self
764 }
765
766 pub fn with_default_modules(self) -> Self {
767 self.with_module(LightningClientInit::default())
768 .with_module(MintClientInit)
769 .with_module(WalletClientInit::default())
770 .with_module(MetaClientInit)
771 .with_module(fedimint_lnv2_client::LightningClientInit::default())
772 }
773
774 pub async fn run(&mut self) {
775 match self.handle_command(self.cli_args.clone()).await {
776 Ok(output) => {
777 let _ = writeln!(std::io::stdout(), "{output}");
779 }
780 Err(err) => {
781 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
782 let _ = writeln!(std::io::stdout(), "{err}");
783 exit(1);
784 }
785 }
786 }
787
788 async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
789 let mut client_builder = Client::builder()
790 .await
791 .map_err_cli()?
792 .with_iroh_enable_dht(cli.iroh_enable_dht())
793 .with_iroh_enable_next(cli.iroh_enable_next());
794 client_builder.with_module_inits(self.module_inits.clone());
795
796 let db = cli.load_database().await?;
797 Ok((client_builder, db))
798 }
799
800 async fn client_join(
801 &mut self,
802 cli: &Opts,
803 invite_code: InviteCode,
804 ) -> CliResult<ClientHandleArc> {
805 let (client_builder, db) = self.make_client_builder(cli).await?;
806
807 let mnemonic = load_or_generate_mnemonic(&db).await?;
808
809 let client = client_builder
810 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
811 .await
812 .map_err_cli()?
813 .join(
814 db,
815 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(
816 &mnemonic,
817 )),
818 )
819 .await
820 .map(Arc::new)
821 .map_err_cli()?;
822
823 print_welcome_message(&client).await;
824 log_expiration_notice(&client).await;
825
826 Ok(client)
827 }
828
829 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
830 let (mut client_builder, db) = self.make_client_builder(cli).await?;
831
832 if let Some(our_id) = cli.our_id {
834 client_builder.set_admin_creds(AdminCreds {
835 peer_id: our_id,
836 auth: cli.auth()?,
837 });
838 } else if let Some(stored_creds) = load_admin_creds(&db).await {
839 debug!(target: LOG_CLIENT, "Using stored admin credentials");
840 client_builder.set_admin_creds(AdminCreds {
841 peer_id: stored_creds.peer_id,
842 auth: ApiAuth(stored_creds.auth),
843 });
844 }
845
846 let mnemonic = Mnemonic::from_entropy(
847 &Client::load_decodable_client_secret::<Vec<u8>>(&db)
848 .await
849 .map_err_cli()?,
850 )
851 .map_err_cli()?;
852
853 let client = client_builder
854 .open(
855 cli.make_endpoints().await.map_err_cli()?,
856 db,
857 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(
858 &mnemonic,
859 )),
860 )
861 .await
862 .map(Arc::new)
863 .map_err_cli()?;
864
865 log_expiration_notice(&client).await;
866
867 Ok(client)
868 }
869
870 async fn client_recover(
871 &mut self,
872 cli: &Opts,
873 mnemonic: Mnemonic,
874 invite_code: InviteCode,
875 ) -> CliResult<ClientHandleArc> {
876 let (builder, db) = self.make_client_builder(cli).await?;
877 match Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
878 .await
879 .map_err_cli()?
880 {
881 Some(existing) => {
882 if existing != mnemonic.to_entropy() {
883 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
884 }
885 }
886 None => {
887 Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
888 .await
889 .map_err_cli()?;
890 }
891 }
892
893 let root_secret = RootSecret::StandardDoubleDerive(
894 Bip39RootSecretStrategy::<12>::to_root_secret(&mnemonic),
895 );
896
897 let preview = builder
898 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
899 .await
900 .map_err_cli()?;
901
902 #[allow(deprecated)]
903 let backup = preview
904 .download_backup_from_federation(root_secret.clone())
905 .await
906 .map_err_cli()?;
907
908 let client = preview
909 .recover(db, root_secret, backup)
910 .await
911 .map(Arc::new)
912 .map_err_cli()?;
913
914 print_welcome_message(&client).await;
915 log_expiration_notice(&client).await;
916
917 Ok(client)
918 }
919
920 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
921 match cli.command.clone() {
922 Command::InviteCode { peer } => {
923 let client = self.client_open(&cli).await?;
924
925 let invite_code = client
926 .invite_code(peer)
927 .await
928 .ok_or_cli_msg("peer not found")?;
929
930 Ok(CliOutput::InviteCode { invite_code })
931 }
932 Command::JoinFederation { invite_code } => {
933 {
934 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
935 .map_err_cli_msg("invalid invite code")?;
936
937 let _client = self.client_join(&cli, invite_code).await?;
939 }
940
941 Ok(CliOutput::JoinFederation {
942 joined: invite_code,
943 })
944 }
945 Command::VersionHash => Ok(CliOutput::VersionHash {
946 hash: fedimint_build_code_version_env!().to_string(),
947 }),
948 Command::Client(ClientCmd::Restore {
949 mnemonic,
950 invite_code,
951 }) => {
952 let invite_code: InviteCode =
953 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
954 let mnemonic = Mnemonic::from_str(&mnemonic).map_err_cli()?;
955 let client = self.client_recover(&cli, mnemonic, invite_code).await?;
956
957 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
960 client.wait_for_all_recoveries().await.map_err_cli()?;
961
962 debug!(target: LOG_CLIENT, "Recovery complete");
963
964 Ok(CliOutput::Raw(serde_json::to_value(()).unwrap()))
965 }
966 Command::Client(command) => {
967 let client = self.client_open(&cli).await?;
968 Ok(CliOutput::Raw(
969 client::handle_command(command, client)
970 .await
971 .map_err_cli()?,
972 ))
973 }
974 Command::Admin(AdminCmd::Auth {
975 peer_id,
976 password,
977 no_verify,
978 force,
979 }) => {
980 let db = cli.load_database().await?;
981 let peer_id = PeerId::from(peer_id);
982 let auth = ApiAuth(password);
983
984 if !force {
986 let existing = load_admin_creds(&db).await;
987 if existing.is_some() {
988 return Err(CliError {
989 error: "Admin credentials already stored. Use --force to overwrite."
990 .to_string(),
991 });
992 }
993 }
994
995 let config = Client::get_config_from_db(&db)
997 .await
998 .ok_or_cli_msg("Client not initialized. Please join a federation first.")?;
999
1000 let peer_url =
1002 config
1003 .global
1004 .api_endpoints
1005 .get(&peer_id)
1006 .ok_or_else(|| CliError {
1007 error: format!(
1008 "Peer ID {} not found in federation. Valid peer IDs are: {:?}",
1009 peer_id,
1010 config.global.api_endpoints.keys().collect::<Vec<_>>()
1011 ),
1012 })?;
1013
1014 if !no_verify {
1016 if !std::io::stdin().is_terminal() {
1018 return Err(CliError {
1019 error: "Interactive verification requires a terminal. Use --no-verify to skip.".to_string(),
1020 });
1021 }
1022
1023 eprintln!("Guardian endpoint for peer {}: {}", peer_id, peer_url.url);
1024 eprint!("Does this look correct? (y/N): ");
1025 std::io::stderr().flush().map_err_cli()?;
1026
1027 let mut input = String::new();
1028 std::io::stdin().read_line(&mut input).map_err_cli()?;
1029 let input = input.trim().to_lowercase();
1030
1031 if input != "y" && input != "yes" {
1032 return Err(CliError {
1033 error: "Endpoint verification cancelled by user.".to_string(),
1034 });
1035 }
1036 }
1037
1038 eprintln!("Verifying credentials...");
1040 let admin_api = DynGlobalApi::new_admin(
1041 cli.make_endpoints().await.map_err_cli()?,
1042 peer_id,
1043 peer_url.url.clone(),
1044 db.begin_transaction_nc()
1045 .await
1046 .get_value(&ApiSecretKey)
1047 .await
1048 .as_deref(),
1049 )
1050 .map_err_cli()?;
1051
1052 admin_api.auth(auth.clone()).await.map_err(|e| CliError {
1054 error: format!(
1055 "Failed to verify credentials: {e}. Please check your peer ID and password."
1056 ),
1057 })?;
1058
1059 store_admin_creds(
1061 &db,
1062 &StoredAdminCreds {
1063 peer_id,
1064 auth: auth.0.clone(),
1065 },
1066 )
1067 .await;
1068
1069 eprintln!("Admin credentials verified and saved successfully.");
1070 Ok(CliOutput::Raw(json!({
1071 "peer_id": peer_id,
1072 "endpoint": peer_url.url.to_string(),
1073 "status": "saved"
1074 })))
1075 }
1076 Command::Admin(AdminCmd::Audit) => {
1077 let client = self.client_open(&cli).await?;
1078
1079 let audit = cli
1080 .admin_client(
1081 &client.get_peer_urls().await,
1082 client.api_secret().as_deref(),
1083 )
1084 .await?
1085 .audit(cli.auth()?)
1086 .await?;
1087 Ok(CliOutput::Raw(
1088 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
1089 ))
1090 }
1091 Command::Admin(AdminCmd::Status) => {
1092 let client = self.client_open(&cli).await?;
1093
1094 let status = cli
1095 .admin_client_with_db(
1096 &client.get_peer_urls().await,
1097 client.api_secret().as_deref(),
1098 Some(client.db()),
1099 )
1100 .await?
1101 .status()
1102 .await?;
1103 Ok(CliOutput::Raw(
1104 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
1105 ))
1106 }
1107 Command::Admin(AdminCmd::GuardianConfigBackup) => {
1108 let client = self.client_open(&cli).await?;
1109
1110 let guardian_config_backup = cli
1111 .admin_client(
1112 &client.get_peer_urls().await,
1113 client.api_secret().as_deref(),
1114 )
1115 .await?
1116 .guardian_config_backup(cli.auth()?)
1117 .await?;
1118 Ok(CliOutput::Raw(
1119 serde_json::to_value(guardian_config_backup)
1120 .map_err_cli_msg("invalid response")?,
1121 ))
1122 }
1123 Command::Admin(AdminCmd::Setup(dkg_args)) => self
1124 .handle_admin_setup_command(cli, dkg_args)
1125 .await
1126 .map(CliOutput::Raw)
1127 .map_err_cli_msg("Config Gen Error"),
1128 Command::Admin(AdminCmd::SignApiAnnouncement {
1129 api_url,
1130 override_url,
1131 }) => {
1132 let client = self.client_open(&cli).await?;
1133
1134 if !["ws", "wss"].contains(&api_url.scheme()) {
1135 return Err(CliError {
1136 error: format!(
1137 "Unsupported URL scheme {}, use ws:// or wss://",
1138 api_url.scheme()
1139 ),
1140 });
1141 }
1142
1143 let announcement = cli
1144 .admin_client(
1145 &override_url
1146 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
1147 .unwrap_or(client.get_peer_urls().await),
1148 client.api_secret().as_deref(),
1149 )
1150 .await?
1151 .sign_api_announcement(api_url, cli.auth()?)
1152 .await?;
1153
1154 Ok(CliOutput::Raw(
1155 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
1156 ))
1157 }
1158 Command::Admin(AdminCmd::SignGuardianMetadata { api_urls, pkarr_id }) => {
1159 let client = self.client_open(&cli).await?;
1160
1161 let metadata = fedimint_core::net::guardian_metadata::GuardianMetadata {
1162 api_urls,
1163 pkarr_id_z32: pkarr_id,
1164 timestamp_secs: fedimint_core::time::duration_since_epoch().as_secs(),
1165 };
1166
1167 let signed_metadata = cli
1168 .admin_client(
1169 &client.get_peer_urls().await,
1170 client.api_secret().as_deref(),
1171 )
1172 .await?
1173 .sign_guardian_metadata(metadata, cli.auth()?)
1174 .await?;
1175
1176 Ok(CliOutput::Raw(
1177 serde_json::to_value(signed_metadata).map_err_cli_msg("invalid response")?,
1178 ))
1179 }
1180 Command::Admin(AdminCmd::Shutdown { session_idx }) => {
1181 let client = self.client_open(&cli).await?;
1182
1183 cli.admin_client(
1184 &client.get_peer_urls().await,
1185 client.api_secret().as_deref(),
1186 )
1187 .await?
1188 .shutdown(Some(session_idx), cli.auth()?)
1189 .await?;
1190
1191 Ok(CliOutput::Raw(json!(null)))
1192 }
1193 Command::Admin(AdminCmd::BackupStatistics) => {
1194 let client = self.client_open(&cli).await?;
1195
1196 let backup_statistics = cli
1197 .admin_client(
1198 &client.get_peer_urls().await,
1199 client.api_secret().as_deref(),
1200 )
1201 .await?
1202 .backup_statistics(cli.auth()?)
1203 .await?;
1204
1205 Ok(CliOutput::Raw(
1206 serde_json::to_value(backup_statistics).expect("Can be encoded"),
1207 ))
1208 }
1209 Command::Admin(AdminCmd::ChangePassword { new_password }) => {
1210 let client = self.client_open(&cli).await?;
1211
1212 cli.admin_client(
1213 &client.get_peer_urls().await,
1214 client.api_secret().as_deref(),
1215 )
1216 .await?
1217 .change_password(cli.auth()?, &new_password)
1218 .await?;
1219
1220 warn!(target: LOG_CLIENT, "Password changed, please restart fedimintd manually");
1221
1222 Ok(CliOutput::Raw(json!(null)))
1223 }
1224 Command::Dev(DevCmd::Api {
1225 method,
1226 params,
1227 peer_id,
1228 password: auth,
1229 module,
1230 }) => {
1231 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
1234 debug!(
1235 target: LOG_CLIENT,
1236 "Failed to serialize params:{}. Converting it to JSON string",
1237 err
1238 );
1239
1240 serde_json::Value::String(params)
1241 });
1242
1243 let mut params = ApiRequestErased::new(params);
1244 if let Some(auth) = auth {
1245 params = params.with_auth(ApiAuth(auth));
1246 }
1247 let client = self.client_open(&cli).await?;
1248
1249 let api = client.api_clone();
1250
1251 let module_api = match module {
1252 Some(selector) => {
1253 Some(api.with_module(selector.resolve(&client).map_err_cli()?))
1254 }
1255 None => None,
1256 };
1257
1258 let response: Value = match (peer_id, module_api) {
1259 (Some(peer_id), Some(module_api)) => module_api
1260 .request_raw(peer_id.into(), &method, ¶ms)
1261 .await
1262 .map_err_cli()?,
1263 (Some(peer_id), None) => api
1264 .request_raw(peer_id.into(), &method, ¶ms)
1265 .await
1266 .map_err_cli()?,
1267 (None, Some(module_api)) => module_api
1268 .request_current_consensus(method, params)
1269 .await
1270 .map_err_cli()?,
1271 (None, None) => api
1272 .request_current_consensus(method, params)
1273 .await
1274 .map_err_cli()?,
1275 };
1276
1277 Ok(CliOutput::UntypedApiOutput { value: response })
1278 }
1279 Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
1280 let client = self.client_open(&cli).await?;
1281
1282 let mint = client
1283 .get_first_module::<MintClientModule>()
1284 .map_err_cli_msg("can't get mint module")?;
1285
1286 for _ in 0..count {
1287 mint.advance_note_idx(amount)
1288 .await
1289 .map_err_cli_msg("failed to advance the note_idx")?;
1290 }
1291
1292 Ok(CliOutput::Raw(serde_json::Value::Null))
1293 }
1294 Command::Dev(DevCmd::ApiAnnouncements) => {
1295 let client = self.client_open(&cli).await?;
1296 let announcements = client.get_peer_url_announcements().await;
1297 Ok(CliOutput::Raw(
1298 serde_json::to_value(announcements).expect("Can be encoded"),
1299 ))
1300 }
1301 Command::Dev(DevCmd::GuardianMetadata) => {
1302 let client = self.client_open(&cli).await?;
1303 let metadata = client.get_guardian_metadata().await;
1304 Ok(CliOutput::Raw(
1305 serde_json::to_value(metadata).expect("Can be encoded"),
1306 ))
1307 }
1308 Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1309 "wait_block_count",
1310 backoff_util::custom_backoff(
1311 Duration::from_millis(100),
1312 Duration::from_secs(5),
1313 None,
1314 ),
1315 || async {
1316 let client = self.client_open(&cli).await?;
1317 let wallet = client.get_first_module::<WalletClientModule>()?;
1318 let count = client
1319 .api()
1320 .with_module(wallet.id)
1321 .fetch_consensus_block_count()
1322 .await?;
1323 if count >= target {
1324 Ok(CliOutput::WaitBlockCount { reached: count })
1325 } else {
1326 info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1327 Err(format_err!("target not reached"))
1328 }
1329 },
1330 )
1331 .await
1332 .map_err_cli(),
1333
1334 Command::Dev(DevCmd::WaitComplete) => {
1335 let client = self.client_open(&cli).await?;
1336 client
1337 .wait_for_all_active_state_machines()
1338 .await
1339 .map_err_cli_msg("failed to wait for all active state machines")?;
1340 Ok(CliOutput::Raw(serde_json::Value::Null))
1341 }
1342 Command::Dev(DevCmd::Wait { seconds }) => {
1343 let client = self.client_open(&cli).await?;
1344 client
1348 .task_group()
1349 .spawn_cancellable("fedimint-cli dev wait: init networking", {
1350 let client = client.clone();
1351 async move {
1352 let _ = client.api().session_count().await;
1353 }
1354 });
1355
1356 if let Some(secs) = seconds {
1357 runtime::sleep(Duration::from_secs_f32(secs)).await;
1358 } else {
1359 pending::<()>().await;
1360 }
1361 Ok(CliOutput::Raw(serde_json::Value::Null))
1362 }
1363 Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1364 DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1365 url: invite_code.url(),
1366 federation_id: invite_code.federation_id(),
1367 }),
1368 DecodeType::Notes { notes, file } => {
1369 let notes = if let Some(notes) = notes {
1370 notes
1371 } else if let Some(file) = file {
1372 let notes_str =
1373 fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1374 OOBNotes::from_str(¬es_str).map_err_cli_msg("failed to decode notes")?
1375 } else {
1376 unreachable!("Clap enforces either notes or file being set");
1377 };
1378
1379 let notes_json = notes
1380 .notes_json()
1381 .map_err_cli_msg("failed to decode notes")?;
1382 Ok(CliOutput::Raw(notes_json))
1383 }
1384 DecodeType::Transaction { hex_string } => {
1385 let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1386 .map_err_cli_msg("failed to decode transaction")?;
1387
1388 let client = self.client_open(&cli).await?;
1389 let tx = fedimint_core::transaction::Transaction::from_bytes(
1390 &bytes,
1391 client.decoders(),
1392 )
1393 .map_err_cli_msg("failed to decode transaction")?;
1394
1395 Ok(CliOutput::DecodeTransaction {
1396 transaction: (format!("{tx:?}")),
1397 })
1398 }
1399 DecodeType::SetupCode { setup_code } => {
1400 let setup_code = base32::decode_prefixed(FEDIMINT_PREFIX, &setup_code)
1401 .map_err_cli_msg("failed to decode setup code")?;
1402
1403 Ok(CliOutput::SetupCode { setup_code })
1404 }
1405 },
1406 Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1407 EncodeType::InviteCode {
1408 url,
1409 federation_id,
1410 peer,
1411 api_secret,
1412 } => Ok(CliOutput::InviteCode {
1413 invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1414 }),
1415 EncodeType::Notes { notes_json } => {
1416 let notes = serde_json::from_str::<OOBNotesJson>(¬es_json)
1417 .map_err_cli_msg("invalid JSON for notes")?;
1418 let prefix =
1419 FederationIdPrefix::from_str(¬es.federation_id_prefix).map_err_cli()?;
1420 let notes = OOBNotes::new(prefix, notes.notes);
1421 Ok(CliOutput::Raw(notes.to_string().into()))
1422 }
1423 },
1424 Command::Dev(DevCmd::SessionCount) => {
1425 let client = self.client_open(&cli).await?;
1426 let count = client.api().session_count().await?;
1427 Ok(CliOutput::EpochCount { count })
1428 }
1429 Command::Dev(DevCmd::ConfigDecrypt {
1430 in_file,
1431 out_file,
1432 salt_file,
1433 password,
1434 }) => {
1435 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1436 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1437 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1438 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1439
1440 let mut out_file_handle = fs::File::options()
1441 .create_new(true)
1442 .write(true)
1443 .open(out_file)
1444 .expect("Could not create output cfg file");
1445 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1446 Ok(CliOutput::ConfigDecrypt)
1447 }
1448 Command::Dev(DevCmd::ConfigEncrypt {
1449 in_file,
1450 out_file,
1451 salt_file,
1452 password,
1453 }) => {
1454 let mut in_file_handle =
1455 fs::File::open(in_file).expect("Could not create output cfg file");
1456 let mut plaintext_bytes = vec![];
1457 in_file_handle.read_to_end(&mut plaintext_bytes).unwrap();
1458
1459 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1460 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1461 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1462 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1463 Ok(CliOutput::ConfigEncrypt)
1464 }
1465 Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1466 #[derive(Serialize)]
1467 struct ReactorLogState {
1468 active: bool,
1469 module_instance: ModuleInstanceId,
1470 creation_time: String,
1471 #[serde(skip_serializing_if = "Option::is_none")]
1472 end_time: Option<String>,
1473 state: String,
1474 }
1475
1476 let client = self.client_open(&cli).await?;
1477
1478 let (active_states, inactive_states) =
1479 client.executor().get_operation_states(operation_id).await;
1480 let all_states =
1481 active_states
1482 .into_iter()
1483 .map(|(active_state, active_meta)| ReactorLogState {
1484 active: true,
1485 module_instance: active_state.module_instance_id(),
1486 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1487 end_time: None,
1488 state: format!("{active_state:?}",),
1489 })
1490 .chain(inactive_states.into_iter().map(
1491 |(inactive_state, inactive_meta)| ReactorLogState {
1492 active: false,
1493 module_instance: inactive_state.module_instance_id(),
1494 creation_time: crate::client::time_to_iso8601(
1495 &inactive_meta.created_at,
1496 ),
1497 end_time: Some(crate::client::time_to_iso8601(
1498 &inactive_meta.exited_at,
1499 )),
1500 state: format!("{inactive_state:?}",),
1501 },
1502 ))
1503 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1504 .collect::<Vec<_>>();
1505
1506 Ok(CliOutput::Raw(json!({
1507 "states": all_states
1508 })))
1509 }
1510 Command::Dev(DevCmd::MetaFields) => {
1511 let client = self.client_open(&cli).await?;
1512 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1513
1514 let meta_fields = source
1515 .fetch(
1516 &client.config().await,
1517 &client.api_clone(),
1518 FetchKind::Initial,
1519 None,
1520 )
1521 .await
1522 .map_err_cli()?;
1523
1524 Ok(CliOutput::Raw(
1525 serde_json::to_value(meta_fields).expect("Can be encoded"),
1526 ))
1527 }
1528 Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1529 let client = self.client_open(&cli).await?;
1530 let version = client
1531 .api()
1532 .fedimintd_version(peer_id.into())
1533 .await
1534 .map_err_cli()?;
1535
1536 Ok(CliOutput::Raw(json!({ "version": version })))
1537 }
1538 Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1539 let client = self.client_open(&cli).await?;
1540
1541 let events: Vec<_> = client
1542 .get_event_log(pos, limit)
1543 .await
1544 .into_iter()
1545 .map(|v| {
1546 let id = v.id();
1547 let v = v.as_raw();
1548 let module_id = v.module.as_ref().map(|m| m.1);
1549 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1550 serde_json::json!({
1551 "id": id,
1552 "kind": v.kind,
1553 "module_kind": module_kind,
1554 "module_id": module_id,
1555 "ts": v.ts_usecs,
1556 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1557 })
1558 })
1559 .collect();
1560
1561 Ok(CliOutput::Raw(
1562 serde_json::to_value(events).expect("Can be encoded"),
1563 ))
1564 }
1565 Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1566 let client = self.client_open(&cli).await?;
1567
1568 let events: Vec<_> = client
1569 .get_event_log_trimable(
1570 pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1571 limit,
1572 )
1573 .await
1574 .into_iter()
1575 .map(|v| {
1576 let id = v.id();
1577 let v = v.as_raw();
1578 let module_id = v.module.as_ref().map(|m| m.1);
1579 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1580 serde_json::json!({
1581 "id": id,
1582 "kind": v.kind,
1583 "module_kind": module_kind,
1584 "module_id": module_id,
1585 "ts": v.ts_usecs,
1586 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1587 })
1588 })
1589 .collect();
1590
1591 Ok(CliOutput::Raw(
1592 serde_json::to_value(events).expect("Can be encoded"),
1593 ))
1594 }
1595 Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1596 let client = self.client_open(&cli).await?;
1597 let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1598 .map_err_cli()?;
1599 let tx_outcome = client
1600 .api()
1601 .submit_transaction(tx)
1602 .await
1603 .try_into_inner(client.decoders())
1604 .map_err_cli()?;
1605
1606 Ok(CliOutput::Raw(
1607 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1608 ))
1609 }
1610 Command::Dev(DevCmd::TestEventLogHandling) => {
1611 let client = self.client_open(&cli).await?;
1612
1613 client
1614 .handle_events(
1615 client.built_in_application_event_log_tracker(),
1616 move |_dbtx, event| {
1617 Box::pin(async move {
1618 info!(target: LOG_CLIENT, "{event:?}");
1619
1620 Ok(())
1621 })
1622 },
1623 )
1624 .await
1625 .map_err_cli()?;
1626 unreachable!(
1627 "handle_events exits only if client shuts down, which we don't do here"
1628 )
1629 }
1630 Command::Dev(DevCmd::ChainId) => {
1631 let client = self.client_open(&cli).await?;
1632 let chain_id = client
1633 .db()
1634 .begin_transaction_nc()
1635 .await
1636 .get_value(&fedimint_client::db::ChainIdKey)
1637 .await
1638 .ok_or_cli_msg("Chain ID not cached in client database")?;
1639
1640 Ok(CliOutput::Raw(serde_json::json!({
1641 "chain_id": chain_id.to_string()
1642 })))
1643 }
1644 Command::Completion { shell } => {
1645 let bin_path = PathBuf::from(
1646 std::env::args_os()
1647 .next()
1648 .expect("Binary name is always provided if we get this far"),
1649 );
1650 let bin_name = bin_path
1651 .file_name()
1652 .expect("path has file name")
1653 .to_string_lossy();
1654 clap_complete::generate(
1655 shell,
1656 &mut Opts::command(),
1657 bin_name.as_ref(),
1658 &mut std::io::stdout(),
1659 );
1660 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1662 }
1663 }
1664 }
1665
1666 async fn handle_admin_setup_command(
1667 &self,
1668 cli: Opts,
1669 args: SetupAdminArgs,
1670 ) -> anyhow::Result<Value> {
1671 let client =
1672 DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone())?;
1673
1674 match &args.subcommand {
1675 SetupAdminCmd::Status => {
1676 let status = client.setup_status(cli.auth()?).await?;
1677
1678 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1679 }
1680 SetupAdminCmd::SetLocalParams {
1681 name,
1682 federation_name,
1683 } => {
1684 let info = client
1685 .set_local_params(
1686 name.clone(),
1687 federation_name.clone(),
1688 None,
1689 None,
1690 cli.auth()?,
1691 )
1692 .await?;
1693
1694 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1695 }
1696 SetupAdminCmd::AddPeer { info } => {
1697 let name = client
1698 .add_peer_connection_info(info.clone(), cli.auth()?)
1699 .await?;
1700
1701 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1702 }
1703 SetupAdminCmd::StartDkg => {
1704 client.start_dkg(cli.auth()?).await?;
1705
1706 Ok(Value::Null)
1707 }
1708 }
1709 }
1710}
1711
1712async fn log_expiration_notice(client: &Client) {
1713 client.get_meta_expiration_timestamp().await;
1714 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1715 match expiration_time.duration_since(fedimint_core::time::now()) {
1716 Ok(until_expiration) => {
1717 let days = until_expiration.as_secs() / (60 * 60 * 24);
1718
1719 if 90 < days {
1720 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1721 } else if 30 < days {
1722 info!(target: LOG_CLIENT, %days, "This federation will expire");
1723 } else {
1724 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1725 }
1726 }
1727 Err(_) => {
1728 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1729 }
1730 }
1731 }
1732}
1733async fn print_welcome_message(client: &Client) {
1734 if let Some(welcome_message) = client
1735 .meta_service()
1736 .get_field::<String>(client.db(), "welcome_message")
1737 .await
1738 .and_then(|v| v.value)
1739 {
1740 eprintln!("{welcome_message}");
1741 }
1742}
1743
1744fn salt_from_file_path(file_path: &Path) -> PathBuf {
1745 file_path
1746 .parent()
1747 .expect("File has no parent?!")
1748 .join(SALT_FILE)
1749}
1750
1751fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1753 let metadata: BTreeMap<String, String> = metadata
1754 .into_iter()
1755 .map(|item| {
1756 match &item
1757 .splitn(2, '=')
1758 .map(ToString::to_string)
1759 .collect::<Vec<String>>()[..]
1760 {
1761 [] => Err(format_err!("Empty metadata argument not allowed")),
1762 [key] => Err(format_err!("Metadata {key} is missing a value")),
1763 [key, val] => Ok((key.clone(), val.clone())),
1764 [..] => unreachable!(),
1765 }
1766 })
1767 .collect::<anyhow::Result<_>>()
1768 .map_err_cli_msg("invalid metadata")?;
1769 Ok(metadata)
1770}
1771
1772#[test]
1773fn metadata_from_clap_cli_test() {
1774 for (args, expected) in [
1775 (
1776 vec!["a=b".to_string()],
1777 BTreeMap::from([("a".into(), "b".into())]),
1778 ),
1779 (
1780 vec!["a=b".to_string(), "c=d".to_string()],
1781 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1782 ),
1783 ] {
1784 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1785 }
1786}