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