This commit is contained in:
Ignacio Porte Stefoni 2026-04-18 19:07:07 -04:00 committed by GitHub
commit 063d0640de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 821 additions and 31 deletions

377
Cargo.lock generated
View file

@ -322,6 +322,22 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf"
[[package]]
name = "bitcoin-payment-instructions"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c300c948b2ff78c965ea3a613372352125448a22f1acf49e95e3878149824091"
dependencies = [
"bitcoin",
"bitreq",
"dnssec-prover",
"getrandom 0.3.3",
"lightning 0.2.2",
"lightning-invoice 0.34.0",
"serde",
"serde_json",
]
[[package]]
name = "bitcoin-units"
version = "0.1.2"
@ -379,6 +395,19 @@ version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "bitreq"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08221cf31c5f00fb6fc8fa697cea54176b06801a518bd9d3482aa27099827a3a"
dependencies = [
"rustls 0.21.12",
"rustls-webpki 0.101.7",
"tokio",
"tokio-rustls 0.24.1",
"webpki-roots",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
@ -744,6 +773,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fragile"
version = "2.0.0"
@ -1028,7 +1066,7 @@ dependencies = [
"rustls 0.23.31",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.2",
"tower-service",
]
@ -1089,6 +1127,109 @@ dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.2.6"
@ -1158,11 +1299,11 @@ dependencies = [
"bitcoin-bech32",
"chrono",
"libc",
"lightning",
"lightning 0.1.3",
"lightning-background-processor",
"lightning-block-sync",
"lightning-dns-resolver",
"lightning-invoice",
"lightning-invoice 0.33.2",
"lightning-net-tokio",
"lightning-persister",
"lightning-rapid-gossip-sync",
@ -1195,10 +1336,27 @@ dependencies = [
"dnssec-prover",
"hashbrown 0.13.2",
"libm",
"lightning-invoice",
"lightning-types",
"lightning-invoice 0.33.2",
"lightning-types 0.2.0",
"musig2",
"possiblyrandom",
"possiblyrandom 0.2.0 (git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix)",
]
[[package]]
name = "lightning"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c90397b635e3ece6b9a723fb470a46cb9b3592f217d72e40540a5fada00289d"
dependencies = [
"bech32 0.11.0",
"bitcoin",
"dnssec-prover",
"hashbrown 0.13.2",
"libm",
"lightning-invoice 0.34.0",
"lightning-macros",
"lightning-types 0.3.1",
"possiblyrandom 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@ -1209,7 +1367,7 @@ dependencies = [
"bitcoin",
"bitcoin-io",
"bitcoin_hashes",
"lightning",
"lightning 0.1.3",
"lightning-rapid-gossip-sync",
]
@ -1220,7 +1378,7 @@ source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-c
dependencies = [
"bitcoin",
"chunked_transfer",
"lightning",
"lightning 0.1.3",
"serde_json",
"tokio",
]
@ -1231,8 +1389,8 @@ version = "0.2.0"
source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f"
dependencies = [
"dnssec-prover",
"lightning",
"lightning-types",
"lightning 0.1.3",
"lightning-types 0.2.0",
"tokio",
]
@ -1243,7 +1401,29 @@ source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-c
dependencies = [
"bech32 0.11.0",
"bitcoin",
"lightning-types",
"lightning-types 0.2.0",
]
[[package]]
name = "lightning-invoice"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85e5e14bcdb30d746e9785b04f27938292e8944f78f26517e01e91691f6b3f2"
dependencies = [
"bech32 0.11.0",
"bitcoin",
"lightning-types 0.3.1",
]
[[package]]
name = "lightning-macros"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
@ -1252,7 +1432,7 @@ version = "0.1.0"
source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f"
dependencies = [
"bitcoin",
"lightning",
"lightning 0.1.3",
"tokio",
]
@ -1262,7 +1442,7 @@ version = "0.1.0"
source = "git+https://github.com/vincenzopalazzo/rust-lightning?branch=2025-06-cherry-pick-exposed-offer-amount-fix#afa9bea123b15fe3f2b602a126de94f13080272f"
dependencies = [
"bitcoin",
"lightning",
"lightning 0.1.3",
"windows-sys 0.48.0",
]
@ -1274,7 +1454,7 @@ dependencies = [
"bitcoin",
"bitcoin-io",
"bitcoin_hashes",
"lightning",
"lightning 0.1.3",
]
[[package]]
@ -1285,12 +1465,27 @@ dependencies = [
"bitcoin",
]
[[package]]
name = "lightning-types"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb1aac93f22f2c2eac8a0ee83bb1a1ea58673caa2c82847302710b83364d04e6"
dependencies = [
"bitcoin",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lndk"
version = "0.3.0"
@ -1298,6 +1493,7 @@ dependencies = [
"async-trait",
"async_fn_traits",
"bitcoin",
"bitcoin-payment-instructions",
"bitcoincore-rpc",
"bytes",
"chrono",
@ -1309,7 +1505,7 @@ dependencies = [
"hex",
"home",
"ldk-sample",
"lightning",
"lightning 0.1.3",
"lndk-tonic-lnd",
"log",
"log4rs",
@ -1328,6 +1524,7 @@ dependencies = [
"tonic-prost-build",
"tonic-types",
"triggered",
"url",
]
[[package]]
@ -1699,6 +1896,15 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "possiblyrandom"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd"
dependencies = [
"getrandom 0.2.15",
]
[[package]]
name = "possiblyrandom"
version = "0.2.0"
@ -1707,6 +1913,15 @@ dependencies = [
"getrandom 0.2.15",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -1840,9 +2055,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.36"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@ -2250,6 +2465,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
@ -2411,6 +2632,16 @@ dependencies = [
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.38.1"
@ -2440,6 +2671,16 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
@ -2505,7 +2746,7 @@ dependencies = [
"socket2 0.6.0",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.2",
"tokio-stream",
"tower",
"tower-layer",
@ -2685,6 +2926,23 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -2981,6 +3239,12 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x509-parser"
version = "0.17.0"
@ -3019,12 +3283,89 @@ dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "zip"
version = "0.6.6"

View file

@ -47,6 +47,8 @@ tar = "0.4.45"
triggered = "0.1.2"
prost = "0.14.1"
async_fn_traits = "0.1.1"
bitcoin-payment-instructions = { version = "0.7", features = ["http"] }
url = "2.5"
# Integration test dependencies, only enabled with itest feature
bitcoincore-rpc = { version = "0.19.0", optional = true }

View file

@ -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);
@ -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;
}

View file

@ -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::{
@ -121,8 +124,37 @@ enum Commands {
/// The offer string.
offer_string: 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.
/// 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>,
@ -150,8 +182,8 @@ enum Commands {
/// The offer string.
offer_string: 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.
/// 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>,
@ -168,8 +200,8 @@ enum Commands {
PayInvoice {
/// The hex-encoded invoice string.
invoice_string: String,
/// Amount 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.
/// 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.
@ -263,6 +295,38 @@ async fn main() {
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,

118
src/dns_resolver.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::OfferError;
use bitcoin_payment_instructions::hrn_resolution::{HrnResolution, HrnResolver, HumanReadableName};
use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver;
use log::{debug, info};
use std::sync::Arc;
use std::time::Duration;
use url::Url;
const DEFAULT_DNS_TIMEOUT_SECS: u64 = 20;
#[derive(Clone)]
pub struct DNSResolverMessageHandler {
resolver: Arc<dyn HrnResolver + Send + Sync>,
}
impl Default for DNSResolverMessageHandler {
fn default() -> Self {
Self::new()
}
}
impl DNSResolverMessageHandler {
pub fn new() -> Self {
Self::with_resolver(HTTPHrnResolver::new())
}
pub fn with_resolver<R: HrnResolver + Send + Sync + 'static>(resolver: R) -> Self {
Self {
resolver: Arc::new(resolver),
}
}
pub async fn resolve_hrn_to_offer(&self, name_str: &str) -> Result<String, OfferError> {
info!("Resolving human-readable name: {}", name_str);
let resolved_uri = self.resolve_locally(name_str.to_string()).await?;
debug!("Resolved {} to URI: {}", name_str, resolved_uri);
let offer = self.extract_offer_from_uri(&resolved_uri)?;
info!("Successfully resolved {} to offer: {}", name_str, offer);
Ok(offer)
}
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 = tokio::time::timeout(
Duration::from_secs(DEFAULT_DNS_TIMEOUT_SECS),
self.resolver.resolve_hrn(&hrn_parsed),
)
.await
.map_err(|_| {
OfferError::HrnResolutionFailure(format!(
"{name}: resolution timed out after {DEFAULT_DNS_TIMEOUT_SECS}s"
))
})?
.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 = DNSResolverMessageHandler::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 = DNSResolverMessageHandler::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 = DNSResolverMessageHandler::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"));
}
}

View file

@ -1,4 +1,5 @@
mod clock;
mod dns_resolver;
mod grpc;
#[allow(dead_code)]
pub mod lnd;

View file

@ -31,8 +31,11 @@ use super::lnd_requests::{
send_invoice_request, LndkBolt12InvoiceInfo,
};
use super::OfferError;
use crate::dns_resolver::DNSResolverMessageHandler;
use crate::offers::lnd_requests::{send_payment, track_payment, CreateOfferArgs};
use crate::offers::{get_destination, parse::decode};
use crate::onion_messenger::MessengerUtilities;
use bitcoin_payment_instructions::hrn_resolution::HrnResolver;
pub const DEFAULT_RESPONSE_INVOICE_TIMEOUT: u32 = 15;
@ -57,6 +60,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: DNSResolverMessageHandler,
}
#[derive(Clone)]
@ -77,6 +81,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,
@ -115,6 +131,36 @@ impl OfferHandler {
response_invoice_timeout: Option<u32>,
seed: Option<[u8; 32]>,
client: Option<Client>,
) -> Self {
Self::with_dns_resolver(
response_invoice_timeout,
seed,
client,
DNSResolverMessageHandler::new(),
)
}
/// Builds an `OfferHandler` with a custom `HrnResolver` implementation. Intended for tests
/// that need to inject a mock resolver; production code should use `new`.
pub fn with_hrn_resolver<R: HrnResolver + Send + Sync + 'static>(
response_invoice_timeout: Option<u32>,
seed: Option<[u8; 32]>,
client: Option<Client>,
hrn_resolver: R,
) -> Self {
Self::with_dns_resolver(
response_invoice_timeout,
seed,
client,
DNSResolverMessageHandler::with_resolver(hrn_resolver),
)
}
pub(crate) fn with_dns_resolver(
response_invoice_timeout: Option<u32>,
seed: Option<[u8; 32]>,
client: Option<Client>,
dns_resolver: DNSResolverMessageHandler,
) -> Self {
let messenger_utils = MessengerUtilities::default();
let random_bytes = match seed {
@ -132,6 +178,7 @@ impl OfferHandler {
expanded_key,
response_invoice_timeout,
client,
dns_resolver,
}
}
@ -152,6 +199,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.resolve_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.

View file

@ -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 {
@ -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",
}
}
@ -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,
}
}
@ -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}")
}
}
}
}

View file

@ -1,7 +1,7 @@
use crate::lnd::{get_lnd_client, get_network, Creds, LndCfg, LndError};
use crate::lndkrpc::{CreateOfferRequest, CreateOfferResponse};
use crate::offers::get_destination;
use crate::offers::handler::{CreateOfferParams, PayOfferParams};
use crate::offers::handler::{CreateOfferParams, PayHumanReadableAddressParams, PayOfferParams};
use crate::offers::parse::decode;
use crate::offers::validate_amount;
use crate::offers::OfferError;
@ -17,8 +17,8 @@ use lightning::util::ser::Writeable;
use lndkrpc::offers_server::Offers;
use lndkrpc::{
Bolt12InvoiceContents, DecodeInvoiceRequest, FeatureBit, GetInvoiceRequest, GetInvoiceResponse,
PayInvoiceRequest, PayInvoiceResponse, PayOfferRequest, PayOfferResponse, PaymentHash,
PaymentPaths,
PayHumanReadableAddressRequest, PayInvoiceRequest, PayInvoiceResponse, PayOfferRequest,
PayOfferResponse, PaymentHash, PaymentPaths,
};
use std::num::NonZeroU64;
use std::str::FromStr;
@ -111,6 +111,57 @@ impl Offers for LNDKServer {
Ok(Response::new(reply))
}
async fn pay_human_readable_address(
&self,
request: Request<PayHumanReadableAddressRequest>,
) -> Result<Response<PayOfferResponse>, Status> {
log::info!("Received a request: {:?}", request.get_ref());
let metadata = request.metadata();
let macaroon = check_auth_metadata(metadata)?;
let creds = Creds::String {
cert: self.lnd_cert.clone(),
macaroon,
};
let lnd_cfg = LndCfg::new(self.address.clone(), creds);
let mut client = get_lnd_client(lnd_cfg)?;
let inner_request = request.get_ref();
let reply_path = None;
let info = client
.lightning()
.get_info(GetInfoRequest {})
.await
.map_err(|e| LndError::ServiceUnavailable(e.message().to_string()))?
.into_inner();
let network = get_network(info).await?;
let fee_limit = create_fee_limit(inner_request.fee_limit, inner_request.fee_limit_percent);
let cfg = PayHumanReadableAddressParams {
name: inner_request.name.clone(),
amount: inner_request.amount,
payer_note: inner_request.payer_note.clone(),
network,
client,
reply_path,
response_invoice_timeout: inner_request.response_invoice_timeout,
fee_limit,
};
let payment = self.offer_handler.pay_hrn(cfg).await?;
log::info!(
"Payment succeeded with preimage: {}",
payment.payment_preimage
);
let reply = PayOfferResponse {
payment_preimage: payment.payment_preimage,
};
Ok(Response::new(reply))
}
async fn decode_invoice(
&self,
request: Request<DecodeInvoiceRequest>,

View file

@ -27,9 +27,9 @@ use std::net::SocketAddr;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
use tokio::time;
use tokio::time::Duration;
use tokio::{select, try_join};
use tonic_lnd::Client;
@ -1440,3 +1440,116 @@ async fn test_receive_payment_from_offer_with_multiple_blinded_paths() {
}
};
}
struct MockHrnResolver {
response_uri: String,
}
impl bitcoin_payment_instructions::hrn_resolution::HrnResolver for MockHrnResolver {
fn resolve_hrn<'a>(
&'a self,
_hrn: &'a bitcoin_payment_instructions::hrn_resolution::HumanReadableName,
) -> bitcoin_payment_instructions::hrn_resolution::HrnResolutionFuture<'a> {
let uri = self.response_uri.clone();
Box::pin(async move {
Ok(
bitcoin_payment_instructions::hrn_resolution::HrnResolution::DNSSEC {
proof: None,
result: uri,
},
)
})
}
fn resolve_lnurl<'a>(
&'a self,
_url: &'a str,
) -> bitcoin_payment_instructions::hrn_resolution::HrnResolutionFuture<'a> {
unimplemented!("LNURL resolution not supported in mock")
}
fn resolve_lnurl_to_invoice<'a>(
&'a self,
_callback_url: String,
_amount: bitcoin_payment_instructions::amount::Amount,
_expected_description_hash: [u8; 32],
) -> bitcoin_payment_instructions::hrn_resolution::LNURLResolutionFuture<'a> {
unimplemented!("LNURL invoice resolution not supported in mock")
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_pay_offer_with_name_and_dns() {
use lndk::offers::handler::PayHumanReadableAddressParams;
use std::time::{Duration, SystemTime};
let test_name = "lndk_pay_offer_with_name";
let (bitcoind, mut lnd, ldk1, ldk2, lndk_dir, _) =
common::setup_test_infrastructure(test_name).await;
let (ldk1_pubkey, ldk2_pubkey, _) =
common::connect_network(&ldk1, &ldk2, false, true, &mut lnd, &bitcoind).await;
let path_pubkeys = vec![ldk2_pubkey, ldk1_pubkey];
let expiration = SystemTime::now() + Duration::from_secs(24 * 60 * 60);
let offer = ldk1
.create_offer(
&path_pubkeys,
Network::Regtest,
20_000,
Quantity::One,
expiration,
)
.await
.expect("should create offer");
let offer_string = offer.to_string();
let human_readable_name = "satoshi@bip353.test";
let mock_resolver = MockHrnResolver {
response_uri: format!("bitcoin:?lno={}", offer_string),
};
let handler = Arc::new(lndk::offers::handler::OfferHandler::with_hrn_resolver(
None,
None,
None,
mock_resolver,
));
let (lndk_cfg, _, messenger, shutdown) = common::setup_lndk(
&lnd.cert_path,
&lnd.macaroon_path,
lnd.address.clone(),
lndk_dir,
)
.await;
let client = lnd.client.clone().unwrap();
let pay_cfg = PayHumanReadableAddressParams {
name: human_readable_name.to_string(),
amount: Some(20_000),
payer_note: None,
network: Network::Regtest,
client: client.clone(),
reply_path: None,
response_invoice_timeout: None,
fee_limit: None,
};
select! {
val = messenger.run(lndk_cfg, Arc::clone(&handler)) => {
panic!("messenger should not complete first {:?}", val);
},
res = handler.pay_hrn(pay_cfg) => {
assert!(res.is_ok(), "pay_hrn failed: {:?}", res.err());
let payment = res.unwrap();
assert!(!payment.payment_preimage.is_empty(), "Payment should have a preimage");
shutdown.trigger();
ldk1.stop().await;
ldk2.stop().await;
}
}
}