From 0c6f1d7e0e6cb812ed4437d31d21a09738e15597 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Mon, 2 Mar 2026 15:47:37 +0200 Subject: [PATCH 1/8] ln: add previous_hop_data helper for HTLCSource --- lightning/src/ln/channelmanager.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 5951c6cdbe6..45e8db1c579 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -857,6 +857,14 @@ mod fuzzy_channelmanager { }, } } + + pub fn previous_hop_data(&self) -> &[HTLCPreviousHopData] { + match self { + HTLCSource::PreviousHopData(prev_hop) => core::slice::from_ref(prev_hop), + HTLCSource::TrampolineForward { previous_hop_data, .. } => &previous_hop_data[..], + HTLCSource::OutboundRoute { .. } => &[], + } + } } /// Tracks the inbound corresponding to an outbound HTLC @@ -19520,17 +19528,11 @@ impl< .into_iter() .filter_map(|(htlc_source, (htlc, preimage_opt))| { let payment_preimage = preimage_opt?; - let prev_htlcs = match &htlc_source { - HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop], - HTLCSource::TrampolineForward { previous_hop_data, .. } => { - previous_hop_data.iter().collect() - }, - // If it was an outbound payment, we've handled it above - if a preimage - // came in and we persisted the `ChannelManager` we either handled it - // and are good to go or the channel force-closed - we don't have to - // handle the channel still live case here. - _ => vec![], - }; + // If it was an outbound payment, we've handled it above - if a preimage + // came in and we persisted the `ChannelManager` we either handled it + // and are good to go or the channel force-closed - we don't have to + // handle the channel still live case here. + let prev_htlcs = htlc_source.previous_hop_data(); let prev_htlcs_count = prev_htlcs.len(); if prev_htlcs_count == 0 { return None; From fb82e4a04c2f4a19cde9483d5bbbc33fad1942a1 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:13:08 +0200 Subject: [PATCH 2/8] [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 9361cd3c749..8e2e35bc96c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7451,7 +7451,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 { @@ -7500,7 +7500,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, }) @@ -7511,12 +7511,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, }, @@ -7524,8 +7524,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 45e8db1c579..2ff7b5e8de9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -18530,35 +18530,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(); - } - }, - _ => {}, + }, + _ => {}, + } } } @@ -19255,10 +19258,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 @@ -19296,13 +19300,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)); + } } } } @@ -19351,17 +19357,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, ); } @@ -19381,7 +19388,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, @@ -19390,29 +19398,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, @@ -19775,27 +19766,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 prev_hop_data in htlcs.iter().map(|h| &h.prev_hop) { - dedup_decode_update_add_htlcs( - &mut decode_update_add_htlcs, - prev_hop_data, - "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.into_iter().map(|h| &h.prev_hop), + "HTLC was already decoded and marked as a claimable payment", + &args.logger, + ); } } @@ -19937,11 +19926,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 @@ -19956,7 +19944,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, @@ -20213,18 +20201,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, + ); } } @@ -20266,18 +20256,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(); + } } } } @@ -20286,21 +20276,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 @@ -20310,11 +20299,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 @@ -20323,7 +20312,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 03860ed76524be20a01c4c3c7af6a8ce8c03bd5b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 15:55:14 +0200 Subject: [PATCH 3/8] [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 8e2e35bc96c..bec133f8d53 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7451,7 +7451,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 { @@ -7500,7 +7500,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, }) @@ -7515,18 +7518,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 2ff7b5e8de9..49a4812e12e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19300,14 +19300,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 88da9a1923db022f6ce629eeb2d2ddf95c43d5b5 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:18:01 +0200 Subject: [PATCH 4/8] [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 bec133f8d53..33273abb97a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1193,7 +1193,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` @@ -9183,8 +9183,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 49a4812e12e..99cc5cea618 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1541,7 +1541,7 @@ enum PostMonitorUpdateChanResume { decode_update_add_htlcs: Option<(u64, 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)>, }, } @@ -10027,7 +10027,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ decode_update_add_htlcs: Option<(u64, 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. @@ -10593,34 +10593,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 26fac87379d57fb78dbe475dac6bce2ca94d963c Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 11:06:29 +0200 Subject: [PATCH 5/8] [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 99cc5cea618..5b11e2da9d8 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -19774,7 +19774,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 2f86b4420526272087cba37e2f5a8237a9e9c39f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:42:59 +0200 Subject: [PATCH 6/8] [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 | 90 +++++++++++++++++++++++++----- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 33273abb97a..63a1eead1d2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -52,7 +52,7 @@ use crate::ln::channel_state::{ use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, HTLCPreviousHopData, HTLCSource, OpenChannelMessage, PaymentClaimDetails, PendingHTLCInfo, - PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, + PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, TrampolineDispatch, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; @@ -356,6 +356,16 @@ 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)>, + incoming_trampoline_shared_secret: [u8; 32], + }, /// This HTLC was received pre-LDK 0.3, before we started persisting the onion for inbound /// committed HTLCs. Legacy, @@ -373,6 +383,11 @@ 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), + (4, incoming_trampoline_shared_secret, required), + }, ); impl_writeable_for_vec!(&InboundUpdateAdd); @@ -7549,20 +7564,69 @@ 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, + incoming_trampoline_shared_secret, + 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"))], + incoming_trampoline_shared_secret: + *incoming_trampoline_shared_secret, + } + }, + _ => { + debug_assert!( + false, + "outbound route should not prune inbound htlc" + ); + return; + }, + }; + return; + }, + _ => {}, } } } @@ -9183,7 +9247,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 5b11e2da9d8..a244efa84be 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10617,7 +10617,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 9e969f53f7708a757dc2bc28f103d4c4331483e4 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:56:41 +0200 Subject: [PATCH 7/8] [wip] return trampoline forwards in inbound_forwarded_htlcs --- lightning/src/ln/channel.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 63a1eead1d2..4a5dd85e9e3 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7520,6 +7520,34 @@ where vec![(HTLCSource::PreviousHopData(prev_hop_data), *outbound_hop)], )) }, + InboundHTLCState::Committed { + update_add_htlc: + InboundUpdateAdd::TrampolineForwarded { + previous_hop_data, + outbound_hops, + incoming_trampoline_shared_secret, + }, + } => { + 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(), + incoming_trampoline_shared_secret: + *incoming_trampoline_shared_secret, + outbound_payment: Some(dispatch.clone()), + }, + *hop, + ) + }) + .collect(); + + Some((htlc.payment_hash, trampoline_sources)) + }, _ => None, }) } From 625bb89fde904ef78520ef83210b88fdbba7994b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 4 Mar 2026 10:59:25 +0200 Subject: [PATCH 8/8] [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 4a5dd85e9e3..e6da617531c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7561,14 +7561,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)