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 Info,
85 Reissue {
87 oob_notes: OOBNotes,
88 #[arg(long = "no-wait", action = clap::ArgAction::SetFalse)]
89 wait: bool,
90 },
91 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 Validate { oob_notes: OOBNotes },
111 Split { oob_notes: OOBNotes },
114 Combine {
116 #[clap(required = true)]
117 oob_notes: Vec<OOBNotes>,
118 },
119 #[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 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, 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 AwaitLnPay { operation_id: OperationId },
156 ListGateways {
158 #[clap(long, default_value = "false")]
160 no_update: bool,
161 },
162 #[clap(hide = true)]
164 DepositAddress,
165 #[clap(hide = true)]
167 AwaitDeposit { operation_id: OperationId },
168 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)]
190 mnemonic: String,
191 #[clap(long)]
192 invite_code: String,
193 },
194 PrintSecret,
196 ListOperations {
197 #[clap(long, default_value = "10")]
198 limit: usize,
199 },
200 #[command(disable_help_flag = true)]
203 Module {
204 module: Option<ModuleSelector>,
206 #[arg(allow_hyphen_values = true, trailing_var_arg = true)]
207 args: Vec<ffi::OsString>,
208 },
209 Config,
211 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 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}