1#![deny(clippy::pedantic, clippy::unwrap_used)]
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 api_request;
13mod cli;
14mod client;
15mod command_encoding;
16mod command_wait;
17mod db;
18pub mod envs;
19mod event_output;
20mod utils;
21mod visualize;
22
23#[cfg(test)]
24mod command_tests;
25
26use core::fmt;
27use std::collections::{BTreeMap, BTreeSet};
28use std::fmt::Debug;
29use std::io::{IsTerminal, Read, Write};
30use std::net::IpAddr;
31use std::path::{Path, PathBuf};
32use std::process::exit;
33use std::str::FromStr;
34use std::sync::Arc;
35use std::time::Duration;
36use std::{fs, result};
37
38use anyhow::{Context, format_err};
39use api_request::ApiRequest;
40use clap::{CommandFactory, Parser};
41use cli::{
42 AdminCmd, Command, DatabaseBackend, DevCmd, Opts, SetupAdminArgs, SetupAdminCmd, VisualizeCmd,
43};
44use envs::SALT_FILE;
45use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
46use fedimint_api_client::api::{DynGlobalApi, FederationApiExt, FederationError};
47use fedimint_api_client::download_from_invite_code;
48use fedimint_bip39::{Bip39RootSecretStrategy, Mnemonic};
49use fedimint_client::db::ApiSecretKey;
50use fedimint_client::module::meta::{FetchKind, LegacyMetaSource, MetaSource};
51use fedimint_client::module::module::init::ClientModuleInit;
52use fedimint_client::module_init::ClientModuleInitRegistry;
53use fedimint_client::secret::RootSecretStrategy;
54use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc, RootSecret};
55use fedimint_connectors::{Connectivity, ConnectorRegistry};
56use fedimint_core::config::FederationId;
57use fedimint_core::core::ModuleInstanceId;
58use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped as _};
59use fedimint_core::encoding::Decodable;
60use fedimint_core::invite_code::InviteCode;
61use fedimint_core::module::registry::ModuleRegistry;
62use fedimint_core::module::{ApiAuth, ApiRequestErased};
63use fedimint_core::setup_code::PeerSetupCode;
64use fedimint_core::transaction::Transaction;
65use fedimint_core::util::{SafeUrl, handle_version_hash_command};
66use fedimint_core::{PeerId, fedimint_build_code_version_env, runtime};
67use fedimint_derive_secret::DerivableSecret;
68use fedimint_eventlog::EventLogTrimableId;
69use fedimint_ln_client::LightningClientInit;
70use fedimint_logging::{LOG_CLIENT, TracingSetup};
71use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
72use fedimint_mint_client::{MintClientInit, MintClientModule};
73use fedimint_wallet_client::WalletClientInit;
74use futures::future::join_all;
75use itertools::Itertools;
76use rand::thread_rng;
77use serde::{Deserialize, Serialize};
78use serde_json::{Value, json};
79use thiserror::Error;
80use tracing::{debug, info, warn};
81
82use crate::client::ClientCmd;
83use crate::db::{StoredAdminCreds, load_admin_creds, store_admin_creds};
84
85#[derive(Serialize)]
87#[serde(rename_all = "snake_case")]
88#[serde(untagged)]
89enum CliOutput {
90 VersionHash {
91 hash: String,
92 },
93
94 UntypedApiOutput {
95 value: Value,
96 },
97
98 WaitBlockCount {
99 reached: u64,
100 },
101
102 InviteCode {
103 invite_code: InviteCode,
104 },
105
106 DecodeInviteCode {
107 url: SafeUrl,
108 federation_id: FederationId,
109 },
110
111 Join {
112 joined: String,
113 },
114
115 DecodeTransaction {
116 transaction: String,
117 },
118
119 EpochCount {
120 count: u64,
121 },
122
123 ConfigDecrypt,
124
125 ConfigEncrypt,
126
127 SetupCode {
128 setup_code: PeerSetupCode,
129 },
130
131 Raw(serde_json::Value),
132}
133
134impl fmt::Display for CliOutput {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 write!(
137 f,
138 "{}",
139 serde_json::to_string_pretty(self).expect("CliOutput is serializable")
140 )
141 }
142}
143
144#[derive(Debug, Serialize)]
145struct FederationPrivacyReport {
146 federation_id: FederationId,
147 guardians: BTreeMap<String, GuardianPrivacyReport>,
148 summary: FederationPrivacySummary,
149}
150
151#[derive(Debug, Serialize)]
152struct GuardianPrivacyReport {
153 name: String,
154 api_url: SafeUrl,
155 addresses: BTreeMap<String, IpInfoReport>,
156 iroh: Option<IrohConnectionReport>,
157 error: Option<String>,
158}
159
160#[derive(Debug, Serialize)]
161struct IrohConnectionReport {
162 node_id: String,
163 connectivity: &'static str,
164 direct_addr: Option<String>,
165 relay_url: Option<String>,
166}
167
168#[derive(Debug, Clone, Default, Serialize)]
169struct IpInfoReport {
170 address_type: &'static str,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 country: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 asn: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 as_name: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 org: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 error: Option<String>,
181}
182
183#[derive(Debug, Serialize)]
184struct FederationPrivacySummary {
185 guardian_count: usize,
186 iroh_guardian_count: usize,
187 resolved_guardian_count: usize,
188 iroh_direct_guardian_count: usize,
189 iroh_relayed_guardian_count: usize,
190 public_ip_count: usize,
191 countries: BTreeMap<String, usize>,
192 autonomous_systems: BTreeMap<String, usize>,
193}
194
195#[derive(Debug, Deserialize)]
196struct IpWhoResponse {
197 success: Option<bool>,
198 message: Option<String>,
199 country_code: Option<String>,
200 country: Option<String>,
201 connection: Option<IpWhoConnection>,
202}
203
204#[derive(Debug, Deserialize)]
205struct IpWhoConnection {
206 asn: Option<u64>,
207 org: Option<String>,
208 isp: Option<String>,
209}
210
211const IP_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
212
213type CliResult<E> = Result<E, CliError>;
215
216type CliOutputResult = Result<CliOutput, CliError>;
218
219#[derive(Serialize, Error)]
221#[serde(tag = "error", rename_all(serialize = "snake_case"))]
222struct CliError {
223 error: String,
224}
225
226trait CliResultExt<O, E> {
229 fn map_err_cli(self) -> Result<O, CliError>;
231 fn map_err_cli_msg(self, msg: impl fmt::Display + Send + Sync + 'static)
233 -> Result<O, CliError>;
234}
235
236impl<O, E> CliResultExt<O, E> for result::Result<O, E>
237where
238 E: Into<anyhow::Error>,
239{
240 fn map_err_cli(self) -> Result<O, CliError> {
241 self.map_err(|e| {
242 let e = e.into();
243 CliError {
244 error: format!("{e:#}"),
245 }
246 })
247 }
248
249 fn map_err_cli_msg(
250 self,
251 msg: impl fmt::Display + Send + Sync + 'static,
252 ) -> Result<O, CliError> {
253 self.map_err(|e| Into::<anyhow::Error>::into(e))
254 .context(msg)
255 .map_err(|e| CliError {
256 error: format!("{e:#}"),
257 })
258 }
259}
260
261trait CliOptionExt<O> {
264 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
265}
266
267impl<O> CliOptionExt<O> for Option<O> {
268 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
269 self.ok_or_else(|| CliError { error: msg.into() })
270 }
271}
272
273impl From<FederationError> for CliError {
275 fn from(e: FederationError) -> Self {
276 CliError {
277 error: e.to_string(),
278 }
279 }
280}
281
282impl Debug for CliError {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 f.debug_struct("CliError")
285 .field("error", &self.error)
286 .finish()
287 }
288}
289
290impl fmt::Display for CliError {
291 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
292 let json = serde_json::to_value(self).expect("CliError is valid json");
293 let json_as_string =
294 serde_json::to_string_pretty(&json).expect("valid json is serializable");
295 write!(f, "{json_as_string}")
296 }
297}
298
299impl Opts {
300 fn data_dir(&self) -> CliResult<&PathBuf> {
301 self.data_dir
302 .as_ref()
303 .ok_or_cli_msg("`--data-dir=` argument not set.")
304 }
305
306 async fn data_dir_create(&self) -> CliResult<&PathBuf> {
308 let dir = self.data_dir()?;
309
310 tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
311
312 Ok(dir)
313 }
314 fn iroh_enable_dht(&self) -> bool {
315 self.iroh_enable_dht.unwrap_or(true)
316 }
317
318 fn use_tor(&self) -> bool {
319 #[cfg(feature = "tor")]
320 return self.use_tor;
321 #[cfg(not(feature = "tor"))]
322 false
323 }
324
325 async fn admin_client(
326 &self,
327 peer_urls: &BTreeMap<PeerId, SafeUrl>,
328 api_secret: Option<&str>,
329 ) -> CliResult<DynGlobalApi> {
330 self.admin_client_with_db(peer_urls, api_secret, None).await
331 }
332
333 async fn admin_client_with_db(
334 &self,
335 peer_urls: &BTreeMap<PeerId, SafeUrl>,
336 api_secret: Option<&str>,
337 db: Option<&Database>,
338 ) -> CliResult<DynGlobalApi> {
339 let our_id = if let Some(id) = self.our_id {
341 id
342 } else if let Some(db) = db {
343 if let Some(stored_creds) = load_admin_creds(db).await {
344 stored_creds.peer_id
345 } else {
346 return Err(CliError {
347 error: "Admin client needs our-id set (no stored credentials found)"
348 .to_string(),
349 });
350 }
351 } else {
352 return Err(CliError {
353 error: "Admin client needs our-id set".to_string(),
354 });
355 };
356
357 Ok(DynGlobalApi::new_admin(
358 self.make_endpoints().await.map_err(|e| CliError {
359 error: e.to_string(),
360 })?,
361 our_id,
362 peer_urls
363 .get(&our_id)
364 .cloned()
365 .context("Our peer URL not found in config")
366 .map_err_cli()?,
367 api_secret,
368 ))
369 }
370
371 async fn make_endpoints(&self) -> Result<ConnectorRegistry, anyhow::Error> {
372 Ok(ConnectorRegistry::build_from_client_defaults()
373 .iroh_pkarr_dht(self.iroh_enable_dht())
374 .ws_force_tor(self.use_tor())
375 .bind()
376 .await)
377 }
378
379 fn auth(&self) -> CliResult<ApiAuth> {
380 let password = self
381 .password
382 .clone()
383 .ok_or_cli_msg("CLI needs password set")?;
384 Ok(ApiAuth::new(password))
385 }
386
387 async fn load_database(&self) -> CliResult<Database> {
388 debug!(target: LOG_CLIENT, "Loading client database");
389 let db_path = self.data_dir_create().await?.join("client.db");
390 match self.db_backend {
391 DatabaseBackend::RocksDb => {
392 debug!(target: LOG_CLIENT, "Using RocksDB database backend");
393 Ok(fedimint_rocksdb::RocksDb::build(db_path)
394 .open()
395 .await
396 .map_err_cli_msg("could not open rocksdb database")?
397 .into())
398 }
399 DatabaseBackend::CursedRedb => {
400 debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
401 Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
402 .await
403 .map_err_cli_msg("could not open cursed redb database")?
404 .into())
405 }
406 }
407 }
408}
409
410fn decode_federation_secret_hex(federation_secret_hex: &str) -> CliResult<DerivableSecret> {
411 <DerivableSecret as Decodable>::consensus_decode_hex(
412 federation_secret_hex,
413 &ModuleRegistry::default(),
414 )
415 .map_err_cli_msg("invalid federation secret hex")
416}
417
418enum RecoverySecret {
419 Mnemonic(Mnemonic),
420 FederationSecret(DerivableSecret),
421}
422
423fn root_secret_from_mnemonic(mnemonic: &Mnemonic) -> RootSecret {
424 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(mnemonic))
425}
426
427async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
428 Ok(
429 if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
430 Mnemonic::from_entropy(&entropy).map_err_cli()?
431 } else {
432 debug!(
433 target: LOG_CLIENT,
434 "Generating mnemonic and writing entropy to client storage"
435 );
436 let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
437 Client::store_encodable_client_secret(db, mnemonic.to_entropy())
438 .await
439 .map_err_cli()?;
440 mnemonic
441 },
442 )
443}
444
445async fn query_guardian_addresses(
446 connectors: &ConnectorRegistry,
447 endpoints: &BTreeMap<PeerId, fedimint_core::config::PeerUrl>,
448 path_timeout: Duration,
449) -> Vec<(PeerId, GuardianPrivacyReport)> {
450 join_all(endpoints.iter().map(|(peer_id, peer_url)| async move {
451 let mut report = GuardianPrivacyReport {
452 name: peer_url.name.clone(),
453 api_url: peer_url.url.clone(),
454 addresses: BTreeMap::new(),
455 iroh: None,
456 error: None,
457 };
458
459 if peer_url.url.scheme() == "iroh" {
460 match connectors.iroh_peer_info(&peer_url.url, path_timeout).await {
461 Ok(Some(iroh_info)) => {
462 let direct_addr = iroh_info.direct_addr;
463 let known_direct_addrs = iroh_info.known_direct_addrs;
464 let known_direct_ips = known_direct_addrs
465 .iter()
466 .chain(direct_addr.iter())
467 .map(std::net::SocketAddr::ip)
468 .collect::<BTreeSet<_>>();
469
470 report.addresses = ip_info_map_from_ips(known_direct_ips);
471 report.iroh = Some(IrohConnectionReport {
472 node_id: iroh_info.node_id,
473 connectivity: connectivity_name(iroh_info.connectivity),
474 direct_addr: direct_addr.map(|addr| addr.to_string()),
475 relay_url: iroh_info.relay_url,
476 });
477 }
478 Ok(None) => {}
479 Err(error) => {
480 report.error = Some(error.to_string());
481 }
482 }
483 } else {
484 match resolve_endpoint_ips(&peer_url.url).await {
485 Ok(ips) => {
486 report.addresses = ip_info_map_from_ips(ips);
487 }
488 Err(error) => {
489 report.error = Some(error.to_string());
490 }
491 }
492 }
493
494 (*peer_id, report)
495 }))
496 .await
497}
498
499fn ip_info_map_from_ips(ips: BTreeSet<IpAddr>) -> BTreeMap<String, IpInfoReport> {
500 ips.into_iter()
501 .map(|ip| {
502 (
503 ip.to_string(),
504 IpInfoReport {
505 address_type: ip_address_type(ip),
506 ..IpInfoReport::default()
507 },
508 )
509 })
510 .collect()
511}
512
513async fn resolve_endpoint_ips(url: &SafeUrl) -> anyhow::Result<BTreeSet<IpAddr>> {
514 let host = url
515 .host_str()
516 .context("endpoint URL does not contain a host")?;
517 if let Ok(ip) = IpAddr::from_str(host) {
518 return Ok(BTreeSet::from([ip]));
519 }
520
521 let port = endpoint_resolution_port(url).ok_or_else(|| {
522 format_err!(
523 "endpoint URL scheme {} does not have a known resolution port",
524 url.scheme()
525 )
526 })?;
527
528 let addresses = tokio::net::lookup_host((host, port))
529 .await
530 .with_context(|| format!("failed to resolve endpoint host {host}"))?
531 .map(|addr| addr.ip())
532 .collect();
533
534 Ok(addresses)
535}
536
537fn endpoint_resolution_port(url: &SafeUrl) -> Option<u16> {
538 url.port_or_known_default().or(match url.scheme() {
539 "http" | "ws" => Some(80),
540 "https" | "wss" => Some(443),
541 _ => None,
542 })
543}
544
545fn all_iroh_guardians_direct(reports: &[(PeerId, GuardianPrivacyReport)]) -> bool {
546 reports
547 .iter()
548 .filter(|(_, report)| report.api_url.scheme() == "iroh")
549 .all(|(_, report)| {
550 report
551 .iroh
552 .as_ref()
553 .is_some_and(|iroh| matches!(iroh.connectivity, "direct" | "mixed"))
554 })
555}
556
557async fn enrich_guardian_ip_info(reports: &mut [(PeerId, GuardianPrivacyReport)]) {
558 let http_client = reqwest::Client::builder()
559 .timeout(IP_LOOKUP_TIMEOUT)
560 .build()
561 .expect("IP lookup HTTP client config is valid");
562 let unique_ips = reports
563 .iter()
564 .flat_map(|(_, report)| {
565 report
566 .addresses
567 .iter()
568 .filter(|(_, ip_info)| ip_info.address_type == "public")
569 .filter_map(|(ip, _)| IpAddr::from_str(ip).ok())
570 })
571 .collect::<BTreeSet<_>>();
572
573 let lookup_cache = join_all(unique_ips.into_iter().map(|ip| {
574 let http_client = http_client.clone();
575 async move { (ip.to_string(), lookup_ip_info(&http_client, ip).await) }
576 }))
577 .await
578 .into_iter()
579 .collect::<BTreeMap<_, _>>();
580
581 for (_, report) in reports {
582 for (ip, ip_info) in &mut report.addresses {
583 if let Some(lookup) = lookup_cache.get(ip) {
584 *ip_info = IpInfoReport {
585 address_type: ip_info.address_type,
586 ..lookup.clone()
587 };
588 }
589 }
590 }
591}
592
593async fn lookup_ip_info(http_client: &reqwest::Client, ip: IpAddr) -> IpInfoReport {
594 let url = format!("https://ipwho.is/{ip}");
595 let response = http_client
596 .get(url)
597 .send()
598 .await
599 .and_then(reqwest::Response::error_for_status);
600
601 let response = match response {
602 Ok(response) => response,
603 Err(error) => {
604 return IpInfoReport {
605 address_type: "public",
606 country: None,
607 asn: None,
608 as_name: None,
609 org: None,
610 error: Some(error.to_string()),
611 };
612 }
613 };
614
615 match response.json::<IpWhoResponse>().await {
616 Ok(body) if body.success.unwrap_or(true) => {
617 let connection = body.connection;
618 let asn = connection
619 .as_ref()
620 .and_then(|connection| connection.asn)
621 .map(|asn| format!("AS{asn}"));
622 let org = connection
623 .as_ref()
624 .and_then(|connection| connection.org.clone());
625 let as_name = org
626 .clone()
627 .or_else(|| connection.and_then(|connection| connection.isp));
628
629 IpInfoReport {
630 address_type: "public",
631 country: body.country_code.or(body.country),
632 asn,
633 as_name,
634 org,
635 error: None,
636 }
637 }
638 Ok(body) => IpInfoReport {
639 address_type: "public",
640 country: None,
641 asn: None,
642 as_name: None,
643 org: None,
644 error: Some(
645 body.message
646 .unwrap_or_else(|| "IP lookup service returned an error".to_owned()),
647 ),
648 },
649 Err(error) => IpInfoReport {
650 address_type: "public",
651 country: None,
652 asn: None,
653 as_name: None,
654 org: None,
655 error: Some(error.to_string()),
656 },
657 }
658}
659
660fn summarize_privacy_report<'a>(
661 reports: impl IntoIterator<Item = &'a GuardianPrivacyReport>,
662) -> FederationPrivacySummary {
663 let mut countries = BTreeMap::new();
664 let mut autonomous_systems = BTreeMap::new();
665 let mut unique_public_ips = BTreeSet::new();
666 let reports = reports.into_iter().collect::<Vec<_>>();
667
668 for report in &reports {
669 let mut guardian_countries = BTreeSet::new();
670 let mut guardian_autonomous_systems = BTreeSet::new();
671
672 unique_public_ips.extend(
673 report
674 .addresses
675 .iter()
676 .filter(|(_, ip_info)| ip_info.address_type == "public")
677 .map(|(ip, _)| ip.clone()),
678 );
679
680 for ip_info in report
681 .addresses
682 .values()
683 .filter(|ip_info| ip_info.address_type == "public")
684 {
685 if let Some(country) = &ip_info.country {
686 guardian_countries.insert(country.clone());
687 }
688 let as_label = match (&ip_info.asn, &ip_info.as_name) {
689 (Some(asn), Some(as_name)) => Some(format!("{asn} {as_name}")),
690 (Some(asn), None) => Some(asn.clone()),
691 (None, Some(as_name)) => Some(as_name.clone()),
692 (None, None) => None,
693 };
694 if let Some(as_label) = as_label {
695 guardian_autonomous_systems.insert(as_label);
696 }
697 }
698
699 for country in guardian_countries {
700 *countries.entry(country).or_insert(0) += 1;
701 }
702 for autonomous_system in guardian_autonomous_systems {
703 *autonomous_systems.entry(autonomous_system).or_insert(0) += 1;
704 }
705 }
706
707 FederationPrivacySummary {
708 guardian_count: reports.len(),
709 iroh_guardian_count: reports
710 .iter()
711 .filter(|report| report.api_url.scheme() == "iroh")
712 .count(),
713 resolved_guardian_count: reports
714 .iter()
715 .filter(|report| !report.addresses.is_empty())
716 .count(),
717 iroh_direct_guardian_count: reports
718 .iter()
719 .filter(|report| {
720 report
721 .iroh
722 .as_ref()
723 .is_some_and(|iroh| matches!(iroh.connectivity, "direct" | "mixed"))
724 })
725 .count(),
726 iroh_relayed_guardian_count: reports
727 .iter()
728 .filter(|report| {
729 report
730 .iroh
731 .as_ref()
732 .is_some_and(|iroh| iroh.connectivity == "relay")
733 })
734 .count(),
735 public_ip_count: unique_public_ips.len(),
736 countries,
737 autonomous_systems,
738 }
739}
740
741fn connectivity_name(connectivity: Connectivity) -> &'static str {
742 match connectivity {
743 Connectivity::Direct => "direct",
744 Connectivity::Relay => "relay",
745 Connectivity::Mixed => "mixed",
746 Connectivity::Tor => "tor",
747 Connectivity::Unknown => "unknown",
748 }
749}
750
751fn ip_address_type(ip: IpAddr) -> &'static str {
752 match ip {
753 IpAddr::V4(ip) => {
754 if ip.is_private() {
755 "private"
756 } else if ip.is_loopback() {
757 "loopback"
758 } else if ip.is_link_local() {
759 "link_local"
760 } else if ip.is_broadcast() {
761 "broadcast"
762 } else if matches!(
763 ip.octets(),
764 [192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]
765 ) {
766 "documentation"
767 } else if matches!(ip.octets(), [100, second, _, _] if (64..=127).contains(&second)) {
768 "shared"
769 } else if matches!(ip.octets(), [198, 18 | 19, _, _]) {
770 "benchmark"
771 } else if ip.is_multicast() {
772 "multicast"
773 } else if ip.is_unspecified() {
774 "unspecified"
775 } else {
776 "public"
777 }
778 }
779 IpAddr::V6(ip) => {
780 if ip.is_loopback() {
781 "loopback"
782 } else if ip.is_unspecified() {
783 "unspecified"
784 } else if ip.is_unique_local() {
785 "unique_local"
786 } else if ip.is_unicast_link_local() {
787 "link_local"
788 } else if ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8 {
789 "documentation"
790 } else if ip.is_multicast() {
791 "multicast"
792 } else {
793 "public"
794 }
795 }
796 }
797}
798
799pub struct FedimintCli {
800 module_inits: ClientModuleInitRegistry,
801 cli_args: Opts,
802}
803
804impl FedimintCli {
805 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
807 assert_eq!(
808 fedimint_build_code_version_env!().len(),
809 version_hash.len(),
810 "version_hash must have an expected length"
811 );
812
813 handle_version_hash_command(version_hash);
814
815 let cli_args = Opts::parse();
816 let base_level = if cli_args.verbose { "debug" } else { "info" };
817 TracingSetup::default()
818 .with_base_level(base_level)
819 .init()
820 .map_err(anyhow::Error::from)
821 .expect("tracing initializes");
822
823 let version = env!("CARGO_PKG_VERSION");
824 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
825
826 Ok(Self {
827 module_inits: ClientModuleInitRegistry::new(),
828 cli_args,
829 })
830 }
831
832 pub fn with_module<T>(mut self, r#gen: T) -> Self
833 where
834 T: ClientModuleInit + 'static + Send + Sync,
835 {
836 self.module_inits.attach(r#gen);
837 self
838 }
839
840 pub fn with_default_modules(self) -> Self {
841 self.with_module(LightningClientInit::default())
842 .with_module(MintClientInit)
843 .with_module(fedimint_mintv2_client::MintClientInit)
844 .with_module(WalletClientInit::default())
845 .with_module(MetaClientInit)
846 .with_module(fedimint_lnv2_client::LightningClientInit::default())
847 .with_module(fedimint_walletv2_client::WalletClientInit)
848 }
849
850 pub async fn run(&mut self) {
851 match self.handle_command(self.cli_args.clone()).await {
852 Ok(output) => {
853 let _ = writeln!(std::io::stdout(), "{output}");
855 }
856 Err(err) => {
857 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
858 let _ = writeln!(std::io::stdout(), "{err}");
859 exit(1);
860 }
861 }
862 }
863
864 async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
865 let mut client_builder = Client::builder()
866 .await
867 .with_iroh_enable_dht(cli.iroh_enable_dht());
868 client_builder.with_module_inits(self.module_inits.clone());
869
870 let db = cli.load_database().await?;
871 Ok((client_builder, db))
872 }
873
874 async fn client_join(
875 &mut self,
876 cli: &Opts,
877 invite_code: InviteCode,
878 ) -> CliResult<ClientHandleArc> {
879 let (client_builder, db) = self.make_client_builder(cli).await?;
880
881 let mnemonic = load_or_generate_mnemonic(&db).await?;
882
883 let client = client_builder
884 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
885 .await
886 .map_err_cli()?
887 .join(db, root_secret_from_mnemonic(&mnemonic))
888 .await
889 .map(Arc::new)
890 .map_err_cli()?;
891
892 print_welcome_message(&client).await;
893 log_expiration_notice(&client).await;
894
895 Ok(client)
896 }
897
898 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
899 let (mut client_builder, db) = self.make_client_builder(cli).await?;
900
901 if let Some(our_id) = cli.our_id {
903 client_builder.set_admin_creds(AdminCreds {
904 peer_id: our_id,
905 auth: cli.auth()?,
906 });
907 } else if let Some(stored_creds) = load_admin_creds(&db).await {
908 debug!(target: LOG_CLIENT, "Using stored admin credentials");
909 client_builder.set_admin_creds(AdminCreds {
910 peer_id: stored_creds.peer_id,
911 auth: ApiAuth::new(stored_creds.auth),
912 });
913 }
914
915 let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
916 .await
917 .map_err_cli()?;
918
919 let root_secret = match (cli.federation_secret_hex.as_deref(), existing_mnemonic) {
920 (Some(_), Some(_)) => {
921 return Err(CliError {
922 error: "client secret is already set in DB; --federation-secret-hex open requires a client DB without any stored secret".to_owned(),
923 });
924 }
925 (Some(federation_secret_hex), None) => {
926 RootSecret::Custom(decode_federation_secret_hex(federation_secret_hex)?)
927 }
928 (None, Some(entropy)) => {
929 let mnemonic = Mnemonic::from_entropy(&entropy).map_err_cli()?;
930 root_secret_from_mnemonic(&mnemonic)
931 }
932 (None, None) => {
933 return Err(CliError {
934 error: "Encoded client secret not present in DB".to_owned(),
935 });
936 }
937 };
938
939 let client = client_builder
940 .open(cli.make_endpoints().await.map_err_cli()?, db, root_secret)
941 .await
942 .map(Arc::new)
943 .map_err_cli()?;
944
945 log_expiration_notice(&client).await;
946
947 Ok(client)
948 }
949
950 async fn federation_ip_query(
951 &self,
952 cli: &Opts,
953 invite_code: &InviteCode,
954 path_timeout: Duration,
955 require_direct: bool,
956 ) -> CliResult<FederationPrivacyReport> {
957 let connectors = cli.make_endpoints().await.map_err_cli()?;
958 let (config, _api) = download_from_invite_code(&connectors, invite_code)
959 .await
960 .map_err_cli_msg("failed to download federation config from invite code")?;
961
962 let federation_id = config.calculate_federation_id();
963 let endpoints = config.global.api_endpoints;
964 if require_direct
965 && !endpoints
966 .values()
967 .any(|peer_url| peer_url.url.scheme() == "iroh")
968 {
969 return Err(CliError {
970 error: "--require-direct requires at least one iroh guardian endpoint".to_owned(),
971 });
972 }
973 let report_timeout = if require_direct {
974 Duration::ZERO
975 } else {
976 path_timeout
977 };
978 let mut guardian_reports =
979 query_guardian_addresses(&connectors, &endpoints, report_timeout).await;
980
981 if require_direct {
982 let deadline = fedimint_core::time::now() + path_timeout;
983 while !all_iroh_guardians_direct(&guardian_reports)
984 && fedimint_core::time::now() < deadline
985 {
986 runtime::sleep(Duration::from_millis(250)).await;
987 guardian_reports =
988 query_guardian_addresses(&connectors, &endpoints, Duration::ZERO).await;
989 }
990
991 if !all_iroh_guardians_direct(&guardian_reports) {
992 let not_direct = guardian_reports
993 .iter()
994 .filter(|(_, report)| {
995 report.api_url.scheme() == "iroh"
996 && !report
997 .iroh
998 .as_ref()
999 .is_some_and(|iroh| matches!(iroh.connectivity, "direct" | "mixed"))
1000 })
1001 .map(|(peer_id, _)| peer_id.to_string())
1002 .join(", ");
1003 return Err(CliError {
1004 error: format!(
1005 "not all iroh guardians reached a direct path within {path_timeout:?}; pending peers: {not_direct}"
1006 ),
1007 });
1008 }
1009 }
1010
1011 enrich_guardian_ip_info(&mut guardian_reports).await;
1012 let summary = summarize_privacy_report(guardian_reports.iter().map(|(_, report)| report));
1013 let guardians = guardian_reports
1014 .into_iter()
1015 .map(|(peer_id, report)| (peer_id.to_string(), report))
1016 .collect();
1017
1018 Ok(FederationPrivacyReport {
1019 federation_id,
1020 guardians,
1021 summary,
1022 })
1023 }
1024
1025 async fn client_recover(
1026 &mut self,
1027 cli: &Opts,
1028 recovery_secret: RecoverySecret,
1029 invite_code: InviteCode,
1030 ) -> CliResult<ClientHandleArc> {
1031 let (builder, db) = self.make_client_builder(cli).await?;
1032 let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
1033 .await
1034 .map_err_cli()?;
1035
1036 let root_secret = match (recovery_secret, existing_mnemonic) {
1037 (RecoverySecret::Mnemonic(mnemonic), Some(existing)) => {
1038 if existing != mnemonic.to_entropy() {
1039 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
1040 }
1041
1042 root_secret_from_mnemonic(&mnemonic)
1043 }
1044 (RecoverySecret::Mnemonic(mnemonic), None) => {
1045 Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
1046 .await
1047 .map_err_cli()?;
1048 root_secret_from_mnemonic(&mnemonic)
1049 }
1050 (RecoverySecret::FederationSecret(federation_secret), None) => {
1051 RootSecret::Custom(federation_secret)
1052 }
1053 (RecoverySecret::FederationSecret(_), Some(_)) => {
1054 return Err(CliError {
1055 error: "client secret is already set in DB; --federation-secret-hex restore requires a client DB without any stored secret".to_owned(),
1056 });
1057 }
1058 };
1059
1060 let preview = builder
1061 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
1062 .await
1063 .map_err_cli()?;
1064
1065 #[allow(deprecated)]
1066 let backup = preview
1067 .download_backup_from_federation(root_secret.clone())
1068 .await
1069 .map_err_cli()?;
1070
1071 let client = preview
1072 .recover(db, root_secret, backup)
1073 .await
1074 .map(Arc::new)
1075 .map_err_cli()?;
1076
1077 print_welcome_message(&client).await;
1078 log_expiration_notice(&client).await;
1079
1080 Ok(client)
1081 }
1082
1083 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
1084 if cli.federation_secret_hex.is_some() && matches!(&cli.command, Command::Join { .. }) {
1085 return Err(CliError {
1086 error: "--federation-secret-hex cannot be used with join".to_owned(),
1087 });
1088 }
1089
1090 match cli.command.clone() {
1091 Command::InviteCode { peer } => {
1092 let client = self.client_open(&cli).await?;
1093
1094 let invite_code = client
1095 .invite_code(peer)
1096 .await
1097 .ok_or_cli_msg("peer not found")?;
1098
1099 Ok(CliOutput::InviteCode { invite_code })
1100 }
1101 Command::Join { invite_code } => {
1102 {
1103 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
1104 .map_err_cli_msg("invalid invite code")?;
1105
1106 let _client = self.client_join(&cli, invite_code).await?;
1108 }
1109
1110 Ok(CliOutput::Join {
1111 joined: invite_code,
1112 })
1113 }
1114 Command::VersionHash => Ok(CliOutput::VersionHash {
1115 hash: fedimint_build_code_version_env!().to_string(),
1116 }),
1117 Command::Client(ClientCmd::Restore {
1118 mnemonic,
1119 invite_code,
1120 }) => {
1121 let invite_code: InviteCode =
1122 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
1123 let recovery_secret = match (
1124 mnemonic.as_deref(),
1125 cli.federation_secret_hex.as_deref(),
1126 ) {
1127 (Some(_), Some(_)) => {
1128 return Err(CliError {
1129 error: "restore accepts either --mnemonic or --federation-secret-hex, not both".to_owned(),
1130 });
1131 }
1132 (Some(mnemonic), None) => {
1133 let mnemonic = Mnemonic::from_str(mnemonic).map_err_cli()?;
1134 RecoverySecret::Mnemonic(mnemonic)
1135 }
1136 (None, Some(federation_secret_hex)) => {
1137 let federation_secret =
1138 decode_federation_secret_hex(federation_secret_hex)?;
1139 RecoverySecret::FederationSecret(federation_secret)
1140 }
1141 (None, None) => {
1142 return Err(CliError {
1143 error: "restore requires either --mnemonic or --federation-secret-hex"
1144 .to_owned(),
1145 });
1146 }
1147 };
1148 let client = self
1149 .client_recover(&cli, recovery_secret, invite_code)
1150 .await?;
1151
1152 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
1155 client.wait_for_all_recoveries().await.map_err_cli()?;
1156
1157 debug!(target: LOG_CLIENT, "Recovery complete");
1158
1159 Ok(CliOutput::Raw(
1160 serde_json::to_value(()).expect("unit type is serializable"),
1161 ))
1162 }
1163 Command::Client(command) => {
1164 let client = self.client_open(&cli).await?;
1165 Ok(CliOutput::Raw(
1166 client::handle_command(command, client)
1167 .await
1168 .map_err_cli()?,
1169 ))
1170 }
1171 Command::Admin(command) => self.handle_admin_command(cli, command).await,
1172 Command::Dev(command) => self.handle_dev_command(cli, command).await,
1173 Command::Completion { shell } => {
1174 let bin_path = PathBuf::from(
1175 std::env::args_os()
1176 .next()
1177 .expect("Binary name is always provided if we get this far"),
1178 );
1179 let bin_name = bin_path
1180 .file_name()
1181 .expect("path has file name")
1182 .to_string_lossy();
1183 clap_complete::generate(
1184 shell,
1185 &mut Opts::command(),
1186 bin_name.as_ref(),
1187 &mut std::io::stdout(),
1188 );
1189 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1191 }
1192 }
1193 }
1194
1195 async fn handle_admin_auth(
1198 &self,
1199 cli: &Opts,
1200 peer_id: u16,
1201 password: String,
1202 no_verify: bool,
1203 force: bool,
1204 ) -> CliOutputResult {
1205 let db = cli.load_database().await?;
1206 let peer_id = PeerId::from(peer_id);
1207 let auth = ApiAuth::new(password);
1208
1209 if !force {
1211 let existing = load_admin_creds(&db).await;
1212 if existing.is_some() {
1213 return Err(CliError {
1214 error: "Admin credentials already stored. Use --force to overwrite."
1215 .to_string(),
1216 });
1217 }
1218 }
1219
1220 let config = Client::get_config_from_db(&db)
1222 .await
1223 .ok_or_cli_msg("Client not initialized. Please join a federation first.")?;
1224
1225 let peer_url = config
1227 .global
1228 .api_endpoints
1229 .get(&peer_id)
1230 .ok_or_else(|| CliError {
1231 error: format!(
1232 "Peer ID {} not found in federation. Valid peer IDs are: {:?}",
1233 peer_id,
1234 config.global.api_endpoints.keys().collect::<Vec<_>>()
1235 ),
1236 })?;
1237
1238 if !no_verify {
1240 if !std::io::stdin().is_terminal() {
1242 return Err(CliError {
1243 error: "Interactive verification requires a terminal. Use --no-verify to skip."
1244 .to_string(),
1245 });
1246 }
1247
1248 eprintln!("Guardian endpoint for peer {}: {}", peer_id, peer_url.url);
1249 eprint!("Does this look correct? (y/N): ");
1250 std::io::stderr().flush().map_err_cli()?;
1251
1252 let mut input = String::new();
1253 std::io::stdin().read_line(&mut input).map_err_cli()?;
1254 let input = input.trim().to_lowercase();
1255
1256 if input != "y" && input != "yes" {
1257 return Err(CliError {
1258 error: "Endpoint verification cancelled by user.".to_string(),
1259 });
1260 }
1261 }
1262
1263 eprintln!("Verifying credentials...");
1265 let admin_api = DynGlobalApi::new_admin(
1266 cli.make_endpoints().await.map_err_cli()?,
1267 peer_id,
1268 peer_url.url.clone(),
1269 db.begin_transaction_nc()
1270 .await
1271 .get_value(&ApiSecretKey)
1272 .await
1273 .as_deref(),
1274 );
1275
1276 admin_api.auth(auth.clone()).await.map_err(|e| CliError {
1278 error: format!(
1279 "Failed to verify credentials: {e}. Please check your peer ID and password."
1280 ),
1281 })?;
1282
1283 store_admin_creds(
1285 &db,
1286 &StoredAdminCreds {
1287 peer_id,
1288 auth: auth.as_str().to_string(),
1289 },
1290 )
1291 .await;
1292
1293 eprintln!("Admin credentials verified and saved successfully.");
1294 Ok(CliOutput::Raw(json!({
1295 "peer_id": peer_id,
1296 "endpoint": peer_url.url.to_string(),
1297 "status": "saved"
1298 })))
1299 }
1300
1301 async fn handle_admin_command(&mut self, cli: Opts, command: AdminCmd) -> CliOutputResult {
1304 match command {
1305 AdminCmd::Auth {
1306 peer_id,
1307 password,
1308 no_verify,
1309 force,
1310 } => {
1311 self.handle_admin_auth(&cli, peer_id, password, no_verify, force)
1312 .await
1313 }
1314 AdminCmd::Audit => {
1315 let client = self.client_open(&cli).await?;
1316
1317 let audit = cli
1318 .admin_client(
1319 &client.get_peer_urls().await,
1320 client.api_secret().as_deref(),
1321 )
1322 .await?
1323 .audit(cli.auth()?)
1324 .await?;
1325 Ok(CliOutput::Raw(
1326 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
1327 ))
1328 }
1329 AdminCmd::Status => {
1330 let client = self.client_open(&cli).await?;
1331
1332 let status = cli
1333 .admin_client_with_db(
1334 &client.get_peer_urls().await,
1335 client.api_secret().as_deref(),
1336 Some(client.db()),
1337 )
1338 .await?
1339 .status()
1340 .await?;
1341 Ok(CliOutput::Raw(
1342 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
1343 ))
1344 }
1345 AdminCmd::GuardianConfigBackup => {
1346 let client = self.client_open(&cli).await?;
1347
1348 let guardian_config_backup = cli
1349 .admin_client(
1350 &client.get_peer_urls().await,
1351 client.api_secret().as_deref(),
1352 )
1353 .await?
1354 .guardian_config_backup(cli.auth()?)
1355 .await?;
1356 Ok(CliOutput::Raw(
1357 serde_json::to_value(guardian_config_backup)
1358 .map_err_cli_msg("invalid response")?,
1359 ))
1360 }
1361 AdminCmd::Setup(dkg_args) => self
1362 .handle_admin_setup_command(cli, dkg_args)
1363 .await
1364 .map(CliOutput::Raw)
1365 .map_err_cli_msg("Config Gen Error"),
1366 AdminCmd::SignApiAnnouncement {
1367 api_url,
1368 override_url,
1369 } => {
1370 let client = self.client_open(&cli).await?;
1371
1372 if !["ws", "wss"].contains(&api_url.scheme()) {
1373 return Err(CliError {
1374 error: format!(
1375 "Unsupported URL scheme {}, use ws:// or wss://",
1376 api_url.scheme()
1377 ),
1378 });
1379 }
1380
1381 let announcement = cli
1382 .admin_client(
1383 &override_url
1384 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
1385 .unwrap_or(client.get_peer_urls().await),
1386 client.api_secret().as_deref(),
1387 )
1388 .await?
1389 .sign_api_announcement(api_url, cli.auth()?)
1390 .await?;
1391
1392 Ok(CliOutput::Raw(
1393 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
1394 ))
1395 }
1396 AdminCmd::SignGuardianMetadata { api_urls, pkarr_id } => {
1397 let client = self.client_open(&cli).await?;
1398
1399 let metadata = fedimint_core::net::guardian_metadata::GuardianMetadata::new(
1400 api_urls,
1401 pkarr_id,
1402 fedimint_core::time::duration_since_epoch().as_secs(),
1403 );
1404
1405 let signed_metadata = cli
1406 .admin_client(
1407 &client.get_peer_urls().await,
1408 client.api_secret().as_deref(),
1409 )
1410 .await?
1411 .sign_guardian_metadata(metadata, cli.auth()?)
1412 .await?;
1413
1414 Ok(CliOutput::Raw(
1415 serde_json::to_value(signed_metadata).map_err_cli_msg("invalid response")?,
1416 ))
1417 }
1418 AdminCmd::Shutdown { session_idx } => {
1419 let client = self.client_open(&cli).await?;
1420
1421 cli.admin_client(
1422 &client.get_peer_urls().await,
1423 client.api_secret().as_deref(),
1424 )
1425 .await?
1426 .shutdown(Some(session_idx), cli.auth()?)
1427 .await?;
1428
1429 Ok(CliOutput::Raw(json!(null)))
1430 }
1431 AdminCmd::BackupStatistics => {
1432 let client = self.client_open(&cli).await?;
1433
1434 let backup_statistics = cli
1435 .admin_client(
1436 &client.get_peer_urls().await,
1437 client.api_secret().as_deref(),
1438 )
1439 .await?
1440 .backup_statistics(cli.auth()?)
1441 .await?;
1442
1443 Ok(CliOutput::Raw(
1444 serde_json::to_value(backup_statistics).expect("Can be encoded"),
1445 ))
1446 }
1447 }
1448 }
1449
1450 async fn handle_api_request(&mut self, cli: &Opts, request: ApiRequest) -> CliOutputResult {
1453 let ApiRequest {
1454 method,
1455 params,
1456 peer_id,
1457 auth,
1458 module,
1459 } = request;
1460 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
1463 debug!(
1464 target: LOG_CLIENT,
1465 "Failed to serialize params:{}. Converting it to JSON string",
1466 err
1467 );
1468
1469 serde_json::Value::String(params)
1470 });
1471
1472 let mut params = ApiRequestErased::new(params);
1473 if let Some(auth) = auth {
1474 params = params.with_auth(ApiAuth::new(auth));
1475 }
1476 let client = self.client_open(cli).await?;
1477
1478 let api = client.api_clone();
1479
1480 let module_api = match module {
1481 Some(selector) => Some(api.with_module(selector.resolve(&client).map_err_cli()?)),
1482 None => None,
1483 };
1484
1485 let response: Value = match (peer_id, module_api) {
1486 (Some(peer_id), Some(module_api)) => module_api
1487 .request_raw(peer_id.into(), &method, ¶ms)
1488 .await
1489 .map_err_cli()?,
1490 (Some(peer_id), None) => api
1491 .request_raw(peer_id.into(), &method, ¶ms)
1492 .await
1493 .map_err_cli()?,
1494 (None, Some(module_api)) => module_api
1495 .request_current_consensus(method, params)
1496 .await
1497 .map_err_cli()?,
1498 (None, None) => api
1499 .request_current_consensus(method, params)
1500 .await
1501 .map_err_cli()?,
1502 };
1503
1504 Ok(CliOutput::UntypedApiOutput { value: response })
1505 }
1506
1507 async fn submit_transaction(&self, cli: &Opts, transaction: &str) -> CliOutputResult {
1510 let client = self.client_open(cli).await?;
1511 let tx = Transaction::consensus_decode_hex(transaction, client.decoders()).map_err_cli()?;
1512 let tx_outcome = client
1513 .api()
1514 .submit_transaction(tx)
1515 .await
1516 .try_into_inner(client.decoders())
1517 .map_err_cli()?;
1518 Ok(CliOutput::Raw(
1519 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1520 ))
1521 }
1522
1523 async fn handle_visualize(&self, cli: &Opts, visualize_type: VisualizeCmd) -> CliOutputResult {
1525 let client = self.client_open(cli).await?;
1526 match visualize_type {
1527 VisualizeCmd::Notes { limit } => {
1528 visualize::cmd_notes(&client, limit).await?;
1529 }
1530 VisualizeCmd::Transactions {
1531 operation_id,
1532 limit,
1533 } => {
1534 visualize::cmd_transactions(&client, operation_id, limit).await?;
1535 }
1536 VisualizeCmd::Operations {
1537 operation_id,
1538 limit,
1539 } => {
1540 visualize::cmd_operations(&client, operation_id, limit).await?;
1541 }
1542 }
1543 Ok(CliOutput::Raw(json!({})))
1544 }
1545
1546 async fn fetch_meta_fields(&self, cli: &Opts) -> CliOutputResult {
1548 let client = self.client_open(cli).await?;
1549 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1550 let meta_fields = source
1551 .fetch(
1552 &client.config().await,
1553 &client.api_clone(),
1554 FetchKind::Initial,
1555 None,
1556 )
1557 .await
1558 .map_err_cli()?;
1559 Ok(CliOutput::Raw(
1560 serde_json::to_value(meta_fields).expect("Can be encoded"),
1561 ))
1562 }
1563
1564 async fn handle_dev_command(&mut self, cli: Opts, command: DevCmd) -> CliOutputResult {
1566 match command {
1567 DevCmd::Api {
1568 method,
1569 params,
1570 peer_id,
1571 password: auth,
1572 module,
1573 } => {
1574 self.handle_api_request(
1575 &cli,
1576 ApiRequest {
1577 method,
1578 params,
1579 peer_id,
1580 auth,
1581 module,
1582 },
1583 )
1584 .await
1585 }
1586 DevCmd::AdvanceNoteIdx { count, amount } => {
1587 let client = self.client_open(&cli).await?;
1588
1589 let mint = client
1590 .get_first_module::<MintClientModule>()
1591 .map_err_cli_msg("can't get mint module")?;
1592
1593 for _ in 0..count {
1594 mint.advance_note_idx(amount).await;
1595 }
1596
1597 Ok(CliOutput::Raw(serde_json::Value::Null))
1598 }
1599 DevCmd::ApiAnnouncements => {
1600 let client = self.client_open(&cli).await?;
1601 let announcements = client.get_peer_url_announcements().await;
1602 Ok(CliOutput::Raw(
1603 serde_json::to_value(announcements).expect("Can be encoded"),
1604 ))
1605 }
1606 DevCmd::GuardianMetadata => {
1607 let client = self.client_open(&cli).await?;
1608 let metadata = client.get_guardian_metadata().await;
1609 Ok(CliOutput::Raw(
1610 serde_json::to_value(metadata).expect("Can be encoded"),
1611 ))
1612 }
1613 DevCmd::WaitBlockCount { count: target } => self.wait_block_count(&cli, target).await,
1614
1615 DevCmd::WaitComplete => {
1616 let client = self.client_open(&cli).await?;
1617 client.wait_for_all_active_state_machines().await;
1618 Ok(CliOutput::Raw(serde_json::Value::Null))
1619 }
1620 DevCmd::Wait { seconds } => self.wait_with_networking(&cli, seconds).await,
1621 DevCmd::Decode { decode_type } => self.handle_decode(&cli, decode_type).await,
1622 DevCmd::Encode { encode_type } => command_encoding::handle_encode(encode_type),
1623 DevCmd::SessionCount => {
1624 let client = self.client_open(&cli).await?;
1625 let count = client.api().session_count().await?;
1626 Ok(CliOutput::EpochCount { count })
1627 }
1628 DevCmd::QueryFederationIps {
1629 invite_code,
1630 path_timeout_seconds,
1631 require_direct,
1632 } => {
1633 let report = self
1634 .federation_ip_query(
1635 &cli,
1636 &invite_code,
1637 Duration::from_secs(path_timeout_seconds),
1638 require_direct,
1639 )
1640 .await?;
1641 Ok(CliOutput::Raw(
1642 serde_json::to_value(report).expect("privacy report is serializable"),
1643 ))
1644 }
1645 DevCmd::Config => {
1646 let client = self.client_open(&cli).await?;
1647 let config = client.get_config_json().await;
1648 Ok(CliOutput::Raw(
1649 serde_json::to_value(config).expect("Client config is serializable"),
1650 ))
1651 }
1652 DevCmd::ConfigDecrypt {
1653 in_file,
1654 out_file,
1655 salt_file,
1656 password,
1657 } => decrypt_config(in_file, out_file, salt_file, &password),
1658 DevCmd::ConfigEncrypt {
1659 in_file,
1660 out_file,
1661 salt_file,
1662 password,
1663 } => encrypt_config(in_file, out_file, salt_file, &password),
1664 DevCmd::ListOperationStates { operation_id } => {
1665 #[derive(Serialize)]
1666 struct ReactorLogState {
1667 active: bool,
1668 module_instance: ModuleInstanceId,
1669 creation_time: String,
1670 #[serde(skip_serializing_if = "Option::is_none")]
1671 end_time: Option<String>,
1672 state: String,
1673 }
1674
1675 let client = self.client_open(&cli).await?;
1676
1677 let (active_states, inactive_states) =
1678 client.executor().get_operation_states(operation_id).await;
1679 let all_states =
1680 active_states
1681 .into_iter()
1682 .map(|(active_state, active_meta)| ReactorLogState {
1683 active: true,
1684 module_instance: active_state.module_instance_id(),
1685 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1686 end_time: None,
1687 state: format!("{active_state:?}",),
1688 })
1689 .chain(inactive_states.into_iter().map(
1690 |(inactive_state, inactive_meta)| ReactorLogState {
1691 active: false,
1692 module_instance: inactive_state.module_instance_id(),
1693 creation_time: crate::client::time_to_iso8601(
1694 &inactive_meta.created_at,
1695 ),
1696 end_time: Some(crate::client::time_to_iso8601(
1697 &inactive_meta.exited_at,
1698 )),
1699 state: format!("{inactive_state:?}",),
1700 },
1701 ))
1702 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1703 .collect::<Vec<_>>();
1704
1705 Ok(CliOutput::Raw(json!({
1706 "states": all_states
1707 })))
1708 }
1709 DevCmd::MetaFields => self.fetch_meta_fields(&cli).await,
1710 DevCmd::PeerVersion { peer_id } => {
1711 let client = self.client_open(&cli).await?;
1712 let version = client
1713 .api()
1714 .fedimintd_version(peer_id.into())
1715 .await
1716 .map_err_cli()?;
1717
1718 Ok(CliOutput::Raw(json!({ "version": version })))
1719 }
1720 DevCmd::ShowEventLog { pos, limit } => {
1721 let client = self.client_open(&cli).await?;
1722
1723 let events: Vec<_> = client
1724 .get_event_log(pos, limit)
1725 .await
1726 .into_iter()
1727 .map(|v| event_output::event_to_json(v.id(), v.as_raw()))
1728 .collect();
1729
1730 Ok(CliOutput::Raw(
1731 serde_json::to_value(events).expect("Can be encoded"),
1732 ))
1733 }
1734 DevCmd::ShowEventLogTrimable { pos, limit } => {
1735 let client = self.client_open(&cli).await?;
1736
1737 let events: Vec<_> = client
1738 .get_event_log_trimable(
1739 pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1740 limit,
1741 )
1742 .await
1743 .into_iter()
1744 .map(|v| event_output::event_to_json(v.id(), v.as_raw()))
1745 .collect();
1746
1747 Ok(CliOutput::Raw(
1748 serde_json::to_value(events).expect("Can be encoded"),
1749 ))
1750 }
1751 DevCmd::NextEventLogId => {
1752 let client = self.client_open(&cli).await?;
1753
1754 let id = client.get_next_event_log_id().await;
1755
1756 Ok(CliOutput::Raw(
1757 serde_json::to_value(id).expect("Can be encoded"),
1758 ))
1759 }
1760 DevCmd::SubmitTransaction { transaction } => {
1761 self.submit_transaction(&cli, &transaction).await
1762 }
1763 DevCmd::TestEventLogHandling => {
1764 let client = self.client_open(&cli).await?;
1765
1766 client
1767 .handle_events(
1768 client.built_in_application_event_log_tracker(),
1769 move |_dbtx, event| {
1770 Box::pin(async move {
1771 info!(target: LOG_CLIENT, "{event:?}");
1772
1773 Ok::<(), std::convert::Infallible>(())
1774 })
1775 },
1776 )
1777 .await
1778 .map_err_cli()?;
1779 unreachable!(
1780 "handle_events exits only if client shuts down, which we don't do here"
1781 )
1782 }
1783 DevCmd::Panic => {
1784 panic!("This panic is intentional for testing backtrace handling");
1785 }
1786 DevCmd::ChainId => {
1787 let client = self.client_open(&cli).await?;
1788 let chain_id = client
1789 .db()
1790 .begin_transaction_nc()
1791 .await
1792 .get_value(&fedimint_client::db::ChainIdKey)
1793 .await
1794 .ok_or_cli_msg("Chain ID not cached in client database")?;
1795
1796 Ok(CliOutput::Raw(serde_json::json!({
1797 "chain_id": chain_id.to_string()
1798 })))
1799 }
1800 DevCmd::Visualize { visualize_type } => {
1801 self.handle_visualize(&cli, visualize_type).await
1802 }
1803 DevCmd::RefreshApiVersions => {
1804 let client = self.client_open(&cli).await?;
1805 let versions = client.refresh_api_versions().await.map_err_cli()?;
1806 Ok(CliOutput::Raw(json!({ "versions": versions })))
1807 }
1808 }
1809 }
1810
1811 async fn handle_admin_setup_command(
1812 &self,
1813 cli: Opts,
1814 args: SetupAdminArgs,
1815 ) -> anyhow::Result<Value> {
1816 let client =
1817 DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone());
1818
1819 match &args.subcommand {
1820 SetupAdminCmd::Status => {
1821 let status = client.setup_status(cli.auth()?).await?;
1822
1823 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1824 }
1825 SetupAdminCmd::SetLocalParams {
1826 name,
1827 federation_name,
1828 federation_size,
1829 } => {
1830 let info = client
1831 .set_local_params(
1832 name.clone(),
1833 federation_name.clone(),
1834 None,
1835 None,
1836 *federation_size,
1837 cli.auth()?,
1838 )
1839 .await?;
1840
1841 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1842 }
1843 SetupAdminCmd::AddPeer { info } => {
1844 let name = client
1845 .add_peer_connection_info(info.clone(), cli.auth()?)
1846 .await?;
1847
1848 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1849 }
1850 SetupAdminCmd::StartDkg => {
1851 client.start_dkg(cli.auth()?).await?;
1852
1853 Ok(Value::Null)
1854 }
1855 }
1856 }
1857}
1858
1859async fn log_expiration_notice(client: &Client) {
1860 client.get_meta_expiration_timestamp().await;
1861 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1862 match expiration_time.duration_since(fedimint_core::time::now()) {
1863 Ok(until_expiration) => {
1864 let days = until_expiration.as_secs() / (60 * 60 * 24);
1865
1866 if 90 < days {
1867 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1868 } else if 30 < days {
1869 info!(target: LOG_CLIENT, %days, "This federation will expire");
1870 } else {
1871 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1872 }
1873 }
1874 Err(_) => {
1875 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1876 }
1877 }
1878 }
1879}
1880async fn print_welcome_message(client: &Client) {
1881 if let Some(welcome_message) = client
1882 .meta_service()
1883 .get_field::<String>(client.db(), "welcome_message")
1884 .await
1885 .and_then(|v| v.value)
1886 {
1887 eprintln!("{welcome_message}");
1888 }
1889}
1890
1891fn decrypt_config(
1893 in_file: PathBuf,
1894 out_file: PathBuf,
1895 salt_file: Option<PathBuf>,
1896 password: &str,
1897) -> CliOutputResult {
1898 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1899 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1900 let key = get_encryption_key(password, &salt).map_err_cli()?;
1901 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1902 let mut out_file_handle = fs::File::options()
1903 .create_new(true)
1904 .write(true)
1905 .open(out_file)
1906 .expect("Could not create output cfg file");
1907 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1908 Ok(CliOutput::ConfigDecrypt)
1909}
1910
1911fn encrypt_config(
1913 in_file: PathBuf,
1914 out_file: PathBuf,
1915 salt_file: Option<PathBuf>,
1916 password: &str,
1917) -> CliOutputResult {
1918 let mut in_file_handle = fs::File::open(in_file).expect("Could not create output cfg file");
1919 let mut plaintext_bytes = vec![];
1920 in_file_handle
1921 .read_to_end(&mut plaintext_bytes)
1922 .expect("Could not read input cfg file");
1923 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1924 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1925 let key = get_encryption_key(password, &salt).map_err_cli()?;
1926 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1927 Ok(CliOutput::ConfigEncrypt)
1928}
1929
1930fn salt_from_file_path(file_path: &Path) -> PathBuf {
1931 file_path
1932 .parent()
1933 .expect("File has no parent?!")
1934 .join(SALT_FILE)
1935}
1936
1937fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1939 let metadata: BTreeMap<String, String> = metadata
1940 .into_iter()
1941 .map(|item| {
1942 match &item
1943 .splitn(2, '=')
1944 .map(ToString::to_string)
1945 .collect::<Vec<String>>()[..]
1946 {
1947 [] => Err(format_err!("Empty metadata argument not allowed")),
1948 [key] => Err(format_err!("Metadata {key} is missing a value")),
1949 [key, val] => Ok((key.clone(), val.clone())),
1950 [..] => unreachable!(),
1951 }
1952 })
1953 .collect::<anyhow::Result<_>>()
1954 .map_err_cli_msg("invalid metadata")?;
1955 Ok(metadata)
1956}
1957
1958#[test]
1959#[allow(clippy::unwrap_used)]
1960fn metadata_from_clap_cli_test() {
1961 for (args, expected) in [
1962 (
1963 vec!["a=b".to_string()],
1964 BTreeMap::from([("a".into(), "b".into())]),
1965 ),
1966 (
1967 vec!["a=b".to_string(), "c=d".to_string()],
1968 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1969 ),
1970 ] {
1971 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1972 }
1973}