use clap::{Parser, Subcommand}; use lightning::offers::invoice::Bolt12Invoice; use lndk::lndkrpc::offers_client::OffersClient; use lndk::lndkrpc::{ CreateOfferRequest, GetInvoiceRequest, PayHumanReadableAddressRequest, PayInvoiceRequest, PayOfferRequest, }; use lndk::offers::decode; use lndk::offers::handler::DEFAULT_RESPONSE_INVOICE_TIMEOUT; use lndk::{ Bolt12InvoiceString, DEFAULT_DATA_DIR, DEFAULT_LNDK_DIR, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, TLS_CERT_FILENAME, }; use std::fs::File; use std::io::BufReader; use std::io::Read; use std::path::PathBuf; use std::process::exit; use tonic::transport::{Certificate, Channel, ClientTlsConfig}; use tonic::Request; use tonic_types::StatusExt; trait ExitGracefully { fn exit_gracefully(self) -> !; } impl ExitGracefully for &str { fn exit_gracefully(self) -> ! { println!("{self}"); exit(1) } } impl ExitGracefully for tonic::Status { fn exit_gracefully(self) -> ! { let details = self.get_error_details(); let error_msg = if let Some(error_info) = details.error_info() { format!("ERROR ({}): {}", error_info.reason, self.message()) } else { format!("ERROR: {}", self.message()) }; error_msg.exit_gracefully(); } } impl ExitGracefully for lndk::offers::OfferError { fn exit_gracefully(self) -> ! { format!("ERROR ({}): {}", self.code(), self).exit_gracefully(); } } fn get_macaroon_path_default(network: &str) -> PathBuf { home::home_dir() .unwrap() .as_path() .join(format!(".lnd/data/chain/bitcoin/{network}/admin.macaroon")) } /// A cli for interacting with lndk. #[derive(Debug, Parser)] #[command(name = "lndk-cli")] #[command(about = "A cli for interacting with lndk", long_about = None)] struct Cli { /// Global variables #[arg( short, long, global = true, required = false, default_value = "regtest" )] network: String, /// Option for passing a file path to a macaroon file obtained from LND node. /// Either this option or macaroon_hex must be set, there is no default. #[arg(short, long, global = true, required = false)] macaroon_path: Option, /// A hex-encoded macaroon string to pass in directly to the cli to authenticate with LND node. /// Either this option or macaroon_path must be set, there is no default. #[arg(long, global = true, required = false)] macaroon_hex: Option, /// This option is for passing a pem-encoded TLS certificate string to establish a connection /// with the LNDK server. If this isn't set, the cli will look for the TLS file in the default /// location (~.lndk/data). /// Only one of cert_pem or cert_path can be set at once. #[arg(long, global = true, required = false)] cert_pem: Option, /// This option is for passing a file path to a pem-encoded TLS certificate string to establish /// a connection with the LNDK server. If this isn't set, the cli will look for the TLS file in /// the default location (~.lndk/data). /// Only one of cert_pem or cert_path can be set at once. #[arg(long, global = true, required = false)] cert_path: Option, /// Host of the LNDK server. #[arg(long, global = true, required = false, default_value = format!("https://{DEFAULT_SERVER_HOST}"))] grpc_host: String, /// Port of the LNDK server. #[arg(long, global = true, required = false, default_value = DEFAULT_SERVER_PORT.to_string())] grpc_port: u16, #[command(subcommand)] command: Commands, } #[derive(Debug, Subcommand)] enum Commands { /// Decodes a bech32-encoded offer string into a BOLT 12 offer. DecodeOffer { /// The offer string to decode. offer_string: String, }, /// Decodes a hex-encoded invoice string into a BOLT 12 invoice. DecodeInvoice { /// The invoice string to decode. invoice_string: String, }, /// PayOffer pays a BOLT 12 offer, provided as a 'lno'-prefaced offer string. PayOffer { /// The offer string. offer_string: String, /// Amount in millisatoshis the user would like to pay. If this isn't set, we'll assume /// the user is paying whatever the offer amount is. #[arg(required = false)] amount: Option, /// A payer-provided note which will be seen by the recipient. #[arg(required = false)] payer_note: Option, /// The amount of time in seconds that the user would like to wait for an invoice to /// arrive. If this isn't set, we'll use the default value. #[arg(long, global = false, required = false, default_value = DEFAULT_RESPONSE_INVOICE_TIMEOUT.to_string())] response_invoice_timeout: Option, /// A fixed fee limit in millisatoshis. /// Mutually exclusive with fee_limit_percent - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit_percent")] fee_limit: Option, /// A percentage-based fee limit of the payment amount. /// Mutually exclusive with fee_limit - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit")] fee_limit_percent: Option, }, /// PayHrn pays a BOLT 12 offer by resolving a human-readable name (BIP-353). #[command(name = "pay-hrn")] PayHrn { /// The human-readable name to resolve (e.g., "user@example.com"). name: String, /// Amount in millisatoshis the user would like to pay. If this isn't set, we'll assume /// the user is paying whatever the offer amount is. #[arg(required = false)] amount: Option, /// A payer-provided note which will be seen by the recipient. #[arg(required = false)] payer_note: Option, /// The amount of time in seconds that the user would like to wait for an invoice to /// arrive. If this isn't set, we'll use the default value. #[arg(long, global = false, required = false, default_value = DEFAULT_RESPONSE_INVOICE_TIMEOUT.to_string())] response_invoice_timeout: Option, /// A fixed fee limit in millisatoshis. /// Mutually exclusive with fee_limit_percent - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit_percent")] fee_limit: Option, /// A percentage-based fee limit of the payment amount. /// Mutually exclusive with fee_limit - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit")] fee_limit_percent: Option, }, /// GetInvoice fetch a BOLT 12 invoice, which will be returned as a hex-encoded string. It /// fetches the invoice from a BOLT 12 offer, provided as a 'lno'-prefaced offer string. GetInvoice { /// The offer string. offer_string: String, /// Amount in millisatoshis the user would like to pay. If this isn't set, we'll assume /// the user is paying whatever the offer amount is. #[arg(required = false)] amount: Option, /// A payer-provided note which will be seen by the recipient. #[arg(required = false)] payer_note: Option, /// The amount of time in seconds that the user would like to wait for an invoice to /// arrive. If this isn't set, we'll use the default value. #[arg(long, global = false, required = false, default_value = DEFAULT_RESPONSE_INVOICE_TIMEOUT.to_string())] response_invoice_timeout: Option, }, /// PayInvoice pays a hex-encoded BOLT12 invoice. PayInvoice { /// The hex-encoded invoice string. invoice_string: String, /// Amount in millisatoshis the user would like to pay. If this isn't set, we'll assume /// the user is paying whatever the invoice amount is set to. #[arg(required = false)] amount: Option, /// A fixed fee limit in millisatoshis. /// Mutually exclusive with fee_limit_percent - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit_percent")] fee_limit: Option, /// A percentage-based fee limit of the payment amount. /// Mutually exclusive with fee_limit - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit")] fee_limit_percent: Option, }, /// CreateOffer creates a BOLT 12 offer. CreateOffer { /// The amount of the offer in millisatoshis. Per BOLT 12, this must be /// greater than zero when present. If omitted, the offer has no minimum /// amount requirement. #[arg(long, required = false)] amount: Option, /// The description of the offer. #[arg(long, required = false)] description: Option, /// The issuer of the offer. #[arg(long, required = false)] issuer: Option, /// Relative expiry of the offer in seconds. #[arg(long, required = false)] expiry: Option, /// The quantity of the offer. If 0, unbounded quantity is assumed. If None, /// quantity defaults to 1. #[arg(long, required = false)] quantity: Option, }, } #[tokio::main] async fn main() { let args = Cli::parse(); match args.command { Commands::DecodeOffer { offer_string } => { println!("Decoding offer: {offer_string}."); match decode(offer_string) { Ok(offer) => println!("Decoded offer: {offer:?}."), Err(e) => e.exit_gracefully(), } } Commands::DecodeInvoice { invoice_string } => { println!("Decoding invoice: {invoice_string}."); let invoice_string: Bolt12InvoiceString = invoice_string.clone().into(); match Bolt12Invoice::try_from(invoice_string) { Ok(invoice) => { println!("Decoded invoice: {invoice:?}."); } Err(e) => e.exit_gracefully(), } } Commands::PayOffer { ref offer_string, amount, payer_note, response_invoice_timeout, fee_limit, fee_limit_percent, } => { let tls = read_cert_from_args_or_exit(args.cert_pem, args.cert_path); let channel = create_grpc_channel(args.grpc_host, args.grpc_port, tls).await; let (mut client, macaroon) = create_authenticated_client( channel, args.macaroon_path, args.macaroon_hex, &args.network, ); let offer = match decode(offer_string.to_string()) { Ok(offer) => offer, Err(e) => e.exit_gracefully(), }; let mut request = Request::new(PayOfferRequest { offer: offer.to_string(), amount, payer_note, response_invoice_timeout, fee_limit, fee_limit_percent, }); add_metadata(&mut request, macaroon); match client.pay_offer(request).await { Ok(_) => println!("Successfully paid for offer!"), Err(err) => err.exit_gracefully(), }; } Commands::PayHrn { ref name, amount, payer_note, response_invoice_timeout, fee_limit, fee_limit_percent, } => { let tls = read_cert_from_args_or_exit(args.cert_pem, args.cert_path); let channel = create_grpc_channel(args.grpc_host, args.grpc_port, tls).await; let (mut client, macaroon) = create_authenticated_client( channel, args.macaroon_path, args.macaroon_hex, &args.network, ); let mut request = Request::new(PayHumanReadableAddressRequest { name: name.clone(), amount, payer_note, response_invoice_timeout, fee_limit, fee_limit_percent, }); add_metadata(&mut request, macaroon); match client.pay_human_readable_address(request).await { Ok(_) => println!("Successfully paid for name {name}!"), Err(err) => err.exit_gracefully(), }; } Commands::GetInvoice { ref offer_string, amount, payer_note, response_invoice_timeout, } => { let tls = read_cert_from_args_or_exit(args.cert_pem, args.cert_path); let channel = create_grpc_channel(args.grpc_host, args.grpc_port, tls).await; let (mut client, macaroon) = create_authenticated_client( channel, args.macaroon_path, args.macaroon_hex, &args.network, ); let offer = match decode(offer_string.to_string()) { Ok(offer) => offer, Err(e) => e.exit_gracefully(), }; let mut request = Request::new(GetInvoiceRequest { offer: offer.to_string(), amount, payer_note, response_invoice_timeout, }); add_metadata(&mut request, macaroon); match client.get_invoice(request).await { Ok(response) => println!("Invoice: {:?}.", response.get_ref()), Err(err) => err.exit_gracefully(), } } Commands::PayInvoice { ref invoice_string, amount, fee_limit, fee_limit_percent, } => { let tls = read_cert_from_args_or_exit(args.cert_pem, args.cert_path); let channel = create_grpc_channel(args.grpc_host, args.grpc_port, tls).await; let (mut client, macaroon) = create_authenticated_client( channel, args.macaroon_path, args.macaroon_hex, &args.network, ); let mut request = Request::new(PayInvoiceRequest { invoice: invoice_string.to_owned(), amount, fee_limit, fee_limit_percent, }); add_metadata(&mut request, macaroon); match client.pay_invoice(request).await { Ok(_) => println!("Successfully paid for offer!"), Err(err) => err.exit_gracefully(), } } Commands::CreateOffer { amount, description, issuer, expiry, quantity, } => { let tls = read_cert_from_args_or_exit(args.cert_pem, args.cert_path); let channel = create_grpc_channel(args.grpc_host, args.grpc_port, tls).await; let (mut client, macaroon) = create_authenticated_client( channel, args.macaroon_path, args.macaroon_hex, &args.network, ); let mut request = Request::new(CreateOfferRequest { amount, quantity, description, issuer, expiry, }); add_metadata(&mut request, macaroon); match client.create_offer(request).await { Ok(response) => println!("Offer: {:?}.", response.get_ref()), Err(err) => err.exit_gracefully(), } } } } async fn create_grpc_channel(grpc_host: String, grpc_port: u16, tls: ClientTlsConfig) -> Channel { Channel::from_shared(format!("{grpc_host}:{grpc_port}")) .unwrap_or_else(|e| format!("ERROR: failed to create endpoint {e:?}").exit_gracefully()) .tls_config(tls) .unwrap_or_else(|e| format!("ERROR: failed to configure tls {e:?}").exit_gracefully()) .connect() .await .unwrap_or_else(|e| format!("ERROR: failed to connect {e:?}").exit_gracefully()) } fn create_authenticated_client( channel: Channel, macaroon_path: Option, macaroon_hex: Option, network: &str, ) -> (OffersClient, String) { let client = OffersClient::new(channel); let macaroon = read_macaroon_from_args(macaroon_path, macaroon_hex, network); (client, macaroon) } fn add_metadata(request: &mut Request, macaroon: String) { let macaroon = macaroon.parse().unwrap_or_else(|e| { format!("ERROR: failed to parse provided macaroon string into tonic metadata {e:?}") .exit_gracefully() }); request.metadata_mut().insert("macaroon", macaroon); } fn read_macaroon_from_file(path: PathBuf) -> Result { let file = File::open(path)?; let mut mac_contents = BufReader::new(file); let mut buffer = Vec::new(); mac_contents.read_to_end(&mut buffer)?; Ok(hex::encode(buffer)) } fn read_cert_from_args( cert_pem: Option, cert_path: Option, ) -> Result { // Make sure both cert options are not set. if cert_path.is_some() && cert_pem.is_some() { return Err("ERROR: Only one of `cert_path` or `cert_pem` should be set.".to_string()); } let pem = match (&cert_pem, &cert_path) { (Some(pem), _) => pem.clone(), (None, Some(cert_path)) => std::fs::read_to_string(cert_path) .map_err(|e| format!("ERROR: failed to read cert: {e:?}"))?, (None, None) => { // If no cert pem string is provided, we'll look for the tls certificate in the // default location. let data_dir = home::home_dir() .unwrap() .join(DEFAULT_LNDK_DIR) .join(DEFAULT_DATA_DIR); std::fs::read_to_string(data_dir.join(TLS_CERT_FILENAME)) .map_err(|e| format!("ERROR: failed to read cert: {e:?}"))? } }; let cert = Certificate::from_pem(pem); Ok(ClientTlsConfig::new() .ca_certificate(cert) .domain_name("localhost")) } fn read_cert_from_args_or_exit( cert_pem: Option, cert_path: Option, ) -> ClientTlsConfig { match read_cert_from_args(cert_pem, cert_path) { Ok(config) => config, Err(err) => err.exit_gracefully(), } } fn read_macaroon_from_args( macaroon_path: Option, macaroon_hex: Option, network: &str, ) -> String { // Make sure both macaroon options are not set. if macaroon_path.is_some() && macaroon_hex.is_some() { "ERROR: Only one of `macaroon_path` or `macaroon_hex` should be set.".exit_gracefully(); } // Let's grab the macaroon string now. If neither macaroon_path nor macaroon_hex are // set, use the default macaroon path. match macaroon_path { Some(path) => read_macaroon_from_file(path.clone()).unwrap_or_else(|e| { format!("ERROR: failed to read macaroon from file {e:?}").exit_gracefully() }), None => match &macaroon_hex { Some(macaroon) => macaroon.clone(), None => { let path = get_macaroon_path_default(network); read_macaroon_from_file(path).unwrap_or_else(|e| { format!("ERROR: failed to read macaroon from file {e:?}").exit_gracefully() }) } }, } } #[cfg(test)] mod tests { use super::*; use std::fs::File; use std::io::Write; use tempfile::{tempdir, TempDir}; fn create_temp_cert_file() -> (PathBuf, String, TempDir) { let dir = tempdir().unwrap(); let file_path = dir.path().join("test_cert.pem"); let cert_content = "-----BEGIN CERTIFICATE-----\nMIIBCgKCAQEA\n-----END CERTIFICATE-----"; let mut file = File::create(&file_path).unwrap(); file.write_all(cert_content.as_bytes()).unwrap(); (file_path, cert_content.to_string(), dir) } #[test] fn test_read_cert_from_args_both_options() { let (temp_path, cert_content, _dir) = create_temp_cert_file(); let result = read_cert_from_args(Some(cert_content), Some(temp_path)); assert!(result.is_err()); assert_eq!( result.unwrap_err(), "ERROR: Only one of `cert_path` or `cert_pem` should be set." ); } #[test] fn test_read_cert_from_args_cert_pem_only() { let cert_content = "-----BEGIN CERTIFICATE-----\nMIIBCgKCAQEA\n-----END CERTIFICATE-----"; let result = read_cert_from_args(Some(cert_content.to_string()), None); assert!(result.is_ok()); } #[test] fn test_read_cert_from_args_cert_path_only() { let (file_path, _cert_content, _dir) = create_temp_cert_file(); let result = read_cert_from_args(None, Some(file_path)); assert!(result.is_ok()); } #[test] fn test_read_cert_from_args_invalid_path() { let invalid_path = PathBuf::from("/path/does/not/exist.pem"); let result = read_cert_from_args(None, Some(invalid_path)); assert!(result.is_err()); assert!(result .unwrap_err() .starts_with("ERROR: failed to read cert:")); } #[test] fn test_read_cert_from_args_neither_option() { let temp_home = tempdir().unwrap(); let temp_home_path = temp_home.path().to_path_buf(); let lndk_dir = temp_home_path.join(DEFAULT_LNDK_DIR).join(DEFAULT_DATA_DIR); std::fs::create_dir_all(&lndk_dir).unwrap(); let cert_content = "-----BEGIN CERTIFICATE-----\nMIIBCgKCAQEA\n-----END CERTIFICATE-----"; let file_path = lndk_dir.join(TLS_CERT_FILENAME); let mut file = File::create(&file_path).unwrap(); file.write_all(cert_content.as_bytes()).unwrap(); let result = { let _guard = EnvironmentGuard::new("HOME", temp_home_path.to_str().unwrap()); read_cert_from_args(None, None) }; assert!(result.is_ok()); } struct EnvironmentGuard<'a> { key: &'a str, original_value: Option, } impl<'a> EnvironmentGuard<'a> { fn new(key: &'a str, value: &str) -> Self { let original_value = std::env::var(key).ok(); std::env::set_var(key, value); Self { key, original_value, } } } impl Drop for EnvironmentGuard<'_> { fn drop(&mut self) { match &self.original_value { Some(value) => std::env::set_var(self.key, value), None => std::env::remove_var(self.key), } } } #[test] fn test_fee_args_conflict_validation() { let result = Cli::try_parse_from([ "lndk-cli", "pay-offer", "lno1qcp4256ypq", "--fee-limit", "1000", "--fee-limit-percent", "1", ]); assert!(result.is_err()); } #[test] fn test_fee_args_single_validation() { let result = Cli::try_parse_from([ "lndk-cli", "pay-offer", "lno1qcp4256ypq", "--fee-limit", "1000", ]); assert!(result.is_ok()); let result = Cli::try_parse_from([ "lndk-cli", "pay-offer", "lno1qcp4256ypq", "--fee-limit-percent", "1.0", ]); assert!(result.is_err()); } }