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