Skip to main content

fedimint_cli/
client.rs

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    /// Display wallet info (holdings, tiers)
85    Info,
86    /// Reissue notes received from a third party to avoid double spends
87    #[clap(hide = true)]
88    Reissue {
89        oob_notes: OOBNotes,
90        #[arg(long = "no-wait", action = clap::ArgAction::SetFalse)]
91        wait: bool,
92    },
93    /// Prepare notes to send to a third party as a payment
94    #[clap(hide = true)]
95    Spend {
96        /// The amount of e-cash to spend
97        amount: Amount,
98        /// If the exact amount cannot be represented, return e-cash of a higher
99        /// value instead of failing
100        #[clap(long)]
101        allow_overpay: bool,
102        /// After how many seconds we will try to reclaim the e-cash if it
103        /// hasn't been redeemed by the recipient. Defaults to one week.
104        #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
105        timeout: u64,
106        /// If the necessary information to join the federation the e-cash
107        /// belongs to should be included in the serialized notes
108        #[clap(long)]
109        include_invite: bool,
110    },
111    /// Splits a string containing multiple e-cash notes (e.g. from the `spend`
112    /// command) into ones that contain exactly one.
113    #[clap(hide = true)]
114    Split { oob_notes: OOBNotes },
115    /// Combines two or more serialized e-cash notes strings
116    #[clap(hide = true)]
117    Combine {
118        #[clap(required = true)]
119        oob_notes: Vec<OOBNotes>,
120    },
121    /// Create a lightning invoice to receive payment via gateway
122    #[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    /// Wait for incoming invoice to be paid
136    #[clap(hide = true)]
137    AwaitInvoice { operation_id: OperationId },
138    /// Pay a lightning invoice or lnurl via a gateway
139    #[clap(hide = true)]
140    LnPay {
141        /// Lightning invoice or lnurl
142        payment_info: String,
143        /// Amount to pay, used for lnurl
144        #[clap(long)]
145        amount: Option<Amount>,
146        /// Invoice comment/description, used on lnurl
147        #[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    /// Wait for a lightning payment to complete
155    #[clap(hide = true)]
156    AwaitLnPay { operation_id: OperationId },
157    /// List registered gateways
158    #[clap(hide = true)]
159    ListGateways {
160        /// Don't fetch the registered gateways from the federation
161        #[clap(long, default_value = "false")]
162        no_update: bool,
163    },
164    /// Generate a new deposit address, funds sent to it can later be claimed
165    #[clap(hide = true)]
166    DepositAddress,
167    /// Wait for deposit on previously generated address
168    #[clap(hide = true)]
169    AwaitDeposit { operation_id: OperationId },
170    /// Withdraw funds from the federation
171    #[clap(hide = true)]
172    Withdraw {
173        #[clap(long)]
174        amount: BitcoinAmountOrAll,
175        #[clap(long)]
176        address: bitcoin::Address<NetworkUnchecked>,
177    },
178    /// Upload the (encrypted) snapshot of mint notes to federation
179    Backup {
180        #[clap(long = "metadata")]
181        /// Backup metadata, encoded as `key=value` (use `--metadata=key=value`,
182        /// possibly multiple times)
183        // TODO: Can we make it `*Map<String, String>` and avoid custom parsing?
184        metadata: Vec<String>,
185    },
186    /// Discover the common api version to use to communicate with the
187    /// federation
188    #[clap(hide = true)]
189    DiscoverVersion,
190    /// Join federation and restore modules that support it
191    Restore {
192        /// BIP-39 mnemonic used by standard Fedimint CLI clients.
193        ///
194        /// If omitted, use global `--federation-secret-hex` instead.
195        #[clap(long)]
196        mnemonic: Option<String>,
197        #[clap(long)]
198        invite_code: String,
199    },
200    /// Print the secret key of the client
201    PrintSecret,
202    ListOperations {
203        #[clap(long, default_value = "10")]
204        limit: usize,
205    },
206    /// Call a module subcommand
207    // Make `--help` be passed to the module handler, not root cli one
208    #[command(disable_help_flag = true)]
209    Module {
210        /// Module selector (either module id or module kind)
211        module: Option<ModuleSelector>,
212        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]
213        args: Vec<ffi::OsString>,
214    },
215    /// Returns the client config
216    #[clap(hide = true)]
217    Config,
218    /// Gets the current fedimint AlephBFT session count
219    #[clap(hide = true)]
220    SessionCount,
221    /// Shows how available each guardian has been, as combined from all
222    /// guardians' views of each other
223    PeerAvailability,
224}
225
226/// Reissue legacy mint notes, optionally waiting for the operation stream.
227async 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
260/// Select and spend legacy notes, preserving overpay and cancellation policy.
261async 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
318/// Split legacy notes into individual notes grouped by denomination.
319fn 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
347/// Combine legacy notes only when all inputs belong to the same federation.
348fn 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
377/// Create a legacy Lightning invoice through the selected gateway.
378async 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
413/// Wait for invoice receipt, preserving terminal stream outcomes.
414async 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
446/// Pay a legacy Lightning invoice or LNURL and wait for its final outcome.
447async 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
485/// Dispatch client commands using the already-open client.
486pub 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
704/// Quote and execute a legacy withdrawal, keeping its amount and fees paired.
705async 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        // The on-chain fee is only part of the cost of withdrawing
718        // everything: funding the peg-out output also incurs the
719        // federation's per-note fees. The returned fees are quoted at
720        // the returned amount, so they must be used together.
721        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    // Try wallet v1 first, then walletv2
760    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}