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