Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
637 changes: 592 additions & 45 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ bytes = "1.4.0"
triggered = "0.1.2"
prost = "0.14.1"
async_fn_traits = "0.1.1"
bitcoin-payment-instructions = { version = "0.5", features = ["http"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
url = "2.5"

# Integration test dependencies, only enabled with itest feature
bitcoincore-rpc = { version = "0.19.0", optional = true }
Expand Down
10 changes: 10 additions & 0 deletions proto/lndkrpc.proto
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "external/google/rpc/error_details.proto";

service Offers {
rpc PayOffer (PayOfferRequest) returns (PayOfferResponse);
rpc PayHumanReadableAddress (PayHumanReadableAddressRequest) returns (PayOfferResponse);
rpc GetInvoice (GetInvoiceRequest) returns (GetInvoiceResponse);
rpc DecodeInvoice (DecodeInvoiceRequest) returns (Bolt12InvoiceContents);
rpc PayInvoice (PayInvoiceRequest) returns (PayInvoiceResponse);
Expand All @@ -20,6 +21,15 @@ message PayOfferRequest {
optional uint32 fee_limit_percent = 6;
}

message PayHumanReadableAddressRequest {
string name = 1;
optional uint64 amount = 2;
optional string payer_note = 3;
optional uint32 response_invoice_timeout = 4;
optional uint32 fee_limit = 5;
optional uint32 fee_limit_percent = 6;
}

message PayOfferResponse {
string payment_preimage = 2;
}
Expand Down
65 changes: 64 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use clap::{Parser, Subcommand};
use lightning::offers::invoice::Bolt12Invoice;
use lndk::lndkrpc::offers_client::OffersClient;
use lndk::lndkrpc::{CreateOfferRequest, GetInvoiceRequest, PayInvoiceRequest, PayOfferRequest};
use lndk::lndkrpc::{
CreateOfferRequest, GetInvoiceRequest, PayHumanReadableAddressRequest, PayInvoiceRequest,
PayOfferRequest,
};
use lndk::offers::decode;
use lndk::offers::handler::DEFAULT_RESPONSE_INVOICE_TIMEOUT;
use lndk::{
Expand Down Expand Up @@ -144,6 +147,34 @@ enum Commands {
#[arg(long, required = false, conflicts_with = "fee_limit")]
fee_limit_percent: Option<u32>,
},
/// PayHumanReadableAddress pays a BOLT 12 offer by resolving a human-readable name (BIP-353).
PayHumanReadableAddress {
/// The human-readable name to resolve (e.g., "user@example.com").
name: String,

/// Amount 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 {
Expand Down Expand Up @@ -261,6 +292,38 @@ async fn main() {
Err(err) => err.exit_gracefully(),
};
}
Commands::PayHumanReadableAddress {
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,
Expand Down
111 changes: 111 additions & 0 deletions src/dns_resolver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use crate::OfferError;
use bitcoin_payment_instructions::hrn_resolution::{HrnResolution, HrnResolver, HumanReadableName};
use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver;
use std::sync::Arc;
use std::time::Duration;
use url::Url;

const DEFAULT_DNS_TIMEOUT_SECS: u64 = 20;

#[derive(Clone)]
pub struct LndkDNSResolverMessageHandler {
resolver: Arc<dyn HrnResolver + Send + Sync>,
}

impl Default for LndkDNSResolverMessageHandler {
fn default() -> Self {
Self::new()
}
}

impl LndkDNSResolverMessageHandler {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(DEFAULT_DNS_TIMEOUT_SECS))
.build()
.expect("Failed to build HTTP client for DNS resolution");

Self::with_resolver(HTTPHrnResolver::with_client(client))
}

pub fn with_resolver<R: HrnResolver + Send + Sync + 'static>(resolver: R) -> Self {
Self {
resolver: Arc::new(resolver),
}
}

pub async fn resolver_hrn_to_offer(&self, name_str: &str) -> Result<String, OfferError> {
let resolved_uri = self.resolve_locally(name_str.to_string()).await?;
self.extract_offer_from_uri(&resolved_uri)
}

pub fn extract_offer_from_uri(&self, uri: &str) -> Result<String, OfferError> {
let url = Url::parse(uri)
.map_err(|_| OfferError::ResolveUriError("Invalid URI format".to_string()))?;

for (key, value) in url.query_pairs() {
if key.eq_ignore_ascii_case("lno") {
return Ok(value.into_owned());
}
}

Err(OfferError::ResolveUriError(
"URI does not contain 'lno' parameter with BOLT12 offer".to_string(),
))
}

pub async fn resolve_locally(&self, name: String) -> Result<String, OfferError> {
let hrn_parsed = HumanReadableName::from_encoded(&name)
.map_err(|_| OfferError::ParseHrnFailure(name.clone()))?;

let resolution = self
.resolver
.resolve_hrn(&hrn_parsed)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it have a time out?

Copy link
Contributor Author

@IgnacioPorte IgnacioPorte Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, now I added

.await
.map_err(|e| OfferError::HrnResolutionFailure(format!("{}: {}", name, e)))?;

let uri = match resolution {
HrnResolution::DNSSEC { result, .. } => result,
HrnResolution::LNURLPay { .. } => {
return Err(OfferError::ResolveUriError(
"LNURL resolution not supported in this flow".to_string(),
))
}
};

Ok(uri)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_extract_offer_from_simple_uri() {
let handler = LndkDNSResolverMessageHandler::new();
let uri = "bitcoin:?lno=lno1qgsqvgnwgcg35z";
let result = handler.extract_offer_from_uri(uri);
assert_eq!(result.unwrap(), "lno1qgsqvgnwgcg35z");
}

#[test]
fn test_extract_offer_with_percent_encoding() {
let handler = LndkDNSResolverMessageHandler::new();
let uri = "bitcoin:?lno=lno1%20test%3Dvalue";
let result = handler.extract_offer_from_uri(uri);
assert_eq!(result.unwrap(), "lno1 test=value");
}

#[test]
fn test_extract_offer_missing_param() {
let handler = LndkDNSResolverMessageHandler::new();
let uri = "bitcoin:?amount=50&label=test";
let result = handler.extract_offer_from_uri(uri);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("does not contain 'lno' parameter"));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod clock;
pub mod dns_resolver;
mod grpc;
#[allow(dead_code)]
pub mod lnd;
Expand Down
52 changes: 52 additions & 0 deletions src/offers/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ use super::lnd_requests::{
send_invoice_request, LndkBolt12InvoiceInfo,
};
use super::OfferError;
use crate::dns_resolver::LndkDNSResolverMessageHandler;
use crate::offers::lnd_requests::{send_payment, track_payment, CreateOfferArgs};
use crate::offers::{get_destination, parse::decode};
use crate::onion_messenger::MessengerUtilities;

pub const DEFAULT_RESPONSE_INVOICE_TIMEOUT: u32 = 15;
Expand All @@ -57,6 +59,7 @@ pub struct OfferHandler {
/// an invoice. If not provided, we will use the default value of 15 seconds.
pub response_invoice_timeout: u32,
client: Option<Client>,
dns_resolver: LndkDNSResolverMessageHandler,
}

#[derive(Clone)]
Expand All @@ -77,6 +80,18 @@ pub struct PayOfferParams {
pub fee_limit: Option<FeeLimit>,
}

#[derive(Clone)]
pub struct PayHumanReadableAddressParams {
pub name: String,
pub amount: Option<u64>,
pub payer_note: Option<String>,
pub network: Network,
pub client: Client,
pub reply_path: Option<BlindedMessagePath>,
pub response_invoice_timeout: Option<u32>,
pub fee_limit: Option<FeeLimit>,
}

#[derive(Clone)]
pub struct SendPaymentParams {
pub path: BlindedPaymentPath,
Expand Down Expand Up @@ -113,6 +128,20 @@ impl OfferHandler {
response_invoice_timeout: Option<u32>,
seed: Option<[u8; 32]>,
client: Option<Client>,
) -> Self {
Self::with_dns_resolver(
response_invoice_timeout,
seed,
client,
LndkDNSResolverMessageHandler::new(),
)
}

pub fn with_dns_resolver(
response_invoice_timeout: Option<u32>,
seed: Option<[u8; 32]>,
client: Option<Client>,
dns_resolver: LndkDNSResolverMessageHandler,
) -> Self {
let messenger_utils = MessengerUtilities::default();
let random_bytes = match seed {
Expand All @@ -130,6 +159,7 @@ impl OfferHandler {
expanded_key,
response_invoice_timeout,
client,
dns_resolver,
}
}

Expand All @@ -150,6 +180,28 @@ impl OfferHandler {
.await
}

/// Resolves a human-readable name (BIP-353) to an offer and pays it.
pub async fn pay_hrn(&self, cfg: PayHumanReadableAddressParams) -> Result<Payment, OfferError> {
let offer_str = self.dns_resolver.resolver_hrn_to_offer(&cfg.name).await?;

let offer = decode(offer_str)?;
let destination = get_destination(&offer).await?;

let pay_offer_cfg = PayOfferParams {
offer,
amount: cfg.amount,
payer_note: cfg.payer_note,
network: cfg.network,
client: cfg.client,
destination,
reply_path: cfg.reply_path,
response_invoice_timeout: cfg.response_invoice_timeout,
fee_limit: cfg.fee_limit,
};

self.pay_offer(pay_offer_cfg).await
}

/// Sends an invoice request and waits for an invoice to be sent back to us.
/// Reminder that if this method returns an error after create_invoice_request is called, we
/// *must* remove the payment_id from self.active_payments.
Expand Down
23 changes: 22 additions & 1 deletion src/offers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ pub enum OfferError {
EncodeInvoiceFailure(BitcoinIoError),
/// Cannot list channels.
ListChannelsFailure(String),
/// Failed to parse human-readable name.
ParseHrnFailure(String),
/// HRN resolution failed.
HrnResolutionFailure(String),
/// DNS resolution or URI parsing error.
ResolveUriError(String),
}

impl OfferError {
Expand Down Expand Up @@ -101,6 +107,9 @@ impl OfferError {
OfferError::IntroductionNodeNotFound => "INTRODUCTION_NODE_NOT_FOUND",
OfferError::GetChannelInfo(_) => "GET_CHANNEL_INFO",
OfferError::ListChannelsFailure(_) => "LIST_CHANNELS_FAILURE",
OfferError::ParseHrnFailure(_) => "PARSE_HRN_FAILURE",
OfferError::HrnResolutionFailure(_) => "HRN_RESOLUTION_FAILURE",
OfferError::ResolveUriError(_) => "RESOLVE_URI_ERROR",
}
}

Expand All @@ -110,7 +119,10 @@ impl OfferError {
| OfferError::InvalidCurrency
| OfferError::ParseOfferFailure(_)
| OfferError::ParseInvoiceFailure(_)
| OfferError::EncodeInvoiceFailure(_) => Code::InvalidArgument,
| OfferError::EncodeInvoiceFailure(_)
| OfferError::ParseHrnFailure(_)
| OfferError::HrnResolutionFailure(_)
| OfferError::ResolveUriError(_) => Code::InvalidArgument,
_ => Code::Internal,
}
}
Expand Down Expand Up @@ -168,6 +180,15 @@ impl Display for OfferError {
}
OfferError::ListChannelsFailure(e) => write!(f, "Error listing channels: {e:?}"),
OfferError::EncodeInvoiceFailure(e) => write!(f, "Failed to encode invoice: {e:?}"),
OfferError::ParseHrnFailure(e) => {
write!(f, "Invalid human-readable name: {}", e)
}
OfferError::HrnResolutionFailure(e) => {
write!(f, "HRN resolution failed: {}", e)
}
OfferError::ResolveUriError(e) => {
write!(f, "Failed to resolve URI: {}", e)
}
}
}
}
Expand Down
Loading
Loading