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 Info,
83 #[clap(hide = true)]
85 Reissue {
86 oob_notes: OOBNotes,
87 #[arg(long = "no-wait", action = clap::ArgAction::SetFalse)]
88 wait: bool,
89 },
90 #[clap(hide = true)]
92 Spend {
93 amount: Amount,
95 #[clap(long)]
98 allow_overpay: bool,
99 #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
102 timeout: u64,
103 #[clap(long)]
106 include_invite: bool,
107 },
108 #[clap(hide = true)]
111 Split { oob_notes: OOBNotes },
112 #[clap(hide = true)]
114 Combine {
115 #[clap(required = true)]
116 oob_notes: Vec<OOBNotes>,
117 },
118 #[clap(hide = true)]
120 LnInvoice {
121 #[clap(long)]
122 amount: Amount,
123 #[clap(long, default_value = "")]
124 description: String,
125 #[clap(long)]
126 expiry_time: Option<u64>,
127 #[clap(long)]
128 gateway_id: Option<secp256k1::PublicKey>,
129 #[clap(long, default_value = "false")]
130 force_internal: bool,
131 },
132 #[clap(hide = true)]
134 AwaitInvoice { operation_id: OperationId },
135 #[clap(hide = true)]
137 LnPay {
138 payment_info: String,
140 #[clap(long)]
142 amount: Option<Amount>,
143 #[clap(long)]
145 lnurl_comment: Option<String>,
146 #[clap(long)]
147 gateway_id: Option<secp256k1::PublicKey>,
148 #[clap(long, default_value = "false")]
149 force_internal: bool,
150 },
151 #[clap(hide = true)]
153 AwaitLnPay { operation_id: OperationId },
154 #[clap(hide = true)]
156 ListGateways {
157 #[clap(long, default_value = "false")]
159 no_update: bool,
160 },
161 #[clap(hide = true)]
163 DepositAddress,
164 #[clap(hide = true)]
166 AwaitDeposit { operation_id: OperationId },
167 #[clap(hide = true)]
169 Withdraw {
170 #[clap(long)]
171 amount: BitcoinAmountOrAll,
172 #[clap(long)]
173 address: bitcoin::Address<NetworkUnchecked>,
174 },
175 Backup {
177 #[clap(long = "metadata")]
178 metadata: Vec<String>,
182 },
183 #[clap(hide = true)]
186 DiscoverVersion,
187 Restore {
189 #[clap(long)]
193 mnemonic: Option<String>,
194 #[clap(long)]
195 invite_code: String,
196 },
197 PrintSecret,
199 ListOperations {
200 #[clap(long, default_value = "10")]
201 limit: usize,
202 },
203 #[command(disable_help_flag = true)]
206 Module {
207 module: Option<ModuleSelector>,
209 #[arg(allow_hyphen_values = true, trailing_var_arg = true)]
210 args: Vec<ffi::OsString>,
211 },
212 #[clap(hide = true)]
214 Config,
215 #[clap(hide = true)]
217 SessionCount,
218}
219
220pub async fn handle_command(
221 command: ClientCmd,
222 client: ClientHandleArc,
223) -> anyhow::Result<serde_json::Value> {
224 match command {
225 ClientCmd::Info => get_note_summary(&client).await,
226 ClientCmd::Reissue { oob_notes, wait } => {
227 warn!(
228 target: LOG_CLIENT,
229 "Command deprecated. Use `fedimint-cli module mint reissue` instead."
230 );
231 let amount = oob_notes.total_amount();
232
233 let mint = client.get_first_module::<MintClientModule>()?;
234
235 let operation_id = mint.reissue_external_notes(oob_notes, ()).await?;
236 if wait {
237 let mut updates = mint
238 .subscribe_reissue_external_notes(operation_id)
239 .await
240 .expect("just created operation can't already be deleted")
241 .into_stream();
242
243 while let Some(update) = updates.next().await {
244 if let fedimint_mint_client::ReissueExternalNotesState::Failed(e) = update {
245 bail!("Reissue failed: {e}");
246 }
247
248 debug!(target: LOG_CLIENT, ?update, "Reissue external notes state update");
249 }
250 }
251
252 Ok(serde_json::to_value(amount).expect("Amount is serializable"))
253 }
254 ClientCmd::Spend {
255 amount,
256 allow_overpay,
257 timeout,
258 include_invite,
259 } => {
260 warn!(
261 target: LOG_CLIENT,
262 "Command deprecated. Use `fedimint-cli module mint spend` instead."
263 );
264 warn!(
265 target: LOG_CLIENT,
266 "The client will try to double-spend these notes after the duration specified by the --timeout option to recover any unclaimed e-cash."
267 );
268
269 let mint_module = client.get_first_module::<MintClientModule>()?;
270 let timeout = Duration::from_secs(timeout);
271 let (operation, notes) = if allow_overpay {
272 let (operation, notes) = mint_module
273 .spend_notes_with_selector(
274 &SelectNotesWithAtleastAmount,
275 amount,
276 timeout,
277 include_invite,
278 (),
279 )
280 .await?;
281
282 let overspend_amount = notes.total_amount().saturating_sub(amount);
283 if overspend_amount != Amount::ZERO {
284 warn!(
285 target: LOG_CLIENT,
286 "Selected notes {} worth more than requested",
287 overspend_amount
288 );
289 }
290
291 (operation, notes)
292 } else {
293 mint_module
294 .spend_notes_with_selector(
295 &SelectNotesWithExactAmount,
296 amount,
297 timeout,
298 include_invite,
299 (),
300 )
301 .await?
302 };
303 info!(target: LOG_CLIENT, "Spend e-cash operation: {}", operation.fmt_short());
304
305 Ok(json!({
306 "notes": notes,
307 }))
308 }
309 ClientCmd::Split { oob_notes } => {
310 warn!(
311 target: LOG_CLIENT,
312 "Command deprecated. Use `fedimint-cli module mint split` instead."
313 );
314 let federation = oob_notes.federation_id_prefix();
315 let notes = oob_notes
316 .notes()
317 .iter()
318 .map(|(amount, notes)| {
319 let notes = notes
320 .iter()
321 .map(|note| {
322 OOBNotes::new(
323 federation,
324 TieredMulti::new(vec![(amount, vec![*note])].into_iter().collect()),
325 )
326 })
327 .collect::<Vec<_>>();
328 (amount, notes)
329 })
330 .collect::<BTreeMap<_, _>>();
331
332 Ok(json!({
333 "notes": notes,
334 }))
335 }
336 ClientCmd::Combine { oob_notes } => {
337 warn!(
338 target: LOG_CLIENT,
339 "Command deprecated. Use `fedimint-cli module mint combine` instead."
340 );
341 let federation_id_prefix = match oob_notes
342 .iter()
343 .map(OOBNotes::federation_id_prefix)
344 .all_equal_value()
345 {
346 Ok(id) => id,
347 Err(None) => panic!("At least one e-cash notes string expected"),
348 Err(Some((a, b))) => {
349 bail!("Trying to combine e-cash from different federations: {a} and {b}");
350 }
351 };
352
353 let combined_notes = oob_notes
354 .iter()
355 .flat_map(|notes| notes.notes().iter_items().map(|(amt, note)| (amt, *note)))
356 .collect();
357
358 let combined_oob_notes = OOBNotes::new(federation_id_prefix, combined_notes);
359
360 Ok(json!({
361 "notes": combined_oob_notes,
362 }))
363 }
364 ClientCmd::LnInvoice {
365 amount,
366 description,
367 expiry_time,
368 gateway_id,
369 force_internal,
370 } => {
371 warn!(
372 target: LOG_CLIENT,
373 "Command deprecated. Use `fedimint-cli module ln invoice` instead."
374 );
375 let lightning_module = client.get_first_module::<LightningClientModule>()?;
376 let ln_gateway = lightning_module
377 .get_gateway(gateway_id, force_internal)
378 .await?;
379
380 let lightning_module = client.get_first_module::<LightningClientModule>()?;
381 let desc = Description::new(description)?;
382 let (operation_id, invoice, _) = lightning_module
383 .create_bolt11_invoice(
384 amount,
385 Bolt11InvoiceDescription::Direct(desc),
386 expiry_time,
387 (),
388 ln_gateway,
389 )
390 .await?;
391 Ok(serde_json::to_value(LnInvoiceResponse {
392 operation_id,
393 invoice: invoice.to_string(),
394 })
395 .expect("LnInvoiceResponse is serializable"))
396 }
397 ClientCmd::AwaitInvoice { operation_id } => {
398 warn!(
399 target: LOG_CLIENT,
400 "Command deprecated. Use `fedimint-cli module ln await-invoice` instead."
401 );
402 let lightning_module = &client.get_first_module::<LightningClientModule>()?;
403 let mut updates = lightning_module
404 .subscribe_ln_receive(operation_id)
405 .await?
406 .into_stream();
407 while let Some(update) = updates.next().await {
408 match update {
409 LnReceiveState::Claimed => {
410 return get_note_summary(&client).await;
411 }
412 LnReceiveState::Canceled { reason } => {
413 return Err(reason.into());
414 }
415 _ => {}
416 }
417
418 debug!(target: LOG_CLIENT, ?update, "Await invoice state update");
419 }
420
421 Err(anyhow::anyhow!(
422 "Unexpected end of update stream. Lightning receive failed"
423 ))
424 }
425 ClientCmd::LnPay {
426 payment_info,
427 amount,
428 lnurl_comment,
429 gateway_id,
430 force_internal,
431 } => {
432 warn!(
433 target: LOG_CLIENT,
434 "Command deprecated. Use `fedimint-cli module ln pay` instead."
435 );
436 let bolt11 =
437 fedimint_ln_client::get_invoice(&payment_info, amount, lnurl_comment).await?;
438 info!(target: LOG_CLIENT, "Paying invoice: {bolt11}");
439 let lightning_module = client.get_first_module::<LightningClientModule>()?;
440 let ln_gateway = lightning_module
441 .get_gateway(gateway_id, force_internal)
442 .await?;
443
444 let lightning_module = client.get_first_module::<LightningClientModule>()?;
445 let OutgoingLightningPayment {
446 payment_type,
447 contract_id: _,
448 fee,
449 } = lightning_module
450 .pay_bolt11_invoice(ln_gateway, bolt11, ())
451 .await?;
452 let operation_id = payment_type.operation_id();
453 info!(
454 target: LOG_CLIENT,
455 "Gateway fee: {fee}, payment operation id: {}",
456 operation_id.fmt_short()
457 );
458 let lnv1 = client.get_first_module::<LightningClientModule>()?;
459 let outcome = lnv1.await_outgoing_payment(operation_id).await?;
460 Ok(serde_json::to_value(outcome).expect("Cant fail"))
461 }
462 ClientCmd::AwaitLnPay { operation_id } => {
463 warn!(
464 target: LOG_CLIENT,
465 "Command deprecated. Use `fedimint-cli module ln await-pay` instead."
466 );
467 let lightning_module = client.get_first_module::<LightningClientModule>()?;
468 let outcome = lightning_module
469 .await_outgoing_payment(operation_id)
470 .await?;
471 Ok(serde_json::to_value(outcome).expect("Cant fail"))
472 }
473 ClientCmd::ListGateways { no_update } => {
474 warn!(
475 target: LOG_CLIENT,
476 "Command deprecated. Use `fedimint-cli module ln list-gateways` instead."
477 );
478 let lightning_module = client.get_first_module::<LightningClientModule>()?;
479 if !no_update {
480 lightning_module.update_gateway_cache().await?;
481 }
482 let gateways = lightning_module.list_gateways().await;
483 if gateways.is_empty() {
484 return Ok(
485 serde_json::to_value(Vec::<String>::new()).expect("empty vec is serializable")
486 );
487 }
488
489 Ok(json!(&gateways))
490 }
491 ClientCmd::DepositAddress => {
492 warn!(
493 target: LOG_CLIENT,
494 "Command deprecated. Use `fedimint-cli module wallet new-deposit-address` instead."
495 );
496 let (operation_id, address, tweak_idx) = client
497 .get_first_module::<WalletClientModule>()?
498 .allocate_deposit_address_expert_only(())
499 .await?;
500 Ok(serde_json::json! {
501 {
502 "address": address,
503 "operation_id": operation_id,
504 "idx": tweak_idx.0
505 }
506 })
507 }
508 ClientCmd::AwaitDeposit { operation_id } => {
509 warn!(
510 target: LOG_CLIENT,
511 "Command deprecated. Use `fedimint-cli module wallet await-deposit` instead."
512 );
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(()).expect("unit type is serializable"))
519 }
520
521 ClientCmd::Backup { metadata } => {
522 let metadata = metadata_from_clap_cli(metadata)?;
523
524 #[allow(deprecated)]
525 client
526 .backup_to_federation(Metadata::from_json_serialized(metadata))
527 .await?;
528 Ok(serde_json::to_value(()).expect("unit type is serializable"))
529 }
530 ClientCmd::Restore { .. } => {
531 panic!("Has to be handled before initializing client")
532 }
533 ClientCmd::PrintSecret => {
534 let entropy = client.get_decoded_client_secret::<Vec<u8>>().await?;
535 let mnemonic = Mnemonic::from_entropy(&entropy)?;
536
537 Ok(json!({
538 "secret": mnemonic,
539 }))
540 }
541 ClientCmd::ListOperations { limit } => {
542 #[derive(Serialize)]
543 #[serde(rename_all = "snake_case")]
544 struct OperationOutput {
545 id: OperationId,
546 creation_time: String,
547 operation_kind: String,
548 operation_meta: serde_json::Value,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 outcome: Option<serde_json::Value>,
551 }
552
553 let operations = client
554 .operation_log()
555 .paginate_operations_rev(limit, None)
556 .await
557 .into_iter()
558 .map(|(k, v)| {
559 let creation_time = time_to_iso8601(&k.creation_time);
560
561 OperationOutput {
562 id: k.operation_id,
563 creation_time,
564 operation_kind: v.operation_module_kind().to_owned(),
565 operation_meta: v.meta(),
566 outcome: v.outcome(),
567 }
568 })
569 .collect::<Vec<_>>();
570
571 Ok(json!({
572 "operations": operations,
573 }))
574 }
575 ClientCmd::Withdraw { amount, address } => {
576 warn!(
577 target: LOG_CLIENT,
578 "Command deprecated. Use `fedimint-cli module wallet withdraw` instead."
579 );
580 let wallet_module = client.get_first_module::<WalletClientModule>()?;
581 let address = address.require_network(wallet_module.get_network())?;
582 let (amount, fees) = match amount {
583 BitcoinAmountOrAll::All => {
586 let balance =
587 bitcoin::Amount::from_sat(client.get_balance_for_btc().await?.msats / 1000);
588 let fees = wallet_module.get_withdraw_fees(&address, balance).await?;
589 let Some(amount) = balance.checked_sub(fees.amount()) else {
590 bail!("Not enough funds to pay fees");
591 };
592 (amount, fees)
593 }
594 BitcoinAmountOrAll::Amount(amount) => (
595 amount,
596 wallet_module.get_withdraw_fees(&address, amount).await?,
597 ),
598 };
599 let absolute_fees = fees.amount();
600
601 info!(
602 target: LOG_CLIENT,
603 "Attempting withdraw with fees: {fees:?}"
604 );
605
606 let operation_id = wallet_module.withdraw(&address, amount, fees, ()).await?;
607
608 let mut updates = wallet_module
609 .subscribe_withdraw_updates(operation_id)
610 .await?
611 .into_stream();
612
613 while let Some(update) = updates.next().await {
614 debug!(target: LOG_CLIENT, ?update, "Withdraw state update");
615
616 match update {
617 WithdrawState::Succeeded(txid) => {
618 return Ok(json!({
619 "txid": txid.consensus_encode_to_hex(),
620 "fees_sat": absolute_fees.to_sat(),
621 }));
622 }
623 WithdrawState::Failed(e) => {
624 bail!("Withdraw failed: {e}");
625 }
626 WithdrawState::Created => {}
627 }
628 }
629
630 unreachable!("Update stream ended without outcome");
631 }
632 ClientCmd::DiscoverVersion => {
633 Ok(json!({ "versions": client.load_and_refresh_common_api_version().await? }))
634 }
635 ClientCmd::Module { module, args } => {
636 if let Some(module) = module {
637 let module_instance_id = module.resolve(&client)?;
638
639 client
640 .get_module_client_dyn(module_instance_id)
641 .context("Module not found")?
642 .handle_cli_command(&args)
643 .await
644 } else {
645 let module_list: Vec<ModuleInfo> = client
646 .config()
647 .await
648 .modules
649 .iter()
650 .map(|(id, ClientModuleConfig { kind, .. })| ModuleInfo {
651 kind: kind.clone(),
652 id: *id,
653 status: if client.has_module(*id) {
654 ModuleStatus::Active
655 } else {
656 ModuleStatus::UnsupportedByClient
657 },
658 })
659 .collect();
660 Ok(json!({
661 "list": module_list,
662 }))
663 }
664 }
665 ClientCmd::Config => {
666 warn!(
667 target: LOG_CLIENT,
668 "Command deprecated. Use `fedimint-cli dev config` instead."
669 );
670 let config = client.get_config_json().await;
671 Ok(serde_json::to_value(config).expect("Client config is serializable"))
672 }
673 ClientCmd::SessionCount => {
674 warn!(
675 target: LOG_CLIENT,
676 "Command deprecated. Use `fedimint-cli dev session-count` instead."
677 );
678 let count = client.api().session_count().await?;
679 Ok(json!({ "count": count }))
680 }
681 }
682}
683
684async fn get_note_summary(client: &ClientHandleArc) -> anyhow::Result<serde_json::Value> {
685 let network = if let Ok(wallet_client) = client.get_first_module::<WalletClientModule>() {
687 wallet_client.get_network()
688 } else if let Ok(wallet_client) =
689 client.get_first_module::<fedimint_walletv2_client::WalletClientModule>()
690 {
691 wallet_client.get_network()
692 } else {
693 anyhow::bail!("No wallet module found");
694 };
695
696 let summary = if let Ok(mint_client) = client.get_first_module::<MintClientModule>() {
697 let mint_module_id = client
698 .get_first_instance(&fedimint_mint_client::KIND)
699 .context("Mint module not found")?;
700 mint_client
701 .get_note_counts_by_denomination(
702 &mut client
703 .db()
704 .begin_transaction_nc()
705 .await
706 .to_ref_with_prefix_module_id(mint_module_id)
707 .0,
708 )
709 .await
710 } else if let Ok(mintv2_client) =
711 client.get_first_module::<fedimint_mintv2_client::MintClientModule>()
712 {
713 let counts = mintv2_client.get_count_by_denomination().await;
714 let mut summary = TieredCounts::default();
715 #[allow(clippy::cast_possible_truncation)]
716 for (denomination, count) in counts {
717 summary.inc(denomination.amount(), count as usize);
718 }
719 summary
720 } else {
721 anyhow::bail!("No mint module found");
722 };
723
724 Ok(serde_json::to_value(InfoResponse {
725 federation_id: client.federation_id(),
726 network,
727 meta: client.config().await.global.meta.clone(),
728 total_amount_msat: summary.total_amount(),
729 total_num_notes: summary.count_items(),
730 denominations_msat: summary,
731 })
732 .expect("InfoResponse is serializable"))
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize)]
736#[serde(rename_all = "snake_case")]
737pub struct InfoResponse {
738 federation_id: FederationId,
739 network: Network,
740 meta: BTreeMap<String, String>,
741 total_amount_msat: Amount,
742 total_num_notes: usize,
743 denominations_msat: TieredCounts,
744}
745
746pub(crate) fn time_to_iso8601(time: &SystemTime) -> String {
747 const ISO8601_CONFIG: iso8601::EncodedConfig = iso8601::Config::DEFAULT
748 .set_formatted_components(iso8601::FormattedComponents::DateTime)
749 .encode();
750
751 OffsetDateTime::from_unix_timestamp_nanos(
752 time.duration_since(UNIX_EPOCH)
753 .expect("Couldn't convert time from SystemTime to timestamp")
754 .as_nanos()
755 .try_into()
756 .expect("Time overflowed"),
757 )
758 .expect("Couldn't convert time from SystemTime to OffsetDateTime")
759 .format(&iso8601::Iso8601::<ISO8601_CONFIG>)
760 .expect("Couldn't format OffsetDateTime as ISO8601")
761}