From e3e883636233fb6617788fc0e6d54fd21d7bf13c Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 14 Apr 2026 09:37:06 -0400 Subject: [PATCH 01/40] 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 570639d8995..f9939d5ff97 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8424,13 +8424,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 b2928cd1df9f8f08e690f2d7be228eaec0aef514 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:28:42 -0400 Subject: [PATCH 02/40] 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 f9939d5ff97..866d55421b9 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); @@ -9373,11 +9367,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!( @@ -9389,8 +9379,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 @@ -9402,6 +9390,7 @@ impl< incoming_packet_shared_secret, blinded_failure, channel_id, + trampoline_shared_secret, .. } = current_hop_data; log_trace!( @@ -9413,13 +9402,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, ), @@ -17911,16 +17904,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 5d0007c315bc753a2b278b6e7b129a23ab66767b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 27 Jan 2026 13:49:35 -0500 Subject: [PATCH 03/40] 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 866d55421b9..0747d4946e3 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. /// @@ -17648,6 +17650,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 f9cb46ef38c8adff0b09802e1a157ebf285fe36b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 13:51:10 +0200 Subject: [PATCH 04/40] 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 eb73b79f6eb..ebde7541c7e 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 c482fd596601f917f2d792bcefc2ec30e316f0be Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 13:54:32 +0200 Subject: [PATCH 05/40] 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 ebde7541c7e..8616ba514c3 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -178,14 +178,14 @@ 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 ) }, 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( + 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 @@ -205,8 +205,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 08247a7f7fcc48c9cad7c0f52e29c5f087de4c6f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 13:56:43 +0200 Subject: [PATCH 06/40] 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 | 12 ++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0747d4946e3..7d2bc603d23 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. /// @@ -17651,6 +17655,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 8616ba514c3..776cd53907d 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, @@ -185,7 +189,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 (_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 @@ -204,6 +208,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, @@ -236,7 +242,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: multipath_trampoline_data, next_trampoline_amt_msat: next_hop_amount, next_trampoline_cltv: next_hop_cltv} => { let next_trampoline_packet_pubkey = match next_packet_pubkey_opt { Some(Ok(pubkey)) => pubkey, _ => return Err(InboundHTLCErr { @@ -265,6 +271,8 @@ pub(super) fn create_fwd_pending_htlc_info( .unwrap_or(BlindedFailure::FromBlindedNode), }), incoming_multipath_data: multipath_trampoline_data, + next_trampoline_amt_msat: next_hop_amount, + next_trampoline_cltv_expiry: next_hop_cltv, } } }; From 9684e5705fe21ee0d52e4e8c69937861a708f0de Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Feb 2026 12:34:15 +0200 Subject: [PATCH 07/40] 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 776cd53907d..c7b1283aecc 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -695,33 +695,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 ade0ef426dc2fb991380594f773934204b8d8ae2 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 30 Mar 2026 14:16:44 -0400 Subject: [PATCH 08/40] 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 7d2bc603d23..8b16fb872a9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1311,6 +1311,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 @@ -2886,6 +2892,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 @@ -3712,6 +3728,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()), @@ -9069,6 +9086,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); @@ -16356,6 +16393,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 { @@ -20233,6 +20295,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 b370a269470a076ca8209387d734ee4510acd03b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 10 Apr 2026 14:07:33 -0400 Subject: [PATCH 09/40] 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 8b16fb872a9..6c8930e985f 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; @@ -8463,6 +8463,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)>, @@ -8497,6 +8631,7 @@ impl< has_recipient_created_payment_secret, invoice_request_opt, trampoline_shared_secret, + trampoline_info, ) = match routing { PendingHTLCRouting::Receive { payment_data, @@ -8525,6 +8660,7 @@ impl< true, None, trampoline_shared_secret, + None, ) }, PendingHTLCRouting::ReceiveKeysend { @@ -8559,6 +8695,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)), ) }, _ => { @@ -8569,6 +8761,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 6f538dc6a4e63685083deedec75f67131e813ed9 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:48:23 -0400 Subject: [PATCH 10/40] 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 99d0eedbad8b070682da73d34f2bae7c78b255df Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:50:45 -0400 Subject: [PATCH 11/40] 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 5c5df21bd0d6d4c595e11ec156799022e6b2f4fd Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 25 Feb 2026 11:42:44 +0200 Subject: [PATCH 12/40] 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 6c8930e985f..62578e2d1cc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5173,6 +5173,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 => { @@ -5180,8 +5181,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 @@ -5220,7 +5247,6 @@ impl< }, }; - let cur_height = self.best_block.read().unwrap().height + 1; check_incoming_htlc_cltv( cur_height, next_hop.outgoing_cltv_value, From 5397c1f4cf2b999ec7b576bdb954cfffd3355f36 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 08:42:32 -0400 Subject: [PATCH 13/40] 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 7e428bd02087f8c6f77ef9cc11e1348e81cd1e28 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 27 Apr 2026 10:57:34 -0400 Subject: [PATCH 14/40] 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 62578e2d1cc..57b2e63d536 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 { @@ -5793,6 +5807,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 18de4be0699dda5283f1b2c45b10bac9c70982e9 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 16 Jan 2026 14:38:21 -0500 Subject: [PATCH 15/40] 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 | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index a6da513845a..686909ff993 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,24 @@ impl_writeable_tlv_based!(NextTrampolineHopInfo, { (7, cltv_expiry_height, required), }); +#[derive(Clone)] +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. + pub(crate) forwading_fee_msat: u64, +} + +impl_writeable_tlv_based!(TrampolineForwardInfo, { + (1, next_hop_info, required), + (3, previous_hop_data, required_vec), + (5, forwading_fee_msat, required), +}); + #[derive(Clone)] pub(crate) struct RetryableInvoiceRequest { pub(crate) invoice_request: InvoiceRequest, @@ -2037,6 +2058,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 +2707,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 +2811,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 b020421466e933b79f4e202cc008ceb938695faf Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 10 Feb 2026 08:46:40 +0200 Subject: [PATCH 16/40] ln: thread trampoline routing information through payment methods --- lightning/src/ln/channelmanager.rs | 2 + lightning/src/ln/outbound_payment.rs | 57 ++++++++++++++++------------ 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 57b2e63d536..8d33d6831e6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5501,6 +5501,7 @@ impl< keysend_preimage, invoice_request: None, bolt12_invoice: None, + trampoline_forward_info: None, session_priv_bytes, hold_htlc_at_next_hop: false, }) @@ -5518,6 +5519,7 @@ impl< bolt12_invoice, session_priv_bytes, hold_htlc_at_next_hop, + .. } = 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 686909ff993..a9614b8085e 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -975,6 +975,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, } @@ -1235,7 +1236,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; @@ -1246,8 +1247,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 @@ -1259,8 +1260,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!( @@ -1643,7 +1644,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); @@ -1651,7 +1652,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); @@ -1718,14 +1719,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(); @@ -1741,6 +1742,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(), @@ -1762,7 +1764,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"); @@ -1802,8 +1804,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( @@ -1954,14 +1957,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)), @@ -2009,7 +2012,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] @@ -2017,15 +2020,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) @@ -2037,7 +2040,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()); @@ -2058,7 +2062,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(), @@ -2149,7 +2153,7 @@ 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 @@ -2163,6 +2167,9 @@ impl OutboundPayments { { return Err(PaymentSendFailure::ParameterError(APIError::APIMisuseError{err: "Payment secret is required for multi-path payments".to_owned()})); } + if trampoline_forward_info.is_some() && keysend_preimage.is_some() { + return Err(PaymentSendFailure::ParameterError(APIError::APIMisuseError{err: "Trampoline forwards cannot include keysend preimage".to_owned()})); + } let our_node_id = node_signer.get_node_id(Recipient::Node).unwrap(); // TODO no unwrap let mut path_errs = Vec::with_capacity(route.paths.len()); 'path_check: for path in route.paths.iter() { @@ -2198,7 +2205,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); @@ -2265,7 +2272,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 }) } @@ -2983,7 +2990,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, @@ -3029,7 +3036,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 dd3463022664e15c6ca63fefcf6124874d88660e Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 10 Feb 2026 08:53:55 +0200 Subject: [PATCH 17/40] ln: add blinding point to new_trampoline_entry When we are forwading 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 ac0b8ce486a..ee1f792c9e1 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 eff315b3820..4e27e7eebc7 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, )?, ); }, @@ -2707,7 +2724,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 4c1bf9800990d947f3da0a1442f326642181e672 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 28 Jan 2026 15:08:29 -0500 Subject: [PATCH 18/40] ln function to build trampoline forwarding onions --- lightning/src/ln/onion_utils.rs | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 4e27e7eebc7..a7aece3a4bc 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}; @@ -2669,6 +2669,49 @@ 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, keysend_preimage: &Option, + 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"); + + 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, + keysend_preimage, + 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 d9598b3ffb5385dfbbec19c593eacc693309e80f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 11 Feb 2026 15:20:15 +0200 Subject: [PATCH 19/40] ln: support trampoline in send_payment_along_path --- lightning/src/ln/channelmanager.rs | 64 +++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8d33d6831e6..d2bd17bf1e3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5517,9 +5517,9 @@ impl< keysend_preimage, invoice_request, bolt12_invoice, + trampoline_forward_info, session_priv_bytes, hold_htlc_at_next_hop, - .. } = args; // The top-level caller should hold the total_consistency_lock read lock. debug_assert!(self.total_consistency_lock.try_write().is_err()); @@ -5533,18 +5533,32 @@ 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 { + onion_utils::create_trampoline_forward_onion( + &self.secp_ctx, + &path, + &session_priv, + payment_hash, + recipient_onion, + keysend_preimage, + &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 })?; @@ -5588,12 +5602,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 5a72ca41ccd2178229585ff8cb80ffa4d8fa2cc7 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Fri, 16 Jan 2026 14:56:00 -0500 Subject: [PATCH 20/40] ln: add send trampoline payment functionality --- lightning/src/ln/outbound_payment.rs | 114 +++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index a9614b8085e..56aac59c4d7 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1666,6 +1666,120 @@ 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, + 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 inter_trampoline_payment_secret = + PaymentSecret(entropy_source.get_secure_random_bytes()); + 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, From 7fdc77b5d33887942823e35c98322bcc9b51467d Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 09:52:35 -0400 Subject: [PATCH 21/40] ln: surface trampoline error packet it could not decrypt For trampoline forwards, we try to decrypt with our chosen session key. If the failing node encrypted the failure once, it's intended for us and we'll be able to decrypt the failure. If they double encrypted it, it's intended for the original sender. To propagate this error back to the original sender, we need the encrypted error packet so that we can ourselves double encrypt it back to the original source. --- lightning/src/ln/onion_utils.rs | 216 ++++++++++++++++++++++---------- 1 file changed, 151 insertions(+), 65 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index a7aece3a4bc..1cdaac2b214 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1013,10 +1013,13 @@ mod fuzzy_onion_utils { pub(crate) failed_within_blinded_path: bool, #[allow(dead_code)] pub(crate) hold_times: Vec, - #[cfg(any(test, feature = "_test_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 couldn't be decoded at the + /// outer onion level, this contains the error packet with outer layers peeled, + /// ready to be passed through with additional trampoline wrapping. + pub(crate) trampoline_peeled_packet: Option, #[cfg(test)] pub(crate) attribution_failed_channel: Option, } @@ -1025,12 +1028,42 @@ mod fuzzy_onion_utils { secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, encrypted_packet: OnionErrorPacket, ) -> DecodedOnionFailure { - let (path, session_priv) = match htlc_source { - HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => (path, session_priv), - _ => unreachable!(), - }; + match htlc_source { + HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => { + process_onion_failure_inner( + secp_ctx, + logger, + &path, + &session_priv, + None, + encrypted_packet, + ) + .0 + }, + HTLCSource::TrampolineForward { outbound_payment, .. } => { + let dispatch = outbound_payment.as_ref() + .expect("processing trampoline onion failure for forward with no outbound payment details"); + + let (mut decoded, peeled_packet) = process_onion_failure_inner( + secp_ctx, + logger, + &dispatch.path, + &dispatch.session_priv, + None, + encrypted_packet, + ); + + // If we couldn't decode the error at the outer onion level, it's a + // trampoline-encrypted error from downstream. Store the peeled packet + // so the caller can pass it through with additional trampoline wrapping. + if decoded.onion_error_code.is_none() { + decoded.trampoline_peeled_packet = Some(peeled_packet); + } - process_onion_failure_inner(secp_ctx, logger, path, &session_priv, None, encrypted_packet) + decoded + }, + _ => unreachable!(), + } } /// Decodes the attribution data that we got back from upstream on a payment we sent. @@ -1092,7 +1125,7 @@ pub(crate) use self::fuzzy_onion_utils::*; fn process_onion_failure_inner( secp_ctx: &Secp256k1, logger: &L, path: &Path, session_priv: &SecretKey, trampoline_session_priv_override: Option, mut encrypted_packet: OnionErrorPacket, -) -> DecodedOnionFailure { +) -> (DecodedOnionFailure, OnionErrorPacket) { // Check that there is at least enough data for an hmac, otherwise none of the checking that we may do makes sense. // Also prevent slice out of bounds further down. if encrypted_packet.data.len() < 32 { @@ -1104,19 +1137,22 @@ 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 ( + DecodedOnionFailure { + network_update: None, + short_channel_id: None, + payment_failed_permanently: true, + failed_within_blinded_path: false, + hold_times: Vec::new(), + onion_error_code: None, + #[cfg(any(test, feature = "_test_utils"))] + onion_error_data: None, + trampoline_peeled_packet: None, + #[cfg(test)] + attribution_failed_channel: None, + }, + encrypted_packet, + ); } // Learnings from the HTLC failure to inform future payment retries and scoring. @@ -1197,16 +1233,41 @@ fn process_onion_failure_inner( let route_hop = match route_hop_option.as_ref() { Some(hop) => hop, None => { - // Got an error from within a blinded route. - _error_code_ret = Some(LocalHTLCFailureReason::InvalidOnionBlinding); - _error_packet_ret = Some(vec![0; 32]); - res = Some(FailureLearnings { - network_update: None, - short_channel_id: None, - payment_failed_permanently: false, - failed_within_blinded_path: true, - }); - break; + // This is a blinded hop. We still need to peel this layer so that + // trampoline pass-through errors can be decoded at a later hop. + crypt_failure_packet(shared_secret.as_ref(), &mut encrypted_packet); + + let um = gen_um_from_shared_secret(shared_secret.as_ref()); + let mut hmac = HmacEngine::::new(&um); + hmac.input(&encrypted_packet.data[32..]); + if &Hmac::from_engine(hmac).to_byte_array() == &encrypted_packet.data[..32] { + // The error was addressed to this blinded hop (trampoline recipient). + let err_packet = msgs::DecodedOnionErrorPacket::read(&mut Cursor::new( + &encrypted_packet.data, + )); + if let Ok(err_packet) = err_packet { + if let Some(error_code_slice) = err_packet.failuremsg.get(0..2) { + _error_code_ret = Some( + u16::from_be_bytes(error_code_slice.try_into().expect("len is 2")) + .into(), + ); + _error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); + } + } + res = Some(FailureLearnings { + network_update: None, + short_channel_id: None, + payment_failed_permanently: false, + failed_within_blinded_path: true, + }); + break; + } + + // HMAC didn't match — continue peeling to find the right hop. + // If we exhaust all hops without a match, the fallthrough will + // handle it (returning onion_error_code: None for trampoline + // pass-through). + continue; }, }; @@ -1223,37 +1284,60 @@ fn process_onion_failure_inner( match next_hop { Some((_, (Some(hop), _))) => hop, _ => { - // The failing hop is within a multi-hop blinded path. - #[cfg(not(test))] - { - _error_code_ret = Some(LocalHTLCFailureReason::InvalidOnionBlinding); - _error_packet_ret = Some(vec![0; 32]); - } - #[cfg(test)] - { - // Actually parse the onion error data in tests so we can check that blinded hops fail - // back correctly. - crypt_failure_packet(shared_secret.as_ref(), &mut encrypted_packet); - let err_packet = msgs::DecodedOnionErrorPacket::read(&mut Cursor::new( - &encrypted_packet.data, - )) - .unwrap(); - _error_code_ret = Some( - u16::from_be_bytes( - err_packet.failuremsg.get(0..2).unwrap().try_into().unwrap(), - ) - .into(), - ); - _error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); + // The next hop is within a multi-hop blinded path. + // Crypt and check HMAC to see if the error was addressed + // to this hop. If not, continue peeling so trampoline + // pass-through errors can be decoded at a later hop. + crypt_failure_packet(shared_secret.as_ref(), &mut encrypted_packet); + + let um = gen_um_from_shared_secret(shared_secret.as_ref()); + let mut hmac = HmacEngine::::new(&um); + hmac.input(&encrypted_packet.data[32..]); + + if &Hmac::from_engine(hmac).to_byte_array() == &encrypted_packet.data[..32] { + #[cfg(not(test))] + { + _error_code_ret = Some(LocalHTLCFailureReason::InvalidOnionBlinding); + _error_packet_ret = Some(vec![0; 32]); + } + #[cfg(test)] + { + // Actually parse the onion error data in tests so we + // can check that blinded hops fail back correctly. + if let Ok(err_packet) = msgs::DecodedOnionErrorPacket::read( + &mut Cursor::new(&encrypted_packet.data), + ) { + _error_code_ret = Some( + u16::from_be_bytes( + err_packet + .failuremsg + .get(0..2) + .unwrap() + .try_into() + .unwrap(), + ) + .into(), + ); + _error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); + } else { + _error_code_ret = + Some(LocalHTLCFailureReason::InvalidOnionBlinding); + _error_packet_ret = Some(vec![0; 32]); + } + } + + res = Some(FailureLearnings { + network_update: None, + short_channel_id: None, + payment_failed_permanently: false, + failed_within_blinded_path: true, + }); + break; } - res = Some(FailureLearnings { - network_update: None, - short_channel_id: None, - payment_failed_permanently: false, - failed_within_blinded_path: true, - }); - break; + // HMAC didn't match — continue peeling to find the right + // hop (trampoline pass-through). + continue; }, } }; @@ -1487,7 +1571,7 @@ fn process_onion_failure_inner( break; } - if let Some(FailureLearnings { + let decoded = if let Some(FailureLearnings { network_update, short_channel_id, payment_failed_permanently, @@ -1500,10 +1584,10 @@ fn process_onion_failure_inner( payment_failed_permanently, failed_within_blinded_path, hold_times: hop_hold_times, - #[cfg(any(test, feature = "_test_utils"))] 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, } @@ -1523,14 +1607,16 @@ fn process_onion_failure_inner( payment_failed_permanently: is_from_final_non_blinded_node, failed_within_blinded_path: false, hold_times: hop_hold_times, - #[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, } - } + }; + + (decoded, encrypted_packet) } const BADONION: u16 = 0x8000; @@ -2168,10 +2254,10 @@ impl HTLCFailReason { 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()), + trampoline_peeled_packet: None, #[cfg(test)] attribution_failed_channel: None, } @@ -3782,7 +3868,7 @@ mod tests { data: >::from_hex(error_packet_hex).unwrap(), attribution_data: None, }; - let decrypted_failure = process_onion_failure_inner( + let (decrypted_failure, _) = process_onion_failure_inner( &secp_ctx, &logger, &build_trampoline_test_path(), From a3b8d4a245cfcdfc9a6c2b9e74c1bad7dfba774f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 09:55:06 -0400 Subject: [PATCH 22/40] [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 56aac59c4d7..6f2d08ccad9 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2573,6 +2573,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 a8dcb8149d63b9816ddf75f6ab01bf9108962288 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 18 Feb 2026 15:26:57 +0200 Subject: [PATCH 23/40] 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 6f2d08ccad9..9d6caf81490 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2969,6 +2969,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.forwading_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 badc25ae2257b6d854df8448015f8d2a4d8f3cc8 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 18 Feb 2026 15:27:28 +0200 Subject: [PATCH 24/40] 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 d2bd17bf1e3..31dfa54f891 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10455,6 +10455,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 9d6caf81490..285f2d8a83f 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2480,6 +2480,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 94886b68aadcd90103519aac2a83d59e720c9454 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 12 Mar 2026 11:07:59 -0400 Subject: [PATCH 25/40] ln: only fail trampoline payments backwards when payment state ready ln: return appropriate error for trampoline failures --- lightning/src/ln/channelmanager.rs | 154 +++++++++++++++++------------ lightning/src/ln/onion_utils.rs | 6 +- 2 files changed, 97 insertions(+), 63 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 31dfa54f891..7d850f05633 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9713,72 +9713,102 @@ 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(onion_error_code) = decoded.onion_error_code { + log_trace!( + WithContext::from(&self.logger, None, None, Some(*payment_hash)), + "Received trampoline error intended for us: {:?}, returning TemporaryTrampolineFailure to original sender", + onion_error_code, + ); + HTLCFailReason::reason( + LocalHTLCFailureReason::TemporaryTrampolineFailure, + Vec::new(), + ) + } else 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 { + log_trace!( + WithContext::from(&self.logger, None, None, Some(*payment_hash)), + "Received a malformed trampoline error, returning TemporaryTrampolineFailure to original sender", + ); + // Couldn't peel outer layers (e.g., packet too short or + // fail_malformed_htlc). Replace with our own error. + 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 1cdaac2b214..17c14d60c25 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1872,7 +1872,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 @@ -2192,6 +2192,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 33c6435f9ba902a283ecdd983bc70a763153af0b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 18 Feb 2026 15:30:07 +0200 Subject: [PATCH 26/40] 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 7d850f05633..aa75d59170b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10601,7 +10601,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(); @@ -10620,10 +10642,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 437ad28cc5fc6b5c84ab9a70eb4c3f223abef0f1 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 2 Feb 2026 13:52:19 -0500 Subject: [PATCH 27/40] 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 17c14d60c25..0153913e05b 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2548,7 +2548,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 a194e39fd69e38730ed1b983ed5e83f1f3104dae Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 24 Feb 2026 16:22:39 +0200 Subject: [PATCH 28/40] 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 aa75d59170b..9d8d11d9e29 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18295,8 +18295,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 d73b6725dc59689223a708bbb905f26911160a62 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 27 Apr 2026 11:03:07 -0400 Subject: [PATCH 29/40] 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 | 100 +++++++++++++++---- lightning/src/ln/trampoline_forward_tests.rs | 2 +- 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index ee1f792c9e1..27f6ef623f2 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2732,9 +2732,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, 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 9d8d11d9e29..506187d6bfb 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; use crate::routing::router::{ 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; @@ -8550,7 +8551,7 @@ impl< // 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(); @@ -8599,6 +8600,10 @@ impl< Ok(true) => {}, }; + let trampoline_payment = trampoline_payments + .remove(&payment_hash) + .expect("payment was just accessed via entry()"); + 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(); @@ -8638,7 +8643,7 @@ impl< ) }; - let _max_total_routing_fee_msat = match our_forwarding_fee_msat + 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)) { @@ -8648,7 +8653,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)) @@ -8659,24 +8664,77 @@ 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, + 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, + TrampolineForwardInfo { + next_hop_info, + previous_hop_data: trampoline_payment + .htlcs + .iter() + .map(|htlc| htlc.prev_hop.clone()) + .collect(), + forwading_fee_msat: our_forwarding_fee_msat, + }, + 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)), ); - 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![]), - )) + 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 fa8492313f91036b4175aa7933e38622d8800019 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 10 Feb 2026 09:57:43 +0200 Subject: [PATCH 30/40] 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 27f6ef623f2..78e65f84a42 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); @@ -2645,21 +2646,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( @@ -2670,10 +2676,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() @@ -2687,8 +2692,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 17bcdccc1c83099475f601cda2bc1f4dbab61dfc Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 3 Feb 2026 09:23:11 -0500 Subject: [PATCH 31/40] [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 1677b12fd6b535e94a41b38097e4a89787c2928c Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 09:07:20 -0400 Subject: [PATCH 32/40] [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 506187d6bfb..fa80e87dae4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5327,7 +5327,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); @@ -5344,7 +5345,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, @@ -7678,6 +7679,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, @@ -7783,6 +7794,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 4732c9a3f841b2977f80087e034f50efd0144956 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Tue, 17 Mar 2026 14:23:43 -0400 Subject: [PATCH 33/40] [wip]: forwarding tests with messy replacement onion code --- lightning/src/ln/blinded_payment_tests.rs | 447 +++++++++++++++++----- lightning/src/routing/router.rs | 2 +- 2 files changed, 348 insertions(+), 101 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 78e65f84a42..f7e19ba8f38 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,42 +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, - )), + 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(); @@ -2653,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() @@ -2687,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() @@ -2698,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, ); @@ -2723,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 d80e9596b36def8c197480c624098c7b33912a0b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:13:08 +0200 Subject: [PATCH 34/40] [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 fbf2a4caa9f..52e046c8c3e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7988,7 +7988,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 { @@ -8037,7 +8037,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, }) @@ -8048,12 +8048,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, }, @@ -8061,8 +8061,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 fa80e87dae4..ba10cb7b3bf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19373,35 +19373,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(); - } - }, - _ => {}, + }, + _ => {}, + } } } @@ -20097,10 +20100,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 @@ -20138,13 +20142,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)); + } } } } @@ -20193,17 +20199,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, ); } @@ -20223,7 +20230,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, @@ -20232,29 +20240,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, @@ -20620,27 +20611,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, + ); } } @@ -20779,11 +20768,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 @@ -20798,7 +20786,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, @@ -21057,18 +21045,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, + ); } } @@ -21110,18 +21100,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(); + } } } } @@ -21130,21 +21120,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 @@ -21154,11 +21143,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 @@ -21167,7 +21156,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 311e846a5b36b81505668b6db1b918b8ff0d946b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 15:55:14 +0200 Subject: [PATCH 35/40] [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 52e046c8c3e..81b9a623fc0 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7988,7 +7988,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 { @@ -8037,7 +8037,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, }) @@ -8052,18 +8055,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 ba10cb7b3bf..4cd0c8d46f9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20142,14 +20142,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 8ab2a55eae1bf1491d94145ae2d02ce2b364f5a6 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:18:01 +0200 Subject: [PATCH 36/40] [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 81b9a623fc0..881fe5bd47e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1197,7 +1197,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` @@ -9786,8 +9786,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 4cd0c8d46f9..50753036993 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1631,7 +1631,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)>, }, } @@ -10750,7 +10750,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. @@ -11323,34 +11323,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 fe647a1960e715916ff81f03991aeee0ffef674c Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 11:06:29 +0200 Subject: [PATCH 37/40] [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 50753036993..a76473c0bc9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20619,7 +20619,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 32d29d432e48f01a611f4b84dd17de18a8db03d0 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:42:59 +0200 Subject: [PATCH 38/40] [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 881fe5bd47e..b05836bdba7 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); @@ -8086,20 +8099,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; + }, + _ => {}, } } } @@ -9786,7 +9845,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 a76473c0bc9..5399b275e9a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11347,7 +11347,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 d7befa32c720da9ab3fb75799adb1e5bc377738d Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:56:41 +0200 Subject: [PATCH 39/40] [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 b05836bdba7..6d6c2dd6d5c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8055,6 +8055,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 9d4fc86bbbbac8903c060a799f6e14660ad05583 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:59:25 +0200 Subject: [PATCH 40/40] [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 6d6c2dd6d5c..ada5421387e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8090,14 +8090,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)