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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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) } From 473242cd83fa45ef55db7e36318e469ea0ba9003 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 16 Jan 2026 14:38:21 -0500 Subject: [PATCH 22/47] ln: add trampoline forward info to PendingOutboundPayment::Retryable Use even persistence value because we can't downgrade with a trampoline payment in flight, we'll fail to claim the appropriate incoming HTLCs. We track previous_hop_data in `TrampolineForwardInfo` so that we have it on hand in our `OutboundPayment::Retryable` to build `HTLCSource` for our retries. --- lightning/src/ln/outbound_payment.rs | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index a6da513845a..a1ff0ed3936 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -18,8 +18,8 @@ use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; use crate::events::{self, PaidBolt12Invoice, PaymentFailureReason}; use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{ - EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, PaymentCompleteUpdate, - PaymentId, + EventCompletionAction, HTLCPreviousHopData, HTLCSource, OptionalBolt11PaymentParams, + PaymentCompleteUpdate, PaymentId, }; use crate::ln::msgs::{DecodeError, TrampolineOnionPacket}; use crate::ln::onion_utils; @@ -127,6 +127,9 @@ pub(crate) enum PendingOutboundPayment { // Storing the BOLT 12 invoice here to allow Proof of Payment after // the payment is made. bolt12_invoice: Option, + // Storing forward information for trampoline payments in order to build next hop info + // or build error or claims to the origin. + trampoline_forward_info: Option, custom_tlvs: Vec<(u64, Vec)>, pending_amt_msat: u64, /// Used to track the fee paid. Present iff the payment was serialized on 0.0.103+. @@ -186,6 +189,26 @@ impl_writeable_tlv_based!(NextTrampolineHopInfo, { (7, cltv_expiry_height, required), }); +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct TrampolineForwardInfo { + /// Information necessary to construct the onion packet for the next Trampoline hop. + pub(crate) next_hop_info: NextTrampolineHopInfo, + /// The incoming HTLCs that were forwarded to us, which need to be settled or failed once + /// our outbound payment has been completed. + pub(crate) previous_hop_data: Vec, + /// The forwarding fee charged for this trampoline payment, persisted here so that we don't + /// need to look up the value of all our incoming/outgoing payments to calculate fee. We may + /// earn more than this fee if the path we find ends up being cheaper than the budget that + /// the original sender allocated for this trampoline hop. + pub(crate) forwarding_fee_msat: u64, +} + +impl_writeable_tlv_based!(TrampolineForwardInfo, { + (1, next_hop_info, required), + (3, previous_hop_data, required_vec), + (5, forwarding_fee_msat, required), +}); + #[derive(Clone)] pub(crate) struct RetryableInvoiceRequest { pub(crate) invoice_request: InvoiceRequest, @@ -2037,6 +2060,7 @@ impl OutboundPayments { keysend_preimage, invoice_request, bolt12_invoice, + trampoline_forward_info: None, custom_tlvs: recipient_onion.custom_tlvs, starting_block_height: best_block_height, total_msat: route.get_total_amount(), @@ -2685,6 +2709,7 @@ impl OutboundPayments { keysend_preimage: None, // only used for retries, and we'll never retry on startup invoice_request: None, // only used for retries, and we'll never retry on startup bolt12_invoice: None, // only used for retries, and we'll never retry on startup! + trampoline_forward_info: None, // only used for retries, and we'll never retry on startup custom_tlvs: Vec::new(), // only used for retries, and we'll never retry on startup pending_amt_msat: path_amt, pending_fee_msat: Some(path_fee), @@ -2788,6 +2813,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, } })), (13, invoice_request, option), + (14, trampoline_forward_info, option), (15, bolt12_invoice, option), (not_written, retry_strategy, (static_value, None)), (not_written, attempts, (static_value, PaymentAttempts::new())), From 36efdc5e3afeeb91ef0bb27a7aa34009744e2de9 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 10 Feb 2026 08:46:40 +0200 Subject: [PATCH 23/47] ln: thread trampoline routing information through payment methods --- lightning/src/ln/channelmanager.rs | 2 + lightning/src/ln/outbound_payment.rs | 58 ++++++++++++++++------------ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1c07fc88b66..30c731400b2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5488,6 +5488,7 @@ impl< keysend_preimage, invoice_request: None, bolt12_invoice: None, + trampoline_forward_info: None, session_priv_bytes, hold_htlc_at_next_hop: false, }) @@ -5505,6 +5506,7 @@ impl< bolt12_invoice, session_priv_bytes, hold_htlc_at_next_hop, + trampoline_forward_info: _, } = args; // The top-level caller should hold the total_consistency_lock read lock. debug_assert!(self.total_consistency_lock.try_write().is_err()); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index a1ff0ed3936..04f535c08fd 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -977,6 +977,7 @@ pub(super) struct SendAlongPathArgs<'a> { pub keysend_preimage: &'a Option, pub invoice_request: Option<&'a InvoiceRequest>, pub bolt12_invoice: Option<&'a PaidBolt12Invoice>, + pub trampoline_forward_info: Option<&'a TrampolineForwardInfo>, pub session_priv_bytes: [u8; 32], pub hold_htlc_at_next_hop: bool, } @@ -1237,7 +1238,7 @@ impl OutboundPayments { hash_map::Entry::Occupied(entry) => match entry.get() { PendingOutboundPayment::InvoiceReceived { .. } => { let (retryable_payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion.clone(), keysend_preimage, None, Some(bolt12_invoice.clone()), &route, + payment_hash, recipient_onion.clone(), keysend_preimage, None, Some(bolt12_invoice.clone()), None, &route, Some(retry_strategy), payment_params, entropy_source, best_block_height, ); *entry.into_mut() = retryable_payment; @@ -1248,8 +1249,8 @@ impl OutboundPayments { invoice_request } else { unreachable!() }; let (retryable_payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion.clone(), keysend_preimage, Some(invreq), Some(bolt12_invoice.clone()), &route, - Some(retry_strategy), payment_params, entropy_source, best_block_height + payment_hash, recipient_onion.clone(), keysend_preimage, Some(invreq), Some(bolt12_invoice.clone()), + None, &route, Some(retry_strategy), payment_params, entropy_source, best_block_height ); outbounds.insert(payment_id, retryable_payment); onion_session_privs @@ -1261,8 +1262,8 @@ impl OutboundPayments { core::mem::drop(outbounds); let result = self.pay_route_internal( - &route, payment_hash, &recipient_onion, keysend_preimage, invoice_request, Some(&bolt12_invoice), payment_id, - &onion_session_privs, hold_htlcs_at_next_hop, node_signer, + &route, payment_hash, &recipient_onion, keysend_preimage, invoice_request, Some(&bolt12_invoice), + None, payment_id, &onion_session_privs, hold_htlcs_at_next_hop, node_signer, best_block_height, &send_payment_along_path ); log_info!( @@ -1645,7 +1646,7 @@ impl OutboundPayments { let onion_session_privs = self.add_new_pending_payment(payment_hash, recipient_onion.clone(), payment_id, keysend_preimage, &route, Some(retry_strategy), - Some(route_params.payment_params.clone()), entropy_source, best_block_height, None) + Some(route_params.payment_params.clone()), entropy_source, best_block_height, None, None) .map_err(|_| { log_error!(logger, "Payment with id {} is already pending. New payment had payment hash {}", payment_id, payment_hash); @@ -1653,7 +1654,7 @@ impl OutboundPayments { })?; let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, - keysend_preimage, None, None, payment_id, &onion_session_privs, false, node_signer, + keysend_preimage, None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); log_info!(logger, "Sending payment with id {} and hash {} returned {:?}", payment_id, payment_hash, res); @@ -1720,14 +1721,14 @@ impl OutboundPayments { } } } - let (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice) = { + let (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice, trampoline_forward_info) = { let mut outbounds = self.pending_outbound_payments.lock().unwrap(); match outbounds.entry(payment_id) { hash_map::Entry::Occupied(mut payment) => { match payment.get() { PendingOutboundPayment::Retryable { total_msat, keysend_preimage, payment_secret, payment_metadata, - custom_tlvs, pending_amt_msat, invoice_request, onion_total_msat, .. + custom_tlvs, pending_amt_msat, invoice_request, trampoline_forward_info, onion_total_msat, .. } => { const RETRY_OVERFLOW_PERCENTAGE: u64 = 10; let retry_amt_msat = route.get_total_amount(); @@ -1743,6 +1744,7 @@ impl OutboundPayments { return } + let trampoline_forward_info = trampoline_forward_info.clone(); let recipient_onion = RecipientOnionFields { payment_secret: *payment_secret, payment_metadata: payment_metadata.clone(), @@ -1764,7 +1766,7 @@ impl OutboundPayments { payment.get_mut().increment_attempts(); let bolt12_invoice = payment.get().bolt12_invoice(); - (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned()) + (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned(), trampoline_forward_info) }, PendingOutboundPayment::Legacy { .. } => { log_error!(logger, "Unable to retry payments that were initially sent on LDK versions prior to 0.0.102"); @@ -1804,8 +1806,9 @@ impl OutboundPayments { } }; let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, keysend_preimage, - invoice_request.as_ref(), bolt12_invoice.as_ref(), payment_id, - &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); + invoice_request.as_ref(), bolt12_invoice.as_ref(), trampoline_forward_info.as_ref(), + payment_id, &onion_session_privs, false, node_signer, best_block_height, + &send_payment_along_path); log_info!(logger, "Result retrying payment id {}: {:?}", &payment_id, res); if let Err(e) = res { self.handle_pay_route_err( @@ -1956,14 +1959,14 @@ impl OutboundPayments { RecipientOnionFields::secret_only(payment_secret, route.get_total_amount()); let onion_session_privs = self.add_new_pending_payment(payment_hash, recipient_onion_fields.clone(), payment_id, None, &route, None, None, - entropy_source, best_block_height, None + entropy_source, best_block_height, None, None, ).map_err(|e| { debug_assert!(matches!(e, PaymentSendFailure::DuplicatePayment)); ProbeSendFailure::DuplicateProbe })?; match self.pay_route_internal(&route, payment_hash, &recipient_onion_fields, - None, None, None, payment_id, &onion_session_privs, false, node_signer, + None, None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path ) { Ok(()) => Ok((payment_hash, payment_id)), @@ -2011,7 +2014,7 @@ impl OutboundPayments { &self, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, route: &Route, retry_strategy: Option, entropy_source: &ES, best_block_height: u32 ) -> Result, PaymentSendFailure> { - self.add_new_pending_payment(payment_hash, recipient_onion, payment_id, None, route, retry_strategy, None, entropy_source, best_block_height, None) + self.add_new_pending_payment(payment_hash, recipient_onion, payment_id, None, route, retry_strategy, None, entropy_source, best_block_height, None, None) } #[rustfmt::skip] @@ -2019,15 +2022,15 @@ impl OutboundPayments { &self, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, keysend_preimage: Option, route: &Route, retry_strategy: Option, payment_params: Option, entropy_source: &ES, best_block_height: u32, - bolt12_invoice: Option + bolt12_invoice: Option, trampoline_forward_info: Option ) -> Result, PaymentSendFailure> { let mut pending_outbounds = self.pending_outbound_payments.lock().unwrap(); match pending_outbounds.entry(payment_id) { hash_map::Entry::Occupied(_) => Err(PaymentSendFailure::DuplicatePayment), hash_map::Entry::Vacant(entry) => { let (payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion, keysend_preimage, None, bolt12_invoice, route, retry_strategy, - payment_params, entropy_source, best_block_height + payment_hash, recipient_onion, keysend_preimage, None, bolt12_invoice, trampoline_forward_info, + route, retry_strategy, payment_params, entropy_source, best_block_height ); entry.insert(payment); Ok(onion_session_privs) @@ -2039,7 +2042,8 @@ impl OutboundPayments { fn create_pending_payment( payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, invoice_request: Option, - bolt12_invoice: Option, route: &Route, retry_strategy: Option, + bolt12_invoice: Option, trampoline_forward_info: Option, + route: &Route, retry_strategy: Option, payment_params: Option, entropy_source: &ES, best_block_height: u32 ) -> (PendingOutboundPayment, Vec<[u8; 32]>) { let mut onion_session_privs = Vec::with_capacity(route.paths.len()); @@ -2060,7 +2064,7 @@ impl OutboundPayments { keysend_preimage, invoice_request, bolt12_invoice, - trampoline_forward_info: None, + trampoline_forward_info, custom_tlvs: recipient_onion.custom_tlvs, starting_block_height: best_block_height, total_msat: route.get_total_amount(), @@ -2151,12 +2155,16 @@ impl OutboundPayments { fn pay_route_internal( &self, route: &Route, payment_hash: PaymentHash, recipient_onion: &RecipientOnionFields, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, bolt12_invoice: Option<&PaidBolt12Invoice>, - payment_id: PaymentId, onion_session_privs: &Vec<[u8; 32]>, + trampoline_forward_info: Option<&TrampolineForwardInfo>, payment_id: PaymentId, onion_session_privs: &Vec<[u8; 32]>, hold_htlcs_at_next_hop: bool, node_signer: &NS, best_block_height: u32, send_payment_along_path: &F ) -> Result<(), PaymentSendFailure> where F: Fn(SendAlongPathArgs) -> Result<(), APIError>, { + debug_assert!( + keysend_preimage.is_none() || trampoline_forward_info.is_none(), + "we only support trampoline to blinded recipients, so do not expect keysends", + ); if route.paths.len() < 1 { return Err(PaymentSendFailure::ParameterError(APIError::InvalidRoute{err: "There must be at least one path to send over".to_owned()})); } @@ -2200,7 +2208,7 @@ impl OutboundPayments { let path_res = send_payment_along_path(SendAlongPathArgs { path: &path, payment_hash: &payment_hash, recipient_onion, cur_height, payment_id, keysend_preimage: &keysend_preimage, invoice_request, - bolt12_invoice, hold_htlc_at_next_hop: hold_htlcs_at_next_hop, + bolt12_invoice, trampoline_forward_info, hold_htlc_at_next_hop: hold_htlcs_at_next_hop, session_priv_bytes: *session_priv_bytes }); results.push(path_res); @@ -2267,7 +2275,7 @@ impl OutboundPayments { F: Fn(SendAlongPathArgs) -> Result<(), APIError>, { self.pay_route_internal(route, payment_hash, &recipient_onion, - keysend_preimage, None, None, payment_id, &onion_session_privs, + keysend_preimage, None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path) .map_err(|e| { self.remove_outbound_if_all_failed(payment_id, &e); e }) } @@ -2985,7 +2993,7 @@ mod tests { outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, Some(Retry::Attempts(1)), Some(expired_route_params.payment_params.clone()), - &&keys_manager, 0, None).unwrap(); + &&keys_manager, 0, None, None).unwrap(); outbound_payments.find_route_and_send_payment( PaymentHash([0; 32]), PaymentId([0; 32]), expired_route_params, &&router, vec![], &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, @@ -3031,7 +3039,7 @@ mod tests { outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, Some(Retry::Attempts(1)), Some(route_params.payment_params.clone()), - &&keys_manager, 0, None).unwrap(); + &&keys_manager, 0, None, None).unwrap(); outbound_payments.find_route_and_send_payment( PaymentHash([0; 32]), PaymentId([0; 32]), route_params, &&router, vec![], &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, From 2f175a99a468d0a40be1bb0e1a64c53e79d8974b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 10 Feb 2026 08:53:55 +0200 Subject: [PATCH 24/47] ln: add blinding point to new_trampoline_entry When we are forwarding as a trampoline within a blinded path, we need to be able to set a blinding point in the outer onion so that the next blinded trampoline can use it to decrypt its inner onion. This is only used for relaying nodes in the blinded path, because the introduction node's inner onion is encrypted using its node_id (unblinded) pubkey so it can retrieve the path key from inside its trampoline onion. Relaying nodes node_id is unknown to the original sender, so their inner onion is encrypted with their blinded identity. Relaying trampoline nodes therefore have to include the path key in the outer payload so that the inner onion can be decrypted, which in turn contains their blinded data for forwarding. This isn't used for the case where we're the sending node, because all we have to do is include the blinding point for the introduction node. For relaying nodes, we just put their encrypted data inside of their trampoline payload, relying on nodes in the blinded path to pass the blinding point along. --- lightning/src/ln/blinded_payment_tests.rs | 4 +- lightning/src/ln/msgs.rs | 1 - lightning/src/ln/onion_route_tests.rs | 2 +- lightning/src/ln/onion_utils.rs | 51 +++++++++++++++-------- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 10566db3db4..dab32605eeb 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2201,7 +2201,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { ).unwrap(); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); - let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, 32, &None, None, Some(trampoline_packet)).unwrap(); + let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, 32, &None, None, Some((trampoline_packet, None))).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, @@ -2496,7 +2496,7 @@ fn replacement_onion( starting_htlc_offset, &None, None, - Some(trampoline_packet), + Some((trampoline_packet, None)), ) .unwrap(); assert_eq!(outer_payloads.len(), 2); diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 6210d26893a..6ac00178536 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -2637,7 +2637,6 @@ mod fuzzy_internal_msgs { /// This is used for Trampoline hops that are not the blinded path intro hop. /// We would only ever construct this variant when we are a Trampoline node forwarding a /// payment along a blinded path. - #[allow(unused)] BlindedTrampolineEntrypoint { amt_to_forward: u64, outgoing_cltv_value: u32, diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index 019d8faf98c..610042a2add 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -2043,7 +2043,7 @@ fn test_trampoline_onion_payload_assembly_values() { cur_height, &None, None, - Some(trampoline_packet), + Some((trampoline_packet, None)), ) .unwrap(); assert_eq!(outer_payloads.len(), 2); diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 706312a7387..b476a66ef21 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -206,7 +206,7 @@ trait OnionPayload<'a, 'b> { ) -> Self; fn new_trampoline_entry( amt_to_forward: u64, outgoing_cltv_value: u32, recipient_onion: &'a RecipientOnionFields, - packet: msgs::TrampolineOnionPacket, + packet: msgs::TrampolineOnionPacket, blinding_point: Option, ) -> Result; } impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { @@ -258,19 +258,29 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundOnionPayload<'a> { fn new_trampoline_entry( amt_to_forward: u64, outgoing_cltv_value: u32, recipient_onion: &'a RecipientOnionFields, - packet: msgs::TrampolineOnionPacket, + packet: msgs::TrampolineOnionPacket, blinding_point: Option, ) -> Result { - Ok(Self::TrampolineEntrypoint { - amt_to_forward, - outgoing_cltv_value, - multipath_trampoline_data: recipient_onion.payment_secret.map(|payment_secret| { - msgs::FinalOnionHopData { - payment_secret, - total_msat: recipient_onion.total_mpp_amount_msat, - } - }), - trampoline_packet: packet, - }) + let total_msat = recipient_onion.total_mpp_amount_msat; + let multipath_trampoline_data = recipient_onion + .payment_secret + .map(|payment_secret| msgs::FinalOnionHopData { payment_secret, total_msat }); + + if let Some(blinding_point) = blinding_point { + Ok(Self::BlindedTrampolineEntrypoint { + amt_to_forward, + outgoing_cltv_value, + multipath_trampoline_data, + trampoline_packet: packet, + current_path_key: blinding_point, + }) + } else { + Ok(Self::TrampolineEntrypoint { + amt_to_forward, + outgoing_cltv_value, + multipath_trampoline_data, + trampoline_packet: packet, + }) + } } } impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundTrampolinePayload<'a> { @@ -314,6 +324,7 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundTrampolinePayload<'a> { fn new_trampoline_entry( _amt_to_forward: u64, _outgoing_cltv_value: u32, _recipient_onion: &'a RecipientOnionFields, _packet: msgs::TrampolineOnionPacket, + _blinding_point: Option, ) -> Result { Err(APIError::InvalidRoute { err: "Trampoline onions cannot contain Trampoline entrypoints!".to_string(), @@ -446,7 +457,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( pub(crate) fn test_build_onion_payloads<'a>( path: &'a Path, recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, - trampoline_packet: Option, + trampoline_packet: Option<(msgs::TrampolineOnionPacket, Option)>, ) -> Result<(Vec>, u64, u32), APIError> { build_onion_payloads( path, @@ -462,7 +473,7 @@ pub(crate) fn test_build_onion_payloads<'a>( fn build_onion_payloads<'a>( path: &'a Path, recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, - trampoline_packet: Option, + trampoline_packet: Option<(msgs::TrampolineOnionPacket, Option)>, ) -> Result<(Vec>, u64, u32), APIError> { let mut res: Vec = Vec::with_capacity( path.hops.len() + path.blinded_tail.as_ref().map_or(0, |t| t.hops.len()), @@ -472,10 +483,11 @@ fn build_onion_payloads<'a>( // means that the blinded path needs not be appended to the regular hops, and is only included // among the Trampoline onion payloads. let blinded_tail_with_hop_iter = path.blinded_tail.as_ref().map(|bt| { - if let Some(trampoline_packet) = trampoline_packet { + if let Some((trampoline_packet, blinding_point)) = trampoline_packet { return BlindedTailDetails::TrampolineEntry { trampoline_packet, final_value_msat: bt.final_value_msat, + blinding_point, }; } BlindedTailDetails::DirectEntry { @@ -511,6 +523,9 @@ enum BlindedTailDetails<'a, I: Iterator> { TrampolineEntry { trampoline_packet: msgs::TrampolineOnionPacket, final_value_msat: u64, + // If forwarding a trampoline payment inside of a blinded path, this blinding_point will + // be set for the trampoline to decrypt its inner onion. + blinding_point: Option, }, } @@ -581,6 +596,7 @@ where Some(BlindedTailDetails::TrampolineEntry { trampoline_packet, final_value_msat, + blinding_point, }) => { cur_value_msat += final_value_msat; callback( @@ -590,6 +606,7 @@ where declared_incoming_cltv, &recipient_onion, trampoline_packet, + blinding_point, )?, ); }, @@ -2704,7 +2721,7 @@ pub(crate) fn create_payment_onion_internal( err: "Route size too large (or empty) considering onion data".to_owned(), })?; - (&trampoline_outer_onion, Some(trampoline_packet)) + (&trampoline_outer_onion, Some((trampoline_packet, None))) } else { (recipient_onion, None) } From 3bba5f211b30796667e73862282cf5906bfb2ee4 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 28 Jan 2026 15:08:29 -0500 Subject: [PATCH 25/47] ln function to build trampoline forwarding onions --- lightning/src/ln/onion_utils.rs | 49 ++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index b476a66ef21..f7bb677cea1 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -18,7 +18,7 @@ use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::channelmanager::HTLCSource; use crate::ln::msgs::{self, DecodeError, InboundOnionDummyPayload, OnionPacket, UpdateAddHTLC}; use crate::ln::onion_payment::{HopConnector, NextPacketDetails}; -use crate::ln::outbound_payment::RecipientOnionFields; +use crate::ln::outbound_payment::{NextTrampolineHopInfo, RecipientOnionFields}; use crate::offers::invoice_request::InvoiceRequest; use crate::routing::gossip::NetworkUpdate; use crate::routing::router::{BlindedTail, Path, RouteHop, RouteParameters, TrampolineHop}; @@ -2666,6 +2666,53 @@ pub(super) fn compute_trampoline_session_priv(outer_onion_session_priv: &SecretK SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") } +/// Builds a payment onion for an inter-trampoline forward. +pub(crate) fn create_trampoline_forward_onion( + secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, payment_hash: &PaymentHash, + recipient_onion: &RecipientOnionFields, trampoline_forward_info: &NextTrampolineHopInfo, + prng_seed: [u8; 32], +) -> Result<(msgs::OnionPacket, u64, u32), APIError> { + // Inter-trampoline payments should always be cleartext because we need to know the node id + // that we need to route to. LDK does not currently support the legacy "trampoline to blinded + // path" approach, where we get a blinded path to pay inside of our trampoline onion. + debug_assert!(path.blinded_tail.is_none(), "trampoline should not be blinded"); + debug_assert!( + path.final_value_msat() == trampoline_forward_info.amount_msat, + "trampoline path is not equal to next trampoline's required amount" + ); + + let mut res: Vec = Vec::with_capacity(path.hops.len()); + + let blinded_tail_with_hop_iter: BlindedTailDetails<'_, core::iter::Empty<&BlindedHop>> = + BlindedTailDetails::TrampolineEntry { + trampoline_packet: trampoline_forward_info.onion_packet.clone(), + final_value_msat: 0, + blinding_point: trampoline_forward_info.blinding_point, + }; + let (value_msat, cltv) = build_onion_payloads_callback( + path.hops.iter(), + Some(blinded_tail_with_hop_iter), + recipient_onion, + // Note that we use the cltv expiry height that the next trampoline is expecting instead + // of the current block height. This is because we need to create an onion that terminates + // at the next trampoline with the cltv we've been told to give them. + trampoline_forward_info.cltv_expiry_height, + &None, + None, + |action, payload| match action { + PayloadCallbackAction::PushBack => res.push(payload), + PayloadCallbackAction::PushFront => res.insert(0, payload), + }, + )?; + + let onion_keys = construct_onion_keys(&secp_ctx, &path, session_priv); + let onion_packet = + construct_onion_packet(res, onion_keys, prng_seed, payment_hash).map_err(|_| { + APIError::InvalidRoute { err: "Route size too large considering onion data".to_owned() } + })?; + Ok((onion_packet, value_msat, cltv)) +} + /// Build a payment onion, returning the first hop msat and cltv values as well. /// `cur_block_height` should be set to the best known block height + 1. pub(crate) fn create_payment_onion_internal( From c14a4696e50f6550f7d3199d8641a8d2efde04bf Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 1 May 2026 11:06:34 -0400 Subject: [PATCH 26/47] ln: support trampoline in send_payment_along_path --- lightning/src/ln/channelmanager.rs | 72 ++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 30c731400b2..b770059d542 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5506,7 +5506,7 @@ impl< bolt12_invoice, session_priv_bytes, hold_htlc_at_next_hop, - trampoline_forward_info: _, + trampoline_forward_info, } = args; // The top-level caller should hold the total_consistency_lock read lock. debug_assert!(self.total_consistency_lock.try_write().is_err()); @@ -5520,18 +5520,40 @@ impl< Some(*payment_hash), payment_id, ); - let (onion_packet, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion( - &self.secp_ctx, - &path, - &session_priv, - recipient_onion, - cur_height, - payment_hash, - keysend_preimage, - invoice_request, - prng_seed, - ) - .map_err(|e| { + let onion_result = if let Some(trampoline_forward_info) = trampoline_forward_info { + // When we're sending a trampoline related payment, it's not associated with our + // typical payment modes (it's to the next trampoline). + debug_assert!(keysend_preimage.is_none(), "trampoline forwards can't be keysends"); + debug_assert!( + invoice_request.is_none(), + "trampoline forwards can't be invoice requests" + ); + debug_assert!(bolt12_invoice.is_none(), "trampoline forwards can't be bot12 payments"); + + onion_utils::create_trampoline_forward_onion( + &self.secp_ctx, + &path, + &session_priv, + payment_hash, + recipient_onion, + &trampoline_forward_info.next_hop_info, + prng_seed, + ) + } else { + onion_utils::create_payment_onion( + &self.secp_ctx, + &path, + &session_priv, + recipient_onion, + cur_height, + payment_hash, + keysend_preimage, + invoice_request, + prng_seed, + ) + }; + + let (onion_packet, htlc_msat, htlc_cltv) = onion_result.map_err(|e| { log_error!(logger, "Failed to build an onion for path"); e })?; @@ -5575,12 +5597,24 @@ impl< }); } let funding_txo = chan.funding.get_funding_txo().unwrap(); - let htlc_source = HTLCSource::OutboundRoute { - path: path.clone(), - session_priv: session_priv.clone(), - first_hop_htlc_msat: htlc_msat, - payment_id, - bolt12_invoice: bolt12_invoice.cloned(), + let htlc_source = match trampoline_forward_info { + None => HTLCSource::OutboundRoute { + path: path.clone(), + session_priv: session_priv.clone(), + first_hop_htlc_msat: htlc_msat, + payment_id, + bolt12_invoice: bolt12_invoice.cloned(), + }, + Some(trampoline_forward_info) => HTLCSource::TrampolineForward { + previous_hop_data: trampoline_forward_info + .previous_hop_data + .clone(), + outbound_payment: Some(TrampolineDispatch { + payment_id, + path: path.clone(), + session_priv, + }), + }, }; let send_res = chan.send_htlc_and_commit( htlc_msat, From 6fe311c2c1b4623ee7fa3f26c93aa5033270c6e3 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 16 Jan 2026 14:56:00 -0500 Subject: [PATCH 27/47] ln: add send trampoline payment functionality --- lightning/src/ln/outbound_payment.rs | 264 ++++++++++++++++++++++----- 1 file changed, 216 insertions(+), 48 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 04f535c08fd..df6bca4bfa9 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1668,6 +1668,119 @@ impl OutboundPayments { Ok(()) } + /// Errors immediately on [`RetryableSendFailure`] error conditions. Otherwise, further errors may + /// be surfaced asynchronously via [`Event::PaymentPathFailed`] and [`Event::PaymentFailed`]. + /// + /// [`Event::PaymentPathFailed`]: crate::events::Event::PaymentPathFailed + /// [`Event::PaymentFailed`]: crate::events::Event::PaymentFailed + pub(super) fn send_payment_for_trampoline_forward< + R: Router, + NS: NodeSigner, + ES: EntropySource, + IH, + SP, + L: Logger, + >( + &self, payment_id: PaymentId, payment_hash: PaymentHash, + inter_trampoline_payment_secret: PaymentSecret, + trampoline_forward_info: TrampolineForwardInfo, retry_strategy: Retry, + mut route_params: RouteParameters, router: &R, first_hops: Vec, + inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, + pending_events: &Mutex)>>, + send_payment_along_path: SP, logger: &WithContext, + ) -> Result<(), RetryableSendFailure> + where + IH: Fn() -> InFlightHtlcs, + SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + { + let recipient_onion = RecipientOnionFields::secret_only( + inter_trampoline_payment_secret, + trampoline_forward_info.next_hop_info.amount_msat, + ); + + let route = self.find_initial_route( + payment_id, + payment_hash, + &recipient_onion, + None, + None, + &mut route_params, + router, + &first_hops, + &inflight_htlcs, + node_signer, + best_block_height, + logger, + )?; + + let onion_session_privs = self + .add_new_pending_payment( + payment_hash, + recipient_onion.clone(), + payment_id, + None, + &route, + Some(retry_strategy), + Some(route_params.payment_params.clone()), + entropy_source, + best_block_height, + None, + Some(trampoline_forward_info.clone()), + ) + .map_err(|_| { + log_error!( + logger, + "Payment with id {} is already pending. New payment had payment hash {}", + payment_id, + payment_hash + ); + RetryableSendFailure::DuplicatePayment + })?; + + let res = self.pay_route_internal( + &route, + payment_hash, + &recipient_onion, + None, + None, + None, + Some(&trampoline_forward_info), + payment_id, + &onion_session_privs, + false, + node_signer, + best_block_height, + &send_payment_along_path, + ); + log_info!( + logger, + "Sending payment with id {} and hash {} returned {:?}", + payment_id, + payment_hash, + res + ); + if let Err(e) = res { + self.handle_pay_route_err( + e, + payment_id, + payment_hash, + route, + route_params, + onion_session_privs, + router, + first_hops, + &inflight_htlcs, + entropy_source, + node_signer, + best_block_height, + pending_events, + &send_payment_along_path, + logger, + ); + } + Ok(()) + } + #[rustfmt::skip] fn find_route_and_send_payment( &self, payment_hash: PaymentHash, payment_id: PaymentId, route_params: RouteParameters, @@ -2913,10 +3026,11 @@ mod tests { use crate::events::{Event, PathFailure, PaymentFailureReason}; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::ExpandedKey; + use crate::ln::msgs::TrampolineOnionPacket; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::outbound_payment::{ - Bolt12PaymentError, OutboundPayments, PendingOutboundPayment, RecipientCustomTlvs, Retry, - RetryableSendFailure, StaleExpiration, + Bolt12PaymentError, NextTrampolineHopInfo, OutboundPayments, PendingOutboundPayment, + RecipientCustomTlvs, Retry, RetryableSendFailure, StaleExpiration, TrampolineForwardInfo, }; #[cfg(feature = "std")] use crate::offers::invoice::DEFAULT_RELATIVE_EXPIRY; @@ -2931,7 +3045,7 @@ mod tests { }; use crate::sync::{Arc, Mutex, RwLock}; use crate::types::features::{Bolt12InvoiceFeatures, ChannelFeatures, NodeFeatures}; - use crate::types::payment::{PaymentHash, PaymentPreimage}; + use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::errors::APIError; use crate::util::hash_tables::new_hash_map; use crate::util::logger::WithContext; @@ -2939,6 +3053,33 @@ mod tests { use alloc::collections::VecDeque; + #[derive(Copy, Clone)] + enum SendMode { + Initial, + Retry, + TrampolineForward, + } + + fn test_trampoline_forward_info(amount_msat: u64) -> TrampolineForwardInfo { + let secp = Secp256k1::new(); + let secret = SecretKey::from_slice(&[42; 32]).unwrap(); + TrampolineForwardInfo { + next_hop_info: NextTrampolineHopInfo { + onion_packet: TrampolineOnionPacket { + version: 0, + public_key: PublicKey::from_secret_key(&secp, &secret), + hop_data: vec![0; 650], + hmac: [0; 32], + }, + blinding_point: None, + amount_msat, + cltv_expiry_height: 0, + }, + previous_hop_data: Vec::new(), + forwarding_fee_msat: 0, + } + } + #[test] #[rustfmt::skip] fn test_recipient_onion_fields_with_custom_tlvs() { @@ -2966,12 +3107,14 @@ mod tests { #[test] #[cfg(feature = "std")] fn fails_paying_after_expiration() { - do_fails_paying_after_expiration(false); - do_fails_paying_after_expiration(true); + do_fails_paying_after_expiration(SendMode::Initial); + do_fails_paying_after_expiration(SendMode::Retry); + do_fails_paying_after_expiration(SendMode::TrampolineForward); } + #[cfg(feature = "std")] #[rustfmt::skip] - fn do_fails_paying_after_expiration(on_retry: bool) { + fn do_fails_paying_after_expiration(mode: SendMode) { let logger = test_utils::TestLogger::new(); let logger_ref = &logger; let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); @@ -2989,36 +3132,49 @@ mod tests { ).with_expiry_time(past_expiry_time); let expired_route_params = RouteParameters::from_payment_params_and_value(payment_params, 0); let pending_events = Mutex::new(VecDeque::new()); - if on_retry { - outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), - PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, - Some(Retry::Attempts(1)), Some(expired_route_params.payment_params.clone()), - &&keys_manager, 0, None, None).unwrap(); - outbound_payments.find_route_and_send_payment( - PaymentHash([0; 32]), PaymentId([0; 32]), expired_route_params, &&router, vec![], - &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, - &|_| Ok(()), &log); - let events = pending_events.lock().unwrap(); - assert_eq!(events.len(), 1); - if let Event::PaymentFailed { ref reason, .. } = events[0].0 { - assert_eq!(reason.unwrap(), PaymentFailureReason::PaymentExpired); - } else { panic!("Unexpected event"); } - } else { - let err = outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), - Retry::Attempts(0), expired_route_params, &&router, vec![], || InFlightHtlcs::new(), - &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); - if let RetryableSendFailure::PaymentExpired = err { } else { panic!("Unexpected error"); } + match mode { + SendMode::Retry => { + outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), + PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, + Some(Retry::Attempts(1)), Some(expired_route_params.payment_params.clone()), + &&keys_manager, 0, None, None).unwrap(); + outbound_payments.find_route_and_send_payment( + PaymentHash([0; 32]), PaymentId([0; 32]), expired_route_params, &&router, vec![], + &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, + &|_| Ok(()), &log); + let events = pending_events.lock().unwrap(); + assert_eq!(events.len(), 1); + if let Event::PaymentFailed { ref reason, .. } = events[0].0 { + assert_eq!(reason.unwrap(), PaymentFailureReason::PaymentExpired); + } else { panic!("Unexpected event"); } + }, + SendMode::Initial => { + let err = outbound_payments.send_payment( + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), + Retry::Attempts(0), expired_route_params, &&router, vec![], || InFlightHtlcs::new(), + &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); + if let RetryableSendFailure::PaymentExpired = err { } else { panic!("Unexpected error"); } + }, + SendMode::TrampolineForward => { + let err = outbound_payments.send_payment_for_trampoline_forward( + PaymentId([0; 32]), PaymentHash([0; 32]), PaymentSecret([0; 32]), + test_trampoline_forward_info(0), Retry::Attempts(0), expired_route_params, + &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, + &pending_events, |_| Ok(()), &log).unwrap_err(); + if let RetryableSendFailure::PaymentExpired = err { } else { panic!("Unexpected error"); } + }, } } #[test] fn find_route_error() { - do_find_route_error(false); - do_find_route_error(true); + do_find_route_error(SendMode::Initial); + do_find_route_error(SendMode::Retry); + do_find_route_error(SendMode::TrampolineForward); } + #[rustfmt::skip] - fn do_find_route_error(on_retry: bool) { + fn do_find_route_error(mode: SendMode) { let logger = test_utils::TestLogger::new(); let logger_ref = &logger; let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); @@ -3035,25 +3191,37 @@ mod tests { router.expect_find_route(route_params.clone(), Err("")); let pending_events = Mutex::new(VecDeque::new()); - if on_retry { - outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), - PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, - Some(Retry::Attempts(1)), Some(route_params.payment_params.clone()), - &&keys_manager, 0, None, None).unwrap(); - outbound_payments.find_route_and_send_payment( - PaymentHash([0; 32]), PaymentId([0; 32]), route_params, &&router, vec![], - &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, - &|_| Ok(()), &log); - let events = pending_events.lock().unwrap(); - assert_eq!(events.len(), 1); - if let Event::PaymentFailed { .. } = events[0].0 { } else { panic!("Unexpected event"); } - } else { - let err = outbound_payments.send_payment( - PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), - Retry::Attempts(0), route_params, &&router, vec![], || InFlightHtlcs::new(), - &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); - if let RetryableSendFailure::RouteNotFound = err { - } else { panic!("Unexpected error"); } + match mode { + SendMode::Retry => { + outbound_payments.add_new_pending_payment(PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), + PaymentId([0; 32]), None, &Route { paths: vec![], route_params: None }, + Some(Retry::Attempts(1)), Some(route_params.payment_params.clone()), + &&keys_manager, 0, None, None).unwrap(); + outbound_payments.find_route_and_send_payment( + PaymentHash([0; 32]), PaymentId([0; 32]), route_params, &&router, vec![], + &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, + &|_| Ok(()), &log); + let events = pending_events.lock().unwrap(); + assert_eq!(events.len(), 1); + if let Event::PaymentFailed { .. } = events[0].0 { } else { panic!("Unexpected event"); } + }, + SendMode::Initial => { + let err = outbound_payments.send_payment( + PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(0), PaymentId([0; 32]), + Retry::Attempts(0), route_params, &&router, vec![], || InFlightHtlcs::new(), + &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); + if let RetryableSendFailure::RouteNotFound = err { + } else { panic!("Unexpected error"); } + }, + SendMode::TrampolineForward => { + let err = outbound_payments.send_payment_for_trampoline_forward( + PaymentId([0; 32]), PaymentHash([0; 32]), PaymentSecret([0; 32]), + test_trampoline_forward_info(0), Retry::Attempts(0), route_params, + &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, + &pending_events, |_| Ok(()), &log).unwrap_err(); + if let RetryableSendFailure::RouteNotFound = err { + } else { panic!("Unexpected error"); } + }, } } From c9755b5e3bfa3f0976e994d854fc9624bb4c64c3 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 4 May 2026 15:43:50 -0400 Subject: [PATCH 28/47] [wip] Clauded error handling --- lightning/src/ln/onion_utils.rs | 51 +++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index f7bb677cea1..5034da55234 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1017,6 +1017,10 @@ mod fuzzy_onion_utils { pub(crate) onion_error_code: Option, #[cfg(any(test, feature = "_test_utils"))] pub(crate) onion_error_data: Option>, + /// When processing a trampoline forward error that we couldn't decrypt with our + /// session keys, this is the packet with our outer layers peeled off — ready to + /// be re-wrapped and passed back to the original sender. + pub(crate) trampoline_peeled_packet: Option, #[cfg(test)] pub(crate) attribution_failed_channel: Option, } @@ -1027,10 +1031,19 @@ mod fuzzy_onion_utils { ) -> DecodedOnionFailure { let (path, session_priv) = match htlc_source { HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => (path, session_priv), + HTLCSource::TrampolineForward { outbound_payment, .. } => { + let Some(dispatch) = outbound_payment.as_ref() else { + debug_assert!( + false, + "Trampoline onion failure on forward with no outbound payment details" + ); + return permanent_failure(); + }; + (&dispatch.path, &dispatch.session_priv) + }, _ => unreachable!(), }; - - process_onion_failure_inner(secp_ctx, logger, path, &session_priv, None, encrypted_packet) + process_onion_failure_inner(secp_ctx, logger, path, session_priv, None, encrypted_packet) } /// Decodes the attribution data that we got back from upstream on a payment we sent. @@ -1087,6 +1100,23 @@ pub use self::fuzzy_onion_utils::*; #[cfg(not(fuzzing))] pub(crate) use self::fuzzy_onion_utils::*; +fn permanent_failure() -> DecodedOnionFailure { + DecodedOnionFailure { + network_update: None, + short_channel_id: None, + payment_failed_permanently: true, + failed_within_blinded_path: false, + hold_times: Vec::new(), + #[cfg(any(test, feature = "_test_utils"))] + onion_error_code: None, + #[cfg(any(test, feature = "_test_utils"))] + onion_error_data: None, + trampoline_peeled_packet: None, + #[cfg(test)] + attribution_failed_channel: None, + } +} + /// Process failure we got back from upstream on a payment we sent (implying htlc_source is an /// OutboundRoute). fn process_onion_failure_inner( @@ -1104,19 +1134,7 @@ fn process_onion_failure_inner( // Signal that we failed permanently. Without a valid hmac, we can't identify the failing node and we can't // apply a penalty. Therefore there is nothing more we can do other than failing the payment. - return DecodedOnionFailure { - network_update: None, - short_channel_id: None, - payment_failed_permanently: true, - failed_within_blinded_path: false, - hold_times: Vec::new(), - #[cfg(any(test, feature = "_test_utils"))] - onion_error_code: None, - #[cfg(any(test, feature = "_test_utils"))] - onion_error_data: None, - #[cfg(test)] - attribution_failed_channel: None, - }; + return permanent_failure(); } // Learnings from the HTLC failure to inform future payment retries and scoring. @@ -1504,6 +1522,7 @@ fn process_onion_failure_inner( onion_error_code: _error_code_ret, #[cfg(any(test, feature = "_test_utils"))] onion_error_data: _error_packet_ret, + trampoline_peeled_packet: None, #[cfg(test)] attribution_failed_channel, } @@ -1527,6 +1546,7 @@ fn process_onion_failure_inner( onion_error_code: None, #[cfg(any(test, feature = "_test_utils"))] onion_error_data: None, + trampoline_peeled_packet: Some(encrypted_packet), #[cfg(test)] attribution_failed_channel, } @@ -2173,6 +2193,7 @@ impl HTLCFailReason { onion_error_code: Some(_failure_reason), #[cfg(any(test, feature = "_test_utils"))] onion_error_data: Some(_data.to_vec()), + trampoline_peeled_packet: None, #[cfg(test)] attribution_failed_channel: None, } From 6d03b12eed7e3976f7b61f97baa86eb9406c2652 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 09:55:06 -0400 Subject: [PATCH 29/47] [wip] ln: add trampoline htlc failure logic to outbound payments - [ ] Check whether we can get away with checking path.hops[0] directly (outbound_payment should always be present?) --- lightning/src/ln/outbound_payment.rs | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index df6bca4bfa9..bcb66d76937 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2575,6 +2575,103 @@ impl OutboundPayments { }); } + // Reports a failed HTLC that is part of an outgoing trampoline forward. Returns Some() if + // the incoming HTLC(s) associated with the trampoline should be failed back. + pub(super) fn trampoline_htlc_failed( + &self, source: &HTLCSource, payment_hash: &PaymentHash, onion_error: &HTLCFailReason, + secp_ctx: &Secp256k1, logger: &WithContext, + ) -> Option { + #[cfg(any(test, feature = "_test_utils"))] + let decoded_onion = onion_error.decode_onion_failure(secp_ctx, &logger, &source); + + #[cfg(not(any(test, feature = "_test_utils")))] + let decoded_onion = onion_error.decode_onion_failure(secp_ctx, &logger, &source); + + let (payment_id, path, session_priv) = match source { + HTLCSource::TrampolineForward { outbound_payment, .. } => { + let outbound_payment = outbound_payment.clone().unwrap(); + (outbound_payment.payment_id, outbound_payment.path, outbound_payment.session_priv) + }, + _ => { + debug_assert!(false, "trampoline payment failed with no dispatch information"); + return None; + }, + }; + + let mut session_priv_bytes = [0; 32]; + session_priv_bytes.copy_from_slice(&session_priv[..]); + let mut outbounds = self.pending_outbound_payments.lock().unwrap(); + + let attempts_remaining = + if let hash_map::Entry::Occupied(mut payment) = outbounds.entry(payment_id) { + if !payment.get_mut().remove(&session_priv_bytes, Some(&path)) { + log_trace!( + logger, + "Received duplicative fail for HTLC with payment_hash {}", + &payment_hash + ); + return None; + } + if payment.get().is_fulfilled() { + log_trace!( + logger, + "Received failure of HTLC with payment_hash {} after payment completion", + &payment_hash + ); + return None; + } + let mut is_retryable_now = payment.get().is_auto_retryable_now(); + if let Some(scid) = decoded_onion.short_channel_id { + // TODO: If we decided to blame ourselves (or one of our channels) in + // process_onion_failure we should close that channel as it implies our + // next-hop is needlessly blaming us! + payment.get_mut().insert_previously_failed_scid(scid); + } + if decoded_onion.failed_within_blinded_path { + debug_assert!(decoded_onion.short_channel_id.is_none()); + if let Some(bt) = &path.blinded_tail { + payment.get_mut().insert_previously_failed_blinded_path(&bt); + } else { + debug_assert!(false); + } + } + + if !is_retryable_now || decoded_onion.payment_failed_permanently { + let reason = if decoded_onion.payment_failed_permanently { + PaymentFailureReason::RecipientRejected + } else { + PaymentFailureReason::RetriesExhausted + }; + payment.get_mut().mark_abandoned(reason); + is_retryable_now = false; + } + if payment.get().remaining_parts() == 0 { + if let PendingOutboundPayment::Abandoned { .. } = payment.get() { + payment.remove(); + return Some(decoded_onion); + } + } + is_retryable_now + } else { + log_trace!( + logger, + "Received fail for HTLC with payment_hash {} not found.", + &payment_hash + ); + return Some(decoded_onion); + }; + core::mem::drop(outbounds); + log_trace!(logger, "Failing Trampoline forward HTLC with payment_hash {}", &payment_hash); + + // If we miss abandoning the payment above, we *must* generate an event here or else the + // payment will sit in our outbounds forever. + if attempts_remaining { + return None; + }; + + return Some(decoded_onion); + } + pub(super) fn fail_htlc( &self, source: &HTLCSource, payment_hash: &PaymentHash, onion_error: &HTLCFailReason, path: &Path, session_priv: &SecretKey, payment_id: &PaymentId, From 87712a8c1deed19f7d762feadec95fa165557e1f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 18 Feb 2026 15:26:57 +0200 Subject: [PATCH 30/47] ln: add claim_trampoline_forward to mark trampoline complete --- lightning/src/ln/outbound_payment.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index bcb66d76937..0246941fe87 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2971,6 +2971,33 @@ impl OutboundPayments { }, } } + + /// Looks up a trampoline forward by its payment id, marks it as fulfilled, and returns the + /// forwarding fee our node earned. Returns None if the payment is not found or it does not + /// have trampoline forwarding information. + pub(crate) fn claim_trampoline_forward( + &self, payment_id: &PaymentId, session_priv: &SecretKey, from_onchain: bool, + ) -> Option { + let mut outbounds = self.pending_outbound_payments.lock().unwrap(); + if let hash_map::Entry::Occupied(mut payment) = outbounds.entry(*payment_id) { + let fee = match payment.get() { + PendingOutboundPayment::Retryable { trampoline_forward_info, .. } => { + trampoline_forward_info.as_ref().map(|info| info.forwarding_fee_msat) + }, + _ => None, + }; + if !payment.get().is_fulfilled() { + payment.get_mut().mark_fulfilled(); + } + if from_onchain { + let session_priv_bytes = session_priv.secret_bytes(); + payment.get_mut().remove(&session_priv_bytes, None); + } + fee + } else { + None + } + } } /// Returns whether a payment with the given [`PaymentHash`] and [`PaymentId`] is, in fact, a From 47bdb9129a0bdfea71799dd2f70223aa8f7025ce Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 18 Feb 2026 15:27:28 +0200 Subject: [PATCH 31/47] ln: handle trampoline payments in finalize_claims --- lightning/src/ln/channelmanager.rs | 2 ++ lightning/src/ln/outbound_payment.rs | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b770059d542..4160d9544a7 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10454,6 +10454,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); Some((source, hold_times)) + } else if let HTLCSource::TrampolineForward { .. } = source { + Some((source, Vec::new())) } else { None } diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 0246941fe87..61bfe82f5db 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2482,6 +2482,14 @@ impl OutboundPayments { }, None)); } } + } else if let HTLCSource::TrampolineForward { + outbound_payment: Some(trampoline_dispatch), .. + } = source { + let session_priv_bytes = trampoline_dispatch.session_priv.secret_bytes(); + if let hash_map::Entry::Occupied(mut payment) = outbounds.entry(trampoline_dispatch.payment_id) { + assert!(payment.get().is_fulfilled()); + payment.get_mut().remove(&session_priv_bytes, None); + } } } } From 5982a3f19e4916c6c9be29a1cf4587472a1ac9f0 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:07:59 -0400 Subject: [PATCH 32/47] ln: only fail trampoline payments backwards when payment state ready ln: return appropriate error for trampoline failures --- lightning/src/ln/channelmanager.rs | 145 +++++++++++++++++------------ lightning/src/ln/onion_utils.rs | 6 +- 2 files changed, 88 insertions(+), 63 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 4160d9544a7..eb71f389066 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9712,72 +9712,93 @@ impl< None, )); }, - HTLCSource::TrampolineForward { previous_hop_data, .. } => { - let decoded_onion_failure = - onion_error.decode_onion_failure(&self.secp_ctx, &self.logger, &source); - log_trace!( - WithContext::from(&self.logger, None, None, Some(*payment_hash)), - "Trampoline forward failed downstream on {}", - if let Some(scid) = decoded_onion_failure.short_channel_id { - scid.to_string() - } else { - "unknown channel".to_string() - }, - ); - // 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 - // pending_outbound_payments in a followup. - for current_hop_data in previous_hop_data { - let HTLCPreviousHopData { - prev_outbound_scid_alias, - htlc_id, - incoming_packet_shared_secret, - blinded_failure, - channel_id, - trampoline_shared_secret, - .. - } = current_hop_data; - log_trace!( - WithContext::from(&self.logger, None, Some(*channel_id), Some(*payment_hash)), - "Failing {}HTLC with payment_hash {} backwards from us following Trampoline forwarding failure: {:?}", - if blinded_failure.is_some() { "blinded " } else { "" }, &payment_hash, onion_error - ); - let onion_error = HTLCFailReason::reason( + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment, .. } => { + let trampoline_error = match outbound_payment { + Some(_) => self + .pending_outbound_payments + .trampoline_htlc_failed( + source, + payment_hash, + onion_error, + &self.secp_ctx, + &WithContext::from(&self.logger, None, None, Some(*payment_hash)), + ) + .map(|decoded| { + if let Some(peeled_packet) = decoded.trampoline_peeled_packet { + log_trace!( + WithContext::from(&self.logger, None, None, Some(*payment_hash)), + "Received trampoline error intended for original sender, returning as-is", + ); + HTLCFailReason::from_onion_error_packet(peeled_packet) + } else { + // Either the failure decoded with our session keys (it was for + // us as forwarder) or we couldn't peel any outer layers (e.g., + // packet too short). Either way, replace with our own error. + log_trace!( + WithContext::from(&self.logger, None, None, Some(*payment_hash)), + "Received trampoline error for us as forwarder, returning TemporaryTrampolineFailure to original sender", + ); + HTLCFailReason::reason( + LocalHTLCFailureReason::TemporaryTrampolineFailure, + Vec::new(), + ) + } + }), + None => Some(HTLCFailReason::reason( 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, + )), + }; + + if let Some(err) = trampoline_error { + for current_hop_data in previous_hop_data { + let HTLCPreviousHopData { + prev_outbound_scid_alias, + htlc_id, incoming_packet_shared_secret, - &trampoline_shared_secret, - &None, - *htlc_id, - ), - ); - } + blinded_failure, + channel_id, + trampoline_shared_secret, + .. + } = current_hop_data; - // We only want to emit a single event for trampoline failures, so we do it once - // we've failed back all of our incoming HTLCs. - let mut pending_events = self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::HTLCHandlingFailed { - prev_channel_ids: previous_hop_data - .iter() - .map(|prev| prev.channel_id) - .collect(), - failure_type, - failure_reason: Some(onion_error.into()), - }, - None, - )); + log_trace!( + WithContext::from(&self.logger, None, Some(*channel_id), Some(*payment_hash)), + "Failing {}HTLC with payment_hash {} backwards from us following Trampoline forwarding failure {:?}", + if blinded_failure.is_some() { "blinded " } else { "" }, &payment_hash, err, + ); + 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, + &err, + incoming_packet_shared_secret, + &trampoline_shared_secret, + &None, + *htlc_id, + ), + ); + } + + // We only want to emit a single event for trampoline failures, so we do it once + // we've failed back all of our incoming HTLCs. + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::HTLCHandlingFailed { + prev_channel_ids: previous_hop_data + .iter() + .map(|prev| prev.channel_id) + .collect(), + failure_type, + failure_reason: Some(onion_error.into()), + }, + None, + )); + } }, } } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 5034da55234..9e240f5ca0a 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1806,7 +1806,7 @@ impl LocalHTLCFailureReason { /// Returns true if the failure is only sent by the final recipient. Note that this function /// only checks [`LocalHTLCFailureReason`] variants that represent bolt 04 errors directly, /// as it's intended to analyze errors we've received as a sender. - fn is_recipient_failure(&self) -> bool { + pub(super) fn is_recipient_failure(&self) -> bool { self.failure_code() == LocalHTLCFailureReason::IncorrectPaymentDetails.failure_code() || *self == LocalHTLCFailureReason::FinalIncorrectCLTVExpiry || *self == LocalHTLCFailureReason::FinalIncorrectHTLCAmount @@ -2126,6 +2126,10 @@ impl HTLCFailReason { }) } + pub(super) fn from_onion_error_packet(packet: OnionErrorPacket) -> Self { + Self(HTLCFailReasonRepr::LightningError { err: packet, hold_time: None }) + } + /// Encrypted a failure packet using a shared secret. /// /// For phantom nodes or inner Trampoline onions, a secondary_shared_secret can be passed, which From 6fb748f23538b0b7ad4cdb4ff0ff810fd1ae5e7a Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 18 Feb 2026 15:30:07 +0200 Subject: [PATCH 33/47] ln: claim trampoline payment on completion --- lightning/src/ln/channelmanager.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index eb71f389066..8e44314a272 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10591,7 +10591,29 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ send_timestamp, ); }, - HTLCSource::TrampolineForward { previous_hop_data, .. } => { + HTLCSource::TrampolineForward { previous_hop_data, outbound_payment, .. } => { + let total_fee_earned_msat = match &outbound_payment { + Some(trampoline_dispatch) => { + let fee = self.pending_outbound_payments.claim_trampoline_forward( + &trampoline_dispatch.payment_id, + &trampoline_dispatch.session_priv, + from_onchain, + ); + debug_assert!( + fee.is_some(), + "Trampoline payment with unknown payment_id: {} settled", + trampoline_dispatch.payment_id + ); + fee + }, + None => { + debug_assert!( + false, + "Trampoline payment settled with no outbound payment dispatched" + ); + None + }, + }; // Only emit a single event for trampoline claims. let prev_htlcs: Vec = previous_hop_data.iter().map(Into::into).collect(); @@ -10610,10 +10632,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: next_user_channel_id, node_id: Some(next_channel_counterparty_node_id), }], - // TODO: When trampoline payments are tracked in our - // pending_outbound_payments, we'll be able to lookup our total - // fee earnings. - total_fee_earned_msat: None, + total_fee_earned_msat, skimmed_fee_msat, claim_from_onchain_tx: from_onchain, // TODO: When trampoline payments are tracked in our From b760884b44b6599aed7fac2d42071fc24c5b706a Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 2 Feb 2026 13:52:19 -0500 Subject: [PATCH 34/47] ln: use correct blinding point for trampoline payload decodes The blinding point that we pass in is supposed to be the "update add" blinding point equivalent, which in blinded trampoline relay is the one that we get in the outer onion. --- lightning/src/ln/onion_utils.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 9e240f5ca0a..30227e38fd9 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2480,7 +2480,10 @@ pub(crate) fn decode_next_payment_hop( &hop_data.trampoline_packet.hop_data, hop_data.trampoline_packet.hmac, Some(payment_hash), - (blinding_point, &node_signer), + // When we have a trampoline packet, the current_path_key in our outer onion + // payload plays the role of the update_add_htlc blinding_point for the inner + // onion. + (hop_data.current_path_key, node_signer), ); match decoded_trampoline_hop { Ok(( From 581f95343bba455c43b24caf1442db10d9f787c2 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 24 Feb 2026 16:22:39 +0200 Subject: [PATCH 35/47] ln: allow reading HTLCSource::TrampolineForward We failed here to prevent downgrade to versions of LDK that didn't have full trampoline support. Now that we're done, we can allow reads. --- lightning/src/ln/channelmanager.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8e44314a272..9bdc6bf6b0c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18294,8 +18294,16 @@ impl Readable for HTLCSource { }) } 1 => Ok(HTLCSource::PreviousHopData(Readable::read(reader)?)), - // Note: we intentionally do not read HTLCSource::TrampolineForward because we do not - // want to allow downgrades with in-flight trampoline forwards. + 2 => { + _init_and_read_len_prefixed_tlv_fields!(reader, { + (1, previous_hop_data, required_vec), + (3, outbound_payment, option), + }); + Ok(HTLCSource::TrampolineForward { + previous_hop_data: _init_tlv_based_struct_field!(previous_hop_data, required_vec), + outbound_payment, + }) + }, _ => Err(DecodeError::UnknownRequiredFeature), } } From ff63248c105e80abd45f05275eac38c8391cf010 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 15:32:30 -0400 Subject: [PATCH 36/47] ln: add trampoline payment dispatch after inbound accumulation To enable trampoline forwarding fully, remove the forced error introduced to prevent forwarding trampoline payments when we weren't ready. --- lightning/src/ln/blinded_payment_tests.rs | 5 +- lightning/src/ln/channelmanager.rs | 88 +++++++++++++++++--- lightning/src/ln/trampoline_forward_tests.rs | 2 +- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index dab32605eeb..830e604b18b 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2735,9 +2735,8 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { /// 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. +/// Carol's inner trampoline onion is a forward to an unknown next node, which is intentionally +/// faked to force a forwarding failure after our MPP parts have accumulated. /// /// Returns (payment_hash, per_path_amount, last_hop_cltv_delta, ev_to_bob, ev_to_barry). fn send_trampoline_mpp_payment<'a, 'b, 'c>( diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9bdc6bf6b0c..1f0d63acdd8 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -92,6 +92,7 @@ use crate::ln::outbound_payment::{ Bolt11PaymentError, Bolt12PaymentError, NextTrampolineHopInfo, OutboundPayments, PendingOutboundPayment, ProbeSendFailure, RecipientCustomTlvs, RecipientOnionFields, Retry, RetryableInvoiceRequest, RetryableSendFailure, SendAlongPathArgs, StaleExpiration, + TrampolineForwardInfo, }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; @@ -116,14 +117,14 @@ use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; use crate::routing::gossip::{NodeId, RoutingFees}; use crate::routing::router::{ compute_fees, BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, - RouteParameters, RouteParametersConfig, Router, + RouteParameters, RouteParametersConfig, Router, DEFAULT_MAX_PATH_COUNT, + MAX_PATH_LENGTH_ESTIMATE, }; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::{EntropySource, NodeSigner, Recipient, SignerProvider}; -#[cfg(any(feature = "_test_utils", test))] -use crate::types::features::Bolt11InvoiceFeatures; use crate::types::features::{ - Bolt12InvoiceFeatures, ChannelFeatures, ChannelTypeFeatures, InitFeatures, NodeFeatures, + Bolt11InvoiceFeatures, Bolt12InvoiceFeatures, ChannelFeatures, ChannelTypeFeatures, + InitFeatures, NodeFeatures, }; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::types::string::UntrustedString; @@ -8545,7 +8546,7 @@ impl< /// 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, + next_hop_info: NextTrampolineHopInfo, next_node_id: PublicKey, ) -> Result<(), (HTLCSource, HTLCFailReason)> { let mut trampoline_payments = self.awaiting_trampoline_forwards.lock().unwrap(); @@ -8646,7 +8647,7 @@ impl< 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 + let max_total_routing_fee_msat = match our_forwarding_fee_msat .and_then(|our_fee| our_fee.checked_add(next_hop_info.amount_msat)) .and_then(|total| incoming_amt_msat.checked_sub(total)) { @@ -8656,7 +8657,7 @@ impl< }, }; - let _max_total_cltv_expiry_delta = match next_hop_info + 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)) @@ -8667,15 +8668,78 @@ impl< }, }; + // Assume any Trampoline node supports MPP + let mut recipient_features = Bolt11InvoiceFeatures::empty(); + recipient_features.set_basic_mpp_optional(); + + let route_parameters = RouteParameters { + payment_params: PaymentParameters { + payee: Payee::Clear { + node_id: next_node_id, // TODO: this can be threaded through from above + route_hints: vec![], + features: Some(recipient_features), + // When sending a trampoline payment, we assume that the original sender has + // baked a final cltv into our instructions. + final_cltv_expiry_delta: 0, + }, + expiry_time: None, + max_total_cltv_expiry_delta, + max_path_count: DEFAULT_MAX_PATH_COUNT, + max_path_length: MAX_PATH_LENGTH_ESTIMATE / 2, + max_channel_saturation_power_of_half: 2, + previously_failed_channels: vec![], + previously_failed_blinded_path_idxs: vec![], + }, + final_value_msat: next_hop_info.amount_msat, + max_total_routing_fee_msat: Some(max_total_routing_fee_msat), + }; + + #[cfg(not(any(test, feature = "_test_utils")))] + let retry_strategy = Retry::Attempts(3); + #[cfg(any(test, feature = "_test_utils"))] + let retry_strategy = Retry::Attempts(0); + log_debug!( self.logger, - "Rejecting trampoline forward because we do not fully support forwarding yet.", + "Attempting to forward trampoline payment that pays us {} with {} fee budget ({} total, {} cltv max)", + our_forwarding_fee_msat.unwrap_or(0), + max_total_routing_fee_msat, + next_hop_info.amount_msat, + max_total_cltv_expiry_delta, + ); + let result = self.pending_outbound_payments.send_payment_for_trampoline_forward( + PaymentId(payment_hash.0), + payment_hash, + PaymentSecret(self.entropy_source.get_secure_random_bytes()), + TrampolineForwardInfo { + next_hop_info, + previous_hop_data: trampoline_payment + .htlcs + .iter() + .map(|htlc| htlc.prev_hop.clone()) + .collect(), + forwarding_fee_msat: our_forwarding_fee_msat.unwrap_or(0), + }, + retry_strategy, + route_parameters.clone(), + &self.router, + self.list_usable_channels(), + || self.compute_inflight_htlcs(), + &self.entropy_source, + &self.node_signer, + self.current_best_block().height, + &self.pending_events, + |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, Some(payment_hash)), ); - Err(( - trampoline_source(), - HTLCFailReason::reason(LocalHTLCFailureReason::TemporaryTrampolineFailure, vec![]), - )) + if let Err(_retryable_send_failure) = result { + return Err(( + trampoline_source(), + HTLCFailReason::reason(LocalHTLCFailureReason::TemporaryTrampolineFailure, vec![]), + )); + }; + Ok(()) } fn process_receive_htlcs( diff --git a/lightning/src/ln/trampoline_forward_tests.rs b/lightning/src/ln/trampoline_forward_tests.rs index 6133bd07583..182a5eb523e 100644 --- a/lightning/src/ln/trampoline_forward_tests.rs +++ b/lightning/src/ln/trampoline_forward_tests.rs @@ -104,7 +104,7 @@ fn do_test_trampoline_mpp_validation(test_case: Option LocalHTLCFailureReason::TemporaryTrampolineFailure, }; From f1bb42ac88f57ab42715cb048340f9b00f38bfb7 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 10 Feb 2026 09:57:43 +0200 Subject: [PATCH 37/47] ln/test: only use replacement onion in trampoline tests when needed Don't always blindly replace with a manually built test onion when we run trampoline tests (only for unblinded / failure cases where we need to mess with the onion). The we update our replacement onion logic to correctly match our internal behavior which adds one block to the current height when dispatching payments. --- lightning/src/ln/blinded_payment_tests.rs | 44 ++++++++++++----------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 830e604b18b..4fe2875ac72 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2438,6 +2438,7 @@ fn replacement_onion( original_trampoline_cltv: u32, payment_hash: PaymentHash, payment_secret: PaymentSecret, blinded: bool, ) -> msgs::OnionPacket { + assert!(!blinded || !matches!(test_case, TrampolineTestCase::Success)); let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(original_amt_msat); @@ -2648,21 +2649,26 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { // Replace the onion to test different scenarios: // - If !blinded: Creates a payload sending to an unblinded trampoline // - If blinded: Modifies outer onion to create outer/inner mismatches if testing failures - update_message.map(|msg| { - msg.onion_routing_packet = replacement_onion( - test_case, - &secp_ctx, - override_random_bytes, - route, - original_amt_msat, - starting_htlc_offset, - original_trampoline_cltv, - excess_final_cltv, - payment_hash, - payment_secret, - blinded, - ) - }); + if !blinded || !matches!(test_case, TrampolineTestCase::Success) { + update_message.map(|msg| { + msg.onion_routing_packet = replacement_onion( + test_case, + &secp_ctx, + override_random_bytes, + route, + original_amt_msat, + // Our internal send payment helpers add one block to the current height to + // create our payments. Do the same here so that our replacement onion will have + // the right cltv. + starting_htlc_offset + 1, + original_trampoline_cltv, + excess_final_cltv, + payment_hash, + payment_secret, + blinded, + ) + }); + } let route: &[&Node] = &[&nodes[1], &nodes[2]]; let args = PassAlongPathArgs::new( @@ -2673,10 +2679,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { first_message_event, ); + let final_cltv_height = original_trampoline_cltv + starting_htlc_offset + excess_final_cltv + 1; let amt_bytes = test_case.outer_onion_amt(original_amt_msat).to_be_bytes(); - let cltv_bytes = test_case - .outer_onion_cltv(original_trampoline_cltv + starting_htlc_offset + excess_final_cltv) - .to_be_bytes(); + let cltv_bytes = test_case.outer_onion_cltv(final_cltv_height).to_be_bytes(); let payment_failure = test_case.payment_failed_conditions(&amt_bytes, &cltv_bytes).map(|p| { if blinded { PaymentFailedConditions::new() @@ -2690,8 +2695,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) } else { - let htlc_cltv = starting_htlc_offset + original_trampoline_cltv + excess_final_cltv; - args.with_payment_secret(payment_secret).with_payment_claimable_cltv(htlc_cltv) + args.with_payment_secret(payment_secret).with_payment_claimable_cltv(final_cltv_height) }; do_pass_along_path(args); From 37fd8c43d547f68b823cdfbd1e315ad2e19298b9 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 3 Feb 2026 09:23:11 -0500 Subject: [PATCH 38/47] [deleteme]: remove assertion that fails on unblinded test - [ ] Right now, we assume that the presence of a trampoline means that we're in a blinded route. This fails when we test an unblinded case (which we do to get coverage for forwarding). We likely need to decouple trampoline and blinded tail to allow this to work properly. --- lightning/src/routing/router.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index edb048c8c7d..7a692f80d0e 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -1326,7 +1326,7 @@ impl PaymentParameters { found_blinded_tail = true; } } - debug_assert!(found_blinded_tail); + //debug_assert!(found_blinded_tail); } } From ccad55751d6dfa432ff71824b9e0fa2039cbf3ae Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 09:07:20 -0400 Subject: [PATCH 39/47] [wip]ln: pass trampoline secret to construct_pending_htlc_fail_msg - [ ] TODO: should we always double wrap? --- lightning/src/ln/channelmanager.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1f0d63acdd8..657208aee24 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5314,7 +5314,8 @@ impl< #[rustfmt::skip] fn construct_pending_htlc_fail_msg<'a>( &self, msg: &msgs::UpdateAddHTLC, counterparty_node_id: &PublicKey, - shared_secret: [u8; 32], inbound_err: InboundHTLCErr + shared_secret: [u8; 32], trampoline_shared_secret: &Option<[u8; 32]>, + inbound_err: InboundHTLCErr, ) -> HTLCFailureMsg { let logger = WithContext::from(&self.logger, Some(*counterparty_node_id), Some(msg.channel_id), Some(msg.payment_hash)); log_info!(logger, "Failed to accept/forward incoming HTLC: {}", inbound_err.msg); @@ -5331,7 +5332,7 @@ impl< } let failure = HTLCFailReason::reason(inbound_err.reason, inbound_err.err_data.to_vec()) - .get_encrypted_failure_packet(&shared_secret, &None); + .get_encrypted_failure_packet(&shared_secret, trampoline_shared_secret); return HTLCFailureMsg::Relay(msgs::UpdateFailHTLC { channel_id: msg.channel_id, htlc_id: msg.htlc_id, @@ -7673,6 +7674,16 @@ impl< } } + // Extract the trampoline shared secret before `next_hop` is consumed, + // so we can double-encrypt errors for trampoline receives. + let trampoline_shared_secret = match &next_hop { + onion_utils::Hop::TrampolineReceive { trampoline_shared_secret, .. } + | onion_utils::Hop::TrampolineBlindedReceive { + trampoline_shared_secret, .. + } => Some(trampoline_shared_secret.secret_bytes()), + _ => None, + }; + match self.get_pending_htlc_info( &update_add_htlc, shared_secret, @@ -7778,6 +7789,7 @@ impl< &update_add_htlc, &incoming_counterparty_node_id, shared_secret, + &trampoline_shared_secret, inbound_err, ); htlc_fails.push((htlc_fail, failure_type, htlc_failure)); From 148ef00b823a14240dac5a5124c363fe1b2e1b95 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 12 May 2026 15:15:50 -0400 Subject: [PATCH 40/47] [wip]: forwarding tests with messy replacement onion code --- lightning/src/ln/blinded_payment_tests.rs | 450 +++++++++++++++++----- lightning/src/routing/router.rs | 2 +- 2 files changed, 348 insertions(+), 104 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 4fe2875ac72..42a23ff063d 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -10,7 +10,7 @@ use crate::blinded_path::payment::{ BlindedPaymentPath, Bolt12RefundContext, DummyTlvs, ForwardNode, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, - PAYMENT_PADDING_ROUND_OFF, + TrampolineForwardTlvs, PAYMENT_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::{self, BlindedHop}; @@ -30,8 +30,10 @@ use crate::ln::outbound_payment::{ use crate::ln::types::ChannelId; use crate::offers::invoice::UnsignedBolt12Invoice; use crate::prelude::*; +use crate::routing::gossip::RoutingFees; use crate::routing::router::{ - BlindedTail, Path, Payee, PaymentParameters, Route, RouteHop, RouteParameters, TrampolineHop, + compute_fees, BlindedTail, Path, Payee, PaymentParameters, Route, RouteHop, RouteParameters, + TrampolineHop, }; use crate::sign::{NodeSigner, PeerStorageKey, ReceiveAuthKey, Recipient}; use crate::types::features::{BlindedHopFeatures, ChannelFeatures, NodeFeatures}; @@ -2401,16 +2403,16 @@ impl<'a> TrampolineTestCase { } } - fn outer_onion_cltv(&self, outer_cltv: u32) -> u32 { + fn inner_onion_cltv(&self, outer_cltv: u32) -> u32 { if *self == TrampolineTestCase::OuterCLTVLessThanTrampoline { - return outer_cltv / 2; + return outer_cltv * 10; } outer_cltv } - fn outer_onion_amt(&self, original_amt: u64) -> u64 { + fn inner_onion_amt(&self, original_amt: u64) -> u64 { if *self == TrampolineTestCase::Underpayment { - return original_amt / 2; + return original_amt * 10; } original_amt } @@ -2434,20 +2436,20 @@ fn test_trampoline_blinded_receive() { // payloads that send to unblinded receives and invalid payloads. fn replacement_onion( test_case: TrampolineTestCase, secp_ctx: &Secp256k1, override_random_bytes: [u8; 32], - route: Route, original_amt_msat: u64, starting_htlc_offset: u32, excess_final_cltv: u32, - original_trampoline_cltv: u32, payment_hash: PaymentHash, payment_secret: PaymentSecret, - blinded: bool, + route: Route, fred_amt_msat: u64, fred_final_cltv: u32, excess_final_cltv_delta: u32, + payment_hash: PaymentHash, payment_secret: PaymentSecret, blinded: bool, + starting_htlc_offset: u32, carol: PublicKey, eve: (PublicKey, &PaymentRelay), fred: PublicKey, ) -> msgs::OnionPacket { assert!(!blinded || !matches!(test_case, TrampolineTestCase::Success)); let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(original_amt_msat); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(fred_amt_msat); let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); - // Rebuild our trampoline packet from the original route. If we want to test Carol receiving - // as an unblinded trampoline hop, we switch out her inner trampoline onion with a direct - // receive payload because LDK doesn't support unblinded trampoline receives. + // Rebuild our trampoline packet from the original route. If we want to test Fred receiving + // as an unblinded trampoline hop, we switch out the trampoline packets with unblinded ones + // because LDK doesn't support unblinded trampoline receives. let (trampoline_packet, outer_total_msat) = { let (mut trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads( @@ -2459,21 +2461,106 @@ fn replacement_onion( .unwrap(); if !blinded { - trampoline_payloads = vec![msgs::OutboundTrampolinePayload::Receive { - payment_data: Some(msgs::FinalOnionHopData { - payment_secret, - total_msat: original_amt_msat, - }), - sender_intended_htlc_amt_msat: original_amt_msat, - cltv_expiry_height: original_trampoline_cltv - + starting_htlc_offset - + excess_final_cltv, - }]; + let eve_trampoline_fees = compute_fees( + fred_amt_msat, + RoutingFees { + base_msat: eve.1.fee_base_msat, + proportional_millionths: eve.1.fee_proportional_millionths, + }, + ) + .unwrap(); + + trampoline_payloads = vec![ + // Carol must forward to Eve with enough fees + CLTV to cover her policy. + msgs::OutboundTrampolinePayload::Forward { + amt_to_forward: fred_amt_msat + eve_trampoline_fees, + outgoing_cltv_value: starting_htlc_offset + + fred_final_cltv + excess_final_cltv_delta + + eve.1.cltv_expiry_delta as u32, + outgoing_node_id: eve.0, + }, + // Eve should forward the final amount to fred, allowing enough CLTV to cover his + // final expiry delta and the excess that the sender added. + msgs::OutboundTrampolinePayload::Forward { + amt_to_forward: fred_amt_msat, + outgoing_cltv_value: starting_htlc_offset + + fred_final_cltv + excess_final_cltv_delta, + outgoing_node_id: fred, + }, + // Fred just needs to receive the amount he's expecting, and since this is an + // unblinded route he'll expect an outgoing cltv that accounts for his final + // expiry delta and excess that the sender added. + msgs::OutboundTrampolinePayload::Receive { + payment_data: Some(msgs::FinalOnionHopData { + payment_secret, + total_msat: fred_amt_msat, + }), + sender_intended_htlc_amt_msat: fred_amt_msat, + cltv_expiry_height: starting_htlc_offset + + fred_final_cltv + excess_final_cltv_delta, + }, + ]; } + match trampoline_payloads.last_mut().unwrap() { + msgs::OutboundTrampolinePayload::Receive { + sender_intended_htlc_amt_msat, + cltv_expiry_height, + .. + } => { + *sender_intended_htlc_amt_msat = + test_case.inner_onion_amt(*sender_intended_htlc_amt_msat); + *cltv_expiry_height = test_case.inner_onion_cltv(*cltv_expiry_height); + }, + msgs::OutboundTrampolinePayload::BlindedReceive { + sender_intended_htlc_amt_msat, + cltv_expiry_height, + .. + } => { + *sender_intended_htlc_amt_msat = + test_case.inner_onion_amt(*sender_intended_htlc_amt_msat); + *cltv_expiry_height = test_case.inner_onion_cltv(*cltv_expiry_height); + }, + _ => panic!("unexpected final trampoline payload type"), + } + + // TODO: clean this up + let key_derivation_tail = if !blinded { + BlindedTail { + // Note: this tail isn't *actually* used in our trampoline key derivation, we just + // have to have one to be able to use the helper function. + trampoline_hops: vec![ + TrampolineHop { + pubkey: carol, + node_features: Features::empty(), + fee_msat: 0, + cltv_expiry_delta: 0, + }, + TrampolineHop { + pubkey: eve.0, + node_features: Features::empty(), + fee_msat: 0, + cltv_expiry_delta: 0, + }, + TrampolineHop { + pubkey: fred, + node_features: Features::empty(), + fee_msat: 0, + cltv_expiry_delta: 0, + }, + ], + hops: vec![], + blinding_point: blinded_tail.blinding_point, + excess_final_cltv_expiry_delta: excess_final_cltv_delta, + final_value_msat: fred_amt_msat, + } + } else { + blinded_tail.clone() + }; + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys( &secp_ctx, - &blinded_tail, + &key_derivation_tail, &trampoline_session_priv, ); let trampoline_packet = onion_utils::construct_trampoline_onion_packet( @@ -2502,22 +2589,6 @@ fn replacement_onion( .unwrap(); assert_eq!(outer_payloads.len(), 2); - // If we're trying to test invalid payloads, we modify Carol's *outer* onion to have values - // that are inconsistent with her inner onion. We need to do this manually because we - // (obviously) can't construct an invalid onion with LDK's built in functions. - match &mut outer_payloads[1] { - msgs::OutboundOnionPayload::TrampolineEntrypoint { - amt_to_forward, - outgoing_cltv_value, - .. - } => { - *amt_to_forward = test_case.outer_onion_amt(original_amt_msat); - let outer_cltv = original_trampoline_cltv + starting_htlc_offset + excess_final_cltv; - *outgoing_cltv_value = test_case.outer_onion_cltv(outer_cltv); - }, - _ => panic!("final payload is not trampoline entrypoint"), - } - let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); onion_utils::construct_onion_packet( @@ -2535,7 +2606,7 @@ fn replacement_onion( // - To hit validation errors by manipulating the trampoline's outer packet. Without this, we would // have to manually construct the onion. fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { - const TOTAL_NODE_COUNT: usize = 3; + const TOTAL_NODE_COUNT: usize = 6; let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); @@ -2546,34 +2617,114 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { let alice_bob_chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); let bob_carol_chan = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let carol_dave_chan = + create_announced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0); + let dave_eve_chan = create_announced_chan_between_nodes_with_value(&nodes, 3, 4, 1_000_000, 0); + let eve_fred_chan = create_announced_chan_between_nodes_with_value(&nodes, 4, 5, 1_000_000, 0); let starting_htlc_offset = (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1; for i in 0..TOTAL_NODE_COUNT { connect_blocks(&nodes[i], starting_htlc_offset - 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 dave_node_id = nodes[3].node().get_our_node_id(); + let eve_node_id = nodes[4].node().get_our_node_id(); + let fred_node_id = nodes[5].node().get_our_node_id(); let alice_bob_scid = get_scid_from_channel_id(&nodes[0], alice_bob_chan.2); let bob_carol_scid = get_scid_from_channel_id(&nodes[1], bob_carol_chan.2); - let original_amt_msat = 1000; - // Note that for TrampolineTestCase::OuterCLTVLessThanTrampoline to work properly, - // (starting_htlc_offset + excess_final_cltv) / 2 < (starting_htlc_offset + excess_final_cltv + original_trampoline_cltv) - // otherwise dividing the CLTV value by 2 won't kick us under the outer trampoline CLTV. - let original_trampoline_cltv = 42; + let fred_recv_amt = 1000; + let fred_cltv_final = 72; let excess_final_cltv = 70; + let carol_dave_policy = carol_dave_chan.1.contents; + let dave_eve_policy = dave_eve_chan.1.contents; + let eve_fred_policy = eve_fred_chan.1.contents; + + let carol_trampoline_cltv_delta = + carol_dave_policy.cltv_expiry_delta + dave_eve_policy.cltv_expiry_delta; + let carol_trampoline_fee_prop = + carol_dave_policy.fee_proportional_millionths + dave_eve_policy.fee_proportional_millionths; + let carol_trampoline_fee_base = carol_dave_policy.fee_base_msat + dave_eve_policy.fee_base_msat; + + let eve_trampoline_relay = PaymentRelay { + // Note that we add 1 to eve's required CLTV so that she has a non-zero CLTV budget, because + // our pathfinding doesn't support a zero cltv detla. In reality, we'd include a larger + // margin than a single node's delta for trampoline payments, so we don't worry about it. + cltv_expiry_delta: eve_fred_policy.cltv_expiry_delta + 1, + fee_proportional_millionths: eve_fred_policy.fee_proportional_millionths, + fee_base_msat: eve_fred_policy.fee_base_msat, + }; let (payment_preimage, payment_hash, payment_secret) = - get_payment_preimage_hash(&nodes[2], Some(original_amt_msat), None); + get_payment_preimage_hash(&nodes[5], Some(fred_recv_amt), None); // We need the session priv to replace the onion packet later. let override_random_bytes = [42; 32]; *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); - let route = Route { + // Create a blinded tail where Carol and Eve are trampoline hops, sending to Fred. In our + // unblinded test cases, we'll override this anyway (with a tail sending to an unblinded + // receive, which LDK doesn't allow). + let carol_relay = PaymentRelay { + // The policy for a blinded trampoline hop needs to cover all the fees for the path to + // the next trampoline. Here we're using the exact values, but IRL the receiving node + // would probably set more general values. + cltv_expiry_delta: carol_trampoline_cltv_delta, + fee_proportional_millionths: carol_trampoline_fee_prop, + fee_base_msat: carol_trampoline_fee_base, + }; + let no_payment_constraints = + PaymentConstraints { max_cltv_expiry: u32::max_value(), htlc_minimum_msat: fred_recv_amt }; + let intermediate_nodes = [ + ForwardNode { + tlvs: TrampolineForwardTlvs { + next_trampoline: eve_node_id, + payment_relay: carol_relay.clone(), + payment_constraints: no_payment_constraints.clone(), + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: carol_node_id, + htlc_maximum_msat: u64::max_value(), + }, + ForwardNode { + tlvs: TrampolineForwardTlvs { + next_trampoline: fred_node_id, + payment_relay: eve_trampoline_relay.clone(), + payment_constraints: no_payment_constraints.clone(), + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: eve_node_id, + htlc_maximum_msat: u64::max_value(), + }, + ]; + + let (blinded_tail, _) = create_trampoline_forward_blinded_tail( + &secp_ctx, + &nodes[5].keys_manager, + &intermediate_nodes, + fred_node_id, + nodes[5].keys_manager.get_receive_auth_key(), + ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: fred_recv_amt, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }, + fred_cltv_final, + excess_final_cltv, + fred_recv_amt, + ); + assert_eq!(blinded_tail.trampoline_hops.len(), 1); + assert_eq!(blinded_tail.hops.len(), 3); + + let mut route = Route { paths: vec![Path { hops: vec![ RouteHop { @@ -2590,45 +2741,59 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { node_features: NodeFeatures::empty(), short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), - fee_msat: 0, - cltv_expiry_delta: original_trampoline_cltv + excess_final_cltv, + fee_msat: blinded_tail.trampoline_hops[0].fee_msat, + cltv_expiry_delta: blinded_tail.trampoline_hops[0].cltv_expiry_delta, maybe_announced_channel: false, }, ], - // 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, - }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), - }, - original_trampoline_cltv, - excess_final_cltv, - original_amt_msat, - ) - .0, - ), + blinded_tail: Some(blinded_tail), }], route_params: None, }; + // For unblinded tests, replace the blinded tail with an unblinded trampoline structure so + // that Alice's stored path has the correct trampoline shared secrets for error decoding. + // The replacement onion (constructed below) uses the same unblinded key derivation. + if !blinded { + let bt = route.paths[0].blinded_tail.as_ref().unwrap(); + let blinding_point = bt.blinding_point; + let total_cltv = route.paths[0].hops.last().unwrap().cltv_expiry_delta; + let eve_cltv = eve_trampoline_relay.cltv_expiry_delta as u32; + let carol_cltv = total_cltv - eve_cltv; + route.paths[0].blinded_tail = Some(BlindedTail { + trampoline_hops: vec![ + TrampolineHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + fee_msat: 0, + cltv_expiry_delta: carol_cltv, + }, + TrampolineHop { + pubkey: eve_node_id, + node_features: NodeFeatures::empty(), + fee_msat: 0, + cltv_expiry_delta: eve_cltv, + }, + TrampolineHop { + pubkey: fred_node_id, + node_features: NodeFeatures::empty(), + fee_msat: 0, + cltv_expiry_delta: 0, + }, + ], + hops: vec![], + blinding_point, + excess_final_cltv_expiry_delta: 0, + final_value_msat: fred_recv_amt, + }); + } + nodes[0] .node .send_payment_with_route( route.clone(), payment_hash, - RecipientOnionFields::spontaneous_empty(original_amt_msat), + RecipientOnionFields::spontaneous_empty(fred_recv_amt), PaymentId(payment_hash.0), ) .unwrap(); @@ -2656,32 +2821,29 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { &secp_ctx, override_random_bytes, route, - original_amt_msat, - // Our internal send payment helpers add one block to the current height to - // create our payments. Do the same here so that our replacement onion will have - // the right cltv. - starting_htlc_offset + 1, - original_trampoline_cltv, + fred_recv_amt, + fred_cltv_final, excess_final_cltv, payment_hash, payment_secret, blinded, + // Our internal send payment helpers add one block to the current height to + // create our payments. Do the same here so that our replacement onion will have + // the right cltv. + starting_htlc_offset + 1, + carol_node_id, + (eve_node_id, &eve_trampoline_relay), + fred_node_id, ) }); } - let route: &[&Node] = &[&nodes[1], &nodes[2]]; - let args = PassAlongPathArgs::new( - &nodes[0], - route, - original_amt_msat, - payment_hash, - first_message_event, - ); - - let final_cltv_height = original_trampoline_cltv + starting_htlc_offset + excess_final_cltv + 1; - let amt_bytes = test_case.outer_onion_amt(original_amt_msat).to_be_bytes(); - let cltv_bytes = test_case.outer_onion_cltv(final_cltv_height).to_be_bytes(); + // We add two blocks to the minimum height that fred will accept because we added one block + // extra CLTV for Eve's forwarding CLTV "budget" and our dispatch adds one block to the + // current height. + let final_cltv_height = fred_cltv_final + starting_htlc_offset + excess_final_cltv + 2; + let amt_bytes = fred_recv_amt.to_be_bytes(); + let cltv_bytes = final_cltv_height.to_be_bytes(); let payment_failure = test_case.payment_failed_conditions(&amt_bytes, &cltv_bytes).map(|p| { if blinded { PaymentFailedConditions::new() @@ -2690,6 +2852,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { p } }); + let route: &[&Node] = &[&nodes[1], &nodes[2], &nodes[3], &nodes[4], &nodes[5]]; + let args = + PassAlongPathArgs::new(&nodes[0], route, fred_recv_amt, payment_hash, first_message_event); let args = if payment_failure.is_some() { args.with_payment_preimage(payment_preimage) .without_claimable_event() @@ -2701,22 +2866,101 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { do_pass_along_path(args); if let Some(failure) = payment_failure { - let node_updates = get_htlc_update_msgs(&nodes[2], &bob_node_id); - nodes[1].node.handle_update_fail_htlc(carol_node_id, &node_updates.update_fail_htlcs[0]); + let alice_node_id = nodes[0].node.get_our_node_id(); + + // Fred is a blinded introduction node recipient, so will fail back with fail htlc. + let updates_fred = get_htlc_update_msgs(&nodes[5], &eve_node_id); + assert_eq!(updates_fred.update_fail_htlcs.len(), 1); + nodes[4].node.handle_update_fail_htlc(fred_node_id, &updates_fred.update_fail_htlcs[0]); + do_commitment_signed_dance( + &nodes[4], + &nodes[5], + &updates_fred.commitment_signed, + false, + false, + ); + + // Eve is a relaying blinded trampoline, so will fail back with malformed htlc. + expect_and_process_pending_htlcs_and_htlc_handling_failed( + &nodes[4], + &[HTLCHandlingFailureType::TrampolineForward {}], + ); + check_added_monitors(&nodes[4], 1); + + let updates_eve = get_htlc_update_msgs(&nodes[4], &dave_node_id); + if blinded { + assert_eq!(updates_eve.update_fail_malformed_htlcs.len(), 1); + nodes[3].node.handle_update_fail_malformed_htlc( + eve_node_id, + &updates_eve.update_fail_malformed_htlcs[0], + ); + } else { + assert_eq!(updates_eve.update_fail_htlcs.len(), 1); + nodes[3].node.handle_update_fail_htlc(eve_node_id, &updates_eve.update_fail_htlcs[0]); + } + + do_commitment_signed_dance( + &nodes[3], + &nodes[4], + &updates_eve.commitment_signed, + true, + false, + ); + + // Dave is a regular forwarding node, so will fail back with fail htlc. + let updates_dave = get_htlc_update_msgs(&nodes[3], &carol_node_id); + assert_eq!(updates_dave.update_fail_htlcs.len(), 1); + nodes[2].node.handle_update_fail_htlc(dave_node_id, &updates_dave.update_fail_htlcs[0]); + do_commitment_signed_dance( + &nodes[2], + &nodes[3], + &updates_dave.commitment_signed, + false, + false, + ); + + // Carol is a blinded trampoline introduction node, so will fail back with htlc fail. + expect_and_process_pending_htlcs_and_htlc_handling_failed( + &nodes[2], + &[HTLCHandlingFailureType::TrampolineForward {}], + ); + + check_added_monitors(&nodes[2], 1); + + let updates_carol = get_htlc_update_msgs(&nodes[2], &bob_node_id); + assert_eq!(updates_carol.update_fail_htlcs.len(), 1); + nodes[1].node.handle_update_fail_htlc(carol_node_id, &updates_carol.update_fail_htlcs[0]); + let bob_carol_chan = nodes[1] + .node + .list_channels() + .iter() + .find(|c| c.counterparty.node_id == carol_node_id) + .unwrap() + .channel_id; do_commitment_signed_dance( &nodes[1], &nodes[2], - &node_updates.commitment_signed, - true, + &updates_carol.commitment_signed, + false, false, ); - let node_updates = get_htlc_update_msgs(&nodes[1], &alice_node_id); - nodes[0].node.handle_update_fail_htlc(bob_node_id, &node_updates.update_fail_htlcs[0]); + // Bob is a regular forwarding node, so will fail back with htlc fail. + expect_and_process_pending_htlcs_and_htlc_handling_failed( + &nodes[1], + &[HTLCHandlingFailureType::Forward { + node_id: Some(carol_node_id), + channel_id: bob_carol_chan, + }], + ); + check_added_monitors(&nodes[1], 1); + let updates_bob = get_htlc_update_msgs(&nodes[1], &alice_node_id); + assert_eq!(updates_bob.update_fail_htlcs.len(), 1); + nodes[0].node.handle_update_fail_htlc(bob_node_id, &updates_bob.update_fail_htlcs[0]); do_commitment_signed_dance( &nodes[0], &nodes[1], - &node_updates.commitment_signed, + &updates_bob.commitment_signed, false, false, ); @@ -2726,10 +2970,10 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { // Because we support blinded paths, we also assert on our expected logs to make sure // that the failure reason hidden by obfuscated blinded errors is as expected. if let Some((module, line, count)) = test_case.expected_log() { - nodes[2].logger.assert_log_contains(module, line, count); + nodes[5].logger.assert_log_contains(module, line, count); } } else { - claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); + claim_payment(&nodes[0], route, payment_preimage); } } diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 7a692f80d0e..783df40649c 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -2473,7 +2473,7 @@ pub(crate) fn compute_fees(amount_msat: u64, channel_fees: RoutingFees) -> Optio /// Calculate the fees required to route the given amount over a channel with the given fees, /// saturating to [`u64::max_value`]. #[rustfmt::skip] -fn compute_fees_saturating(amount_msat: u64, channel_fees: RoutingFees) -> u64 { +pub(crate) fn compute_fees_saturating(amount_msat: u64, channel_fees: RoutingFees) -> u64 { amount_msat.checked_mul(channel_fees.proportional_millionths as u64) .map(|prop| prop / 1_000_000).unwrap_or(u64::max_value()) .saturating_add(channel_fees.base_msat as u64) From 0c35faa0ab2c98cede6b4fcf2e587e639dd66e74 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:13:08 +0200 Subject: [PATCH 41/47] [wip]: track already_forwarded_htlcs by full HTLCSource When we add handling for trampoline payments, we're going to need the full HTLCSource (with multiple prev_htlcs) to replay settles/claims. Here we update our existing logic to support tracking by source. --- lightning/src/ln/channel.rs | 14 +-- lightning/src/ln/channelmanager.rs | 191 ++++++++++++++--------------- 2 files changed, 97 insertions(+), 108 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e6397aefbcb..966dc469466 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7977,7 +7977,7 @@ where /// when reconstructing the set of pending HTLCs when deserializing the `ChannelManager`. pub(super) fn inbound_forwarded_htlcs( &self, - ) -> impl Iterator + '_ { + ) -> impl Iterator + '_ { // We don't want to return an HTLC as needing processing if it already has a resolution that's // pending in the holding cell. let htlc_resolution_in_holding_cell = |id: u64| -> bool { @@ -8026,7 +8026,7 @@ where counterparty_node_id: Some(counterparty_node_id), cltv_expiry: Some(htlc.cltv_expiry), }; - Some((htlc.payment_hash, prev_hop_data, *outbound_hop)) + Some((htlc.payment_hash, HTLCSource::PreviousHopData(prev_hop_data), *outbound_hop)) }, _ => None, }) @@ -8037,12 +8037,12 @@ where /// present in the outbound edge, or else we'll double-forward. pub(super) fn outbound_htlc_forwards( &self, - ) -> impl Iterator + '_ { + ) -> impl Iterator + '_ { let holding_cell_outbounds = self.context.holding_cell_htlc_updates.iter().filter_map(|htlc| match htlc { HTLCUpdateAwaitingACK::AddHTLC { source, payment_hash, .. } => match source { - HTLCSource::PreviousHopData(prev_hop_data) => { - Some((*payment_hash, prev_hop_data.clone())) + HTLCSource::PreviousHopData(_) => { + Some((*payment_hash, source.clone())) }, _ => None, }, @@ -8050,8 +8050,8 @@ where }); let committed_outbounds = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| match &htlc.source { - HTLCSource::PreviousHopData(prev_hop_data) => { - Some((htlc.payment_hash, prev_hop_data.clone())) + HTLCSource::PreviousHopData(_) => { + Some((htlc.payment_hash, htlc.source.clone())) }, _ => None, }); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 657208aee24..d4164a3214e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19378,35 +19378,38 @@ impl< // If the HTLC corresponding to `prev_hop_data` is present in `decode_update_add_htlcs`, remove it // from the map as it is already being stored and processed elsewhere. -fn dedup_decode_update_add_htlcs( +fn dedup_decode_update_add_htlcs<'a, L: Logger>( decode_update_add_htlcs: &mut HashMap>, - prev_hop_data: &HTLCPreviousHopData, removal_reason: &'static str, logger: &L, + previous_hops: impl Iterator, removal_reason: &'static str, + logger: &L, ) { - match decode_update_add_htlcs.entry(prev_hop_data.prev_outbound_scid_alias) { - hash_map::Entry::Occupied(mut update_add_htlcs) => { - update_add_htlcs.get_mut().retain(|update_add| { - let matches = update_add.htlc_id == prev_hop_data.htlc_id; - if matches { - let logger = WithContext::from( - logger, - prev_hop_data.counterparty_node_id, - Some(update_add.channel_id), - Some(update_add.payment_hash), - ); - log_info!( - logger, - "Removing pending to-decode HTLC with id {}: {}", - update_add.htlc_id, - removal_reason - ); + for prev_hop_data in previous_hops { + match decode_update_add_htlcs.entry(prev_hop_data.prev_outbound_scid_alias) { + hash_map::Entry::Occupied(mut update_add_htlcs) => { + update_add_htlcs.get_mut().retain(|update_add| { + let matches = update_add.htlc_id == prev_hop_data.htlc_id; + if matches { + let logger = WithContext::from( + logger, + prev_hop_data.counterparty_node_id, + Some(update_add.channel_id), + Some(update_add.payment_hash), + ); + log_info!( + logger, + "Removing pending to-decode HTLC with id {}: {}", + update_add.htlc_id, + removal_reason + ); + } + !matches + }); + if update_add_htlcs.get().is_empty() { + update_add_htlcs.remove(); } - !matches - }); - if update_add_htlcs.get().is_empty() { - update_add_htlcs.remove(); - } - }, - _ => {}, + }, + _ => {}, + } } } @@ -20102,10 +20105,11 @@ impl< // store an identifier for it here and verify that it is either (a) present in the outbound // edge or (b) removed from the outbound edge via claim. If it's in neither of these states, we // infer that it was removed from the outbound edge via fail, and fail it backwards to ensure - // that it is handled. + // that it is handled. For trampoline forwards where it is possible that we have multiple + // inbound HTLCs, each incoming HTLC's entry will store the full HTLCSource. let mut already_forwarded_htlcs: HashMap< (ChannelId, PaymentHash), - Vec<(HTLCPreviousHopData, OutboundHop)>, + Vec<(HTLCSource, OutboundHop)>, > = new_hash_map(); { // If we're tracking pending payments, ensure we haven't lost any by looking at the @@ -20143,13 +20147,15 @@ impl< .or_insert_with(Vec::new) .push(update_add_htlc); } - for (payment_hash, prev_hop, next_hop) in + for (payment_hash, htlc_source, next_hop) in funded_chan.inbound_forwarded_htlcs() { - already_forwarded_htlcs - .entry((prev_hop.channel_id, payment_hash)) - .or_insert_with(Vec::new) - .push((prev_hop, next_hop)); + for prev_hop in htlc_source.previous_hop_data() { + already_forwarded_htlcs + .entry((prev_hop.channel_id, payment_hash)) + .or_insert_with(Vec::new) + .push((htlc_source.clone(), next_hop)); + } } } } @@ -20198,17 +20204,18 @@ impl< if reconstruct_manager_from_monitors { if let Some(funded_chan) = chan.as_funded() { - for (payment_hash, prev_hop) in funded_chan.outbound_htlc_forwards() + for (payment_hash, htlc_source) in + funded_chan.outbound_htlc_forwards() { dedup_decode_update_add_htlcs( &mut decode_update_add_htlcs, - &prev_hop, + htlc_source.previous_hop_data().iter(), "HTLC already forwarded to the outbound edge", &args.logger, ); prune_forwarded_htlc( &mut already_forwarded_htlcs, - &prev_hop, + &htlc_source, &payment_hash, ); } @@ -20228,7 +20235,8 @@ impl< ); let htlc_id = SentHTLCId::from_source(&htlc_source); match htlc_source { - HTLCSource::PreviousHopData(prev_hop_data) => { + HTLCSource::PreviousHopData(_) + | HTLCSource::TrampolineForward { .. } => { reconcile_pending_htlcs_with_monitor( reconstruct_manager_from_monitors, &mut already_forwarded_htlcs, @@ -20237,29 +20245,12 @@ impl< &mut pending_intercepted_htlcs_legacy, &mut decode_update_add_htlcs, &mut decode_update_add_htlcs_legacy, - prev_hop_data, + &htlc_source, &logger, htlc.payment_hash, monitor.channel_id(), ); }, - HTLCSource::TrampolineForward { previous_hop_data, .. } => { - for prev_hop_data in previous_hop_data { - reconcile_pending_htlcs_with_monitor( - reconstruct_manager_from_monitors, - &mut already_forwarded_htlcs, - &mut forward_htlcs_legacy, - &mut pending_events_read, - &mut pending_intercepted_htlcs_legacy, - &mut decode_update_add_htlcs, - &mut decode_update_add_htlcs_legacy, - prev_hop_data, - &logger, - htlc.payment_hash, - monitor.channel_id(), - ); - } - }, HTLCSource::OutboundRoute { payment_id, session_priv, @@ -20625,27 +20616,25 @@ impl< // De-duplicate HTLCs that are present in both `failed_htlcs` and `decode_update_add_htlcs`. // Omitting this de-duplication could lead to redundant HTLC processing and/or bugs. for (src, payment_hash, _, _, _, _) in failed_htlcs.iter() { - if let HTLCSource::PreviousHopData(prev_hop_data) = src { + if let HTLCSource::PreviousHopData(_) = src { dedup_decode_update_add_htlcs( &mut decode_update_add_htlcs, - prev_hop_data, + src.previous_hop_data().iter(), "HTLC was failed backwards during manager read", &args.logger, ); - prune_forwarded_htlc(&mut already_forwarded_htlcs, prev_hop_data, payment_hash); + prune_forwarded_htlc(&mut already_forwarded_htlcs, &src, payment_hash); } } // See above comment on `failed_htlcs`. - for htlcs in claimable_payments.values().map(|pmt| &pmt.htlcs) { - for htlc in htlcs.iter() { - dedup_decode_update_add_htlcs( - &mut decode_update_add_htlcs, - &htlc.mpp_part.prev_hop, - "HTLC was already decoded and marked as a claimable payment", - &args.logger, - ); - } + for claimable_htlcs in claimable_payments.values().map(|pmt| &pmt.htlcs) { + dedup_decode_update_add_htlcs( + &mut decode_update_add_htlcs, + claimable_htlcs.iter().map(|h| &h.mpp_part.prev_hop), + "HTLC was already decoded and marked as a claimable payment", + &args.logger, + ); } } @@ -20784,11 +20773,10 @@ impl< if let Some(forwarded_htlcs) = already_forwarded_htlcs.remove(&(*channel_id, payment_hash)) { - for (prev_hop, next_hop) in forwarded_htlcs { - let new_pending_claim = - !pending_claims_to_replay.iter().any(|(src, _, _, _, _, _, _, _)| { - matches!(src, HTLCSource::PreviousHopData(hop) if hop.htlc_id == prev_hop.htlc_id && hop.channel_id == prev_hop.channel_id) - }); + for (source, next_hop) in forwarded_htlcs { + let new_pending_claim = !pending_claims_to_replay + .iter() + .any(|(src, _, _, _, _, _, _, _)| *src == source); if new_pending_claim { let is_downstream_closed = channel_manager .per_peer_state @@ -20803,7 +20791,7 @@ impl< .contains_key(&next_hop.channel_id) }); pending_claims_to_replay.push(( - HTLCSource::PreviousHopData(prev_hop), + source, payment_preimage, next_hop.amt_msat, is_downstream_closed, @@ -21062,18 +21050,20 @@ impl< ); } for ((_, hash), htlcs) in already_forwarded_htlcs.into_iter() { - for (htlc, _) in htlcs { - let channel_id = htlc.channel_id; - let node_id = htlc.counterparty_node_id; - let source = HTLCSource::PreviousHopData(htlc); + for (source, next_hop) in htlcs { let failure_reason = LocalHTLCFailureReason::TemporaryChannelFailure; let failure_data = channel_manager.get_htlc_inbound_temp_fail_data(failure_reason); let reason = HTLCFailReason::reason(failure_reason, failure_data); - let receiver = HTLCHandlingFailureType::Forward { node_id, channel_id }; + let failure_type = source.failure_type(next_hop.node_id, next_hop.channel_id); // The event completion action is only relevant for HTLCs that originate from our node, not // forwarded HTLCs. - channel_manager - .fail_htlc_backwards_internal(&source, &hash, &reason, receiver, None); + channel_manager.fail_htlc_backwards_internal( + &source, + &hash, + &reason, + failure_type, + None, + ); } } @@ -21115,18 +21105,18 @@ impl< } fn prune_forwarded_htlc( - already_forwarded_htlcs: &mut HashMap< - (ChannelId, PaymentHash), - Vec<(HTLCPreviousHopData, OutboundHop)>, - >, - prev_hop: &HTLCPreviousHopData, payment_hash: &PaymentHash, + already_forwarded_htlcs: &mut HashMap<(ChannelId, PaymentHash), Vec<(HTLCSource, OutboundHop)>>, + htlc_source: &HTLCSource, payment_hash: &PaymentHash, ) { - if let hash_map::Entry::Occupied(mut entry) = - already_forwarded_htlcs.entry((prev_hop.channel_id, *payment_hash)) - { - entry.get_mut().retain(|(htlc, _)| prev_hop.htlc_id != htlc.htlc_id); - if entry.get().is_empty() { - entry.remove(); + for prev_hop in htlc_source.previous_hop_data() { + if let hash_map::Entry::Occupied(mut entry) = + already_forwarded_htlcs.entry((prev_hop.channel_id, *payment_hash)) + { + // TODO: check how we populate each of these sources to make sure they'll be equal. + entry.get_mut().retain(|(source, _)| source != htlc_source); + if entry.get().is_empty() { + entry.remove(); + } } } } @@ -21135,21 +21125,20 @@ fn prune_forwarded_htlc( /// cleaning up state mismatches that can occur during restart. fn reconcile_pending_htlcs_with_monitor( reconstruct_manager_from_monitors: bool, - already_forwarded_htlcs: &mut HashMap< - (ChannelId, PaymentHash), - Vec<(HTLCPreviousHopData, OutboundHop)>, - >, + already_forwarded_htlcs: &mut HashMap<(ChannelId, PaymentHash), Vec<(HTLCSource, OutboundHop)>>, forward_htlcs_legacy: &mut HashMap>, pending_events_read: &mut VecDeque<(Event, Option)>, pending_intercepted_htlcs_legacy: &mut HashMap, decode_update_add_htlcs: &mut HashMap>, decode_update_add_htlcs_legacy: &mut HashMap>, - prev_hop_data: HTLCPreviousHopData, logger: &impl Logger, payment_hash: PaymentHash, + htlc_source: &HTLCSource, logger: &impl Logger, payment_hash: PaymentHash, channel_id: ChannelId, ) { let pending_forward_matches_htlc = |info: &PendingAddHTLCInfo| { - info.prev_funding_outpoint == prev_hop_data.outpoint - && info.prev_htlc_id == prev_hop_data.htlc_id + htlc_source.previous_hop_data().iter().any(|prev_hop_data| { + info.prev_funding_outpoint == prev_hop_data.outpoint + && info.prev_htlc_id == prev_hop_data.htlc_id + }) }; // If `reconstruct_manager_from_monitors` is set, we always add all inbound committed @@ -21159,11 +21148,11 @@ fn reconcile_pending_htlcs_with_monitor( if reconstruct_manager_from_monitors { dedup_decode_update_add_htlcs( decode_update_add_htlcs, - &prev_hop_data, + htlc_source.previous_hop_data().iter(), "HTLC already forwarded to the outbound edge", &&logger, ); - prune_forwarded_htlc(already_forwarded_htlcs, &prev_hop_data, &payment_hash); + prune_forwarded_htlc(already_forwarded_htlcs, htlc_source, &payment_hash); } // The ChannelMonitor is now responsible for this HTLC's failure/success and will let us know @@ -21172,7 +21161,7 @@ fn reconcile_pending_htlcs_with_monitor( // not persisted after the monitor was when forwarding the payment. dedup_decode_update_add_htlcs( decode_update_add_htlcs_legacy, - &prev_hop_data, + htlc_source.previous_hop_data().iter(), "HTLC was forwarded to the closed channel", &&logger, ); From 499ab9748a24094d8b45d4d12df0c3f59b8d0228 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 15:55:14 +0200 Subject: [PATCH 42/47] [wip]: support muti-out sources in inbound_forwarded_htlcs For trampoline, we have multiple outgoing HTLCs for our single source. --- lightning/src/ln/channel.rs | 15 +++++++-------- lightning/src/ln/channelmanager.rs | 14 ++++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 966dc469466..6f33858cd83 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7977,7 +7977,7 @@ where /// when reconstructing the set of pending HTLCs when deserializing the `ChannelManager`. pub(super) fn inbound_forwarded_htlcs( &self, - ) -> impl Iterator + '_ { + ) -> impl Iterator)> + '_ { // We don't want to return an HTLC as needing processing if it already has a resolution that's // pending in the holding cell. let htlc_resolution_in_holding_cell = |id: u64| -> bool { @@ -8026,7 +8026,10 @@ where counterparty_node_id: Some(counterparty_node_id), cltv_expiry: Some(htlc.cltv_expiry), }; - Some((htlc.payment_hash, HTLCSource::PreviousHopData(prev_hop_data), *outbound_hop)) + Some(( + htlc.payment_hash, + vec![(HTLCSource::PreviousHopData(prev_hop_data), *outbound_hop)], + )) }, _ => None, }) @@ -8041,18 +8044,14 @@ where let holding_cell_outbounds = self.context.holding_cell_htlc_updates.iter().filter_map(|htlc| match htlc { HTLCUpdateAwaitingACK::AddHTLC { source, payment_hash, .. } => match source { - HTLCSource::PreviousHopData(_) => { - Some((*payment_hash, source.clone())) - }, + HTLCSource::PreviousHopData(_) => Some((*payment_hash, source.clone())), _ => None, }, _ => None, }); let committed_outbounds = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| match &htlc.source { - HTLCSource::PreviousHopData(_) => { - Some((htlc.payment_hash, htlc.source.clone())) - }, + HTLCSource::PreviousHopData(_) => Some((htlc.payment_hash, htlc.source.clone())), _ => None, }); holding_cell_outbounds.chain(committed_outbounds) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d4164a3214e..4d39df2251c 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20147,14 +20147,16 @@ impl< .or_insert_with(Vec::new) .push(update_add_htlc); } - for (payment_hash, htlc_source, next_hop) in + for (payment_hash, source_and_hop) in funded_chan.inbound_forwarded_htlcs() { - for prev_hop in htlc_source.previous_hop_data() { - already_forwarded_htlcs - .entry((prev_hop.channel_id, payment_hash)) - .or_insert_with(Vec::new) - .push((htlc_source.clone(), next_hop)); + for (htlc_source, next_hop) in source_and_hop { + for prev_hop in htlc_source.previous_hop_data() { + already_forwarded_htlcs + .entry((prev_hop.channel_id, payment_hash)) + .or_insert_with(Vec::new) + .push((htlc_source.clone(), next_hop)); + } } } } From 930b108e46e2d6abdcb8e912c0755a77f8cb74a6 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:18:01 +0200 Subject: [PATCH 43/47] [wip]: pass full HTLCSource through in committed_outbound_htlc_sources --- lightning/src/ln/channel.rs | 6 ++-- lightning/src/ln/channelmanager.rs | 52 +++++++++++++++++------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 6f33858cd83..2e8e720862b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1194,7 +1194,7 @@ pub(super) struct MonitorRestoreUpdates { /// The sources of outbound HTLCs that were forwarded and irrevocably committed on this channel /// (the outbound edge), along with their outbound amounts. Useful to store in the inbound HTLC /// to ensure it gets resolved. - pub committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, + pub committed_outbound_htlc_sources: Vec<(HTLCSource, u64)>, } /// The return value of `signer_maybe_unblocked` @@ -9776,8 +9776,8 @@ where mem::swap(&mut pending_update_adds, &mut self.context.monitor_pending_update_adds); let committed_outbound_htlc_sources = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| { if let &OutboundHTLCState::LocalAnnounced(_) = &htlc.state { - if let HTLCSource::PreviousHopData(prev_hop_data) = &htlc.source { - return Some((prev_hop_data.clone(), htlc.amount_msat)) + if let HTLCSource::PreviousHopData(_) = &htlc.source { + return Some((htlc.source.clone(), htlc.amount_msat)) } } None diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 4d39df2251c..df3573a42d9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1618,7 +1618,7 @@ enum PostMonitorUpdateChanResume { htlc_forwards: Vec, finalized_claimed_htlcs: Vec<(HTLCSource, Option)>, failed_htlcs: Vec<(HTLCSource, PaymentHash, HTLCFailReason)>, - committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, + committed_outbound_htlc_sources: Vec<(HTLCSource, u64)>, }, } @@ -10746,7 +10746,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ update_actions: Vec, htlc_forwards: Vec, finalized_claimed_htlcs: Vec<(HTLCSource, Option)>, failed_htlcs: Vec<(HTLCSource, PaymentHash, HTLCFailReason)>, - committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, + committed_outbound_htlc_sources: Vec<(HTLCSource, u64)>, ) { // If the channel belongs to a batch funding transaction, the progress of the batch // should be updated as we have received funding_signed and persisted the monitor. @@ -11319,34 +11319,40 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ fn prune_persisted_inbound_htlc_onions( &self, outbound_channel_id: ChannelId, outbound_node_id: PublicKey, outbound_funding_txo: OutPoint, outbound_user_channel_id: u128, - committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>, + committed_outbound_htlc_sources: Vec<(HTLCSource, u64)>, ) { let per_peer_state = self.per_peer_state.read().unwrap(); for (source, outbound_amt_msat) in committed_outbound_htlc_sources { - let counterparty_node_id = match source.counterparty_node_id.as_ref() { - Some(id) => id, - None => continue, - }; - let mut peer_state = - match per_peer_state.get(counterparty_node_id).map(|state| state.lock().unwrap()) { + for previous_hop in source.previous_hop_data() { + let counterparty_node_id = match previous_hop.counterparty_node_id.as_ref() { + Some(id) => id, + None => continue, + }; + let mut peer_state = match per_peer_state + .get(counterparty_node_id) + .map(|state| state.lock().unwrap()) + { Some(peer_state) => peer_state, None => continue, }; - if let Some(chan) = - peer_state.channel_by_id.get_mut(&source.channel_id).and_then(|c| c.as_funded_mut()) - { - chan.prune_inbound_htlc_onion( - source.htlc_id, - &source, - OutboundHop { - amt_msat: outbound_amt_msat, - channel_id: outbound_channel_id, - node_id: outbound_node_id, - funding_txo: outbound_funding_txo, - user_channel_id: outbound_user_channel_id, - }, - ); + if let Some(chan) = peer_state + .channel_by_id + .get_mut(&previous_hop.channel_id) + .and_then(|c| c.as_funded_mut()) + { + chan.prune_inbound_htlc_onion( + previous_hop.htlc_id, + &previous_hop, + OutboundHop { + amt_msat: outbound_amt_msat, + channel_id: outbound_channel_id, + node_id: outbound_node_id, + funding_txo: outbound_funding_txo, + user_channel_id: outbound_user_channel_id, + }, + ); + } } } } From e1bc2281b808c46006d44411a8323758451dbb8d Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 11:06:29 +0200 Subject: [PATCH 44/47] [wip] dedup trampoline forwards with failed_htlcs --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index df3573a42d9..6f4c4bb29ab 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20624,7 +20624,7 @@ impl< // De-duplicate HTLCs that are present in both `failed_htlcs` and `decode_update_add_htlcs`. // Omitting this de-duplication could lead to redundant HTLC processing and/or bugs. for (src, payment_hash, _, _, _, _) in failed_htlcs.iter() { - if let HTLCSource::PreviousHopData(_) = src { + if let HTLCSource::PreviousHopData(_) | HTLCSource::TrampolineForward { .. } = src { dedup_decode_update_add_htlcs( &mut decode_update_add_htlcs, src.previous_hop_data().iter(), From b92f29183a12994dd6911613d3f4e8e6c9b3e0d8 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:42:59 +0200 Subject: [PATCH 45/47] [wip] persist trampoline information in InboundUpdateAdd Taking the bluntest approach of storing all information for trampoline forwards as a first stab, can possibly reduce data later. --- lightning/src/ln/channel.rs | 87 +++++++++++++++++++++++++----- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2e8e720862b..69d1c0453e6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -52,8 +52,8 @@ use crate::ln::channel_state::{ use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, HTLCPreviousHopData, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, - PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrustedChannelFeatures, BREAKDOWN_TIMEOUT, - MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, + PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrampolineDispatch, TrustedChannelFeatures, + BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{ FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution, @@ -357,6 +357,15 @@ enum InboundUpdateAdd { blinded_failure: Option, outbound_hop: OutboundHop, }, + /// This inbound HTLC is a forward that was irrevocably committed to outbound edge(s) as part + /// of a trampoline forward, allowing its onion to be pruned and no longer persisted. + /// + /// Contains data that is useful if we need to fail or claim this HTLC backwards after a + /// restart and it's missing in the outbound edge. + TrampolineForwarded { + previous_hop_data: Vec, + outbound_hops: Vec<(OutboundHop, TrampolineDispatch)>, + }, /// This HTLC was received pre-LDK 0.3, before we started persisting the onion for inbound /// committed HTLCs. Legacy, @@ -374,6 +383,10 @@ impl_writeable_tlv_based_enum_upgradable!(InboundUpdateAdd, (6, trampoline_shared_secret, option), (8, blinded_failure, option), }, + (6, TrampolineForwarded) => { + (0, previous_hop_data, required_vec), + (2, outbound_hops, required_vec), + }, ); impl_writeable_for_vec!(&InboundUpdateAdd); @@ -8075,20 +8088,66 @@ where /// This inbound HTLC was irrevocably forwarded to the outbound edge, so we no longer need to /// persist its onion. pub(super) fn prune_inbound_htlc_onion( - &mut self, htlc_id: u64, prev_hop_data: &HTLCPreviousHopData, - outbound_hop_data: OutboundHop, + &mut self, htlc_id: u64, htlc_source: &HTLCSource, outbound_hop_data: OutboundHop, ) { for htlc in self.context.pending_inbound_htlcs.iter_mut() { + // TODO: all these returns are super mif if htlc.htlc_id == htlc_id { - if let InboundHTLCState::Committed { ref mut update_add_htlc } = htlc.state { - *update_add_htlc = InboundUpdateAdd::Forwarded { - incoming_packet_shared_secret: prev_hop_data.incoming_packet_shared_secret, - phantom_shared_secret: prev_hop_data.phantom_shared_secret, - trampoline_shared_secret: prev_hop_data.trampoline_shared_secret, - blinded_failure: prev_hop_data.blinded_failure, - outbound_hop: outbound_hop_data, - }; - return; + match &mut htlc.state { + InboundHTLCState::Committed { + update_add_htlc: InboundUpdateAdd::TrampolineForwarded { outbound_hops, .. }, + } => { + if let HTLCSource::TrampolineForward { + outbound_payment: Some(trampoline_dispatch), + .. + } = htlc_source + { + if !outbound_hops.iter().any(|(_, dispatch)| { + dispatch.session_priv == trampoline_dispatch.session_priv + }) { + outbound_hops.push((outbound_hop_data, trampoline_dispatch.clone())) + } + return; + } else { + debug_assert!(false, "prune inbound onion called for trampoline with no dispatch or on non-trampoline inbound"); + return; + } + }, + InboundHTLCState::Committed { update_add_htlc } => { + *update_add_htlc = match htlc_source { + HTLCSource::PreviousHopData(prev_hop_data) => { + InboundUpdateAdd::Forwarded { + incoming_packet_shared_secret: prev_hop_data + .incoming_packet_shared_secret, + phantom_shared_secret: prev_hop_data.phantom_shared_secret, + trampoline_shared_secret: prev_hop_data + .trampoline_shared_secret, + blinded_failure: prev_hop_data.blinded_failure, + outbound_hop: outbound_hop_data, + } + }, + HTLCSource::TrampolineForward { + previous_hop_data, + outbound_payment, + } => { + InboundUpdateAdd::TrampolineForwarded { + previous_hop_data: previous_hop_data.to_vec(), + outbound_hops: vec![(outbound_hop_data, outbound_payment + .clone() // TODO: no clone / expect + .expect("trampoline shouldn't be pruned with no payment data"))], + } + }, + _ => { + debug_assert!( + false, + "outbound route should not prune inbound htlc" + ); + return; + }, + }; + return; + }, + _ => {}, } } } @@ -9776,7 +9835,7 @@ where mem::swap(&mut pending_update_adds, &mut self.context.monitor_pending_update_adds); let committed_outbound_htlc_sources = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| { if let &OutboundHTLCState::LocalAnnounced(_) = &htlc.state { - if let HTLCSource::PreviousHopData(_) = &htlc.source { + if let HTLCSource::PreviousHopData(_) | HTLCSource::TrampolineForward { .. } = &htlc.source { return Some((htlc.source.clone(), htlc.amount_msat)) } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6f4c4bb29ab..6892293310e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11343,7 +11343,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ { chan.prune_inbound_htlc_onion( previous_hop.htlc_id, - &previous_hop, + &source, OutboundHop { amt_msat: outbound_amt_msat, channel_id: outbound_channel_id, From 9f53e0403c486afd167d44393e1eea828cac5354 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:56:41 +0200 Subject: [PATCH 46/47] [wip] return trampoline forwards in inbound_forwarded_htlcs --- lightning/src/ln/channel.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 69d1c0453e6..68eee6e117c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8044,6 +8044,28 @@ where vec![(HTLCSource::PreviousHopData(prev_hop_data), *outbound_hop)], )) }, + InboundHTLCState::Committed { + update_add_htlc: + InboundUpdateAdd::TrampolineForwarded { previous_hop_data, outbound_hops }, + } => { + if htlc_resolution_in_holding_cell(htlc.htlc_id) { + return None; + } + let trampoline_sources: Vec<(HTLCSource, OutboundHop)> = outbound_hops + .iter() + .map(|(hop, dispatch)| { + ( + HTLCSource::TrampolineForward { + previous_hop_data: previous_hop_data.clone(), + outbound_payment: Some(dispatch.clone()), + }, + *hop, + ) + }) + .collect(); + + Some((htlc.payment_hash, trampoline_sources)) + }, _ => None, }) } From 0149f8d6682fa2bb2dd7528b138640ac1f266f8e Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:59:25 +0200 Subject: [PATCH 47/47] [wip]: return trampoline forwards from outbound_htlc_forwards --- lightning/src/ln/channel.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 68eee6e117c..3c0f5ac76ac 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8079,14 +8079,18 @@ where let holding_cell_outbounds = self.context.holding_cell_htlc_updates.iter().filter_map(|htlc| match htlc { HTLCUpdateAwaitingACK::AddHTLC { source, payment_hash, .. } => match source { - HTLCSource::PreviousHopData(_) => Some((*payment_hash, source.clone())), + HTLCSource::PreviousHopData(_) | HTLCSource::TrampolineForward { .. } => { + Some((*payment_hash, source.clone())) + }, _ => None, }, _ => None, }); let committed_outbounds = self.context.pending_outbound_htlcs.iter().filter_map(|htlc| match &htlc.source { - HTLCSource::PreviousHopData(_) => Some((htlc.payment_hash, htlc.source.clone())), + HTLCSource::PreviousHopData(_) | HTLCSource::TrampolineForward { .. } => { + Some((htlc.payment_hash, htlc.source.clone())) + }, _ => None, }); holding_cell_outbounds.chain(committed_outbounds)