From 113727950b5217e2f16b6a69b8d76acc27ecca3b Mon Sep 17 00:00:00 2001 From: Kian Paimani <5588131+kianenigma@users.noreply.github.com> Date: Mon, 12 Sep 2022 15:27:11 +0100 Subject: [PATCH] Fuzz testing for nomination pools (#12002) * some additional tests and stuff * make sanity public * add some sort of fuzz test for pools * breaks every now and then * breaks every now and then * IT WORKS AND PASSES 100k TESTS * cleanup * safe id addition * fix assert_eq_error_rate * Update frame/nomination-pools/src/tests.rs Co-authored-by: Oliver Tale-Yazdi <oliver.tale-yazdi@parity.io> * Update frame/nomination-pools/src/tests.rs Co-authored-by: Oliver Tale-Yazdi <oliver.tale-yazdi@parity.io> * add some doc * Fix * ".git/.scripts/fmt.sh" 1 Co-authored-by: Oliver Tale-Yazdi <oliver.tale-yazdi@parity.io> Co-authored-by: command-bot <> --- substrate/Cargo.lock | 77 ++- substrate/frame/nomination-pools/Cargo.toml | 2 + substrate/frame/nomination-pools/src/lib.rs | 49 +- substrate/frame/nomination-pools/src/mock.rs | 3 +- substrate/frame/nomination-pools/src/tests.rs | 537 ++++++++++++++++++ substrate/frame/support/src/traits/misc.rs | 15 +- substrate/primitives/runtime/src/lib.rs | 21 +- .../benchmarking-cli/src/machine/hardware.rs | 8 +- 8 files changed, 647 insertions(+), 65 deletions(-) diff --git a/substrate/Cargo.lock b/substrate/Cargo.lock index f47ac8ba408..335e8b35267 100644 --- a/substrate/Cargo.lock +++ b/substrate/Cargo.lock @@ -905,7 +905,7 @@ dependencies = [ "ansi_term", "clap 3.1.18", "node-cli", - "rand 0.8.4", + "rand 0.8.5", "sc-chain-spec", "sc-keystore", "sp-core", @@ -2038,7 +2038,7 @@ dependencies = [ "num-traits", "parity-scale-codec", "parking_lot 0.12.1", - "rand 0.8.4", + "rand 0.8.5", "scale-info", ] @@ -2049,7 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c" dependencies = [ "byteorder", - "rand 0.8.4", + "rand 0.8.5", "rustc-hex", "static_assertions", ] @@ -2143,7 +2143,7 @@ dependencies = [ "log", "memory-db", "parity-scale-codec", - "rand 0.8.4", + "rand 0.8.5", "rand_pcg 0.3.1", "sc-block-builder", "sc-cli", @@ -2215,7 +2215,7 @@ dependencies = [ "frame-support", "honggfuzz", "parity-scale-codec", - "rand 0.8.4", + "rand 0.8.5", "scale-info", "sp-arithmetic", "sp-npos-elections", @@ -3212,7 +3212,7 @@ dependencies = [ "jsonrpsee-types", "lazy_static", "parking_lot 0.12.1", - "rand 0.8.4", + "rand 0.8.5", "rustc-hash", "serde", "serde_json", @@ -3593,7 +3593,7 @@ dependencies = [ "log", "prost", "prost-build", - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -3620,7 +3620,7 @@ dependencies = [ "pin-project", "prost", "prost-build", - "rand 0.8.4", + "rand 0.8.5", "ring", "rw-stream-sink", "sha2 0.10.2", @@ -3767,7 +3767,7 @@ dependencies = [ "libp2p-core", "libp2p-swarm", "log", - "rand 0.8.4", + "rand 0.8.5", "smallvec", "socket2", "void", @@ -3821,7 +3821,7 @@ dependencies = [ "log", "prost", "prost-build", - "rand 0.8.4", + "rand 0.8.5", "sha2 0.10.2", "snow", "static_assertions", @@ -3895,7 +3895,7 @@ dependencies = [ "prost", "prost-build", "prost-codec", - "rand 0.8.4", + "rand 0.8.5", "smallvec", "static_assertions", "thiserror", @@ -3918,7 +3918,7 @@ dependencies = [ "log", "prost", "prost-build", - "rand 0.8.4", + "rand 0.8.5", "sha2 0.10.2", "thiserror", "unsigned-varint", @@ -4076,7 +4076,7 @@ dependencies = [ "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", - "rand 0.8.4", + "rand 0.8.5", "serde", "sha2 0.9.8", "typenum", @@ -4502,7 +4502,7 @@ dependencies = [ "num-complex", "num-rational 0.4.0", "num-traits", - "rand 0.8.4", + "rand 0.8.5", "rand_distr", "simba", "typenum", @@ -4525,7 +4525,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7d66043b25d4a6cccb23619d10c19c25304b355a7dccd4a8e11423dd2382146" dependencies = [ - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -4685,7 +4685,7 @@ dependencies = [ "pallet-transaction-payment", "parity-scale-codec", "platforms", - "rand 0.8.4", + "rand 0.8.5", "regex", "remote-externalities", "sc-authority-discovery", @@ -5341,7 +5341,7 @@ dependencies = [ "frame-election-provider-support", "honggfuzz", "pallet-bags-list", - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -5495,7 +5495,7 @@ dependencies = [ "pallet-utility", "parity-scale-codec", "pretty_assertions", - "rand 0.8.4", + "rand 0.8.5", "rand_pcg 0.3.1", "scale-info", "serde", @@ -5931,6 +5931,7 @@ dependencies = [ "log", "pallet-balances", "parity-scale-codec", + "rand 0.8.5", "scale-info", "sp-core", "sp-io", @@ -6554,7 +6555,7 @@ dependencies = [ "lz4", "memmap2 0.2.1", "parking_lot 0.11.2", - "rand 0.8.4", + "rand 0.8.5", "snap", ] @@ -7168,7 +7169,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -7207,20 +7208,19 @@ dependencies = [ "libc", "rand_chacha 0.2.2", "rand_core 0.5.1", - "rand_hc 0.2.0", + "rand_hc", "rand_pcg 0.2.1", ] [[package]] name = "rand" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.0", "rand_core 0.6.2", - "rand_hc 0.3.0", ] [[package]] @@ -7268,7 +7268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -7280,15 +7280,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rand_hc" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" -dependencies = [ - "rand_core 0.6.2", -] - [[package]] name = "rand_pcg" version = "0.2.1" @@ -7907,7 +7898,7 @@ dependencies = [ "parity-scale-codec", "parking_lot 0.12.1", "quickcheck", - "rand 0.8.4", + "rand 0.8.5", "sc-client-api", "sc-state-db", "sp-arithmetic", @@ -8279,7 +8270,7 @@ dependencies = [ "log", "parity-scale-codec", "parking_lot 0.12.1", - "rand 0.8.4", + "rand 0.8.5", "sc-block-builder", "sc-chain-spec", "sc-client-api", @@ -9397,7 +9388,7 @@ dependencies = [ "futures", "httparse", "log", - "rand 0.8.4", + "rand 0.8.5", "sha-1 0.9.4", ] @@ -9877,7 +9868,7 @@ dependencies = [ "clap 3.1.18", "honggfuzz", "parity-scale-codec", - "rand 0.8.4", + "rand 0.8.5", "scale-info", "sp-npos-elections", "sp-runtime", @@ -10282,7 +10273,7 @@ dependencies = [ "lazy_static", "nalgebra", "num-traits", - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -11053,7 +11044,7 @@ dependencies = [ "ipnet", "lazy_static", "log", - "rand 0.8.4", + "rand 0.8.5", "smallvec", "thiserror", "tinyvec", @@ -11141,7 +11132,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if 1.0.0", "digest 0.10.3", - "rand 0.8.4", + "rand 0.7.3", "static_assertions", ] @@ -11859,7 +11850,7 @@ dependencies = [ "memfd", "memoffset", "paste 1.0.6", - "rand 0.8.4", + "rand 0.8.5", "rustix", "thiserror", "wasmtime-asm-macros", @@ -12151,7 +12142,7 @@ dependencies = [ "log", "nohash-hasher", "parking_lot 0.12.1", - "rand 0.8.4", + "rand 0.8.5", "static_assertions", ] diff --git a/substrate/frame/nomination-pools/Cargo.toml b/substrate/frame/nomination-pools/Cargo.toml index 1d613511eac..ef8465e670b 100644 --- a/substrate/frame/nomination-pools/Cargo.toml +++ b/substrate/frame/nomination-pools/Cargo.toml @@ -29,10 +29,12 @@ 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"] } [features] runtime-benchmarks = [] try-runtime = [ "frame-support/try-runtime" ] +fuzz-test = [] default = ["std"] std = [ "codec/std", diff --git a/substrate/frame/nomination-pools/src/lib.rs b/substrate/frame/nomination-pools/src/lib.rs index a8c4e50daa0..9e872955669 100644 --- a/substrate/frame/nomination-pools/src/lib.rs +++ b/substrate/frame/nomination-pools/src/lib.rs @@ -1453,7 +1453,7 @@ pub mod pallet { PartialUnbondNotAllowedPermissionlessly, } - #[derive(Encode, Decode, PartialEq, TypeInfo, frame_support::PalletError)] + #[derive(Encode, Decode, PartialEq, TypeInfo, frame_support::PalletError, RuntimeDebug)] pub enum DefensiveError { /// There isn't enough space in the unbond pool. NotEnoughSpaceInUnbondPool, @@ -1758,8 +1758,8 @@ pub mod pallet { let bonded_pool = BondedPool::<T>::get(member.pool_id) .defensive_ok_or::<Error<T>>(DefensiveError::PoolNotFound.into())?; - let mut sub_pools = SubPoolsStorage::<T>::get(member.pool_id) - .defensive_ok_or::<Error<T>>(DefensiveError::SubPoolsNotFound.into())?; + let mut sub_pools = + SubPoolsStorage::<T>::get(member.pool_id).ok_or(Error::<T>::SubPoolsNotFound)?; bonded_pool.ok_to_withdraw_unbonded_with(&caller, &member_account)?; @@ -1887,10 +1887,10 @@ pub mod pallet { ); ensure!(!PoolMembers::<T>::contains_key(&who), Error::<T>::AccountBelongsToOtherPool); - let pool_id = LastPoolId::<T>::mutate(|id| { - *id += 1; - *id - }); + let pool_id = LastPoolId::<T>::try_mutate::<_, Error<T>, _>(|id| { + *id = id.checked_add(1).ok_or(Error::<T>::OverflowRisk)?; + Ok(*id) + })?; let mut bonded_pool = BondedPool::<T>::new( pool_id, PoolRoles { @@ -2416,16 +2416,46 @@ impl<T: Config> Pallet<T> { for id in reward_pools { let account = Self::create_reward_account(id); - assert!(T::Currency::free_balance(&account) >= T::Currency::minimum_balance()); + assert!( + T::Currency::free_balance(&account) >= T::Currency::minimum_balance(), + "reward pool of {id}: {:?} (ed = {:?})", + T::Currency::free_balance(&account), + T::Currency::minimum_balance() + ); } let mut pools_members = BTreeMap::<PoolId, u32>::new(); + let mut pools_members_pending_rewards = BTreeMap::<PoolId, BalanceOf<T>>::new(); let mut all_members = 0u32; PoolMembers::<T>::iter().for_each(|(_, d)| { - assert!(BondedPools::<T>::contains_key(d.pool_id)); + let bonded_pool = BondedPools::<T>::get(d.pool_id).unwrap(); assert!(!d.total_points().is_zero(), "no member should have zero points: {:?}", d); *pools_members.entry(d.pool_id).or_default() += 1; all_members += 1; + + let reward_pool = RewardPools::<T>::get(d.pool_id).unwrap(); + if !bonded_pool.points.is_zero() { + let current_rc = + reward_pool.current_reward_counter(d.pool_id, bonded_pool.points).unwrap(); + *pools_members_pending_rewards.entry(d.pool_id).or_default() += + d.pending_rewards(current_rc).unwrap(); + } // else this pool has been heavily slashed and cannot have any rewards anymore. + }); + + RewardPools::<T>::iter_keys().for_each(|id| { + // the sum of the pending rewards must be less than the leftover balance. Since the + // reward math rounds down, we might accumulate some dust here. + log!( + trace, + "pool {:?}, sum pending rewards = {:?}, remaining balance = {:?}", + id, + pools_members_pending_rewards.get(&id), + RewardPool::<T>::current_balance(id) + ); + assert!( + RewardPool::<T>::current_balance(id) >= + pools_members_pending_rewards.get(&id).map(|x| *x).unwrap_or_default() + ) }); BondedPools::<T>::iter().for_each(|(id, inner)| { @@ -2470,6 +2500,7 @@ impl<T: Config> Pallet<T> { sum_unbonding_balance ); } + Ok(()) } diff --git a/substrate/frame/nomination-pools/src/mock.rs b/substrate/frame/nomination-pools/src/mock.rs index 4428c28558f..f1574b3684a 100644 --- a/substrate/frame/nomination-pools/src/mock.rs +++ b/substrate/frame/nomination-pools/src/mock.rs @@ -4,6 +4,7 @@ use frame_support::{assert_ok, parameter_types, PalletId}; use frame_system::RawOrigin; use sp_runtime::FixedU128; +pub type BlockNumber = u64; pub type AccountId = u128; pub type Balance = u128; pub type RewardCounter = FixedU128; @@ -129,7 +130,7 @@ impl frame_system::Config for Runtime { type BaseCallFilter = frame_support::traits::Everything; type Origin = Origin; type Index = u64; - type BlockNumber = u64; + type BlockNumber = BlockNumber; type Call = Call; type Hash = sp_core::H256; type Hashing = sp_runtime::traits::BlakeTwo256; diff --git a/substrate/frame/nomination-pools/src/tests.rs b/substrate/frame/nomination-pools/src/tests.rs index 20ba2b76fe3..362fece3f3d 100644 --- a/substrate/frame/nomination-pools/src/tests.rs +++ b/substrate/frame/nomination-pools/src/tests.rs @@ -1774,6 +1774,54 @@ mod claim_payout { }) } + #[test] + fn bond_extra_pending_rewards_works() { + ExtBuilder::default().add_members(vec![(20, 20)]).build_and_execute(|| { + MaxPoolMembers::<Runtime>::set(None); + MaxPoolMembersPerPool::<Runtime>::set(None); + + // pool receives some rewards. + Balances::mutate_account(&default_reward_account(), |f| f.free += 30).unwrap(); + System::reset_events(); + + // 10 cashes it out, and bonds it. + { + assert_ok!(Pools::claim_payout(Origin::signed(10))); + let (member, _, reward_pool) = Pools::get_member_with_pools(&10).unwrap(); + // there is 30 points and 30 reward points in the system RC is 1. + assert_eq!(member.last_recorded_reward_counter, 1.into()); + assert_eq!(reward_pool.total_rewards_claimed, 10); + // these two are not updated -- only updated when the points change. + assert_eq!(reward_pool.last_recorded_total_payouts, 0); + assert_eq!(reward_pool.last_recorded_reward_counter, 0.into()); + + assert_eq!( + pool_events_since_last_call(), + vec![Event::PaidOut { member: 10, pool_id: 1, payout: 10 }] + ); + } + + // 20 re-bonds it. + { + assert_ok!(Pools::bond_extra(Origin::signed(20), BondExtra::Rewards)); + let (member, _, reward_pool) = Pools::get_member_with_pools(&10).unwrap(); + assert_eq!(member.last_recorded_reward_counter, 1.into()); + assert_eq!(reward_pool.total_rewards_claimed, 30); + // since points change, these two are updated. + assert_eq!(reward_pool.last_recorded_total_payouts, 30); + assert_eq!(reward_pool.last_recorded_reward_counter, 1.into()); + + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::PaidOut { member: 20, pool_id: 1, payout: 20 }, + Event::Bonded { member: 20, pool_id: 1, bonded: 20, joined: false } + ] + ); + } + }) + } + #[test] fn unbond_updates_recorded_data() { ExtBuilder::default() @@ -3733,6 +3781,170 @@ mod withdraw_unbonded { }) } + #[test] + fn out_of_sync_unbonding_chunks() { + // the unbonding_eras in pool member are always fixed to the era at which they are unlocked, + // but the actual unbonding pools get pruned and might get combined in the no_era pool. + // Pools are only merged when one unbonds, so we unbond a little bit on every era to + // simulate this. + ExtBuilder::default() + .add_members(vec![(20, 100), (30, 100)]) + .build_and_execute(|| { + System::reset_events(); + + // when + assert_ok!(Pools::unbond(Origin::signed(20), 20, 5)); + assert_ok!(Pools::unbond(Origin::signed(30), 30, 5)); + + // then member-local unbonding is pretty much in sync with the global pools. + assert_eq!( + PoolMembers::<Runtime>::get(20).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 5) + ); + assert_eq!( + PoolMembers::<Runtime>::get(30).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 5) + ); + assert_eq!( + SubPoolsStorage::<Runtime>::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 10, balance: 10 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::Unbonded { member: 20, pool_id: 1, points: 5, balance: 5, era: 3 }, + Event::Unbonded { member: 30, pool_id: 1, points: 5, balance: 5, era: 3 }, + ] + ); + + // when + CurrentEra::set(1); + assert_ok!(Pools::unbond(Origin::signed(20), 20, 5)); + + // then still member-local unbonding is pretty much in sync with the global pools. + assert_eq!( + PoolMembers::<Runtime>::get(20).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 5, 4 => 5) + ); + assert_eq!( + SubPoolsStorage::<Runtime>::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 10, balance: 10 }, + 4 => UnbondPool { points: 5, balance: 5 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Unbonded { member: 20, pool_id: 1, points: 5, balance: 5, era: 4 }] + ); + + // when + CurrentEra::set(2); + assert_ok!(Pools::unbond(Origin::signed(20), 20, 5)); + + // then still member-local unbonding is pretty much in sync with the global pools. + assert_eq!( + PoolMembers::<Runtime>::get(20).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 5, 4 => 5, 5 => 5) + ); + assert_eq!( + SubPoolsStorage::<Runtime>::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 10, balance: 10 }, + 4 => UnbondPool { points: 5, balance: 5 }, + 5 => UnbondPool { points: 5, balance: 5 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Unbonded { member: 20, pool_id: 1, points: 5, balance: 5, era: 5 }] + ); + + // when + CurrentEra::set(5); + assert_ok!(Pools::unbond(Origin::signed(20), 20, 5)); + + // then + assert_eq!( + PoolMembers::<Runtime>::get(20).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 5, 4 => 5, 5 => 5, 8 => 5) + ); + assert_eq!( + SubPoolsStorage::<Runtime>::get(1).unwrap(), + SubPools { + // era 3 is merged into no_era. + no_era: UnbondPool { points: 10, balance: 10 }, + with_era: unbonding_pools_with_era! { + 4 => UnbondPool { points: 5, balance: 5 }, + 5 => UnbondPool { points: 5, balance: 5 }, + 8 => UnbondPool { points: 5, balance: 5 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Unbonded { member: 20, pool_id: 1, points: 5, balance: 5, era: 8 }] + ); + + // now we start withdrawing unlocked bonds. + + // when + assert_ok!(Pools::withdraw_unbonded(Origin::signed(20), 20, 0)); + // then + assert_eq!( + PoolMembers::<Runtime>::get(20).unwrap().unbonding_eras, + member_unbonding_eras!(8 => 5) + ); + assert_eq!( + SubPoolsStorage::<Runtime>::get(1).unwrap(), + SubPools { + // era 3 is merged into no_era. + no_era: UnbondPool { points: 5, balance: 5 }, + with_era: unbonding_pools_with_era! { + 8 => UnbondPool { points: 5, balance: 5 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Withdrawn { member: 20, pool_id: 1, points: 15, balance: 15 }] + ); + + // when + assert_ok!(Pools::withdraw_unbonded(Origin::signed(30), 30, 0)); + // then + assert_eq!( + PoolMembers::<Runtime>::get(30).unwrap().unbonding_eras, + member_unbonding_eras!() + ); + assert_eq!( + SubPoolsStorage::<Runtime>::get(1).unwrap(), + SubPools { + // era 3 is merged into no_era. + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 8 => UnbondPool { points: 5, balance: 5 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Withdrawn { member: 30, pool_id: 1, points: 5, balance: 5 }] + ); + }) + } + #[test] fn full_multi_step_withdrawing_depositor() { ExtBuilder::default().ed(1).build_and_execute(|| { @@ -4768,3 +4980,328 @@ 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<R: Rng>(rng: &mut R) -> (Origin, AccountId) { + let count = PoolMembers::<T>::count(); + if rng.gen::<bool>() && 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::<T>::iter_keys().skip(skip).take(1).next().unwrap(); + let acc = + if candidate == REWARD_AGENT_ACCOUNT { rng.gen::<AccountId>() } else { candidate }; + + (Origin::signed(acc), acc) + } else { + // create a new account + let acc = rng.gen::<AccountId>(); + (Origin::signed(acc), acc) + } + } + + fn random_ed_multiple<R: Rng>(rng: &mut R) -> Balance { + let multiple = rng.gen_range(MIN_ED_MULTIPLE..MAX_ED_MULTIPLE); + ExistentialDeposit::get() * multiple + } + + fn fund_account<R: Rng>(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<R: Rng>(mut rng: &mut R) -> Option<PoolId> { + BondedPools::<T>::iter_keys().collect::<Vec<_>>().choose(&mut rng).map(|x| *x) + } + + fn random_call<R: Rng>(mut rng: &mut R) -> (crate::pallet::Call<T>, Origin) { + let op = rng.gen::<usize>(); + let mut op_count = + <crate::pallet::Call<T> 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::<T>::join { amount, pool_id }, origin) + }, + 1 => { + // bond_extra + let (origin, who) = random_signed_origin(&mut rng); + let extra = if rng.gen::<bool>() { + BondExtra::Rewards + } else { + fund_account(&mut rng, &who); + let amount = random_ed_multiple(&mut rng); + BondExtra::FreeBalance(amount) + }; + (PoolsCall::<T>::bond_extra { extra }, origin) + }, + 2 => { + // claim_payout + let (origin, _) = random_signed_origin(&mut rng); + (PoolsCall::<T>::claim_payout {}, origin) + }, + 3 => { + // unbond + let (origin, who) = random_signed_origin(&mut rng); + let amount = random_ed_multiple(&mut rng); + (PoolsCall::<T>::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::<T>::pool_withdraw_unbonded { pool_id, num_slashing_spans: 0 }, origin) + }, + 5 => { + // withdraw_unbonded + let (origin, who) = random_signed_origin(&mut rng); + ( + PoolsCall::<T>::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::<T>::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::<T>::nominate { pool_id, validators }, origin) + }, + _ => unreachable!(), + } + } + + #[derive(Default)] + struct RewardAgent { + who: AccountId, + pool_id: Option<PoolId>, + 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::<T>::get(); + let amount = 10 * ExistentialDeposit::get(); + let origin = Origin::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::<T> { 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::<T>::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 = Origin::signed(42); + assert_ok!(PoolsCall::<T>::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<T>, u32)>::default(); + let mut iteration = 0 as BlockNumber; + let mut ok = 0; + let mut err = 0; + + ext.execute_with(|| { + MaxPoolMembers::<T>::set(Some(10_000)); + MaxPoolMembersPerPool::<T>::set(Some(1000)); + MaxPools::<T>::set(Some(1_000)); + + MinCreateBond::<T>::set(10 * ExistentialDeposit::get()); + MinJoinBond::<T>::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::<T>::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::<u64>().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::<T>::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::<T>::get(id).map(|p| p.points).unwrap(); + let member_points = + PoolMembers::<T>::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::<T>::count(), + PoolMembers::<T>::count(), + ok, + err, + events_histogram + .iter() + .map(|(x, c)| ( + format!("{:?}", x) + .split(" ") + .map(|x| x.to_string()) + .collect::<Vec<_>>() + .first() + .cloned() + .unwrap(), + c, + )) + .collect::<Vec<_>>(), + ); + } + }); + } + } +} diff --git a/substrate/frame/support/src/traits/misc.rs b/substrate/frame/support/src/traits/misc.rs index 0f781d0bbd4..59d5ec067ba 100644 --- a/substrate/frame/support/src/traits/misc.rs +++ b/substrate/frame/support/src/traits/misc.rs @@ -151,10 +151,10 @@ pub trait DefensiveOption<T> { /// Defensively transform this option to a result, mapping `None` to the return value of an /// error closure. - fn defensive_ok_or_else<E, F: FnOnce() -> E>(self, err: F) -> Result<T, E>; + fn defensive_ok_or_else<E: sp_std::fmt::Debug, F: FnOnce() -> E>(self, err: F) -> Result<T, E>; /// Defensively transform this option to a result, mapping `None` to a default value. - fn defensive_ok_or<E>(self, err: E) -> Result<T, E>; + fn defensive_ok_or<E: sp_std::fmt::Debug>(self, err: E) -> Result<T, E>; /// Exactly the same as `map`, but it prints the appropriate warnings if the value being mapped /// is `None`. @@ -318,16 +318,17 @@ impl<T> DefensiveOption<T> for Option<T> { ) } - fn defensive_ok_or_else<E, F: FnOnce() -> E>(self, err: F) -> Result<T, E> { + fn defensive_ok_or_else<E: sp_std::fmt::Debug, F: FnOnce() -> E>(self, err: F) -> Result<T, E> { self.ok_or_else(|| { - defensive!(); - err() + let err_value = err(); + defensive!(err_value); + err_value }) } - fn defensive_ok_or<E>(self, err: E) -> Result<T, E> { + fn defensive_ok_or<E: sp_std::fmt::Debug>(self, err: E) -> Result<T, E> { self.ok_or_else(|| { - defensive!(); + defensive!(err); err }) } diff --git a/substrate/primitives/runtime/src/lib.rs b/substrate/primitives/runtime/src/lib.rs index 7006a13e415..9c2b691f64f 100644 --- a/substrate/primitives/runtime/src/lib.rs +++ b/substrate/primitives/runtime/src/lib.rs @@ -36,6 +36,8 @@ pub use sp_std; #[doc(hidden)] pub use paste; +#[doc(hidden)] +pub use sp_arithmetic::traits::Saturating; #[doc(hidden)] pub use sp_application_crypto as app_crypto; @@ -825,7 +827,24 @@ pub fn verify_encoded_lazy<V: Verify, T: codec::Encode>( macro_rules! assert_eq_error_rate { ($x:expr, $y:expr, $error:expr $(,)?) => { assert!( - ($x) >= (($y) - ($error)) && ($x) <= (($y) + ($error)), + ($x >= $crate::Saturating::saturating_sub($y, $error)) && + ($x <= $crate::Saturating::saturating_add($y, $error)), + "{:?} != {:?} (with error rate {:?})", + $x, + $y, + $error, + ); + }; +} + +/// Same as [`assert_eq_error_rate`], but intended to be used with floating point number, or +/// generally those who do not have over/underflow potentials. +#[macro_export] +#[cfg(feature = "std")] +macro_rules! assert_eq_error_rate_float { + ($x:expr, $y:expr, $error:expr $(,)?) => { + assert!( + ($x >= $y - $error) && ($x <= $y + $error), "{:?} != {:?} (with error rate {:?})", $x, $y, diff --git a/substrate/utils/frame/benchmarking-cli/src/machine/hardware.rs b/substrate/utils/frame/benchmarking-cli/src/machine/hardware.rs index 5c62660cc7c..97960de99c4 100644 --- a/substrate/utils/frame/benchmarking-cli/src/machine/hardware.rs +++ b/substrate/utils/frame/benchmarking-cli/src/machine/hardware.rs @@ -161,7 +161,7 @@ impl fmt::Display for Throughput { #[cfg(test)] mod tests { use super::*; - use sp_runtime::assert_eq_error_rate; + use sp_runtime::assert_eq_error_rate_float; /// `SUBSTRATE_REFERENCE_HARDWARE` can be en- and decoded. #[test] @@ -179,9 +179,9 @@ mod tests { const EPS: f64 = 0.1; let gib = Throughput::GiBs(14.324); - assert_eq_error_rate!(14.324, gib.to_gibs(), EPS); - assert_eq_error_rate!(14667.776, gib.to_mibs(), EPS); - assert_eq_error_rate!(14667.776 * 1024.0, gib.to_kibs(), EPS); + assert_eq_error_rate_float!(14.324, gib.to_gibs(), EPS); + assert_eq_error_rate_float!(14667.776, gib.to_mibs(), EPS); + assert_eq_error_rate_float!(14667.776 * 1024.0, gib.to_kibs(), EPS); assert_eq!("14.32 GiB/s", gib.to_string()); assert_eq!("14.32 GiB/s", gib.normalize().to_string()); -- GitLab