diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index 125d4316d12..b7f429de6c8 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -175,6 +175,7 @@ mod test { use lightning::onion_message::messenger::{ AOnionMessenger, Destination, MessageRouter, OnionMessagePath, OnionMessenger, }; + use lightning::routing::router::DEFAULT_PAYMENT_DUMMY_HOPS; use lightning::sign::{KeysManager, NodeSigner, ReceiveAuthKey, Recipient}; use lightning::types::features::InitFeatures; use lightning::types::payment::PaymentHash; @@ -419,6 +420,12 @@ mod test { let updates = get_htlc_update_msgs(&nodes[0], &payee_id); nodes[1].node.handle_update_add_htlc(payer_id, &updates.update_add_htlcs[0]); do_commitment_signed_dance(&nodes[1], &nodes[0], &updates.commitment_signed, false, false); + + for _ in 0..DEFAULT_PAYMENT_DUMMY_HOPS { + assert!(nodes[1].node.needs_pending_htlc_processing()); + nodes[1].node.process_pending_htlc_forwards(); + } + expect_and_process_pending_htlcs(&nodes[1], false); let claimable_events = nodes[1].node.get_and_clear_pending_events(); diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 13ade222f5b..b68be811cb4 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -33,7 +33,6 @@ use crate::util::ser::{ Writeable, Writer, }; -use core::mem; use core::ops::Deref; #[allow(unused_imports)] @@ -121,6 +120,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, ) -> Result + 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( + 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, + ) -> Result + 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( + 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, + ) -> Result where ES::Target: EntropySource, { @@ -133,6 +187,7 @@ impl BlindedPaymentPath { let blinded_payinfo = compute_payinfo( intermediate_nodes, + dummy_tlvs, &payee_tlvs, htlc_maximum_msat, min_final_cltv_expiry_delta, @@ -145,6 +200,7 @@ impl BlindedPaymentPath { secp_ctx, intermediate_nodes, payee_node_id, + dummy_tlvs, payee_tlvs, &blinding_secret, local_node_receive_key, @@ -191,28 +247,31 @@ impl BlindedPaymentPath { NL::Target: NodeIdLookUp, T: secp256k1::Signing + secp256k1::Verification, { - match self.decrypt_intro_payload::(node_signer) { - Ok(( - BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), - control_tlvs_ss, - )) => { - let next_node_id = match node_id_lookup.next_node_id(short_channel_id) { - Some(node_id) => node_id, - None => return Err(()), - }; - let mut new_blinding_point = onion_utils::next_hop_pubkey( - secp_ctx, - self.inner_path.blinding_point, - control_tlvs_ss.as_ref(), - ) - .map_err(|_| ())?; - mem::swap(&mut self.inner_path.blinding_point, &mut new_blinding_point); - self.inner_path.introduction_node = IntroductionNode::NodeId(next_node_id); - self.inner_path.blinded_hops.remove(0); - Ok(()) - }, - _ => Err(()), - } + let (next_node_id, control_tlvs_ss) = + match self.decrypt_intro_payload::(node_signer).map_err(|_| ())? { + (BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), ss) => { + let node_id = node_id_lookup.next_node_id(short_channel_id).ok_or(())?; + (node_id, ss) + }, + (BlindedPaymentTlvs::Dummy(_), ss) => { + let node_id = node_signer.get_node_id(Recipient::Node)?; + (node_id, ss) + }, + _ => return Err(()), + }; + + let new_blinding_point = onion_utils::next_hop_pubkey( + secp_ctx, + self.inner_path.blinding_point, + control_tlvs_ss.as_ref(), + ) + .map_err(|_| ())?; + + self.inner_path.blinding_point = new_blinding_point; + self.inner_path.introduction_node = IntroductionNode::NodeId(next_node_id); + self.inner_path.blinded_hops.remove(0); + + Ok(()) } pub(crate) fn decrypt_intro_payload( @@ -234,9 +293,9 @@ impl BlindedPaymentPath { .map_err(|_| ())?; match (&readable, used_aad) { - (BlindedPaymentTlvs::Forward(_), false) | (BlindedPaymentTlvs::Receive(_), true) => { - Ok((readable, control_tlvs_ss)) - }, + (BlindedPaymentTlvs::Forward(_), false) + | (BlindedPaymentTlvs::Dummy(_), true) + | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), _ => Err(()), } } @@ -328,6 +387,37 @@ pub struct TrampolineForwardTlvs { pub next_blinding_override: Option, } +/// 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 +436,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), } @@ -361,15 +453,17 @@ 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), 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 +477,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 +606,17 @@ impl Writeable for TrampolineForwardTlvs { } } +impl Writeable for DummyTlvs { + fn write(&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 +637,7 @@ impl<'a> Writeable for BlindedPaymentTlvsRef<'a> { fn write(&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 +658,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), } } } @@ -620,21 +739,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( secp_ctx: &Secp256k1, 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 { 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) } @@ -694,14 +838,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 { - 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 @@ -730,6 +882,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); @@ -874,7 +1036,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); @@ -891,7 +1053,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); @@ -950,6 +1112,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, @@ -1009,6 +1172,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 @@ -1018,6 +1182,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, @@ -1078,6 +1243,7 @@ mod tests { let blinded_payinfo = super::compute_payinfo( &intermediate_nodes[..], + &[], &recv_tlvs, 10_000, TEST_FINAL_CLTV as u16, diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 8e7fbdf94fd..a485e77f76b 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -10,8 +10,8 @@ use crate::blinded_path::message::{ BlindedMessagePath, MessageContext, NextMessageHop, OffersContext, }; -use crate::blinded_path::payment::PaymentContext; use crate::blinded_path::payment::{AsyncBolt12OfferContext, BlindedPaymentTlvs}; +use crate::blinded_path::payment::{DummyTlvs, PaymentContext}; use crate::chain::channelmonitor::{HTLC_FAIL_BACK_BUFFER, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::events::{ Event, EventsProvider, HTLCHandlingFailureReason, HTLCHandlingFailureType, PaidBolt12Invoice, @@ -55,7 +55,7 @@ use crate::onion_message::messenger::{ use crate::onion_message::offers::OffersMessage; use crate::onion_message::packet::ParsedOnionMessageContents; use crate::prelude::*; -use crate::routing::router::{Payee, PaymentParameters}; +use crate::routing::router::{Payee, PaymentParameters, DEFAULT_PAYMENT_DUMMY_HOPS}; use crate::sign::NodeSigner; use crate::sync::Mutex; use crate::types::features::Bolt12InvoiceFeatures; @@ -984,7 +984,8 @@ fn ignore_duplicate_invoice() { check_added_monitors!(sender, 1); let route: &[&[&Node]] = &[&[always_online_node, async_recipient]]; - let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = @@ -1063,7 +1064,8 @@ fn ignore_duplicate_invoice() { check_added_monitors!(sender, 1); let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) - .without_clearing_recipient_events(); + .without_clearing_recipient_events() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); let payment_preimage = match get_event!(async_recipient, Event::PaymentClaimable) { @@ -1138,7 +1140,8 @@ fn async_receive_flow_success() { assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = @@ -1375,11 +1378,13 @@ fn async_receive_mpp() { }; let args = PassAlongPathArgs::new(&nodes[0], expected_route[0], amt_msat, payment_hash, ev) - .without_claimable_event(); + .without_claimable_event() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); let ev = remove_first_msg_event_to_node(&nodes[2].node.get_our_node_id(), &mut events); - let args = PassAlongPathArgs::new(&nodes[0], expected_route[1], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(&nodes[0], expected_route[1], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = match claimable_ev { Event::PaymentClaimable { @@ -1497,7 +1502,8 @@ fn amount_doesnt_match_invreq() { let route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]]]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); // Modify the invoice request stored in our outbounds to be the correct one, to make sure the @@ -1521,7 +1527,8 @@ fn amount_doesnt_match_invreq() { ev, MessageSendEvent::UpdateHTLCs { ref updates, .. } if updates.update_add_htlcs.len() == 1)); check_added_monitors!(nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[2], &nodes[3]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); @@ -1712,7 +1719,8 @@ fn invalid_async_receive_with_retry( let payment_hash = extract_payment_hash(&ev); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); // Fail the HTLC backwards to enable us to more easily modify the now-Retryable outbound to test @@ -1739,7 +1747,8 @@ fn invalid_async_receive_with_retry( let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1], &nodes[2]], true); @@ -1751,7 +1760,8 @@ fn invalid_async_receive_with_retry( let mut ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); check_added_monitors!(nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); @@ -1858,6 +1868,13 @@ fn expired_static_invoice_payment_path() { blinded_path .advance_path_by_one(&nodes[1].keys_manager, &nodes[1].node, &secp_ctx) .unwrap(); + + for _ in 0..DEFAULT_PAYMENT_DUMMY_HOPS { + blinded_path + .advance_path_by_one(&nodes[2].keys_manager, &nodes[2].node, &secp_ctx) + .unwrap(); + } + match blinded_path.decrypt_intro_payload(&nodes[2].keys_manager).unwrap().0 { BlindedPaymentTlvs::Receive(tlvs) => tlvs.payment_constraints.max_cltv_expiry, _ => panic!(), @@ -1920,7 +1937,8 @@ fn expired_static_invoice_payment_path() { let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1], &nodes[2]], false); nodes[2].logger.assert_log_contains( @@ -2363,7 +2381,8 @@ fn refresh_static_invoices_for_used_offers() { check_added_monitors!(sender, 1); let route: &[&[&Node]] = &[&[server, recipient]]; - let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); @@ -2697,7 +2716,8 @@ fn invoice_server_is_not_channel_peer() { check_added_monitors!(sender, 1); let route: &[&[&Node]] = &[&[forwarding_node, recipient]]; - let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); @@ -2936,7 +2956,8 @@ fn async_payment_e2e() { check_added_monitors!(sender_lsp, 1); let path: &[&Node] = &[invoice_server, recipient]; - let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; @@ -3173,7 +3194,8 @@ fn intercepted_hold_htlc() { check_added_monitors!(lsp, 1); let path: &[&Node] = &[recipient]; - let args = PassAlongPathArgs::new(lsp, path, amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(lsp, path, amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let route: &[&[&Node]] = &[&[lsp, recipient]]; @@ -3276,7 +3298,8 @@ fn async_payment_mpp() { assert_eq!(events.len(), 1); let ev = remove_first_msg_event_to_node(&recipient.node.get_our_node_id(), &mut events); let args = PassAlongPathArgs::new(lsp_a, expected_path, amt_msat, payment_hash, ev) - .without_claimable_event(); + .without_claimable_event() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); lsp_b.node.process_pending_htlc_forwards(); @@ -3284,7 +3307,8 @@ fn async_payment_mpp() { let mut events = lsp_b.node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); let ev = remove_first_msg_event_to_node(&recipient.node.get_our_node_id(), &mut events); - let args = PassAlongPathArgs::new(lsp_b, expected_path, amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(lsp_b, expected_path, amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = match claimable_ev { @@ -3420,7 +3444,8 @@ fn release_htlc_races_htlc_onion_decode() { check_added_monitors!(sender_lsp, 1); let path: &[&Node] = &[invoice_server, recipient]; - let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev); + let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); let claimable_ev = do_pass_along_path(args).unwrap(); let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index a902cfebd12..3f7f36f454a 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -8,8 +8,8 @@ // licenses. use crate::blinded_path::payment::{ - BlindedPaymentPath, Bolt12RefundContext, ForwardTlvs, PaymentConstraints, PaymentContext, - PaymentForwardNode, PaymentRelay, ReceiveTlvs, PAYMENT_PADDING_ROUND_OFF, + BlindedPaymentPath, Bolt12RefundContext, DummyTlvs, ForwardTlvs, PaymentConstraints, + PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, PAYMENT_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::{self, BlindedHop}; @@ -196,6 +196,72 @@ fn do_one_hop_blinded_path(success: bool) { } } +#[test] +fn one_hop_blinded_path_with_dummy_hops() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_upd = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.contents; + + let amt_msat = 5000; + let (payment_preimage, payment_hash, payment_secret) = + get_payment_preimage_hash(&nodes[1], Some(amt_msat), None); + let payee_tlvs = ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: chan_upd.htlc_minimum_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }; + let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key(); + let dummy_tlvs = [DummyTlvs::default(); 2]; + + let mut secp_ctx = Secp256k1::new(); + let blinded_path = BlindedPaymentPath::new_with_dummy_hops( + &[], + nodes[1].node.get_our_node_id(), + &dummy_tlvs, + receive_auth_key, + payee_tlvs, + u64::MAX, + TEST_FINAL_CLTV as u16, + &chanmon_cfgs[1].keys_manager, + &secp_ctx, + ) + .unwrap(); + + let route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::blinded(vec![blinded_path]), + amt_msat, + ); + nodes[0] + .node + .send_payment( + payment_hash, + RecipientOnionFields::spontaneous_empty(), + PaymentId(payment_hash.0), + route_params, + Retry::Attempts(0), + ) + .unwrap(); + check_added_monitors(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + + let path = &[&nodes[1]]; + let args = PassAlongPathArgs::new(&nodes[0], path, amt_msat, payment_hash, ev) + .with_dummy_tlvs(&dummy_tlvs) + .with_payment_secret(payment_secret); + + do_pass_along_path(args); + claim_payment(&nodes[0], &[&nodes[1]], payment_preimage); +} + #[test] #[rustfmt::skip] fn mpp_to_one_hop_blinded_path() { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 72585d69f80..b55b1798d45 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4974,6 +4974,11 @@ where ) -> Result<(), LocalHTLCFailureReason> { let outgoing_scid = match next_packet_details.outgoing_connector { HopConnector::ShortChannelId(scid) => scid, + HopConnector::Dummy => { + // Dummy hops are only used for path padding and must not reach HTLC processing. + debug_assert!(false, "Dummy hop reached HTLC handling."); + return Err(LocalHTLCFailureReason::InvalidOnionPayload); + } HopConnector::Trampoline(_) => { return Err(LocalHTLCFailureReason::InvalidTrampolineForward); } @@ -5105,6 +5110,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) }, @@ -6864,6 +6883,7 @@ where fn process_pending_update_add_htlcs(&self) -> bool { let mut should_persist = false; let mut decode_update_add_htlcs = new_hash_map(); + let mut dummy_update_add_htlcs = new_hash_map(); mem::swap(&mut decode_update_add_htlcs, &mut self.decode_update_add_htlcs.lock().unwrap()); let get_htlc_failure_type = |outgoing_scid_opt: Option, payment_hash: PaymentHash| { @@ -6927,7 +6947,36 @@ where &*self.logger, &self.secp_ctx, ) { - Ok(decoded_onion) => decoded_onion, + Ok(decoded_onion) => match decoded_onion { + ( + onion_utils::Hop::Dummy { + dummy_hop_data, + next_hop_hmac, + new_packet_bytes, + .. + }, + Some(next_packet_details), + ) => { + let new_update_add_htlc = + onion_utils::peel_dummy_hop_update_add_htlc( + update_add_htlc, + dummy_hop_data, + next_hop_hmac, + new_packet_bytes, + next_packet_details, + &*self.node_signer, + &self.secp_ctx, + ); + + dummy_update_add_htlcs + .entry(incoming_scid_alias) + .or_insert_with(Vec::new) + .push(new_update_add_htlc); + + continue; + }, + _ => decoded_onion, + }, Err((htlc_fail, reason)) => { let failure_type = HTLCHandlingFailureType::InvalidOnion; @@ -6940,6 +6989,13 @@ where let outgoing_scid_opt = next_packet_details_opt.as_ref().and_then(|d| match d.outgoing_connector { HopConnector::ShortChannelId(scid) => Some(scid), + HopConnector::Dummy => { + debug_assert!( + false, + "Dummy hops must never be processed at this stage." + ); + None + }, HopConnector::Trampoline(_) => None, }); let shared_secret = next_hop.shared_secret().secret_bytes(); @@ -7083,6 +7139,19 @@ where )); } } + + // Merge peeled dummy HTLCs into the existing decode queue so they can be + // processed in the next iteration. We avoid replacing the whole queue + // (e.g. via mem::swap) because other threads may have enqueued new HTLCs + // meanwhile; merging preserves everything safely. + if !dummy_update_add_htlcs.is_empty() { + let mut decode_update_add_htlc_source = self.decode_update_add_htlcs.lock().unwrap(); + + for (incoming_scid_alias, htlcs) in dummy_update_add_htlcs.into_iter() { + decode_update_add_htlc_source.entry(incoming_scid_alias).or_default().extend(htlcs); + } + } + should_persist } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index ff33d7508b5..6d607eb2771 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -10,6 +10,7 @@ //! A bunch of useful utilities for building networks of nodes and exchanging messages between //! nodes for functional tests. +use crate::blinded_path::payment::DummyTlvs; use crate::chain::channelmonitor::ChannelMonitor; use crate::chain::transaction::OutPoint; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; @@ -3435,6 +3436,7 @@ fn fail_payment_along_path<'a, 'b, 'c>(expected_path: &[&Node<'a, 'b, 'c>]) { pub struct PassAlongPathArgs<'a, 'b, 'c, 'd> { pub origin_node: &'a Node<'b, 'c, 'd>, pub expected_path: &'a [&'a Node<'b, 'c, 'd>], + pub dummy_tlvs: Vec, pub recv_value: u64, pub payment_hash: PaymentHash, pub payment_secret: Option, @@ -3456,6 +3458,7 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { Self { origin_node, expected_path, + dummy_tlvs: vec![], recv_value, payment_hash, payment_secret: None, @@ -3503,12 +3506,17 @@ impl<'a, 'b, 'c, 'd> PassAlongPathArgs<'a, 'b, 'c, 'd> { self.expected_failure = Some(failure); self } + pub fn with_dummy_tlvs(mut self, dummy_tlvs: &[DummyTlvs]) -> Self { + self.dummy_tlvs = dummy_tlvs.to_vec(); + self + } } pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option { let PassAlongPathArgs { origin_node, expected_path, + dummy_tlvs, recv_value, payment_hash: our_payment_hash, payment_secret: our_payment_secret, @@ -3543,6 +3551,16 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option node.node.process_pending_htlc_forwards(); } + if is_last_hop { + // At the final hop, the incoming packet contains N dummy-hop layers + // before the real HTLC. Each call to `process_pending_htlc_forwards` + // strips exactly one dummy layer, so we call it N times. + for _ in 0..dummy_tlvs.len() { + assert!(node.node.needs_pending_htlc_processing()); + node.node.process_pending_htlc_forwards(); + } + } + if is_last_hop && clear_recipient_events { let events_2 = node.node.get_and_clear_pending_events(); if payment_claimable_expected { @@ -3755,6 +3773,29 @@ pub struct ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { pub origin_node: &'a Node<'b, 'c, 'd>, pub expected_paths: &'a [&'a [&'a Node<'b, 'c, 'd>]], pub expected_extra_fees: Vec, + /// A one-off adjustment used only in tests to account for an existing + /// fee-handling trade-off in LDK. + /// + /// When the payer is the introduction node of a blinded path, LDK does not + /// subtract the forward fee for the `payer -> next_hop` channel + /// (see [`BlindedPaymentPath::advance_path_by_one`]). This keeps the fee + /// logic simpler at the cost of a small, intentional overpayment. + /// + /// In the simple two-hop case (payer as introduction node → payee), + /// this overpayment has historically been avoided by simply not charging + /// the payer the forward fee, since the payer knows there is only + /// a single hop after them. + /// + /// However, with the introduction of dummy hops in LDK v0.3, even a + /// two-node real path (payer as introduction node → payee) may appear as a + /// multi-hop blinded path. This makes the existing overpayment surface in + /// tests. + /// + /// Until the fee-handling trade-off is revisited, this field allows tests + /// to compensate for that expected difference. + /// + /// [`BlindedPaymentPath::advance_path_by_one`]: crate::blinded_path::payment::BlindedPaymentPath::advance_path_by_one + pub expected_extra_total_fees_msat: u64, pub expected_min_htlc_overpay: Vec, pub skip_last: bool, pub payment_preimage: PaymentPreimage, @@ -3778,6 +3819,7 @@ impl<'a, 'b, 'c, 'd> ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { origin_node, expected_paths, expected_extra_fees: vec![0; expected_paths.len()], + expected_extra_total_fees_msat: 0, expected_min_htlc_overpay: vec![0; expected_paths.len()], skip_last: false, payment_preimage, @@ -3793,6 +3835,10 @@ impl<'a, 'b, 'c, 'd> ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { self.expected_extra_fees = extra_fees; self } + pub fn with_expected_extra_total_fees_msat(mut self, extra_total_fees: u64) -> Self { + self.expected_extra_total_fees_msat = extra_total_fees; + self + } pub fn with_expected_min_htlc_overpay(mut self, extra_fees: Vec) -> Self { self.expected_min_htlc_overpay = extra_fees; self @@ -4060,13 +4106,21 @@ pub fn pass_claimed_payment_along_route_from_ev( expected_total_fee_msat } + pub fn claim_payment_along_route( args: ClaimAlongRouteArgs, ) -> (Option, Vec) { - let origin_node = args.origin_node; - let payment_preimage = args.payment_preimage; - let skip_last = args.skip_last; - let expected_total_fee_msat = do_claim_payment_along_route(args); + let ClaimAlongRouteArgs { + origin_node, + payment_preimage, + skip_last, + expected_extra_total_fees_msat, + .. + } = args; + + let expected_total_fee_msat = + do_claim_payment_along_route(args) + expected_extra_total_fees_msat; + if !skip_last { expect_payment_sent!(origin_node, payment_preimage, Some(expected_total_fee_msat)) } else { diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 8e230fab1d9..1a7d52ebca8 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, pub next_blinding_override: Option, } + pub struct InboundOnionDummyPayload { + pub payment_relay: PaymentRelay, + pub payment_constraints: PaymentConstraints, + pub intro_node_blinding_point: Option, + } 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/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 4c53aefe58d..cd24c2de7ef 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -47,7 +47,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1}; use core::time::Duration; use crate::blinded_path::IntroductionNode; use crate::blinded_path::message::BlindedMessagePath; -use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, PaymentContext}; +use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, DummyTlvs, PaymentContext}; use crate::blinded_path::message::OffersContext; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self}; @@ -63,7 +63,7 @@ use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, PADDED_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; use crate::routing::gossip::{NodeAlias, NodeId}; -use crate::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig}; +use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig}; use crate::sign::{NodeSigner, Recipient}; use crate::util::ser::Writeable; @@ -178,14 +178,28 @@ fn route_bolt12_payment<'a, 'b, 'c>( let amount_msats = invoice.amount_msats(); let payment_hash = invoice.payment_hash(); let args = PassAlongPathArgs::new(node, path, amount_msats, payment_hash, ev) - .without_clearing_recipient_events(); + .without_clearing_recipient_events() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); } fn claim_bolt12_payment<'a, 'b, 'c>( node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], expected_payment_context: PaymentContext, invoice: &Bolt12Invoice ) { - let recipient = &path[path.len() - 1]; + claim_bolt12_payment_with_extra_fees( + node, + path, + expected_payment_context, + invoice, + None, + ) +} + +fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], expected_payment_context: PaymentContext, invoice: &Bolt12Invoice, + expected_extra_fees_msat: Option, +) { + let recipient = path.last().expect("Empty path?"); let payment_purpose = match get_event!(recipient, Event::PaymentClaimable) { Event::PaymentClaimable { purpose, .. } => purpose, _ => panic!("No Event::PaymentClaimable"), @@ -194,20 +208,29 @@ fn claim_bolt12_payment<'a, 'b, 'c>( Some(preimage) => preimage, None => panic!("No preimage in Event::PaymentClaimable"), }; - match payment_purpose { - PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { - assert_eq!(PaymentContext::Bolt12Offer(payment_context), expected_payment_context); - }, - PaymentPurpose::Bolt12RefundPayment { payment_context, .. } => { - assert_eq!(PaymentContext::Bolt12Refund(payment_context), expected_payment_context); - }, + let context = match payment_purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => + PaymentContext::Bolt12Offer(payment_context), + PaymentPurpose::Bolt12RefundPayment { payment_context, .. } => + PaymentContext::Bolt12Refund(payment_context), _ => panic!("Unexpected payment purpose: {:?}", payment_purpose), - } - if let Some(inv) = claim_payment(node, path, payment_preimage) { - assert_eq!(inv, PaidBolt12Invoice::Bolt12Invoice(invoice.to_owned())); - } else { - panic!("Expected PaidInvoice::Bolt12Invoice"); }; + + assert_eq!(context, expected_payment_context); + + let expected_paths = [path]; + let mut args = ClaimAlongRouteArgs::new( + node, + &expected_paths, + payment_preimage, + ); + + if let Some(extra) = expected_extra_fees_msat { + args = args.with_expected_extra_total_fees_msat(extra); + } + + let (inv, _) = claim_payment_along_route(args); + assert_eq!(inv, Some(PaidBolt12Invoice::Bolt12Invoice(invoice.clone()))); } fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { @@ -1410,7 +1433,20 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + // When the payer is the introduction node of a blinded path, LDK doesn't + // subtract the forward fee for the `payer -> next_hop` channel (see + // `BlindedPaymentPath::advance_path_by_one`). This keeps fee logic simple, + // at the cost of a small, intentional overpayment. + // + // In the old two-hop case (payer as introduction node → payee), this never + // surfaced because the payer simply wasn’t charged the forward fee. + // + // With dummy hops in LDK v0.3, even a real two-node path can appear as a + // longer blinded route, so the overpayment shows up in tests. + // + // Until the fee-handling trade-off is revisited, we pass an expected extra + // fee here so tests can compensate for it. + claim_bolt12_payment_with_extra_fees(bob, &[alice], payment_context, &invoice, Some(1000)); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -2422,12 +2458,13 @@ fn rejects_keysend_to_non_static_invoice_path() { let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .with_payment_preimage(payment_preimage) - .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); let mut updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); - nodes[0].node.handle_update_fail_htlc(nodes[1].node.get_our_node_id(), &updates.update_fail_htlcs[0]); + nodes[0].node.handle_update_fail_malformed_htlc(nodes[1].node.get_our_node_id(), &updates.update_fail_malformed_htlcs[0]); do_commitment_signed_dance(&nodes[0], &nodes[1], &updates.commitment_signed, false, false); - expect_payment_failed_conditions(&nodes[0], payment_hash, true, PaymentFailedConditions::new()); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, PaymentFailedConditions::new()); } #[test] @@ -2486,12 +2523,14 @@ fn no_double_pay_with_stale_channelmanager() { let ev = remove_first_msg_event_to_node(&bob_id, &mut events); let args = PassAlongPathArgs::new(&nodes[0], expected_route[0], amt_msat, payment_hash, ev) - .without_clearing_recipient_events(); + .without_clearing_recipient_events() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); let ev = remove_first_msg_event_to_node(&bob_id, &mut events); let args = PassAlongPathArgs::new(&nodes[0], expected_route[0], amt_msat, payment_hash, ev) - .without_clearing_recipient_events(); + .without_clearing_recipient_events() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); do_pass_along_path(args); expect_recent_payment!(nodes[0], RecentPaymentDetails::Pending, payment_id); diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 1abe4330a25..9e8672ab48c 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, @@ -478,7 +494,7 @@ where L::Target: Logger, { let (hop, next_packet_details_opt) = - decode_incoming_update_add_htlc_onion(msg, node_signer, logger, secp_ctx + decode_incoming_update_add_htlc_onion(msg, &*node_signer, &*logger, secp_ctx ).map_err(|(msg, failure_reason)| { let (reason, err_data) = match msg { HTLCFailureMsg::Malformed(_) => (failure_reason, Vec::new()), @@ -516,6 +532,29 @@ where // onion here and check it. create_fwd_pending_htlc_info(msg, hop, shared_secret.secret_bytes(), Some(next_packet_pubkey))? }, + onion_utils::Hop::Dummy { dummy_hop_data, next_hop_hmac, new_packet_bytes, .. } => { + let next_packet_details = match next_packet_details_opt { + Some(next_packet_details) => next_packet_details, + // Dummy Hops should always include the next hop details + None => return Err(InboundHTLCErr { + msg: "Failed to decode update add htlc onion", + reason: LocalHTLCFailureReason::InvalidOnionPayload, + err_data: Vec::new(), + }), + }; + + let new_update_add_htlc = onion_utils::peel_dummy_hop_update_add_htlc( + msg, + dummy_hop_data, + next_hop_hmac, + new_packet_bytes, + next_packet_details, + &*node_signer, + secp_ctx + ); + + peel_payment_onion(&new_update_add_htlc, node_signer, logger, secp_ctx, cur_height, allow_skimmed_fees)? + }, _ => { let shared_secret = hop.shared_secret().secret_bytes(); create_recv_pending_htlc_info( @@ -529,6 +568,8 @@ where pub(super) enum HopConnector { // scid-based routing ShortChannelId(u64), + // Dummy hop for path padding + Dummy, // Trampoline-based routing #[allow(unused)] Trampoline(PublicKey), @@ -633,6 +674,22 @@ where outgoing_cltv_value }) } + onion_utils::Hop::Dummy { dummy_hop_data: msgs::InboundOnionDummyPayload { ref payment_relay, ref payment_constraints, .. }, shared_secret, .. } => { + let (amt_to_forward, outgoing_cltv_value) = match check_blinded_forward( + msg.amount_msat, msg.cltv_expiry, &payment_relay, &payment_constraints, &BlindedHopFeatures::empty() + ) { + Ok((amt, cltv)) => (amt, cltv), + Err(()) => { + return encode_relay_error("Underflow calculating outbound amount or cltv value for blinded forward", + LocalHTLCFailureReason::InvalidOnionBlinding, shared_secret.secret_bytes(), None, &[0; 32]); + } + }; + + let next_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, + msg.onion_routing_packet.public_key.unwrap(), &shared_secret.secret_bytes()); + + Some(NextPacketDetails { next_packet_pubkey, outgoing_connector: HopConnector::Dummy, outgoing_amt_msat: amt_to_forward, outgoing_cltv_value }) + } onion_utils::Hop::TrampolineForward { next_trampoline_hop_data: msgs::InboundTrampolineForwardPayload { amt_to_forward, outgoing_cltv_value, next_trampoline }, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes()); diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 18aa43e27c6..b82c60a896d 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -14,7 +14,8 @@ use crate::crypto::streams::ChaChaReader; use crate::events::HTLCHandlingFailureReason; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::channelmanager::{HTLCSource, RecipientOnionFields}; -use crate::ln::msgs::{self, DecodeError}; +use crate::ln::msgs::{self, DecodeError, InboundOnionDummyPayload, OnionPacket, UpdateAddHTLC}; +use crate::ln::onion_payment::{HopConnector, NextPacketDetails}; use crate::offers::invoice_request::InvoiceRequest; use crate::routing::gossip::NetworkUpdate; use crate::routing::router::{BlindedTail, Path, RouteHop, RouteParameters, TrampolineHop}; @@ -2223,6 +2224,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 +2289,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, @@ -2344,6 +2357,12 @@ where new_packet_bytes, }) }, + msgs::InboundOnionPayload::Dummy(dummy_hop_data) => Ok(Hop::Dummy { + dummy_hop_data, + shared_secret, + next_hop_hmac, + new_packet_bytes, + }), _ => { if blinding_point.is_some() { return Err(OnionDecodeErr::Malformed { @@ -2521,6 +2540,61 @@ where } } +/// Peels a single dummy hop from an inbound `UpdateAddHTLC` by reconstructing the next +/// onion packet and HTLC state. +/// +/// This helper is used when processing dummy hops in a blinded path. Dummy hops are not +/// forwarded on the network; instead, their onion layer is removed locally and a new +/// `UpdateAddHTLC` is constructed with the next onion packet and updated amount/CLTV +/// values. +/// +/// This function performs no validation and does not enqueue or forward the HTLC. +/// It only reconstructs the next `UpdateAddHTLC` for further local processing. +pub(super) fn peel_dummy_hop_update_add_htlc( + msg: &UpdateAddHTLC, dummy_hop_data: InboundOnionDummyPayload, next_hop_hmac: [u8; 32], + new_packet_bytes: [u8; ONION_DATA_LEN], next_packet_details: NextPacketDetails, + node_signer: NS, secp_ctx: &Secp256k1, +) -> UpdateAddHTLC +where + NS::Target: NodeSigner, +{ + let NextPacketDetails { + next_packet_pubkey, + outgoing_amt_msat, + outgoing_connector, + outgoing_cltv_value, + } = next_packet_details; + + debug_assert!( + matches!(outgoing_connector, HopConnector::Dummy), + "Dummy hop must always map to HopConnector::Dummy" + ); + + let next_blinding_point = dummy_hop_data + .intro_node_blinding_point + .or(msg.blinding_point) + .and_then(|blinding_point| { + let ss = node_signer.ecdh(Recipient::Node, &blinding_point, None).ok()?.secret_bytes(); + + next_hop_pubkey(secp_ctx, blinding_point, &ss).ok() + }); + + let new_onion_packet = OnionPacket { + version: 0, + public_key: next_packet_pubkey, + hop_data: new_packet_bytes, + hmac: next_hop_hmac, + }; + + UpdateAddHTLC { + onion_routing_packet: new_onion_packet, + blinding_point: next_blinding_point, + amount_msat: outgoing_amt_msat, + cltv_expiry: outgoing_cltv_value, + ..msg.clone() + } +} + /// Build a payment onion, returning the first hop msat and cltv values as well. /// `cur_block_height` should be set to the best known block height + 1. pub fn create_payment_onion( diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index c06e5174263..cb1b3d0fc1d 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -13,8 +13,8 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; use lightning_invoice::Bolt11Invoice; use crate::blinded_path::payment::{ - BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentForwardNode, PaymentRelay, - ReceiveTlvs, + BlindedPaymentPath, DummyTlvs, ForwardTlvs, PaymentConstraints, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, }; use crate::blinded_path::{BlindedHop, Direction, IntroductionNode}; use crate::crypto::chacha20::ChaCha20; @@ -74,6 +74,9 @@ pub struct DefaultRouter< score_params: SP, } +/// The number of dummy hops included in [`BlindedPaymentPath`]s created by [`DefaultRouter`]. +pub const DEFAULT_PAYMENT_DUMMY_HOPS: usize = 3; + impl< G: Deref>, L: Deref, @@ -198,9 +201,9 @@ where }) }) .map(|forward_node| { - BlindedPaymentPath::new( - &[forward_node], recipient, local_node_receive_key, tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, - &*self.entropy_source, secp_ctx + BlindedPaymentPath::new_with_dummy_hops( + &[forward_node], recipient, &[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS], + local_node_receive_key, tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, secp_ctx ) }) .take(MAX_PAYMENT_PATHS) @@ -210,9 +213,9 @@ where Ok(paths) if !paths.is_empty() => Ok(paths), _ => { if network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)) { - BlindedPaymentPath::new( - &[], recipient, local_node_receive_key, tlvs, u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, - secp_ctx + BlindedPaymentPath::new_with_dummy_hops( + &[], recipient, &[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS], + local_node_receive_key, tlvs, u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, &*self.entropy_source, secp_ctx ).map(|path| vec![path]) } else { Err(())