From 37e75e7ab369162b81ec2bc4556a0d228899da18 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 8 Feb 2026 23:43:07 +0000 Subject: [PATCH 1/6] Use HTLC CLTV instead of onion CLTV values for payment claim timer When we receive an HTLC as a part of a claim, we validate that the CLTV on the HTLC is >= the CLTV that the sender requested we receive, but then we use the CLTV value that the sender requested we receive as the deadline to claim the HTLC anyway. This isn't generally all that interesting (they're always the same unless the previous-hop node gave us "free CLTV"), but for trampoline payments where we're both a trampoline hop and the blinded intro point and the recipient, it means we end up allowing ourselves less claim time than we actually have. Instead, here, we just use the actual HTLC CLTV deadline. --- lightning/src/ln/blinded_payment_tests.rs | 15 +++++---------- lightning/src/ln/onion_payment.rs | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index e8469cade60..b945b8949d8 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -981,11 +981,11 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { }; let amt_msat = 5000; - let excess_final_cltv_delta_opt = if check == ReceiveCheckFail::ProcessPendingHTLCsCheck { - // Set the final CLTV expiry too low to trigger the failure in process_pending_htlc_forwards. - Some(TEST_FINAL_CLTV as u16 - 2) + let required_final_cltv = if check == ReceiveCheckFail::ProcessPendingHTLCsCheck { + // Set the final CLTV required much too high to trigger the failure in process_pending_htlc_forwards. + Some((TEST_FINAL_CLTV as u16) * 10) } else { None }; - let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), excess_final_cltv_delta_opt); + let (_, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), required_final_cltv); let mut route_params = get_blinded_route_parameters(amt_msat, payment_secret, 1, 1_0000_0000, nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2], &chanmon_cfgs[2].keys_manager); @@ -993,11 +993,7 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { route_params.payment_params.max_path_length = 17; let route = if check == ReceiveCheckFail::ProcessPendingHTLCsCheck { - let mut route = get_route(&nodes[0], &route_params).unwrap(); - // Set the final CLTV expiry too low to trigger the failure in process_pending_htlc_forwards. - route.paths[0].hops.last_mut().map(|h| h.cltv_expiry_delta += excess_final_cltv_delta_opt.unwrap() as u32); - route.paths[0].blinded_tail.as_mut().map(|bt| bt.excess_final_cltv_expiry_delta = excess_final_cltv_delta_opt.unwrap() as u32); - route + get_route(&nodes[0], &route_params).unwrap() } else if check == ReceiveCheckFail::PaymentConstraints { // Create a blinded path where the receiver's encrypted payload has an htlc_minimum_msat that is // violated by `amt_msat`, and stick it in the route_params without changing the corresponding @@ -1115,7 +1111,6 @@ fn do_multi_hop_receiver_fail(check: ReceiveCheckFail) { check_added_monitors(&nodes[2], 1); }, ReceiveCheckFail::ProcessPendingHTLCsCheck => { - assert_eq!(payment_event_1_2.msgs[0].cltv_expiry, nodes[0].best_block_info().1 + 1 + excess_final_cltv_delta_opt.unwrap() as u32 + TEST_FINAL_CLTV); nodes[2].node.handle_update_add_htlc(nodes[1].node.get_our_node_id(), &payment_event_1_2.msgs[0]); check_added_monitors(&nodes[2], 0); do_commitment_signed_dance(&nodes[2], &nodes[1], &payment_event_1_2.commitment_msg, true, true); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index def4a1861c4..5111f6982fe 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -438,7 +438,7 @@ pub(super) fn create_recv_pending_htlc_info( payment_data, payment_preimage, payment_metadata, - incoming_cltv_expiry: onion_cltv_expiry, + incoming_cltv_expiry: cltv_expiry, custom_tlvs, requires_blinded_error, has_recipient_created_payment_secret, @@ -450,7 +450,7 @@ pub(super) fn create_recv_pending_htlc_info( payment_data: data, payment_metadata, payment_context, - incoming_cltv_expiry: onion_cltv_expiry, + incoming_cltv_expiry: cltv_expiry, phantom_shared_secret, trampoline_shared_secret, custom_tlvs, @@ -842,7 +842,7 @@ mod tests { PendingHTLCRouting::ReceiveKeysend { payment_preimage, payment_data, incoming_cltv_expiry, .. } => { assert_eq!(payment_preimage, preimage); assert_eq!(peeled2.outgoing_amt_msat, recipient_amount); - assert_eq!(incoming_cltv_expiry, peeled2.outgoing_cltv_value); + assert_eq!(incoming_cltv_expiry, msg.cltv_expiry); let msgs::FinalOnionHopData{total_msat, payment_secret} = payment_data.unwrap(); assert_eq!(total_msat, total_amt_msat); assert_eq!(payment_secret, pay_secret); From 4867c309385e2db7f5210ac14757c0c2146db5cb Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 8 Feb 2026 23:45:27 +0000 Subject: [PATCH 2/6] Fix trampoline onion encoding to match doc-declared CLTV rules The docs for `RouteHop::cltv_expiry_delta` claim that it includes any trampoline hops, but the way we actually implemented onion building it did not. Because the docs described a simpler and more backwards-compatible API, we update the onion-building logic to match rather than updating the docs. --- lightning/src/ln/blinded_payment_tests.rs | 74 +++++++++++++---------- lightning/src/ln/functional_test_utils.rs | 15 ++++- lightning/src/ln/onion_route_tests.rs | 22 +++---- lightning/src/ln/onion_utils.rs | 33 ++++------ lightning/src/routing/router.rs | 4 +- 5 files changed, 81 insertions(+), 67 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index b945b8949d8..e148ce2c474 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1852,7 +1852,7 @@ fn test_combined_trampoline_onion_creation_vectors() { short_channel_id: (572330 << 40) + (42 << 16) + 2821, channel_features: ChannelFeatures::empty(), fee_msat: 153_000, - cltv_expiry_delta: 0, + cltv_expiry_delta: 24 + 36, maybe_announced_channel: false, }, ], @@ -1947,7 +1947,7 @@ fn test_trampoline_inbound_payment_decoding() { short_channel_id: (572330 << 40) + (42 << 16) + 2821, channel_features: ChannelFeatures::empty(), fee_msat: 150_153_000, - cltv_expiry_delta: 0, + cltv_expiry_delta: 24 + 36, maybe_announced_channel: false, }, ], @@ -2115,7 +2115,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { blinded_path::utils::construct_blinded_hops( &secp_ctx, path.into_iter(), &trampoline_session_priv, ) - }; + }; let route = Route { paths: vec![Path { @@ -2138,7 +2138,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: 24 + 39, maybe_announced_channel: false, } ], @@ -2149,7 +2149,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 24 + 39, }, ], hops: carol_blinded_hops, @@ -2176,7 +2176,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { }); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(amt_msat); - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, &recipient_onion_fields, 32, &None).unwrap(); + let (mut trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, &recipient_onion_fields, 32, &None).unwrap(); // pop the last dummy hop trampoline_payloads.pop(); @@ -2191,7 +2191,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { ).unwrap(); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(outer_total_msat); - let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let (outer_payloads, _, _) = onion_utils::test_build_onion_payloads(&route.paths[0], &recipient_onion_fields, 32, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, @@ -2304,7 +2304,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: 104 + 39, maybe_announced_channel: false, } ], @@ -2315,7 +2315,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 104, + cltv_expiry_delta: 104 + 39, }, ], hops: blinded_path.blinded_hops().to_vec(), @@ -2423,8 +2423,8 @@ fn test_trampoline_blinded_receive() { /// Creates a blinded tail where Carol receives via a blinded path. fn create_blinded_tail( secp_ctx: &Secp256k1, override_random_bytes: [u8; 32], carol_node_id: PublicKey, - carol_auth_key: ReceiveAuthKey, trampoline_cltv_expiry_delta: u32, final_value_msat: u64, - payment_secret: PaymentSecret, + carol_auth_key: ReceiveAuthKey, trampoline_cltv_expiry_delta: u32, + excess_final_cltv_delta: u32, final_value_msat: u64, payment_secret: PaymentSecret, ) -> BlindedTail { let outer_session_priv = SecretKey::from_slice(&override_random_bytes).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); @@ -2455,11 +2455,11 @@ fn create_blinded_tail( pubkey: carol_node_id, node_features: Features::empty(), fee_msat: final_value_msat, - cltv_expiry_delta: trampoline_cltv_expiry_delta, + cltv_expiry_delta: trampoline_cltv_expiry_delta + excess_final_cltv_delta, }], hops: carol_blinded_hops, blinding_point: carol_blinding_point, - excess_final_cltv_expiry_delta: 39, + excess_final_cltv_expiry_delta: excess_final_cltv_delta, final_value_msat, } } @@ -2468,8 +2468,9 @@ fn create_blinded_tail( // payloads that send to unblinded receives and invalid payloads. fn replacement_onion( test_case: TrampolineTestCase, secp_ctx: &Secp256k1, override_random_bytes: [u8; 32], - route: Route, original_amt_msat: u64, starting_htlc_offset: u32, original_trampoline_cltv: u32, - payment_hash: PaymentHash, payment_secret: PaymentSecret, blinded: bool, + route: Route, original_amt_msat: u64, starting_htlc_offset: u32, excess_final_cltv: u32, + original_trampoline_cltv: u32, payment_hash: PaymentHash, payment_secret: PaymentSecret, + blinded: bool, ) -> msgs::OnionPacket { let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); @@ -2480,8 +2481,8 @@ fn replacement_onion( // Rebuild our trampoline packet from the original route. If we want to test Carol receiving // as an unblinded trampoline hop, we switch out her inner trampoline onion with a direct // receive payload because LDK doesn't support unblinded trampoline receives. - let (trampoline_packet, outer_total_msat, outer_starting_htlc_offset) = { - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = + let (trampoline_packet, outer_total_msat) = { + let (mut trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads( &blinded_tail, &recipient_onion_fields, @@ -2497,7 +2498,9 @@ fn replacement_onion( total_msat: original_amt_msat, }), sender_intended_htlc_amt_msat: original_amt_msat, - cltv_expiry_height: original_trampoline_cltv + starting_htlc_offset, + cltv_expiry_height: original_trampoline_cltv + + starting_htlc_offset + + excess_final_cltv, }]; } @@ -2515,7 +2518,7 @@ fn replacement_onion( ) .unwrap(); - (trampoline_packet, outer_total_msat, outer_starting_htlc_offset) + (trampoline_packet, outer_total_msat) }; // Use a different session key to construct the replacement onion packet. Note that the @@ -2524,7 +2527,7 @@ fn replacement_onion( let (mut outer_payloads, _, _) = onion_utils::test_build_onion_payloads( &route.paths[0], &recipient_onion_fields, - outer_starting_htlc_offset, + starting_htlc_offset, &None, None, Some(trampoline_packet), @@ -2542,7 +2545,7 @@ fn replacement_onion( .. } => { *amt_to_forward = test_case.outer_onion_amt(original_amt_msat); - let outer_cltv = original_trampoline_cltv + starting_htlc_offset; + let outer_cltv = original_trampoline_cltv + starting_htlc_offset + excess_final_cltv; *outgoing_cltv_value = test_case.outer_onion_cltv(outer_cltv); }, _ => panic!("final payload is not trampoline entrypoint"), @@ -2577,11 +2580,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { let alice_bob_chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); let bob_carol_chan = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let starting_htlc_offset = (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1; for i in 0..TOTAL_NODE_COUNT { - connect_blocks( - &nodes[i], - (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1, - ); + connect_blocks(&nodes[i], starting_htlc_offset - nodes[i].best_block_info().1); } let alice_node_id = nodes[0].node.get_our_node_id(); @@ -2592,8 +2593,11 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { let bob_carol_scid = get_scid_from_channel_id(&nodes[1], bob_carol_chan.2); let original_amt_msat = 1000; - let original_trampoline_cltv = 72; - let starting_htlc_offset = 32; + // Note that for TrampolineTestCase::OuterCLTVLessThanTrampoline to work properly, + // (starting_htlc_offset + excess_final_cltv) / 2 < (starting_htlc_offset + excess_final_cltv + original_trampoline_cltv) + // otherwise dividing the CLTV value by 2 won't kick us under the outer trampoline CLTV. + let original_trampoline_cltv = 42; + let excess_final_cltv = 70; let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(original_amt_msat), None); @@ -2620,7 +2624,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: original_trampoline_cltv + excess_final_cltv, maybe_announced_channel: false, }, ], @@ -2633,6 +2637,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { carol_node_id, nodes[2].keys_manager.get_receive_auth_key(), original_trampoline_cltv, + excess_final_cltv, original_amt_msat, payment_secret, )), @@ -2675,6 +2680,7 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { original_amt_msat, starting_htlc_offset, original_trampoline_cltv, + excess_final_cltv, payment_hash, payment_secret, blinded, @@ -2691,8 +2697,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { ); let amt_bytes = test_case.outer_onion_amt(original_amt_msat).to_be_bytes(); - let cltv_bytes = - test_case.outer_onion_cltv(original_trampoline_cltv + starting_htlc_offset).to_be_bytes(); + let cltv_bytes = test_case + .outer_onion_cltv(original_trampoline_cltv + starting_htlc_offset + excess_final_cltv) + .to_be_bytes(); let payment_failure = test_case.payment_failed_conditions(&amt_bytes, &cltv_bytes).map(|p| { if blinded { PaymentFailedConditions::new() @@ -2706,7 +2713,8 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) { .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) } else { - args.with_payment_secret(payment_secret) + let htlc_cltv = starting_htlc_offset + original_trampoline_cltv + excess_final_cltv; + args.with_payment_secret(payment_secret).with_payment_claimable_cltv(htlc_cltv) }; do_pass_along_path(args); @@ -2792,7 +2800,7 @@ fn test_trampoline_forward_rejection() { short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), fee_msat: 0, - cltv_expiry_delta: 48, + cltv_expiry_delta: 24 + 24 + 39, maybe_announced_channel: false, } ], @@ -2811,7 +2819,7 @@ fn test_trampoline_forward_rejection() { pubkey: alice_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 24 + 39, }, ], hops: vec![BlindedHop{ diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 16616e5077c..680a0d98d1b 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -11,7 +11,7 @@ //! nodes for functional tests. use crate::blinded_path::payment::DummyTlvs; -use crate::chain::channelmonitor::ChannelMonitor; +use crate::chain::channelmonitor::{ChannelMonitor, HTLC_FAIL_BACK_BUFFER}; use crate::chain::transaction::OutPoint; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; @@ -3490,6 +3490,7 @@ pub struct PassAlongPathArgs<'a, 'b, 'c, 'd> { pub custom_tlvs: Vec<(u64, Vec)>, pub payment_metadata: Option>, pub expected_failure: Option, + pub payment_claimable_cltv: Option, } impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { @@ -3512,6 +3513,7 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { custom_tlvs: Vec::new(), payment_metadata: None, expected_failure: None, + payment_claimable_cltv: None, } } pub fn without_clearing_recipient_events(mut self) -> Self { @@ -3552,6 +3554,10 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { self.dummy_tlvs = dummy_tlvs.to_vec(); self } + pub fn with_payment_claimable_cltv(mut self, cltv: u32) -> Self { + self.payment_claimable_cltv = Some(cltv); + self + } } pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option { @@ -3570,6 +3576,7 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option custom_tlvs, payment_metadata, expected_failure, + payment_claimable_cltv, } = args; let mut payment_event = SendEvent::from_event(ev); @@ -3685,6 +3692,12 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option assert_eq!(*user_chan_id, Some(chan.user_channel_id)); } assert!(claim_deadline.unwrap() > node.best_block_info().1); + if let Some(expected_cltv) = payment_claimable_cltv { + assert_eq!( + claim_deadline.unwrap(), + expected_cltv - HTLC_FAIL_BACK_BUFFER, + ); + } }, _ => panic!("Unexpected event"), } diff --git a/lightning/src/ln/onion_route_tests.rs b/lightning/src/ln/onion_route_tests.rs index ceb930014ff..019d8faf98c 100644 --- a/lightning/src/ln/onion_route_tests.rs +++ b/lightning/src/ln/onion_route_tests.rs @@ -1918,7 +1918,7 @@ fn test_trampoline_onion_payload_assembly_values() { short_channel_id: (572330 << 40) + (42 << 16) + 2821, channel_features: ChannelFeatures::empty(), fee_msat: 153_000, - cltv_expiry_delta: 0, + cltv_expiry_delta: 36 + 24, // Last hop should include the CLTV of the trampoline hops maybe_announced_channel: false, }, ], @@ -1974,17 +1974,15 @@ fn test_trampoline_onion_payload_assembly_values() { SecretKey::from_slice(&>::from_hex(SECRET_HEX).unwrap()).unwrap().secret_bytes(), ); let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret, amt_msat); - let (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = - onion_utils::build_trampoline_onion_payloads( - &path.blinded_tail.as_ref().unwrap(), - &recipient_onion_fields, - cur_height, - &None, - ) - .unwrap(); + let (trampoline_payloads, outer_total_msat) = onion_utils::build_trampoline_onion_payloads( + &path.blinded_tail.as_ref().unwrap(), + &recipient_onion_fields, + cur_height, + &None, + ) + .unwrap(); assert_eq!(trampoline_payloads.len(), 3); assert_eq!(outer_total_msat, 150_153_000); - assert_eq!(outer_starting_htlc_offset, 800_060); let trampoline_carol_payload = &trampoline_payloads[0]; let trampoline_dave_payload = &trampoline_payloads[1]; @@ -2042,7 +2040,7 @@ fn test_trampoline_onion_payload_assembly_values() { let (outer_payloads, total_msat, total_htlc_offset) = test_build_onion_payloads( &path, &recipient_onion_fields, - outer_starting_htlc_offset, + cur_height, &None, None, Some(trampoline_packet), @@ -2067,7 +2065,7 @@ fn test_trampoline_onion_payload_assembly_values() { outer_bob_payload { assert_eq!(amt_to_forward, &150_153_000); - assert_eq!(outgoing_cltv_value, &800_084); + assert_eq!(outgoing_cltv_value, &800_060); } else { panic!("Bob payload must be Forward"); } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index a95012dc7f2..5c003680ed1 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -416,7 +416,7 @@ pub(super) fn construct_trampoline_onion_keys( pub(super) fn build_trampoline_onion_payloads<'a>( blinded_tail: &'a BlindedTail, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, keysend_preimage: &Option, -) -> Result<(Vec>, u64, u32), APIError> { +) -> Result<(Vec>, u64), APIError> { let mut res: Vec = Vec::with_capacity(blinded_tail.trampoline_hops.len() + blinded_tail.hops.len()); let blinded_tail_with_hop_iter = BlindedTailDetails::DirectEntry { @@ -426,7 +426,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( excess_final_cltv_expiry_delta: blinded_tail.excess_final_cltv_expiry_delta, }; - let (value_msat, cltv) = build_onion_payloads_callback( + let (value_msat, _) = build_onion_payloads_callback( blinded_tail.trampoline_hops.iter(), Some(blinded_tail_with_hop_iter), recipient_onion, @@ -438,7 +438,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( PayloadCallbackAction::PushFront => res.insert(0, payload), }, )?; - Ok((res, value_msat, cltv)) + Ok((res, value_msat)) } /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. @@ -539,11 +539,7 @@ where // exactly as it should be (and the next hop isn't trying to probe to find out if we're // the intended recipient). let value_msat = if cur_value_msat == 0 { hop.fee_msat() } else { cur_value_msat }; - let cltv = if cur_cltv == starting_htlc_offset { - hop.cltv_expiry_delta().saturating_add(starting_htlc_offset) - } else { - cur_cltv - }; + let cltv = hop.cltv_expiry_delta().saturating_add(cur_cltv); if idx == 0 { match blinded_tail.take() { Some(BlindedTailDetails::DirectEntry { @@ -591,7 +587,7 @@ where PayloadCallbackAction::PushBack, OP::new_trampoline_entry( final_value_msat + hop.fee_msat(), - cur_cltv, + cltv, &recipient_onion, trampoline_packet, )?, @@ -610,7 +606,7 @@ where err: "Next hop ID must be known for non-final hops".to_string(), })?, value_msat, - cltv, + cur_cltv, ); callback(PayloadCallbackAction::PushFront, payload); } @@ -2638,8 +2634,6 @@ pub(crate) fn create_payment_onion_internal( prng_seed: [u8; 32], trampoline_session_priv_override: Option, trampoline_prng_seed_override: Option<[u8; 32]>, ) -> Result<(msgs::OnionPacket, u64, u32), APIError> { - let mut outer_starting_htlc_offset = cur_block_height; - // If we're paying to a recipient through a trampoline, we use the `payment_secret` provided in // `recipient_onion` as the MPP identifier for the trampoline entry point, allowing it to // detect when when it has received all the MPP parts. @@ -2661,13 +2655,12 @@ pub(crate) fn create_payment_onion_internal( if !blinded_tail.trampoline_hops.is_empty() { let trampoline_payloads; let outer_total_msat; - (trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = - build_trampoline_onion_payloads( - &blinded_tail, - recipient_onion, - cur_block_height, - keysend_preimage, - )?; + (trampoline_payloads, outer_total_msat) = build_trampoline_onion_payloads( + &blinded_tail, + recipient_onion, + cur_block_height, + keysend_preimage, + )?; trampoline_outer_onion.total_mpp_amount_msat = outer_total_msat; let trampoline_session_priv = trampoline_session_priv_override @@ -2698,7 +2691,7 @@ pub(crate) fn create_payment_onion_internal( let (onion_payloads, htlc_msat, htlc_cltv) = build_onion_payloads( &path, outer_onion, - outer_starting_htlc_offset, + cur_block_height, keysend_preimage, invoice_request, trampoline_packet_option, diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 75c6a05a86d..ee08f9edca9 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -512,6 +512,7 @@ pub struct RouteHop { /// to reach this node. pub channel_features: ChannelFeatures, /// The fee taken on this hop (for paying for the use of the *next* channel in the path). + /// /// If this is the last hop in [`Path::hops`]: /// * if we're sending to a [`BlindedPaymentPath`], this is the fee paid for use of the entire /// blinded path (including any Trampoline hops) @@ -557,8 +558,9 @@ pub struct TrampolineHop { /// the entire blinded path. pub fee_msat: u64, /// The CLTV delta added for this hop. + /// /// If this is the last Trampoline hop within [`BlindedTail`], this is the CLTV delta for the entire - /// blinded path. + /// blinded path (including the [`BlindedTail::excess_final_cltv_expiry_delta`]). pub cltv_expiry_delta: u32, } From ec8580b0df2e9cb35b9b6fd62e0b40dce25df0c1 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Feb 2026 02:00:11 +0000 Subject: [PATCH 3/6] Clarify CLTV value selection in the first blinded hop marginally --- lightning/src/ln/onion_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 5c003680ed1..a74d5fe11d3 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -559,7 +559,7 @@ where OP::new_blinded_receive( final_value_msat, recipient_onion.total_mpp_amount_msat, - cur_cltv + excess_final_cltv_expiry_delta, + starting_htlc_offset + excess_final_cltv_expiry_delta, &blinded_hop.encrypted_payload, blinding_point.take(), *keysend_preimage, From 5ce6e42b03b192fe2a3f52c9be6d2709f9257c16 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Feb 2026 02:00:37 +0000 Subject: [PATCH 4/6] Add a `Path::total_cltv_expiry_delta` accessor --- lightning/src/ln/onion_utils.rs | 1 + lightning/src/routing/router.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index a74d5fe11d3..ffb4f4cfa99 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2696,6 +2696,7 @@ pub(crate) fn create_payment_onion_internal( invoice_request, trampoline_packet_option, )?; + debug_assert_eq!(htlc_cltv - cur_block_height, path.total_cltv_expiry_delta()); let onion_keys = construct_onion_keys(&secp_ctx, &path, session_priv); let onion_packet = construct_onion_packet(onion_payloads, onion_keys, prng_seed, payment_hash) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index ee08f9edca9..97f9871444d 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -644,6 +644,12 @@ impl Path { } } + /// Gets the total CLTV expiry delta which will be added to the current block height (plus some + /// extra headroom) when sending the HTLC + pub fn total_cltv_expiry_delta(&self) -> u32 { + self.hops.iter().map(|hop| hop.cltv_expiry_delta).sum() + } + /// True if this [`Path`] has at least one Trampoline hop. pub fn has_trampoline_hops(&self) -> bool { self.blinded_tail.as_ref().is_some_and(|bt| !bt.trampoline_hops.is_empty()) From 54be6eff97e7f9f199c4dfbeca07c39f373b7976 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 9 Feb 2026 02:00:58 +0000 Subject: [PATCH 5/6] Validate CLTV somewhat in `Route::debug_assert_route_meets_params` Now that we've cleaned up trampoline CLTV building and added `Path::total_cltv_expiry_delta`, we can use both to do some basic validation of CLTV values on blinded tails in `Route::debug_assert_route_meets_params` --- lightning/src/ln/htlc_reserve_unit_tests.rs | 3 +- lightning/src/ln/onion_utils.rs | 11 ++++-- lightning/src/routing/router.rs | 43 +++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index 6f02c936cff..d88b9a2dc3f 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -1429,9 +1429,10 @@ pub fn test_update_add_htlc_bolt2_sender_cltv_expiry_too_high() { let _chan = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1000000, 0); - let payment_params = PaymentParameters::from_node_id(node_b_id, 0) + let mut payment_params = PaymentParameters::from_node_id(node_b_id, 0) .with_bolt11_features(nodes[1].node.bolt11_invoice_features()) .unwrap(); + payment_params.max_total_cltv_expiry_delta = 500000001; let (mut route, our_payment_hash, _, our_payment_secret) = get_route_and_payment_hash!(nodes[0], nodes[1], payment_params, 100000000); route.paths[0].hops.last_mut().unwrap().cltv_expiry_delta = 500000001; diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index ffb4f4cfa99..099690ed33e 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -539,8 +539,8 @@ where // exactly as it should be (and the next hop isn't trying to probe to find out if we're // the intended recipient). let value_msat = if cur_value_msat == 0 { hop.fee_msat() } else { cur_value_msat }; - let cltv = hop.cltv_expiry_delta().saturating_add(cur_cltv); if idx == 0 { + let declared_incoming_cltv = hop.cltv_expiry_delta().saturating_add(cur_cltv); match blinded_tail.take() { Some(BlindedTailDetails::DirectEntry { blinding_point, @@ -587,7 +587,7 @@ where PayloadCallbackAction::PushBack, OP::new_trampoline_entry( final_value_msat + hop.fee_msat(), - cltv, + declared_incoming_cltv, &recipient_onion, trampoline_packet, )?, @@ -596,7 +596,12 @@ where None => { callback( PayloadCallbackAction::PushBack, - OP::new_receive(&recipient_onion, *keysend_preimage, value_msat, cltv)?, + OP::new_receive( + &recipient_onion, + *keysend_preimage, + value_msat, + declared_incoming_cltv, + )?, ); }, } diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 97f9871444d..90697ad246e 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -725,6 +725,17 @@ impl Route { return Err(()); } + let total_cltv_delta = path.total_cltv_expiry_delta(); + if total_cltv_delta > route_params.payment_params.max_total_cltv_expiry_delta { + let err = format!( + "Path had a total CLTV of {total_cltv_delta} which is greater than the maximum we're allowed {}", + route_params.payment_params.max_total_cltv_expiry_delta, + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + return Err(()); + } + if path.hops.len() > route_params.payment_params.max_path_length.into() { let err = format!( "Path had a length of {}, which is greater than the maximum we're allowed ({})", @@ -737,6 +748,38 @@ impl Route { // This is a bug, but there's not a material safety risk to making this // payment, so we don't bother to error here. } + + if let Some(tail) = &path.blinded_tail { + let trampoline_cltv_sum: u32 = + tail.trampoline_hops.iter().map(|hop| hop.cltv_expiry_delta).sum(); + let last_hop_cltv_delta = path.hops.last().unwrap().cltv_expiry_delta; + if trampoline_cltv_sum > last_hop_cltv_delta { + let err = format!( + "Path had a total trampoline CLTV of {trampoline_cltv_sum}, which is less than the total last-hop CLTV delta of {last_hop_cltv_delta}" + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + } + let last_trampoline_cltv_opt = + tail.trampoline_hops.last().map(|h| h.cltv_expiry_delta); + let last_trampoline_cltv = last_trampoline_cltv_opt.unwrap_or(u32::MAX); + if tail.excess_final_cltv_expiry_delta > last_trampoline_cltv { + let err = format!( + "Last trampoline CLTV of {last_trampoline_cltv} is less than the excess blinded path cltv of {}", + tail.excess_final_cltv_expiry_delta + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + } + if tail.excess_final_cltv_expiry_delta > last_hop_cltv_delta { + let err = format!( + "Last path hop CLTV of {last_hop_cltv_delta} is less than the excess blinded path cltv of {}", + tail.excess_final_cltv_expiry_delta + ); + debug_assert!(false, "{}", err); + log_error!(logger, "{}", err); + } + } } // Test that we don't contain any "extra" MPP parts - while we're allowed to overshoot From e39437db94a50f46fb0a10a6e54e2862c2a757ff Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 10 Feb 2026 12:57:40 +0000 Subject: [PATCH 6/6] Rename `starting_htlc_offset` `cur_block_height` in onion building Now that we are consistently using the `RouteHop::cltv_expiry_delta` as the last hop's starting CLTV rather than summing trampoline hops, `starting_htlc_offset` is a bit confusing - its actually always the current block height. Thus, here we rename it. --- lightning/src/ln/onion_utils.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 099690ed33e..9b1b009e93a 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -415,7 +415,7 @@ pub(super) fn construct_trampoline_onion_keys( pub(super) fn build_trampoline_onion_payloads<'a>( blinded_tail: &'a BlindedTail, recipient_onion: &'a RecipientOnionFields, - starting_htlc_offset: u32, keysend_preimage: &Option, + cur_block_height: u32, keysend_preimage: &Option, ) -> Result<(Vec>, u64), APIError> { let mut res: Vec = Vec::with_capacity(blinded_tail.trampoline_hops.len() + blinded_tail.hops.len()); @@ -430,7 +430,7 @@ pub(super) fn build_trampoline_onion_payloads<'a>( blinded_tail.trampoline_hops.iter(), Some(blinded_tail_with_hop_iter), recipient_onion, - starting_htlc_offset, + cur_block_height, keysend_preimage, None, |action, payload| match action { @@ -444,14 +444,14 @@ pub(super) fn build_trampoline_onion_payloads<'a>( /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. #[cfg(any(test, feature = "_externalize_tests"))] pub(crate) fn test_build_onion_payloads<'a>( - path: &'a Path, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + path: &'a Path, recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, trampoline_packet: Option, ) -> Result<(Vec>, u64, u32), APIError> { build_onion_payloads( path, recipient_onion, - starting_htlc_offset, + cur_block_height, keysend_preimage, invoice_request, trampoline_packet, @@ -460,7 +460,7 @@ pub(crate) fn test_build_onion_payloads<'a>( /// returns the hop data, as well as the first-hop value_msat and CLTV value we should send. fn build_onion_payloads<'a>( - path: &'a Path, recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + path: &'a Path, recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, trampoline_packet: Option, ) -> Result<(Vec>, u64, u32), APIError> { @@ -490,7 +490,7 @@ fn build_onion_payloads<'a>( path.hops.iter(), blinded_tail_with_hop_iter, recipient_onion, - starting_htlc_offset, + cur_block_height, keysend_preimage, invoice_request, |action, payload| match action { @@ -520,7 +520,7 @@ enum PayloadCallbackAction { } fn build_onion_payloads_callback<'a, 'b, H, B, F, OP>( hops: H, mut blinded_tail: Option>, - recipient_onion: &'a RecipientOnionFields, starting_htlc_offset: u32, + recipient_onion: &'a RecipientOnionFields, cur_block_height: u32, keysend_preimage: &Option, invoice_request: Option<&'a InvoiceRequest>, mut callback: F, ) -> Result<(u64, u32), APIError> @@ -531,7 +531,7 @@ where OP: OnionPayload<'a, 'b, ReceiveType = OP>, { let mut cur_value_msat = 0u64; - let mut cur_cltv = starting_htlc_offset; + let mut cur_cltv = cur_block_height; let mut last_hop_id = None; for (idx, hop) in hops.rev().enumerate() { @@ -559,7 +559,7 @@ where OP::new_blinded_receive( final_value_msat, recipient_onion.total_mpp_amount_msat, - starting_htlc_offset + excess_final_cltv_expiry_delta, + cur_block_height + excess_final_cltv_expiry_delta, &blinded_hop.encrypted_payload, blinding_point.take(), *keysend_preimage,