diff --git a/Cargo.lock b/Cargo.lock index 0312a9c..7b4ff7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ca59e0c..c3f1838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/proto/lndkrpc.proto b/proto/lndkrpc.proto index c8a5055..bf595d5 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); @@ -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; } diff --git a/src/cli.rs b/src/cli.rs index c424566..6f2f45f 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::{ @@ -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, + + /// A payer-provided note which will be seen by the recipient. + #[arg(required = false)] + payer_note: Option, + + /// The amount of time in seconds that the user would like to wait for an invoice to + /// arrive. If this isn't set, we'll use the default value. + #[arg(long, global = false, required = false, default_value = DEFAULT_RESPONSE_INVOICE_TIMEOUT.to_string())] + response_invoice_timeout: Option, + + /// A fixed fee limit in millisatoshis. + /// Mutually exclusive with fee_limit_percent - only one can be set. + #[arg(long, required = false, conflicts_with = "fee_limit_percent")] + fee_limit: Option, + /// A percentage-based fee limit of the payment amount. + /// Mutually exclusive with fee_limit - only one can be set. + #[arg(long, required = false, conflicts_with = "fee_limit")] + fee_limit_percent: Option, + }, + /// PayHrn pays a BOLT 12 offer by resolving a human-readable name (BIP-353). + #[command(name = "pay-hrn")] + PayHrn { + /// The human-readable name to resolve (e.g., "user@example.com"). + name: String, + + /// Amount in millisatoshis the user would like to pay. If this isn't set, we'll assume + /// the user is paying whatever the offer amount is. #[arg(required = false)] amount: Option, @@ -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, @@ -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, /// 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, diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs new file mode 100644 index 0000000..301717f --- /dev/null +++ b/src/dns_resolver.rs @@ -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, +} + +impl Default for DNSResolverMessageHandler { + fn default() -> Self { + Self::new() + } +} + +impl DNSResolverMessageHandler { + pub fn new() -> Self { + Self::with_resolver(HTTPHrnResolver::new()) + } + + pub fn with_resolver(resolver: R) -> Self { + Self { + resolver: Arc::new(resolver), + } + } + + pub async fn resolve_hrn_to_offer(&self, name_str: &str) -> Result { + 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 { + 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 { + 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")); + } +} diff --git a/src/lib.rs b/src/lib.rs index b6614ac..ec228d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod clock; +mod dns_resolver; mod grpc; #[allow(dead_code)] pub mod lnd; diff --git a/src/offers/handler.rs b/src/offers/handler.rs index 0160d77..716fc34 100644 --- a/src/offers/handler.rs +++ b/src/offers/handler.rs @@ -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, + dns_resolver: DNSResolverMessageHandler, } #[derive(Clone)] @@ -77,6 +81,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, @@ -115,6 +131,36 @@ impl OfferHandler { response_invoice_timeout: Option, seed: Option<[u8; 32]>, client: Option, + ) -> 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( + response_invoice_timeout: Option, + seed: Option<[u8; 32]>, + client: Option, + 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, + seed: Option<[u8; 32]>, + client: Option, + 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 { + 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. diff --git a/src/offers/mod.rs b/src/offers/mod.rs index 81608d7..8b72a48 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 78b8f56..309db08 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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, + ) -> 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 508277d..823a582 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,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; + } + } +}