diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0ae4c87d511..1e1f8fdf9fd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3604,6 +3604,35 @@ fn create_htlc_intercepted_event( }) } +/// Trims likely-unannounced last hops from a probe path and ensures it has at least two hops. +/// +/// Used by pre-flight and background probe sending. Returns `None` if fewer than two hops remain +/// after trimming. +pub(crate) fn trim_unannounced_probe_last_hops( + mut path: Path, logger: &L, +) -> Option { + while let Some(last_hop) = path.hops.last() { + if last_hop.maybe_announced_channel { + break; + } + log_debug!( + logger, + "Avoided sending payment probe all the way to last hop {} as it is likely unannounced.", + last_hop.short_channel_id + ); + let final_value_msat = path.final_value_msat(); + path.hops.pop(); + if let Some(new_last) = path.hops.last_mut() { + new_last.fee_msat += final_value_msat; + } + } + if path.hops.len() < 2 { + None + } else { + Some(path) + } +} + /// Sets the features of the accepted channel in [`ChannelManager::accept_inbound_channel_from_trusted_peer`] #[derive(Clone, Copy)] pub enum TrustedChannelFeatures { @@ -6144,6 +6173,62 @@ impl< ) } + /// Finds a single probe-optimised path to `target_node_id` and sends a background liquidity probe + /// over it. + /// + /// Unlike [`Self::send_spontaneous_preflight_probes`], which sends probes over all paths of a + /// payment route to train the scorer before an imminent payment, tries to avoid saturating + /// liquidity immediately before the payment is sent, and uses payment-oriented route parameters, + /// this method sends exactly one probe with uncapped routing fees. It is intended for ongoing + /// background exploration unrelated to any specific payment. + /// + /// When using [`DefaultRouter`], diversity scoring is applied automatically via + /// [`ScoreLookUp::params_for_probe`](crate::routing::scoring::ScoreLookUp::params_for_probe) + /// because [`RouteParameters::from_probe_target`] marks the lookup as a background probe. + /// Custom [`Router`] implementations must apply probe score-param tuning themselves if desired. + /// + /// Returns [`ProbeSendFailure::RouteNotFound`] when path finding fails, including for unreachable + /// or unannounced targets, routing to self, zero amounts, or when no valid multi-hop path + /// remains after trimming likely-unannounced last hops. + pub fn send_probe_to_node( + &self, target_node_id: PublicKey, amount_msat: u64, final_cltv_expiry_delta: u32, + ) -> Result<(PaymentHash, PaymentId), ProbeSendFailure> { + let payer = self.get_our_node_id(); + let usable_channels = self.list_usable_channels(); + let first_hops = usable_channels.iter().collect::>(); + let inflight_htlcs = self.compute_inflight_htlcs(); + + let route_params = RouteParameters::from_probe_target( + target_node_id, + amount_msat, + final_cltv_expiry_delta, + ); + let route = self + .router + .find_route(&payer, &route_params, Some(&first_hops), inflight_htlcs) + .map_err(|e| { + log_error!(self.logger, "Failed to find path for background probe: {:?}", e); + ProbeSendFailure::RouteNotFound + })?; + + if route.paths.is_empty() { + debug_assert!(false, "find_route returned Ok with no paths — router bug"); + log_error!(self.logger, "Router returned no paths for background probe."); + return Err(ProbeSendFailure::RouteNotFound); + } + let path = route.paths.into_iter().next().unwrap(); + + let path = trim_unannounced_probe_last_hops(path, &self.logger).ok_or_else(|| { + log_debug!( + self.logger, + "Skipped background probe: path has fewer than 2 hops after trimming." + ); + ProbeSendFailure::RouteNotFound + })?; + + self.send_probe(path) + } + /// Returns whether a payment with the given [`PaymentHash`] and [`PaymentId`] is, in fact, a /// payment probe. #[cfg(test)] @@ -6205,35 +6290,17 @@ impl< let mut res = Vec::new(); - for mut path in route.paths { - // If the last hop is probably an unannounced channel we refrain from probing all the - // way through to the end and instead probe up to the second-to-last channel. - while let Some(last_path_hop) = path.hops.last() { - if last_path_hop.maybe_announced_channel { - // We found a potentially announced last hop. - break; - } else { - // Drop the last hop, as it's likely unannounced. + for path in route.paths { + let path = match trim_unannounced_probe_last_hops(path, &self.logger) { + Some(p) => p, + None => { log_debug!( self.logger, - "Avoided sending payment probe all the way to last hop {} as it is likely unannounced.", - last_path_hop.short_channel_id + "Skipped sending payment probe over path with less than two hops." ); - let final_value_msat = path.final_value_msat(); - path.hops.pop(); - if let Some(new_last) = path.hops.last_mut() { - new_last.fee_msat += final_value_msat; - } - } - } - - if path.hops.len() < 2 { - log_debug!( - self.logger, - "Skipped sending payment probe over path with less than two hops." - ); - continue; - } + continue; + }, + }; if let Some(first_path_hop) = path.hops.first() { if let Some(first_hop) = first_hops.iter().find(|h| { diff --git a/lightning/src/ln/mod.rs b/lightning/src/ln/mod.rs index d6e0b92f1d0..2b831c47381 100644 --- a/lightning/src/ln/mod.rs +++ b/lightning/src/ln/mod.rs @@ -106,6 +106,9 @@ mod payment_tests; #[allow(unused_mut)] mod priv_short_conf_tests; #[cfg(test)] +#[allow(unused_mut)] +mod probing_tests; +#[cfg(test)] mod quiescence_tests; #[cfg(test)] #[allow(unused_mut)] diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 234af588eae..aafb2c2d629 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -4062,6 +4062,7 @@ mod tests { payment_params: PaymentParameters::for_keysend(recipient, u32::MAX, true), final_value_msat: u64::MAX, max_total_routing_fee_msat: Some(u64::MAX), + background_probe: false, }; route_params.payment_params.max_total_cltv_expiry_delta = u32::MAX; let recipient_onion = RecipientOnionFields::spontaneous_empty(u64::MAX); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 67fea5092c3..acb15cdf513 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1464,6 +1464,7 @@ impl OutboundPayments { payment_params: params.clone(), max_total_routing_fee_msat: *remaining_max_total_routing_fee_msat, + background_probe: false, }, )); break; @@ -3382,6 +3383,7 @@ mod tests { payment_params: PaymentParameters::from_bolt12_invoice(&invoice), final_value_msat: invoice.amount_msats(), max_total_routing_fee_msat: Some(1234), + background_probe: false, }; router.expect_find_route( route_params.clone(), @@ -3482,6 +3484,7 @@ mod tests { payment_params, final_value_msat: 0, max_total_routing_fee_msat: None, + background_probe: false, }; let payment_hash = PaymentHash([0; 32]); let outbound = PendingOutboundPayment::StaticInvoiceReceived { @@ -3532,6 +3535,7 @@ mod tests { payment_params, final_value_msat: 0, max_total_routing_fee_msat: None, + background_probe: false, }; let payment_hash = PaymentHash([0; 32]); let outbound = PendingOutboundPayment::StaticInvoiceReceived { diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 33c7df93ddb..34b6870af00 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -4189,6 +4189,7 @@ fn test_threaded_payment_retries() { payment_params, final_value_msat: amt_msat, max_total_routing_fee_msat: Some(500_000), + background_probe: false, }; let mut route = Route { @@ -5944,6 +5945,7 @@ fn bolt11_multi_node_mpp_with_retry() { final_value_msat: node_a_payment_amt, payment_params: retry_payment_params, max_total_routing_fee_msat: route_params.max_total_routing_fee_msat, + background_probe: false, }; route.route_params = Some(retry_route_params.clone()); nodes[0].router.expect_find_route(retry_route_params, Ok(route)); diff --git a/lightning/src/ln/probing_tests.rs b/lightning/src/ln/probing_tests.rs new file mode 100644 index 00000000000..38350335fff --- /dev/null +++ b/lightning/src/ln/probing_tests.rs @@ -0,0 +1,50 @@ +// 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 crate::ln::functional_test_utils::*; +use crate::ln::outbound_payment::ProbeSendFailure; +use crate::types::payment::PaymentHash; + +#[test] +fn send_probe_to_node_happy_path() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes(&nodes, 0, 1); + create_announced_chan_between_nodes(&nodes, 1, 2); + + let res = + nodes[0].node.send_probe_to_node(nodes[2].node.get_our_node_id(), 50_000, 40).unwrap(); + assert!(nodes[0].node.payment_is_probe(&res.0, &res.1)); + + let expected_route: &[(&[&Node], PaymentHash)] = &[(&[&nodes[1], &nodes[2]], res.0)]; + + send_probe_along_route(&nodes[0], expected_route); + + expect_probe_successful_events(&nodes[0], vec![res]); + + assert!(!nodes[0].node.has_pending_payments()); +} + +#[test] +fn send_probe_to_node_single_hop_fails() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes(&nodes, 0, 1); + + assert!(matches!( + nodes[0].node.send_probe_to_node(nodes[1].node.get_our_node_id(), 50_000, 40), + Err(ProbeSendFailure::RouteNotFound) + )); +} diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 2032eb680af..8b7b41c4847 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -100,7 +100,7 @@ impl< L: Logger, ES: EntropySource, S: Deref, - SP: Sized, + SP: Sized + Clone, Sc: ScoreLookUp, > Router for DefaultRouter where @@ -115,12 +115,27 @@ where inflight_htlcs: InFlightHtlcs ) -> Result { let random_seed_bytes = self.entropy_source.get_secure_random_bytes(); - find_route( - payer, params, &self.network_graph, first_hops, &self.logger, - &ScorerAccountingForInFlightHtlcs::new(self.scorer.read_lock(), &inflight_htlcs), - &self.score_params, - &random_seed_bytes - ) + + if params.background_probe { + let score_params = self.scorer.read_lock().params_for_probe( + &self.score_params, params.final_value_msat, + ); + find_route( + payer, params, &self.network_graph, first_hops, &self.logger, + &ScorerAccountingForInFlightHtlcs::new( + self.scorer.read_lock(), &inflight_htlcs, + ), + &score_params, &random_seed_bytes, + ) + } else { + find_route( + payer, params, &self.network_graph, first_hops, &self.logger, + &ScorerAccountingForInFlightHtlcs::new( + self.scorer.read_lock(), &inflight_htlcs, + ), + &self.score_params, &random_seed_bytes, + ) + } } #[rustfmt::skip] @@ -404,6 +419,15 @@ where self.scorer.channel_penalty_msat(candidate, usage, score_params) } } + + fn params_for_probe( + &self, score_params: &Self::ScoreParams, amount_msat: u64, + ) -> Self::ScoreParams + where + Self::ScoreParams: Clone, + { + self.scorer.params_for_probe(score_params, amount_msat) + } } /// A data structure for tracking in-flight HTLCs. May be used during pathfinding to account for @@ -896,7 +920,12 @@ impl Readable for Route { // If we previously wrote the corresponding fields, reconstruct RouteParameters. let route_params = match (payment_params, final_value_msat) { (Some(payment_params), Some(final_value_msat)) => { - Some(RouteParameters { payment_params, final_value_msat, max_total_routing_fee_msat }) + Some(RouteParameters { + payment_params, + final_value_msat, + max_total_routing_fee_msat, + background_probe: false, + }) } _ => None, }; @@ -923,6 +952,10 @@ pub struct RouteParameters { /// /// Note that values below a few sats may result in some paths being spuriously ignored. pub max_total_routing_fee_msat: Option, + + /// When true, indicates this route lookup is for a background liquidity probe rather than + /// a payment. [`DefaultRouter`] uses this to call [`ScoreLookUp::params_for_probe`] before routing. + pub(crate) background_probe: bool, } impl RouteParameters { @@ -931,7 +964,36 @@ impl RouteParameters { /// [`Self::max_total_routing_fee_msat`] defaults to 1% of the payment amount + 50 sats #[rustfmt::skip] pub fn from_payment_params_and_value(payment_params: PaymentParameters, final_value_msat: u64) -> Self { - Self { payment_params, final_value_msat, max_total_routing_fee_msat: Some(final_value_msat / 100 + 50_000) } + Self { + payment_params, + final_value_msat, + max_total_routing_fee_msat: Some(final_value_msat / 100 + 50_000), + background_probe: false, + } + } + + /// Constructs [`RouteParameters`] for background liquidity probing to `target_node_id`. + /// + /// Unlike [`Self::from_payment_params_and_value`], this sets [`Self::max_total_routing_fee_msat`] + /// to `None`, leaving routing fees uncapped during path selection, and marks the route lookup + /// as a background probe so that [`DefaultRouter`] applies + /// [`ScoreLookUp::params_for_probe`](crate::routing::scoring::ScoreLookUp::params_for_probe) + /// automatically. + /// + /// Callers of the free [`find_route`] function must also call + /// [`ScoreLookUp::params_for_probe`](crate::routing::scoring::ScoreLookUp::params_for_probe) + /// themselves and pass the returned params to [`find_route`]. + pub fn from_probe_target( + target_node_id: PublicKey, amount_msat: u64, final_cltv_expiry_delta: u32, + ) -> Self { + let payment_params = + PaymentParameters::from_node_id(target_node_id, final_cltv_expiry_delta); + Self { + payment_params, + final_value_msat: amount_msat, + max_total_routing_fee_msat: None, + background_probe: true, + } } /// Sets the maximum number of hops that can be included in a payment path, based on the provided @@ -980,6 +1042,7 @@ impl Readable for RouteParameters { payment_params, final_value_msat: final_value_msat.0.unwrap(), max_total_routing_fee_msat, + background_probe: false, }) } } @@ -3907,7 +3970,9 @@ pub(crate) fn get_route( } } - let route = Route { paths, route_params: Some(route_params.clone()) }; + let mut stored_route_params = route_params.clone(); + stored_route_params.background_probe = false; + let route = Route { paths, route_params: Some(stored_route_params) }; // Make sure we would never create a route whose total fees exceed max_total_routing_fee_msat. if let Some(max_total_routing_fee_msat) = route_params.max_total_routing_fee_msat { @@ -4093,15 +4158,15 @@ mod tests { use crate::ln::types::ChannelId; use crate::routing::gossip::{EffectiveCapacity, NetworkGraph, NodeId, P2PGossipSync}; use crate::routing::router::{ - add_random_cltv_offset, build_route_from_hops_internal, default_node_features, get_route, - BlindedPathCandidate, BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, + add_random_cltv_offset, build_route_from_hops_internal, default_node_features, find_route, + get_route, BlindedPathCandidate, BlindedTail, CandidateRouteHop, InFlightHtlcs, Path, PaymentParameters, PublicHopCandidate, Route, RouteHint, RouteHintHop, RouteHop, RouteParameters, RoutingFees, ScorerAccountingForInFlightHtlcs, DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE, }; use crate::routing::scoring::{ ChannelUsage, FixedPenaltyScorer, ProbabilisticScorer, ProbabilisticScoringDecayParameters, - ProbabilisticScoringFeeParameters, ScoreLookUp, + ProbabilisticScoringFeeParameters, ScoreLookUp, ScoreUpdate, }; use crate::routing::test_utils::*; use crate::routing::utxo::UtxoResult; @@ -4243,6 +4308,177 @@ mod tests { Arc::clone(&logger), &scorer, &Default::default(), &random_seed_bytes).unwrap_err(); } + #[test] + fn find_probe_route_basic() { + let (secp_ctx, network_graph, _, _, logger) = build_graph(); + let (_, our_id, _, nodes) = get_nodes(&secp_ctx); + let decay_params = ProbabilisticScoringDecayParameters::default(); + let scorer = + ProbabilisticScorer::new(decay_params, Arc::clone(&network_graph), Arc::clone(&logger)); + let amount_msat = 100_000; + let route_params = RouteParameters::from_probe_target(nodes[2], amount_msat, 42); + let score_params = + scorer.params_for_probe(&ProbabilisticScoringFeeParameters::default(), amount_msat); + let random_seed_bytes = [42; 32]; + + let route = find_route( + &our_id, + &route_params, + &network_graph, + None, + Arc::clone(&logger), + &scorer, + &score_params, + &random_seed_bytes, + ) + .unwrap(); + assert!(!route.paths.is_empty()); + assert!(route.paths[0].hops.len() >= 2); + } + + #[test] + fn find_probe_route_diversity() { + use core::time::Duration; + + // Diamond topology with two equal-cost 2-hop paths: + // B (nodes[0]) + // / \ + // our_id D (nodes[2], target) + // \ / + // C (nodes[1]) + let secp_ctx = Secp256k1::new(); + let logger = Arc::new(ln_test_utils::TestLogger::new()); + let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, Arc::clone(&logger))); + let gossip_sync = P2PGossipSync::new(Arc::clone(&network_graph), None, Arc::clone(&logger)); + let (our_privkey, our_id, privkeys, nodes) = get_nodes(&secp_ctx); + let target = nodes[2]; + + let add_bidir_channel = + |scid: u64, node_a: &SecretKey, node_b: &SecretKey, features_id: u8| { + add_channel( + &gossip_sync, + &secp_ctx, + node_a, + node_b, + ChannelFeatures::from_le_bytes(id_to_feature_flags(features_id)), + scid, + ); + for (key, channel_flags) in [(node_a, 0u8), (node_b, 1u8)] { + update_channel( + &gossip_sync, + &secp_ctx, + key, + UnsignedChannelUpdate { + chain_hash: ChainHash::using_genesis_block(Network::Testnet), + short_channel_id: scid, + timestamp: 1, + message_flags: 1, + channel_flags, + cltv_expiry_delta: 18, + htlc_minimum_msat: 0, + htlc_maximum_msat: MAX_VALUE_MSAT, + fee_base_msat: 0, + fee_proportional_millionths: 0, + excess_data: Vec::new(), + }, + ); + } + }; + + add_bidir_channel(1, &our_privkey, &privkeys[0], 1); + add_or_update_node( + &gossip_sync, + &secp_ctx, + &privkeys[0], + NodeFeatures::from_le_bytes(id_to_feature_flags(1)), + 0, + ); + add_bidir_channel(2, &our_privkey, &privkeys[1], 2); + add_or_update_node( + &gossip_sync, + &secp_ctx, + &privkeys[1], + NodeFeatures::from_le_bytes(id_to_feature_flags(2)), + 0, + ); + add_bidir_channel(3, &privkeys[0], &privkeys[2], 3); + add_or_update_node( + &gossip_sync, + &secp_ctx, + &privkeys[2], + NodeFeatures::from_le_bytes(id_to_feature_flags(3)), + 0, + ); + add_bidir_channel(4, &privkeys[1], &privkeys[2], 4); + + let decay_params = ProbabilisticScoringDecayParameters::default(); + let mut scorer = + ProbabilisticScorer::new(decay_params, Arc::clone(&network_graph), Arc::clone(&logger)); + let amount_msat = 100_000; + let route_params = RouteParameters::from_probe_target(target, amount_msat, 42); + let score_params = + scorer.params_for_probe(&ProbabilisticScoringFeeParameters::default(), amount_msat); + let random_seed_bytes = [42; 32]; + + let route1 = find_route( + &our_id, + &route_params, + &network_graph, + None, + Arc::clone(&logger), + &scorer, + &score_params, + &random_seed_bytes, + ) + .unwrap(); + let first_hop_scid_1 = route1.paths[0].hops[0].short_channel_id; + + scorer.probe_successful(&route1.paths[0], Duration::from_secs(1)); + + let route2 = find_route( + &our_id, + &route_params, + &network_graph, + None, + Arc::clone(&logger), + &scorer, + &score_params, + &random_seed_bytes, + ) + .unwrap(); + let first_hop_scid_2 = route2.paths[0].hops[0].short_channel_id; + assert_ne!(first_hop_scid_1, first_hop_scid_2); + + // Without diversity penalty, the same first hop should be selected again. + let no_diversity_params = ProbabilisticScoringFeeParameters::default(); + let route3 = find_route( + &our_id, + &route_params, + &network_graph, + None, + Arc::clone(&logger), + &scorer, + &no_diversity_params, + &random_seed_bytes, + ) + .unwrap(); + let route4 = find_route( + &our_id, + &route_params, + &network_graph, + None, + Arc::clone(&logger), + &scorer, + &no_diversity_params, + &random_seed_bytes, + ) + .unwrap(); + assert_eq!( + route3.paths[0].hops[0].short_channel_id, + route4.paths[0].hops[0].short_channel_id, + ); + } + #[test] #[rustfmt::skip] fn invalid_first_hop_test() { @@ -6680,7 +6916,7 @@ mod tests { { // Attempt to route while setting max_total_routing_fee_msat to 149_999 results in a failure. let route_params = RouteParameters { payment_params: payment_params.clone(), final_value_msat: 200_000, - max_total_routing_fee_msat: Some(149_999) }; + max_total_routing_fee_msat: Some(149_999), background_probe: false }; if let Err(err) = get_route( &our_id, &route_params, &network_graph.read_only(), None, Arc::clone(&logger), &scorer, &Default::default(), &random_seed_bytes) { @@ -6691,7 +6927,7 @@ mod tests { { // Now, attempt to route 200 sats (exact amount we can route). let route_params = RouteParameters { payment_params: payment_params.clone(), final_value_msat: 200_000, - max_total_routing_fee_msat: Some(150_000) }; + max_total_routing_fee_msat: Some(150_000), background_probe: false }; let route = get_route(&our_id, &route_params, &network_graph.read_only(), None, Arc::clone(&logger), &scorer, &Default::default(), &random_seed_bytes).unwrap(); assert_eq!(route.paths.len(), 2); diff --git a/lightning/src/routing/scoring.rs b/lightning/src/routing/scoring.rs index c74f27b92f4..e8aec4f4f4a 100644 --- a/lightning/src/routing/scoring.rs +++ b/lightning/src/routing/scoring.rs @@ -107,6 +107,20 @@ pub trait ScoreLookUp { fn channel_penalty_msat( &self, candidate: &CandidateRouteHop, usage: ChannelUsage, score_params: &Self::ScoreParams ) -> u64; + + /// Returns score parameters tuned for a background liquidity probe of `amount_msat`. + /// + /// The default implementation returns `score_params` unchanged. Scorers that support + /// probe path diversity (e.g. [`ProbabilisticScorer`]) override this to apply a diversity + /// penalty so that successive probes to the same target take different paths. + fn params_for_probe( + &self, score_params: &Self::ScoreParams, _amount_msat: u64, + ) -> Self::ScoreParams + where + Self::ScoreParams: Clone, + { + score_params.clone() + } } /// `ScoreUpdate` is used to update the scorer's internal state after a payment attempt. @@ -149,6 +163,15 @@ impl> ScoreLookUp for T { ) -> u64 { self.deref().channel_penalty_msat(candidate, usage, score_params) } + + fn params_for_probe( + &self, score_params: &Self::ScoreParams, amount_msat: u64, + ) -> Self::ScoreParams + where + Self::ScoreParams: Clone, + { + self.deref().params_for_probe(score_params, amount_msat) + } } #[cfg(not(c_bindings))] @@ -332,6 +355,15 @@ impl<'a, T: Score> ScoreLookUp for MultiThreadedScoreLockRead<'a, T> { ) -> u64 { self.0.channel_penalty_msat(candidate, usage, score_params) } + + fn params_for_probe( + &self, score_params: &Self::ScoreParams, amount_msat: u64, + ) -> Self::ScoreParams + where + Self::ScoreParams: Clone, + { + self.0.params_for_probe(score_params, amount_msat) + } } #[cfg(c_bindings)] @@ -836,6 +868,34 @@ impl ProbabilisticScoringFeeParameters { pub fn clear_manual_penalties(&mut self) { self.manual_node_penalties = new_hash_map(); } + + /// Returns score parameters suitable for background probing path selection. + /// + /// If [`Self::probing_diversity_penalty_msat`] is zero, sets it to a value comparable to the + /// other routing penalties at `amount_msat`, following the guidance on + /// [`Self::probing_diversity_penalty_msat`]. + pub fn for_probing(&self, amount_msat: u64) -> Self { + let mut params = self.clone(); + if params.probing_diversity_penalty_msat == 0 { + params.probing_diversity_penalty_msat = params + .base_penalty_msat + .saturating_add(params.historical_liquidity_penalty_multiplier_msat) + .saturating_add( + params.base_penalty_amount_multiplier_msat.saturating_mul(amount_msat) + / (1_u64 << 30), + ) + .saturating_add( + params + .historical_liquidity_penalty_amount_multiplier_msat + .saturating_mul(amount_msat) + / AMOUNT_PENALTY_DIVISOR, + // In `combined_penalty_msat`, `negative_log10_times_2048` is on the order of + // 2048 for a typical worst-case channel, cancelling the `/ 2048` in that + // function and leaving `AMOUNT_PENALTY_DIVISOR` as the effective divisor. + ); + } + params + } } #[cfg(test)] @@ -1724,6 +1784,12 @@ impl>, L: Logger> ScoreLookUp for Probabilisti .saturating_add(anti_probing_penalty_msat) .saturating_add(base_penalty_msat) } + + fn params_for_probe( + &self, score_params: &ProbabilisticScoringFeeParameters, amount_msat: u64, + ) -> ProbabilisticScoringFeeParameters { + score_params.for_probing(amount_msat) + } } impl>, L: Logger> ScoreUpdate for ProbabilisticScorer { @@ -1895,6 +1961,12 @@ impl>, L: Logger> ScoreLookUp for CombinedScor ) -> u64 { self.scorer.channel_penalty_msat(candidate, usage, score_params) } + + fn params_for_probe( + &self, score_params: &ProbabilisticScoringFeeParameters, amount_msat: u64, + ) -> ProbabilisticScoringFeeParameters { + self.scorer.params_for_probe(score_params, amount_msat) + } } impl>, L: Logger> ScoreUpdate for CombinedScorer { @@ -4312,6 +4384,34 @@ mod tests { scorer.time_passed(Duration::from_secs(86400/2 + 1)); assert_eq!(scorer.channel_penalty_msat(&candidate, usage, ¶ms), 250_000); } + + #[test] + fn for_probing_sets_diversity_penalty() { + let params = ProbabilisticScoringFeeParameters::default(); + let amount_msat = 100_000; + let probed = params.for_probing(amount_msat); + let expected = params + .base_penalty_msat + .saturating_add(params.historical_liquidity_penalty_multiplier_msat) + .saturating_add( + params.base_penalty_amount_multiplier_msat.saturating_mul(amount_msat) + / (1_u64 << 30), + ) + .saturating_add( + params + .historical_liquidity_penalty_amount_multiplier_msat + .saturating_mul(amount_msat) + / super::AMOUNT_PENALTY_DIVISOR, + ); + assert_eq!(probed.probing_diversity_penalty_msat, expected); + } + + #[test] + fn for_probing_leaves_existing_penalty() { + let mut params = ProbabilisticScoringFeeParameters::default(); + params.probing_diversity_penalty_msat = 42; + assert_eq!(params.for_probing(100_000).probing_diversity_penalty_msat, 42); + } } #[cfg(ldk_bench)]