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 }