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;