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