From defc54096d366f2903bf99e5d23e3e34c580eeee Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 22 May 2024 14:43:05 -0500 Subject: [PATCH 01/16] Move DefaultMessageRouter::create_blinded_paths An upcoming change to the MessageRouter trait will require reusing DefaultMessageRouter::create_blinded_paths. Move the code to a utility function so facilitate this. --- lightning/src/onion_message/messenger.rs | 93 +++++++++++++----------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index eb4a837feb6..50049866325 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -449,50 +449,12 @@ where pub fn new(network_graph: G, entropy_source: ES) -> Self { Self { network_graph, entropy_source } } -} - -impl>, L: Deref, ES: Deref> MessageRouter for DefaultMessageRouter -where - L::Target: Logger, - ES::Target: EntropySource, -{ - fn find_path( - &self, sender: PublicKey, peers: Vec, mut destination: Destination - ) -> Result { - let network_graph = self.network_graph.deref().read_only(); - destination.resolve(&network_graph); - - let first_node = match destination.first_node() { - Some(first_node) => first_node, - None => return Err(()), - }; - - if peers.contains(&first_node) || sender == first_node { - Ok(OnionMessagePath { - intermediate_nodes: vec![], destination, first_node_addresses: None - }) - } else { - let node_details = network_graph - .node(&NodeId::from_pubkey(&first_node)) - .and_then(|node_info| node_info.announcement_info.as_ref()) - .map(|announcement_info| (announcement_info.features(), announcement_info.addresses())); - - match node_details { - Some((features, addresses)) if features.supports_onion_messages() && addresses.len() > 0 => { - let first_node_addresses = Some(addresses.clone()); - Ok(OnionMessagePath { - intermediate_nodes: vec![], destination, first_node_addresses - }) - }, - _ => Err(()), - } - } - } - fn create_blinded_paths< + fn create_blinded_paths_from_iter< + I: Iterator, T: secp256k1::Signing + secp256k1::Verification >( - &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + &self, recipient: PublicKey, peers: I, secp_ctx: &Secp256k1, ) -> Result, ()> { // Limit the number of blinded paths that are computed. const MAX_PATHS: usize = 3; @@ -505,7 +467,7 @@ where let is_recipient_announced = network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)); - let mut peer_info = peers.into_iter() + let mut peer_info = peers // Limit to peers with announced channels .filter_map(|peer| network_graph @@ -548,6 +510,53 @@ where } } +impl>, L: Deref, ES: Deref> MessageRouter for DefaultMessageRouter +where + L::Target: Logger, + ES::Target: EntropySource, +{ + fn find_path( + &self, sender: PublicKey, peers: Vec, mut destination: Destination + ) -> Result { + let network_graph = self.network_graph.deref().read_only(); + destination.resolve(&network_graph); + + let first_node = match destination.first_node() { + Some(first_node) => first_node, + None => return Err(()), + }; + + if peers.contains(&first_node) || sender == first_node { + Ok(OnionMessagePath { + intermediate_nodes: vec![], destination, first_node_addresses: None + }) + } else { + let node_details = network_graph + .node(&NodeId::from_pubkey(&first_node)) + .and_then(|node_info| node_info.announcement_info.as_ref()) + .map(|announcement_info| (announcement_info.features(), announcement_info.addresses())); + + match node_details { + Some((features, addresses)) if features.supports_onion_messages() && addresses.len() > 0 => { + let first_node_addresses = Some(addresses.clone()); + Ok(OnionMessagePath { + intermediate_nodes: vec![], destination, first_node_addresses + }) + }, + _ => Err(()), + } + } + } + + fn create_blinded_paths< + T: secp256k1::Signing + secp256k1::Verification + >( + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.create_blinded_paths_from_iter(recipient, peers.into_iter(), secp_ctx) + } +} + /// A path for sending an [`OnionMessage`]. #[derive(Clone)] pub struct OnionMessagePath { From 532617188577150cbd974401b10d2b4e0c2e0809 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 22 May 2024 15:32:32 -0500 Subject: [PATCH 02/16] Refactor MessageRouter::create_blinded_paths Using compact blinded paths isn't always necessary or desirable. For instance, reply paths are communicated via onion messages where space isn't a premium unlike in QR codes. Additionally, long-lived paths could become invalid if the channel associated with the SCID is closed. Refactor MessageRouter::create_blinded_paths into two methods: one for compact blinded paths and one for normal blinded paths. --- fuzz/src/chanmon_consistency.rs | 3 +- fuzz/src/full_stack.rs | 3 +- fuzz/src/onion_message.rs | 3 +- lightning/src/ln/channelmanager.rs | 12 ++--- lightning/src/onion_message/messenger.rs | 56 +++++++++++++++++++----- lightning/src/routing/router.rs | 10 ++++- lightning/src/util/test_utils.rs | 18 +++++++- 7 files changed, 80 insertions(+), 25 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 1bcba1fbf37..1cdf617fa07 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -34,7 +34,6 @@ use bitcoin::hashes::sha256d::Hash as Sha256dHash; use bitcoin::hash_types::BlockHash; use lightning::blinded_path::BlindedPath; -use lightning::blinded_path::message::ForwardNode; use lightning::blinded_path::payment::ReceiveTlvs; use lightning::chain; use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, chainmonitor, channelmonitor, Confirm, Watch}; @@ -124,7 +123,7 @@ impl MessageRouter for FuzzRouter { } fn create_blinded_paths( - &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1, + &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1, ) -> Result, ()> { unreachable!() } diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 2ae5ba2225d..bdd29be9129 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -31,7 +31,6 @@ use bitcoin::hashes::sha256d::Hash as Sha256dHash; use bitcoin::hash_types::{Txid, BlockHash}; use lightning::blinded_path::BlindedPath; -use lightning::blinded_path::message::ForwardNode; use lightning::blinded_path::payment::ReceiveTlvs; use lightning::chain; use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen}; @@ -162,7 +161,7 @@ impl MessageRouter for FuzzRouter { } fn create_blinded_paths( - &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1, + &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1, ) -> Result, ()> { unreachable!() } diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 4c1c5ac1122..371a9421fc7 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -7,7 +7,6 @@ use bitcoin::secp256k1::ecdsa::RecoverableSignature; use bitcoin::secp256k1::schnorr; use lightning::blinded_path::{BlindedPath, EmptyNodeIdLookUp}; -use lightning::blinded_path::message::ForwardNode; use lightning::ln::features::InitFeatures; use lightning::ln::msgs::{self, DecodeError, OnionMessageHandler}; use lightning::ln::script::ShutdownScript; @@ -89,7 +88,7 @@ impl MessageRouter for TestMessageRouter { } fn create_blinded_paths( - &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1, + &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1, ) -> Result, ()> { unreachable!() } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e3f7243cd3f..69139194194 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8245,8 +8245,8 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { /// /// # Privacy /// - /// Uses [`MessageRouter::create_blinded_paths`] to construct a [`BlindedPath`] for the offer. - /// However, if one is not found, uses a one-hop [`BlindedPath`] with + /// Uses [`MessageRouter::create_compact_blinded_paths`] to construct a [`BlindedPath`] for the + /// offer. However, if one is not found, uses a one-hop [`BlindedPath`] with /// [`ChannelManager::get_our_node_id`] as the introduction node instead. In the latter case, /// the node must be announced, otherwise, there is no way to find a path to the introduction in /// order to send the [`InvoiceRequest`]. @@ -8304,8 +8304,8 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { /// /// # Privacy /// - /// Uses [`MessageRouter::create_blinded_paths`] to construct a [`BlindedPath`] for the refund. - /// However, if one is not found, uses a one-hop [`BlindedPath`] with + /// Uses [`MessageRouter::create_compact_blinded_paths`] to construct a [`BlindedPath`] for the + /// refund. However, if one is not found, uses a one-hop [`BlindedPath`] with /// [`ChannelManager::get_our_node_id`] as the introduction node instead. In the latter case, /// the node must be announced, otherwise, there is no way to find a path to the introduction in /// order to send the [`Bolt12Invoice`]. @@ -8686,7 +8686,7 @@ where inbound_payment::get_payment_preimage(payment_hash, payment_secret, &self.inbound_payment_key) } - /// Creates a blinded path by delegating to [`MessageRouter::create_blinded_paths`]. + /// Creates a blinded path by delegating to [`MessageRouter::create_compact_blinded_paths`]. /// /// Errors if the `MessageRouter` errors or returns an empty `Vec`. fn create_blinded_path(&self) -> Result { @@ -8708,7 +8708,7 @@ where .collect::>(); self.router - .create_blinded_paths(recipient, peers, secp_ctx) + .create_compact_blinded_paths(recipient, peers, secp_ctx) .and_then(|paths| paths.into_iter().next().ok_or(())) } diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 50049866325..5ceb0683c57 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -162,7 +162,7 @@ for OnionMessenger where /// # }) /// # } /// # fn create_blinded_paths( -/// # &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1 +/// # &self, _recipient: PublicKey, _peers: Vec, _secp_ctx: &Secp256k1 /// # ) -> Result, ()> { /// # unreachable!() /// # } @@ -426,8 +426,33 @@ pub trait MessageRouter { fn create_blinded_paths< T: secp256k1::Signing + secp256k1::Verification >( - &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()>; + + /// Creates compact [`BlindedPath`]s to the `recipient` node. The nodes in `peers` are assumed + /// to be direct peers with the `recipient`. + /// + /// Compact blinded paths use short channel ids instead of pubkeys for a smaller serialization, + /// which is beneficial when a QR code is used to transport the data. The SCID is passed using a + /// [`ForwardNode`] but may be `None` for graceful degradation. + /// + /// Implementations using additional intermediate nodes are responsible for using a + /// [`ForwardNode`] with `Some` short channel id, if possible. Similarly, implementations should + /// call [`BlindedPath::use_compact_introduction_node`]. + /// + /// The provided implementation simply delegates to [`MessageRouter::create_blinded_paths`], + /// ignoring the short channel ids. + fn create_compact_blinded_paths< + T: secp256k1::Signing + secp256k1::Verification + >( + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + let peers = peers + .into_iter() + .map(|ForwardNode { node_id, short_channel_id: _ }| node_id) + .collect(); + self.create_blinded_paths(recipient, peers, secp_ctx) + } } /// A [`MessageRouter`] that can only route to a directly connected [`Destination`]. @@ -454,7 +479,7 @@ where I: Iterator, T: secp256k1::Signing + secp256k1::Verification >( - &self, recipient: PublicKey, peers: I, secp_ctx: &Secp256k1, + &self, recipient: PublicKey, peers: I, secp_ctx: &Secp256k1, compact_paths: bool ) -> Result, ()> { // Limit the number of blinded paths that are computed. const MAX_PATHS: usize = 3; @@ -502,8 +527,11 @@ where } }, }?; - for path in &mut paths { - path.use_compact_introduction_node(&network_graph); + + if compact_paths { + for path in &mut paths { + path.use_compact_introduction_node(&network_graph); + } } Ok(paths) @@ -550,10 +578,21 @@ where fn create_blinded_paths< T: secp256k1::Signing + secp256k1::Verification + >( + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + let peers = peers + .into_iter() + .map(|node_id| ForwardNode { node_id, short_channel_id: None }); + self.create_blinded_paths_from_iter(recipient, peers, secp_ctx, false) + } + + fn create_compact_blinded_paths< + T: secp256k1::Signing + secp256k1::Verification >( &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { - self.create_blinded_paths_from_iter(recipient, peers.into_iter(), secp_ctx) + self.create_blinded_paths_from_iter(recipient, peers.into_iter(), secp_ctx, true) } } @@ -1090,10 +1129,7 @@ where let peers = self.message_recipients.lock().unwrap() .iter() .filter(|(_, peer)| matches!(peer, OnionMessageRecipient::ConnectedPeer(_))) - .map(|(node_id, _ )| ForwardNode { - node_id: *node_id, - short_channel_id: None, - }) + .map(|(node_id, _ )| *node_id) .collect::>(); self.message_router diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index e1b5b655719..c105e914679 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -173,10 +173,18 @@ impl< G: Deref> + Clone, L: Deref, ES: Deref, S: Deref, fn create_blinded_paths< T: secp256k1::Signing + secp256k1::Verification > ( - &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { self.message_router.create_blinded_paths(recipient, peers, secp_ctx) } + + fn create_compact_blinded_paths< + T: secp256k1::Signing + secp256k1::Verification + > ( + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.message_router.create_compact_blinded_paths(recipient, peers, secp_ctx) + } } /// A trait defining behavior for routing a payment. diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index a5363d32c76..f6616a8e5d2 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -250,10 +250,18 @@ impl<'a> MessageRouter for TestRouter<'a> { fn create_blinded_paths< T: secp256k1::Signing + secp256k1::Verification >( - &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { self.router.create_blinded_paths(recipient, peers, secp_ctx) } + + fn create_compact_blinded_paths< + T: secp256k1::Signing + secp256k1::Verification + >( + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.router.create_compact_blinded_paths(recipient, peers, secp_ctx) + } } impl<'a> Drop for TestRouter<'a> { @@ -285,10 +293,16 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { } fn create_blinded_paths( - &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { self.inner.create_blinded_paths(recipient, peers, secp_ctx) } + + fn create_compact_blinded_paths( + &self, recipient: PublicKey, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.inner.create_compact_blinded_paths(recipient, peers, secp_ctx) + } } pub struct OnlyReadsKeysInterface {} From 411b9b43f6089b1482eb9831742bf5ecb8238e0d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 22 May 2024 16:35:57 -0500 Subject: [PATCH 03/16] Don't use compact blinded paths for reply paths There's no need to save space when creating reply paths since they are part of onion messages rather than in QR codes. Use normal blinded paths for these instead as they are less likely to become invalid in case of channel closure. --- lightning/src/ln/channelmanager.rs | 26 +++++++++++++++++++++++--- lightning/src/ln/offers_tests.rs | 8 ++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 69139194194..391f5b330bf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8270,7 +8270,8 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { let entropy = &*$self.entropy_source; let secp_ctx = &$self.secp_ctx; - let path = $self.create_blinded_path().map_err(|_| Bolt12SemanticError::MissingPaths)?; + let path = $self.create_compact_blinded_path() + .map_err(|_| Bolt12SemanticError::MissingPaths)?; let builder = OfferBuilder::deriving_signing_pubkey( node_id, expanded_key, entropy, secp_ctx ) @@ -8337,7 +8338,8 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { let entropy = &*$self.entropy_source; let secp_ctx = &$self.secp_ctx; - let path = $self.create_blinded_path().map_err(|_| Bolt12SemanticError::MissingPaths)?; + let path = $self.create_compact_blinded_path() + .map_err(|_| Bolt12SemanticError::MissingPaths)?; let builder = RefundBuilder::deriving_payer_id( node_id, expanded_key, entropy, secp_ctx, amount_msats, payment_id )? @@ -8686,13 +8688,31 @@ where inbound_payment::get_payment_preimage(payment_hash, payment_secret, &self.inbound_payment_key) } - /// Creates a blinded path by delegating to [`MessageRouter::create_compact_blinded_paths`]. + /// Creates a blinded path by delegating to [`MessageRouter::create_blinded_paths`]. /// /// Errors if the `MessageRouter` errors or returns an empty `Vec`. fn create_blinded_path(&self) -> Result { let recipient = self.get_our_node_id(); let secp_ctx = &self.secp_ctx; + let peers = self.per_peer_state.read().unwrap() + .iter() + .filter(|(_, peer)| peer.lock().unwrap().latest_features.supports_onion_messages()) + .map(|(node_id, _)| *node_id) + .collect::>(); + + self.router + .create_blinded_paths(recipient, peers, secp_ctx) + .and_then(|paths| paths.into_iter().next().ok_or(())) + } + + /// Creates a blinded path by delegating to [`MessageRouter::create_compact_blinded_paths`]. + /// + /// Errors if the `MessageRouter` errors or returns an empty `Vec`. + fn create_compact_blinded_path(&self) -> Result { + let recipient = self.get_our_node_id(); + let secp_ctx = &self.secp_ctx; + let peers = self.per_peer_state.read().unwrap() .iter() .map(|(node_id, peer_state)| (node_id, peer_state.lock().unwrap())) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index eedd82c569b..5594c8ba930 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -427,11 +427,9 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { payer_note_truncated: None, }, }); - let introduction_node_id = resolve_introduction_node(alice, &reply_path); assert_eq!(invoice_request.amount_msats(), None); assert_ne!(invoice_request.payer_id(), david_id); - assert_eq!(introduction_node_id, charlie_id); - assert!(matches!(reply_path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + assert_eq!(reply_path.introduction_node, IntroductionNode::NodeId(charlie_id)); let onion_message = alice.onion_messenger.next_onion_message_for_peer(charlie_id).unwrap(); charlie.onion_messenger.handle_onion_message(&alice_id, &onion_message); @@ -582,11 +580,9 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { payer_note_truncated: None, }, }); - let introduction_node_id = resolve_introduction_node(alice, &reply_path); assert_eq!(invoice_request.amount_msats(), None); assert_ne!(invoice_request.payer_id(), bob_id); - assert_eq!(introduction_node_id, bob_id); - assert!(matches!(reply_path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + assert_eq!(reply_path.introduction_node, IntroductionNode::NodeId(bob_id)); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); bob.onion_messenger.handle_onion_message(&alice_id, &onion_message); From 8012c2b213127372bdad150cdc354920d971087d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 28 May 2024 18:43:45 -0500 Subject: [PATCH 04/16] Use compact blinded paths for short-lived offers When an offer is short-lived, the likelihood of a channel used in a compact blinded path going away is low. Require passing the absolute expiry of an offer to ChannelManager::create_offer_builder so that it can be used to determine whether or not compact blinded path should be used. Use the same criteria for creating blinded paths for refunds as well. --- lightning/src/ln/channelmanager.rs | 83 ++++++++++++--- lightning/src/ln/offers_tests.rs | 158 ++++++++++++++++++++++++----- 2 files changed, 203 insertions(+), 38 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 391f5b330bf..2fd170ebfc9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1554,8 +1554,9 @@ where /// # /// # fn example(channel_manager: T) -> Result<(), Bolt12SemanticError> { /// # let channel_manager = channel_manager.get_cm(); +/// # let absolute_expiry = None; /// let offer = channel_manager -/// .create_offer_builder()? +/// .create_offer_builder(absolute_expiry)? /// # ; /// # // Needed for compiling for c_bindings /// # let builder: lightning::offers::offer::OfferBuilder<_, _> = offer.into(); @@ -2287,6 +2288,19 @@ const MAX_UNFUNDED_CHANNEL_PEERS: usize = 50; /// many peers we reject new (inbound) connections. const MAX_NO_CHANNEL_PEERS: usize = 250; +/// The maximum expiration from the current time where an [`Offer`] or [`Refund`] is considered +/// short-lived, while anything with a greater expiration is considered long-lived. +/// +/// Using [`ChannelManager::create_offer_builder`] or [`ChannelManager::create_refund_builder`], +/// will included a [`BlindedPath`] created using: +/// - [`MessageRouter::create_compact_blinded_paths`] when short-lived, and +/// - [`MessageRouter::create_blinded_paths`] when long-lived. +/// +/// Using compact [`BlindedPath`]s may provide better privacy as the [`MessageRouter`] could select +/// more hops. However, since they use short channel ids instead of pubkeys, they are more likely to +/// become invalid over time as channels are closed. Thus, they are only suitable for short-term use. +pub const MAX_SHORT_LIVED_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); + /// Used by [`ChannelManager::list_recent_payments`] to express the status of recent payments. /// These include payments that have yet to find a successful path, or have unresolved HTLCs. #[derive(Debug, PartialEq)] @@ -8240,16 +8254,17 @@ where macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the - /// [`ChannelManager`] when handling [`InvoiceRequest`] messages for the offer. The offer will - /// not have an expiration unless otherwise set on the builder. + /// [`ChannelManager`] when handling [`InvoiceRequest`] messages for the offer. The offer's + /// expiration will be `absolute_expiry` if `Some`, otherwise it will not expire. /// /// # Privacy /// - /// Uses [`MessageRouter::create_compact_blinded_paths`] to construct a [`BlindedPath`] for the - /// offer. However, if one is not found, uses a one-hop [`BlindedPath`] with + /// Uses [`MessageRouter`] to construct a [`BlindedPath`] for the offer based on the given + /// `absolute_expiry` according to [`MAX_SHORT_LIVED_RELATIVE_EXPIRY`]. See those docs for + /// privacy implications. However, if one is not found, uses a one-hop [`BlindedPath`] with /// [`ChannelManager::get_our_node_id`] as the introduction node instead. In the latter case, - /// the node must be announced, otherwise, there is no way to find a path to the introduction in - /// order to send the [`InvoiceRequest`]. + /// the node must be announced, otherwise, there is no way to find a path to the introduction + /// node in order to send the [`InvoiceRequest`]. /// /// Also, uses a derived signing pubkey in the offer for recipient privacy. /// @@ -8264,13 +8279,15 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { /// /// [`Offer`]: crate::offers::offer::Offer /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest - pub fn create_offer_builder(&$self) -> Result<$builder, Bolt12SemanticError> { + pub fn create_offer_builder( + &$self, absolute_expiry: Option + ) -> Result<$builder, Bolt12SemanticError> { let node_id = $self.get_our_node_id(); let expanded_key = &$self.inbound_payment_key; let entropy = &*$self.entropy_source; let secp_ctx = &$self.secp_ctx; - let path = $self.create_compact_blinded_path() + let path = $self.create_blinded_path_using_absolute_expiry(absolute_expiry) .map_err(|_| Bolt12SemanticError::MissingPaths)?; let builder = OfferBuilder::deriving_signing_pubkey( node_id, expanded_key, entropy, secp_ctx @@ -8278,6 +8295,11 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { .chain_hash($self.chain_hash) .path(path); + let builder = match absolute_expiry { + None => builder, + Some(absolute_expiry) => builder.absolute_expiry(absolute_expiry), + }; + Ok(builder.into()) } } } @@ -8305,11 +8327,12 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { /// /// # Privacy /// - /// Uses [`MessageRouter::create_compact_blinded_paths`] to construct a [`BlindedPath`] for the - /// refund. However, if one is not found, uses a one-hop [`BlindedPath`] with + /// Uses [`MessageRouter`] to construct a [`BlindedPath`] for the refund based on the given + /// `absolute_expiry` according to [`MAX_SHORT_LIVED_RELATIVE_EXPIRY`]. See those docs for + /// privacy implications. However, if one is not found, uses a one-hop [`BlindedPath`] with /// [`ChannelManager::get_our_node_id`] as the introduction node instead. In the latter case, - /// the node must be announced, otherwise, there is no way to find a path to the introduction in - /// order to send the [`Bolt12Invoice`]. + /// the node must be announced, otherwise, there is no way to find a path to the introduction + /// node in order to send the [`Bolt12Invoice`]. /// /// Also, uses a derived payer id in the refund for payer privacy. /// @@ -8338,7 +8361,7 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { let entropy = &*$self.entropy_source; let secp_ctx = &$self.secp_ctx; - let path = $self.create_compact_blinded_path() + let path = $self.create_blinded_path_using_absolute_expiry(Some(absolute_expiry)) .map_err(|_| Bolt12SemanticError::MissingPaths)?; let builder = RefundBuilder::deriving_payer_id( node_id, expanded_key, entropy, secp_ctx, amount_msats, payment_id @@ -8688,6 +8711,38 @@ where inbound_payment::get_payment_preimage(payment_hash, payment_secret, &self.inbound_payment_key) } + /// Creates a blinded path by delegating to [`MessageRouter`] based on the path's intended + /// lifetime. + /// + /// Whether or not the path is compact depends on whether the path is short-lived or long-lived, + /// respectively, based on the given `absolute_expiry` as seconds since the Unix epoch. See + /// [`MAX_SHORT_LIVED_RELATIVE_EXPIRY`]. + fn create_blinded_path_using_absolute_expiry( + &self, absolute_expiry: Option + ) -> Result { + let now = self.duration_since_epoch(); + let max_short_lived_absolute_expiry = now.saturating_add(MAX_SHORT_LIVED_RELATIVE_EXPIRY); + + if absolute_expiry.unwrap_or(Duration::MAX) <= max_short_lived_absolute_expiry { + self.create_compact_blinded_path() + } else { + self.create_blinded_path() + } + } + + pub(super) fn duration_since_epoch(&self) -> Duration { + #[cfg(not(feature = "std"))] + let now = Duration::from_secs( + self.highest_seen_timestamp.load(Ordering::Acquire) as u64 + ); + #[cfg(feature = "std")] + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); + + now + } + /// Creates a blinded path by delegating to [`MessageRouter::create_blinded_paths`]. /// /// Errors if the `MessageRouter` errors or returns an empty `Vec`. diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 5594c8ba930..725b8dfe5c7 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -46,7 +46,7 @@ use core::time::Duration; use crate::blinded_path::{BlindedPath, IntroductionNode}; use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, PaymentContext}; use crate::events::{Event, MessageSendEventsProvider, PaymentPurpose}; -use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, Retry, self}; +use crate::ln::channelmanager::{MAX_SHORT_LIVED_RELATIVE_EXPIRY, PaymentId, RecentPaymentDetails, Retry, self}; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; use crate::offers::invoice::Bolt12Invoice; @@ -274,7 +274,7 @@ fn prefers_non_tor_nodes_in_blinded_paths() { announce_node_address(charlie, &[alice, bob, david, &nodes[4], &nodes[5]], tor.clone()); let offer = bob.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); assert_ne!(offer.signing_pubkey(), Some(bob_id)); @@ -290,7 +290,7 @@ fn prefers_non_tor_nodes_in_blinded_paths() { announce_node_address(&nodes[5], &[alice, bob, charlie, david, &nodes[4]], tor.clone()); let offer = bob.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); assert_ne!(offer.signing_pubkey(), Some(bob_id)); @@ -341,7 +341,7 @@ fn prefers_more_connected_nodes_in_blinded_paths() { disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); let offer = bob.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); assert_ne!(offer.signing_pubkey(), Some(bob_id)); @@ -352,6 +352,124 @@ fn prefers_more_connected_nodes_in_blinded_paths() { } } +/// Checks that blinded paths are compact for short-lived offers. +#[test] +fn creates_short_lived_offer() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + + let absolute_expiry = alice.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; + let offer = alice.node + .create_offer_builder(Some(absolute_expiry)).unwrap() + .build().unwrap(); + assert_eq!(offer.absolute_expiry(), Some(absolute_expiry)); + assert!(!offer.paths().is_empty()); + for path in offer.paths() { + let introduction_node_id = resolve_introduction_node(bob, &path); + assert_eq!(introduction_node_id, alice_id); + assert!(matches!(path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + } +} + +/// Checks that blinded paths are not compact for long-lived offers. +#[test] +fn creates_long_lived_offer() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + + let absolute_expiry = alice.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY + + Duration::from_secs(1); + let offer = alice.node + .create_offer_builder(Some(absolute_expiry)) + .unwrap() + .build().unwrap(); + assert_eq!(offer.absolute_expiry(), Some(absolute_expiry)); + assert!(!offer.paths().is_empty()); + for path in offer.paths() { + assert_eq!(path.introduction_node, IntroductionNode::NodeId(alice_id)); + } + + let offer = alice.node + .create_offer_builder(None).unwrap() + .build().unwrap(); + assert_eq!(offer.absolute_expiry(), None); + assert!(!offer.paths().is_empty()); + for path in offer.paths() { + assert_eq!(path.introduction_node, IntroductionNode::NodeId(alice_id)); + } +} + +/// Checks that blinded paths are compact for short-lived refunds. +#[test] +fn creates_short_lived_refund() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + let absolute_expiry = bob.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; + let payment_id = PaymentId([1; 32]); + let refund = bob.node + .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), None) + .unwrap() + .build().unwrap(); + assert_eq!(refund.absolute_expiry(), Some(absolute_expiry)); + assert!(!refund.paths().is_empty()); + for path in refund.paths() { + let introduction_node_id = resolve_introduction_node(alice, &path); + assert_eq!(introduction_node_id, bob_id); + assert!(matches!(path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + } +} + +/// Checks that blinded paths are not compact for long-lived refunds. +#[test] +fn creates_long_lived_refund() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + let absolute_expiry = bob.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY + + Duration::from_secs(1); + let payment_id = PaymentId([1; 32]); + let refund = bob.node + .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), None) + .unwrap() + .build().unwrap(); + assert_eq!(refund.absolute_expiry(), Some(absolute_expiry)); + assert!(!refund.paths().is_empty()); + for path in refund.paths() { + assert_eq!(path.introduction_node, IntroductionNode::NodeId(bob_id)); + } +} + /// Checks that an offer can be paid through blinded paths and that ephemeral pubkeys are used /// rather than exposing a node's pubkey. #[test] @@ -391,16 +509,14 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); let offer = alice.node - .create_offer_builder() + .create_offer_builder(None) .unwrap() .amount_msats(10_000_000) .build().unwrap(); assert_ne!(offer.signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { - let introduction_node_id = resolve_introduction_node(david, &path); - assert_eq!(introduction_node_id, bob_id); - assert!(matches!(path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + assert_eq!(path.introduction_node, IntroductionNode::NodeId(bob_id)); } let payment_id = PaymentId([1; 32]); @@ -501,9 +617,7 @@ fn creates_and_pays_for_refund_using_two_hop_blinded_path() { assert_ne!(refund.payer_id(), david_id); assert!(!refund.paths().is_empty()); for path in refund.paths() { - let introduction_node_id = resolve_introduction_node(alice, &path); - assert_eq!(introduction_node_id, charlie_id); - assert!(matches!(path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + assert_eq!(path.introduction_node, IntroductionNode::NodeId(charlie_id)); } expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -553,15 +667,13 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { let bob_id = bob.node.get_our_node_id(); let offer = alice.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); assert_ne!(offer.signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { - let introduction_node_id = resolve_introduction_node(bob, &path); - assert_eq!(introduction_node_id, alice_id); - assert!(matches!(path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + assert_eq!(path.introduction_node, IntroductionNode::NodeId(alice_id)); } let payment_id = PaymentId([1; 32]); @@ -630,9 +742,7 @@ fn creates_and_pays_for_refund_using_one_hop_blinded_path() { assert_ne!(refund.payer_id(), bob_id); assert!(!refund.paths().is_empty()); for path in refund.paths() { - let introduction_node_id = resolve_introduction_node(alice, &path); - assert_eq!(introduction_node_id, bob_id); - assert!(matches!(path.introduction_node, IntroductionNode::DirectedShortChannelId(..))); + assert_eq!(path.introduction_node, IntroductionNode::NodeId(bob_id)); } expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -677,7 +787,7 @@ fn pays_for_offer_without_blinded_paths() { let bob_id = bob.node.get_our_node_id(); let offer = alice.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .clear_paths() .amount_msats(10_000_000) .build().unwrap(); @@ -765,7 +875,7 @@ fn fails_creating_offer_without_blinded_paths() { create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); - match nodes[0].node.create_offer_builder() { + match nodes[0].node.create_offer_builder(None) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), } @@ -808,7 +918,7 @@ fn fails_creating_invoice_request_for_unsupported_chain() { let bob = &nodes[1]; let offer = alice.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .clear_chains() .chain(Network::Signet) .build().unwrap(); @@ -868,7 +978,7 @@ fn fails_creating_invoice_request_without_blinded_reply_path() { disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); let offer = alice.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); @@ -902,7 +1012,7 @@ fn fails_creating_invoice_request_with_duplicate_payment_id() { disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]); let offer = alice.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); @@ -988,7 +1098,7 @@ fn fails_sending_invoice_without_blinded_payment_paths_for_offer() { disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); let offer = alice.node - .create_offer_builder().unwrap() + .create_offer_builder(None).unwrap() .amount_msats(10_000_000) .build().unwrap(); From 15fa0d8f2084d0ad9f9223e5b185ee0543e9e365 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 4 Jun 2024 18:42:51 -0500 Subject: [PATCH 05/16] Fix functional_test_utils::reconnect_nodes When reconnecting nodes, make sure to notify OnionMessenger that the nodes are now connected. --- lightning/src/ln/functional_test_utils.rs | 47 +++++++++++------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index c0f3458b3d6..11e0737da1a 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3259,30 +3259,34 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>(node_count: usize, cfgs: &'b Vec(node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>) { + let node_id_a = node_a.node.get_our_node_id(); + let node_id_b = node_b.node.get_our_node_id(); + + let init_a = msgs::Init { + features: node_a.init_features(&node_id_b), + networks: None, + remote_network_address: None, + }; + let init_b = msgs::Init { + features: node_b.init_features(&node_id_a), + networks: None, + remote_network_address: None, + }; + + node_a.node.peer_connected(&node_id_b, &init_b, true).unwrap(); + node_b.node.peer_connected(&node_id_a, &init_a, false).unwrap(); + node_a.onion_messenger.peer_connected(&node_id_b, &init_b, true).unwrap(); + node_b.onion_messenger.peer_connected(&node_id_a, &init_a, false).unwrap(); +} + pub fn connect_dummy_node<'a, 'b: 'a, 'c: 'b>(node: &Node<'a, 'b, 'c>) { let node_id_dummy = PublicKey::from_slice(&[2; 33]).unwrap(); @@ -3643,13 +3647,8 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) { pending_cell_htlc_claims, pending_cell_htlc_fails, pending_raa, pending_responding_commitment_signed, pending_responding_commitment_signed_dup_monitor, } = args; - node_a.node.peer_connected(&node_b.node.get_our_node_id(), &msgs::Init { - features: node_b.node.init_features(), networks: None, remote_network_address: None - }, true).unwrap(); + connect_nodes(node_a, node_b); let reestablish_1 = get_chan_reestablish_msgs!(node_a, node_b); - node_b.node.peer_connected(&node_a.node.get_our_node_id(), &msgs::Init { - features: node_a.node.init_features(), networks: None, remote_network_address: None - }, false).unwrap(); let reestablish_2 = get_chan_reestablish_msgs!(node_b, node_a); if send_channel_ready.0 { From 7db6616e52e3a2910b7237ce95e80a63abf9e0a5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 4 Jun 2024 18:48:07 -0500 Subject: [PATCH 06/16] Exclude disconnected peers from BlindedPath When calling MessageRouter::create_blinded_path, ChannelManager was including disconnected peers. Filter peers such that only connected ones are included. Otherwise, the resulting BlindedPath may not work. --- lightning/src/ln/channelmanager.rs | 5 +- lightning/src/ln/offers_tests.rs | 123 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2fd170ebfc9..7e81730ce7d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8752,7 +8752,9 @@ where let peers = self.per_peer_state.read().unwrap() .iter() - .filter(|(_, peer)| peer.lock().unwrap().latest_features.supports_onion_messages()) + .map(|(node_id, peer_state)| (node_id, peer_state.lock().unwrap())) + .filter(|(_, peer)| peer.is_connected) + .filter(|(_, peer)| peer.latest_features.supports_onion_messages()) .map(|(node_id, _)| *node_id) .collect::>(); @@ -8771,6 +8773,7 @@ where let peers = self.per_peer_state.read().unwrap() .iter() .map(|(node_id, peer_state)| (node_id, peer_state.lock().unwrap())) + .filter(|(_, peer)| peer.is_connected) .filter(|(_, peer)| peer.latest_features.supports_onion_messages()) .map(|(node_id, peer)| ForwardNode { node_id: *node_id, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 725b8dfe5c7..c7fb5f8fd59 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -904,6 +904,129 @@ fn fails_creating_refund_without_blinded_paths() { assert!(nodes[0].node.list_recent_payments().is_empty()); } +/// Fails creating or paying an offer when a blinded path cannot be created because no peers are +/// connected. +#[test] +fn fails_creating_or_paying_for_offer_without_connected_peers() { + let chanmon_cfgs = create_chanmon_cfgs(6); + let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(6, &node_cfgs, &[None, None, None, None, None, None]); + let nodes = create_network(6, &node_cfgs, &node_chanmgrs); + + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000); + + let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]); + + disconnect_peers(alice, &[bob, charlie, david, &nodes[4], &nodes[5]]); + disconnect_peers(david, &[bob, charlie, &nodes[4], &nodes[5]]); + + let absolute_expiry = alice.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; + match alice.node.create_offer_builder(Some(absolute_expiry)) { + Ok(_) => panic!("Expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), + } + + let mut args = ReconnectArgs::new(alice, bob); + args.send_channel_ready = (true, true); + reconnect_nodes(args); + + let offer = alice.node + .create_offer_builder(Some(absolute_expiry)).unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + let payment_id = PaymentId([1; 32]); + + match david.node.pay_for_offer(&offer, None, None, None, payment_id, Retry::Attempts(0), None) { + Ok(_) => panic!("Expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), + } + + assert!(nodes[0].node.list_recent_payments().is_empty()); + + let mut args = ReconnectArgs::new(charlie, david); + args.send_channel_ready = (true, true); + reconnect_nodes(args); + + assert!( + david.node.pay_for_offer( + &offer, None, None, None, payment_id, Retry::Attempts(0), None + ).is_ok() + ); + + expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); +} + +/// Fails creating or sending an invoice for a refund when a blinded path cannot be created because +/// no peers are connected. +#[test] +fn fails_creating_refund_or_sending_invoice_without_connected_peers() { + let mut accept_forward_cfg = test_default_channel_config(); + accept_forward_cfg.accept_forwards_to_priv_channels = true; + + let mut features = channelmanager::provided_init_features(&accept_forward_cfg); + features.set_onion_messages_optional(); + features.set_route_blinding_optional(); + + let chanmon_cfgs = create_chanmon_cfgs(6); + let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); + + *node_cfgs[1].override_init_features.borrow_mut() = Some(features); + + let node_chanmgrs = create_node_chanmgrs( + 6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None] + ); + let nodes = create_network(6, &node_cfgs, &node_chanmgrs); + + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000); + + let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]); + + disconnect_peers(alice, &[bob, charlie, david, &nodes[4], &nodes[5]]); + disconnect_peers(david, &[bob, charlie, &nodes[4], &nodes[5]]); + + let absolute_expiry = david.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; + let payment_id = PaymentId([1; 32]); + match david.node.create_refund_builder( + 10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), None + ) { + Ok(_) => panic!("Expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), + } + + let mut args = ReconnectArgs::new(charlie, david); + args.send_channel_ready = (true, true); + reconnect_nodes(args); + + let refund = david.node + .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), None) + .unwrap() + .build().unwrap(); + + match alice.node.request_refund_payment(&refund) { + Ok(_) => panic!("Expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), + } + + let mut args = ReconnectArgs::new(alice, bob); + args.send_channel_ready = (true, true); + reconnect_nodes(args); + + assert!(alice.node.request_refund_payment(&refund).is_ok()); +} + /// Fails creating an invoice request when the offer contains an unsupported chain. #[test] fn fails_creating_invoice_request_for_unsupported_chain() { From c17a026b0716a1226f1803f31645b9707eaf36f3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 30 May 2024 14:38:15 -0500 Subject: [PATCH 07/16] Clarify docs regarding one-hop blinded paths The docs assumed ChannelManager is parameterized by DefaultRouter, which may not be the case. Clarify the behavior is specific to using DefaultRouter. --- lightning/src/ln/channelmanager.rs | 19 +++++++------------ lightning/src/onion_message/messenger.rs | 7 +++++++ lightning/src/routing/router.rs | 5 +++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7e81730ce7d..992fd3c6f02 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8261,10 +8261,8 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { /// /// Uses [`MessageRouter`] to construct a [`BlindedPath`] for the offer based on the given /// `absolute_expiry` according to [`MAX_SHORT_LIVED_RELATIVE_EXPIRY`]. See those docs for - /// privacy implications. However, if one is not found, uses a one-hop [`BlindedPath`] with - /// [`ChannelManager::get_our_node_id`] as the introduction node instead. In the latter case, - /// the node must be announced, otherwise, there is no way to find a path to the introduction - /// node in order to send the [`InvoiceRequest`]. + /// privacy implications as well as those of the parameterized [`Router`], which implements + /// [`MessageRouter`]. /// /// Also, uses a derived signing pubkey in the offer for recipient privacy. /// @@ -8329,10 +8327,8 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { /// /// Uses [`MessageRouter`] to construct a [`BlindedPath`] for the refund based on the given /// `absolute_expiry` according to [`MAX_SHORT_LIVED_RELATIVE_EXPIRY`]. See those docs for - /// privacy implications. However, if one is not found, uses a one-hop [`BlindedPath`] with - /// [`ChannelManager::get_our_node_id`] as the introduction node instead. In the latter case, - /// the node must be announced, otherwise, there is no way to find a path to the introduction - /// node in order to send the [`Bolt12Invoice`]. + /// privacy implications as well as those of the parameterized [`Router`], which implements + /// [`MessageRouter`]. /// /// Also, uses a derived payer id in the refund for payer privacy. /// @@ -8431,10 +8427,9 @@ where /// /// # Privacy /// - /// Uses a one-hop [`BlindedPath`] for the reply path with [`ChannelManager::get_our_node_id`] - /// as the introduction node and a derived payer id for payer privacy. As such, currently, the - /// node must be announced. Otherwise, there is no way to find a path to the introduction node - /// in order to send the [`Bolt12Invoice`]. + /// For payer privacy, uses a derived payer id and uses [`MessageRouter::create_blinded_paths`] + /// to construct a [`BlindedPath`] for the reply path. For further privacy implications, see the + /// docs of the parameterized [`Router`], which implements [`MessageRouter`]. /// /// # Limitations /// diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 5ceb0683c57..ee49d00e99d 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -456,6 +456,13 @@ pub trait MessageRouter { } /// A [`MessageRouter`] that can only route to a directly connected [`Destination`]. +/// +/// # Privacy +/// +/// Creating [`BlindedPath`]s may affect privacy since, if a suitable path cannot be found, it will +/// create a one-hop path using the recipient as the introduction node if it is a announced node. +/// Otherwise, there is no way to find a path to the introduction node in order to send a message, +/// and thus an `Err` is returned. pub struct DefaultMessageRouter>, L: Deref, ES: Deref> where L::Target: Logger, diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index c105e914679..5b202604e37 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -36,6 +36,11 @@ use core::{cmp, fmt}; use core::ops::Deref; /// A [`Router`] implemented using [`find_route`]. +/// +/// # Privacy +/// +/// Implements [`MessageRouter`] by delegating to [`DefaultMessageRouter`]. See those docs for +/// privacy implications. pub struct DefaultRouter> + Clone, L: Deref, ES: Deref, S: Deref, SP: Sized, Sc: ScoreLookUp> where L::Target: Logger, S::Target: for <'a> LockableScore<'a, ScoreLookUp = Sc>, From 8e3a220ee93e55644496da2e2b04d95a795d9a90 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 10 Jan 2024 15:01:52 -0600 Subject: [PATCH 08/16] Fix doc comment example --- lightning/src/onion_message/messenger.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index ee49d00e99d..1a065da0c82 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -186,7 +186,7 @@ for OnionMessenger where /// &keys_manager, &keys_manager, logger, &node_id_lookup, message_router, /// &offers_message_handler, &custom_message_handler /// ); - +/// /// # #[derive(Debug)] /// # struct YourCustomMessage {} /// impl Writeable for YourCustomMessage { @@ -195,6 +195,7 @@ for OnionMessenger where /// // Write your custom onion message to `w` /// } /// } +/// /// impl OnionMessageContents for YourCustomMessage { /// fn tlv_type(&self) -> u64 { /// # let your_custom_message_type = 42; @@ -202,6 +203,7 @@ for OnionMessenger where /// } /// fn msg_type(&self) -> &'static str { "YourCustomMessageType" } /// } +/// /// // Send a custom onion message to a node id. /// let destination = Destination::Node(destination_node_id); /// let reply_path = None; From 744af6f3d3e0ddaec51b3d9fd8985a59d7303480 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 12 Dec 2023 18:36:21 -0600 Subject: [PATCH 09/16] WIP: three-hop blinded paths --- lightning/src/onion_message/messenger.rs | 78 ++++++++++++++++-------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 1a065da0c82..5ded27b5f51 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -490,6 +490,9 @@ where >( &self, recipient: PublicKey, peers: I, secp_ctx: &Secp256k1, compact_paths: bool ) -> Result, ()> { + let entropy_source = &*self.entropy_source; + let recipient_node_id = NodeId::from_pubkey(&recipient); + // Limit the number of blinded paths that are computed. const MAX_PATHS: usize = 3; @@ -502,40 +505,67 @@ where network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)); let mut peer_info = peers + .map(|peer| (NodeId::from_pubkey(&peer.node_id), peer)) // Limit to peers with announced channels - .filter_map(|peer| + .filter_map(|(node_id, peer)| network_graph - .node(&NodeId::from_pubkey(&peer.node_id)) + .node(&node_id) .filter(|info| info.channels.len() >= MIN_PEER_CHANNELS) - .map(|info| (peer, info.is_tor_only(), info.channels.len())) + .map(|info| (node_id, peer, info.is_tor_only(), &info.channels)) ) // Exclude Tor-only nodes when the recipient is announced. - .filter(|(_, is_tor_only, _)| !(*is_tor_only && is_recipient_announced)) + .filter(|(_, _, is_tor_only, _)| !(*is_tor_only && is_recipient_announced)) .collect::>(); // Prefer using non-Tor nodes with the most channels as the introduction node. - peer_info.sort_unstable_by(|(_, a_tor_only, a_channels), (_, b_tor_only, b_channels)| { - a_tor_only.cmp(b_tor_only).then(a_channels.cmp(b_channels).reverse()) + peer_info.sort_unstable_by(|(_, _, a_tor_only, a_channels), (_, _, b_tor_only, b_channels)| { + a_tor_only.cmp(b_tor_only).then(a_channels.len().cmp(&b_channels.len()).reverse()) }); - let paths = peer_info.into_iter() - .map(|(peer, _, _)| { - BlindedPath::new_for_message(&[peer], recipient, &*self.entropy_source, secp_ctx) - }) - .take(MAX_PATHS) - .collect::, _>>(); - - let mut paths = match paths { - Ok(paths) if !paths.is_empty() => Ok(paths), - _ => { - if is_recipient_announced { - BlindedPath::one_hop_for_message(recipient, &*self.entropy_source, secp_ctx) - .map(|path| vec![path]) - } else { - Err(()) - } - }, - }?; + let three_hop_paths = peer_info.iter() + // Pair peers with their other peers + .flat_map(|(node_id, peer, _, channels)| + channels + .iter() + .filter_map(|scid| network_graph.channels().get(scid)) + .filter_map(move |info| info + .as_directed_to(&node_id) + .map(|(_, source)| source) + ) + .filter(|source| **source != recipient_node_id) + .filter(|source| network_graph + .node(source) + .and_then(|info| info.announcement_info.as_ref()) + .map(|info| info.features().supports_onion_messages()) + .unwrap_or(false) + ) + .filter_map(|source| source.as_pubkey().ok()) + .map(move |source_pubkey| (source_pubkey, peer.clone())) + ) + .map(|(source_pubkey, peer)| BlindedPath::new_for_message(&[ForwardNode { node_id: source_pubkey, short_channel_id: None }, peer], recipient, entropy_source, secp_ctx)) + .take(MAX_PATHS); + + let two_hop_paths = peer_info + .iter() + .map(|(_, peer, _, _)| BlindedPath::new_for_message(&[peer.clone()], recipient, entropy_source, secp_ctx)) + .take(MAX_PATHS); + + let mut paths = three_hop_paths + .collect::, _>>().ok() + .and_then(|paths| (!paths.is_empty()).then(|| paths)) + .or_else(|| two_hop_paths.collect::, _>>().ok()) + .and_then(|paths| (!paths.is_empty()).then(|| paths)) + .or_else(|| is_recipient_announced + .then(|| BlindedPath::one_hop_for_message(recipient, entropy_source, secp_ctx) + .map(|path| vec![path]) + .unwrap_or(vec![]) + ) + ) + .ok_or(())?; + + if paths.is_empty() { + return Err(()); + } if compact_paths { for path in &mut paths { From 3e1a356944ad539e15cd08ff3fe4d3d9a1c0da90 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 5 Jun 2024 17:01:37 -0500 Subject: [PATCH 10/16] f - only use three-hop for compact paths --- lightning/src/onion_message/messenger.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 5ded27b5f51..5ad687a9867 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -459,6 +459,9 @@ pub trait MessageRouter { /// A [`MessageRouter`] that can only route to a directly connected [`Destination`]. /// +/// When creating [`BlindedPath`]s, prefers three-hop paths over two-hops paths for the compact +/// representation. For the non-compact representation, three-hop paths are not considered. +/// /// # Privacy /// /// Creating [`BlindedPath`]s may affect privacy since, if a suitable path cannot be found, it will @@ -550,8 +553,10 @@ where .map(|(_, peer, _, _)| BlindedPath::new_for_message(&[peer.clone()], recipient, entropy_source, secp_ctx)) .take(MAX_PATHS); - let mut paths = three_hop_paths - .collect::, _>>().ok() + // Prefer three-hop paths over two-hop paths for compact paths. Fallback to a one-hop path + // if none were found and the recipient node is announced. + let mut paths = (!compact_paths).then(|| vec![]) + .or_else(|| three_hop_paths.collect::, _>>().ok()) .and_then(|paths| (!paths.is_empty()).then(|| paths)) .or_else(|| two_hop_paths.collect::, _>>().ok()) .and_then(|paths| (!paths.is_empty()).then(|| paths)) From 677b6a2ea31129cd0b8e1a689b23b9f8700b946f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 12 Dec 2023 14:50:03 -0600 Subject: [PATCH 11/16] DirectedChannelInfo to PaymentRelay mapping --- lightning/src/blinded_path/payment.rs | 41 +++++++++++++++++++++------ lightning/src/routing/gossip.rs | 2 +- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 3b71f77c6d5..1f26f956937 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -23,6 +23,7 @@ use crate::ln::msgs::DecodeError; use crate::offers::invoice::BlindedPayInfo; use crate::offers::invoice_request::InvoiceRequestFields; use crate::offers::offer::OfferId; +use crate::routing::gossip::DirectedChannelInfo; use crate::util::ser::{HighZeroBytesDroppedBigSize, Readable, Writeable, Writer}; #[allow(unused_imports)] @@ -170,6 +171,19 @@ impl PaymentContext { } } +impl PaymentRelay { + fn normalize_cltv_expiry_delta(cltv_expiry_delta: u16) -> Result { + // Avoid exposing esoteric CLTV expiry deltas, which could de-anonymize the path. + match cltv_expiry_delta { + 0..=40 => Ok(40), + 41..=80 => Ok(80), + 81..=144 => Ok(144), + 145..=216 => Ok(216), + _ => Err(()), + } + } +} + impl TryFrom for PaymentRelay { type Error = (); @@ -178,16 +192,25 @@ impl TryFrom for PaymentRelay { fee_base_msat, fee_proportional_millionths, cltv_expiry_delta } = info; - // Avoid exposing esoteric CLTV expiry deltas - let cltv_expiry_delta = match cltv_expiry_delta { - 0..=40 => 40, - 41..=80 => 80, - 81..=144 => 144, - 145..=216 => 216, - _ => return Err(()), - }; + Ok(Self { + cltv_expiry_delta: Self::normalize_cltv_expiry_delta(cltv_expiry_delta)?, + fee_proportional_millionths, + fee_base_msat + }) + } +} + +impl<'a> TryFrom> for PaymentRelay { + type Error = (); + + fn try_from(info: DirectedChannelInfo<'a>) -> Result { + let direction = info.direction(); - Ok(Self { cltv_expiry_delta, fee_proportional_millionths, fee_base_msat }) + Ok(Self { + cltv_expiry_delta: Self::normalize_cltv_expiry_delta(direction.cltv_expiry_delta)?, + fee_proportional_millionths: direction.fees.proportional_millionths, + fee_base_msat: direction.fees.base_msat, + }) } } diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index 25ee1b97ffd..6ed0fa1d0f3 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -1040,7 +1040,7 @@ impl<'a> DirectedChannelInfo<'a> { /// Returns information for the direction. #[inline] - pub(super) fn direction(&self) -> &'a ChannelUpdateInfo { self.direction } + pub(crate) fn direction(&self) -> &'a ChannelUpdateInfo { self.direction } /// Returns the `node_id` of the source hop. /// From 50ed6da85073907687eb84b96062d03c43e56884 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 12 Dec 2023 14:55:56 -0600 Subject: [PATCH 12/16] WIP: three-hop blinded payment paths --- lightning/src/routing/router.rs | 113 +++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 24 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 5b202604e37..df2f63b4a05 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -93,6 +93,9 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, &self, recipient: PublicKey, first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: u64, secp_ctx: &Secp256k1 ) -> Result, ()> { + let entropy_source = &*self.entropy_source; + let recipient_node_id = NodeId::from_pubkey(&recipient); + // Limit the number of blinded paths that are computed. const MAX_PAYMENT_PATHS: usize = 3; @@ -101,17 +104,20 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, const MIN_PEER_CHANNELS: usize = 3; let network_graph = self.network_graph.deref().read_only(); - let paths = first_hops.into_iter() + let counterparty_channels = first_hops.into_iter() .filter(|details| details.counterparty.features.supports_route_blinding()) .filter(|details| amount_msats <= details.inbound_capacity_msat) .filter(|details| amount_msats >= details.inbound_htlc_minimum_msat.unwrap_or(0)) .filter(|details| amount_msats <= details.inbound_htlc_maximum_msat.unwrap_or(u64::MAX)) - .filter(|details| network_graph + // Limit to counterparties with announced channels + .filter_map(|details| + network_graph .node(&NodeId::from_pubkey(&details.counterparty.node_id)) - .map(|node_info| node_info.channels.len() >= MIN_PEER_CHANNELS) - .unwrap_or(false) + .map(|info| &info.channels[..]) + .and_then(|channels| (channels.len() >= MIN_PEER_CHANNELS).then(|| channels)) + .map(|channels| (details, channels)) ) - .filter_map(|details| { + .filter_map(|(details, counterparty_channels)| { let short_channel_id = match details.get_inbound_payment_scid() { Some(short_channel_id) => short_channel_id, None => return None, @@ -129,7 +135,7 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, max_cltv_expiry: tlvs.payment_constraints.max_cltv_expiry + cltv_expiry_delta, htlc_minimum_msat: details.inbound_htlc_minimum_msat.unwrap_or(0), }; - Some(payment::ForwardNode { + let forward_node = payment::ForwardNode { tlvs: ForwardTlvs { short_channel_id, payment_relay, @@ -138,29 +144,88 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, }, node_id: details.counterparty.node_id, htlc_maximum_msat: details.inbound_htlc_maximum_msat.unwrap_or(u64::MAX), - }) + }; + Some((forward_node, counterparty_channels)) + }); + + let three_hop_paths = counterparty_channels.clone() + // Pair counterparties with their other channels + .flat_map(|(forward_node, counterparty_channels)| + counterparty_channels + .iter() + .filter_map(|scid| network_graph.channels().get_key_value(scid)) + .filter_map(move |(scid, info)| info + .as_directed_to(&NodeId::from_pubkey(&forward_node.node_id)) + .map(|(info, source)| (source, *scid, info)) + ) + .filter(|(source, _, _)| **source != recipient_node_id) + .filter(|(source, _, _)| network_graph + .node(source) + .and_then(|info| info.announcement_info.as_ref()) + .map(|info| info.features().supports_route_blinding()) + .unwrap_or(false) + ) + .filter(|(_, _, info)| amount_msats >= info.direction().htlc_minimum_msat) + .filter(|(_, _, info)| amount_msats <= info.direction().htlc_maximum_msat) + .map(move |(source, scid, info)| (source, scid, info, forward_node.clone())) + ) + // Construct blinded paths where the counterparty's counterparty is the introduction + // node: + // + // source --- info ---> counterparty --- counterparty_forward_node ---> recipient + .filter_map(|(introduction_node_id, scid, info, counterparty_forward_node)| { + let htlc_minimum_msat = info.direction().htlc_minimum_msat; + let htlc_maximum_msat = info.direction().htlc_maximum_msat; + let payment_relay: PaymentRelay = match info.try_into() { + Ok(payment_relay) => payment_relay, + Err(()) => return None, + }; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: payment_relay.cltv_expiry_delta as u32 + + counterparty_forward_node.tlvs.payment_constraints.max_cltv_expiry, + htlc_minimum_msat, + }; + let introduction_forward_node = payment::ForwardNode { + tlvs: ForwardTlvs { + short_channel_id: scid, + payment_relay, + payment_constraints, + features: BlindedHopFeatures::empty(), + }, + node_id: introduction_node_id.as_pubkey().unwrap(), + htlc_maximum_msat, + }; + Some(BlindedPath::new_for_payment( + &[introduction_forward_node, counterparty_forward_node], recipient, + tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, entropy_source, secp_ctx + )) }) - .map(|forward_node| { + .take(MAX_PAYMENT_PATHS); + + let two_hop_paths = counterparty_channels + .map(|(forward_node, _)| { BlindedPath::new_for_payment( &[forward_node], recipient, tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, - &*self.entropy_source, secp_ctx + entropy_source, secp_ctx ) }) - .take(MAX_PAYMENT_PATHS) - .collect::, _>>(); - - match paths { - Ok(paths) if !paths.is_empty() => Ok(paths), - _ => { - if network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)) { - BlindedPath::one_hop_for_payment( - recipient, tlvs, MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, secp_ctx - ).map(|path| vec![path]) - } else { - Err(()) - } - }, - } + .take(MAX_PAYMENT_PATHS); + + three_hop_paths + .collect::, _>>().ok() + .and_then(|paths| (!paths.is_empty()).then(|| paths)) + .or_else(|| two_hop_paths.collect::, _>>().ok()) + .and_then(|paths| (!paths.is_empty()).then(|| paths)) + .or_else(|| network_graph + .node(&NodeId::from_pubkey(&recipient)).ok_or(()) + .and_then(|_| BlindedPath::one_hop_for_payment( + recipient, tlvs, MIN_FINAL_CLTV_EXPIRY_DELTA, entropy_source, secp_ctx + ) + ) + .map(|path| vec![path]) + .ok() + ) + .ok_or(()) } } From 1172a66491dde1456510b963f3ac280be5cc6c3b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jan 2024 14:48:49 -0600 Subject: [PATCH 13/16] Consider channel balance certainty in blinded path How certain a scorer is about a channel's liquidity balance is useful in determining if the channel should be included in a blinded payment path. Channels with uncertain balances should be avoided to facilitate successful payments. Expand ScoreLookUp with a channel_balance_certainty method and use it in DefaultRouter::create_blinded_payment_paths. --- lightning/src/routing/router.rs | 7 +++++++ lightning/src/routing/scoring.rs | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index df2f63b4a05..836867570fc 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -103,6 +103,9 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, // recipient's node_id. const MIN_PEER_CHANNELS: usize = 3; + // The minimum channel balance certainty required for using a channel in a blinded path. + const MIN_CHANNEL_CERTAINTY: f64 = 0.5; + let network_graph = self.network_graph.deref().read_only(); let counterparty_channels = first_hops.into_iter() .filter(|details| details.counterparty.features.supports_route_blinding()) @@ -148,6 +151,7 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, Some((forward_node, counterparty_channels)) }); + let scorer = self.scorer.read_lock(); let three_hop_paths = counterparty_channels.clone() // Pair counterparties with their other channels .flat_map(|(forward_node, counterparty_channels)| @@ -167,6 +171,9 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, ) .filter(|(_, _, info)| amount_msats >= info.direction().htlc_minimum_msat) .filter(|(_, _, info)| amount_msats <= info.direction().htlc_maximum_msat) + .filter(|(_, scid, info)| { + scorer.channel_balance_certainty(*scid, info) >= MIN_CHANNEL_CERTAINTY + }) .map(move |(source, scid, info)| (source, scid, info, forward_node.clone())) ) // Construct blinded paths where the counterparty's counterparty is the introduction diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index d782958ca91..fa1cc7d7e26 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -57,7 +57,7 @@ //! [`find_route`]: crate::routing::router::find_route use crate::ln::msgs::DecodeError; -use crate::routing::gossip::{EffectiveCapacity, NetworkGraph, NodeId}; +use crate::routing::gossip::{DirectedChannelInfo, EffectiveCapacity, NetworkGraph, NodeId}; use crate::routing::router::{Path, CandidateRouteHop, PublicHopCandidate}; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use crate::util::logger::Logger; @@ -96,6 +96,7 @@ pub trait ScoreLookUp { /// on a per-routefinding-call basis through to the scorer methods, /// which are used to determine the parameters for the suitability of channels for use. type ScoreParams; + /// Returns the fee in msats willing to be paid to avoid routing `send_amt_msat` through the /// given channel in the direction from `source` to `target`. /// @@ -107,6 +108,16 @@ pub trait ScoreLookUp { fn channel_penalty_msat( &self, candidate: &CandidateRouteHop, usage: ChannelUsage, score_params: &Self::ScoreParams ) -> u64; + + /// Returns how certain any knowledge gained about the channel's liquidity balance is. + /// + /// Expected to return a value between `0.0` and `1.0`, inclusive, where `0.0` indicates + /// complete uncertainty and `1.0` complete certainty. + /// + /// This is useful to determine whether a channel should be included in a blinded path. + fn channel_balance_certainty( + &self, _short_channel_id: u64, _info: &DirectedChannelInfo + ) -> f64 { 0.5 } } /// `ScoreUpdate` is used to update the scorer's internal state after a payment attempt. From dd3b86d20c615d3a2dc729021d0ca57130449162 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jan 2024 15:53:32 -0600 Subject: [PATCH 14/16] WIP: Balance certainty for ProbabilisticScorer --- lightning/src/routing/scoring.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index fa1cc7d7e26..e9494b8f323 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -1377,6 +1377,17 @@ impl>, L: Deref> ScoreLookUp for Probabilistic .saturating_add(anti_probing_penalty_msat) .saturating_add(base_penalty_msat) } + + fn channel_balance_certainty(&self, short_channel_id: u64, info: &DirectedChannelInfo) -> f64 { + self.channel_liquidities + .get(&short_channel_id) + .map(|channel| + ((channel.min_liquidity_offset_msat + channel.max_liquidity_offset_msat) as f64) + / info.effective_capacity().as_msat() as f64 + ) + .and_then(|certainty| certainty.is_finite().then(|| certainty)) + .unwrap_or(0.0) + } } impl>, L: Deref> ScoreUpdate for ProbabilisticScorer where L::Target: Logger { From 53c006a61ff4cd1a1c2c73afb3123bc20371a662 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 25 Jan 2024 14:29:37 -0600 Subject: [PATCH 15/16] Consider success probability in blinded path How certain a scorer is about a channel's success probability is useful in determining if the channel should be included in a blinded payment path. Channels with low success probability for a given amount should be avoided to facilitate successful payments. Expand ScoreLookUp with a channel_success_probability method and use it in DefaultRouter::create_blinded_payment_paths. --- lightning/src/routing/router.rs | 31 +++++++++++++++++++++++++++---- lightning/src/routing/scoring.rs | 12 ++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 836867570fc..57828dceece 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -106,6 +106,9 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, // The minimum channel balance certainty required for using a channel in a blinded path. const MIN_CHANNEL_CERTAINTY: f64 = 0.5; + // The minimum success probability required for using a channel in a blinded path. + const MIN_SUCCESS_PROBABILITY: f64 = 0.25; + let network_graph = self.network_graph.deref().read_only(); let counterparty_channels = first_hops.into_iter() .filter(|details| details.counterparty.features.supports_route_blinding()) @@ -181,6 +184,21 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, // // source --- info ---> counterparty --- counterparty_forward_node ---> recipient .filter_map(|(introduction_node_id, scid, info, counterparty_forward_node)| { + let amount_msat = amount_msats; + let effective_capacity = info.effective_capacity(); + let usage = ChannelUsage { amount_msat, inflight_htlc_msat: 0, effective_capacity }; + let success_probability = scorer.channel_success_probability( + scid, &info, usage, &self.score_params + ); + + if !success_probability.is_finite() { + return None; + } + + if success_probability < MIN_SUCCESS_PROBABILITY { + return None; + } + let htlc_minimum_msat = info.direction().htlc_minimum_msat; let htlc_maximum_msat = info.direction().htlc_maximum_msat; let payment_relay: PaymentRelay = match info.try_into() { @@ -202,12 +220,13 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, node_id: introduction_node_id.as_pubkey().unwrap(), htlc_maximum_msat, }; - Some(BlindedPath::new_for_payment( + let path = BlindedPath::new_for_payment( &[introduction_forward_node, counterparty_forward_node], recipient, tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, entropy_source, secp_ctx - )) - }) - .take(MAX_PAYMENT_PATHS); + ); + + Some(path.map(|path| (path, success_probability))) + }); let two_hop_paths = counterparty_channels .map(|(forward_node, _)| { @@ -221,6 +240,10 @@ impl> + Clone, L: Deref, ES: Deref, S: Deref, three_hop_paths .collect::, _>>().ok() .and_then(|paths| (!paths.is_empty()).then(|| paths)) + .map(|mut paths| { + paths.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + paths.into_iter().map(|(path, _)| path).take(MAX_PAYMENT_PATHS).collect::>() + }) .or_else(|| two_hop_paths.collect::, _>>().ok()) .and_then(|paths| (!paths.is_empty()).then(|| paths)) .or_else(|| network_graph diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index e9494b8f323..ac58d47ec53 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -109,6 +109,18 @@ pub trait ScoreLookUp { &self, candidate: &CandidateRouteHop, usage: ChannelUsage, score_params: &Self::ScoreParams ) -> u64; + /// Returns the success probability of sending an HTLC through a channel. + /// + /// Expected to return a value between `0.0` and `1.0`, inclusive, where `0.0` indicates + /// highly unlikely and `1.0` highly likely. + /// + /// This is useful to determine whether a channel should be included in a blinded path and the + /// preferred ordering of blinded paths. + fn channel_success_probability( + &self, _short_channel_id: u64, _info: &DirectedChannelInfo, _usage: ChannelUsage, + _score_params: &Self::ScoreParams + ) -> f64 { 0.5 } + /// Returns how certain any knowledge gained about the channel's liquidity balance is. /// /// Expected to return a value between `0.0` and `1.0`, inclusive, where `0.0` indicates From ee8895127b09658d6ce484ecd219e9105a8ded54 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 25 Jan 2024 16:16:56 -0600 Subject: [PATCH 16/16] WIP: Success probability for ProbabilisticScorer --- lightning/src/routing/scoring.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index ac58d47ec53..9acd792b9b0 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -1251,6 +1251,27 @@ DirectedChannelLiquidity< L, BRT, T> { liquidity_penalty_msat.saturating_add(amount_penalty_msat) } + fn success_probability( + &self, usage: ChannelUsage, score_params: &ProbabilisticScoringFeeParameters + ) -> f64 { + let amount_msat = usage.amount_msat; + let available_capacity = self.capacity_msat; + let max_liquidity_msat = self.max_liquidity_msat(); + let min_liquidity_msat = core::cmp::min(self.min_liquidity_msat(), max_liquidity_msat); + + if amount_msat <= min_liquidity_msat { + 1.0 + } else if amount_msat >= max_liquidity_msat { + 0.0 + } else { + let (numerator, denominator) = success_probability( + amount_msat, min_liquidity_msat, max_liquidity_msat, available_capacity, + score_params, false + ); + numerator as f64 / denominator as f64 + } + } + /// Returns the lower bound of the channel liquidity balance in this direction. #[inline(always)] fn min_liquidity_msat(&self) -> u64 { @@ -1390,6 +1411,17 @@ impl>, L: Deref> ScoreLookUp for Probabilistic .saturating_add(base_penalty_msat) } + fn channel_success_probability( + &self, short_channel_id: u64, info: &DirectedChannelInfo, usage: ChannelUsage, + score_params: &ProbabilisticScoringFeeParameters + ) -> f64 { + self.channel_liquidities + .get(&short_channel_id) + .unwrap_or(&ChannelLiquidity::new(Duration::ZERO)) + .as_directed(info.source(), info.target(), usage.effective_capacity.as_msat()) + .success_probability(usage, score_params) + } + fn channel_balance_certainty(&self, short_channel_id: u64, info: &DirectedChannelInfo) -> f64 { self.channel_liquidities .get(&short_channel_id)