diff --git a/Cargo.lock b/Cargo.lock index 14fd4778c639a..3a9c98d8901ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8928,6 +8928,18 @@ dependencies = [ "trybuild", ] +[[package]] +name = "sp-npos-elections-compact-fuzzer" +version = "2.0.0-alpha.5" +dependencies = [ + "honggfuzz", + "parity-scale-codec", + "rand 0.7.3", + "sp-npos-elections", + "sp-runtime", + "structopt", +] + [[package]] name = "sp-npos-elections-fuzzer" version = "2.0.0-alpha.5" diff --git a/Cargo.toml b/Cargo.toml index 1b35c7181d17d..ba1c06a7a4472 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ members = [ "primitives/maybe-compressed-blob", "primitives/npos-elections", "primitives/npos-elections/compact", + "primitives/npos-elections/compact/fuzzer", "primitives/npos-elections/fuzzer", "primitives/offchain", "primitives/panic-handler", diff --git a/primitives/npos-elections/compact/fuzzer/Cargo.toml b/primitives/npos-elections/compact/fuzzer/Cargo.toml new file mode 100644 index 0000000000000..ce54be7fec623 --- /dev/null +++ b/primitives/npos-elections/compact/fuzzer/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "sp-npos-elections-compact-fuzzer" +version = "2.0.0-alpha.5" +authors = ["Parity Technologies "] +edition = "2018" +license = "Apache-2.0" +homepage = "https://substrate.dev" +repository = "https://github.com/paritytech/substrate/" +description = "Fuzzer for sp-npos-elections-compact macro" +publish = false + +[dependencies] +codec = { package = "parity-scale-codec", version = "2.0.0", default-features = false, features = ["derive"] } +honggfuzz = "0.5" +rand = { version = "0.7.3", features = ["std", "small_rng"] } +structopt = "0.3.21" + +sp-npos-elections = { version = "3.0.0", path = "../.." } +sp-runtime = { version = "3.0.0", path = "../../../runtime" } + +[[bin]] +name = "compact_32_16_16" +path = "src/compact_32_16_16.rs" + +# [[bin]] +# name = "compact_24" +# path = "src/compact24.rs" + +# [[bin]] +# name = "non_compact_16" +# path = "src/noncompact16.rs" + +# [[bin]] +# name = "non_compact_24" +# path = "src/noncompact24.rs" diff --git a/primitives/npos-elections/compact/fuzzer/src/common.rs b/primitives/npos-elections/compact/fuzzer/src/common.rs new file mode 100644 index 0000000000000..eb3463f8dc895 --- /dev/null +++ b/primitives/npos-elections/compact/fuzzer/src/common.rs @@ -0,0 +1,168 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-2021 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Common fuzzing utils. + +use rand::{self, seq::SliceRandom, Rng}; +use std::{ + collections::{HashSet, HashMap}, + convert::TryInto, + hash::Hash, +}; + +pub type AccountId = u64; +/// The candidate mask allows easy disambiguation between voters and candidates: accounts +/// for which this bit is set are candidates, and without it, are voters. +pub const CANDIDATE_MASK: AccountId = 1 << ((std::mem::size_of::() * 8) - 1); +pub type CandidateId = AccountId; + +pub type Accuracy = sp_runtime::Perbill; + +pub type Assignment = sp_npos_elections::Assignment; +pub type Voter = (AccountId, sp_npos_elections::VoteWeight, Vec); + +/// converts x into the range [a, b] in a pseudo-fair way. +pub fn to_range(x: usize, a: usize, b: usize) -> usize { + // does not work correctly if b < 2 * a + assert!(b >= 2 * a); + let collapsed = x % b; + if collapsed >= a { + collapsed + } else { + collapsed + a + } +} + +/// Generate voter and assignment lists. Makes no attempt to be realistic about winner or assignment fairness. +/// +/// Maintains these invariants: +/// +/// - candidate ids have `CANDIDATE_MASK` bit set +/// - voter ids do not have `CANDIDATE_MASK` bit set +/// - assignments have the same ordering as voters +/// - `assignments.distribution.iter().map(|(_, frac)| frac).sum() == One::one()` +/// - a coherent set of winners is chosen. +/// - the winner set is a subset of the candidate set. +/// - `assignments.distribution.iter().all(|(who, _)| winners.contains(who))` +pub fn generate_random_votes( + candidate_count: usize, + voter_count: usize, + mut rng: impl Rng, +) -> (Vec, Vec, Vec) { + // cache for fast generation of unique candidate and voter ids + let mut used_ids = HashSet::with_capacity(candidate_count + voter_count); + + // candidates are easy: just a completely random set of IDs + let mut candidates: Vec = Vec::with_capacity(candidate_count); + while candidates.len() < candidate_count { + let mut new = || rng.gen::() | CANDIDATE_MASK; + let mut id = new(); + // insert returns `false` when the value was already present + while !used_ids.insert(id) { + id = new(); + } + candidates.push(id); + } + + // voters are random ids, random weights, random selection from the candidates + let mut voters = Vec::with_capacity(voter_count); + while voters.len() < voter_count { + let mut new = || rng.gen::() & !CANDIDATE_MASK; + let mut id = new(); + // insert returns `false` when the value was already present + while !used_ids.insert(id) { + id = new(); + } + + let vote_weight = rng.gen(); + + // it's not interesting if a voter chooses 0 or all candidates, so rule those cases out. + let n_candidates_chosen = rng.gen_range(1, candidates.len()); + + let mut chosen_candidates = Vec::with_capacity(n_candidates_chosen); + chosen_candidates.extend(candidates.choose_multiple(&mut rng, n_candidates_chosen)); + voters.push((id, vote_weight, chosen_candidates)); + } + + // always generate a sensible number of winners: elections are uninteresting if nobody wins, + // or everybody wins + let num_winners = rng.gen_range(1, candidate_count); + let mut winners: HashSet = HashSet::with_capacity(num_winners); + winners.extend(candidates.choose_multiple(&mut rng, num_winners)); + assert_eq!(winners.len(), num_winners); + + let mut assignments = Vec::with_capacity(voters.len()); + for (voter_id, _, votes) in voters.iter() { + let chosen_winners = votes.iter().filter(|vote| winners.contains(vote)).cloned(); + let num_chosen_winners = chosen_winners.clone().count(); + + // distribute the available stake randomly + let stake_distribution = if num_chosen_winners == 0 { + Vec::new() + } else { + let mut available_stake = 1000; + let mut stake_distribution = Vec::with_capacity(num_chosen_winners); + for _ in 0..num_chosen_winners - 1 { + let stake = rng.gen_range(0, available_stake); + stake_distribution.push(Accuracy::from_perthousand(stake)); + available_stake -= stake; + } + stake_distribution.push(Accuracy::from_perthousand(available_stake)); + stake_distribution.shuffle(&mut rng); + stake_distribution + }; + + assignments.push(Assignment { + who: *voter_id, + distribution: chosen_winners.zip(stake_distribution).collect(), + }); + } + + (voters, assignments, candidates) +} + +fn generate_cache(voters: Voters) -> HashMap +where + Voters: Iterator, + Item: Hash + Eq + Copy, +{ + let mut cache = HashMap::new(); + for (idx, voter_id) in voters.enumerate() { + cache.insert(voter_id, idx); + } + cache +} + +/// Create a function that returns the index of a voter in the voters list. +pub fn make_voter_fn(voters: &[Voter]) -> impl Fn(&AccountId) -> Option +where + usize: TryInto, +{ + let cache = generate_cache(voters.iter().map(|(id, _, _)| *id)); + move |who| cache.get(who).cloned().and_then(|i| i.try_into().ok()) +} + +/// Create a function that returns the index of a candidate in the candidates list. +pub fn make_target_fn( + candidates: &[CandidateId], +) -> impl Fn(&CandidateId) -> Option +where + usize: TryInto, +{ + let cache = generate_cache(candidates.iter().cloned()); + move |who| cache.get(who).cloned().and_then(|i| i.try_into().ok()) +} diff --git a/primitives/npos-elections/compact/fuzzer/src/compact_32_16_16.rs b/primitives/npos-elections/compact/fuzzer/src/compact_32_16_16.rs new file mode 100644 index 0000000000000..dc70b793d3710 --- /dev/null +++ b/primitives/npos-elections/compact/fuzzer/src/compact_32_16_16.rs @@ -0,0 +1,112 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-2021 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Fuzzing which ensures that the generated implementation of `encoded_size_for` always accurately +//! predicts the correct encoded size. +//! +//! ## Running a single iteration +//! +//! Simply run the program without the `fuzzing` configuration to run a single iteration: +//! `cargo run --bin compact_16`. +//! +//! ## Running +//! +//! Run with `cargo hfuzz run compact_16`. +//! +//! ## Debugging a panic +//! +//! Once a panic is found, it can be debugged with +//! `cargo hfuzz run-debug compact_16 hfuzz_workspace/compact_16/*.fuzz`. + +use _npos::CompactSolution; +use codec::Encode; +#[cfg(fuzzing)] +use honggfuzz::fuzz; + +#[cfg(not(fuzzing))] +use structopt::StructOpt; + +mod common; + +use common::{Accuracy, generate_random_votes, make_target_fn, make_voter_fn, to_range}; +use rand::{self, SeedableRng}; + +sp_npos_elections::generate_solution_type!( + #[compact] + pub struct Compact::(16) +); + +const MIN_CANDIDATES: usize = 250; +const MAX_CANDIDATES: usize = 1000; +const MIN_VOTERS: usize = 500; +const MAX_VOTERS: usize = 2500; + +#[cfg(fuzzing)] +fn main() { + loop { + fuzz!(|data: (usize, usize, u64)| { + let (candidate_count, voter_count, seed) = data; + iteration(candidate_count, voter_count, seed); + }); + } +} + +#[cfg(not(fuzzing))] +#[derive(Debug, StructOpt)] +struct Opt { + /// How many candidates participate in this election + #[structopt(short, long)] + candidates: Option, + + /// How many voters participate in this election + #[structopt(short, long)] + voters: Option, + + /// Random seed to use in this election + #[structopt(long)] + seed: Option, +} + +#[cfg(not(fuzzing))] +fn main() { + let opt = Opt::from_args(); + // candidates and voters by default use the maxima, which turn out to be one less than + // the constant. + iteration( + opt.candidates.unwrap_or(MAX_CANDIDATES - 1), + opt.voters.unwrap_or(MAX_VOTERS - 1), + opt.seed.unwrap_or_default(), + ); +} + +fn iteration(mut candidate_count: usize, mut voter_count: usize, seed: u64) { + let rng = rand::rngs::SmallRng::seed_from_u64(seed); + candidate_count = to_range(candidate_count, MIN_CANDIDATES, MAX_CANDIDATES); + voter_count = to_range(voter_count, MIN_VOTERS, MAX_VOTERS); + + let (voters, assignments, candidates) = + generate_random_votes(candidate_count, voter_count, rng); + + let predicted_size = Compact::encoded_size_for(&assignments); + + let compact = + Compact::from_assignment(assignments, make_voter_fn(&voters), make_target_fn(&candidates)) + .unwrap(); + let encoding = compact.encode(); + + assert_eq!(predicted_size, encoding.len()); +}