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