ln: add awaiting_trampoline_forwards to accumulate inbound MPP
What changed, and why it matters
This commit adds a new internal bookkeeping map for a not-yet-enabled feature called trampoline routing. The commit itself is defensive: it documents that the new map is intentionally not saved to disk and warns that using it in production could cause a channel force-close after a restart. Because the feature is currently rejected earlier in the code path, the commit says the risky state cannot be reached today. It is best read as a work-in-progress foundation rather than an active vulnerability.
Treat this as a feature-in-progress commit, not an immediate security patch. Ensure follow-up commits add persistence and proper restart recovery before trampoline forwarding is enabled. Reviewers should verify that the upstream rejection of trampoline HTLCs remains in place until the persistence work lands.
Security signals we found
New non-persistent state map for pending trampoline MPP HTLCs
Commit message explicitly warns of force-close risk if used in production before persistence/restart logic is added
Trampoline HTLCs are currently rejected earlier in the lifecycle, mitigating the documented risk
Timeout/failure paths added for both MPP timeout and on-chain CLTV expiry
No persistence logic added for the new map
Evidence from the diff
The patch introduces awaiting_trampoline_forwards, a runtime-only HashMap<PaymentHash, TrampolinePayment> in ChannelManager. It mirrors the existing MPP accumulation logic for inbound trampoline HTLCs and adds timeout handling in two places: check_mpp_timeout and on-chain CLTV expiry. The map is initialized empty both on creation and deserialization, and the commit message explicitly notes it is not persisted. The documented risk is that a restart would drop pending trampoline MPP parts, so the local node would neither fulfill nor fail them on time, leaving the inbound counterparty to force-close. The commit states trampoline HTLCs are still rejected upstream, so this state is unreachable.
Changed components
lightning/src/ln/channelmanager.rsChannelManager MPP/trampoline forward accumulationHTLC timeout/failure handling pathsInspect captured patch +63 / −0
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 0303483..3cacbdc 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -1313,6 +1313,12 @@ fn check_mpp_timeout<'a>(
timed_out
}
+/// Tracks trampoline HTLCs being accumulated before forwarding.
+struct TrampolinePayment {
+ onion_fields: RecipientOnionFields,
+ htlcs: Vec<MppPart>,
+}
+
/// Represent the channel funding transaction type.
enum FundingType {
/// This variant is useful when we want LDK to validate the funding transaction and
@@ -2894,6 +2900,16 @@ pub struct ChannelManager<
/// [`ClaimablePayments`]' individual field docs for more info.
claimable_payments: Mutex<ClaimablePayments>,
+ /// 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<HashMap<PaymentHash, TrampolinePayment>>,
+
/// 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
@@ -3741,6 +3757,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()),
@@ -9098,6 +9115,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);
@@ -16586,6 +16623,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 {
@@ -20484,6 +20546,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(),
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.