diff --git a/Cargo.lock b/Cargo.lock index 2948de15..47a8a52e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1756,7 +1756,9 @@ dependencies = [ "clap", "ctrlc", "dialoguer", + "hex", "log", + "rand", "serde", "serde_json", "sim-lib", @@ -1782,6 +1784,7 @@ dependencies = [ "mpsc", "ntest", "rand", + "rand_chacha", "rand_distr", "serde", "serde_json", diff --git a/README.md b/README.md index ebf9a7c6..5adbb003 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ of the random activity that is generated: capacity in a month. * `capacity-multiplier=0.5` means that each node sends half their capacity in a month. +* `--fix-seed`: a `u64` value that allows you to generate random activities deterministically from the provided seed, albeit with some limitations. The simulations are not guaranteed to be perfectly deterministic because tasks complete in slightly different orders on each run of the simulator. With a fixed seed, we can guarantee that the order in which activities are dispatched will be deterministic. ### Setup - Defined Activity If you would like SimLN to generate a specific payments between source diff --git a/sim-cli/Cargo.toml b/sim-cli/Cargo.toml index 67a6f41b..5789138d 100644 --- a/sim-cli/Cargo.toml +++ b/sim-cli/Cargo.toml @@ -20,3 +20,5 @@ sim-lib = { path = "../sim-lib" } tokio = { version = "1.26.0", features = ["full"] } bitcoin = { version = "0.30.1" } ctrlc = "3.4.0" +rand = "0.8.5" +hex = {version = "0.4.3"} diff --git a/sim-cli/src/main.rs b/sim-cli/src/main.rs index f88aa2a1..3021023b 100644 --- a/sim-cli/src/main.rs +++ b/sim-cli/src/main.rs @@ -74,6 +74,9 @@ struct Cli { /// Do not create an output file containing the simulations results #[clap(long, default_value_t = false)] no_results: bool, + /// Seed to run random activity generator deterministically + #[clap(long, short)] + fix_seed: Option, } #[tokio::main] @@ -208,6 +211,7 @@ async fn main() -> anyhow::Result<()> { cli.expected_pmt_amt, cli.capacity_multiplier, write_results, + cli.fix_seed, ); let sim2 = sim.clone(); diff --git a/sim-lib/Cargo.toml b/sim-lib/Cargo.toml index 0e0d9ec1..65bb822e 100644 --- a/sim-lib/Cargo.toml +++ b/sim-lib/Cargo.toml @@ -30,6 +30,7 @@ csv = "1.2.2" serde_millis = "0.1.1" rand_distr = "0.4.3" mockall = "0.12.1" +rand_chacha = "0.3.1" [dev-dependencies] ntest = "0.9.0" \ No newline at end of file diff --git a/sim-lib/src/defined_activity.rs b/sim-lib/src/defined_activity.rs index c27cf0eb..899e47f5 100644 --- a/sim-lib/src/defined_activity.rs +++ b/sim-lib/src/defined_activity.rs @@ -1,5 +1,6 @@ use crate::{ - DestinationGenerator, NodeInfo, PaymentGenerationError, PaymentGenerator, ValueOrRange, + DestinationGenerationError, DestinationGenerator, NodeInfo, PaymentGenerationError, + PaymentGenerator, ValueOrRange, }; use std::fmt; use tokio::time::Duration; @@ -42,8 +43,11 @@ impl fmt::Display for DefinedPaymentActivity { } impl DestinationGenerator for DefinedPaymentActivity { - fn choose_destination(&self, _: bitcoin::secp256k1::PublicKey) -> (NodeInfo, Option) { - (self.destination.clone(), None) + fn choose_destination( + &self, + _: bitcoin::secp256k1::PublicKey, + ) -> Result<(NodeInfo, Option), DestinationGenerationError> { + Ok((self.destination.clone(), None)) } } @@ -56,8 +60,8 @@ impl PaymentGenerator for DefinedPaymentActivity { self.count } - fn next_payment_wait(&self) -> Duration { - Duration::from_secs(self.wait.value() as u64) + fn next_payment_wait(&self) -> Result { + Ok(Duration::from_secs(self.wait.value() as u64)) } fn payment_amount( @@ -97,7 +101,7 @@ mod tests { crate::ValueOrRange::Value(payment_amt), ); - let (dest, dest_capacity) = generator.choose_destination(source.1); + let (dest, dest_capacity) = generator.choose_destination(source.1).unwrap(); assert_eq!(node.pubkey, dest.pubkey); assert!(dest_capacity.is_none()); diff --git a/sim-lib/src/lib.rs b/sim-lib/src/lib.rs index 716682ec..0fd96392 100644 --- a/sim-lib/src/lib.rs +++ b/sim-lib/src/lib.rs @@ -4,13 +4,16 @@ use bitcoin::Network; use csv::WriterBuilder; use lightning::ln::features::NodeFeatures; use lightning::ln::PaymentHash; -use rand::Rng; +use rand::rngs::StdRng; +use rand::{Rng, RngCore, SeedableRng}; +use rand_chacha::ChaCha8Rng; use random_activity::RandomActivityError; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fmt::{Display, Formatter}; use std::marker::Send; use std::path::PathBuf; +use std::sync::Mutex as StdMutex; use std::time::{SystemTimeError, UNIX_EPOCH}; use std::{collections::HashMap, sync::Arc, time::SystemTime}; use thiserror::Error; @@ -235,6 +238,8 @@ pub enum SimulationError { MpscChannelError(String), #[error("Payment Generation Error: {0}")] PaymentGenerationError(PaymentGenerationError), + #[error("Destination Generation Error: {0}")] + DestinationGenerationError(DestinationGenerationError), } #[derive(Debug, Error)] @@ -304,10 +309,17 @@ pub trait LightningNode: Send { async fn list_channels(&mut self) -> Result, LightningError>; } +#[derive(Debug, Error)] +#[error("Destination generation error: {0}")] +pub struct DestinationGenerationError(String); + pub trait DestinationGenerator: Send { /// choose_destination picks a destination node within the network, returning the node's information and its /// capacity (if available). - fn choose_destination(&self, source: PublicKey) -> (NodeInfo, Option); + fn choose_destination( + &self, + source: PublicKey, + ) -> Result<(NodeInfo, Option), DestinationGenerationError>; } #[derive(Debug, Error)] @@ -322,7 +334,7 @@ pub trait PaymentGenerator: Display + Send { fn payment_count(&self) -> Option; /// Returns the number of seconds that a node should wait until firing its next payment. - fn next_payment_wait(&self) -> time::Duration; + fn next_payment_wait(&self) -> Result; /// Returns a payment amount based, with a destination capacity optionally provided to inform the amount picked. fn payment_amount( @@ -435,6 +447,36 @@ enum SimulationOutput { SendPaymentFailure(Payment, PaymentResult), } +/// MutRngType is a convenient type alias for any random number generator (RNG) type that +/// allows shared and exclusive access. This is necessary because a single RNG +/// is to be shared across multiple `DestinationGenerator`s and `PaymentGenerator`s +/// for deterministic outcomes. +/// +/// **Note**: `StdMutex`, i.e. (`std::sync::Mutex`), is used here to avoid making the traits +/// `DestinationGenerator` and `PaymentGenerator` async. +type MutRngType = Arc>>; + +/// Newtype for `MutRngType` to encapsulate and hide implementation details for +/// creating new `MutRngType` types. Provides convenient API for the same purpose. +#[derive(Clone)] +struct MutRng(pub MutRngType); + +impl MutRng { + /// Creates a new MutRng given an optional `u64` argument. If `seed_opt` is `Some`, + /// random activity generation in the simulator occurs near-deterministically. + /// If it is `None`, activity generation is truly random, and based on a + /// non-deterministic source of entropy. + pub fn new(seed_opt: Option) -> Self { + if let Some(seed) = seed_opt { + Self(Arc::new(StdMutex::new( + Box::new(ChaCha8Rng::seed_from_u64(seed)) as Box, + ))) + } else { + Self(Arc::new(StdMutex::new(Box::new(StdRng::from_entropy())))) + } + } +} + #[derive(Clone)] pub struct Simulation { /// The lightning node that is being simulated. @@ -453,6 +495,8 @@ pub struct Simulation { activity_multiplier: f64, /// Configurations for printing results to CSV. Results are not written if this option is None. write_results: Option, + /// Random number generator created from fixed seed. + seeded_rng: MutRng, } #[derive(Clone)] @@ -462,7 +506,7 @@ pub struct WriteResults { /// The number of activity results to batch before printing in CSV. pub batch_size: u32, } -/// + /// ExecutorKit contains the components required to spin up an activity configured by the user, to be used to /// spin up the appropriate producers and consumers for the activity. struct ExecutorKit { @@ -481,6 +525,7 @@ impl Simulation { expected_payment_msat: u64, activity_multiplier: f64, write_results: Option, + seed: Option, ) -> Self { let (shutdown_trigger, shutdown_listener) = triggered::trigger(); Self { @@ -492,6 +537,7 @@ impl Simulation { expected_payment_msat, activity_multiplier, write_results, + seeded_rng: MutRng::new(seed), } } @@ -823,8 +869,11 @@ impl Simulation { } let network_generator = Arc::new(Mutex::new( - NetworkGraphView::new(active_nodes.values().cloned().collect()) - .map_err(SimulationError::RandomActivityError)?, + NetworkGraphView::new( + active_nodes.values().cloned().collect(), + self.seeded_rng.clone(), + ) + .map_err(SimulationError::RandomActivityError)?, )); log::info!( @@ -841,6 +890,7 @@ impl Simulation { *capacity, self.expected_payment_msat, self.activity_multiplier, + self.seeded_rng.clone(), ) .map_err(SimulationError::RandomActivityError)?, ), @@ -1047,11 +1097,11 @@ async fn produce_events { - let (destination, capacity) = network_generator.lock().await.choose_destination(source.pubkey); + let (destination, capacity) = network_generator.lock().await.choose_destination(source.pubkey).map_err(SimulationError::DestinationGenerationError)?; // Only proceed with a payment if the amount is non-zero, otherwise skip this round. If we can't get // a payment amount something has gone wrong (because we should have validated that we can always @@ -1327,3 +1377,34 @@ async fn track_payment_result( Ok(()) } + +#[cfg(test)] +mod tests { + use crate::MutRng; + + #[test] + fn create_seeded_mut_rng() { + let seeds = vec![u64::MIN, u64::MAX]; + + for seed in seeds { + let mut_rng_1 = MutRng::new(Some(seed)); + let mut_rng_2 = MutRng::new(Some(seed)); + + let mut rng_1 = mut_rng_1.0.lock().unwrap(); + let mut rng_2 = mut_rng_2.0.lock().unwrap(); + + assert_eq!(rng_1.next_u64(), rng_2.next_u64()) + } + } + + #[test] + fn create_unseeded_mut_rng() { + let mut_rng_1 = MutRng::new(None); + let mut_rng_2 = MutRng::new(None); + + let mut rng_1 = mut_rng_1.0.lock().unwrap(); + let mut rng_2 = mut_rng_2.0.lock().unwrap(); + + assert_ne!(rng_1.next_u64(), rng_2.next_u64()) + } +} diff --git a/sim-lib/src/random_activity.rs b/sim-lib/src/random_activity.rs index f8a50e75..db231c87 100644 --- a/sim-lib/src/random_activity.rs +++ b/sim-lib/src/random_activity.rs @@ -6,7 +6,10 @@ use bitcoin::secp256k1::PublicKey; use rand_distr::{Distribution, Exp, LogNormal, WeightedIndex}; use std::time::Duration; -use crate::{DestinationGenerator, NodeInfo, PaymentGenerationError, PaymentGenerator}; +use crate::{ + DestinationGenerationError, DestinationGenerator, MutRng, NodeInfo, PaymentGenerationError, + PaymentGenerator, +}; const HOURS_PER_MONTH: u64 = 30 * 24; const SECONDS_PER_MONTH: u64 = HOURS_PER_MONTH * 60 * 60; @@ -19,20 +22,22 @@ pub enum RandomActivityError { 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, -/// which has a view of the full network except for itself). +/// `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. The `NetworkGraphView` also keeps a handle on a random number generator +/// that allows it to deterministically pick nodes. 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, which has a view of the full network except for itself). pub struct NetworkGraphView { node_picker: WeightedIndex, nodes: Vec<(NodeInfo, u64)>, + rng: MutRng, } 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)>, rng: MutRng) -> Result { if nodes.len() < 2 { return Err(RandomActivityError::ValueError( "at least two nodes required for activity generation".to_string(), @@ -52,7 +57,11 @@ impl NetworkGraphView { let node_picker = WeightedIndex::new(nodes.iter().map(|(_, v)| *v).collect::>()) .map_err(|e| RandomActivityError::ValueError(e.to_string()))?; - Ok(NetworkGraphView { node_picker, nodes }) + Ok(NetworkGraphView { + node_picker, + nodes, + rng, + }) } } @@ -60,20 +69,26 @@ impl DestinationGenerator for NetworkGraphView { /// Randomly samples the network for a node, weighted by capacity. Using a single graph view means that it's /// possible for a source node to select itself. After sufficient retries, this is highly improbable (even with /// very small graphs, or those with one node significantly more capitalized than others). - fn choose_destination(&self, source: PublicKey) -> (NodeInfo, Option) { - let mut rng = rand::thread_rng(); - + fn choose_destination( + &self, + source: PublicKey, + ) -> Result<(NodeInfo, Option), DestinationGenerationError> { + let mut rng = self + .rng + .0 + .lock() + .map_err(|e| DestinationGenerationError(e.to_string()))?; // While it's very unlikely that we can't pick a destination that is not our source, it's possible that there's // a bug in our selection, so we track attempts to select a non-source node so that we can warn if this takes // improbably long. let mut i = 1; loop { - let index = self.node_picker.sample(&mut rng); + let index = self.node_picker.sample(&mut *rng); // Unwrapping is safe given `NetworkGraphView` has the same amount of elements for `nodes` and `node_picker` let (node_info, capacity) = self.nodes.get(index).unwrap(); if node_info.pubkey != source { - return (node_info.clone(), Some(*capacity)); + return Ok((node_info.clone(), Some(*capacity))); } if i % 50 == 0 { @@ -94,12 +109,14 @@ impl Display for NetworkGraphView { /// node's capacity, it will produce payments such that the node sends multiplier * capacity over a calendar month. /// While the expected amount to be sent in a month and the mean payment amount are set, the generator will introduce /// randomness both in the time between events and the variance of payment amounts sent to mimic more realistic -/// payment flows. +/// payment flows. For deterministic time between events and payment amounts, the `RandomPaymentActivity` keeps a +/// handle on a random number generator. pub struct RandomPaymentActivity { multiplier: f64, expected_payment_amt: u64, source_capacity: u64, event_dist: Exp, + rng: MutRng, } impl RandomPaymentActivity { @@ -110,6 +127,7 @@ impl RandomPaymentActivity { source_capacity_msat: u64, expected_payment_amt: u64, multiplier: f64, + rng: MutRng, ) -> Result { if source_capacity_msat == 0 { return Err(RandomActivityError::ValueError( @@ -145,6 +163,7 @@ impl RandomPaymentActivity { expected_payment_amt, source_capacity: source_capacity_msat, event_dist, + rng, }) } @@ -205,9 +224,15 @@ impl PaymentGenerator for RandomPaymentActivity { } /// Returns the amount of time until the next payment should be scheduled for the node. - fn next_payment_wait(&self) -> Duration { - let mut rng = rand::thread_rng(); - Duration::from_secs(self.event_dist.sample(&mut rng) as u64) + fn next_payment_wait(&self) -> Result { + let mut rng = self + .rng + .0 + .lock() + .map_err(|e| PaymentGenerationError(e.to_string()))?; + let duration_in_secs = self.event_dist.sample(&mut *rng) as u64; + + Ok(Duration::from_secs(duration_in_secs)) } /// Returns the payment amount for a payment to a node with the destination capacity provided. The expected value @@ -242,8 +267,14 @@ impl PaymentGenerator for RandomPaymentActivity { let log_normal = LogNormal::new(mu, sigma_square.sqrt()) .map_err(|e| PaymentGenerationError(e.to_string()))?; - let mut rng = rand::thread_rng(); - Ok(log_normal.sample(&mut rng) as u64) + let mut rng = self + .rng + .0 + .lock() + .map_err(|e| PaymentGenerationError(e.to_string()))?; + let payment_amount = log_normal.sample(&mut *rng) as u64; + + Ok(payment_amount) } } @@ -277,9 +308,10 @@ mod tests { #[test] fn test_new() { // Check that we need, at least, two nodes + let rng = MutRng::new(Some(u64::MAX)); for i in 0..2 { assert!(matches!( - NetworkGraphView::new(create_nodes(i, 42 * (i as u64 + 1))), + NetworkGraphView::new(create_nodes(i, 42 * (i as u64 + 1)), rng.clone()), Err(RandomActivityError::ValueError { .. }) )); } @@ -289,18 +321,18 @@ mod tests { let mut nodes = create_nodes(1, 0); nodes.extend(create_nodes(1, 21)); assert!(matches!( - NetworkGraphView::new(nodes), + NetworkGraphView::new(nodes, rng.clone()), Err(RandomActivityError::InsufficientCapacity { .. }) )); // All of them are 0 assert!(matches!( - NetworkGraphView::new(create_nodes(2, 0)), + NetworkGraphView::new(create_nodes(2, 0), rng.clone()), Err(RandomActivityError::InsufficientCapacity { .. }) )); // Otherwise we should be good - assert!(NetworkGraphView::new(create_nodes(2, 42)).is_ok()); + assert!(NetworkGraphView::new(create_nodes(2, 42), rng).is_ok()); } #[test] @@ -330,10 +362,11 @@ mod tests { nodes.extend(create_nodes(big_node_count, big_node_capacity)); let big_node = nodes.last().unwrap().0.pubkey; - let view = NetworkGraphView::new(nodes).unwrap(); + let rng = MutRng::new(Some(u64::MAX)); + let view = NetworkGraphView::new(nodes, rng).unwrap(); for _ in 0..10 { - view.choose_destination(big_node); + view.choose_destination(big_node).unwrap(); } } } @@ -347,27 +380,47 @@ mod tests { // 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 rng = MutRng::new(Some(u64::MAX)); let expected_payment = get_random_int(1, 100); - assert!( - RandomPaymentActivity::new(2 * expected_payment, expected_payment, 1.0).is_ok() - ); + assert!(RandomPaymentActivity::new( + 2 * expected_payment, + expected_payment, + 1.0, + rng.clone() + ) + .is_ok()); assert!(matches!( - RandomPaymentActivity::new(2 * expected_payment, expected_payment + 1, 1.0), + RandomPaymentActivity::new( + 2 * expected_payment, + expected_payment + 1, + 1.0, + rng.clone() + ), 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!( - RandomPaymentActivity::new(0, get_random_int(1, 10), get_random_int(1, 10) as f64), + RandomPaymentActivity::new( + 0, + get_random_int(1, 10), + get_random_int(1, 10) as f64, + rng.clone() + ), Err(RandomActivityError::ValueError { .. }) )); assert!(matches!( - RandomPaymentActivity::new(get_random_int(1, 10), 0, get_random_int(1, 10) as f64), + RandomPaymentActivity::new( + get_random_int(1, 10), + 0, + get_random_int(1, 10) as f64, + rng.clone() + ), Err(RandomActivityError::ValueError { .. }) )); assert!(matches!( - RandomPaymentActivity::new(get_random_int(1, 10), get_random_int(1, 10), 0.0), + RandomPaymentActivity::new(get_random_int(1, 10), get_random_int(1, 10), 0.0, rng), Err(RandomActivityError::ValueError { .. }) )); } @@ -400,7 +453,9 @@ mod tests { // 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 = RandomPaymentActivity::new(source_capacity, expected_payment, 1.0).unwrap(); + let rng = MutRng::new(Some(u64::MAX)); + let pag = + RandomPaymentActivity::new(source_capacity, expected_payment, 1.0, rng).unwrap(); // Wrong cases for i in 0..source_capacity {