Validate CLTV somewhat in `Route::debug_assert_route_meets_params`
What changed, and why it matters
This commit adds extra sanity checks inside a Lightning payment routing library to catch cases where time-lock (CLTV) values on payment paths are internally inconsistent. The checks live in a debug-only assertion helper, so they do not change normal runtime behavior; they mainly help developers detect bugs during testing. The patch also fixes one unit test that previously relied on an unrealistic CLTV value and adjusts how the first-hop CLTV is computed when building onion payloads for trampoline/blinded routes.
Treat as a defensive hardening/debug-assertion improvement rather than an active vulnerability fix. Review whether these checks should eventually be promoted from debug_assert to runtime errors, especially for blinded/trampoline paths where CLTV inconsistencies could affect payment safety. No urgent patch or incident response is indicated by the commit alone.
Security signals we found
New CLTV consistency assertions in route validation
Blinded path / trampoline CLTV delta checks
Addition of max_total_cltv_expiry_delta enforcement in debug assertion
Unit test adjustment to accommodate new validation
Onion payload CLTV computation refactor for first hop only
Evidence from the diff
The change extends Route::debug_assert_route_meets_params to validate: (1) the path’s total CLTV delta does not exceed the user-configured max_total_cltv_expiry_delta; (2) on blinded tails, the sum of trampoline-hop CLTV deltas is not greater than the last unblinded hop’s CLTV delta; (3) the excess final CLTV delta on the blinded tail is not greater than the last trampoline hop’s CLTV delta nor the last path hop’s CLTV delta. Violations trigger debug_assert!(false) and an error log, returning Err(()) from the assertion helper. A unit test was updated to raise max_total_cltv_expiry_delta so its intentionally large hop CLTV no longer trips the new check. onion_utils.rs was also touched to compute declared_incoming_cltv only for the first hop and use it consistently for receive/trampoline entry payloads.
Changed components
lightning/src/routing/router.rslightning/src/ln/onion_utils.rslightning/src/ln/htlc_reserve_unit_tests.rsInspect captured patch +53 / −4
diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs
index 6f02c93..d88b9a2 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 ffb4f4c..099690e 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 97f9871..90697ad 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
Why this scored 36/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.