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 }