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