Introduce Dummy BlindedPaymentTlv
What changed, and why it matters
This commit adds a new 'dummy hop' feature for blinded payment routes in LDK. It lets senders insert fake intermediate routing steps before the real recipient in a private Lightning payment path. Because these dummy hops look and behave like real forwarding nodes, outside observers watching timing or route structure have a harder time figuring out where the payment actually ends up. The change is defensive: it strengthens recipient privacy rather than fixing an active bug or vulnerability.
No immediate action required. This is a privacy-hardening feature. Operators and downstream users should review release notes for any compatibility or behavior changes related to blinded payment path construction once this lands in a release.
Security signals we found
Adds dummy intermediate hops to blinded payment paths to obscure recipient position
Treats dummy hops with realistic relay semantics (fees, CLTV, constraints) to preserve indistinguishability
Explicitly intended to mitigate timing-based route analysis attacks
Adds defensive failure paths (debug_assert + InvalidOnionPayload/InvalidOnionBlinding) if dummy hops leak into normal forwarding/receive handling
Changes PaymentRelay and PaymentConstraints to Copy, a benign API change supporting the new variant
Evidence from the diff
The patch introduces a DummyTlvs type and a corresponding BlindedPaymentTlvs::Dummy variant. Dummy hops carry PaymentRelay and PaymentConstraints TLVs (types 10 and 12) plus a sentinel TLV 65539, so they are encoded and validated like real forwarding hops. Receivers peel the dummy layer locally in process_pending_update_add_htlcs and re-enqueue the HTLC; if a dummy hop reaches normal forwarding/receive handling it is rejected with a debug_assert and an InvalidOnionPayload/InvalidOnionBlinding failure. PaymentRelay and PaymentConstraints are changed from Clone to Copy to support the new dummy structure. The commit is framed by its author as a privacy improvement against timing-based deanonymization of blinded-path recipients.
Changed components
lightning/src/blinded_path/payment.rslightning/src/ln/channelmanager.rslightning/src/ln/msgs.rslightning/src/ln/onion_payment.rslightning/src/ln/onion_utils.rsInspect captured patch +149 / −23
diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs
index 13ade22..549eb38 100644
--- a/lightning/src/blinded_path/payment.rs
+++ b/lightning/src/blinded_path/payment.rs
@@ -328,6 +328,37 @@ pub struct TrampolineForwardTlvs {
pub next_blinding_override: Option<PublicKey>,
}
+/// TLVs carried by a dummy hop within a blinded payment path.
+///
+/// Dummy hops do not correspond to real forwarding decisions, but are processed
+/// identically to real hops at the protocol level. The TLVs contained here define
+/// the relay requirements and constraints that must be satisfied for the payment
+/// to continue through this hop.
+///
+/// By enforcing realistic relay semantics on dummy hops, the payment path remains
+/// indistinguishable from a fully real route with respect to fees, CLTV deltas, and
+/// validation behavior.
+#[derive(Clone, Copy)]
+pub struct DummyTlvs {
+ /// Relay requirements (fees and CLTV delta) that must be satisfied when
+ /// processing this dummy hop.
+ pub payment_relay: PaymentRelay,
+ /// Constraints that apply to the payment when relaying over this dummy hop.
+ pub payment_constraints: PaymentConstraints,
+}
+
+impl Default for DummyTlvs {
+ fn default() -> Self {
+ let payment_relay =
+ PaymentRelay { cltv_expiry_delta: 0, fee_proportional_millionths: 0, fee_base_msat: 0 };
+
+ let payment_constraints =
+ PaymentConstraints { max_cltv_expiry: u32::MAX, htlc_minimum_msat: 0 };
+
+ Self { payment_relay, payment_constraints }
+ }
+}
+
/// Data to construct a [`BlindedHop`] for receiving a payment. This payload is custom to LDK and
/// may not be valid if received by another lightning implementation.
#[derive(Clone, Debug)]
@@ -346,6 +377,8 @@ pub struct ReceiveTlvs {
pub(crate) enum BlindedPaymentTlvs {
/// This blinded payment data is for a forwarding node.
Forward(ForwardTlvs),
+ /// This blinded payment data is dummy and is to be peeled by receiving node.
+ Dummy(DummyTlvs),
/// This blinded payment data is for the receiving node.
Receive(ReceiveTlvs),
}
@@ -363,13 +396,14 @@ pub(crate) enum BlindedTrampolineTlvs {
// Used to include forward and receive TLVs in the same iterator for encoding.
enum BlindedPaymentTlvsRef<'a> {
Forward(&'a ForwardTlvs),
+ Dummy(&'a DummyTlvs),
Receive(&'a ReceiveTlvs),
}
/// Parameters for relaying over a given [`BlindedHop`].
///
/// [`BlindedHop`]: crate::blinded_path::BlindedHop
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PaymentRelay {
/// Number of blocks subtracted from an incoming HTLC's `cltv_expiry` for this [`BlindedHop`].
pub cltv_expiry_delta: u16,
@@ -383,7 +417,7 @@ pub struct PaymentRelay {
/// Constraints for relaying over a given [`BlindedHop`].
///
/// [`BlindedHop`]: crate::blinded_path::BlindedHop
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PaymentConstraints {
/// The maximum total CLTV that is acceptable when relaying a payment over this [`BlindedHop`].
pub max_cltv_expiry: u32,
@@ -512,6 +546,17 @@ impl Writeable for TrampolineForwardTlvs {
}
}
+impl Writeable for DummyTlvs {
+ fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
+ encode_tlv_stream!(w, {
+ (10, self.payment_relay, required),
+ (12, self.payment_constraints, required),
+ (65539, (), required),
+ });
+ Ok(())
+ }
+}
+
// Note: The `authentication` TLV field was removed in LDK v0.3 following
// the introduction of `ReceiveAuthKey`-based authentication for inbound
// `BlindedPaymentPaths`s. Because we do not support receiving to those
@@ -532,6 +577,7 @@ impl<'a> Writeable for BlindedPaymentTlvsRef<'a> {
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
match self {
Self::Forward(tlvs) => tlvs.write(w)?,
+ Self::Dummy(tlvs) => tlvs.write(w)?,
Self::Receive(tlvs) => tlvs.write(w)?,
}
Ok(())
@@ -552,28 +598,41 @@ impl Readable for BlindedPaymentTlvs {
(14, features, (option, encoding: (BlindedHopFeatures, WithoutLength))),
(65536, payment_secret, option),
(65537, payment_context, option),
+ (65539, is_dummy, option)
});
- if let Some(short_channel_id) = scid {
- if payment_secret.is_some() {
- return Err(DecodeError::InvalidValue);
- }
- Ok(BlindedPaymentTlvs::Forward(ForwardTlvs {
- short_channel_id,
- payment_relay: payment_relay.ok_or(DecodeError::InvalidValue)?,
- payment_constraints: payment_constraints.0.unwrap(),
- next_blinding_override,
- features: features.unwrap_or_else(BlindedHopFeatures::empty),
- }))
- } else {
- if payment_relay.is_some() || features.is_some() {
- return Err(DecodeError::InvalidValue);
- }
- Ok(BlindedPaymentTlvs::Receive(ReceiveTlvs {
- payment_secret: payment_secret.ok_or(DecodeError::InvalidValue)?,
- payment_constraints: payment_constraints.0.unwrap(),
- payment_context: payment_context.ok_or(DecodeError::InvalidValue)?,
- }))
+ match (
+ scid,
+ next_blinding_override,
+ payment_relay,
+ features,
+ payment_secret,
+ payment_context,
+ is_dummy,
+ ) {
+ (Some(short_channel_id), next_override, Some(relay), features, None, None, None) => {
+ Ok(BlindedPaymentTlvs::Forward(ForwardTlvs {
+ short_channel_id,
+ payment_relay: relay,
+ payment_constraints: payment_constraints.0.unwrap(),
+ next_blinding_override: next_override,
+ features: features.unwrap_or_else(BlindedHopFeatures::empty),
+ }))
+ },
+ (None, None, None, None, Some(secret), Some(context), None) => {
+ Ok(BlindedPaymentTlvs::Receive(ReceiveTlvs {
+ payment_secret: secret,
+ payment_constraints: payment_constraints.0.unwrap(),
+ payment_context: context,
+ }))
+ },
+ (None, None, Some(relay), None, None, None, Some(())) => {
+ Ok(BlindedPaymentTlvs::Dummy(DummyTlvs {
+ payment_relay: relay,
+ payment_constraints: payment_constraints.0.unwrap(),
+ }))
+ },
+ _ => return Err(DecodeError::InvalidValue),
}
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 72585d6..aef57a6 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -5105,6 +5105,20 @@ where
onion_utils::Hop::Forward { .. } | onion_utils::Hop::BlindedForward { .. } => {
create_fwd_pending_htlc_info(msg, decoded_hop, shared_secret, next_packet_pubkey_opt)
},
+ onion_utils::Hop::Dummy { .. } => {
+ debug_assert!(
+ false,
+ "Reached unreachable dummy-hop HTLC. Dummy hops are peeled in \
+ `process_pending_update_add_htlcs`, and the resulting HTLC is \
+ re-enqueued for processing. Hitting this means the peel-and-requeue \
+ step was missed."
+ );
+ return Err(InboundHTLCErr {
+ msg: "Failed to decode update add htlc onion",
+ reason: LocalHTLCFailureReason::InvalidOnionPayload,
+ err_data: Vec::new(),
+ })
+ },
onion_utils::Hop::TrampolineForward { .. } | onion_utils::Hop::TrampolineBlindedForward { .. } => {
create_fwd_pending_htlc_info(msg, decoded_hop, shared_secret, next_packet_pubkey_opt)
},
diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs
index 8e230fa..1a7d52e 100644
--- a/lightning/src/ln/msgs.rs
+++ b/lightning/src/ln/msgs.rs
@@ -32,7 +32,7 @@ use bitcoin::secp256k1::PublicKey;
use bitcoin::{secp256k1, Transaction, Witness};
use crate::blinded_path::message::BlindedMessagePath;
-use crate::blinded_path::payment::{BlindedPaymentTlvs, ForwardTlvs, ReceiveTlvs};
+use crate::blinded_path::payment::{BlindedPaymentTlvs, DummyTlvs, ForwardTlvs, ReceiveTlvs};
use crate::blinded_path::payment::{BlindedTrampolineTlvs, TrampolineForwardTlvs};
use crate::ln::onion_utils;
use crate::ln::types::ChannelId;
@@ -2336,6 +2336,11 @@ mod fuzzy_internal_msgs {
pub intro_node_blinding_point: Option<PublicKey>,
pub next_blinding_override: Option<PublicKey>,
}
+ pub struct InboundOnionDummyPayload {
+ pub payment_relay: PaymentRelay,
+ pub payment_constraints: PaymentConstraints,
+ pub intro_node_blinding_point: Option<PublicKey>,
+ }
pub struct InboundOnionBlindedReceivePayload {
pub sender_intended_htlc_amt_msat: u64,
pub total_msat: u64,
@@ -2355,6 +2360,7 @@ mod fuzzy_internal_msgs {
Receive(InboundOnionReceivePayload),
BlindedForward(InboundOnionBlindedForwardPayload),
BlindedReceive(InboundOnionBlindedReceivePayload),
+ Dummy(InboundOnionDummyPayload),
}
pub struct InboundTrampolineForwardPayload {
@@ -3694,6 +3700,25 @@ where
next_blinding_override,
}))
},
+ ChaChaDualPolyReadAdapter {
+ readable:
+ BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }),
+ used_aad,
+ } => {
+ if amt.is_some()
+ || cltv_value.is_some() || total_msat.is_some()
+ || keysend_preimage.is_some()
+ || invoice_request.is_some()
+ || !used_aad
+ {
+ return Err(DecodeError::InvalidValue);
+ }
+ Ok(Self::Dummy(InboundOnionDummyPayload {
+ payment_relay,
+ payment_constraints,
+ intro_node_blinding_point,
+ }))
+ },
ChaChaDualPolyReadAdapter {
readable: BlindedPaymentTlvs::Receive(receive_tlvs),
used_aad,
diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs
index 1abe433..c1d07f7 100644
--- a/lightning/src/ln/onion_payment.rs
+++ b/lightning/src/ln/onion_payment.rs
@@ -149,6 +149,14 @@ pub(super) fn create_fwd_pending_htlc_info(
(RoutingInfo::Direct { short_channel_id, new_packet_bytes, next_hop_hmac }, amt_to_forward, outgoing_cltv_value, intro_node_blinding_point,
next_blinding_override)
},
+ onion_utils::Hop::Dummy { .. } => {
+ debug_assert!(false, "Dummy hop should have been peeled earlier");
+ return Err(InboundHTLCErr {
+ msg: "Dummy Hop OnionHopData provided for us as an intermediary node",
+ reason: LocalHTLCFailureReason::InvalidOnionPayload,
+ err_data: Vec::new(),
+ })
+ },
onion_utils::Hop::Receive { .. } | onion_utils::Hop::BlindedReceive { .. } =>
return Err(InboundHTLCErr {
msg: "Final Node OnionHopData provided for us as an intermediary node",
@@ -364,6 +372,14 @@ pub(super) fn create_recv_pending_htlc_info(
msg: "Got blinded non final data with an HMAC of 0",
})
},
+ onion_utils::Hop::Dummy { .. } => {
+ debug_assert!(false, "Dummy hop should have been peeled earlier");
+ return Err(InboundHTLCErr {
+ reason: LocalHTLCFailureReason::InvalidOnionBlinding,
+ err_data: vec![0; 32],
+ msg: "Got blinded non final data with an HMAC of 0",
+ })
+ }
onion_utils::Hop::TrampolineForward { .. } | onion_utils::Hop::TrampolineBlindedForward { .. } => {
return Err(InboundHTLCErr {
reason: LocalHTLCFailureReason::InvalidOnionPayload,
diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs
index 18aa43e..7e87954 100644
--- a/lightning/src/ln/onion_utils.rs
+++ b/lightning/src/ln/onion_utils.rs
@@ -2223,6 +2223,17 @@ pub(crate) enum Hop {
/// Bytes of the onion packet we're forwarding.
new_packet_bytes: [u8; ONION_DATA_LEN],
},
+ /// This onion payload is dummy, and needs to be peeled by us.
+ Dummy {
+ /// Blinding point for introduction-node dummy hops.
+ dummy_hop_data: msgs::InboundOnionDummyPayload,
+ /// Shared secret for decrypting the next-hop public key.
+ shared_secret: SharedSecret,
+ /// HMAC of the next hop's onion packet.
+ next_hop_hmac: [u8; 32],
+ /// Onion packet bytes after this dummy layer is peeled.
+ new_packet_bytes: [u8; ONION_DATA_LEN],
+ },
/// This onion payload was for us, not for forwarding to a next-hop. Contains information for
/// verifying the incoming payment.
Receive {
@@ -2277,6 +2288,7 @@ impl Hop {
match self {
Hop::Forward { shared_secret, .. } => shared_secret,
Hop::BlindedForward { shared_secret, .. } => shared_secret,
+ Hop::Dummy { shared_secret, .. } => shared_secret,
Hop::TrampolineForward { outer_shared_secret, .. } => outer_shared_secret,
Hop::TrampolineBlindedForward { outer_shared_secret, .. } => outer_shared_secret,
Hop::Receive { shared_secret, .. } => shared_secret,
Why this scored 45/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.