From e5b172c718d89e29fef103cf4d071357fad6ec45 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 9 Oct 2025 23:00:40 +0530 Subject: [PATCH 1/7] Introduce Dummy BlindedPaymentTlv Dummy BlindedPaymentTlvs is an empty TLV inserted immediately before the actual ReceiveTlvs in a blinded path. Receivers treat these dummy hops as real hops, which prevents timing-based attacks. Allowing arbitrary dummy hops before the final ReceiveTlvs obscures the recipient's true position in the route and makes it harder for an onlooker to infer the destination, strengthening recipient privacy. --- lightning/src/blinded_path/payment.rs | 62 +++++++++++++++++++-------- lightning/src/ln/channelmanager.rs | 14 ++++++ lightning/src/ln/msgs.rs | 12 ++++++ lightning/src/ln/onion_payment.rs | 16 +++++++ lightning/src/ln/onion_utils.rs | 12 ++++++ 5 files changed, 97 insertions(+), 19 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 13ade222f5b..0993597576b 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -346,6 +346,8 @@ pub struct ReceiveTlvs { pub(crate) enum BlindedPaymentTlvs { /// This blinded payment data is for a forwarding node. Forward(ForwardTlvs), + /// This blinded payment data is dummy and is to be peeled by receiving node. + Dummy, /// This blinded payment data is for the receiving node. Receive(ReceiveTlvs), } @@ -363,6 +365,7 @@ pub(crate) enum BlindedTrampolineTlvs { // Used to include forward and receive TLVs in the same iterator for encoding. enum BlindedPaymentTlvsRef<'a> { Forward(&'a ForwardTlvs), + Dummy, Receive(&'a ReceiveTlvs), } @@ -532,6 +535,11 @@ impl<'a> Writeable for BlindedPaymentTlvsRef<'a> { fn write(&self, w: &mut W) -> Result<(), io::Error> { match self { Self::Forward(tlvs) => tlvs.write(w)?, + Self::Dummy => { + encode_tlv_stream!(w, { + (65539, (), required), + }) + }, Self::Receive(tlvs) => tlvs.write(w)?, } Ok(()) @@ -548,32 +556,48 @@ impl Readable for BlindedPaymentTlvs { (2, scid, option), (8, next_blinding_override, option), (10, payment_relay, option), - (12, payment_constraints, required), + (12, payment_constraints, option), (14, features, (option, encoding: (BlindedHopFeatures, WithoutLength))), (65536, payment_secret, option), (65537, payment_context, option), + (65539, is_dummy, option) }); - if let Some(short_channel_id) = scid { - if payment_secret.is_some() { - return Err(DecodeError::InvalidValue); - } - Ok(BlindedPaymentTlvs::Forward(ForwardTlvs { + match ( + scid, + next_blinding_override, + payment_relay, + payment_constraints, + features, + payment_secret, + payment_context, + is_dummy, + ) { + ( + Some(short_channel_id), + next_override, + Some(relay), + Some(constraints), + features, + None, + None, + None, + ) => Ok(BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, - payment_relay: payment_relay.ok_or(DecodeError::InvalidValue)?, - payment_constraints: payment_constraints.0.unwrap(), - next_blinding_override, + payment_relay: relay, + payment_constraints: constraints, + next_blinding_override: next_override, features: features.unwrap_or_else(BlindedHopFeatures::empty), - })) - } else { - if payment_relay.is_some() || features.is_some() { - return Err(DecodeError::InvalidValue); - } - Ok(BlindedPaymentTlvs::Receive(ReceiveTlvs { - payment_secret: payment_secret.ok_or(DecodeError::InvalidValue)?, - payment_constraints: payment_constraints.0.unwrap(), - payment_context: payment_context.ok_or(DecodeError::InvalidValue)?, - })) + })), + (None, None, None, Some(constraints), None, Some(secret), Some(context), None) => { + Ok(BlindedPaymentTlvs::Receive(ReceiveTlvs { + payment_secret: secret, + payment_constraints: constraints, + payment_context: context, + })) + }, + (None, None, None, None, None, None, None, Some(())) => Ok(BlindedPaymentTlvs::Dummy), + _ => return Err(DecodeError::InvalidValue), } } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ea9b14211c5..50a50ee93f2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5089,6 +5089,20 @@ where onion_utils::Hop::Forward { .. } | onion_utils::Hop::BlindedForward { .. } => { create_fwd_pending_htlc_info(msg, decoded_hop, shared_secret, next_packet_pubkey_opt) }, + onion_utils::Hop::Dummy { .. } => { + debug_assert!( + false, + "Reached unreachable dummy-hop HTLC. Dummy hops are peeled in \ + `process_pending_update_add_htlcs`, and the resulting HTLC is \ + re-enqueued for processing. Hitting this means the peel-and-requeue \ + step was missed." + ); + return Err(InboundHTLCErr { + msg: "Failed to decode update add htlc onion", + reason: LocalHTLCFailureReason::InvalidOnionPayload, + err_data: Vec::new(), + }) + }, onion_utils::Hop::TrampolineForward { .. } | onion_utils::Hop::TrampolineBlindedForward { .. } => { create_fwd_pending_htlc_info(msg, decoded_hop, shared_secret, next_packet_pubkey_opt) }, diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 8e230fab1d9..ec8301a894e 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -2355,6 +2355,7 @@ mod fuzzy_internal_msgs { Receive(InboundOnionReceivePayload), BlindedForward(InboundOnionBlindedForwardPayload), BlindedReceive(InboundOnionBlindedReceivePayload), + Dummy { intro_node_blinding_point: Option }, } pub struct InboundTrampolineForwardPayload { @@ -3694,6 +3695,17 @@ where next_blinding_override, })) }, + ChaChaDualPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy, used_aad } => { + if amt.is_some() + || cltv_value.is_some() || total_msat.is_some() + || keysend_preimage.is_some() + || invoice_request.is_some() + || !used_aad + { + return Err(DecodeError::InvalidValue); + } + Ok(Self::Dummy { intro_node_blinding_point }) + }, ChaChaDualPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), used_aad, diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index f52a2d56e85..ab107133e67 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -123,6 +123,14 @@ pub(super) fn create_fwd_pending_htlc_info( (RoutingInfo::Direct { short_channel_id, new_packet_bytes, next_hop_hmac }, amt_to_forward, outgoing_cltv_value, intro_node_blinding_point, next_blinding_override) }, + onion_utils::Hop::Dummy { .. } => { + debug_assert!(false, "Dummy hop should have been peeled earlier"); + return Err(InboundHTLCErr { + msg: "Dummy Hop OnionHopData provided for us as an intermediary node", + reason: LocalHTLCFailureReason::InvalidOnionPayload, + err_data: Vec::new(), + }) + }, onion_utils::Hop::Receive { .. } | onion_utils::Hop::BlindedReceive { .. } => return Err(InboundHTLCErr { msg: "Final Node OnionHopData provided for us as an intermediary node", @@ -327,6 +335,14 @@ pub(super) fn create_recv_pending_htlc_info( msg: "Got blinded non final data with an HMAC of 0", }) }, + onion_utils::Hop::Dummy { .. } => { + debug_assert!(false, "Dummy hop should have been peeled earlier"); + return Err(InboundHTLCErr { + reason: LocalHTLCFailureReason::InvalidOnionBlinding, + err_data: vec![0; 32], + msg: "Got blinded non final data with an HMAC of 0", + }) + } onion_utils::Hop::TrampolineForward { .. } | onion_utils::Hop::TrampolineBlindedForward { .. } => { return Err(InboundHTLCErr { reason: LocalHTLCFailureReason::InvalidOnionPayload, diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index e32b39775fe..0d4184ac873 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2202,6 +2202,17 @@ pub(crate) enum Hop { /// Bytes of the onion packet we're forwarding. new_packet_bytes: [u8; ONION_DATA_LEN], }, + /// This onion payload is dummy, and needs to be peeled by us. + Dummy { + /// Blinding point for introduction-node dummy hops. + intro_node_blinding_point: Option, + /// Shared secret for decrypting the next-hop public key. + shared_secret: SharedSecret, + /// HMAC of the next hop's onion packet. + next_hop_hmac: [u8; 32], + /// Onion packet bytes after this dummy layer is peeled. + new_packet_bytes: [u8; ONION_DATA_LEN], + }, /// This onion payload was for us, not for forwarding to a next-hop. Contains information for /// verifying the incoming payment. Receive { @@ -2256,6 +2267,7 @@ impl Hop { match self { Hop::Forward { shared_secret, .. } => shared_secret, Hop::BlindedForward { shared_secret, .. } => shared_secret, + Hop::Dummy { shared_secret, .. } => shared_secret, Hop::TrampolineForward { outer_shared_secret, .. } => outer_shared_secret, Hop::TrampolineBlindedForward { outer_shared_secret, .. } => outer_shared_secret, Hop::Receive { shared_secret, .. } => shared_secret, From d2ccc93701dc0972ebe2cb73f41998c41e305fdc Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 9 Oct 2025 23:09:50 +0530 Subject: [PATCH 2/7] Introduce Dummy Hop support in Blinded Path Constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new constructor for blinded paths that allows specifying the number of dummy hops. This enables users to insert arbitrary hops before the real destination, enhancing privacy by making it harder to infer the sender–receiver distance or identify the final destination. Lays the groundwork for future use of dummy hops in blinded path construction. --- lightning/src/blinded_path/payment.rs | 62 ++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 0993597576b..1b27dd55316 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -12,6 +12,7 @@ use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; +use crate::blinded_path::message::MAX_DUMMY_HOPS_COUNT; use crate::blinded_path::utils::{self, BlindedPathWithPadding}; use crate::blinded_path::{BlindedHop, BlindedPath, IntroductionNode, NodeIdLookUp}; use crate::crypto::streams::ChaChaDualPolyReadAdapter; @@ -121,6 +122,32 @@ impl BlindedPaymentPath { local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, entropy_source: ES, secp_ctx: &Secp256k1, ) -> Result + where + ES::Target: EntropySource, + { + BlindedPaymentPath::new_with_dummy_hops( + intermediate_nodes, + payee_node_id, + 0, + local_node_receive_key, + payee_tlvs, + htlc_maximum_msat, + min_final_cltv_expiry_delta, + entropy_source, + secp_ctx, + ) + } + + /// Same as [`BlindedPaymentPath::new`], but allows specifying a number of dummy hops. + /// + /// Note: + /// At most [`MAX_DUMMY_HOPS_COUNT`] dummy hops can be added to the blinded path. + pub fn new_with_dummy_hops( + intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey, + dummy_hop_count: usize, local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, + htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, entropy_source: ES, + secp_ctx: &Secp256k1, + ) -> Result where ES::Target: EntropySource, { @@ -145,6 +172,7 @@ impl BlindedPaymentPath { secp_ctx, intermediate_nodes, payee_node_id, + dummy_hop_count, payee_tlvs, &blinding_secret, local_node_receive_key, @@ -644,22 +672,46 @@ pub(crate) const PAYMENT_PADDING_ROUND_OFF: usize = 30; /// Construct blinded payment hops for the given `intermediate_nodes` and payee info. pub(super) fn blinded_hops( secp_ctx: &Secp256k1, intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey, - payee_tlvs: ReceiveTlvs, session_priv: &SecretKey, local_node_receive_key: ReceiveAuthKey, + dummy_hop_count: usize, payee_tlvs: ReceiveTlvs, session_priv: &SecretKey, + local_node_receive_key: ReceiveAuthKey, ) -> Vec { + let dummy_count = core::cmp::min(dummy_hop_count, MAX_DUMMY_HOPS_COUNT); let pks = intermediate_nodes .iter() .map(|node| (node.node_id, None)) + .chain(core::iter::repeat((payee_node_id, Some(local_node_receive_key))).take(dummy_count)) .chain(core::iter::once((payee_node_id, Some(local_node_receive_key)))); let tlvs = intermediate_nodes .iter() .map(|node| BlindedPaymentTlvsRef::Forward(&node.tlvs)) + .chain((0..dummy_count).map(|_| BlindedPaymentTlvsRef::Dummy)) .chain(core::iter::once(BlindedPaymentTlvsRef::Receive(&payee_tlvs))); - let path = pks.zip( - tlvs.map(|tlv| BlindedPathWithPadding { tlvs: tlv, round_off: PAYMENT_PADDING_ROUND_OFF }), - ); + let path: Vec<_> = pks + .zip( + tlvs.map(|tlv| BlindedPathWithPadding { + tlvs: tlv, + round_off: PAYMENT_PADDING_ROUND_OFF, + }), + ) + .collect(); + + // Debug invariant: all non-final hops must have identical serialized size. + #[cfg(debug_assertions)] + if let Some((_, first)) = path.first() { + if path.len() > 2 { + let expected = first.serialized_length(); + + for (_, hop) in path.iter().skip(1).take(path.len() - 2) { + debug_assert!( + hop.serialized_length() == expected, + "All intermediate blinded hops must have identical serialized size" + ); + } + } + } - utils::construct_blinded_hops(secp_ctx, path, session_priv) + utils::construct_blinded_hops(secp_ctx, path.into_iter(), session_priv) } /// `None` if underflow occurs. From 7defb72180145bd14d5d7454656aa165ea9ff214 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 20 Oct 2025 17:13:31 +0530 Subject: [PATCH 3/7] Refactor: Introduce ForwardInfo NextPacketDetails currently bundles four fields used to define the forwarding details for the packet. With the introduction of dummy hops, not all of these fields apply in those paths. To avoid overloading NextPacketDetails with conditional semantics, this refactor extracts the forwarding-specific pieces into a dedicated ForwardInfo struct. This keeps the data model clean, reusable, and makes the logic around dummy hops easier to follow. --- lightning/src/ln/blinded_payment_tests.rs | 12 +++-- lightning/src/ln/channelmanager.rs | 29 +++++++----- lightning/src/ln/onion_payment.rs | 55 ++++++++++++++++------- 3 files changed, 65 insertions(+), 31 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 5bf015e7c81..d6f84e447b1 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1663,8 +1663,9 @@ fn route_blinding_spec_test_vector() { hop_data: carol_packet_bytes, hmac: carol_hmac, }; + let carol_forward_info = carol_packet_details.next_hop_forward_info.unwrap(); let carol_update_add = update_add_msg( - carol_packet_details.outgoing_amt_msat, carol_packet_details.outgoing_cltv_value, + carol_forward_info.outgoing_amt_msat, carol_forward_info.outgoing_cltv_value, Some(pubkey_from_hex("034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0")), carol_onion ); @@ -1697,8 +1698,9 @@ fn route_blinding_spec_test_vector() { hop_data: dave_packet_bytes, hmac: dave_hmac, }; + let dave_forward_info = dave_packet_details.next_hop_forward_info.unwrap(); let dave_update_add = update_add_msg( - dave_packet_details.outgoing_amt_msat, dave_packet_details.outgoing_cltv_value, + dave_forward_info.outgoing_amt_msat, dave_forward_info.outgoing_cltv_value, Some(pubkey_from_hex("031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")), dave_onion ); @@ -1731,8 +1733,9 @@ fn route_blinding_spec_test_vector() { hop_data: eve_packet_bytes, hmac: eve_hmac, }; + let eve_forward_info = eve_packet_details.next_hop_forward_info.unwrap(); let eve_update_add = update_add_msg( - eve_packet_details.outgoing_amt_msat, eve_packet_details.outgoing_cltv_value, + eve_forward_info.outgoing_amt_msat, eve_forward_info.outgoing_cltv_value, Some(pubkey_from_hex("03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a")), eve_onion ); @@ -1963,7 +1966,8 @@ fn test_trampoline_inbound_payment_decoding() { hop_data: carol_packet_bytes, hmac: carol_hmac, }; - let carol_update_add = update_add_msg(carol_packet_details.outgoing_amt_msat, carol_packet_details.outgoing_cltv_value, None, carol_onion); + let carol_forward_info = carol_packet_details.next_hop_forward_info.unwrap(); + let carol_update_add = update_add_msg(carol_forward_info.outgoing_amt_msat, carol_forward_info.outgoing_cltv_value, None, carol_onion); let carol_node_signer = TestEcdhSigner { node_secret: carol_secret }; let (carol_peeled_onion, _) = onion_payment::decode_incoming_update_add_htlc_onion( diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 50a50ee93f2..07eb8867600 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -74,7 +74,7 @@ use crate::ln::msgs::{ use crate::ln::onion_payment::{ check_incoming_htlc_cltv, create_fwd_pending_htlc_info, create_recv_pending_htlc_info, decode_incoming_update_add_htlc_onion, invalid_payment_err_data, HopConnector, InboundHTLCErr, - NextPacketDetails, + NextHopForwardInfo, NextPacketDetails, }; use crate::ln::onion_utils::{self}; use crate::ln::onion_utils::{ @@ -4888,7 +4888,7 @@ where #[rustfmt::skip] fn can_forward_htlc_to_outgoing_channel( - &self, chan: &mut FundedChannel, msg: &msgs::UpdateAddHTLC, next_packet: &NextPacketDetails + &self, chan: &mut FundedChannel, msg: &msgs::UpdateAddHTLC, forward_info: &NextHopForwardInfo ) -> Result<(), LocalHTLCFailureReason> { if !chan.context.should_announce() && !self.config.read().unwrap().accept_forwards_to_priv_channels @@ -4898,7 +4898,7 @@ where // we don't allow forwards outbound over them. return Err(LocalHTLCFailureReason::PrivateChannelForward); } - if let HopConnector::ShortChannelId(outgoing_scid) = next_packet.outgoing_connector { + if let HopConnector::ShortChannelId(outgoing_scid) = forward_info.outgoing_connector { if chan.funding.get_channel_type().supports_scid_privacy() && outgoing_scid != chan.context.outbound_scid_alias() { // `option_scid_alias` (referred to in LDK as `scid_privacy`) means // "refuse to forward unless the SCID alias was used", so we pretend @@ -4921,10 +4921,10 @@ where return Err(LocalHTLCFailureReason::ChannelNotReady); } } - if next_packet.outgoing_amt_msat < chan.context.get_counterparty_htlc_minimum_msat() { + if forward_info.outgoing_amt_msat < chan.context.get_counterparty_htlc_minimum_msat() { return Err(LocalHTLCFailureReason::AmountBelowMinimum); } - chan.htlc_satisfies_config(msg, next_packet.outgoing_amt_msat, next_packet.outgoing_cltv_value)?; + chan.htlc_satisfies_config(msg, forward_info.outgoing_amt_msat, forward_info.outgoing_cltv_value)?; Ok(()) } @@ -4956,14 +4956,20 @@ where fn can_forward_htlc( &self, msg: &msgs::UpdateAddHTLC, next_packet_details: &NextPacketDetails ) -> Result<(), LocalHTLCFailureReason> { - let outgoing_scid = match next_packet_details.outgoing_connector { + let next_hop_forward_info = next_packet_details + .next_hop_forward_info + .as_ref() + .ok_or(LocalHTLCFailureReason::InvalidOnionPayload)?; + + let outgoing_scid = match next_hop_forward_info.outgoing_connector { HopConnector::ShortChannelId(scid) => scid, HopConnector::Trampoline(_) => { return Err(LocalHTLCFailureReason::InvalidTrampolineForward); } }; + match self.do_funded_channel_callback(outgoing_scid, |chan: &mut FundedChannel| { - self.can_forward_htlc_to_outgoing_channel(chan, msg, next_packet_details) + self.can_forward_htlc_to_outgoing_channel(chan, msg, next_hop_forward_info) }) { Some(Ok(())) => {}, Some(Err(e)) => return Err(e), @@ -4980,7 +4986,7 @@ where } let cur_height = self.best_block.read().unwrap().height + 1; - check_incoming_htlc_cltv(cur_height, next_packet_details.outgoing_cltv_value, msg.cltv_expiry)?; + check_incoming_htlc_cltv(cur_height, next_hop_forward_info.outgoing_cltv_value, msg.cltv_expiry)?; Ok(()) } @@ -6924,11 +6930,12 @@ where }; let is_intro_node_blinded_forward = next_hop.is_intro_node_blinded_forward(); - let outgoing_scid_opt = - next_packet_details_opt.as_ref().and_then(|d| match d.outgoing_connector { + let outgoing_scid_opt = next_packet_details_opt.as_ref().and_then(|d| { + d.next_hop_forward_info.as_ref().and_then(|f| match f.outgoing_connector { HopConnector::ShortChannelId(scid) => Some(scid), HopConnector::Trampoline(_) => None, - }); + }) + }); let shared_secret = next_hop.shared_secret().secret_bytes(); // Nodes shouldn't expect us to hold HTLCs for them if we don't advertise htlc_hold feature diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index ab107133e67..65226a152aa 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -469,16 +469,19 @@ where Ok(match hop { onion_utils::Hop::Forward { shared_secret, .. } | onion_utils::Hop::BlindedForward { shared_secret, .. } => { - let NextPacketDetails { - next_packet_pubkey, outgoing_amt_msat: _, outgoing_connector: _, outgoing_cltv_value - } = match next_packet_details_opt { - Some(next_packet_details) => next_packet_details, + let (next_packet_pubkey, outgoing_cltv_value) = match next_packet_details_opt { + Some(NextPacketDetails { + next_packet_pubkey, + next_hop_forward_info: Some(NextHopForwardInfo { outgoing_cltv_value, .. }), + }) => (next_packet_pubkey, outgoing_cltv_value), // Forward should always include the next hop details - None => return Err(InboundHTLCErr { - msg: "Failed to decode update add htlc onion", - reason: LocalHTLCFailureReason::InvalidOnionPayload, - err_data: Vec::new(), - }), + _ => { + return Err(InboundHTLCErr { + msg: "Failed to decode update add htlc onion", + reason: LocalHTLCFailureReason::InvalidOnionPayload, + err_data: Vec::new(), + }); + } }; if let Err(reason) = check_incoming_htlc_cltv( @@ -515,6 +518,10 @@ pub(super) enum HopConnector { pub(super) struct NextPacketDetails { pub(super) next_packet_pubkey: Result, + pub(super) next_hop_forward_info: Option, +} + +pub(super) struct NextHopForwardInfo { pub(super) outgoing_connector: HopConnector, pub(super) outgoing_amt_msat: u64, pub(super) outgoing_cltv_value: u32, @@ -591,8 +598,12 @@ where let next_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, msg.onion_routing_packet.public_key.unwrap(), &shared_secret.secret_bytes()); Some(NextPacketDetails { - next_packet_pubkey, outgoing_connector: HopConnector::ShortChannelId(short_channel_id), - outgoing_amt_msat: amt_to_forward, outgoing_cltv_value + next_packet_pubkey, + next_hop_forward_info: Some(NextHopForwardInfo { + outgoing_connector: HopConnector::ShortChannelId(short_channel_id), + outgoing_amt_msat: amt_to_forward, + outgoing_cltv_value, + }), }) } onion_utils::Hop::BlindedForward { next_hop_data: msgs::InboundOnionBlindedForwardPayload { short_channel_id, ref payment_relay, ref payment_constraints, ref features, .. }, shared_secret, .. } => { @@ -608,8 +619,12 @@ where let next_packet_pubkey = onion_utils::next_hop_pubkey(&secp_ctx, msg.onion_routing_packet.public_key.unwrap(), &shared_secret.secret_bytes()); Some(NextPacketDetails { - next_packet_pubkey, outgoing_connector: HopConnector::ShortChannelId(short_channel_id), outgoing_amt_msat: amt_to_forward, - outgoing_cltv_value + next_packet_pubkey, + next_hop_forward_info: Some(NextHopForwardInfo { + outgoing_connector: HopConnector::ShortChannelId(short_channel_id), + outgoing_amt_msat: amt_to_forward, + outgoing_cltv_value, + }), }) } onion_utils::Hop::TrampolineForward { next_trampoline_hop_data: msgs::InboundTrampolineForwardPayload { amt_to_forward, outgoing_cltv_value, next_trampoline }, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { @@ -617,10 +632,18 @@ where incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes()); Some(NextPacketDetails { next_packet_pubkey: next_trampoline_packet_pubkey, - outgoing_connector: HopConnector::Trampoline(next_trampoline), - outgoing_amt_msat: amt_to_forward, - outgoing_cltv_value, + next_hop_forward_info: Some(NextHopForwardInfo { + outgoing_connector: HopConnector::Trampoline(next_trampoline), + outgoing_amt_msat: amt_to_forward, + outgoing_cltv_value, + }), }) + }, + onion_utils::Hop::Dummy { shared_secret, .. } => { + let next_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, + msg.onion_routing_packet.public_key.unwrap(), &shared_secret.secret_bytes()); + + Some(NextPacketDetails { next_packet_pubkey, next_hop_forward_info: None }) } _ => None }; From fa07199ae981e7a3c18b2bb559a19a4c21bc96c6 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 20 Oct 2025 17:00:05 +0530 Subject: [PATCH 4/7] Introduce Payment Dummy Hop parsing mechanism --- lightning/src/blinded_path/payment.rs | 54 +++++++++--------- lightning/src/ln/channelmanager.rs | 82 ++++++++++++++++++++++++++- lightning/src/ln/onion_utils.rs | 6 ++ 3 files changed, 114 insertions(+), 28 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 1b27dd55316..2acd5d20981 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -34,7 +34,6 @@ use crate::util::ser::{ Writeable, Writer, }; -use core::mem; use core::ops::Deref; #[allow(unused_imports)] @@ -219,28 +218,31 @@ impl BlindedPaymentPath { NL::Target: NodeIdLookUp, T: secp256k1::Signing + secp256k1::Verification, { - match self.decrypt_intro_payload::(node_signer) { - Ok(( - BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), - control_tlvs_ss, - )) => { - let next_node_id = match node_id_lookup.next_node_id(short_channel_id) { - Some(node_id) => node_id, - None => return Err(()), - }; - let mut new_blinding_point = onion_utils::next_hop_pubkey( - secp_ctx, - self.inner_path.blinding_point, - control_tlvs_ss.as_ref(), - ) - .map_err(|_| ())?; - mem::swap(&mut self.inner_path.blinding_point, &mut new_blinding_point); - self.inner_path.introduction_node = IntroductionNode::NodeId(next_node_id); - self.inner_path.blinded_hops.remove(0); - Ok(()) - }, - _ => Err(()), - } + let (next_node_id, control_tlvs_ss) = + match self.decrypt_intro_payload::(node_signer).map_err(|_| ())? { + (BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), ss) => { + let node_id = node_id_lookup.next_node_id(short_channel_id).ok_or(())?; + (node_id, ss) + }, + (BlindedPaymentTlvs::Dummy, ss) => { + let node_id = node_signer.get_node_id(Recipient::Node)?; + (node_id, ss) + }, + _ => return Err(()), + }; + + let new_blinding_point = onion_utils::next_hop_pubkey( + secp_ctx, + self.inner_path.blinding_point, + control_tlvs_ss.as_ref(), + ) + .map_err(|_| ())?; + + self.inner_path.blinding_point = new_blinding_point; + self.inner_path.introduction_node = IntroductionNode::NodeId(next_node_id); + self.inner_path.blinded_hops.remove(0); + + Ok(()) } pub(crate) fn decrypt_intro_payload( @@ -262,9 +264,9 @@ impl BlindedPaymentPath { .map_err(|_| ())?; match (&readable, used_aad) { - (BlindedPaymentTlvs::Forward(_), false) | (BlindedPaymentTlvs::Receive(_), true) => { - Ok((readable, control_tlvs_ss)) - }, + (BlindedPaymentTlvs::Forward(_), false) + | (BlindedPaymentTlvs::Dummy, true) + | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), _ => Err(()), } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 07eb8867600..16dfed38f7f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -66,7 +66,7 @@ use crate::ln::channel_state::ChannelDetails; use crate::ln::funding::SpliceContribution; use crate::ln::inbound_payment; use crate::ln::interactivetxs::InteractiveTxMessageSend; -use crate::ln::msgs; +use crate::ln::msgs::{self, OnionPacket, UpdateAddHTLC}; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, DecodeError, LightningError, MessageSendEvent, @@ -6857,6 +6857,7 @@ where pub(crate) fn process_pending_update_add_htlcs(&self) -> bool { let mut should_persist = false; let mut decode_update_add_htlcs = new_hash_map(); + let mut dummy_update_add_htlcs = new_hash_map(); mem::swap(&mut decode_update_add_htlcs, &mut self.decode_update_add_htlcs.lock().unwrap()); let get_htlc_failure_type = |outgoing_scid_opt: Option, payment_hash: PaymentHash| { @@ -6920,7 +6921,67 @@ where &*self.logger, &self.secp_ctx, ) { - Ok(decoded_onion) => decoded_onion, + Ok(decoded_onion) => match decoded_onion { + ( + onion_utils::Hop::Dummy { + intro_node_blinding_point, + next_hop_hmac, + new_packet_bytes, + .. + }, + Some(NextPacketDetails { + next_packet_pubkey, + next_hop_forward_info, + }), + ) => { + debug_assert!( + next_hop_forward_info.is_none(), + "Dummy hops must not contain any forward info, since they are not actually forwarded." + ); + + // Dummy hops are not forwarded. Instead, we reconstruct a new UpdateAddHTLC + // with the next onion packet (ephemeral pubkey, hop data, and HMAC) and push + // it back into our own processing queue. This lets us step through the dummy + // layers locally until we reach the next real hop. + let next_blinding_point = intro_node_blinding_point + .or(update_add_htlc.blinding_point) + .and_then(|blinding_point| { + let ss = self + .node_signer + .ecdh(Recipient::Node, &blinding_point, None) + .ok()? + .secret_bytes(); + + onion_utils::next_hop_pubkey( + &self.secp_ctx, + blinding_point, + &ss, + ) + .ok() + }); + + let new_onion_packet = OnionPacket { + version: 0, + public_key: next_packet_pubkey, + hop_data: new_packet_bytes, + hmac: next_hop_hmac, + }; + + let new_update_add_htlc = UpdateAddHTLC { + onion_routing_packet: new_onion_packet, + blinding_point: next_blinding_point, + ..update_add_htlc.clone() + }; + + dummy_update_add_htlcs + .entry(incoming_scid_alias) + .or_insert_with(Vec::new) + .push(new_update_add_htlc); + + continue; + }, + _ => decoded_onion, + }, Err((htlc_fail, reason)) => { let failure_type = HTLCHandlingFailureType::InvalidOnion; @@ -7077,6 +7138,23 @@ where )); } } + + // Merge peeled dummy HTLCs into the existing decode queue so they can be + // processed in the next iteration. We avoid replacing the whole queue + // (e.g. via mem::swap) because other threads may have enqueued new HTLCs + // meanwhile; merging preserves everything safely. + if !dummy_update_add_htlcs.is_empty() { + let mut decode_update_add_htlc_source = + self.decode_update_add_htlcs.lock().unwrap(); + + for (incoming_scid_alias, htlcs) in dummy_update_add_htlcs.into_iter() { + decode_update_add_htlc_source + .entry(incoming_scid_alias) + .or_default() + .extend(htlcs); + } + } + should_persist } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 0d4184ac873..9d85a2510d5 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2335,6 +2335,12 @@ where new_packet_bytes, }) }, + msgs::InboundOnionPayload::Dummy { intro_node_blinding_point } => Ok(Hop::Dummy { + intro_node_blinding_point, + shared_secret, + next_hop_hmac, + new_packet_bytes, + }), _ => { if blinding_point.is_some() { return Err(OnionDecodeErr::Malformed { From 00daca8827e855cb56386708af0554469fb4357a Mon Sep 17 00:00:00 2001 From: shaavan Date: Tue, 21 Oct 2025 18:58:53 +0530 Subject: [PATCH 5/7] Update PaymentPath, and ClaimAlongRoute arguments Upcoming commits will need the ability to specify whether a blinded path contains dummy hops. This change adds that support to the testing framework ahead of time, so later tests can express dummy-hop scenarios explicitly. --- lightning/src/ln/channelmanager.rs | 8 +--- lightning/src/ln/functional_test_utils.rs | 52 +++++++++++++++++++++-- lightning/src/ln/offers_tests.rs | 48 +++++++++++++++------ 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 16dfed38f7f..49a7b6c7156 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -7144,14 +7144,10 @@ where // (e.g. via mem::swap) because other threads may have enqueued new HTLCs // meanwhile; merging preserves everything safely. if !dummy_update_add_htlcs.is_empty() { - let mut decode_update_add_htlc_source = - self.decode_update_add_htlcs.lock().unwrap(); + let mut decode_update_add_htlc_source = self.decode_update_add_htlcs.lock().unwrap(); for (incoming_scid_alias, htlcs) in dummy_update_add_htlcs.into_iter() { - decode_update_add_htlc_source - .entry(incoming_scid_alias) - .or_default() - .extend(htlcs); + decode_update_add_htlc_source.entry(incoming_scid_alias).or_default().extend(htlcs); } } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index e31630a4926..03f9b2434ce 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3435,6 +3435,7 @@ fn fail_payment_along_path<'a, 'b, 'c>(expected_path: &[&Node<'a, 'b, 'c>]) { pub struct PassAlongPathArgs<'a, 'b, 'c, 'd> { pub origin_node: &'a Node<'b, 'c, 'd>, pub expected_path: &'a [&'a Node<'b, 'c, 'd>], + pub dummy_hop_override: Option, pub recv_value: u64, pub payment_hash: PaymentHash, pub payment_secret: Option, @@ -3456,6 +3457,7 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { Self { origin_node, expected_path, + dummy_hop_override: None, recv_value, payment_hash, payment_secret: None, @@ -3503,12 +3505,17 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { self.expected_failure = Some(failure); self } + pub fn with_dummy_override(mut self, dummy_override: usize) -> Self { + self.dummy_hop_override = Some(dummy_override); + self + } } pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option { let PassAlongPathArgs { origin_node, expected_path, + dummy_hop_override, recv_value, payment_hash: our_payment_hash, payment_secret: our_payment_secret, @@ -3755,6 +3762,29 @@ pub struct ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { pub origin_node: &'a Node<'b, 'c, 'd>, pub expected_paths: &'a [&'a [&'a Node<'b, 'c, 'd>]], pub expected_extra_fees: Vec, + /// A one-off adjustment used only in tests to account for an existing + /// fee-handling trade-off in LDK. + /// + /// When the payer is the introduction node of a blinded path, LDK does not + /// subtract the forward fee for the `payer -> next_hop` channel + /// (see [`BlindedPaymentPath::advance_path_by_one`]). This keeps the fee + /// logic simpler at the cost of a small, intentional overpayment. + /// + /// In the simple two-hop case (payer as introduction node → payee), + /// this overpayment has historically been avoided by simply not charging + /// the payer the forward fee, since the payer knows there is only + /// a single hop after them. + /// + /// However, with the introduction of dummy hops in LDK v0.3, even a + /// two-node real path (payer as introduction node → payee) may appear as a + /// multi-hop blinded path. This makes the existing overpayment surface in + /// tests. + /// + /// Until the fee-handling trade-off is revisited, this field allows tests + /// to compensate for that expected difference. + /// + /// [`BlindedPaymentPath::advance_path_by_one`]: crate::blinded_path::payment::BlindedPaymentPath::advance_path_by_one + pub expected_extra_total_fees_msat: u64, pub expected_min_htlc_overpay: Vec, pub skip_last: bool, pub payment_preimage: PaymentPreimage, @@ -3778,6 +3808,7 @@ impl<'a, 'b, 'c, 'd> ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { origin_node, expected_paths, expected_extra_fees: vec![0; expected_paths.len()], + expected_extra_total_fees_msat: 0, expected_min_htlc_overpay: vec![0; expected_paths.len()], skip_last: false, payment_preimage, @@ -3793,6 +3824,10 @@ impl<'a, 'b, 'c, 'd> ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { self.expected_extra_fees = extra_fees; self } + pub fn with_expected_extra_total_fees_msat(mut self, extra_total_fees: u64) -> Self { + self.expected_extra_total_fees_msat = extra_total_fees; + self + } pub fn with_expected_min_htlc_overpay(mut self, extra_fees: Vec) -> Self { self.expected_min_htlc_overpay = extra_fees; self @@ -3817,6 +3852,7 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { payment_preimage: our_payment_preimage, allow_1_msat_fee_overpay, custom_tlvs, + .. } = args; let claim_event = expected_paths[0].last().unwrap().node.get_and_clear_pending_events(); assert_eq!(claim_event.len(), 1); @@ -4049,13 +4085,21 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { expected_total_fee_msat } + pub fn claim_payment_along_route( args: ClaimAlongRouteArgs, ) -> (Option, Vec) { - let origin_node = args.origin_node; - let payment_preimage = args.payment_preimage; - let skip_last = args.skip_last; - let expected_total_fee_msat = do_claim_payment_along_route(args); + let ClaimAlongRouteArgs { + origin_node, + payment_preimage, + skip_last, + expected_extra_total_fees_msat, + .. + } = args; + + let expected_total_fee_msat = + do_claim_payment_along_route(args) + expected_extra_total_fees_msat; + if !skip_last { expect_payment_sent!(origin_node, payment_preimage, Some(expected_total_fee_msat)) } else { diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 4c53aefe58d..0b2d5b86add 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -185,7 +185,20 @@ fn route_bolt12_payment<'a, 'b, 'c>( fn claim_bolt12_payment<'a, 'b, 'c>( node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], expected_payment_context: PaymentContext, invoice: &Bolt12Invoice ) { - let recipient = &path[path.len() - 1]; + claim_bolt12_payment_with_extra_fees( + node, + path, + expected_payment_context, + invoice, + None, + ) +} + +fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], expected_payment_context: PaymentContext, invoice: &Bolt12Invoice, + expected_extra_fees_msat: Option, +) { + let recipient = path.last().expect("Empty path?"); let payment_purpose = match get_event!(recipient, Event::PaymentClaimable) { Event::PaymentClaimable { purpose, .. } => purpose, _ => panic!("No Event::PaymentClaimable"), @@ -194,20 +207,29 @@ fn claim_bolt12_payment<'a, 'b, 'c>( Some(preimage) => preimage, None => panic!("No preimage in Event::PaymentClaimable"), }; - match payment_purpose { - PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { - assert_eq!(PaymentContext::Bolt12Offer(payment_context), expected_payment_context); - }, - PaymentPurpose::Bolt12RefundPayment { payment_context, .. } => { - assert_eq!(PaymentContext::Bolt12Refund(payment_context), expected_payment_context); - }, + let context = match payment_purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => + PaymentContext::Bolt12Offer(payment_context), + PaymentPurpose::Bolt12RefundPayment { payment_context, .. } => + PaymentContext::Bolt12Refund(payment_context), _ => panic!("Unexpected payment purpose: {:?}", payment_purpose), - } - if let Some(inv) = claim_payment(node, path, payment_preimage) { - assert_eq!(inv, PaidBolt12Invoice::Bolt12Invoice(invoice.to_owned())); - } else { - panic!("Expected PaidInvoice::Bolt12Invoice"); }; + + assert_eq!(context, expected_payment_context); + + let expected_paths = [path]; + let mut args = ClaimAlongRouteArgs::new( + node, + &expected_paths, + payment_preimage, + ); + + if let Some(extra) = expected_extra_fees_msat { + args = args.with_expected_extra_total_fees_msat(extra); + } + + let (inv, _) = claim_payment_along_route(args); + assert_eq!(inv, Some(PaidBolt12Invoice::Bolt12Invoice(invoice.clone()))); } fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { From 2a96286f650dc1bb57131193658eee680fd566a7 Mon Sep 17 00:00:00 2001 From: shaavan Date: Tue, 18 Nov 2025 23:26:03 +0530 Subject: [PATCH 6/7] Introduce payment dummy hops in DefaultRouter --- lightning-dns-resolver/src/lib.rs | 6 ++++++ lightning/src/ln/async_payments_tests.rs | 9 ++++++++- lightning/src/ln/functional_test_utils.rs | 13 ++++++++++++- lightning/src/ln/offers_tests.rs | 19 ++++++++++++++++--- lightning/src/routing/router.rs | 15 +++++++++------ 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index 471c7562702..ac8028649ef 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -175,6 +175,7 @@ mod test { use lightning::onion_message::messenger::{ AOnionMessenger, Destination, MessageRouter, OnionMessagePath, OnionMessenger, }; + use lightning::routing::router::DEFAULT_PAYMENT_DUMMY_HOPS; use lightning::sign::{KeysManager, NodeSigner, ReceiveAuthKey, Recipient}; use lightning::types::features::InitFeatures; use lightning::types::payment::PaymentHash; @@ -419,6 +420,11 @@ mod test { let updates = get_htlc_update_msgs(&nodes[0], &payee_id); nodes[1].node.handle_update_add_htlc(payer_id, &updates.update_add_htlcs[0]); do_commitment_signed_dance(&nodes[1], &nodes[0], &updates.commitment_signed, false, false); + + for _ in 0..DEFAULT_PAYMENT_DUMMY_HOPS { + nodes[1].node.process_pending_htlc_forwards(); + } + expect_and_process_pending_htlcs(&nodes[1], false); let claimable_events = nodes[1].node.get_and_clear_pending_events(); diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 8e7fbdf94fd..01abf21f6eb 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -55,7 +55,7 @@ use crate::onion_message::messenger::{ use crate::onion_message::offers::OffersMessage; use crate::onion_message::packet::ParsedOnionMessageContents; use crate::prelude::*; -use crate::routing::router::{Payee, PaymentParameters}; +use crate::routing::router::{Payee, PaymentParameters, DEFAULT_PAYMENT_DUMMY_HOPS}; use crate::sign::NodeSigner; use crate::sync::Mutex; use crate::types::features::Bolt12InvoiceFeatures; @@ -1858,6 +1858,13 @@ fn expired_static_invoice_payment_path() { blinded_path .advance_path_by_one(&nodes[1].keys_manager, &nodes[1].node, &secp_ctx) .unwrap(); + + for _ in 0..DEFAULT_PAYMENT_DUMMY_HOPS { + blinded_path + .advance_path_by_one(&nodes[2].keys_manager, &nodes[2].node, &secp_ctx) + .unwrap(); + } + match blinded_path.decrypt_intro_payload(&nodes[2].keys_manager).unwrap().0 { BlindedPaymentTlvs::Receive(tlvs) => tlvs.payment_constraints.max_cltv_expiry, _ => panic!(), diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 03f9b2434ce..1f144dada31 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -39,7 +39,9 @@ use crate::ln::peer_handler::IgnoringMessageHandler; use crate::ln::types::ChannelId; use crate::onion_message::messenger::OnionMessenger; use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync}; -use crate::routing::router::{self, PaymentParameters, Route, RouteParameters}; +use crate::routing::router::{ + self, PaymentParameters, Route, RouteParameters, DEFAULT_PAYMENT_DUMMY_HOPS, +}; use crate::sign::{EntropySource, RandomBytes}; use crate::types::features::ChannelTypeFeatures; use crate::types::features::InitFeatures; @@ -3550,6 +3552,15 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option node.node.process_pending_htlc_forwards(); } + if is_last_hop { + // At the final hop, the incoming packet contains N dummy-hop layers + // before the real HTLC. Each call to `process_pending_htlc_forwards` + // strips exactly one dummy layer, so we call it N times. + for _ in 0..dummy_hop_override.unwrap_or(DEFAULT_PAYMENT_DUMMY_HOPS) { + node.node.process_pending_htlc_forwards(); + } + } + if is_last_hop && clear_recipient_events { let events_2 = node.node.get_and_clear_pending_events(); if payment_claimable_expected { diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 0b2d5b86add..1adb4d44ac6 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -1432,7 +1432,20 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + // When the payer is the introduction node of a blinded path, LDK doesn't + // subtract the forward fee for the `payer -> next_hop` channel (see + // `BlindedPaymentPath::advance_path_by_one`). This keeps fee logic simple, + // at the cost of a small, intentional overpayment. + // + // In the old two-hop case (payer as introduction node → payee), this never + // surfaced because the payer simply wasn’t charged the forward fee. + // + // With dummy hops in LDK v0.3, even a real two-node path can appear as a + // longer blinded route, so the overpayment shows up in tests. + // + // Until the fee-handling trade-off is revisited, we pass an expected extra + // fee here so tests can compensate for it. + claim_bolt12_payment_with_extra_fees(bob, &[alice], payment_context, &invoice, Some(1000)); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -2447,9 +2460,9 @@ fn rejects_keysend_to_non_static_invoice_path() { .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); do_pass_along_path(args); let mut updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); - nodes[0].node.handle_update_fail_htlc(nodes[1].node.get_our_node_id(), &updates.update_fail_htlcs[0]); + nodes[0].node.handle_update_fail_malformed_htlc(nodes[1].node.get_our_node_id(), &updates.update_fail_malformed_htlcs[0]); do_commitment_signed_dance(&nodes[0], &nodes[1], &updates.commitment_signed, false, false); - expect_payment_failed_conditions(&nodes[0], payment_hash, true, PaymentFailedConditions::new()); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, PaymentFailedConditions::new()); } #[test] diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 371c5232511..70b13384e96 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -74,6 +74,9 @@ pub struct DefaultRouter< score_params: SP, } +/// The number of dummy hops included in [`BlindedPaymentPath`]s created by [`DefaultRouter`]. +pub const DEFAULT_PAYMENT_DUMMY_HOPS: usize = 3; + impl< G: Deref>, L: Deref, @@ -198,9 +201,9 @@ where }) }) .map(|forward_node| { - BlindedPaymentPath::new( - &[forward_node], recipient, local_node_receive_key, tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, - &*self.entropy_source, secp_ctx + BlindedPaymentPath::new_with_dummy_hops( + &[forward_node], recipient, DEFAULT_PAYMENT_DUMMY_HOPS, local_node_receive_key, tlvs.clone(), u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, secp_ctx ) }) .take(MAX_PAYMENT_PATHS) @@ -210,9 +213,9 @@ where Ok(paths) if !paths.is_empty() => Ok(paths), _ => { if network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)) { - BlindedPaymentPath::new( - &[], recipient, local_node_receive_key, tlvs, u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, - secp_ctx + BlindedPaymentPath::new_with_dummy_hops( + &[], recipient, DEFAULT_PAYMENT_DUMMY_HOPS, local_node_receive_key, tlvs, u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, secp_ctx ).map(|path| vec![path]) } else { Err(()) From 6c78eb85ac1a8a178bd5104ca6e5e7d720508938 Mon Sep 17 00:00:00 2001 From: shaavan Date: Tue, 21 Oct 2025 19:10:53 +0530 Subject: [PATCH 7/7] Introduce Blinded Payment Dummy Path test --- lightning/src/ln/blinded_payment_tests.rs | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d6f84e447b1..9c746268719 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -190,6 +190,56 @@ fn do_one_hop_blinded_path(success: bool) { } } +#[test] +fn one_hop_blinded_path_with_dummy_hops() { + 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); + let chan_upd = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.contents; + + let amt_msat = 5000; + let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[1], Some(amt_msat), None); + let payee_tlvs = ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: chan_upd.htlc_minimum_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }; + let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key(); + let dummy_hops = 2; + + let mut secp_ctx = Secp256k1::new(); + let blinded_path = BlindedPaymentPath::new_with_dummy_hops( + &[], nodes[1].node.get_our_node_id(), dummy_hops, receive_auth_key, + payee_tlvs, u64::MAX, TEST_FINAL_CLTV as u16, + &chanmon_cfgs[1].keys_manager, &secp_ctx + ).unwrap(); + + let route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::blinded(vec![blinded_path]), + amt_msat, + ); + nodes[0].node.send_payment(payment_hash, RecipientOnionFields::spontaneous_empty(), + PaymentId(payment_hash.0), route_params, Retry::Attempts(0)).unwrap(); + check_added_monitors(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + + let path = &[&nodes[1]]; + let args = + PassAlongPathArgs::new(&nodes[0], path, amt_msat, payment_hash, ev) + .with_dummy_override(dummy_hops) + .with_payment_secret(payment_secret); + + do_pass_along_path(args); + claim_payment(&nodes[0], &[&nodes[1]], payment_preimage); +} + #[test] fn mpp_to_one_hop_blinded_path() { let chanmon_cfgs = create_chanmon_cfgs(4);