Expose the outgoing HTLC's CLTV expiry in `Event::HTLCIntercepted`
What changed, and why it matters
This commit adds a new piece of information—the outgoing HTLC's CLTV expiry block height—to the Event::HTLCIntercepted event in the Lightning Dev Kit. This lets developers who intercept payments inspect when the forwarded HTLC would time out, so they can validate it before forwarding. It is a feature/API enhancement, not a fix for an active vulnerability.
No immediate security action required. This is an API enhancement. Developers using HTLC interception should consider validating outgoing_htlc_expiry_block_height before forwarding, as now enabled by this change.
Security signals we found
Adds defensive visibility into HTLC timeout parameters for intercepting nodes
Backward-compatible TLV serialization of new optional field
No logic change to forwarding, validation, or channel state machine
Commit message references prior documentation about CLTV validation
Evidence from the diff
The change exposes pending_add.forward_info.outgoing_cltv_value as outgoing_htlc_expiry_block_height in Event::HTLCIntercepted. It updates the event struct, serialization (Writeable), deserialization (MaybeReadable), and a test to verify the value matches the expected CLTV expiry. The field is optional (Option
Changed components
lightning/src/events/mod.rslightning/src/ln/channelmanager.rslightning/src/ln/payment_tests.rsInspect captured patch +17 / −0
diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs
index 277ce61..b029caa 100644
--- a/lightning/src/events/mod.rs
+++ b/lightning/src/events/mod.rs
@@ -1287,6 +1287,13 @@ pub enum Event {
/// [`Self::HTLCIntercepted::inbound_amount_msat`]) or subtract it as required. Further,
/// LDK will not stop you from forwarding more than you received.
expected_outbound_amount_msat: u64,
+ /// The block height at which the forwarded HTLC sent to our peer will time out. In
+ /// practice, LDK will refuse to forward an HTLC several blocks before this height (as if
+ /// we attempted to forward an HTLC at this height we'd run some risk that our peer
+ /// force-closes the channel immediately).
+ ///
+ /// This will only be `None` for events generated or serialized by LDK 0.2 or prior.
+ outgoing_htlc_expiry_block_height: Option<u32>,
},
/// Used to indicate that an output which you should know how to spend was confirmed on chain
/// and is now spendable.
@@ -2017,11 +2024,13 @@ impl Writeable for Event {
inbound_amount_msat,
expected_outbound_amount_msat,
intercept_id,
+ outgoing_htlc_expiry_block_height,
} => {
6u8.write(writer)?;
let intercept_scid = InterceptNextHop::FakeScid { requested_next_hop_scid };
write_tlv_fields!(writer, {
(0, intercept_id, required),
+ (1, outgoing_htlc_expiry_block_height, option),
(2, intercept_scid, required),
(4, payment_hash, required),
(6, inbound_amount_msat, required),
@@ -2526,8 +2535,10 @@ impl MaybeReadable for Event {
InterceptNextHop::FakeScid { requested_next_hop_scid: 0 };
let mut inbound_amount_msat = 0;
let mut expected_outbound_amount_msat = 0;
+ let mut outgoing_htlc_expiry_block_height = None;
read_tlv_fields!(reader, {
(0, intercept_id, required),
+ (1, outgoing_htlc_expiry_block_height, option),
(2, requested_next_hop_scid, required),
(4, payment_hash, required),
(6, inbound_amount_msat, required),
@@ -2542,6 +2553,7 @@ impl MaybeReadable for Event {
inbound_amount_msat,
expected_outbound_amount_msat,
intercept_id,
+ outgoing_htlc_expiry_block_height,
}))
},
7u8 => {
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index eeb5a53..fd5e5d1 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -3409,6 +3409,7 @@ fn create_htlc_intercepted_event(
inbound_amount_msat,
expected_outbound_amount_msat: pending_add.forward_info.outgoing_amt_msat,
intercept_id,
+ outgoing_htlc_expiry_block_height: Some(pending_add.forward_info.outgoing_cltv_value),
})
}
diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs
index 8ac87fb..1444623 100644
--- a/lightning/src/ln/payment_tests.rs
+++ b/lightning/src/ln/payment_tests.rs
@@ -2277,6 +2277,7 @@ fn do_test_intercepted_payment(test: InterceptTest) {
// Check that we generate the PaymentIntercepted event when an intercept forward is detected.
let events = nodes[1].node.get_and_clear_pending_events();
assert_eq!(events.len(), 1);
+ let expected_cltv = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + 1;
let (intercept_id, outbound_amt) = match events[0] {
crate::events::Event::HTLCIntercepted {
intercept_id,
@@ -2284,10 +2285,12 @@ fn do_test_intercepted_payment(test: InterceptTest) {
payment_hash,
inbound_amount_msat,
requested_next_hop_scid: short_channel_id,
+ outgoing_htlc_expiry_block_height,
} => {
assert_eq!(payment_hash, hash);
assert_eq!(inbound_amount_msat, route.get_total_amount() + route.get_total_fees());
assert_eq!(short_channel_id, intercept_scid);
+ assert_eq!(outgoing_htlc_expiry_block_height.unwrap(), expected_cltv);
(intercept_id, expected_outbound_amount_msat)
},
_ => panic!(),
@@ -2356,6 +2359,7 @@ fn do_test_intercepted_payment(test: InterceptTest) {
assert_eq!(events.len(), 1);
SendEvent::from_event(events.remove(0))
};
+ assert_eq!(payment_event.msgs[0].cltv_expiry, expected_cltv);
nodes[2].node.handle_update_add_htlc(node_b_id, &payment_event.msgs[0]);
let commitment = &payment_event.commitment_msg;
do_commitment_signed_dance(&nodes[2], &nodes[1], commitment, false, true);
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.