Skip to content

Commit 61aca4d

Browse files
Merge pull request #4072 from TheBlueMatt/2025-09-bp-usage-tracking
Properly consider blinded paths in InFlightHtlcs
2 parents ec0b969 + dc25a5b commit 61aca4d

File tree

2 files changed

+175
-33
lines changed

2 files changed

+175
-33
lines changed

lightning/src/routing/router.rs

Lines changed: 163 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,18 @@ where
323323
type ScoreParams = <S::Target as ScoreLookUp>::ScoreParams;
324324
#[rustfmt::skip]
325325
fn channel_penalty_msat(&self, candidate: &CandidateRouteHop, usage: ChannelUsage, score_params: &Self::ScoreParams) -> u64 {
326+
if let CandidateRouteHop::Blinded(blinded_candidate) = candidate {
327+
if let Some(used_liquidity) = self.inflight_htlcs.used_blinded_liquidity_msat(
328+
*blinded_candidate.source_node_id, blinded_candidate.hint.blinding_point(),
329+
) {
330+
let usage = ChannelUsage {
331+
inflight_htlc_msat: usage.inflight_htlc_msat.saturating_add(used_liquidity),
332+
..usage
333+
};
334+
335+
return self.scorer.channel_penalty_msat(candidate, usage, score_params);
336+
}
337+
}
326338
let target = match candidate.target() {
327339
Some(target) => target,
328340
None => return self.scorer.channel_penalty_msat(candidate, usage, score_params),
@@ -350,45 +362,68 @@ where
350362
/// A data structure for tracking in-flight HTLCs. May be used during pathfinding to account for
351363
/// in-use channel liquidity.
352364
#[derive(Clone)]
353-
pub struct InFlightHtlcs(
365+
pub struct InFlightHtlcs {
354366
// A map with liquidity value (in msat) keyed by a short channel id and the direction the HTLC
355367
// is traveling in. The direction boolean is determined by checking if the HTLC source's public
356368
// key is less than its destination. See `InFlightHtlcs::used_liquidity_msat` for more
357369
// details.
358-
HashMap<(u64, bool), u64>,
359-
);
370+
unblinded_hops: HashMap<(u64, bool), u64>,
371+
/// A map with liquidity value (in msat) keyed by the introduction point of a blinded path and
372+
/// the blinding point. In general blinding points should be globally unique, but just in case
373+
/// we add the introduction point as well.
374+
blinded_hops: HashMap<(NodeId, PublicKey), u64>,
375+
}
360376

361377
impl InFlightHtlcs {
362378
/// Constructs an empty `InFlightHtlcs`.
363-
#[rustfmt::skip]
364-
pub fn new() -> Self { InFlightHtlcs(new_hash_map()) }
379+
pub fn new() -> Self {
380+
InFlightHtlcs { unblinded_hops: new_hash_map(), blinded_hops: new_hash_map() }
381+
}
365382

366383
/// Takes in a path with payer's node id and adds the path's details to `InFlightHtlcs`.
367-
#[rustfmt::skip]
368384
pub fn process_path(&mut self, path: &Path, payer_node_id: PublicKey) {
369-
if path.hops.is_empty() { return };
385+
if path.hops.is_empty() {
386+
return;
387+
}
370388

371389
let mut cumulative_msat = 0;
372390
if let Some(tail) = &path.blinded_tail {
373391
cumulative_msat += tail.final_value_msat;
392+
if tail.hops.len() > 1 {
393+
// Single-hop blinded paths aren't really "blinded" paths, as they terminate at the
394+
// introduction point. In that case, we don't need to track anything.
395+
let last_trampoline_hop =
396+
tail.trampoline_hops.last().map(|hop| (hop.pubkey, hop.fee_msat));
397+
let last_normal_hop = path.hops.last().unwrap();
398+
let last_hop = last_trampoline_hop
399+
.unwrap_or((last_normal_hop.pubkey, last_normal_hop.fee_msat));
400+
let intro_node = NodeId::from_pubkey(&last_hop.0);
401+
// The amount we send into the blinded path is the sum of the blinded path final
402+
// amount and the fee we pay in it, which is the `fee_msat` of the last hop.
403+
let blinded_path_sent_amt = last_hop.1 + cumulative_msat;
404+
self.blinded_hops
405+
.entry((intro_node, tail.blinding_point))
406+
.and_modify(|used_liquidity_msat| *used_liquidity_msat += blinded_path_sent_amt)
407+
.or_insert(blinded_path_sent_amt);
408+
}
374409
}
375410

376411
// total_inflight_map needs to be direction-sensitive when keeping track of the HTLC value
377412
// that is held up. However, the `hops` array, which is a path returned by `find_route` in
378413
// the router excludes the payer node. In the following lines, the payer's information is
379414
// hardcoded with an inflight value of 0 so that we can correctly represent the first hop
380415
// in our sliding window of two.
381-
let reversed_hops_with_payer = path.hops.iter().rev().skip(1)
382-
.map(|hop| hop.pubkey)
383-
.chain(core::iter::once(payer_node_id));
416+
let reversed_hops = path.hops.iter().rev().skip(1).map(|hop| hop.pubkey);
417+
let reversed_hops_with_payer = reversed_hops.chain(core::iter::once(payer_node_id));
384418

385419
// Taking the reversed vector from above, we zip it with just the reversed hops list to
386420
// work "backwards" of the given path, since the last hop's `fee_msat` actually represents
387421
// the total amount sent.
388422
for (next_hop, prev_hop) in path.hops.iter().rev().zip(reversed_hops_with_payer) {
389423
cumulative_msat += next_hop.fee_msat;
390-
self.0
391-
.entry((next_hop.short_channel_id, NodeId::from_pubkey(&prev_hop) < NodeId::from_pubkey(&next_hop.pubkey)))
424+
let direction = NodeId::from_pubkey(&prev_hop) < NodeId::from_pubkey(&next_hop.pubkey);
425+
self.unblinded_hops
426+
.entry((next_hop.short_channel_id, direction))
392427
.and_modify(|used_liquidity_msat| *used_liquidity_msat += cumulative_msat)
393428
.or_insert(cumulative_msat);
394429
}
@@ -399,7 +434,7 @@ impl InFlightHtlcs {
399434
pub fn add_inflight_htlc(
400435
&mut self, source: &NodeId, target: &NodeId, channel_scid: u64, used_msat: u64,
401436
) {
402-
self.0
437+
self.unblinded_hops
403438
.entry((channel_scid, source < target))
404439
.and_modify(|used_liquidity_msat| *used_liquidity_msat += used_msat)
405440
.or_insert(used_msat);
@@ -410,19 +445,14 @@ impl InFlightHtlcs {
410445
pub fn used_liquidity_msat(
411446
&self, source: &NodeId, target: &NodeId, channel_scid: u64,
412447
) -> Option<u64> {
413-
self.0.get(&(channel_scid, source < target)).map(|v| *v)
448+
self.unblinded_hops.get(&(channel_scid, source < target)).map(|v| *v)
414449
}
415-
}
416-
417-
impl Writeable for InFlightHtlcs {
418-
#[rustfmt::skip]
419-
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> { self.0.write(writer) }
420-
}
421450

422-
impl Readable for InFlightHtlcs {
423-
fn read<R: io::Read>(reader: &mut R) -> Result<Self, DecodeError> {
424-
let infight_map: HashMap<(u64, bool), u64> = Readable::read(reader)?;
425-
Ok(Self(infight_map))
451+
/// Returns liquidity in msat given the blinded path introduction point and blinding point.
452+
pub fn used_blinded_liquidity_msat(
453+
&self, introduction_point: NodeId, blinding_point: PublicKey,
454+
) -> Option<u64> {
455+
self.blinded_hops.get(&(introduction_point, blinding_point)).map(|v| *v)
426456
}
427457
}
428458

@@ -3899,8 +3929,9 @@ mod tests {
38993929
use crate::routing::gossip::{EffectiveCapacity, NetworkGraph, NodeId, P2PGossipSync};
39003930
use crate::routing::router::{
39013931
add_random_cltv_offset, build_route_from_hops_internal, default_node_features, get_route,
3902-
BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, PaymentParameters, PublicHopCandidate,
3903-
Route, RouteHint, RouteHintHop, RouteHop, RouteParameters, RoutingFees,
3932+
BlindedPathCandidate, BlindedTail, CandidateRouteHop, InFlightHtlcs, Path,
3933+
PaymentParameters, PublicHopCandidate, Route, RouteHint, RouteHintHop, RouteHop,
3934+
RouteParameters, RoutingFees, ScorerAccountingForInFlightHtlcs,
39043935
DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE,
39053936
};
39063937
use crate::routing::scoring::{
@@ -3932,7 +3963,7 @@ mod tests {
39323963

39333964
use crate::io::Cursor;
39343965
use crate::prelude::*;
3935-
use crate::sync::Arc;
3966+
use crate::sync::{Arc, Mutex};
39363967

39373968
#[rustfmt::skip]
39383969
fn get_channel_details(short_channel_id: Option<u64>, node_id: PublicKey,
@@ -7969,9 +8000,9 @@ mod tests {
79698000

79708001
#[test]
79718002
#[rustfmt::skip]
7972-
fn blinded_path_inflight_processing() {
7973-
// Ensure we'll score the channel that's inbound to a blinded path's introduction node, and
7974-
// account for the blinded tail's final amount_msat.
8003+
fn one_hop_blinded_path_inflight_processing() {
8004+
// Ensure we'll score the channel that's inbound to a one-hop blinded path's introduction
8005+
// node, and account for the blinded tail's final amount_msat.
79758006
let mut inflight_htlcs = InFlightHtlcs::new();
79768007
let path = Path {
79778008
hops: vec![RouteHop {
@@ -8001,8 +8032,108 @@ mod tests {
80018032
}),
80028033
};
80038034
inflight_htlcs.process_path(&path, ln_test_utils::pubkey(44));
8004-
assert_eq!(*inflight_htlcs.0.get(&(42, true)).unwrap(), 301);
8005-
assert_eq!(*inflight_htlcs.0.get(&(43, false)).unwrap(), 201);
8035+
assert_eq!(*inflight_htlcs.unblinded_hops.get(&(42, true)).unwrap(), 301);
8036+
assert_eq!(*inflight_htlcs.unblinded_hops.get(&(43, false)).unwrap(), 201);
8037+
assert!(inflight_htlcs.blinded_hops.is_empty());
8038+
}
8039+
8040+
struct UsageTrackingScorer(Mutex<Option<ChannelUsage>>);
8041+
8042+
impl ScoreLookUp for UsageTrackingScorer {
8043+
type ScoreParams = ();
8044+
fn channel_penalty_msat(&self, _: &CandidateRouteHop, usage: ChannelUsage, _: &()) -> u64 {
8045+
let mut inner = self.0.lock().unwrap();
8046+
assert!(inner.is_none());
8047+
*inner = Some(usage);
8048+
0
8049+
}
8050+
}
8051+
8052+
#[test]
8053+
fn blinded_path_inflight_processing() {
8054+
// Ensure we'll score the channel that's inbound to a blinded path's introduction node, and
8055+
// account for the blinded tail's final amount_msat as well as track the blinded path
8056+
// in-flight.
8057+
let mut inflight_htlcs = InFlightHtlcs::new();
8058+
let blinding_point = ln_test_utils::pubkey(48);
8059+
let mut blinded_hops = Vec::new();
8060+
for i in 0..2 {
8061+
blinded_hops.push(BlindedHop {
8062+
blinded_node_id: ln_test_utils::pubkey(49 + i as u8),
8063+
encrypted_payload: Vec::new(),
8064+
});
8065+
}
8066+
let intro_point = ln_test_utils::pubkey(43);
8067+
let path = Path {
8068+
hops: vec![
8069+
RouteHop {
8070+
pubkey: ln_test_utils::pubkey(42),
8071+
node_features: NodeFeatures::empty(),
8072+
short_channel_id: 42,
8073+
channel_features: ChannelFeatures::empty(),
8074+
fee_msat: 100,
8075+
cltv_expiry_delta: 0,
8076+
maybe_announced_channel: false,
8077+
},
8078+
RouteHop {
8079+
pubkey: intro_point,
8080+
node_features: NodeFeatures::empty(),
8081+
short_channel_id: 43,
8082+
channel_features: ChannelFeatures::empty(),
8083+
fee_msat: 1,
8084+
cltv_expiry_delta: 0,
8085+
maybe_announced_channel: false,
8086+
},
8087+
],
8088+
blinded_tail: Some(BlindedTail {
8089+
trampoline_hops: vec![],
8090+
hops: blinded_hops.clone(),
8091+
blinding_point,
8092+
excess_final_cltv_expiry_delta: 0,
8093+
final_value_msat: 200,
8094+
}),
8095+
};
8096+
inflight_htlcs.process_path(&path, ln_test_utils::pubkey(44));
8097+
assert_eq!(*inflight_htlcs.unblinded_hops.get(&(42, true)).unwrap(), 301);
8098+
assert_eq!(*inflight_htlcs.unblinded_hops.get(&(43, false)).unwrap(), 201);
8099+
let intro_node_id = NodeId::from_pubkey(&ln_test_utils::pubkey(43));
8100+
assert_eq!(
8101+
*inflight_htlcs.blinded_hops.get(&(intro_node_id, blinding_point)).unwrap(),
8102+
201
8103+
);
8104+
8105+
let tracking_scorer = UsageTrackingScorer(Mutex::new(None));
8106+
let inflight_scorer =
8107+
ScorerAccountingForInFlightHtlcs::new(&tracking_scorer, &inflight_htlcs);
8108+
8109+
let blinded_payinfo = BlindedPayInfo {
8110+
fee_base_msat: 100,
8111+
fee_proportional_millionths: 500,
8112+
htlc_minimum_msat: 1000,
8113+
htlc_maximum_msat: 100_000_000,
8114+
cltv_expiry_delta: 15,
8115+
features: BlindedHopFeatures::empty(),
8116+
};
8117+
let blinded_path = BlindedPaymentPath::from_blinded_path_and_payinfo(
8118+
intro_point,
8119+
blinding_point,
8120+
blinded_hops,
8121+
blinded_payinfo,
8122+
);
8123+
8124+
let candidate = CandidateRouteHop::Blinded(BlindedPathCandidate {
8125+
source_node_id: &intro_node_id,
8126+
hint: &blinded_path,
8127+
hint_idx: 0,
8128+
source_node_counter: 0,
8129+
});
8130+
let empty_usage = ChannelUsage {
8131+
amount_msat: 42,
8132+
inflight_htlc_msat: 0,
8133+
effective_capacity: EffectiveCapacity::HintMaxHTLC { amount_msat: 500 },
8134+
};
8135+
inflight_scorer.channel_penalty_msat(&candidate, empty_usage, &());
8136+
assert_eq!(tracking_scorer.0.lock().unwrap().unwrap().inflight_htlc_msat, 201);
80068137
}
80078138

80088139
#[test]

lightning/src/routing/scoring.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ use crate::prelude::hash_map::Entry;
5757
use crate::prelude::*;
5858
use crate::routing::gossip::{DirectedChannelInfo, EffectiveCapacity, NetworkGraph, NodeId};
5959
use crate::routing::log_approx;
60-
use crate::routing::router::{CandidateRouteHop, Path, PublicHopCandidate};
60+
use crate::routing::router::{BlindedPathCandidate, CandidateRouteHop, Path, PublicHopCandidate};
6161
use crate::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
6262
use crate::util::logger::Logger;
6363
use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer};
@@ -1682,6 +1682,17 @@ where
16821682
CandidateRouteHop::PublicHop(PublicHopCandidate { info, short_channel_id }) => {
16831683
(short_channel_id, info.target())
16841684
},
1685+
CandidateRouteHop::Blinded(BlindedPathCandidate { hint, .. }) => {
1686+
let total_inflight_amount_msat =
1687+
usage.amount_msat.saturating_add(usage.inflight_htlc_msat);
1688+
if usage.amount_msat > hint.payinfo.htlc_maximum_msat {
1689+
return u64::max_value();
1690+
} else if total_inflight_amount_msat > hint.payinfo.htlc_maximum_msat {
1691+
return score_params.considered_impossible_penalty_msat;
1692+
} else {
1693+
return 0;
1694+
}
1695+
},
16851696
_ => return 0,
16861697
};
16871698
let source = candidate.source();

0 commit comments

Comments
 (0)