From f129b38af3e1ff62fde97ad9e528c61c352277e3 Mon Sep 17 00:00:00 2001 From: Ignacio Porte Date: Fri, 10 Oct 2025 23:29:20 -0300 Subject: [PATCH 1/6] feat: implement bip-0353 with self resolve. Add support for resolving human-readable names (e.g., satoshi@example.com) to BOLT12 offers via DNSSEC-validated DNS lookups. The PayOfferRequest now accepts an optional `name` field that the server resolves to an offer string before processing payment. --- Cargo.lock | 636 ++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 + proto/lndkrpc.proto | 1 + src/cli.rs | 6 + src/dns_resolver.rs | 81 ++++++ src/lib.rs | 1 + src/main.rs | 6 + src/server.rs | 23 +- 8 files changed, 710 insertions(+), 46 deletions(-) create mode 100644 src/dns_resolver.rs diff --git a/Cargo.lock b/Cargo.lock index 5a0bcfa4..7a14a77b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,8 +188,8 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", "itoa", "matchit", @@ -199,7 +199,7 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower", "tower-layer", "tower-service", @@ -213,13 +213,13 @@ checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", ] @@ -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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f00d509810205bfef492f1d6cefe1e2ac35b5e66675d51642315ddc5cee0e78" +dependencies = [ + "bitcoin", + "dnssec-prover", + "getrandom 0.3.3", + "lightning 0.1.6", + "lightning-invoice 0.33.2 (registry+https://github.com/rust-lang/crates.io-index)", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "bitcoin-units" version = "0.1.2" @@ -537,6 +553,16 @@ dependencies = [ "void", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -682,6 +708,15 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -744,6 +779,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" @@ -868,6 +912,25 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.12" @@ -879,7 +942,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap", "slab", "tokio", @@ -941,6 +1004,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -952,6 +1026,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -959,7 +1044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -970,8 +1055,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -993,6 +1078,30 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.7.0" @@ -1003,9 +1112,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1016,19 +1125,33 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.3.1", + "hyper 1.7.0", "hyper-util", "rustls 0.23.31", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.2", "tower-service", ] @@ -1038,7 +1161,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "tokio", @@ -1055,9 +1178,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.7.0", "libc", "pin-project-lite", "socket2 0.6.0", @@ -1089,6 +1212,113 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "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" @@ -1099,6 +1329,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is_terminal_polyfill" version = "1.70.0" @@ -1158,11 +1394,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 (git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed)", "lightning-net-tokio", "lightning-persister", "lightning-rapid-gossip-sync", @@ -1195,10 +1431,26 @@ dependencies = [ "dnssec-prover", "hashbrown 0.13.2", "libm", - "lightning-invoice", - "lightning-types", + "lightning-invoice 0.33.2 (git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed)", + "lightning-types 0.2.0 (git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed)", "musig2", - "possiblyrandom", + "possiblyrandom 0.2.0 (git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed)", +] + +[[package]] +name = "lightning" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b60a63d4f4e545a9ce8c92a73a8d0cbc62f5e5e6c029e18e8159cf8e8ce5e0" +dependencies = [ + "bech32 0.11.0", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice 0.33.2 (registry+https://github.com/rust-lang/crates.io-index)", + "lightning-types 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "possiblyrandom 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1209,7 +1461,7 @@ dependencies = [ "bitcoin", "bitcoin-io", "bitcoin_hashes", - "lightning", + "lightning 0.1.3", "lightning-rapid-gossip-sync", ] @@ -1220,7 +1472,7 @@ source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pic dependencies = [ "bitcoin", "chunked_transfer", - "lightning", + "lightning 0.1.3", "serde_json", "tokio", ] @@ -1231,11 +1483,22 @@ version = "0.2.0" source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" dependencies = [ "dnssec-prover", - "lightning", - "lightning-types", + "lightning 0.1.3", + "lightning-types 0.2.0 (git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed)", "tokio", ] +[[package]] +name = "lightning-invoice" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" +dependencies = [ + "bech32 0.11.0", + "bitcoin", + "lightning-types 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "lightning-invoice" version = "0.33.2" @@ -1243,7 +1506,7 @@ source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pic dependencies = [ "bech32 0.11.0", "bitcoin", - "lightning-types", + "lightning-types 0.2.0 (git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed)", ] [[package]] @@ -1252,7 +1515,7 @@ version = "0.1.0" source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" dependencies = [ "bitcoin", - "lightning", + "lightning 0.1.3", "tokio", ] @@ -1262,7 +1525,7 @@ version = "0.1.0" source = "git+https://github.com/a-mpch/rust-lightning?branch=2025-06-cherry-pick-exposed#878619b2a95af44a5c5ab811805ae41846fb0cc4" dependencies = [ "bitcoin", - "lightning", + "lightning 0.1.3", "windows-sys 0.48.0", ] @@ -1274,7 +1537,16 @@ dependencies = [ "bitcoin", "bitcoin-io", "bitcoin_hashes", - "lightning", + "lightning 0.1.3", +] + +[[package]] +name = "lightning-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7" +dependencies = [ + "bitcoin", ] [[package]] @@ -1291,6 +1563,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lndk" version = "0.3.0" @@ -1298,6 +1576,7 @@ dependencies = [ "async-trait", "async_fn_traits", "bitcoin", + "bitcoin-payment-instructions", "bitcoincore-rpc", "bytes", "chrono", @@ -1309,7 +1588,7 @@ dependencies = [ "hex", "home", "ldk-sample", - "lightning", + "lightning 0.1.3", "lndk-tonic-lnd", "log", "log4rs", @@ -1318,6 +1597,7 @@ dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", "rcgen", + "reqwest", "tempfile", "tokio", "tonic", @@ -1333,13 +1613,13 @@ version = "0.1.3" source = "git+https://github.com/lndk-org/tonic_lnd?rev=f89f49aadc439b37d65947cc454eecffdc8c35e0#f89f49aadc439b37d65947cc454eecffdc8c35e0" dependencies = [ "hex", - "http", - "hyper", - "hyper-rustls", + "http 1.3.1", + "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-util", "prost", "rustls 0.23.31", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "thiserror 2.0.16", "tokio", "tokio-stream", @@ -1696,6 +1976,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" @@ -1704,6 +1993,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1970,6 +2268,47 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "ring" version = "0.17.14" @@ -2045,6 +2384,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -2174,6 +2522,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2237,6 +2597,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" @@ -2271,6 +2637,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2288,6 +2660,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tar" version = "0.4.41" @@ -2398,6 +2791,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.38.1" @@ -2427,6 +2830,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" @@ -2480,19 +2893,19 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.7.0", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "socket2 0.6.0", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.2", "tokio-stream", "tower", "tower-layer", @@ -2561,7 +2974,7 @@ dependencies = [ "indexmap", "pin-project-lite", "slab", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-util", "tower-layer", @@ -2672,6 +3085,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" @@ -2733,6 +3163,18 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.92" @@ -2762,6 +3204,16 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +[[package]] +name = "web-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -2962,12 +3414,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "x509-parser" version = "0.17.0" @@ -3006,12 +3474,90 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zip" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 41ba98b7..3f5361b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ 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"] } # Integration test dependencies, only enabled with itest feature bitcoincore-rpc = { version = "0.19.0", optional = true } diff --git a/proto/lndkrpc.proto b/proto/lndkrpc.proto index c8a50552..3307b1be 100644 --- a/proto/lndkrpc.proto +++ b/proto/lndkrpc.proto @@ -18,6 +18,7 @@ message PayOfferRequest { optional uint32 response_invoice_timeout = 4; optional uint32 fee_limit = 5; optional uint32 fee_limit_percent = 6; + optional string name = 7; } message PayOfferResponse { diff --git a/src/cli.rs b/src/cli.rs index 458882ca..e57b27a8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -143,6 +143,10 @@ enum Commands { /// Mutually exclusive with fee_limit - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit")] fee_limit_percent: Option, + + /// The human readable name of the user to pay. + #[arg(required = false)] + name: 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. @@ -231,6 +235,7 @@ async fn main() { response_invoice_timeout, fee_limit, fee_limit_percent, + name, } => { 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; @@ -253,6 +258,7 @@ async fn main() { response_invoice_timeout, fee_limit, fee_limit_percent, + name, }); add_metadata(&mut request, macaroon); diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs new file mode 100644 index 00000000..77cf086d --- /dev/null +++ b/src/dns_resolver.rs @@ -0,0 +1,81 @@ +use bitcoin_payment_instructions::hrn_resolution::{HrnResolution, HrnResolver, HumanReadableName}; +use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; +use lightning::onion_message::dns_resolution::OMNameResolver; + +pub struct LndkDNSResolverMessageHandler { + om_resolver: OMNameResolver, +} + +impl LndkDNSResolverMessageHandler { + pub fn new(latest_block_time: u32, latest_block_height: u32) -> Self { + Self { + om_resolver: OMNameResolver::new(latest_block_time, latest_block_height), + } + } + + pub fn resolver(&self) -> &OMNameResolver { + &self.om_resolver + } + + pub async fn resolve_name_to_offer( + &self, + name_str: &str, + ) -> Result> { + let resolved_uri = self.resolve_locally(name_str.to_string()).await?; + + self.extract_offer_from_uri(&resolved_uri).map_err(|e| { + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) + as Box + }) + } + + pub fn extract_offer_from_uri(&self, uri: &str) -> Result { + if let Some((_scheme, params)) = uri.split_once("?") { + for param in params.split("&") { + if let Some((key, value)) = param.split_once("=") { + if key.eq_ignore_ascii_case("lno") { + return Ok(value.to_string()); + } + } + } + Err("URI does not contain 'lno' parameter with BOLT12 offer".to_string()) + } else { + Err("Invalid URI format - expected bitcoin:?lno=".to_string()) + } + } + + pub async fn resolve_locally( + &self, + name: String, + ) -> Result> { + let client = reqwest::Client::new(); + let resolver = HTTPHrnResolver::with_client(client); + + let hrn_parsed = HumanReadableName::from_encoded(&name).map_err(|_| { + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid human-readable name: {}", name), + )) as Box + })?; + + let resolution = resolver.resolve_hrn(&hrn_parsed).await.map_err(|e| { + Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + format!("HRN resolution failed for {}: {}", name, e), + )) as Box + })?; + + let uri = match resolution { + HrnResolution::DNSSEC { result, .. } => result, + HrnResolution::LNURLPay { .. } => { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + "LNURL resolution not supported in this flow", + )) + as Box) + } + }; + + Ok(uri) + } +} diff --git a/src/lib.rs b/src/lib.rs index 07c99c4b..ea43513f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod clock; +pub mod dns_resolver; mod grpc; #[allow(dead_code)] pub mod lnd; diff --git a/src/main.rs b/src/main.rs index 53711fc8..4dbfdd79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod internal { use home::home_dir; use internal::*; +use lndk::dns_resolver::LndkDNSResolverMessageHandler; use lndk::lnd::{build_seed_from_lnd_node, get_lnd_client, validate_lnd_creds, LndCfg}; use lndk::offers::handler::OfferHandler; use lndk::server::LNDKServer; @@ -222,6 +223,10 @@ async fn main() -> Result<(), ()> { Some(seed), Some(client.clone()), )); + let latest_block_height: u32 = info.block_height as u32; + let latest_block_time: u32 = + (info.best_header_timestamp as i64).clamp(0, u32::MAX as i64) as u32; + let dns_resolver = LndkDNSResolverMessageHandler::new(latest_block_time, latest_block_height); let messenger = LndkOnionMessenger::new(); let server = LNDKServer::new( @@ -229,6 +234,7 @@ async fn main() -> Result<(), ()> { &info.identity_pubkey, lnd_tls_str, address, + dns_resolver, ) .await; diff --git a/src/server.rs b/src/server.rs index 14047480..edd02c9e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use crate::dns_resolver::LndkDNSResolverMessageHandler; use crate::lnd::{get_lnd_client, get_network, Creds, LndCfg, LndError}; use crate::lndkrpc::{CreateOfferRequest, CreateOfferResponse}; use crate::offers::get_destination; @@ -36,6 +37,7 @@ pub struct LNDKServer { // The LND tls cert we need to establish a connection with LND. lnd_cert: String, address: String, + dns_resolver: LndkDNSResolverMessageHandler, } impl LNDKServer { @@ -44,12 +46,14 @@ impl LNDKServer { node_id: &str, lnd_cert: String, address: String, + dns_resolver: LndkDNSResolverMessageHandler, ) -> Self { Self { offer_handler, node_id: PublicKey::from_str(node_id).unwrap(), lnd_cert, address, + dns_resolver, } } } @@ -72,7 +76,24 @@ impl Offers for LNDKServer { let mut client = get_lnd_client(lnd_cfg)?; let inner_request = request.get_ref(); - let offer = decode(inner_request.offer.clone())?; + + let offer_str = if let Some(name_str) = &inner_request.name { + if !name_str.is_empty() { + self.dns_resolver + .resolve_name_to_offer(name_str) + .await + .map_err(|e| { + log::error!("DNS resolution failed: {}", e); + Status::internal(format!("DNS resolution failed: {}", e)) + })? + } else { + inner_request.offer.clone() + } + } else { + inner_request.offer.clone() + }; + + let offer = decode(offer_str)?; let destination = get_destination(&offer).await?; let reply_path = None; From 1a56d82c6c0bf67642ab4f80489953a507718598 Mon Sep 17 00:00:00 2001 From: Ignacio Porte Date: Sat, 11 Oct 2025 16:24:27 -0300 Subject: [PATCH 2/6] feat: add specific BIP-353 errors for DNS resolution failures Add dedicated error types for human-readable name parsing, DNS resolution failures, and invalid payment URI formats to improve error diagnostics. --- src/dns_resolver.rs | 78 ++++++++++++++++++++------------------------- src/main.rs | 6 ---- src/offers/mod.rs | 23 ++++++++++++- src/server.rs | 11 ++----- 4 files changed, 59 insertions(+), 59 deletions(-) diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index 77cf086d..d967a61a 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -1,35 +1,35 @@ +use crate::OfferError; use bitcoin_payment_instructions::hrn_resolution::{HrnResolution, HrnResolver, HumanReadableName}; use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; -use lightning::onion_message::dns_resolution::OMNameResolver; +use std::sync::Arc; pub struct LndkDNSResolverMessageHandler { - om_resolver: OMNameResolver, + resolver: Arc, +} + +impl Default for LndkDNSResolverMessageHandler { + fn default() -> Self { + Self::new() + } } impl LndkDNSResolverMessageHandler { - pub fn new(latest_block_time: u32, latest_block_height: u32) -> Self { - Self { - om_resolver: OMNameResolver::new(latest_block_time, latest_block_height), - } + pub fn new() -> Self { + Self::with_resolver(HTTPHrnResolver::new()) } - pub fn resolver(&self) -> &OMNameResolver { - &self.om_resolver + pub fn with_resolver(resolver: R) -> Self { + Self { + resolver: Arc::new(resolver), + } } - pub async fn resolve_name_to_offer( - &self, - name_str: &str, - ) -> Result> { + pub async fn resolve_name_to_offer(&self, name_str: &str) -> Result { let resolved_uri = self.resolve_locally(name_str.to_string()).await?; - - self.extract_offer_from_uri(&resolved_uri).map_err(|e| { - Box::new(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) - as Box - }) + self.extract_offer_from_uri(&resolved_uri) } - pub fn extract_offer_from_uri(&self, uri: &str) -> Result { + pub fn extract_offer_from_uri(&self, uri: &str) -> Result { if let Some((_scheme, params)) = uri.split_once("?") { for param in params.split("&") { if let Some((key, value)) = param.split_once("=") { @@ -38,41 +38,33 @@ impl LndkDNSResolverMessageHandler { } } } - Err("URI does not contain 'lno' parameter with BOLT12 offer".to_string()) + Err(OfferError::ResolveUriError( + "URI does not contain 'lno' parameter with BOLT12 offer".to_string(), + )) } else { - Err("Invalid URI format - expected bitcoin:?lno=".to_string()) + Err(OfferError::ResolveUriError(format!( + "Invalid URI format - expected bitcoin:?lno=, got: {}", + uri + ))) } } - pub async fn resolve_locally( - &self, - name: String, - ) -> Result> { - let client = reqwest::Client::new(); - let resolver = HTTPHrnResolver::with_client(client); - - let hrn_parsed = HumanReadableName::from_encoded(&name).map_err(|_| { - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("invalid human-readable name: {}", name), - )) as Box - })?; + pub async fn resolve_locally(&self, name: String) -> Result { + let hrn_parsed = HumanReadableName::from_encoded(&name) + .map_err(|_| OfferError::ParseHrnFailure(name.clone()))?; - let resolution = resolver.resolve_hrn(&hrn_parsed).await.map_err(|e| { - Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - format!("HRN resolution failed for {}: {}", name, e), - )) as Box - })?; + let resolution = self + .resolver + .resolve_hrn(&hrn_parsed) + .await + .map_err(|e| OfferError::HrnResolutionFailure(format!("{}: {}", name, e)))?; let uri = match resolution { HrnResolution::DNSSEC { result, .. } => result, HrnResolution::LNURLPay { .. } => { - return Err(Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - "LNURL resolution not supported in this flow", + return Err(OfferError::ResolveUriError( + "LNURL resolution not supported in this flow".to_string(), )) - as Box) } }; diff --git a/src/main.rs b/src/main.rs index 4dbfdd79..53711fc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,6 @@ mod internal { use home::home_dir; use internal::*; -use lndk::dns_resolver::LndkDNSResolverMessageHandler; use lndk::lnd::{build_seed_from_lnd_node, get_lnd_client, validate_lnd_creds, LndCfg}; use lndk::offers::handler::OfferHandler; use lndk::server::LNDKServer; @@ -223,10 +222,6 @@ async fn main() -> Result<(), ()> { Some(seed), Some(client.clone()), )); - let latest_block_height: u32 = info.block_height as u32; - let latest_block_time: u32 = - (info.best_header_timestamp as i64).clamp(0, u32::MAX as i64) as u32; - let dns_resolver = LndkDNSResolverMessageHandler::new(latest_block_time, latest_block_height); let messenger = LndkOnionMessenger::new(); let server = LNDKServer::new( @@ -234,7 +229,6 @@ async fn main() -> Result<(), ()> { &info.identity_pubkey, lnd_tls_str, address, - dns_resolver, ) .await; diff --git a/src/offers/mod.rs b/src/offers/mod.rs index 81608d79..247b2f59 100644 --- a/src/offers/mod.rs +++ b/src/offers/mod.rs @@ -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) + } } } } diff --git a/src/server.rs b/src/server.rs index edd02c9e..3f0cfb2d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -37,7 +37,6 @@ pub struct LNDKServer { // The LND tls cert we need to establish a connection with LND. lnd_cert: String, address: String, - dns_resolver: LndkDNSResolverMessageHandler, } impl LNDKServer { @@ -46,14 +45,12 @@ impl LNDKServer { node_id: &str, lnd_cert: String, address: String, - dns_resolver: LndkDNSResolverMessageHandler, ) -> Self { Self { offer_handler, node_id: PublicKey::from_str(node_id).unwrap(), lnd_cert, address, - dns_resolver, } } } @@ -79,13 +76,9 @@ impl Offers for LNDKServer { let offer_str = if let Some(name_str) = &inner_request.name { if !name_str.is_empty() { - self.dns_resolver + LndkDNSResolverMessageHandler::new() .resolve_name_to_offer(name_str) - .await - .map_err(|e| { - log::error!("DNS resolution failed: {}", e); - Status::internal(format!("DNS resolution failed: {}", e)) - })? + .await? } else { inner_request.offer.clone() } From c746a641d56f097009d0791d411f9bc9443d1560 Mon Sep 17 00:00:00 2001 From: Ignacio Porte Date: Mon, 13 Oct 2025 19:12:12 -0300 Subject: [PATCH 3/6] test: add BIP-353 integration test for pay_offer with name resolution Add integration test validating end-to-end payment using human-readable names. Uses mock DNS resolver to test server-side name resolution and payment flow. --- src/dns_resolver.rs | 16 +++ tests/integration_tests.rs | 229 ++++++++++++++++++++++++++++++++++++- 2 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index d967a61a..95b2432f 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -3,6 +3,12 @@ use bitcoin_payment_instructions::hrn_resolution::{HrnResolution, HrnResolver, H use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; use std::sync::Arc; +#[cfg(itest)] +use std::sync::RwLock; + +#[cfg(itest)] +pub static TEST_RESOLVER: RwLock>> = RwLock::new(None); + pub struct LndkDNSResolverMessageHandler { resolver: Arc, } @@ -15,6 +21,16 @@ impl Default for LndkDNSResolverMessageHandler { impl LndkDNSResolverMessageHandler { pub fn new() -> Self { + #[cfg(itest)] + { + if let Ok(guard) = TEST_RESOLVER.read() { + if let Some(test_resolver) = guard.as_ref() { + return Self { + resolver: Arc::clone(test_resolver), + }; + } + } + } Self::with_resolver(HTTPHrnResolver::new()) } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1fa8e3b3..0bbd8ce3 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -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,228 @@ async fn test_receive_payment_from_offer_with_multiple_blinded_paths() { } }; } + +struct MockHrnResolver { + response_uri: String, +} + +fn set_test_dns_resolver< + R: bitcoin_payment_instructions::hrn_resolution::HrnResolver + Send + Sync + 'static, +>( + resolver: R, +) { + if let Ok(mut guard) = lndk::dns_resolver::TEST_RESOLVER.write() { + *guard = Some(std::sync::Arc::new(resolver)); + } +} + +fn clear_test_dns_resolver() { + if let Ok(mut guard) = lndk::dns_resolver::TEST_RESOLVER.write() { + *guard = None; + } +} + +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> { + Box::pin(async { Err("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> { + Box::pin(async { Err("LNURL invoice resolution not supported in mock") }) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pay_offer_with_name_and_dns() { + use lndk::lndkrpc::offers_client::OffersClient; + use lndk::lndkrpc::offers_server::OffersServer; + use lndk::lndkrpc::PayOfferRequest; + use std::time::{Duration, SystemTime}; + use tonic::transport::{Identity, Server, ServerTlsConfig}; + + 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), + }; + + set_test_dns_resolver(mock_resolver); + + let dns_resolver = lndk::dns_resolver::LndkDNSResolverMessageHandler::new(); + let resolved_offer = dns_resolver + .resolve_name_to_offer(human_readable_name) + .await; + assert!( + resolved_offer.is_ok(), + "DNS resolution failed: {:?}", + resolved_offer.err() + ); + assert_eq!(resolved_offer.unwrap(), offer_string); + + let (lndk_cfg, handler, messenger, shutdown) = common::setup_lndk( + &lnd.cert_path, + &lnd.macaroon_path, + lnd.address.clone(), + lndk_dir.clone(), + ) + .await; + + let mut lnd_client = lnd.client.clone().unwrap(); + let info = lnd_client + .lightning() + .get_info(tonic_lnd::lnrpc::GetInfoRequest {}) + .await + .expect("failed to get info") + .into_inner(); + + let server_addr = format!( + "{}:{}", + lndk::DEFAULT_SERVER_HOST, + lndk::DEFAULT_SERVER_PORT + ); + let server_socket_addr: SocketAddr = server_addr.parse().unwrap(); + + let lnd_cert_path = PathBuf::from(&lnd.cert_path); + let lnd_cert = std::fs::read_to_string(lnd_cert_path).unwrap(); + + let lndk_server = lndk::server::LNDKServer::new( + handler.clone(), + &info.identity_pubkey, + lnd_cert.clone(), + lnd.address.clone(), + ) + .await; + + let tls_cert_path = lndk_dir.join(lndk::TLS_CERT_FILENAME); + let tls_key_path = lndk_dir.join(lndk::TLS_KEY_FILENAME); + + if !tls_cert_path.exists() || !tls_key_path.exists() { + use rcgen::generate_simple_self_signed; + let cert_key = generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("failed to generate certificate"); + std::fs::write( + &tls_key_path, + cert_key.signing_key.serialize_pem().as_bytes(), + ) + .expect("failed to write key"); + std::fs::write(&tls_cert_path, cert_key.cert.pem()).expect("failed to write cert"); + } + + let tls_cert = std::fs::read_to_string(tls_cert_path.clone()).unwrap(); + let tls_key = std::fs::read_to_string(tls_key_path).unwrap(); + let identity = Identity::from_pem(tls_cert.clone(), tls_key); + + let (server_shutdown, server_listener) = triggered::trigger(); + let server_handle = tokio::spawn(async move { + Server::builder() + .tls_config(ServerTlsConfig::new().identity(identity)) + .expect("couldn't configure tls") + .add_service(OffersServer::new(lndk_server)) + .serve_with_shutdown(server_socket_addr, server_listener) + .await + }); + + tokio::time::sleep(Duration::from_secs(1)).await; + + select! { + val = messenger.run(lndk_cfg, Arc::clone(&handler)) => { + panic!("messenger should not complete first {:?}", val); + }, + result = async { + let lndk_cert_pem = std::fs::read_to_string(tls_cert_path).unwrap(); + let macaroon_bytes = std::fs::read(&lnd.macaroon_path).unwrap(); + let macaroon_hex = hex::encode(macaroon_bytes); + + let cert = tonic::transport::Certificate::from_pem(lndk_cert_pem.as_bytes()); + let tls_config = tonic::transport::ClientTlsConfig::new() + .ca_certificate(cert) + .domain_name("localhost"); + + let channel = tonic::transport::Channel::from_shared(format!("https://{}", server_addr)) + .unwrap() + .tls_config(tls_config) + .unwrap() + .connect() + .await + .expect("failed to connect to lndk server"); + + let mut client = OffersClient::new(channel); + + let mut request = tonic::Request::new(PayOfferRequest { + offer: String::new(), + amount: Some(20_000), + payer_note: None, + response_invoice_timeout: None, + fee_limit: None, + fee_limit_percent: None, + name: Some(human_readable_name.to_string()), + }); + + request.metadata_mut().insert( + "macaroon", + macaroon_hex.parse().expect("failed to parse macaroon"), + ); + + let result = client.pay_offer(request).await; + assert!(result.is_ok(), "pay_offer failed: {:?}", result.err()); + + let response = result.as_ref().unwrap().get_ref(); + assert!(!response.payment_preimage.is_empty(), "Payment should have a preimage"); + result + } => { + assert!(result.is_ok()); + + server_shutdown.trigger(); + shutdown.trigger(); + let _ = tokio::time::timeout(Duration::from_secs(5), server_handle).await; + ldk1.stop().await; + ldk2.stop().await; + + clear_test_dns_resolver(); + } + } +} From 07693eaf95d573b38899d5f40aac70933ac28cca Mon Sep 17 00:00:00 2001 From: Ignacio Porte Date: Tue, 14 Oct 2025 22:17:19 -0300 Subject: [PATCH 4/6] refactor: move pay offer with name to a separate endpoint Extract BIP-353 human-readable name payment into dedicated PayName RPC endpoint, separate from PayOffer. This improves API clarity by distinguishing direct offer payments from name-based payments that require DNS resolution. --- proto/lndkrpc.proto | 11 +++- src/cli.rs | 67 ++++++++++++++++++-- src/dns_resolver.rs | 2 +- src/offers/handler.rs | 38 ++++++++++++ src/server.rs | 73 ++++++++++++++++------ tests/integration_tests.rs | 124 ++++++------------------------------- 6 files changed, 184 insertions(+), 131 deletions(-) diff --git a/proto/lndkrpc.proto b/proto/lndkrpc.proto index 3307b1be..bf595d5e 100644 --- a/proto/lndkrpc.proto +++ b/proto/lndkrpc.proto @@ -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); @@ -18,7 +19,15 @@ message PayOfferRequest { optional uint32 response_invoice_timeout = 4; optional uint32 fee_limit = 5; optional uint32 fee_limit_percent = 6; - optional string name = 7; +} + +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 { diff --git a/src/cli.rs b/src/cli.rs index e57b27a8..8eb86f37 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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::{ @@ -143,10 +146,34 @@ enum Commands { /// Mutually exclusive with fee_limit - only one can be set. #[arg(long, required = false, conflicts_with = "fee_limit")] fee_limit_percent: Option, + }, + /// 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, - /// The human readable name of the user to pay. + /// 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)] - name: Option, + 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. @@ -235,7 +262,6 @@ async fn main() { response_invoice_timeout, fee_limit, fee_limit_percent, - name, } => { 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; @@ -258,7 +284,6 @@ async fn main() { response_invoice_timeout, fee_limit, fee_limit_percent, - name, }); add_metadata(&mut request, macaroon); @@ -267,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, diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index 95b2432f..b6104e68 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -40,7 +40,7 @@ impl LndkDNSResolverMessageHandler { } } - pub async fn resolve_name_to_offer(&self, name_str: &str) -> Result { + pub async fn resolver_hrn_to_offer(&self, name_str: &str) -> Result { let resolved_uri = self.resolve_locally(name_str.to_string()).await?; self.extract_offer_from_uri(&resolved_uri) } diff --git a/src/offers/handler.rs b/src/offers/handler.rs index b451620b..e2c51f19 100644 --- a/src/offers/handler.rs +++ b/src/offers/handler.rs @@ -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; @@ -77,6 +79,18 @@ pub struct PayOfferParams { pub fee_limit: Option, } +#[derive(Clone)] +pub struct PayHumanReadableAddressParams { + pub name: String, + pub amount: Option, + pub payer_note: Option, + pub network: Network, + pub client: Client, + pub reply_path: Option, + pub response_invoice_timeout: Option, + pub fee_limit: Option, +} + #[derive(Clone)] pub struct SendPaymentParams { pub path: BlindedPaymentPath, @@ -150,6 +164,30 @@ 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 { + let offer_str = LndkDNSResolverMessageHandler::new() + .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. diff --git a/src/server.rs b/src/server.rs index 3f0cfb2d..4265ed15 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,8 +1,7 @@ -use crate::dns_resolver::LndkDNSResolverMessageHandler; 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; @@ -18,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; @@ -73,20 +72,7 @@ impl Offers for LNDKServer { let mut client = get_lnd_client(lnd_cfg)?; let inner_request = request.get_ref(); - - let offer_str = if let Some(name_str) = &inner_request.name { - if !name_str.is_empty() { - LndkDNSResolverMessageHandler::new() - .resolve_name_to_offer(name_str) - .await? - } else { - inner_request.offer.clone() - } - } else { - inner_request.offer.clone() - }; - - let offer = decode(offer_str)?; + let offer = decode(inner_request.offer.clone())?; let destination = get_destination(&offer).await?; let reply_path = None; @@ -125,6 +111,57 @@ impl Offers for LNDKServer { Ok(Response::new(reply)) } + async fn pay_human_readable_address( + &self, + request: Request, + ) -> Result, 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, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0bbd8ce3..64fedcd0 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1496,11 +1496,8 @@ impl bitcoin_payment_instructions::hrn_resolution::HrnResolver for MockHrnResolv #[tokio::test(flavor = "multi_thread")] async fn test_pay_offer_with_name_and_dns() { - use lndk::lndkrpc::offers_client::OffersClient; - use lndk::lndkrpc::offers_server::OffersServer; - use lndk::lndkrpc::PayOfferRequest; + use lndk::offers::handler::PayHumanReadableAddressParams; use std::time::{Duration, SystemTime}; - use tonic::transport::{Identity, Server, ServerTlsConfig}; let test_name = "lndk_pay_offer_with_name"; let (bitcoind, mut lnd, ldk1, ldk2, lndk_dir, _) = @@ -1533,7 +1530,7 @@ async fn test_pay_offer_with_name_and_dns() { let dns_resolver = lndk::dns_resolver::LndkDNSResolverMessageHandler::new(); let resolved_offer = dns_resolver - .resolve_name_to_offer(human_readable_name) + .resolver_hrn_to_offer(human_readable_name) .await; assert!( resolved_offer.is_ok(), @@ -1546,118 +1543,33 @@ async fn test_pay_offer_with_name_and_dns() { &lnd.cert_path, &lnd.macaroon_path, lnd.address.clone(), - lndk_dir.clone(), - ) - .await; - - let mut lnd_client = lnd.client.clone().unwrap(); - let info = lnd_client - .lightning() - .get_info(tonic_lnd::lnrpc::GetInfoRequest {}) - .await - .expect("failed to get info") - .into_inner(); - - let server_addr = format!( - "{}:{}", - lndk::DEFAULT_SERVER_HOST, - lndk::DEFAULT_SERVER_PORT - ); - let server_socket_addr: SocketAddr = server_addr.parse().unwrap(); - - let lnd_cert_path = PathBuf::from(&lnd.cert_path); - let lnd_cert = std::fs::read_to_string(lnd_cert_path).unwrap(); - - let lndk_server = lndk::server::LNDKServer::new( - handler.clone(), - &info.identity_pubkey, - lnd_cert.clone(), - lnd.address.clone(), + lndk_dir, ) .await; - let tls_cert_path = lndk_dir.join(lndk::TLS_CERT_FILENAME); - let tls_key_path = lndk_dir.join(lndk::TLS_KEY_FILENAME); - - if !tls_cert_path.exists() || !tls_key_path.exists() { - use rcgen::generate_simple_self_signed; - let cert_key = generate_simple_self_signed(vec!["localhost".to_string()]) - .expect("failed to generate certificate"); - std::fs::write( - &tls_key_path, - cert_key.signing_key.serialize_pem().as_bytes(), - ) - .expect("failed to write key"); - std::fs::write(&tls_cert_path, cert_key.cert.pem()).expect("failed to write cert"); - } - - let tls_cert = std::fs::read_to_string(tls_cert_path.clone()).unwrap(); - let tls_key = std::fs::read_to_string(tls_key_path).unwrap(); - let identity = Identity::from_pem(tls_cert.clone(), tls_key); - - let (server_shutdown, server_listener) = triggered::trigger(); - let server_handle = tokio::spawn(async move { - Server::builder() - .tls_config(ServerTlsConfig::new().identity(identity)) - .expect("couldn't configure tls") - .add_service(OffersServer::new(lndk_server)) - .serve_with_shutdown(server_socket_addr, server_listener) - .await - }); + let client = lnd.client.clone().unwrap(); - tokio::time::sleep(Duration::from_secs(1)).await; + 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); }, - result = async { - let lndk_cert_pem = std::fs::read_to_string(tls_cert_path).unwrap(); - let macaroon_bytes = std::fs::read(&lnd.macaroon_path).unwrap(); - let macaroon_hex = hex::encode(macaroon_bytes); - - let cert = tonic::transport::Certificate::from_pem(lndk_cert_pem.as_bytes()); - let tls_config = tonic::transport::ClientTlsConfig::new() - .ca_certificate(cert) - .domain_name("localhost"); - - let channel = tonic::transport::Channel::from_shared(format!("https://{}", server_addr)) - .unwrap() - .tls_config(tls_config) - .unwrap() - .connect() - .await - .expect("failed to connect to lndk server"); - - let mut client = OffersClient::new(channel); - - let mut request = tonic::Request::new(PayOfferRequest { - offer: String::new(), - amount: Some(20_000), - payer_note: None, - response_invoice_timeout: None, - fee_limit: None, - fee_limit_percent: None, - name: Some(human_readable_name.to_string()), - }); - - request.metadata_mut().insert( - "macaroon", - macaroon_hex.parse().expect("failed to parse macaroon"), - ); - - let result = client.pay_offer(request).await; - assert!(result.is_ok(), "pay_offer failed: {:?}", result.err()); - - let response = result.as_ref().unwrap().get_ref(); - assert!(!response.payment_preimage.is_empty(), "Payment should have a preimage"); - result - } => { - assert!(result.is_ok()); + 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"); - server_shutdown.trigger(); shutdown.trigger(); - let _ = tokio::time::timeout(Duration::from_secs(5), server_handle).await; ldk1.stop().await; ldk2.stop().await; From 3623a11f1e5108fe41d50bb75ca167fed0840809 Mon Sep 17 00:00:00 2001 From: Ignacio Porte Date: Wed, 5 Nov 2025 21:15:40 -0300 Subject: [PATCH 5/6] refactor: inject DNS resolver into OfferHandler Replaces global TEST_RESOLVER static with proper dependency injection via a new with_dns_resolver() constructor. This makes the code more testable and eliminates potential race conditions from shared mutable state. --- src/dns_resolver.rs | 17 +---------------- src/offers/handler.rs | 20 +++++++++++++++++--- tests/integration_tests.rs | 31 ++++++++++--------------------- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index b6104e68..1bf111c4 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -3,12 +3,7 @@ use bitcoin_payment_instructions::hrn_resolution::{HrnResolution, HrnResolver, H use bitcoin_payment_instructions::http_resolver::HTTPHrnResolver; use std::sync::Arc; -#[cfg(itest)] -use std::sync::RwLock; - -#[cfg(itest)] -pub static TEST_RESOLVER: RwLock>> = RwLock::new(None); - +#[derive(Clone)] pub struct LndkDNSResolverMessageHandler { resolver: Arc, } @@ -21,16 +16,6 @@ impl Default for LndkDNSResolverMessageHandler { impl LndkDNSResolverMessageHandler { pub fn new() -> Self { - #[cfg(itest)] - { - if let Ok(guard) = TEST_RESOLVER.read() { - if let Some(test_resolver) = guard.as_ref() { - return Self { - resolver: Arc::clone(test_resolver), - }; - } - } - } Self::with_resolver(HTTPHrnResolver::new()) } diff --git a/src/offers/handler.rs b/src/offers/handler.rs index e2c51f19..497414c9 100644 --- a/src/offers/handler.rs +++ b/src/offers/handler.rs @@ -59,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, + dns_resolver: LndkDNSResolverMessageHandler, } #[derive(Clone)] @@ -127,6 +128,20 @@ impl OfferHandler { response_invoice_timeout: Option, seed: Option<[u8; 32]>, client: Option, + ) -> Self { + Self::with_dns_resolver( + response_invoice_timeout, + seed, + client, + LndkDNSResolverMessageHandler::new(), + ) + } + + pub fn with_dns_resolver( + response_invoice_timeout: Option, + seed: Option<[u8; 32]>, + client: Option, + dns_resolver: LndkDNSResolverMessageHandler, ) -> Self { let messenger_utils = MessengerUtilities::default(); let random_bytes = match seed { @@ -144,6 +159,7 @@ impl OfferHandler { expanded_key, response_invoice_timeout, client, + dns_resolver, } } @@ -166,9 +182,7 @@ impl OfferHandler { /// Resolves a human-readable name (BIP-353) to an offer and pays it. pub async fn pay_hrn(&self, cfg: PayHumanReadableAddressParams) -> Result { - let offer_str = LndkDNSResolverMessageHandler::new() - .resolver_hrn_to_offer(&cfg.name) - .await?; + let offer_str = self.dns_resolver.resolver_hrn_to_offer(&cfg.name).await?; let offer = decode(offer_str)?; let destination = get_destination(&offer).await?; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 64fedcd0..e353c87b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1445,22 +1445,6 @@ struct MockHrnResolver { response_uri: String, } -fn set_test_dns_resolver< - R: bitcoin_payment_instructions::hrn_resolution::HrnResolver + Send + Sync + 'static, ->( - resolver: R, -) { - if let Ok(mut guard) = lndk::dns_resolver::TEST_RESOLVER.write() { - *guard = Some(std::sync::Arc::new(resolver)); - } -} - -fn clear_test_dns_resolver() { - if let Ok(mut guard) = lndk::dns_resolver::TEST_RESOLVER.write() { - *guard = None; - } -} - impl bitcoin_payment_instructions::hrn_resolution::HrnResolver for MockHrnResolver { fn resolve_hrn<'a>( &'a self, @@ -1526,9 +1510,9 @@ async fn test_pay_offer_with_name_and_dns() { response_uri: format!("bitcoin:?lno={}", offer_string), }; - set_test_dns_resolver(mock_resolver); + let dns_resolver = + lndk::dns_resolver::LndkDNSResolverMessageHandler::with_resolver(mock_resolver); - let dns_resolver = lndk::dns_resolver::LndkDNSResolverMessageHandler::new(); let resolved_offer = dns_resolver .resolver_hrn_to_offer(human_readable_name) .await; @@ -1539,7 +1523,14 @@ async fn test_pay_offer_with_name_and_dns() { ); assert_eq!(resolved_offer.unwrap(), offer_string); - let (lndk_cfg, handler, messenger, shutdown) = common::setup_lndk( + let handler = Arc::new(lndk::offers::handler::OfferHandler::with_dns_resolver( + None, + None, + None, + dns_resolver.clone(), + )); + + let (lndk_cfg, _, messenger, shutdown) = common::setup_lndk( &lnd.cert_path, &lnd.macaroon_path, lnd.address.clone(), @@ -1572,8 +1563,6 @@ async fn test_pay_offer_with_name_and_dns() { shutdown.trigger(); ldk1.stop().await; ldk2.stop().await; - - clear_test_dns_resolver(); } } } From 9848db580ae796a6bf7fac792015d81800ee90a5 Mon Sep 17 00:00:00 2001 From: Ignacio Porte Date: Wed, 5 Nov 2025 21:21:34 -0300 Subject: [PATCH 6/6] chore: add timeout and improve URI parsing in DNS resolver Adds 20-second timeout to HTTP client used for DNS resolution to prevent requests from hanging indefinitely on unresponsive servers. Replaces manual URI query parsing with url crate for proper handling of percent-encoded characters (%20, %3D, etc.) and robust parsing of BIP-21 bitcoin URIs. --- Cargo.lock | 1 + Cargo.toml | 1 + src/dns_resolver.rs | 69 ++++++++++++++++++++++++++++++++++----------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a14a77b..1997ba0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,6 +1605,7 @@ dependencies = [ "tonic-prost-build", "tonic-types", "triggered", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3f5361b4..39c85bc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ 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 } diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index 1bf111c4..dd67157a 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -2,6 +2,10 @@ 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 { @@ -16,7 +20,12 @@ impl Default for LndkDNSResolverMessageHandler { impl LndkDNSResolverMessageHandler { pub fn new() -> Self { - Self::with_resolver(HTTPHrnResolver::new()) + 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(resolver: R) -> Self { @@ -31,23 +40,18 @@ impl LndkDNSResolverMessageHandler { } pub fn extract_offer_from_uri(&self, uri: &str) -> Result { - if let Some((_scheme, params)) = uri.split_once("?") { - for param in params.split("&") { - if let Some((key, value)) = param.split_once("=") { - if key.eq_ignore_ascii_case("lno") { - return Ok(value.to_string()); - } - } + 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(), - )) - } else { - Err(OfferError::ResolveUriError(format!( - "Invalid URI format - expected bitcoin:?lno=, got: {}", - uri - ))) } + + Err(OfferError::ResolveUriError( + "URI does not contain 'lno' parameter with BOLT12 offer".to_string(), + )) } pub async fn resolve_locally(&self, name: String) -> Result { @@ -72,3 +76,36 @@ impl LndkDNSResolverMessageHandler { 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")); + } +}