From d14446b7d304cd5084a0f06d6c8455b345169fe9 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 12 Mar 2026 10:39:39 +0530 Subject: [PATCH 1/6] Return dummy-hop-specific error on blinded-forward underflow When amount or CLTV validation underflows on a dummy hop, return a dummy-hop-specific error. This avoids misleading logs and makes blinded-path failures easier to debug. --- lightning/src/ln/onion_payment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 5111f6982fe..b59a4f8e8d3 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -673,7 +673,7 @@ pub(super) fn decode_incoming_update_add_htlc_onion (amt, cltv), Err(()) => { - return encode_relay_error("Underflow calculating outbound amount or cltv value for blinded forward", + return encode_relay_error("Underflow calculating outbound amount or cltv value for dummy hop", LocalHTLCFailureReason::InvalidOnionBlinding, shared_secret.secret_bytes(), None, &[0; 32]); } }; From f857e6e637734ec4f2ba2ecc9c8e103cad69b0ee Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 12 Mar 2026 10:32:24 +0530 Subject: [PATCH 2/6] Track dummy-hop skimmed fees through receive handling Propagate dummy-hop skimmed fees through HTLC peeling, channel persistence, and receive-side payment tracking. Expose the accumulated value on `PaymentClaimable` and `PaymentClaimed`. This keeps `amount_msat` focused on the amount delivered to the final receive TLVs, while still surfacing the additional revenue from dummy hops. Receivers can account for these fees without changing the meaning of existing skimmed-fee fields. --- lightning/src/events/mod.rs | 32 +++++++++ lightning/src/ln/blinded_payment_tests.rs | 1 + lightning/src/ln/channel.rs | 78 ++++++++++++++++++--- lightning/src/ln/channelmanager.rs | 49 +++++++++++-- lightning/src/ln/functional_tests.rs | 1 + lightning/src/ln/htlc_reserve_unit_tests.rs | 5 ++ lightning/src/ln/msgs.rs | 5 ++ lightning/src/ln/onion_payment.rs | 10 ++- lightning/src/ln/onion_utils.rs | 5 ++ lightning/src/ln/payment_tests.rs | 1 + 10 files changed, 168 insertions(+), 19 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 011b7f595bc..db8f46ebaa0 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -902,6 +902,12 @@ pub enum Event { /// /// [`ChannelConfig::accept_underpaying_htlcs`]: crate::util::config::ChannelConfig::accept_underpaying_htlcs amount_msat: u64, + /// The additional skimmed fee, in thousandths of a satoshi, that the receiver earns from + /// dummy hops preceding the final receipt, in addition to the `amount_msat`. + /// + /// For backwards compatibility with older LDK versions where this TLV was not serialized, + /// this defaults to 0 when absent. + dummy_hops_skimmed_fee_msat: u64, /// The value, in thousands of a satoshi, that was skimmed off of this payment as an extra fee /// taken by our channel counterparty. /// @@ -965,6 +971,12 @@ pub enum Event { /// The value, in thousandths of a satoshi, that this payment is for. May be greater than the /// invoice amount. amount_msat: u64, + /// The additional skimmed fee, in thousandths of a satoshi, that the receiver earns from + /// dummy hops preceding the final receipt, in addition to the `amount_msat`. + /// + /// For backwards compatibility with older LDK versions where this TLV was not serialized, + /// this defaults to 0 when absent. + dummy_hops_skimmed_fee_msat: u64, /// The purpose of the claimed payment, i.e. whether the payment was for an invoice or a /// spontaneous payment. purpose: PaymentPurpose, @@ -1891,6 +1903,7 @@ impl Writeable for Event { &Event::PaymentClaimable { ref payment_hash, ref amount_msat, + dummy_hops_skimmed_fee_msat, counterparty_skimmed_fee_msat, ref purpose, ref receiver_node_id, @@ -1938,6 +1951,11 @@ impl Writeable for Event { } else { Some(counterparty_skimmed_fee_msat) }; + let dummy_skimmed_fee_opt = if dummy_hops_skimmed_fee_msat == 0 { + None + } else { + Some(dummy_hops_skimmed_fee_msat) + }; let (receiving_channel_id_legacy, receiving_user_channel_id_legacy) = match receiving_channel_ids.last() { @@ -1964,6 +1982,7 @@ impl Writeable for Event { (11, payment_context, option), (13, payment_id, option), (15, *receiving_channel_ids, optional_vec), + (17, dummy_skimmed_fee_opt, option), }); }, &Event::PaymentSent { @@ -2189,6 +2208,7 @@ impl Writeable for Event { &Event::PaymentClaimed { ref payment_hash, ref amount_msat, + dummy_hops_skimmed_fee_msat, ref purpose, ref receiver_node_id, ref htlcs, @@ -2197,6 +2217,11 @@ impl Writeable for Event { ref payment_id, } => { 19u8.write(writer)?; + let dummy_skimmed_fee_opt = if dummy_hops_skimmed_fee_msat == 0 { + None + } else { + Some(dummy_hops_skimmed_fee_msat) + }; write_tlv_fields!(writer, { (0, payment_hash, required), (1, receiver_node_id, option), @@ -2206,6 +2231,7 @@ impl Writeable for Event { (7, sender_intended_total_msat, option), (9, onion_fields, option), (11, payment_id, option), + (13, dummy_skimmed_fee_opt, option), }); }, &Event::ProbeSuccessful { ref payment_id, ref payment_hash, ref path } => { @@ -2407,6 +2433,7 @@ impl MaybeReadable for Event { let mut payment_preimage = None; let mut payment_secret = None; let mut amount_msat = 0; + let mut dummy_skimmed_fee_msat_opt = None; let mut counterparty_skimmed_fee_msat_opt = None; let mut receiver_node_id = None; let mut _user_payment_id = None::; // Used in 0.0.103 and earlier, no longer written in 0.0.116+. @@ -2432,6 +2459,7 @@ impl MaybeReadable for Event { (11, payment_context, option), (13, payment_id, option), (15, receiving_channel_ids_opt, optional_vec), + (17, dummy_skimmed_fee_msat_opt, option), }); let purpose = match payment_secret { Some(secret) => { @@ -2455,6 +2483,7 @@ impl MaybeReadable for Event { receiver_node_id, payment_hash, amount_msat, + dummy_hops_skimmed_fee_msat: dummy_skimmed_fee_msat_opt.unwrap_or(0), counterparty_skimmed_fee_msat: counterparty_skimmed_fee_msat_opt .unwrap_or(0), purpose, @@ -2760,6 +2789,7 @@ impl MaybeReadable for Event { let mut payment_hash = PaymentHash([0; 32]); let mut purpose = UpgradableRequired(None); let mut amount_msat = 0; + let mut dummy_hops_skimmed_fee_msat_opt = None; let mut receiver_node_id = None; let mut htlcs: Option> = Some(vec![]); let mut sender_intended_total_msat: Option = None; @@ -2775,12 +2805,14 @@ impl MaybeReadable for Event { (9, onion_fields, (option: ReadableArgs, sender_intended_total_msat.unwrap_or(amount_msat))), (11, payment_id, option), + (13, dummy_hops_skimmed_fee_msat_opt, option), }); Ok(Some(Event::PaymentClaimed { receiver_node_id, payment_hash, purpose: _init_tlv_based_struct_field!(purpose, upgradable_required), amount_msat, + dummy_hops_skimmed_fee_msat: dummy_hops_skimmed_fee_msat_opt.unwrap_or(0), htlcs: htlcs.unwrap_or_default(), sender_intended_total_msat, onion_fields, diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index e148ce2c474..d945afa811a 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1586,6 +1586,7 @@ fn update_add_msg( cltv_expiry, payment_hash: PaymentHash([0; 32]), onion_routing_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point, hold_htlc: None, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..81d1f7bb660 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -518,6 +518,7 @@ struct OutboundHTLCOutput { state: OutboundHTLCState, source: HTLCSource, blinding_point: Option, + dummy_hops_skimmed_fee_msat: Option, skimmed_fee_msat: Option, send_timestamp: Option, hold_htlc: Option<()>, @@ -536,6 +537,7 @@ enum HTLCUpdateAwaitingACK { payment_hash: PaymentHash, source: HTLCSource, onion_routing_packet: msgs::OnionPacket, + dummy_hops_skimmed_fee_msat: Option, // The extra fee we're skimming off the top of this HTLC. skimmed_fee_msat: Option, blinding_point: Option, @@ -8196,6 +8198,7 @@ where ref payment_hash, ref source, ref onion_routing_packet, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat, blinding_point, hold_htlc, @@ -8208,6 +8211,7 @@ where source.clone(), onion_routing_packet.clone(), false, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat, blinding_point, hold_htlc.is_some(), @@ -9521,6 +9525,7 @@ where payment_hash: htlc.payment_hash, cltv_expiry: htlc.cltv_expiry, onion_routing_packet: (**onion_packet).clone(), + dummy_hops_skimmed_fee_msat: htlc.dummy_hops_skimmed_fee_msat, skimmed_fee_msat: htlc.skimmed_fee_msat, blinding_point: htlc.blinding_point, hold_htlc: htlc.hold_htlc, @@ -12380,7 +12385,8 @@ where /// commitment update. pub fn queue_add_htlc( &mut self, amount_msat: u64, payment_hash: PaymentHash, cltv_expiry: u32, - source: HTLCSource, onion_routing_packet: msgs::OnionPacket, skimmed_fee_msat: Option, + source: HTLCSource, onion_routing_packet: msgs::OnionPacket, + dummy_hops_skimmed_fee_msat: Option, skimmed_fee_msat: Option, blinding_point: Option, accountable: bool, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result<(), (LocalHTLCFailureReason, String)> { @@ -12391,6 +12397,7 @@ where source, onion_routing_packet, true, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat, blinding_point, // This method is only called for forwarded HTLCs, which are never held at the next hop @@ -12426,8 +12433,9 @@ where fn send_htlc( &mut self, amount_msat: u64, payment_hash: PaymentHash, cltv_expiry: u32, source: HTLCSource, onion_routing_packet: msgs::OnionPacket, mut force_holding_cell: bool, - skimmed_fee_msat: Option, blinding_point: Option, hold_htlc: bool, - accountable: bool, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, + dummy_hops_skimmed_fee_msat: Option, skimmed_fee_msat: Option, + blinding_point: Option, hold_htlc: bool, accountable: bool, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { if !matches!(self.context.channel_state, ChannelState::ChannelReady(_)) || self.context.channel_state.is_local_shutdown_sent() @@ -12507,6 +12515,7 @@ where cltv_expiry, source, onion_routing_packet, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat, blinding_point, hold_htlc: hold_htlc.then(|| ()), @@ -12530,6 +12539,7 @@ where state: OutboundHTLCState::LocalAnnounced(Box::new(onion_routing_packet.clone())), source, blinding_point, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat, send_timestamp, hold_htlc: hold_htlc.then(|| ()), @@ -12772,9 +12782,9 @@ where /// [`Self::send_htlc`] and [`Self::build_commitment_no_state_update`] for more info. pub fn send_htlc_and_commit( &mut self, amount_msat: u64, payment_hash: PaymentHash, cltv_expiry: u32, - source: HTLCSource, onion_routing_packet: msgs::OnionPacket, skimmed_fee_msat: Option, - hold_htlc: bool, accountable: bool, fee_estimator: &LowerBoundedFeeEstimator, - logger: &L, + source: HTLCSource, onion_routing_packet: msgs::OnionPacket, + dummy_hops_skimmed_fee_msat: Option, skimmed_fee_msat: Option, hold_htlc: bool, + accountable: bool, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result, ChannelError> { let send_res = self.send_htlc( amount_msat, @@ -12783,6 +12793,7 @@ where source, onion_routing_packet, false, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat, None, hold_htlc, @@ -14409,6 +14420,7 @@ impl Writeable for FundedChannel { // but we still serialize the option to maintain backwards compatibility let mut preimages: Vec> = vec![]; let mut fulfill_attribution_data = vec![]; + let mut pending_outbound_dummy_hops_skimmed_fees: Vec> = Vec::new(); let mut pending_outbound_skimmed_fees: Vec> = Vec::new(); let mut pending_outbound_blinding_points: Vec> = Vec::new(); let mut pending_outbound_held_htlc_flags: Vec> = Vec::new(); @@ -14453,6 +14465,7 @@ impl Writeable for FundedChannel { reason.write(writer)?; }, } + pending_outbound_dummy_hops_skimmed_fees.push(htlc.dummy_hops_skimmed_fee_msat); pending_outbound_skimmed_fees.push(htlc.skimmed_fee_msat); pending_outbound_blinding_points.push(htlc.blinding_point); pending_outbound_held_htlc_flags.push(htlc.hold_htlc); @@ -14460,6 +14473,8 @@ impl Writeable for FundedChannel { } let holding_cell_htlc_update_count = self.context.holding_cell_htlc_updates.len(); + let mut holding_cell_dummy_hops_skimmed_fees: Vec> = + Vec::with_capacity(holding_cell_htlc_update_count); let mut holding_cell_skimmed_fees: Vec> = Vec::with_capacity(holding_cell_htlc_update_count); let mut holding_cell_blinding_points: Vec> = @@ -14481,6 +14496,7 @@ impl Writeable for FundedChannel { ref payment_hash, ref source, ref onion_routing_packet, + dummy_hops_skimmed_fee_msat, blinding_point, skimmed_fee_msat, hold_htlc, @@ -14493,6 +14509,7 @@ impl Writeable for FundedChannel { source.write(writer)?; onion_routing_packet.write(writer)?; + holding_cell_dummy_hops_skimmed_fees.push(dummy_hops_skimmed_fee_msat); holding_cell_skimmed_fees.push(skimmed_fee_msat); holding_cell_blinding_points.push(blinding_point); holding_cell_held_htlc_flags.push(hold_htlc); @@ -14737,6 +14754,7 @@ impl Writeable for FundedChannel { (28, holder_max_accepted_htlcs, option), (29, self.context.temporary_channel_id, option), (31, channel_pending_event_emitted, option), + (33, pending_outbound_dummy_hops_skimmed_fees, optional_vec), // Added in 0.3 (35, pending_outbound_skimmed_fees, optional_vec), (37, holding_cell_skimmed_fees, optional_vec), (38, self.context.is_batch_funding, option), @@ -14764,6 +14782,7 @@ impl Writeable for FundedChannel { (75, inbound_committed_update_adds, optional_vec), (77, holding_cell_accountable_flags, optional_vec), // Added in 0.3 (79, pending_outbound_accountable, optional_vec), // Added in 0.3 + (81, holding_cell_dummy_hops_skimmed_fees, optional_vec), // Added in 0.3 }); Ok(()) @@ -14924,6 +14943,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> }, _ => return Err(DecodeError::InvalidValue), }, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, send_timestamp: None, @@ -14945,6 +14965,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> payment_hash: Readable::read(reader)?, source: Readable::read(reader)?, onion_routing_packet: Readable::read(reader)?, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -15117,7 +15138,9 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let mut blocked_monitor_updates = Some(Vec::new()); let mut pending_outbound_skimmed_fees_opt: Option>> = None; + let mut pending_outbound_dummy_hops_skimmed_fees_opt: Option>> = None; let mut holding_cell_skimmed_fees_opt: Option>> = None; + let mut holding_cell_dummy_hops_skimmed_fees_opt: Option>> = None; let mut is_batch_funding: Option<()> = None; @@ -15180,6 +15203,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> (28, holder_max_accepted_htlcs, option), (29, temporary_channel_id, option), (31, channel_pending_event_emitted, option), + (33, pending_outbound_dummy_hops_skimmed_fees_opt, optional_vec), (35, pending_outbound_skimmed_fees_opt, optional_vec), (37, holding_cell_skimmed_fees_opt, optional_vec), (38, is_batch_funding, option), @@ -15207,6 +15231,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> (75, inbound_committed_update_adds_opt, optional_vec), (77, holding_cell_accountable, optional_vec), // Added in 0.3 (79, pending_outbound_accountable, optional_vec), // Added in 0.3 + (81, holding_cell_dummy_hops_skimmed_fees_opt, optional_vec), }); let holder_signer = signer_provider.derive_channel_signer(channel_keys_id); @@ -15264,6 +15289,15 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let holder_max_accepted_htlcs = holder_max_accepted_htlcs.unwrap_or(DEFAULT_MAX_HTLCS); + if let Some(dummy_hops_skimmed_fees) = pending_outbound_dummy_hops_skimmed_fees_opt { + let mut iter = dummy_hops_skimmed_fees.into_iter(); + for htlc in pending_outbound_htlcs.iter_mut() { + htlc.dummy_hops_skimmed_fee_msat = iter.next().ok_or(DecodeError::InvalidValue)?; + } + if iter.next().is_some() { + return Err(DecodeError::InvalidValue); + } + } if let Some(skimmed_fees) = pending_outbound_skimmed_fees_opt { let mut iter = skimmed_fees.into_iter(); for htlc in pending_outbound_htlcs.iter_mut() { @@ -15274,6 +15308,20 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> return Err(DecodeError::InvalidValue); } } + if let Some(dummy_hops_skimmed_fees) = holding_cell_dummy_hops_skimmed_fees_opt { + let mut iter = dummy_hops_skimmed_fees.into_iter(); + for htlc in holding_cell_htlc_updates.iter_mut() { + if let HTLCUpdateAwaitingACK::AddHTLC { + ref mut dummy_hops_skimmed_fee_msat, .. + } = htlc + { + *dummy_hops_skimmed_fee_msat = iter.next().ok_or(DecodeError::InvalidValue)?; + } + } + if iter.next().is_some() { + return Err(DecodeError::InvalidValue); + } + } if let Some(skimmed_fees) = holding_cell_skimmed_fees_opt { let mut iter = skimmed_fees.into_iter(); for htlc in holding_cell_htlc_updates.iter_mut() { @@ -15953,11 +16001,12 @@ mod tests { path: Path { hops: Vec::new(), blinded_tail: None }, session_priv: SecretKey::from_slice(&>::from_hex("0fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").unwrap()[..]).unwrap(), first_hop_htlc_msat: 548, - payment_id: PaymentId([42; 32]), - bolt12_invoice: None, - }, - skimmed_fee_msat: None, - blinding_point: None, + payment_id: PaymentId([42; 32]), + bolt12_invoice: None, + }, + dummy_hops_skimmed_fee_msat: None, + skimmed_fee_msat: None, + blinding_point: None, send_timestamp: None, hold_htlc: None, accountable: false, @@ -16414,6 +16463,7 @@ mod tests { cltv_expiry: 0, state: OutboundHTLCState::Committed, source: dummy_htlc_source.clone(), + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, send_timestamp: None, @@ -16425,6 +16475,9 @@ mod tests { if idx % 2 == 0 { htlc.blinding_point = Some(test_utils::pubkey(42 + idx as u8)); } + if idx % 4 == 0 { + htlc.dummy_hops_skimmed_fee_msat = Some(2); + } if idx % 3 == 0 { htlc.skimmed_fee_msat = Some(1); } @@ -16442,6 +16495,7 @@ mod tests { hop_data: [0; 20 * 65], hmac: [0; 32], }, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -16480,11 +16534,13 @@ mod tests { let mut dummy_add = dummy_holding_cell_add_htlc.clone(); if let HTLCUpdateAwaitingACK::AddHTLC { ref mut blinding_point, + ref mut dummy_hops_skimmed_fee_msat, ref mut skimmed_fee_msat, .. } = &mut dummy_add { *blinding_point = Some(test_utils::pubkey(42 + i)); + *dummy_hops_skimmed_fee_msat = Some(41); *skimmed_fee_msat = Some(42); } else { panic!() diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 5951c6cdbe6..726e4ebdd1e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -427,6 +427,14 @@ pub struct PendingHTLCInfo { /// This is used to allow LSPs to take fees as a part of payments, without the sender having to /// shoulder them. pub skimmed_fee_msat: Option, + /// The fee skimmed by preceding [`DummyTlvs`] hops. + /// + /// Dummy hops are currently applied only to inbound payments. The skimmed fee + /// represents additional revenue for the receiver and is surfaced separately + /// in the corresponding [`Event::PaymentClaimable`]. + /// + /// [`DummyTlvs`]: crate::blinded_path::payment::DummyTlvs + pub dummy_hops_skimmed_fee_msat: Option, /// An experimental field indicating whether our node's reputation would be held accountable /// for the timely resolution of the received HTLC. pub incoming_accountable: bool, @@ -540,6 +548,10 @@ struct ClaimableHTLC { /// The total value received for a payment (sum of all MPP parts if the payment is a MPP). /// Gets set to the amount reported when pushing [`Event::PaymentClaimable`]. total_value_received: Option, + /// The amount (in msats) skimmed off by the dummy hops preceeding the HTLC. + /// This amount is the extra amount that the final receiver earns in addition + /// to the [`Self::value`]. And is set as such in [`Event::PaymentClaimable`]. + dummy_hops_skimmed_fee_msat: Option, /// The extra fee our counterparty skimmed off the top of this HTLC. counterparty_skimmed_fee_msat: Option, } @@ -1180,6 +1192,7 @@ pub(super) enum ChannelReadyOrder { #[derive(Clone, Debug, PartialEq, Eq)] struct ClaimingPayment { amount_msat: u64, + dummy_hops_skimmed_fee_msat: u64, payment_purpose: events::PaymentPurpose, receiver_node_id: PublicKey, htlcs: Vec, @@ -1206,6 +1219,7 @@ impl_writeable_tlv_based!(ClaimingPayment, { // onion_fields was added (and always set for new payments) in 0.0.124 (9, onion_fields, (required: ReadableArgs, amount_msat.0.unwrap())), (11, payment_id, option), + (13, dummy_hops_skimmed_fee_msat, (default_value, 0u64)), }); struct ClaimablePayment { @@ -1363,6 +1377,9 @@ impl ClaimablePayments { debug_assert!(durable_preimage_channel.is_some()); ClaimingPayment { amount_msat: payment.htlcs.iter().map(|source| source.value).sum(), + dummy_hops_skimmed_fee_msat: payment.htlcs.iter() + .map(|source| source.dummy_hops_skimmed_fee_msat.unwrap_or(0)) + .sum(), payment_purpose: payment.purpose, receiver_node_id, htlcs, @@ -5173,7 +5190,8 @@ impl< // delay) once they've send us a commitment_signed! let current_height: u32 = self.best_block.read().unwrap().height; create_recv_pending_htlc_info(decoded_hop, shared_secret, msg.payment_hash, - msg.amount_msat, msg.cltv_expiry, None, allow_underpay, msg.skimmed_fee_msat, + msg.amount_msat, msg.cltv_expiry, None, allow_underpay, + msg.dummy_hops_skimmed_fee_msat, msg.skimmed_fee_msat, msg.accountable.unwrap_or(false), current_height) }, onion_utils::Hop::Forward { .. } | onion_utils::Hop::BlindedForward { .. } => { @@ -5405,6 +5423,7 @@ impl< htlc_source, onion_packet, None, + None, hold_htlc_at_next_hop, false, // Not accountable by default for sender. &self.fee_estimator, @@ -7768,6 +7787,7 @@ impl< Some(phantom_shared_secret), false, None, + None, incoming_accountable, current_height, ); @@ -7883,6 +7903,7 @@ impl< outgoing_cltv_value, routing, skimmed_fee_msat, + dummy_hops_skimmed_fee_msat, incoming_accountable, .. }, @@ -7991,6 +8012,7 @@ impl< *outgoing_cltv_value, htlc_source.clone(), onion_packet.clone(), + *dummy_hops_skimmed_fee_msat, *skimmed_fee_msat, next_blinding_point, *incoming_accountable, @@ -8123,6 +8145,7 @@ impl< incoming_amt_msat, outgoing_amt_msat, skimmed_fee_msat, + dummy_hops_skimmed_fee_msat, .. }, .. @@ -8217,6 +8240,7 @@ impl< total_value_received: None, cltv_expiry, onion_payload, + dummy_hops_skimmed_fee_msat, counterparty_skimmed_fee_msat: skimmed_fee_msat, }; @@ -8322,6 +8346,8 @@ impl< claimable_payment.htlcs.iter().map(|htlc| htlc.value).sum(); claimable_payment.htlcs.iter_mut() .for_each(|htlc| htlc.total_value_received = Some(amount_msat)); + let dummy_hops_skimmed_fee_msat = claimable_payment.htlcs.iter() + .map(|htlc| htlc.dummy_hops_skimmed_fee_msat.unwrap_or(0)).sum(); let counterparty_skimmed_fee_msat = claimable_payment.htlcs.iter() .map(|htlc| htlc.counterparty_skimmed_fee_msat.unwrap_or(0)).sum(); debug_assert!(total_intended_recvd_value.saturating_sub(amount_msat) @@ -8334,6 +8360,7 @@ impl< payment_hash, purpose: $purpose, amount_msat, + dummy_hops_skimmed_fee_msat, counterparty_skimmed_fee_msat, receiving_channel_ids: claimable_payment.receiving_channel_ids(), claim_deadline: Some(earliest_expiry - HTLC_FAIL_BACK_BUFFER), @@ -10176,6 +10203,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .remove(&payment_hash); if let Some(ClaimingPayment { amount_msat, + dummy_hops_skimmed_fee_msat, payment_purpose: purpose, receiver_node_id, htlcs, @@ -10189,6 +10217,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ payment_hash, purpose, amount_msat, + dummy_hops_skimmed_fee_msat, receiver_node_id: Some(receiver_node_id), htlcs, sender_intended_total_msat, @@ -17316,6 +17345,7 @@ impl_writeable_tlv_based!(PendingHTLCInfo, { (9, incoming_amt_msat, option), (10, skimmed_fee_msat, option), (11, incoming_accountable, (default_value, false)), + (12, dummy_hops_skimmed_fee_msat, option), }); impl Writeable for HTLCFailureMsg { @@ -17435,6 +17465,7 @@ fn write_claimable_htlc( (6, htlc.cltv_expiry, required), (8, keysend_preimage, option), (10, htlc.counterparty_skimmed_fee_msat, option), + (12, htlc.dummy_hops_skimmed_fee_msat, option), }); Ok(()) } @@ -17452,6 +17483,7 @@ impl Readable for (ClaimableHTLC, u64) { (6, cltv_expiry, required), (8, keysend_preimage, option), (10, counterparty_skimmed_fee_msat, option), + (12, dummy_hops_skimmed_fee_msat, option), }); let payment_data: Option = payment_data_opt; let value = value_ser.0.unwrap(); @@ -17473,6 +17505,7 @@ impl Readable for (ClaimableHTLC, u64) { onion_payload, cltv_expiry: cltv_expiry.0.unwrap(), counterparty_skimmed_fee_msat, + dummy_hops_skimmed_fee_msat, }, total_msat.0.expect("required field"))) } } @@ -20175,12 +20208,18 @@ impl< payment.inbound_payment_id(&inbound_payment_id_secret.unwrap()); let htlcs = payment.htlcs.iter().map(events::ClaimedHTLC::from).collect(); let sender_intended_total_msat = payment.onion_fields.total_mpp_amount_msat; + let dummy_hops_skimmed_fee_msat = payment + .htlcs + .iter() + .map(|htlc| htlc.dummy_hops_skimmed_fee_msat.unwrap_or(0)) + .sum(); pending_events.push_back(( events::Event::PaymentClaimed { receiver_node_id, payment_hash, purpose: payment.purpose, amount_msat: claimable_amt_msat, + dummy_hops_skimmed_fee_msat, htlcs, sender_intended_total_msat: Some(sender_intended_total_msat), onion_fields: Some(payment.onion_fields), @@ -21226,8 +21265,8 @@ mod tests { let current_height: u32 = node[0].node.best_block.read().unwrap().height; if let Err(crate::ln::channelmanager::InboundHTLCErr { reason, .. }) = create_recv_pending_htlc_info(hop_data, [0; 32], PaymentHash([0; 32]), - sender_intended_amt_msat - extra_fee_msat - 1, 42, None, true, Some(extra_fee_msat), - false, current_height) + sender_intended_amt_msat - extra_fee_msat - 1, 42, None, true, + None, Some(extra_fee_msat), false, current_height) { assert_eq!(reason, LocalHTLCFailureReason::FinalIncorrectHTLCAmount); } else { panic!(); } @@ -21249,7 +21288,7 @@ mod tests { }; let current_height: u32 = node[0].node.best_block.read().unwrap().height; assert!(create_recv_pending_htlc_info(hop_data, [0; 32], PaymentHash([0; 32]), - sender_intended_amt_msat - extra_fee_msat, 42, None, true, Some(extra_fee_msat), + sender_intended_amt_msat - extra_fee_msat, 42, None, true, None, Some(extra_fee_msat), false, current_height).is_ok()); } @@ -21275,7 +21314,7 @@ mod tests { custom_tlvs: Vec::new(), }, shared_secret: SharedSecret::from_bytes([0; 32]), - }, [0; 32], PaymentHash([0; 32]), 100, TEST_FINAL_CLTV + 1, None, true, None, false, current_height); + }, [0; 32], PaymentHash([0; 32]), 100, TEST_FINAL_CLTV + 1, None, true, None, None, false, current_height); // Should not return an error as this condition: // https://github.com/lightning/bolts/blob/4dcc377209509b13cf89a4b91fde7d478f5b46d8/04-onion-routing.md?plain=1#L334 diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 17fbc1fce28..2c39915cef4 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -2307,6 +2307,7 @@ pub fn fail_backward_pending_htlc_upon_channel_failure() { payment_hash, cltv_expiry, onion_routing_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index d88b9a2dc3f..14bb8bf4b7f 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -841,6 +841,7 @@ pub fn do_test_fee_spike_buffer(cfg: Option, htlc_fails: bool) { payment_hash, cltv_expiry: htlc_cltv, onion_routing_packet: onion_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -1088,6 +1089,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_outbound_channel() { payment_hash, cltv_expiry: htlc_cltv, onion_routing_packet: onion_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -1276,6 +1278,7 @@ pub fn test_chan_reserve_violation_inbound_htlc_inbound_chan() { payment_hash: our_payment_hash_1, cltv_expiry: htlc_cltv, onion_routing_packet: onion_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -1663,6 +1666,7 @@ pub fn test_update_add_htlc_bolt2_receiver_check_max_htlc_limit() { payment_hash: our_payment_hash, cltv_expiry: htlc_cltv, onion_routing_packet: onion_packet.clone(), + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -2265,6 +2269,7 @@ pub fn do_test_dust_limit_fee_accounting(can_afford: bool) { payment_hash: payment_hash_0_1, cltv_expiry, onion_routing_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index ac549ddd50c..f6241c7c9b3 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -758,6 +758,8 @@ pub struct UpdateAddHTLC { /// /// [`ChannelConfig::accept_underpaying_htlcs`]: crate::util::config::ChannelConfig::accept_underpaying_htlcs pub skimmed_fee_msat: Option, + /// The extra fees skimmed by the Dummy Hop + pub dummy_hops_skimmed_fee_msat: Option, /// The onion routing packet with encrypted data for the next hop. pub onion_routing_packet: OnionPacket, /// Provided if we are relaying or receiving a payment within a blinded path, to decrypt the onion @@ -3633,6 +3635,7 @@ impl_writeable_msg!(UpdateAddHTLC, { }, { (0, blinding_point, option), (65537, skimmed_fee_msat, option), + (65539, dummy_hops_skimmed_fee_msat, option), // TODO: currently we may fail to read the `ChannelManager` if we write a new even TLV in this message // and then downgrade. Once this is fixed, update the type here to match BOLTs PR 989. (75537, hold_htlc, option), @@ -6151,6 +6154,7 @@ mod tests { payment_hash: PaymentHash([1; 32]), cltv_expiry: 821716, onion_routing_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, @@ -7050,6 +7054,7 @@ mod tests { amount_msat: 1000, payment_hash: PaymentHash([1; 32]), cltv_expiry: 500000, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, onion_routing_packet: msgs::OnionPacket { version: 0, diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index b59a4f8e8d3..c7ff7d1557e 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -272,6 +272,7 @@ pub(super) fn create_fwd_pending_htlc_info( incoming_amt_msat: Some(msg.amount_msat), outgoing_amt_msat: amt_to_forward, outgoing_cltv_value, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, incoming_accountable: msg.accountable.unwrap_or(false), }) @@ -281,7 +282,8 @@ pub(super) fn create_fwd_pending_htlc_info( pub(super) fn create_recv_pending_htlc_info( hop_data: onion_utils::Hop, shared_secret: [u8; 32], payment_hash: PaymentHash, amt_msat: u64, cltv_expiry: u32, phantom_shared_secret: Option<[u8; 32]>, allow_underpay: bool, - counterparty_skimmed_fee_msat: Option, incoming_accountable: bool, current_height: u32 + dummy_hops_skimmed_fee_msat: Option, counterparty_skimmed_fee_msat: Option, + incoming_accountable: bool, current_height: u32 ) -> Result { let ( payment_data, keysend_preimage, custom_tlvs, onion_amt_msat, onion_cltv_expiry, @@ -470,6 +472,7 @@ pub(super) fn create_recv_pending_htlc_info( incoming_amt_msat: Some(amt_msat), outgoing_amt_msat: onion_amt_msat, outgoing_cltv_value: onion_cltv_expiry, + dummy_hops_skimmed_fee_msat, skimmed_fee_msat: counterparty_skimmed_fee_msat, incoming_accountable, }) @@ -555,8 +558,8 @@ pub fn peel_payment_onion let shared_secret = hop.shared_secret().secret_bytes(); create_recv_pending_htlc_info( hop, shared_secret, msg.payment_hash, msg.amount_msat, msg.cltv_expiry, - None, allow_skimmed_fees, msg.skimmed_fee_msat, - msg.accountable.unwrap_or(false), cur_height, + None, allow_skimmed_fees, msg.dummy_hops_skimmed_fee_msat, + msg.skimmed_fee_msat, msg.accountable.unwrap_or(false), cur_height, )? } }) @@ -862,6 +865,7 @@ mod tests { cltv_expiry, payment_hash, onion_routing_packet, + dummy_hops_skimmed_fee_msat: None, skimmed_fee_msat: None, blinding_point: None, hold_htlc: None, diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 9b1b009e93a..759c8f8521e 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -2574,6 +2574,10 @@ pub(super) fn peel_dummy_hop_update_add_htlc Date: Thu, 12 Mar 2026 10:38:02 +0530 Subject: [PATCH 3/6] Account for dummy-hop fees in payment test helpers Update functional test helpers and their callers to derive per-path claim amounts and expected forwarded values when dummy hops are present, including cumulative fees across multiple dummy hops. Without this, tests claiming blinded payments with dummy hops treat the payee's `amount_msat` as the amount forwarded through the route, which no longer matches the wire amounts once dummy-hop fees are applied. --- lightning/src/ln/async_payments_tests.rs | 111 +++++++++++------- lightning/src/ln/blinded_payment_tests.rs | 5 +- lightning/src/ln/functional_test_utils.rs | 134 ++++++++++++++++++++-- lightning/src/ln/offers_tests.rs | 11 +- 4 files changed, 205 insertions(+), 56 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 25522346d9c..0b4ff365cba 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -982,12 +982,14 @@ fn ignore_duplicate_invoice() { check_added_monitors(&sender, 1); let route: &[&[&Node]] = &[&[always_online_node, async_recipient]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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)); + let (res, _) = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice.clone()))); // After paying the static invoice, check that regular invoice received from async recipient is ignored. @@ -1063,7 +1065,7 @@ fn ignore_duplicate_invoice() { let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) .without_clearing_recipient_events() - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); do_pass_along_path(args); let payment_preimage = match get_event!(async_recipient, Event::PaymentClaimable) { @@ -1072,7 +1074,11 @@ fn ignore_duplicate_invoice() { }; // After paying invoice, check that static invoice is ignored. - let res = claim_payment(sender, route[0], payment_preimage); + let res = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, &[route[0]], payment_preimage) + .with_dummy_tlvs(&dummy_tlvs), + ) + .0; assert_eq!(res, Some(PaidBolt12Invoice::Bolt12Invoice(invoice))); sender.onion_messenger.handle_onion_message(always_online_node_id, &static_invoice_om); @@ -1138,12 +1144,14 @@ 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 dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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(&nodes[0], route, keysend_preimage)); + let (res, _) = claim_payment_along_route( + ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } @@ -1375,14 +1383,15 @@ fn async_receive_mpp() { _ => panic!(), }; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(&nodes[0], expected_route[0], amt_msat, payment_hash, ev) .without_claimable_event() - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = match claimable_ev { Event::PaymentClaimable { @@ -1391,11 +1400,10 @@ fn async_receive_mpp() { } => payment_preimage.unwrap(), _ => panic!(), }; - claim_payment_along_route(ClaimAlongRouteArgs::new( - &nodes[0], - expected_route, - keysend_preimage, - )); + claim_payment_along_route( + ClaimAlongRouteArgs::new(&nodes[0], expected_route, keysend_preimage) + .with_dummy_tlvs(&dummy_tlvs), + ); } #[test] @@ -1498,10 +1506,11 @@ fn amount_doesnt_match_invreq() { let payment_hash = extract_payment_hash(&ev); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); do_pass_along_path(args); // Modify the invoice request stored in our outbounds to be the correct one, to make sure the @@ -1526,10 +1535,12 @@ fn amount_doesnt_match_invreq() { 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) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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)); + claim_payment_along_route( + ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); } #[test] @@ -1717,8 +1728,9 @@ fn invalid_async_receive_with_retry( let payment_hash = extract_payment_hash(&ev); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); do_pass_along_path(args); // Fail the HTLC backwards to enable us to more easily modify the now-Retryable outbound to test @@ -1746,7 +1758,7 @@ fn invalid_async_receive_with_retry( let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); do_pass_along_path(args); fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1], &nodes[2]], true); @@ -1759,10 +1771,12 @@ fn invalid_async_receive_with_retry( 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) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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)); + claim_payment_along_route( + ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); } #[cfg_attr(feature = "std", ignore)] @@ -1933,10 +1947,11 @@ fn expired_static_invoice_payment_path() { check_added_monitors(&nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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( @@ -2379,11 +2394,14 @@ fn refresh_static_invoices_for_used_offers() { check_added_monitors(&sender, 1); let route: &[&[&Node]] = &[&[server, recipient]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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)); + let res = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res.0, Some(PaidBolt12Invoice::StaticInvoice(updated_invoice))); } @@ -2714,11 +2732,14 @@ fn invoice_server_is_not_channel_peer() { check_added_monitors(&sender, 1); let route: &[&[&Node]] = &[&[forwarding_node, recipient]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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)); + let res = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res.0, Some(PaidBolt12Invoice::StaticInvoice(invoice))); } @@ -2954,14 +2975,16 @@ fn async_payment_e2e() { check_added_monitors(&sender_lsp, 1); let path: &[&Node] = &[invoice_server, recipient]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); let claimable_ev = do_pass_along_path(args).unwrap(); let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = - claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); + let (res, _) = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } @@ -3191,14 +3214,16 @@ fn intercepted_hold_htlc() { check_added_monitors(&lsp, 1); let path: &[&Node] = &[recipient]; - let args = PassAlongPathArgs::new(lsp, path, amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; + let args = + PassAlongPathArgs::new(lsp, path, amt_msat, payment_hash, ev).with_dummy_tlvs(&dummy_tlvs); let claimable_ev = do_pass_along_path(args).unwrap(); let route: &[&[&Node]] = &[&[lsp, recipient]]; let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = - claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); + let (res, _) = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } @@ -3294,9 +3319,10 @@ fn async_payment_mpp() { let mut events = lsp_a.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 dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(lsp_a, expected_path, amt_msat, payment_hash, ev) .without_claimable_event() - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); do_pass_along_path(args); lsp_b.node.process_pending_htlc_forwards(); @@ -3305,7 +3331,7 @@ 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_b, expected_path, amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = match claimable_ev { @@ -3317,7 +3343,10 @@ fn async_payment_mpp() { }; let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]]; - claim_payment_along_route(ClaimAlongRouteArgs::new(sender, expected_route, keysend_preimage)); + claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, expected_route, keysend_preimage) + .with_per_path_dummy_tlvs(&vec![dummy_tlvs.to_vec(); expected_route.len()]), + ); } #[test] @@ -3441,13 +3470,15 @@ fn release_htlc_races_htlc_onion_decode() { check_added_monitors(&sender_lsp, 1); let path: &[&Node] = &[invoice_server, recipient]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); let claimable_ev = do_pass_along_path(args).unwrap(); let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = - claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); + let (res, _) = claim_payment_along_route( + ClaimAlongRouteArgs::new(sender, route, keysend_preimage).with_dummy_tlvs(&dummy_tlvs), + ); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d945afa811a..71c2a325edb 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -261,7 +261,10 @@ fn one_hop_blinded_path_with_dummy_hops() { .with_payment_secret(payment_secret); do_pass_along_path(args); - claim_payment(&nodes[0], &[&nodes[1]], payment_preimage); + let path: &[&[&Node<'_, '_, '_>]] = &[&[&nodes[1]]]; + let claim_args = + ClaimAlongRouteArgs::new(&nodes[0], path, payment_preimage).with_dummy_tlvs(&dummy_tlvs); + claim_payment_along_route(claim_args); } #[test] diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 641842ddaff..51eb227a762 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -1312,6 +1312,24 @@ fn check_claimed_htlcs_match_route<'a, 'b, 'c>( } } +fn claimed_htlc_value_msats_for_paths<'a, 'b, 'c>( + origin_node: &Node<'a, 'b, 'c>, expected_paths: &[&[&Node<'a, 'b, 'c>]], htlcs: &[ClaimedHTLC], +) -> Vec { + let mut remaining_htlcs: Vec<&ClaimedHTLC> = htlcs.iter().collect(); + + expected_paths + .iter() + .map(|path| { + let idx = remaining_htlcs + .iter() + .position(|htlc| claimed_htlc_matches_path(origin_node, path, htlc)) + .expect("each path must have a unique matching claimed HTLC"); + + remaining_htlcs.remove(idx).value_msat + }) + .collect() +} + pub fn _reload_node<'a, 'b, 'c>( node: &'a Node<'a, 'b, 'c>, config: UserConfig, chanman_encoded: &[u8], monitors_encoded: &[&[u8]], _reconstruct_manager_from_monitors: Option, @@ -3673,6 +3691,7 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option ref payment_hash, ref purpose, amount_msat, + dummy_hops_skimmed_fee_msat, receiver_node_id, ref receiving_channel_ids, claim_deadline, @@ -3687,6 +3706,16 @@ pub fn do_pass_along_path<'a, 'b, 'c>(args: PassAlongPathArgs) -> Option onion_fields.as_ref().unwrap().payment_metadata, payment_metadata ); + // Freshly generated `PaymentClaimable` events include one + // `receiving_channel_ids` entry per inbound HTLC, without + // deduplicating by channel, so `len() == 1` implies a + // single-part payment here. + if receiving_channel_ids.len() == 1 { + assert_eq!( + dummy_hops_total_fee_msat(recv_value, &dummy_tlvs), + *dummy_hops_skimmed_fee_msat + ); + } match &purpose { PaymentPurpose::Bolt11InvoicePayment { payment_preimage, @@ -3881,6 +3910,7 @@ pub fn do_claim_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { 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 dummy_tlvs: Vec>, pub expected_extra_fees: Vec, /// A one-off adjustment used only in tests to account for an existing /// fee-handling trade-off in LDK. @@ -3927,6 +3957,7 @@ impl<'a, 'b, 'c, 'd> ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { Self { origin_node, expected_paths, + dummy_tlvs: vec![Vec::new(); expected_paths.len()], expected_extra_fees: vec![0; expected_paths.len()], expected_extra_total_fees_msat: 0, expected_min_htlc_overpay: vec![0; expected_paths.len()], @@ -3960,6 +3991,39 @@ impl<'a, 'b, 'c, 'd> ClaimAlongRouteArgs<'a, 'b, 'c, 'd> { self.custom_tlvs = custom_tlvs; self } + pub fn with_dummy_tlvs(mut self, dummy_tlvs: &[DummyTlvs]) -> Self { + self.dummy_tlvs = vec![dummy_tlvs.to_vec(); self.expected_paths.len()]; + self + } + pub fn with_per_path_dummy_tlvs(mut self, dummy_tlvs: &[Vec]) -> Self { + assert_eq!(dummy_tlvs.len(), self.expected_paths.len()); + self.dummy_tlvs = dummy_tlvs.to_vec(); + self + } +} + +/// Computes the total fees skimmed by all dummy hops for a single received HTLC. +/// +/// The provided `final_amount_msat` is the amount that reaches the recipient after all dummy hops +/// have been traversed. Because dummy hops each charge fees on the amount forwarded through them, +/// their fees must be accumulated in reverse order, with each hop's fee increasing the amount that +/// the previous dummy hop forwarded. +fn dummy_hops_total_fee_msat(final_amount_msat: u64, dummy_tlvs: &[DummyTlvs]) -> u64 { + let mut amount_msat = final_amount_msat; + let mut total_fee_msat = 0; + + // The last dummy hop forwards directly to the receiver, so work backwards from the final + // amount that reaches the recipient and accumulate the fees each earlier dummy hop must cover. + for tlvs in dummy_tlvs.iter().rev() { + let base_fee_msat = tlvs.payment_relay.fee_base_msat as u64; + let proportional_fee_millionths = tlvs.payment_relay.fee_proportional_millionths as u64; + let fee_msat = (amount_msat * proportional_fee_millionths / 1_000_000) + base_fee_msat; + + total_fee_msat += fee_msat; + amount_msat += fee_msat; + } + + total_fee_msat } macro_rules! single_fulfill_commit_from_ev { @@ -3994,9 +4058,7 @@ macro_rules! single_fulfill_commit_from_ev { pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { let claim_event = args.expected_paths[0].last().unwrap().node.get_and_clear_pending_events(); assert_eq!(claim_event.len(), 1, "{claim_event:?}"); - #[allow(unused)] - let mut fwd_amt_msat = 0; - match claim_event[0] { + let per_path_claim_amt_msats = match claim_event[0] { Event::PaymentClaimed { purpose: PaymentPurpose::SpontaneousPayment(preimage) @@ -4004,16 +4066,26 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { | PaymentPurpose::Bolt12OfferPayment { payment_preimage: Some(preimage), .. } | PaymentPurpose::Bolt12RefundPayment { payment_preimage: Some(preimage), .. }, amount_msat, + dummy_hops_skimmed_fee_msat, ref htlcs, ref onion_fields, .. } => { - assert_eq!(preimage, args.payment_preimage); assert_eq!(htlcs.len(), args.expected_paths.len()); // One per path. + assert_eq!(args.dummy_tlvs.len(), args.expected_paths.len()); + let expected_dummy_hops_skimmed_fee_msat = htlcs + .iter() + .zip(args.dummy_tlvs.iter()) + .map(|(htlc, path_dummy_tlvs)| { + dummy_hops_total_fee_msat(htlc.value_msat, path_dummy_tlvs) + }) + .sum::(); + assert_eq!(preimage, args.payment_preimage); assert_eq!(htlcs.iter().map(|h| h.value_msat).sum::(), amount_msat); + assert_eq!(dummy_hops_skimmed_fee_msat, expected_dummy_hops_skimmed_fee_msat); assert_eq!(onion_fields.as_ref().unwrap().custom_tlvs, args.custom_tlvs); check_claimed_htlcs_match_route(args.origin_node, args.expected_paths, htlcs); - fwd_amt_msat = amount_msat; + claimed_htlc_value_msats_for_paths(args.origin_node, args.expected_paths, htlcs) }, Event::PaymentClaimed { purpose: @@ -4022,19 +4094,29 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { | PaymentPurpose::Bolt12RefundPayment { .. }, payment_hash, amount_msat, + dummy_hops_skimmed_fee_msat, ref htlcs, ref onion_fields, .. } => { - assert_eq!(&payment_hash.0, &Sha256::hash(&args.payment_preimage.0)[..]); assert_eq!(htlcs.len(), args.expected_paths.len()); // One per path. + assert_eq!(args.dummy_tlvs.len(), args.expected_paths.len()); + let expected_dummy_hops_skimmed_fee_msat = htlcs + .iter() + .zip(args.dummy_tlvs.iter()) + .map(|(htlc, path_dummy_tlvs)| { + dummy_hops_total_fee_msat(htlc.value_msat, path_dummy_tlvs) + }) + .sum::(); + assert_eq!(&payment_hash.0, &Sha256::hash(&args.payment_preimage.0)[..]); assert_eq!(htlcs.iter().map(|h| h.value_msat).sum::(), amount_msat); + assert_eq!(dummy_hops_skimmed_fee_msat, expected_dummy_hops_skimmed_fee_msat); assert_eq!(onion_fields.as_ref().unwrap().custom_tlvs, args.custom_tlvs); check_claimed_htlcs_match_route(args.origin_node, args.expected_paths, htlcs); - fwd_amt_msat = amount_msat; + claimed_htlc_value_msats_for_paths(args.origin_node, args.expected_paths, htlcs) }, _ => panic!(), - } + }; check_added_monitors(args.expected_paths[0].last().unwrap(), args.expected_paths.len()); @@ -4063,17 +4145,35 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { } } - pass_claimed_payment_along_route_from_ev(fwd_amt_msat, per_path_msgs, args) + pass_claimed_payment_along_route_from_ev_with_path_amounts( + per_path_claim_amt_msats, + per_path_msgs, + args, + ) } pub fn pass_claimed_payment_along_route_from_ev( each_htlc_claim_amt_msat: u64, + per_path_msgs: Vec<((msgs::UpdateFulfillHTLC, Vec), PublicKey)>, + args: ClaimAlongRouteArgs, +) -> u64 { + let per_path_claim_amt_msats = vec![each_htlc_claim_amt_msat; args.expected_paths.len()]; + pass_claimed_payment_along_route_from_ev_with_path_amounts( + per_path_claim_amt_msats, + per_path_msgs, + args, + ) +} + +fn pass_claimed_payment_along_route_from_ev_with_path_amounts( + per_path_claim_amt_msats: Vec, mut per_path_msgs: Vec<((msgs::UpdateFulfillHTLC, Vec), PublicKey)>, args: ClaimAlongRouteArgs, ) -> u64 { let ClaimAlongRouteArgs { origin_node, expected_paths, + dummy_tlvs, expected_extra_fees, expected_min_htlc_overpay, skip_last, @@ -4081,13 +4181,23 @@ pub fn pass_claimed_payment_along_route_from_ev( allow_1_msat_fee_overpay, .. } = args; + assert_eq!(dummy_tlvs.len(), expected_paths.len()); - let mut fwd_amt_msat = each_htlc_claim_amt_msat; let mut expected_total_fee_msat = 0; - for (i, (expected_route, (path_msgs, next_hop))) in - expected_paths.iter().zip(per_path_msgs.drain(..)).enumerate() + for (i, (((expected_route, path_claim_amt_msat), path_dummy_tlvs), (path_msgs, next_hop))) in + expected_paths + .iter() + .zip(per_path_claim_amt_msats.into_iter()) + .zip(dummy_tlvs.into_iter()) + .zip(per_path_msgs.drain(..)) + .enumerate() { + let mut fwd_amt_msat = path_claim_amt_msat; + let dummy_hops_fee_msat = dummy_hops_total_fee_msat(fwd_amt_msat, &path_dummy_tlvs); + expected_total_fee_msat += dummy_hops_fee_msat; + fwd_amt_msat += dummy_hops_fee_msat; + let mut next_msgs = Some(path_msgs); let mut expected_next_node = next_hop; diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..c4e128eae49 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -244,7 +244,8 @@ fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( node, &expected_paths, payment_preimage, - ); + ) + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); if let Some(extra) = expected_extra_fees_msat { args = args.with_expected_extra_total_fees_msat(extra); @@ -2454,7 +2455,11 @@ fn rejects_keysend_to_non_static_invoice_path() { _ => panic!() }; - claim_payment(&nodes[0], &[&nodes[1]], payment_preimage); + let path: &[&Node<'_, '_, '_>] = &[&nodes[1]]; + let dummy_tlvs = [DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]; + claim_payment_along_route( + ClaimAlongRouteArgs::new(&nodes[0], &[path], payment_preimage).with_dummy_tlvs(&dummy_tlvs), + ); expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); // Time out the payment from recent payments so we can attempt to pay it again via keysend. @@ -2481,7 +2486,7 @@ 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 }) - .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + .with_dummy_tlvs(&dummy_tlvs); 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_malformed_htlc(nodes[1].node.get_our_node_id(), &updates.update_fail_malformed_htlcs[0]); From 617be0136ff4558037a355e1d71a1e6a9dbea752 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 12 Mar 2026 10:23:48 +0530 Subject: [PATCH 4/6] Use realistic relay defaults for DummyTlvs Replace zeroed dummy-hop relay parameters with non-trivial default fee, CLTV, and HTLC minimum values, and propagate these defaults into blinded payinfo construction and routing tests. Dummy hops only preserve their privacy value if they behave like plausible forwarding hops. Realistic defaults ensure hidden hops affect fees and CLTV like real relays, instead of standing out as zero-cost padding. --- lightning/src/blinded_path/payment.rs | 56 +++++++++++++++++++++--- lightning/src/ln/async_payments_tests.rs | 9 +++- lightning/src/routing/router.rs | 14 ++++-- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 03b676adc92..c2b7dbf2dc8 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -392,15 +392,57 @@ pub struct DummyTlvs { 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 }; +// Default parameters used for dummy hops in blinded paths. +// +// These values are chosen to resemble typical forwarding hops while remaining +// stable and predictable for tests. - let payment_constraints = - PaymentConstraints { max_cltv_expiry: u32::MAX, htlc_minimum_msat: 0 }; +/// Adds a realistic but stable CLTV cost per dummy hop. +/// +/// The router folds this into the blinded path's advertised CLTV delta, so it must +/// be non-trivial enough to model hidden relay latency while remaining predictable +/// for tests and callers reasoning about timeout budgets. +pub(crate) const DEFAULT_DUMMY_HOP_CLTV_EXPIRY_DELTA: u16 = 80; - Self { payment_relay, payment_constraints } +/// Keeps dummy-hop fee aggregation linear and deterministic. +/// +/// A non-zero proportional fee would compound across dummy hops and introduce rounding +/// effects into blinded payinfo. The base fee still makes dummy hops look like plausible relays. +pub(crate) const DEFAULT_DUMMY_HOP_FEE_PROPORTIONAL_MILLIONTHS: u32 = 0; + +/// Matches the default relay base fee used by the standard test channel configuration. +/// +/// This keeps dummy hops aligned with typical forwarding hops in tests rather than +/// making them appear unrealistically cheap or expensive. +pub(crate) const DEFAULT_DUMMY_HOP_FEE_BASE_MSAT: u32 = 1000; + +/// Leaves the dummy hop's absolute CLTV ceiling effectively unbounded by default. +/// +/// `PaymentConstraints::max_cltv_expiry` is interpreted as an absolute block height, so using a +/// fixed low value here would cause dummy hops to reject otherwise-valid payments on live chains. +pub(crate) const DEFAULT_DUMMY_HOP_MAX_CLTV_EXPIRY: u32 = u32::MAX; + +/// Matches the default test channel HTLC minimum. +/// +/// The router takes the max of the introduction node's inbound HTLC minimum and this value, +/// so keeping them aligned prevents dummy hops from unexpectedly tightening or loosening +/// admission. +pub(crate) const DEFAULT_DUMMY_HOP_HTLC_MINIMUM_MSAT: u64 = 1000; + +impl Default for DummyTlvs { + /// Returns the documented default relay requirements and constraints for synthetic hops. + fn default() -> Self { + Self { + payment_relay: PaymentRelay { + cltv_expiry_delta: DEFAULT_DUMMY_HOP_CLTV_EXPIRY_DELTA, + fee_proportional_millionths: DEFAULT_DUMMY_HOP_FEE_PROPORTIONAL_MILLIONTHS, + fee_base_msat: DEFAULT_DUMMY_HOP_FEE_BASE_MSAT, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: DEFAULT_DUMMY_HOP_MAX_CLTV_EXPIRY, + htlc_minimum_msat: DEFAULT_DUMMY_HOP_HTLC_MINIMUM_MSAT, + }, + } } } diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 0b4ff365cba..278201dcf33 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -11,7 +11,9 @@ use crate::blinded_path::message::{ BlindedMessagePath, MessageContext, NextMessageHop, OffersContext, }; use crate::blinded_path::payment::{AsyncBolt12OfferContext, BlindedPaymentTlvs}; -use crate::blinded_path::payment::{DummyTlvs, PaymentContext}; +use crate::blinded_path::payment::{ + DummyTlvs, PaymentContext, DEFAULT_DUMMY_HOP_CLTV_EXPIRY_DELTA, +}; use crate::chain::channelmonitor::{HTLC_FAIL_BACK_BUFFER, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::events::{ Event, EventsProvider, HTLCHandlingFailureReason, HTLCHandlingFailureType, PaidBolt12Invoice, @@ -3034,11 +3036,16 @@ fn held_htlc_timeout() { // Extract the release_htlc_om, but don't deliver it to the sender's LSP. let _ = extract_release_htlc_oms(recipient, &[sender, sender_lsp, invoice_server]); + // Dummy hops add to the blinded path's total advertised CLTV delta. + let additional_cltv_expiry = + DEFAULT_PAYMENT_DUMMY_HOPS as u32 * DEFAULT_DUMMY_HOP_CLTV_EXPIRY_DELTA as u32; + // Connect blocks to the sender's LSP until they timeout the HTLC. connect_blocks( sender_lsp, MIN_CLTV_EXPIRY_DELTA as u32 + TEST_FINAL_CLTV + + additional_cltv_expiry + HTLC_FAIL_BACK_BUFFER + LATENCY_GRACE_PERIOD_BLOCKS, ); diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 0c0d14b43fd..4d02d5562ac 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -150,6 +150,7 @@ where let network_graph = self.network_graph.deref().read_only(); let is_recipient_announced = network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)); + let dummy_tlvs = DummyTlvs::default(); let paths = first_hops.into_iter() .filter(|details| details.counterparty.features.supports_route_blinding()) @@ -176,12 +177,17 @@ where None => return None, }; - let cltv_expiry_delta = payment_relay.cltv_expiry_delta as u32; + let cltv_expiry_delta = payment_relay.cltv_expiry_delta as u32 + + dummy_tlvs.payment_relay.cltv_expiry_delta as u32 * DEFAULT_PAYMENT_DUMMY_HOPS as u32; + let htlc_minimum_msat = cmp::max( + details.inbound_htlc_minimum_msat.unwrap_or(0), + dummy_tlvs.payment_constraints.htlc_minimum_msat, + ); let payment_constraints = PaymentConstraints { max_cltv_expiry: tlvs.payment_constraints .max_cltv_expiry .saturating_add(cltv_expiry_delta), - htlc_minimum_msat: details.inbound_htlc_minimum_msat.unwrap_or(0), + htlc_minimum_msat, }; Some(PaymentForwardNode { tlvs: ForwardTlvs { @@ -197,7 +203,7 @@ where }) .map(|forward_node| { BlindedPaymentPath::new_with_dummy_hops( - &[forward_node], recipient, &[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS], + &[forward_node], recipient, &[dummy_tlvs; DEFAULT_PAYMENT_DUMMY_HOPS], local_node_receive_key, tlvs.clone(), u64::MAX, MIN_FINAL_CLTV_EXPIRY_DELTA, &self.entropy_source, secp_ctx ) }) @@ -209,7 +215,7 @@ where _ => { if network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)) { BlindedPaymentPath::new_with_dummy_hops( - &[], recipient, &[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS], + &[], recipient, &[dummy_tlvs; 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 { From 66b2dd3290277a2888116049f413b60ccdc47041 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 21 Mar 2026 20:22:42 +0530 Subject: [PATCH 5/6] Test payinfo handling for dummy-hop blinded paths Expand dummy-hop blinded payment tests to cover advertised payinfo behavior end to end. Introduce an underpayment case that strips dummy-hop relay fees from the sender's payinfo, and assert that receive-path construction exposes the expected aggregated base fee and CLTV delta for non-default dummy hops. Also verify that under-advertising the blinded path HTLC minimum causes the receiver to reject the payment while processing hidden dummy hops. Co-Authored-By: OpenAI Codex --- lightning/src/ln/blinded_payment_tests.rs | 215 +++++++++++++++++++++- 1 file changed, 213 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 71c2a325edb..5381b8bfe36 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -219,7 +219,30 @@ fn one_hop_blinded_path_with_dummy_hops() { 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 dummy_tlvs = [ + DummyTlvs { + payment_relay: PaymentRelay { + cltv_expiry_delta: 21, + fee_proportional_millionths: 0, + fee_base_msat: 750, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: chan_upd.htlc_minimum_msat, + }, + }, + DummyTlvs { + payment_relay: PaymentRelay { + cltv_expiry_delta: 33, + fee_proportional_millionths: 0, + fee_base_msat: 1_250, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: chan_upd.htlc_minimum_msat, + }, + }, + ]; let mut secp_ctx = Secp256k1::new(); let blinded_path = BlindedPaymentPath::new_with_dummy_hops( @@ -234,6 +257,9 @@ fn one_hop_blinded_path_with_dummy_hops() { &secp_ctx, ) .unwrap(); + assert_eq!(blinded_path.payinfo.fee_base_msat, 2_000); + assert_eq!(blinded_path.payinfo.fee_proportional_millionths, 0); + assert_eq!(blinded_path.payinfo.cltv_expiry_delta, TEST_FINAL_CLTV as u16 + 21 + 33); let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::blinded(vec![blinded_path]), @@ -254,11 +280,14 @@ fn one_hop_blinded_path_with_dummy_hops() { 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 payment_event = SendEvent::from_event(ev.clone()); + let expected_claimable_cltv = payment_event.msgs[0].cltv_expiry - (21 + 33) as u32; 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); + .with_payment_secret(payment_secret) + .with_payment_claimable_cltv(expected_claimable_cltv); do_pass_along_path(args); let path: &[&[&Node<'_, '_, '_>]] = &[&[&nodes[1]]]; @@ -267,6 +296,188 @@ fn one_hop_blinded_path_with_dummy_hops() { claim_payment_along_route(claim_args); } +#[test] +fn one_hop_blinded_path_with_dummy_hops_underpaid() { + 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_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(); + // Advertise a receive path that includes dummy-hop relay requirements. + 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(); + assert!(blinded_path.payinfo.fee_base_msat > 0); + assert!(blinded_path.payinfo.cltv_expiry_delta > TEST_FINAL_CLTV as u16); + + let mut route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::blinded(vec![blinded_path]), + amt_msat, + ); + if let Payee::Blinded { ref mut route_hints, .. } = route_params.payment_params.payee { + // Simulate a payer that funds only the recipient amount, not the dummy-hop fees. + route_hints[0].payinfo.fee_base_msat = 0; + route_hints[0].payinfo.fee_proportional_millionths = 0; + } else { + panic!(); + } + + nodes[0] + .node + .send_payment( + payment_hash, + RecipientOnionFields::spontaneous_empty(amt_msat), + 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) + // The receiver rejects the HTLC while processing the hidden dummy hops. + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + .with_payment_secret(payment_secret) + .with_dummy_tlvs(&dummy_tlvs); + do_pass_along_path(args); + + let mut updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); + // Blinded receive failures are surfaced to the sender as malformed onion blinding errors. + 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, + false, + PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionBlinding, &[0; 32]), + ); +} + +#[test] +fn one_hop_blinded_path_with_dummy_hops_underadvertised_htlc_minimum_fails() { + 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 = 5_000; + let (_, 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 { + payment_relay: PaymentRelay { + cltv_expiry_delta: 18, + fee_proportional_millionths: 0, + fee_base_msat: 500, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat + 2_000, + }, + }]; + + 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(); + assert!(blinded_path.payinfo.htlc_minimum_msat > amt_msat); + + let mut route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::blinded(vec![blinded_path]), + amt_msat, + ); + if let Payee::Blinded { ref mut route_hints, .. } = route_params.payment_params.payee { + route_hints[0].payinfo.htlc_minimum_msat = amt_msat; + } else { + panic!(); + } + + nodes[0] + .node + .send_payment( + payment_hash, + RecipientOnionFields::spontaneous_empty(amt_msat), + 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 payment_event = SendEvent::from_event(ev); + nodes[1].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &payment_event.msgs[0]); + check_added_monitors(&nodes[1], 0); + do_commitment_signed_dance(&nodes[1], &nodes[0], &payment_event.commitment_msg, false, false); + while nodes[1].node.needs_pending_htlc_processing() { + nodes[1].node.process_pending_htlc_forwards(); + } + expect_htlc_handling_failed_destinations!( + nodes[1].node.get_and_clear_pending_events(), + &[HTLCHandlingFailureType::InvalidOnion] + ); + let mut updates = get_htlc_update_msgs(&nodes[1], &nodes[0].node.get_our_node_id()); + assert_eq!(updates.update_fail_htlcs.len() + updates.update_fail_malformed_htlcs.len(), 1); + check_added_monitors(&nodes[1], 1); +} + #[test] #[rustfmt::skip] fn mpp_to_one_hop_blinded_path() { From 0badc510c013429d748086c06241fc75574e9c43 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 21 Mar 2026 20:22:53 +0530 Subject: [PATCH 6/6] Make `BlindedPaymentPath::new_with_dummy_hops` public Allow external callers to construct blinded receive paths with dummy hops via `new_with_dummy_hops`. --- lightning/src/blinded_path/payment.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index c2b7dbf2dc8..e05a3aeea88 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -109,7 +109,6 @@ impl BlindedPaymentPath { /// Errors if: /// * [`BlindedPayInfo`] calculation results in an integer overflow /// * any unknown features are required in the provided [`ForwardTlvs`] - // TODO: make all payloads the same size with padding + add dummy hops pub fn new( intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey, local_node_receive_key: ReceiveAuthKey, payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64, @@ -136,10 +135,7 @@ impl BlindedPaymentPath { /// /// 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< + pub fn new_with_dummy_hops< ES: EntropySource, T: secp256k1::Signing + secp256k1::Verification, >(