Use compact blinded paths for short-lived offers
authorJeffrey Czyz <jkczyz@gmail.com>
Tue, 28 May 2024 23:43:45 +0000 (18:43 -0500)
committerJeffrey Czyz <jkczyz@gmail.com>
Wed, 5 Jun 2024 22:52:30 +0000 (17:52 -0500)
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
lightning/src/ln/offers_tests.rs

index 391f5b330bf13d00ac7311bad8a91b835f4c8b37..2fd170ebfc9d605c0a421b61e75510bc2003c48a 100644 (file)
@@ -1554,8 +1554,9 @@ where
 /// #
 /// # fn example<T: AChannelManager>(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<Duration>
+       ) -> 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<Duration>
+       ) -> Result<BlindedPath, ()> {
+               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`.
index 5594c8ba930694bf5d92421d9204dfd0b44016d9..725b8dfe5c771782e5a7a27c26139a0e968699f8 100644 (file)
@@ -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();