From 89259253c46f8f6bc33bdc38ac2e3b2370d2c0a4 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 14 Apr 2026 09:37:06 -0400 Subject: [PATCH 01/21] ln/refactor: use amount_msat and counterparty_skimmed_fee_msat vars Followup from prefactor PR. --- lightning/src/ln/channelmanager.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1f32423507f..78845cab27d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8411,13 +8411,8 @@ impl< receiver_node_id: Some(receiver_node_id), payment_hash, purpose, - amount_msat: claimable_payment - .htlcs - .iter() - .map(|htlc| htlc.mpp_part.value) - .sum(), - counterparty_skimmed_fee_msat: claimable_payment - .total_counterparty_skimmed_msat(), + amount_msat, + counterparty_skimmed_fee_msat, receiving_channel_ids: claimable_payment.receiving_channel_ids(), claim_deadline, onion_fields: Some(claimable_payment.onion_fields.clone()), From 6168bedeae092f816e48092854fdafa35946dba3 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:28:42 -0400 Subject: [PATCH 02/21] ln: remove incoming trampoline secret from HTLCSource We don't need to track a single trampoline secret in our HTLCSource because this is already tracked in each of our previous hops contained in the source. This field was unnecessarily added under the belief that each inner trampoline onion we receive for inbound MPP trampoline would have the same session key. It can be removed with breaking changes to persistence because we currently refuse to decode trampoline forwards, and will not read HTLCSource::Trampoline to prevent downgrades. --- lightning/src/ln/channelmanager.rs | 32 ++++++++++-------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 78845cab27d..bce946d68b3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -858,7 +858,6 @@ mod fuzzy_channelmanager { /// We might be forwarding an incoming payment that was received over MPP, and therefore /// need to store the vector of corresponding `HTLCPreviousHopData` values. previous_hop_data: Vec, - incoming_trampoline_shared_secret: [u8; 32], /// Track outbound payment details once the payment has been dispatched, will be `None` /// when waiting for incoming MPP to accumulate. outbound_payment: Option, @@ -963,14 +962,9 @@ impl core::hash::Hash for HTLCSource { first_hop_htlc_msat.hash(hasher); bolt12_invoice.hash(hasher); }, - HTLCSource::TrampolineForward { - previous_hop_data, - incoming_trampoline_shared_secret, - outbound_payment, - } => { + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment } => { 2u8.hash(hasher); previous_hop_data.hash(hasher); - incoming_trampoline_shared_secret.hash(hasher); if let Some(payment) = outbound_payment { payment.payment_id.hash(hasher); payment.path.hash(hasher); @@ -9360,11 +9354,7 @@ impl< None, )); }, - HTLCSource::TrampolineForward { - previous_hop_data, - incoming_trampoline_shared_secret, - .. - } => { + HTLCSource::TrampolineForward { previous_hop_data, .. } => { let decoded_onion_failure = onion_error.decode_onion_failure(&self.secp_ctx, &self.logger, &source); log_trace!( @@ -9376,8 +9366,6 @@ impl< "unknown channel".to_string() }, ); - let incoming_trampoline_shared_secret = Some(*incoming_trampoline_shared_secret); - // TODO: when we receive a failure from a single outgoing trampoline HTLC, we don't // necessarily want to fail all of our incoming HTLCs back yet. We may have other // outgoing HTLCs that need to resolve first. This will be tracked in our @@ -9389,6 +9377,7 @@ impl< incoming_packet_shared_secret, blinded_failure, channel_id, + trampoline_shared_secret, .. } = current_hop_data; log_trace!( @@ -9400,13 +9389,17 @@ impl< LocalHTLCFailureReason::TemporaryTrampolineFailure, Vec::new(), ); + debug_assert!( + trampoline_shared_secret.is_some(), + "trampoline hop should have secret" + ); push_forward_htlcs_failure( *prev_outbound_scid_alias, get_htlc_forward_failure( blinded_failure, &onion_error, incoming_packet_shared_secret, - &incoming_trampoline_shared_secret, + &trampoline_shared_secret, &None, *htlc_id, ), @@ -17907,16 +17900,11 @@ impl Writeable for HTLCSource { 1u8.write(writer)?; field.write(writer)?; }, - HTLCSource::TrampolineForward { - ref previous_hop_data, - incoming_trampoline_shared_secret, - ref outbound_payment, - } => { + HTLCSource::TrampolineForward { ref previous_hop_data, ref outbound_payment } => { 2u8.write(writer)?; write_tlv_fields!(writer, { (1, *previous_hop_data, required_vec), - (3, incoming_trampoline_shared_secret, required), - (5, outbound_payment, option), + (3, outbound_payment, option), }); }, } From a54a4d9b7b8bdb9d58ed87924bb50eebc8f59e18 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 27 Jan 2026 13:49:35 -0500 Subject: [PATCH 03/21] ln: store incoming mpp data in PendingHTLCRouting When we receive a trampoline forward, we need to wait for MPP parts to arrive at our node before we can forward the outgoing payment onwards. This commit threads this information through to our pending htlc struct which we'll use to validate the parts we receive. --- lightning/src/ln/channelmanager.rs | 3 +++ lightning/src/ln/onion_payment.rs | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index bce946d68b3..37376c7864a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -237,6 +237,8 @@ pub enum PendingHTLCRouting { blinded: Option, /// The absolute CLTV of the inbound HTLC incoming_cltv_expiry: u32, + /// MPP data for accumulating incoming HTLCs before dispatching an outbound payment. + incoming_multipath_data: Option, }, /// The onion indicates that this is a payment for an invoice (supposedly) generated by us. /// @@ -17644,6 +17646,7 @@ impl_writeable_tlv_based_enum!(PendingHTLCRouting, (4, blinded, option), (6, node_id, required), (8, incoming_cltv_expiry, required), + (10, incoming_multipath_data, option), } ); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index e8ff9788f3c..eb73b79f6eb 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -111,6 +111,7 @@ enum RoutingInfo { next_hop_hmac: [u8; 32], shared_secret: SharedSecret, current_path_key: Option, + incoming_multipath_data: Option, }, } @@ -167,14 +168,15 @@ pub(super) fn create_fwd_pending_htlc_info( reason: LocalHTLCFailureReason::InvalidOnionPayload, err_data: Vec::new(), }), - onion_utils::Hop::TrampolineForward { next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + onion_utils::Hop::TrampolineForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { ( RoutingInfo::Trampoline { next_trampoline: next_trampoline_hop_data.next_trampoline, new_packet_bytes: new_trampoline_packet_bytes, next_hop_hmac: next_trampoline_hop_hmac, shared_secret: trampoline_shared_secret, - current_path_key: None + current_path_key: None, + incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, next_trampoline_hop_data.amt_to_forward, next_trampoline_hop_data.outgoing_cltv_value, @@ -200,7 +202,8 @@ pub(super) fn create_fwd_pending_htlc_info( new_packet_bytes: new_trampoline_packet_bytes, next_hop_hmac: next_trampoline_hop_hmac, shared_secret: trampoline_shared_secret, - current_path_key: outer_hop_data.current_path_key + current_path_key: outer_hop_data.current_path_key, + incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, amt_to_forward, outgoing_cltv_value, @@ -233,7 +236,7 @@ pub(super) fn create_fwd_pending_htlc_info( }), } } - RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key } => { + RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data: multipath_trampoline_data } => { let next_trampoline_packet_pubkey = match next_packet_pubkey_opt { Some(Ok(pubkey)) => pubkey, _ => return Err(InboundHTLCErr { @@ -260,7 +263,8 @@ pub(super) fn create_fwd_pending_htlc_info( failure: intro_node_blinding_point .map(|_| BlindedFailure::FromIntroductionNode) .unwrap_or(BlindedFailure::FromBlindedNode), - }) + }), + incoming_multipath_data: multipath_trampoline_data, } } }; From 98f00cb83521be7dc7172e29f52f53bb8996357a Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 08:39:20 -0400 Subject: [PATCH 04/21] f: don't rebind incoming_multipath_data in destructure Review comment from valentinewallace on lightning/src/ln/onion_payment.rs:239: "nit: no need to rebind the field here" The field name `incoming_multipath_data` is fine on its own; no rename was needed for the destructure or the construction site below. --- lightning/src/ln/onion_payment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index eb73b79f6eb..4c31b63e865 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -236,7 +236,7 @@ pub(super) fn create_fwd_pending_htlc_info( }), } } - RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data: multipath_trampoline_data } => { + RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data } => { let next_trampoline_packet_pubkey = match next_packet_pubkey_opt { Some(Ok(pubkey)) => pubkey, _ => return Err(InboundHTLCErr { @@ -264,7 +264,7 @@ pub(super) fn create_fwd_pending_htlc_info( .map(|_| BlindedFailure::FromIntroductionNode) .unwrap_or(BlindedFailure::FromBlindedNode), }), - incoming_multipath_data: multipath_trampoline_data, + incoming_multipath_data, } } }; From 7d012ae56dba0bdf891f3bfb5ecea93d34061032 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 13:51:10 +0200 Subject: [PATCH 05/21] ln: use total_msat to calculate the amount for our next trampoline For regular blinded forwards, it's okay to use the amount in our update_add_htlc to calculate the amount that we need to foward onwards because we're only expecting on HTLC in and one HTLC out. For blinded trampoline forwards, it's possible that we have multiple incoming HTLCs that need to accumulate at our node that make our total incoming amount from which we'll calculate the amount that we need to forward onwards to the next trampoline. This commit updates our next trampoline amount calculation to use the total intended incoming amount for the payment so we can correctly calculate our next trampoline's amount. `decode_incoming_update_add_htlc_onion` is left unchanged because the call to `check_blinded` will be removed in upcoming commits. --- lightning/src/ln/onion_payment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 4c31b63e865..5c5ee454076 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -186,7 +186,7 @@ pub(super) fn create_fwd_pending_htlc_info( }, onion_utils::Hop::TrampolineBlindedForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( - msg.amount_msat, msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features + outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { // We should be returning malformed here if `msg.blinding_point` is set, but this is // unreachable right now since we checked it in `decode_update_add_htlc_onion`. From c26671637af5c337d73cfda1626b23a83d9e24e5 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 08:40:31 -0400 Subject: [PATCH 06/21] f: clarify total_msat in check_blinded_forward for MPP Review comment from ldk-claude-review-bot on lightning/src/ln/onion_payment.rs:193: > This changes the input to check_blinded_forward from msg.amount_msat > (single HTLC amount) to the MPP total_msat. ... check_blinded_payment_ > constraints at line 69 of this file calls > check_blinded_payment_constraints(inbound_amt_msat, ...) which checks > against htlc_minimum_msat. Using the total here means a per-HTLC > amount below htlc_minimum_msat would still pass if the total is above > it. Is that the intended behavior for trampoline MPP? Yes - the blinded path payinfo applies to the aggregate amount that the trampoline relay will forward onward, not to individual MPP parts arriving on the inbound. Document this inline to be clear about why we do this. --- lightning/src/ln/onion_payment.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 5c5ee454076..4fe44ac8190 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -185,6 +185,10 @@ pub(super) fn create_fwd_pending_htlc_info( ) }, onion_utils::Hop::TrampolineBlindedForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + // The blinded path's payment_relay and payment_constraints apply to the aggregate + // amount that the trampoline node will forward onward, not the individual amount that + // arrives in a single (incoming MPP) HTLC. We used the desired total amount to + // calculate our outbound values. let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { From bbeec58cb7346b5939a93bc1ee1c0f9128543e37 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 13:55:39 -0400 Subject: [PATCH 07/21] ln: use outer onion values in PendingHTLCInfo for trampoline When we are a trampoline node receiving an incoming HTLC, we need access to our outer onion's amount_to_forward to check that we have been forwarded the correct amount. We can't use the amount in the inner onion, because that contains our fee budget - somebody could forward us less than we were intended to receive, and provided it is within the trampoline fee budget we wouldn't know. In this commit we set our outer onion values in PendingHTLCInfo to perform this validation properly. In the commit that follows, we'll start tracking our expected trampoline values in trampoline-specific routing info. --- lightning/src/ln/onion_payment.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 4fe44ac8190..df32d50f5c1 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -178,8 +178,8 @@ pub(super) fn create_fwd_pending_htlc_info( current_path_key: None, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, - next_trampoline_hop_data.amt_to_forward, - next_trampoline_hop_data.outgoing_cltv_value, + outer_hop_data.amt_to_forward, + outer_hop_data.outgoing_cltv_value, None, None ) @@ -189,7 +189,7 @@ pub(super) fn create_fwd_pending_htlc_info( // amount that the trampoline node will forward onward, not the individual amount that // arrives in a single (incoming MPP) HTLC. We used the desired total amount to // calculate our outbound values. - let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( + let (_next_hop_amount, _next_hop_cltv) = check_blinded_forward( outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { // We should be returning malformed here if `msg.blinding_point` is set, but this is @@ -209,8 +209,8 @@ pub(super) fn create_fwd_pending_htlc_info( current_path_key: outer_hop_data.current_path_key, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, }, - amt_to_forward, - outgoing_cltv_value, + outer_hop_data.amt_to_forward, + outer_hop_data.outgoing_cltv_value, next_trampoline_hop_data.intro_node_blinding_point, next_trampoline_hop_data.next_blinding_override ) From 6b50ad607b0d8a11b3f73b63d09fbd4e74d68024 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 13:56:58 -0400 Subject: [PATCH 08/21] ln: store next trampoline amount and cltv in PendingHTLCRouting When we're forwarding a trampoline payment, we need to remember the amount and CLTV that the next trampoline is expecting. --- lightning/src/ln/channelmanager.rs | 6 ++++++ lightning/src/ln/onion_payment.rs | 13 +++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 37376c7864a..3658e8ca570 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -239,6 +239,10 @@ pub enum PendingHTLCRouting { incoming_cltv_expiry: u32, /// MPP data for accumulating incoming HTLCs before dispatching an outbound payment. incoming_multipath_data: Option, + /// The amount that the next trampoline is expecting to receive. + next_trampoline_amt_msat: u64, + /// The CLTV expiry height that the next trampoline is expecting to receive. + next_trampoline_cltv_expiry: u32, }, /// The onion indicates that this is a payment for an invoice (supposedly) generated by us. /// @@ -17647,6 +17651,8 @@ impl_writeable_tlv_based_enum!(PendingHTLCRouting, (6, node_id, required), (8, incoming_cltv_expiry, required), (10, incoming_multipath_data, option), + (12, next_trampoline_amt_msat, required), + (14, next_trampoline_cltv_expiry, required), } ); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index df32d50f5c1..36270ebb5e0 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -112,6 +112,8 @@ enum RoutingInfo { shared_secret: SharedSecret, current_path_key: Option, incoming_multipath_data: Option, + next_trampoline_amt_msat: u64, + next_trampoline_cltv: u32, }, } @@ -177,6 +179,8 @@ pub(super) fn create_fwd_pending_htlc_info( shared_secret: trampoline_shared_secret, current_path_key: None, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, + next_trampoline_amt_msat: next_trampoline_hop_data.amt_to_forward, + next_trampoline_cltv: next_trampoline_hop_data.outgoing_cltv_value, }, outer_hop_data.amt_to_forward, outer_hop_data.outgoing_cltv_value, @@ -189,7 +193,7 @@ pub(super) fn create_fwd_pending_htlc_info( // amount that the trampoline node will forward onward, not the individual amount that // arrives in a single (incoming MPP) HTLC. We used the desired total amount to // calculate our outbound values. - let (_next_hop_amount, _next_hop_cltv) = check_blinded_forward( + let (next_hop_amount, next_hop_cltv) = check_blinded_forward( outer_hop_data.multipath_trampoline_data.as_ref().map(|f| f.total_msat).unwrap_or(msg.amount_msat), msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { // We should be returning malformed here if `msg.blinding_point` is set, but this is @@ -208,6 +212,8 @@ pub(super) fn create_fwd_pending_htlc_info( shared_secret: trampoline_shared_secret, current_path_key: outer_hop_data.current_path_key, incoming_multipath_data: outer_hop_data.multipath_trampoline_data, + next_trampoline_amt_msat: next_hop_amount, + next_trampoline_cltv: next_hop_cltv, }, outer_hop_data.amt_to_forward, outer_hop_data.outgoing_cltv_value, @@ -240,7 +246,7 @@ pub(super) fn create_fwd_pending_htlc_info( }), } } - RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data } => { + RoutingInfo::Trampoline { next_trampoline, new_packet_bytes, next_hop_hmac, shared_secret, current_path_key, incoming_multipath_data, next_trampoline_amt_msat, next_trampoline_cltv } => { let next_trampoline_packet_pubkey = match next_packet_pubkey_opt { Some(Ok(pubkey)) => pubkey, _ => return Err(InboundHTLCErr { @@ -269,6 +275,9 @@ pub(super) fn create_fwd_pending_htlc_info( .unwrap_or(BlindedFailure::FromBlindedNode), }), incoming_multipath_data, + next_trampoline_amt_msat, + next_trampoline_cltv_expiry: next_trampoline_cltv, + } } }; From 75b316b89b6888d877fa83bc6856287e02ee3a6d Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Feb 2026 12:34:15 +0200 Subject: [PATCH 09/21] ln: use outer onion values for trampoline NextPacketDetails When we receive trampoline payments, we first want to validate the values in our outer onion to ensure that we've been given the amount/ expiry that the sender was intending us to receive to make sure that forwarding nodes haven't sent us less than they should. --- lightning/src/ln/onion_payment.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 36270ebb5e0..3dbb274b8e6 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -700,33 +700,24 @@ pub(super) fn decode_incoming_update_add_htlc_onion { + onion_utils::Hop::TrampolineForward { next_trampoline_hop_data: msgs::InboundTrampolineForwardPayload { next_trampoline, .. }, ref outer_hop_data, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, 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, + outgoing_amt_msat: outer_hop_data.amt_to_forward, + outgoing_cltv_value: outer_hop_data.outgoing_cltv_value, }) } - onion_utils::Hop::TrampolineBlindedForward { next_trampoline_hop_data: msgs::InboundTrampolineBlindedForwardPayload { next_trampoline, ref payment_relay, ref payment_constraints, ref features, .. }, outer_shared_secret, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { - let (amt_to_forward, outgoing_cltv_value) = match check_blinded_forward( - msg.amount_msat, msg.cltv_expiry, &payment_relay, &payment_constraints, &features - ) { - Ok((amt, cltv)) => (amt, cltv), - Err(()) => { - return encode_relay_error("Underflow calculating outbound amount or cltv value for blinded trampoline forward", - LocalHTLCFailureReason::InvalidOnionBlinding, outer_shared_secret.secret_bytes(), Some(trampoline_shared_secret.secret_bytes()), &[0; 32]); - } - }; + onion_utils::Hop::TrampolineBlindedForward { next_trampoline_hop_data: msgs::InboundTrampolineBlindedForwardPayload { next_trampoline, .. }, ref outer_hop_data, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, 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, + outgoing_amt_msat: outer_hop_data.amt_to_forward, + outgoing_cltv_value: outer_hop_data.outgoing_cltv_value, }) } _ => None From 7b17d4fbd95a406f6da65c230ca26c9293db6d6f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 30 Mar 2026 14:16:44 -0400 Subject: [PATCH 10/21] ln: add awaiting_trampoline_forwards to accumulate inbound MPP When we are a trampoline router, we need to accumulate incoming HTLCs (if MPP is used) before forwarding the trampoline-routed outgoing HTLC(s). This commit adds a new map in channel manager, and mimics the handling done for claimable_payments. We will rely on our pending_outbound_payments (which will contain a payment for trampoline forwards) for completing MPP claims, not want to surface `PaymentClaimable` events for trampoline, so do not need to have pending_claiming_payments like we have for MPP receives. This map is not persisted, as we're currently working on refactoring restart logic to depend on channel monitors. We should not use this accumulation map in production yet, as we can hit a force close if: - We are used as a trampoline, despite not supporting the feature - A trampoline MPP part arrives and is committed to the inbound channel and added to `awaiting_trampoline_forwards` - We restart and the MPP part is not re-added to `awaiting_trampoline_forwards` In this scenario, we will not hit our MPP timeout logic for this HTLC because we have "forgotten" about it. It will be up to our counterparty to force close the channel on us, because we're not failing it back after we hit MPP timeout. Likewise, even if other MPP parts arrive, we won't consider the inbound accumulation to be complete so we'll fail them back but forget about the HTLC that came before the restart. We currently reject trampoline HTLCs earlier in the lifecycle, so we are not at risk of producing a state that could trigger such a force close. In the commits that follow, we'll allow forwarding of trampoline HTLC for tests so that we can start to cover this code. --- lightning/src/ln/channelmanager.rs | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3658e8ca570..b14947345e5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1298,6 +1298,12 @@ fn check_mpp_timeout<'a>( timed_out } +/// Tracks trampoline HTLCs being accumulated before forwarding. +struct TrampolinePayment { + onion_fields: RecipientOnionFields, + htlcs: Vec, +} + /// Represent the channel funding transaction type. enum FundingType { /// This variant is useful when we want LDK to validate the funding transaction and @@ -2873,6 +2879,16 @@ pub struct ChannelManager< /// [`ClaimablePayments`]' individual field docs for more info. claimable_payments: Mutex, + /// The sets of trampoline payments which are in the process of being accumulated on inbound + /// channel(s). + /// + /// Note that this map is currently not persisted, as there is ongoing work to refactor our + /// reload from disk depending only on channel managers. Until proper restart logic is added + /// we will "forget" about any HTLCs that are pending in this map on restart waiting for MPP + /// timeout. For this reason, we should not forward any trampoline HTLCs until properly + /// implemented. + awaiting_trampoline_forwards: Mutex>, + /// The set of outbound SCID aliases across all our channels, including unconfirmed channels /// and some closed channels which reached a usable state prior to being closed. This is used /// only to avoid duplicates, and is not persisted explicitly to disk, but rebuilt from the @@ -3699,6 +3715,7 @@ impl< forward_htlcs: Mutex::new(new_hash_map()), decode_update_add_htlcs: Mutex::new(new_hash_map()), claimable_payments: Mutex::new(ClaimablePayments { claimable_payments: new_hash_map(), pending_claiming_payments: new_hash_map() }), + awaiting_trampoline_forwards: Mutex::new(new_hash_map()), pending_intercepted_htlcs: Mutex::new(new_hash_map()), short_to_chan_info: FairRwLock::new(new_hash_map()), @@ -9056,6 +9073,26 @@ impl< }, ); + self.awaiting_trampoline_forwards.lock().unwrap().retain(|payment_hash, payment| { + if payment.htlcs.is_empty() { + debug_assert!(false); + return false; + } + let mpp_timeout = + check_mpp_timeout(payment.htlcs.iter_mut(), &payment.onion_fields); + if mpp_timeout { + let previous_hop_data = + payment.htlcs.drain(..).map(|claimable| claimable.prev_hop).collect(); + + timed_out_mpp_htlcs.push(( + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment: None }, + *payment_hash, + HTLCHandlingFailureType::TrampolineForward {}, + )); + } + !mpp_timeout + }); + for (htlc_source, payment_hash, failure_type) in timed_out_mpp_htlcs.drain(..) { let failure_reason = LocalHTLCFailureReason::MPPTimeout; let reason = HTLCFailReason::from_failure_code(failure_reason); @@ -16352,6 +16389,31 @@ impl< }, ); + self.awaiting_trampoline_forwards.lock().unwrap().retain(|payment_hash, payment| { + if payment.htlcs.is_empty() { + debug_assert!(false); + return false; + } + let htlc_timed_out = + payment.htlcs.iter().any(|htlc| htlc.check_onchain_timeout(height)); + if htlc_timed_out { + let previous_hop_data = + payment.htlcs.drain(..).map(|claimable| claimable.prev_hop).collect(); + + let failure_reason = LocalHTLCFailureReason::CLTVExpiryTooSoon; + timed_out_htlcs.push(( + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment: None }, + *payment_hash, + HTLCFailReason::reason( + failure_reason, + self.get_htlc_inbound_temp_fail_data(failure_reason), + ), + HTLCHandlingFailureType::TrampolineForward {}, + )); + } + !htlc_timed_out + }); + let mut intercepted_htlcs = self.pending_intercepted_htlcs.lock().unwrap(); intercepted_htlcs.retain(|_, htlc| { if height >= htlc.forward_info.outgoing_cltv_value - HTLC_FAIL_BACK_BUFFER { @@ -20229,6 +20291,7 @@ impl< claimable_payments, pending_claiming_payments, }), + awaiting_trampoline_forwards: Mutex::new(new_hash_map()), outbound_scid_aliases: Mutex::new(outbound_scid_aliases), short_to_chan_info: FairRwLock::new(short_to_chan_info), fake_scid_rand_bytes: fake_scid_rand_bytes.unwrap(), From 05d97917dcf1ee80b86d21509eed99476f32714b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 10 Apr 2026 14:07:33 -0400 Subject: [PATCH 11/21] ln: add trampoline mpp accumulation with rejection on completion Add our MPP accumulation logic for trampoline payments, but reject them when they fully arrive. This allows us to test parts of our trampoline flow without fully implementing outbound dispatch. This commit keeps the same first_claimable_htlc debug_assert behavior as MPP claims, asserting that we do not fail our check_claimable_incoming_htlc merge for the first HTLC that we add to a set. This assert can only be hit if our first part exceeds the `MAX_VALUE_MSAT`, which should not be hit because we check individual amounts elsewhere in the codebase (the check exists to check that multiple parts combined don't hit this overflow). --- lightning/src/ln/channelmanager.rs | 227 ++++++++++++++++++++++++++- lightning/src/ln/outbound_payment.rs | 23 ++- 2 files changed, 245 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b14947345e5..68bebd1a6e0 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -89,9 +89,9 @@ use crate::ln::outbound_payment; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::outbound_payment::PaymentSendFailure; use crate::ln::outbound_payment::{ - Bolt11PaymentError, Bolt12PaymentError, OutboundPayments, PendingOutboundPayment, - ProbeSendFailure, RecipientCustomTlvs, RecipientOnionFields, Retry, RetryableInvoiceRequest, - RetryableSendFailure, SendAlongPathArgs, StaleExpiration, + Bolt11PaymentError, Bolt12PaymentError, NextTrampolineHopInfo, OutboundPayments, + PendingOutboundPayment, ProbeSendFailure, RecipientCustomTlvs, RecipientOnionFields, Retry, + RetryableInvoiceRequest, RetryableSendFailure, SendAlongPathArgs, StaleExpiration, }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; @@ -8450,6 +8450,140 @@ impl< } } + // Handles the addition of a HTLC associated with a trampoline forward that we need to accumulate + // on the incoming link before forwarding onwards. If the HTLC is failed, it returns the source + // and error that should be used to fail the HTLC(s) back. + fn handle_trampoline_htlc( + &self, mpp_part: MppPart, onion_fields: RecipientOnionFields, payment_hash: PaymentHash, + next_hop_info: NextTrampolineHopInfo, _next_node_id: PublicKey, + ) -> Result<(), (HTLCSource, HTLCFailReason)> { + let mut trampoline_payments = self.awaiting_trampoline_forwards.lock().unwrap(); + + // We should not fail if we're adding the first htlc to a ClaimablePayment (as our + // validation compares fields across parts, and our first part can't overflow maximum + // msats because each htlc's amount is individually validated - overflow is only possible + // with multiple parts). + let mut first_trampoline_htlc = false; + let trampoline_payment = trampoline_payments.entry(payment_hash).or_insert_with(|| { + first_trampoline_htlc = true; + TrampolinePayment { htlcs: Vec::new(), onion_fields: onion_fields.clone() } + }); + + // TODO: add restriction to specification that trampoline should be consistent across + // MPP parts? Currently, we'll accept a MPP trampoline payments that specify different + // next_node_id destinations (just forwarding to the last one that arrives). + + // If MPP hasn't fully arrived yet, return early (saving indentation below). + let prev_hop = mpp_part.prev_hop.clone(); + match self.check_incoming_mpp_part( + &mut trampoline_payment.htlcs, + &mut trampoline_payment.onion_fields, + mpp_part, + onion_fields, + payment_hash, + ) { + Ok(false) => return Ok(()), + Err(()) => { + debug_assert!( + !first_trampoline_htlc, + "first trampoline HTLC should not fail check_incoming_mpp_part" + ); + return Err(( + // When we couldn't add a new HTLC, we just fail back our last received htlc, + // allowing others to wait for more MPP parts to arrive. + HTLCSource::TrampolineForward { + previous_hop_data: vec![prev_hop], + outbound_payment: None, + }, + HTLCFailReason::reason( + LocalHTLCFailureReason::InvalidTrampolineForward, + vec![], + ), + )); + }, + Ok(true) => {}, + }; + + let incoming_amt_msat: u64 = trampoline_payment.htlcs.iter().map(|h| h.value).sum(); + let incoming_cltv_expiry = + trampoline_payment.htlcs.iter().map(|h| h.cltv_expiry).min().unwrap(); + + let (forwarding_fee_proportional_millionths, forwarding_fee_base_msat, cltv_delta) = { + let config = self.config.read().unwrap(); + ( + config.channel_config.forwarding_fee_proportional_millionths, + config.channel_config.forwarding_fee_base_msat, + config.channel_config.cltv_expiry_delta as u32, + ) + }; + + let proportional_fee = (forwarding_fee_proportional_millionths as u128 + * next_hop_info.amount_msat as u128 + / 1_000_000) as u64; + let our_forwarding_fee_msat = proportional_fee + forwarding_fee_base_msat as u64; + + let trampoline_source = || -> HTLCSource { + HTLCSource::TrampolineForward { + previous_hop_data: trampoline_payment + .htlcs + .iter() + .map(|htlc| htlc.prev_hop.clone()) + .collect(), + outbound_payment: None, + } + }; + let trampoline_failure = || -> HTLCFailReason { + let mut err_data = Vec::with_capacity(10); + err_data.extend_from_slice(&forwarding_fee_base_msat.to_be_bytes()); + err_data.extend_from_slice(&forwarding_fee_proportional_millionths.to_be_bytes()); + err_data.extend_from_slice(&(cltv_delta as u16).to_be_bytes()); + HTLCFailReason::reason( + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient, + err_data, + ) + }; + + let _max_total_routing_fee_msat = match our_forwarding_fee_msat + .checked_add(next_hop_info.amount_msat) + .and_then(|total| incoming_amt_msat.checked_sub(total)) + { + Some(amount) => amount, + None => { + return Err((trampoline_source(), trampoline_failure())); + }, + }; + + let _max_total_cltv_expiry_delta = match next_hop_info + .cltv_expiry_height + .checked_add(cltv_delta) + .and_then(|total| incoming_cltv_expiry.checked_sub(total)) + { + Some(cltv_delta) => cltv_delta, + None => { + return Err((trampoline_source(), trampoline_failure())); + }, + }; + + log_debug!( + self.logger, + "Rejecting trampoline forward because we do not fully support forwarding yet.", + ); + + let source = trampoline_source(); + if trampoline_payments.remove(&payment_hash).is_none() { + log_error!( + &self.logger, + "Dispatched trampoline payment: {} was not present in awaiting inbound", + payment_hash + ); + } + + Err(( + source, + HTLCFailReason::reason(LocalHTLCFailureReason::TemporaryTrampolineFailure, vec![]), + )) + } + fn process_receive_htlcs( &self, pending_forwards: &mut Vec, new_events: &mut VecDeque<(Event, Option)>, @@ -8484,6 +8618,7 @@ impl< has_recipient_created_payment_secret, invoice_request_opt, trampoline_shared_secret, + trampoline_info, ) = match routing { PendingHTLCRouting::Receive { payment_data, @@ -8512,6 +8647,7 @@ impl< true, None, trampoline_shared_secret, + None, ) }, PendingHTLCRouting::ReceiveKeysend { @@ -8546,6 +8682,62 @@ impl< has_recipient_created_payment_secret, invoice_request, None, + None, + ) + }, + PendingHTLCRouting::TrampolineForward { + trampoline_shared_secret: incoming_trampoline_shared_secret, + onion_packet, + node_id: next_trampoline, + blinded, + incoming_cltv_expiry, + incoming_multipath_data, + next_trampoline_amt_msat, + next_trampoline_cltv_expiry, + } => { + // Trampoline forwards only *need* to have MPP data if they're + // multi-part. + let onion_fields = match incoming_multipath_data { + Some(ref final_mpp) => RecipientOnionFields::secret_only( + final_mpp.payment_secret, + final_mpp.total_msat, + ), + None => RecipientOnionFields::spontaneous_empty(outgoing_amt_msat), + }; + + let next_hop_info = NextTrampolineHopInfo { + onion_packet, + blinding_point: blinded.and_then(|b| { + b.next_blinding_override.or_else(|| { + let encrypted_tlvs_ss = self + .node_signer + .ecdh(Recipient::Node, &b.inbound_blinding_point, None) + .unwrap() + .secret_bytes(); + onion_utils::next_hop_pubkey( + &self.secp_ctx, + b.inbound_blinding_point, + &encrypted_tlvs_ss, + ) + .ok() + }) + }), + amount_msat: next_trampoline_amt_msat, + cltv_expiry_height: next_trampoline_cltv_expiry, + }; + ( + incoming_cltv_expiry, + // Unused for trampoline forwards; MppPart is constructed + // directly below. + OnionPayload::Invoice { _legacy_hop_data: None }, + incoming_multipath_data, + None, + None, + onion_fields, + false, + None, + Some(incoming_trampoline_shared_secret), + Some((next_hop_info, next_trampoline)), ) }, _ => { @@ -8556,6 +8748,35 @@ impl< // if possible so that we don't prematurely mark MPP payments complete // if routing nodes overpay let value = incoming_amt_msat.unwrap_or(outgoing_amt_msat); + // For trampoline forwards, construct MppPart directly and handle separately + // from claimable HTLCs. + if let Some((next_hop_info, next_trampoline)) = trampoline_info { + let mpp_part = MppPart { + prev_hop, + cltv_expiry, + value, + sender_intended_value: outgoing_amt_msat, + timer_ticks: 0, + total_value_received: None, + }; + if let Err((htlc_source, failure_reason)) = self.handle_trampoline_htlc( + mpp_part, + onion_fields, + payment_hash, + next_hop_info, + next_trampoline, + ) { + failed_forwards.push(( + htlc_source, + payment_hash, + failure_reason, + HTLCHandlingFailureType::TrampolineForward {}, + )); + } + continue 'next_forwardable_htlc; + } + + // If we don't have a trampoline forward, we're dealing with a MPP receive. let htlc_source = HTLCSource::PreviousHopData(HTLCPreviousHopData { prev_outbound_scid_alias: prev_hop.prev_outbound_scid_alias, user_channel_id: prev_hop.user_channel_id, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 7259f60796f..a6da513845a 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -11,7 +11,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use lightning_invoice::Bolt11Invoice; use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; @@ -21,7 +21,7 @@ use crate::ln::channelmanager::{ EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, PaymentCompleteUpdate, PaymentId, }; -use crate::ln::msgs::DecodeError; +use crate::ln::msgs::{DecodeError, TrampolineOnionPacket}; use crate::ln::onion_utils; use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; @@ -167,6 +167,25 @@ pub(crate) enum PendingOutboundPayment { }, } +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct NextTrampolineHopInfo { + /// The Trampoline packet to include for the next Trampoline hop. + pub(crate) onion_packet: TrampolineOnionPacket, + /// If blinded, the current_path_key to set at the next Trampoline hop. + pub(crate) blinding_point: Option, + /// The amount that the next trampoline is expecting to receive. + pub(crate) amount_msat: u64, + /// The cltv expiry height that the next trampoline is expecting. + pub(crate) cltv_expiry_height: u32, +} + +impl_writeable_tlv_based!(NextTrampolineHopInfo, { + (1, onion_packet, required), + (3, blinding_point, option), + (5, amount_msat, required), + (7, cltv_expiry_height, required), +}); + #[derive(Clone)] pub(crate) struct RetryableInvoiceRequest { pub(crate) invoice_request: InvoiceRequest, From 4b26edcb57b99d46fbf734559065e02e7721c853 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 08:45:53 -0400 Subject: [PATCH 12/21] f: use doc comment for handle_trampoline_htlc Review comment from valentinewallace on lightning/src/ln/channelmanager.rs:8455: "nit: doc comment" Switch the leading // comments above handle_trampoline_htlc to /// so they become proper rustdoc. --- lightning/src/ln/channelmanager.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 68bebd1a6e0..6ef10282aff 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8450,9 +8450,9 @@ impl< } } - // Handles the addition of a HTLC associated with a trampoline forward that we need to accumulate - // on the incoming link before forwarding onwards. If the HTLC is failed, it returns the source - // and error that should be used to fail the HTLC(s) back. + /// Handles the addition of a HTLC associated with a trampoline forward that we need to + /// accumulate on the incoming link before forwarding onwards. If the HTLC is failed, it + /// returns the source and error that should be used to fail the HTLC(s) back. fn handle_trampoline_htlc( &self, mpp_part: MppPart, onion_fields: RecipientOnionFields, payment_hash: PaymentHash, next_hop_info: NextTrampolineHopInfo, _next_node_id: PublicKey, From dd81ad9e8e3cf3b21c75796fff19159a3da5f957 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 08:46:10 -0400 Subject: [PATCH 13/21] f: use compute_fees and document per-trampoline-hop fee Addresses two related review comments: ldk-claude-review-bot on lightning/src/ln/channelmanager.rs:8576: > The as u64 truncation of the u128 result could silently lose data if > forwarding_fee_proportional_millionths is very large. ... the as u64 > would silently truncate, resulting in an *understated* fee, which lets > the attacker get their payment forwarded at below the node's required > fee. valentinewallace on lightning/src/ln/channelmanager.rs:8577: > Can we use router's compute_fees or compute_fees_saturating? valentinewallace on lightning/src/ln/channelmanager.rs:8576: > I wasn't sure about this at first because for normal forwards we > calculate the proportional fee based on the amount we're relaying to > the direct next hop, whereas in this case we calculate it on the next > *trampoline* hop's amount (i.e. there may be hops in between). It > looks like this is how eclair does it too, though, and maybe it isn't > possible to do any other way? Could possibly use a comment. Replace the hand-rolled `as u64` proportional-fee calculation with router::compute_fees, which uses checked_mul/checked_add internally and returns None on overflow. Chain it into the existing checked_add / checked_sub overflow handling so the trampoline failure path is taken when any step would overflow, rather than silently truncating to an understated fee. Add a comment at the call site explaining why the fee is computed against the next trampoline hop's amount. --- lightning/src/ln/channelmanager.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6ef10282aff..fbe2ccd9b3f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -113,9 +113,9 @@ use crate::onion_message::messenger::{ MessageRouter, MessageSendInstructions, Responder, ResponseInstruction, }; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; -use crate::routing::gossip::NodeId; +use crate::routing::gossip::{NodeId, RoutingFees}; use crate::routing::router::{ - BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, + compute_fees, BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, RouteParameters, RouteParametersConfig, Router, }; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -8516,12 +8516,6 @@ impl< config.channel_config.cltv_expiry_delta as u32, ) }; - - let proportional_fee = (forwarding_fee_proportional_millionths as u128 - * next_hop_info.amount_msat as u128 - / 1_000_000) as u64; - let our_forwarding_fee_msat = proportional_fee + forwarding_fee_base_msat as u64; - let trampoline_source = || -> HTLCSource { HTLCSource::TrampolineForward { previous_hop_data: trampoline_payment @@ -8543,8 +8537,21 @@ impl< ) }; + // We need to pick the maximum fee that we'll charge as a trampoline node. This could + // be any trampoline fee policy - this isn't specified or advertised. To keep things + // simple, we just calculate the amount that we would have charged to forward the amount + // going to the trampoline with our default fees, and make sure we have at least that. + // The amount that we actually dispatch will be slightly more than the amount for the next + // trampoline (since it'll also include fees for subsequent hops), so we're actually + // charging a little less than we would if this were a regular forward of that amount. As + // use of trampoline grows, we can investigate more sophisticated options. + let routing_fees = RoutingFees { + base_msat: forwarding_fee_base_msat, + proportional_millionths: forwarding_fee_proportional_millionths, + }; + let our_forwarding_fee_msat = compute_fees(next_hop_info.amount_msat, routing_fees); let _max_total_routing_fee_msat = match our_forwarding_fee_msat - .checked_add(next_hop_info.amount_msat) + .and_then(|our_fee| our_fee.checked_add(next_hop_info.amount_msat)) .and_then(|total| incoming_amt_msat.checked_sub(total)) { Some(amount) => amount, From 4bac8b0ca2739c8db381ac4eecef422292fd0bca Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 16:00:11 -0400 Subject: [PATCH 14/21] f: add trampoline mpp accumulation with rejection on completion remove accumulator entry on MPP completion regardless of failure mode The fee/CLTV underflow paths previously returned without removing the entry from awaiting_trampoline_forwards, leaving a stale accumulator that the MPP-timeout sweep would re-fail. Restructure the match so remove() runs unconditionally on Ok(true), and consume the owned TrampolinePayment for the rest of the function. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/channelmanager.rs | 37 ++++++++++++++---------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index fbe2ccd9b3f..d58acec9001 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8464,7 +8464,7 @@ impl< // msats because each htlc's amount is individually validated - overflow is only possible // with multiple parts). let mut first_trampoline_htlc = false; - let trampoline_payment = trampoline_payments.entry(payment_hash).or_insert_with(|| { + trampoline_payments.entry(payment_hash).or_insert_with(|| { first_trampoline_htlc = true; TrampolinePayment { htlcs: Vec::new(), onion_fields: onion_fields.clone() } }); @@ -8473,15 +8473,21 @@ impl< // MPP parts? Currently, we'll accept a MPP trampoline payments that specify different // next_node_id destinations (just forwarding to the last one that arrives). - // If MPP hasn't fully arrived yet, return early (saving indentation below). + // If MPP hasn't fully arrived yet, return early (saving indentation below). Once it has + // arrived, remove the entry from the map so that all downstream paths consume it. let prev_hop = mpp_part.prev_hop.clone(); - match self.check_incoming_mpp_part( - &mut trampoline_payment.htlcs, - &mut trampoline_payment.onion_fields, - mpp_part, - onion_fields, - payment_hash, - ) { + let check_result = { + let trampoline_payment = + trampoline_payments.get_mut(&payment_hash).expect("just inserted"); + self.check_incoming_mpp_part( + &mut trampoline_payment.htlcs, + &mut trampoline_payment.onion_fields, + mpp_part, + onion_fields, + payment_hash, + ) + }; + let trampoline_payment = match check_result { Ok(false) => return Ok(()), Err(()) => { debug_assert!( @@ -8501,7 +8507,7 @@ impl< ), )); }, - Ok(true) => {}, + Ok(true) => trampoline_payments.remove(&payment_hash).expect("just inserted"), }; let incoming_amt_msat: u64 = trampoline_payment.htlcs.iter().map(|h| h.value).sum(); @@ -8576,17 +8582,8 @@ impl< "Rejecting trampoline forward because we do not fully support forwarding yet.", ); - let source = trampoline_source(); - if trampoline_payments.remove(&payment_hash).is_none() { - log_error!( - &self.logger, - "Dispatched trampoline payment: {} was not present in awaiting inbound", - payment_hash - ); - } - Err(( - source, + trampoline_source(), HTLCFailReason::reason(LocalHTLCFailureReason::TemporaryTrampolineFailure, vec![]), )) } From 164f2036c1bbe217d432ab80613cef5aa6a9607f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:48:23 -0400 Subject: [PATCH 15/21] ln: double encrypt errors received from downstream failures If we're a trampoline node and received an error from downstream that we can't fully decrypt, we want to double-wrap it for the original sender. Previously not implemented because we'd only focused on receives, where there's no possibility of a downstream error. While proper error handling will be added in a followup, we add the bare minimum required here for testing. --- lightning/src/ln/onion_utils.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 602d731bac6..fc52fcf1c92 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2128,6 +2128,10 @@ impl HTLCFailReason { let mut err = err.clone(); let hold_time = hold_time.unwrap_or(0); + if let Some(secondary_shared_secret) = secondary_shared_secret { + process_failure_packet(&mut err, secondary_shared_secret, hold_time); + crypt_failure_packet(secondary_shared_secret, &mut err); + } process_failure_packet(&mut err, incoming_packet_shared_secret, hold_time); crypt_failure_packet(incoming_packet_shared_secret, &mut err); From fd5dc1ea36d7653de1c542222f7fa1488cb26b7b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:50:45 -0400 Subject: [PATCH 16/21] ln: handle DecodedOnionFailure for local trampoline failures While proper error handling will be added in a followup, we add the bare minimum required here for testing. Note that we intentionally keep the behavior of not setting `payment_failed_permanently` for local failures because we can possibly retry it because we're the sender as a trampoline forwarder. For example, a local ChannelClosed error is considered to be permanent, but we can still retry along another channel. --- lightning/src/ln/onion_utils.rs | 46 +++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index fc52fcf1c92..eff315b3820 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2143,6 +2143,23 @@ impl HTLCFailReason { pub(super) fn decode_onion_failure( &self, secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, ) -> DecodedOnionFailure { + macro_rules! decoded_onion_failure { + ($short_channel_id:expr, $failure_reason:expr, $data:expr) => { + DecodedOnionFailure { + network_update: None, + payment_failed_permanently: false, + short_channel_id: $short_channel_id, + failed_within_blinded_path: false, + hold_times: Vec::new(), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_code: Some($failure_reason), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_data: Some($data.clone()), + #[cfg(test)] + attribution_failed_channel: None, + } + }; + } match self.0 { HTLCFailReasonRepr::LightningError { ref err, .. } => { process_onion_failure(secp_ctx, logger, &htlc_source, err.clone()) @@ -2154,22 +2171,19 @@ impl HTLCFailReason { // failures here, but that would be insufficient as find_route // generally ignores its view of our own channels as we provide them via // ChannelDetails. - if let &HTLCSource::OutboundRoute { ref path, .. } = htlc_source { - DecodedOnionFailure { - network_update: None, - payment_failed_permanently: false, - short_channel_id: Some(path.hops[0].short_channel_id), - failed_within_blinded_path: false, - hold_times: Vec::new(), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_code: Some(*failure_reason), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_data: Some(data.clone()), - #[cfg(test)] - attribution_failed_channel: None, - } - } else { - unreachable!(); + match htlc_source { + &HTLCSource::OutboundRoute { ref path, .. } => { + decoded_onion_failure!( + (Some(path.hops[0].short_channel_id)), + *failure_reason, + data + ) + }, + &HTLCSource::TrampolineForward { ref outbound_payment, .. } => { + debug_assert!(outbound_payment.is_none()); + decoded_onion_failure!(None, *failure_reason, data) + }, + _ => unreachable!(), } }, } From 90a0a4bb319823ffd566958887f046847cafbfbb Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 08:47:12 -0400 Subject: [PATCH 17/21] f: replace decoded_onion_failure macro with closure Review comment from valentinewallace on lightning/src/ln/onion_utils.rs:2146: > Looks like this can be a closure, which avoids the somewhat awkward > parenthesis around the scid option below Convert the `decoded_onion_failure!` macro inside `decode_onion_failure` into a closure so call sites can pass `Some(scid)` and an `&[u8]` data slice directly without the macro-style parenthesization. Match the patch suggested in valentinewallace/00b24b3c: take `data: &[u8]` and `to_vec()` into the optional `onion_error_data`, drop the now-unneeded `#[allow(unused)]` on the Reason arm. --- lightning/src/ln/onion_utils.rs | 49 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index eff315b3820..706312a7387 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2143,28 +2143,27 @@ impl HTLCFailReason { pub(super) fn decode_onion_failure( &self, secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, ) -> DecodedOnionFailure { - macro_rules! decoded_onion_failure { - ($short_channel_id:expr, $failure_reason:expr, $data:expr) => { - DecodedOnionFailure { - network_update: None, - payment_failed_permanently: false, - short_channel_id: $short_channel_id, - failed_within_blinded_path: false, - hold_times: Vec::new(), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_code: Some($failure_reason), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_data: Some($data.clone()), - #[cfg(test)] - attribution_failed_channel: None, - } - }; - } + let decoded_onion_failure = |short_channel_id: Option, + _failure_reason: LocalHTLCFailureReason, + _data: &[u8]| { + DecodedOnionFailure { + network_update: None, + payment_failed_permanently: false, + short_channel_id, + failed_within_blinded_path: false, + hold_times: Vec::new(), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_code: Some(_failure_reason), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_data: Some(_data.to_vec()), + #[cfg(test)] + attribution_failed_channel: None, + } + }; match self.0 { HTLCFailReasonRepr::LightningError { ref err, .. } => { process_onion_failure(secp_ctx, logger, &htlc_source, err.clone()) }, - #[allow(unused)] HTLCFailReasonRepr::Reason { ref data, ref failure_reason } => { // we get a fail_malformed_htlc from the first hop // TODO: We'd like to generate a NetworkUpdate for temporary @@ -2172,16 +2171,14 @@ impl HTLCFailReason { // generally ignores its view of our own channels as we provide them via // ChannelDetails. match htlc_source { - &HTLCSource::OutboundRoute { ref path, .. } => { - decoded_onion_failure!( - (Some(path.hops[0].short_channel_id)), - *failure_reason, - data - ) - }, + &HTLCSource::OutboundRoute { ref path, .. } => decoded_onion_failure( + Some(path.hops[0].short_channel_id), + *failure_reason, + data, + ), &HTLCSource::TrampolineForward { ref outbound_payment, .. } => { debug_assert!(outbound_payment.is_none()); - decoded_onion_failure!(None, *failure_reason, data) + decoded_onion_failure(None, *failure_reason, data) }, _ => unreachable!(), } From 4e53faff2f29c7aa9db2a05d5190c62f112d072e Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 11:42:44 +0200 Subject: [PATCH 18/21] ln: process added trampoline htlcs with CLTV validation in tests We can't perform proper validation because we don't know the outgoing channel id until we forward the HTLC, so we just perform a basic CLTV check. We don't yet have proper handling of trampoline forwards on restart, so we only enable this in our tests. --- lightning/src/ln/blinded_payment_tests.rs | 120 ---------------------- lightning/src/ln/channelmanager.rs | 30 +++++- 2 files changed, 28 insertions(+), 122 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 621c5103353..1099a3f3b79 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2723,123 +2723,3 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } } - -#[test] -#[rustfmt::skip] -fn test_trampoline_forward_rejection() { - const TOTAL_NODE_COUNT: usize = 3; - - let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); - let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); - let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); - let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); - - let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); - - for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks - connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); - } - - let alice_node_id = nodes[0].node().get_our_node_id(); - let bob_node_id = nodes[1].node().get_our_node_id(); - let carol_node_id = nodes[2].node().get_our_node_id(); - - let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); - let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); - - let amt_msat = 1000; - let (payment_preimage, payment_hash, _) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); - - let route = Route { - paths: vec![Path { - hops: vec![ - // Bob - RouteHop { - pubkey: bob_node_id, - node_features: NodeFeatures::empty(), - short_channel_id: alice_bob_scid, - channel_features: ChannelFeatures::empty(), - fee_msat: 1000, - cltv_expiry_delta: 48, - maybe_announced_channel: false, - }, - - // Carol - RouteHop { - pubkey: carol_node_id, - node_features: NodeFeatures::empty(), - short_channel_id: bob_carol_scid, - channel_features: ChannelFeatures::empty(), - fee_msat: 0, - cltv_expiry_delta: 24 + 24 + 39, - maybe_announced_channel: false, - } - ], - blinded_tail: Some(BlindedTail { - trampoline_hops: vec![ - // Carol - TrampolineHop { - pubkey: carol_node_id, - node_features: Features::empty(), - fee_msat: amt_msat, - cltv_expiry_delta: 24, - }, - - // Alice (unreachable) - TrampolineHop { - pubkey: alice_node_id, - node_features: Features::empty(), - fee_msat: amt_msat, - cltv_expiry_delta: 24 + 39, - }, - ], - hops: vec![BlindedHop{ - // Fake public key - blinded_node_id: alice_node_id, - encrypted_payload: vec![], - }], - blinding_point: alice_node_id, - excess_final_cltv_expiry_delta: 39, - final_value_msat: amt_msat, - }) - }], - route_params: None, - }; - - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(amt_msat), PaymentId(payment_hash.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 first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); - - let route: &[&Node] = &[&nodes[1], &nodes[2]]; - let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) - .with_payment_preimage(payment_preimage) - .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); - do_pass_along_path(args); - - { - let unblinded_node_updates = get_htlc_update_msgs(&nodes[2], &nodes[1].node.get_our_node_id()); - nodes[1].node.handle_update_fail_htlc( - nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] - ); - do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); - } - { - let unblinded_node_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(), &unblinded_node_updates.update_fail_htlcs[0] - ); - do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); - } - { - // Expect UnknownNextPeer error while we are unable to route forwarding Trampoline payments. - let payment_failed_conditions = PaymentFailedConditions::new() - .expected_htlc_error_data(LocalHTLCFailureReason::UnknownNextPeer, &[0; 0]); - expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); - } -} diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d58acec9001..7dc01da9766 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5160,6 +5160,7 @@ impl< fn can_forward_htlc_should_intercept( &self, msg: &msgs::UpdateAddHTLC, prev_chan_public: bool, next_hop: &NextPacketDetails, ) -> Result { + let cur_height = self.best_block.read().unwrap().height + 1; let outgoing_scid = match next_hop.outgoing_connector { HopConnector::ShortChannelId(scid) => scid, HopConnector::Dummy => { @@ -5167,8 +5168,34 @@ impl< debug_assert!(false, "Dummy hop reached HTLC handling."); return Err(LocalHTLCFailureReason::InvalidOnionPayload); }, + // We can't make forwarding checks on trampoline forwards where we don't know the + // outgoing channel on receipt of the incoming htlc. Our trampoline logic will check + // our required delta and fee later on, so here we just check that the forwarding node + // did not "skim" off some of the sender's intended fee/cltv. HopConnector::Trampoline(_) => { - return Err(LocalHTLCFailureReason::InvalidTrampolineForward); + // We do not yet support reloading our trampoline HTLCs on restart, so we just + // fail them for now (except in tests). + #[cfg(not(test))] + { + return Err(LocalHTLCFailureReason::InvalidTrampolineForward); + } + + #[cfg(test)] + { + if msg.amount_msat < next_hop.outgoing_amt_msat { + return Err(LocalHTLCFailureReason::FeeInsufficient); + } + + check_incoming_htlc_cltv( + cur_height, + next_hop.outgoing_cltv_value, + msg.cltv_expiry, + 0, + )?; + + // TODO: add interception flag specifically for trampoline + return Ok(false); + } }, }; // TODO: We do the fake SCID namespace check a bunch of times here (and indirectly via @@ -5207,7 +5234,6 @@ impl< }, }; - let cur_height = self.best_block.read().unwrap().height + 1; check_incoming_htlc_cltv( cur_height, next_hop.outgoing_cltv_value, From ff4f9930ae4e8c64b184610b25c2ea99ea0bb1f8 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 08:42:32 -0400 Subject: [PATCH 19/21] ln/test: add test coverage for MPP trampoline --- lightning/src/ln/blinded_payment_tests.rs | 291 +++++++++++++++++++++- 1 file changed, 287 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 1099a3f3b79..ac0b8ce486a 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -8,13 +8,15 @@ // licenses. use crate::blinded_path::payment::{ - BlindedPaymentPath, Bolt12RefundContext, DummyTlvs, ForwardTlvs, PaymentConstraints, - PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, PAYMENT_PADDING_ROUND_OFF, + BlindedPaymentPath, Bolt12RefundContext, DummyTlvs, ForwardNode, ForwardTlvs, + PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, + PAYMENT_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::{self, BlindedHop}; +use crate::chain::channelmonitor::HTLC_FAIL_BACK_BUFFER; use crate::events::{Event, HTLCHandlingFailureType, PaymentFailureReason}; -use crate::ln::channelmanager::{self, HTLCFailureMsg, PaymentId}; +use crate::ln::channelmanager::{self, HTLCFailureMsg, PaymentId, MPP_TIMEOUT_TICKS}; use crate::ln::functional_test_utils::*; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::{ @@ -34,7 +36,7 @@ use crate::routing::router::{ use crate::sign::{NodeSigner, PeerStorageKey, ReceiveAuthKey, Recipient}; use crate::types::features::{BlindedHopFeatures, ChannelFeatures, NodeFeatures}; use crate::types::payment::{PaymentHash, PaymentSecret}; -use crate::util::config::{HTLCInterceptionFlags, UserConfig}; +use crate::util::config::{ChannelConfig, HTLCInterceptionFlags, UserConfig}; use crate::util::ser::{WithoutLength, Writeable}; use crate::util::test_utils::{self, bytes_from_hex, pubkey_from_hex, secret_from_hex}; use bitcoin::hex::DisplayHex; @@ -2723,3 +2725,284 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } } + +/// Sets up channels and sends a trampoline MPP payment across two paths. +/// +/// Topology: +/// Alice (0) --> Bob (1) --> Carol (2, trampoline node) +/// Alice (0) --> Barry (3) --> Carol (2, trampoline node) +/// +/// Carol's inner trampoline onion is a forward to an unknown next node. We don't need the +/// next hop as a real node since forwarding isn't implemented yet -- we just need the onion to +/// contain a valid forward payload. +/// +/// Returns (payment_hash, per_path_amount, ev_to_bob, ev_to_barry). +fn send_trampoline_mpp_payment<'a, 'b, 'c>( + nodes: &'a Vec>, +) -> (PaymentHash, u64, MessageSendEvent, MessageSendEvent) { + let secp_ctx = Secp256k1::new(); + + let alice_bob_chan = + create_announced_chan_between_nodes_with_value(nodes, 0, 1, 1_000_000, 0).2; + let bob_carol_chan = + create_announced_chan_between_nodes_with_value(nodes, 1, 2, 1_000_000, 0).2; + let alice_barry_chan = + create_announced_chan_between_nodes_with_value(nodes, 0, 3, 1_000_000, 0).2; + let barry_carol_chan = + create_announced_chan_between_nodes_with_value(nodes, 3, 2, 1_000_000, 0).2; + + let per_path_amt = 500_000; + let total_amt = per_path_amt * 2; + let (_, payment_hash, payment_secret) = + get_payment_preimage_hash(&nodes[2], Some(total_amt), None); + + let bob_node_id = nodes[1].node.get_our_node_id(); + let carol_node_id = nodes[2].node.get_our_node_id(); + let barry_node_id = nodes[3].node.get_our_node_id(); + + let alice_bob_scid = get_scid_from_channel_id(&nodes[0], alice_bob_chan); + let bob_carol_scid = get_scid_from_channel_id(&nodes[1], bob_carol_chan); + let alice_barry_scid = get_scid_from_channel_id(&nodes[0], alice_barry_chan); + let barry_carol_scid = get_scid_from_channel_id(&nodes[3], barry_carol_chan); + + let trampoline_cltv = 42; + let excess_final_cltv = 70; + + // Note we don't actually have an outgoing channel for Carol, we just use our default fee + // policy. + let carol_relay = ChannelConfig::default(); + + let next_trampoline = PublicKey::from_slice(&[2; 33]).unwrap(); + let fwd_tail = || { + let intermediate_nodes = [ForwardNode { + tlvs: blinded_path::payment::TrampolineForwardTlvs { + next_trampoline, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: 1, + }, + features: BlindedHopFeatures::empty(), + payment_relay: PaymentRelay { + cltv_expiry_delta: carol_relay.cltv_expiry_delta, + fee_proportional_millionths: carol_relay.forwarding_fee_proportional_millionths, + fee_base_msat: carol_relay.forwarding_fee_base_msat, + }, + next_blinding_override: None, + }, + node_id: carol_node_id, + htlc_maximum_msat: u64::max_value(), + }]; + let payee_tlvs = ReceiveTlvs { + payment_secret: PaymentSecret([0; 32]), + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: 1, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }; + create_trampoline_forward_blinded_tail( + &secp_ctx, + &nodes[2].keys_manager, + &intermediate_nodes, + next_trampoline, + ReceiveAuthKey([0; 32]), + payee_tlvs, + trampoline_cltv, + excess_final_cltv, + per_path_amt, + ) + }; + + let hop = |pubkey, short_channel_id, fee_msat, cltv_expiry_delta| RouteHop { + pubkey, + node_features: NodeFeatures::empty(), + short_channel_id, + channel_features: ChannelFeatures::empty(), + fee_msat, + cltv_expiry_delta, + maybe_announced_channel: true, + }; + let build_path_hops = |first_hop_node_id, first_hop_scid, second_hop_scid| { + vec![ + hop(first_hop_node_id, first_hop_scid, 1000, 48), + hop(carol_node_id, second_hop_scid, 0, trampoline_cltv + excess_final_cltv), + ] + }; + + let placeholder_tail = fwd_tail(); + let mut route = Route { + paths: vec![ + Path { + hops: build_path_hops(bob_node_id, alice_bob_scid, bob_carol_scid), + blinded_tail: Some(placeholder_tail.clone()), + }, + Path { + hops: build_path_hops(barry_node_id, alice_barry_scid, barry_carol_scid), + blinded_tail: Some(placeholder_tail), + }, + ], + route_params: None, + }; + + let cur_height = nodes[0].best_block_info().1 + 1; + let payment_id = PaymentId(payment_hash.0); + let onion = RecipientOnionFields::secret_only(payment_secret, total_amt); + let session_privs = nodes[0] + .node + .test_add_new_pending_payment(payment_hash, onion.clone(), payment_id, &route) + .unwrap(); + + route.paths[0].blinded_tail = Some(fwd_tail()); + route.paths[1].blinded_tail = Some(fwd_tail()); + + for (i, path) in route.paths.iter().enumerate() { + nodes[0] + .node + .test_send_payment_along_path( + path, + &payment_hash, + onion.clone(), + cur_height, + payment_id, + &None, + session_privs[i], + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + } + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 2); + let ev_bob = remove_first_msg_event_to_node(&bob_node_id, &mut events); + let ev_barry = remove_first_msg_event_to_node(&barry_node_id, &mut events); + (payment_hash, per_path_amt, ev_bob, ev_barry) +} + +/// How an incomplete trampoline MPP times out (if at all). +enum TrampolineTimeout { + /// Tick timers until MPP timeout fires. + Ticks, + /// Mine blocks until on-chain CLTV timeout fires. + OnChain, +} + +fn do_trampoline_mpp_test(timeout: Option) { + let chanmon_cfgs = create_chanmon_cfgs(4); + let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &vec![None; 4]); + let nodes = create_network(4, &node_cfgs, &node_chanmgrs); + + let (payment_hash, per_path_amt, ev_bob, ev_barry) = send_trampoline_mpp_payment(&nodes); + let send_both = timeout.is_none(); + + let bob_path: &[&Node] = &[&nodes[1], &nodes[2]]; + let barry_path: &[&Node] = &[&nodes[3], &nodes[2]]; + + // Pass first part along Alice -> Bob -> Carol. + let args = PassAlongPathArgs::new(&nodes[0], bob_path, per_path_amt, payment_hash, ev_bob) + .without_claimable_event(); + do_pass_along_path(args); + + // Either complete the MPP (triggering trampoline rejection) or trigger a timeout. + let expected_reason = match timeout { + None => { + let args = + PassAlongPathArgs::new(&nodes[0], barry_path, per_path_amt, payment_hash, ev_barry) + .without_clearing_recipient_events(); + do_pass_along_path(args); + LocalHTLCFailureReason::TemporaryTrampolineFailure + }, + Some(TrampolineTimeout::Ticks) => { + for _ in 0..MPP_TIMEOUT_TICKS { + nodes[2].node.timer_tick_occurred(); + } + LocalHTLCFailureReason::MPPTimeout + }, + Some(TrampolineTimeout::OnChain) => { + let current_height = nodes[2].best_block_info().1; + let send_height = nodes[0].best_block_info().1; + let htlc_cltv = send_height + 1 + 48 + 42 + 70; + connect_blocks(&nodes[2], htlc_cltv - HTLC_FAIL_BACK_BUFFER - current_height); + LocalHTLCFailureReason::CLTVExpiryTooSoon + }, + }; + + // Carol rejects the trampoline forward (either after MPP completion or timeout). + let events = nodes[2].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + crate::events::Event::HTLCHandlingFailed { + ref failure_type, ref failure_reason, .. + } => { + assert_eq!(failure_type, &HTLCHandlingFailureType::TrampolineForward {}); + match failure_reason { + Some(crate::events::HTLCHandlingFailureReason::Local { reason }) => { + assert_eq!(*reason, expected_reason) + }, + Some(_) | None => panic!("expected failure_reason for failed trampoline"), + } + }, + _ => panic!("Unexpected destination"), + } + expect_and_process_pending_htlcs(&nodes[2], false); + assert!(nodes[2].node.get_and_clear_pending_events().is_empty()); + + // Propagate failures back through each forwarded path to Alice. + let both: [&[&Node]; 2] = [bob_path, barry_path]; + let one: [&[&Node]; 1] = [bob_path]; + let forwarded: &[&[&Node]] = if send_both { &both } else { &one }; + let carol_id = nodes[2].node.get_our_node_id(); + check_added_monitors(&nodes[2], forwarded.len()); + let mut carol_msgs = nodes[2].node.get_and_clear_pending_msg_events(); + assert_eq!(carol_msgs.len(), forwarded.len()); + for path in forwarded { + let hop = path[0]; + let hop_id = hop.node.get_our_node_id(); + let ev = remove_first_msg_event_to_node(&hop_id, &mut carol_msgs); + let updates = match ev { + MessageSendEvent::UpdateHTLCs { updates, .. } => updates, + _ => panic!("Expected UpdateHTLCs"), + }; + hop.node.handle_update_fail_htlc(carol_id, &updates.update_fail_htlcs[0]); + do_commitment_signed_dance(hop, &nodes[2], &updates.commitment_signed, true, false); + + let fwd = get_htlc_update_msgs(hop, &nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc(hop_id, &fwd.update_fail_htlcs[0]); + do_commitment_signed_dance(&nodes[0], hop, &fwd.commitment_signed, false, false); + } + + // Check Alice's failure events. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), if send_both { 3 } else { 1 }); + for ev in &events[..forwarded.len()] { + match ev { + Event::PaymentPathFailed { payment_hash: h, payment_failed_permanently, .. } => { + assert_eq!(*h, payment_hash); + assert!(!payment_failed_permanently); + }, + _ => panic!("Expected PaymentPathFailed, got {:?}", ev), + } + } + if send_both { + match &events[2] { + Event::PaymentFailed { payment_hash: h, reason, .. } => { + assert_eq!(*h, Some(payment_hash)); + assert_eq!(*reason, Some(PaymentFailureReason::RetriesExhausted)); + }, + _ => panic!("Expected PaymentFailed, got {:?}", events[2]), + } + + // Verify no spurious timeout fires after the MPP set was dispatched. + for _ in 0..(MPP_TIMEOUT_TICKS * 3) { + nodes[2].node.timer_tick_occurred(); + } + assert!(nodes[2].node.get_and_clear_pending_events().is_empty()); + } +} + +#[test] +fn test_trampoline_mpp_accumulation() { + do_trampoline_mpp_test(None); + do_trampoline_mpp_test(Some(TrampolineTimeout::Ticks)); + do_trampoline_mpp_test(Some(TrampolineTimeout::OnChain)); +} From 5ba70941f412d3e64700528cb269f7bb8311ad7b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 27 Apr 2026 10:57:34 -0400 Subject: [PATCH 20/21] ln/test: add tests for mpp accumulation of trampoline forwards --- lightning/src/ln/channelmanager.rs | 30 ++- lightning/src/ln/mod.rs | 2 + lightning/src/ln/trampoline_forward_tests.rs | 193 +++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 lightning/src/ln/trampoline_forward_tests.rs diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7dc01da9766..1c07fc88b66 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -527,7 +527,7 @@ enum OnionPayload { } #[derive(PartialEq, Eq)] -struct MppPart { +pub(super) struct MppPart { prev_hop: HTLCPreviousHopData, cltv_expiry: u32, /// The amount (in msats) of this MPP part @@ -542,6 +542,20 @@ struct MppPart { } impl MppPart { + #[cfg(test)] + pub(super) fn new( + prev_hop: HTLCPreviousHopData, value: u64, sender_intended_value: u64, cltv_expiry: u32, + ) -> Self { + MppPart { + prev_hop, + cltv_expiry, + value, + sender_intended_value, + timer_ticks: 0, + total_value_received: None, + } + } + /// Returns a boolean indicating whether the HTLC has timed out on chain, accounting for a buffer /// that gives us time to resolve it. fn check_onchain_timeout(&self, height: u32) -> bool { @@ -5780,6 +5794,20 @@ impl< self.pending_outbound_payments.test_set_payment_metadata(payment_id, new_payment_metadata); } + #[cfg(test)] + pub(super) fn test_handle_trampoline_htlc( + &self, mpp_part: MppPart, onion_fields: RecipientOnionFields, payment_hash: PaymentHash, + next_hop_info: NextTrampolineHopInfo, next_node_id: PublicKey, + ) -> Result<(), (HTLCSource, onion_utils::HTLCFailReason)> { + self.handle_trampoline_htlc( + mpp_part, + onion_fields, + payment_hash, + next_hop_info, + next_node_id, + ) + } + /// Pays a [`Bolt11Invoice`] associated with the `payment_id`. See [`Self::send_payment`] for more info. /// /// # Payment Id diff --git a/lightning/src/ln/mod.rs b/lightning/src/ln/mod.rs index d6e0b92f1d0..30a8109fc43 100644 --- a/lightning/src/ln/mod.rs +++ b/lightning/src/ln/mod.rs @@ -118,6 +118,8 @@ mod reorg_tests; mod shutdown_tests; #[cfg(any(feature = "_test_utils", test))] pub mod splicing_tests; +#[cfg(test)] +mod trampoline_forward_tests; #[cfg(any(test, feature = "_externalize_tests"))] #[allow(unused_mut)] pub mod update_fee_tests; diff --git a/lightning/src/ln/trampoline_forward_tests.rs b/lightning/src/ln/trampoline_forward_tests.rs new file mode 100644 index 00000000000..6133bd07583 --- /dev/null +++ b/lightning/src/ln/trampoline_forward_tests.rs @@ -0,0 +1,193 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Tests for trampoline MPP accumulation and forwarding validation in +//! [`ChannelManager::handle_trampoline_htlc`]. + +use crate::chain::transaction::OutPoint; +use crate::events::HTLCHandlingFailureReason; +use crate::ln::channelmanager::{HTLCPreviousHopData, MppPart}; +use crate::ln::functional_test_utils::*; +use crate::ln::msgs; +use crate::ln::onion_utils::LocalHTLCFailureReason; +use crate::ln::outbound_payment::{NextTrampolineHopInfo, RecipientOnionFields}; +use crate::ln::types::ChannelId; +use crate::types::payment::{PaymentHash, PaymentSecret}; + +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + +fn test_prev_hop_data(htlc_id: u64) -> HTLCPreviousHopData { + HTLCPreviousHopData { + prev_outbound_scid_alias: 0, + user_channel_id: None, + htlc_id, + incoming_packet_shared_secret: [0; 32], + phantom_shared_secret: None, + trampoline_shared_secret: Some([0; 32]), + blinded_failure: None, + channel_id: ChannelId::from_bytes([0; 32]), + outpoint: OutPoint { txid: bitcoin::Txid::all_zeros(), index: 0 }, + counterparty_node_id: None, + cltv_expiry: None, + } +} + +fn test_trampoline_onion_packet() -> msgs::TrampolineOnionPacket { + let secp = Secp256k1::new(); + let test_secret = SecretKey::from_slice(&[42; 32]).unwrap(); + msgs::TrampolineOnionPacket { + version: 0, + public_key: PublicKey::from_secret_key(&secp, &test_secret), + hop_data: vec![0; 650], + hmac: [0; 32], + } +} + +fn test_onion_fields(total_msat: u64) -> RecipientOnionFields { + RecipientOnionFields { + payment_secret: Some(PaymentSecret([0; 32])), + total_mpp_amount_msat: total_msat, + payment_metadata: None, + custom_tlvs: Vec::new(), + } +} + +enum TrampolineMppValidationTestCase { + FeeInsufficient, + CltvInsufficient, + TrampolineAmountExceedsReceived, + TrampolineCLTVExceedsReceived, + MismatchedPaymentSecret, +} + +/// Sends two MPP parts through [`ChannelManager::handle_trampoline_htlc`], testing various MPP +/// validation steps with a base case that succeeds. +fn do_test_trampoline_mpp_validation(test_case: Option) { + let update_add_value: u64 = 500_000; // Actual amount we received in update_add_htlc. + let update_add_cltv: u32 = 500; // Actual CLTV we received in update_add_htlc. + let sender_intended_incoming_value: u64 = 500_000; // Amount we expect for one HTLC, outer onion. + let incoming_mpp_total: u64 = 1_000_000; // Total we expect to receive across MPP parts, outer onion. + let mut next_trampoline_amount: u64 = 750_000; // Total next trampoline expects, inner onion. + let mut next_trampoline_cltv: u32 = 100; // CLTV next trampoline expects, inner onion. + + // By default, set our forwarding fee and CLTV delta to exactly what we're being offered + // for this trampoline forward, so that we can force failures by just adding one. + let mut forwarding_fee_base_msat = incoming_mpp_total - next_trampoline_amount; + let mut cltv_delta = update_add_cltv - next_trampoline_cltv; + let mut mismatch_payment_secret = false; + + let expected = match test_case { + Some(TrampolineMppValidationTestCase::FeeInsufficient) => { + forwarding_fee_base_msat += 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::CltvInsufficient) => { + cltv_delta += 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::TrampolineAmountExceedsReceived) => { + next_trampoline_amount = incoming_mpp_total + 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::TrampolineCLTVExceedsReceived) => { + next_trampoline_cltv = update_add_cltv + 1; + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient + }, + Some(TrampolineMppValidationTestCase::MismatchedPaymentSecret) => { + mismatch_payment_secret = true; + LocalHTLCFailureReason::InvalidTrampolineForward + }, + // We currently reject trampoline forwards once accumulated. + None => LocalHTLCFailureReason::TemporaryTrampolineFailure, + }; + + let chanmon_cfgs = create_chanmon_cfgs(1); + let node_cfgs = create_node_cfgs(1, &chanmon_cfgs); + let mut cfg = test_default_channel_config(); + cfg.channel_config.forwarding_fee_base_msat = forwarding_fee_base_msat as u32; + cfg.channel_config.forwarding_fee_proportional_millionths = 0; + cfg.channel_config.cltv_expiry_delta = cltv_delta as u16; + let node_chanmgrs = create_node_chanmgrs(1, &node_cfgs, &[Some(cfg)]); + let nodes = create_network(1, &node_cfgs, &node_chanmgrs); + + let payment_hash = PaymentHash([1; 32]); + + let secp = Secp256k1::new(); + let test_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let next_trampoline = PublicKey::from_secret_key(&secp, &test_secret); + let next_hop_info = NextTrampolineHopInfo { + onion_packet: test_trampoline_onion_packet(), + blinding_point: None, + amount_msat: next_trampoline_amount, + cltv_expiry_height: next_trampoline_cltv, + }; + + let htlc1 = MppPart::new( + test_prev_hop_data(0), + update_add_value, + sender_intended_incoming_value, + update_add_cltv, + ); + assert!(nodes[0] + .node + .test_handle_trampoline_htlc( + htlc1, + test_onion_fields(incoming_mpp_total), + payment_hash, + next_hop_info.clone(), + next_trampoline, + ) + .is_ok()); + + let htlc2 = MppPart::new( + test_prev_hop_data(1), + update_add_value, + sender_intended_incoming_value, + update_add_cltv, + ); + let onion2 = if mismatch_payment_secret { + RecipientOnionFields { + payment_secret: Some(PaymentSecret([1; 32])), + total_mpp_amount_msat: incoming_mpp_total, + payment_metadata: None, + custom_tlvs: Vec::new(), + } + } else { + test_onion_fields(incoming_mpp_total) + }; + let result = nodes[0].node.test_handle_trampoline_htlc( + htlc2, + onion2, + payment_hash, + next_hop_info, + next_trampoline, + ); + + assert_eq!( + HTLCHandlingFailureReason::from(&result.expect_err("expect trampoline failure").1), + HTLCHandlingFailureReason::Local { reason: expected }, + ); +} + +#[test] +fn test_trampoline_mpp_validation() { + do_test_trampoline_mpp_validation(Some(TrampolineMppValidationTestCase::FeeInsufficient)); + do_test_trampoline_mpp_validation(Some(TrampolineMppValidationTestCase::CltvInsufficient)); + do_test_trampoline_mpp_validation(Some( + TrampolineMppValidationTestCase::TrampolineAmountExceedsReceived, + )); + do_test_trampoline_mpp_validation(Some( + TrampolineMppValidationTestCase::TrampolineCLTVExceedsReceived, + )); + do_test_trampoline_mpp_validation(Some( + TrampolineMppValidationTestCase::MismatchedPaymentSecret, + )); + do_test_trampoline_mpp_validation(None); +} From e48b8f8d77008a4547bcac13d9dad13fe000e3df Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 14:35:57 -0400 Subject: [PATCH 21/21] f: dispatch trampoline MPP via public send_payment_with_route Review comment from valentinewallace on lightning/src/ln/blinded_payment_tests.rs:2861: > Generally preferred to use the public API in tests. I clauded this > patch but haven't vetted it much, let me know your thoughts: > https://github.com/valentinewallace/rust-lightning/commit/79a0771f59d8623d6b8aad85404737a2e8c31490 Adopt the patch's core idea: * Extend `create_trampoline_forward_blinded_tail` to also return the underlying `BlindedPaymentPath` so callers can register it in `PaymentParameters`. Update the existing single-path caller to use `.0` on the new tuple. * Build `PaymentParameters::blinded(vec![path_bob, path_barry])` from the two paths and dispatch the MPP via `send_payment_with_route` instead of `test_add_new_pending_payment` + `test_send_payment_along_path`. The registered blinded paths are required, not decorative: `insert_previously_failed_blinded_path` (outbound_payment.rs:2508) is invoked on the trampoline rejection / timeout failures this test exercises, and `debug_assert!`s that the failing tail is registered in payment_params. Also bump Carol's outer-hop `cltv_expiry_delta` from `trampoline_cltv + excess_final_cltv` (112) to `carol_relay.cltv_ expiry_delta + trampoline_cltv + excess_final_cltv` (184). Required by `FixedRouter` (router.rs:765-773), which validates that the last outer- hop's `cltv_expiry_delta` equals `sum(trampoline_hops[*].cltv_expiry_ delta)`. That sum is `blinded_path.payinfo.cltv_expiry_delta + excess_final_cltv_delta`, and the payinfo aggregates the intermediate trampoline relay's `cltv_expiry_delta` (= `carol_relay.cltv_expiry_ delta` from `fwd_tail`) plus `min_final_cltv_expiry_delta` (= `trampoline_cltv`). The `test_send_payment_along_path` helpers bypassed this validation, so 112 worked there; the public API does not. Reverting to 112 panics with "Path had a total trampoline CLTV of 184, which is not equal to the total last-hop CLTV delta of 112". Comment the derivation at the call site so the overloaded "relay" naming doesn't trip up future readers. Return `last_hop_cltv_delta` from `send_trampoline_mpp_payment` so the on-chain timeout branch can compute Carol's incoming HTLC CLTV from the same value the route is built with, rather than re-stating the magic constant 184 in two places. --- lightning/src/ln/blinded_payment_tests.rs | 95 ++++++++++------------- lightning/src/ln/functional_test_utils.rs | 11 ++- 2 files changed, 51 insertions(+), 55 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index ac0b8ce486a..10566db3db4 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2597,24 +2597,27 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { // Create a blinded tail where Carol is receiving. In our unblinded test cases, we'll // override this anyway (with a tail sending to an unblinded receive, which LDK doesn't // allow). - blinded_tail: Some(create_trampoline_forward_blinded_tail( - &secp_ctx, - &nodes[2].keys_manager, - &[], - carol_node_id, - nodes[2].keys_manager.get_receive_auth_key(), - ReceiveTlvs { - payment_secret, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: original_amt_msat, + blinded_tail: Some( + create_trampoline_forward_blinded_tail( + &secp_ctx, + &nodes[2].keys_manager, + &[], + carol_node_id, + nodes[2].keys_manager.get_receive_auth_key(), + ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: original_amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), - }, - original_trampoline_cltv, - excess_final_cltv, - original_amt_msat, - )), + original_trampoline_cltv, + excess_final_cltv, + original_amt_msat, + ) + .0, + ), }], route_params: None, }; @@ -2736,10 +2739,10 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { /// next hop as a real node since forwarding isn't implemented yet -- we just need the onion to /// contain a valid forward payload. /// -/// Returns (payment_hash, per_path_amount, ev_to_bob, ev_to_barry). +/// Returns (payment_hash, per_path_amount, last_hop_cltv_delta, ev_to_bob, ev_to_barry). fn send_trampoline_mpp_payment<'a, 'b, 'c>( nodes: &'a Vec>, -) -> (PaymentHash, u64, MessageSendEvent, MessageSendEvent) { +) -> (PaymentHash, u64, u32, MessageSendEvent, MessageSendEvent) { let secp_ctx = Secp256k1::new(); let alice_bob_chan = @@ -2822,60 +2825,47 @@ fn send_trampoline_mpp_payment<'a, 'b, 'c>( cltv_expiry_delta, maybe_announced_channel: true, }; + let last_hop_cltv_delta = + carol_relay.cltv_expiry_delta as u32 + trampoline_cltv + excess_final_cltv; let build_path_hops = |first_hop_node_id, first_hop_scid, second_hop_scid| { vec![ hop(first_hop_node_id, first_hop_scid, 1000, 48), - hop(carol_node_id, second_hop_scid, 0, trampoline_cltv + excess_final_cltv), + hop(carol_node_id, second_hop_scid, 0, last_hop_cltv_delta), ] }; - let placeholder_tail = fwd_tail(); - let mut route = Route { + let (tail_bob, blinded_path_bob) = fwd_tail(); + let (tail_barry, blinded_path_barry) = fwd_tail(); + let payment_params = PaymentParameters::blinded(vec![blinded_path_bob, blinded_path_barry]); + let route_params = RouteParameters { + payment_params, + final_value_msat: total_amt, + max_total_routing_fee_msat: None, + }; + let route = Route { paths: vec![ Path { hops: build_path_hops(bob_node_id, alice_bob_scid, bob_carol_scid), - blinded_tail: Some(placeholder_tail.clone()), + blinded_tail: Some(tail_bob), }, Path { hops: build_path_hops(barry_node_id, alice_barry_scid, barry_carol_scid), - blinded_tail: Some(placeholder_tail), + blinded_tail: Some(tail_barry), }, ], - route_params: None, + route_params: Some(route_params), }; - let cur_height = nodes[0].best_block_info().1 + 1; let payment_id = PaymentId(payment_hash.0); let onion = RecipientOnionFields::secret_only(payment_secret, total_amt); - let session_privs = nodes[0] - .node - .test_add_new_pending_payment(payment_hash, onion.clone(), payment_id, &route) - .unwrap(); - - route.paths[0].blinded_tail = Some(fwd_tail()); - route.paths[1].blinded_tail = Some(fwd_tail()); - - for (i, path) in route.paths.iter().enumerate() { - nodes[0] - .node - .test_send_payment_along_path( - path, - &payment_hash, - onion.clone(), - cur_height, - payment_id, - &None, - session_privs[i], - ) - .unwrap(); - check_added_monitors(&nodes[0], 1); - } + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + check_added_monitors(&nodes[0], 2); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 2); let ev_bob = remove_first_msg_event_to_node(&bob_node_id, &mut events); let ev_barry = remove_first_msg_event_to_node(&barry_node_id, &mut events); - (payment_hash, per_path_amt, ev_bob, ev_barry) + (payment_hash, per_path_amt, last_hop_cltv_delta, ev_bob, ev_barry) } /// How an incomplete trampoline MPP times out (if at all). @@ -2892,7 +2882,8 @@ fn do_trampoline_mpp_test(timeout: Option) { let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &vec![None; 4]); let nodes = create_network(4, &node_cfgs, &node_chanmgrs); - let (payment_hash, per_path_amt, ev_bob, ev_barry) = send_trampoline_mpp_payment(&nodes); + let (payment_hash, per_path_amt, last_hop_cltv_delta, ev_bob, ev_barry) = + send_trampoline_mpp_payment(&nodes); let send_both = timeout.is_none(); let bob_path: &[&Node] = &[&nodes[1], &nodes[2]]; @@ -2921,7 +2912,7 @@ fn do_trampoline_mpp_test(timeout: Option) { Some(TrampolineTimeout::OnChain) => { let current_height = nodes[2].best_block_info().1; let send_height = nodes[0].best_block_info().1; - let htlc_cltv = send_height + 1 + 48 + 42 + 70; + let htlc_cltv = send_height + 1 + last_hop_cltv_delta; connect_blocks(&nodes[2], htlc_cltv - HTLC_FAIL_BACK_BUFFER - current_height); LocalHTLCFailureReason::CLTVExpiryTooSoon }, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index b48d76d646d..e8ce1e3c879 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -5776,12 +5776,16 @@ pub fn get_scid_from_channel_id<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, channel_id: /// /// The resulting tail contains blinded hops built from `intermediate_nodes` plus a dummy receive /// TLV, with the `TrampolineHop` fee and CLTV derived from the blinded path's aggregated payinfo. +/// The constructed [`BlindedPaymentPath`] is also returned so callers can register it in +/// [`PaymentParameters`]. +/// +/// [`PaymentParameters`]: crate::routing::router::PaymentParameters pub fn create_trampoline_forward_blinded_tail( secp_ctx: &bitcoin::secp256k1::Secp256k1, entropy_source: ES, intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, payee_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, min_final_cltv_expiry_delta: u32, excess_final_cltv_delta: u32, final_value_msat: u64, -) -> BlindedTail { +) -> (BlindedTail, BlindedPaymentPath) { let blinded_path = BlindedPaymentPath::new_for_trampoline( intermediate_nodes, payee_node_id, @@ -5794,7 +5798,7 @@ pub fn create_trampoline_forward_blinded_tail( ) .unwrap(); - BlindedTail { + let tail = BlindedTail { trampoline_hops: vec![TrampolineHop { pubkey: intermediate_nodes.first().map(|n| n.node_id).unwrap_or(payee_node_id), node_features: types::features::Features::empty(), @@ -5813,5 +5817,6 @@ pub fn create_trampoline_forward_blinded_tail( blinding_point: blinded_path.blinding_point(), excess_final_cltv_expiry_delta: excess_final_cltv_delta, final_value_msat, - } + }; + (tail, blinded_path) }