From bc8a65911dfa82c46ebc94467f8bd7b1ea916529 Mon Sep 17 00:00:00 2001 From: kianenigma Date: Mon, 24 Oct 2022 16:27:16 +0100 Subject: [PATCH] move pools fuzzing to hongfuzz --- Cargo.lock | 33 +- Cargo.toml | 1 + frame/nomination-pools/Cargo.toml | 9 +- frame/nomination-pools/fuzzer/Cargo.toml | 33 ++ frame/nomination-pools/fuzzer/src/call.rs | 373 ++++++++++++++++++++++ frame/nomination-pools/src/lib.rs | 13 +- frame/nomination-pools/src/mock.rs | 25 +- frame/nomination-pools/src/tests.rs | 325 ------------------- 8 files changed, 449 insertions(+), 363 deletions(-) create mode 100644 frame/nomination-pools/fuzzer/Cargo.toml create mode 100644 frame/nomination-pools/fuzzer/src/call.rs diff --git a/Cargo.lock b/Cargo.lock index 397846693e907..60f71c5936767 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2861,13 +2861,14 @@ dependencies = [ [[package]] name = "honggfuzz" -version = "0.5.54" +version = "0.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea09577d948a98a5f59b7c891e274c4fb35ad52f67782b3d0cb53b9c05301f1" +checksum = "848e9c511092e0daa0a35a63e8e6e475a3e8f870741448b9f6028d69b142f18e" dependencies = [ "arbitrary", "lazy_static", - "memmap", + "memmap2 0.5.0", + "rustc_version 0.4.0", ] [[package]] @@ -4100,16 +4101,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "memmap" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "memmap2" version = "0.2.1" @@ -5737,7 +5728,6 @@ dependencies = [ "log", "pallet-balances", "parity-scale-codec", - "rand 0.8.5", "scale-info", "sp-core", "sp-io", @@ -5771,6 +5761,21 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-nomination-pools-fuzzer" +version = "2.0.0" +dependencies = [ + "frame-support", + "frame-system", + "honggfuzz", + "log", + "pallet-nomination-pools", + "rand 0.8.5", + "sp-io", + "sp-runtime", + "sp-tracing", +] + [[package]] name = "pallet-nomination-pools-runtime-api" version = "1.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index d3c801fc2c7be..45c287ed65ba9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,7 @@ members = [ "frame/preimage", "frame/proxy", "frame/nomination-pools", + "frame/nomination-pools/fuzzer", "frame/nomination-pools/benchmarking", "frame/nomination-pools/test-staking", "frame/nomination-pools/runtime-api", diff --git a/frame/nomination-pools/Cargo.toml b/frame/nomination-pools/Cargo.toml index 459ee5f440a12..ffc16e4b856b3 100644 --- a/frame/nomination-pools/Cargo.toml +++ b/frame/nomination-pools/Cargo.toml @@ -26,13 +26,13 @@ sp-core = { version = "6.0.0", default-features = false, path = "../../primitive sp-io = { version = "6.0.0", default-features = false, path = "../../primitives/io" } log = { version = "0.4.0", default-features = false } -[dev-dependencies] -pallet-balances = { version = "4.0.0-dev", path = "../balances" } -sp-tracing = { version = "5.0.0", path = "../../primitives/tracing" } -rand = { version = "0.8.5", features = ["small_rng"] } +# Optional: usef for testing and/or fuzzing +pallet-balances = { version = "4.0.0-dev", path = "../balances", optional = true } +sp-tracing = { version = "5.0.0", path = "../../primitives/tracing", optional = true } [features] default = ["std"] +fuzzing = ["pallet-balances", "sp-tracing"] std = [ "codec/std", "scale-info/std", @@ -51,4 +51,3 @@ runtime-benchmarks = [ try-runtime = [ "frame-support/try-runtime" ] -fuzz-test = [] diff --git a/frame/nomination-pools/fuzzer/Cargo.toml b/frame/nomination-pools/fuzzer/Cargo.toml new file mode 100644 index 0000000000000..7dde8733e3f60 --- /dev/null +++ b/frame/nomination-pools/fuzzer/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pallet-nomination-pools-fuzzer" +version = "2.0.0" +authors = ["Parity Technologies "] +edition = "2021" +license = "Apache-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "Fuzzer for fixed point arithmetic primitives." +documentation = "https://docs.rs/sp-arithmetic-fuzzer" +publish = false + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +honggfuzz = "0.5.54" + +pallet-nomination-pools = { path = "..", features = ["fuzzing"] } + +frame-system = { path = "../../system" } +frame-support = { path = "../../support" } + +sp-runtime = { path = "../../../primitives/runtime" } +sp-io = { path = "../../../primitives/io" } +sp-tracing = { path = "../../../primitives/tracing" } + +rand = { version = "0.8.5", features = ["small_rng"] } +log = "0.4.17" + +[[bin]] +name = "call" +path = "src/call.rs" diff --git a/frame/nomination-pools/fuzzer/src/call.rs b/frame/nomination-pools/fuzzer/src/call.rs new file mode 100644 index 0000000000000..bf86b8db99d1a --- /dev/null +++ b/frame/nomination-pools/fuzzer/src/call.rs @@ -0,0 +1,373 @@ +// This file is part of Substrate. + +// Copyright (C) 2019-2022 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. + +//! # Running +//! Running this fuzzer can be done with `cargo hfuzz run call`. `honggfuzz` CLI +//! options can be used by setting `HFUZZ_RUN_ARGS`, such as `-n 4` to use 4 threads. +//! +//! # Debugging a panic +//! Once a panic is found, it can be debugged with +//! `cargo hfuzz run-debug per_thing_rational hfuzz_workspace/call/*.fuzz`. + +use frame_support::{ + assert_ok, + traits::{Currency, GetCallName, UnfilteredDispatchable}, +}; +use honggfuzz::{arbitrary::Arbitrary, fuzz}; +use pallet_nomination_pools::{ + log, + mock::*, + pallet as pools, + pallet::{BondedPools, Call as PoolsCall, Event as PoolsEvents, PoolMembers}, + BondExtra, BondedPool, LastPoolId, MaxPoolMembers, MaxPoolMembersPerPool, MaxPools, + MinCreateBond, MinJoinBond, PoolId, +}; +use rand::{seq::SliceRandom, Rng}; +use sp_runtime::{assert_eq_error_rate, Perquintill}; + +const ERA: BlockNumber = 1000; +const MAX_ED_MULTIPLE: Balance = 10_000; +const MIN_ED_MULTIPLE: Balance = 10; + +// not quite elegant, just to make it available in random_signed_origin. +const REWARD_AGENT_ACCOUNT: AccountId = 42; + +/// Grab random accounts, either known ones, or new ones. +fn random_signed_origin(rng: &mut R) -> (RuntimeOrigin, AccountId) { + let count = PoolMembers::::count(); + if rng.gen::() && count > 0 { + // take an existing account. + let skip = rng.gen_range(0..count as usize); + + // this is tricky: the account might be our reward agent, which we never want to be + // randomly chosen here. Try another one, or, if it is only our agent, return a random + // one nonetheless. + let candidate = PoolMembers::::iter_keys().skip(skip).take(1).next().unwrap(); + let acc = + if candidate == REWARD_AGENT_ACCOUNT { rng.gen::() } else { candidate }; + + (RuntimeOrigin::signed(acc), acc) + } else { + // create a new account + let acc = rng.gen::(); + (RuntimeOrigin::signed(acc), acc) + } +} + +fn random_ed_multiple(rng: &mut R) -> Balance { + let multiple = rng.gen_range(MIN_ED_MULTIPLE..MAX_ED_MULTIPLE); + ExistentialDeposit::get() * multiple +} + +fn fund_account(rng: &mut R, account: &AccountId) { + let target_amount = random_ed_multiple(rng); + if let Some(top_up) = target_amount.checked_sub(Balances::free_balance(account)) { + let _ = Balances::deposit_creating(account, top_up); + } + assert!(Balances::free_balance(account) >= target_amount); +} + +fn random_existing_pool(mut rng: &mut R) -> Option { + BondedPools::::iter_keys().collect::>().choose(&mut rng).map(|x| *x) +} + +fn random_call(mut rng: &mut R) -> (pools::Call, RuntimeOrigin) { + let op = rng.gen::(); + let mut op_count = as GetCallName>::get_call_names().len(); + // Exclude set_state, set_metadata, set_configs, update_roles and chill. + op_count -= 5; + + match op % op_count { + 0 => { + // join + let pool_id = random_existing_pool(&mut rng).unwrap_or_default(); + let (origin, who) = random_signed_origin(&mut rng); + fund_account(&mut rng, &who); + let amount = random_ed_multiple(&mut rng); + (PoolsCall::::join { amount, pool_id }, origin) + }, + 1 => { + // bond_extra + let (origin, who) = random_signed_origin(&mut rng); + let extra = if rng.gen::() { + BondExtra::Rewards + } else { + fund_account(&mut rng, &who); + let amount = random_ed_multiple(&mut rng); + BondExtra::FreeBalance(amount) + }; + (PoolsCall::::bond_extra { extra }, origin) + }, + 2 => { + // claim_payout + let (origin, _) = random_signed_origin(&mut rng); + (PoolsCall::::claim_payout {}, origin) + }, + 3 => { + // unbond + let (origin, who) = random_signed_origin(&mut rng); + let amount = random_ed_multiple(&mut rng); + (PoolsCall::::unbond { member_account: who, unbonding_points: amount }, origin) + }, + 4 => { + // pool_withdraw_unbonded + let pool_id = random_existing_pool(&mut rng).unwrap_or_default(); + let (origin, _) = random_signed_origin(&mut rng); + (PoolsCall::::pool_withdraw_unbonded { pool_id, num_slashing_spans: 0 }, origin) + }, + 5 => { + // withdraw_unbonded + let (origin, who) = random_signed_origin(&mut rng); + ( + PoolsCall::::withdraw_unbonded { member_account: who, num_slashing_spans: 0 }, + origin, + ) + }, + 6 => { + // create + let (origin, who) = random_signed_origin(&mut rng); + let amount = random_ed_multiple(&mut rng); + fund_account(&mut rng, &who); + let root = who.clone(); + let state_toggler = who.clone(); + let nominator = who.clone(); + (PoolsCall::::create { amount, root, state_toggler, nominator }, origin) + }, + 7 => { + // nominate + let (origin, _) = random_signed_origin(&mut rng); + let pool_id = random_existing_pool(&mut rng).unwrap_or_default(); + let validators = Default::default(); + (PoolsCall::::nominate { pool_id, validators }, origin) + }, + _ => unreachable!(), + } +} + +// TODO: not particularly making things more ergonomic.. might ditch. +struct Fuzzable { + call: pools::Call, + origin: RuntimeOrigin, +} + +impl<'a> Arbitrary<'a> for Fuzzable { + fn arbitrary( + u: &mut honggfuzz::arbitrary::Unstructured<'a>, + ) -> honggfuzz::arbitrary::Result { + use ::rand::{rngs::SmallRng, SeedableRng}; + let mut seed = [0u8; 32]; + let rand_seed = u.bytes(32).unwrap(); + seed.copy_from_slice(rand_seed); + let mut rng = SmallRng::from_seed(seed); + let (call, origin) = random_call(&mut rng); + Ok(Fuzzable { call, origin }) + } +} + +#[derive(Default)] +struct RewardAgent { + who: AccountId, + pool_id: Option, + expected_reward: Balance, +} + +// TODO: inject some slashes into the game. +impl RewardAgent { + fn new(who: AccountId) -> Self { + Self { who, ..Default::default() } + } + + fn join(&mut self) { + if self.pool_id.is_some() { + return + } + let pool_id = LastPoolId::::get(); + let amount = 10 * ExistentialDeposit::get(); + let origin = RuntimeOrigin::signed(self.who); + let _ = Balances::deposit_creating(&self.who, 10 * amount); + self.pool_id = Some(pool_id); + log::info!(target: "reward-agent", "🤖 reward agent joining in {} with {}", pool_id, amount); + assert_ok!(PoolsCall::join:: { amount, pool_id }.dispatch_bypass_filter(origin)); + } + + fn claim_payout(&mut self) { + // 10 era later, we claim our payout. We expect our income to be roughly what we + // calculated. + if !PoolMembers::::contains_key(&self.who) { + log!(warn, "reward agent is not in the pool yet, cannot claim"); + return + } + let pre = Balances::free_balance(&42); + let origin = RuntimeOrigin::signed(42); + assert_ok!(PoolsCall::::claim_payout {}.dispatch_bypass_filter(origin)); + let post = Balances::free_balance(&42); + + let income = post - pre; + log::info!( + target: "reward-agent", "🤖 CLAIM: actual: {}, expected: {}", + income, + self.expected_reward, + ); + assert_eq_error_rate!(income, self.expected_reward, 10); + self.expected_reward = 0; + } +} + +fn main() { + let mut reward_agent = RewardAgent::new(42); + sp_tracing::try_init_simple(); + let mut ext = sp_io::TestExternalities::new_empty(); + let mut events_histogram = Vec::<(PoolsEvents, u32)>::default(); + let mut iteration = 0 as BlockNumber; + let mut ok = 0; + let mut err = 0; + + let dot: Balance = (10 as Balance).pow(10); + ExistentialDeposit::set(dot); + BondingDuration::set(8); + + ext.execute_with(|| { + MaxPoolMembers::::set(Some(10_000)); + MaxPoolMembersPerPool::::set(Some(1000)); + MaxPools::::set(Some(1_000)); + + MinCreateBond::::set(10 * ExistentialDeposit::get()); + MinJoinBond::::set(5 * ExistentialDeposit::get()); + System::set_block_number(1); + }); + + loop { + fuzz!(|seed: [u8; 32]| { + use ::rand::{rngs::SmallRng, SeedableRng}; + let mut rng = SmallRng::from_seed(seed); + + ext.execute_with(|| { + let (call, origin) = random_call(&mut rng); + let outcome = call.clone().dispatch_bypass_filter(origin.clone()); + iteration += 1; + match outcome { + Ok(_) => ok += 1, + Err(_) => err += 1, + }; + + log!( + trace, + "iteration {}, call {:?}, origin {:?}, outcome: {:?}, so far {} ok {} err", + iteration, + call, + origin, + outcome, + ok, + err, + ); + + // possibly join the reward_agent + if iteration > ERA / 2 && BondedPools::::count() > 0 { + reward_agent.join(); + } + // and possibly roughly every 4 era, trigger payout for the agent. Doing this more + // frequent is also harmless. + if rng.gen_range(0..(4 * ERA)) == 0 { + reward_agent.claim_payout(); + } + + // execute sanity checks at a fixed interval, possibly on every block. + if iteration % + (std::env::var("SANITY_CHECK_INTERVAL") + .ok() + .and_then(|x| x.parse::().ok())) + .unwrap_or(1) == 0 + { + log!(info, "running sanity checks at {}", iteration); + Pools::do_try_state(u8::MAX).unwrap(); + } + + // collect and reset events. + System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let pallet_nomination_pools::mock::RuntimeEvent::Pools(inner) = e { + Some(inner) + } else { + None + } + }) + .for_each(|e| { + if let Some((_, c)) = events_histogram + .iter_mut() + .find(|(x, _)| std::mem::discriminant(x) == std::mem::discriminant(&e)) + { + *c += 1; + } else { + events_histogram.push((e, 1)) + } + }); + System::reset_events(); + + // trigger an era change, and check the status of the reward agent. + if iteration % ERA == 0 { + CurrentEra::mutate(|c| *c += 1); + BondedPools::::iter().for_each(|(id, _)| { + let amount = random_ed_multiple(&mut rng); + let _ = + Balances::deposit_creating(&Pools::create_reward_account(id), amount); + // if we just paid out the reward agent, let's calculate how much we expect + // our reward agent to have earned. + if reward_agent.pool_id.map_or(false, |mid| mid == id) { + let all_points = BondedPool::::get(id).map(|p| p.points).unwrap(); + let member_points = + PoolMembers::::get(reward_agent.who).map(|m| m.points).unwrap(); + let agent_share = Perquintill::from_rational(member_points, all_points); + log::info!( + target: "reward-agent", + "🤖 REWARD = amount = {:?}, ratio: {:?}, share {:?}", + amount, + agent_share, + agent_share * amount, + ); + reward_agent.expected_reward += agent_share * amount; + } + }); + + log!( + info, + "iteration {}, {} pools, {} members, {} ok {} err, events = {:?}", + iteration, + BondedPools::::count(), + PoolMembers::::count(), + ok, + err, + events_histogram + .iter() + .map(|(x, c)| ( + format!("{:?}", x) + .split(" ") + .map(|x| x.to_string()) + .collect::>() + .first() + .cloned() + .unwrap(), + c, + )) + .collect::>(), + ); + } + }) + }) + } +} diff --git a/frame/nomination-pools/src/lib.rs b/frame/nomination-pools/src/lib.rs index 9e77adaeee677..88ed1bd2432e6 100644 --- a/frame/nomination-pools/src/lib.rs +++ b/frame/nomination-pools/src/lib.rs @@ -299,14 +299,14 @@ pub const LOG_TARGET: &'static str = "runtime::nomination-pools"; macro_rules! log { ($level:tt, $patter:expr $(, $values:expr)* $(,)?) => { log::$level!( - target: crate::LOG_TARGET, + target: $crate::LOG_TARGET, concat!("[{:?}] 🏊‍♂️ ", $patter), >::block_number() $(, $values)* ) }; } -#[cfg(test)] -mod mock; +#[cfg(any(test, feature = "fuzzing"))] +pub mod mock; #[cfg(test)] mod tests; @@ -594,7 +594,7 @@ impl BondedPool { } /// Get [`Self`] from storage. Returns `None` if no entry for `pool_account` exists. - fn get(id: PoolId) -> Option { + pub fn get(id: PoolId) -> Option { BondedPools::::try_get(id).ok().map(|inner| Self { id, inner }) } @@ -1487,7 +1487,6 @@ pub mod pallet { /// `existential deposit + amount` in their account. /// * Only a pool with [`PoolState::Open`] can be joined #[pallet::weight(T::WeightInfo::join())] - #[transactional] pub fn join( origin: OriginFor, #[pallet::compact] amount: BalanceOf, @@ -1548,7 +1547,6 @@ pub mod pallet { T::WeightInfo::bond_extra_transfer() .max(T::WeightInfo::bond_extra_reward()) )] - #[transactional] pub fn bond_extra(origin: OriginFor, extra: BondExtra>) -> DispatchResult { let who = ensure_signed(origin)?; let (mut member, mut bonded_pool, mut reward_pool) = Self::get_member_with_pools(&who)?; @@ -1591,7 +1589,6 @@ pub mod pallet { /// The member will earn rewards pro rata based on the members stake vs the sum of the /// members in the pools stake. Rewards do not "expire". #[pallet::weight(T::WeightInfo::claim_payout())] - #[transactional] pub fn claim_payout(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; let (mut member, mut bonded_pool, mut reward_pool) = Self::get_member_with_pools(&who)?; @@ -2352,6 +2349,8 @@ impl Pallet { &bonded_pool.reward_account(), &member_account, pending_rewards, + // defensive: the depositor has put existential deposit into the pool and it stays + // untouched, reward account shall not die. ExistenceRequirement::AllowDeath, )?; diff --git a/frame/nomination-pools/src/mock.rs b/frame/nomination-pools/src/mock.rs index 1b3372dae56ee..743b27c33ea1a 100644 --- a/frame/nomination-pools/src/mock.rs +++ b/frame/nomination-pools/src/mock.rs @@ -230,44 +230,45 @@ impl Default for ExtBuilder { } } +#[cfg_attr(feature = "fuzzing", allow(dead_code))] impl ExtBuilder { // Add members to pool 0. - pub(crate) fn add_members(mut self, members: Vec<(AccountId, Balance)>) -> Self { + fn add_members(mut self, members: Vec<(AccountId, Balance)>) -> Self { self.members = members; self } - pub(crate) fn ed(self, ed: Balance) -> Self { + fn ed(self, ed: Balance) -> Self { ExistentialDeposit::set(ed); self } - pub(crate) fn min_bond(self, min: Balance) -> Self { + fn min_bond(self, min: Balance) -> Self { StakingMinBond::set(min); self } - pub(crate) fn min_join_bond(self, min: Balance) -> Self { + fn min_join_bond(self, min: Balance) -> Self { MinJoinBondConfig::set(min); self } - pub(crate) fn with_check(self, level: u8) -> Self { + fn with_check(self, level: u8) -> Self { CheckLevel::set(level); self } - pub(crate) fn max_members(mut self, max: Option) -> Self { + fn max_members(mut self, max: Option) -> Self { self.max_members = max; self } - pub(crate) fn max_members_per_pool(mut self, max: Option) -> Self { + fn max_members_per_pool(mut self, max: Option) -> Self { self.max_members_per_pool = max; self } - pub(crate) fn build(self) -> sp_io::TestExternalities { + fn build(self) -> sp_io::TestExternalities { sp_tracing::try_init_simple(); let mut storage = frame_system::GenesisConfig::default().build_storage::().unwrap(); @@ -302,7 +303,7 @@ impl ExtBuilder { ext } - pub fn build_and_execute(self, test: impl FnOnce() -> ()) { + fn build_and_execute(self, test: impl FnOnce() -> ()) { self.build().execute_with(|| { test(); Pools::do_try_state(CheckLevel::get()).unwrap(); @@ -310,7 +311,7 @@ impl ExtBuilder { } } -pub(crate) fn unsafe_set_state(pool_id: PoolId, state: PoolState) { +pub fn unsafe_set_state(pool_id: PoolId, state: PoolState) { BondedPools::::try_mutate(pool_id, |maybe_bonded_pool| { maybe_bonded_pool.as_mut().ok_or(()).map(|bonded_pool| { bonded_pool.state = state; @@ -325,7 +326,7 @@ parameter_types! { } /// All events of this pallet. -pub(crate) fn pool_events_since_last_call() -> Vec> { +pub fn pool_events_since_last_call() -> Vec> { let events = System::events() .into_iter() .map(|r| r.event) @@ -337,7 +338,7 @@ pub(crate) fn pool_events_since_last_call() -> Vec> { } /// All events of the `Balances` pallet. -pub(crate) fn balances_events_since_last_call() -> Vec> { +pub fn balances_events_since_last_call() -> Vec> { let events = System::events() .into_iter() .map(|r| r.event) diff --git a/frame/nomination-pools/src/tests.rs b/frame/nomination-pools/src/tests.rs index 5074a7ffa695a..431b38e0994df 100644 --- a/frame/nomination-pools/src/tests.rs +++ b/frame/nomination-pools/src/tests.rs @@ -5025,328 +5025,3 @@ mod reward_counter_precision { }); } } - -// NOTE: run this with debug_assertions, but in release mode. -#[cfg(feature = "fuzz-test")] -mod fuzz_test { - use super::*; - use crate::pallet::{Call as PoolsCall, Event as PoolsEvents}; - use frame_support::traits::UnfilteredDispatchable; - use rand::{seq::SliceRandom, thread_rng, Rng}; - use sp_runtime::{assert_eq_error_rate, Perquintill}; - - const ERA: BlockNumber = 1000; - const MAX_ED_MULTIPLE: Balance = 10_000; - const MIN_ED_MULTIPLE: Balance = 10; - - // not quite elegant, just to make it available in random_signed_origin. - const REWARD_AGENT_ACCOUNT: AccountId = 42; - - /// Grab random accounts, either known ones, or new ones. - fn random_signed_origin(rng: &mut R) -> (RuntimeOrigin, AccountId) { - let count = PoolMembers::::count(); - if rng.gen::() && count > 0 { - // take an existing account. - let skip = rng.gen_range(0..count as usize); - - // this is tricky: the account might be our reward agent, which we never want to be - // randomly chosen here. Try another one, or, if it is only our agent, return a random - // one nonetheless. - let candidate = PoolMembers::::iter_keys().skip(skip).take(1).next().unwrap(); - let acc = - if candidate == REWARD_AGENT_ACCOUNT { rng.gen::() } else { candidate }; - - (RuntimeOrigin::signed(acc), acc) - } else { - // create a new account - let acc = rng.gen::(); - (RuntimeOrigin::signed(acc), acc) - } - } - - fn random_ed_multiple(rng: &mut R) -> Balance { - let multiple = rng.gen_range(MIN_ED_MULTIPLE..MAX_ED_MULTIPLE); - ExistentialDeposit::get() * multiple - } - - fn fund_account(rng: &mut R, account: &AccountId) { - let target_amount = random_ed_multiple(rng); - if let Some(top_up) = target_amount.checked_sub(Balances::free_balance(account)) { - let _ = Balances::deposit_creating(account, top_up); - } - assert!(Balances::free_balance(account) >= target_amount); - } - - fn random_existing_pool(mut rng: &mut R) -> Option { - BondedPools::::iter_keys().collect::>().choose(&mut rng).map(|x| *x) - } - - fn random_call(mut rng: &mut R) -> (crate::pallet::Call, RuntimeOrigin) { - let op = rng.gen::(); - let mut op_count = - as frame_support::dispatch::GetCallName>::get_call_names() - .len(); - // Exclude set_state, set_metadata, set_configs, update_roles and chill. - op_count -= 5; - - match op % op_count { - 0 => { - // join - let pool_id = random_existing_pool(&mut rng).unwrap_or_default(); - let (origin, who) = random_signed_origin(&mut rng); - fund_account(&mut rng, &who); - let amount = random_ed_multiple(&mut rng); - (PoolsCall::::join { amount, pool_id }, origin) - }, - 1 => { - // bond_extra - let (origin, who) = random_signed_origin(&mut rng); - let extra = if rng.gen::() { - BondExtra::Rewards - } else { - fund_account(&mut rng, &who); - let amount = random_ed_multiple(&mut rng); - BondExtra::FreeBalance(amount) - }; - (PoolsCall::::bond_extra { extra }, origin) - }, - 2 => { - // claim_payout - let (origin, _) = random_signed_origin(&mut rng); - (PoolsCall::::claim_payout {}, origin) - }, - 3 => { - // unbond - let (origin, who) = random_signed_origin(&mut rng); - let amount = random_ed_multiple(&mut rng); - (PoolsCall::::unbond { member_account: who, unbonding_points: amount }, origin) - }, - 4 => { - // pool_withdraw_unbonded - let pool_id = random_existing_pool(&mut rng).unwrap_or_default(); - let (origin, _) = random_signed_origin(&mut rng); - (PoolsCall::::pool_withdraw_unbonded { pool_id, num_slashing_spans: 0 }, origin) - }, - 5 => { - // withdraw_unbonded - let (origin, who) = random_signed_origin(&mut rng); - ( - PoolsCall::::withdraw_unbonded { - member_account: who, - num_slashing_spans: 0, - }, - origin, - ) - }, - 6 => { - // create - let (origin, who) = random_signed_origin(&mut rng); - let amount = random_ed_multiple(&mut rng); - fund_account(&mut rng, &who); - let root = who.clone(); - let state_toggler = who.clone(); - let nominator = who.clone(); - (PoolsCall::::create { amount, root, state_toggler, nominator }, origin) - }, - 7 => { - // nominate - let (origin, _) = random_signed_origin(&mut rng); - let pool_id = random_existing_pool(&mut rng).unwrap_or_default(); - let validators = Default::default(); - (PoolsCall::::nominate { pool_id, validators }, origin) - }, - _ => unreachable!(), - } - } - - #[derive(Default)] - struct RewardAgent { - who: AccountId, - pool_id: Option, - expected_reward: Balance, - } - - // TODO: inject some slashes into the game. - impl RewardAgent { - fn new(who: AccountId) -> Self { - Self { who, ..Default::default() } - } - - fn join(&mut self) { - if self.pool_id.is_some() { - return - } - let pool_id = LastPoolId::::get(); - let amount = 10 * ExistentialDeposit::get(); - let origin = RuntimeOrigin::signed(self.who); - let _ = Balances::deposit_creating(&self.who, 10 * amount); - self.pool_id = Some(pool_id); - log::info!(target: "reward-agent", "🤖 reward agent joining in {} with {}", pool_id, amount); - assert_ok!(PoolsCall::join:: { amount, pool_id }.dispatch_bypass_filter(origin)); - } - - fn claim_payout(&mut self) { - // 10 era later, we claim our payout. We expect our income to be roughly what we - // calculated. - if !PoolMembers::::contains_key(&self.who) { - log!(warn, "reward agent is not in the pool yet, cannot claim"); - return - } - let pre = Balances::free_balance(&42); - let origin = RuntimeOrigin::signed(42); - assert_ok!(PoolsCall::::claim_payout {}.dispatch_bypass_filter(origin)); - let post = Balances::free_balance(&42); - - let income = post - pre; - log::info!( - target: "reward-agent", "🤖 CLAIM: actual: {}, expected: {}", - income, - self.expected_reward, - ); - assert_eq_error_rate!(income, self.expected_reward, 10); - self.expected_reward = 0; - } - } - - #[test] - fn fuzz_test() { - let mut reward_agent = RewardAgent::new(42); - sp_tracing::try_init_simple(); - // NOTE: use this to get predictable (non)randomness: - // use::{rngs::SmallRng, SeedableRng}; - // let mut rng = SmallRng::from_seed([0u8; 32]); - let mut rng = thread_rng(); - let mut ext = sp_io::TestExternalities::new_empty(); - // NOTE: sadly events don't fulfill the requirements of hashmap or btreemap. - let mut events_histogram = Vec::<(PoolsEvents, u32)>::default(); - let mut iteration = 0 as BlockNumber; - let mut ok = 0; - let mut err = 0; - - ext.execute_with(|| { - MaxPoolMembers::::set(Some(10_000)); - MaxPoolMembersPerPool::::set(Some(1000)); - MaxPools::::set(Some(1_000)); - - MinCreateBond::::set(10 * ExistentialDeposit::get()); - MinJoinBond::::set(5 * ExistentialDeposit::get()); - System::set_block_number(1); - }); - - ExistentialDeposit::set(10u128.pow(12u32)); - BondingDuration::set(8); - - loop { - ext.execute_with(|| { - iteration += 1; - let (call, origin) = random_call(&mut rng); - let outcome = call.clone().dispatch_bypass_filter(origin.clone()); - - match outcome { - Ok(_) => ok += 1, - Err(_) => err += 1, - }; - - log!( - debug, - "iteration {}, call {:?}, origin {:?}, outcome: {:?}, so far {} ok {} err", - iteration, - call, - origin, - outcome, - ok, - err, - ); - - // possibly join the reward_agent - if iteration > ERA / 2 && BondedPools::::count() > 0 { - reward_agent.join(); - } - // and possibly roughly every 4 era, trigger payout for the agent. Doing this more - // frequent is also harmless. - if rng.gen_range(0..(4 * ERA)) == 0 { - reward_agent.claim_payout(); - } - - // execute sanity checks at a fixed interval, possibly on every block. - if iteration % - (std::env::var("SANITY_CHECK_INTERVAL") - .ok() - .and_then(|x| x.parse::().ok())) - .unwrap_or(1) == 0 - { - log!(info, "running sanity checks at {}", iteration); - Pools::do_try_state(u8::MAX).unwrap(); - } - - // collect and reset events. - System::events() - .into_iter() - .map(|r| r.event) - .filter_map( - |e| if let mock::Event::Pools(inner) = e { Some(inner) } else { None }, - ) - .for_each(|e| { - if let Some((_, c)) = events_histogram - .iter_mut() - .find(|(x, _)| std::mem::discriminant(x) == std::mem::discriminant(&e)) - { - *c += 1; - } else { - events_histogram.push((e, 1)) - } - }); - System::reset_events(); - - // trigger an era change, and check the status of the reward agent. - if iteration % ERA == 0 { - CurrentEra::mutate(|c| *c += 1); - BondedPools::::iter().for_each(|(id, _)| { - let amount = random_ed_multiple(&mut rng); - let _ = - Balances::deposit_creating(&Pools::create_reward_account(id), amount); - // if we just paid out the reward agent, let's calculate how much we expect - // our reward agent to have earned. - if reward_agent.pool_id.map_or(false, |mid| mid == id) { - let all_points = BondedPool::::get(id).map(|p| p.points).unwrap(); - let member_points = - PoolMembers::::get(reward_agent.who).map(|m| m.points).unwrap(); - let agent_share = Perquintill::from_rational(member_points, all_points); - log::info!( - target: "reward-agent", - "🤖 REWARD = amount = {:?}, ratio: {:?}, share {:?}", - amount, - agent_share, - agent_share * amount, - ); - reward_agent.expected_reward += agent_share * amount; - } - }); - - log!( - info, - "iteration {}, {} pools, {} members, {} ok {} err, events = {:?}", - iteration, - BondedPools::::count(), - PoolMembers::::count(), - ok, - err, - events_histogram - .iter() - .map(|(x, c)| ( - format!("{:?}", x) - .split(" ") - .map(|x| x.to_string()) - .collect::>() - .first() - .cloned() - .unwrap(), - c, - )) - .collect::>(), - ); - } - }); - } - } -}