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 e00e2b79..ff765f8d 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. @@ -32,6 +35,9 @@ 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 { @@ -47,6 +53,7 @@ async fn main() -> anyhow::Result<()> { tasks.clone(), interceptors, CustomRecords::default(), + pathfinder, ) .await? }; diff --git a/sim-cli/src/parsing.rs b/sim-cli/src/parsing.rs index d2c012ba..c518441c 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, SimulatedChannel, + 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( 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,7 @@ 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; + 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?; @@ -589,3 +591,216 @@ 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 PathFinder for AlwaysFailPathFinder { + fn find_route( + &self, + _source: &PublicKey, + _dest: PublicKey, + _amount_msat: u64, + _pathfinding_graph: &NetworkGraph<&'static WrappedLog>, + ) -> Result { + Err(SimulationError::SimulatedNetworkError( + "No route found".to_string(), + )) + } + } + + /// A pathfinder that only returns single-hop paths. + #[derive(Clone)] + pub struct SingleHopOnlyPathFinder; + + impl PathFinder for SingleHopOnlyPathFinder { + fn find_route( + &self, + source: &PublicKey, + dest: PublicKey, + amount_msat: u64, + pathfinding_graph: &NetworkGraph<&'static WrappedLog>, + ) -> Result { + 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, + &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)), + } + } + } + + #[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); + + // 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); + + 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(), + 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 8a504d53..3c4d83f9 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 { @@ -484,46 +494,111 @@ pub trait SimNetwork: Send + Sync { fn list_nodes(&self) -> Result, LightningError>; } +/// 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: &NetworkGraph<&'static WrappedLog>, + ) -> Result; +} + +/// 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() + } +} + +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)) + } +} + /// 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 { info: NodeInfo, /// The underlying execution network that will be responsible for dispatching payments. network: Arc>, /// 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> SimNode<'a, T> { +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, } } @@ -572,39 +647,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 LightningNode for SimNode { fn get_info(&self) -> &NodeInfo { &self.info } @@ -620,8 +664,23 @@ 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, + ) { + 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,32 +697,7 @@ 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. + // Dispatch the payment through the network self.network .lock() .await @@ -1007,10 +1041,14 @@ 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: PathFinder + 'static, +{ let mut nodes: HashMap>> = HashMap::new(); for pk in graph.lock().await.nodes.keys() { @@ -1020,6 +1058,7 @@ pub async fn ln_node_from_graph( *pk, graph.clone(), routing_graph.clone(), + pathfinder.clone(), ))), ); } @@ -1507,7 +1546,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. @@ -1897,7 +1935,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)); + 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; @@ -1982,15 +2025,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: @@ -2008,12 +2051,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 @@ -2034,8 +2071,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. @@ -2078,18 +2115,16 @@ 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) + .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. @@ -2287,6 +2322,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( 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"), + } + } +}