From 963aa9defd2b94546faa55156182ea1efe00c16f Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Thu, 19 Mar 2026 01:44:40 +0100 Subject: [PATCH 01/16] Add probing service Introduce a background probing service that periodically dispatches probes to improve the scorer's liquidity estimates. Includes two built-in strategies. --- src/builder.rs | 164 +++++++++++- src/config.rs | 4 + src/event.rs | 22 +- src/lib.rs | 48 ++++ src/probing.rs | 391 ++++++++++++++++++++++++++++ tests/common/mod.rs | 73 +++++- tests/probing_tests.rs | 565 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1261 insertions(+), 6 deletions(-) create mode 100644 src/probing.rs create mode 100644 tests/probing_tests.rs diff --git a/src/builder.rs b/src/builder.rs index 806c676b3..0d16993fb 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -9,8 +9,9 @@ use std::collections::HashMap; use std::convert::TryInto; use std::default::Default; use std::path::PathBuf; +use std::sync::atomic::AtomicU64; use std::sync::{Arc, Mutex, Once, RwLock}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use std::{fmt, fs}; use bdk_wallet::template::Bip84; @@ -47,6 +48,8 @@ use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MAX_PROBE_LOCKED_MSAT, DEFAULT_PROBING_INTERVAL_SECS, + MIN_PROBE_AMOUNT_MSAT, }; use crate::connection::ConnectionManager; use crate::entropy::NodeEntropy; @@ -73,6 +76,7 @@ use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; +use crate::probing; use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ @@ -151,6 +155,37 @@ impl std::fmt::Debug for LogWriterConfig { } } +enum ProbingStrategyKind { + HighDegree { top_n: usize }, + Random { max_hops: usize }, + Custom(Arc), +} + +struct ProbingStrategyConfig { + kind: ProbingStrategyKind, + interval: Duration, + max_locked_msat: u64, +} + +impl fmt::Debug for ProbingStrategyConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let kind_str = match &self.kind { + ProbingStrategyKind::HighDegree { top_n } => { + format!("HighDegree {{ top_n: {} }}", top_n) + }, + ProbingStrategyKind::Random { max_hops } => { + format!("Random {{ max_hops: {} }}", max_hops) + }, + ProbingStrategyKind::Custom(_) => "Custom()".to_string(), + }; + f.debug_struct("ProbingStrategyConfig") + .field("kind", &kind_str) + .field("interval", &self.interval) + .field("max_locked_msat", &self.max_locked_msat) + .finish() + } +} + /// An error encountered during building a [`Node`]. /// /// [`Node`]: crate::Node @@ -281,6 +316,8 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, recovery_mode: bool, + probing_strategy: Option, + probing_diversity_penalty_msat: Option, } impl NodeBuilder { @@ -299,6 +336,9 @@ impl NodeBuilder { let runtime_handle = None; let pathfinding_scores_sync_config = None; let recovery_mode = false; + let async_payments_role = None; + let probing_strategy = None; + let probing_diversity_penalty_msat = None; Self { config, chain_data_source_config, @@ -306,9 +346,11 @@ impl NodeBuilder { liquidity_source_config, log_writer_config, runtime_handle, - async_payments_role: None, + async_payments_role, pathfinding_scores_sync_config, recovery_mode, + probing_strategy, + probing_diversity_penalty_msat, } } @@ -614,6 +656,80 @@ impl NodeBuilder { self } + /// Configures background probing toward the highest-degree nodes in the network graph. + /// + /// `top_n` controls how many of the most-connected nodes are cycled through. + pub fn set_high_degree_probing_strategy(&mut self, top_n: usize) -> &mut Self { + let kind = ProbingStrategyKind::HighDegree { top_n }; + self.probing_strategy = Some(self.make_probing_config(kind)); + self + } + + /// Configures background probing via random graph walks of up to `max_hops` hops. + pub fn set_random_probing_strategy(&mut self, max_hops: usize) -> &mut Self { + let kind = ProbingStrategyKind::Random { max_hops }; + self.probing_strategy = Some(self.make_probing_config(kind)); + self + } + + /// Configures a custom probing strategy for background channel probing. + /// + /// When set, the node will periodically call [`ProbingStrategy::next_probe`] and dispatch the + /// returned probe via the channel manager. + pub fn set_custom_probing_strategy( + &mut self, strategy: Arc, + ) -> &mut Self { + let kind = ProbingStrategyKind::Custom(strategy); + self.probing_strategy = Some(self.make_probing_config(kind)); + self + } + + /// Overrides the interval between probe attempts. Only has effect if a probing strategy is set. + pub fn set_probing_interval(&mut self, interval: Duration) -> &mut Self { + if let Some(cfg) = &mut self.probing_strategy { + cfg.interval = interval; + } + self + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// Only has effect if a probing strategy is set. + pub fn set_max_probe_locked_msat(&mut self, max_msat: u64) -> &mut Self { + if let Some(cfg) = &mut self.probing_strategy { + cfg.max_locked_msat = max_msat; + } + self + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// This is only useful for probing strategies that route through the scorer + /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`RandomStrategy`]) bypass the scorer entirely. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn set_probing_diversity_penalty_msat(&mut self, penalty_msat: u64) -> &mut Self { + self.probing_diversity_penalty_msat = Some(penalty_msat); + self + } + + fn make_probing_config(&self, kind: ProbingStrategyKind) -> ProbingStrategyConfig { + let existing = self.probing_strategy.as_ref(); + ProbingStrategyConfig { + kind, + interval: existing + .map(|c| c.interval) + .unwrap_or(Duration::from_secs(DEFAULT_PROBING_INTERVAL_SECS)), + max_locked_msat: existing + .map(|c| c.max_locked_msat) + .unwrap_or(DEFAULT_MAX_PROBE_LOCKED_MSAT), + } + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -791,6 +907,8 @@ impl NodeBuilder { runtime, logger, Arc::new(DynStoreWrapper(kv_store)), + self.probing_strategy.as_ref(), + self.probing_diversity_penalty_msat, ) } } @@ -1081,6 +1199,11 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_wallet_recovery_mode(); } + /// Configures a probing strategy for background channel probing. + pub fn set_custom_probing_strategy(&self, strategy: Arc) { + self.inner.write().unwrap().set_custom_probing_strategy(strategy); + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { @@ -1226,6 +1349,7 @@ fn build_with_store_internal( pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, async_payments_role: Option, recovery_mode: bool, seed_bytes: [u8; 64], runtime: Arc, logger: Arc, kv_store: Arc, + probing_config: Option<&ProbingStrategyConfig>, probing_diversity_penalty_msat: Option, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1626,7 +1750,10 @@ fn build_with_store_internal( }, } - let scoring_fee_params = ProbabilisticScoringFeeParameters::default(); + let mut scoring_fee_params = ProbabilisticScoringFeeParameters::default(); + if let Some(penalty) = probing_diversity_penalty_msat { + scoring_fee_params.probing_diversity_penalty_msat = penalty; + } let router = Arc::new(DefaultRouter::new( Arc::clone(&network_graph), Arc::clone(&logger), @@ -1965,6 +2092,36 @@ fn build_with_store_internal( _leak_checker.0.push(Arc::downgrade(&wallet) as Weak); } + let prober = probing_config.map(|probing_cfg| { + let strategy: Arc = match &probing_cfg.kind { + ProbingStrategyKind::HighDegree { top_n } => { + Arc::new(probing::HighDegreeStrategy::new( + network_graph.clone(), + *top_n, + MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + )) + }, + ProbingStrategyKind::Random { max_hops } => Arc::new(probing::RandomStrategy::new( + network_graph.clone(), + channel_manager.clone(), + *max_hops, + MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + )), + ProbingStrategyKind::Custom(s) => s.clone(), + }; + Arc::new(probing::Prober { + channel_manager: channel_manager.clone(), + logger: logger.clone(), + strategy, + interval: probing_cfg.interval, + liquidity_limit_multiplier: Some(config.probing_liquidity_limit_multiplier), + max_locked_msat: probing_cfg.max_locked_msat, + locked_msat: Arc::new(AtomicU64::new(0)), + }) + }); + Ok(Node { runtime, stop_sender, @@ -1998,6 +2155,7 @@ fn build_with_store_internal( om_mailbox, async_payments_role, hrn_resolver, + prober, #[cfg(cycle_tests)] _leak_checker, }) diff --git a/src/config.rs b/src/config.rs index 71e4d2314..2332c38ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,10 @@ const DEFAULT_BDK_WALLET_SYNC_INTERVAL_SECS: u64 = 80; const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30; const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3; +pub(crate) const DEFAULT_PROBING_INTERVAL_SECS: u64 = 10; +pub(crate) const DEFAULT_MAX_PROBE_LOCKED_MSAT: u64 = 100_000_000; // 100k sats +pub(crate) const MIN_PROBE_AMOUNT_MSAT: u64 = 1_000_000; // 1k sats +pub(crate) const DEFAULT_MAX_PROBE_AMOUNT_MSAT: u64 = 10_000_000; // 10k sats const DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS: u64 = 25_000; // The default timeout after which we abort a wallet syncing operation. diff --git a/src/event.rs b/src/event.rs index ccee8e50b..6bffb135e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -9,6 +9,7 @@ use core::future::Future; use core::task::{Poll, Waker}; use std::collections::VecDeque; use std::ops::Deref; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -515,6 +516,7 @@ where static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, + probe_locked_msat: Option>, } impl EventHandler @@ -531,6 +533,7 @@ where keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, runtime: Arc, logger: L, config: Arc, + probe_locked_msat: Option>, ) -> Self { Self { event_queue, @@ -550,6 +553,7 @@ where static_invoice_store, onion_messenger, om_mailbox, + probe_locked_msat, } } @@ -1135,8 +1139,22 @@ where LdkEvent::PaymentPathSuccessful { .. } => {}, LdkEvent::PaymentPathFailed { .. } => {}, - LdkEvent::ProbeSuccessful { .. } => {}, - LdkEvent::ProbeFailed { .. } => {}, + LdkEvent::ProbeSuccessful { path, .. } => { + if let Some(counter) = &self.probe_locked_msat { + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + let _ = counter.fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { + Some(v.saturating_sub(amount)) + }); + } + }, + LdkEvent::ProbeFailed { path, .. } => { + if let Some(counter) = &self.probe_locked_msat { + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + let _ = counter.fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { + Some(v.saturating_sub(amount)) + }); + } + }, LdkEvent::HTLCHandlingFailed { failure_type, .. } => { if let Some(liquidity_source) = self.liquidity_source.as_ref() { liquidity_source.handle_htlc_handling_failed(failure_type).await; diff --git a/src/lib.rs b/src/lib.rs index 2e02e996c..792ba8b93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,7 @@ pub mod logger; mod message_handler; pub mod payment; mod peer_store; +mod probing; mod runtime; mod scoring; mod tx_broadcaster; @@ -170,6 +171,7 @@ use payment::{ UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; +pub use probing::{HighDegreeStrategy, Probe, ProbingStrategy, RandomStrategy}; use runtime::Runtime; pub use tokio; use types::{ @@ -239,6 +241,7 @@ pub struct Node { om_mailbox: Option>, async_payments_role: Option, hrn_resolver: Arc, + prober: Option>, #[cfg(cycle_tests)] _leak_checker: LeakChecker, } @@ -575,6 +578,7 @@ impl Node { None }; + let probe_locked_msat = self.prober.as_ref().map(|p| Arc::clone(&p.locked_msat)); let event_handler = Arc::new(EventHandler::new( Arc::clone(&self.event_queue), Arc::clone(&self.wallet), @@ -593,8 +597,16 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), + probe_locked_msat, )); + if let Some(prober) = self.prober.clone() { + let stop_rx = self.stop_sender.subscribe(); + self.runtime.spawn_cancellable_background_task(async move { + probing::run_prober(prober, stop_rx).await; + }); + } + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -1067,6 +1079,42 @@ impl Node { )) } + /// Returns the total millisatoshis currently locked in in-flight probes, or `None` if no + /// probing strategy is configured. + pub fn probe_locked_msat(&self) -> Option { + self.prober.as_ref().map(|p| p.locked_msat.load(std::sync::atomic::Ordering::Relaxed)) + } + + /// Returns the scorer's estimated `(min, max)` liquidity range for the given channel in the + /// direction toward `target`, or `None` if the scorer has no data for that channel. + /// + /// Works by serializing the `CombinedScorer` (which writes `local_only_scorer`) and + /// deserializing it as a plain `ProbabilisticScorer` to call `estimated_channel_liquidity_range`. + pub fn scorer_channel_liquidity(&self, scid: u64, target: PublicKey) -> Option<(u64, u64)> { + use lightning::routing::scoring::{ + ProbabilisticScorer, ProbabilisticScoringDecayParameters, + }; + use lightning::util::ser::{ReadableArgs, Writeable}; + + let target_node_id = lightning::routing::gossip::NodeId::from_pubkey(&target); + + let bytes = { + let scorer = self.scorer.lock().unwrap(); + let mut buf = Vec::new(); + scorer.write(&mut buf).ok()?; + buf + }; + + let decay_params = ProbabilisticScoringDecayParameters::default(); + let prob_scorer = ProbabilisticScorer::read( + &mut &bytes[..], + (decay_params, Arc::clone(&self.network_graph), Arc::clone(&self.logger)), + ) + .ok()?; + + prob_scorer.estimated_channel_liquidity_range(scid, &target_node_id) + } + /// Retrieve a list of known channels. pub fn list_channels(&self) -> Vec { self.channel_manager.list_channels().into_iter().map(|c| c.into()).collect() diff --git a/src/probing.rs b/src/probing.rs new file mode 100644 index 000000000..dcfce2d8f --- /dev/null +++ b/src/probing.rs @@ -0,0 +1,391 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use lightning::routing::gossip::NodeId; +use lightning::routing::router::{Path, RouteHop, MAX_PATH_LENGTH_ESTIMATE}; +use lightning_invoice::DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning_types::features::NodeFeatures; + +use crate::logger::{log_debug, LdkLogger, Logger}; +use crate::types::{ChannelManager, Graph}; + +/// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). +fn random_range(min: u64, max: u64) -> u64 { + debug_assert!(min <= max); + if min == max { + return min; + } + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + let range = max - min + 1; + min + (u64::from_ne_bytes(buf) % range) +} + +/// A probe to be dispatched by the Prober. +pub enum Probe { + /// A manually constructed path; dispatched via `send_probe`. + PrebuiltRoute(Path), + /// A destination to reach; the router selects the actual path via + /// `send_spontaneous_preflight_probes`. + Destination { + /// The destination node. + final_node: PublicKey, + /// The probe amount in millisatoshis. + amount_msat: u64, + }, +} + +/// Strategy can be used for determining the next target and amount for probing. +pub trait ProbingStrategy: Send + Sync + 'static { + /// Returns the next probe to run, or `None` to skip this tick. + fn next_probe(&self) -> Option; +} + +/// Probes toward the most-connected nodes in the graph. +/// +/// Sorts all graph nodes by channel count descending, then cycles through the +/// top-`top_node_count` entries using `Destination` so the router finds the actual path. +/// The probe amount is chosen uniformly at random from `[min_amount_msat, max_amount_msat]`. +pub struct HighDegreeStrategy { + network_graph: Arc, + /// How many of the highest-degree nodes to cycle through. + pub top_node_count: usize, + /// Lower bound for the randomly chosen probe amount. + pub min_amount_msat: u64, + /// Upper bound for the randomly chosen probe amount. + pub max_amount_msat: u64, + cursor: AtomicUsize, +} + +impl HighDegreeStrategy { + /// Creates a new high-degree probing strategy. + pub(crate) fn new( + network_graph: Arc, top_node_count: usize, min_amount_msat: u64, + max_amount_msat: u64, + ) -> Self { + assert!( + min_amount_msat <= max_amount_msat, + "min_amount_msat must not exceed max_amount_msat" + ); + Self { + network_graph, + top_node_count, + min_amount_msat, + max_amount_msat, + cursor: AtomicUsize::new(0), + } + } +} + +impl ProbingStrategy for HighDegreeStrategy { + fn next_probe(&self) -> Option { + let graph = self.network_graph.read_only(); + + // Collect (pubkey, channel_count) for all nodes. + let mut nodes_by_degree: Vec<(PublicKey, usize)> = graph + .nodes() + .unordered_iter() + .filter_map(|(id, info)| { + PublicKey::try_from(*id).ok().map(|pk| (pk, info.channels.len())) + }) + .collect(); + + if nodes_by_degree.is_empty() { + return None; + } + + nodes_by_degree.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + + let top_node_count = self.top_node_count.min(nodes_by_degree.len()); + + let cursor = self.cursor.fetch_add(1, Ordering::Relaxed); + let (final_node, _degree) = nodes_by_degree[cursor % top_node_count]; + + let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); + Some(Probe::Destination { final_node, amount_msat }) + } +} + +/// Explores the graph by walking a random number of hops outward from one of our own +/// channels, constructing the [`Path`] explicitly. +/// +/// On each tick: +/// 1. Picks one of our confirmed, usable channels to start from. +/// 2. Performs a deterministic walk of a randomly chosen depth (up to +/// [`MAX_PATH_LENGTH_ESTIMATE`]) through the gossip graph, skipping disabled +/// channels and dead-ends. +/// 3. Returns `Probe::PrebuiltRoute(path)` so the prober calls `send_probe` directly. +/// +/// The probe amount is chosen uniformly at random from `[min_amount_msat, max_amount_msat]`. +/// +/// Because path selection ignores the scorer, this probes channels the router +/// would never try on its own, teaching the scorer about previously unknown paths. +pub struct RandomStrategy { + network_graph: Arc, + channel_manager: Arc, + /// Upper bound on the number of hops in a randomly constructed path. + pub max_hops: usize, + /// Lower bound for the randomly chosen probe amount. + pub min_amount_msat: u64, + /// Upper bound for the randomly chosen probe amount. + pub max_amount_msat: u64, +} + +impl RandomStrategy { + /// Creates a new random-walk probing strategy. + pub(crate) fn new( + network_graph: Arc, channel_manager: Arc, max_hops: usize, + min_amount_msat: u64, max_amount_msat: u64, + ) -> Self { + assert!( + min_amount_msat <= max_amount_msat, + "min_amount_msat must not exceed max_amount_msat" + ); + Self { + network_graph, + channel_manager, + max_hops: max_hops.clamp(1, MAX_PATH_LENGTH_ESTIMATE as usize), + min_amount_msat, + max_amount_msat, + } + } + + /// Tries to build a path of `target_hops` hops. Returns `None` if the local node has no + /// usable channels, or the walk terminates before reaching `target_hops`. + fn try_build_path(&self, target_hops: usize, amount_msat: u64) -> Option { + let initial_channels = self + .channel_manager + .list_channels() + .into_iter() + .filter(|c| c.is_usable && c.short_channel_id.is_some()) + .collect::>(); + + if initial_channels.is_empty() { + return None; + } + + let graph = self.network_graph.read_only(); + let first_hop = + &initial_channels[random_range(0, initial_channels.len() as u64 - 1) as usize]; + let first_hop_scid = first_hop.short_channel_id.unwrap(); + let next_peer_pubkey = first_hop.counterparty.node_id; + let next_peer_node_id = NodeId::from_pubkey(&next_peer_pubkey); + + // Track the tightest HTLC limit across all hops to cap the probe amount. + // The first hop limit comes from our live channel state; subsequent hops use htlc_maximum_msat from the gossip channel update. + let mut route_least_htlc_upper_bound = first_hop.next_outbound_htlc_limit_msat; + + // Walk the graph: each entry is (node_id, arrived_via_scid, pubkey); first entry is set: + let mut route: Vec<(NodeId, u64, PublicKey)> = + vec![(next_peer_node_id, first_hop_scid, next_peer_pubkey)]; + + let mut prev_scid = first_hop_scid; + let mut current_node_id = next_peer_node_id; + + for _ in 1..target_hops { + let node_info = match graph.node(¤t_node_id) { + Some(n) => n, + None => break, + }; + + // Outward channels: skip the one we arrived on to avoid backtracking. + let candidates: Vec = + node_info.channels.iter().copied().filter(|&scid| scid != prev_scid).collect(); + + if candidates.is_empty() { + break; + } + + let next_scid = candidates[random_range(0, candidates.len() as u64 - 1) as usize]; + let next_channel = match graph.channel(next_scid) { + Some(c) => c, + None => break, + }; + + // as_directed_from validates that current_node_id is a channel endpoint and that + // both direction updates are present; effective_capacity covers both htlc_maximum_msat + // and funding capacity. + let Some((directed, next_node_id)) = next_channel.as_directed_from(¤t_node_id) + else { + break; + }; + // Retrieve the direction-specific update via the public ChannelInfo fields. + // Safe to unwrap: as_directed_from already checked both directions are Some. + let update = if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref().unwrap() + } else { + next_channel.two_to_one.as_ref().unwrap() + }; + + if !update.enabled { + break; + } + + route_least_htlc_upper_bound = + route_least_htlc_upper_bound.min(update.htlc_maximum_msat); + + let next_pubkey = match PublicKey::try_from(*next_node_id) { + Ok(pk) => pk, + Err(_) => break, + }; + + route.push((*next_node_id, next_scid, next_pubkey)); + prev_scid = next_scid; + current_node_id = *next_node_id; + } + + let amount_msat = amount_msat.min(route_least_htlc_upper_bound); //cap probe amount + if amount_msat < self.min_amount_msat { + return None; + } + + // Assemble hops. + // For hop i: fee and CLTV are determined by the *next* channel (what route[i] + // will charge to forward onward). For the last hop they are amount_msat and zero expiry delta. + let mut hops = Vec::with_capacity(route.len()); + for i in 0..route.len() { + let (node_id, via_scid, pubkey) = route[i]; + + let channel_info = graph.channel(via_scid)?; + + let node_features = graph + .node(&node_id) + .and_then(|n| n.announcement_info.as_ref().map(|a| a.features().clone())) + .unwrap_or_else(NodeFeatures::empty); + + let (fee_msat, cltv_expiry_delta) = if i + 1 < route.len() { + // non-final hop + let (_, next_scid, _) = route[i + 1]; + let next_channel = graph.channel(next_scid)?; + let (directed, _) = next_channel.as_directed_from(&node_id)?; + let update = if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref().unwrap() + } else { + next_channel.two_to_one.as_ref().unwrap() + }; + let fee = update.fees.base_msat as u64 + + (amount_msat * update.fees.proportional_millionths as u64 / 1_000_000); + (fee, update.cltv_expiry_delta as u32) + } else { + // Final hop: fee_msat carries the delivery amount; cltv delta is zero. + (amount_msat, 0) + }; + + hops.push(RouteHop { + pubkey, + node_features, + short_channel_id: via_scid, + channel_features: channel_info.features.clone(), + fee_msat, + cltv_expiry_delta, + maybe_announced_channel: true, + }); + } + + // The first-hop HTLC carries amount_msat + all intermediate fees. + // Verify the total fits within our live outbound limit before returning. + let total_outgoing: u64 = hops.iter().map(|h| h.fee_msat).sum(); + if total_outgoing > first_hop.next_outbound_htlc_limit_msat { + return None; + } + + Some(Path { hops, blinded_tail: None }) + } +} + +impl ProbingStrategy for RandomStrategy { + fn next_probe(&self) -> Option { + let target_hops = random_range(1, self.max_hops as u64) as usize; + let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); + + self.try_build_path(target_hops, amount_msat).map(Probe::PrebuiltRoute) + } +} + +/// Periodically dispatches probes according to a [`ProbingStrategy`]. +pub struct Prober { + /// The channel manager used to send probes. + pub channel_manager: Arc, + /// Logger. + pub logger: Arc, + /// The strategy that decides what to probe. + pub strategy: Arc, + /// How often to fire a probe attempt. + pub interval: Duration, + /// Passed to `send_spontaneous_preflight_probes`. `None` uses LDK default (3×). + pub liquidity_limit_multiplier: Option, + /// Maximum total millisatoshis that may be locked in in-flight probes at any time. + pub max_locked_msat: u64, + /// Current millisatoshis locked in in-flight probes. Shared with the event handler, + /// which decrements it on `ProbeSuccessful` / `ProbeFailed`. + pub(crate) locked_msat: Arc, +} + +/// Runs the probing loop for the given [`Prober`] until `stop_rx` fires. +pub(crate) async fn run_prober(prober: Arc, mut stop_rx: tokio::sync::watch::Receiver<()>) { + let mut ticker = tokio::time::interval(prober.interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = stop_rx.changed() => { + log_debug!(prober.logger, "Stopping background probing."); + return; + } + _ = ticker.tick() => { + match prober.strategy.next_probe() { + None => {} + Some(Probe::PrebuiltRoute(path)) => { + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + if prober.locked_msat.load(Ordering::Acquire) + amount > prober.max_locked_msat { + log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); + } else { + match prober.channel_manager.send_probe(path) { + Ok(_) => { + prober.locked_msat.fetch_add(amount, Ordering::Release); + } + Err(e) => { + log_debug!(prober.logger, "Prebuilt path probe failed: {:?}", e); + } + } + } + } + Some(Probe::Destination { final_node, amount_msat }) => { + if prober.locked_msat.load(Ordering::Acquire) + amount_msat + > prober.max_locked_msat + { + log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); + } else { + match prober.channel_manager.send_spontaneous_preflight_probes( + final_node, + amount_msat, + DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32, + prober.liquidity_limit_multiplier, + ) { + Ok(probes) => { + if !probes.is_empty() { + prober.locked_msat.fetch_add(amount_msat, Ordering::Release); + } else { + log_debug!(prober.logger, "No probe paths found for destination {}; skipping budget increment.", final_node); + } + } + Err(e) => { + log_debug!(prober.logger, "Route-follow probe to {} failed: {:?}", final_node, e); + } + } + } + } + } + } + } + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7854a77f2..98dd94c02 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -32,7 +32,7 @@ use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; use ldk_node::{ Builder, CustomTlvRecord, Event, LightningBalance, Node, NodeError, PendingSweepBalance, - UserChannelId, + ProbingStrategy, UserChannelId, }; use lightning::io; use lightning::ln::msgs::SocketAddress; @@ -340,6 +340,21 @@ impl Default for TestStoreType { } } +#[derive(Clone)] +pub(crate) enum TestProbingStrategy { + Random { max_hops: usize }, + HighDegree { top_n: usize }, + Custom(Arc), +} + +#[derive(Clone)] +pub(crate) struct TestProbingConfig { + pub strategy: TestProbingStrategy, + pub interval: Duration, + pub max_locked_msat: u64, + pub diversity_penalty_msat: Option, +} + #[derive(Clone)] pub(crate) struct TestConfig { pub node_config: Config, @@ -348,6 +363,7 @@ pub(crate) struct TestConfig { pub node_entropy: NodeEntropy, pub async_payments_role: Option, pub recovery_mode: bool, + pub probing: Option, } impl Default for TestConfig { @@ -367,6 +383,7 @@ impl Default for TestConfig { node_entropy, async_payments_role, recovery_mode, + probing: None, } } } @@ -483,6 +500,25 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.set_wallet_recovery_mode(); } + if let Some(probing) = config.probing { + match probing.strategy { + TestProbingStrategy::Random { max_hops } => { + builder.set_random_probing_strategy(max_hops); + }, + TestProbingStrategy::HighDegree { top_n } => { + builder.set_high_degree_probing_strategy(top_n); + }, + TestProbingStrategy::Custom(strategy) => { + builder.set_custom_probing_strategy(strategy); + }, + } + builder.set_probing_interval(probing.interval); + builder.set_max_probe_locked_msat(probing.max_locked_msat); + if let Some(penalty) = probing.diversity_penalty_msat { + builder.set_probing_diversity_penalty_msat(penalty); + } + } + let node = match config.store_type { TestStoreType::TestSyncStore => { let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); @@ -713,6 +749,41 @@ pub async fn open_channel( open_channel_push_amt(node_a, node_b, funding_amount_sat, None, should_announce, electrsd).await } +/// Like [`open_channel`] but skips the `wait_for_tx` electrum check so that +/// multiple channels can be opened back-to-back before any blocks are mined. +/// The caller is responsible for mining blocks and confirming the funding txs. +pub async fn open_channel_no_electrum_wait( + node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool, +) -> OutPoint { + if should_announce { + node_a + .open_announced_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + funding_amount_sat, + None, + None, + ) + .unwrap(); + } else { + node_a + .open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + funding_amount_sat, + None, + None, + ) + .unwrap(); + } + assert!(node_a.list_peers().iter().find(|c| { c.node_id == node_b.node_id() }).is_some()); + + let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); + let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); + assert_eq!(funding_txo_a, funding_txo_b); + funding_txo_a +} + pub async fn open_channel_push_amt( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, should_announce: bool, electrsd: &ElectrsD, diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs new file mode 100644 index 000000000..dc521bebd --- /dev/null +++ b/tests/probing_tests.rs @@ -0,0 +1,565 @@ +// Integration tests for the probing service. +// +// Budget tests – linear A ──[1M sats]──▶ B ──[1M sats]──▶ C topology: +// +// probe_budget_increments_and_decrements +// Verifies locked_msat rises when a probe is dispatched and returns +// to zero once the probe resolves. +// +// exhausted_probe_budget_blocks_new_probes +// Stops B mid-flight so the HTLC cannot resolve; confirms the budget +// stays exhausted and no further probes are sent. After B restarts +// the probe fails, the budget clears, and new probes resume. +// +// Strategy tests: +// +// probing_strategies_perfomance +// Brings up a random mesh of nodes, fires random-walk probes via +// RandomStrategy and high-degree probes via HighDegreeStrategy, then +// runs payment rounds and prints probing perfomance tables. + +mod common; + +use lightning::routing::gossip::NodeAlias; +use lightning_invoice::{Bolt11InvoiceDescription, Description}; + +use common::{ + expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, + open_channel_no_electrum_wait, premine_and_distribute_funds, random_config, + setup_bitcoind_and_electrsd, setup_node, TestChainSource, TestProbingConfig, + TestProbingStrategy, +}; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Amount; +use ldk_node::{Event, Node, Probe, ProbingStrategy}; + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +const PROBE_AMOUNT_MSAT: u64 = 1_000_000; +const MAX_LOCKED_MSAT: u64 = 100_000_000; +const PROBING_INTERVAL_MILLISECONDS: u64 = 500; +const PROBING_DIVERSITY_PENALTY: u64 = 50_000; + +/// FixedDestStrategy — always targets one node; used by budget tests. +struct FixedDestStrategy { + destination: PublicKey, + amount_msat: u64, +} + +impl FixedDestStrategy { + fn new(destination: PublicKey, amount_msat: u64) -> Arc { + Arc::new(Self { destination, amount_msat }) + } +} + +impl ProbingStrategy for FixedDestStrategy { + fn next_probe(&self) -> Option { + Some(Probe::Destination { final_node: self.destination, amount_msat: self.amount_msat }) + } +} + +// helpers +async fn wait_until(timeout: Duration, predicate: impl Fn() -> bool) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if predicate() { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn config_with_label(label: &str) -> common::TestConfig { + let mut config = random_config(false); + let mut alias_bytes = [0u8; 32]; + let b = label.as_bytes(); + alias_bytes[..b.len()].copy_from_slice(b); + config.node_config.node_alias = Some(NodeAlias(alias_bytes)); + config +} + +fn probing_config( + strategy: TestProbingStrategy, max_locked_msat: u64, diversity_penalty_msat: Option, +) -> Option { + Some(TestProbingConfig { + strategy, + interval: Duration::from_millis(PROBING_INTERVAL_MILLISECONDS), + max_locked_msat, + diversity_penalty_msat, + }) +} + +fn build_node_fixed_dest_probing( + chain_source: &TestChainSource<'_>, destination_node_id: PublicKey, +) -> Node { + let mut config = random_config(false); + let strategy = FixedDestStrategy::new(destination_node_id, PROBE_AMOUNT_MSAT); + config.probing = probing_config(TestProbingStrategy::Custom(strategy), PROBE_AMOUNT_MSAT, None); + setup_node(chain_source, config) +} + +fn build_node_random_probing(chain_source: &TestChainSource<'_>, max_hops: usize) -> Node { + let mut config = config_with_label("Random"); + config.probing = + probing_config(TestProbingStrategy::Random { max_hops }, MAX_LOCKED_MSAT, None); + setup_node(chain_source, config) +} + +fn build_node_highdegree_probing(chain_source: &TestChainSource<'_>, top_n: usize) -> Node { + let mut config = config_with_label("HiDeg"); + config.probing = + probing_config(TestProbingStrategy::HighDegree { top_n }, MAX_LOCKED_MSAT, None); + setup_node(chain_source, config) +} + +fn build_node_z_highdegree_probing( + chain_source: &TestChainSource<'_>, top_n: usize, diversity_penalty_msat: u64, +) -> Node { + let mut config = config_with_label("HiDeg+P"); + config.probing = probing_config( + TestProbingStrategy::HighDegree { top_n }, + MAX_LOCKED_MSAT, + Some(diversity_penalty_msat), + ); + setup_node(chain_source, config) +} + +// helpers, formatting +fn node_label(node: &Node) -> String { + node.node_alias() + .map(|alias| { + let end = alias.0.iter().position(|&b| b == 0).unwrap_or(32); + String::from_utf8_lossy(&alias.0[..end]).to_string() + }) + .unwrap_or_else(|| format!("{:.8}", node.node_id())) +} + +fn print_topology(all_nodes: &[&Node]) { + let labels: HashMap = + all_nodes.iter().map(|n| (n.node_id(), node_label(n))).collect(); + let label_of = |pk: PublicKey| labels.get(&pk).cloned().unwrap_or_else(|| format!("{:.8}", pk)); + + let mut adjacency: BTreeMap> = BTreeMap::new(); + for node in all_nodes { + let local = label_of(node.node_id()); + let mut peers: Vec = node + .list_channels() + .into_iter() + .filter(|ch| ch.short_channel_id.is_some()) + .map(|ch| label_of(ch.counterparty_node_id)) + .collect(); + peers.sort(); + peers.dedup(); + adjacency.entry(local).or_default().extend(peers); + } + + println!("\n=== Topology ==="); + for (node, peers) in &adjacency { + println!(" {node} ── {}", peers.join(", ")); + } +} + +const LABEL_MAX: usize = 8; +const DIR_W: usize = LABEL_MAX * 2 + 1; +const SCORER_W: usize = 28; + +fn thousands(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(' '); + } + out.push(c); + } + out.chars().rev().collect() +} + +fn short_label(label: &str) -> String { + label.chars().take(LABEL_MAX).collect() +} + +fn fmt_est(est: Option<(u64, u64)>) -> String { + match est { + Some((lo, hi)) => format!("[{}, {}]", thousands(lo), thousands(hi)), + None => "unknown".into(), + } +} + +fn print_probing_perfomance(observers: &[&Node], all_nodes: &[&Node]) { + let labels: HashMap = + all_nodes.iter().chain(observers.iter()).map(|n| (n.node_id(), node_label(n))).collect(); + let label_of = |pk: PublicKey| { + short_label(&labels.get(&pk).cloned().unwrap_or_else(|| format!("{:.8}", pk))) + }; + + let mut by_scid: BTreeMap> = BTreeMap::new(); + for node in all_nodes { + let local_pk = node.node_id(); + for ch in node.list_channels() { + if let Some(scid) = ch.short_channel_id { + by_scid.entry(scid).or_default().push(( + local_pk, + ch.counterparty_node_id, + ch.outbound_capacity_msat, + )); + } + } + } + + print!("\n{:<15} {: 0).await; + assert!(went_up, "locked_msat never increased — no probe was dispatched"); + println!("First probe dispatched; locked_msat = {}", node_a.probe_locked_msat().unwrap()); + + let cleared = + wait_until(Duration::from_secs(20), || node_a.probe_locked_msat().unwrap_or(1) == 0).await; + assert!(cleared, "locked_msat never returned to zero after probe resolved"); + println!("Probe resolved; locked_msat = 0"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Test that probing stops if the upper locked in flight probe limit is reached. +/// Uses a slow probing interval (3s) so we can capture baseline capacity before the first probe. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exhausted_probe_budget_blocks_new_probes() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Electrum(&electrsd); + + let node_b = setup_node(&chain_source, random_config(false)); + let node_c = setup_node(&chain_source, random_config(false)); + + // Use a slow probing interval so we can read capacity before the first probe fires. + let mut config_a = random_config(false); + let strategy = FixedDestStrategy::new(node_c.node_id(), PROBE_AMOUNT_MSAT); + config_a.probing = Some(TestProbingConfig { + strategy: TestProbingStrategy::Custom(strategy), + interval: Duration::from_secs(3), + max_locked_msat: PROBE_AMOUNT_MSAT, + diversity_penalty_msat: None, + }); + let node_a = setup_node(&chain_source, config_a); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + // Record capacity before the first probe fires (interval is 3s, so we have time). + let capacity_at_open = node_a + .list_channels() + .iter() + .find(|ch| ch.counterparty_node_id == node_b.node_id()) + .map(|ch| ch.outbound_capacity_msat) + .expect("A→B channel not found"); + + // Give gossip time to propagate to A, then wait for the first probe. + let locked = + wait_until(Duration::from_secs(15), || node_a.probe_locked_msat().unwrap_or(0) > 0).await; + assert!(locked, "no probe dispatched within 15 s"); + + // Capacity should have decreased due to the in-flight probe HTLC. + let capacity_with_probe = node_a + .list_channels() + .iter() + .find(|ch| ch.counterparty_node_id == node_b.node_id()) + .map(|ch| ch.outbound_capacity_msat) + .expect("A→B channel not found"); + assert!( + capacity_with_probe < capacity_at_open, + "HTLC not visible in channel state: capacity unchanged ({capacity_at_open} msat)" + ); + + // Stop B while the probe HTLC is in-flight. + node_b.stop().unwrap(); + + // Let several Prober ticks fire (interval is 3s); the budget is exhausted so + // they must be skipped. Wait, then check both conditions at once. + tokio::time::sleep(Duration::from_secs(5)).await; + assert!( + node_a.probe_locked_msat().unwrap_or(0) > 0, + "probe resolved unexpectedly while B was offline" + ); + let capacity_after_wait = node_a + .list_channels() + .iter() + .find(|ch| ch.counterparty_node_id == node_b.node_id()) + .map(|ch| ch.outbound_capacity_msat) + .unwrap_or(u64::MAX); + assert!( + capacity_after_wait >= capacity_with_probe, + "a new probe HTLC was sent despite budget being exhausted" + ); + + // Bring B back and explicitly reconnect to A and C so the stuck HTLC resolves + // without waiting for the background reconnection backoff. + node_b.start().unwrap(); + let node_a_addr = node_a.listening_addresses().unwrap().first().unwrap().clone(); + let node_c_addr = node_c.listening_addresses().unwrap().first().unwrap().clone(); + node_b.connect(node_a.node_id(), node_a_addr, false).unwrap(); + node_b.connect(node_c.node_id(), node_c_addr, false).unwrap(); + + let cleared = + wait_until(Duration::from_secs(15), || node_a.probe_locked_msat().unwrap_or(1) == 0).await; + assert!(cleared, "locked_msat never cleared after B came back online"); + + // Once the budget is freed, a new probe should be dispatched within a few ticks. + let new_probe = + wait_until(Duration::from_secs(10), || node_a.probe_locked_msat().unwrap_or(0) > 0).await; + assert!(new_probe, "no new probe dispatched after budget was freed"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Strategies perfomance test +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn probing_strategies_perfomance() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Electrum(&electrsd); + + let num_nodes = 5; + let channel_capacity_sat = 1_000_000u64; + // Each observer opens 1 channel; regular nodes open at most (num_nodes-1) each. + // num_nodes UTXOs per node is a safe upper bound for funding. + let utxos_per_node = num_nodes; + let utxo_per_channel = Amount::from_sat(channel_capacity_sat + 50_000); + + let mut nodes: Vec = Vec::new(); + for i in 0..num_nodes { + let label = char::from(b'B' + i as u8).to_string(); + let mut config = random_config(false); + let mut alias_bytes = [0u8; 32]; + alias_bytes[..label.as_bytes().len()].copy_from_slice(label.as_bytes()); + config.node_config.node_alias = Some(NodeAlias(alias_bytes)); + nodes.push(setup_node(&chain_source, config)); + } + let node_a = build_node_random_probing(&chain_source, 4); + let node_x = setup_node(&chain_source, config_with_label("nostrat")); + let node_y = build_node_highdegree_probing(&chain_source, 4); + let node_z = build_node_z_highdegree_probing(&chain_source, 4, PROBING_DIVERSITY_PENALTY); + + let seed = std::env::var("TEST_SEED") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| rand::rng().random()); + println!("RNG seed: {seed} (re-run with TEST_SEED={seed} to reproduce)"); + let mut rng = StdRng::seed_from_u64(seed); + let channels_per_node = rng.random_range(1..=num_nodes - 1); + let channels_per_nodes: Vec = + (0..num_nodes).map(|_| rng.random_range(1..=channels_per_node)).collect(); + + let observer_nodes: [&Node; 4] = [&node_a, &node_y, &node_z, &node_x]; + + let mut addresses = Vec::new(); + for node in observer_nodes { + for _ in 0..utxos_per_node { + addresses.push(node.onchain_payment().new_address().unwrap()); + } + } + for node in &nodes { + for _ in 0..utxos_per_node { + addresses.push(node.onchain_payment().new_address().unwrap()); + } + } + + premine_and_distribute_funds(&bitcoind.client, &electrsd.client, addresses, utxo_per_channel) + .await; + + println!("distributed initial sats"); + for node in nodes.iter().chain(observer_nodes) { + node.sync_wallets().unwrap(); + } + + fn drain_events(node: &Node) { + while let Some(_) = node.next_event() { + node.event_handled().unwrap(); + } + } + + println!("opening channels"); + for node in observer_nodes { + let idx = rng.random_range(0..num_nodes); + open_channel_no_electrum_wait(node, &nodes[idx], channel_capacity_sat, true).await; + } + for (i, &count) in channels_per_nodes.iter().enumerate() { + let targets: Vec = (0..num_nodes).filter(|&j| j != i).take(count).collect(); + for j in targets { + open_channel_no_electrum_wait(&nodes[i], &nodes[j], channel_capacity_sat, true).await; + } + } + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + for node in nodes.iter().chain(observer_nodes) { + node.sync_wallets().unwrap(); + } + for node in nodes.iter().chain(observer_nodes) { + drain_events(node); + } + + tokio::time::sleep(Duration::from_secs(3)).await; + + let mut node_map = HashMap::new(); + for (i, node) in nodes.iter().enumerate() { + node_map.insert(node.node_id(), i); + } + + let all_nodes: Vec<&Node> = nodes.iter().chain(observer_nodes).collect(); + + print_topology(&all_nodes); + + println!("\nbefore payments"); + print_probing_perfomance(&observer_nodes, &all_nodes); + + let desc = Bolt11InvoiceDescription::Direct(Description::new("test".to_string()).unwrap()); + for round in 0..10 { + let mut sent = 0u32; + for sender_idx in 0..num_nodes { + let channels: Vec<_> = nodes[sender_idx] + .list_channels() + .into_iter() + .filter(|ch| ch.is_channel_ready && ch.outbound_capacity_msat > 1_000) + .collect(); + if channels.is_empty() { + continue; + } + let ch = &channels[rng.random_range(0..channels.len())]; + let amount_msat = rng.random_range(1_000..=ch.outbound_capacity_msat.min(100_000_000)); + if let Some(&receiver_idx) = node_map.get(&ch.counterparty_node_id) { + let invoice = nodes[receiver_idx] + .bolt11_payment() + .receive(amount_msat, &desc.clone().into(), 3600) + .unwrap(); + if nodes[sender_idx].bolt11_payment().send(&invoice, None).is_ok() { + sent += 1; + } + } + } + println!("round {round}: sent {sent} payments"); + tokio::time::sleep(Duration::from_millis(500)).await; + for node in nodes.iter().chain(observer_nodes) { + drain_events(node); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + println!("\n=== after payments ==="); + print_probing_perfomance(&observer_nodes, &all_nodes); + + for node in nodes.iter().chain(observer_nodes) { + node.stop().unwrap(); + } +} From 3571c5e040c0c0befc28c2e8cb4ac99b0a1feb4a Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Thu, 19 Mar 2026 02:37:47 +0100 Subject: [PATCH 02/16] Fix uniffi and docs --- src/builder.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 0d16993fb..fafde023e 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -155,6 +155,7 @@ impl std::fmt::Debug for LogWriterConfig { } } +#[cfg_attr(feature = "uniffi", allow(dead_code))] enum ProbingStrategyKind { HighDegree { top_n: usize }, Random { max_hops: usize }, @@ -659,6 +660,7 @@ impl NodeBuilder { /// Configures background probing toward the highest-degree nodes in the network graph. /// /// `top_n` controls how many of the most-connected nodes are cycled through. + #[cfg_attr(feature = "uniffi", allow(dead_code))] pub fn set_high_degree_probing_strategy(&mut self, top_n: usize) -> &mut Self { let kind = ProbingStrategyKind::HighDegree { top_n }; self.probing_strategy = Some(self.make_probing_config(kind)); @@ -666,6 +668,7 @@ impl NodeBuilder { } /// Configures background probing via random graph walks of up to `max_hops` hops. + #[cfg_attr(feature = "uniffi", allow(dead_code))] pub fn set_random_probing_strategy(&mut self, max_hops: usize) -> &mut Self { let kind = ProbingStrategyKind::Random { max_hops }; self.probing_strategy = Some(self.make_probing_config(kind)); @@ -674,8 +677,9 @@ impl NodeBuilder { /// Configures a custom probing strategy for background channel probing. /// - /// When set, the node will periodically call [`ProbingStrategy::next_probe`] and dispatch the + /// When set, the node will periodically call [`probing::ProbingStrategy::next_probe`] and dispatch the /// returned probe via the channel manager. + #[cfg_attr(feature = "uniffi", allow(dead_code))] pub fn set_custom_probing_strategy( &mut self, strategy: Arc, ) -> &mut Self { @@ -685,6 +689,7 @@ impl NodeBuilder { } /// Overrides the interval between probe attempts. Only has effect if a probing strategy is set. + #[cfg_attr(feature = "uniffi", allow(dead_code))] pub fn set_probing_interval(&mut self, interval: Duration) -> &mut Self { if let Some(cfg) = &mut self.probing_strategy { cfg.interval = interval; @@ -694,6 +699,7 @@ impl NodeBuilder { /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. /// Only has effect if a probing strategy is set. + #[cfg_attr(feature = "uniffi", allow(dead_code))] pub fn set_max_probe_locked_msat(&mut self, max_msat: u64) -> &mut Self { if let Some(cfg) = &mut self.probing_strategy { cfg.max_locked_msat = max_msat; @@ -708,15 +714,17 @@ impl NodeBuilder { /// quadratically over 24 hours. /// /// This is only useful for probing strategies that route through the scorer - /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually - /// (e.g., [`RandomStrategy`]) bypass the scorer entirely. + /// (e.g., [`probing::HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`probing::RandomStrategy`]) bypass the scorer entirely. /// /// If unset, LDK's default of `0` (no penalty) is used. + #[cfg_attr(feature = "uniffi", allow(dead_code))] pub fn set_probing_diversity_penalty_msat(&mut self, penalty_msat: u64) -> &mut Self { self.probing_diversity_penalty_msat = Some(penalty_msat); self } + #[cfg_attr(feature = "uniffi", allow(dead_code))] fn make_probing_config(&self, kind: ProbingStrategyKind) -> ProbingStrategyConfig { let existing = self.probing_strategy.as_ref(); ProbingStrategyConfig { From c31f1ce4ca0fe06cd63374d0fa10d23839415863 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Thu, 19 Mar 2026 03:10:22 +0100 Subject: [PATCH 03/16] Add short descriptions to probing tests --- tests/probing_tests.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index dc521bebd..6eb690ade 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -253,7 +253,8 @@ fn print_probing_perfomance(observers: &[&Node], all_nodes: &[&Node]) { println!(); } -/// Test change of locked_msat amount +/// Verifies that `locked_msat` increases when a probe is dispatched and returns +/// to zero once the probe resolves (succeeds or fails). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn probe_budget_increments_and_decrements() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -308,8 +309,11 @@ async fn probe_budget_increments_and_decrements() { node_c.stop().unwrap(); } -/// Test that probing stops if the upper locked in flight probe limit is reached. -/// Uses a slow probing interval (3s) so we can capture baseline capacity before the first probe. +/// Verifies that no new probes are dispatched once the in-flight budget is exhausted. +/// +/// Exhaustion is triggered by stopping the intermediate node (B) while a probe HTLC +/// is in-flight, preventing resolution and keeping the budget locked. After B restarts +/// the HTLC fails, the budget clears, and probing resumes. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exhausted_probe_budget_blocks_new_probes() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -424,7 +428,9 @@ async fn exhausted_probe_budget_blocks_new_probes() { node_c.stop().unwrap(); } -/// Strategies perfomance test +/// Builds a random mesh of nodes, runs `RandomStrategy` and `HighDegreeStrategy` +/// probers alongside payment rounds, then prints scorer liquidity estimates to +/// compare probing coverage. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn probing_strategies_perfomance() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 200de8067b5d2891604acaa2cb498af0c2f2ef7e Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Thu, 26 Mar 2026 03:04:08 +0100 Subject: [PATCH 04/16] Add uniffi support of probing --- src/builder.rs | 27 ++++++++++++++++++++++++++- tests/common/mod.rs | 4 ++-- tests/probing_tests.rs | 26 +++++++++++++------------- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index fafde023e..972d146af 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1207,11 +1207,36 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_wallet_recovery_mode(); } - /// Configures a probing strategy for background channel probing. + /// Configures background probing toward the highest-degree nodes in the network graph. + pub fn set_high_degree_probing_strategy(&self, top_n: usize) { + self.inner.write().unwrap().set_high_degree_probing_strategy(top_n); + } + + /// Configures background probing via random graph walks of up to `max_hops` hops. + pub fn set_random_probing_strategy(&self, max_hops: usize) { + self.inner.write().unwrap().set_random_probing_strategy(max_hops); + } + + /// Configures a custom probing strategy for background channel probing. pub fn set_custom_probing_strategy(&self, strategy: Arc) { self.inner.write().unwrap().set_custom_probing_strategy(strategy); } + /// Overrides the interval between probe attempts. + pub fn set_probing_interval(&self, interval: Duration) { + self.inner.write().unwrap().set_probing_interval(interval); + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + pub fn set_max_probe_locked_msat(&self, max_msat: u64) { + self.inner.write().unwrap().set_max_probe_locked_msat(max_msat); + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + pub fn set_probing_diversity_penalty_msat(&self, penalty_msat: u64) { + self.inner.write().unwrap().set_probing_diversity_penalty_msat(penalty_msat); + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 98dd94c02..3faee39ab 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -316,9 +316,9 @@ pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { } #[cfg(feature = "uniffi")] -type TestNode = Arc; +pub(crate) type TestNode = Arc; #[cfg(not(feature = "uniffi"))] -type TestNode = Node; +pub(crate) type TestNode = Node; #[derive(Clone)] pub(crate) enum TestChainSource<'a> { diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index 6eb690ade..bdcab2fb1 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -26,13 +26,13 @@ use lightning_invoice::{Bolt11InvoiceDescription, Description}; use common::{ expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, open_channel_no_electrum_wait, premine_and_distribute_funds, random_config, - setup_bitcoind_and_electrsd, setup_node, TestChainSource, TestProbingConfig, + setup_bitcoind_and_electrsd, setup_node, TestChainSource, TestNode, TestProbingConfig, TestProbingStrategy, }; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Amount; -use ldk_node::{Event, Node, Probe, ProbingStrategy}; +use ldk_node::{Event, Probe, ProbingStrategy}; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -100,21 +100,21 @@ fn probing_config( fn build_node_fixed_dest_probing( chain_source: &TestChainSource<'_>, destination_node_id: PublicKey, -) -> Node { +) -> TestNode { let mut config = random_config(false); let strategy = FixedDestStrategy::new(destination_node_id, PROBE_AMOUNT_MSAT); config.probing = probing_config(TestProbingStrategy::Custom(strategy), PROBE_AMOUNT_MSAT, None); setup_node(chain_source, config) } -fn build_node_random_probing(chain_source: &TestChainSource<'_>, max_hops: usize) -> Node { +fn build_node_random_probing(chain_source: &TestChainSource<'_>, max_hops: usize) -> TestNode { let mut config = config_with_label("Random"); config.probing = probing_config(TestProbingStrategy::Random { max_hops }, MAX_LOCKED_MSAT, None); setup_node(chain_source, config) } -fn build_node_highdegree_probing(chain_source: &TestChainSource<'_>, top_n: usize) -> Node { +fn build_node_highdegree_probing(chain_source: &TestChainSource<'_>, top_n: usize) -> TestNode { let mut config = config_with_label("HiDeg"); config.probing = probing_config(TestProbingStrategy::HighDegree { top_n }, MAX_LOCKED_MSAT, None); @@ -123,7 +123,7 @@ fn build_node_highdegree_probing(chain_source: &TestChainSource<'_>, top_n: usiz fn build_node_z_highdegree_probing( chain_source: &TestChainSource<'_>, top_n: usize, diversity_penalty_msat: u64, -) -> Node { +) -> TestNode { let mut config = config_with_label("HiDeg+P"); config.probing = probing_config( TestProbingStrategy::HighDegree { top_n }, @@ -134,7 +134,7 @@ fn build_node_z_highdegree_probing( } // helpers, formatting -fn node_label(node: &Node) -> String { +fn node_label(node: &TestNode) -> String { node.node_alias() .map(|alias| { let end = alias.0.iter().position(|&b| b == 0).unwrap_or(32); @@ -143,7 +143,7 @@ fn node_label(node: &Node) -> String { .unwrap_or_else(|| format!("{:.8}", node.node_id())) } -fn print_topology(all_nodes: &[&Node]) { +fn print_topology(all_nodes: &[&TestNode]) { let labels: HashMap = all_nodes.iter().map(|n| (n.node_id(), node_label(n))).collect(); let label_of = |pk: PublicKey| labels.get(&pk).cloned().unwrap_or_else(|| format!("{:.8}", pk)); @@ -195,7 +195,7 @@ fn fmt_est(est: Option<(u64, u64)>) -> String { } } -fn print_probing_perfomance(observers: &[&Node], all_nodes: &[&Node]) { +fn print_probing_perfomance(observers: &[&TestNode], all_nodes: &[&TestNode]) { let labels: HashMap = all_nodes.iter().chain(observers.iter()).map(|n| (n.node_id(), node_label(n))).collect(); let label_of = |pk: PublicKey| { @@ -443,7 +443,7 @@ async fn probing_strategies_perfomance() { let utxos_per_node = num_nodes; let utxo_per_channel = Amount::from_sat(channel_capacity_sat + 50_000); - let mut nodes: Vec = Vec::new(); + let mut nodes: Vec = Vec::new(); for i in 0..num_nodes { let label = char::from(b'B' + i as u8).to_string(); let mut config = random_config(false); @@ -467,7 +467,7 @@ async fn probing_strategies_perfomance() { let channels_per_nodes: Vec = (0..num_nodes).map(|_| rng.random_range(1..=channels_per_node)).collect(); - let observer_nodes: [&Node; 4] = [&node_a, &node_y, &node_z, &node_x]; + let observer_nodes: [&TestNode; 4] = [&node_a, &node_y, &node_z, &node_x]; let mut addresses = Vec::new(); for node in observer_nodes { @@ -489,7 +489,7 @@ async fn probing_strategies_perfomance() { node.sync_wallets().unwrap(); } - fn drain_events(node: &Node) { + fn drain_events(node: &TestNode) { while let Some(_) = node.next_event() { node.event_handled().unwrap(); } @@ -523,7 +523,7 @@ async fn probing_strategies_perfomance() { node_map.insert(node.node_id(), i); } - let all_nodes: Vec<&Node> = nodes.iter().chain(observer_nodes).collect(); + let all_nodes: Vec<&TestNode> = nodes.iter().chain(observer_nodes).collect(); print_topology(&all_nodes); From 83ae5952f87fc92cdaff6a379e9e2d06043023cf Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Sat, 28 Mar 2026 16:13:00 +0100 Subject: [PATCH 05/16] Add dedicated probing builder Change cursor of top nodes from HighDegreeStrategy to use cac: Create src/util.rs Add probe HTLC maximal lower bound Fix styling (config argument order), explicit Arc::clone instead of .clone() Change tests open_channel to reuse existing code --- src/builder.rs | 203 +++++++------------------------- src/config.rs | 1 + src/event.rs | 23 ++-- src/lib.rs | 16 +-- src/probing.rs | 261 ++++++++++++++++++++++++++++++++++++----- src/util.rs | 29 +++++ tests/common/mod.rs | 84 +++---------- tests/probing_tests.rs | 111 ++++++++++-------- 8 files changed, 398 insertions(+), 330 deletions(-) create mode 100644 src/util.rs diff --git a/src/builder.rs b/src/builder.rs index 972d146af..f4df35313 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -11,7 +11,7 @@ use std::default::Default; use std::path::PathBuf; use std::sync::atomic::AtomicU64; use std::sync::{Arc, Mutex, Once, RwLock}; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use std::{fmt, fs}; use bdk_wallet::template::Bip84; @@ -48,8 +48,7 @@ use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, - DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MAX_PROBE_LOCKED_MSAT, DEFAULT_PROBING_INTERVAL_SECS, - MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, MIN_PROBE_AMOUNT_MSAT, }; use crate::connection::ConnectionManager; use crate::entropy::NodeEntropy; @@ -155,38 +154,6 @@ impl std::fmt::Debug for LogWriterConfig { } } -#[cfg_attr(feature = "uniffi", allow(dead_code))] -enum ProbingStrategyKind { - HighDegree { top_n: usize }, - Random { max_hops: usize }, - Custom(Arc), -} - -struct ProbingStrategyConfig { - kind: ProbingStrategyKind, - interval: Duration, - max_locked_msat: u64, -} - -impl fmt::Debug for ProbingStrategyConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let kind_str = match &self.kind { - ProbingStrategyKind::HighDegree { top_n } => { - format!("HighDegree {{ top_n: {} }}", top_n) - }, - ProbingStrategyKind::Random { max_hops } => { - format!("Random {{ max_hops: {} }}", max_hops) - }, - ProbingStrategyKind::Custom(_) => "Custom()".to_string(), - }; - f.debug_struct("ProbingStrategyConfig") - .field("kind", &kind_str) - .field("interval", &self.interval) - .field("max_locked_msat", &self.max_locked_msat) - .finish() - } -} - /// An error encountered during building a [`Node`]. /// /// [`Node`]: crate::Node @@ -317,8 +284,7 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, recovery_mode: bool, - probing_strategy: Option, - probing_diversity_penalty_msat: Option, + probing_config: Option, } impl NodeBuilder { @@ -338,8 +304,7 @@ impl NodeBuilder { let pathfinding_scores_sync_config = None; let recovery_mode = false; let async_payments_role = None; - let probing_strategy = None; - let probing_diversity_penalty_msat = None; + let probing_config = None; Self { config, chain_data_source_config, @@ -350,8 +315,7 @@ impl NodeBuilder { async_payments_role, pathfinding_scores_sync_config, recovery_mode, - probing_strategy, - probing_diversity_penalty_msat, + probing_config, } } @@ -657,87 +621,23 @@ impl NodeBuilder { self } - /// Configures background probing toward the highest-degree nodes in the network graph. - /// - /// `top_n` controls how many of the most-connected nodes are cycled through. - #[cfg_attr(feature = "uniffi", allow(dead_code))] - pub fn set_high_degree_probing_strategy(&mut self, top_n: usize) -> &mut Self { - let kind = ProbingStrategyKind::HighDegree { top_n }; - self.probing_strategy = Some(self.make_probing_config(kind)); - self - } - - /// Configures background probing via random graph walks of up to `max_hops` hops. - #[cfg_attr(feature = "uniffi", allow(dead_code))] - pub fn set_random_probing_strategy(&mut self, max_hops: usize) -> &mut Self { - let kind = ProbingStrategyKind::Random { max_hops }; - self.probing_strategy = Some(self.make_probing_config(kind)); - self - } - - /// Configures a custom probing strategy for background channel probing. - /// - /// When set, the node will periodically call [`probing::ProbingStrategy::next_probe`] and dispatch the - /// returned probe via the channel manager. - #[cfg_attr(feature = "uniffi", allow(dead_code))] - pub fn set_custom_probing_strategy( - &mut self, strategy: Arc, - ) -> &mut Self { - let kind = ProbingStrategyKind::Custom(strategy); - self.probing_strategy = Some(self.make_probing_config(kind)); - self - } - - /// Overrides the interval between probe attempts. Only has effect if a probing strategy is set. - #[cfg_attr(feature = "uniffi", allow(dead_code))] - pub fn set_probing_interval(&mut self, interval: Duration) -> &mut Self { - if let Some(cfg) = &mut self.probing_strategy { - cfg.interval = interval; - } - self - } - - /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. - /// Only has effect if a probing strategy is set. - #[cfg_attr(feature = "uniffi", allow(dead_code))] - pub fn set_max_probe_locked_msat(&mut self, max_msat: u64) -> &mut Self { - if let Some(cfg) = &mut self.probing_strategy { - cfg.max_locked_msat = max_msat; - } - self - } - - /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// Configures background probing. /// - /// When set, the scorer will penalize channels that have been recently probed, - /// encouraging path diversity during background probing. The penalty decays - /// quadratically over 24 hours. + /// Use [`probing::ProbingConfig`] to build the configuration: + /// ```ignore + /// use ldk_node::probing::ProbingConfig; /// - /// This is only useful for probing strategies that route through the scorer - /// (e.g., [`probing::HighDegreeStrategy`]). Strategies that build paths manually - /// (e.g., [`probing::RandomStrategy`]) bypass the scorer entirely. - /// - /// If unset, LDK's default of `0` (no penalty) is used. - #[cfg_attr(feature = "uniffi", allow(dead_code))] - pub fn set_probing_diversity_penalty_msat(&mut self, penalty_msat: u64) -> &mut Self { - self.probing_diversity_penalty_msat = Some(penalty_msat); + /// builder.set_probing_config( + /// ProbingConfig::high_degree(100) + /// .interval(Duration::from_secs(30)) + /// .build() + /// ); + /// ``` + pub fn set_probing_config(&mut self, config: probing::ProbingConfig) -> &mut Self { + self.probing_config = Some(config); self } - #[cfg_attr(feature = "uniffi", allow(dead_code))] - fn make_probing_config(&self, kind: ProbingStrategyKind) -> ProbingStrategyConfig { - let existing = self.probing_strategy.as_ref(); - ProbingStrategyConfig { - kind, - interval: existing - .map(|c| c.interval) - .unwrap_or(Duration::from_secs(DEFAULT_PROBING_INTERVAL_SECS)), - max_locked_msat: existing - .map(|c| c.max_locked_msat) - .unwrap_or(DEFAULT_MAX_PROBE_LOCKED_MSAT), - } - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -909,14 +809,13 @@ impl NodeBuilder { self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), + self.probing_config.as_ref(), self.async_payments_role, self.recovery_mode, seed_bytes, runtime, logger, Arc::new(DynStoreWrapper(kv_store)), - self.probing_strategy.as_ref(), - self.probing_diversity_penalty_msat, ) } } @@ -1207,34 +1106,11 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_wallet_recovery_mode(); } - /// Configures background probing toward the highest-degree nodes in the network graph. - pub fn set_high_degree_probing_strategy(&self, top_n: usize) { - self.inner.write().unwrap().set_high_degree_probing_strategy(top_n); - } - - /// Configures background probing via random graph walks of up to `max_hops` hops. - pub fn set_random_probing_strategy(&self, max_hops: usize) { - self.inner.write().unwrap().set_random_probing_strategy(max_hops); - } - - /// Configures a custom probing strategy for background channel probing. - pub fn set_custom_probing_strategy(&self, strategy: Arc) { - self.inner.write().unwrap().set_custom_probing_strategy(strategy); - } - - /// Overrides the interval between probe attempts. - pub fn set_probing_interval(&self, interval: Duration) { - self.inner.write().unwrap().set_probing_interval(interval); - } - - /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. - pub fn set_max_probe_locked_msat(&self, max_msat: u64) { - self.inner.write().unwrap().set_max_probe_locked_msat(max_msat); - } - - /// Sets the probing diversity penalty applied by the probabilistic scorer. - pub fn set_probing_diversity_penalty_msat(&self, penalty_msat: u64) { - self.inner.write().unwrap().set_probing_diversity_penalty_msat(penalty_msat); + /// Configures background probing. + /// + /// See [`probing::ProbingConfig`] for details. + pub fn set_probing_config(&self, config: probing::ProbingConfig) { + self.inner.write().unwrap().set_probing_config(config); } /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options @@ -1380,9 +1256,9 @@ fn build_with_store_internal( gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, + probing_config: Option<&probing::ProbingConfig>, async_payments_role: Option, recovery_mode: bool, seed_bytes: [u8; 64], runtime: Arc, logger: Arc, kv_store: Arc, - probing_config: Option<&ProbingStrategyConfig>, probing_diversity_penalty_msat: Option, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1784,7 +1660,7 @@ fn build_with_store_internal( } let mut scoring_fee_params = ProbabilisticScoringFeeParameters::default(); - if let Some(penalty) = probing_diversity_penalty_msat { + if let Some(penalty) = probing_config.and_then(|c| c.diversity_penalty_msat) { scoring_fee_params.probing_diversity_penalty_msat = penalty; } let router = Arc::new(DefaultRouter::new( @@ -2127,26 +2003,29 @@ fn build_with_store_internal( let prober = probing_config.map(|probing_cfg| { let strategy: Arc = match &probing_cfg.kind { - ProbingStrategyKind::HighDegree { top_n } => { + probing::ProbingStrategyKind::HighDegree { top_node_count } => { Arc::new(probing::HighDegreeStrategy::new( - network_graph.clone(), - *top_n, + Arc::clone(&network_graph), + *top_node_count, + MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + probing_cfg.cooldown, + )) + }, + probing::ProbingStrategyKind::Random { max_hops } => { + Arc::new(probing::RandomStrategy::new( + Arc::clone(&network_graph), + Arc::clone(&channel_manager), + *max_hops, MIN_PROBE_AMOUNT_MSAT, DEFAULT_MAX_PROBE_AMOUNT_MSAT, )) }, - ProbingStrategyKind::Random { max_hops } => Arc::new(probing::RandomStrategy::new( - network_graph.clone(), - channel_manager.clone(), - *max_hops, - MIN_PROBE_AMOUNT_MSAT, - DEFAULT_MAX_PROBE_AMOUNT_MSAT, - )), - ProbingStrategyKind::Custom(s) => s.clone(), + probing::ProbingStrategyKind::Custom(s) => Arc::clone(s), }; Arc::new(probing::Prober { - channel_manager: channel_manager.clone(), - logger: logger.clone(), + channel_manager: Arc::clone(&channel_manager), + logger: Arc::clone(&logger), strategy, interval: probing_cfg.interval, liquidity_limit_multiplier: Some(config.probing_liquidity_limit_multiplier), diff --git a/src/config.rs b/src/config.rs index 2332c38ac..a7d72ceaf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30; const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3; pub(crate) const DEFAULT_PROBING_INTERVAL_SECS: u64 = 10; +pub(crate) const DEFAULT_PROBED_NODE_COOLDOWN_SECS: u64 = 60 * 60; // 1 hour pub(crate) const DEFAULT_MAX_PROBE_LOCKED_MSAT: u64 = 100_000_000; // 100k sats pub(crate) const MIN_PROBE_AMOUNT_MSAT: u64 = 1_000_000; // 1k sats pub(crate) const DEFAULT_MAX_PROBE_AMOUNT_MSAT: u64 = 10_000_000; // 10k sats diff --git a/src/event.rs b/src/event.rs index 6bffb135e..adb6e46ff 100644 --- a/src/event.rs +++ b/src/event.rs @@ -9,7 +9,6 @@ use core::future::Future; use core::task::{Poll, Waker}; use std::collections::VecDeque; use std::ops::Deref; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -53,6 +52,7 @@ use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; +use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, @@ -516,7 +516,7 @@ where static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, - probe_locked_msat: Option>, + prober: Option>, } impl EventHandler @@ -532,8 +532,7 @@ where payment_store: Arc, peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, - runtime: Arc, logger: L, config: Arc, - probe_locked_msat: Option>, + runtime: Arc, logger: L, config: Arc, prober: Option>, ) -> Self { Self { event_queue, @@ -553,7 +552,7 @@ where static_invoice_store, onion_messenger, om_mailbox, - probe_locked_msat, + prober, } } @@ -1140,19 +1139,13 @@ where LdkEvent::PaymentPathSuccessful { .. } => {}, LdkEvent::PaymentPathFailed { .. } => {}, LdkEvent::ProbeSuccessful { path, .. } => { - if let Some(counter) = &self.probe_locked_msat { - let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); - let _ = counter.fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { - Some(v.saturating_sub(amount)) - }); + if let Some(prober) = &self.prober { + prober.handle_probe_successful(&path); } }, LdkEvent::ProbeFailed { path, .. } => { - if let Some(counter) = &self.probe_locked_msat { - let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); - let _ = counter.fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { - Some(v.saturating_sub(amount)) - }); + if let Some(prober) = &self.prober { + prober.handle_probe_failed(&path); } }, LdkEvent::HTLCHandlingFailed { failure_type, .. } => { diff --git a/src/lib.rs b/src/lib.rs index 792ba8b93..cb418280d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,6 +106,7 @@ mod runtime; mod scoring; mod tx_broadcaster; mod types; +mod util; mod wallet; use std::default::Default; @@ -171,7 +172,10 @@ use payment::{ UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; -pub use probing::{HighDegreeStrategy, Probe, ProbingStrategy, RandomStrategy}; +pub use probing::{ + HighDegreeStrategy, Probe, Prober, ProbingConfig, ProbingConfigBuilder, ProbingStrategy, + RandomStrategy, +}; use runtime::Runtime; pub use tokio; use types::{ @@ -578,7 +582,6 @@ impl Node { None }; - let probe_locked_msat = self.prober.as_ref().map(|p| Arc::clone(&p.locked_msat)); let event_handler = Arc::new(EventHandler::new( Arc::clone(&self.event_queue), Arc::clone(&self.wallet), @@ -597,7 +600,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), - probe_locked_msat, + self.prober.clone(), )); if let Some(prober) = self.prober.clone() { @@ -1079,10 +1082,9 @@ impl Node { )) } - /// Returns the total millisatoshis currently locked in in-flight probes, or `None` if no - /// probing strategy is configured. - pub fn probe_locked_msat(&self) -> Option { - self.prober.as_ref().map(|p| p.locked_msat.load(std::sync::atomic::Ordering::Relaxed)) + /// Returns a reference to the [`Prober`], or `None` if no probing strategy is configured. + pub fn prober(&self) -> Option<&Prober> { + self.prober.as_deref() } /// Returns the scorer's estimated `(min, max)` liquidity range for the given channel in the diff --git a/src/probing.rs b/src/probing.rs index dcfce2d8f..3d7c616b2 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -5,9 +5,11 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Duration; +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use bitcoin::secp256k1::PublicKey; use lightning::routing::gossip::NodeId; @@ -15,19 +17,166 @@ use lightning::routing::router::{Path, RouteHop, MAX_PATH_LENGTH_ESTIMATE}; use lightning_invoice::DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning_types::features::NodeFeatures; +use crate::config::{ + DEFAULT_MAX_PROBE_LOCKED_MSAT, DEFAULT_PROBED_NODE_COOLDOWN_SECS, DEFAULT_PROBING_INTERVAL_SECS, +}; use crate::logger::{log_debug, LdkLogger, Logger}; use crate::types::{ChannelManager, Graph}; +use crate::util::random_range; + +/// Which built-in probing strategy to use, or a custom one. +#[derive(Clone)] +pub(crate) enum ProbingStrategyKind { + HighDegree { top_node_count: usize }, + Random { max_hops: usize }, + Custom(Arc), +} + +/// Configuration for the background probing subsystem. +/// +/// Use the constructor methods [`high_degree`], [`random_walk`], or [`custom`] to start +/// building, then chain optional setters and call [`build`]. +/// +/// # Example +/// ```ignore +/// let config = ProbingConfig::high_degree(100) +/// .interval(Duration::from_secs(30)) +/// .max_locked_msat(500_000) +/// .diversity_penalty_msat(250) +/// .build(); +/// builder.set_probing_config(config); +/// ``` +/// +/// [`high_degree`]: Self::high_degree +/// [`random_walk`]: Self::random_walk +/// [`custom`]: Self::custom +/// [`build`]: ProbingConfigBuilder::build +#[derive(Clone)] +pub struct ProbingConfig { + pub(crate) kind: ProbingStrategyKind, + pub(crate) interval: Duration, + pub(crate) max_locked_msat: u64, + pub(crate) diversity_penalty_msat: Option, + pub(crate) cooldown: Duration, +} + +impl fmt::Debug for ProbingConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let kind_str = match &self.kind { + ProbingStrategyKind::HighDegree { top_node_count } => { + format!("HighDegree {{ top_node_count: {} }}", top_node_count) + }, + ProbingStrategyKind::Random { max_hops } => { + format!("Random {{ max_hops: {} }}", max_hops) + }, + ProbingStrategyKind::Custom(_) => "Custom()".to_string(), + }; + f.debug_struct("ProbingConfig") + .field("kind", &kind_str) + .field("interval", &self.interval) + .field("max_locked_msat", &self.max_locked_msat) + .field("diversity_penalty_msat", &self.diversity_penalty_msat) + .field("cooldown", &self.cooldown) + .finish() + } +} + +impl ProbingConfig { + /// Start building a config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + pub fn high_degree(top_node_count: usize) -> ProbingConfigBuilder { + ProbingConfigBuilder::new(ProbingStrategyKind::HighDegree { top_node_count }) + } + + /// Start building a config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + pub fn random_walk(max_hops: usize) -> ProbingConfigBuilder { + ProbingConfigBuilder::new(ProbingStrategyKind::Random { max_hops }) + } + + /// Start building a config with a custom [`ProbingStrategy`] implementation. + pub fn custom(strategy: Arc) -> ProbingConfigBuilder { + ProbingConfigBuilder::new(ProbingStrategyKind::Custom(strategy)) + } +} + +/// Builder for [`ProbingConfig`]. +/// +/// Created via [`ProbingConfig::high_degree`], [`ProbingConfig::random_walk`], or +/// [`ProbingConfig::custom`]. Call [`build`] to finalize. +/// +/// [`build`]: Self::build +pub struct ProbingConfigBuilder { + kind: ProbingStrategyKind, + interval: Duration, + max_locked_msat: u64, + diversity_penalty_msat: Option, + cooldown: Duration, +} + +impl ProbingConfigBuilder { + fn new(kind: ProbingStrategyKind) -> Self { + Self { + kind, + interval: Duration::from_secs(DEFAULT_PROBING_INTERVAL_SECS), + max_locked_msat: DEFAULT_MAX_PROBE_LOCKED_MSAT, + diversity_penalty_msat: None, + cooldown: Duration::from_secs(DEFAULT_PROBED_NODE_COOLDOWN_SECS), + } + } -/// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). -fn random_range(min: u64, max: u64) -> u64 { - debug_assert!(min <= max); - if min == max { - return min; + /// Overrides the interval between probe attempts. + /// + /// Defaults to 10 seconds. + pub fn interval(mut self, interval: Duration) -> Self { + self.interval = interval; + self + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// + /// Defaults to 100 000 000 msat (100k sats). + pub fn max_locked_msat(mut self, max_msat: u64) -> Self { + self.max_locked_msat = max_msat; + self + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// This is only useful for probing strategies that route through the scorer + /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`RandomStrategy`]) bypass the scorer entirely. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn diversity_penalty_msat(mut self, penalty_msat: u64) -> Self { + self.diversity_penalty_msat = Some(penalty_msat); + self + } + + /// Sets how long a probed node stays ineligible before being probed again. + /// + /// Only applies to [`HighDegreeStrategy`]. Defaults to 1 hour. + pub fn cooldown(mut self, cooldown: Duration) -> Self { + self.cooldown = cooldown; + self + } + + /// Builds the [`ProbingConfig`]. + pub fn build(self) -> ProbingConfig { + ProbingConfig { + kind: self.kind, + interval: self.interval, + max_locked_msat: self.max_locked_msat, + diversity_penalty_msat: self.diversity_penalty_msat, + cooldown: self.cooldown, + } } - let mut buf = [0u8; 8]; - getrandom::fill(&mut buf).expect("getrandom failed"); - let range = max - min + 1; - min + (u64::from_ne_bytes(buf) % range) } /// A probe to be dispatched by the Prober. @@ -52,9 +201,15 @@ pub trait ProbingStrategy: Send + Sync + 'static { /// Probes toward the most-connected nodes in the graph. /// -/// Sorts all graph nodes by channel count descending, then cycles through the -/// top-`top_node_count` entries using `Destination` so the router finds the actual path. -/// The probe amount is chosen uniformly at random from `[min_amount_msat, max_amount_msat]`. +/// On each tick the strategy reads the current gossip graph, sorts nodes by +/// channel count, and picks the highest-degree node from the top +/// `top_node_count` that has not been probed within `cooldown`. +/// Nodes probed more recently are skipped so that the strategy +/// naturally spreads across the top nodes and picks up graph changes. +/// Returns `None` (skips the tick) if all top nodes are on cooldown. +/// +/// The probe amount is chosen uniformly at random from +/// `[min_amount_msat, max_amount_msat]`. pub struct HighDegreeStrategy { network_graph: Arc, /// How many of the highest-degree nodes to cycle through. @@ -63,14 +218,17 @@ pub struct HighDegreeStrategy { pub min_amount_msat: u64, /// Upper bound for the randomly chosen probe amount. pub max_amount_msat: u64, - cursor: AtomicUsize, + /// How long a node stays ineligible after being probed. + pub cooldown: Duration, + /// Nodes probed recently, with the time they were last probed. + recently_probed: Mutex>, } impl HighDegreeStrategy { /// Creates a new high-degree probing strategy. pub(crate) fn new( network_graph: Arc, top_node_count: usize, min_amount_msat: u64, - max_amount_msat: u64, + max_amount_msat: u64, cooldown: Duration, ) -> Self { assert!( min_amount_msat <= max_amount_msat, @@ -81,7 +239,8 @@ impl HighDegreeStrategy { top_node_count, min_amount_msat, max_amount_msat, - cursor: AtomicUsize::new(0), + cooldown, + recently_probed: Mutex::new(HashMap::new()), } } } @@ -95,7 +254,7 @@ impl ProbingStrategy for HighDegreeStrategy { .nodes() .unordered_iter() .filter_map(|(id, info)| { - PublicKey::try_from(*id).ok().map(|pk| (pk, info.channels.len())) + PublicKey::try_from(*id).ok().map(|pubkey| (pubkey, info.channels.len())) }) .collect(); @@ -106,9 +265,28 @@ impl ProbingStrategy for HighDegreeStrategy { nodes_by_degree.sort_unstable_by(|a, b| b.1.cmp(&a.1)); let top_node_count = self.top_node_count.min(nodes_by_degree.len()); + let now = Instant::now(); + + let mut probed = self.recently_probed.lock().unwrap(); - let cursor = self.cursor.fetch_add(1, Ordering::Relaxed); - let (final_node, _degree) = nodes_by_degree[cursor % top_node_count]; + // We could check staleness when we use the entry, but that way we'd not clear cache at + // all. For hundreds of top nodes it's okay to call retain each tick. + probed.retain(|_, probed_at| now.duration_since(*probed_at) < self.cooldown); + + // If all top nodes are on cooldown, reset and start a new cycle. + let final_node = match nodes_by_degree[..top_node_count] + .iter() + .find(|(pubkey, _)| !probed.contains_key(pubkey)) + { + Some((pubkey, _)) => *pubkey, + None => { + probed.clear(); + nodes_by_degree[0].0 + }, + }; + + probed.insert(final_node, now); + drop(probed); let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); Some(Probe::Destination { final_node, amount_msat }) @@ -183,6 +361,7 @@ impl RandomStrategy { // Track the tightest HTLC limit across all hops to cap the probe amount. // The first hop limit comes from our live channel state; subsequent hops use htlc_maximum_msat from the gossip channel update. let mut route_least_htlc_upper_bound = first_hop.next_outbound_htlc_limit_msat; + let mut route_greatest_htlc_lower_bound = first_hop.next_outbound_htlc_minimum_msat; // Walk the graph: each entry is (node_id, arrived_via_scid, pubkey); first entry is set: let mut route: Vec<(NodeId, u64, PublicKey)> = @@ -233,6 +412,9 @@ impl RandomStrategy { route_least_htlc_upper_bound = route_least_htlc_upper_bound.min(update.htlc_maximum_msat); + route_greatest_htlc_lower_bound = + route_greatest_htlc_lower_bound.max(update.htlc_minimum_msat); + let next_pubkey = match PublicKey::try_from(*next_node_id) { Ok(pk) => pk, Err(_) => break, @@ -243,7 +425,12 @@ impl RandomStrategy { current_node_id = *next_node_id; } - let amount_msat = amount_msat.min(route_least_htlc_upper_bound); //cap probe amount + // The route is infeasible if any hop's minimum exceeds another hop's maximum. + if route_greatest_htlc_lower_bound > route_least_htlc_upper_bound { + return None; + } + let amount_msat = + amount_msat.max(route_greatest_htlc_lower_bound).min(route_least_htlc_upper_bound); if amount_msat < self.min_amount_msat { return None; } @@ -313,10 +500,8 @@ impl ProbingStrategy for RandomStrategy { /// Periodically dispatches probes according to a [`ProbingStrategy`]. pub struct Prober { - /// The channel manager used to send probes. - pub channel_manager: Arc, - /// Logger. - pub logger: Arc, + pub(crate) channel_manager: Arc, + pub(crate) logger: Arc, /// The strategy that decides what to probe. pub strategy: Arc, /// How often to fire a probe attempt. @@ -325,11 +510,30 @@ pub struct Prober { pub liquidity_limit_multiplier: Option, /// Maximum total millisatoshis that may be locked in in-flight probes at any time. pub max_locked_msat: u64, - /// Current millisatoshis locked in in-flight probes. Shared with the event handler, - /// which decrements it on `ProbeSuccessful` / `ProbeFailed`. pub(crate) locked_msat: Arc, } +impl Prober { + /// Returns the total millisatoshis currently locked in in-flight probes. + pub fn locked_msat(&self) -> u64 { + self.locked_msat.load(Ordering::Relaxed) + } + + pub(crate) fn handle_probe_successful(&self, path: &lightning::routing::router::Path) { + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + let _ = self + .locked_msat + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| Some(v.saturating_sub(amount))); + } + + pub(crate) fn handle_probe_failed(&self, path: &lightning::routing::router::Path) { + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + let _ = self + .locked_msat + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| Some(v.saturating_sub(amount))); + } +} + /// Runs the probing loop for the given [`Prober`] until `stop_rx` fires. pub(crate) async fn run_prober(prober: Arc, mut stop_rx: tokio::sync::watch::Receiver<()>) { let mut ticker = tokio::time::interval(prober.interval); @@ -337,6 +541,7 @@ pub(crate) async fn run_prober(prober: Arc, mut stop_rx: tokio::sync::wa loop { tokio::select! { + biased; _ = stop_rx.changed() => { log_debug!(prober.logger, "Stopping background probing."); return; diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 000000000..aa1a35bae --- /dev/null +++ b/src/util.rs @@ -0,0 +1,29 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +/// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). +pub(crate) fn random_range(min: u64, max: u64) -> u64 { + debug_assert!(min <= max); + if min == max { + return min; + } + let range = max - min + 1; + // We remove bias due to the fact that the range does not evenly divide 2⁶⁴. + // Imagine we had a range from 0 to 2⁶⁴-2 (of length 2⁶⁴-1), then + // the outcomes of 0 would be twice as frequent as any other, as 0 can be produced + // as randomly drawn 0 % 2⁶⁴-1 and as well as 2⁶⁴-1 % 2⁶⁴-1 + let limit = u64::MAX - (u64::MAX % range); + loop { + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + let val = u64::from_ne_bytes(buf); + if val < limit { + return min + (val % range); + } + // loop runs ~1 iteration on average, in worst case it's ~2 iterations on average + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3faee39ab..0f987a359 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -32,7 +32,7 @@ use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; use ldk_node::{ Builder, CustomTlvRecord, Event, LightningBalance, Node, NodeError, PendingSweepBalance, - ProbingStrategy, UserChannelId, + ProbingConfig, UserChannelId, }; use lightning::io; use lightning::ln::msgs::SocketAddress; @@ -340,21 +340,6 @@ impl Default for TestStoreType { } } -#[derive(Clone)] -pub(crate) enum TestProbingStrategy { - Random { max_hops: usize }, - HighDegree { top_n: usize }, - Custom(Arc), -} - -#[derive(Clone)] -pub(crate) struct TestProbingConfig { - pub strategy: TestProbingStrategy, - pub interval: Duration, - pub max_locked_msat: u64, - pub diversity_penalty_msat: Option, -} - #[derive(Clone)] pub(crate) struct TestConfig { pub node_config: Config, @@ -363,7 +348,7 @@ pub(crate) struct TestConfig { pub node_entropy: NodeEntropy, pub async_payments_role: Option, pub recovery_mode: bool, - pub probing: Option, + pub probing: Option, } impl Default for TestConfig { @@ -501,22 +486,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> } if let Some(probing) = config.probing { - match probing.strategy { - TestProbingStrategy::Random { max_hops } => { - builder.set_random_probing_strategy(max_hops); - }, - TestProbingStrategy::HighDegree { top_n } => { - builder.set_high_degree_probing_strategy(top_n); - }, - TestProbingStrategy::Custom(strategy) => { - builder.set_custom_probing_strategy(strategy); - }, - } - builder.set_probing_interval(probing.interval); - builder.set_max_probe_locked_msat(probing.max_locked_msat); - if let Some(penalty) = probing.diversity_penalty_msat { - builder.set_probing_diversity_penalty_msat(penalty); - } + builder.set_probing_config(probing); } let node = match config.store_type { @@ -746,14 +716,18 @@ pub async fn open_channel( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool, electrsd: &ElectrsD, ) -> OutPoint { - open_channel_push_amt(node_a, node_b, funding_amount_sat, None, should_announce, electrsd).await + let funding_txo = + open_channel_no_wait(node_a, node_b, funding_amount_sat, None, should_announce).await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + funding_txo } /// Like [`open_channel`] but skips the `wait_for_tx` electrum check so that /// multiple channels can be opened back-to-back before any blocks are mined. /// The caller is responsible for mining blocks and confirming the funding txs. -pub async fn open_channel_no_electrum_wait( - node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool, +pub async fn open_channel_no_wait( + node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, + should_announce: bool, ) -> OutPoint { if should_announce { node_a @@ -761,7 +735,7 @@ pub async fn open_channel_no_electrum_wait( node_b.node_id(), node_b.listening_addresses().unwrap().first().unwrap().clone(), funding_amount_sat, - None, + push_amount_msat, None, ) .unwrap(); @@ -771,7 +745,7 @@ pub async fn open_channel_no_electrum_wait( node_b.node_id(), node_b.listening_addresses().unwrap().first().unwrap().clone(), funding_amount_sat, - None, + push_amount_msat, None, ) .unwrap(); @@ -788,35 +762,11 @@ pub async fn open_channel_push_amt( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, should_announce: bool, electrsd: &ElectrsD, ) -> OutPoint { - if should_announce { - node_a - .open_announced_channel( - node_b.node_id(), - node_b.listening_addresses().unwrap().first().unwrap().clone(), - funding_amount_sat, - push_amount_msat, - None, - ) - .unwrap(); - } else { - node_a - .open_channel( - node_b.node_id(), - node_b.listening_addresses().unwrap().first().unwrap().clone(), - funding_amount_sat, - push_amount_msat, - None, - ) - .unwrap(); - } - assert!(node_a.list_peers().iter().find(|c| { c.node_id == node_b.node_id() }).is_some()); - - let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); - let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); - assert_eq!(funding_txo_a, funding_txo_b); - wait_for_tx(&electrsd.client, funding_txo_a.txid).await; - - funding_txo_a + let funding_txo = + open_channel_no_wait(node_a, node_b, funding_amount_sat, push_amount_msat, should_announce) + .await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + funding_txo } pub async fn open_channel_with_all( diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index bdcab2fb1..83138c4ba 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -25,14 +25,13 @@ use lightning_invoice::{Bolt11InvoiceDescription, Description}; use common::{ expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, - open_channel_no_electrum_wait, premine_and_distribute_funds, random_config, - setup_bitcoind_and_electrsd, setup_node, TestChainSource, TestNode, TestProbingConfig, - TestProbingStrategy, + open_channel_no_wait, premine_and_distribute_funds, random_config, setup_bitcoind_and_electrsd, + setup_node, TestChainSource, TestNode, }; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Amount; -use ldk_node::{Event, Probe, ProbingStrategy}; +use ldk_node::{Event, Probe, ProbingConfig, ProbingStrategy}; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -64,18 +63,17 @@ impl ProbingStrategy for FixedDestStrategy { } } -// helpers async fn wait_until(timeout: Duration, predicate: impl Fn() -> bool) -> bool { - let deadline = tokio::time::Instant::now() + timeout; - loop { - if predicate() { - return true; - } - if tokio::time::Instant::now() >= deadline { - return false; + tokio::time::timeout(timeout, async { + loop { + if predicate() { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; } - tokio::time::sleep(Duration::from_millis(100)).await; - } + }) + .await + .is_ok() } fn config_with_label(label: &str) -> common::TestConfig { @@ -87,48 +85,54 @@ fn config_with_label(label: &str) -> common::TestConfig { config } -fn probing_config( - strategy: TestProbingStrategy, max_locked_msat: u64, diversity_penalty_msat: Option, -) -> Option { - Some(TestProbingConfig { - strategy, - interval: Duration::from_millis(PROBING_INTERVAL_MILLISECONDS), - max_locked_msat, - diversity_penalty_msat, - }) -} - fn build_node_fixed_dest_probing( chain_source: &TestChainSource<'_>, destination_node_id: PublicKey, ) -> TestNode { let mut config = random_config(false); let strategy = FixedDestStrategy::new(destination_node_id, PROBE_AMOUNT_MSAT); - config.probing = probing_config(TestProbingStrategy::Custom(strategy), PROBE_AMOUNT_MSAT, None); + config.probing = Some( + ProbingConfig::custom(strategy) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(PROBE_AMOUNT_MSAT) + .build(), + ); setup_node(chain_source, config) } fn build_node_random_probing(chain_source: &TestChainSource<'_>, max_hops: usize) -> TestNode { let mut config = config_with_label("Random"); - config.probing = - probing_config(TestProbingStrategy::Random { max_hops }, MAX_LOCKED_MSAT, None); + config.probing = Some( + ProbingConfig::random_walk(max_hops) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(MAX_LOCKED_MSAT) + .build(), + ); setup_node(chain_source, config) } -fn build_node_highdegree_probing(chain_source: &TestChainSource<'_>, top_n: usize) -> TestNode { +fn build_node_highdegree_probing( + chain_source: &TestChainSource<'_>, top_node_count: usize, +) -> TestNode { let mut config = config_with_label("HiDeg"); - config.probing = - probing_config(TestProbingStrategy::HighDegree { top_n }, MAX_LOCKED_MSAT, None); + config.probing = Some( + ProbingConfig::high_degree(top_node_count) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(MAX_LOCKED_MSAT) + .build(), + ); setup_node(chain_source, config) } fn build_node_z_highdegree_probing( - chain_source: &TestChainSource<'_>, top_n: usize, diversity_penalty_msat: u64, + chain_source: &TestChainSource<'_>, top_node_count: usize, diversity_penalty: u64, ) -> TestNode { let mut config = config_with_label("HiDeg+P"); - config.probing = probing_config( - TestProbingStrategy::HighDegree { top_n }, - MAX_LOCKED_MSAT, - Some(diversity_penalty_msat), + config.probing = Some( + ProbingConfig::high_degree(top_node_count) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(MAX_LOCKED_MSAT) + .diversity_penalty_msat(diversity_penalty) + .build(), ); setup_node(chain_source, config) } @@ -295,12 +299,14 @@ async fn probe_budget_increments_and_decrements() { tokio::time::sleep(Duration::from_secs(3)).await; let went_up = - wait_until(Duration::from_secs(10), || node_a.probe_locked_msat().unwrap_or(0) > 0).await; + wait_until(Duration::from_secs(10), || node_a.prober().map_or(0, |p| p.locked_msat()) > 0) + .await; assert!(went_up, "locked_msat never increased — no probe was dispatched"); - println!("First probe dispatched; locked_msat = {}", node_a.probe_locked_msat().unwrap()); + println!("First probe dispatched; locked_msat = {}", node_a.prober().unwrap().locked_msat()); let cleared = - wait_until(Duration::from_secs(20), || node_a.probe_locked_msat().unwrap_or(1) == 0).await; + wait_until(Duration::from_secs(20), || node_a.prober().map_or(1, |p| p.locked_msat()) == 0) + .await; assert!(cleared, "locked_msat never returned to zero after probe resolved"); println!("Probe resolved; locked_msat = 0"); @@ -325,12 +331,12 @@ async fn exhausted_probe_budget_blocks_new_probes() { // Use a slow probing interval so we can read capacity before the first probe fires. let mut config_a = random_config(false); let strategy = FixedDestStrategy::new(node_c.node_id(), PROBE_AMOUNT_MSAT); - config_a.probing = Some(TestProbingConfig { - strategy: TestProbingStrategy::Custom(strategy), - interval: Duration::from_secs(3), - max_locked_msat: PROBE_AMOUNT_MSAT, - diversity_penalty_msat: None, - }); + config_a.probing = Some( + ProbingConfig::custom(strategy) + .interval(Duration::from_secs(3)) + .max_locked_msat(PROBE_AMOUNT_MSAT) + .build(), + ); let node_a = setup_node(&chain_source, config_a); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -370,7 +376,8 @@ async fn exhausted_probe_budget_blocks_new_probes() { // Give gossip time to propagate to A, then wait for the first probe. let locked = - wait_until(Duration::from_secs(15), || node_a.probe_locked_msat().unwrap_or(0) > 0).await; + wait_until(Duration::from_secs(15), || node_a.prober().map_or(0, |p| p.locked_msat()) > 0) + .await; assert!(locked, "no probe dispatched within 15 s"); // Capacity should have decreased due to the in-flight probe HTLC. @@ -392,7 +399,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { // they must be skipped. Wait, then check both conditions at once. tokio::time::sleep(Duration::from_secs(5)).await; assert!( - node_a.probe_locked_msat().unwrap_or(0) > 0, + node_a.prober().map_or(0, |p| p.locked_msat()) > 0, "probe resolved unexpectedly while B was offline" ); let capacity_after_wait = node_a @@ -415,12 +422,14 @@ async fn exhausted_probe_budget_blocks_new_probes() { node_b.connect(node_c.node_id(), node_c_addr, false).unwrap(); let cleared = - wait_until(Duration::from_secs(15), || node_a.probe_locked_msat().unwrap_or(1) == 0).await; + wait_until(Duration::from_secs(15), || node_a.prober().map_or(1, |p| p.locked_msat()) == 0) + .await; assert!(cleared, "locked_msat never cleared after B came back online"); // Once the budget is freed, a new probe should be dispatched within a few ticks. let new_probe = - wait_until(Duration::from_secs(10), || node_a.probe_locked_msat().unwrap_or(0) > 0).await; + wait_until(Duration::from_secs(10), || node_a.prober().map_or(0, |p| p.locked_msat()) > 0) + .await; assert!(new_probe, "no new probe dispatched after budget was freed"); node_a.stop().unwrap(); @@ -498,12 +507,12 @@ async fn probing_strategies_perfomance() { println!("opening channels"); for node in observer_nodes { let idx = rng.random_range(0..num_nodes); - open_channel_no_electrum_wait(node, &nodes[idx], channel_capacity_sat, true).await; + open_channel_no_wait(node, &nodes[idx], channel_capacity_sat, None, true).await; } for (i, &count) in channels_per_nodes.iter().enumerate() { let targets: Vec = (0..num_nodes).filter(|&j| j != i).take(count).collect(); for j in targets { - open_channel_no_electrum_wait(&nodes[i], &nodes[j], channel_capacity_sat, true).await; + open_channel_no_wait(&nodes[i], &nodes[j], channel_capacity_sat, None, true).await; } } From ebb62274f1c85521a3217ca95c3739e3e80576c0 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Sat, 28 Mar 2026 16:15:59 +0100 Subject: [PATCH 06/16] Fix formatting --- src/probing.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/probing.rs b/src/probing.rs index 3d7c616b2..a7be9cb64 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -269,8 +269,8 @@ impl ProbingStrategy for HighDegreeStrategy { let mut probed = self.recently_probed.lock().unwrap(); - // We could check staleness when we use the entry, but that way we'd not clear cache at - // all. For hundreds of top nodes it's okay to call retain each tick. + // We could check staleness when we use the entry, but that way we'd not clear cache at + // all. For hundreds of top nodes it's okay to call retain each tick. probed.retain(|_, probed_at| now.duration_since(*probed_at) < self.cooldown); // If all top nodes are on cooldown, reset and start a new cycle. From 1e73e6e0d7265c0aba251e65dd5df3864921fc83 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 31 Mar 2026 14:50:34 +0200 Subject: [PATCH 07/16] Fix probing tests --- src/probing.rs | 34 +++++++-- tests/probing_tests.rs | 154 +++++++++++++++++++++++++---------------- 2 files changed, 125 insertions(+), 63 deletions(-) diff --git a/src/probing.rs b/src/probing.rs index a7be9cb64..20d82101f 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -513,6 +513,14 @@ pub struct Prober { pub(crate) locked_msat: Arc, } +fn fmt_path(path: &lightning::routing::router::Path) -> String { + path.hops + .iter() + .map(|h| format!("{}(scid={})", h.pubkey, h.short_channel_id)) + .collect::>() + .join(" -> ") +} + impl Prober { /// Returns the total millisatoshis currently locked in in-flight probes. pub fn locked_msat(&self) -> u64 { @@ -521,16 +529,34 @@ impl Prober { pub(crate) fn handle_probe_successful(&self, path: &lightning::routing::router::Path) { let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); - let _ = self + let prev = self .locked_msat - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| Some(v.saturating_sub(amount))); + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| Some(v.saturating_sub(amount))) + .unwrap_or(0); + log_debug!( + self.logger, + "Probe successful: released {} msat (locked_msat {} -> {}), path: {}", + amount, + prev, + prev.saturating_sub(amount), + fmt_path(path) + ); } pub(crate) fn handle_probe_failed(&self, path: &lightning::routing::router::Path) { let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); - let _ = self + let prev = self .locked_msat - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| Some(v.saturating_sub(amount))); + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| Some(v.saturating_sub(amount))) + .unwrap_or(0); + log_debug!( + self.logger, + "Probe failed: released {} msat (locked_msat {} -> {}), path: {}", + amount, + prev, + prev.saturating_sub(amount), + fmt_path(path) + ); } } diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index 83138c4ba..284bf2bdf 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -19,6 +19,7 @@ // runs payment rounds and prints probing perfomance tables. mod common; +use std::sync::atomic::{AtomicBool, Ordering}; use lightning::routing::gossip::NodeAlias; use lightning_invoice::{Bolt11InvoiceDescription, Description}; @@ -42,38 +43,38 @@ use std::time::Duration; const PROBE_AMOUNT_MSAT: u64 = 1_000_000; const MAX_LOCKED_MSAT: u64 = 100_000_000; -const PROBING_INTERVAL_MILLISECONDS: u64 = 500; +const PROBING_INTERVAL_MILLISECONDS: u64 = 100; const PROBING_DIVERSITY_PENALTY: u64 = 50_000; /// FixedDestStrategy — always targets one node; used by budget tests. struct FixedDestStrategy { destination: PublicKey, amount_msat: u64, + ready_to_probe: AtomicBool, } impl FixedDestStrategy { fn new(destination: PublicKey, amount_msat: u64) -> Arc { - Arc::new(Self { destination, amount_msat }) + Arc::new(Self { destination, amount_msat, ready_to_probe: AtomicBool::new(false) }) } -} -impl ProbingStrategy for FixedDestStrategy { - fn next_probe(&self) -> Option { - Some(Probe::Destination { final_node: self.destination, amount_msat: self.amount_msat }) + fn start_probing(&self) { + self.ready_to_probe.store(true, Ordering::Relaxed); + } + + fn stop_probing(&self) { + self.ready_to_probe.store(false, Ordering::Relaxed); } } -async fn wait_until(timeout: Duration, predicate: impl Fn() -> bool) -> bool { - tokio::time::timeout(timeout, async { - loop { - if predicate() { - return; - } - tokio::time::sleep(Duration::from_millis(100)).await; +impl ProbingStrategy for FixedDestStrategy { + fn next_probe(&self) -> Option { + if self.ready_to_probe.load(Ordering::Relaxed) { + Some(Probe::Destination { final_node: self.destination, amount_msat: self.amount_msat }) + } else { + None } - }) - .await - .is_ok() + } } fn config_with_label(label: &str) -> common::TestConfig { @@ -85,20 +86,6 @@ fn config_with_label(label: &str) -> common::TestConfig { config } -fn build_node_fixed_dest_probing( - chain_source: &TestChainSource<'_>, destination_node_id: PublicKey, -) -> TestNode { - let mut config = random_config(false); - let strategy = FixedDestStrategy::new(destination_node_id, PROBE_AMOUNT_MSAT); - config.probing = Some( - ProbingConfig::custom(strategy) - .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) - .max_locked_msat(PROBE_AMOUNT_MSAT) - .build(), - ); - setup_node(chain_source, config) -} - fn build_node_random_probing(chain_source: &TestChainSource<'_>, max_hops: usize) -> TestNode { let mut config = config_with_label("Random"); config.probing = Some( @@ -259,14 +246,23 @@ fn print_probing_perfomance(observers: &[&TestNode], all_nodes: &[&TestNode]) { /// Verifies that `locked_msat` increases when a probe is dispatched and returns /// to zero once the probe resolves (succeeds or fails). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test(flavor = "multi_thread")] async fn probe_budget_increments_and_decrements() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::Electrum(&electrsd); let node_b = setup_node(&chain_source, random_config(false)); let node_c = setup_node(&chain_source, random_config(false)); - let node_a = build_node_fixed_dest_probing(&chain_source, node_c.node_id()); + + let mut config_a = random_config(false); + let strategy = FixedDestStrategy::new(node_c.node_id(), PROBE_AMOUNT_MSAT); + config_a.probing = Some( + ProbingConfig::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(PROBE_AMOUNT_MSAT) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -295,20 +291,34 @@ async fn probe_budget_increments_and_decrements() { expect_event!(node_b, ChannelReady); expect_event!(node_c, ChannelReady); - // Give gossip time to propagate to A. + // Give gossip time to propagate to A, then enable probing. tokio::time::sleep(Duration::from_secs(3)).await; + strategy.start_probing(); - let went_up = - wait_until(Duration::from_secs(10), || node_a.prober().map_or(0, |p| p.locked_msat()) > 0) - .await; + let went_up = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().map_or(0, |p| p.locked_msat()) > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .is_ok(); assert!(went_up, "locked_msat never increased — no probe was dispatched"); println!("First probe dispatched; locked_msat = {}", node_a.prober().unwrap().locked_msat()); - let cleared = - wait_until(Duration::from_secs(20), || node_a.prober().map_or(1, |p| p.locked_msat()) == 0) - .await; + let cleared = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().map_or(1, |p| p.locked_msat()) == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .is_ok(); assert!(cleared, "locked_msat never returned to zero after probe resolved"); - println!("Probe resolved; locked_msat = 0"); node_a.stop().unwrap(); node_b.stop().unwrap(); @@ -320,7 +330,7 @@ async fn probe_budget_increments_and_decrements() { /// Exhaustion is triggered by stopping the intermediate node (B) while a probe HTLC /// is in-flight, preventing resolution and keeping the budget locked. After B restarts /// the HTLC fails, the budget clears, and probing resumes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test(flavor = "multi_thread")] async fn exhausted_probe_budget_blocks_new_probes() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::Electrum(&electrsd); @@ -328,12 +338,11 @@ async fn exhausted_probe_budget_blocks_new_probes() { let node_b = setup_node(&chain_source, random_config(false)); let node_c = setup_node(&chain_source, random_config(false)); - // Use a slow probing interval so we can read capacity before the first probe fires. let mut config_a = random_config(false); let strategy = FixedDestStrategy::new(node_c.node_id(), PROBE_AMOUNT_MSAT); config_a.probing = Some( - ProbingConfig::custom(strategy) - .interval(Duration::from_secs(3)) + ProbingConfig::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) .max_locked_msat(PROBE_AMOUNT_MSAT) .build(), ); @@ -366,7 +375,6 @@ async fn exhausted_probe_budget_blocks_new_probes() { expect_event!(node_b, ChannelReady); expect_event!(node_c, ChannelReady); - // Record capacity before the first probe fires (interval is 3s, so we have time). let capacity_at_open = node_a .list_channels() .iter() @@ -374,11 +382,23 @@ async fn exhausted_probe_budget_blocks_new_probes() { .map(|ch| ch.outbound_capacity_msat) .expect("A→B channel not found"); - // Give gossip time to propagate to A, then wait for the first probe. - let locked = - wait_until(Duration::from_secs(15), || node_a.prober().map_or(0, |p| p.locked_msat()) > 0) - .await; - assert!(locked, "no probe dispatched within 15 s"); + assert_eq!(node_a.prober().map_or(1, |p| p.locked_msat()), 0, "initial locked_msat is nonzero"); + + tokio::time::sleep(Duration::from_secs(3)).await; + strategy.start_probing(); + + // Wait for the first probe to be in-flight. + let locked = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().map_or(0, |p| p.locked_msat()) > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .is_ok(); + assert!(locked, "no probe dispatched within 30 s"); // Capacity should have decreased due to the in-flight probe HTLC. let capacity_with_probe = node_a @@ -395,8 +415,6 @@ async fn exhausted_probe_budget_blocks_new_probes() { // Stop B while the probe HTLC is in-flight. node_b.stop().unwrap(); - // Let several Prober ticks fire (interval is 3s); the budget is exhausted so - // they must be skipped. Wait, then check both conditions at once. tokio::time::sleep(Duration::from_secs(5)).await; assert!( node_a.prober().map_or(0, |p| p.locked_msat()) > 0, @@ -413,6 +431,9 @@ async fn exhausted_probe_budget_blocks_new_probes() { "a new probe HTLC was sent despite budget being exhausted" ); + // Pause probing so the budget can clear without a new probe re-locking it. + strategy.stop_probing(); + // Bring B back and explicitly reconnect to A and C so the stuck HTLC resolves // without waiting for the background reconnection backoff. node_b.start().unwrap(); @@ -421,15 +442,30 @@ async fn exhausted_probe_budget_blocks_new_probes() { node_b.connect(node_a.node_id(), node_a_addr, false).unwrap(); node_b.connect(node_c.node_id(), node_c_addr, false).unwrap(); - let cleared = - wait_until(Duration::from_secs(15), || node_a.prober().map_or(1, |p| p.locked_msat()) == 0) - .await; + let cleared = tokio::time::timeout(Duration::from_secs(60), async { + loop { + if node_a.prober().map_or(1, |p| p.locked_msat()) == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .is_ok(); assert!(cleared, "locked_msat never cleared after B came back online"); - // Once the budget is freed, a new probe should be dispatched within a few ticks. - let new_probe = - wait_until(Duration::from_secs(10), || node_a.prober().map_or(0, |p| p.locked_msat()) > 0) - .await; + // Re-enable probing; a new probe should be dispatched within a few ticks. + strategy.start_probing(); + let new_probe = tokio::time::timeout(Duration::from_secs(60), async { + loop { + if node_a.prober().map_or(0, |p| p.locked_msat()) > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .is_ok(); assert!(new_probe, "no new probe dispatched after budget was freed"); node_a.stop().unwrap(); From c470da6f346c70a1c2df2423db43fd57caaba456 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 31 Mar 2026 16:25:20 +0200 Subject: [PATCH 08/16] Increase probing test timeout --- tests/probing_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index 284bf2bdf..c194f793c 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -442,7 +442,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { node_b.connect(node_a.node_id(), node_a_addr, false).unwrap(); node_b.connect(node_c.node_id(), node_c_addr, false).unwrap(); - let cleared = tokio::time::timeout(Duration::from_secs(60), async { + let cleared = tokio::time::timeout(Duration::from_secs(120), async { loop { if node_a.prober().map_or(1, |p| p.locked_msat()) == 0 { break; From a727bcf20b1d2b911d44bbdcf6cc4daffa6456b3 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Mon, 6 Apr 2026 17:20:05 +0200 Subject: [PATCH 09/16] Fix probing budget accounting and remove Probe::Destination The locked_msat budget tracking was broken for Destination probes: send_spontaneous_preflight_probes only returns (PaymentHash, PaymentId) without exposing the actual paths or per-hop amounts. This meant we locked amount_msat at send time but released amount+fees per path in ProbeSuccessful/ProbeFailed events, causing a systematic mismatch. Fix by removing Probe::Destination entirely. Strategies now return a fully constructed Path, and run_prober always uses send_probe(path), locking and releasing the same path.hops.sum(fee_msat) on both sides. HighDegreeStrategy now calls Router::find_route directly and applies the liquidity-limit check itself, mirroring send_preflight_probes. Other fixes in this commit: - Fix RandomStrategy fee calculation: compute proportional fees on the forwarded amount (delivery + downstream fees), not just delivery - Fix HighDegreeStrategy doc - Fix random_range overflow when max - min == u64::MAX - Add doc warning about scorer_channel_liquidity being O(scorer size) - Make probing module public, import objects directly in builder.rs - Reorder EventHandler fields (prober after om_mailbox) Co-Authored-By: Claude Sonnet 4.6 --- src/builder.rs | 50 +++++----- src/event.rs | 14 +-- src/lib.rs | 18 ++-- src/probing.rs | 216 +++++++++++++++++++++++------------------ src/util.rs | 10 +- tests/common/mod.rs | 3 +- tests/probing_tests.rs | 108 +++++++++++++++------ 7 files changed, 257 insertions(+), 162 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index f4df35313..b61ec136d 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -75,7 +75,9 @@ use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; -use crate::probing; +use crate::probing::{ + HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, RandomStrategy, +}; use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ @@ -284,7 +286,7 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, recovery_mode: bool, - probing_config: Option, + probing_config: Option, } impl NodeBuilder { @@ -623,7 +625,7 @@ impl NodeBuilder { /// Configures background probing. /// - /// Use [`probing::ProbingConfig`] to build the configuration: + /// Use [`ProbingConfig`] to build the configuration: /// ```ignore /// use ldk_node::probing::ProbingConfig; /// @@ -633,7 +635,7 @@ impl NodeBuilder { /// .build() /// ); /// ``` - pub fn set_probing_config(&mut self, config: probing::ProbingConfig) -> &mut Self { + pub fn set_probing_config(&mut self, config: ProbingConfig) -> &mut Self { self.probing_config = Some(config); self } @@ -1108,8 +1110,8 @@ impl ArcedNodeBuilder { /// Configures background probing. /// - /// See [`probing::ProbingConfig`] for details. - pub fn set_probing_config(&self, config: probing::ProbingConfig) { + /// See [`ProbingConfig`] for details. + pub fn set_probing_config(&self, config: ProbingConfig) { self.inner.write().unwrap().set_probing_config(config); } @@ -1256,9 +1258,9 @@ fn build_with_store_internal( gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, - probing_config: Option<&probing::ProbingConfig>, - async_payments_role: Option, recovery_mode: bool, seed_bytes: [u8; 64], - runtime: Arc, logger: Arc, kv_store: Arc, + probing_config: Option<&ProbingConfig>, async_payments_role: Option, + recovery_mode: bool, seed_bytes: [u8; 64], runtime: Arc, logger: Arc, + kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -2002,33 +2004,33 @@ fn build_with_store_internal( } let prober = probing_config.map(|probing_cfg| { - let strategy: Arc = match &probing_cfg.kind { - probing::ProbingStrategyKind::HighDegree { top_node_count } => { - Arc::new(probing::HighDegreeStrategy::new( + let strategy: Arc = match &probing_cfg.kind { + ProbingStrategyKind::HighDegree { top_node_count } => { + Arc::new(HighDegreeStrategy::new( Arc::clone(&network_graph), + Arc::clone(&channel_manager), + Arc::clone(&router), *top_node_count, MIN_PROBE_AMOUNT_MSAT, DEFAULT_MAX_PROBE_AMOUNT_MSAT, probing_cfg.cooldown, + config.probing_liquidity_limit_multiplier, )) }, - probing::ProbingStrategyKind::Random { max_hops } => { - Arc::new(probing::RandomStrategy::new( - Arc::clone(&network_graph), - Arc::clone(&channel_manager), - *max_hops, - MIN_PROBE_AMOUNT_MSAT, - DEFAULT_MAX_PROBE_AMOUNT_MSAT, - )) - }, - probing::ProbingStrategyKind::Custom(s) => Arc::clone(s), + ProbingStrategyKind::Random { max_hops } => Arc::new(RandomStrategy::new( + Arc::clone(&network_graph), + Arc::clone(&channel_manager), + *max_hops, + MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + )), + ProbingStrategyKind::Custom(s) => Arc::clone(s), }; - Arc::new(probing::Prober { + Arc::new(Prober { channel_manager: Arc::clone(&channel_manager), logger: Arc::clone(&logger), strategy, interval: probing_cfg.interval, - liquidity_limit_multiplier: Some(config.probing_liquidity_limit_multiplier), max_locked_msat: probing_cfg.max_locked_msat, locked_msat: Arc::new(AtomicU64::new(0)), }) diff --git a/src/event.rs b/src/event.rs index adb6e46ff..18bb48eb3 100644 --- a/src/event.rs +++ b/src/event.rs @@ -510,13 +510,13 @@ where payment_store: Arc, peer_store: Arc>, keys_manager: Arc, - runtime: Arc, - logger: L, - config: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + runtime: Arc, + logger: L, + config: Arc, } impl EventHandler @@ -532,7 +532,7 @@ where payment_store: Arc, peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, - runtime: Arc, logger: L, config: Arc, prober: Option>, + prober: Option>, runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, @@ -546,13 +546,13 @@ where payment_store, peer_store, keys_manager, - logger, - runtime, - config, static_invoice_store, onion_messenger, om_mailbox, prober, + runtime, + logger, + config, } } diff --git a/src/lib.rs b/src/lib.rs index cb418280d..3f25b9241 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,7 +101,7 @@ pub mod logger; mod message_handler; pub mod payment; mod peer_store; -mod probing; +pub mod probing; mod runtime; mod scoring; mod tx_broadcaster; @@ -172,10 +172,7 @@ use payment::{ UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; -pub use probing::{ - HighDegreeStrategy, Probe, Prober, ProbingConfig, ProbingConfigBuilder, ProbingStrategy, - RandomStrategy, -}; +use probing::{run_prober, Prober}; use runtime::Runtime; pub use tokio; use types::{ @@ -245,7 +242,7 @@ pub struct Node { om_mailbox: Option>, async_payments_role: Option, hrn_resolver: Arc, - prober: Option>, + prober: Option>, #[cfg(cycle_tests)] _leak_checker: LeakChecker, } @@ -597,16 +594,16 @@ impl Node { static_invoice_store, Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), + self.prober.clone(), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), - self.prober.clone(), )); if let Some(prober) = self.prober.clone() { let stop_rx = self.stop_sender.subscribe(); self.runtime.spawn_cancellable_background_task(async move { - probing::run_prober(prober, stop_rx).await; + run_prober(prober, stop_rx).await; }); } @@ -1090,8 +1087,9 @@ impl Node { /// Returns the scorer's estimated `(min, max)` liquidity range for the given channel in the /// direction toward `target`, or `None` if the scorer has no data for that channel. /// - /// Works by serializing the `CombinedScorer` (which writes `local_only_scorer`) and - /// deserializing it as a plain `ProbabilisticScorer` to call `estimated_channel_liquidity_range`. + /// **Warning:** This is expensive — O(scorer size) per call. It works by serializing the + /// entire `CombinedScorer` and deserializing it as a plain `ProbabilisticScorer` to access + /// `estimated_channel_liquidity_range`. Intended for testing and debugging, not hot paths. pub fn scorer_channel_liquidity(&self, scid: u64, target: PublicKey) -> Option<(u64, u64)> { use lightning::routing::scoring::{ ProbabilisticScorer, ProbabilisticScoringDecayParameters, diff --git a/src/probing.rs b/src/probing.rs index 20d82101f..2076fba66 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -5,6 +5,8 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +//! Background probing strategies for training the payment scorer. + use std::collections::HashMap; use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; @@ -13,7 +15,9 @@ use std::time::{Duration, Instant}; use bitcoin::secp256k1::PublicKey; use lightning::routing::gossip::NodeId; -use lightning::routing::router::{Path, RouteHop, MAX_PATH_LENGTH_ESTIMATE}; +use lightning::routing::router::{ + Path, PaymentParameters, RouteHop, RouteParameters, MAX_PATH_LENGTH_ESTIMATE, +}; use lightning_invoice::DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning_types::features::NodeFeatures; @@ -21,9 +25,11 @@ use crate::config::{ DEFAULT_MAX_PROBE_LOCKED_MSAT, DEFAULT_PROBED_NODE_COOLDOWN_SECS, DEFAULT_PROBING_INTERVAL_SECS, }; use crate::logger::{log_debug, LdkLogger, Logger}; -use crate::types::{ChannelManager, Graph}; +use crate::types::{ChannelManager, Graph, Router}; use crate::util::random_range; +use lightning::routing::router::Router as LdkRouter; + /// Which built-in probing strategy to use, or a custom one. #[derive(Clone)] pub(crate) enum ProbingStrategyKind { @@ -179,24 +185,10 @@ impl ProbingConfigBuilder { } } -/// A probe to be dispatched by the Prober. -pub enum Probe { - /// A manually constructed path; dispatched via `send_probe`. - PrebuiltRoute(Path), - /// A destination to reach; the router selects the actual path via - /// `send_spontaneous_preflight_probes`. - Destination { - /// The destination node. - final_node: PublicKey, - /// The probe amount in millisatoshis. - amount_msat: u64, - }, -} - /// Strategy can be used for determining the next target and amount for probing. pub trait ProbingStrategy: Send + Sync + 'static { - /// Returns the next probe to run, or `None` to skip this tick. - fn next_probe(&self) -> Option; + /// Returns the next probe path to run, or `None` to skip this tick. + fn next_probe(&self) -> Option; } /// Probes toward the most-connected nodes in the graph. @@ -206,12 +198,15 @@ pub trait ProbingStrategy: Send + Sync + 'static { /// `top_node_count` that has not been probed within `cooldown`. /// Nodes probed more recently are skipped so that the strategy /// naturally spreads across the top nodes and picks up graph changes. -/// Returns `None` (skips the tick) if all top nodes are on cooldown. +/// If all top nodes are on cooldown, the cooldown map is cleared and a new cycle begins +/// immediately. /// /// The probe amount is chosen uniformly at random from /// `[min_amount_msat, max_amount_msat]`. pub struct HighDegreeStrategy { network_graph: Arc, + channel_manager: Arc, + router: Arc, /// How many of the highest-degree nodes to cycle through. pub top_node_count: usize, /// Lower bound for the randomly chosen probe amount. @@ -220,6 +215,9 @@ pub struct HighDegreeStrategy { pub max_amount_msat: u64, /// How long a node stays ineligible after being probed. pub cooldown: Duration, + /// Skip a path when the first-hop outbound liquidity is less than + /// `path_value * liquidity_limit_multiplier`. + pub liquidity_limit_multiplier: u64, /// Nodes probed recently, with the time they were last probed. recently_probed: Mutex>, } @@ -227,8 +225,9 @@ pub struct HighDegreeStrategy { impl HighDegreeStrategy { /// Creates a new high-degree probing strategy. pub(crate) fn new( - network_graph: Arc, top_node_count: usize, min_amount_msat: u64, - max_amount_msat: u64, cooldown: Duration, + network_graph: Arc, channel_manager: Arc, router: Arc, + top_node_count: usize, min_amount_msat: u64, max_amount_msat: u64, cooldown: Duration, + liquidity_limit_multiplier: u64, ) -> Self { assert!( min_amount_msat <= max_amount_msat, @@ -236,20 +235,22 @@ impl HighDegreeStrategy { ); Self { network_graph, + channel_manager, + router, top_node_count, min_amount_msat, max_amount_msat, cooldown, + liquidity_limit_multiplier, recently_probed: Mutex::new(HashMap::new()), } } } impl ProbingStrategy for HighDegreeStrategy { - fn next_probe(&self) -> Option { + fn next_probe(&self) -> Option { let graph = self.network_graph.read_only(); - // Collect (pubkey, channel_count) for all nodes. let mut nodes_by_degree: Vec<(PublicKey, usize)> = graph .nodes() .unordered_iter() @@ -287,9 +288,43 @@ impl ProbingStrategy for HighDegreeStrategy { probed.insert(final_node, now); drop(probed); + drop(graph); let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); - Some(Probe::Destination { final_node, amount_msat }) + let payment_params = + PaymentParameters::from_node_id(final_node, DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, amount_msat); + + let payer = self.channel_manager.get_our_node_id(); + let usable_channels = self.channel_manager.list_usable_channels(); + let first_hops: Vec<&_> = usable_channels.iter().collect(); + let inflight_htlcs = self.channel_manager.compute_inflight_htlcs(); + + let route = self + .router + .find_route(&payer, &route_params, Some(&first_hops), inflight_htlcs) + .ok()?; + + let path = route.paths.into_iter().next()?; + + // Liquidity-limit check (mirrors send_preflight_probes): skip the path when the + // first-hop outbound liquidity is less than path_value * liquidity_limit_multiplier. + if let Some(first_hop_hop) = path.hops.first() { + if let Some(ch) = usable_channels + .iter() + .find(|h| h.get_outbound_payment_scid() == Some(first_hop_hop.short_channel_id)) + { + let path_value = path.final_value_msat() + path.fee_msat(); + if ch.next_outbound_htlc_limit_msat + < path_value.saturating_mul(self.liquidity_limit_multiplier) + { + return None; + } + } + } + + Some(path) } } @@ -301,7 +336,7 @@ impl ProbingStrategy for HighDegreeStrategy { /// 2. Performs a deterministic walk of a randomly chosen depth (up to /// [`MAX_PATH_LENGTH_ESTIMATE`]) through the gossip graph, skipping disabled /// channels and dead-ends. -/// 3. Returns `Probe::PrebuiltRoute(path)` so the prober calls `send_probe` directly. +/// 3. Returns the constructed `Path` so the prober calls `send_probe` directly. /// /// The probe amount is chosen uniformly at random from `[min_amount_msat, max_amount_msat]`. /// @@ -425,7 +460,6 @@ impl RandomStrategy { current_node_id = *next_node_id; } - // The route is infeasible if any hop's minimum exceeds another hop's maximum. if route_greatest_htlc_lower_bound > route_least_htlc_upper_bound { return None; } @@ -435,49 +469,64 @@ impl RandomStrategy { return None; } - // Assemble hops. - // For hop i: fee and CLTV are determined by the *next* channel (what route[i] - // will charge to forward onward). For the last hop they are amount_msat and zero expiry delta. + // Assemble hops backwards so each hop's proportional fee is computed on the amount it actually forwards let mut hops = Vec::with_capacity(route.len()); - for i in 0..route.len() { - let (node_id, via_scid, pubkey) = route[i]; + let mut forwarded = amount_msat; + let last = route.len() - 1; + // Final hop: fee_msat carries the delivery amount; cltv delta is zero. + { + let (node_id, via_scid, pubkey) = route[last]; let channel_info = graph.channel(via_scid)?; + let node_features = graph + .node(&node_id) + .and_then(|n| n.announcement_info.as_ref().map(|a| a.features().clone())) + .unwrap_or_else(NodeFeatures::empty); + hops.push(RouteHop { + pubkey, + node_features, + short_channel_id: via_scid, + channel_features: channel_info.features.clone(), + fee_msat: amount_msat, + cltv_expiry_delta: 0, + maybe_announced_channel: true, + }); + } + // Non-final hops, from second-to-last back to first. + for i in (0..last).rev() { + let (node_id, via_scid, pubkey) = route[i]; + let channel_info = graph.channel(via_scid)?; let node_features = graph .node(&node_id) .and_then(|n| n.announcement_info.as_ref().map(|a| a.features().clone())) .unwrap_or_else(NodeFeatures::empty); - let (fee_msat, cltv_expiry_delta) = if i + 1 < route.len() { - // non-final hop - let (_, next_scid, _) = route[i + 1]; - let next_channel = graph.channel(next_scid)?; - let (directed, _) = next_channel.as_directed_from(&node_id)?; - let update = if directed.source() == &next_channel.node_one { - next_channel.one_to_two.as_ref().unwrap() - } else { - next_channel.two_to_one.as_ref().unwrap() - }; - let fee = update.fees.base_msat as u64 - + (amount_msat * update.fees.proportional_millionths as u64 / 1_000_000); - (fee, update.cltv_expiry_delta as u32) + let (_, next_scid, _) = route[i + 1]; + let next_channel = graph.channel(next_scid)?; + let (directed, _) = next_channel.as_directed_from(&node_id)?; + let update = if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref().unwrap() } else { - // Final hop: fee_msat carries the delivery amount; cltv delta is zero. - (amount_msat, 0) + next_channel.two_to_one.as_ref().unwrap() }; + let fee = update.fees.base_msat as u64 + + (forwarded * update.fees.proportional_millionths as u64 / 1_000_000); + forwarded += fee; hops.push(RouteHop { pubkey, node_features, short_channel_id: via_scid, channel_features: channel_info.features.clone(), - fee_msat, - cltv_expiry_delta, + fee_msat: fee, + cltv_expiry_delta: update.cltv_expiry_delta as u32, maybe_announced_channel: true, }); } + hops.reverse(); + // The first-hop HTLC carries amount_msat + all intermediate fees. // Verify the total fits within our live outbound limit before returning. let total_outgoing: u64 = hops.iter().map(|h| h.fee_msat).sum(); @@ -490,11 +539,11 @@ impl RandomStrategy { } impl ProbingStrategy for RandomStrategy { - fn next_probe(&self) -> Option { + fn next_probe(&self) -> Option { let target_hops = random_range(1, self.max_hops as u64) as usize; let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); - self.try_build_path(target_hops, amount_msat).map(Probe::PrebuiltRoute) + self.try_build_path(target_hops, amount_msat) } } @@ -506,8 +555,6 @@ pub struct Prober { pub strategy: Arc, /// How often to fire a probe attempt. pub interval: Duration, - /// Passed to `send_spontaneous_preflight_probes`. `None` uses LDK default (3×). - pub liquidity_limit_multiplier: Option, /// Maximum total millisatoshis that may be locked in in-flight probes at any time. pub max_locked_msat: u64, pub(crate) locked_msat: Arc, @@ -573,47 +620,32 @@ pub(crate) async fn run_prober(prober: Arc, mut stop_rx: tokio::sync::wa return; } _ = ticker.tick() => { - match prober.strategy.next_probe() { - None => {} - Some(Probe::PrebuiltRoute(path)) => { - let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); - if prober.locked_msat.load(Ordering::Acquire) + amount > prober.max_locked_msat { - log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); - } else { - match prober.channel_manager.send_probe(path) { - Ok(_) => { - prober.locked_msat.fetch_add(amount, Ordering::Release); - } - Err(e) => { - log_debug!(prober.logger, "Prebuilt path probe failed: {:?}", e); - } - } - } + let path = match prober.strategy.next_probe() { + Some(p) => p, + None => continue, + }; + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + if prober.locked_msat.load(Ordering::Acquire) + amount > prober.max_locked_msat { + log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); + continue; + } + match prober.channel_manager.send_probe(path.clone()) { + Ok(_) => { + prober.locked_msat.fetch_add(amount, Ordering::Release); + log_debug!( + prober.logger, + "Probe sent: locked {} msat, path: {}", + amount, + fmt_path(&path) + ); } - Some(Probe::Destination { final_node, amount_msat }) => { - if prober.locked_msat.load(Ordering::Acquire) + amount_msat - > prober.max_locked_msat - { - log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); - } else { - match prober.channel_manager.send_spontaneous_preflight_probes( - final_node, - amount_msat, - DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32, - prober.liquidity_limit_multiplier, - ) { - Ok(probes) => { - if !probes.is_empty() { - prober.locked_msat.fetch_add(amount_msat, Ordering::Release); - } else { - log_debug!(prober.logger, "No probe paths found for destination {}; skipping budget increment.", final_node); - } - } - Err(e) => { - log_debug!(prober.logger, "Route-follow probe to {} failed: {:?}", final_node, e); - } - } - } + Err(e) => { + log_debug!( + prober.logger, + "Probe send failed: {:?}, path: {}", + e, + fmt_path(&path) + ); } } } diff --git a/src/util.rs b/src/util.rs index aa1a35bae..3350ad2c7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -11,7 +11,15 @@ pub(crate) fn random_range(min: u64, max: u64) -> u64 { if min == max { return min; } - let range = max - min + 1; + let range = match (max - min).checked_add(1) { + Some(r) => r, + None => { + // overflowed — full u64::MAX range + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + return u64::from_ne_bytes(buf); + }, + }; // We remove bias due to the fact that the range does not evenly divide 2⁶⁴. // Imagine we had a range from 0 to 2⁶⁴-2 (of length 2⁶⁴-1), then // the outcomes of 0 would be twice as frequent as any other, as 0 can be produced diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0f987a359..c04c3855a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -30,9 +30,10 @@ use ldk_node::config::{AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyn use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use ldk_node::probing::ProbingConfig; use ldk_node::{ Builder, CustomTlvRecord, Event, LightningBalance, Node, NodeError, PendingSweepBalance, - ProbingConfig, UserChannelId, + UserChannelId, }; use lightning::io; use lightning::ln::msgs::SocketAddress; diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index c194f793c..29f451f1c 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -26,19 +26,22 @@ use lightning_invoice::{Bolt11InvoiceDescription, Description}; use common::{ expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, - open_channel_no_wait, premine_and_distribute_funds, random_config, setup_bitcoind_and_electrsd, - setup_node, TestChainSource, TestNode, + open_channel_no_wait, premine_and_distribute_funds, random_chain_source, random_config, + setup_bitcoind_and_electrsd, setup_node, TestChainSource, TestNode, }; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Amount; -use ldk_node::{Event, Probe, ProbingConfig, ProbingStrategy}; +use ldk_node::probing::{ProbingConfig, ProbingStrategy}; +use ldk_node::Event; + +use lightning::routing::router::Path; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use std::collections::{BTreeMap, HashMap}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; const PROBE_AMOUNT_MSAT: u64 = 1_000_000; @@ -46,16 +49,21 @@ const MAX_LOCKED_MSAT: u64 = 100_000_000; const PROBING_INTERVAL_MILLISECONDS: u64 = 100; const PROBING_DIVERSITY_PENALTY: u64 = 50_000; -/// FixedDestStrategy — always targets one node; used by budget tests. -struct FixedDestStrategy { - destination: PublicKey, - amount_msat: u64, +/// FixedPathStrategy — returns a fixed pre-built path; used by budget tests. +/// +/// The path is set after node and channel setup via [`set_path`]. +struct FixedPathStrategy { + path: Mutex>, ready_to_probe: AtomicBool, } -impl FixedDestStrategy { - fn new(destination: PublicKey, amount_msat: u64) -> Arc { - Arc::new(Self { destination, amount_msat, ready_to_probe: AtomicBool::new(false) }) +impl FixedPathStrategy { + fn new() -> Arc { + Arc::new(Self { path: Mutex::new(None), ready_to_probe: AtomicBool::new(false) }) + } + + fn set_path(&self, path: Path) { + *self.path.lock().unwrap() = Some(path); } fn start_probing(&self) { @@ -67,16 +75,59 @@ impl FixedDestStrategy { } } -impl ProbingStrategy for FixedDestStrategy { - fn next_probe(&self) -> Option { +impl ProbingStrategy for FixedPathStrategy { + fn next_probe(&self) -> Option { if self.ready_to_probe.load(Ordering::Relaxed) { - Some(Probe::Destination { final_node: self.destination, amount_msat: self.amount_msat }) + self.path.lock().unwrap().clone() } else { None } } } +/// Builds a 2-hop probe path: node_a → node_b → node_c using live channel info. +fn build_probe_path( + node_a: &TestNode, node_b: &TestNode, node_c: &TestNode, amount_msat: u64, +) -> Path { + use lightning::routing::router::RouteHop; + use lightning_types::features::{ChannelFeatures, NodeFeatures}; + + let ch_ab = node_a + .list_channels() + .into_iter() + .find(|ch| ch.counterparty_node_id == node_b.node_id() && ch.short_channel_id.is_some()) + .expect("A→B channel not found"); + let ch_bc = node_b + .list_channels() + .into_iter() + .find(|ch| ch.counterparty_node_id == node_c.node_id() && ch.short_channel_id.is_some()) + .expect("B→C channel not found"); + + Path { + hops: vec![ + RouteHop { + pubkey: node_b.node_id(), + node_features: NodeFeatures::empty(), + short_channel_id: ch_ab.short_channel_id.unwrap(), + channel_features: ChannelFeatures::empty(), + fee_msat: 0, + cltv_expiry_delta: 40, + maybe_announced_channel: true, + }, + RouteHop { + pubkey: node_c.node_id(), + node_features: NodeFeatures::empty(), + short_channel_id: ch_bc.short_channel_id.unwrap(), + channel_features: ChannelFeatures::empty(), + fee_msat: amount_msat, + cltv_expiry_delta: 0, + maybe_announced_channel: true, + }, + ], + blinded_tail: None, + } +} + fn config_with_label(label: &str) -> common::TestConfig { let mut config = random_config(false); let mut alias_bytes = [0u8; 32]; @@ -249,17 +300,17 @@ fn print_probing_perfomance(observers: &[&TestNode], all_nodes: &[&TestNode]) { #[tokio::test(flavor = "multi_thread")] async fn probe_budget_increments_and_decrements() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = TestChainSource::Electrum(&electrsd); + let chain_source = random_chain_source(&bitcoind, &electrsd); let node_b = setup_node(&chain_source, random_config(false)); let node_c = setup_node(&chain_source, random_config(false)); let mut config_a = random_config(false); - let strategy = FixedDestStrategy::new(node_c.node_id(), PROBE_AMOUNT_MSAT); + let strategy = FixedPathStrategy::new(); config_a.probing = Some( ProbingConfig::custom(strategy.clone()) .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) - .max_locked_msat(PROBE_AMOUNT_MSAT) + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) .build(), ); let node_a = setup_node(&chain_source, config_a); @@ -291,13 +342,14 @@ async fn probe_budget_increments_and_decrements() { expect_event!(node_b, ChannelReady); expect_event!(node_c, ChannelReady); - // Give gossip time to propagate to A, then enable probing. + // Build the probe path now that channels are ready, then enable probing. + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); tokio::time::sleep(Duration::from_secs(3)).await; strategy.start_probing(); let went_up = tokio::time::timeout(Duration::from_secs(30), async { loop { - if node_a.prober().map_or(0, |p| p.locked_msat()) > 0 { + if node_a.prober().unwrap().locked_msat() > 0 { break; } tokio::time::sleep(Duration::from_millis(1)).await; @@ -308,12 +360,13 @@ async fn probe_budget_increments_and_decrements() { assert!(went_up, "locked_msat never increased — no probe was dispatched"); println!("First probe dispatched; locked_msat = {}", node_a.prober().unwrap().locked_msat()); + strategy.stop_probing(); let cleared = tokio::time::timeout(Duration::from_secs(30), async { loop { - if node_a.prober().map_or(1, |p| p.locked_msat()) == 0 { + if node_a.prober().unwrap().locked_msat() == 0 { break; } - tokio::time::sleep(Duration::from_millis(1)).await; + tokio::time::sleep(Duration::from_millis(100)).await; } }) .await @@ -333,17 +386,17 @@ async fn probe_budget_increments_and_decrements() { #[tokio::test(flavor = "multi_thread")] async fn exhausted_probe_budget_blocks_new_probes() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = TestChainSource::Electrum(&electrsd); + let chain_source = random_chain_source(&bitcoind, &electrsd); let node_b = setup_node(&chain_source, random_config(false)); let node_c = setup_node(&chain_source, random_config(false)); let mut config_a = random_config(false); - let strategy = FixedDestStrategy::new(node_c.node_id(), PROBE_AMOUNT_MSAT); + let strategy = FixedPathStrategy::new(); config_a.probing = Some( ProbingConfig::custom(strategy.clone()) .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) - .max_locked_msat(PROBE_AMOUNT_MSAT) + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) .build(), ); let node_a = setup_node(&chain_source, config_a); @@ -384,6 +437,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { assert_eq!(node_a.prober().map_or(1, |p| p.locked_msat()), 0, "initial locked_msat is nonzero"); + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); tokio::time::sleep(Duration::from_secs(3)).await; strategy.start_probing(); @@ -479,7 +533,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn probing_strategies_perfomance() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = TestChainSource::Electrum(&electrsd); + let chain_source = random_chain_source(&bitcoind, &electrsd); let num_nodes = 5; let channel_capacity_sat = 1_000_000u64; @@ -572,7 +626,7 @@ async fn probing_strategies_perfomance() { print_topology(&all_nodes); - println!("\nbefore payments"); + println!("\n=== before random payments ==="); print_probing_perfomance(&observer_nodes, &all_nodes); let desc = Bolt11InvoiceDescription::Direct(Description::new("test".to_string()).unwrap()); @@ -607,7 +661,7 @@ async fn probing_strategies_perfomance() { } tokio::time::sleep(Duration::from_secs(5)).await; - println!("\n=== after payments ==="); + println!("\n=== after random payments ==="); print_probing_perfomance(&observer_nodes, &all_nodes); for node in nodes.iter().chain(observer_nodes) { From b90f76d872177a69d648260cb30952f3c045d89c Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Mon, 6 Apr 2026 18:02:34 +0200 Subject: [PATCH 10/16] fix probing test polling --- tests/probing_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index 29f451f1c..a54e3abb7 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -496,12 +496,12 @@ async fn exhausted_probe_budget_blocks_new_probes() { node_b.connect(node_a.node_id(), node_a_addr, false).unwrap(); node_b.connect(node_c.node_id(), node_c_addr, false).unwrap(); - let cleared = tokio::time::timeout(Duration::from_secs(120), async { + let cleared = tokio::time::timeout(Duration::from_secs(150), async { loop { if node_a.prober().map_or(1, |p| p.locked_msat()) == 0 { break; } - tokio::time::sleep(Duration::from_millis(1)).await; + tokio::time::sleep(Duration::from_millis(100)).await; } }) .await @@ -515,7 +515,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { if node_a.prober().map_or(0, |p| p.locked_msat()) > 0 { break; } - tokio::time::sleep(Duration::from_millis(1)).await; + tokio::time::sleep(Duration::from_millis(100)).await; } }) .await From 9acf20ec943a179a4b1a6545114c45926086f23f Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 7 Apr 2026 13:19:49 +0200 Subject: [PATCH 11/16] Add uniffi support for probing --- bindings/ldk_node.udl | 3 +++ src/builder.rs | 4 ++-- src/lib.rs | 2 ++ src/probing.rs | 55 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 014993690..d8a4cafac 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -13,6 +13,8 @@ typedef dictionary TorConfig; typedef interface NodeEntropy; +typedef interface ProbingConfig; + typedef enum WordCount; [Remote] @@ -61,6 +63,7 @@ interface Builder { [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); void set_wallet_recovery_mode(); + void set_probing_config(ProbingConfig config); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] diff --git a/src/builder.rs b/src/builder.rs index b61ec136d..46ff0f7c1 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1111,8 +1111,8 @@ impl ArcedNodeBuilder { /// Configures background probing. /// /// See [`ProbingConfig`] for details. - pub fn set_probing_config(&self, config: ProbingConfig) { - self.inner.write().unwrap().set_probing_config(config); + pub fn set_probing_config(&self, config: Arc) { + self.inner.write().unwrap().set_probing_config((*config).clone()); } /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options diff --git a/src/lib.rs b/src/lib.rs index 3f25b9241..2c0b0a9ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(cycle_tests)] use std::{any::Any, sync::Weak}; +#[cfg(feature = "uniffi")] +use crate::probing::ProbingConfig; pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; pub use bip39; pub use bitcoin; diff --git a/src/probing.rs b/src/probing.rs index 2076fba66..36b534670 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -58,6 +58,7 @@ pub(crate) enum ProbingStrategyKind { /// [`custom`]: Self::custom /// [`build`]: ProbingConfigBuilder::build #[derive(Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct ProbingConfig { pub(crate) kind: ProbingStrategyKind, pub(crate) interval: Duration, @@ -108,6 +109,60 @@ impl ProbingConfig { } } +#[cfg(feature = "uniffi")] +#[uniffi::export] +impl ProbingConfig { + /// Creates a probing config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + /// All other parameters are optional and fall back to sensible defaults when `None`. + #[uniffi::constructor] + pub fn new_high_degree( + top_node_count: u64, interval_secs: Option, max_locked_msat: Option, + diversity_penalty_msat: Option, cooldown_secs: Option, + ) -> Self { + let mut builder = Self::high_degree(top_node_count as usize); + if let Some(secs) = interval_secs { + builder = builder.interval(Duration::from_secs(secs)); + } + if let Some(msat) = max_locked_msat { + builder = builder.max_locked_msat(msat); + } + if let Some(penalty) = diversity_penalty_msat { + builder = builder.diversity_penalty_msat(penalty); + } + if let Some(secs) = cooldown_secs { + builder = builder.cooldown(Duration::from_secs(secs)); + } + builder.build() + } + + /// Creates a probing config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + /// All other parameters are optional and fall back to sensible defaults when `None`. + #[uniffi::constructor] + pub fn new_random_walk( + max_hops: u64, interval_secs: Option, max_locked_msat: Option, + diversity_penalty_msat: Option, cooldown_secs: Option, + ) -> Self { + let mut builder = Self::random_walk(max_hops as usize); + if let Some(secs) = interval_secs { + builder = builder.interval(Duration::from_secs(secs)); + } + if let Some(msat) = max_locked_msat { + builder = builder.max_locked_msat(msat); + } + if let Some(penalty) = diversity_penalty_msat { + builder = builder.diversity_penalty_msat(penalty); + } + if let Some(secs) = cooldown_secs { + builder = builder.cooldown(Duration::from_secs(secs)); + } + builder.build() + } +} + /// Builder for [`ProbingConfig`]. /// /// Created via [`ProbingConfig::high_degree`], [`ProbingConfig::random_walk`], or From 67ea01326dc5d58c387488fbe1a7fa14f2b10400 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 7 Apr 2026 14:43:20 +0200 Subject: [PATCH 12/16] fix uniffi tests probing initialization --- tests/common/mod.rs | 2 +- tests/probing_tests.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c04c3855a..406b6a817 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -487,7 +487,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> } if let Some(probing) = config.probing { - builder.set_probing_config(probing); + builder.set_probing_config(probing.into()); } let node = match config.store_type { diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index a54e3abb7..3ac164c65 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -496,12 +496,12 @@ async fn exhausted_probe_budget_blocks_new_probes() { node_b.connect(node_a.node_id(), node_a_addr, false).unwrap(); node_b.connect(node_c.node_id(), node_c_addr, false).unwrap(); - let cleared = tokio::time::timeout(Duration::from_secs(150), async { + let cleared = tokio::time::timeout(Duration::from_secs(180), async { loop { if node_a.prober().map_or(1, |p| p.locked_msat()) == 0 { break; } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(500)).await; } }) .await @@ -515,7 +515,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { if node_a.prober().map_or(0, |p| p.locked_msat()) > 0 { break; } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(500)).await; } }) .await From babd23c268bf75175c6a4f99e5d515c1295b3629 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 14 Apr 2026 19:22:09 +0200 Subject: [PATCH 13/16] Remove probing strategies perfomance test --- src/lib.rs | 31 ---- tests/probing_tests.rs | 330 +---------------------------------------- 2 files changed, 2 insertions(+), 359 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2c0b0a9ca..fe3d50bc0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1086,37 +1086,6 @@ impl Node { self.prober.as_deref() } - /// Returns the scorer's estimated `(min, max)` liquidity range for the given channel in the - /// direction toward `target`, or `None` if the scorer has no data for that channel. - /// - /// **Warning:** This is expensive — O(scorer size) per call. It works by serializing the - /// entire `CombinedScorer` and deserializing it as a plain `ProbabilisticScorer` to access - /// `estimated_channel_liquidity_range`. Intended for testing and debugging, not hot paths. - pub fn scorer_channel_liquidity(&self, scid: u64, target: PublicKey) -> Option<(u64, u64)> { - use lightning::routing::scoring::{ - ProbabilisticScorer, ProbabilisticScoringDecayParameters, - }; - use lightning::util::ser::{ReadableArgs, Writeable}; - - let target_node_id = lightning::routing::gossip::NodeId::from_pubkey(&target); - - let bytes = { - let scorer = self.scorer.lock().unwrap(); - let mut buf = Vec::new(); - scorer.write(&mut buf).ok()?; - buf - }; - - let decay_params = ProbabilisticScoringDecayParameters::default(); - let prob_scorer = ProbabilisticScorer::read( - &mut &bytes[..], - (decay_params, Arc::clone(&self.network_graph), Arc::clone(&self.logger)), - ) - .ok()?; - - prob_scorer.estimated_channel_liquidity_range(scid, &target_node_id) - } - /// Retrieve a list of known channels. pub fn list_channels(&self) -> Vec { self.channel_manager.list_channels().into_iter().map(|c| c.into()).collect() diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index 3ac164c65..2f5032121 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -10,44 +10,27 @@ // Stops B mid-flight so the HTLC cannot resolve; confirms the budget // stays exhausted and no further probes are sent. After B restarts // the probe fails, the budget clears, and new probes resume. -// -// Strategy tests: -// -// probing_strategies_perfomance -// Brings up a random mesh of nodes, fires random-walk probes via -// RandomStrategy and high-degree probes via HighDegreeStrategy, then -// runs payment rounds and prints probing perfomance tables. mod common; use std::sync::atomic::{AtomicBool, Ordering}; -use lightning::routing::gossip::NodeAlias; -use lightning_invoice::{Bolt11InvoiceDescription, Description}; - use common::{ expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, - open_channel_no_wait, premine_and_distribute_funds, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_node, TestChainSource, TestNode, + premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, + setup_node, TestNode, }; -use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Amount; use ldk_node::probing::{ProbingConfig, ProbingStrategy}; use ldk_node::Event; use lightning::routing::router::Path; -use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; - -use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; use std::time::Duration; const PROBE_AMOUNT_MSAT: u64 = 1_000_000; -const MAX_LOCKED_MSAT: u64 = 100_000_000; const PROBING_INTERVAL_MILLISECONDS: u64 = 100; -const PROBING_DIVERSITY_PENALTY: u64 = 50_000; /// FixedPathStrategy — returns a fixed pre-built path; used by budget tests. /// @@ -128,173 +111,6 @@ fn build_probe_path( } } -fn config_with_label(label: &str) -> common::TestConfig { - let mut config = random_config(false); - let mut alias_bytes = [0u8; 32]; - let b = label.as_bytes(); - alias_bytes[..b.len()].copy_from_slice(b); - config.node_config.node_alias = Some(NodeAlias(alias_bytes)); - config -} - -fn build_node_random_probing(chain_source: &TestChainSource<'_>, max_hops: usize) -> TestNode { - let mut config = config_with_label("Random"); - config.probing = Some( - ProbingConfig::random_walk(max_hops) - .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) - .max_locked_msat(MAX_LOCKED_MSAT) - .build(), - ); - setup_node(chain_source, config) -} - -fn build_node_highdegree_probing( - chain_source: &TestChainSource<'_>, top_node_count: usize, -) -> TestNode { - let mut config = config_with_label("HiDeg"); - config.probing = Some( - ProbingConfig::high_degree(top_node_count) - .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) - .max_locked_msat(MAX_LOCKED_MSAT) - .build(), - ); - setup_node(chain_source, config) -} - -fn build_node_z_highdegree_probing( - chain_source: &TestChainSource<'_>, top_node_count: usize, diversity_penalty: u64, -) -> TestNode { - let mut config = config_with_label("HiDeg+P"); - config.probing = Some( - ProbingConfig::high_degree(top_node_count) - .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) - .max_locked_msat(MAX_LOCKED_MSAT) - .diversity_penalty_msat(diversity_penalty) - .build(), - ); - setup_node(chain_source, config) -} - -// helpers, formatting -fn node_label(node: &TestNode) -> String { - node.node_alias() - .map(|alias| { - let end = alias.0.iter().position(|&b| b == 0).unwrap_or(32); - String::from_utf8_lossy(&alias.0[..end]).to_string() - }) - .unwrap_or_else(|| format!("{:.8}", node.node_id())) -} - -fn print_topology(all_nodes: &[&TestNode]) { - let labels: HashMap = - all_nodes.iter().map(|n| (n.node_id(), node_label(n))).collect(); - let label_of = |pk: PublicKey| labels.get(&pk).cloned().unwrap_or_else(|| format!("{:.8}", pk)); - - let mut adjacency: BTreeMap> = BTreeMap::new(); - for node in all_nodes { - let local = label_of(node.node_id()); - let mut peers: Vec = node - .list_channels() - .into_iter() - .filter(|ch| ch.short_channel_id.is_some()) - .map(|ch| label_of(ch.counterparty_node_id)) - .collect(); - peers.sort(); - peers.dedup(); - adjacency.entry(local).or_default().extend(peers); - } - - println!("\n=== Topology ==="); - for (node, peers) in &adjacency { - println!(" {node} ── {}", peers.join(", ")); - } -} - -const LABEL_MAX: usize = 8; -const DIR_W: usize = LABEL_MAX * 2 + 1; -const SCORER_W: usize = 28; - -fn thousands(n: u64) -> String { - let s = n.to_string(); - let mut out = String::with_capacity(s.len() + s.len() / 3); - for (i, c) in s.chars().rev().enumerate() { - if i > 0 && i % 3 == 0 { - out.push(' '); - } - out.push(c); - } - out.chars().rev().collect() -} - -fn short_label(label: &str) -> String { - label.chars().take(LABEL_MAX).collect() -} - -fn fmt_est(est: Option<(u64, u64)>) -> String { - match est { - Some((lo, hi)) => format!("[{}, {}]", thousands(lo), thousands(hi)), - None => "unknown".into(), - } -} - -fn print_probing_perfomance(observers: &[&TestNode], all_nodes: &[&TestNode]) { - let labels: HashMap = - all_nodes.iter().chain(observers.iter()).map(|n| (n.node_id(), node_label(n))).collect(); - let label_of = |pk: PublicKey| { - short_label(&labels.get(&pk).cloned().unwrap_or_else(|| format!("{:.8}", pk))) - }; - - let mut by_scid: BTreeMap> = BTreeMap::new(); - for node in all_nodes { - let local_pk = node.node_id(); - for ch in node.list_channels() { - if let Some(scid) = ch.short_channel_id { - by_scid.entry(scid).or_default().push(( - local_pk, - ch.counterparty_node_id, - ch.outbound_capacity_msat, - )); - } - } - } - - print!("\n{:<15} {: = Vec::new(); - for i in 0..num_nodes { - let label = char::from(b'B' + i as u8).to_string(); - let mut config = random_config(false); - let mut alias_bytes = [0u8; 32]; - alias_bytes[..label.as_bytes().len()].copy_from_slice(label.as_bytes()); - config.node_config.node_alias = Some(NodeAlias(alias_bytes)); - nodes.push(setup_node(&chain_source, config)); - } - let node_a = build_node_random_probing(&chain_source, 4); - let node_x = setup_node(&chain_source, config_with_label("nostrat")); - let node_y = build_node_highdegree_probing(&chain_source, 4); - let node_z = build_node_z_highdegree_probing(&chain_source, 4, PROBING_DIVERSITY_PENALTY); - - let seed = std::env::var("TEST_SEED") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or_else(|| rand::rng().random()); - println!("RNG seed: {seed} (re-run with TEST_SEED={seed} to reproduce)"); - let mut rng = StdRng::seed_from_u64(seed); - let channels_per_node = rng.random_range(1..=num_nodes - 1); - let channels_per_nodes: Vec = - (0..num_nodes).map(|_| rng.random_range(1..=channels_per_node)).collect(); - - let observer_nodes: [&TestNode; 4] = [&node_a, &node_y, &node_z, &node_x]; - - let mut addresses = Vec::new(); - for node in observer_nodes { - for _ in 0..utxos_per_node { - addresses.push(node.onchain_payment().new_address().unwrap()); - } - } - for node in &nodes { - for _ in 0..utxos_per_node { - addresses.push(node.onchain_payment().new_address().unwrap()); - } - } - - premine_and_distribute_funds(&bitcoind.client, &electrsd.client, addresses, utxo_per_channel) - .await; - - println!("distributed initial sats"); - for node in nodes.iter().chain(observer_nodes) { - node.sync_wallets().unwrap(); - } - - fn drain_events(node: &TestNode) { - while let Some(_) = node.next_event() { - node.event_handled().unwrap(); - } - } - - println!("opening channels"); - for node in observer_nodes { - let idx = rng.random_range(0..num_nodes); - open_channel_no_wait(node, &nodes[idx], channel_capacity_sat, None, true).await; - } - for (i, &count) in channels_per_nodes.iter().enumerate() { - let targets: Vec = (0..num_nodes).filter(|&j| j != i).take(count).collect(); - for j in targets { - open_channel_no_wait(&nodes[i], &nodes[j], channel_capacity_sat, None, true).await; - } - } - - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - - for node in nodes.iter().chain(observer_nodes) { - node.sync_wallets().unwrap(); - } - for node in nodes.iter().chain(observer_nodes) { - drain_events(node); - } - - tokio::time::sleep(Duration::from_secs(3)).await; - - let mut node_map = HashMap::new(); - for (i, node) in nodes.iter().enumerate() { - node_map.insert(node.node_id(), i); - } - - let all_nodes: Vec<&TestNode> = nodes.iter().chain(observer_nodes).collect(); - - print_topology(&all_nodes); - - println!("\n=== before random payments ==="); - print_probing_perfomance(&observer_nodes, &all_nodes); - - let desc = Bolt11InvoiceDescription::Direct(Description::new("test".to_string()).unwrap()); - for round in 0..10 { - let mut sent = 0u32; - for sender_idx in 0..num_nodes { - let channels: Vec<_> = nodes[sender_idx] - .list_channels() - .into_iter() - .filter(|ch| ch.is_channel_ready && ch.outbound_capacity_msat > 1_000) - .collect(); - if channels.is_empty() { - continue; - } - let ch = &channels[rng.random_range(0..channels.len())]; - let amount_msat = rng.random_range(1_000..=ch.outbound_capacity_msat.min(100_000_000)); - if let Some(&receiver_idx) = node_map.get(&ch.counterparty_node_id) { - let invoice = nodes[receiver_idx] - .bolt11_payment() - .receive(amount_msat, &desc.clone().into(), 3600) - .unwrap(); - if nodes[sender_idx].bolt11_payment().send(&invoice, None).is_ok() { - sent += 1; - } - } - } - println!("round {round}: sent {sent} payments"); - tokio::time::sleep(Duration::from_millis(500)).await; - for node in nodes.iter().chain(observer_nodes) { - drain_events(node); - } - } - - tokio::time::sleep(Duration::from_secs(5)).await; - println!("\n=== after random payments ==="); - print_probing_perfomance(&observer_nodes, &all_nodes); - - for node in nodes.iter().chain(observer_nodes) { - node.stop().unwrap(); - } -} From 56b5b0f068284308f5d093c16de27397f2abbbb4 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 14 Apr 2026 19:33:13 +0200 Subject: [PATCH 14/16] remove unwrap() calls --- src/probing.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/probing.rs b/src/probing.rs index 36b534670..35a303259 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -323,7 +323,7 @@ impl ProbingStrategy for HighDegreeStrategy { let top_node_count = self.top_node_count.min(nodes_by_degree.len()); let now = Instant::now(); - let mut probed = self.recently_probed.lock().unwrap(); + let mut probed = self.recently_probed.lock().unwrap_or_else(|e| e.into_inner()); // We could check staleness when we use the entry, but that way we'd not clear cache at // all. For hundreds of top nodes it's okay to call retain each tick. @@ -444,7 +444,7 @@ impl RandomStrategy { let graph = self.network_graph.read_only(); let first_hop = &initial_channels[random_range(0, initial_channels.len() as u64 - 1) as usize]; - let first_hop_scid = first_hop.short_channel_id.unwrap(); + let first_hop_scid = first_hop.short_channel_id?; let next_peer_pubkey = first_hop.counterparty.node_id; let next_peer_node_id = NodeId::from_pubkey(&next_peer_pubkey); @@ -488,11 +488,15 @@ impl RandomStrategy { break; }; // Retrieve the direction-specific update via the public ChannelInfo fields. - // Safe to unwrap: as_directed_from already checked both directions are Some. - let update = if directed.source() == &next_channel.node_one { - next_channel.one_to_two.as_ref().unwrap() + // as_directed_from already checked both directions are Some, but we break + // defensively rather than unwrap. + let update = match if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref() } else { - next_channel.two_to_one.as_ref().unwrap() + next_channel.two_to_one.as_ref() + } { + Some(u) => u, + None => break, }; if !update.enabled { @@ -560,10 +564,13 @@ impl RandomStrategy { let (_, next_scid, _) = route[i + 1]; let next_channel = graph.channel(next_scid)?; let (directed, _) = next_channel.as_directed_from(&node_id)?; - let update = if directed.source() == &next_channel.node_one { - next_channel.one_to_two.as_ref().unwrap() + let update = match if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref() } else { - next_channel.two_to_one.as_ref().unwrap() + next_channel.two_to_one.as_ref() + } { + Some(u) => u, + None => return None, }; let fee = update.fees.base_msat as u64 + (forwarded * update.fees.proportional_millionths as u64 / 1_000_000); From 10426ad3a3e57441aa895e315850b3245b0868ba Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 15 Apr 2026 16:35:33 +0200 Subject: [PATCH 15/16] Change probing builder for uniffi bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strategy constructors (high_degree/random_walk/custom) moved from ProbingConfig to ProbingConfigBuilder, so they live on the builder rather than on the thing being built. ProbingConfigBuilder setters switched from consuming `self -> Self` to `&mut self -> &mut Self`, matching NodeBuilder. `build` now takes `&self`. Existing fluent call sites still compile unchanged. Removed the flat new_high_degree/new_random_walk UniFFI constructors on ProbingConfig that replicated the builder wiring. Bindings now go through ArcedProbingConfigBuilder (exposed as ProbingConfigBuilder via UDL), which wraps ProbingConfigBuilder in an RwLock for the Arc semantics UniFFI requires — mirroring ArcedNodeBuilder. AI-assisted (Claude Code). --- bindings/ldk_node.udl | 2 + src/builder.rs | 8 +- src/lib.rs | 2 + src/probing.rs | 202 ++++++++++++++++++++++------------------- tests/probing_tests.rs | 6 +- 5 files changed, 122 insertions(+), 98 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index d8a4cafac..2a5a9d423 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -15,6 +15,8 @@ typedef interface NodeEntropy; typedef interface ProbingConfig; +typedef interface ProbingConfigBuilder; + typedef enum WordCount; [Remote] diff --git a/src/builder.rs b/src/builder.rs index 46ff0f7c1..9d1fa57ca 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -625,16 +625,18 @@ impl NodeBuilder { /// Configures background probing. /// - /// Use [`ProbingConfig`] to build the configuration: + /// Use [`ProbingConfigBuilder`] to build the configuration: /// ```ignore - /// use ldk_node::probing::ProbingConfig; + /// use ldk_node::probing::ProbingConfigBuilder; /// /// builder.set_probing_config( - /// ProbingConfig::high_degree(100) + /// ProbingConfigBuilder::high_degree(100) /// .interval(Duration::from_secs(30)) /// .build() /// ); /// ``` + /// + /// [`ProbingConfigBuilder`]: crate::probing::ProbingConfigBuilder pub fn set_probing_config(&mut self, config: ProbingConfig) -> &mut Self { self.probing_config = Some(config); self diff --git a/src/lib.rs b/src/lib.rs index fe3d50bc0..a16306365 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -174,6 +174,8 @@ use payment::{ UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; +#[cfg(feature = "uniffi")] +pub use probing::ArcedProbingConfigBuilder as ProbingConfigBuilder; use probing::{run_prober, Prober}; use runtime::Runtime; pub use tokio; diff --git a/src/probing.rs b/src/probing.rs index 35a303259..b56eb5462 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -10,6 +10,8 @@ use std::collections::HashMap; use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(feature = "uniffi")] +use std::sync::RwLock; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -40,23 +42,20 @@ pub(crate) enum ProbingStrategyKind { /// Configuration for the background probing subsystem. /// -/// Use the constructor methods [`high_degree`], [`random_walk`], or [`custom`] to start -/// building, then chain optional setters and call [`build`]. +/// Construct via [`ProbingConfigBuilder`]. Pick a strategy with +/// [`ProbingConfigBuilder::high_degree`], [`ProbingConfigBuilder::random_walk`], or +/// [`ProbingConfigBuilder::custom`], chain optional setters, and finalize with +/// [`ProbingConfigBuilder::build`]. /// /// # Example /// ```ignore -/// let config = ProbingConfig::high_degree(100) +/// let config = ProbingConfigBuilder::high_degree(100) /// .interval(Duration::from_secs(30)) /// .max_locked_msat(500_000) /// .diversity_penalty_msat(250) /// .build(); /// builder.set_probing_config(config); /// ``` -/// -/// [`high_degree`]: Self::high_degree -/// [`random_walk`]: Self::random_walk -/// [`custom`]: Self::custom -/// [`build`]: ProbingConfigBuilder::build #[derive(Clone)] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct ProbingConfig { @@ -88,86 +87,14 @@ impl fmt::Debug for ProbingConfig { } } -impl ProbingConfig { - /// Start building a config that probes toward the highest-degree nodes in the graph. - /// - /// `top_node_count` controls how many of the most-connected nodes are cycled through. - pub fn high_degree(top_node_count: usize) -> ProbingConfigBuilder { - ProbingConfigBuilder::new(ProbingStrategyKind::HighDegree { top_node_count }) - } - - /// Start building a config that probes via random graph walks. - /// - /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. - pub fn random_walk(max_hops: usize) -> ProbingConfigBuilder { - ProbingConfigBuilder::new(ProbingStrategyKind::Random { max_hops }) - } - - /// Start building a config with a custom [`ProbingStrategy`] implementation. - pub fn custom(strategy: Arc) -> ProbingConfigBuilder { - ProbingConfigBuilder::new(ProbingStrategyKind::Custom(strategy)) - } -} - -#[cfg(feature = "uniffi")] -#[uniffi::export] -impl ProbingConfig { - /// Creates a probing config that probes toward the highest-degree nodes in the graph. - /// - /// `top_node_count` controls how many of the most-connected nodes are cycled through. - /// All other parameters are optional and fall back to sensible defaults when `None`. - #[uniffi::constructor] - pub fn new_high_degree( - top_node_count: u64, interval_secs: Option, max_locked_msat: Option, - diversity_penalty_msat: Option, cooldown_secs: Option, - ) -> Self { - let mut builder = Self::high_degree(top_node_count as usize); - if let Some(secs) = interval_secs { - builder = builder.interval(Duration::from_secs(secs)); - } - if let Some(msat) = max_locked_msat { - builder = builder.max_locked_msat(msat); - } - if let Some(penalty) = diversity_penalty_msat { - builder = builder.diversity_penalty_msat(penalty); - } - if let Some(secs) = cooldown_secs { - builder = builder.cooldown(Duration::from_secs(secs)); - } - builder.build() - } - - /// Creates a probing config that probes via random graph walks. - /// - /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. - /// All other parameters are optional and fall back to sensible defaults when `None`. - #[uniffi::constructor] - pub fn new_random_walk( - max_hops: u64, interval_secs: Option, max_locked_msat: Option, - diversity_penalty_msat: Option, cooldown_secs: Option, - ) -> Self { - let mut builder = Self::random_walk(max_hops as usize); - if let Some(secs) = interval_secs { - builder = builder.interval(Duration::from_secs(secs)); - } - if let Some(msat) = max_locked_msat { - builder = builder.max_locked_msat(msat); - } - if let Some(penalty) = diversity_penalty_msat { - builder = builder.diversity_penalty_msat(penalty); - } - if let Some(secs) = cooldown_secs { - builder = builder.cooldown(Duration::from_secs(secs)); - } - builder.build() - } -} - /// Builder for [`ProbingConfig`]. /// -/// Created via [`ProbingConfig::high_degree`], [`ProbingConfig::random_walk`], or -/// [`ProbingConfig::custom`]. Call [`build`] to finalize. +/// Pick a strategy with [`high_degree`], [`random_walk`], or [`custom`], chain optional +/// setters, and call [`build`] to finalize. /// +/// [`high_degree`]: Self::high_degree +/// [`random_walk`]: Self::random_walk +/// [`custom`]: Self::custom /// [`build`]: Self::build pub struct ProbingConfigBuilder { kind: ProbingStrategyKind, @@ -178,7 +105,7 @@ pub struct ProbingConfigBuilder { } impl ProbingConfigBuilder { - fn new(kind: ProbingStrategyKind) -> Self { + fn with_kind(kind: ProbingStrategyKind) -> Self { Self { kind, interval: Duration::from_secs(DEFAULT_PROBING_INTERVAL_SECS), @@ -188,10 +115,29 @@ impl ProbingConfigBuilder { } } + /// Start building a config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + pub fn high_degree(top_node_count: usize) -> Self { + Self::with_kind(ProbingStrategyKind::HighDegree { top_node_count }) + } + + /// Start building a config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + pub fn random_walk(max_hops: usize) -> Self { + Self::with_kind(ProbingStrategyKind::Random { max_hops }) + } + + /// Start building a config with a custom [`ProbingStrategy`] implementation. + pub fn custom(strategy: Arc) -> Self { + Self::with_kind(ProbingStrategyKind::Custom(strategy)) + } + /// Overrides the interval between probe attempts. /// /// Defaults to 10 seconds. - pub fn interval(mut self, interval: Duration) -> Self { + pub fn interval(&mut self, interval: Duration) -> &mut Self { self.interval = interval; self } @@ -199,7 +145,7 @@ impl ProbingConfigBuilder { /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. /// /// Defaults to 100 000 000 msat (100k sats). - pub fn max_locked_msat(mut self, max_msat: u64) -> Self { + pub fn max_locked_msat(&mut self, max_msat: u64) -> &mut Self { self.max_locked_msat = max_msat; self } @@ -215,7 +161,7 @@ impl ProbingConfigBuilder { /// (e.g., [`RandomStrategy`]) bypass the scorer entirely. /// /// If unset, LDK's default of `0` (no penalty) is used. - pub fn diversity_penalty_msat(mut self, penalty_msat: u64) -> Self { + pub fn diversity_penalty_msat(&mut self, penalty_msat: u64) -> &mut Self { self.diversity_penalty_msat = Some(penalty_msat); self } @@ -223,15 +169,15 @@ impl ProbingConfigBuilder { /// Sets how long a probed node stays ineligible before being probed again. /// /// Only applies to [`HighDegreeStrategy`]. Defaults to 1 hour. - pub fn cooldown(mut self, cooldown: Duration) -> Self { + pub fn cooldown(&mut self, cooldown: Duration) -> &mut Self { self.cooldown = cooldown; self } /// Builds the [`ProbingConfig`]. - pub fn build(self) -> ProbingConfig { + pub fn build(&self) -> ProbingConfig { ProbingConfig { - kind: self.kind, + kind: self.kind.clone(), interval: self.interval, max_locked_msat: self.max_locked_msat, diversity_penalty_msat: self.diversity_penalty_msat, @@ -240,6 +186,78 @@ impl ProbingConfigBuilder { } } +/// A UniFFI-compatible wrapper around [`ProbingConfigBuilder`] that uses interior mutability +/// so it can be shared behind an `Arc` as required by the FFI object model. +/// +/// Obtain one via the constructors [`new_high_degree`] or [`new_random_walk`], configure it +/// with the `set_*` methods, then call [`build`] to produce a [`ProbingConfig`]. +/// +/// [`new_high_degree`]: Self::new_high_degree +/// [`new_random_walk`]: Self::new_random_walk +/// [`build`]: Self::build +#[cfg(feature = "uniffi")] +#[derive(uniffi::Object)] +pub struct ArcedProbingConfigBuilder { + inner: RwLock, +} + +#[cfg(feature = "uniffi")] +#[uniffi::export] +impl ArcedProbingConfigBuilder { + /// Creates a builder configured to probe toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + #[uniffi::constructor] + pub fn new_high_degree(top_node_count: u64) -> Arc { + Arc::new(Self { + inner: RwLock::new(ProbingConfigBuilder::high_degree(top_node_count as usize)), + }) + } + + /// Creates a builder configured to probe via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + #[uniffi::constructor] + pub fn new_random_walk(max_hops: u64) -> Arc { + Arc::new(Self { inner: RwLock::new(ProbingConfigBuilder::random_walk(max_hops as usize)) }) + } + + /// Overrides the interval between probe attempts. Defaults to 10 seconds. + pub fn set_interval(&self, secs: u64) { + self.inner.write().unwrap().interval(Duration::from_secs(secs)); + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// + /// Defaults to 100 000 000 msat (100k sats). + pub fn set_max_locked_msat(&self, max_msat: u64) { + self.inner.write().unwrap().max_locked_msat(max_msat); + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn set_diversity_penalty_msat(&self, penalty_msat: u64) { + self.inner.write().unwrap().diversity_penalty_msat(penalty_msat); + } + + /// Sets how long a probed node stays ineligible before being probed again. + /// + /// Only applies to the high-degree strategy. Defaults to 1 hour. + pub fn set_cooldown(&self, secs: u64) { + self.inner.write().unwrap().cooldown(Duration::from_secs(secs)); + } + + /// Builds the [`ProbingConfig`]. + pub fn build(&self) -> Arc { + Arc::new(self.inner.read().unwrap().build()) + } +} + /// Strategy can be used for determining the next target and amount for probing. pub trait ProbingStrategy: Send + Sync + 'static { /// Returns the next probe path to run, or `None` to skip this tick. diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index 2f5032121..9a456a55a 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -21,7 +21,7 @@ use common::{ }; use ldk_node::bitcoin::Amount; -use ldk_node::probing::{ProbingConfig, ProbingStrategy}; +use ldk_node::probing::{ProbingConfigBuilder, ProbingStrategy}; use ldk_node::Event; use lightning::routing::router::Path; @@ -124,7 +124,7 @@ async fn probe_budget_increments_and_decrements() { let mut config_a = random_config(false); let strategy = FixedPathStrategy::new(); config_a.probing = Some( - ProbingConfig::custom(strategy.clone()) + ProbingConfigBuilder::custom(strategy.clone()) .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) .max_locked_msat(10 * PROBE_AMOUNT_MSAT) .build(), @@ -210,7 +210,7 @@ async fn exhausted_probe_budget_blocks_new_probes() { let mut config_a = random_config(false); let strategy = FixedPathStrategy::new(); config_a.probing = Some( - ProbingConfig::custom(strategy.clone()) + ProbingConfigBuilder::custom(strategy.clone()) .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) .max_locked_msat(10 * PROBE_AMOUNT_MSAT) .build(), From a4c49286091846239873029bb16db63e0dbe2cb2 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 15 Apr 2026 16:56:07 +0200 Subject: [PATCH 16/16] retrigger CI