Persist outbound channel info in inbound HTLCs
What changed, and why it matters
This commit fixes a data-loss bug in the Lightning Dev Kit (LDK) routing/payment-forwarding logic. When LDK forwards a payment through a node and then restarts, it may need to claim the inbound payment (HTLC) even though the outbound side has already been settled. Previously, after restart LDK only remembered the outbound amount, not which outbound channel or peer the payment went to. That meant it could emit an incomplete or incorrect 'PaymentForwarded' event and possibly mishandle the post-restart claim. The patch now persists the full outbound hop details (channel ID, peer, funding outpoint, user channel ID, and amount) inside the stored inbound HTLC state so the event can be reconstructed correctly after restart.
Treat as a reliability/accounting bug fix rather than an exploitable vulnerability. Users running LDK nodes that route payments should upgrade to avoid incorrect PaymentForwarded events and potential post-restart HTLC handling inconsistencies. No immediate emergency response is indicated.
Security signals we found
Data persistence gap in cross-channel HTLC state
Post-restart event reconstruction could be incorrect or incomplete
Fix is additive and backward-compatible via TLV upgradable enum
No explicit cryptographic, authorization, or network validation change
Evidence from the diff
The change introduces a new OutboundHop struct and stores it in InboundUpdateAdd::Forwarded instead of a bare outbound_amt_msat. It threads the outbound channel’s funding_txo and user_channel_id through PostMonitorUpdateChanResume::Unblocked and post_monitor_update_unlock into prune_persisted_inbound_htlc_onions, which now builds an OutboundHop and passes it to Channel::prune_inbound_htlc_onion. Serialization is updated via impl_writeable_tlv_based! for OutboundHop and the enum variant. This ensures that, after a restart, claiming an inbound HTLC whose outbound edge was already removed can still produce a correct PaymentForwarded event.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rsInboundHTLCState / InboundUpdateAdd::Forwarded serializationPostMonitorUpdateChanResume::UnblockedPaymentForwarded event generation after restartInspect captured patch +72 / −23
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index b12061b..37a0661 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -308,6 +308,32 @@ impl InboundHTLCState {
}
}
+/// Information about the outbound hop for a forwarded HTLC. Useful for generating an accurate
+/// [`Event::PaymentForwarded`] if we need to claim this HTLC post-restart.
+///
+/// [`Event::PaymentForwarded`]: crate::events::Event::PaymentForwarded
+#[derive(Debug, Copy, Clone)]
+pub(super) struct OutboundHop {
+ /// The amount forwarded outbound.
+ pub(super) amt_msat: u64,
+ /// The outbound channel this HTLC was forwarded over.
+ pub(super) channel_id: ChannelId,
+ /// The next-hop recipient of this HTLC.
+ pub(super) node_id: PublicKey,
+ /// The outbound channel's funding outpoint.
+ pub(super) funding_txo: OutPoint,
+ /// The outbound channel's user channel ID.
+ pub(super) user_channel_id: u128,
+}
+
+impl_writeable_tlv_based!(OutboundHop, {
+ (0, amt_msat, required),
+ (2, channel_id, required),
+ (4, node_id, required),
+ (6, funding_txo, required),
+ (8, user_channel_id, required),
+});
+
/// A field of `InboundHTLCState::Committed` containing the HTLC's `update_add_htlc` message. If
/// the HTLC is a forward and gets irrevocably committed to the outbound edge, we convert to
/// `InboundUpdateAdd::Forwarded`, thus pruning the onion and not persisting it on every
@@ -328,11 +354,7 @@ enum InboundUpdateAdd {
phantom_shared_secret: Option<[u8; 32]>,
trampoline_shared_secret: Option<[u8; 32]>,
blinded_failure: Option<BlindedFailure>,
- /// Useful for generating an accurate [`Event::PaymentForwarded`], if we need to claim this
- /// HTLC post-restart.
- ///
- /// [`Event::PaymentForwarded`]: crate::events::Event::PaymentForwarded
- outbound_amt_msat: u64,
+ outbound_hop: OutboundHop,
},
/// This HTLC was received pre-LDK 0.3, before we started persisting the onion for inbound
/// committed HTLCs.
@@ -346,7 +368,7 @@ impl_writeable_tlv_based_enum_upgradable!(InboundUpdateAdd,
(2, Legacy) => {},
(4, Forwarded) => {
(0, incoming_packet_shared_secret, required),
- (2, outbound_amt_msat, required),
+ (2, outbound_hop, required),
(4, phantom_shared_secret, option),
(6, trampoline_shared_secret, option),
(8, blinded_failure, option),
@@ -7948,7 +7970,7 @@ where
phantom_shared_secret,
trampoline_shared_secret,
blinded_failure,
- outbound_amt_msat,
+ outbound_hop: OutboundHop { amt_msat, .. },
},
} => {
if htlc_resolution_in_holding_cell(htlc.htlc_id) {
@@ -7956,7 +7978,7 @@ where
}
// The reconstructed `HTLCPreviousHopData` is used to fail or claim the HTLC backwards
// post-restart, if it is missing in the outbound edge.
- let hop_data = HTLCPreviousHopData {
+ let prev_hop_data = HTLCPreviousHopData {
prev_outbound_scid_alias,
user_channel_id: Some(user_channel_id),
htlc_id: htlc.htlc_id,
@@ -7969,7 +7991,7 @@ where
counterparty_node_id: Some(counterparty_node_id),
cltv_expiry: Some(htlc.cltv_expiry),
};
- Some((htlc.payment_hash, hop_data, *outbound_amt_msat))
+ Some((htlc.payment_hash, prev_hop_data, *amt_msat))
},
_ => None,
})
@@ -8019,17 +8041,18 @@ 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, hop_data: &HTLCPreviousHopData, outbound_amt_msat: u64,
+ &mut self, htlc_id: u64, prev_hop_data: &HTLCPreviousHopData,
+ outbound_hop_data: OutboundHop,
) {
for htlc in self.context.pending_inbound_htlcs.iter_mut() {
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: hop_data.incoming_packet_shared_secret,
- phantom_shared_secret: hop_data.phantom_shared_secret,
- trampoline_shared_secret: hop_data.trampoline_shared_secret,
- blinded_failure: hop_data.blinded_failure,
- outbound_amt_msat,
+ 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;
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index ac2af35..b7b3969 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -59,9 +59,9 @@ use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight;
use crate::ln::channel::QuiescentAction;
use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult,
- FundedChannel, FundingTxSigned, InboundV1Channel, OutboundV1Channel, PendingV2Channel,
- ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse, UpdateFulfillCommitFetch,
- WithChannelContext,
+ FundedChannel, FundingTxSigned, InboundV1Channel, OutboundHop, OutboundV1Channel,
+ PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse,
+ UpdateFulfillCommitFetch, WithChannelContext,
};
use crate::ln::channel_state::ChannelDetails;
use crate::ln::funding::SpliceContribution;
@@ -1402,6 +1402,8 @@ enum PostMonitorUpdateChanResume {
Unblocked {
channel_id: ChannelId,
counterparty_node_id: PublicKey,
+ funding_txo: OutPoint,
+ user_channel_id: u128,
unbroadcasted_batch_funding_txid: Option<Txid>,
update_actions: Vec<MonitorUpdateCompletionAction>,
htlc_forwards: Option<PerSourcePendingForward>,
@@ -9582,8 +9584,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
/// Handles actions which need to complete after a [`ChannelMonitorUpdate`] has been applied
/// which can happen after the per-peer state lock has been dropped.
fn post_monitor_update_unlock(
- &self, channel_id: ChannelId, counterparty_node_id: PublicKey,
- unbroadcasted_batch_funding_txid: Option<Txid>,
+ &self, channel_id: ChannelId, counterparty_node_id: PublicKey, funding_txo: OutPoint,
+ user_channel_id: u128, unbroadcasted_batch_funding_txid: Option<Txid>,
update_actions: Vec<MonitorUpdateCompletionAction>,
htlc_forwards: Option<PerSourcePendingForward>,
decode_update_add_htlcs: Option<(u64, Vec<msgs::UpdateAddHTLC>)>,
@@ -9660,7 +9662,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
};
self.fail_htlc_backwards_internal(&failure.0, &failure.1, &failure.2, receiver, None);
}
- self.prune_persisted_inbound_htlc_onions(committed_outbound_htlc_sources);
+ self.prune_persisted_inbound_htlc_onions(
+ channel_id,
+ counterparty_node_id,
+ funding_txo,
+ user_channel_id,
+ committed_outbound_htlc_sources,
+ );
}
fn handle_monitor_update_completion_actions<
@@ -10129,6 +10137,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
PostMonitorUpdateChanResume::Unblocked {
channel_id: chan_id,
counterparty_node_id,
+ funding_txo: chan.funding_outpoint(),
+ user_channel_id: chan.context.get_user_id(),
unbroadcasted_batch_funding_txid,
update_actions,
htlc_forwards,
@@ -10144,7 +10154,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
/// HTLC set on `ChannelManager` read. If an HTLC has been irrevocably forwarded to the outbound
/// edge, we no longer need to persist the inbound edge's onion and can prune it here.
fn prune_persisted_inbound_htlc_onions(
- &self, committed_outbound_htlc_sources: Vec<(HTLCPreviousHopData, u64)>,
+ &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)>,
) {
let per_peer_state = self.per_peer_state.read().unwrap();
for (source, outbound_amt_msat) in committed_outbound_htlc_sources {
@@ -10161,7 +10173,17 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
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, outbound_amt_msat);
+ 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,
+ },
+ );
}
}
}
@@ -10217,6 +10239,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
PostMonitorUpdateChanResume::Unblocked {
channel_id,
counterparty_node_id,
+ funding_txo,
+ user_channel_id,
unbroadcasted_batch_funding_txid,
update_actions,
htlc_forwards,
@@ -10228,6 +10252,8 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
self.post_monitor_update_unlock(
channel_id,
counterparty_node_id,
+ funding_txo,
+ user_channel_id,
unbroadcasted_batch_funding_txid,
update_actions,
htlc_forwards,
Why this scored 32/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.