diff --git a/Cargo.lock b/Cargo.lock index ed408fa6..9901124a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -962,6 +962,39 @@ dependencies = [ "libc", ] +[[package]] +name = "ntest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da8ec6d2b73d45307e926f5af46809768581044384637af6b3f3fe7c3c88f512" +dependencies = [ + "ntest_test_cases", + "ntest_timeout", +] + +[[package]] +name = "ntest_test_cases" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be7d33be719c6f4d09e64e27c1ef4e73485dc4cc1f4d22201f89860a7fe22e22" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ntest_timeout" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066b468120587a402f0b47d8f80035c921f6a46f8209efd0632a89a16f5188a4" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -1115,6 +1148,16 @@ dependencies = [ "syn 2.0.38", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.69" @@ -1661,6 +1704,7 @@ dependencies = [ "lightning", "log", "mpsc", + "ntest", "rand", "rand_distr", "serde", @@ -1924,6 +1968,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.0.2", + "toml_datetime", + "winnow", +] + [[package]] name = "tonic" version = "0.8.3" @@ -2375,3 +2436,12 @@ name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b801d0e0a6726477cc207f60162da452f3a95adb368399bef20a946e06f65c" +dependencies = [ + "memchr", +] diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs index 0e0f4518..6cb67037 100644 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -39,10 +39,10 @@ struct Cli { #[clap(long, short, verbatim_doc_comment, default_value = "info")] log_level: LevelFilter, /// Expected payment amount for the random activity generator - #[clap(long, short, default_value_t = EXPECTED_PAYMENT_AMOUNT)] + #[clap(long, short, default_value_t = EXPECTED_PAYMENT_AMOUNT, value_parser = clap::builder::RangedU64ValueParser::::new().range(1..u64::MAX))] expected_pmt_amt: u64, /// Multiplier of the overall network capacity used by the random activity generator - #[clap(long, short, default_value_t = ACTIVITY_MULTIPLIER)] + #[clap(long, short, default_value_t = ACTIVITY_MULTIPLIER, value_parser = clap::builder::RangedU64ValueParser::::new().range(1..u64::MAX))] capacity_multiplier: f64, /// Do not create an output file containing the simulations results #[clap(long, default_value_t = false)] diff --git a/sim-lib/Cargo.toml b/sim-lib/Cargo.toml index 380d034f..4c32f6e0 100644 --- a/sim-lib/Cargo.toml +++ b/sim-lib/Cargo.toml @@ -29,3 +29,6 @@ hex = "0.4.3" csv = "1.2.2" serde_millis = "0.1.1" rand_distr = "0.4.3" + +[dev-dependencies] +ntest = "0.9.0" \ No newline at end of file diff --git a/sim-lib/src/lib.rs b/sim-lib/src/lib.rs index f6110a25..3cfd94f1 100644 --- a/sim-lib/src/lib.rs +++ b/sim-lib/src/lib.rs @@ -4,6 +4,7 @@ use bitcoin::Network; use csv::WriterBuilder; use lightning::ln::features::NodeFeatures; use lightning::ln::PaymentHash; +use random_activity::RandomActivityError; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fmt::{Display, Formatter}; @@ -23,6 +24,8 @@ pub mod cln; pub mod lnd; mod random_activity; mod serializers; +#[cfg(test)] +mod test_utils; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(untagged)] @@ -125,8 +128,8 @@ pub enum SimulationError { CsvError(#[from] csv::Error), #[error("File Error")] FileError, - #[error("Random activity Error: {0}")] - RandomActivityError(String), + #[error("{0}")] + RandomActivityError(RandomActivityError), } // Phase 2: Event Queue @@ -203,12 +206,15 @@ pub trait NetworkGenerator { fn sample_node_by_capacity(&self, source: PublicKey) -> (NodeInfo, u64); } +#[derive(Debug, Error)] +#[error("Payment generation error: {0}")] +pub struct PaymentGenerationError(String); pub trait PaymentGenerator { // Returns the number of seconds that a node should wait until firing its next payment. fn next_payment_wait(&self) -> time::Duration; // Returns a payment amount based on the capacity of the sending and receiving node. - fn payment_amount(&self, destination_capacity: u64) -> Result; + fn payment_amount(&self, destination_capacity: u64) -> Result; } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -642,9 +648,10 @@ impl Simulation { producer_channels: HashMap>, tasks: &mut JoinSet<()>, ) -> Result<(), SimulationError> { - let network_generator = Arc::new(Mutex::new(NetworkGraphView::new( - node_capacities.values().cloned().collect(), - )?)); + let network_generator = Arc::new(Mutex::new( + NetworkGraphView::new(node_capacities.values().cloned().collect()) + .map_err(SimulationError::RandomActivityError)?, + )); log::info!( "Created network generator: {}.", @@ -655,10 +662,12 @@ impl Simulation { let (info, source_capacity) = match node_capacities.get(&pk) { Some((info, capacity)) => (info.clone(), *capacity), None => { - return Err(SimulationError::RandomActivityError(format!( - "Random activity generator run for: {} with unknown capacity.", - pk - ))); + return Err(SimulationError::RandomActivityError( + RandomActivityError::ValueError(format!( + "Random activity generator run for: {} with unknown capacity.", + pk + )), + )); } }; @@ -666,7 +675,8 @@ impl Simulation { source_capacity, self.expected_payment_msat, self.activity_multiplier, - )?; + ) + .map_err(SimulationError::RandomActivityError)?; tasks.spawn(produce_random_events( info, diff --git a/sim-lib/src/random_activity.rs b/sim-lib/src/random_activity.rs index 93b8b6cf..63606d72 100644 --- a/sim-lib/src/random_activity.rs +++ b/sim-lib/src/random_activity.rs @@ -1,15 +1,24 @@ use core::fmt; use std::fmt::Display; +use thiserror::Error; use bitcoin::secp256k1::PublicKey; use rand_distr::{Distribution, Exp, LogNormal, WeightedIndex}; use std::time::Duration; -use crate::{NetworkGenerator, NodeInfo, PaymentGenerator, SimulationError}; +use crate::{NetworkGenerator, NodeInfo, PaymentGenerationError, PaymentGenerator}; const HOURS_PER_MONTH: u64 = 30 * 24; const SECONDS_PER_MONTH: u64 = HOURS_PER_MONTH * 60 * 60; +#[derive(Debug, Error)] +pub enum RandomActivityError { + #[error("Value error: {0}")] + ValueError(String), + #[error("InsufficientCapacity: {0}")] + InsufficientCapacity(String), +} + /// NetworkGraphView maintains a view of the network graph that can be used to pick nodes by their deployed liquidity /// and track node capacity within the network. Tracking nodes in the network is memory-expensive, so we use a single /// tracker for the whole network (in an unbounded environment, we'd make one _per_ node generating random activity, @@ -23,15 +32,15 @@ impl NetworkGraphView { // Creates a network view for the map of node public keys to capacity (in millisatoshis) provided. Returns an error // if any node's capacity is zero (the node cannot receive), or there are not at least two nodes (one node can't // send to itself). - pub fn new(nodes: Vec<(NodeInfo, u64)>) -> Result { + pub fn new(nodes: Vec<(NodeInfo, u64)>) -> Result { if nodes.len() < 2 { - return Err(SimulationError::RandomActivityError( + return Err(RandomActivityError::ValueError( "at least two nodes required for activity generation".to_string(), )); } if nodes.iter().any(|(_, v)| *v == 0) { - return Err(SimulationError::RandomActivityError( + return Err(RandomActivityError::InsufficientCapacity( "network generator created with zero capacity node".to_string(), )); } @@ -41,7 +50,7 @@ impl NetworkGraphView { // capacity along with the node info because we query the two at the same time. Zero capacity nodes are // filtered out because they have no chance of being selected (and wont' be able to receive payments). let node_picker = WeightedIndex::new(nodes.iter().map(|(_, v)| *v).collect::>()) - .map_err(|e| SimulationError::RandomActivityError(e.to_string()))?; + .map_err(|e| RandomActivityError::ValueError(e.to_string()))?; Ok(NetworkGraphView { node_picker, nodes }) } @@ -101,16 +110,35 @@ impl PaymentActivityGenerator { source_capacity_msat: u64, expected_payment_amt: u64, multiplier: f64, - ) -> Result { + ) -> Result { + if source_capacity_msat == 0 { + return Err(RandomActivityError::ValueError( + "source_capacity_msat cannot be zero".into(), + )); + } + + if expected_payment_amt == 0 { + return Err(RandomActivityError::ValueError( + "expected_payment_amt cannot be zero".into(), + )); + } + + if multiplier == 0.0 { + return Err(RandomActivityError::ValueError( + "multiplier cannot be zero".into(), + )); + } + PaymentActivityGenerator::validate_capacity(source_capacity_msat, expected_payment_amt)?; // Lamda for the exponential distribution that we'll use to randomly time events is equal to the number of // events that we expect to see within our set period. + let lamda = events_per_month(source_capacity_msat, multiplier, expected_payment_amt) / (SECONDS_PER_MONTH as f64); let event_dist = - Exp::new(lamda).map_err(|e| SimulationError::RandomActivityError(e.to_string()))?; + Exp::new(lamda).map_err(|e| RandomActivityError::ValueError(e.to_string()))?; Ok(PaymentActivityGenerator { multiplier, @@ -125,7 +153,7 @@ impl PaymentActivityGenerator { pub fn validate_capacity( node_capacity_msat: u64, expected_payment_amt: u64, - ) -> Result<(), SimulationError> { + ) -> Result<(), RandomActivityError> { // We will not be able to generate payments if the variance of sigma squared for our log normal distribution // is < 0 (because we have to take a square root). // @@ -140,7 +168,7 @@ impl PaymentActivityGenerator { // node_capacity_msat >= 2 * expected_payment_amt let min_required_capacity = 2 * expected_payment_amt; if node_capacity_msat < min_required_capacity { - return Err(SimulationError::RandomActivityError(format!( + return Err(RandomActivityError::InsufficientCapacity(format!( "node needs at least {} capacity (has: {}) to process expected payment amount: {}", min_required_capacity, node_capacity_msat, expected_payment_amt ))); @@ -179,7 +207,7 @@ impl PaymentGenerator for PaymentActivityGenerator { /// capacity. While the expected value of payments remains the same, scaling variance by node capacity means that /// nodes with more deployed capital will see a larger range of payment values than those with smaller total /// channel capacity. - fn payment_amount(&self, destination_capacity: u64) -> Result { + fn payment_amount(&self, destination_capacity: u64) -> Result { let payment_limit = std::cmp::min(self.source_capacity, destination_capacity) / 2; let ln_pmt_amt = (self.expected_payment_amt as f64).ln(); @@ -189,13 +217,13 @@ impl PaymentGenerator for PaymentActivityGenerator { let sigma_square = 2.0 * (ln_limit - ln_pmt_amt); if sigma_square < 0.0 { - return Err(SimulationError::RandomActivityError(format!( + return Err(PaymentGenerationError(format!( "payment amount not possible for limit: {payment_limit}, sigma squared: {sigma_square}" ))); } let log_normal = LogNormal::new(mu, sigma_square.sqrt()) - .map_err(|e| SimulationError::RandomActivityError(e.to_string()))?; + .map_err(|e| PaymentGenerationError(e.to_string()))?; let mut rng = rand::thread_rng(); Ok(log_normal.sample(&mut rng) as u64) @@ -220,3 +248,186 @@ impl Display for PaymentActivityGenerator { ) } } + +#[cfg(test)] +mod tests { + mod test_network_graph_view { + use lightning::ln::features::Features; + use ntest::timeout; + + use super::super::*; + use crate::test_utils::get_random_keypair; + use crate::NodeInfo; + + fn create_nodes(n: usize, node_capacity: u64) -> Vec<(NodeInfo, u64)> { + (1..=n) + .map(|_| { + ( + NodeInfo { + pubkey: get_random_keypair().1, + alias: String::new(), + features: Features::empty(), + }, + node_capacity, + ) + }) + .collect() + } + + #[test] + fn test_new() { + // Check that we need, at least, two nodes + for i in 0..2 { + assert!(matches!( + NetworkGraphView::new(create_nodes(i, 42 * (i as u64 + 1))), + Err(RandomActivityError::ValueError { .. }) + )); + } + + // Check that, even if we have two nodes, the node capacity of all of them must be greater than 0 + // One of them is 0 + let mut nodes = create_nodes(1, 0); + nodes.extend(create_nodes(1, 21)); + assert!(matches!( + NetworkGraphView::new(nodes), + Err(RandomActivityError::InsufficientCapacity { .. }) + )); + + // All of them are 0 + assert!(matches!( + NetworkGraphView::new(create_nodes(2, 0)), + Err(RandomActivityError::InsufficientCapacity { .. }) + )); + + // Otherwise we should be good + assert!(NetworkGraphView::new(create_nodes(2, 42)).is_ok()); + } + + #[test] + #[timeout(5000)] + fn test_sample_node_by_capacity() { + // Sample node by capacity returns a node to be used as payment destination of a random payment generation + // given a graph view and a source node, ensuring the source and the destination are distinct. + // The method is guaranteed to return, though how long it takes to pick a destination depends on the graph: + // the bigger a node's capacity within the graph, the more likely it is to be picked. + // + // For efficiency reasons, there is a single `NetworkGraphView` shared between all workers in the simulator, therefore, + // the source is part of the sampling pool when a destination is requested. This means that if the source capacity is + // extremely big compared to the rest of nodes in the graph, it may take extraordinarily long for a destination + // to be found. + // + // This tests a completely unrealistic yet pathological setup in where a single node has the vast majority of the network's + // capacity, while every other single node has almost none. The scenario represents a start topology in where the big node has a + // connection with every single other node, while the rest are only connected to it. Even in this extreme and unrealistic + // situation, the method returns rather fast. + + let small_node_count = 999; + let big_node_count = 1; + let small_node_capacity = 1_000; + let big_node_capacity = small_node_capacity * small_node_count as u64; + + let mut nodes = create_nodes(small_node_count, small_node_capacity); + nodes.extend(create_nodes(big_node_count, big_node_capacity)); + let big_node = nodes.last().unwrap().0.pubkey; + + let view = NetworkGraphView::new(nodes).unwrap(); + + for _ in 0..10 { + view.sample_node_by_capacity(big_node); + } + } + } + + mod payment_activity_generator { + use super::super::*; + use crate::test_utils::get_random_int; + + #[test] + fn test_new() { + // For the payment activity generator to fail during construction either the provided capacity must fail validation or the exponential + // distribution must fail building given the inputs. The former will be thoroughly tested in its own unit test, but we'll test some basic cases + // here. Mainly, if the `capacity < expected_payment_amnt / 2`, the generator will fail building + let expected_payment = get_random_int(1, 100); + assert!( + PaymentActivityGenerator::new(2 * expected_payment, expected_payment, 1.0).is_ok() + ); + assert!(matches!( + PaymentActivityGenerator::new(2 * expected_payment, expected_payment + 1, 1.0), + Err(RandomActivityError::InsufficientCapacity { .. }) + )); + + // Respecting the internal exponential distribution creation, neither of the parameters can be zero. Otherwise we may try to create an exponential + // function with lambda = NaN, which will error out, or with lambda = Inf, which does not make sense for our use-case + assert!(matches!( + PaymentActivityGenerator::new( + 0, + get_random_int(1, 10), + get_random_int(1, 10) as f64 + ), + Err(RandomActivityError::ValueError { .. }) + )); + assert!(matches!( + PaymentActivityGenerator::new( + get_random_int(1, 10), + 0, + get_random_int(1, 10) as f64 + ), + Err(RandomActivityError::ValueError { .. }) + )); + assert!(matches!( + PaymentActivityGenerator::new(get_random_int(1, 10), get_random_int(1, 10), 0.0), + Err(RandomActivityError::ValueError { .. }) + )); + } + + #[test] + fn test_validate_capacity() { + // There's not much to be tested here, given a `node_capacity` and an `expected_payment` + // if the former over two is smaller than the latter, the function will error out + for _ in 0..=get_random_int(20, 100) { + let capacity = get_random_int(0, 100); + let payment_amt = get_random_int(0, 100); + + let r = PaymentActivityGenerator::validate_capacity(capacity, payment_amt); + if capacity < 2 * payment_amt { + assert!(matches!( + r, + Err(RandomActivityError::InsufficientCapacity { .. }) + )); + } else { + assert!(r.is_ok()); + } + } + } + + #[test] + fn test_payment_amount() { + // The special cases for payment_amount are those who may make the internal log normal distribution fail to build, which happens if + // sigma squared is either +-INF or NaN. Given that the constructor of the PaymentActivityGenerator already forces its internal values + // to be greater than zero, the only values that are left are all values of `destination_capacity` smaller or equal to the `source_capacity` + // All of them will yield a sigma squared smaller than 0, which we have a sanity check for. + let expected_payment = get_random_int(1, 100); + let source_capacity = 2 * expected_payment; + let pag = + PaymentActivityGenerator::new(source_capacity, expected_payment, 1.0).unwrap(); + + // Wrong cases + for i in 0..source_capacity { + assert!(matches!( + pag.payment_amount(i), + Err(PaymentGenerationError(..)) + )) + } + + // All other cases will work. We are not going to exhaustively test for the rest up to u64::MAX, let just pick a bunch + for i in source_capacity + 1..100 * source_capacity { + assert!(pag.payment_amount(i).is_ok()) + } + + // We can even try really high numbers to make sure they are not troublesome + for i in u64::MAX - 10000..u64::MAX { + assert!(pag.payment_amount(i).is_ok()) + } + } + } +} diff --git a/sim-lib/src/test_utils.rs b/sim-lib/src/test_utils.rs new file mode 100644 index 00000000..876b33c8 --- /dev/null +++ b/sim-lib/src/test_utils.rs @@ -0,0 +1,28 @@ +use rand::distributions::Uniform; +use rand::Rng; + +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + +/// Utility function to create a vector of pseudo random bytes. +/// +/// Mainly used for testing purposes. +pub fn get_random_bytes(size: usize) -> Vec { + rand::thread_rng() + .sample_iter(Uniform::new(u8::MIN, u8::MAX)) + .take(size) + .collect() +} + +/// Utility function to create a random integer in a given range +pub fn get_random_int(s: u64, e: u64) -> u64 { + rand::thread_rng().gen_range(s..e) +} + +/// Gets a key pair generated in a pseudorandom way. +pub fn get_random_keypair() -> (SecretKey, PublicKey) { + loop { + if let Ok(sk) = SecretKey::from_slice(&get_random_bytes(32)) { + return (sk, PublicKey::from_secret_key(&Secp256k1::new(), &sk)); + } + } +}