Expose sources for pending outbound HTLCs
What changed, and why it matters
This commit adds extra bookkeeping information to Lightning payment records so users can tell which incoming payment caused each outgoing payment. It is a feature/enhancement change, not a fix for an active security bug. The new data is exposed through existing read-only APIs and serialized in a backward-compatible way, so it does not appear to introduce a vulnerability.
No security action required. Treat as a normal feature/API enhancement; review the new public types for API stability and documentation completeness during regular code review.
Security signals we found
No memory-safety issues, input validation changes, or cryptographic modifications observed.
New public fields expose existing internal identifiers; this is an informational API change.
Serialization is backward/forward-compatible via optional upgradable TLV.
No bug fix, bounds check, or permission change present in the diff.
Evidence from the diff
The patch exposes a new OutboundHTLCSource enum on OutboundHTLCDetails, populated from the existing internal HTLCSource. It distinguishes locally-initiated payments (by PaymentId), single forwards (by inbound channel id + htlc id), and trampoline forwards (by a list of inbound references). Serialization uses an optional upgradable TLV so older versions read None and unknown future variants degrade gracefully. Tests are updated to assert the new source mapping for forwarded HTLCs and local MPP parts.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channel_state.rslightning/src/ln/channelmanager.rslightning/src/ln/functional_tests.rsInspect captured patch +160 / −5
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index a4e79df..03a1932 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -6543,7 +6543,6 @@ impl<SP: SignerProvider> ChannelContext<SP> {
#[rustfmt::skip]
pub fn get_pending_outbound_htlc_details(&self, funding: &FundingScope) -> Vec<OutboundHTLCDetails> {
let mut outbound_details = Vec::new();
-
let dust_buffer_feerate = self.get_dust_buffer_feerate(None);
let (_, htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(
funding.get_channel_type(), dust_buffer_feerate,
@@ -6558,6 +6557,7 @@ impl<SP: SignerProvider> ChannelContext<SP> {
skimmed_fee_msat: htlc.skimmed_fee_msat,
state: Some((&htlc.state).into()),
is_dust: htlc.amount_msat / 1000 < holder_dust_limit_timeout_sat,
+ source: Some(htlc.source.to_outbound()),
});
}
for holding_cell_update in self.holding_cell_htlc_updates.iter() {
@@ -6566,6 +6566,7 @@ impl<SP: SignerProvider> ChannelContext<SP> {
cltv_expiry,
payment_hash,
skimmed_fee_msat,
+ ref source,
..
} = *holding_cell_update {
outbound_details.push(OutboundHTLCDetails{
@@ -6576,6 +6577,7 @@ impl<SP: SignerProvider> ChannelContext<SP> {
skimmed_fee_msat: skimmed_fee_msat,
state: Some(OutboundHTLCStateDetails::AwaitingRemoteRevokeToAdd),
is_dust: amount_msat / 1000 < holder_dust_limit_timeout_sat,
+ source: Some(source.to_outbound()),
});
}
}
diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs
index ea99d4c..48379f9 100644
--- a/lightning/src/ln/channel_state.rs
+++ b/lightning/src/ln/channel_state.rs
@@ -17,6 +17,7 @@ use bitcoin::Txid;
use crate::chain::chaininterface::{FeeEstimator, LowerBoundedFeeEstimator};
use crate::chain::transaction::OutPoint;
use crate::ln::channel::Channel;
+use crate::ln::channelmanager::PaymentId;
use crate::ln::funding::FundingContribution;
use crate::ln::types::ChannelId;
use crate::sign::SignerProvider;
@@ -160,6 +161,52 @@ impl_writeable_tlv_based_enum_upgradable!(OutboundHTLCStateDetails,
(6, AwaitingRemoteRevokeToRemoveFailure) => {},
);
+/// Identifies an inbound HTLC.
+#[derive(Clone, Debug, PartialEq)]
+pub struct InboundHTLCReference {
+ /// The channel on which the HTLC was received.
+ pub channel_id: ChannelId,
+ /// The HTLC ID assigned by the inbound channel.
+ pub htlc_id: u64,
+}
+
+impl_ser_tlv_based!(InboundHTLCReference, {
+ (0, channel_id, required),
+ (2, htlc_id, required),
+});
+
+/// Describes how an outbound HTLC originated.
+#[derive(Clone, Debug, PartialEq)]
+pub enum OutboundHTLCSource {
+ /// A locally initiated payment or probe.
+ Local {
+ /// The payment or probe identifier.
+ payment_id: PaymentId,
+ },
+ /// A forward of a single inbound HTLC.
+ Forwarded {
+ /// The inbound HTLC.
+ inbound_htlc: InboundHTLCReference,
+ },
+ /// A trampoline forward of one or more inbound HTLCs.
+ TrampolineForwarded {
+ /// The inbound HTLCs.
+ inbound_htlcs: Vec<InboundHTLCReference>,
+ },
+}
+
+impl_writeable_tlv_based_enum_upgradable!(OutboundHTLCSource,
+ (0, Local) => {
+ (0, payment_id, required),
+ },
+ (2, Forwarded) => {
+ (0, inbound_htlc, required),
+ },
+ (4, TrampolineForwarded) => {
+ (0, inbound_htlcs, required_vec),
+ },
+);
+
/// Exposes details around pending outbound HTLCs.
#[derive(Clone, Debug, PartialEq)]
pub struct OutboundHTLCDetails {
@@ -175,6 +222,10 @@ pub struct OutboundHTLCDetails {
/// The block height at which this HTLC expires.
pub cltv_expiry: u32,
/// The payment hash.
+ ///
+ /// A payment hash is not sufficient to correlate HTLCs in a multipart payment because multiple
+ /// parts sharing a payment hash may traverse the same channel. Use [`Self::source`] to correlate
+ /// the HTLC with its locally initiated payment or inbound HTLCs.
pub payment_hash: PaymentHash,
/// The state of the HTLC in the state machine.
///
@@ -200,6 +251,12 @@ pub struct OutboundHTLCDetails {
/// Note that dust limits are specific to each party. An HTLC can be dust for the local
/// commitment transaction but not for the counterparty's commitment transaction and vice versa.
pub is_dust: bool,
+ /// The source of this outbound HTLC.
+ ///
+ /// LDK will always fill this field in, but it will be `None` for objects serialized with LDK
+ /// versions prior to 0.4 or when downgrading to a version that does not understand the source
+ /// variant.
+ pub source: Option<OutboundHTLCSource>,
}
impl_ser_tlv_based!(OutboundHTLCDetails, {
@@ -210,6 +267,7 @@ impl_ser_tlv_based!(OutboundHTLCDetails, {
(7, state, upgradable_option),
(8, skimmed_fee_msat, required),
(10, is_dust, required),
+ (11, source, upgradable_option),
});
/// Information needed for constructing an invoice route hint for this channel.
@@ -937,8 +995,8 @@ mod tests {
ln::{
chan_utils::make_funding_redeemscript,
channel_state::{
- InboundHTLCDetails, InboundHTLCStateDetails, OutboundHTLCDetails,
- OutboundHTLCStateDetails,
+ InboundHTLCDetails, InboundHTLCReference, InboundHTLCStateDetails,
+ OutboundHTLCDetails, OutboundHTLCSource, OutboundHTLCStateDetails,
},
types::ChannelId,
},
@@ -1014,6 +1072,12 @@ mod tests {
state: Some(OutboundHTLCStateDetails::AwaitingRemoteRevokeToAdd),
skimmed_fee_msat: Some(42),
is_dust: false,
+ source: Some(OutboundHTLCSource::TrampolineForwarded {
+ inbound_htlcs: vec![
+ InboundHTLCReference { channel_id: ChannelId([5; 32]), htlc_id: 11 },
+ InboundHTLCReference { channel_id: ChannelId([6; 32]), htlc_id: 12 },
+ ],
+ }),
}],
current_dust_exposure_msat: Some(150_000),
splice_details: Some(SpliceDetails {
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 27765f9..e481513 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -64,7 +64,7 @@ use crate::ln::channel::{
OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse,
UpdateFulfillCommitFetch, WithChannelContext,
};
-use crate::ln::channel_state::ChannelDetails;
+use crate::ln::channel_state::{ChannelDetails, InboundHTLCReference, OutboundHTLCSource};
use crate::ln::funding::{FundingContribution, FundingTemplate};
use crate::ln::inbound_payment;
use crate::ln::interactivetxs::InteractiveTxMessageSend;
@@ -913,6 +913,26 @@ mod fuzzy_channelmanager {
}
impl HTLCSource {
+ pub(crate) fn to_outbound(&self) -> OutboundHTLCSource {
+ let inbound_htlc = |prev_hop: &HTLCPreviousHopData| InboundHTLCReference {
+ channel_id: prev_hop.channel_id,
+ htlc_id: prev_hop.htlc_id,
+ };
+ match self {
+ Self::OutboundRoute { payment_id, .. } => {
+ OutboundHTLCSource::Local { payment_id: *payment_id }
+ },
+ Self::PreviousHopData(prev_hop) => {
+ OutboundHTLCSource::Forwarded { inbound_htlc: inbound_htlc(prev_hop) }
+ },
+ Self::TrampolineForward { previous_hop_data, .. } => {
+ OutboundHTLCSource::TrampolineForwarded {
+ inbound_htlcs: previous_hop_data.iter().map(inbound_htlc).collect(),
+ }
+ },
+ }
+ }
+
pub fn failure_type(
&self, counterparty_node: PublicKey, channel_id: ChannelId,
) -> HTLCHandlingFailureType {
diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs
index 2e21974..fdf092d 100644
--- a/lightning/src/ln/functional_tests.rs
+++ b/lightning/src/ln/functional_tests.rs
@@ -33,6 +33,7 @@ use crate::ln::channel::{
get_holder_selected_channel_reserve_satoshis, Channel, DISCONNECT_PEER_AWAITING_RESPONSE_TICKS,
MIN_CHAN_DUST_LIMIT_SATOSHIS, UNFUNDED_CHANNEL_AGE_LIMIT_TICKS,
};
+use crate::ln::channel_state::OutboundHTLCSource;
use crate::ln::channelmanager::{
PaymentId, RAACommitmentOrder, BREAKDOWN_TIMEOUT, DISABLE_GOSSIP_TICKS, ENABLE_GOSSIP_TICKS,
MIN_CLTV_EXPIRY_DELTA,
@@ -3425,6 +3426,7 @@ fn do_test_holding_cell_htlc_add_timeouts(forwarded_htlc: bool) {
let sending_node = if forwarded_htlc { &nodes[0] } else { &nodes[1] };
let (route, second_payment_hash, _, second_payment_secret) =
get_route_and_payment_hash!(sending_node, nodes[2], 100000);
+ assert_ne!(second_payment_hash, first_payment_hash);
let onion = RecipientOnionFields::secret_only(second_payment_secret, 100000);
let id = PaymentId(second_payment_hash.0);
sending_node.node.send_payment_with_route(route, second_payment_hash, onion, id).unwrap();
@@ -3439,6 +3441,30 @@ fn do_test_holding_cell_htlc_add_timeouts(forwarded_htlc: bool) {
expect_and_process_pending_htlcs(&nodes[1], false);
}
check_added_monitors(&nodes[1], 0);
+ if forwarded_htlc {
+ let channels = nodes[1].node.list_channels();
+ let inbound_channel =
+ channels.iter().find(|details| details.counterparty.node_id == node_a_id).unwrap();
+ let outbound_channel =
+ channels.iter().find(|details| details.counterparty.node_id == node_c_id).unwrap();
+ let inbound_htlc = inbound_channel
+ .pending_inbound_htlcs
+ .iter()
+ .find(|details| details.payment_hash == second_payment_hash)
+ .unwrap();
+ let outbound_htlc = outbound_channel
+ .pending_outbound_htlcs
+ .iter()
+ .find(|details| details.payment_hash == second_payment_hash)
+ .unwrap();
+ assert_eq!(outbound_htlc.htlc_id, None);
+ let inbound_reference = match &outbound_htlc.source {
+ Some(OutboundHTLCSource::Forwarded { inbound_htlc }) => inbound_htlc,
+ _ => panic!("Unexpected outbound HTLC source"),
+ };
+ assert_eq!(inbound_reference.channel_id, inbound_channel.channel_id);
+ assert_eq!(inbound_reference.htlc_id, inbound_htlc.htlc_id);
+ }
connect_blocks(&nodes[1], TEST_FINAL_CLTV - LATENCY_GRACE_PERIOD_BLOCKS);
assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
@@ -7219,7 +7245,50 @@ pub fn test_simple_mpp() {
route.paths[1].hops[1].short_channel_id = chan_4_id;
route.route_params.final_value_msat = 200_000;
let paths: &[&[_]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]];
- send_along_route_with_secret(&nodes[0], route, paths, 200_000, payment_hash, payment_secret);
+ let payment_id = send_along_route_with_secret(
+ &nodes[0],
+ route,
+ paths,
+ 200_000,
+ payment_hash,
+ payment_secret,
+ );
+
+ let locally_originated = nodes[0]
+ .node
+ .list_channels()
+ .into_iter()
+ .flat_map(|channel| channel.pending_outbound_htlcs)
+ .collect::<Vec<_>>();
+ assert_eq!(locally_originated.len(), 2);
+ assert!(locally_originated
+ .iter()
+ .all(|details| { details.source == Some(OutboundHTLCSource::Local { payment_id }) }));
+
+ let node_a_id = nodes[0].node.get_our_node_id();
+ let node_d_id = nodes[3].node.get_our_node_id();
+ let mut inbound_references = Vec::new();
+ for forwarder in [&nodes[1], &nodes[2]] {
+ let channels = forwarder.node.list_channels();
+ let inbound_channel =
+ channels.iter().find(|details| details.counterparty.node_id == node_a_id).unwrap();
+ let outbound_channel =
+ channels.iter().find(|details| details.counterparty.node_id == node_d_id).unwrap();
+ assert_eq!(inbound_channel.pending_inbound_htlcs.len(), 1);
+ assert_eq!(outbound_channel.pending_outbound_htlcs.len(), 1);
+
+ let outbound_htlc = &outbound_channel.pending_outbound_htlcs[0];
+ assert_eq!(outbound_htlc.payment_hash, payment_hash);
+ let inbound_reference = match &outbound_htlc.source {
+ Some(OutboundHTLCSource::Forwarded { inbound_htlc }) => inbound_htlc,
+ _ => panic!("Unexpected outbound HTLC source"),
+ };
+ assert_eq!(inbound_reference.channel_id, inbound_channel.channel_id);
+ assert_eq!(inbound_reference.htlc_id, inbound_channel.pending_inbound_htlcs[0].htlc_id);
+ inbound_references.push(inbound_reference.clone());
+ }
+ assert_ne!(inbound_references[0], inbound_references[1]);
+
claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], paths, payment_preimage));
}
Why this scored 20/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.