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