From 4a74f9f77a7316add5cda9176e12d62b5de10961 Mon Sep 17 00:00:00 2001 From: sangbida Date: Mon, 26 May 2025 15:33:56 +1000 Subject: [PATCH 1/4] simln-lib/feat: Add Pathfinder trait --- sim-cli/src/main.rs | 9 +- sim-cli/src/parsing.rs | 9 +- simln-lib/src/sim_node.rs | 192 +++++++++++++++++++++----------------- 3 files changed, 120 insertions(+), 90 deletions(-) diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs index e6c7cf9d..1301c37f 100755 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -12,6 +12,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. @@ -33,6 +36,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? @@ -51,6 +57,7 @@ async fn main() -> anyhow::Result<()> { clock, tasks.clone(), interceptors, + pathfinder, CustomRecords::default(), ) .await?; @@ -66,4 +73,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 523cf0d3..38cd654e 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -6,8 +6,9 @@ use serde::{Deserialize, Serialize}; use simln_lib::clock::SimulationClock; use simln_lib::sim_node::{ ln_node_from_graph, populate_network_graph, ChannelPolicy, CustomRecords, Interceptor, - SimGraph, SimNode, SimulatedChannel, + PathFinder, SimGraph, SimulatedChannel, }; + use simln_lib::{ cln, cln::ClnNode, eclair, eclair::EclairNode, lnd, lnd::LndNode, serializers, ActivityDefinition, Amount, Interval, LightningError, LightningNode, NodeId, NodeInfo, @@ -258,6 +259,7 @@ pub async fn create_simulation_with_network( tasks: TaskTracker, interceptors: Vec>, custom_records: CustomRecords, + pathfinder: P, ) -> Result< ( Simulation, @@ -308,12 +310,11 @@ pub async fn create_simulation_with_network( populate_network_graph(channels, clock.clone()) .map_err(|e| SimulationError::SimulatedNetworkError(format!("{:?}", e)))?, ); - // We want the full set of nodes in our graph to return to the caller so that they can take // custom actions on the simulated network. For the nodes we'll pass our simulation, cast them // to a dyn trait and exclude any nodes that shouldn't be included in random activity // generation. - let nodes = ln_node_from_graph(simulation_graph.clone(), routing_graph, clock.clone()).await?; + let nodes = ln_node_from_graph(simulation_graph.clone(), routing_graph, clock.clone(), pathfinder).await?; let mut nodes_dyn: HashMap<_, Arc>> = nodes .iter() .map(|(pk, node)| (*pk, Arc::clone(node) as Arc>)) @@ -321,7 +322,6 @@ pub async fn create_simulation_with_network( for pk in exclude { nodes_dyn.remove(pk); } - let validated_activities = get_validated_activities(&nodes_dyn, nodes_info, sim_params.activity.clone()).await?; @@ -339,6 +339,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 bf908115..b3047662 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -489,7 +489,75 @@ pub trait SimNetwork: Send + Sync { fn list_nodes(&self) -> Vec; } -type LdkNetworkGraph = NetworkGraph>; +//type LdkNetworkGraph = NetworkGraph>; +type LdkNetworkGraph = NetworkGraph<&'static WrappedLog>; +/// 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, + source: &PublicKey, + dest: PublicKey, + amount_msat: u64, + pathfinding_graph: &LdkNetworkGraph, + ) -> Result; +} + +/// The default pathfinding implementation that uses LDK's built-in pathfinding algorithm. +#[derive(Clone)] +pub struct 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<&'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 { + 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, // This is the real network graph used for pathfinding + None, + &WrappedLog {}, + &scorer, // LDK requires a scorer, so we provide a simple one + &Default::default(), + &[0; 32], + ) + .map_err(|e| SimulationError::SimulatedNetworkError(e.err)) + } +} struct InFlightPayment { /// The channel used to report payment results to. @@ -504,7 +572,7 @@ struct InFlightPayment { /// 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 { +pub struct SimNode { info: NodeInfo, /// The underlying execution network that will be responsible for dispatching payments. network: Arc>, @@ -512,14 +580,13 @@ pub struct SimNode { in_flight: Mutex>, /// 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: Mutex, Arc>>, /// Clock for tracking simulation time. clock: Arc, + /// The pathfinder implementation to use for finding routes + pathfinder: P, } -impl SimNode { +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( @@ -527,24 +594,16 @@ impl SimNode { payment_network: Arc>, pathfinding_graph: Arc, clock: Arc, - ) -> Result { - // 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(), - Arc::new(WrappedLog {}), - ); - - Ok(SimNode { + pathfinder: P, + ) -> Self { + SimNode { info, network: payment_network, in_flight: Mutex::new(HashMap::new()), pathfinding_graph, - scorer: Mutex::new(scorer), clock, - }) + pathfinder, + } } /// Dispatches a payment to a specified route. If `custom_records` is `Some`, they will be attached to the outgoing @@ -609,40 +668,8 @@ fn node_info(pubkey: PublicKey, alias: String) -> NodeInfo { } } -/// Uses LDK's pathfinding algorithm with default parameters to find a path from source to destination, with no -/// restrictions on fee budget. -async fn find_payment_route( - source: &PublicKey, - dest: PublicKey, - amount_msat: u64, - pathfinding_graph: &LdkNetworkGraph, - scorer: &Mutex, Arc>>, -) -> Result { - let scorer_guard = scorer.lock().await; - 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_guard, - &Default::default(), - &[0; 32], - ) - .map_err(|e| SimulationError::SimulatedNetworkError(e.err)) -} - #[async_trait] -impl LightningNode for SimNode { +impl LightningNode for SimNode { fn get_info(&self) -> &NodeInfo { &self.info } @@ -658,8 +685,7 @@ impl LightningNode for SimNode { 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. + // Create a channel to receive the payment result. let (sender, receiver) = channel(); let preimage = PaymentPreimage(rand::random()); let payment_hash = preimage.into(); @@ -676,15 +702,12 @@ impl LightningNode for SimNode { }; // Use the stored scorer when finding a route - let route = match find_payment_route( + let route = match self.pathfinder.find_route( &self.info.pubkey, dest, amount_msat, &self.pathfinding_graph, - &self.scorer, - ) - .await - { + ) { 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. @@ -724,7 +747,7 @@ impl LightningNode for SimNode { self.network.lock().await.dispatch_payment( self.info.pubkey, route, - None, // Default custom records. + None, payment_hash, sender, ); @@ -1088,14 +1111,18 @@ 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, clock: Arc, -) -> Result>>>, LightningError> { - let mut nodes: HashMap>>> = HashMap::new(); - - for node in graph.lock().await.nodes.iter() { + pathfinder: P, +) -> Result>>, LightningError> +where + P: PathFinder + 'static, +{ + let mut nodes: HashMap>> = HashMap::new(); + + for pk in graph.lock().await.nodes.keys() { nodes.insert( *node.0, Arc::new(Mutex::new(SimNode::new( @@ -1103,7 +1130,8 @@ pub async fn ln_node_from_graph( graph.clone(), routing_graph.clone(), clock.clone(), - )?)), + pathfinder.clone(), + ))), ); } @@ -1595,7 +1623,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. @@ -1994,6 +2021,7 @@ mod tests { sim_network.clone(), Arc::new(graph), Arc::new(SystemClock {}), + DefaultPathFinder ) .unwrap(); @@ -2079,6 +2107,7 @@ mod tests { Arc::new(Mutex::new(test_kit.graph)), test_kit.routing_graph.clone(), Arc::new(SystemClock {}), + DefaultPathFinder::new() ) .unwrap(); @@ -2147,8 +2176,8 @@ mod tests { graph: SimGraph, nodes: Vec, routing_graph: Arc, - scorer: Mutex, Arc>>, shutdown: (Trigger, Listener), + pathfinder: DefaultPathFinder, } impl DispatchPaymentTestKit { @@ -2169,12 +2198,6 @@ mod tests { populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap(), ); - let scorer = Mutex::new(ProbabilisticScorer::new( - ProbabilisticScoringDecayParameters::default(), - routing_graph.clone(), - Arc::new(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 @@ -2195,8 +2218,8 @@ mod tests { .expect("could not create test graph"), nodes, routing_graph, - scorer, shutdown: shutdown_clone, + pathfinder: DefaultPathFinder::new(), }; // Assert that our channel balance is all on the side of the channel opener when we start up. @@ -2239,19 +2262,17 @@ mod tests { dest: PublicKey, amt: u64, ) -> (Route, Result) { - let route = find_payment_route(&source, dest, amt, &self.routing_graph, &self.scorer) - .await - .unwrap(); + let route = self.pathfinder.find_route( + &source, + dest, + amt, + &self.routing_graph, + ).unwrap(); - let (sender, receiver) = oneshot::channel(); self.graph - .dispatch_payment(source, route.clone(), None, 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(), None, 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. @@ -2450,6 +2471,7 @@ mod tests { Arc::new(Mutex::new(test_kit.graph)), test_kit.routing_graph.clone(), Arc::new(SystemClock {}), + DefaultPathFinder::new(), ) .unwrap(); From 8c438043738f874f49836d77530d85ca6b4fecca Mon Sep 17 00:00:00 2001 From: sangbida Date: Sat, 31 May 2025 18:57:30 +1000 Subject: [PATCH 2/4] simln-lib/test: Add tests for custom pathfinding --- Cargo.lock | 1 + sim-cli/Cargo.toml | 1 + sim-cli/src/main.rs | 6 +- sim-cli/src/parsing.rs | 230 +++++++++++++++++++++++++++++++++++- simln-lib/src/sim_node.rs | 12 +- simln-lib/src/test_utils.rs | 32 +++++ 6 files changed, 277 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1c67002..de6e8bee 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 1301c37f..111877a4 100755 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -36,7 +36,7 @@ async fn main() -> anyhow::Result<()> { cli.validate(&sim_params)?; let tasks = TaskTracker::new(); - + // Create the pathfinder instance let pathfinder = DefaultPathFinder; @@ -57,8 +57,8 @@ async fn main() -> anyhow::Result<()> { clock, tasks.clone(), interceptors, - pathfinder, CustomRecords::default(), + pathfinder, ) .await?; (sim, validated_activities) @@ -73,4 +73,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 38cd654e..f3b43fe1 100755 --- a/sim-cli/src/parsing.rs +++ b/sim-cli/src/parsing.rs @@ -339,7 +339,6 @@ 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( @@ -637,3 +636,232 @@ 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, + alias: "".to_string(), + 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, + alias: "".to_string(), + 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,); + + // 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,); + + 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,); + + // 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(), + SystemClock {}, + simln_lib::sim_node::DefaultPathFinder, + ) + .await; + + let nodes_fail = ln_node_from_graph( + sim_graph.clone(), + routing_graph.clone(), + SystemClock {}, + 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 b3047662..ec3928d4 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -338,6 +338,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 { @@ -1121,7 +1131,7 @@ where P: PathFinder + 'static, { let mut nodes: HashMap>> = HashMap::new(); - + for pk in graph.lock().await.nodes.keys() { nodes.insert( *node.0, diff --git a/simln-lib/src/test_utils.rs b/simln-lib/src/test_utils.rs index 398dd53e..a19f494e 100644 --- a/simln-lib/src/test_utils.rs +++ b/simln-lib/src/test_utils.rs @@ -250,3 +250,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 e5b94fa5cb2ba709b7d060e4b56fe24be01ebeac Mon Sep 17 00:00:00 2001 From: sangbida Date: Mon, 9 Jun 2025 17:34:22 +1000 Subject: [PATCH 3/4] sim_node/feat: 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 ec3928d4..a59ec05a 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -526,6 +526,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 From 04842b18385aa13883219eb61e59c1e85e65dc99 Mon Sep 17 00:00:00 2001 From: Chuks Agbakuru Date: Wed, 10 Sep 2025 17:09:42 +0100 Subject: [PATCH 4/4] sim_node/feat: Implement Scoring for DefaultPathfinder --- sim-cli/src/main.rs | 7 - sim-cli/src/parsing.rs | 248 ++------------------------- simln-lib/src/sim_node.rs | 343 ++++++++++++++++++++++++++++++++------ 3 files changed, 302 insertions(+), 296 deletions(-) diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs index 111877a4..e6c7cf9d 100755 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -12,9 +12,6 @@ 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. @@ -37,9 +34,6 @@ async fn main() -> anyhow::Result<()> { 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? } else { @@ -58,7 +52,6 @@ async fn main() -> anyhow::Result<()> { tasks.clone(), interceptors, CustomRecords::default(), - pathfinder, ) .await?; (sim, validated_activities) diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index f3b43fe1..de7b1a76 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, DefaultPathFinder, + Interceptor, SimGraph, SimulatedChannel, }; use simln_lib::{ @@ -259,12 +259,11 @@ pub async fn create_simulation_with_network( tasks: TaskTracker, interceptors: Vec>, custom_records: CustomRecords, - pathfinder: P, ) -> Result< ( Simulation, Vec, - HashMap>>>, + HashMap>>, ), anyhow::Error, > { @@ -310,11 +309,21 @@ pub async fn create_simulation_with_network( populate_network_graph(channels, clock.clone()) .map_err(|e| SimulationError::SimulatedNetworkError(format!("{:?}", e)))?, ); + + // Create the pathfinder instance + let pathfinder = DefaultPathFinder::new(routing_graph.clone()); + // We want the full set of nodes in our graph to return to the caller so that they can take // custom actions on the simulated network. For the nodes we'll pass our simulation, cast them // to a dyn trait and exclude any nodes that shouldn't be included in random activity // generation. - let nodes = ln_node_from_graph(simulation_graph.clone(), routing_graph, clock.clone(), pathfinder).await?; + let nodes = ln_node_from_graph( + simulation_graph.clone(), + routing_graph, + clock.clone(), + pathfinder, + ) + .await?; let mut nodes_dyn: HashMap<_, Arc>> = nodes .iter() .map(|(pk, node)| (*pk, Arc::clone(node) as Arc>)) @@ -636,232 +645,3 @@ 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, - alias: "".to_string(), - 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, - alias: "".to_string(), - 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,); - - // 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,); - - 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,); - - // 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(), - SystemClock {}, - simln_lib::sim_node::DefaultPathFinder, - ) - .await; - - let nodes_fail = ln_node_from_graph( - sim_graph.clone(), - routing_graph.clone(), - SystemClock {}, - 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 a59ec05a..930b2be8 100755 --- a/simln-lib/src/sim_node.rs +++ b/simln-lib/src/sim_node.rs @@ -13,6 +13,7 @@ use std::fmt::Display; use std::sync::Arc; use std::time::UNIX_EPOCH; use tokio::task::JoinSet; +use tokio::time::Duration; use tokio_util::task::TaskTracker; use lightning::ln::features::{ChannelFeatures, NodeFeatures}; @@ -499,8 +500,7 @@ pub trait SimNetwork: Send + Sync { fn list_nodes(&self) -> Vec; } -//type LdkNetworkGraph = NetworkGraph>; -type LdkNetworkGraph = NetworkGraph<&'static WrappedLog>; +type LdkNetworkGraph = NetworkGraph>; /// A trait for custom pathfinding implementations. /// Finds a route from the source node to the destination node for the specified amount. /// @@ -512,47 +512,60 @@ type LdkNetworkGraph = NetworkGraph<&'static WrappedLog>; /// /// # Returns /// Returns a `Route` containing the payment path, or a `SimulationError` if no route is found. +#[async_trait] pub trait PathFinder: Send + Sync + Clone { - fn find_route( + async fn find_route( &self, source: &PublicKey, dest: PublicKey, amount_msat: u64, pathfinding_graph: &LdkNetworkGraph, ) -> Result; + + async fn report_payment_success( + &self, + path: &Path, + duration_since_epoch: Duration, + ) -> Result<(), SimulationError>; + + async fn report_payment_failure( + &self, + path: &Path, + short_channel_id: u64, + duration_since_epoch: Duration, + ) -> Result<(), SimulationError>; } /// The default pathfinding implementation that uses LDK's built-in pathfinding algorithm. #[derive(Clone)] -pub struct DefaultPathFinder; - -impl Default for DefaultPathFinder { - fn default() -> Self { - Self::new() - } +pub struct DefaultPathFinder { + scorer: Arc, Arc>>>, + network_graph: Arc, } impl DefaultPathFinder { - pub fn new() -> Self { - Self + pub fn new(network_graph: Arc) -> Self { + Self { + scorer: Arc::new(Mutex::new(ProbabilisticScorer::new( + ProbabilisticScoringDecayParameters::default(), + network_graph.clone(), + Arc::new(WrappedLog {}), + ))), + network_graph, + } } } +#[async_trait] impl PathFinder for DefaultPathFinder { - fn find_route( + async fn find_route( &self, source: &PublicKey, dest: PublicKey, amount_msat: u64, - pathfinding_graph: &NetworkGraph<&'static WrappedLog>, + _pathfinding_graph: &LdkNetworkGraph, ) -> Result { - let scorer_graph = NetworkGraph::new(bitcoin::Network::Regtest, &WrappedLog {}); - let scorer = ProbabilisticScorer::new( - ProbabilisticScoringDecayParameters::default(), - Arc::new(scorer_graph), - &WrappedLog {}, - ); - + let scorer_guard = self.scorer.lock().await; // Call LDK's find_route with the scorer (LDK-specific requirement) find_route( source, @@ -564,15 +577,36 @@ impl PathFinder for DefaultPathFinder { final_value_msat: amount_msat, max_total_routing_fee_msat: None, }, - pathfinding_graph, // This is the real network graph used for pathfinding + self.network_graph.as_ref(), // This is the real network graph used for pathfinding None, &WrappedLog {}, - &scorer, // LDK requires a scorer, so we provide a simple one + &scorer_guard, // LDK requires a scorer, so we provide a simple one &Default::default(), &[0; 32], ) .map_err(|e| SimulationError::SimulatedNetworkError(e.err)) } + + async fn report_payment_success( + &self, + path: &Path, + duration_since_epoch: Duration, + ) -> Result<(), SimulationError> { + let mut scorer_guard = self.scorer.lock().await; + scorer_guard.payment_path_successful(path, duration_since_epoch); + Ok(()) + } + + async fn report_payment_failure( + &self, + path: &Path, + short_channel_id: u64, + duration_since_epoch: Duration, + ) -> Result<(), SimulationError> { + let mut scorer_guard = self.scorer.lock().await; + scorer_guard.payment_path_failed(path, short_channel_id, duration_since_epoch); + Ok(()) + } } struct InFlightPayment { @@ -717,13 +751,16 @@ impl LightningNode for SimNode Entry::Vacant(vacant) => vacant, }; - // Use the stored scorer when finding a route - let route = match self.pathfinder.find_route( - &self.info.pubkey, - dest, - amount_msat, - &self.pathfinding_graph, - ) { + let route = match self + .pathfinder + .find_route( + &self.info.pubkey, + dest, + amount_msat, + &self.pathfinding_graph, + ) + .await + { 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. @@ -801,9 +838,9 @@ impl LightningNode for SimNode match &in_flight.path { Some(path) => { if payment_result.payment_outcome == PaymentOutcome::Success { - self.scorer.lock().await.payment_path_successful(path, duration); + let _ = self.pathfinder.report_payment_success(path, duration).await; } else if let PaymentOutcome::IndexFailure(index) = payment_result.payment_outcome { - self.scorer.lock().await.payment_path_failed(path, index as u64, duration); + let _ = self.pathfinder.report_payment_failure(path, index as u64, duration).await; } }, None => { @@ -1127,18 +1164,15 @@ 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, clock: Arc, pathfinder: P, -) -> Result>>, LightningError> -where - P: PathFinder + 'static, -{ +) -> Result>>, LightningError> { let mut nodes: HashMap>> = HashMap::new(); - for pk in graph.lock().await.nodes.keys() { + for node in graph.lock().await.nodes.iter() { nodes.insert( *node.0, Arc::new(Mutex::new(SimNode::new( @@ -1632,7 +1666,7 @@ impl UtxoLookup for UtxoValidator { #[cfg(test)] mod tests { use super::*; - use crate::clock::SystemClock; + use crate::clock::{SimulationClock, SystemClock}; use crate::test_utils::get_random_keypair; use lightning::routing::router::build_route_from_hops; use lightning::routing::router::Route; @@ -2029,6 +2063,8 @@ mod tests { let sim_network = Arc::new(Mutex::new(mock)); let channels = create_simulated_channels(5, 300000000); let graph = populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap(); + let graph_for_pf = + populate_network_graph(channels.clone(), Arc::new(SystemClock {})).unwrap(); // Create a simulated node for the first channel in our network. let pk = channels[0].node_1.policy.pubkey; @@ -2037,9 +2073,8 @@ mod tests { sim_network.clone(), Arc::new(graph), Arc::new(SystemClock {}), - DefaultPathFinder - ) - .unwrap(); + DefaultPathFinder::new(graph_for_pf.into()), + ); // 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; @@ -2123,9 +2158,8 @@ mod tests { Arc::new(Mutex::new(test_kit.graph)), test_kit.routing_graph.clone(), Arc::new(SystemClock {}), - DefaultPathFinder::new() - ) - .unwrap(); + DefaultPathFinder::new(test_kit.routing_graph.clone()), + ); let route = build_route_from_hops( &test_kit.nodes[0], @@ -2233,9 +2267,9 @@ mod tests { ) .expect("could not create test graph"), nodes, - routing_graph, + routing_graph: routing_graph.clone(), shutdown: shutdown_clone, - pathfinder: DefaultPathFinder::new(), + pathfinder: DefaultPathFinder::new(routing_graph.clone()), }; // Assert that our channel balance is all on the side of the channel opener when we start up. @@ -2278,12 +2312,12 @@ mod tests { dest: PublicKey, amt: u64, ) -> (Route, Result) { - let route = self.pathfinder.find_route( - &source, - dest, - amt, - &self.routing_graph, - ).unwrap(); + let route = self + .pathfinder + .find_route(&source, dest, amt, &self.routing_graph) + .await + .unwrap(); + let (sender, receiver) = tokio::sync::oneshot::channel(); self.graph .dispatch_payment(source, route.clone(), None, PaymentHash([0; 32]), sender); @@ -2487,9 +2521,8 @@ mod tests { Arc::new(Mutex::new(test_kit.graph)), test_kit.routing_graph.clone(), Arc::new(SystemClock {}), - DefaultPathFinder::new(), - ) - .unwrap(); + test_kit.pathfinder.clone(), + ); let route = build_route_from_hops( &test_kit.nodes[0], @@ -2752,4 +2785,204 @@ mod tests { .send_test_payment(test_kit.nodes[0], test_kit.nodes[2], 150_000) .await; } + + /// A pathfinder that always fails to find a path. + #[derive(Clone)] + pub struct AlwaysFailPathFinder; + + #[async_trait] + impl PathFinder for AlwaysFailPathFinder { + async fn find_route( + &self, + _source: &PublicKey, + _dest: PublicKey, + _amount_msat: u64, + _pathfinding_graph: &LdkNetworkGraph, + ) -> Result { + Err(SimulationError::SimulatedNetworkError( + "No route found".to_string(), + )) + } + + async fn report_payment_success( + &self, + _path: &Path, + _duration_since_epoch: Duration, + ) -> Result<(), SimulationError> { + Err(SimulationError::SimulatedNetworkError( + "No scorer found".to_string(), + )) + } + + async fn report_payment_failure( + &self, + _path: &Path, + _short_channel_id: u64, + _duration_since_epoch: Duration, + ) -> Result<(), SimulationError> { + Err(SimulationError::SimulatedNetworkError( + "No scorer found".to_string(), + )) + } + } + + /// A pathfinder that only returns single-hop paths. + #[derive(Clone)] + pub struct SingleHopOnlyPathFinder; + + #[async_trait] + impl PathFinder for SingleHopOnlyPathFinder { + async fn find_route( + &self, + source: &PublicKey, + dest: PublicKey, + amount_msat: u64, + pathfinding_graph: &LdkNetworkGraph, + ) -> Result { + let scorer = ProbabilisticScorer::new( + ProbabilisticScoringDecayParameters::default(), + pathfinding_graph, + Arc::new(WrappedLog {}), + ); + + // Try to find a route - if it fails or has more than one hop, return an error. + match 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], + ) { + Ok(route) => { + // Only allow single-hop routes. + if route.paths.len() == 1 && route.paths[0].hops.len() == 1 { + Ok(route) + } else { + Err(SimulationError::SimulatedNetworkError( + "Only single-hop routes allowed".to_string(), + )) + } + }, + Err(e) => Err(SimulationError::SimulatedNetworkError(e.err)), + } + } + + async fn report_payment_success( + &self, + _path: &Path, + _duration_since_epoch: Duration, + ) -> Result<(), SimulationError> { + Err(SimulationError::SimulatedNetworkError( + "No scorer found".to_string(), + )) + } + + async fn report_payment_failure( + &self, + _path: &Path, + _short_channel_id: u64, + _duration_since_epoch: Duration, + ) -> Result<(), SimulationError> { + Err(SimulationError::SimulatedNetworkError( + "No scorer found".to_string(), + )) + } + } + + #[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 result = pathfinder + .find_route(&source, dest, 100_000, &routing_graph) + .await; + + // 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(); + + // 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) + .await; + + 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); + + // 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(), + Arc::new(SimulationClock::new(1).unwrap()), + DefaultPathFinder::new(routing_graph.clone()), + ) + .await + .unwrap(); + + let nodes_fail = ln_node_from_graph( + sim_graph.clone(), + routing_graph.clone(), + Arc::new(SimulationClock::new(1).unwrap()), + AlwaysFailPathFinder, + ) + .await + .unwrap(); + + // Both should create the same structure. + assert_eq!(nodes_default.len(), nodes_fail.len()); + } }