From 7e378fac7f16acc80898d222d2e444bfac979511 Mon Sep 17 00:00:00 2001 From: Chuks Agbakuru Date: Thu, 16 Oct 2025 08:33:00 +0100 Subject: [PATCH 1/4] Add configuration options for HRN settings Introduce new configuration parameters to manage Human-Readable Name (HRN) resolution and DNSSEC validation behavior. These settings allow users to define custom resolution preferences for BOLT12 offer lookups. Moving these parameters into the central configuration struct ensures that node behavior is customizable at runtime and consistent across different network environments. This abstraction is necessary to support diverse DNSSEC requirements without hard-coding resolution logic. --- bindings/ldk_node.udl | 4 +++ src/config.rs | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index c1b97e0e7..c32604708 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -416,3 +416,7 @@ typedef string LSPSDateTime; typedef string ScriptBuf; typedef enum Event; + +typedef interface HRNResolverConfig; + +typedef dictionary HumanReadableNamesConfig; diff --git a/src/config.rs b/src/config.rs index 71e4d2314..6f786d764 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,7 @@ //! Objects for configuring the node. use std::fmt; +use std::str::FromStr; use std::time::Duration; use bitcoin::secp256k1::PublicKey; @@ -128,6 +129,7 @@ pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15; /// | `anchor_channels_config` | Some(..) | /// | `route_parameters` | None | /// | `tor_config` | None | +/// | `hrn_config` | HumanReadableNamesConfig::default() | /// /// See [`AnchorChannelsConfig`] and [`RouteParametersConfig`] for more information regarding their /// respective default values. @@ -199,6 +201,10 @@ pub struct Config { /// /// **Note**: If unset, connecting to peer OnionV3 addresses will fail. pub tor_config: Option, + /// Configuration options for Human-Readable Names ([BIP 353]). + /// + /// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki + pub hrn_config: HumanReadableNamesConfig, } impl Default for Config { @@ -214,6 +220,62 @@ impl Default for Config { tor_config: None, route_parameters: None, node_alias: None, + hrn_config: HumanReadableNamesConfig::default(), + } + } +} + +/// Configuration options for how our node resolves Human-Readable Names (BIP 353). +/// +/// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HRNResolverConfig { + /// Use [bLIP-32] to ask other nodes to resolve names for us. + /// + /// [bLIP-32]: https://github.com/lightning/blips/blob/master/blip-0032.md + Blip32, + /// Resolve names locally using a specific DNS server. + Dns { + /// The IP and port of the DNS server. + /// + /// **Default:** `8.8.8.8:53` (Google Public DNS) + dns_server_address: SocketAddress, + /// If set to true, this allows others to use our node for HRN resolutions. + /// + /// **Default:** `false` + /// + /// **Note:** Enabling `enable_hrn_resolution_service` allows your node to act + /// as a resolver for the rest of the network. For this to work, your node must + /// be announceable (publicly visible in the network graph) so that other nodes + /// can route resolution requests to you via Onion Messages. This does not affect + /// your node's ability to resolve names for its own outgoing payments. + enable_hrn_resolution_service: bool, + }, +} + +/// Configuration options for Human-Readable Names ([BIP 353]). +/// +/// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HumanReadableNamesConfig { + /// This sets how our node resolves names when we want to send a payment. + /// + /// By default, this uses the `Dns` variant with the following settings: + /// * **DNS Server**: `8.8.8.8:53` (Google Public DNS) + /// * **Resolution Service**: Enabled (`false`) + pub resolution_config: HRNResolverConfig, +} + +impl Default for HumanReadableNamesConfig { + fn default() -> Self { + HumanReadableNamesConfig { + resolution_config: HRNResolverConfig::Dns { + dns_server_address: SocketAddress::from_str("8.8.8.8:53") + .expect("Socket address conversion failed."), + enable_hrn_resolution_service: false, + }, } } } From 9081f1e1a6d2775cfbafadde730e4b69fb3ecbb1 Mon Sep 17 00:00:00 2001 From: Chuks Agbakuru Date: Thu, 4 Sep 2025 08:10:51 +0100 Subject: [PATCH 2/4] Pass HRNResolver or DomainResolver into OnionMessenger Inject specialized resolution capabilities into OnionMessenger to support outbound payments and third-party resolution services. This change refines the previous resolution logic by allowing the node to act as a robust BIP 353 participant. If configured as a service provider, the node utilizes a Domain Resolver to handle requests for other participants. Otherwise, it uses an HRN Resolver specifically for initiating its own outbound payments. Providing these as optional parameters in the Node constructor ensures the logic matches the node's designated role in the ecosystem. --- Cargo.toml | 6 +++ src/builder.rs | 100 +++++++++++++++++++++++++++++++++++++++++-------- src/runtime.rs | 2 +- src/types.rs | 46 +++++++++++++++++++++-- 4 files changed, 135 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d9afeb9a7..142aabf8e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ default = [] #lightning-transaction-sync = { version = "0.2.0", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } #lightning-liquidity = { version = "0.2.0", features = ["std"] } #lightning-macros = { version = "0.2.0" } +#lightning-dns-resolver = { version = "0.3.0" } lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "38a62c32454d3eac22578144c479dbf9a6d9bff6", features = ["std"] } lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "38a62c32454d3eac22578144c479dbf9a6d9bff6" } @@ -50,6 +51,7 @@ lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightnin lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "38a62c32454d3eac22578144c479dbf9a6d9bff6", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "38a62c32454d3eac22578144c479dbf9a6d9bff6", features = ["std"] } lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "38a62c32454d3eac22578144c479dbf9a6d9bff6" } +lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "38a62c32454d3eac22578144c479dbf9a6d9bff6" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -144,6 +146,7 @@ harness = false #lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } #lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } #lightning-macros = { path = "../rust-lightning/lightning-macros" } +#lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } #lightning = { git = "https://github.com/lightningdevkit/rust-lightning", branch = "main" } #lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", branch = "main" } @@ -156,6 +159,7 @@ harness = false #lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", branch = "main" } #lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", branch = "main" } #lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", branch = "main" } +#lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", branch = "main" } #lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "21e9a9c0ef80021d0669f2a366f55d08ba8d9b03" } #lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "21e9a9c0ef80021d0669f2a366f55d08ba8d9b03" } @@ -168,6 +172,7 @@ harness = false #lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "21e9a9c0ef80021d0669f2a366f55d08ba8d9b03" } #lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "21e9a9c0ef80021d0669f2a366f55d08ba8d9b03" } #lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "21e9a9c0ef80021d0669f2a366f55d08ba8d9b03" } +#lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "21e9a9c0ef80021d0669f2a366f55d08ba8d9b03" } #vss-client-ng = { path = "../vss-client" } #vss-client-ng = { git = "https://github.com/lightningdevkit/vss-client", branch = "main" } @@ -184,3 +189,4 @@ harness = false #lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } #lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } #lightning-macros = { path = "../rust-lightning/lightning-macros" } +#lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } diff --git a/src/builder.rs b/src/builder.rs index 3d12ee103..a55b49d7e 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -8,8 +8,9 @@ use std::collections::HashMap; use std::convert::TryInto; use std::default::Default; +use std::net::ToSocketAddrs; use std::path::PathBuf; -use std::sync::{Arc, Mutex, Once, RwLock}; +use std::sync::{Arc, Mutex, Once, RwLock, Weak}; use std::time::SystemTime; use std::{fmt, fs}; @@ -19,12 +20,14 @@ use bitcoin::bip32::{ChildNumber, Xpriv}; use bitcoin::key::Secp256k1; use bitcoin::secp256k1::PublicKey; use bitcoin::{BlockHash, Network}; +use bitcoin_payment_instructions::dns_resolver::DNSHrnResolver; use bitcoin_payment_instructions::onion_message_resolver::LDKOnionMessageDNSSECHrnResolver; use lightning::chain::{chainmonitor, BestBlock}; use lightning::ln::channelmanager::{self, ChainParameters, ChannelManagerReadArgs}; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::{IgnoringMessageHandler, MessageHandler}; use lightning::log_trace; +use lightning::onion_message::dns_resolution::DNSResolverMessageHandler; use lightning::routing::gossip::NodeAlias; use lightning::routing::router::DefaultRouter; use lightning::routing::scoring::{ @@ -39,14 +42,15 @@ use lightning::util::persist::{ }; use lightning::util::ser::ReadableArgs; use lightning::util::sweep::OutputSweeper; +use lightning_dns_resolver::OMDomainResolver; use lightning_persister::fs_store::v1::FilesystemStore; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, - BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, TorConfig, - DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, + BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, + TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, }; use crate::connection::ConnectionManager; use crate::entropy::NodeEntropy; @@ -77,8 +81,8 @@ use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper, - GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, - PendingPaymentStore, SyncAndAsyncKVStore, + GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore, + PeerManager, PendingPaymentStore, SyncAndAsyncKVStore, }; use crate::wallet::persist::KVStoreWalletPersister; use crate::wallet::Wallet; @@ -195,6 +199,8 @@ pub enum BuildError { NetworkMismatch, /// The role of the node in an asynchronous payments context is not compatible with the current configuration. AsyncPaymentsConfigMismatch, + /// An attempt to setup a DNS Resolver failed. + DNSResolverSetupFailed, } impl fmt::Display for BuildError { @@ -229,6 +235,9 @@ impl fmt::Display for BuildError { "The async payments role is not compatible with the current configuration." ) }, + Self::DNSResolverSetupFailed => { + write!(f, "An attempt to setup a DNS resolver has failed.") + }, } } } @@ -1726,7 +1735,71 @@ fn build_with_store_internal( })?; } - let hrn_resolver = Arc::new(LDKOnionMessageDNSSECHrnResolver::new(Arc::clone(&network_graph))); + // This hook resolves a circular dependency: + // 1. PeerManager requires OnionMessenger (via MessageHandler). + // 2. OnionMessenger (via HRN resolver) needs to call PeerManager::process_events. + // + // We provide the resolver with a Weak pointer via this Mutex-protected "hook." + // This allows us to initialize the resolver before the PeerManager exists, + // and prevents a reference cycle (memory leak). + let peer_manager_hook: Arc>>> = Arc::new(Mutex::new(None)); + let hrn_resolver; + + let runtime_handle = runtime.handle(); + + let om_resolver: Arc = match &config + .hrn_config + .resolution_config + { + HRNResolverConfig::Blip32 => { + let hrn_res = + Arc::new(LDKOnionMessageDNSSECHrnResolver::new(Arc::clone(&network_graph))); + hrn_resolver = HRNResolver::Onion(Arc::clone(&hrn_res)); + + // We clone the hook because it's moved into a Send + Sync closure that outlives this scope. + let pm_hook_clone = Arc::clone(&peer_manager_hook); + hrn_res.register_post_queue_action(Box::new(move || { + if let Ok(guard) = pm_hook_clone.lock() { + if let Some(pm) = guard.as_ref().and_then(|weak| weak.upgrade()) { + pm.process_events(); + } + } + })); + hrn_res as Arc + }, + HRNResolverConfig::Dns { dns_server_address, enable_hrn_resolution_service, .. } => { + let addr = dns_server_address + .to_socket_addrs() + .map_err(|_| BuildError::DNSResolverSetupFailed)? + .next() + .ok_or({ + log_error!(logger, "No valid address found for: {}", dns_server_address); + BuildError::DNSResolverSetupFailed + })?; + let hrn_res = Arc::new(DNSHrnResolver(addr)); + hrn_resolver = HRNResolver::Local(hrn_res); + + if *enable_hrn_resolution_service { + if let Err(_) = may_announce_channel(&config) { + log_error!( + logger, + "HRN resolution service enabled, but node is not announceable." + ); + return Err(BuildError::DNSResolverSetupFailed); + } + + Arc::new(OMDomainResolver::::with_runtime( + addr, + None, + Some(runtime_handle.clone()), + )) as Arc + } else { + // The user wants to use DNS to pay others, but NOT provide a service to others. + Arc::new(IgnoringMessageHandler {}) + as Arc + } + }, + }; // Initialize the PeerManager let onion_messenger: Arc = @@ -1739,7 +1812,7 @@ fn build_with_store_internal( message_router, Arc::clone(&channel_manager), Arc::clone(&channel_manager), - Arc::clone(&hrn_resolver), + Arc::clone(&om_resolver), IgnoringMessageHandler {}, )) } else { @@ -1751,7 +1824,7 @@ fn build_with_store_internal( message_router, Arc::clone(&channel_manager), Arc::clone(&channel_manager), - Arc::clone(&hrn_resolver), + Arc::clone(&om_resolver), IgnoringMessageHandler {}, )) }; @@ -1882,12 +1955,9 @@ fn build_with_store_internal( Arc::clone(&keys_manager), )); - let peer_manager_clone = Arc::downgrade(&peer_manager); - hrn_resolver.register_post_queue_action(Box::new(move || { - if let Some(upgraded_pointer) = peer_manager_clone.upgrade() { - upgraded_pointer.process_events(); - } - })); + if let Ok(mut guard) = peer_manager_hook.lock() { + *guard = Some(Arc::downgrade(&peer_manager)); + } liquidity_source.as_ref().map(|l| l.set_peer_manager(Arc::downgrade(&peer_manager))); @@ -2001,7 +2071,7 @@ fn build_with_store_internal( node_metrics, om_mailbox, async_payments_role, - hrn_resolver, + hrn_resolver: Arc::new(hrn_resolver), #[cfg(cycle_tests)] _leak_checker, }) diff --git a/src/runtime.rs b/src/runtime.rs index 48d60a7bf..1d8eb32b0 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -210,7 +210,7 @@ impl Runtime { ); } - fn handle(&self) -> &tokio::runtime::Handle { + pub(crate) fn handle(&self) -> &tokio::runtime::Handle { match &self.mode { RuntimeMode::Owned(rt) => rt.handle(), RuntimeMode::Handle(handle) => handle, diff --git a/src/types.rs b/src/types.rs index 3424d2779..5d5515dcc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -10,15 +10,23 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; +use bitcoin_payment_instructions::amount::Amount as BPIAmount; +use bitcoin_payment_instructions::dns_resolver::DNSHrnResolver; +use bitcoin_payment_instructions::hrn_resolution::{ + HrnResolutionFuture, HrnResolver, HumanReadableName, LNURLResolutionFuture, +}; +use bitcoin_payment_instructions::onion_message_resolver::LDKOnionMessageDNSSECHrnResolver; + use bitcoin::secp256k1::PublicKey; use bitcoin::{OutPoint, ScriptBuf}; -use bitcoin_payment_instructions::onion_message_resolver::LDKOnionMessageDNSSECHrnResolver; + use lightning::chain::chainmonitor; use lightning::impl_writeable_tlv_based; use lightning::ln::channel_state::{ChannelDetails as LdkChannelDetails, ChannelShutdownState}; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::ln::types::ChannelId; +use lightning::onion_message::dns_resolution::DNSResolverMessageHandler; use lightning::routing::gossip; use lightning::routing::router::DefaultRouter; use lightning::routing::scoring::{CombinedScorer, ProbabilisticScoringFeeParameters}; @@ -318,11 +326,43 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - Arc, + Arc, IgnoringMessageHandler, >; -pub(crate) type HRNResolver = LDKOnionMessageDNSSECHrnResolver, Arc>; +pub enum HRNResolver { + Onion(Arc, Arc>>), + Local(Arc), +} + +impl HrnResolver for HRNResolver { + fn resolve_hrn<'a>(&'a self, hrn: &'a HumanReadableName) -> HrnResolutionFuture<'a> { + match self { + HRNResolver::Onion(inner) => inner.resolve_hrn(hrn), + HRNResolver::Local(inner) => inner.resolve_hrn(hrn), + } + } + + fn resolve_lnurl<'a>(&'a self, url: &'a str) -> HrnResolutionFuture<'a> { + match self { + HRNResolver::Onion(inner) => inner.resolve_lnurl(url), + HRNResolver::Local(inner) => inner.resolve_lnurl(url), + } + } + + fn resolve_lnurl_to_invoice<'a>( + &'a self, callback_url: String, amount: BPIAmount, expected_description_hash: [u8; 32], + ) -> LNURLResolutionFuture<'a> { + match self { + HRNResolver::Onion(inner) => { + inner.resolve_lnurl_to_invoice(callback_url, amount, expected_description_hash) + }, + HRNResolver::Local(inner) => { + inner.resolve_lnurl_to_invoice(callback_url, amount, expected_description_hash) + }, + } + } +} pub(crate) type MessageRouter = lightning::onion_message::messenger::DefaultMessageRouter< Arc, From ae53d4b60bd3b136490fc03a1c9ecf9db186b11f Mon Sep 17 00:00:00 2001 From: Chuks Agbakuru Date: Thu, 4 Sep 2025 08:22:27 +0100 Subject: [PATCH 3/4] Add end-to-end test for HRN resolution Introduce a comprehensive test case to verify the full lifecycle of a payment initiated via a Human Readable Name (HRN). This test ensures that the integration between HRN parsing, BIP 353 resolution, and BOLT12 offer execution is functioning correctly within the node. By asserting that an encoded URI can be successfully resolved to a valid offer and subsequently paid, we validate the reliability of the resolution pipeline and ensure that recent architectural changes to the OnionMessenger and Node configuration work in unison. --- Cargo.toml | 1 + src/payment/unified.rs | 102 +++++++++++++++++++++++++++------ tests/common/mod.rs | 22 ++++++- tests/integration_tests_hrn.rs | 83 +++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 tests/integration_tests_hrn.rs diff --git a/Cargo.toml b/Cargo.toml index 142aabf8e..e8364c909 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,6 +128,7 @@ check-cfg = [ "cfg(cln_test)", "cfg(lnd_test)", "cfg(cycle_tests)", + "cfg(hrn_tests)", ] [[bench]] diff --git a/src/payment/unified.rs b/src/payment/unified.rs index 8681dbf6e..9352ee974 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -25,8 +25,7 @@ use bitcoin::{Amount, Txid}; use bitcoin_payment_instructions::amount::Amount as BPIAmount; use bitcoin_payment_instructions::{PaymentInstructions, PaymentMethod}; use lightning::ln::channelmanager::PaymentId; -use lightning::offers::offer::Offer; -use lightning::onion_message::dns_resolution::HumanReadableName; +use lightning::offers::offer::Offer as LdkOffer; use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; @@ -40,6 +39,16 @@ use crate::Config; type Uri<'a> = bip21::Uri<'a, NetworkChecked, Extras>; +#[cfg(not(feature = "uniffi"))] +type HumanReadableName = lightning::onion_message::dns_resolution::HumanReadableName; +#[cfg(feature = "uniffi")] +type HumanReadableName = crate::ffi::HumanReadableName; + +#[cfg(not(feature = "uniffi"))] +type Offer = LdkOffer; +#[cfg(feature = "uniffi")] +type Offer = Arc; + #[derive(Debug, Clone)] struct Extras { bolt11_invoice: Option, @@ -66,6 +75,8 @@ pub struct UnifiedPayment { config: Arc, logger: Arc, hrn_resolver: Arc, + #[cfg(hrn_tests)] + test_offer: std::sync::Mutex>, } impl UnifiedPayment { @@ -74,7 +85,16 @@ impl UnifiedPayment { bolt12_payment: Arc, config: Arc, logger: Arc, hrn_resolver: Arc, ) -> Self { - Self { onchain_payment, bolt11_invoice, bolt12_payment, config, logger, hrn_resolver } + Self { + onchain_payment, + bolt11_invoice, + bolt12_payment, + config, + logger, + hrn_resolver, + #[cfg(hrn_tests)] + test_offer: std::sync::Mutex::new(None), + } } } @@ -115,7 +135,7 @@ impl UnifiedPayment { let bolt12_offer = match self.bolt12_payment.receive_inner(amount_msats, description, None, None) { - Ok(offer) => Some(offer), + Ok(offer) => Some(maybe_wrap(offer)), Err(e) => { log_error!(self.logger, "Failed to create offer: {}", e); None @@ -165,12 +185,19 @@ impl UnifiedPayment { &self, uri_str: &str, amount_msat: Option, route_parameters: Option, ) -> Result { - let parse_fut = PaymentInstructions::parse( - uri_str, - self.config.network, - self.hrn_resolver.as_ref(), - false, - ); + let target_network; + + #[cfg(hrn_tests)] + { + target_network = bitcoin::Network::Bitcoin; + } + #[cfg(not(hrn_tests))] + { + target_network = self.config.network; + } + + let parse_fut = + PaymentInstructions::parse(uri_str, target_network, self.hrn_resolver.as_ref(), false); let instructions = tokio::time::timeout(Duration::from_secs(HRN_RESOLUTION_TIMEOUT_SECS), parse_fut) @@ -233,8 +260,30 @@ impl UnifiedPayment { for method in sorted_payment_methods { match method { - PaymentMethod::LightningBolt12(offer) => { - let offer = maybe_wrap(offer.clone()); + PaymentMethod::LightningBolt12(_offer) => { + #[cfg(not(hrn_tests))] + let offer = maybe_wrap(_offer.clone()); + + #[cfg(hrn_tests)] + // We inject a test-only offer here because full DNSSEC validation is + // currently infeasible in regtest environments. This allows us to + // bypass the validation requirements that would otherwise fail + // without a functional global DNSSEC root in the test runner. + let offer = { + let test_offer_guard = self.test_offer.lock().map_err(|e| { + log_error!( + self.logger, + "Failed to lock test_offer due to poisoning: {:?}", + e + ); + Error::PaymentSendingFailed + })?; + + match &*test_offer_guard { + Some(o) => o.clone(), + None => maybe_wrap(_offer.clone()), + } + }; let payment_result = if let Ok(hrn) = HumanReadableName::from_encoded(uri_str) { let hrn = maybe_wrap(hrn.clone()); @@ -290,6 +339,24 @@ impl UnifiedPayment { } } +#[cfg(hrn_tests)] +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl UnifiedPayment { + /// Sets a test offer to be used in the `send` method when the `hrn_tests` config flag is enabled. + /// + /// This is necessary for Bolt12 payments in HRN tests because we typically resolve offers + /// via [BIP 353] DNS addresses. Since full DNSSEC validation is infeasible in regtest + /// environments, the automated resolution of an offer from a URI will fail. Injected + /// offers allow us to bypass this resolution step and test the subsequent payment flow. + /// + /// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki + pub fn set_test_offer(&self, _offer: Offer) { + let _ = self.test_offer.lock().map(|mut guard| *guard = Some(_offer)).map_err(|e| { + log_error!(self.logger, "Failed to set test offer due to poisoned lock: {:?}", e) + }); + } +} + /// Represents the result of a payment made using a [BIP 21] URI or a [BIP 353] Human-Readable Name. /// /// After a successful on-chain transaction, the transaction ID ([`Txid`]) is returned. @@ -395,9 +462,10 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { "lno" => { let bolt12_value = String::try_from(value).map_err(|_| Error::UriParameterParsingFailed)?; - let offer = - bolt12_value.parse::().map_err(|_| Error::UriParameterParsingFailed)?; - self.bolt12_offer = Some(offer); + let offer = bolt12_value + .parse::() + .map_err(|_| Error::UriParameterParsingFailed)?; + self.bolt12_offer = Some(maybe_wrap(offer)); Ok(bip21::de::ParamKind::Known) }, _ => Ok(bip21::de::ParamKind::Unknown), @@ -420,7 +488,7 @@ mod tests { use bitcoin::address::NetworkUnchecked; use bitcoin::{Address, Network}; - use super::{Amount, Bolt11Invoice, Extras, Offer}; + use super::{maybe_wrap, Amount, Bolt11Invoice, Extras, LdkOffer}; #[test] fn parse_uri() { @@ -474,7 +542,7 @@ mod tests { } if let Some(offer) = parsed_uri_with_offer.extras.bolt12_offer { - assert_eq!(offer, Offer::from_str(expected_bolt12_offer_2).unwrap()); + assert_eq!(offer, maybe_wrap(LdkOffer::from_str(expected_bolt12_offer_2).unwrap())); } else { panic!("No offer found."); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index be9e16189..850c6f22c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -14,6 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::future::Future; use std::path::PathBuf; +use std::str::FromStr; use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -27,7 +28,10 @@ use bitcoin::{ use electrsd::corepc_node::{Client as BitcoindClient, Node as BitcoinD}; use electrsd::{corepc_node, ElectrsD}; use electrum_client::ElectrumApi; -use ldk_node::config::{AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyncConfig}; +use ldk_node::config::{ + AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, + HumanReadableNamesConfig, +}; use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; @@ -400,11 +404,27 @@ pub(crate) fn setup_two_nodes_with_store( println!("== Node A =="); let mut config_a = random_config(anchor_channels); config_a.store_type = store_type; + + if cfg!(hrn_tests) { + config_a.node_config.hrn_config = + HumanReadableNamesConfig { resolution_config: HRNResolverConfig::Blip32 }; + } + let node_a = setup_node(chain_source, config_a); println!("\n== Node B =="); let mut config_b = random_config(anchor_channels); config_b.store_type = store_type; + + if cfg!(hrn_tests) { + config_b.node_config.hrn_config = HumanReadableNamesConfig { + resolution_config: HRNResolverConfig::Dns { + dns_server_address: SocketAddress::from_str("8.8.8.8:53").unwrap(), + enable_hrn_resolution_service: true, + }, + }; + } + if allow_0conf { config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); } diff --git a/tests/integration_tests_hrn.rs b/tests/integration_tests_hrn.rs new file mode 100644 index 000000000..910240039 --- /dev/null +++ b/tests/integration_tests_hrn.rs @@ -0,0 +1,83 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +#![cfg(hrn_tests)] + +mod common; + +use bitcoin::Amount; +use common::{ + expect_channel_ready_event, expect_payment_successful_event, generate_blocks_and_wait, + open_channel, premine_and_distribute_funds, random_chain_source, setup_bitcoind_and_electrsd, + setup_two_nodes, TestChainSource, +}; +use ldk_node::payment::UnifiedPaymentResult; +use ldk_node::Event; +use lightning::ln::channelmanager::PaymentId; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn unified_send_to_hrn() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premined_sats = 5_000_000; + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(premined_sats), + ) + .await; + + node_a.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Wait until node_b broadcasts a node announcement + while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Sleep to make sure the node announcement propagates + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let test_offer = node_b.bolt12_payment().receive(1000000, "test offer", None, None).unwrap(); + + let hrn_str = "matt@mattcorallo.com"; + + let unified_handler = node_a.unified_payment(); + unified_handler.set_test_offer(test_offer); + + let offer_payment_id: PaymentId = + match unified_handler.send(&hrn_str, Some(1000000), None).await { + Ok(UnifiedPaymentResult::Bolt12 { payment_id }) => { + println!("\nBolt12 payment sent successfully with PaymentID: {:?}", payment_id); + payment_id + }, + Ok(UnifiedPaymentResult::Bolt11 { payment_id: _ }) => { + panic!("Expected Bolt12 payment but got Bolt11"); + }, + Ok(UnifiedPaymentResult::Onchain { txid: _ }) => { + panic!("Expected Bolt12 payment but got On-chain transaction"); + }, + Err(e) => { + panic!("Expected Bolt12 payment but got error: {:?}", e); + }, + }; + + expect_payment_successful_event!(node_a, Some(offer_payment_id), None); +} From 07654fa2381bb674188a5f7604bdf33af4c62e1a Mon Sep 17 00:00:00 2001 From: Chuks Agbakuru Date: Fri, 9 Jan 2026 18:54:19 +0100 Subject: [PATCH 4/4] Update CI workflow to include hrn_tests coverage Update the GitHub Actions workflow to include coverage for the new hrn_tests feature across multiple build configurations. This ensures that the DNSSEC override logic is validated in both standard Rust and UniFFI-enabled environments. Including these flags in CI prevents regressions where testing-specific code might break the primary build or fail to compile due to type mismatches between the LDK and FFI wrappers. Testing both feature combinations (with and without UniFFI) guarantees that the abstraction for HumanReadableName remains consistent across all supported platforms and integration layers. --- .github/workflows/hrn-integration.yml | 45 +++++++++++++++++++++++++++ .github/workflows/rust.yml | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/hrn-integration.yml diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml new file mode 100644 index 000000000..f7ded7bc5 --- /dev/null +++ b/.github/workflows/hrn-integration.yml @@ -0,0 +1,45 @@ +name: CI Checks - HRN Integration Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v3 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + - name: Enable caching for bitcoind + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v4 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind/electrs + if: "steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/download_bitcoind_electrs.sh + mkdir -p bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set bitcoind/electrs environment variables + run: | + echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + - name: Run HRN Integration Tests + run: | + RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn + RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn --features uniffi \ No newline at end of file diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 00bf196d1..b2575aca1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -114,4 +114,4 @@ jobs: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/install@cargo-docs-rs - - run: cargo docs-rs + - run: cargo docs-rs \ No newline at end of file