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