Skip to content

Commit 6574bf9

Browse files
committed
Add probing tests
There are 3 probing tests: - check that probing lock liquidity amount changes - test that new probes are not fired if the max locked liquidity is reached (for example if on of the nodes goes down) - probing perfomance test which sets up 4 probing nodes (random strategy, high-degree without penalty, high-degree with penalty, control node without probing). A network of several nodes is set up; these nodes make payments to one another and probing nodes observe their behaviour. Next the scorer estimates are printed out. Scorer channel estimates are exposed for the purposes of the test. Scoring parameters (probing penalty) is exposed to be set up during node builiding. Random probing strategy constructs the maximal possible route (up to the set limit) instead of failing when the next hop is not possible to construct.
1 parent 7f3ce11 commit 6574bf9

6 files changed

Lines changed: 867 additions & 68 deletions

File tree

src/builder.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ use lightning::routing::gossip::NodeAlias;
3030
use lightning::routing::router::DefaultRouter;
3131
use lightning::routing::scoring::{
3232
CombinedScorer, ProbabilisticScorer, ProbabilisticScoringDecayParameters,
33-
ProbabilisticScoringFeeParameters,
3433
};
3534
use lightning::sign::{EntropySource, NodeSigner};
3635
use lightning::util::config::HTLCInterceptionFlags;
@@ -1551,7 +1550,7 @@ fn build_with_store_internal(
15511550
},
15521551
}
15531552

1554-
let scoring_fee_params = ProbabilisticScoringFeeParameters::default();
1553+
let scoring_fee_params = config.scoring_fee_params.clone();
15551554
let router = Arc::new(DefaultRouter::new(
15561555
Arc::clone(&network_graph),
15571556
Arc::clone(&logger),

src/config.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use bitcoin::Network;
1515
use lightning::ln::msgs::SocketAddress;
1616
use lightning::routing::gossip::NodeAlias;
1717
use lightning::routing::router::RouteParametersConfig;
18+
use lightning::routing::scoring::ProbabilisticScoringFeeParameters;
1819
use lightning::util::config::{
1920
ChannelConfig as LdkChannelConfig, MaxDustHTLCExposure as LdkMaxDustHTLCExposure, UserConfig,
2021
};
@@ -131,9 +132,11 @@ pub(crate) const HRN_RESOLUTION_TIMEOUT_SECS: u64 = 5;
131132
/// | `log_level` | Debug |
132133
/// | `anchor_channels_config` | Some(..) |
133134
/// | `route_parameters` | None |
135+
/// | `scoring_fee_params` | See [`ProbabilisticScoringFeeParameters`] |
134136
///
135-
/// See [`AnchorChannelsConfig`] and [`RouteParametersConfig`] for more information regarding their
136-
/// respective default values.
137+
/// See [`AnchorChannelsConfig`], [`RouteParametersConfig`], and
138+
/// [`ProbabilisticScoringFeeParameters`] for more information regarding their respective default
139+
/// values.
137140
///
138141
/// [`Node`]: crate::Node
139142
pub struct Config {
@@ -195,6 +198,12 @@ pub struct Config {
195198
/// **Note:** If unset, default parameters will be used, and you will be able to override the
196199
/// parameters on a per-payment basis in the corresponding method calls.
197200
pub route_parameters: Option<RouteParametersConfig>,
201+
/// Parameters for the probabilistic scorer used when computing payment routes.
202+
///
203+
/// These correspond to [`ProbabilisticScoringFeeParameters`] in LDK. If unset, LDK defaults
204+
/// are used. Notably, [`ProbabilisticScoringFeeParameters::probing_diversity_penalty_msat`]
205+
/// should be set to a non-zero value for some of the probing strategies.
206+
pub scoring_fee_params: ProbabilisticScoringFeeParameters,
198207
}
199208

200209
impl Default for Config {
@@ -209,6 +218,7 @@ impl Default for Config {
209218
anchor_channels_config: Some(AnchorChannelsConfig::default()),
210219
route_parameters: None,
211220
node_alias: None,
221+
scoring_fee_params: ProbabilisticScoringFeeParameters::default(),
212222
}
213223
}
214224
}

src/lib.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,9 +1050,35 @@ impl Node {
10501050
self.prober.as_ref().map(|p| p.locked_msat.load(std::sync::atomic::Ordering::Relaxed))
10511051
}
10521052

1053-
/// Gives access to the scorer; needed to valuate the probing tests.
1054-
#[cfg(test)]
1055-
pub fn scorer(&self) -> &Arc<Mutex<Scorer>> { &self.scorer }
1053+
/// Returns the scorer's estimated `(min, max)` liquidity range for the given channel in the
1054+
/// direction toward `target`, or `None` if the scorer has no data for that channel.
1055+
///
1056+
/// Works by serializing the `CombinedScorer` (which writes `local_only_scorer`) and
1057+
/// deserializing it as a plain `ProbabilisticScorer` to call `estimated_channel_liquidity_range`.
1058+
pub fn scorer_channel_liquidity(
1059+
&self, scid: u64, target: PublicKey,
1060+
) -> Option<(u64, u64)> {
1061+
use lightning::routing::scoring::{ProbabilisticScorer, ProbabilisticScoringDecayParameters};
1062+
use lightning::util::ser::{ReadableArgs, Writeable};
1063+
1064+
let target_node_id = lightning::routing::gossip::NodeId::from_pubkey(&target);
1065+
1066+
let bytes = {
1067+
let scorer = self.scorer.lock().unwrap();
1068+
let mut buf = Vec::new();
1069+
scorer.write(&mut buf).ok()?;
1070+
buf
1071+
};
1072+
1073+
let decay_params = ProbabilisticScoringDecayParameters::default();
1074+
let prob_scorer = ProbabilisticScorer::read(
1075+
&mut &bytes[..],
1076+
(decay_params, Arc::clone(&self.network_graph), Arc::clone(&self.logger)),
1077+
)
1078+
.ok()?;
1079+
1080+
prob_scorer.estimated_channel_liquidity_range(scid, &target_node_id)
1081+
}
10561082

10571083

10581084
/// Retrieve a list of known channels.

src/probing.rs

Lines changed: 60 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ impl ProbingStrategy for HighDegreeStrategy {
6363
let graph = self.network_graph.read_only();
6464

6565
// Collect (pubkey, channel_count) for all nodes.
66-
// wtf it does why we need to iterate here and then sort? maybe we can go just once?
6766
let mut nodes_by_degree: Vec<(PublicKey, usize)> = graph
6867
.nodes()
6968
.unordered_iter()
@@ -76,8 +75,6 @@ impl ProbingStrategy for HighDegreeStrategy {
7675
return None;
7776
}
7877

79-
// Most-connected first.
80-
// wtf it does
8178
nodes_by_degree.sort_unstable_by(|a, b| b.1.cmp(&a.1));
8279

8380
let top_n = self.top_n.min(nodes_by_degree.len());
@@ -137,37 +134,30 @@ impl RandomStrategy {
137134
/// Tries to build a path for the given cursor value and hop count. Returns `None` if the
138135
/// local node has no usable channels, or the walk terminates before reaching `target_hops`.
139136
fn try_build_path(&self, cursor: usize, target_hops: usize, amount_msat: u64) -> Option<Path> {
140-
// Collect confirmed, usable channels: (scid, peer_pubkey).
141-
let our_channels: Vec<(u64, PublicKey)> = self
142-
.channel_manager
143-
.list_channels()
144-
.into_iter()
145-
.filter_map(|c| {
146-
if c.is_usable {
147-
c.short_channel_id.map(|scid| (scid, c.counterparty.node_id))
148-
} else {
149-
None
150-
}
151-
})
152-
.collect();
137+
let initial_channels =
138+
self.channel_manager.list_channels().into_iter().filter(|c|
139+
c.is_usable && c.short_channel_id.is_some()).collect::<Vec<_>>();
153140

154-
if our_channels.is_empty() {
141+
if initial_channels.is_empty() {
155142
return None;
156143
}
157144

158145
let graph = self.network_graph.read_only();
159-
let (our_scid, peer_pubkey) = our_channels[cursor % our_channels.len()];
160-
let peer_node_id = NodeId::from_pubkey(&peer_pubkey);
146+
let first_hop = &initial_channels[cursor % initial_channels.len()];
147+
let first_hop_scid = first_hop.short_channel_id.unwrap();
148+
let next_peer_pubkey = first_hop.counterparty.node_id;
149+
let next_peer_node_id = NodeId::from_pubkey(&next_peer_pubkey);
161150

162-
// Walk the graph: each entry is (node_id, arrived_via_scid, pubkey).
163-
// We start by having "arrived at peer via our_scid".
164-
let mut visited: Vec<(NodeId, u64, PublicKey)> =
165-
vec![(peer_node_id, our_scid, peer_pubkey)];
151+
// Track the tightest HTLC limit across all hops to cap the probe amount.
152+
// The first hop limit comes from our live channel state; subsequent hops use htlc_maximum_msat from the gossip channel update.
153+
let mut route_least_htlc_upper_bound = first_hop.next_outbound_htlc_limit_msat;
166154

167-
let mut prev_scid = our_scid;
168-
let mut current_node_id = peer_node_id;
155+
// Walk the graph: each entry is (node_id, arrived_via_scid, pubkey); first entry is set:
156+
let mut route: Vec<(NodeId, u64, PublicKey)> = vec![(next_peer_node_id, first_hop_scid, next_peer_pubkey)];
157+
158+
let mut prev_scid = first_hop_scid;
159+
let mut current_node_id = next_peer_node_id;
169160

170-
//wtf the real amount of hops is -1 of target?
171161
for hop_idx in 1..target_hops {
172162
let node_info = match graph.node(&current_node_id) {
173163
Some(n) => n,
@@ -187,46 +177,48 @@ impl RandomStrategy {
187177
None => break,
188178
};
189179

190-
// Determine direction and fetch the channel-update.
191-
let (update, next_node_id) = if next_channel.node_one == current_node_id {
192-
match next_channel.one_to_two.as_ref() {
193-
Some(u) => (u, next_channel.node_two),
194-
None => break,
195-
}
196-
} else if next_channel.node_two == current_node_id {
197-
match next_channel.two_to_one.as_ref() {
198-
Some(u) => (u, next_channel.node_one),
199-
None => break,
200-
}
201-
} else {
180+
// as_directed_from validates that current_node_id is a channel endpoint and that
181+
// both direction updates are present; effective_capacity covers both htlc_maximum_msat
182+
// and funding capacity.
183+
let Some((directed, next_node_id)) = next_channel.as_directed_from(&current_node_id)
184+
else {
202185
break;
203186
};
187+
// Retrieve the direction-specific update via the public ChannelInfo fields.
188+
// Safe to unwrap: as_directed_from already checked both directions are Some.
189+
let update = if directed.source() == &next_channel.node_one {
190+
next_channel.one_to_two.as_ref().unwrap()
191+
} else {
192+
next_channel.two_to_one.as_ref().unwrap()
193+
};
204194

205195
if !update.enabled {
206196
break;
207197
}
208198

209-
let next_pubkey = match PublicKey::try_from(next_node_id) {
199+
route_least_htlc_upper_bound = route_least_htlc_upper_bound.min(update.htlc_maximum_msat);
200+
201+
let next_pubkey = match PublicKey::try_from(*next_node_id) {
210202
Ok(pk) => pk,
211203
Err(_) => break,
212204
};
213205

214-
visited.push((next_node_id, next_scid, next_pubkey));
206+
route.push((*next_node_id, next_scid, next_pubkey));
215207
prev_scid = next_scid;
216-
current_node_id = next_node_id;
208+
current_node_id = *next_node_id;
217209
}
218210

219-
// Require the full requested depth; shorter walks are uninformative.
220-
if visited.len() < target_hops {
211+
let amount_msat = amount_msat.min(route_least_htlc_upper_bound); //cap probe amount
212+
if amount_msat < self.min_amount_msat {
221213
return None;
222214
}
223215

224216
// Assemble hops.
225-
// For hop i: fee and CLTV are determined by the *next* channel (what visited[i]
217+
// For hop i: fee and CLTV are determined by the *next* channel (what route[i]
226218
// will charge to forward onward). For the last hop they are amount_msat and zero expiry delta.
227-
let mut hops = Vec::with_capacity(visited.len());
228-
for i in 0..visited.len() {
229-
let (node_id, via_scid, pubkey) = visited[i];
219+
let mut hops = Vec::with_capacity(route.len());
220+
for i in 0..route.len() {
221+
let (node_id, via_scid, pubkey) = route[i];
230222

231223
let channel_info = graph.channel(via_scid)?;
232224

@@ -235,22 +227,22 @@ impl RandomStrategy {
235227
.and_then(|n| n.announcement_info.as_ref().map(|a| a.features().clone()))
236228
.unwrap_or_else(NodeFeatures::empty);
237229

238-
let (fee_msat, cltv_expiry_delta) = if i + 1 < visited.len() {
239-
// Intermediate hop: look up the next channel's update from node_id.
240-
let (_next_node_id, next_scid, _) = visited[i + 1];
241-
let next_channel = graph.channel(next_scid)?;
242-
let update = if next_channel.node_one == node_id {
243-
next_channel.one_to_two.as_ref()?
230+
let (fee_msat, cltv_expiry_delta) =
231+
if i + 1 < route.len() { // non-final hop
232+
let (_, next_scid, _) = route[i + 1];
233+
let next_channel = graph.channel(next_scid)?;
234+
let (directed, _) = next_channel.as_directed_from(&node_id)?;
235+
let update = if directed.source() == &next_channel.node_one {
236+
next_channel.one_to_two.as_ref().unwrap()
237+
} else {
238+
next_channel.two_to_one.as_ref().unwrap()
239+
};
240+
let fee = update.fees.base_msat as u64 + (amount_msat * update.fees.proportional_millionths as u64 / 1_000_000);
241+
(fee, update.cltv_expiry_delta as u32)
244242
} else {
245-
next_channel.two_to_one.as_ref()?
243+
// Final hop: fee_msat carries the delivery amount; cltv delta is zero.
244+
(amount_msat, 0)
246245
};
247-
let fee = update.fees.base_msat as u64
248-
+ (amount_msat * update.fees.proportional_millionths as u64 / 1_000_000);
249-
(fee, update.cltv_expiry_delta as u32)
250-
} else {
251-
// Final hop.
252-
(amount_msat, 0)
253-
};
254246

255247
hops.push(RouteHop {
256248
pubkey,
@@ -263,6 +255,13 @@ impl RandomStrategy {
263255
});
264256
}
265257

258+
// The first-hop HTLC carries amount_msat + all intermediate fees.
259+
// Verify the total fits within our live outbound limit before returning.
260+
let total_outgoing: u64 = hops.iter().map(|h| h.fee_msat).sum();
261+
if total_outgoing > first_hop.next_outbound_htlc_limit_msat {
262+
return None;
263+
}
264+
266265
Some(Path { hops, blinded_tail: None })
267266
}
268267
}

tests/common/mod.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,41 @@ pub async fn open_channel(
684684
open_channel_push_amt(node_a, node_b, funding_amount_sat, None, should_announce, electrsd).await
685685
}
686686

687+
/// Like [`open_channel`] but skips the `wait_for_tx` electrum check so that
688+
/// multiple channels can be opened back-to-back before any blocks are mined.
689+
/// The caller is responsible for mining blocks and confirming the funding txs.
690+
pub async fn open_channel_no_electrum_wait(
691+
node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool,
692+
) -> OutPoint {
693+
if should_announce {
694+
node_a
695+
.open_announced_channel(
696+
node_b.node_id(),
697+
node_b.listening_addresses().unwrap().first().unwrap().clone(),
698+
funding_amount_sat,
699+
None,
700+
None,
701+
)
702+
.unwrap();
703+
} else {
704+
node_a
705+
.open_channel(
706+
node_b.node_id(),
707+
node_b.listening_addresses().unwrap().first().unwrap().clone(),
708+
funding_amount_sat,
709+
None,
710+
None,
711+
)
712+
.unwrap();
713+
}
714+
assert!(node_a.list_peers().iter().find(|c| { c.node_id == node_b.node_id() }).is_some());
715+
716+
let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id());
717+
let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id());
718+
assert_eq!(funding_txo_a, funding_txo_b);
719+
funding_txo_a
720+
}
721+
687722
pub async fn open_channel_push_amt(
688723
node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option<u64>,
689724
should_announce: bool, electrsd: &ElectrsD,

0 commit comments

Comments
 (0)