From b4cbe4e77087cf55fef3a4264c178140d476f6c9 Mon Sep 17 00:00:00 2001 From: f3r10 Date: Fri, 19 Sep 2025 11:34:53 -0500 Subject: [PATCH 1/3] offers: better peer selection for offers The current approach uses random peers. These random peers may be temporary connections, so that if someone tries to pay and offer, the selected peers may not be connected anymore, and the payment will fail. Instead of choosing random peers, this commit will select public open channels, creating long-lived offers. It tries to choose three different channels at most. In order for these channels to be valid, the node has to have onion support, and the node has to be connect to more public channels. This new logic is only for creating offers. The current logic of choosing random peers is better for outgoing payments and remains as such. --- src/lnd.rs | 5 +- src/message_router.rs | 1 + src/offers/client_impls.rs | 19 +- src/offers/lnd_requests.rs | 368 +++++++++++++++++++++++++++++++++++-- src/offers/mod.rs | 7 +- src/onion_messenger.rs | 1 + tests/integration_tests.rs | 10 +- 7 files changed, 387 insertions(+), 24 deletions(-) diff --git a/src/lnd.rs b/src/lnd.rs index 93e40c8..66c84f8 100644 --- a/src/lnd.rs +++ b/src/lnd.rs @@ -28,8 +28,8 @@ use tonic::{Code, Status}; use tonic_lnd::lnrpc::AddInvoiceResponse; use tonic_lnd::lnrpc::PayReq; use tonic_lnd::lnrpc::{ - FeeLimit, GetInfoResponse, HtlcAttempt, ListPeersResponse, NodeInfo, Payment, - QueryRoutesResponse, Route, + FeeLimit, GetInfoResponse, HtlcAttempt, ListChannelsResponse, ListPeersResponse, NodeInfo, + Payment, QueryRoutesResponse, Route, }; use tonic_lnd::signrpc::KeyLocator; use tonic_lnd::tonic::Status as LndStatus; @@ -562,6 +562,7 @@ pub trait PeerConnector { pub_key: String, include_channels: bool, ) -> Result; + async fn list_active_public_channels(&mut self) -> Result; } /// InvoicePayer provides a layer of abstraction over the LND API for paying for a BOLT 12 invoice. diff --git a/src/message_router.rs b/src/message_router.rs index d78ee47..5d4aae6 100644 --- a/src/message_router.rs +++ b/src/message_router.rs @@ -181,6 +181,7 @@ mod tests { async fn list_peers(&mut self) -> Result; async fn connect_peer(&mut self, node_id: String, addr: String) -> Result<(), tonic_lnd::tonic::Status>; async fn get_node_info(&mut self, pub_key: String, include_channels: bool) -> Result; + async fn list_active_public_channels(&mut self) -> Result; } } diff --git a/src/offers/client_impls.rs b/src/offers/client_impls.rs index 76a0ef0..b628f86 100644 --- a/src/offers/client_impls.rs +++ b/src/offers/client_impls.rs @@ -12,7 +12,9 @@ use tonic_lnd::routerrpc::TrackPaymentRequest; use tonic_lnd::tonic::Status; use tonic_lnd::LightningClient; use tonic_lnd::{ - lnrpc::{ListPeersRequest, ListPeersResponse, NodeInfo}, + lnrpc::{ + ListChannelsRequest, ListChannelsResponse, ListPeersRequest, ListPeersResponse, NodeInfo, + }, signrpc::{KeyLocator, SignMessageReq}, Client, }; @@ -58,6 +60,19 @@ impl PeerConnector for LightningClient { self.get_node_info(req).await.map(|resp| resp.into_inner()) } + + async fn list_active_public_channels(&mut self) -> Result { + let list_req = ListChannelsRequest { + active_only: true, + inactive_only: false, + public_only: true, + private_only: false, + ..Default::default() + }; + self.list_channels(list_req) + .await + .map(|resp| resp.into_inner()) + } } #[async_trait] @@ -264,6 +279,7 @@ pub(super) mod tests { async fn list_peers(&mut self) -> Result; async fn get_node_info(&mut self, pub_key: String, include_channels: bool) -> Result; async fn connect_peer(&mut self, node_id: String, addr: String) -> Result<(), Status>; + async fn list_active_public_channels(&mut self) -> Result; } } @@ -291,6 +307,7 @@ pub(super) mod tests { async fn list_peers(&mut self) -> Result; async fn get_node_info(&mut self, pub_key: String, include_channels: bool) -> Result; async fn connect_peer(&mut self, node_id: String, addr: String) -> Result<(), Status>; + async fn list_active_public_channels(&mut self) -> Result; } } diff --git a/src/offers/lnd_requests.rs b/src/offers/lnd_requests.rs index 3f0f87a..5d02bd8 100644 --- a/src/offers/lnd_requests.rs +++ b/src/offers/lnd_requests.rs @@ -42,6 +42,7 @@ use crate::{ offers::handler::{CreateOfferParams, SendPaymentParams}, onion_messenger::MessengerUtilities, }; +use std::collections::HashSet; use super::{validate_amount, OfferError}; @@ -190,13 +191,33 @@ pub(super) async fn create_offer( let secp_ctx = Secp256k1::new(); let message_context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce }); - let path = create_reply_path(creator, node_id, message_context, entropy_source).await?; + let paths = + create_reply_path_for_offer_creation(creator, node_id, message_context, entropy_source) + .await?; let mut builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, &secp_ctx) .amount_msats(args.amount_msats) - .chain(args.chain) - .path(path); + .chain(args.chain); + + builder = if let Some(path) = paths.first() { + builder.path(path.clone()) + } else { + builder + }; + + builder = if let Some(path) = paths.get(1) { + builder.path(path.clone()) + } else { + builder + }; + + builder = if let Some(path) = paths.get(2) { + builder.path(path.clone()) + } else { + builder + }; + builder = match args.description { Some(description) => builder.description(description), None => builder, @@ -219,6 +240,7 @@ pub(super) async fn create_offer( } None => builder, }; + let offer = builder.build().map_err(OfferError::CreateOfferFailure)?; Ok(offer) } @@ -253,15 +275,99 @@ pub(super) async fn create_invoice_info_from_request( payment_paths, }) } -/// create_reply_path creates a blinded path to provide to the offer node when requesting an -/// invoice so they know where to send the invoice back to. We try to find a peer that we're -/// connected to with the necessary requirements to form a blinded path. The peer needs two + +/// create_reply_path_for_offer_creation creates blinded paths to provide to the offer. +/// We try to find at least one active public channel and at most three active public channels that +/// can act as blinded paths. +/// +/// Otherwise we create a blinded path directly to ourselves. +pub async fn create_reply_path_for_offer_creation( + mut connector: impl PeerConnector + std::marker::Send + 'static, + node_id: PublicKey, + message_context: MessageContext, + messenger_utils: &MessengerUtilities, +) -> Result, OfferError> { + // Find introduction channels for our blinded paths. + let current_channels = connector.list_active_public_channels().await.map_err(|e| { + error!("Could not lookup current channels: {e}."); + OfferError::ListChannelsFailure(e) + })?; + + let mut intro_channels = HashSet::new(); + for channel in current_channels.channels.iter() { + let pubkey = channel.remote_pubkey.clone(); + if intro_channels.len() > 3 { + break; + } + match connector.get_node_info(pubkey, true).await { + Ok(node_info) => match node_info.node { + Some(node) => { + if node_info.channels.is_empty() { + continue; + } + let onion_support = features_support_onion_messages(&node.features); + let other_channels = node_info + .channels + .into_iter() + .filter(|peer_channel| peer_channel.channel_id != channel.chan_id) + .collect::>(); + if !other_channels.is_empty() && onion_support { + let pubkey = PublicKey::from_str(&channel.remote_pubkey).unwrap(); + intro_channels.insert(pubkey); + } + } + None => continue, + }, + Err(_) => continue, + } + } + + let secp_ctx = Secp256k1::new(); + if intro_channels.is_empty() { + debug!( + "Failed to create a blinded path for the offer. + No active public channels were found on which to build a path." + ); + let path = + BlindedMessagePath::one_hop(node_id, message_context, messenger_utils, &secp_ctx) + .map_err(|_| { + error!("Could not create blinded path."); + OfferError::BuildBlindedPathFailure + })?; + Ok(vec![path]) + } else { + let mut paths = vec![]; + for node in intro_channels { + let nodes = vec![lightning::blinded_path::message::MessageForwardNode { + node_id: node, + short_channel_id: None, + }]; + let path = BlindedMessagePath::new( + &nodes, + node_id, + message_context.clone(), + messenger_utils, + &secp_ctx, + ) + .map_err(|_| { + error!("Could not create blinded path."); + OfferError::BuildBlindedPathFailure + })?; + paths.push(path) + } + Ok(paths) + } +} + +/// create_reply_path_for_outgoing_payments creates a blinded path to provide to the offer node when +/// requesting an invoice so they know where to send the invoice back to. We try to find a peer that +/// we're connected to with the necessary requirements to form a blinded path. The peer needs two /// things: /// 1) Onion messaging support. /// 2) To be an advertised node with at least one public channel. /// /// Otherwise we create a blinded path directly to ourselves. -pub async fn create_reply_path( +pub async fn create_reply_path_for_outgoing_payments( mut connector: impl PeerConnector + std::marker::Send + 'static, node_id: PublicKey, message_context: MessageContext, @@ -349,7 +455,7 @@ pub async fn send_invoice_request( let pubkey = PublicKey::from_str(&info.identity_pubkey).unwrap(); let message_context = MessageContext::Offers(offer_context); - let reply_path = create_reply_path( + let reply_path = create_reply_path_for_outgoing_payments( client.lightning().clone(), pubkey, message_context, @@ -526,7 +632,8 @@ mod tests { use tonic_lnd::{ lnrpc::{ AddInvoiceResponse, ChannelEdge, GetInfoResponse, HtlcAttempt, LightningNode, - ListPeersResponse, NodeAddress, NodeInfo, PayReq, QueryRoutesResponse, Route, + ListChannelsResponse, ListPeersResponse, NodeAddress, NodeInfo, PayReq, + QueryRoutesResponse, Route, }, tonic::Status, }; @@ -687,7 +794,24 @@ mod tests { let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); let message_context = get_message_context(); - let response = create_reply_path( + let response = create_reply_path_for_outgoing_payments( + connector_mock, + receiver_node_id, + message_context, + &MessengerUtilities::new([42; 32]), + ) + .await; + assert!(response.is_ok()); + + let mut connector_mock = MockTestPeerConnector::new(); + + connector_mock + .expect_list_active_public_channels() + .returning(|| Ok(ListChannelsResponse { channels: vec![] })); + + let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); + let message_context = get_message_context(); + let response = create_reply_path_for_offer_creation( connector_mock, receiver_node_id, message_context, @@ -698,7 +822,7 @@ mod tests { } #[tokio::test] - async fn test_create_reply_path() { + async fn test_reply_path_for_outgoing_payments() { let mut connector_mock = MockTestPeerConnector::new(); connector_mock.expect_list_peers().returning(|| { @@ -729,7 +853,54 @@ mod tests { let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); let message_context = get_message_context(); - let response = create_reply_path( + let response = create_reply_path_for_outgoing_payments( + connector_mock, + receiver_node_id, + message_context, + &MessengerUtilities::new([42; 32]), + ) + .await; + assert!(response.is_ok()); + } + + #[tokio::test] + async fn test_create_reply_path_for_offer_creation() { + let mut connector_mock = MockTestPeerConnector::new(); + + connector_mock + .expect_list_active_public_channels() + .returning(|| { + let channel = tonic_lnd::lnrpc::Channel { + remote_pubkey: get_pubkeys()[0].clone(), + ..Default::default() + }; + Ok(ListChannelsResponse { + channels: vec![channel], + }) + }); + + connector_mock.expect_get_node_info().returning(|_, _| { + let node_addr = NodeAddress { + network: String::from("regtest"), + addr: String::from("127.0.0.1"), + }; + let node = Some(LightningNode { + addresses: vec![node_addr], + ..Default::default() + }); + + let node_info = NodeInfo { + node, + ..Default::default() + }; + + Ok(node_info) + }); + + let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); + + let message_context = get_message_context(); + let response = create_reply_path_for_offer_creation( connector_mock, receiver_node_id, message_context, @@ -749,7 +920,24 @@ mod tests { let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); let message_context = get_message_context(); - let response = create_reply_path( + let response = create_reply_path_for_outgoing_payments( + connector_mock, + receiver_node_id, + message_context, + &MessengerUtilities::new([42; 32]), + ) + .await; + assert!(response.is_err()); + + let mut connector_mock = MockTestPeerConnector::new(); + + connector_mock + .expect_list_active_public_channels() + .returning(|| Err(Status::unknown("unknown error"))); + + let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); + let message_context = get_message_context(); + let response = create_reply_path_for_offer_creation( connector_mock, receiver_node_id, message_context, @@ -760,7 +948,7 @@ mod tests { } #[tokio::test] - async fn test_create_reply_path_not_advertised() { + async fn test_create_reply_path_for_outgoing_payments_not_advertised() { // First lets test that if we're only connected to one peer. It has onion support, but the // node isn't advertised, meaning it has no public channels. This should return // a blinded path with only one hop. @@ -786,7 +974,7 @@ mod tests { let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); let message_context = get_message_context(); - let response = create_reply_path( + let response = create_reply_path_for_outgoing_payments( connector_mock, receiver_node_id, message_context, @@ -851,7 +1039,7 @@ mod tests { let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); let message_context = get_message_context(); - let response = create_reply_path( + let response = create_reply_path_for_outgoing_payments( connector_mock, receiver_node_id, message_context, @@ -864,6 +1052,124 @@ mod tests { assert!(hops.len() == 2); } + #[tokio::test] + async fn test_create_reply_path_for_offer_creation_not_advertised() { + // First lets test that the one that we are trying to connect to, does not have public + // channels This should return a blinded path with only one hop. + let mut connector_mock = MockTestPeerConnector::new(); + + connector_mock + .expect_list_active_public_channels() + .returning(|| Ok(ListChannelsResponse { channels: vec![] })); + + connector_mock.expect_get_node_info().returning(|_, _| { + let node_addr = NodeAddress { + network: String::from("regtest"), + addr: String::from("127.0.0.1"), + }; + let node = Some(LightningNode { + addresses: vec![node_addr], + ..Default::default() + }); + + let node_info = NodeInfo { + node, + ..Default::default() + }; + + Ok(node_info) + }); + + let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); + let message_context = get_message_context(); + let response = create_reply_path_for_offer_creation( + connector_mock, + receiver_node_id, + message_context, + &MessengerUtilities::new([42; 32]), + ) + .await; + assert!(response.is_ok()); + let reply_path = response.unwrap(); + let hops = reply_path.first().unwrap().blinded_hops(); + assert!(hops.len() == 1); + + // Now let's test that we are connected to two channels. + // One channel isn't advertised (i.e. it has no public channels). But the second is. This + // should succeed. + let mut connector_mock = MockTestPeerConnector::new(); + + // Only second channel is advertised. + connector_mock + .expect_list_active_public_channels() + .returning(|| { + let keys = get_pubkeys(); + + let channel2 = tonic_lnd::lnrpc::Channel { + remote_pubkey: keys[1].clone(), + chan_id: 10, + ..Default::default() + }; + + Ok(ListChannelsResponse { + channels: vec![channel2], + }) + }); + + connector_mock.expect_get_node_info().returning(|_, _| { + let node_addr = NodeAddress { + network: String::from("regtest"), + addr: String::from("127.0.0.1"), + }; + let mut features = std::collections::HashMap::new(); + // Add onion message feature (feature bit 38/39) + features.insert( + 38, + tonic_lnd::lnrpc::Feature { + name: "onion_messages_optional".to_string(), + is_required: false, + is_known: true, + }, + ); + let node = Some(LightningNode { + addresses: vec![node_addr], + features, + ..Default::default() + }); + + let node_info = NodeInfo { + node, + channels: vec![ + ChannelEdge { + channel_id: 10, + ..Default::default() + }, + ChannelEdge { + channel_id: 20, + ..Default::default() + }, + ], + ..Default::default() + }; + + Ok(node_info) + }); + + let receiver_node_id = PublicKey::from_str(&get_pubkeys()[0]).unwrap(); + let message_context = get_message_context(); + let response = create_reply_path_for_offer_creation( + connector_mock, + receiver_node_id, + message_context, + &MessengerUtilities::new([42; 32]), + ) + .await; + assert!(response.is_ok()); + let reply_path = response.unwrap(); + let hops = reply_path.first().unwrap().blinded_hops(); + assert!(hops.len() == 2); + } + fn get_blinded_payment_path() -> BlindedPaymentPath { let entropy_source = MessengerUtilities::new([42; 32]); let secp_ctx = Secp256k1::new(); @@ -1080,6 +1386,36 @@ mod tests { Ok(ListPeersResponse { peers: vec![peer] }) }); + creator_mock + .expect_list_active_public_channels() + .returning(|| { + let channel = tonic_lnd::lnrpc::Channel { + remote_pubkey: get_pubkeys()[0].clone(), + ..Default::default() + }; + Ok(ListChannelsResponse { + channels: vec![channel], + }) + }); + + creator_mock.expect_get_node_info().returning(|_, _| { + let node_addr = NodeAddress { + network: String::from("regtest"), + addr: String::from("127.0.0.1"), + }; + let node = Some(LightningNode { + addresses: vec![node_addr], + ..Default::default() + }); + + let node_info = NodeInfo { + node, + ..Default::default() + }; + + Ok(node_info) + }); + creator_mock } diff --git a/src/offers/mod.rs b/src/offers/mod.rs index d59068d..07c8dae 100644 --- a/src/offers/mod.rs +++ b/src/offers/mod.rs @@ -15,7 +15,8 @@ mod lnd_requests; mod parse; pub(crate) use lnd_requests::connect_to_peer_with_retry; -pub use lnd_requests::create_reply_path; +pub use lnd_requests::create_reply_path_for_offer_creation; +pub use lnd_requests::create_reply_path_for_outgoing_payments; pub use parse::{decode, get_destination, validate_amount}; #[derive(Debug)] @@ -69,6 +70,8 @@ pub enum OfferError { ParseInvoiceFailure(DecodeError), /// Failed to encode invoice. EncodeInvoiceFailure(BitcoinIoError), + /// Cannot list channels. + ListChannelsFailure(LndStatus), } impl OfferError { @@ -98,6 +101,7 @@ impl OfferError { OfferError::InvoiceTimeout(_) => "INVOICE_TIMEOUT", OfferError::IntroductionNodeNotFound => "INTRODUCTION_NODE_NOT_FOUND", OfferError::GetChannelInfo(_) => "GET_CHANNEL_INFO", + OfferError::ListChannelsFailure(_) => "LIST_CHANNELS_FAILURE", } } @@ -173,6 +177,7 @@ impl Display for OfferError { OfferError::EncodeInvoiceFailure(e) => { write!(f, "Failed to encode invoice to hex format. Error: {e:?}") } + OfferError::ListChannelsFailure(e) => write!(f, "Error listing channels: {e:?}"), } } } diff --git a/src/onion_messenger.rs b/src/onion_messenger.rs index 4158706..b25dd62 100644 --- a/src/onion_messenger.rs +++ b/src/onion_messenger.rs @@ -1135,6 +1135,7 @@ mod tests { async fn list_peers(&mut self) -> Result; async fn get_node_info(&mut self, pub_key: String, include_channels: bool) -> Result; async fn connect_peer(&mut self, node_id: String, addr: String) -> Result<(), Status>; + async fn list_active_public_channels(&mut self) -> Result; } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index a6c8ece..8d6331c 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -20,7 +20,7 @@ use lightning::offers::offer::Quantity; use lightning::onion_message::messenger::Destination; use lndk::lnd::validate_lnd_creds; use lndk::offers::handler::{CreateOfferParams, OfferHandler, PayOfferParams}; -use lndk::offers::{create_reply_path, OfferError}; +use lndk::offers::{create_reply_path_for_outgoing_payments, OfferError}; use lndk::onion_messenger::MessengerUtilities; use lndk::{setup_logger, LifecycleSignals}; use std::net::SocketAddr; @@ -498,7 +498,7 @@ async fn test_reply_path_unannounced_peers() { // private channel and as such, is an unadvertised node. Because of that, create_reply_path // should not use ldk2 as an introduction node and should return a reply path directly to // itself. - let reply_path = create_reply_path( + let reply_path = create_reply_path_for_outgoing_payments( lnd.client.clone().unwrap().lightning().clone(), lnd_pubkey, offer_context, @@ -541,7 +541,7 @@ async fn test_reply_path_announced_peers() { // public channel and as such, is indeed an advertised node. Because of this, we make sure // create_reply_path produces a path of length two with ldk2 as the introduction node, as we // expected. - let reply_path = create_reply_path( + let reply_path = create_reply_path_for_outgoing_payments( lnd.client.clone().unwrap().lightning().clone(), lnd_pubkey, offer_context, @@ -1116,16 +1116,18 @@ async fn test_receive_payment_from_offer() { expiry: None, }; + lnd.wait_for_addresses_to_sync(ldk1_pubkey).await; + let offer = handler.create_offer(create_offer_params).await; assert!(offer.is_ok()); let offer = offer.unwrap(); - lnd.wait_for_addresses_to_sync(ldk1_pubkey).await; select! { val = messenger.run(lndk_cfg, Arc::clone(&handler)) => { panic!("lndk should not have completed first {:?}", val); }, res = pay_offer_and_wait_for_payment(&ldk1, offer, lnd.client.clone().unwrap()) => { + log::info!("res: {:?}", res); assert!(res.is_ok()); shutdown.trigger(); ldk1.stop().await; From ad575148dfcf2f668cb6703eb2fcdf9eeeecfe95 Mon Sep 17 00:00:00 2001 From: f3r10 Date: Mon, 22 Sep 2025 13:00:04 -0500 Subject: [PATCH 2/3] tests: Add integration test for check pay offer This commit adds an integration test for checking the pay offer feature using 3 available blinded paths, which nodes support onion messages and are connected to more channels. --- tests/integration_tests.rs | 211 +++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8d6331c..295e684 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1122,6 +1122,217 @@ async fn test_receive_payment_from_offer() { assert!(offer.is_ok()); let offer = offer.unwrap(); + // we check that according the current topology, it is only one blinded path available + assert!(offer.paths().len() == 1); + + select! { + val = messenger.run(lndk_cfg, Arc::clone(&handler)) => { + panic!("lndk should not have completed first {:?}", val); + }, + res = pay_offer_and_wait_for_payment(&ldk1, offer, lnd.client.clone().unwrap()) => { + log::info!("res: {:?}", res); + assert!(res.is_ok()); + shutdown.trigger(); + ldk1.stop().await; + ldk2.stop().await; + } + }; +} + +#[tokio::test(flavor = "multi_thread")] +// Test that we can receive a payment from an offer which constains multiple paths. +async fn test_receive_payment_from_offer_with_multiple_blinded_paths() { + let test_name = "test_receive_payment_from_offer_with_multiple_blinded_paths"; + let (bitcoind, mut lnd, ldk1, ldk2, lndk_dir, ldk_test_dir) = + common::setup_test_infrastructure(test_name).await; + + let (ldk1_pubkey, ldk2_pubkey, _lnd_pubkey) = + common::connect_network(&ldk1, &ldk2, true, true, &mut lnd, &bitcoind).await; + + let log_file = Some(lndk_dir.join(format!("lndk-logs.txt"))); + setup_logger(None, log_file).unwrap(); + + // Network topology with 4 LDK nodes and 1 LND node: + // + // LDK2 ────────────────────LND + // / | \ /|\ + // / | \ / | \ + // LDK1 | LDK3 ─────────────── | \ + // | / | \ + // | / | \ + // LDK4 ──────────────────────────── + + // Second blinded path. + let ldk3 = common::setup_ldk_node(&bitcoind, 3, &ldk_test_dir, test_name).await; + + // Third blinded path. + let ldk4 = common::setup_ldk_node(&bitcoind, 4, &ldk_test_dir, test_name).await; + let (ldk3_pubkey, addr_3) = ldk3.get_node_info(); + let (ldk4_pubkey, addr_4) = ldk4.get_node_info(); + let (_, addr_2) = ldk2.get_node_info(); + let lnd_info = lnd.get_info().await; + let lnd_pubkey = PublicKey::from_str(&lnd_info.identity_pubkey).unwrap(); + + lnd.connect_to_peer(ldk3_pubkey, addr_3).await; + lnd.connect_to_peer(ldk4_pubkey, addr_4).await; + + ldk2.connect_to_peer(ldk4_pubkey, addr_4).await.unwrap(); + ldk2.connect_to_peer(ldk3_pubkey, addr_3).await.unwrap(); + + let ldk3_fund_addr = ldk3.bitcoind_client.get_new_address().await; + let ldk3_addr_string = ldk3_fund_addr.to_string(); + let ldk3_addr = bitcoincore_rpc::bitcoin::Address::from_str(&ldk3_addr_string) + .unwrap() + .require_network(bitcoincore_rpc::bitcoin::Network::Regtest) + .unwrap(); + + let ldk4_fund_addr = ldk4.bitcoind_client.get_new_address().await; + let ldk4_addr_string = ldk4_fund_addr.to_string(); + let ldk4_addr = bitcoincore_rpc::bitcoin::Address::from_str(&ldk4_addr_string) + .unwrap() + .require_network(bitcoincore_rpc::bitcoin::Network::Regtest) + .unwrap(); + + bitcoind + .node + .client + .generate_to_address(6, &ldk3_addr) + .unwrap(); + + bitcoind + .node + .client + .generate_to_address(6, &ldk4_addr) + .unwrap(); + lnd.wait_for_chain_sync().await; + + ldk3.open_channel(ldk2_pubkey, addr_2, 200000, 10000000, true) + .await + .unwrap(); + + ldk4.open_channel(ldk2_pubkey, addr_2, 200000, 10000000, true) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(6, &ldk3_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + let lnd_addr = lnd + .address + .replace("localhost", "127.0.0.1") + .replace("https://", ""); + + ldk3.open_channel( + lnd_pubkey, + SocketAddr::from_str(&lnd_addr).unwrap(), + 200000, + 10000000, + true, + ) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(6, &ldk3_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + ldk4.open_channel( + lnd_pubkey, + SocketAddr::from_str(&lnd_addr).unwrap(), + 200000, + 10000000, + true, + ) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(6, &ldk3_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + ldk3.open_channel(ldk4_pubkey, addr_4, 200000, 10000000, true) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(20, &ldk3_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + lnd.wait_for_nodes_addresses(&[&ldk1, &ldk2, &ldk3, &ldk4]) + .await; + + let (shutdown, listener) = triggered::trigger(); + let creds = validate_lnd_creds( + Some(PathBuf::from_str(&lnd.cert_path).unwrap()), + None, + Some(PathBuf::from_str(&lnd.macaroon_path).unwrap()), + None, + ) + .unwrap(); + let lnd_cfg = lndk::lnd::LndCfg::new(lnd.address.clone(), creds); + + let signals = LifecycleSignals { + shutdown: shutdown.clone(), + listener, + }; + + let lndk_cfg = lndk::Cfg { + lnd: lnd_cfg, + signals, + skip_version_check: false, + rate_limit_count: 10, + rate_limit_period_secs: 1, + }; + let handler = Arc::new(OfferHandler::new( + None, + None, + Some(lnd.client.clone().unwrap()), + )); + let messenger = lndk::LndkOnionMessenger::new(); + + let create_offer_params = CreateOfferParams { + client: lnd.client.clone().unwrap(), + amount_msats: 20_000, + chain: Network::Regtest, + description: None, + issuer: None, + quantity: None, + expiry: None, + }; + + lnd.wait_for_addresses_to_sync(ldk1_pubkey).await; + + let offer = handler.create_offer(create_offer_params).await; + assert!(offer.is_ok()); + let offer = offer.unwrap(); + + // we check that according the current topology, there are 3 blinded paths available. + assert!(offer.paths().len() == 3); + select! { val = messenger.run(lndk_cfg, Arc::clone(&handler)) => { panic!("lndk should not have completed first {:?}", val); From 4556ea16e629ca7c053fe36ed4423f3855643fd8 Mon Sep 17 00:00:00 2001 From: f3r10 Date: Wed, 24 Sep 2025 10:20:24 -0500 Subject: [PATCH 3/3] itest: testing same node with multiple channels If a node has multiple channels open, only one them has to be used as a blinded path, ignoring the rest. --- tests/integration_tests.rs | 104 +++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 295e684..1fa8e3b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1056,10 +1056,21 @@ async fn pay_offer_and_wait_for_payment( offer: Offer, mut lnd_client: Client, ) -> Result<(), ()> { - let payment = ldk.pay_offer(offer, None).await; - assert!(payment.is_ok()); - // Wait for the payment to complete on ldk side. - common::wait_for_ldk_payment_completion(ldk, Duration::from_secs(30)).await?; + let mut retries = 0; + let max_retries = 3; + let delay = Duration::from_secs(2); + while retries < max_retries { + tokio::time::sleep(delay).await; + let payment = ldk.pay_offer(offer.clone(), None).await; + assert!(payment.is_ok()); + // Wait for the payment to complete on ldk side. + match common::wait_for_ldk_payment_completion(ldk, Duration::from_secs(10)).await { + Ok(_) => break, + _ => println!("Payment timedout, trying again"), + }; + retries += 1; + } + // Wait for the payment to complete on lnd side. common::wait_for_lnd_payment_completion(&mut lnd_client, Duration::from_secs(10)).await?; Ok(()) @@ -1074,6 +1085,88 @@ async fn test_receive_payment_from_offer() { let (ldk1_pubkey, _ldk2_pubkey, _lnd_pubkey) = common::connect_network(&ldk1, &ldk2, true, true, &mut lnd, &bitcoind).await; + // Network topology with 2 LDK nodes and 1 LND node and 3 open channels: + // + // LND + // /|\ + // / | \ + // LDK1────────LDK2 ───────────────── + + let ldk2_fund_addr = ldk2.bitcoind_client.get_new_address().await; + let ldk2_addr_string = ldk2_fund_addr.to_string(); + let ldk2_addr = bitcoincore_rpc::bitcoin::Address::from_str(&ldk2_addr_string) + .unwrap() + .require_network(bitcoincore_rpc::bitcoin::Network::Regtest) + .unwrap(); + bitcoind + .node + .client + .generate_to_address(6, &ldk2_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + let lnd_addr = lnd + .address + .replace("localhost", "127.0.0.1") + .replace("https://", ""); + + ldk2.open_channel( + _lnd_pubkey, + SocketAddr::from_str(&lnd_addr).unwrap(), + 200000, + 10000000, + true, + ) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(20, &ldk2_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + let ldk2_fund_addr = ldk2.bitcoind_client.get_new_address().await; + let ldk2_addr_string = ldk2_fund_addr.to_string(); + let ldk2_addr = bitcoincore_rpc::bitcoin::Address::from_str(&ldk2_addr_string) + .unwrap() + .require_network(bitcoincore_rpc::bitcoin::Network::Regtest) + .unwrap(); + bitcoind + .node + .client + .generate_to_address(6, &ldk2_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + ldk2.open_channel( + _lnd_pubkey, + SocketAddr::from_str(&lnd_addr).unwrap(), + 200000, + 10000000, + true, + ) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(20, &ldk2_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + lnd.wait_for_nodes_addresses(&[&ldk1, &ldk2]).await; + let log_file = Some(lndk_dir.join(format!("lndk-logs.txt"))); setup_logger(None, log_file).unwrap(); @@ -1122,7 +1215,8 @@ async fn test_receive_payment_from_offer() { assert!(offer.is_ok()); let offer = offer.unwrap(); - // we check that according the current topology, it is only one blinded path available + // We check that according to the current topology even though there are 3 open channels, it is + // only one blinded path available. assert!(offer.paths().len() == 1); select! {