From 59dbbe6a0ed55b351568ebb3f5b37eef160e331e Mon Sep 17 00:00:00 2001 From: sangbida Date: Mon, 26 May 2025 15:33:56 +1000 Subject: [PATCH 1/6] Add pathfinder trait --- sim-cli/src/main.rs | 9 +- sim-cli/src/parsing.rs | 10 +- simln-lib/src/sim_node.rs | 201 ++++++++++++++++++++++++-------------- 3 files changed, 143 insertions(+), 77 deletions(-) diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs index e00e2b79..fedcba9a 100755 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -10,6 +10,9 @@ use simln_lib::{ use simple_logger::SimpleLogger; use tokio_util::task::TaskTracker; +// Import the pathfinder types +use simln_lib::sim_node::DefaultPathFinder; + #[tokio::main] async fn main() -> anyhow::Result<()> { // Enable tracing if building in developer mode. @@ -31,6 +34,9 @@ async fn main() -> anyhow::Result<()> { cli.validate(&sim_params)?; let tasks = TaskTracker::new(); + + // Create the pathfinder instance + let pathfinder = DefaultPathFinder; let (sim, validated_activities) = if sim_params.sim_network.is_empty() { create_simulation(&cli, &sim_params, tasks.clone()).await? @@ -46,6 +52,7 @@ async fn main() -> anyhow::Result<()> { &sim_params, tasks.clone(), interceptors, + pathfinder, CustomRecords::default(), ) .await? @@ -60,4 +67,4 @@ async fn main() -> anyhow::Result<()> { sim.run(&validated_activities).await?; Ok(()) -} +} \ No newline at end of file diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index d2c012ba..3833f05e 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -5,9 +5,10 @@ use log::LevelFilter; use serde::{Deserialize, Serialize}; use simln_lib::clock::SimulationClock; use simln_lib::sim_node::{ - ln_node_from_graph, populate_network_graph, ChannelPolicy, CustomRecords, Interceptor, + ln_node_from_graph, populate_network_graph, ChannelPolicy, CustomRecords, Interceptor, PathFinder, SimGraph, SimulatedChannel, }; + use simln_lib::{ cln, cln::ClnNode, eclair, eclair::EclairNode, lnd, lnd::LndNode, serializers, ActivityDefinition, Amount, Interval, LightningError, LightningNode, NodeId, NodeInfo, @@ -224,12 +225,13 @@ struct NodeMapping { alias_node_map: HashMap, } -pub async fn create_simulation_with_network( +pub async fn create_simulation_with_network PathFinder<'a> + Clone + 'static>( cli: &Cli, sim_params: &SimParams, tasks: TaskTracker, interceptors: Vec>, custom_records: CustomRecords, + pathfinder: P, ) -> Result<(Simulation, Vec), anyhow::Error> { let cfg: SimulationCfg = SimulationCfg::try_from(cli)?; let SimParams { @@ -276,7 +278,8 @@ pub async fn create_simulation_with_network( .map_err(|e| SimulationError::SimulatedNetworkError(format!("{:?}", e)))?, ); - let nodes = ln_node_from_graph(simulation_graph.clone(), routing_graph).await; + // Pass the pathfinder to ln_node_from_graph + let nodes = ln_node_from_graph(simulation_graph.clone(), routing_graph, pathfinder).await; let validated_activities = get_validated_activities(&nodes, nodes_info, sim_params.activity.clone()).await?; @@ -293,6 +296,7 @@ pub async fn create_simulation_with_network( )) } + /// Parses the cli options provided and creates a simulation to be run, connecting to lightning nodes and validating /// any activity described in the simulation file. pub async fn create_simulation( diff --git a/simln-lib/src/sim_node.rs b/simln-lib/src/sim_node.rs index 8a504d53..694b43ba 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -484,11 +484,57 @@ pub trait SimNetwork: Send + Sync { fn list_nodes(&self) -> Result, LightningError>; } +/// A trait for custom pathfinding implementations. +pub trait PathFinder<'a>: Send + Sync { + fn find_route( + &self, + source: &PublicKey, + dest: PublicKey, + amount_msat: u64, + pathfinding_graph: &NetworkGraph<&'a WrappedLog>, + scorer: &ProbabilisticScorer>, &'a WrappedLog>, + ) -> Result; +} + +/// Default pathfinder that uses LDK's pathfinding algorithm. +#[derive(Clone)] +pub struct DefaultPathFinder; + +impl<'a> PathFinder<'a> for DefaultPathFinder { + fn find_route( + &self, + source: &PublicKey, + dest: PublicKey, + amount_msat: u64, + pathfinding_graph: &NetworkGraph<&'a WrappedLog>, + scorer: &ProbabilisticScorer>, &'a WrappedLog>, + ) -> Result { + find_route( + source, + &RouteParameters { + payment_params: PaymentParameters::from_node_id(dest, 0) + .with_max_total_cltv_expiry_delta(u32::MAX) + .with_max_path_count(1) + .with_max_channel_saturation_power_of_half(1), + final_value_msat: amount_msat, + max_total_routing_fee_msat: None, + }, + pathfinding_graph, + None, + &WrappedLog {}, + scorer, + &Default::default(), + &[0; 32], + ) + .map_err(|e| SimulationError::SimulatedNetworkError(e.err)) + } +} + /// A wrapper struct used to implement the LightningNode trait (can be thought of as "the" lightning node). Passes /// all functionality through to a coordinating simulation network. This implementation contains both the [`SimNetwork`] /// implementation that will allow us to dispatch payments and a read-only NetworkGraph that is used for pathfinding. /// While these two could be combined, we re-use the LDK-native struct to allow re-use of their pathfinding logic. -pub struct SimNode<'a, T: SimNetwork> { +pub struct SimNode<'a, T: SimNetwork, P: PathFinder<'a> = DefaultPathFinder> { info: NodeInfo, /// The underlying execution network that will be responsible for dispatching payments. network: Arc>, @@ -499,15 +545,18 @@ pub struct SimNode<'a, T: SimNetwork> { /// Probabilistic scorer used to rank paths through the network for routing. This is reused across /// multiple payments to maintain scoring state. scorer: ProbabilisticScorer>, &'a WrappedLog>, + /// The pathfinder implementation to use for finding routes + pathfinder: P, } -impl<'a, T: SimNetwork> SimNode<'a, T> { +impl<'a, T: SimNetwork, P: PathFinder<'a>> SimNode<'a, T, P> { /// Creates a new simulation node that refers to the high level network coordinator provided to process payments /// on its behalf. The pathfinding graph is provided separately so that each node can handle its own pathfinding. pub fn new( pubkey: PublicKey, payment_network: Arc>, pathfinding_graph: Arc>, + pathfinder: P, ) -> Self { // Initialize the probabilistic scorer with default parameters for learning from payment // history. These parameters control how much successful/failed payments affect routing @@ -524,6 +573,7 @@ impl<'a, T: SimNetwork> SimNode<'a, T> { in_flight: HashMap::new(), pathfinding_graph, scorer, + pathfinder, } } @@ -557,6 +607,37 @@ impl<'a, T: SimNetwork> SimNode<'a, T> { Ok(()) } + + /// Dispatches a payment to a specified route. + /// The [`lightning::routing::router::build_route_from_hops`] function can be used to build the route to be passed here. + /// + /// **Note:** The payment hash passed in here should be used in track_payment to track the payment outcome. + pub async fn send_to_route( + &mut self, + route: Route, + payment_hash: PaymentHash, + ) -> Result<(), LightningError> { + let (sender, receiver) = channel(); + + // Check for payment hash collision, failing the payment if we happen to repeat one. + match self.in_flight.entry(payment_hash) { + Entry::Occupied(_) => { + return Err(LightningError::SendPaymentError( + "payment hash exists".to_string(), + )); + }, + Entry::Vacant(vacant) => { + vacant.insert(receiver); + }, + } + + self.network + .lock() + .await + .dispatch_payment(self.info.pubkey, route, payment_hash, sender); + + Ok(()) + } } /// Produces the node info for a mocked node, filling in the features that the simulator requires. @@ -572,39 +653,8 @@ fn node_info(pubkey: PublicKey) -> NodeInfo { } } -/// Uses LDK's pathfinding algorithm with default parameters to find a path from source to destination, with no -/// restrictions on fee budget. -fn find_payment_route<'a>( - source: &PublicKey, - dest: PublicKey, - amount_msat: u64, - pathfinding_graph: &NetworkGraph<&'a WrappedLog>, - scorer: &ProbabilisticScorer>, &'a WrappedLog>, -) -> Result { - find_route( - source, - &RouteParameters { - payment_params: PaymentParameters::from_node_id(dest, 0) - .with_max_total_cltv_expiry_delta(u32::MAX) - // TODO: set non-zero value to support MPP. - .with_max_path_count(1) - // Allow sending htlcs up to 50% of the channel's capacity. - .with_max_channel_saturation_power_of_half(1), - final_value_msat: amount_msat, - max_total_routing_fee_msat: None, - }, - pathfinding_graph, - None, - &WrappedLog {}, - scorer, - &Default::default(), - &[0; 32], - ) - .map_err(|e| SimulationError::SimulatedNetworkError(e.err)) -} - #[async_trait] -impl LightningNode for SimNode<'_, T> { +impl<'a, T: SimNetwork, P: PathFinder<'a>> LightningNode for SimNode<'a, T, P> { fn get_info(&self) -> &NodeInfo { &self.info } @@ -620,8 +670,24 @@ impl LightningNode for SimNode<'_, T> { dest: PublicKey, amount_msat: u64, ) -> Result { - // Create a sender and receiver pair that will be used to report the results of the payment and add them to - // our internal tracking state along with the chosen payment hash. + // Use the stored scorer when finding a route + let route = match self.pathfinder.find_route( + &self.info.pubkey, + dest, + amount_msat, + &self.pathfinding_graph, + &self.scorer, + ) { + Ok(route) => route, + Err(e) => { + log::warn!("No route found: {e}"); + return Err(LightningError::SendPaymentError(format!( + "No route found: {e}" + ))); + }, + }; + + // Create a channel to receive the payment result. let (sender, receiver) = channel(); let preimage = PaymentPreimage(rand::random()); let payment_hash = preimage.into(); @@ -638,36 +704,13 @@ impl LightningNode for SimNode<'_, T> { }, } - // Use the stored scorer when finding a route - let route = match find_payment_route( - &self.info.pubkey, - dest, - amount_msat, - &self.pathfinding_graph, - &self.scorer, - ) { - Ok(path) => path, - // In the case that we can't find a route for the payment, we still report a successful payment *api call* - // and report RouteNotFound to the tracking channel. This mimics the behavior of real nodes. - Err(e) => { - log::trace!("Could not find path for payment: {:?}.", e); - - if let Err(e) = sender.send(Ok(PaymentResult { - htlc_count: 0, - payment_outcome: PaymentOutcome::RouteNotFound, - })) { - log::error!("Could not send payment result: {:?}.", e); - } - - return Ok(payment_hash); - }, - }; - - // If we did successfully obtain a route, dispatch the payment through the network and then report success. - self.network - .lock() - .await - .dispatch_payment(self.info.pubkey, route, payment_hash, sender); + // Dispatch the payment through the network + self.network.lock().await.dispatch_payment( + self.info.pubkey, + route, + payment_hash, + sender, + ); Ok(payment_hash) } @@ -1007,12 +1050,16 @@ impl SimGraph { } /// Produces a map of node public key to lightning node implementation to be used for simulations. -pub async fn ln_node_from_graph( +pub async fn ln_node_from_graph

( graph: Arc>, - routing_graph: Arc>, -) -> HashMap>> { + routing_graph: Arc>, + pathfinder: P, +) -> HashMap>> +where + P: for<'a> PathFinder<'a> + Clone + 'static, +{ let mut nodes: HashMap>> = HashMap::new(); - + for pk in graph.lock().await.nodes.keys() { nodes.insert( *pk, @@ -1020,6 +1067,7 @@ pub async fn ln_node_from_graph( *pk, graph.clone(), routing_graph.clone(), + pathfinder.clone(), ))), ); } @@ -1897,7 +1945,7 @@ mod tests { // Create a simulated node for the first channel in our network. let pk = channels[0].node_1.policy.pubkey; - let mut node = SimNode::new(pk, sim_network.clone(), Arc::new(graph)); + let mut node = SimNode::new(pk, sim_network.clone(), Arc::new(graph), DefaultPathFinder); // Prime mock to return node info from lookup and assert that we get the pubkey we're expecting. let lookup_pk = channels[3].node_1.policy.pubkey; @@ -1988,6 +2036,7 @@ mod tests { routing_graph: Arc>, scorer: ProbabilisticScorer>, &'a WrappedLog>, shutdown: (Trigger, Listener), + pathfinder: DefaultPathFinder, } impl DispatchPaymentTestKit<'_> { @@ -2036,6 +2085,7 @@ mod tests { routing_graph, scorer, shutdown: shutdown_clone, + pathfinder: DefaultPathFinder, }; // Assert that our channel balance is all on the side of the channel opener when we start up. @@ -2078,8 +2128,13 @@ mod tests { dest: PublicKey, amt: u64, ) -> (Route, Result) { - let route = - find_payment_route(&source, dest, amt, &self.routing_graph, &self.scorer).unwrap(); + let route = self.pathfinder.find_route( + &source, + dest, + amt, + &self.routing_graph, + &self.scorer, + ).unwrap(); let (sender, receiver) = oneshot::channel(); self.graph From 59e82f051b8df8ddfa89cc9e8fc2bca0a9547569 Mon Sep 17 00:00:00 2001 From: sangbida Date: Sat, 31 May 2025 18:57:30 +1000 Subject: [PATCH 2/6] Add tests --- Cargo.lock | 1 + sim-cli/Cargo.toml | 1 + sim-cli/src/main.rs | 6 +- sim-cli/src/parsing.rs | 226 +++++++++++++++++++++++++++++++++++- simln-lib/src/sim_node.rs | 35 +++--- simln-lib/src/test_utils.rs | 32 +++++ 6 files changed, 282 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d0bd0e3..4370958d 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2567,6 +2567,7 @@ dependencies = [ "dialoguer", "futures", "hex", + "lightning", "log", "openssl", "rand", diff --git a/sim-cli/Cargo.toml b/sim-cli/Cargo.toml index 6e4a9ac4..c953b627 100755 --- a/sim-cli/Cargo.toml +++ b/sim-cli/Cargo.toml @@ -28,6 +28,7 @@ futures = "0.3.30" console-subscriber = { version = "0.4.0", optional = true} tokio-util = { version = "0.7.13", features = ["rt"] } openssl = { version = "0.10", features = ["vendored"] } +lightning = { version = "0.0.123" } [features] dev = ["console-subscriber"] diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs index fedcba9a..ff765f8d 100755 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -34,7 +34,7 @@ async fn main() -> anyhow::Result<()> { cli.validate(&sim_params)?; let tasks = TaskTracker::new(); - + // Create the pathfinder instance let pathfinder = DefaultPathFinder; @@ -52,8 +52,8 @@ async fn main() -> anyhow::Result<()> { &sim_params, tasks.clone(), interceptors, - pathfinder, CustomRecords::default(), + pathfinder, ) .await? }; @@ -67,4 +67,4 @@ async fn main() -> anyhow::Result<()> { sim.run(&validated_activities).await?; Ok(()) -} \ No newline at end of file +} diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index 3833f05e..d7513dd6 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -296,7 +296,6 @@ pub async fn create_simulation_with_network PathFinder<'a> + Clone + )) } - /// Parses the cli options provided and creates a simulation to be run, connecting to lightning nodes and validating /// any activity described in the simulation file. pub async fn create_simulation( @@ -593,3 +592,228 @@ pub async fn get_validated_activities( validate_activities(activity.to_vec(), activity_validation_params).await } + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use lightning::routing::gossip::NetworkGraph; + use lightning::routing::router::{find_route, PaymentParameters, Route, RouteParameters}; + use lightning::routing::scoring::{ProbabilisticScorer, ProbabilisticScoringDecayParameters}; + use rand::RngCore; + use simln_lib::clock::SystemClock; + use simln_lib::sim_node::{ + ln_node_from_graph, populate_network_graph, PathFinder, SimGraph, WrappedLog, + }; + use simln_lib::SimulationError; + use std::sync::Arc; + use tokio::sync::Mutex; + use tokio_util::task::TaskTracker; + + /// Gets a key pair generated in a pseudorandom way. + fn get_random_keypair() -> (SecretKey, PublicKey) { + let secp = Secp256k1::new(); + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + let secret_key = SecretKey::from_slice(&bytes).expect("Failed to create secret key"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + (secret_key, public_key) + } + + /// Helper function to create simulated channels for testing + fn create_simulated_channels(num_channels: usize, capacity_msat: u64) -> Vec { + let mut channels = Vec::new(); + for i in 0..num_channels { + let (_node1_sk, node1_pubkey) = get_random_keypair(); + let (_node2_sk, node2_pubkey) = get_random_keypair(); + + let channel = SimulatedChannel::new( + capacity_msat, + ShortChannelID::from(i as u64), + ChannelPolicy { + pubkey: node1_pubkey, + max_htlc_count: 483, + max_in_flight_msat: capacity_msat / 2, + min_htlc_size_msat: 1000, + max_htlc_size_msat: capacity_msat / 2, + cltv_expiry_delta: 144, + base_fee: 1000, + fee_rate_prop: 100, + }, + ChannelPolicy { + pubkey: node2_pubkey, + max_htlc_count: 483, + max_in_flight_msat: capacity_msat / 2, + min_htlc_size_msat: 1000, + max_htlc_size_msat: capacity_msat / 2, + cltv_expiry_delta: 144, + base_fee: 1000, + fee_rate_prop: 100, + }, + ); + channels.push(channel); + } + channels + } + + /// A pathfinder that always fails to find a path + #[derive(Clone)] + pub struct AlwaysFailPathFinder; + + impl<'a> PathFinder<'a> for AlwaysFailPathFinder { + fn find_route( + &self, + _source: &PublicKey, + _dest: PublicKey, + _amount_msat: u64, + _pathfinding_graph: &NetworkGraph<&'a WrappedLog>, + _scorer: &ProbabilisticScorer>, &'a WrappedLog>, + ) -> Result { + Err(SimulationError::SimulatedNetworkError( + "No route found".to_string(), + )) + } + } + + /// A pathfinder that only returns single-hop paths + #[derive(Clone)] + pub struct SingleHopOnlyPathFinder; + + impl<'a> PathFinder<'a> for SingleHopOnlyPathFinder { + fn find_route( + &self, + source: &PublicKey, + dest: PublicKey, + amount_msat: u64, + pathfinding_graph: &NetworkGraph<&'a WrappedLog>, + scorer: &ProbabilisticScorer>, &'a WrappedLog>, + ) -> Result { + // Try to find a direct route only (single hop) + let route_params = RouteParameters { + payment_params: PaymentParameters::from_node_id(dest, 0) + .with_max_total_cltv_expiry_delta(u32::MAX) + .with_max_path_count(1) + .with_max_channel_saturation_power_of_half(1), + final_value_msat: amount_msat, + max_total_routing_fee_msat: None, + }; + + // Try to find a route - if it fails or has more than one hop, return an error + match find_route( + source, + &route_params, + pathfinding_graph, + None, + &WrappedLog {}, + scorer, + &Default::default(), + &[0; 32], + ) { + Ok(route) => { + // Check if the route has exactly one hop + if route.paths.len() == 1 && route.paths[0].hops.len() == 1 { + Ok(route) + } else { + Err(SimulationError::SimulatedNetworkError( + "No direct route found".to_string(), + )) + } + }, + Err(e) => Err(SimulationError::SimulatedNetworkError(e.err)), + } + } + } + + #[tokio::test] + async fn test_always_fail_pathfinder() { + let channels = create_simulated_channels(3, 1_000_000_000); + let routing_graph = + Arc::new(populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap()); + + let pathfinder = AlwaysFailPathFinder; + let source = channels[0].get_node_1_pubkey(); + let dest = channels[2].get_node_2_pubkey(); + + let scorer = ProbabilisticScorer::new( + ProbabilisticScoringDecayParameters::default(), + routing_graph.clone(), + &WrappedLog {}, + ); + + let result = pathfinder.find_route(&source, dest, 100_000, &routing_graph, &scorer); + + // Should always fail + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_single_hop_only_pathfinder() { + let channels = create_simulated_channels(3, 1_000_000_000); + let routing_graph = + Arc::new(populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap()); + + let pathfinder = SingleHopOnlyPathFinder; + let source = channels[0].get_node_1_pubkey(); + + let scorer = ProbabilisticScorer::new( + ProbabilisticScoringDecayParameters::default(), + routing_graph.clone(), + &WrappedLog {}, + ); + + // Test direct connection (should work) + let direct_dest = channels[0].get_node_2_pubkey(); + let result = pathfinder.find_route(&source, direct_dest, 100_000, &routing_graph, &scorer); + + if result.is_ok() { + let route = result.unwrap(); + assert_eq!(route.paths[0].hops.len(), 1); // Only one hop + } + + // Test indirect connection (should fail) + let indirect_dest = channels[2].get_node_2_pubkey(); + let _result = + pathfinder.find_route(&source, indirect_dest, 100_000, &routing_graph, &scorer); + + // May fail because no direct route exists + // (depends on your test network topology) + } + + /// Test that different pathfinders produce different behavior in payments + #[tokio::test] + async fn test_pathfinder_affects_payment_behavior() { + let channels = create_simulated_channels(3, 1_000_000_000); + let (shutdown_trigger, shutdown_listener) = triggered::trigger(); + let sim_graph = Arc::new(Mutex::new( + SimGraph::new( + channels.clone(), + TaskTracker::new(), + Vec::new(), + HashMap::new(), // Empty custom records + (shutdown_trigger.clone(), shutdown_listener.clone()), + ) + .unwrap(), + )); + let routing_graph = + Arc::new(populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap()); + + // Create nodes with different pathfinders + let nodes_default = ln_node_from_graph( + sim_graph.clone(), + routing_graph.clone(), + simln_lib::sim_node::DefaultPathFinder, + ) + .await; + + let nodes_fail = ln_node_from_graph( + sim_graph.clone(), + routing_graph.clone(), + AlwaysFailPathFinder, + ) + .await; + + // Both should create the same structure + assert_eq!(nodes_default.len(), nodes_fail.len()); + } +} diff --git a/simln-lib/src/sim_node.rs b/simln-lib/src/sim_node.rs index 694b43ba..6860c55f 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -334,6 +334,16 @@ impl SimulatedChannel { } } + /// Gets the public key of node 1 in the channel. + pub fn get_node_1_pubkey(&self) -> PublicKey { + self.node_1.policy.pubkey + } + + /// Gets the public key of node 2 in the channel. + pub fn get_node_2_pubkey(&self) -> PublicKey { + self.node_2.policy.pubkey + } + /// Validates that a simulated channel has distinct node pairs and valid routing policies. fn validate(&self) -> Result<(), SimulationError> { if self.node_1.policy.pubkey == self.node_2.policy.pubkey { @@ -705,12 +715,10 @@ impl<'a, T: SimNetwork, P: PathFinder<'a>> LightningNode for SimNode<'a, T, P> { } // Dispatch the payment through the network - self.network.lock().await.dispatch_payment( - self.info.pubkey, - route, - payment_hash, - sender, - ); + self.network + .lock() + .await + .dispatch_payment(self.info.pubkey, route, payment_hash, sender); Ok(payment_hash) } @@ -1054,12 +1062,12 @@ pub async fn ln_node_from_graph

( graph: Arc>, routing_graph: Arc>, pathfinder: P, -) -> HashMap>> +) -> HashMap>> where P: for<'a> PathFinder<'a> + Clone + 'static, { let mut nodes: HashMap>> = HashMap::new(); - + for pk in graph.lock().await.nodes.keys() { nodes.insert( *pk, @@ -2128,13 +2136,10 @@ mod tests { dest: PublicKey, amt: u64, ) -> (Route, Result) { - let route = self.pathfinder.find_route( - &source, - dest, - amt, - &self.routing_graph, - &self.scorer, - ).unwrap(); + let route = self + .pathfinder + .find_route(&source, dest, amt, &self.routing_graph, &self.scorer) + .unwrap(); let (sender, receiver) = oneshot::channel(); self.graph diff --git a/simln-lib/src/test_utils.rs b/simln-lib/src/test_utils.rs index c0aa8c37..e653d312 100644 --- a/simln-lib/src/test_utils.rs +++ b/simln-lib/src/test_utils.rs @@ -224,3 +224,35 @@ pub fn create_activity( amount_msat: ValueOrRange::Value(amount_msat), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_activity() { + let (_source_sk, source_pk) = get_random_keypair(); + let (_dest_sk, dest_pk) = get_random_keypair(); + + let source_info = NodeInfo { + pubkey: source_pk, + alias: "source".to_string(), + features: Features::empty(), + }; + + let dest_info = NodeInfo { + pubkey: dest_pk, + alias: "destination".to_string(), + features: Features::empty(), + }; + + let activity = create_activity(source_info.clone(), dest_info.clone(), 1000); + + assert_eq!(activity.source.pubkey, source_info.pubkey); + assert_eq!(activity.destination.pubkey, dest_info.pubkey); + match activity.amount_msat { + ValueOrRange::Value(amount) => assert_eq!(amount, 1000), + ValueOrRange::Range(_, _) => panic!("Expected Value variant, got Range"), + } + } +} From aaef04db3f5a6b2d00ff59a83c0356e02740318e Mon Sep 17 00:00:00 2001 From: sangbida Date: Sat, 31 May 2025 19:13:04 +1000 Subject: [PATCH 3/6] Fix formatting --- sim-cli/src/parsing.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index d7513dd6..95e308ce 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -5,8 +5,8 @@ use log::LevelFilter; use serde::{Deserialize, Serialize}; use simln_lib::clock::SimulationClock; use simln_lib::sim_node::{ - ln_node_from_graph, populate_network_graph, ChannelPolicy, CustomRecords, Interceptor, PathFinder, - SimGraph, SimulatedChannel, + ln_node_from_graph, populate_network_graph, ChannelPolicy, CustomRecords, Interceptor, + PathFinder, SimGraph, SimulatedChannel, }; use simln_lib::{ From 3e726584eb9c8d3c085dbe1a524d252048c23d0b Mon Sep 17 00:00:00 2001 From: sangbida Date: Mon, 9 Jun 2025 16:48:29 +1000 Subject: [PATCH 4/6] Remove LDK specific scoring from PathFinder trait --- sim-cli/src/parsing.rs | 85 ++++++++++++++------------------ simln-lib/src/sim_node.rs | 101 +++++++++++++++++++------------------- 2 files changed, 87 insertions(+), 99 deletions(-) diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index 95e308ce..c518441c 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -225,7 +225,7 @@ struct NodeMapping { alias_node_map: HashMap, } -pub async fn create_simulation_with_network PathFinder<'a> + Clone + 'static>( +pub async fn create_simulation_with_network( cli: &Cli, sim_params: &SimParams, tasks: TaskTracker, @@ -278,7 +278,6 @@ pub async fn create_simulation_with_network PathFinder<'a> + Clone + .map_err(|e| SimulationError::SimulatedNetworkError(format!("{:?}", e)))?, ); - // Pass the pathfinder to ln_node_from_graph let nodes = ln_node_from_graph(simulation_graph.clone(), routing_graph, pathfinder).await; let validated_activities = get_validated_activities(&nodes, nodes_info, sim_params.activity.clone()).await?; @@ -621,7 +620,7 @@ mod tests { (secret_key, public_key) } - /// Helper function to create simulated channels for testing + /// Helper function to create simulated channels for testing. fn create_simulated_channels(num_channels: usize, capacity_msat: u64) -> Vec { let mut channels = Vec::new(); for i in 0..num_channels { @@ -657,18 +656,17 @@ mod tests { channels } - /// A pathfinder that always fails to find a path + /// A pathfinder that always fails to find a path. #[derive(Clone)] pub struct AlwaysFailPathFinder; - impl<'a> PathFinder<'a> for AlwaysFailPathFinder { + impl PathFinder for AlwaysFailPathFinder { fn find_route( &self, _source: &PublicKey, _dest: PublicKey, _amount_msat: u64, - _pathfinding_graph: &NetworkGraph<&'a WrappedLog>, - _scorer: &ProbabilisticScorer>, &'a WrappedLog>, + _pathfinding_graph: &NetworkGraph<&'static WrappedLog>, ) -> Result { Err(SimulationError::SimulatedNetworkError( "No route found".to_string(), @@ -676,47 +674,49 @@ mod tests { } } - /// A pathfinder that only returns single-hop paths + /// A pathfinder that only returns single-hop paths. #[derive(Clone)] pub struct SingleHopOnlyPathFinder; - impl<'a> PathFinder<'a> for SingleHopOnlyPathFinder { + impl PathFinder for SingleHopOnlyPathFinder { fn find_route( &self, source: &PublicKey, dest: PublicKey, amount_msat: u64, - pathfinding_graph: &NetworkGraph<&'a WrappedLog>, - scorer: &ProbabilisticScorer>, &'a WrappedLog>, + pathfinding_graph: &NetworkGraph<&'static WrappedLog>, ) -> Result { - // Try to find a direct route only (single hop) - let route_params = RouteParameters { - payment_params: PaymentParameters::from_node_id(dest, 0) - .with_max_total_cltv_expiry_delta(u32::MAX) - .with_max_path_count(1) - .with_max_channel_saturation_power_of_half(1), - final_value_msat: amount_msat, - max_total_routing_fee_msat: None, - }; - - // Try to find a route - if it fails or has more than one hop, return an error + let scorer = ProbabilisticScorer::new( + ProbabilisticScoringDecayParameters::default(), + pathfinding_graph, + &WrappedLog {}, + ); + + // Try to find a route - if it fails or has more than one hop, return an error. match find_route( source, - &route_params, + &RouteParameters { + payment_params: PaymentParameters::from_node_id(dest, 0) + .with_max_total_cltv_expiry_delta(u32::MAX) + .with_max_path_count(1) + .with_max_channel_saturation_power_of_half(1), + final_value_msat: amount_msat, + max_total_routing_fee_msat: None, + }, pathfinding_graph, None, &WrappedLog {}, - scorer, + &scorer, &Default::default(), &[0; 32], ) { Ok(route) => { - // Check if the route has exactly one hop + // Only allow single-hop routes. if route.paths.len() == 1 && route.paths[0].hops.len() == 1 { Ok(route) } else { Err(SimulationError::SimulatedNetworkError( - "No direct route found".to_string(), + "Only single-hop routes allowed".to_string(), )) } }, @@ -735,15 +735,9 @@ mod tests { let source = channels[0].get_node_1_pubkey(); let dest = channels[2].get_node_2_pubkey(); - let scorer = ProbabilisticScorer::new( - ProbabilisticScoringDecayParameters::default(), - routing_graph.clone(), - &WrappedLog {}, - ); - - let result = pathfinder.find_route(&source, dest, 100_000, &routing_graph, &scorer); + let result = pathfinder.find_route(&source, dest, 100_000, &routing_graph); - // Should always fail + // Should always fail. assert!(result.is_err()); } @@ -756,31 +750,24 @@ mod tests { let pathfinder = SingleHopOnlyPathFinder; let source = channels[0].get_node_1_pubkey(); - let scorer = ProbabilisticScorer::new( - ProbabilisticScoringDecayParameters::default(), - routing_graph.clone(), - &WrappedLog {}, - ); - - // Test direct connection (should work) + // Test direct connection (should work). let direct_dest = channels[0].get_node_2_pubkey(); - let result = pathfinder.find_route(&source, direct_dest, 100_000, &routing_graph, &scorer); + let result = pathfinder.find_route(&source, direct_dest, 100_000, &routing_graph); if result.is_ok() { let route = result.unwrap(); assert_eq!(route.paths[0].hops.len(), 1); // Only one hop } - // Test indirect connection (should fail) + // Test indirect connection (should fail). let indirect_dest = channels[2].get_node_2_pubkey(); - let _result = - pathfinder.find_route(&source, indirect_dest, 100_000, &routing_graph, &scorer); + let _result = pathfinder.find_route(&source, indirect_dest, 100_000, &routing_graph); - // May fail because no direct route exists + // May fail because no direct route exists. // (depends on your test network topology) } - /// Test that different pathfinders produce different behavior in payments + /// Test that different pathfinders produce different behavior in payments. #[tokio::test] async fn test_pathfinder_affects_payment_behavior() { let channels = create_simulated_channels(3, 1_000_000_000); @@ -798,7 +785,7 @@ mod tests { let routing_graph = Arc::new(populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap()); - // Create nodes with different pathfinders + // Create nodes with different pathfinders. let nodes_default = ln_node_from_graph( sim_graph.clone(), routing_graph.clone(), @@ -813,7 +800,7 @@ mod tests { ) .await; - // Both should create the same structure + // Both should create the same structure. assert_eq!(nodes_default.len(), nodes_fail.len()); } } diff --git a/simln-lib/src/sim_node.rs b/simln-lib/src/sim_node.rs index 6860c55f..fca5c46c 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -495,30 +495,53 @@ pub trait SimNetwork: Send + Sync { } /// A trait for custom pathfinding implementations. -pub trait PathFinder<'a>: Send + Sync { +/// Finds a route from the source node to the destination node for the specified amount. +/// +/// # Arguments +/// * `source` - The public key of the node initiating the payment. +/// * `dest` - The public key of the destination node to receive the payment. +/// * `amount_msat` - The amount to send in millisatoshis. +/// * `pathfinding_graph` - The network graph containing channel topology and routing information. +/// +/// # Returns +/// Returns a `Route` containing the payment path, or a `SimulationError` if no route is found. + +pub trait PathFinder: Send + Sync + Clone { fn find_route( &self, source: &PublicKey, dest: PublicKey, amount_msat: u64, - pathfinding_graph: &NetworkGraph<&'a WrappedLog>, - scorer: &ProbabilisticScorer>, &'a WrappedLog>, + pathfinding_graph: &NetworkGraph<&'static WrappedLog>, ) -> Result; } -/// Default pathfinder that uses LDK's pathfinding algorithm. +/// The default pathfinding implementation that uses LDK's built-in pathfinding algorithm. #[derive(Clone)] pub struct DefaultPathFinder; -impl<'a> PathFinder<'a> for DefaultPathFinder { +impl DefaultPathFinder { + pub fn new() -> Self { + Self + } +} + +impl PathFinder for DefaultPathFinder { fn find_route( &self, source: &PublicKey, dest: PublicKey, amount_msat: u64, - pathfinding_graph: &NetworkGraph<&'a WrappedLog>, - scorer: &ProbabilisticScorer>, &'a WrappedLog>, + pathfinding_graph: &NetworkGraph<&'static WrappedLog>, ) -> Result { + let scorer_graph = NetworkGraph::new(bitcoin::Network::Regtest, &WrappedLog {}); + let scorer = ProbabilisticScorer::new( + ProbabilisticScoringDecayParameters::default(), + Arc::new(scorer_graph), + &WrappedLog {}, + ); + + // Call LDK's find_route with the scorer (LDK-specific requirement) find_route( source, &RouteParameters { @@ -529,10 +552,10 @@ impl<'a> PathFinder<'a> for DefaultPathFinder { final_value_msat: amount_msat, max_total_routing_fee_msat: None, }, - pathfinding_graph, + pathfinding_graph, // This is the real network graph used for pathfinding None, &WrappedLog {}, - scorer, + &scorer, // LDK requires a scorer, so we provide a simple one &Default::default(), &[0; 32], ) @@ -551,38 +574,25 @@ pub struct SimNode<'a, T: SimNetwork, P: PathFinder<'a> = DefaultPathFinder> { /// Tracks the channel that will provide updates for payments by hash. in_flight: HashMap>>, /// A read-only graph used for pathfinding. - pathfinding_graph: Arc>, - /// Probabilistic scorer used to rank paths through the network for routing. This is reused across - /// multiple payments to maintain scoring state. - scorer: ProbabilisticScorer>, &'a WrappedLog>, + pathfinding_graph: Arc>, /// The pathfinder implementation to use for finding routes pathfinder: P, } -impl<'a, T: SimNetwork, P: PathFinder<'a>> SimNode<'a, T, P> { +impl SimNode { /// Creates a new simulation node that refers to the high level network coordinator provided to process payments /// on its behalf. The pathfinding graph is provided separately so that each node can handle its own pathfinding. pub fn new( pubkey: PublicKey, payment_network: Arc>, - pathfinding_graph: Arc>, + pathfinding_graph: Arc>, pathfinder: P, ) -> Self { - // Initialize the probabilistic scorer with default parameters for learning from payment - // history. These parameters control how much successful/failed payments affect routing - // scores and how quickly these scores decay over time. - let scorer = ProbabilisticScorer::new( - ProbabilisticScoringDecayParameters::default(), - pathfinding_graph.clone(), - &WrappedLog {}, - ); - SimNode { info: node_info(pubkey), network: payment_network, in_flight: HashMap::new(), pathfinding_graph, - scorer, pathfinder, } } @@ -664,7 +674,7 @@ fn node_info(pubkey: PublicKey) -> NodeInfo { } #[async_trait] -impl<'a, T: SimNetwork, P: PathFinder<'a>> LightningNode for SimNode<'a, T, P> { +impl LightningNode for SimNode { fn get_info(&self) -> &NodeInfo { &self.info } @@ -686,7 +696,6 @@ impl<'a, T: SimNetwork, P: PathFinder<'a>> LightningNode for SimNode<'a, T, P> { dest, amount_msat, &self.pathfinding_graph, - &self.scorer, ) { Ok(route) => route, Err(e) => { @@ -1064,7 +1073,7 @@ pub async fn ln_node_from_graph

( pathfinder: P, ) -> HashMap>> where - P: for<'a> PathFinder<'a> + Clone + 'static, + P: PathFinder + 'static, { let mut nodes: HashMap>> = HashMap::new(); @@ -1563,7 +1572,6 @@ mod tests { use mockall::mock; use ntest::assert_true; use std::time::Duration; - use tokio::sync::oneshot; use tokio::time::{self, timeout}; /// Creates a test channel policy with its maximum HTLC size set to half of the in flight limit of the channel. @@ -1953,7 +1961,12 @@ mod tests { // Create a simulated node for the first channel in our network. let pk = channels[0].node_1.policy.pubkey; - let mut node = SimNode::new(pk, sim_network.clone(), Arc::new(graph), DefaultPathFinder); + let mut node = SimNode::new( + pk, + sim_network.clone(), + Arc::new(graph), + DefaultPathFinder::new(), + ); // Prime mock to return node info from lookup and assert that we get the pubkey we're expecting. let lookup_pk = channels[3].node_1.policy.pubkey; @@ -2038,16 +2051,15 @@ mod tests { } /// Contains elements required to test dispatch_payment functionality. - struct DispatchPaymentTestKit<'a> { + struct DispatchPaymentTestKit { graph: SimGraph, nodes: Vec, - routing_graph: Arc>, - scorer: ProbabilisticScorer>, &'a WrappedLog>, + routing_graph: Arc>, shutdown: (Trigger, Listener), pathfinder: DefaultPathFinder, } - impl DispatchPaymentTestKit<'_> { + impl DispatchPaymentTestKit { /// Creates a test graph with a set of nodes connected by three channels, with all the capacity of the channel /// on the side of the first node. For example, if called with capacity = 100 it will set up the following /// network: @@ -2065,12 +2077,6 @@ mod tests { populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap(), ); - let scorer = ProbabilisticScorer::new( - ProbabilisticScoringDecayParameters::default(), - routing_graph.clone(), - &WrappedLog {}, - ); - // Collect pubkeys in-order, pushing the last node on separately because they don't have an outgoing // channel (they are not node_1 in any channel, only node_2). let mut nodes = channels @@ -2091,9 +2097,8 @@ mod tests { .expect("could not create test graph"), nodes, routing_graph, - scorer, shutdown: shutdown_clone, - pathfinder: DefaultPathFinder, + pathfinder: DefaultPathFinder::new(), }; // Assert that our channel balance is all on the side of the channel opener when we start up. @@ -2138,18 +2143,14 @@ mod tests { ) -> (Route, Result) { let route = self .pathfinder - .find_route(&source, dest, amt, &self.routing_graph, &self.scorer) + .find_route(&source, dest, amt, &self.routing_graph) .unwrap(); + let (sender, receiver) = tokio::sync::oneshot::channel(); - let (sender, receiver) = oneshot::channel(); self.graph - .dispatch_payment(source, route.clone(), PaymentHash([1; 32]), sender); - - let payment_result = timeout(Duration::from_millis(10), receiver).await; - // Assert that we receive from the channel or fail. - assert!(payment_result.is_ok()); + .dispatch_payment(source, route.clone(), PaymentHash([0; 32]), sender); - (route, payment_result.unwrap().unwrap()) + (route, receiver.await.unwrap()) } // Sets the balance on the channel to the tuple provided, used to arrange liquidity for testing. From b7416fea39c4d0d0c998c74cad0025b7fac4818e Mon Sep 17 00:00:00 2001 From: sangbida Date: Mon, 9 Jun 2025 17:19:35 +1000 Subject: [PATCH 5/6] Fixup rebase --- simln-lib/src/sim_node.rs | 39 ++++----------------------------------- 1 file changed, 4 insertions(+), 35 deletions(-) diff --git a/simln-lib/src/sim_node.rs b/simln-lib/src/sim_node.rs index fca5c46c..0f8d0183 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -496,16 +496,15 @@ pub trait SimNetwork: Send + Sync { /// A trait for custom pathfinding implementations. /// Finds a route from the source node to the destination node for the specified amount. -/// +/// /// # Arguments /// * `source` - The public key of the node initiating the payment. /// * `dest` - The public key of the destination node to receive the payment. /// * `amount_msat` - The amount to send in millisatoshis. /// * `pathfinding_graph` - The network graph containing channel topology and routing information. -/// +/// /// # Returns /// Returns a `Route` containing the payment path, or a `SimulationError` if no route is found. - pub trait PathFinder: Send + Sync + Clone { fn find_route( &self, @@ -567,7 +566,7 @@ impl PathFinder for DefaultPathFinder { /// all functionality through to a coordinating simulation network. This implementation contains both the [`SimNetwork`] /// implementation that will allow us to dispatch payments and a read-only NetworkGraph that is used for pathfinding. /// While these two could be combined, we re-use the LDK-native struct to allow re-use of their pathfinding logic. -pub struct SimNode<'a, T: SimNetwork, P: PathFinder<'a> = DefaultPathFinder> { +pub struct SimNode { info: NodeInfo, /// The underlying execution network that will be responsible for dispatching payments. network: Arc>, @@ -627,37 +626,6 @@ impl SimNode { Ok(()) } - - /// Dispatches a payment to a specified route. - /// The [`lightning::routing::router::build_route_from_hops`] function can be used to build the route to be passed here. - /// - /// **Note:** The payment hash passed in here should be used in track_payment to track the payment outcome. - pub async fn send_to_route( - &mut self, - route: Route, - payment_hash: PaymentHash, - ) -> Result<(), LightningError> { - let (sender, receiver) = channel(); - - // Check for payment hash collision, failing the payment if we happen to repeat one. - match self.in_flight.entry(payment_hash) { - Entry::Occupied(_) => { - return Err(LightningError::SendPaymentError( - "payment hash exists".to_string(), - )); - }, - Entry::Vacant(vacant) => { - vacant.insert(receiver); - }, - } - - self.network - .lock() - .await - .dispatch_payment(self.info.pubkey, route, payment_hash, sender); - - Ok(()) - } } /// Produces the node info for a mocked node, filling in the features that the simulator requires. @@ -2348,6 +2316,7 @@ mod tests { test_kit.nodes[0], Arc::new(Mutex::new(test_kit.graph)), test_kit.routing_graph.clone(), + test_kit.pathfinder.clone(), ); let route = build_route_from_hops( From 02f00c218fb47e2cfeab20e7d01f4ae4f5653919 Mon Sep 17 00:00:00 2001 From: sangbida Date: Mon, 9 Jun 2025 17:34:22 +1000 Subject: [PATCH 6/6] Add a default implementation for DefaultPathFinder --- simln-lib/src/sim_node.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/simln-lib/src/sim_node.rs b/simln-lib/src/sim_node.rs index 0f8d0183..3c4d83f9 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -519,6 +519,12 @@ pub trait PathFinder: Send + Sync + Clone { #[derive(Clone)] pub struct DefaultPathFinder; +impl Default for DefaultPathFinder { + fn default() -> Self { + Self::new() + } +} + impl DefaultPathFinder { pub fn new() -> Self { Self