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 Reissue {
85 oob_notes: OOBNotes,
86 #[arg(long = "no-wait", action = clap::ArgAction::SetFalse)]
87 wait: bool,
88 },
89 Spend {
91 amount: Amount,
93 #[clap(long)]
96 allow_overpay: bool,
97 #[clap(long, default_value_t = 60 * 60 * 24 * 7)]
100 timeout: u64,
101 #[clap(long)]
104 include_invite: bool,
105 },
106 Split { oob_notes: OOBNotes },
109 Combine {
111 #[clap(required = true)]
112 oob_notes: Vec<OOBNotes>,
113 },
114 #[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 AwaitInvoice { operation_id: OperationId },
130 #[clap(hide = true)]
132 LnPay {
133 payment_info: String,
135 #[clap(long)]
137 amount: Option<Amount>,
138 #[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 AwaitLnPay { operation_id: OperationId },
148 ListGateways {
150 #[clap(long, default_value = "false")]
152 no_update: bool,
153 },
154 #[clap(hide = true)]
156 DepositAddress,
157 #[clap(hide = true)]
159 AwaitDeposit { operation_id: OperationId },
160 Withdraw {
162 #[clap(long)]
163 amount: BitcoinAmountOrAll,
164 #[clap(long)]
165 address: bitcoin::Address<NetworkUnchecked>,
166 },
167 Backup {
169 #[clap(long = "metadata")]
170 metadata: Vec<String>,
174 },
175 #[clap(hide = true)]
178 DiscoverVersion,
179 Restore {
181 #[clap(long)]
182 mnemonic: String,
183 #[clap(long)]
184 invite_code: String,
185 },
186 PrintSecret,
188 ListOperations {
189 #[clap(long, default_value = "10")]
190 limit: usize,
191 },
192 #[command(disable_help_flag = true)]
195 Module {
196 module: Option<ModuleSelector>,
198 #[arg(allow_hyphen_values = true, trailing_var_arg = true)]
199 args: Vec<ffi::OsString>,
200 },
201 Config,
203 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 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}