Introduce Dummy Hop support in Blinded Path Constructor
What changed, and why it matters
This commit adds a new internal-only constructor for creating private payment routes that can include fake 'dummy hops' between the sender and the real recipient. The goal is to make it harder for outside observers to guess how far apart two users are or to identify the final recipient. It is a privacy enhancement, not a fix for an active security bug. The new API is marked pub(crate), meaning it is not exposed to outside users yet, and the commit message includes a TODO noting that end-to-end tests are still needed before it becomes public.
No immediate security action required. Treat as a normal feature/refactor commit. If reviewing for release readiness, ensure the TODO for end-to-end tests covering fee aggregation, CLTV deltas, and HTLC bounds with dummy hops is completed before the API is made public.
Security signals we found
Adds privacy-oriented dummy/blinded hops to payment path construction
New API is currently pub(crate) and not publicly exposed
Commit message explicitly notes missing end-to-end tests before public exposure
Adds debug-only invariant check for uniform intermediate hop serialized size
No mention of vulnerability, CVE, security bug, or incident in commit or supplied references
Evidence from the diff
The change refactors BlindedPaymentPath::new into a shared new_inner helper and adds new_with_dummy_hops, which accepts a slice of DummyTlvs. These dummy TLVs are inserted into the blinded hop chain alongside real intermediate nodes, using the payee’s own node_id and receive key as the hop identity. compute_payinfo is updated to aggregate dummy-hop fees and CLTV expiry deltas into the BlindedPayInfo, and a debug-only assertion checks that all non-final hops serialize to the same length. Existing tests are updated to pass an empty dummy_tlvs slice. No external security advisory, CVE, or researcher attribution is present in the commit or supplied references.
Changed components
lightning/src/blinded_path/payment.rsBlindedPaymentPath constructorcompute_payinfo fee/CLTV aggregationblinded_hops path constructionInspect captured patch +112 / −7
diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs
index 549eb38..f0bf3f9 100644
--- a/lightning/src/blinded_path/payment.rs
+++ b/lightning/src/blinded_path/payment.rs
@@ -121,6 +121,61 @@ impl BlindedPaymentPath {
local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64,
min_final_cltv_expiry_delta: u16, entropy_source: ES, secp_ctx: &Secp256k1<T>,
) -> Result<Self, ()>
+ where
+ ES::Target: EntropySource,
+ {
+ BlindedPaymentPath::new_inner(
+ intermediate_nodes,
+ payee_node_id,
+ local_node_receive_key,
+ &[],
+ payee_tlvs,
+ htlc_maximum_msat,
+ min_final_cltv_expiry_delta,
+ entropy_source,
+ secp_ctx,
+ )
+ }
+
+ /// Same as [`BlindedPaymentPath::new`], but allows specifying a number of dummy hops.
+ ///
+ /// Dummy TLVs allow callers to override the payment relay values used for dummy hops.
+ /// Any additional fees introduced by these dummy hops are ultimately paid to the final
+ /// recipient as part of the total amount.
+ ///
+ /// This improves privacy by making path-length analysis based on fee and CLTV delta
+ /// values less reliable.
+ ///
+ /// TODO: Add end-to-end tests validating fee aggregation, CLTV deltas, and
+ /// HTLC bounds when dummy hops are present, before exposing this API publicly.
+ pub(crate) fn new_with_dummy_hops<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
+ intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey,
+ dummy_tlvs: &[DummyTlvs], local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs,
+ htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, entropy_source: ES,
+ secp_ctx: &Secp256k1<T>,
+ ) -> Result<Self, ()>
+ where
+ ES::Target: EntropySource,
+ {
+ BlindedPaymentPath::new_inner(
+ intermediate_nodes,
+ payee_node_id,
+ local_node_receive_key,
+ dummy_tlvs,
+ payee_tlvs,
+ htlc_maximum_msat,
+ min_final_cltv_expiry_delta,
+ entropy_source,
+ secp_ctx,
+ )
+ }
+
+ fn new_inner<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
+ intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey,
+ local_node_receive_key: ReceiveAuthKey, dummy_tlvs: &[DummyTlvs], payee_tlvs: ReceiveTlvs,
+ htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, entropy_source: ES,
+ secp_ctx: &Secp256k1<T>,
+ ) -> Result<Self, ()>
where
ES::Target: EntropySource,
{
@@ -133,6 +188,7 @@ impl BlindedPaymentPath {
let blinded_payinfo = compute_payinfo(
intermediate_nodes,
+ dummy_tlvs,
&payee_tlvs,
htlc_maximum_msat,
min_final_cltv_expiry_delta,
@@ -145,6 +201,7 @@ impl BlindedPaymentPath {
secp_ctx,
intermediate_nodes,
payee_node_id,
+ dummy_tlvs,
payee_tlvs,
&blinding_secret,
local_node_receive_key,
@@ -394,6 +451,7 @@ pub(crate) enum BlindedTrampolineTlvs {
}
// Used to include forward and receive TLVs in the same iterator for encoding.
+#[derive(Clone)]
enum BlindedPaymentTlvsRef<'a> {
Forward(&'a ForwardTlvs),
Dummy(&'a DummyTlvs),
@@ -679,21 +737,46 @@ pub(crate) const PAYMENT_PADDING_ROUND_OFF: usize = 30;
/// Construct blinded payment hops for the given `intermediate_nodes` and payee info.
pub(super) fn blinded_hops<T: secp256k1::Signing + secp256k1::Verification>(
secp_ctx: &Secp256k1<T>, intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey,
- payee_tlvs: ReceiveTlvs, session_priv: &SecretKey, local_node_receive_key: ReceiveAuthKey,
+ dummy_tlvs: &[DummyTlvs], payee_tlvs: ReceiveTlvs, session_priv: &SecretKey,
+ local_node_receive_key: ReceiveAuthKey,
) -> Vec<BlindedHop> {
let pks = intermediate_nodes
.iter()
.map(|node| (node.node_id, None))
+ .chain(dummy_tlvs.iter().map(|_| (payee_node_id, Some(local_node_receive_key))))
.chain(core::iter::once((payee_node_id, Some(local_node_receive_key))));
let tlvs = intermediate_nodes
.iter()
.map(|node| BlindedPaymentTlvsRef::Forward(&node.tlvs))
+ .chain(dummy_tlvs.iter().map(|tlvs| BlindedPaymentTlvsRef::Dummy(tlvs)))
.chain(core::iter::once(BlindedPaymentTlvsRef::Receive(&payee_tlvs)));
let path = pks.zip(
tlvs.map(|tlv| BlindedPathWithPadding { tlvs: tlv, round_off: PAYMENT_PADDING_ROUND_OFF }),
);
+ // Debug invariant: all non-final hops must have identical serialized size.
+ #[cfg(debug_assertions)]
+ {
+ let mut iter = path.clone();
+ if let Some((_, first)) = iter.next() {
+ let remaining = iter.clone().count(); // includes intermediate + final
+
+ // At least one intermediate hop
+ if remaining > 1 {
+ let expected = first.serialized_length();
+
+ // skip final hop: take(remaining - 1)
+ for (_, hop) in iter.take(remaining - 1) {
+ debug_assert!(
+ hop.serialized_length() == expected,
+ "All intermediate blinded hops must have identical serialized size"
+ );
+ }
+ }
+ }
+ }
+
utils::construct_blinded_hops(secp_ctx, path, session_priv)
}
@@ -753,14 +836,22 @@ where
}
pub(super) fn compute_payinfo(
- intermediate_nodes: &[PaymentForwardNode], payee_tlvs: &ReceiveTlvs,
+ intermediate_nodes: &[PaymentForwardNode], dummy_tlvs: &[DummyTlvs], payee_tlvs: &ReceiveTlvs,
payee_htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16,
) -> Result<BlindedPayInfo, ()> {
- let (aggregated_base_fee, aggregated_prop_fee) =
- compute_aggregated_base_prop_fee(intermediate_nodes.iter().map(|node| RoutingFees {
+ let routing_fees = intermediate_nodes
+ .iter()
+ .map(|node| RoutingFees {
base_msat: node.tlvs.payment_relay.fee_base_msat,
proportional_millionths: node.tlvs.payment_relay.fee_proportional_millionths,
- }))?;
+ })
+ .chain(dummy_tlvs.iter().map(|tlvs| RoutingFees {
+ base_msat: tlvs.payment_relay.fee_base_msat,
+ proportional_millionths: tlvs.payment_relay.fee_proportional_millionths,
+ }));
+
+ let (aggregated_base_fee, aggregated_prop_fee) =
+ compute_aggregated_base_prop_fee(routing_fees)?;
let mut htlc_minimum_msat: u64 = 1;
let mut htlc_maximum_msat: u64 = 21_000_000 * 100_000_000 * 1_000; // Total bitcoin supply
@@ -789,6 +880,16 @@ pub(super) fn compute_payinfo(
)
.ok_or(())?; // If underflow occurs, we cannot send to this hop without exceeding their max
}
+ for dummy_tlvs in dummy_tlvs.iter() {
+ cltv_expiry_delta =
+ cltv_expiry_delta.checked_add(dummy_tlvs.payment_relay.cltv_expiry_delta).ok_or(())?;
+
+ htlc_minimum_msat = amt_to_forward_msat(
+ core::cmp::max(dummy_tlvs.payment_constraints.htlc_minimum_msat, htlc_minimum_msat),
+ &dummy_tlvs.payment_relay,
+ )
+ .unwrap_or(1); // If underflow occurs, we definitely reached this node's min
+ }
htlc_minimum_msat =
core::cmp::max(payee_tlvs.payment_constraints.htlc_minimum_msat, htlc_minimum_msat);
htlc_maximum_msat = core::cmp::min(payee_htlc_maximum_msat, htlc_maximum_msat);
@@ -933,7 +1034,7 @@ mod tests {
};
let htlc_maximum_msat = 100_000;
let blinded_payinfo =
- super::compute_payinfo(&intermediate_nodes[..], &recv_tlvs, htlc_maximum_msat, 12)
+ super::compute_payinfo(&intermediate_nodes[..], &[], &recv_tlvs, htlc_maximum_msat, 12)
.unwrap();
assert_eq!(blinded_payinfo.fee_base_msat, 201);
assert_eq!(blinded_payinfo.fee_proportional_millionths, 1001);
@@ -950,7 +1051,7 @@ mod tests {
payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
};
let blinded_payinfo =
- super::compute_payinfo(&[], &recv_tlvs, 4242, TEST_FINAL_CLTV as u16).unwrap();
+ super::compute_payinfo(&[], &[], &recv_tlvs, 4242, TEST_FINAL_CLTV as u16).unwrap();
assert_eq!(blinded_payinfo.fee_base_msat, 0);
assert_eq!(blinded_payinfo.fee_proportional_millionths, 0);
assert_eq!(blinded_payinfo.cltv_expiry_delta, TEST_FINAL_CLTV as u16);
@@ -1009,6 +1110,7 @@ mod tests {
let htlc_maximum_msat = 100_000;
let blinded_payinfo = super::compute_payinfo(
&intermediate_nodes[..],
+ &[],
&recv_tlvs,
htlc_maximum_msat,
TEST_FINAL_CLTV as u16,
@@ -1068,6 +1170,7 @@ mod tests {
let htlc_minimum_msat = 3798;
assert!(super::compute_payinfo(
&intermediate_nodes[..],
+ &[],
&recv_tlvs,
htlc_minimum_msat - 1,
TEST_FINAL_CLTV as u16
@@ -1077,6 +1180,7 @@ mod tests {
let htlc_maximum_msat = htlc_minimum_msat + 1;
let blinded_payinfo = super::compute_payinfo(
&intermediate_nodes[..],
+ &[],
&recv_tlvs,
htlc_maximum_msat,
TEST_FINAL_CLTV as u16,
@@ -1137,6 +1241,7 @@ mod tests {
let blinded_payinfo = super::compute_payinfo(
&intermediate_nodes[..],
+ &[],
&recv_tlvs,
10_000,
TEST_FINAL_CLTV as u16,
Why this scored 18/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.