diff --git a/CHANGELOG.md b/CHANGELOG.md index 353c77ea06..293737b04e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https:// - validator-client: add `denom` argument and add simple test for querying an account balance - gateway, validator-api: Checks for coconut credential double spending attempts, taking the coconut bandwidth contract as source of truth ([#1457]) - coconut-bandwidth-contract: Record the state of a coconut credential; create specific proposal for releasing funds ([#1457]) +- inclusion-probability: add simulator for active set inclusion probability ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a075fc0a4f..cc35237b23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2496,6 +2496,14 @@ dependencies = [ "syn", ] +[[package]] +name = "inclusion-probability" +version = "0.1.0" +dependencies = [ + "rand 0.8.5", + "thiserror", +] + [[package]] name = "indenter" version = "0.3.3" @@ -5652,18 +5660,18 @@ checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" [[package]] name = "thiserror" -version = "1.0.30" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.30" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8b8d9d560b..6e3dc19265 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,22 +22,23 @@ members = [ "clients/native", "clients/native/websocket-requests", "clients/socks5", + "common/bandwidth-claim-contract", "common/client-libs/gateway-client", "common/client-libs/mixnet-client", "common/client-libs/validator-client", - "common/credential-storage", "common/coconut-interface", "common/config", - "common/credentials", - "common/crypto", - "common/crypto/dkg", - "common/execute", - "common/bandwidth-claim-contract", "common/cosmwasm-smart-contracts/coconut-bandwidth-contract", "common/cosmwasm-smart-contracts/contracts-common", "common/cosmwasm-smart-contracts/mixnet-contract", "common/cosmwasm-smart-contracts/multisig-contract", "common/cosmwasm-smart-contracts/vesting-contract", + "common/credential-storage", + "common/credentials", + "common/crypto", + "common/crypto/dkg", + "common/execute", + "common/inclusion-probability", "common/mixnode-common", "common/network-defaults", "common/nonexhaustive-delayqueue", @@ -53,9 +54,9 @@ members = [ "common/nymsphinx/params", "common/nymsphinx/types", "common/pemstore", - "common/statistics", "common/socks5/proxy-helpers", "common/socks5/requests", + "common/statistics", "common/task", "common/topology", "common/types", diff --git a/common/inclusion-probability/Cargo.toml b/common/inclusion-probability/Cargo.toml new file mode 100644 index 0000000000..0df3bfe4e7 --- /dev/null +++ b/common/inclusion-probability/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "inclusion-probability" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rand = "0.8.5" +thiserror = "1.0.32" diff --git a/common/inclusion-probability/src/error.rs b/common/inclusion-probability/src/error.rs new file mode 100644 index 0000000000..9e9908a585 --- /dev/null +++ b/common/inclusion-probability/src/error.rs @@ -0,0 +1,9 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The list of cumulative stake was unexpectedly empty")] + EmptyListCumulStake, + #[error("Sample point was unexpectedly out of bounds")] + SamplePointOutOfBounds, + #[error("Norm computation failed on different size arrarys")] + NormDifferenceSizeArrays, +} diff --git a/common/inclusion-probability/src/lib.rs b/common/inclusion-probability/src/lib.rs new file mode 100644 index 0000000000..b392ab9803 --- /dev/null +++ b/common/inclusion-probability/src/lib.rs @@ -0,0 +1,278 @@ +//! Active set inclusion probability simulator + +use error::Error; + +mod error; + +const TOLERANCE_L2_NORM: f64 = 1e-4; +const TOLERANCE_MAX_NORM: f64 = 1e-3; + +pub struct SelectionProbability { + pub active_set_probability: Vec, + pub reserve_set_probability: Vec, + pub samples: u32, + pub delta_l2: f64, + pub delta_max: f64, +} + +pub fn simulate_selection_probability_mixnodes( + list_stake_for_mixnodes: &[u64], + active_set_size: usize, + reserve_set_size: usize, + max_samples: u32, +) -> Result { + // Total number of existing (registered) nodes + let num_mixnodes = list_stake_for_mixnodes.len(); + + // Cumulative stake ordered by node index + let list_cumul = cumul_sum(list_stake_for_mixnodes); + + // The computed probabilities + let mut active_set_probability = vec![0.0; num_mixnodes]; + let mut reserve_set_probability = vec![0.0; num_mixnodes]; + + // Number sufficiently large to have a good approximation of selection probability + let mut samples = 0; + let mut delta_l2; + let mut delta_max; + let mut rng = rand::thread_rng(); + + loop { + samples += 1; + let mut sample_active_mixnodes = Vec::new(); + let mut sample_reserve_mixnodes = Vec::new(); + let mut list_cumul_temp = list_cumul.clone(); + + let active_set_probability_previous = active_set_probability.clone(); + + // Select the active nodes for the epoch (hour) + while sample_active_mixnodes.len() < active_set_size { + let candidate = sample_candidate(&list_cumul_temp, &mut rng)?; + + if !sample_active_mixnodes.contains(&candidate) { + sample_active_mixnodes.push(candidate); + remove_mixnode_from_cumul_stake(candidate, &mut list_cumul_temp); + } + } + + // Select the reserve nodes for the epoch (hour) + while sample_reserve_mixnodes.len() < reserve_set_size { + let candidate = sample_candidate(&list_cumul_temp, &mut rng)?; + + if !sample_reserve_mixnodes.contains(&candidate) + && !sample_active_mixnodes.contains(&candidate) + { + sample_reserve_mixnodes.push(candidate); + remove_mixnode_from_cumul_stake(candidate, &mut list_cumul_temp); + } + } + + // Sum up nodes being in active or reserve set + for active_mixnodes in sample_active_mixnodes { + active_set_probability[active_mixnodes] += 1.0; + } + for reserve_mixnodes in sample_reserve_mixnodes { + reserve_set_probability[reserve_mixnodes] += 1.0; + } + + // Convergence critera only on active set. + // We devide by samples to get the average, that is not really part of the delta + // computation. + delta_l2 = l2_diff(&active_set_probability, &active_set_probability_previous)? + / f64::from(samples); + delta_max = max_diff(&active_set_probability, &active_set_probability_previous)? + / f64::from(samples); + if samples > 10 && delta_l2 < TOLERANCE_L2_NORM && delta_max < TOLERANCE_MAX_NORM + || samples >= max_samples + { + break; + } + } + + active_set_probability + .iter_mut() + .for_each(|x| *x /= f64::from(samples)); + reserve_set_probability + .iter_mut() + .for_each(|x| *x /= f64::from(samples)); + + Ok(SelectionProbability { + active_set_probability, + reserve_set_probability, + samples, + delta_l2, + delta_max, + }) +} + +// Compute the cumulative sum +fn cumul_sum<'a>(list: impl IntoIterator) -> Vec { + let mut list_cumul = Vec::new(); + let mut cumul = 0; + for entry in list { + cumul += entry; + list_cumul.push(cumul); + } + list_cumul +} + +fn sample_candidate(list_cumul: &[u64], rng: &mut rand::rngs::ThreadRng) -> Result { + use rand::distributions::{Distribution, Uniform}; + let uniform = Uniform::from(0..*list_cumul.last().ok_or(Error::EmptyListCumulStake)?); + let r = uniform.sample(rng); + + let candidate = list_cumul + .iter() + .enumerate() + .find(|(_, x)| *x >= &r) + .ok_or(Error::SamplePointOutOfBounds)? + .0; + + Ok(candidate) +} + +// Update list of cumulative stake to reflect eliminating the picked node +fn remove_mixnode_from_cumul_stake(candidate: usize, list_cumul_stake: &mut [u64]) { + let prob_candidate = if candidate == 0 { + list_cumul_stake[0] + } else { + list_cumul_stake[candidate] - list_cumul_stake[candidate - 1] + }; + + for cumul in list_cumul_stake.iter_mut().skip(candidate) { + *cumul -= prob_candidate; + } +} + +// Compute the difference in l2-norm +fn l2_diff(v1: &[f64], v2: &[f64]) -> Result { + if v1.len() != v2.len() { + return Err(Error::NormDifferenceSizeArrays); + } + Ok(v1 + .iter() + .zip(v2) + .map(|(&i1, &i2)| (i1 - i2).powi(2)) + .sum::() + .sqrt()) +} + +// Compute the difference in max-norm +fn max_diff(v1: &[f64], v2: &[f64]) -> Result { + if v1.len() != v2.len() { + return Err(Error::NormDifferenceSizeArrays); + } + Ok(v1 + .iter() + .zip(v2) + .map(|(x, y)| (x - y).abs()) + .fold(f64::NEG_INFINITY, f64::max)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_cumul_sum() { + let v = cumul_sum(&vec![1, 2, 3]); + assert_eq!(v, &[1, 3, 6]); + } + + #[test] + fn remove_mixnode_from_cumul() { + let mut cumul_stake = vec![1, 2, 3, 4, 5, 6]; + remove_mixnode_from_cumul_stake(3, &mut cumul_stake); + assert_eq!(cumul_stake, &[1, 2, 3, 3, 4, 5]); + } + + #[test] + fn max_norm() { + let v1 = vec![1.0, 2.0, 3.0]; + let v2 = vec![2.0, 4.0, -6.0]; + assert!((max_diff(&v1, &v2).unwrap() - 9.0).abs() < f64::EPSILON); + } + + #[test] + fn ls_norm() { + let v1 = vec![1.0, 2.0, 3.0]; + let v2 = vec![2.0, 3.0, -2.0]; + assert!((l2_diff(&v1, &v2).unwrap() - 5.196_152_422_706_632).abs() < 1e2 * f64::EPSILON); + } + + // Replicate the results from the Python simulation code in https://github.com/nymtech/team-core/issues/114 + #[test] + fn replicate_python_simulation() { + let active_set_size = 4; + let standby_set_size = 1; + + // this has to contain the total stake per node + let list_mix = vec![ + 100, 100, 3000, 500_000, 100, 10, 10, 10, 10, 10, 30000, 500, 200, 52345, + ]; + + let max_samples = 100_000; + + let SelectionProbability { + active_set_probability, + reserve_set_probability, + samples, + delta_l2, + delta_max, + } = simulate_selection_probability_mixnodes( + &list_mix, + active_set_size, + standby_set_size, + max_samples, + ) + .unwrap(); + + // These values comes from running the python simulator for a very long time + let expected_active_set_probability = vec![ + 0.025_070_8, + 0.025_073_2, + 0.744_117, + 0.999_999, + 0.025_000_2, + 0.002_524_4, + 0.002_527_8, + 0.002_528_6, + 0.002_569_6, + 0.002_513_6, + 0.994, + 0.125_482_8, + 0.050_279_8, + 0.998_313_2, + ]; + // The same check is used in the convergence criterion, and hence should be reflected in + // `delta_max` too. + assert!( + max_diff(&active_set_probability, &expected_active_set_probability).unwrap() < 1e-2 + ); + + let expected_reserve_set_probability = vec![ + 0.076_392_4, + 0.076_499, + 0.204_893_6, + 1e-06, + 0.076_278_8, + 0.007_720_6, + 0.007_673, + 0.007_700_2, + 0.007_669_4, + 0.007_731_2, + 0.005_789_4, + 0.368_465_6, + 0.151_537_2, + 0.001_648_6, + ]; + assert!( + max_diff(&reserve_set_probability, &expected_reserve_set_probability).unwrap() < 1e-2 + ); + + // We converge around 20_000, add another 500 for some slack due to random values + assert!(samples < 20_500); + assert!(delta_l2 < TOLERANCE_L2_NORM); + assert!(delta_max < TOLERANCE_MAX_NORM); + } +} diff --git a/nym-connect/Cargo.lock b/nym-connect/Cargo.lock index a94e7ab621..229016b1ce 100644 --- a/nym-connect/Cargo.lock +++ b/nym-connect/Cargo.lock @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.0.1" +version = "1.0.2" dependencies = [ "clap", "client-core", diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 2561ca57b6..1a48fd35b5 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -3055,7 +3055,7 @@ dependencies = [ [[package]] name = "nym_wallet" -version = "1.0.7" +version = "1.0.8" dependencies = [ "aes-gcm", "argon2 0.3.4",