From 66e59f958f2bc385ef50332410ee6f3285f1ef26 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 15 Sep 2025 19:58:44 +0000 Subject: [PATCH 1/3] Make `InFlightHtlcs` a named-field struct instead of a tuple struct In the next commit we'll track blinded path in-flights in `InFlightHtlcs` as well as unblinded hops, so here we convert `InFlightHtlcs` to a named-field struct so that we can add more fields to it with less confusion. --- lightning/src/routing/router.rs | 43 ++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index e3443b5e45a..88672867186 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -350,23 +350,25 @@ where /// A data structure for tracking in-flight HTLCs. May be used during pathfinding to account for /// in-use channel liquidity. #[derive(Clone)] -pub struct InFlightHtlcs( +pub struct InFlightHtlcs { // A map with liquidity value (in msat) keyed by a short channel id and the direction the HTLC // is traveling in. The direction boolean is determined by checking if the HTLC source's public // key is less than its destination. See `InFlightHtlcs::used_liquidity_msat` for more // details. - HashMap<(u64, bool), u64>, -); + unblinded_hops: HashMap<(u64, bool), u64>, +} impl InFlightHtlcs { /// Constructs an empty `InFlightHtlcs`. - #[rustfmt::skip] - pub fn new() -> Self { InFlightHtlcs(new_hash_map()) } + pub fn new() -> Self { + InFlightHtlcs { unblinded_hops: new_hash_map() } + } /// Takes in a path with payer's node id and adds the path's details to `InFlightHtlcs`. - #[rustfmt::skip] pub fn process_path(&mut self, path: &Path, payer_node_id: PublicKey) { - if path.hops.is_empty() { return }; + if path.hops.is_empty() { + return; + } let mut cumulative_msat = 0; if let Some(tail) = &path.blinded_tail { @@ -378,17 +380,17 @@ impl InFlightHtlcs { // the router excludes the payer node. In the following lines, the payer's information is // hardcoded with an inflight value of 0 so that we can correctly represent the first hop // in our sliding window of two. - let reversed_hops_with_payer = path.hops.iter().rev().skip(1) - .map(|hop| hop.pubkey) - .chain(core::iter::once(payer_node_id)); + let reversed_hops = path.hops.iter().rev().skip(1).map(|hop| hop.pubkey); + let reversed_hops_with_payer = reversed_hops.chain(core::iter::once(payer_node_id)); // Taking the reversed vector from above, we zip it with just the reversed hops list to // work "backwards" of the given path, since the last hop's `fee_msat` actually represents // the total amount sent. for (next_hop, prev_hop) in path.hops.iter().rev().zip(reversed_hops_with_payer) { cumulative_msat += next_hop.fee_msat; - self.0 - .entry((next_hop.short_channel_id, NodeId::from_pubkey(&prev_hop) < NodeId::from_pubkey(&next_hop.pubkey))) + let direction = NodeId::from_pubkey(&prev_hop) < NodeId::from_pubkey(&next_hop.pubkey); + self.unblinded_hops + .entry((next_hop.short_channel_id, direction)) .and_modify(|used_liquidity_msat| *used_liquidity_msat += cumulative_msat) .or_insert(cumulative_msat); } @@ -399,7 +401,7 @@ impl InFlightHtlcs { pub fn add_inflight_htlc( &mut self, source: &NodeId, target: &NodeId, channel_scid: u64, used_msat: u64, ) { - self.0 + self.unblinded_hops .entry((channel_scid, source < target)) .and_modify(|used_liquidity_msat| *used_liquidity_msat += used_msat) .or_insert(used_msat); @@ -410,19 +412,20 @@ impl InFlightHtlcs { pub fn used_liquidity_msat( &self, source: &NodeId, target: &NodeId, channel_scid: u64, ) -> Option { - self.0.get(&(channel_scid, source < target)).map(|v| *v) + self.unblinded_hops.get(&(channel_scid, source < target)).map(|v| *v) } } impl Writeable for InFlightHtlcs { - #[rustfmt::skip] - fn write(&self, writer: &mut W) -> Result<(), io::Error> { self.0.write(writer) } + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + self.unblinded_hops.write(writer) + } } impl Readable for InFlightHtlcs { fn read(reader: &mut R) -> Result { - let infight_map: HashMap<(u64, bool), u64> = Readable::read(reader)?; - Ok(Self(infight_map)) + let unblinded_hops: HashMap<(u64, bool), u64> = Readable::read(reader)?; + Ok(Self { unblinded_hops }) } } @@ -8002,8 +8005,8 @@ mod tests { }), }; inflight_htlcs.process_path(&path, ln_test_utils::pubkey(44)); - assert_eq!(*inflight_htlcs.0.get(&(42, true)).unwrap(), 301); - assert_eq!(*inflight_htlcs.0.get(&(43, false)).unwrap(), 201); + assert_eq!(*inflight_htlcs.unblinded_hops.get(&(42, true)).unwrap(), 301); + assert_eq!(*inflight_htlcs.unblinded_hops.get(&(43, false)).unwrap(), 201); } #[test] From a5d07675f002fe86c134d25e6785dae9f08c5790 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 15 Sep 2025 20:18:59 +0000 Subject: [PATCH 2/3] Drop `Readable`/`Writeable` for `InFlightHtlcs` Its not clear why this needs to be serialized at all (it should generally always be generated live when needed), but its serialization isn't forward-compatible, so really needs to be dropped so that we can add additional field(s) to `InFlightHtlcs`. --- lightning/src/routing/router.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 88672867186..69d677b7bf5 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -416,19 +416,6 @@ impl InFlightHtlcs { } } -impl Writeable for InFlightHtlcs { - fn write(&self, writer: &mut W) -> Result<(), io::Error> { - self.unblinded_hops.write(writer) - } -} - -impl Readable for InFlightHtlcs { - fn read(reader: &mut R) -> Result { - let unblinded_hops: HashMap<(u64, bool), u64> = Readable::read(reader)?; - Ok(Self { unblinded_hops }) - } -} - /// A hop in a route, and additional metadata about it. "Hop" is defined as a node and the channel /// that leads to it. #[derive(Clone, Debug, Hash, PartialEq, Eq)] From 4cbd556461a3ec90e344273f0dc06b4102d29a49 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 15 Sep 2025 21:46:18 +0000 Subject: [PATCH 3/3] Properly consider blinded paths in `InFlightHtlcs` When paying a BOLT 12 invoice an amount greater than any one of multiple blinded paths, we need to track how much is in-flight across the paths when retrying. Here we do so, tracking blinded path usage in a new field in `InFlightHtlcs`. Fixes #2737 --- lightning/src/routing/router.rs | 151 +++++++++++++++++++++++++++++-- lightning/src/routing/scoring.rs | 13 ++- 2 files changed, 156 insertions(+), 8 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 69d677b7bf5..ee5e63d1a71 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -323,6 +323,18 @@ where type ScoreParams = ::ScoreParams; #[rustfmt::skip] fn channel_penalty_msat(&self, candidate: &CandidateRouteHop, usage: ChannelUsage, score_params: &Self::ScoreParams) -> u64 { + if let CandidateRouteHop::Blinded(blinded_candidate) = candidate { + if let Some(used_liquidity) = self.inflight_htlcs.used_blinded_liquidity_msat( + *blinded_candidate.source_node_id, blinded_candidate.hint.blinding_point(), + ) { + let usage = ChannelUsage { + inflight_htlc_msat: usage.inflight_htlc_msat.saturating_add(used_liquidity), + ..usage + }; + + return self.scorer.channel_penalty_msat(candidate, usage, score_params); + } + } let target = match candidate.target() { Some(target) => target, None => return self.scorer.channel_penalty_msat(candidate, usage, score_params), @@ -356,12 +368,16 @@ pub struct InFlightHtlcs { // key is less than its destination. See `InFlightHtlcs::used_liquidity_msat` for more // details. unblinded_hops: HashMap<(u64, bool), u64>, + /// A map with liquidity value (in msat) keyed by the introduction point of a blinded path and + /// the blinding point. In general blinding points should be globally unique, but just in case + /// we add the introduction point as well. + blinded_hops: HashMap<(NodeId, PublicKey), u64>, } impl InFlightHtlcs { /// Constructs an empty `InFlightHtlcs`. pub fn new() -> Self { - InFlightHtlcs { unblinded_hops: new_hash_map() } + InFlightHtlcs { unblinded_hops: new_hash_map(), blinded_hops: new_hash_map() } } /// Takes in a path with payer's node id and adds the path's details to `InFlightHtlcs`. @@ -373,6 +389,19 @@ impl InFlightHtlcs { let mut cumulative_msat = 0; if let Some(tail) = &path.blinded_tail { cumulative_msat += tail.final_value_msat; + if tail.hops.len() > 1 { + // Single-hop blinded paths aren't really "blinded" paths, as they terminate at the + // introduction point. In that case, we don't need to track anything. + let last_hop = path.hops.last().unwrap(); + let intro_node = NodeId::from_pubkey(&last_hop.pubkey); + // The amount we send into the blinded path is the sum of the blinded path final + // amount and the fee we pay in it, which is the `fee_msat` of the last hop. + let blinded_path_sent_amt = last_hop.fee_msat + cumulative_msat; + self.blinded_hops + .entry((intro_node, tail.blinding_point)) + .and_modify(|used_liquidity_msat| *used_liquidity_msat += blinded_path_sent_amt) + .or_insert(blinded_path_sent_amt); + } } // total_inflight_map needs to be direction-sensitive when keeping track of the HTLC value @@ -414,6 +443,13 @@ impl InFlightHtlcs { ) -> Option { self.unblinded_hops.get(&(channel_scid, source < target)).map(|v| *v) } + + /// Returns liquidity in msat given the blinded path introduction point and blinding point. + pub fn used_blinded_liquidity_msat( + &self, introduction_point: NodeId, blinding_point: PublicKey, + ) -> Option { + self.blinded_hops.get(&(introduction_point, blinding_point)).map(|v| *v) + } } /// A hop in a route, and additional metadata about it. "Hop" is defined as a node and the channel @@ -3890,8 +3926,9 @@ mod tests { use crate::routing::gossip::{EffectiveCapacity, NetworkGraph, NodeId, P2PGossipSync}; use crate::routing::router::{ add_random_cltv_offset, build_route_from_hops_internal, default_node_features, get_route, - BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, PaymentParameters, PublicHopCandidate, - Route, RouteHint, RouteHintHop, RouteHop, RouteParameters, RoutingFees, + BlindedPathCandidate, BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, + PaymentParameters, PublicHopCandidate, Route, RouteHint, RouteHintHop, RouteHop, + RouteParameters, RoutingFees, ScorerAccountingForInFlightHtlcs, DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE, }; use crate::routing::scoring::{ @@ -3923,7 +3960,7 @@ mod tests { use crate::io::Cursor; use crate::prelude::*; - use crate::sync::Arc; + use crate::sync::{Arc, Mutex}; #[rustfmt::skip] fn get_channel_details(short_channel_id: Option, node_id: PublicKey, @@ -7960,9 +7997,9 @@ mod tests { #[test] #[rustfmt::skip] - fn blinded_path_inflight_processing() { - // Ensure we'll score the channel that's inbound to a blinded path's introduction node, and - // account for the blinded tail's final amount_msat. + fn one_hop_blinded_path_inflight_processing() { + // Ensure we'll score the channel that's inbound to a one-hop blinded path's introduction + // node, and account for the blinded tail's final amount_msat. let mut inflight_htlcs = InFlightHtlcs::new(); let path = Path { hops: vec![RouteHop { @@ -7994,6 +8031,106 @@ mod tests { inflight_htlcs.process_path(&path, ln_test_utils::pubkey(44)); assert_eq!(*inflight_htlcs.unblinded_hops.get(&(42, true)).unwrap(), 301); assert_eq!(*inflight_htlcs.unblinded_hops.get(&(43, false)).unwrap(), 201); + assert!(inflight_htlcs.blinded_hops.is_empty()); + } + + struct UsageTrackingScorer(Mutex>); + + impl ScoreLookUp for UsageTrackingScorer { + type ScoreParams = (); + fn channel_penalty_msat(&self, _: &CandidateRouteHop, usage: ChannelUsage, _: &()) -> u64 { + let mut inner = self.0.lock().unwrap(); + assert!(inner.is_none()); + *inner = Some(usage); + 0 + } + } + + #[test] + fn blinded_path_inflight_processing() { + // Ensure we'll score the channel that's inbound to a blinded path's introduction node, and + // account for the blinded tail's final amount_msat as well as track the blinded path + // in-flight. + let mut inflight_htlcs = InFlightHtlcs::new(); + let blinding_point = ln_test_utils::pubkey(48); + let mut blinded_hops = Vec::new(); + for i in 0..2 { + blinded_hops.push(BlindedHop { + blinded_node_id: ln_test_utils::pubkey(49 + i as u8), + encrypted_payload: Vec::new(), + }); + } + let intro_point = ln_test_utils::pubkey(43); + let path = Path { + hops: vec![ + RouteHop { + pubkey: ln_test_utils::pubkey(42), + node_features: NodeFeatures::empty(), + short_channel_id: 42, + channel_features: ChannelFeatures::empty(), + fee_msat: 100, + cltv_expiry_delta: 0, + maybe_announced_channel: false, + }, + RouteHop { + pubkey: intro_point, + node_features: NodeFeatures::empty(), + short_channel_id: 43, + channel_features: ChannelFeatures::empty(), + fee_msat: 1, + cltv_expiry_delta: 0, + maybe_announced_channel: false, + }, + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![], + hops: blinded_hops.clone(), + blinding_point, + excess_final_cltv_expiry_delta: 0, + final_value_msat: 200, + }), + }; + inflight_htlcs.process_path(&path, ln_test_utils::pubkey(44)); + assert_eq!(*inflight_htlcs.unblinded_hops.get(&(42, true)).unwrap(), 301); + assert_eq!(*inflight_htlcs.unblinded_hops.get(&(43, false)).unwrap(), 201); + let intro_node_id = NodeId::from_pubkey(&ln_test_utils::pubkey(43)); + assert_eq!( + *inflight_htlcs.blinded_hops.get(&(intro_node_id, blinding_point)).unwrap(), + 201 + ); + + let tracking_scorer = UsageTrackingScorer(Mutex::new(None)); + let inflight_scorer = + ScorerAccountingForInFlightHtlcs::new(&tracking_scorer, &inflight_htlcs); + + let blinded_payinfo = BlindedPayInfo { + fee_base_msat: 100, + fee_proportional_millionths: 500, + htlc_minimum_msat: 1000, + htlc_maximum_msat: 100_000_000, + cltv_expiry_delta: 15, + features: BlindedHopFeatures::empty(), + }; + let blinded_path = BlindedPaymentPath::from_blinded_path_and_payinfo( + intro_point, + blinding_point, + blinded_hops, + blinded_payinfo, + ); + + let candidate = CandidateRouteHop::Blinded(BlindedPathCandidate { + source_node_id: &intro_node_id, + hint: &blinded_path, + hint_idx: 0, + source_node_counter: 0, + }); + let empty_usage = ChannelUsage { + amount_msat: 42, + inflight_htlc_msat: 0, + effective_capacity: EffectiveCapacity::HintMaxHTLC { amount_msat: 500 }, + }; + inflight_scorer.channel_penalty_msat(&candidate, empty_usage, &()); + assert_eq!(tracking_scorer.0.lock().unwrap().unwrap().inflight_htlc_msat, 201); } #[test] diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index 6c111ab475b..d741adf58d3 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -57,7 +57,7 @@ use crate::prelude::hash_map::Entry; use crate::prelude::*; use crate::routing::gossip::{DirectedChannelInfo, EffectiveCapacity, NetworkGraph, NodeId}; use crate::routing::log_approx; -use crate::routing::router::{CandidateRouteHop, Path, PublicHopCandidate}; +use crate::routing::router::{BlindedPathCandidate, CandidateRouteHop, Path, PublicHopCandidate}; use crate::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use crate::util::logger::Logger; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; @@ -1682,6 +1682,17 @@ where CandidateRouteHop::PublicHop(PublicHopCandidate { info, short_channel_id }) => { (short_channel_id, info.target()) }, + CandidateRouteHop::Blinded(BlindedPathCandidate { hint, .. }) => { + let total_inflight_amount_msat = + usage.amount_msat.saturating_add(usage.inflight_htlc_msat); + if usage.amount_msat > hint.payinfo.htlc_maximum_msat { + return u64::max_value(); + } else if total_inflight_amount_msat > hint.payinfo.htlc_maximum_msat { + return score_params.considered_impossible_penalty_msat; + } else { + return 0; + } + }, _ => return 0, }; let source = candidate.source();