lndk/src/cli.rs
Ignacio Porte bb14ecd7ec feat: pay BOLT 12 offers via BIP-353 human-readable names
Resolves a name like `alice@example.com` to a BOLT 12 offer over DoH
and pays it. DNSSEC is validated locally, as required by BIP-353.
Self-resolution only for now; peer-based resolution via BLIP-32 will
follow. Exposes a `PayHumanReadableAddress` gRPC endpoint and a
`lndk-cli pay-hrn` command.
2026-04-18 19:06:00 -04:00

671 lines
23 KiB
Rust

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<PathBuf>,
/// 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<String>,
/// 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<String>,
/// 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<PathBuf>,
/// 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<u64>,
/// A payer-provided note which will be seen by the recipient.
#[arg(required = false)]
payer_note: Option<String>,
/// 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<u32>,
/// 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<u32>,
/// 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<u32>,
},
/// 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<u64>,
/// A payer-provided note which will be seen by the recipient.
#[arg(required = false)]
payer_note: Option<String>,
/// 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<u32>,
/// 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<u32>,
/// 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<u32>,
},
/// 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<u64>,
/// A payer-provided note which will be seen by the recipient.
#[arg(required = false)]
payer_note: Option<String>,
/// 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<u32>,
},
/// 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<u64>,
/// 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<u32>,
/// 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<u32>,
},
/// 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<u64>,
/// The description of the offer.
#[arg(long, required = false)]
description: Option<String>,
/// The issuer of the offer.
#[arg(long, required = false)]
issuer: Option<String>,
/// Relative expiry of the offer in seconds.
#[arg(long, required = false)]
expiry: Option<u64>,
/// The quantity of the offer. If 0, unbounded quantity is assumed. If None,
/// quantity defaults to 1.
#[arg(long, required = false)]
quantity: Option<u64>,
},
}
#[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<PathBuf>,
macaroon_hex: Option<String>,
network: &str,
) -> (OffersClient<Channel>, String) {
let client = OffersClient::new(channel);
let macaroon = read_macaroon_from_args(macaroon_path, macaroon_hex, network);
(client, macaroon)
}
fn add_metadata<R>(request: &mut Request<R>, 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<String, std::io::Error> {
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<String>,
cert_path: Option<PathBuf>,
) -> Result<ClientTlsConfig, String> {
// 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<String>,
cert_path: Option<PathBuf>,
) -> 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<PathBuf>,
macaroon_hex: Option<String>,
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<String>,
}
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());
}
}