Skip to main content

fedimint_cli/
client.rs

1use std::collections::BTreeMap;
2use std::ffi;
3use std::str::FromStr;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, bail};
7use bitcoin::address::NetworkUnchecked;
8use bitcoin::{Network, secp256k1};
9use clap::Subcommand;
10use fedimint_bip39::Mnemonic;
11use fedimint_client::backup::Metadata;
12use fedimint_client::{Client, ClientHandleArc};
13use fedimint_core::config::{ClientModuleConfig, FederationId};
14use fedimint_core::core::{ModuleInstanceId, ModuleKind, OperationId};
15use fedimint_core::encoding::Encodable;
16use fedimint_core::{Amount, BitcoinAmountOrAll, TieredCounts, TieredMulti};
17use fedimint_ln_client::cli::LnInvoiceResponse;
18use fedimint_ln_client::{LightningClientModule, LnReceiveState, OutgoingLightningPayment};
19use fedimint_logging::LOG_CLIENT;
20use fedimint_mint_client::{
21    MintClientModule, OOBNotes, SelectNotesWithAtleastAmount, SelectNotesWithExactAmount,
22};
23use fedimint_wallet_client::{WalletClientModule, WithdrawState};
24use futures::StreamExt;
25use itertools::Itertools;
26use lightning_invoice::{Bolt11InvoiceDescription, Description};
27use serde::{Deserialize, Serialize};
28use serde_json::json;
29use time::OffsetDateTime;
30use time::format_description::well_known::iso8601;
31use tracing::{debug, info, warn};
32
33use crate::metadata_from_clap_cli;
34
35#[derive(Debug, Clone)]
36pub enum ModuleSelector {
37    Id(ModuleInstanceId),
38    Kind(ModuleKind),
39}
40
41impl ModuleSelector {
42    pub fn resolve(&self, client: &Client) -> anyhow::Result<ModuleInstanceId> {
43        Ok(match self {
44            ModuleSelector::Id(id) => {
45                client.get_module_client_dyn(*id)?;
46                *id
47            }
48            ModuleSelector::Kind(kind) => client
49                .get_first_instance(kind)
50                .context("No module with this kind found")?,
51        })
52    }
53}
54#[derive(Debug, Clone, Serialize)]
55pub enum ModuleStatus {
56    Active,
57    UnsupportedByClient,
58}
59
60#[derive(Serialize)]
61struct ModuleInfo {
62    kind: ModuleKind,
63    id: u16,
64    status: ModuleStatus,
65}
66
67impl FromStr for ModuleSelector {
68    type Err = anyhow::Error;
69
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        Ok(if s.chars().all(|ch| ch.is_ascii_digit()) {
72            Self::Id(s.parse()?)
73        } else {
74            Self::Kind(ModuleKind::clone_from_str(s))
75        })
76    }
77}
78
79#[derive(Debug, Clone, Subcommand)]
80pub enum ClientCmd {
81    /// Display wallet info (holdings, tiers)
82    Info,
83    /// Reissue notes received from a third party to avoid double spends
84    #[clap(hide = true)]
85    Reissue {
86        oob_notes: OOBNotes,
87        #[arg(long = "no-wait", action = clap::ArgAction::SetFalse)]
88        wait: bool,
89    },
90    /// Prepare notes to send to a third party as a payment
91    #[clap(hide = true)]
92    Spend {
93        /// The amount of e-cash to spend
94        amount: Amount,
95        /// If the exact amount cannot be represented, return e-cash of a higher
96        /// value instead of failing
97        #[clap(long)]
98        allow_overpay: bool,
99        /// After how many seconds we will try to reclaim the e-cash if it
100        /// hasn't been redeemed by the recipient. Defaults to one week.
101        #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
102        timeout: u64,
103        /// If the necessary information to join the federation the e-cash
104        /// belongs to should be included in the serialized notes
105        #[clap(long)]
106        include_invite: bool,
107    },
108    /// Splits a string containing multiple e-cash notes (e.g. from the `spend`
109    /// command) into ones that contain exactly one.
110    #[clap(hide = true)]
111    Split { oob_notes: OOBNotes },
112    /// Combines two or more serialized e-cash notes strings
113    #[clap(hide = true)]
114    Combine {
115        #[clap(required = true)]
116        oob_notes: Vec<OOBNotes>,
117    },
118    /// Create a lightning invoice to receive payment via gateway
119    #[clap(hide = true)]
120    LnInvoice {
121        #[clap(long)]
122        amount: Amount,
123        #[clap(long, default_value = "")]
124        description: String,
125        #[clap(long)]
126        expiry_time: Option<u64>,
127        #[clap(long)]
128        gateway_id: Option<secp256k1::PublicKey>,
129        #[clap(long, default_value = "false")]
130        force_internal: bool,
131    },
132    /// Wait for incoming invoice to be paid
133    #[clap(hide = true)]
134    AwaitInvoice { operation_id: OperationId },
135    /// Pay a lightning invoice or lnurl via a gateway
136    #[clap(hide = true)]
137    LnPay {
138        /// Lightning invoice or lnurl
139        payment_info: String,
140        /// Amount to pay, used for lnurl
141        #[clap(long)]
142        amount: Option<Amount>,
143        /// Invoice comment/description, used on lnurl
144        #[clap(long)]
145        lnurl_comment: Option<String>,
146        #[clap(long)]
147        gateway_id: Option<secp256k1::PublicKey>,
148        #[clap(long, default_value = "false")]
149        force_internal: bool,
150    },
151    /// Wait for a lightning payment to complete
152    #[clap(hide = true)]
153    AwaitLnPay { operation_id: OperationId },
154    /// List registered gateways
155    #[clap(hide = true)]
156    ListGateways {
157        /// Don't fetch the registered gateways from the federation
158        #[clap(long, default_value = "false")]
159        no_update: bool,
160    },
161    /// Generate a new deposit address, funds sent to it can later be claimed
162    #[clap(hide = true)]
163    DepositAddress,
164    /// Wait for deposit on previously generated address
165    #[clap(hide = true)]
166    AwaitDeposit { operation_id: OperationId },
167    /// Withdraw funds from the federation
168    #[clap(hide = true)]
169    Withdraw {
170        #[clap(long)]
171        amount: BitcoinAmountOrAll,
172        #[clap(long)]
173        address: bitcoin::Address<NetworkUnchecked>,
174    },
175    /// Upload the (encrypted) snapshot of mint notes to federation
176    Backup {
177        #[clap(long = "metadata")]
178        /// Backup metadata, encoded as `key=value` (use `--metadata=key=value`,
179        /// possibly multiple times)
180        // TODO: Can we make it `*Map<String, String>` and avoid custom parsing?
181        metadata: Vec<String>,
182    },
183    /// Discover the common api version to use to communicate with the
184    /// federation
185    #[clap(hide = true)]
186    DiscoverVersion,
187    /// Join federation and restore modules that support it
188    Restore {
189        /// BIP-39 mnemonic used by standard Fedimint CLI clients.
190        ///
191        /// If omitted, use global `--federation-secret-hex` instead.
192        #[clap(long)]
193        mnemonic: Option<String>,
194        #[clap(long)]
195        invite_code: String,
196    },
197    /// Print the secret key of the client
198    PrintSecret,
199    ListOperations {
200        #[clap(long, default_value = "10")]
201        limit: usize,
202    },
203    /// Call a module subcommand
204    // Make `--help` be passed to the module handler, not root cli one
205    #[command(disable_help_flag = true)]
206    Module {
207        /// Module selector (either module id or module kind)
208        module: Option<ModuleSelector>,
209        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]
210        args: Vec<ffi::OsString>,
211    },
212    /// Returns the client config
213    #[clap(hide = true)]
214    Config,
215    /// Gets the current fedimint AlephBFT session count
216    #[clap(hide = true)]
217    SessionCount,
218}
219
220pub async fn handle_command(
221    command: ClientCmd,
222    client: ClientHandleArc,
223) -> anyhow::Result<serde_json::Value> {
224    match command {
225        ClientCmd::Info => get_note_summary(&client).await,
226        ClientCmd::Reissue { oob_notes, wait } => {
227            warn!(
228                target: LOG_CLIENT,
229                "Command deprecated. Use `fedimint-cli module mint reissue` instead."
230            );
231            let amount = oob_notes.total_amount();
232
233            let mint = client.get_first_module::<MintClientModule>()?;
234
235            let operation_id = mint.reissue_external_notes(oob_notes, ()).await?;
236            if wait {
237                let mut updates = mint
238                    .subscribe_reissue_external_notes(operation_id)
239                    .await
240                    .unwrap()
241                    .into_stream();
242
243                while let Some(update) = updates.next().await {
244                    if let fedimint_mint_client::ReissueExternalNotesState::Failed(e) = update {
245                        bail!("Reissue failed: {e}");
246                    }
247
248                    debug!(target: LOG_CLIENT, ?update, "Reissue external notes state update");
249                }
250            }
251
252            Ok(serde_json::to_value(amount).unwrap())
253        }
254        ClientCmd::Spend {
255            amount,
256            allow_overpay,
257            timeout,
258            include_invite,
259        } => {
260            warn!(
261                target: LOG_CLIENT,
262                "Command deprecated. Use `fedimint-cli module mint spend` instead."
263            );
264            warn!(
265                target: LOG_CLIENT,
266                "The client will try to double-spend these notes after the duration specified by the --timeout option to recover any unclaimed e-cash."
267            );
268
269            let mint_module = client.get_first_module::<MintClientModule>()?;
270            let timeout = Duration::from_secs(timeout);
271            let (operation, notes) = if allow_overpay {
272                let (operation, notes) = mint_module
273                    .spend_notes_with_selector(
274                        &SelectNotesWithAtleastAmount,
275                        amount,
276                        timeout,
277                        include_invite,
278                        (),
279                    )
280                    .await?;
281
282                let overspend_amount = notes.total_amount().saturating_sub(amount);
283                if overspend_amount != Amount::ZERO {
284                    warn!(
285                        target: LOG_CLIENT,
286                        "Selected notes {} worth more than requested",
287                        overspend_amount
288                    );
289                }
290
291                (operation, notes)
292            } else {
293                mint_module
294                    .spend_notes_with_selector(
295                        &SelectNotesWithExactAmount,
296                        amount,
297                        timeout,
298                        include_invite,
299                        (),
300                    )
301                    .await?
302            };
303            info!(target: LOG_CLIENT, "Spend e-cash operation: {}", operation.fmt_short());
304
305            Ok(json!({
306                "notes": notes,
307            }))
308        }
309        ClientCmd::Split { oob_notes } => {
310            warn!(
311                target: LOG_CLIENT,
312                "Command deprecated. Use `fedimint-cli module mint split` instead."
313            );
314            let federation = oob_notes.federation_id_prefix();
315            let notes = oob_notes
316                .notes()
317                .iter()
318                .map(|(amount, notes)| {
319                    let notes = notes
320                        .iter()
321                        .map(|note| {
322                            OOBNotes::new(
323                                federation,
324                                TieredMulti::new(vec![(amount, vec![*note])].into_iter().collect()),
325                            )
326                        })
327                        .collect::<Vec<_>>();
328                    (amount, notes)
329                })
330                .collect::<BTreeMap<_, _>>();
331
332            Ok(json!({
333                "notes": notes,
334            }))
335        }
336        ClientCmd::Combine { oob_notes } => {
337            warn!(
338                target: LOG_CLIENT,
339                "Command deprecated. Use `fedimint-cli module mint combine` instead."
340            );
341            let federation_id_prefix = match oob_notes
342                .iter()
343                .map(OOBNotes::federation_id_prefix)
344                .all_equal_value()
345            {
346                Ok(id) => id,
347                Err(None) => panic!("At least one e-cash notes string expected"),
348                Err(Some((a, b))) => {
349                    bail!("Trying to combine e-cash from different federations: {a} and {b}");
350                }
351            };
352
353            let combined_notes = oob_notes
354                .iter()
355                .flat_map(|notes| notes.notes().iter_items().map(|(amt, note)| (amt, *note)))
356                .collect();
357
358            let combined_oob_notes = OOBNotes::new(federation_id_prefix, combined_notes);
359
360            Ok(json!({
361                "notes": combined_oob_notes,
362            }))
363        }
364        ClientCmd::LnInvoice {
365            amount,
366            description,
367            expiry_time,
368            gateway_id,
369            force_internal,
370        } => {
371            warn!(
372                target: LOG_CLIENT,
373                "Command deprecated. Use `fedimint-cli module ln invoice` instead."
374            );
375            let lightning_module = client.get_first_module::<LightningClientModule>()?;
376            let ln_gateway = lightning_module
377                .get_gateway(gateway_id, force_internal)
378                .await?;
379
380            let lightning_module = client.get_first_module::<LightningClientModule>()?;
381            let desc = Description::new(description)?;
382            let (operation_id, invoice, _) = lightning_module
383                .create_bolt11_invoice(
384                    amount,
385                    Bolt11InvoiceDescription::Direct(desc),
386                    expiry_time,
387                    (),
388                    ln_gateway,
389                )
390                .await?;
391            Ok(serde_json::to_value(LnInvoiceResponse {
392                operation_id,
393                invoice: invoice.to_string(),
394            })
395            .unwrap())
396        }
397        ClientCmd::AwaitInvoice { operation_id } => {
398            warn!(
399                target: LOG_CLIENT,
400                "Command deprecated. Use `fedimint-cli module ln await-invoice` instead."
401            );
402            let lightning_module = &client.get_first_module::<LightningClientModule>()?;
403            let mut updates = lightning_module
404                .subscribe_ln_receive(operation_id)
405                .await?
406                .into_stream();
407            while let Some(update) = updates.next().await {
408                match update {
409                    LnReceiveState::Claimed => {
410                        return get_note_summary(&client).await;
411                    }
412                    LnReceiveState::Canceled { reason } => {
413                        return Err(reason.into());
414                    }
415                    _ => {}
416                }
417
418                debug!(target: LOG_CLIENT, ?update, "Await invoice state update");
419            }
420
421            Err(anyhow::anyhow!(
422                "Unexpected end of update stream. Lightning receive failed"
423            ))
424        }
425        ClientCmd::LnPay {
426            payment_info,
427            amount,
428            lnurl_comment,
429            gateway_id,
430            force_internal,
431        } => {
432            warn!(
433                target: LOG_CLIENT,
434                "Command deprecated. Use `fedimint-cli module ln pay` instead."
435            );
436            let bolt11 =
437                fedimint_ln_client::get_invoice(&payment_info, amount, lnurl_comment).await?;
438            info!(target: LOG_CLIENT, "Paying invoice: {bolt11}");
439            let lightning_module = client.get_first_module::<LightningClientModule>()?;
440            let ln_gateway = lightning_module
441                .get_gateway(gateway_id, force_internal)
442                .await?;
443
444            let lightning_module = client.get_first_module::<LightningClientModule>()?;
445            let OutgoingLightningPayment {
446                payment_type,
447                contract_id: _,
448                fee,
449            } = lightning_module
450                .pay_bolt11_invoice(ln_gateway, bolt11, ())
451                .await?;
452            let operation_id = payment_type.operation_id();
453            info!(
454                target: LOG_CLIENT,
455                "Gateway fee: {fee}, payment operation id: {}",
456                operation_id.fmt_short()
457            );
458            let lnv1 = client.get_first_module::<LightningClientModule>()?;
459            let outcome = lnv1.await_outgoing_payment(operation_id).await?;
460            Ok(serde_json::to_value(outcome).expect("Cant fail"))
461        }
462        ClientCmd::AwaitLnPay { operation_id } => {
463            warn!(
464                target: LOG_CLIENT,
465                "Command deprecated. Use `fedimint-cli module ln await-pay` instead."
466            );
467            let lightning_module = client.get_first_module::<LightningClientModule>()?;
468            let outcome = lightning_module
469                .await_outgoing_payment(operation_id)
470                .await?;
471            Ok(serde_json::to_value(outcome).expect("Cant fail"))
472        }
473        ClientCmd::ListGateways { no_update } => {
474            warn!(
475                target: LOG_CLIENT,
476                "Command deprecated. Use `fedimint-cli module ln list-gateways` instead."
477            );
478            let lightning_module = client.get_first_module::<LightningClientModule>()?;
479            if !no_update {
480                lightning_module.update_gateway_cache().await?;
481            }
482            let gateways = lightning_module.list_gateways().await;
483            if gateways.is_empty() {
484                return Ok(serde_json::to_value(Vec::<String>::new()).unwrap());
485            }
486
487            Ok(json!(&gateways))
488        }
489        ClientCmd::DepositAddress => {
490            warn!(
491                target: LOG_CLIENT,
492                "Command deprecated. Use `fedimint-cli module wallet new-deposit-address` instead."
493            );
494            let (operation_id, address, tweak_idx) = client
495                .get_first_module::<WalletClientModule>()?
496                .allocate_deposit_address_expert_only(())
497                .await?;
498            Ok(serde_json::json! {
499                {
500                    "address": address,
501                    "operation_id": operation_id,
502                    "idx": tweak_idx.0
503                }
504            })
505        }
506        ClientCmd::AwaitDeposit { operation_id } => {
507            warn!(
508                target: LOG_CLIENT,
509                "Command deprecated. Use `fedimint-cli module wallet await-deposit` instead."
510            );
511            client
512                .get_first_module::<WalletClientModule>()?
513                .await_num_deposits_by_operation_id(operation_id, 1)
514                .await?;
515
516            Ok(serde_json::to_value(()).unwrap())
517        }
518
519        ClientCmd::Backup { metadata } => {
520            let metadata = metadata_from_clap_cli(metadata)?;
521
522            #[allow(deprecated)]
523            client
524                .backup_to_federation(Metadata::from_json_serialized(metadata))
525                .await?;
526            Ok(serde_json::to_value(()).unwrap())
527        }
528        ClientCmd::Restore { .. } => {
529            panic!("Has to be handled before initializing client")
530        }
531        ClientCmd::PrintSecret => {
532            let entropy = client.get_decoded_client_secret::<Vec<u8>>().await?;
533            let mnemonic = Mnemonic::from_entropy(&entropy)?;
534
535            Ok(json!({
536                "secret": mnemonic,
537            }))
538        }
539        ClientCmd::ListOperations { limit } => {
540            #[derive(Serialize)]
541            #[serde(rename_all = "snake_case")]
542            struct OperationOutput {
543                id: OperationId,
544                creation_time: String,
545                operation_kind: String,
546                operation_meta: serde_json::Value,
547                #[serde(skip_serializing_if = "Option::is_none")]
548                outcome: Option<serde_json::Value>,
549            }
550
551            let operations = client
552                .operation_log()
553                .paginate_operations_rev(limit, None)
554                .await
555                .into_iter()
556                .map(|(k, v)| {
557                    let creation_time = time_to_iso8601(&k.creation_time);
558
559                    OperationOutput {
560                        id: k.operation_id,
561                        creation_time,
562                        operation_kind: v.operation_module_kind().to_owned(),
563                        operation_meta: v.meta(),
564                        outcome: v.outcome(),
565                    }
566                })
567                .collect::<Vec<_>>();
568
569            Ok(json!({
570                "operations": operations,
571            }))
572        }
573        ClientCmd::Withdraw { amount, address } => {
574            warn!(
575                target: LOG_CLIENT,
576                "Command deprecated. Use `fedimint-cli module wallet withdraw` instead."
577            );
578            let wallet_module = client.get_first_module::<WalletClientModule>()?;
579            let address = address.require_network(wallet_module.get_network())?;
580            let (amount, fees) = match amount {
581                // If the amount is "all", then we need to subtract the fees from
582                // the amount we are withdrawing
583                BitcoinAmountOrAll::All => {
584                    let balance =
585                        bitcoin::Amount::from_sat(client.get_balance_for_btc().await?.msats / 1000);
586                    let fees = wallet_module.get_withdraw_fees(&address, balance).await?;
587                    let amount = balance.checked_sub(fees.amount());
588                    if amount.is_none() {
589                        bail!("Not enough funds to pay fees");
590                    }
591                    (amount.unwrap(), fees)
592                }
593                BitcoinAmountOrAll::Amount(amount) => (
594                    amount,
595                    wallet_module.get_withdraw_fees(&address, amount).await?,
596                ),
597            };
598            let absolute_fees = fees.amount();
599
600            info!(
601                target: LOG_CLIENT,
602                "Attempting withdraw with fees: {fees:?}"
603            );
604
605            let operation_id = wallet_module.withdraw(&address, amount, fees, ()).await?;
606
607            let mut updates = wallet_module
608                .subscribe_withdraw_updates(operation_id)
609                .await?
610                .into_stream();
611
612            while let Some(update) = updates.next().await {
613                debug!(target: LOG_CLIENT, ?update, "Withdraw state update");
614
615                match update {
616                    WithdrawState::Succeeded(txid) => {
617                        return Ok(json!({
618                            "txid": txid.consensus_encode_to_hex(),
619                            "fees_sat": absolute_fees.to_sat(),
620                        }));
621                    }
622                    WithdrawState::Failed(e) => {
623                        bail!("Withdraw failed: {e}");
624                    }
625                    WithdrawState::Created => {}
626                }
627            }
628
629            unreachable!("Update stream ended without outcome");
630        }
631        ClientCmd::DiscoverVersion => {
632            Ok(json!({ "versions": client.load_and_refresh_common_api_version().await? }))
633        }
634        ClientCmd::Module { module, args } => {
635            if let Some(module) = module {
636                let module_instance_id = module.resolve(&client)?;
637
638                client
639                    .get_module_client_dyn(module_instance_id)
640                    .context("Module not found")?
641                    .handle_cli_command(&args)
642                    .await
643            } else {
644                let module_list: Vec<ModuleInfo> = client
645                    .config()
646                    .await
647                    .modules
648                    .iter()
649                    .map(|(id, ClientModuleConfig { kind, .. })| ModuleInfo {
650                        kind: kind.clone(),
651                        id: *id,
652                        status: if client.has_module(*id) {
653                            ModuleStatus::Active
654                        } else {
655                            ModuleStatus::UnsupportedByClient
656                        },
657                    })
658                    .collect();
659                Ok(json!({
660                    "list": module_list,
661                }))
662            }
663        }
664        ClientCmd::Config => {
665            warn!(
666                target: LOG_CLIENT,
667                "Command deprecated. Use `fedimint-cli dev config` instead."
668            );
669            let config = client.get_config_json().await;
670            Ok(serde_json::to_value(config).expect("Client config is serializable"))
671        }
672        ClientCmd::SessionCount => {
673            warn!(
674                target: LOG_CLIENT,
675                "Command deprecated. Use `fedimint-cli dev session-count` instead."
676            );
677            let count = client.api().session_count().await?;
678            Ok(json!({ "count": count }))
679        }
680    }
681}
682
683async fn get_note_summary(client: &ClientHandleArc) -> anyhow::Result<serde_json::Value> {
684    // Try wallet v1 first, then walletv2
685    let network = if let Ok(wallet_client) = client.get_first_module::<WalletClientModule>() {
686        wallet_client.get_network()
687    } else if let Ok(wallet_client) =
688        client.get_first_module::<fedimint_walletv2_client::WalletClientModule>()
689    {
690        wallet_client.get_network()
691    } else {
692        anyhow::bail!("No wallet module found");
693    };
694
695    let summary = if let Ok(mint_client) = client.get_first_module::<MintClientModule>() {
696        let mint_module_id = client
697            .get_first_instance(&fedimint_mint_client::KIND)
698            .context("Mint module not found")?;
699        mint_client
700            .get_note_counts_by_denomination(
701                &mut client
702                    .db()
703                    .begin_transaction_nc()
704                    .await
705                    .to_ref_with_prefix_module_id(mint_module_id)
706                    .0,
707            )
708            .await
709    } else if let Ok(mintv2_client) =
710        client.get_first_module::<fedimint_mintv2_client::MintClientModule>()
711    {
712        let counts = mintv2_client.get_count_by_denomination().await;
713        let mut summary = TieredCounts::default();
714        #[allow(clippy::cast_possible_truncation)]
715        for (denomination, count) in counts {
716            summary.inc(denomination.amount(), count as usize);
717        }
718        summary
719    } else {
720        anyhow::bail!("No mint module found");
721    };
722
723    Ok(serde_json::to_value(InfoResponse {
724        federation_id: client.federation_id(),
725        network,
726        meta: client.config().await.global.meta.clone(),
727        total_amount_msat: summary.total_amount(),
728        total_num_notes: summary.count_items(),
729        denominations_msat: summary,
730    })
731    .unwrap())
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
735#[serde(rename_all = "snake_case")]
736pub struct InfoResponse {
737    federation_id: FederationId,
738    network: Network,
739    meta: BTreeMap<String, String>,
740    total_amount_msat: Amount,
741    total_num_notes: usize,
742    denominations_msat: TieredCounts,
743}
744
745pub(crate) fn time_to_iso8601(time: &SystemTime) -> String {
746    const ISO8601_CONFIG: iso8601::EncodedConfig = iso8601::Config::DEFAULT
747        .set_formatted_components(iso8601::FormattedComponents::DateTime)
748        .encode();
749
750    OffsetDateTime::from_unix_timestamp_nanos(
751        time.duration_since(UNIX_EPOCH)
752            .expect("Couldn't convert time from SystemTime to timestamp")
753            .as_nanos()
754            .try_into()
755            .expect("Time overflowed"),
756    )
757    .expect("Couldn't convert time from SystemTime to OffsetDateTime")
758    .format(&iso8601::Iso8601::<ISO8601_CONFIG>)
759    .expect("Couldn't format OffsetDateTime as ISO8601")
760}