From 9e41e40c4016deff3a1babccbf7ce3419e966a79 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Wed, 27 Apr 2022 03:46:47 -0700 Subject: [PATCH] Nomination Pools (#10694) * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Add admin roles and make some calls permissionless * Destroy pool in withdraw unbonded * Add docs on pool admin * Fixup tests * Test unbond_other permissionless scenarios * Test withdraw unbonded permissionless * Test only can join open pools * Move unsafe set state to mock * Test: nominate_works * Add bounds: MinJoinBond, MinCreateBond, MaxPools * Test MinCreateBond, MinJoinBond, MaxPools * Add post checks to tests * Remove some TODOs * Setup weight infrastructure * Benchmark claim_payout * Benchmark create * Benchmark nominate * Benchmark join * Benchmark unbond_other * Refactor join benchmark to use scenario setup * Clean up and address warnings * Basic withdraw unbonded benchmarks * Refactor nominate benchmark * Refactor claim payout * Add feature sp-staking/runtime-benchmarks * Get node runtime to compile * Get node to run * Make claim_payout bench work with node * Make pool_withdraw_unbonded bench work with node * Make withdraw_unbonded_other work with node runtime' * Make create benchmark work with node * Make nominate benchmark work with node runtime * WiP new benchmark crate * Implement initial mock for benchmarks * Establish benchmark setup logic * Get claim payout and nominate benchmarks working * Remove pool bench utils; make struct fields pub insteaad * Get more benchmarks to work; trim interface trait * Some more top level docs * Finish tranistion benchmarks to crate * Hook up benchmark pallet to node runtime * Get benches to work with node runtime * cargo run --quiet --profile=production --features=runtime-benchmarks --manifest-path=bin/node/cli/Cargo.toml -- benchmark --chain=dev --steps=50 --repeat=20 --pallet=pallet_nomination_pools --extrinsic=* --execution=wasm --wasm-execution=compiled --heap-pages=4096 --output=./frame/nomination-pools/src/weights.rs --template=./.maintain/frame-weight-template.hbs * Benchmark withdraw_unbonded_other_kill * Delete old benchmarking files * Refunds for withdraw_unbonded * Remove some TODOs * 'Don't return an option for the current_era' * Streamline extrinsic docs * small docs tweaks * Refactor ledger::slash * Add on_slash impl for nomination pools * slash refactor wip * WIP slash working * DRY Ledger::stash * Fix slash saturation * Remove unused param from slash * Docs and warnings * Test ledger::slash * save progress * Introduce counter for delegators * Add tests for max delegator errors * Reproducible account ids * Adapt tests to new account id format * Simplify create_accounts api * Fix staking tests * Save PerBill slash impl before removing * Rever ledger slash test * Get node runtime to work * Organize sub pools by unbond era, not curren era * staking: Proportional ledger slashing * Some comment cleanup * Add more test post checks * Update frame/staking/src/pallet/mod.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Tests: account for storing unbond era * Improve docs for staking interface * Add events Created, Destroyed * withdraw_unbonded: Remove useless withdraw dust check * Test: withdraw_unbonded_other_handles_faulty_sub_pool_accounting * Add extrinsics: set_state_other, set_metadata * Test: set_state_other_works * Test: set_metadata_works * Add benchmarks for set_state_other, set_metadata * Fix benchmarks * Add weight info for new extrinsics * Some feedback * duo feedback * Incorporate some more feedback * integrate more kian feedback * integrate more kian feedback * More improvements * Add destroying_mul * Make do_reward_payout take refs * Remove some TODOs * Add test for saturating * feedback * Fix join test * use `inner` for nested types in nomination pools (#11030) * Use nested inner type for pool * make tests and benchmarks work * remove feat * all tests work now * fix node-runtime * nomination-pools: update benches for new account format (#11033) * Update benches to new account format * More sensible seeds * bring back rward account sanity check * Comment * Add extrinsic set_configs (#11038) * Better sanity checks for nomination pools (#11042) * new sanity checks, few other changes * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * nomination-pools: Fix state event regression + benches (#11045) * new sanity checks, few other changes * Fix benches, improve sanity check * Remove useless clear storage in benchmarking * Set state * Save * Doc * Update frame/nomination-pools/src/lib.rs Co-authored-by: kianenigma * FMT * Try fill in all staking configs * Fix build * More changes to nomination pools (#11050) * new sanity checks, few other changes * some last touches as a whole * Apply suggestions from code review * Remove redundant event * Improve unbond_other error handling * Remove comment Co-authored-by: Zeke Mostov Co-authored-by: emostov <32168567+emostov@users.noreply.github.com> * Remove sanity module and some TODOs * round of feedback and imp from kian * Add TODO for ED QoL at reward pool creation * Make sure reward pool never gets dusted * Improve error type * demonstrate per_thing usage * Update sanity check & fix create_works * Improve test ext pool creation & fix some more tests * Try revert * Revert "Try revert" This reverts commit a6d1c9b920ad995fa8cdcbf6561b8db81c7a7783. * Revert "Improve test ext pool creation & fix some more tests" This reverts commit b410010ec4c9bc05410b62ebbba2a39d68d18c99. * Revert "Update sanity check & fix create_works" This reverts commit cdc2a32d22e6c00609bf496a5854b8f99f031156. Roll back reward account funding * Revert "Improve error type" This reverts commit f48cd379e608f98c70bd64a83fbc2e4530b115e1. * Revert "Make sure reward pool never gets dusted" This reverts commit 850cb3e80aa6462830bec7b545a434d0528ae774. revert * Update some tests * FMT * Test that era offset works correctly * Update mocks * Remove unnescary docs * Doc updates * Update calculate_delegator_payout_works_with_a_pool_of_1 * Fix test: claim_payout_works * do_reward_payout_correctly_sets_pool_state_to_destroying * Remove test do_reward_payout_errors_correctly * Fix test: do_reward_payout_works * Fix test: create_errors_correctly * Fix test: create works * Fix test: unbond_other_of_3_works * Ensure that ED is transferred into reward pool upon creation * WIP pool lifecycle test * Fix benchmarks * Add sanity check for ED + reward pools * `bond_extra` for nomination pools (#11100) * bond_extra for nomination pools * Update frame/nomination-pools/src/lib.rs * Update frame/nomination-pools/src/lib.rs * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * add benchmarks * remove the min logic of bond_extra Co-authored-by: Zeke Mostov * FMT * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * make it compile end to end * Update some type viz * Update kick terminology * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Update frame/nomination-pools/src/lib.rs * Cache bonded account when creating pool * Add bond extra weight stuff * Update frame/nomination-pools/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * Update docs for pool withdraw unbonded * Update docs for unbond * Improve Doc * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Update frame/nomination-pools/Cargo.toml Co-authored-by: Oliver Tale-Yazdi * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Improve Docs * Some docs improvements * fmt * Remove unlock_era * Fix accidental frame-support regression * Fix issue with transactions in tests * Fix doc links * Make sure result in test is used * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Fix can toggle state * Account for new_funds in ok to be open * Update docs: ok_to_withdraw_unbonded_other_with * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Update frame/nomination-pools/src/lib.rs Co-authored-by: Oliver Tale-Yazdi * Remove some staking comments * Rename SubPoolsWithEra to UnbondingPoolsWithEra * Use validators length for benchmarks * Use metadata length for benchmarks * Remove debug assert eq * docs * Fix test: withdraw_unbonded_other_errors_correctly * Fix check for having enough balance to create the pool * Bond event for pool creation * Ok to be open * FMT * Remove _other postfix * Update frame/staking/src/lib.rs * Adjust tests to account for only remove when < ED * Remove stale TODOs * Remove dupe test * Fix build * Make sure to convert to u256 so we don't saturate * Refund depositor with reward pool fee * FMT * Remove reachable defensive * Use compact encoding for relevant extrinsics * Remove unnescary make_free_be for cleaning reward account * Add not to maintainers for reward account accounting * Remove note to maintainers from public doc * Make sure all configs have currency balance * Avoid saturation in balance_to_unbond * Partial Unbonding for Nomination Pools (#11212) * first draft of partial unbonding for pools * remove option * Add some more tests and fix issues * Fix all tests * simplify some tests * Update frame/nomination-pools/src/mock.rs * remove clone * rename to delegator_unbonding_eras * Update frame/nomination-pools/src/tests.rs Co-authored-by: Zeke Mostov * Update frame/nomination-pools/src/tests.rs Co-authored-by: Zeke Mostov * Update frame/nomination-pools/src/tests.rs Co-authored-by: Zeke Mostov * remove pub * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * undo * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * leftovers * fix invariant * Fix the depositor assumption * round of self-review * little bit more cleanup * Update frame/nomination-pools/src/mock.rs * Apply suggestions from code review * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * Fix interpretation of MinCreateBond * controvesial refactor * rename * make everything build * add TODO about killing the reward account * Update frame/nomination-pools/src/lib.rs Co-authored-by: Zeke Mostov * Update frame/nomination-pools/src/lib.rs * last self-review Co-authored-by: Zeke Mostov * Update Cargo.lock * Rename Delegator to PoolMember * fmt * Get runtime to build with runtime-benchmarks feature * Update Cargo.lock * Fix asserts to work in more scenarios * gte not gt * cargo run --quiet --profile=production --features=runtime-benchmarks --manifest-path=bin/node/cli/Cargo.toml -- benchmark pallet --chain=dev --steps=50 --repeat=20 --pallet=pallet_nomination_pools --extrinsic=* --execution=wasm --wasm-execution=compiled --heap-pages=4096 --output=./frame/nomination-pools/src/weights.rs --template=./.maintain/frame-weight-template.hbs * Update frame/staking/src/mock.rs * Update frame/nomination-pools/src/lib.rs * Update frame/staking/src/slashing.rs * Apply suggestions from code review * fmt * Fix some tests Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> Co-authored-by: Parity Bot Co-authored-by: kianenigma Co-authored-by: Oliver Tale-Yazdi Co-authored-by: Shawn Tabrizi --- Cargo.lock | 42 + Cargo.toml | 2 + bin/node/cli/src/chain_spec.rs | 1 + bin/node/runtime/Cargo.toml | 4 + bin/node/runtime/src/lib.rs | 39 +- bin/node/testing/src/genesis.rs | 1 + frame/bags-list/src/benchmarks.rs | 12 +- frame/nomination-pools/Cargo.toml | 44 + .../nomination-pools/benchmarking/Cargo.toml | 55 + frame/nomination-pools/benchmarking/README.md | 0 .../nomination-pools/benchmarking/src/lib.rs | 639 ++++ .../nomination-pools/benchmarking/src/mock.rs | 200 ++ frame/nomination-pools/src/lib.rs | 2217 +++++++++++++ frame/nomination-pools/src/mock.rs | 309 ++ frame/nomination-pools/src/tests.rs | 2929 +++++++++++++++++ frame/nomination-pools/src/weights.rs | 482 +++ frame/staking/Cargo.toml | 1 + frame/staking/src/benchmarking.rs | 2 +- frame/staking/src/lib.rs | 12 + frame/staking/src/mock.rs | 2 +- frame/staking/src/pallet/impls.rs | 71 +- frame/staking/src/pallet/mod.rs | 2 +- frame/support/src/lib.rs | 21 + .../support/src/storage/bounded_btree_map.rs | 8 + frame/support/src/traits/misc.rs | 13 +- primitives/staking/Cargo.toml | 1 + primitives/staking/src/lib.rs | 80 +- 27 files changed, 7174 insertions(+), 15 deletions(-) create mode 100644 frame/nomination-pools/Cargo.toml create mode 100644 frame/nomination-pools/benchmarking/Cargo.toml create mode 100644 frame/nomination-pools/benchmarking/README.md create mode 100644 frame/nomination-pools/benchmarking/src/lib.rs create mode 100644 frame/nomination-pools/benchmarking/src/mock.rs create mode 100644 frame/nomination-pools/src/lib.rs create mode 100644 frame/nomination-pools/src/mock.rs create mode 100644 frame/nomination-pools/src/tests.rs create mode 100644 frame/nomination-pools/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index eeca5975a6f2d..596b737e22d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5027,6 +5027,8 @@ dependencies = [ "pallet-membership", "pallet-mmr", "pallet-multisig", + "pallet-nomination-pools", + "pallet-nomination-pools-benchmarking", "pallet-offences", "pallet-offences-benchmarking", "pallet-preimage", @@ -6182,6 +6184,46 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-nomination-pools" +version = "1.0.0" +dependencies = [ + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-staking", + "sp-std", + "sp-tracing", +] + +[[package]] +name = "pallet-nomination-pools-benchmarking" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-election-provider-support", + "frame-support", + "frame-system", + "pallet-bags-list", + "pallet-balances", + "pallet-nomination-pools", + "pallet-staking", + "pallet-staking-reward-curve", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-staking", + "sp-std", +] + [[package]] name = "pallet-offences" version = "4.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index 5cc90ec6f183b..f91e6226ccfd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,8 @@ members = [ "frame/offences", "frame/preimage", "frame/proxy", + "frame/nomination-pools", + "frame/nomination-pools/benchmarking", "frame/randomness-collective-flip", "frame/recovery", "frame/referenda", diff --git a/bin/node/cli/src/chain_spec.rs b/bin/node/cli/src/chain_spec.rs index 1212d3b3d07ed..221c229876275 100644 --- a/bin/node/cli/src/chain_spec.rs +++ b/bin/node/cli/src/chain_spec.rs @@ -363,6 +363,7 @@ pub fn testnet_genesis( gilt: Default::default(), transaction_storage: Default::default(), transaction_payment: Default::default(), + nomination_pools: Default::default(), } } diff --git a/bin/node/runtime/Cargo.toml b/bin/node/runtime/Cargo.toml index 847530e5c61bc..09f7534e7f521 100644 --- a/bin/node/runtime/Cargo.toml +++ b/bin/node/runtime/Cargo.toml @@ -76,6 +76,8 @@ pallet-lottery = { version = "4.0.0-dev", default-features = false, path = "../. pallet-membership = { version = "4.0.0-dev", default-features = false, path = "../../../frame/membership" } pallet-mmr = { version = "4.0.0-dev", default-features = false, path = "../../../frame/merkle-mountain-range" } pallet-multisig = { version = "4.0.0-dev", default-features = false, path = "../../../frame/multisig" } +pallet-nomination-pools = { version = "1.0.0", default-features = false, path = "../../../frame/nomination-pools"} +pallet-nomination-pools-benchmarking = { version = "1.0.0", default-features = false, optional = true, path = "../../../frame/nomination-pools/benchmarking" } pallet-offences = { version = "4.0.0-dev", default-features = false, path = "../../../frame/offences" } pallet-offences-benchmarking = { version = "4.0.0-dev", path = "../../../frame/offences/benchmarking", default-features = false, optional = true } pallet-preimage = { version = "4.0.0-dev", default-features = false, path = "../../../frame/preimage" } @@ -140,6 +142,7 @@ std = [ "pallet-membership/std", "pallet-mmr/std", "pallet-multisig/std", + "pallet-nomination-pools/std", "pallet-identity/std", "pallet-scheduler/std", "node-primitives/std", @@ -210,6 +213,7 @@ runtime-benchmarks = [ "pallet-membership/runtime-benchmarks", "pallet-mmr/runtime-benchmarks", "pallet-multisig/runtime-benchmarks", + "pallet-nomination-pools-benchmarking", "pallet-offences-benchmarking", "pallet-preimage/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index f073482d887bd..54101cc222f84 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -561,7 +561,7 @@ impl pallet_staking::Config for Runtime { type GenesisElectionProvider = onchain::UnboundedExecution; type VoterList = BagsList; type MaxUnlockingChunks = ConstU32<32>; - type OnStakerSlash = (); + type OnStakerSlash = NominationPools; type WeightInfo = pallet_staking::weights::SubstrateWeight; type BenchmarkingConfig = StakingBenchmarkingConfig; } @@ -705,6 +705,38 @@ impl pallet_bags_list::Config for Runtime { type Score = VoteWeight; } +parameter_types! { + pub const PostUnbondPoolsWindow: u32 = 4; + pub const NominationPoolsPalletId: PalletId = PalletId(*b"py/npols"); +} + +use sp_runtime::traits::Convert; +pub struct BalanceToU256; +impl Convert for BalanceToU256 { + fn convert(balance: Balance) -> sp_core::U256 { + sp_core::U256::from(balance) + } +} +pub struct U256ToBalance; +impl Convert for U256ToBalance { + fn convert(n: sp_core::U256) -> Balance { + n.try_into().unwrap_or(Balance::max_value()) + } +} + +impl pallet_nomination_pools::Config for Runtime { + type WeightInfo = (); + type Event = Event; + type Currency = Balances; + type BalanceToU256 = BalanceToU256; + type U256ToBalance = U256ToBalance; + type StakingInterface = pallet_staking::Pallet; + type PostUnbondingPoolsWindow = PostUnbondPoolsWindow; + type MaxMetadataLen = ConstU32<256>; + type MaxUnbonding = ConstU32<8>; + type PalletId = NominationPoolsPalletId; +} + parameter_types! { pub const VoteLockingPeriod: BlockNumber = 30 * DAYS; } @@ -1470,6 +1502,7 @@ construct_runtime!( Remark: pallet_remark, ConvictionVoting: pallet_conviction_voting, Whitelist: pallet_whitelist, + NominationPools: pallet_nomination_pools, } ); @@ -1554,6 +1587,7 @@ mod benches { [pallet_membership, TechnicalMembership] [pallet_mmr, Mmr] [pallet_multisig, Multisig] + [pallet_nomination_pools, NominationPoolsBench::] [pallet_offences, OffencesBench::] [pallet_preimage, Preimage] [pallet_proxy, Proxy] @@ -1869,6 +1903,7 @@ impl_runtime_apis! { use pallet_election_provider_support_benchmarking::Pallet as EPSBench; use frame_system_benchmarking::Pallet as SystemBench; use baseline::Pallet as BaselineBench; + use pallet_nomination_pools_benchmarking::Pallet as NominationPoolsBench; let mut list = Vec::::new(); list_benchmarks!(list, extra); @@ -1891,12 +1926,14 @@ impl_runtime_apis! { use pallet_election_provider_support_benchmarking::Pallet as EPSBench; use frame_system_benchmarking::Pallet as SystemBench; use baseline::Pallet as BaselineBench; + use pallet_nomination_pools_benchmarking::Pallet as NominationPoolsBench; impl pallet_session_benchmarking::Config for Runtime {} impl pallet_offences_benchmarking::Config for Runtime {} impl pallet_election_provider_support_benchmarking::Config for Runtime {} impl frame_system_benchmarking::Config for Runtime {} impl baseline::Config for Runtime {} + impl pallet_nomination_pools_benchmarking::Config for Runtime {} let whitelist: Vec = vec![ // Block Number diff --git a/bin/node/testing/src/genesis.rs b/bin/node/testing/src/genesis.rs index 8d2b53b0b7210..73e7ea50261d3 100644 --- a/bin/node/testing/src/genesis.rs +++ b/bin/node/testing/src/genesis.rs @@ -92,5 +92,6 @@ pub fn config_endowed(code: Option<&[u8]>, extra_endowed: Vec) -> Gen gilt: Default::default(), transaction_storage: Default::default(), transaction_payment: Default::default(), + nomination_pools: Default::default(), } } diff --git a/frame/bags-list/src/benchmarks.rs b/frame/bags-list/src/benchmarks.rs index bcfd1e3392b0e..1a901c4d9d5a1 100644 --- a/frame/bags-list/src/benchmarks.rs +++ b/frame/bags-list/src/benchmarks.rs @@ -179,10 +179,10 @@ frame_benchmarking::benchmarks! { vec![heavier, lighter, heavier_prev, heavier_next] ) } -} -frame_benchmarking::impl_benchmark_test_suite!( - Pallet, - crate::mock::ExtBuilder::default().skip_genesis_ids().build(), - crate::mock::Runtime -); + impl_benchmark_test_suite!( + Pallet, + crate::mock::ExtBuilder::default().skip_genesis_ids().build(), + crate::mock::Runtime + ); +} diff --git a/frame/nomination-pools/Cargo.toml b/frame/nomination-pools/Cargo.toml new file mode 100644 index 0000000000000..882a6f363a14f --- /dev/null +++ b/frame/nomination-pools/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "pallet-nomination-pools" +version = "1.0.0" +authors = ["Parity Technologies "] +edition = "2021" +license = "Apache-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME nomination pools pallet" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +# parity +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +scale-info = { version = "2.0.1", default-features = false, features = ["derive"] } + +# FRAME +frame-support = { version = "4.0.0-dev", default-features = false, path = "../support" } +frame-system = { version = "4.0.0-dev", default-features = false, path = "../system" } +sp-runtime = { version = "6.0.0", default-features = false, path = "../../primitives/runtime" } +sp-std = { version = "4.0.0", default-features = false, path = "../../primitives/std" } +sp-staking = { version = "4.0.0-dev", default-features = false, path = "../../primitives/staking" } +sp-core = { version = "6.0.0", default-features = false, path = "../../primitives/core" } + +[dev-dependencies] +pallet-balances = { version = "4.0.0-dev", path = "../balances" } +sp-io = { version = "6.0.0", path = "../../primitives/io" } +sp-tracing = { version = "5.0.0", path = "../../primitives/tracing" } + +[features] +runtime-benchmarks = [] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "sp-runtime/std", + "sp-std/std", + "sp-staking/std", + "sp-core/std", +] diff --git a/frame/nomination-pools/benchmarking/Cargo.toml b/frame/nomination-pools/benchmarking/Cargo.toml new file mode 100644 index 0000000000000..58158c20cf195 --- /dev/null +++ b/frame/nomination-pools/benchmarking/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "pallet-nomination-pools-benchmarking" +version = "1.0.0" +authors = ["Parity Technologies "] +edition = "2021" +license = "Apache-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME nomination pools pallet benchmarking" +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +# parity +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +scale-info = { version = "2.0.1", default-features = false, features = ["derive"] } + +# FRAME +frame-benchmarking = { version = "4.0.0-dev", default-features = false, path = "../../benchmarking" } +frame-election-provider-support = { version = "4.0.0-dev", default-features = false, path = "../../election-provider-support" } +frame-support = { version = "4.0.0-dev", default-features = false, path = "../../support" } +frame-system = { version = "4.0.0-dev", default-features = false, path = "../../system" } +pallet-bags-list = { version = "4.0.0-dev", default-features = false, features = ["runtime-benchmarks"], path = "../../bags-list" } +pallet-staking = { version = "4.0.0-dev", default-features = false, features = ["runtime-benchmarks"], path = "../../staking" } +pallet-nomination-pools = { version = "1.0.0", default-features = false, path = "../", features = ["runtime-benchmarks"] } + +# Substrate Primitives +sp-runtime = { version = "6.0.0", default-features = false, path = "../../../primitives/runtime" } +sp-staking = { version = "4.0.0-dev", default-features = false, path = "../../../primitives/staking" } +sp-std = { version = "4.0.0", default-features = false, path = "../../../primitives/std" } + +[dev-dependencies] +pallet-balances = { version = "4.0.0-dev", default-features = false, path = "../../balances" } +pallet-timestamp = { version = "4.0.0-dev", path = "../../timestamp" } +pallet-staking-reward-curve = { version = "4.0.0-dev", path = "../../staking/reward-curve" } +sp-core = { version = "6.0.0", path = "../../../primitives/core" } +sp-io = { version = "6.0.0", path = "../../../primitives/io" } + +[features] +default = ["std"] +std = [ + "frame-benchmarking/std", + "frame-election-provider-support/std", + "frame-support/std", + "frame-system/std", + "pallet-bags-list/std", + "pallet-staking/std", + "pallet-nomination-pools/std", + "sp-runtime/std", + "sp-staking/std", + "sp-std/std", + "pallet-balances/std", +] diff --git a/frame/nomination-pools/benchmarking/README.md b/frame/nomination-pools/benchmarking/README.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/frame/nomination-pools/benchmarking/src/lib.rs b/frame/nomination-pools/benchmarking/src/lib.rs new file mode 100644 index 0000000000000..7ae6338e6304a --- /dev/null +++ b/frame/nomination-pools/benchmarking/src/lib.rs @@ -0,0 +1,639 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-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. + +//! Benchmarks for the nomination pools coupled with the staking and bags list pallets. + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; + +use frame_benchmarking::{account, frame_support::traits::Currency, vec, whitelist_account, Vec}; +use frame_election_provider_support::SortedListProvider; +use frame_support::{ensure, traits::Get}; +use frame_system::RawOrigin as Origin; +use pallet_nomination_pools::{ + BalanceOf, BondExtra, BondedPoolInner, BondedPools, ConfigOp, MaxPoolMembers, + MaxPoolMembersPerPool, MaxPools, Metadata, MinCreateBond, MinJoinBond, Pallet as Pools, + PoolMembers, PoolRoles, PoolState, RewardPools, SubPoolsStorage, +}; +use sp_runtime::traits::{Bounded, Zero}; +use sp_staking::{EraIndex, StakingInterface}; +// `frame_benchmarking::benchmarks!` macro needs this +use pallet_nomination_pools::Call; + +type CurrencyOf = ::Currency; + +const USER_SEED: u32 = 0; +const MAX_SPANS: u32 = 100; + +pub trait Config: + pallet_nomination_pools::Config + pallet_staking::Config + pallet_bags_list::Config +{ +} + +pub struct Pallet(Pools); + +fn create_funded_user_with_balance( + string: &'static str, + n: u32, + balance: BalanceOf, +) -> T::AccountId { + let user = account(string, n, USER_SEED); + T::Currency::make_free_balance_be(&user, balance); + user +} + +// Create a bonded pool account, bonding `balance` and giving the account `balance * 2` free +// balance. +fn create_pool_account( + n: u32, + balance: BalanceOf, +) -> (T::AccountId, T::AccountId) { + let ed = CurrencyOf::::minimum_balance(); + let pool_creator: T::AccountId = + create_funded_user_with_balance::("pool_creator", n, ed + balance * 2u32.into()); + + Pools::::create( + Origin::Signed(pool_creator.clone()).into(), + balance, + pool_creator.clone(), + pool_creator.clone(), + pool_creator.clone(), + ) + .unwrap(); + + let pool_account = pallet_nomination_pools::BondedPools::::iter() + .find(|(_, bonded_pool)| bonded_pool.roles.depositor == pool_creator) + .map(|(pool_id, _)| Pools::::create_bonded_account(pool_id)) + .expect("pool_creator created a pool above"); + + (pool_creator, pool_account) +} + +fn vote_to_balance( + vote: u64, +) -> Result, &'static str> { + vote.try_into().map_err(|_| "could not convert u64 to Balance") +} + +#[allow(unused)] +struct ListScenario { + /// Stash/Controller that is expected to be moved. + origin1: T::AccountId, + creator1: T::AccountId, + dest_weight: BalanceOf, + origin1_member: Option, +} + +impl ListScenario { + /// An expensive scenario for bags-list implementation: + /// + /// - the node to be updated (r) is the head of a bag that has at least one other node. The bag + /// itself will need to be read and written to update its head. The node pointed to by r.next + /// will need to be read and written as it will need to have its prev pointer updated. Note + /// that there are two other worst case scenarios for bag removal: 1) the node is a tail and + /// 2) the node is a middle node with prev and next; all scenarios end up with the same number + /// of storage reads and writes. + /// + /// - the destination bag has at least one node, which will need its next pointer updated. + pub(crate) fn new( + origin_weight: BalanceOf, + is_increase: bool, + ) -> Result { + ensure!(!origin_weight.is_zero(), "origin weight must be greater than 0"); + + ensure!( + pallet_nomination_pools::MaxPools::::get().unwrap_or(0) >= 3, + "must allow at least three pools for benchmarks" + ); + + // Burn the entire issuance. + let i = CurrencyOf::::burn(CurrencyOf::::total_issuance()); + sp_std::mem::forget(i); + + // Create accounts with the origin weight + let (pool_creator1, pool_origin1) = create_pool_account::(USER_SEED + 1, origin_weight); + T::StakingInterface::nominate( + pool_origin1.clone(), + // NOTE: these don't really need to be validators. + vec![account("random_validator", 0, USER_SEED)], + )?; + + let (_, pool_origin2) = create_pool_account::(USER_SEED + 2, origin_weight); + T::StakingInterface::nominate( + pool_origin2.clone(), + vec![account("random_validator", 0, USER_SEED)].clone(), + )?; + + // Find a destination weight that will trigger the worst case scenario + let dest_weight_as_vote = ::VoterList::score_update_worst_case( + &pool_origin1, + is_increase, + ); + + let dest_weight: BalanceOf = + dest_weight_as_vote.try_into().map_err(|_| "could not convert u64 to Balance")?; + + // Create an account with the worst case destination weight + let (_, pool_dest1) = create_pool_account::(USER_SEED + 3, dest_weight); + T::StakingInterface::nominate( + pool_dest1.clone(), + vec![account("random_validator", 0, USER_SEED)], + )?; + + let weight_of = pallet_staking::Pallet::::weight_of_fn(); + assert_eq!(vote_to_balance::(weight_of(&pool_origin1)).unwrap(), origin_weight); + assert_eq!(vote_to_balance::(weight_of(&pool_origin2)).unwrap(), origin_weight); + assert_eq!(vote_to_balance::(weight_of(&pool_dest1)).unwrap(), dest_weight); + + Ok(ListScenario { + origin1: pool_origin1, + creator1: pool_creator1, + dest_weight, + origin1_member: None, + }) + } + + fn add_joiner(mut self, amount: BalanceOf) -> Self { + let amount = MinJoinBond::::get() + .max(CurrencyOf::::minimum_balance()) + // Max `amount` with minimum thresholds for account balance and joining a pool + // to ensure 1) the user can be created and 2) can join the pool + .max(amount); + + let joiner: T::AccountId = account("joiner", USER_SEED, 0); + self.origin1_member = Some(joiner.clone()); + CurrencyOf::::make_free_balance_be(&joiner, amount * 2u32.into()); + + let original_bonded = T::StakingInterface::active_stake(&self.origin1).unwrap(); + + // Unbond `amount` from the underlying pool account so when the member joins + // we will maintain `current_bonded`. + T::StakingInterface::unbond(self.origin1.clone(), amount) + .expect("the pool was created in `Self::new`."); + + // Account pool points for the unbonded balance. + BondedPools::::mutate(&1, |maybe_pool| { + maybe_pool.as_mut().map(|pool| pool.points -= amount) + }); + + Pools::::join(Origin::Signed(joiner.clone()).into(), amount, 1).unwrap(); + + // Sanity check that the vote weight is still the same as the original bonded + let weight_of = pallet_staking::Pallet::::weight_of_fn(); + assert_eq!(vote_to_balance::(weight_of(&self.origin1)).unwrap(), original_bonded); + + // Sanity check the member was added correctly + let member = PoolMembers::::get(&joiner).unwrap(); + assert_eq!(member.points, amount); + assert_eq!(member.pool_id, 1); + + self + } +} + +frame_benchmarking::benchmarks! { + join { + let origin_weight = pallet_nomination_pools::MinCreateBond::::get() + .max(CurrencyOf::::minimum_balance()) + * 2u32.into(); + + // setup the worst case list scenario. + let scenario = ListScenario::::new(origin_weight, true)?; + assert_eq!( + T::StakingInterface::active_stake(&scenario.origin1).unwrap(), + origin_weight + ); + + let max_additional = scenario.dest_weight.clone() - origin_weight; + let joiner_free = CurrencyOf::::minimum_balance() + max_additional; + + let joiner: T::AccountId + = create_funded_user_with_balance::("joiner", 0, joiner_free); + + whitelist_account!(joiner); + }: _(Origin::Signed(joiner.clone()), max_additional, 1) + verify { + assert_eq!(CurrencyOf::::free_balance(&joiner), joiner_free - max_additional); + assert_eq!( + T::StakingInterface::active_stake(&scenario.origin1).unwrap(), + scenario.dest_weight + ); + } + + bond_extra_transfer { + let origin_weight = pallet_nomination_pools::MinCreateBond::::get() + .max(CurrencyOf::::minimum_balance()) + * 2u32.into(); + let scenario = ListScenario::::new(origin_weight, true)?; + let extra = scenario.dest_weight.clone() - origin_weight; + + // creator of the src pool will bond-extra, bumping itself to dest bag. + + }: bond_extra(Origin::Signed(scenario.creator1.clone()), BondExtra::FreeBalance(extra)) + verify { + assert!( + T::StakingInterface::active_stake(&scenario.origin1).unwrap() >= + scenario.dest_weight + ); + } + + bond_extra_reward { + let origin_weight = pallet_nomination_pools::MinCreateBond::::get() + .max(CurrencyOf::::minimum_balance()) + * 2u32.into(); + let scenario = ListScenario::::new(origin_weight, true)?; + let extra = (scenario.dest_weight.clone() - origin_weight).max(CurrencyOf::::minimum_balance()); + + // transfer exactly `extra` to the depositor of the src pool (1), + let reward_account1 = Pools::::create_reward_account(1); + assert!(extra >= CurrencyOf::::minimum_balance()); + CurrencyOf::::deposit_creating(&reward_account1, extra); + + }: bond_extra(Origin::Signed(scenario.creator1.clone()), BondExtra::Rewards) + verify { + assert!( + T::StakingInterface::active_stake(&scenario.origin1).unwrap() >= + scenario.dest_weight + ); + } + + claim_payout { + let origin_weight = pallet_nomination_pools::MinCreateBond::::get().max(CurrencyOf::::minimum_balance()) * 2u32.into(); + let ed = CurrencyOf::::minimum_balance(); + let (depositor, pool_account) = create_pool_account::(0, origin_weight); + let reward_account = Pools::::create_reward_account(1); + + // Send funds to the reward account of the pool + CurrencyOf::::make_free_balance_be(&reward_account, ed + origin_weight); + + // Sanity check + assert_eq!( + CurrencyOf::::free_balance(&depositor), + origin_weight + ); + + whitelist_account!(depositor); + }:_(Origin::Signed(depositor.clone())) + verify { + assert_eq!( + CurrencyOf::::free_balance(&depositor), + origin_weight * 2u32.into() + ); + assert_eq!( + CurrencyOf::::free_balance(&reward_account), + ed + Zero::zero() + ); + } + + unbond { + // The weight the nominator will start at. The value used here is expected to be + // significantly higher than the first position in a list (e.g. the first bag threshold). + let origin_weight = BalanceOf::::try_from(952_994_955_240_703u128) + .map_err(|_| "balance expected to be a u128") + .unwrap(); + let scenario = ListScenario::::new(origin_weight, false)?; + let amount = origin_weight - scenario.dest_weight.clone(); + + let scenario = scenario.add_joiner(amount); + let member_id = scenario.origin1_member.unwrap().clone(); + let all_points = PoolMembers::::get(&member_id).unwrap().points; + whitelist_account!(member_id); + }: _(Origin::Signed(member_id.clone()), member_id.clone(), all_points) + verify { + let bonded_after = T::StakingInterface::active_stake(&scenario.origin1).unwrap(); + // We at least went down to the destination bag + assert!(bonded_after <= scenario.dest_weight.clone()); + let member = PoolMembers::::get( + &member_id + ) + .unwrap(); + assert_eq!( + member.unbonding_eras.keys().cloned().collect::>(), + vec![0 + T::StakingInterface::bonding_duration()] + ); + assert_eq!( + member.unbonding_eras.values().cloned().collect::>(), + vec![all_points] + ); + } + + pool_withdraw_unbonded { + let s in 0 .. MAX_SPANS; + + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + let (depositor, pool_account) = create_pool_account::(0, min_create_bond); + + // Add a new member + let min_join_bond = MinJoinBond::::get().max(CurrencyOf::::minimum_balance()); + let joiner = create_funded_user_with_balance::("joiner", 0, min_join_bond * 2u32.into()); + Pools::::join(Origin::Signed(joiner.clone()).into(), min_join_bond, 1) + .unwrap(); + + // Sanity check join worked + assert_eq!( + T::StakingInterface::active_stake(&pool_account).unwrap(), + min_create_bond + min_join_bond + ); + assert_eq!(CurrencyOf::::free_balance(&joiner), min_join_bond); + + // Unbond the new member + Pools::::fully_unbond(Origin::Signed(joiner.clone()).into(), joiner.clone()).unwrap(); + + // Sanity check that unbond worked + assert_eq!( + T::StakingInterface::active_stake(&pool_account).unwrap(), + min_create_bond + ); + assert_eq!(pallet_staking::Ledger::::get(&pool_account).unwrap().unlocking.len(), 1); + // Set the current era + pallet_staking::CurrentEra::::put(EraIndex::max_value()); + + // Add `s` count of slashing spans to storage. + pallet_staking::benchmarking::add_slashing_spans::(&pool_account, s); + whitelist_account!(pool_account); + }: _(Origin::Signed(pool_account.clone()), 1, s) + verify { + // The joiners funds didn't change + assert_eq!(CurrencyOf::::free_balance(&joiner), min_join_bond); + // The unlocking chunk was removed + assert_eq!(pallet_staking::Ledger::::get(pool_account).unwrap().unlocking.len(), 0); + } + + withdraw_unbonded_update { + let s in 0 .. MAX_SPANS; + + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + let (depositor, pool_account) = create_pool_account::(0, min_create_bond); + + // Add a new member + let min_join_bond = MinJoinBond::::get().max(CurrencyOf::::minimum_balance()); + let joiner = create_funded_user_with_balance::("joiner", 0, min_join_bond * 2u32.into()); + Pools::::join(Origin::Signed(joiner.clone()).into(), min_join_bond, 1) + .unwrap(); + + // Sanity check join worked + assert_eq!( + T::StakingInterface::active_stake(&pool_account).unwrap(), + min_create_bond + min_join_bond + ); + assert_eq!(CurrencyOf::::free_balance(&joiner), min_join_bond); + + // Unbond the new member + pallet_staking::CurrentEra::::put(0); + Pools::::fully_unbond(Origin::Signed(joiner.clone()).into(), joiner.clone()).unwrap(); + + // Sanity check that unbond worked + assert_eq!( + T::StakingInterface::active_stake(&pool_account).unwrap(), + min_create_bond + ); + assert_eq!(pallet_staking::Ledger::::get(&pool_account).unwrap().unlocking.len(), 1); + + // Set the current era to ensure we can withdraw unbonded funds + pallet_staking::CurrentEra::::put(EraIndex::max_value()); + + pallet_staking::benchmarking::add_slashing_spans::(&pool_account, s); + whitelist_account!(joiner); + }: withdraw_unbonded(Origin::Signed(joiner.clone()), joiner.clone(), s) + verify { + assert_eq!( + CurrencyOf::::free_balance(&joiner), + min_join_bond * 2u32.into() + ); + // The unlocking chunk was removed + assert_eq!(pallet_staking::Ledger::::get(&pool_account).unwrap().unlocking.len(), 0); + } + + withdraw_unbonded_kill { + let s in 0 .. MAX_SPANS; + + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + + let (depositor, pool_account) = create_pool_account::(0, min_create_bond); + + // We set the pool to the destroying state so the depositor can leave + BondedPools::::try_mutate(&1, |maybe_bonded_pool| { + maybe_bonded_pool.as_mut().ok_or(()).map(|bonded_pool| { + bonded_pool.state = PoolState::Destroying; + }) + }) + .unwrap(); + + // Unbond the creator + pallet_staking::CurrentEra::::put(0); + // Simulate some rewards so we can check if the rewards storage is cleaned up. We check this + // here to ensure the complete flow for destroying a pool works - the reward pool account + // should never exist by time the depositor withdraws so we test that it gets cleaned + // up when unbonding. + let reward_account = Pools::::create_reward_account(1); + assert!(frame_system::Account::::contains_key(&reward_account)); + Pools::::fully_unbond(Origin::Signed(depositor.clone()).into(), depositor.clone()).unwrap(); + + // Sanity check that unbond worked + assert_eq!( + T::StakingInterface::active_stake(&pool_account).unwrap(), + Zero::zero() + ); + assert_eq!( + CurrencyOf::::free_balance(&pool_account), + min_create_bond + ); + assert_eq!(pallet_staking::Ledger::::get(&pool_account).unwrap().unlocking.len(), 1); + + // Set the current era to ensure we can withdraw unbonded funds + pallet_staking::CurrentEra::::put(EraIndex::max_value()); + + // Some last checks that storage items we expect to get cleaned up are present + assert!(pallet_staking::Ledger::::contains_key(&pool_account)); + assert!(BondedPools::::contains_key(&1)); + assert!(SubPoolsStorage::::contains_key(&1)); + assert!(RewardPools::::contains_key(&1)); + assert!(PoolMembers::::contains_key(&depositor)); + assert!(frame_system::Account::::contains_key(&reward_account)); + + whitelist_account!(depositor); + }: withdraw_unbonded(Origin::Signed(depositor.clone()), depositor.clone(), s) + verify { + // Pool removal worked + assert!(!pallet_staking::Ledger::::contains_key(&pool_account)); + assert!(!BondedPools::::contains_key(&1)); + assert!(!SubPoolsStorage::::contains_key(&1)); + assert!(!RewardPools::::contains_key(&1)); + assert!(!PoolMembers::::contains_key(&depositor)); + assert!(!frame_system::Account::::contains_key(&pool_account)); + assert!(!frame_system::Account::::contains_key(&reward_account)); + + // Funds where transferred back correctly + assert_eq!( + CurrencyOf::::free_balance(&depositor), + // gets bond back + rewards collecting when unbonding + min_create_bond * 2u32.into() + CurrencyOf::::minimum_balance() + ); + } + + create { + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + let depositor: T::AccountId = account("depositor", USER_SEED, 0); + + // Give the depositor some balance to bond + CurrencyOf::::make_free_balance_be(&depositor, min_create_bond * 2u32.into()); + + // Make sure no pools exist as a pre-condition for our verify checks + assert_eq!(RewardPools::::count(), 0); + assert_eq!(BondedPools::::count(), 0); + + whitelist_account!(depositor); + }: _( + Origin::Signed(depositor.clone()), + min_create_bond, + depositor.clone(), + depositor.clone(), + depositor.clone() + ) + verify { + assert_eq!(RewardPools::::count(), 1); + assert_eq!(BondedPools::::count(), 1); + let (_, new_pool) = BondedPools::::iter().next().unwrap(); + assert_eq!( + new_pool, + BondedPoolInner { + points: min_create_bond, + state: PoolState::Open, + member_counter: 1, + roles: PoolRoles { + depositor: depositor.clone(), + root: depositor.clone(), + nominator: depositor.clone(), + state_toggler: depositor.clone(), + }, + } + ); + assert_eq!( + T::StakingInterface::active_stake(&Pools::::create_bonded_account(1)), + Some(min_create_bond) + ); + } + + nominate { + let n in 1 .. T::MaxNominations::get(); + + // Create a pool + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + let (depositor, pool_account) = create_pool_account::(0, min_create_bond); + + // Create some accounts to nominate. For the sake of benchmarking they don't need to be + // actual validators + let validators: Vec<_> = (0..n) + .map(|i| account("stash", USER_SEED, i)) + .collect(); + + whitelist_account!(depositor); + }:_(Origin::Signed(depositor.clone()), 1, validators) + verify { + assert_eq!(RewardPools::::count(), 1); + assert_eq!(BondedPools::::count(), 1); + let (_, new_pool) = BondedPools::::iter().next().unwrap(); + assert_eq!( + new_pool, + BondedPoolInner { + points: min_create_bond, + state: PoolState::Open, + member_counter: 1, + roles: PoolRoles { + depositor: depositor.clone(), + root: depositor.clone(), + nominator: depositor.clone(), + state_toggler: depositor.clone(), + } + } + ); + assert_eq!( + T::StakingInterface::active_stake(&Pools::::create_bonded_account(1)), + Some(min_create_bond) + ); + } + + set_state { + // Create a pool + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + let (depositor, pool_account) = create_pool_account::(0, min_create_bond); + BondedPools::::mutate(&1, |maybe_pool| { + // Force the pool into an invalid state + maybe_pool.as_mut().map(|mut pool| pool.points = min_create_bond * 10u32.into()); + }); + + let caller = account("caller", 0, USER_SEED); + whitelist_account!(caller); + }:_(Origin::Signed(caller), 1, PoolState::Destroying) + verify { + assert_eq!(BondedPools::::get(1).unwrap().state, PoolState::Destroying); + } + + set_metadata { + let n in 1 .. ::MaxMetadataLen::get(); + + // Create a pool + let min_create_bond = MinCreateBond::::get() + .max(T::StakingInterface::minimum_bond()) + .max(CurrencyOf::::minimum_balance()); + let (depositor, pool_account) = create_pool_account::(0, min_create_bond); + + // Create metadata of the max possible size + let metadata: Vec = (0..n).map(|_| 42).collect(); + + whitelist_account!(depositor); + }:_(Origin::Signed(depositor), 1, metadata.clone()) + verify { + assert_eq!(Metadata::::get(&1), metadata); + } + + set_configs { + }:_( + Origin::Root, + ConfigOp::Set(BalanceOf::::max_value()), + ConfigOp::Set(BalanceOf::::max_value()), + ConfigOp::Set(u32::MAX), + ConfigOp::Set(u32::MAX), + ConfigOp::Set(u32::MAX) + ) verify { + assert_eq!(MinJoinBond::::get(), BalanceOf::::max_value()); + assert_eq!(MinCreateBond::::get(), BalanceOf::::max_value()); + assert_eq!(MaxPools::::get(), Some(u32::MAX)); + assert_eq!(MaxPoolMembers::::get(), Some(u32::MAX)); + assert_eq!(MaxPoolMembersPerPool::::get(), Some(u32::MAX)); + } + + impl_benchmark_test_suite!( + Pallet, + crate::mock::new_test_ext(), + crate::mock::Runtime + ); +} diff --git a/frame/nomination-pools/benchmarking/src/mock.rs b/frame/nomination-pools/benchmarking/src/mock.rs new file mode 100644 index 0000000000000..d98bcb542f514 --- /dev/null +++ b/frame/nomination-pools/benchmarking/src/mock.rs @@ -0,0 +1,200 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-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. + +use frame_election_provider_support::VoteWeight; +use frame_support::{pallet_prelude::*, parameter_types, traits::ConstU64, PalletId}; +use sp_runtime::traits::{Convert, IdentityLookup}; + +type AccountId = u128; +type AccountIndex = u32; +type BlockNumber = u64; +type Balance = u128; + +impl frame_system::Config for Runtime { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Index = AccountIndex; + type BlockNumber = BlockNumber; + type Call = Call; + type Hash = sp_core::H256; + type Hashing = sp_runtime::traits::BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = sp_runtime::testing::Header; + type Event = Event; + type BlockHashCount = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +impl pallet_timestamp::Config for Runtime { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<5>; + type WeightInfo = (); +} + +parameter_types! { + pub const ExistentialDeposit: Balance = 10; +} +impl pallet_balances::Config for Runtime { + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type Balance = Balance; + type Event = Event; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); +} + +pallet_staking_reward_curve::build! { + const I_NPOS: sp_runtime::curve::PiecewiseLinear<'static> = curve!( + min_inflation: 0_025_000, + max_inflation: 0_100_000, + ideal_stake: 0_500_000, + falloff: 0_050_000, + max_piece_count: 40, + test_precision: 0_005_000, + ); +} +parameter_types! { + pub const RewardCurve: &'static sp_runtime::curve::PiecewiseLinear<'static> = &I_NPOS; +} +impl pallet_staking::Config for Runtime { + type MaxNominations = ConstU32<16>; + type Currency = Balances; + type CurrencyBalance = Balance; + type UnixTime = pallet_timestamp::Pallet; + type CurrencyToVote = frame_support::traits::SaturatingCurrencyToVote; + type RewardRemainder = (); + type Event = Event; + type Slash = (); + type Reward = (); + type SessionsPerEra = (); + type SlashDeferDuration = (); + type SlashCancelOrigin = frame_system::EnsureRoot; + type BondingDuration = ConstU32<3>; + type SessionInterface = (); + type EraPayout = pallet_staking::ConvertCurve; + type NextNewSession = (); + type MaxNominatorRewardedPerValidator = ConstU32<64>; + type OffendingValidatorsThreshold = (); + type ElectionProvider = + frame_election_provider_support::NoElection<(AccountId, BlockNumber, Staking)>; + type GenesisElectionProvider = Self::ElectionProvider; + type VoterList = pallet_bags_list::Pallet; + type MaxUnlockingChunks = ConstU32<32>; + type OnStakerSlash = Pools; + type BenchmarkingConfig = pallet_staking::TestBenchmarkingConfig; + type WeightInfo = (); +} + +parameter_types! { + pub static BagThresholds: &'static [VoteWeight] = &[10, 20, 30, 40, 50, 60, 1_000, 2_000, 10_000]; +} + +impl pallet_bags_list::Config for Runtime { + type Event = Event; + type WeightInfo = (); + type BagThresholds = BagThresholds; + type ScoreProvider = Staking; + type Score = VoteWeight; +} + +pub struct BalanceToU256; +impl Convert for BalanceToU256 { + fn convert(n: Balance) -> sp_core::U256 { + n.into() + } +} + +pub struct U256ToBalance; +impl Convert for U256ToBalance { + fn convert(n: sp_core::U256) -> Balance { + n.try_into().unwrap() + } +} + +parameter_types! { + pub static PostUnbondingPoolsWindow: u32 = 10; + pub const PoolsPalletId: PalletId = PalletId(*b"py/nopls"); +} + +impl pallet_nomination_pools::Config for Runtime { + type Event = Event; + type WeightInfo = (); + type Currency = Balances; + type BalanceToU256 = BalanceToU256; + type U256ToBalance = U256ToBalance; + type StakingInterface = Staking; + type PostUnbondingPoolsWindow = PostUnbondingPoolsWindow; + type MaxMetadataLen = ConstU32<256>; + type MaxUnbonding = ConstU32<8>; + type PalletId = PoolsPalletId; +} + +impl crate::Config for Runtime {} + +impl frame_system::offchain::SendTransactionTypes for Runtime +where + Call: From, +{ + type OverarchingCall = Call; + type Extrinsic = UncheckedExtrinsic; +} + +type Block = frame_system::mocking::MockBlock; +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +frame_support::construct_runtime!( + pub enum Runtime where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic + { + System: frame_system::{Pallet, Call, Event}, + Timestamp: pallet_timestamp::{Pallet, Call, Storage, Inherent}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + Staking: pallet_staking::{Pallet, Call, Config, Storage, Event}, + BagsList: pallet_bags_list::{Pallet, Call, Storage, Event}, + Pools: pallet_nomination_pools::{Pallet, Call, Storage, Event}, + } +); + +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut storage = frame_system::GenesisConfig::default().build_storage::().unwrap(); + let _ = pallet_nomination_pools::GenesisConfig:: { + min_join_bond: 2, + min_create_bond: 2, + max_pools: Some(3), + max_members_per_pool: Some(3), + max_members: Some(3 * 3), + } + .assimilate_storage(&mut storage); + sp_io::TestExternalities::from(storage) +} diff --git a/frame/nomination-pools/src/lib.rs b/frame/nomination-pools/src/lib.rs new file mode 100644 index 0000000000000..bafed9fc2f5b4 --- /dev/null +++ b/frame/nomination-pools/src/lib.rs @@ -0,0 +1,2217 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-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. + +//! # Nomination Pools for Staking Delegation +//! +//! A pallet that allows members to delegate their stake to nominating pools. A nomination pool +//! acts as nominator and nominates validators on the members behalf. +//! +//! # Index +//! +//! * [Key terms](#key-terms) +//! * [Usage](#usage) +//! * [Design](#design) +//! +//! ## Key terms +//! +//! * bonded pool: Tracks the distribution of actively staked funds. See [`BondedPool`] and +//! [`BondedPoolInner`]. Bonded pools are identified via the pools bonded account. +//! * reward pool: Tracks rewards earned by actively staked funds. See [`RewardPool`] and +//! [`RewardPools`]. Reward pools are identified via the pools bonded account. +//! * unbonding sub pools: Collection of pools at different phases of the unbonding lifecycle. See +//! [`SubPools`] and [`SubPoolsStorage`]. Sub pools are identified via the pools bonded account. +//! * members: Accounts that are members of pools. See [`PoolMember`] and [`PoolMembers`]. Pool +//! members are identified via their account. +//! * point: A unit of measure for a members portion of a pool's funds. +//! * kick: The act of a pool administrator forcibly ejecting a member. +//! +//! ## Usage +//! +//! ### Join +//! +//! A account can stake funds with a nomination pool by calling [`Call::join`]. +//! +//! ### Claim rewards +//! +//! After joining a pool, a member can claim rewards by calling [`Call::claim_payout`]. +//! +//! For design docs see the [reward pool](#reward-pool) section. +//! +//! ### Leave +//! +//! In order to leave, a member must take two steps. +//! +//! First, they must call [`Call::unbond`]. The unbond other extrinsic will start the +//! unbonding process by unbonding all of the members funds. +//! +//! Second, once [`sp_staking::StakingInterface::bonding_duration`] eras have passed, the member +//! can call [`Call::withdraw_unbonded`] to withdraw all their funds. +//! +//! For design docs see the [bonded pool](#bonded-pool) and [unbonding sub +//! pools](#unbonding-sub-pools) sections. +//! +//! ### Slashes +//! +//! Slashes are distributed evenly across the bonded pool and the unbonding pools from slash era+1 +//! through the slash apply era. Thus, any member who either a) unbonded or b) was actively +//! bonded in the aforementioned range of eras will be affected by the slash. A member is slashed +//! pro-rata based on its stake relative to the total slash amount. +//! +//! For design docs see the [slashing](#slashing) section. +//! +//! ### Administration +//! +//! A pool can be created with the [`Call::create`] call. Once created, the pools nominator or root +//! user must call [`Call::nominate`] to start nominating. [`Call::nominate`] can be called at +//! anytime to update validator selection. +//! +//! To help facilitate pool administration the pool has one of three states (see [`PoolState`]): +//! +//! * Open: Anyone can join the pool and no members can be permissionlessly removed. +//! * Blocked: No members can join and some admin roles can kick members. +//! * Destroying: No members can join and all members can be permissionlessly removed with +//! [`Call::unbond`] and [`Call::withdraw_unbonded`]. Once a pool is in destroying state, it +//! cannot be reverted to another state. +//! +//! A pool has 3 administrative roles (see [`PoolRoles`]): +//! +//! * Depositor: creates the pool and is the initial member. They can only leave the pool once all +//! other members have left. Once they fully leave the pool is destroyed. +//! * Nominator: can select which validators the pool nominates. +//! * State-Toggler: can change the pools state and kick members if the pool is blocked. +//! * Root: can change the nominator, state-toggler, or itself and can perform any of the actions +//! the nominator or state-toggler can. +//! +//! ## Design +//! +//! _Notes_: this section uses pseudo code to explain general design and does not necessarily +//! reflect the exact implementation. Additionally, a working knowledge of `pallet-staking`'s api is +//! assumed. +//! +//! ### Goals +//! +//! * Maintain network security by upholding integrity of slashing events, sufficiently penalizing +//! members that where in the pool while it was backing a validator that got slashed. +//! * Maximize scalability in terms of member count. +//! +//! In order to maintain scalability, all operations are independent of the number of members. To +//! do this, delegation specific information is stored local to the member while the pool data +//! structures have bounded datum. +//! +//! ### Bonded pool +//! +//! A bonded pool nominates with its total balance, excluding that which has been withdrawn for +//! unbonding. The total points of a bonded pool are always equal to the sum of points of the +//! delegation members. A bonded pool tracks its points and reads its bonded balance. +//! +//! When a member joins a pool, `amount_transferred` is transferred from the members account +//! to the bonded pools account. Then the pool calls `staking::bond_extra(amount_transferred)` and +//! issues new points which are tracked by the member and added to the bonded pool's points. +//! +//! When the pool already has some balance, we want the value of a point before the transfer to +//! equal the value of a point after the transfer. So, when a member joins a bonded pool with a +//! given `amount_transferred`, we maintain the ratio of bonded balance to points such that: +//! +//! ```text +//! balance_after_transfer / points_after_transfer == balance_before_transfer / points_before_transfer; +//! ``` +//! +//! To achieve this, we issue points based on the following: +//! +//! ```text +//! points_issued = (points_before_transfer / balance_before_transfer) * amount_transferred; +//! ``` +//! +//! For new bonded pools we can set the points issued per balance arbitrarily. In this +//! implementation we use a 1 points to 1 balance ratio for pool creation (see +//! [`POINTS_TO_BALANCE_INIT_RATIO`]). +//! +//! **Relevant extrinsics:** +//! +//! * [`Call::create`] +//! * [`Call::join`] +//! +//! ### Reward pool +//! +//! When a pool is first bonded it sets up an deterministic, inaccessible account as its reward +//! destination. To track staking rewards we track how the balance of this reward account changes. +//! +//! The reward pool needs to store: +//! +//! * The pool balance at the time of the last payout: `reward_pool.balance` +//! * The total earnings ever at the time of the last payout: `reward_pool.total_earnings` +//! * The total points in the pool at the time of the last payout: `reward_pool.points` +//! +//! And the member needs to store: +//! +//! * The total payouts at the time of the last payout by that member: +//! `member.reward_pool_total_earnings` +//! +//! Before the first reward claim is initiated for a pool, all the above variables are set to zero. +//! +//! When a member initiates a claim, the following happens: +//! +//! 1) Compute the reward pool's total points and the member's virtual points in the reward pool +//! * First `current_total_earnings` is computed (`current_balance` is the free balance of the +//! reward pool at the beginning of these operations.) +//! ```text +//! current_total_earnings = +//! current_balance - reward_pool.balance + pool.total_earnings; +//! ``` +//! * Then the `current_points` is computed. Every balance unit that was added to the reward +//! pool since last time recorded means that the `pool.points` is increased by +//! `bonding_pool.total_points`. In other words, for every unit of balance that has been +//! earned by the reward pool, the reward pool points are inflated by `bonded_pool.points`. In +//! effect this allows each, single unit of balance (e.g. planck) to be divvied up pro-rata +//! among members based on points. +//! ```text +//! new_earnings = current_total_earnings - reward_pool.total_earnings; +//! current_points = reward_pool.points + bonding_pool.points * new_earnings; +//! ``` +//! * Finally, the`member_virtual_points` are computed: the product of the member's points in +//! the bonding pool and the total inflow of balance units since the last time the member +//! claimed rewards +//! ```text +//! new_earnings_since_last_claim = current_total_earnings - member.reward_pool_total_earnings; +//! member_virtual_points = member.points * new_earnings_since_last_claim; +//! ``` +//! 2) Compute the `member_payout`: +//! ```text +//! member_pool_point_ratio = member_virtual_points / current_points; +//! member_payout = current_balance * member_pool_point_ratio; +//! ``` +//! 3) Transfer `member_payout` to the member +//! 4) For the member set: +//! ```text +//! member.reward_pool_total_earnings = current_total_earnings; +//! ``` +//! 5) For the pool set: +//! ```text +//! reward_pool.points = current_points - member_virtual_points; +//! reward_pool.balance = current_balance - member_payout; +//! reward_pool.total_earnings = current_total_earnings; +//! ``` +//! +//! _Note_: One short coming of this design is that new joiners can claim rewards for the era after +//! they join even though their funds did not contribute to the pools vote weight. When a +//! member joins, it's `reward_pool_total_earnings` field is set equal to the `total_earnings` +//! of the reward pool at that point in time. At best the reward pool has the rewards up through the +//! previous era. If a member joins prior to the election snapshot it will benefit from the +//! rewards for the active era despite not contributing to the pool's vote weight. If it joins +//! after the election snapshot is taken it will benefit from the rewards of the next _2_ eras +//! because it's vote weight will not be counted until the election snapshot in active era + 1. +//! Related: +// _Note to maintainers_: In order to ensure the reward account never falls below the existential +// deposit, at creation the reward account must be endowed with the existential deposit. All logic +// for calculating rewards then does not see that existential deposit as part of the free balance. +// See `RewardPool::current_balance`. +//! +//! **Relevant extrinsics:** +//! +//! * [`Call::claim_payout`] +//! +//! ### Unbonding sub pools +//! +//! When a member unbonds, it's balance is unbonded in the bonded pool's account and tracked in +//! an unbonding pool associated with the active era. If no such pool exists, one is created. To +//! track which unbonding sub pool a member belongs too, a member tracks it's +//! `unbonding_era`. +//! +//! When a member initiates unbonding it's claim on the bonded pool +//! (`balance_to_unbond`) is computed as: +//! +//! ```text +//! balance_to_unbond = (bonded_pool.balance / bonded_pool.points) * member.points; +//! ``` +//! +//! If this is the first transfer into an unbonding pool arbitrary amount of points can be issued +//! per balance. In this implementation unbonding pools are initialized with a 1 point to 1 balance +//! ratio (see [`POINTS_TO_BALANCE_INIT_RATIO`]). Otherwise, the unbonding pools hold the same +//! points to balance ratio properties as the bonded pool, so member points in the +//! unbonding pool are issued based on +//! +//! ```text +//! new_points_issued = (points_before_transfer / balance_before_transfer) * balance_to_unbond; +//! ``` +//! +//! For scalability, a bound is maintained on the number of unbonding sub pools (see +//! [`TotalUnbondingPools`]). An unbonding pool is removed once its older than `current_era - +//! TotalUnbondingPools`. An unbonding pool is merged into the unbonded pool with +//! +//! ```text +//! unbounded_pool.balance = unbounded_pool.balance + unbonding_pool.balance; +//! unbounded_pool.points = unbounded_pool.points + unbonding_pool.points; +//! ``` +//! +//! This scheme "averages" out the points value in the unbonded pool. +//! +//! Once a members `unbonding_era` is older than `current_era - +//! [sp_staking::StakingInterface::bonding_duration]`, it can can cash it's points out of the +//! corresponding unbonding pool. If it's `unbonding_era` is older than `current_era - +//! TotalUnbondingPools`, it can cash it's points from the unbonded pool. +//! +//! **Relevant extrinsics:** +//! +//! * [`Call::unbond`] +//! * [`Call::withdraw_unbonded`] +//! +//! ### Slashing +//! +//! This section assumes that the slash computation is executed by +//! `pallet_staking::StakingLedger::slash`, which passes the information to this pallet via +//! [`sp_staking::OnStakerSlash::on_slash`]. +//! +//! Unbonding pools need to be slashed to ensure all nominators whom where in the bonded pool +//! while it was backing a validator that equivocated are punished. Without these measures a +//! member could unbond right after a validator equivocated with no consequences. +//! +//! This strategy is unfair to members who joined after the slash, because they get slashed as +//! well, but spares members who unbond. The latter is much more important for security: if a +//! pool's validators are attacking the network, their members need to unbond fast! Avoiding +//! slashes gives them an incentive to do that if validators get repeatedly slashed. +//! +//! To be fair to joiners, this implementation also need joining pools, which are actively staking, +//! in addition to the unbonding pools. For maintenance simplicity these are not implemented. +//! Related: +//! +//! **Relevant methods:** +//! +//! * [`Pallet::on_slash`] +//! +//! ### Limitations +//! +//! * PoolMembers cannot vote with their staked funds because they are transferred into the pools +//! account. In the future this can be overcome by allowing the members to vote with their bonded +//! funds via vote splitting. +//! * PoolMembers cannot quickly transfer to another pool if they do no like nominations, instead +//! they must wait for the unbonding duration. +//! +//! # Runtime builder warnings +//! +//! * Watch out for overflow of [`RewardPoints`] and [`BalanceOf`] types. Consider things like the +//! chains total issuance, staking reward rate, and burn rate. + +#![cfg_attr(not(feature = "std"), no_std)] + +use codec::Codec; +use frame_support::{ + defensive, ensure, + pallet_prelude::{MaxEncodedLen, *}, + storage::bounded_btree_map::BoundedBTreeMap, + traits::{ + Currency, Defensive, DefensiveOption, DefensiveResult, DefensiveSaturating, + ExistenceRequirement, Get, + }, + CloneNoBound, DefaultNoBound, PartialEqNoBound, RuntimeDebugNoBound, +}; +use scale_info::TypeInfo; +use sp_core::U256; +use sp_runtime::traits::{AccountIdConversion, Bounded, CheckedSub, Convert, Saturating, Zero}; +use sp_staking::{EraIndex, OnStakerSlash, StakingInterface}; +use sp_std::{collections::btree_map::BTreeMap, fmt::Debug, ops::Div, vec::Vec}; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +pub mod weights; + +pub use pallet::*; +pub use weights::WeightInfo; + +/// The balance type used by the currency system. +pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; +/// Type used to track the points of a reward pool. +pub type RewardPoints = U256; +/// Type used for unique identifier of each pool. +pub type PoolId = u32; + +type UnbondingPoolsWithEra = BoundedBTreeMap, TotalUnbondingPools>; + +pub const POINTS_TO_BALANCE_INIT_RATIO: u32 = 1; + +/// Possible operations on the configuration values of this pallet. +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, RuntimeDebugNoBound, PartialEq, Clone)] +pub enum ConfigOp { + /// Don't change. + Noop, + /// Set the given value. + Set(T), + /// Remove from storage. + Remove, +} + +/// The type of bonding that can happen to a pool. +enum BondType { + /// Someone is bonding into the pool upon creation. + Create, + /// Someone is adding more funds later to this pool. + Later, +} + +/// How to increase the bond of a member. +#[derive(Encode, Decode, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum BondExtra { + /// Take from the free balance. + FreeBalance(Balance), + /// Take the entire amount from the accumulated rewards. + Rewards, +} + +/// The type of account being created. +#[derive(Encode, Decode)] +enum AccountType { + Bonded, + Reward, +} + +/// A member in a pool. +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, RuntimeDebugNoBound, CloneNoBound)] +#[cfg_attr(feature = "std", derive(PartialEqNoBound, DefaultNoBound))] +#[codec(mel_bound(T: Config))] +#[scale_info(skip_type_params(T))] +pub struct PoolMember { + /// The identifier of the pool to which `who` belongs. + pub pool_id: PoolId, + /// The quantity of points this member has in the bonded pool or in a sub pool if + /// `Self::unbonding_era` is some. + pub points: BalanceOf, + /// The reward pools total earnings _ever_ the last time this member claimed a payout. + /// Assuming no massive burning events, we expect this value to always be below total issuance. + /// This value lines up with the [`RewardPool::total_earnings`] after a member claims a + /// payout. + pub reward_pool_total_earnings: BalanceOf, + /// The eras in which this member is unbonding, mapped from era index to the number of + /// points scheduled to unbond in the given era. + pub unbonding_eras: BoundedBTreeMap, T::MaxUnbonding>, +} + +impl PoolMember { + #[cfg(any(test, debug_assertions))] + fn total_points(&self) -> BalanceOf { + self.active_points().saturating_add(self.unbonding_points()) + } + + /// Active balance of the member. + /// + /// This is derived from the ratio of points in the pool to which the member belongs to. + /// Might return different values based on the pool state for the same member and points. + pub(crate) fn active_balance(&self) -> BalanceOf { + if let Some(pool) = BondedPool::::get(self.pool_id).defensive() { + pool.points_to_balance(self.points) + } else { + Zero::zero() + } + } + + /// Active points of the member. + pub(crate) fn active_points(&self) -> BalanceOf { + self.points + } + + /// Inactive points of the member, waiting to be withdrawn. + pub(crate) fn unbonding_points(&self) -> BalanceOf { + self.unbonding_eras + .as_ref() + .iter() + .fold(BalanceOf::::zero(), |acc, (_, v)| acc.saturating_add(*v)) + } + + /// Try and unbond `points` from self, with the given target unbonding era. + /// + /// Returns `Ok(())` and updates `unbonding_eras` and `points` if success, `Err(_)` otherwise. + fn try_unbond( + &mut self, + points: BalanceOf, + unbonding_era: EraIndex, + ) -> Result<(), Error> { + if let Some(new_points) = self.points.checked_sub(&points) { + match self.unbonding_eras.get_mut(&unbonding_era) { + Some(already_unbonding_points) => + *already_unbonding_points = already_unbonding_points.saturating_add(points), + None => self + .unbonding_eras + .try_insert(unbonding_era, points) + .map(|old| { + if old.is_some() { + defensive!("value checked to not exist in the map; qed"); + } + }) + .map_err(|_| Error::::MaxUnbondingLimit)?, + } + self.points = new_points; + Ok(()) + } else { + Err(Error::::NotEnoughPointsToUnbond) + } + } + + /// Withdraw any funds in [`Self::unbonding_eras`] who's deadline in reached and is fully + /// unlocked. + /// + /// Returns a a subset of [`Self::unbonding_eras`] that got withdrawn. + /// + /// Infallible, noop if no unbonding eras exist. + fn withdraw_unlocked( + &mut self, + current_era: EraIndex, + ) -> BoundedBTreeMap, T::MaxUnbonding> { + // NOTE: if only drain-filter was stable.. + let mut removed_points = + BoundedBTreeMap::, T::MaxUnbonding>::default(); + self.unbonding_eras.retain(|e, p| { + if *e > current_era { + true + } else { + removed_points + .try_insert(*e, p.clone()) + .expect("source map is bounded, this is a subset, will be bounded; qed"); + false + } + }); + removed_points + } +} + +/// A pool's possible states. +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, PartialEq, RuntimeDebugNoBound, Clone, Copy)] +pub enum PoolState { + /// The pool is open to be joined, and is working normally. + Open, + /// The pool is blocked. No one else can join. + Blocked, + /// The pool is in the process of being destroyed. + /// + /// All members can now be permissionlessly unbonded, and the pool can never go back to any + /// other state other than being dissolved. + Destroying, +} + +/// Pool adminstration roles. +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Debug, PartialEq, Clone)] +pub struct PoolRoles { + /// Creates the pool and is the initial member. They can only leave the pool once all + /// other members have left. Once they fully leave, the pool is destroyed. + pub depositor: AccountId, + /// Can change the nominator, state-toggler, or itself and can perform any of the actions + /// the nominator or state-toggler can. + pub root: AccountId, + /// Can select which validators the pool nominates. + pub nominator: AccountId, + /// Can change the pools state and kick members if the pool is blocked. + pub state_toggler: AccountId, +} + +/// Pool permissions and state +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, DebugNoBound, PartialEq, Clone)] +#[codec(mel_bound(T: Config))] +#[scale_info(skip_type_params(T))] +pub struct BondedPoolInner { + /// Total points of all the members in the pool who are actively bonded. + pub points: BalanceOf, + /// The current state of the pool. + pub state: PoolState, + /// Count of members that belong to the pool. + pub member_counter: u32, + /// See [`PoolRoles`]. + pub roles: PoolRoles, +} + +/// A wrapper for bonded pools, with utility functions. +/// +/// The main purpose of this is to wrap a [`BondedPoolInner`], with the account + id of the pool, +/// for easier access. +#[derive(RuntimeDebugNoBound)] +#[cfg_attr(feature = "std", derive(Clone, PartialEq))] +pub struct BondedPool { + /// The identifier of the pool. + id: PoolId, + /// The inner fields. + inner: BondedPoolInner, +} + +impl sp_std::ops::Deref for BondedPool { + type Target = BondedPoolInner; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl sp_std::ops::DerefMut for BondedPool { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl BondedPool { + /// Create a new bonded pool with the given roles and identifier. + fn new(id: PoolId, roles: PoolRoles) -> Self { + Self { + id, + inner: BondedPoolInner { + roles, + state: PoolState::Open, + points: Zero::zero(), + member_counter: Zero::zero(), + }, + } + } + + /// Get [`Self`] from storage. Returns `None` if no entry for `pool_account` exists. + fn get(id: PoolId) -> Option { + BondedPools::::try_get(id).ok().map(|inner| Self { id, inner }) + } + + /// Get the bonded account id of this pool. + fn bonded_account(&self) -> T::AccountId { + Pallet::::create_bonded_account(self.id) + } + + /// Get the reward account id of this pool. + fn reward_account(&self) -> T::AccountId { + Pallet::::create_reward_account(self.id) + } + + /// Consume self and put into storage. + fn put(self) { + BondedPools::::insert(self.id, BondedPoolInner { ..self.inner }); + } + + /// Consume self and remove from storage. + fn remove(self) { + BondedPools::::remove(self.id); + } + + /// Convert the given amount of balance to points given the current pool state. + /// + /// This is often used for bonding and issuing new funds into the pool. + fn balance_to_point(&self, new_funds: BalanceOf) -> BalanceOf { + let bonded_balance = + T::StakingInterface::active_stake(&self.bonded_account()).unwrap_or(Zero::zero()); + Pallet::::balance_to_point(bonded_balance, self.points, new_funds) + } + + /// Convert the given number of points to balance given the current pool state. + /// + /// This is often used for unbonding. + fn points_to_balance(&self, points: BalanceOf) -> BalanceOf { + let bonded_balance = + T::StakingInterface::active_stake(&self.bonded_account()).unwrap_or(Zero::zero()); + Pallet::::point_to_balance(bonded_balance, self.points, points) + } + + /// Issue points to [`Self`] for `new_funds`. + fn issue(&mut self, new_funds: BalanceOf) -> BalanceOf { + let points_to_issue = self.balance_to_point(new_funds); + self.points = self.points.saturating_add(points_to_issue); + points_to_issue + } + + /// Dissolve some points from the pool i.e. unbond the given amount of points from this pool. + /// This is the opposite of issuing some funds into the pool. + /// + /// Mutates self in place, but does not write anything to storage. + /// + /// Returns the equivalent balance amount that actually needs to get unbonded. + fn dissolve(&mut self, points: BalanceOf) -> BalanceOf { + // NOTE: do not optimize by removing `balance`. it must be computed before mutating + // `self.point`. + let balance = self.points_to_balance(points); + self.points = self.points.saturating_sub(points); + balance + } + + /// Increment the member counter. Ensures that the pool and system member limits are + /// respected. + fn try_inc_members(&mut self) -> Result<(), DispatchError> { + ensure!( + MaxPoolMembersPerPool::::get() + .map_or(true, |max_per_pool| self.member_counter < max_per_pool), + Error::::MaxPoolMembers + ); + ensure!( + MaxPoolMembers::::get().map_or(true, |max| PoolMembers::::count() < max), + Error::::MaxPoolMembers + ); + self.member_counter = self.member_counter.defensive_saturating_add(1); + Ok(()) + } + + /// Decrement the member counter. + fn dec_members(mut self) -> Self { + self.member_counter = self.member_counter.defensive_saturating_sub(1); + self + } + + /// The pools balance that is transferrable. + fn transferrable_balance(&self) -> BalanceOf { + let account = self.bonded_account(); + T::Currency::free_balance(&account) + .saturating_sub(T::StakingInterface::active_stake(&account).unwrap_or_default()) + } + + fn can_nominate(&self, who: &T::AccountId) -> bool { + *who == self.roles.root || *who == self.roles.nominator + } + + fn can_kick(&self, who: &T::AccountId) -> bool { + (*who == self.roles.root || *who == self.roles.state_toggler) && + self.state == PoolState::Blocked + } + + fn can_toggle_state(&self, who: &T::AccountId) -> bool { + (*who == self.roles.root || *who == self.roles.state_toggler) && !self.is_destroying() + } + + fn can_set_metadata(&self, who: &T::AccountId) -> bool { + *who == self.roles.root || *who == self.roles.state_toggler + } + + fn is_destroying(&self) -> bool { + matches!(self.state, PoolState::Destroying) + } + + fn is_destroying_and_only_depositor(&self, alleged_depositor_points: BalanceOf) -> bool { + // NOTE: if we add `&& self.member_counter == 1`, then this becomes even more strict and + // ensures that there are no unbonding members hanging around either. + self.is_destroying() && self.points == alleged_depositor_points + } + + /// Whether or not the pool is ok to be in `PoolSate::Open`. If this returns an `Err`, then the + /// pool is unrecoverable and should be in the destroying state. + fn ok_to_be_open(&self, new_funds: BalanceOf) -> Result<(), DispatchError> { + ensure!(!self.is_destroying(), Error::::CanNotChangeState); + + let bonded_balance = + T::StakingInterface::active_stake(&self.bonded_account()).unwrap_or(Zero::zero()); + ensure!(!bonded_balance.is_zero(), Error::::OverflowRisk); + + let points_to_balance_ratio_floor = self + .points + // We checked for zero above + .div(bonded_balance); + + // Pool points can inflate relative to balance, but only if the pool is slashed. + // If we cap the ratio of points:balance so one cannot join a pool that has been slashed + // 90%, + ensure!(points_to_balance_ratio_floor < 10u32.into(), Error::::OverflowRisk); + // while restricting the balance to 1/10th of max total issuance, + let next_bonded_balance = bonded_balance.saturating_add(new_funds); + ensure!( + next_bonded_balance < BalanceOf::::max_value().div(10u32.into()), + Error::::OverflowRisk + ); + // then we can be decently confident the bonding pool points will not overflow + // `BalanceOf`. Note that these are just heuristics. + + Ok(()) + } + + /// Check that the pool can accept a member with `new_funds`. + fn ok_to_join(&self, new_funds: BalanceOf) -> Result<(), DispatchError> { + ensure!(self.state == PoolState::Open, Error::::NotOpen); + self.ok_to_be_open(new_funds)?; + Ok(()) + } + + fn ok_to_unbond_with( + &self, + caller: &T::AccountId, + target_account: &T::AccountId, + target_member: &PoolMember, + unbonding_points: BalanceOf, + ) -> Result<(), DispatchError> { + let is_permissioned = caller == target_account; + let is_depositor = *target_account == self.roles.depositor; + match (is_permissioned, is_depositor) { + // If the pool is blocked, then an admin with kicking permissions can remove a + // member. If the pool is being destroyed, anyone can remove a member + (false, false) => { + ensure!( + self.can_kick(caller) || self.is_destroying(), + Error::::NotKickerOrDestroying + ) + }, + // Any member who is not the depositor can always unbond themselves + (true, false) => (), + (_, true) => { + if self.is_destroying_and_only_depositor(target_member.active_points()) { + // if the pool is about to be destroyed, anyone can unbond the depositor, and + // they can fully unbond. + } else { + // only the depositor can partially unbond, and they can only unbond up to the + // threshold. + ensure!(is_permissioned, Error::::DoesNotHavePermission); + let balance_after_unbond = { + let new_depositor_points = + target_member.active_points().saturating_sub(unbonding_points); + let mut depositor_after_unbond = (*target_member).clone(); + depositor_after_unbond.points = new_depositor_points; + depositor_after_unbond.active_balance() + }; + ensure!( + balance_after_unbond >= MinCreateBond::::get(), + Error::::NotOnlyPoolMember + ); + } + }, + }; + Ok(()) + } + + /// # Returns + /// + /// * Ok(()) if [`Call::withdraw_unbonded`] can be called, `Err(DispatchError)` otherwise. + fn ok_to_withdraw_unbonded_with( + &self, + caller: &T::AccountId, + target_account: &T::AccountId, + target_member: &PoolMember, + sub_pools: &SubPools, + ) -> Result<(), DispatchError> { + if *target_account == self.roles.depositor { + ensure!( + sub_pools.sum_unbonding_points() == target_member.unbonding_points(), + Error::::NotOnlyPoolMember + ); + debug_assert_eq!(self.member_counter, 1, "only member must exist at this point"); + Ok(()) + } else { + // This isn't a depositor + let is_permissioned = caller == target_account; + ensure!( + is_permissioned || self.can_kick(caller) || self.is_destroying(), + Error::::NotKickerOrDestroying + ); + Ok(()) + } + } + + /// Bond exactly `amount` from `who`'s funds into this pool. + /// + /// If the bond type is `Create`, `StakingInterface::bond` is called, and `who` + /// is allowed to be killed. Otherwise, `StakingInterface::bond_extra` is called and `who` + /// cannot be killed. + /// + /// Returns `Ok(points_issues)`, `Err` otherwise. + fn try_bond_funds( + &mut self, + who: &T::AccountId, + amount: BalanceOf, + ty: BondType, + ) -> Result, DispatchError> { + // Cache the value + let bonded_account = self.bonded_account(); + T::Currency::transfer( + &who, + &bonded_account, + amount, + match ty { + BondType::Create => ExistenceRequirement::AllowDeath, + BondType::Later => ExistenceRequirement::KeepAlive, + }, + )?; + // We must calculate the points issued *before* we bond who's funds, else points:balance + // ratio will be wrong. + let points_issued = self.issue(amount); + + match ty { + BondType::Create => T::StakingInterface::bond( + bonded_account.clone(), + bonded_account, + amount, + self.reward_account(), + )?, + // The pool should always be created in such a way its in a state to bond extra, but if + // the active balance is slashed below the minimum bonded or the account cannot be + // found, we exit early. + BondType::Later => T::StakingInterface::bond_extra(bonded_account, amount)?, + } + + Ok(points_issued) + } + + /// If `n` saturates at it's upper bound, mark the pool as destroying. This is useful when a + /// number saturating indicates the pool can no longer correctly keep track of state. + fn bound_check(&mut self, n: U256) -> U256 { + if n == U256::max_value() { + self.set_state(PoolState::Destroying) + } + + n + } + + // Set the state of `self`, and deposit an event if the state changed. State should never be set + // directly in in order to ensure a state change event is always correctly deposited. + fn set_state(&mut self, state: PoolState) { + if self.state != state { + self.state = state; + Pallet::::deposit_event(Event::::StateChanged { + pool_id: self.id, + new_state: state, + }); + }; + } +} + +/// A reward pool. +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, RuntimeDebugNoBound)] +#[cfg_attr(feature = "std", derive(Clone, PartialEq))] +#[codec(mel_bound(T: Config))] +#[scale_info(skip_type_params(T))] +pub struct RewardPool { + /// The balance of this reward pool after the last claimed payout. + pub balance: BalanceOf, + /// The total earnings _ever_ of this reward pool after the last claimed payout. I.E. the sum + /// of all incoming balance through the pools life. + /// + /// NOTE: We assume this will always be less than total issuance and thus can use the runtimes + /// `Balance` type. However in a chain with a burn rate higher than the rate this increases, + /// this type should be bigger than `Balance`. + pub total_earnings: BalanceOf, + /// The total points of this reward pool after the last claimed payout. + pub points: RewardPoints, +} + +impl RewardPool { + /// Mutate the reward pool by updating the total earnings and current free balance. + fn update_total_earnings_and_balance(&mut self, id: PoolId) { + let current_balance = Self::current_balance(id); + // The earnings since the last time it was updated + let new_earnings = current_balance.saturating_sub(self.balance); + // The lifetime earnings of the of the reward pool + self.total_earnings = new_earnings.saturating_add(self.total_earnings); + self.balance = current_balance; + } + + /// Get a reward pool and update its total earnings and balance + fn get_and_update(id: PoolId) -> Option { + RewardPools::::get(id).map(|mut r| { + r.update_total_earnings_and_balance(id); + r + }) + } + + /// The current balance of the reward pool. Never access the reward pools free balance directly. + /// The existential deposit was not received as a reward, so the reward pool can not use it. + fn current_balance(id: PoolId) -> BalanceOf { + T::Currency::free_balance(&Pallet::::create_reward_account(id)) + .saturating_sub(T::Currency::minimum_balance()) + } +} + +/// An unbonding pool. This is always mapped with an era. +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, DefaultNoBound, RuntimeDebugNoBound)] +#[cfg_attr(feature = "std", derive(Clone, PartialEq, Eq))] +#[codec(mel_bound(T: Config))] +#[scale_info(skip_type_params(T))] +pub struct UnbondPool { + /// The points in this pool. + points: BalanceOf, + /// The funds in the pool. + balance: BalanceOf, +} + +impl UnbondPool { + fn balance_to_point(&self, new_funds: BalanceOf) -> BalanceOf { + Pallet::::balance_to_point(self.balance, self.points, new_funds) + } + + fn point_to_balance(&self, points: BalanceOf) -> BalanceOf { + Pallet::::point_to_balance(self.balance, self.points, points) + } + + /// Issue points and update the balance given `new_balance`. + fn issue(&mut self, new_funds: BalanceOf) { + self.points = self.points.saturating_add(self.balance_to_point(new_funds)); + self.balance = self.balance.saturating_add(new_funds); + } + + /// Dissolve some points from the unbonding pool, reducing the balance of the pool + /// proportionally. + /// + /// This is the opposite of `issue`. + /// + /// Returns the actual amount of `Balance` that was removed from the pool. + fn dissolve(&mut self, points: BalanceOf) -> BalanceOf { + let balance_to_unbond = self.point_to_balance(points); + self.points = self.points.saturating_sub(points); + self.balance = self.balance.saturating_sub(balance_to_unbond); + + balance_to_unbond + } +} + +#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, DefaultNoBound, RuntimeDebugNoBound)] +#[cfg_attr(feature = "std", derive(Clone, PartialEq))] +#[codec(mel_bound(T: Config))] +#[scale_info(skip_type_params(T))] +pub struct SubPools { + /// A general, era agnostic pool of funds that have fully unbonded. The pools + /// of `Self::with_era` will lazily be merged into into this pool if they are + /// older then `current_era - TotalUnbondingPools`. + no_era: UnbondPool, + /// Map of era in which a pool becomes unbonded in => unbond pools. + with_era: UnbondingPoolsWithEra, +} + +impl SubPools { + /// Merge the oldest `with_era` unbond pools into the `no_era` unbond pool. + /// + /// This is often used whilst getting the sub-pool from storage, thus it consumes and returns + /// `Self` for ergonomic purposes. + fn maybe_merge_pools(mut self, unbond_era: EraIndex) -> Self { + // Ex: if `TotalUnbondingPools` is 5 and current era is 10, we only want to retain pools + // 6..=10. Note that in the first few eras where `checked_sub` is `None`, we don't remove + // anything. + if let Some(newest_era_to_remove) = unbond_era.checked_sub(TotalUnbondingPools::::get()) + { + self.with_era.retain(|k, v| { + if *k > newest_era_to_remove { + // keep + true + } else { + // merge into the no-era pool + self.no_era.points = self.no_era.points.saturating_add(v.points); + self.no_era.balance = self.no_era.balance.saturating_add(v.balance); + false + } + }); + } + + self + } + + /// The sum of all unbonding points, regardless of whether they are actually unlocked or not. + fn sum_unbonding_points(&self) -> BalanceOf { + self.no_era.points.saturating_add( + self.with_era + .values() + .fold(BalanceOf::::zero(), |acc, pool| acc.saturating_add(pool.points)), + ) + } + + /// The sum of all unbonding balance, regardless of whether they are actually unlocked or not. + #[cfg(any(test, debug_assertions))] + fn sum_unbonding_balance(&self) -> BalanceOf { + self.no_era.balance.saturating_add( + self.with_era + .values() + .fold(BalanceOf::::zero(), |acc, pool| acc.saturating_add(pool.balance)), + ) + } +} + +/// The maximum amount of eras an unbonding pool can exist prior to being merged with the +/// `no_era` pool. This is guaranteed to at least be equal to the staking `UnbondingDuration`. For +/// improved UX [`Config::PostUnbondingPoolsWindow`] should be configured to a non-zero value. +pub struct TotalUnbondingPools(PhantomData); +impl Get for TotalUnbondingPools { + fn get() -> u32 { + // NOTE: this may be dangerous in the scenario bonding_duration gets decreased because + // we would no longer be able to decode `UnbondingPoolsWithEra`, which uses + // `TotalUnbondingPools` as the bound + T::StakingInterface::bonding_duration() + T::PostUnbondingPoolsWindow::get() + } +} + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::transactional; + use frame_system::{ensure_signed, pallet_prelude::*}; + + #[pallet::pallet] + #[pallet::generate_store(pub(crate) trait Store)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type Event: From> + IsType<::Event>; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: weights::WeightInfo; + + /// The nominating balance. + type Currency: Currency; + + /// The nomination pool's pallet id. + #[pallet::constant] + type PalletId: Get; + + /// Infallible method for converting `Currency::Balance` to `U256`. + type BalanceToU256: Convert, U256>; + + /// Infallible method for converting `U256` to `Currency::Balance`. + type U256ToBalance: Convert>; + + /// The interface for nominating. + type StakingInterface: StakingInterface< + Balance = BalanceOf, + AccountId = Self::AccountId, + >; + + /// The amount of eras a `SubPools::with_era` pool can exist before it gets merged into the + /// `SubPools::no_era` pool. In other words, this is the amount of eras a member will be + /// able to withdraw from an unbonding pool which is guaranteed to have the correct ratio of + /// points to balance; once the `with_era` pool is merged into the `no_era` pool, the ratio + /// can become skewed due to some slashed ratio getting merged in at some point. + type PostUnbondingPoolsWindow: Get; + + /// The maximum length, in bytes, that a pools metadata maybe. + type MaxMetadataLen: Get; + + /// The maximum number of simultaneous unbonding chunks that can exist per member. + type MaxUnbonding: Get; + } + + /// Minimum amount to bond to join a pool. + #[pallet::storage] + pub type MinJoinBond = StorageValue<_, BalanceOf, ValueQuery>; + + /// Minimum bond required to create a pool. + /// + /// This is the amount that the depositor must put as their initial stake in the pool, as an + /// indication of "skin in the game". + #[pallet::storage] + pub type MinCreateBond = StorageValue<_, BalanceOf, ValueQuery>; + + /// Maximum number of nomination pools that can exist. If `None`, then an unbounded number of + /// pools can exist. + #[pallet::storage] + pub type MaxPools = StorageValue<_, u32, OptionQuery>; + + /// Maximum number of members that can exist in the system. If `None`, then the count + /// members are not bound on a system wide basis. + #[pallet::storage] + pub type MaxPoolMembers = StorageValue<_, u32, OptionQuery>; + + /// Maximum number of members that may belong to pool. If `None`, then the count of + /// members is not bound on a per pool basis. + #[pallet::storage] + pub type MaxPoolMembersPerPool = StorageValue<_, u32, OptionQuery>; + + /// Active members. + #[pallet::storage] + pub type PoolMembers = + CountedStorageMap<_, Twox64Concat, T::AccountId, PoolMember>; + + /// Storage for bonded pools. + // To get or insert a pool see [`BondedPool::get`] and [`BondedPool::put`] + #[pallet::storage] + pub type BondedPools = + CountedStorageMap<_, Twox64Concat, PoolId, BondedPoolInner>; + + /// Reward pools. This is where there rewards for each pool accumulate. When a members payout + /// is claimed, the balance comes out fo the reward pool. Keyed by the bonded pools account. + #[pallet::storage] + pub type RewardPools = CountedStorageMap<_, Twox64Concat, PoolId, RewardPool>; + + /// Groups of unbonding pools. Each group of unbonding pools belongs to a bonded pool, + /// hence the name sub-pools. Keyed by the bonded pools account. + #[pallet::storage] + pub type SubPoolsStorage = CountedStorageMap<_, Twox64Concat, PoolId, SubPools>; + + /// Metadata for the pool. + #[pallet::storage] + pub type Metadata = + CountedStorageMap<_, Twox64Concat, PoolId, BoundedVec, ValueQuery>; + + #[pallet::storage] + pub type LastPoolId = StorageValue<_, u32, ValueQuery>; + + #[pallet::storage] + pub type ReversePoolIdLookup = + CountedStorageMap<_, Twox64Concat, T::AccountId, PoolId, OptionQuery>; + + #[pallet::genesis_config] + pub struct GenesisConfig { + pub min_join_bond: BalanceOf, + pub min_create_bond: BalanceOf, + pub max_pools: Option, + pub max_members_per_pool: Option, + pub max_members: Option, + } + + #[cfg(feature = "std")] + impl Default for GenesisConfig { + fn default() -> Self { + Self { + min_join_bond: Zero::zero(), + min_create_bond: Zero::zero(), + max_pools: Some(16), + max_members_per_pool: Some(32), + max_members: Some(16 * 32), + } + } + } + + #[pallet::genesis_build] + impl GenesisBuild for GenesisConfig { + fn build(&self) { + MinJoinBond::::put(self.min_join_bond); + MinCreateBond::::put(self.min_create_bond); + if let Some(max_pools) = self.max_pools { + MaxPools::::put(max_pools); + } + if let Some(max_members_per_pool) = self.max_members_per_pool { + MaxPoolMembersPerPool::::put(max_members_per_pool); + } + if let Some(max_members) = self.max_members { + MaxPoolMembers::::put(max_members); + } + } + } + + /// Events of this pallet. + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + /// A pool has been created. + Created { depositor: T::AccountId, pool_id: PoolId }, + /// A member has became bonded in a pool. + Bonded { member: T::AccountId, pool_id: PoolId, bonded: BalanceOf, joined: bool }, + /// A payout has been made to a member. + PaidOut { member: T::AccountId, pool_id: PoolId, payout: BalanceOf }, + /// A member has unbonded from their pool. + Unbonded { member: T::AccountId, pool_id: PoolId, amount: BalanceOf }, + /// A member has withdrawn from their pool. + Withdrawn { member: T::AccountId, pool_id: PoolId, amount: BalanceOf }, + /// A pool has been destroyed. + Destroyed { pool_id: PoolId }, + /// The state of a pool has changed + StateChanged { pool_id: PoolId, new_state: PoolState }, + } + + #[pallet::error] + #[cfg_attr(test, derive(PartialEq))] + pub enum Error { + /// A (bonded) pool id does not exist. + PoolNotFound, + /// An account is not a member. + PoolMemberNotFound, + /// A reward pool does not exist. In all cases this is a system logic error. + RewardPoolNotFound, + /// A sub pool does not exist. + SubPoolsNotFound, + /// An account is already delegating in another pool. An account may only belong to one + /// pool at a time. + AccountBelongsToOtherPool, + /// The pool has insufficient balance to bond as a nominator. + InsufficientBond, + /// The member is already unbonding in this era. + AlreadyUnbonding, + /// The member is fully unbonded (and thus cannot access the bonded and reward pool + /// anymore to, for example, collect rewards). + FullyUnbonding, + /// The member cannot unbond further chunks due to reaching the limit. + MaxUnbondingLimit, + /// None of the funds can be withdrawn yet because the bonding duration has not passed. + CannotWithdrawAny, + /// The amount does not meet the minimum bond to either join or create a pool. + MinimumBondNotMet, + /// The transaction could not be executed due to overflow risk for the pool. + OverflowRisk, + /// A pool must be in [`PoolState::Destroying`] in order for the depositor to unbond or for + /// other members to be permissionlessly unbonded. + NotDestroying, + /// The depositor must be the only member in the bonded pool in order to unbond. And the + /// depositor must be the only member in the sub pools in order to withdraw unbonded. + NotOnlyPoolMember, + /// The caller does not have nominating permissions for the pool. + NotNominator, + /// Either a) the caller cannot make a valid kick or b) the pool is not destroying. + NotKickerOrDestroying, + /// The pool is not open to join + NotOpen, + /// The system is maxed out on pools. + MaxPools, + /// Too many members in the pool or system. + MaxPoolMembers, + /// The pools state cannot be changed. + CanNotChangeState, + /// The caller does not have adequate permissions. + DoesNotHavePermission, + /// Metadata exceeds [`Config::MaxMetadataLen`] + MetadataExceedsMaxLen, + /// Some error occurred that should never happen. This should be reported to the + /// maintainers. + DefensiveError, + /// Not enough points. Ty unbonding less. + NotEnoughPointsToUnbond, + } + + #[pallet::call] + impl Pallet { + /// Stake funds with a pool. The amount to bond is transferred from the member to the + /// pools account and immediately increases the pools bond. + /// + /// # Note + /// + /// * An account can only be a member of a single pool. + /// * An account cannot join the same pool multiple times. + /// * This call will *not* dust the member account, so the member must have at least + /// `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, + pool_id: PoolId, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(amount >= MinJoinBond::::get(), Error::::MinimumBondNotMet); + // If a member already exists that means they already belong to a pool + ensure!(!PoolMembers::::contains_key(&who), Error::::AccountBelongsToOtherPool); + + let mut bonded_pool = BondedPool::::get(pool_id).ok_or(Error::::PoolNotFound)?; + bonded_pool.ok_to_join(amount)?; + + // We just need its total earnings at this point in time, but we don't need to write it + // because we are not adjusting its points (all other values can calculated virtual). + let reward_pool = RewardPool::::get_and_update(pool_id) + .defensive_ok_or_else(|| Error::::RewardPoolNotFound)?; + + bonded_pool.try_inc_members()?; + let points_issued = bonded_pool.try_bond_funds(&who, amount, BondType::Later)?; + + PoolMembers::insert( + who.clone(), + PoolMember:: { + pool_id, + points: points_issued, + // At best the reward pool has the rewards up through the previous era. If the + // member joins prior to the snapshot they will benefit from the rewards of + // the active era despite not contributing to the pool's vote weight. If they + // join after the snapshot is taken they will benefit from the rewards of the + // next 2 eras because their vote weight will not be counted until the + // snapshot in active era + 1. + reward_pool_total_earnings: reward_pool.total_earnings, + unbonding_eras: Default::default(), + }, + ); + + Self::deposit_event(Event::::Bonded { + member: who, + pool_id, + bonded: amount, + joined: true, + }); + bonded_pool.put(); + + Ok(()) + } + + /// Bond `extra` more funds from `origin` into the pool to which they already belong. + /// + /// Additional funds can come from either the free balance of the account, of from the + /// accumulated rewards, see [`BondExtra`]. + // NOTE: this transaction is implemented with the sole purpose of readability and + // correctness, not optimization. We read/write several storage items multiple times instead + // of just once, in the spirit reusing code. + #[pallet::weight( + 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)?; + + let (points_issued, bonded) = match extra { + BondExtra::FreeBalance(amount) => + (bonded_pool.try_bond_funds(&who, amount, BondType::Later)?, amount), + BondExtra::Rewards => { + let claimed = Self::do_reward_payout( + &who, + &mut member, + &mut bonded_pool, + &mut reward_pool, + )?; + (bonded_pool.try_bond_funds(&who, claimed, BondType::Later)?, claimed) + }, + }; + bonded_pool.ok_to_be_open(bonded)?; + member.points = member.points.saturating_add(points_issued); + + Self::deposit_event(Event::::Bonded { + member: who.clone(), + pool_id: member.pool_id, + bonded, + joined: false, + }); + Self::put_member_with_pools(&who, member, bonded_pool, reward_pool); + + Ok(()) + } + + /// A bonded member can use this to claim their payout based on the rewards that the pool + /// has accumulated since their last claimed payout (OR since joining if this is there first + /// time claiming rewards). The payout will be transferred to the member's account. + /// + /// 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)?; + + let _ = Self::do_reward_payout(&who, &mut member, &mut bonded_pool, &mut reward_pool)?; + + Self::put_member_with_pools(&who, member, bonded_pool, reward_pool); + Ok(()) + } + + /// Unbond up to `unbonding_points` of the `member_account`'s funds from the pool. It + /// implicitly collects the rewards one last time, since not doing so would mean some + /// rewards would go forfeited. + /// + /// Under certain conditions, this call can be dispatched permissionlessly (i.e. by any + /// account). + /// + /// # Conditions for a permissionless dispatch. + /// + /// * The pool is blocked and the caller is either the root or state-toggler. This is + /// refereed to as a kick. + /// * The pool is destroying and the member is not the depositor. + /// * The pool is destroying, the member is the depositor and no other members are in the + /// pool. + /// + /// ## Conditions for permissioned dispatch (i.e. the caller is also the + /// `member_account`): + /// + /// * The caller is not the depositor. + /// * The caller is the depositor, the pool is destroying and no other members are in the + /// pool. + /// + /// # Note + /// + /// If there are too many unlocking chunks to unbond with the pool account, + /// [`Call::pool_withdraw_unbonded`] can be called to try and minimize unlocking chunks. If + /// there are too many unlocking chunks, the result of this call will likely be the + /// `NoMoreChunks` error from the staking system. + #[pallet::weight(T::WeightInfo::unbond())] + #[transactional] + pub fn unbond( + origin: OriginFor, + member_account: T::AccountId, + #[pallet::compact] unbonding_points: BalanceOf, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + let (mut member, mut bonded_pool, mut reward_pool) = + Self::get_member_with_pools(&member_account)?; + + bonded_pool.ok_to_unbond_with(&caller, &member_account, &member, unbonding_points)?; + + // Claim the the payout prior to unbonding. Once the user is unbonding their points + // no longer exist in the bonded pool and thus they can no longer claim their payouts. + // It is not strictly necessary to claim the rewards, but we do it here for UX. + Self::do_reward_payout( + &member_account, + &mut member, + &mut bonded_pool, + &mut reward_pool, + )?; + + let current_era = T::StakingInterface::current_era(); + let unbond_era = T::StakingInterface::bonding_duration().saturating_add(current_era); + + // Try and unbond in the member map. + member.try_unbond(unbonding_points, unbond_era)?; + + // Unbond in the actual underlying nominator. + let unbonding_balance = bonded_pool.dissolve(unbonding_points); + T::StakingInterface::unbond(bonded_pool.bonded_account(), unbonding_balance)?; + + // Note that we lazily create the unbonding pools here if they don't already exist + let mut sub_pools = SubPoolsStorage::::get(member.pool_id) + .unwrap_or_default() + .maybe_merge_pools(unbond_era); + + // Update the unbond pool associated with the current era with the unbonded funds. Note + // that we lazily create the unbond pool if it does not yet exist. + if !sub_pools.with_era.contains_key(&unbond_era) { + sub_pools + .with_era + .try_insert(unbond_era, UnbondPool::default()) + // The above call to `maybe_merge_pools` should ensure there is + // always enough space to insert. + .defensive_map_err(|_| Error::::DefensiveError)?; + } + + sub_pools + .with_era + .get_mut(&unbond_era) + // The above check ensures the pool exists. + .defensive_ok_or_else(|| Error::::DefensiveError)? + .issue(unbonding_balance); + + Self::deposit_event(Event::::Unbonded { + member: member_account.clone(), + pool_id: member.pool_id, + amount: unbonding_balance, + }); + + // Now that we know everything has worked write the items to storage. + SubPoolsStorage::insert(&member.pool_id, sub_pools); + Self::put_member_with_pools(&member_account, member, bonded_pool, reward_pool); + + Ok(()) + } + + /// Call `withdraw_unbonded` for the pools account. This call can be made by any account. + /// + /// This is useful if their are too many unlocking chunks to call `unbond`, and some + /// can be cleared by withdrawing. In the case there are too many unlocking chunks, the user + /// would probably see an error like `NoMoreChunks` emitted from the staking system when + /// they attempt to unbond. + #[pallet::weight(T::WeightInfo::pool_withdraw_unbonded(*num_slashing_spans))] + #[transactional] + pub fn pool_withdraw_unbonded( + origin: OriginFor, + pool_id: PoolId, + num_slashing_spans: u32, + ) -> DispatchResult { + let _ = ensure_signed(origin)?; + let pool = BondedPool::::get(pool_id).ok_or(Error::::PoolNotFound)?; + // For now we only allow a pool to withdraw unbonded if its not destroying. If the pool + // is destroying then `withdraw_unbonded` can be used. + ensure!(pool.state != PoolState::Destroying, Error::::NotDestroying); + T::StakingInterface::withdraw_unbonded(pool.bonded_account(), num_slashing_spans)?; + Ok(()) + } + + /// Withdraw unbonded funds from `member_account`. If no bonded funds can be unbonded, an + /// error is returned. + /// + /// Under certain conditions, this call can be dispatched permissionlessly (i.e. by any + /// account). + /// + /// # Conditions for a permissionless dispatch + /// + /// * The pool is in destroy mode and the target is not the depositor. + /// * The target is the depositor and they are the only member in the sub pools. + /// * The pool is blocked and the caller is either the root or state-toggler. + /// + /// # Conditions for permissioned dispatch + /// + /// * The caller is the target and they are not the depositor. + /// + /// # Note + /// + /// If the target is the depositor, the pool will be destroyed. + #[pallet::weight( + T::WeightInfo::withdraw_unbonded_kill(*num_slashing_spans) + )] + #[transactional] + pub fn withdraw_unbonded( + origin: OriginFor, + member_account: T::AccountId, + num_slashing_spans: u32, + ) -> DispatchResultWithPostInfo { + let caller = ensure_signed(origin)?; + let mut member = + PoolMembers::::get(&member_account).ok_or(Error::::PoolMemberNotFound)?; + let current_era = T::StakingInterface::current_era(); + + let bonded_pool = BondedPool::::get(member.pool_id) + .defensive_ok_or_else(|| Error::::PoolNotFound)?; + let mut sub_pools = SubPoolsStorage::::get(member.pool_id) + .defensive_ok_or_else(|| Error::::SubPoolsNotFound)?; + + bonded_pool.ok_to_withdraw_unbonded_with( + &caller, + &member_account, + &member, + &sub_pools, + )?; + + // NOTE: must do this after we have done the `ok_to_withdraw_unbonded_other_with` check. + let withdrawn_points = member.withdraw_unlocked(current_era); + ensure!(!withdrawn_points.is_empty(), Error::::CannotWithdrawAny); + + // Before calculate the `balance_to_unbond`, with call withdraw unbonded to ensure the + // `transferrable_balance` is correct. + T::StakingInterface::withdraw_unbonded( + bonded_pool.bonded_account(), + num_slashing_spans, + )?; + + let balance_to_unbond = withdrawn_points + .iter() + .fold(BalanceOf::::zero(), |accumulator, (era, unlocked_points)| { + if let Some(era_pool) = sub_pools.with_era.get_mut(&era) { + let balance_to_unbond = era_pool.dissolve(*unlocked_points); + if era_pool.points.is_zero() { + sub_pools.with_era.remove(&era); + } + accumulator.saturating_add(balance_to_unbond) + } else { + // A pool does not belong to this era, so it must have been merged to the + // era-less pool. + accumulator.saturating_add(sub_pools.no_era.dissolve(*unlocked_points)) + } + }) + // A call to this function may cause the pool's stash to get dusted. If this happens + // before the last member has withdrawn, then all subsequent withdraws will be 0. + // However the unbond pools do no get updated to reflect this. In the aforementioned + // scenario, this check ensures we don't try to withdraw funds that don't exist. + // This check is also defensive in cases where the unbond pool does not update its + // balance (e.g. a bug in the slashing hook.) We gracefully proceed in order to + // ensure members can leave the pool and it can be destroyed. + .min(bonded_pool.transferrable_balance()); + + T::Currency::transfer( + &bonded_pool.bonded_account(), + &member_account, + balance_to_unbond, + ExistenceRequirement::AllowDeath, + ) + .defensive()?; + + Self::deposit_event(Event::::Withdrawn { + member: member_account.clone(), + pool_id: member.pool_id, + amount: balance_to_unbond, + }); + + let post_info_weight = if member.active_points().is_zero() { + // member being reaped. + PoolMembers::::remove(&member_account); + + if member_account == bonded_pool.roles.depositor { + Pallet::::dissolve_pool(bonded_pool); + None + } else { + bonded_pool.dec_members().put(); + SubPoolsStorage::::insert(&member.pool_id, sub_pools); + Some(T::WeightInfo::withdraw_unbonded_update(num_slashing_spans)) + } + } else { + // we certainly don't need to delete any pools, because no one is being removed. + SubPoolsStorage::::insert(&member.pool_id, sub_pools); + PoolMembers::::insert(&member_account, member); + Some(T::WeightInfo::withdraw_unbonded_update(num_slashing_spans)) + }; + + Ok(post_info_weight.into()) + } + + /// Create a new delegation pool. + /// + /// # Arguments + /// + /// * `amount` - The amount of funds to delegate to the pool. This also acts of a sort of + /// deposit since the pools creator cannot fully unbond funds until the pool is being + /// destroyed. + /// * `index` - A disambiguation index for creating the account. Likely only useful when + /// creating multiple pools in the same extrinsic. + /// * `root` - The account to set as [`PoolRoles::root`]. + /// * `nominator` - The account to set as the [`PoolRoles::nominator`]. + /// * `state_toggler` - The account to set as the [`PoolRoles::state_toggler`]. + /// + /// # Note + /// + /// In addition to `amount`, the caller will transfer the existential deposit; so the caller + /// needs at have at least `amount + existential_deposit` transferrable. + #[pallet::weight(T::WeightInfo::create())] + #[transactional] + pub fn create( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + root: T::AccountId, + nominator: T::AccountId, + state_toggler: T::AccountId, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!( + amount >= + T::StakingInterface::minimum_bond() + .max(MinCreateBond::::get()) + .max(MinJoinBond::::get()), + Error::::MinimumBondNotMet + ); + ensure!( + MaxPools::::get() + .map_or(true, |max_pools| BondedPools::::count() < max_pools), + Error::::MaxPools + ); + ensure!(!PoolMembers::::contains_key(&who), Error::::AccountBelongsToOtherPool); + + let pool_id = LastPoolId::::mutate(|id| { + *id += 1; + *id + }); + let mut bonded_pool = BondedPool::::new( + pool_id, + PoolRoles { root, nominator, state_toggler, depositor: who.clone() }, + ); + + bonded_pool.try_inc_members()?; + let points = bonded_pool.try_bond_funds(&who, amount, BondType::Create)?; + + T::Currency::transfer( + &who, + &bonded_pool.reward_account(), + T::Currency::minimum_balance(), + ExistenceRequirement::AllowDeath, + )?; + + PoolMembers::::insert( + who.clone(), + PoolMember:: { + pool_id, + points, + reward_pool_total_earnings: Zero::zero(), + unbonding_eras: Default::default(), + }, + ); + RewardPools::::insert( + pool_id, + RewardPool:: { + balance: Zero::zero(), + points: U256::zero(), + total_earnings: Zero::zero(), + }, + ); + ReversePoolIdLookup::::insert(bonded_pool.bonded_account(), pool_id); + Self::deposit_event(Event::::Created { + depositor: who.clone(), + pool_id: pool_id.clone(), + }); + Self::deposit_event(Event::::Bonded { + member: who, + pool_id, + bonded: amount, + joined: true, + }); + bonded_pool.put(); + + Ok(()) + } + + #[pallet::weight(T::WeightInfo::nominate(validators.len() as u32))] + pub fn nominate( + origin: OriginFor, + pool_id: PoolId, + validators: Vec, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let bonded_pool = BondedPool::::get(pool_id).ok_or(Error::::PoolNotFound)?; + ensure!(bonded_pool.can_nominate(&who), Error::::NotNominator); + T::StakingInterface::nominate(bonded_pool.bonded_account(), validators)?; + Ok(()) + } + + #[pallet::weight(T::WeightInfo::set_state())] + pub fn set_state( + origin: OriginFor, + pool_id: PoolId, + state: PoolState, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let mut bonded_pool = BondedPool::::get(pool_id).ok_or(Error::::PoolNotFound)?; + ensure!(bonded_pool.state != PoolState::Destroying, Error::::CanNotChangeState); + + if bonded_pool.can_toggle_state(&who) { + bonded_pool.set_state(state); + } else if bonded_pool.ok_to_be_open(Zero::zero()).is_err() && + state == PoolState::Destroying + { + // If the pool has bad properties, then anyone can set it as destroying + bonded_pool.set_state(PoolState::Destroying); + } else { + Err(Error::::CanNotChangeState)?; + } + + bonded_pool.put(); + + Ok(()) + } + + #[pallet::weight(T::WeightInfo::set_metadata(metadata.len() as u32))] + pub fn set_metadata( + origin: OriginFor, + pool_id: PoolId, + metadata: Vec, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let metadata: BoundedVec<_, _> = + metadata.try_into().map_err(|_| Error::::MetadataExceedsMaxLen)?; + ensure!( + BondedPool::::get(pool_id) + .ok_or(Error::::PoolNotFound)? + .can_set_metadata(&who), + Error::::DoesNotHavePermission + ); + + Metadata::::mutate(pool_id, |pool_meta| *pool_meta = metadata); + + Ok(()) + } + + /// Update configurations for the nomination pools. The origin must for this call must be + /// Root. + /// + /// # Arguments + /// + /// * `min_join_bond` - Set [`MinJoinBond`]. + /// * `min_create_bond` - Set [`MinCreateBond`]. + /// * `max_pools` - Set [`MaxPools`]. + /// * `max_members` - Set [`MaxPoolMembers`]. + /// * `max_members_per_pool` - Set [`MaxPoolMembersPerPool`]. + #[pallet::weight(T::WeightInfo::set_configs())] + pub fn set_configs( + origin: OriginFor, + min_join_bond: ConfigOp>, + min_create_bond: ConfigOp>, + max_pools: ConfigOp, + max_members: ConfigOp, + max_members_per_pool: ConfigOp, + ) -> DispatchResult { + ensure_root(origin)?; + + macro_rules! config_op_exp { + ($storage:ty, $op:ident) => { + match $op { + ConfigOp::Noop => (), + ConfigOp::Set(v) => <$storage>::put(v), + ConfigOp::Remove => <$storage>::kill(), + } + }; + } + + config_op_exp!(MinJoinBond::, min_join_bond); + config_op_exp!(MinCreateBond::, min_create_bond); + config_op_exp!(MaxPools::, max_pools); + config_op_exp!(MaxPoolMembers::, max_members); + config_op_exp!(MaxPoolMembersPerPool::, max_members_per_pool); + + Ok(()) + } + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn integrity_test() { + assert!( + sp_std::mem::size_of::() >= + 2 * sp_std::mem::size_of::>(), + "bit-length of the reward points must be at least twice as much as balance" + ); + + assert!( + T::StakingInterface::bonding_duration() < TotalUnbondingPools::::get(), + "There must be more unbonding pools then the bonding duration / + so a slash can be applied to relevant unboding pools. (We assume / + the bonding duration > slash deffer duration.", + ); + } + } +} + +impl Pallet { + /// Remove everything related to the given bonded pool. + /// + /// All sub-pools are also deleted. All accounts are dusted and the leftover of the reward + /// account is returned to the depositor. + pub fn dissolve_pool(bonded_pool: BondedPool) { + let reward_account = bonded_pool.reward_account(); + let bonded_account = bonded_pool.bonded_account(); + + ReversePoolIdLookup::::remove(&bonded_account); + RewardPools::::remove(bonded_pool.id); + SubPoolsStorage::::remove(bonded_pool.id); + + // Kill accounts from storage by making their balance go below ED. We assume that the + // accounts have no references that would prevent destruction once we get to this point. We + // don't work with the system pallet directly, but + // 1. we drain the reward account and kill it. This account should never have any extra + // consumers anyway. + // 2. the bonded account should become a 'killed stash' in the staking system, and all of + // its consumers removed. + debug_assert_eq!(frame_system::Pallet::::consumers(&reward_account), 0); + debug_assert_eq!(frame_system::Pallet::::consumers(&bonded_account), 0); + debug_assert_eq!( + T::StakingInterface::total_stake(&bonded_account).unwrap_or_default(), + Zero::zero() + ); + + // This shouldn't fail, but if it does we don't really care + let reward_pool_remaining = T::Currency::free_balance(&reward_account); + let _ = T::Currency::transfer( + &reward_account, + &bonded_pool.roles.depositor, + reward_pool_remaining, + ExistenceRequirement::AllowDeath, + ); + + // TODO: this is purely defensive. + T::Currency::make_free_balance_be(&reward_account, Zero::zero()); + T::Currency::make_free_balance_be(&bonded_pool.bonded_account(), Zero::zero()); + + Self::deposit_event(Event::::Destroyed { pool_id: bonded_pool.id }); + bonded_pool.remove(); + } + + /// Create the main, bonded account of a pool with the given id. + pub fn create_bonded_account(id: PoolId) -> T::AccountId { + T::PalletId::get().into_sub_account((AccountType::Bonded, id)) + } + + /// Create the reward account of a pool with the given id. + pub fn create_reward_account(id: PoolId) -> T::AccountId { + // NOTE: in order to have a distinction in the test account id type (u128), we put + // account_type first so it does not get truncated out. + T::PalletId::get().into_sub_account((AccountType::Reward, id)) + } + + /// Get the member with their associated bonded and reward pool. + fn get_member_with_pools( + who: &T::AccountId, + ) -> Result<(PoolMember, BondedPool, RewardPool), Error> { + let member = PoolMembers::::get(&who).ok_or(Error::::PoolMemberNotFound)?; + let bonded_pool = + BondedPool::::get(member.pool_id).defensive_ok_or(Error::::PoolNotFound)?; + let reward_pool = + RewardPools::::get(member.pool_id).defensive_ok_or(Error::::PoolNotFound)?; + Ok((member, bonded_pool, reward_pool)) + } + + /// Persist the member with their associated bonded and reward pool into storage, consuming + /// all of them. + fn put_member_with_pools( + member_account: &T::AccountId, + member: PoolMember, + bonded_pool: BondedPool, + reward_pool: RewardPool, + ) { + bonded_pool.put(); + RewardPools::insert(member.pool_id, reward_pool); + PoolMembers::::insert(member_account, member); + } + + /// Calculate the equivalent point of `new_funds` in a pool with `current_balance` and + /// `current_points`. + fn balance_to_point( + current_balance: BalanceOf, + current_points: BalanceOf, + new_funds: BalanceOf, + ) -> BalanceOf { + let u256 = |x| T::BalanceToU256::convert(x); + let balance = |x| T::U256ToBalance::convert(x); + match (current_balance.is_zero(), current_points.is_zero()) { + (_, true) => new_funds.saturating_mul(POINTS_TO_BALANCE_INIT_RATIO.into()), + (true, false) => { + // The pool was totally slashed. + // This is the equivalent of `(current_points / 1) * new_funds`. + new_funds.saturating_mul(current_points) + }, + (false, false) => { + // Equivalent to (current_points / current_balance) * new_funds + balance( + u256(current_points) + .saturating_mul(u256(new_funds)) + // We check for zero above + .div(u256(current_balance)), + ) + }, + } + } + + /// Calculate the equivalent balance of `points` in a pool with `current_balance` and + /// `current_points`. + fn point_to_balance( + current_balance: BalanceOf, + current_points: BalanceOf, + points: BalanceOf, + ) -> BalanceOf { + let u256 = |x| T::BalanceToU256::convert(x); + let balance = |x| T::U256ToBalance::convert(x); + if current_balance.is_zero() || current_points.is_zero() || points.is_zero() { + // There is nothing to unbond + return Zero::zero() + } + + // Equivalent of (current_balance / current_points) * points + balance(u256(current_balance).saturating_mul(u256(points))) + // We check for zero above + .div(current_points) + } + + /// Calculate the rewards for `member`. + /// + /// Returns the payout amount. + fn calculate_member_payout( + member: &mut PoolMember, + bonded_pool: &mut BondedPool, + reward_pool: &mut RewardPool, + ) -> Result, DispatchError> { + let u256 = |x| T::BalanceToU256::convert(x); + let balance = |x| T::U256ToBalance::convert(x); + + let last_total_earnings = reward_pool.total_earnings; + reward_pool.update_total_earnings_and_balance(bonded_pool.id); + + // Notice there is an edge case where total_earnings have not increased and this is zero + let new_earnings = u256(reward_pool.total_earnings.saturating_sub(last_total_earnings)); + + // The new points that will be added to the pool. For every unit of balance that has been + // earned by the reward pool, we inflate the reward pool points by `bonded_pool.points`. In + // effect this allows each, single unit of balance (e.g. plank) to be divvied up pro rata + // among members based on points. + let new_points = u256(bonded_pool.points).saturating_mul(new_earnings); + + // The points of the reward pool after taking into account the new earnings. Notice that + // this only stays even or increases over time except for when we subtract member virtual + // shares. + let current_points = bonded_pool.bound_check(reward_pool.points.saturating_add(new_points)); + + // The rewards pool's earnings since the last time this member claimed a payout. + let new_earnings_since_last_claim = + reward_pool.total_earnings.saturating_sub(member.reward_pool_total_earnings); + + // The points of the reward pool that belong to the member. + let member_virtual_points = + // The members portion of the reward pool + u256(member.active_points()) + // times the amount the pool has earned since the member last claimed. + .saturating_mul(u256(new_earnings_since_last_claim)); + + let member_payout = if member_virtual_points.is_zero() || + current_points.is_zero() || + reward_pool.balance.is_zero() + { + Zero::zero() + } else { + // Equivalent to `(member_virtual_points / current_points) * reward_pool.balance` + let numerator = { + let numerator = member_virtual_points.saturating_mul(u256(reward_pool.balance)); + bonded_pool.bound_check(numerator) + }; + balance( + numerator + // We check for zero above + .div(current_points), + ) + }; + + // Record updates + if reward_pool.total_earnings == BalanceOf::::max_value() { + bonded_pool.set_state(PoolState::Destroying); + }; + member.reward_pool_total_earnings = reward_pool.total_earnings; + reward_pool.points = current_points.saturating_sub(member_virtual_points); + reward_pool.balance = reward_pool.balance.saturating_sub(member_payout); + + Ok(member_payout) + } + + /// If the member has some rewards, transfer a payout from the reward pool to the member. + // Emits events and potentially modifies pool state if any arithmetic saturates, but does + // not persist any of the mutable inputs to storage. + fn do_reward_payout( + member_account: &T::AccountId, + member: &mut PoolMember, + bonded_pool: &mut BondedPool, + reward_pool: &mut RewardPool, + ) -> Result, DispatchError> { + debug_assert_eq!(member.pool_id, bonded_pool.id); + // a member who has no skin in the game anymore cannot claim any rewards. + ensure!(!member.active_points().is_zero(), Error::::FullyUnbonding); + let was_destroying = bonded_pool.is_destroying(); + + let member_payout = Self::calculate_member_payout(member, bonded_pool, reward_pool)?; + + // Transfer payout to the member. + T::Currency::transfer( + &bonded_pool.reward_account(), + &member_account, + member_payout, + ExistenceRequirement::AllowDeath, + )?; + + Self::deposit_event(Event::::PaidOut { + member: member_account.clone(), + pool_id: member.pool_id, + payout: member_payout, + }); + + if bonded_pool.is_destroying() && !was_destroying { + Self::deposit_event(Event::::StateChanged { + pool_id: member.pool_id, + new_state: PoolState::Destroying, + }); + } + + Ok(member_payout) + } + + /// Ensure the correctness of the state of this pallet. + /// + /// This should be valid before or after each state transition of this pallet. + /// + /// ## Invariants: + /// + /// First, let's consider pools: + /// + /// * `BondedPools` and `RewardPools` must all have the EXACT SAME key-set. + /// * `SubPoolsStorage` must be a subset of the above superset. + /// * `Metadata` keys must be a subset of the above superset. + /// * the count of the above set must be less than `MaxPools`. + /// + /// Then, considering members as well: + /// + /// * each `BondedPool.member_counter` must be: + /// - correct (compared to actual count of member who have `.pool_id` this pool) + /// - less than `MaxPoolMembersPerPool`. + /// * each `member.pool_id` must correspond to an existing `BondedPool.id` (which implies the + /// existence of the reward pool as well). + /// * count of all members must be less than `MaxPoolMembers`. + /// + /// Then, considering unbonding members: + /// + /// for each pool: + /// * sum of the balance that's tracked in all unbonding pools must be the same as the + /// unbonded balance of the main account, as reported by the staking interface. + /// * sum of the balance that's tracked in all unbonding pools, plus the bonded balance of the + /// main account should be less than or qual to the total balance of the main account. + /// + /// ## Sanity check level + /// + /// To cater for tests that want to escape parts of these checks, this function is split into + /// multiple `level`s, where the higher the level, the more checks we performs. So, + /// `sanity_check(255)` is the strongest sanity check, and `0` performs no checks. + #[cfg(any(test, debug_assertions))] + pub fn sanity_checks(level: u8) -> Result<(), &'static str> { + if level.is_zero() { + return Ok(()) + } + // note: while a bit wacky, since they have the same key, even collecting to vec should + // result in the same set of keys, in the same order. + let bonded_pools = BondedPools::::iter_keys().collect::>(); + let reward_pools = RewardPools::::iter_keys().collect::>(); + assert_eq!(bonded_pools, reward_pools); + + assert!(Metadata::::iter_keys().all(|k| bonded_pools.contains(&k))); + assert!(SubPoolsStorage::::iter_keys().all(|k| bonded_pools.contains(&k))); + + assert!(MaxPools::::get().map_or(true, |max| bonded_pools.len() <= (max as usize))); + + for id in reward_pools { + let account = Self::create_reward_account(id); + assert!(T::Currency::free_balance(&account) >= T::Currency::minimum_balance()); + } + + let mut pools_members = BTreeMap::::new(); + let mut all_members = 0u32; + PoolMembers::::iter().for_each(|(_, d)| { + assert!(BondedPools::::contains_key(d.pool_id)); + 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; + }); + + BondedPools::::iter().for_each(|(id, inner)| { + let bonded_pool = BondedPool { id, inner }; + assert_eq!( + pools_members.get(&id).map(|x| *x).unwrap_or_default(), + bonded_pool.member_counter + ); + assert!(MaxPoolMembersPerPool::::get() + .map_or(true, |max| bonded_pool.member_counter <= max)); + + let depositor = PoolMembers::::get(&bonded_pool.roles.depositor).unwrap(); + assert!( + bonded_pool.is_destroying_and_only_depositor(depositor.active_points()) || + depositor.active_points() >= MinCreateBond::::get(), + "depositor must always have MinCreateBond stake in the pool, except for when the \ + pool is being destroyed and the depositor is the last member", + ); + }); + assert!(MaxPoolMembers::::get().map_or(true, |max| all_members <= max)); + + if level <= 1 { + return Ok(()) + } + + for (pool_id, _pool) in BondedPools::::iter() { + let pool_account = Pallet::::create_bonded_account(pool_id); + let subs = SubPoolsStorage::::get(pool_id).unwrap_or_default(); + + let sum_unbonding_balance = subs.sum_unbonding_balance(); + let bonded_balance = + T::StakingInterface::active_stake(&pool_account).unwrap_or_default(); + let total_balance = T::Currency::total_balance(&pool_account); + + assert!( + total_balance >= bonded_balance + sum_unbonding_balance, + "faulty pool: {:?} / {:?}, total_balance {:?} >= bonded_balance {:?} + sum_unbonding_balance {:?}", + pool_id, + _pool, + total_balance, + bonded_balance, + sum_unbonding_balance + ); + } + + Ok(()) + } + + /// Fully unbond the shares of `member`, when executed from `origin`. + /// + /// This is useful for backwards compatibility with the majority of tests that only deal with + /// full unbonding, not partial unbonding. + #[cfg(any(feature = "runtime-benchmarks", test))] + pub fn fully_unbond( + origin: frame_system::pallet_prelude::OriginFor, + member: T::AccountId, + ) -> DispatchResult { + let points = PoolMembers::::get(&member).map(|d| d.active_points()).unwrap_or_default(); + Self::unbond(origin, member, points) + } +} + +impl OnStakerSlash> for Pallet { + fn on_slash( + pool_account: &T::AccountId, + // Bonded balance is always read directly from staking, therefore we need not update + // anything here. + _slashed_bonded: BalanceOf, + slashed_unlocking: &BTreeMap>, + ) { + if let Some(pool_id) = ReversePoolIdLookup::::get(pool_account) { + let mut sub_pools = match SubPoolsStorage::::get(pool_id).defensive() { + Some(sub_pools) => sub_pools, + None => return, + }; + for (era, slashed_balance) in slashed_unlocking.iter() { + if let Some(pool) = sub_pools.with_era.get_mut(era) { + pool.balance = *slashed_balance + } + } + SubPoolsStorage::::insert(pool_id, sub_pools); + } + } +} diff --git a/frame/nomination-pools/src/mock.rs b/frame/nomination-pools/src/mock.rs new file mode 100644 index 0000000000000..5498496965adb --- /dev/null +++ b/frame/nomination-pools/src/mock.rs @@ -0,0 +1,309 @@ +use super::*; +use crate::{self as pools}; +use frame_support::{assert_ok, parameter_types, PalletId}; +use frame_system::RawOrigin; +use std::collections::HashMap; + +pub type AccountId = u128; +pub type Balance = u128; + +// Ext builder creates a pool with id 1. +pub fn default_bonded_account() -> AccountId { + Pools::create_bonded_account(1) +} + +// Ext builder creates a pool with id 1. +pub fn default_reward_account() -> AccountId { + Pools::create_reward_account(1) +} + +parameter_types! { + pub static CurrentEra: EraIndex = 0; + pub static BondingDuration: EraIndex = 3; + static BondedBalanceMap: HashMap = Default::default(); + static UnbondingBalanceMap: HashMap = Default::default(); + #[derive(Clone, PartialEq)] + pub static MaxUnbonding: u32 = 8; + pub static Nominations: Vec = vec![]; +} + +pub struct StakingMock; +impl StakingMock { + pub(crate) fn set_bonded_balance(who: AccountId, bonded: Balance) { + BONDED_BALANCE_MAP.with(|m| m.borrow_mut().insert(who, bonded)); + } +} + +impl sp_staking::StakingInterface for StakingMock { + type Balance = Balance; + type AccountId = AccountId; + + fn minimum_bond() -> Self::Balance { + 10 + } + + fn current_era() -> EraIndex { + CurrentEra::get() + } + + fn bonding_duration() -> EraIndex { + BondingDuration::get() + } + + fn active_stake(who: &Self::AccountId) -> Option { + BondedBalanceMap::get().get(who).map(|v| *v) + } + + fn total_stake(who: &Self::AccountId) -> Option { + match ( + UnbondingBalanceMap::get().get(who).map(|v| *v), + BondedBalanceMap::get().get(who).map(|v| *v), + ) { + (None, None) => None, + (Some(v), None) | (None, Some(v)) => Some(v), + (Some(a), Some(b)) => Some(a + b), + } + } + + fn bond_extra(who: Self::AccountId, extra: Self::Balance) -> DispatchResult { + BONDED_BALANCE_MAP.with(|m| *m.borrow_mut().get_mut(&who).unwrap() += extra); + Ok(()) + } + + fn unbond(who: Self::AccountId, amount: Self::Balance) -> DispatchResult { + BONDED_BALANCE_MAP.with(|m| *m.borrow_mut().get_mut(&who).unwrap() -= amount); + UNBONDING_BALANCE_MAP + .with(|m| *m.borrow_mut().entry(who).or_insert(Self::Balance::zero()) += amount); + Ok(()) + } + + fn withdraw_unbonded(who: Self::AccountId, _: u32) -> Result { + // Simulates removing unlocking chunks and only having the bonded balance locked + let _maybe_new_free = UNBONDING_BALANCE_MAP.with(|m| m.borrow_mut().remove(&who)); + + Ok(100) + } + + fn bond( + stash: Self::AccountId, + _: Self::AccountId, + value: Self::Balance, + _: Self::AccountId, + ) -> DispatchResult { + StakingMock::set_bonded_balance(stash, value); + Ok(()) + } + + fn nominate(_: Self::AccountId, nominations: Vec) -> DispatchResult { + Nominations::set(nominations); + Ok(()) + } +} + +impl frame_system::Config for Runtime { + type SS58Prefix = (); + type BaseCallFilter = frame_support::traits::Everything; + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Call = Call; + type Hash = sp_core::H256; + type Hashing = sp_runtime::traits::BlakeTwo256; + type AccountId = AccountId; + type Lookup = sp_runtime::traits::IdentityLookup; + type Header = sp_runtime::testing::Header; + type Event = Event; + type BlockHashCount = (); + type DbWeight = (); + type BlockLength = (); + type BlockWeights = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +parameter_types! { + pub static ExistentialDeposit: Balance = 5; +} + +impl pallet_balances::Config for Runtime { + type MaxLocks = frame_support::traits::ConstU32<1024>; + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type Balance = Balance; + type Event = Event; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); +} + +pub struct BalanceToU256; +impl Convert for BalanceToU256 { + fn convert(n: Balance) -> U256 { + n.into() + } +} + +pub struct U256ToBalance; +impl Convert for U256ToBalance { + fn convert(n: U256) -> Balance { + n.try_into().unwrap() + } +} + +parameter_types! { + pub static PostUnbondingPoolsWindow: u32 = 2; + pub static MaxMetadataLen: u32 = 2; + pub static CheckLevel: u8 = 255; + pub const PoolsPalletId: PalletId = PalletId(*b"py/nopls"); +} +impl pools::Config for Runtime { + type Event = Event; + type WeightInfo = (); + type Currency = Balances; + type BalanceToU256 = BalanceToU256; + type U256ToBalance = U256ToBalance; + type StakingInterface = StakingMock; + type PostUnbondingPoolsWindow = PostUnbondingPoolsWindow; + type PalletId = PoolsPalletId; + type MaxMetadataLen = MaxMetadataLen; + type MaxUnbonding = MaxUnbonding; +} + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; +frame_support::construct_runtime!( + pub enum Runtime where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Storage, Event, Config}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + Pools: pools::{Pallet, Call, Storage, Event}, + } +); + +#[derive(Default)] +pub struct ExtBuilder { + members: Vec<(AccountId, Balance)>, +} + +impl ExtBuilder { + // Add members to pool 0. + pub(crate) fn add_members(mut self, members: Vec<(AccountId, Balance)>) -> Self { + self.members = members; + self + } + + pub(crate) fn ed(self, ed: Balance) -> Self { + ExistentialDeposit::set(ed); + self + } + + pub(crate) fn with_check(self, level: u8) -> Self { + CheckLevel::set(level); + self + } + + pub(crate) fn build(self) -> sp_io::TestExternalities { + let mut storage = + frame_system::GenesisConfig::default().build_storage::().unwrap(); + + let _ = crate::GenesisConfig:: { + min_join_bond: 2, + min_create_bond: 2, + max_pools: Some(2), + max_members_per_pool: Some(3), + max_members: Some(4), + } + .assimilate_storage(&mut storage); + + let mut ext = sp_io::TestExternalities::from(storage); + + ext.execute_with(|| { + // for events to be deposited. + frame_system::Pallet::::set_block_number(1); + + // make a pool + let amount_to_bond = ::StakingInterface::minimum_bond(); + Balances::make_free_balance_be(&10, amount_to_bond * 2); + assert_ok!(Pools::create(RawOrigin::Signed(10).into(), amount_to_bond, 900, 901, 902)); + + let last_pool = LastPoolId::::get(); + for (account_id, bonded) in self.members { + Balances::make_free_balance_be(&account_id, bonded * 2); + assert_ok!(Pools::join(RawOrigin::Signed(account_id).into(), bonded, last_pool)); + } + }); + + ext + } + + pub fn build_and_execute(self, test: impl FnOnce() -> ()) { + self.build().execute_with(|| { + test(); + Pools::sanity_checks(CheckLevel::get()).unwrap(); + }) + } +} + +pub(crate) fn unsafe_set_state(pool_id: PoolId, state: PoolState) -> Result<(), ()> { + BondedPools::::try_mutate(pool_id, |maybe_bonded_pool| { + maybe_bonded_pool.as_mut().ok_or(()).map(|bonded_pool| { + bonded_pool.state = state; + }) + }) +} + +parameter_types! { + static ObservedEvents: usize = 0; +} + +/// All events of this pallet. +pub(crate) fn pool_events_since_last_call() -> Vec> { + let events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| if let Event::Pools(inner) = e { Some(inner) } else { None }) + .collect::>(); + let already_seen = ObservedEvents::get(); + ObservedEvents::set(events.len()); + events.into_iter().skip(already_seen).collect() +} + +/// Same as `fully_unbond`, in permissioned setting. +pub fn fully_unbond_permissioned(member: AccountId) -> DispatchResult { + let points = PoolMembers::::get(&member) + .map(|d| d.active_points()) + .unwrap_or_default(); + Pools::unbond(Origin::signed(member), member, points) +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn u256_to_balance_convert_works() { + assert_eq!(U256ToBalance::convert(0u32.into()), Zero::zero()); + assert_eq!(U256ToBalance::convert(Balance::max_value().into()), Balance::max_value()) + } + + #[test] + #[should_panic] + fn u256_to_balance_convert_panics_correctly() { + U256ToBalance::convert(U256::from(Balance::max_value()).saturating_add(1u32.into())); + } + + #[test] + fn balance_to_u256_convert_works() { + assert_eq!(BalanceToU256::convert(0u32.into()), U256::zero()); + assert_eq!(BalanceToU256::convert(Balance::max_value()), Balance::max_value().into()) + } +} diff --git a/frame/nomination-pools/src/tests.rs b/frame/nomination-pools/src/tests.rs new file mode 100644 index 0000000000000..7df922280873b --- /dev/null +++ b/frame/nomination-pools/src/tests.rs @@ -0,0 +1,2929 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-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. + +use super::*; +use crate::{mock::*, Event}; +use frame_support::{ + assert_noop, assert_ok, assert_storage_noop, bounded_btree_map, + storage::{with_transaction, TransactionOutcome}, +}; + +macro_rules! unbonding_pools_with_era { + ($($k:expr => $v:expr),* $(,)?) => {{ + use sp_std::iter::{Iterator, IntoIterator}; + let not_bounded: BTreeMap<_, _> = Iterator::collect(IntoIterator::into_iter([$(($k, $v),)*])); + UnbondingPoolsWithEra::try_from(not_bounded).unwrap() + }}; +} + +macro_rules! member_unbonding_eras { + ($( $any:tt )*) => {{ + let x: BoundedBTreeMap = bounded_btree_map!($( $any )*); + x + }}; +} + +pub const DEFAULT_ROLES: PoolRoles = + PoolRoles { depositor: 10, root: 900, nominator: 901, state_toggler: 902 }; + +#[test] +fn test_setup_works() { + ExtBuilder::default().build_and_execute(|| { + assert_eq!(BondedPools::::count(), 1); + assert_eq!(RewardPools::::count(), 1); + assert_eq!(SubPoolsStorage::::count(), 0); + assert_eq!(PoolMembers::::count(), 1); + assert_eq!(StakingMock::bonding_duration(), 3); + + let last_pool = LastPoolId::::get(); + assert_eq!( + BondedPool::::get(last_pool).unwrap(), + BondedPool:: { + id: last_pool, + inner: BondedPoolInner { + state: PoolState::Open, + points: 10, + member_counter: 1, + roles: DEFAULT_ROLES + }, + } + ); + assert_eq!( + RewardPools::::get(last_pool).unwrap(), + RewardPool:: { balance: 0, points: 0.into(), total_earnings: 0 } + ); + assert_eq!( + PoolMembers::::get(10).unwrap(), + PoolMember:: { pool_id: last_pool, points: 10, ..Default::default() } + ) + }) +} + +mod bonded_pool { + use super::*; + #[test] + fn points_to_issue_works() { + let mut bonded_pool = BondedPool:: { + id: 123123, + inner: BondedPoolInner { + state: PoolState::Open, + points: 100, + member_counter: 1, + roles: DEFAULT_ROLES, + }, + }; + + // 1 points : 1 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 100); + assert_eq!(bonded_pool.balance_to_point(10), 10); + assert_eq!(bonded_pool.balance_to_point(0), 0); + + // 2 points : 1 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 50); + assert_eq!(bonded_pool.balance_to_point(10), 20); + + // 1 points : 2 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 100); + bonded_pool.points = 50; + assert_eq!(bonded_pool.balance_to_point(10), 5); + + // 100 points : 0 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 0); + bonded_pool.points = 100; + assert_eq!(bonded_pool.balance_to_point(10), 100 * 10); + + // 0 points : 100 balance + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 100); + bonded_pool.points = 100; + assert_eq!(bonded_pool.balance_to_point(10), 10); + + // 10 points : 3 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 30); + assert_eq!(bonded_pool.balance_to_point(10), 33); + + // 2 points : 3 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 300); + bonded_pool.points = 200; + assert_eq!(bonded_pool.balance_to_point(10), 6); + + // 4 points : 9 balance ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 900); + bonded_pool.points = 400; + assert_eq!(bonded_pool.balance_to_point(90), 40); + } + + #[test] + fn balance_to_unbond_works() { + // 1 balance : 1 points ratio + let mut bonded_pool = BondedPool:: { + id: 123123, + inner: BondedPoolInner { + state: PoolState::Open, + points: 100, + member_counter: 1, + roles: DEFAULT_ROLES, + }, + }; + + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 100); + assert_eq!(bonded_pool.points_to_balance(10), 10); + assert_eq!(bonded_pool.points_to_balance(0), 0); + + // 2 balance : 1 points ratio + bonded_pool.points = 50; + assert_eq!(bonded_pool.points_to_balance(10), 20); + + // 100 balance : 0 points ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 0); + bonded_pool.points = 0; + assert_eq!(bonded_pool.points_to_balance(10), 0); + + // 0 balance : 100 points ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 0); + bonded_pool.points = 100; + assert_eq!(bonded_pool.points_to_balance(10), 0); + + // 10 balance : 3 points ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 100); + bonded_pool.points = 30; + assert_eq!(bonded_pool.points_to_balance(10), 33); + + // 2 balance : 3 points ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 200); + bonded_pool.points = 300; + assert_eq!(bonded_pool.points_to_balance(10), 6); + + // 4 balance : 9 points ratio + StakingMock::set_bonded_balance(bonded_pool.bonded_account(), 400); + bonded_pool.points = 900; + assert_eq!(bonded_pool.points_to_balance(90), 40); + } + + #[test] + fn ok_to_join_with_works() { + ExtBuilder::default().build_and_execute(|| { + let pool = BondedPool:: { + id: 123, + inner: BondedPoolInner { + state: PoolState::Open, + points: 100, + member_counter: 1, + roles: DEFAULT_ROLES, + }, + }; + + // Simulate a 100% slashed pool + StakingMock::set_bonded_balance(pool.bonded_account(), 0); + assert_noop!(pool.ok_to_join(0), Error::::OverflowRisk); + + // Simulate a 89% + StakingMock::set_bonded_balance(pool.bonded_account(), 11); + assert_ok!(pool.ok_to_join(0)); + + // Simulate a 90% slashed pool + StakingMock::set_bonded_balance(pool.bonded_account(), 10); + assert_noop!(pool.ok_to_join(0), Error::::OverflowRisk); + + StakingMock::set_bonded_balance(pool.bonded_account(), Balance::MAX / 10); + // New bonded balance would be over 1/10th of Balance type + assert_noop!(pool.ok_to_join(0), Error::::OverflowRisk); + // and a sanity check + StakingMock::set_bonded_balance(pool.bonded_account(), Balance::MAX / 10 - 1); + assert_ok!(pool.ok_to_join(0)); + }); + } +} + +mod reward_pool { + #[test] + fn current_balance_only_counts_balance_over_existential_deposit() { + use super::*; + + ExtBuilder::default().build_and_execute(|| { + let reward_account = Pools::create_reward_account(2); + + // Given + assert_eq!(Balances::free_balance(&reward_account), 0); + + // Then + assert_eq!(RewardPool::::current_balance(2), 0); + + // Given + Balances::make_free_balance_be(&reward_account, Balances::minimum_balance()); + + // Then + assert_eq!(RewardPool::::current_balance(2), 0); + + // Given + Balances::make_free_balance_be(&reward_account, Balances::minimum_balance() + 1); + + // Then + assert_eq!(RewardPool::::current_balance(2), 1); + }); + } +} + +mod unbond_pool { + use super::*; + + #[test] + fn points_to_issue_works() { + // 1 points : 1 balance ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 100 }; + assert_eq!(unbond_pool.balance_to_point(10), 10); + assert_eq!(unbond_pool.balance_to_point(0), 0); + + // 2 points : 1 balance ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 50 }; + assert_eq!(unbond_pool.balance_to_point(10), 20); + + // 1 points : 2 balance ratio + let unbond_pool = UnbondPool:: { points: 50, balance: 100 }; + assert_eq!(unbond_pool.balance_to_point(10), 5); + + // 100 points : 0 balance ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 0 }; + assert_eq!(unbond_pool.balance_to_point(10), 100 * 10); + + // 0 points : 100 balance + let unbond_pool = UnbondPool:: { points: 0, balance: 100 }; + assert_eq!(unbond_pool.balance_to_point(10), 10); + + // 10 points : 3 balance ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 30 }; + assert_eq!(unbond_pool.balance_to_point(10), 33); + + // 2 points : 3 balance ratio + let unbond_pool = UnbondPool:: { points: 200, balance: 300 }; + assert_eq!(unbond_pool.balance_to_point(10), 6); + + // 4 points : 9 balance ratio + let unbond_pool = UnbondPool:: { points: 400, balance: 900 }; + assert_eq!(unbond_pool.balance_to_point(90), 40); + } + + #[test] + fn balance_to_unbond_works() { + // 1 balance : 1 points ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 100 }; + assert_eq!(unbond_pool.point_to_balance(10), 10); + assert_eq!(unbond_pool.point_to_balance(0), 0); + + // 1 balance : 2 points ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 50 }; + assert_eq!(unbond_pool.point_to_balance(10), 5); + + // 2 balance : 1 points ratio + let unbond_pool = UnbondPool:: { points: 50, balance: 100 }; + assert_eq!(unbond_pool.point_to_balance(10), 20); + + // 100 balance : 0 points ratio + let unbond_pool = UnbondPool:: { points: 0, balance: 100 }; + assert_eq!(unbond_pool.point_to_balance(10), 0); + + // 0 balance : 100 points ratio + let unbond_pool = UnbondPool:: { points: 100, balance: 0 }; + assert_eq!(unbond_pool.point_to_balance(10), 0); + + // 10 balance : 3 points ratio + let unbond_pool = UnbondPool:: { points: 30, balance: 100 }; + assert_eq!(unbond_pool.point_to_balance(10), 33); + + // 2 balance : 3 points ratio + let unbond_pool = UnbondPool:: { points: 300, balance: 200 }; + assert_eq!(unbond_pool.point_to_balance(10), 6); + + // 4 balance : 9 points ratio + let unbond_pool = UnbondPool:: { points: 900, balance: 400 }; + assert_eq!(unbond_pool.point_to_balance(90), 40); + } +} + +mod sub_pools { + use super::*; + + #[test] + fn maybe_merge_pools_works() { + ExtBuilder::default().build_and_execute(|| { + assert_eq!(TotalUnbondingPools::::get(), 5); + + // Given + let mut sub_pool_0 = SubPools:: { + no_era: UnbondPool::::default(), + with_era: unbonding_pools_with_era! { + 0 => UnbondPool:: { points: 10, balance: 10 }, + 1 => UnbondPool:: { points: 10, balance: 10 }, + 2 => UnbondPool:: { points: 20, balance: 20 }, + 3 => UnbondPool:: { points: 30, balance: 30 }, + 4 => UnbondPool:: { points: 40, balance: 40 }, + }, + }; + + // When `current_era < TotalUnbondingPools`, + let sub_pool_1 = sub_pool_0.clone().maybe_merge_pools(3); + + // Then it exits early without modifications + assert_eq!(sub_pool_1, sub_pool_0); + + // When `current_era == TotalUnbondingPools`, + let sub_pool_1 = sub_pool_1.maybe_merge_pools(4); + + // Then it exits early without modifications + assert_eq!(sub_pool_1, sub_pool_0); + + // When `current_era - TotalUnbondingPools == 0`, + let mut sub_pool_1 = sub_pool_1.maybe_merge_pools(5); + + // Then era 0 is merged into the `no_era` pool + sub_pool_0.no_era = sub_pool_0.with_era.remove(&0).unwrap(); + assert_eq!(sub_pool_1, sub_pool_0); + + // Given we have entries for era 1..=5 + sub_pool_1 + .with_era + .try_insert(5, UnbondPool:: { points: 50, balance: 50 }) + .unwrap(); + sub_pool_0 + .with_era + .try_insert(5, UnbondPool:: { points: 50, balance: 50 }) + .unwrap(); + + // When `current_era - TotalUnbondingPools == 1` + let sub_pool_2 = sub_pool_1.maybe_merge_pools(6); + let era_1_pool = sub_pool_0.with_era.remove(&1).unwrap(); + + // Then era 1 is merged into the `no_era` pool + sub_pool_0.no_era.points += era_1_pool.points; + sub_pool_0.no_era.balance += era_1_pool.balance; + assert_eq!(sub_pool_2, sub_pool_0); + + // When `current_era - TotalUnbondingPools == 5`, so all pools with era <= 4 are removed + let sub_pool_3 = sub_pool_2.maybe_merge_pools(10); + + // Then all eras <= 5 are merged into the `no_era` pool + for era in 2..=5 { + let to_merge = sub_pool_0.with_era.remove(&era).unwrap(); + sub_pool_0.no_era.points += to_merge.points; + sub_pool_0.no_era.balance += to_merge.balance; + } + assert_eq!(sub_pool_3, sub_pool_0); + }); + } +} + +mod join { + use super::*; + + #[test] + fn join_works() { + let bonded = |points, member_counter| BondedPool:: { + id: 1, + inner: BondedPoolInner { + state: PoolState::Open, + points, + member_counter, + roles: DEFAULT_ROLES, + }, + }; + ExtBuilder::default().build_and_execute(|| { + // Given + Balances::make_free_balance_be(&11, ExistentialDeposit::get() + 2); + assert!(!PoolMembers::::contains_key(&11)); + + // When + assert_ok!(Pools::join(Origin::signed(11), 2, 1)); + + // then + assert_eq!( + PoolMembers::::get(&11).unwrap(), + PoolMember:: { pool_id: 1, points: 2, ..Default::default() } + ); + assert_eq!(BondedPool::::get(1).unwrap(), bonded(12, 2)); + + // Given + // The bonded balance is slashed in half + StakingMock::set_bonded_balance(Pools::create_bonded_account(1), 6); + + // And + Balances::make_free_balance_be(&12, ExistentialDeposit::get() + 12); + assert!(!PoolMembers::::contains_key(&12)); + + // When + assert_ok!(Pools::join(Origin::signed(12), 12, 1)); + + // Then + assert_eq!( + PoolMembers::::get(&12).unwrap(), + PoolMember:: { pool_id: 1, points: 24, ..Default::default() } + ); + assert_eq!(BondedPool::::get(1).unwrap(), bonded(12 + 24, 3)); + }); + } + + #[test] + fn join_errors_correctly() { + ExtBuilder::default().with_check(0).build_and_execute(|| { + // 10 is already part of the default pool created. + assert_eq!(PoolMembers::::get(&10).unwrap().pool_id, 1); + + assert_noop!( + Pools::join(Origin::signed(10), 420, 123), + Error::::AccountBelongsToOtherPool + ); + + assert_noop!(Pools::join(Origin::signed(11), 420, 123), Error::::PoolNotFound); + + // Force the pools bonded balance to 0, simulating a 100% slash + StakingMock::set_bonded_balance(Pools::create_bonded_account(1), 0); + assert_noop!(Pools::join(Origin::signed(11), 420, 1), Error::::OverflowRisk); + + // Given a mocked bonded pool + BondedPool:: { + id: 123, + inner: BondedPoolInner { + member_counter: 1, + state: PoolState::Open, + points: 100, + roles: DEFAULT_ROLES, + }, + } + .put(); + + // and reward pool + RewardPools::::insert( + 123, + RewardPool:: { + balance: Zero::zero(), + total_earnings: Zero::zero(), + points: U256::from(0u32), + }, + ); + + // Force the points:balance ratio to 100/10 + StakingMock::set_bonded_balance(Pools::create_bonded_account(123), 10); + assert_noop!(Pools::join(Origin::signed(11), 420, 123), Error::::OverflowRisk); + + StakingMock::set_bonded_balance(Pools::create_bonded_account(123), Balance::MAX / 10); + // Balance is gt 1/10 of Balance::MAX + assert_noop!(Pools::join(Origin::signed(11), 5, 123), Error::::OverflowRisk); + + StakingMock::set_bonded_balance(Pools::create_bonded_account(1), 10); + + // Cannot join a pool that isn't open + unsafe_set_state(123, PoolState::Blocked).unwrap(); + assert_noop!(Pools::join(Origin::signed(11), 10, 123), Error::::NotOpen); + + unsafe_set_state(123, PoolState::Destroying).unwrap(); + assert_noop!(Pools::join(Origin::signed(11), 10, 123), Error::::NotOpen); + + // Given + MinJoinBond::::put(100); + + // Then + assert_noop!( + Pools::join(Origin::signed(11), 99, 123), + Error::::MinimumBondNotMet + ); + }); + } + + #[test] + #[cfg_attr(debug_assertions, should_panic(expected = "Defensive failure has been triggered!"))] + #[cfg_attr(not(debug_assertions), should_panic)] + fn join_panics_when_reward_pool_not_found() { + ExtBuilder::default().build_and_execute(|| { + StakingMock::set_bonded_balance(Pools::create_bonded_account(123), 100); + BondedPool:: { + id: 123, + inner: BondedPoolInner { + state: PoolState::Open, + points: 100, + member_counter: 1, + roles: DEFAULT_ROLES, + }, + } + .put(); + let _ = Pools::join(Origin::signed(11), 420, 123); + }); + } + + #[test] + fn join_max_member_limits_are_respected() { + ExtBuilder::default().build_and_execute(|| { + // Given + assert_eq!(MaxPoolMembersPerPool::::get(), Some(3)); + for i in 1..3 { + let account = i + 100; + Balances::make_free_balance_be(&account, 100 + Balances::minimum_balance()); + + assert_ok!(Pools::join(Origin::signed(account), 100, 1)); + } + + Balances::make_free_balance_be(&103, 100 + Balances::minimum_balance()); + + // Then + assert_noop!( + Pools::join(Origin::signed(103), 100, 1), + Error::::MaxPoolMembers + ); + + // Given + assert_eq!(PoolMembers::::count(), 3); + assert_eq!(MaxPoolMembers::::get(), Some(4)); + + Balances::make_free_balance_be(&104, 100 + Balances::minimum_balance()); + assert_ok!(Pools::create(Origin::signed(104), 100, 104, 104, 104)); + + let pool_account = BondedPools::::iter() + .find(|(_, bonded_pool)| bonded_pool.roles.depositor == 104) + .map(|(pool_account, _)| pool_account) + .unwrap(); + + // Then + assert_noop!( + Pools::join(Origin::signed(103), 100, pool_account), + Error::::MaxPoolMembers + ); + }); + } +} + +mod claim_payout { + use super::*; + + fn del(points: Balance, reward_pool_total_earnings: Balance) -> PoolMember { + PoolMember { + pool_id: 1, + points, + reward_pool_total_earnings, + unbonding_eras: Default::default(), + } + } + + fn rew(balance: Balance, points: u32, total_earnings: Balance) -> RewardPool { + RewardPool { balance, points: points.into(), total_earnings } + } + + #[test] + fn claim_payout_works() { + ExtBuilder::default() + .add_members(vec![(40, 40), (50, 50)]) + .build_and_execute(|| { + // Given each member currently has a free balance of + Balances::make_free_balance_be(&10, 0); + Balances::make_free_balance_be(&40, 0); + Balances::make_free_balance_be(&50, 0); + let ed = Balances::minimum_balance(); + // and the reward pool has earned 100 in rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 100); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(10))); + + // Then + // Expect a payout of 10: (10 del virtual points / 100 pool points) * 100 pool + // balance + assert_eq!(PoolMembers::::get(10).unwrap(), del(10, 100)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew(90, 100 * 100 - 100 * 10, 100) + ); + assert_eq!(Balances::free_balance(&10), 10); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 90); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(40))); + + // Then + // Expect payout 40: (400 del virtual points / 900 pool points) * 90 pool balance + assert_eq!(PoolMembers::::get(40).unwrap(), del(40, 100)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew(50, 9_000 - 100 * 40, 100) + ); + assert_eq!(Balances::free_balance(&40), 40); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 50); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(50))); + + // Then + // Expect payout 50: (50 del virtual points / 50 pool points) * 50 pool balance + assert_eq!(PoolMembers::::get(50).unwrap(), del(50, 100)); + assert_eq!(RewardPools::::get(&1).unwrap(), rew(0, 0, 100)); + assert_eq!(Balances::free_balance(&50), 50); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 0); + + // Given the reward pool has some new rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 50); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(10))); + + // Then + // Expect payout 5: (500 del virtual points / 5,000 pool points) * 50 pool balance + assert_eq!(PoolMembers::::get(10).unwrap(), del(10, 150)); + assert_eq!(RewardPools::::get(&1).unwrap(), rew(45, 5_000 - 50 * 10, 150)); + assert_eq!(Balances::free_balance(&10), 10 + 5); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 45); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(40))); + + // Then + // Expect payout 20: (2,000 del virtual points / 4,500 pool points) * 45 pool + // balance + assert_eq!(PoolMembers::::get(40).unwrap(), del(40, 150)); + assert_eq!(RewardPools::::get(&1).unwrap(), rew(25, 4_500 - 50 * 40, 150)); + assert_eq!(Balances::free_balance(&40), 40 + 20); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 25); + + // Given del 50 hasn't claimed and the reward pools has just earned 50 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 50)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 75); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(50))); + + // Then + // We expect a payout of 50: (5,000 del virtual points / 7,5000 pool points) * 75 + // pool balance + assert_eq!(PoolMembers::::get(50).unwrap(), del(50, 200)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew( + 25, + // old pool points + points from new earnings - del points. + // + // points from new earnings = new earnings(50) * bonded_pool.points(100) + // del points = member.points(50) * new_earnings_since_last_claim (100) + (2_500 + 50 * 100) - 50 * 100, + 200, + ) + ); + assert_eq!(Balances::free_balance(&50), 50 + 50); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 25); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(10))); + + // Then + // We expect a payout of 5 + assert_eq!(PoolMembers::::get(10).unwrap(), del(10, 200)); + assert_eq!(RewardPools::::get(&1).unwrap(), rew(20, 2_500 - 10 * 50, 200)); + assert_eq!(Balances::free_balance(&10), 15 + 5); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 20); + + // Given del 40 hasn't claimed and the reward pool has just earned 400 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 400)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 420); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(10))); + + // Then + // We expect a payout of 40 + assert_eq!(PoolMembers::::get(10).unwrap(), del(10, 600)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew( + 380, + // old pool points + points from new earnings - del points + // + // points from new earnings = new earnings(400) * bonded_pool.points(100) + // del points = member.points(10) * new_earnings_since_last_claim(400) + (2_000 + 400 * 100) - 10 * 400, + 600 + ) + ); + assert_eq!(Balances::free_balance(&10), 20 + 40); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 380); + + // Given del 40 + del 50 haven't claimed and the reward pool has earned 20 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 20)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 400); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(10))); + + // Then + // Expect a payout of 2: (200 del virtual points / 38,000 pool points) * 400 pool + // balance + assert_eq!(PoolMembers::::get(10).unwrap(), del(10, 620)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew(398, (38_000 + 20 * 100) - 10 * 20, 620) + ); + assert_eq!(Balances::free_balance(&10), 60 + 2); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 398); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(40))); + + // Then + // Expect a payout of 188: (18,800 del virtual points / 39,800 pool points) * 399 + // pool balance + assert_eq!(PoolMembers::::get(40).unwrap(), del(40, 620)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew(210, 39_800 - 40 * 470, 620) + ); + assert_eq!(Balances::free_balance(&40), 60 + 188); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 210); + + // When + assert_ok!(Pools::claim_payout(Origin::signed(50))); + + // Then + // Expect payout of 210: (21,000 / 21,000) * 210 + assert_eq!(PoolMembers::::get(50).unwrap(), del(50, 620)); + assert_eq!( + RewardPools::::get(&1).unwrap(), + rew(0, 21_000 - 50 * 420, 620) + ); + assert_eq!(Balances::free_balance(&50), 100 + 210); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 0); + }); + } + + #[test] + fn do_reward_payout_correctly_sets_pool_state_to_destroying() { + ExtBuilder::default().build_and_execute(|| { + let _ = with_transaction(|| -> TransactionOutcome { + let mut bonded_pool = BondedPool::::get(1).unwrap(); + let mut reward_pool = RewardPools::::get(1).unwrap(); + let mut member = PoolMembers::::get(10).unwrap(); + + // -- reward_pool.total_earnings saturates + + // Given + Balances::make_free_balance_be(&default_reward_account(), Balance::MAX); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut member, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + assert!(bonded_pool.is_destroying()); + + storage::TransactionOutcome::Rollback(Ok(())) + }); + + // -- current_points saturates (reward_pool.points + new_earnings * bonded_pool.points) + let _ = with_transaction(|| -> TransactionOutcome { + // Given + let mut bonded_pool = BondedPool::::get(1).unwrap(); + let mut reward_pool = RewardPools::::get(1).unwrap(); + let mut member = PoolMembers::::get(10).unwrap(); + // Force new_earnings * bonded_pool.points == 100 + Balances::make_free_balance_be(&default_reward_account(), 5 + 10); + assert_eq!(bonded_pool.points, 10); + // Force reward_pool.points == U256::MAX - new_earnings * bonded_pool.points + reward_pool.points = U256::MAX - U256::from(100u32); + RewardPools::::insert(1, reward_pool.clone()); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut member, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + assert!(bonded_pool.is_destroying()); + + storage::TransactionOutcome::Rollback(Ok(())) + }); + }); + } + + #[test] + fn reward_payout_errors_if_a_member_is_fully_unbonding() { + ExtBuilder::default().add_members(vec![(11, 11)]).build_and_execute(|| { + // fully unbond the member. + assert_ok!(Pools::fully_unbond(Origin::signed(11), 11)); + + let mut bonded_pool = BondedPool::::get(1).unwrap(); + let mut reward_pool = RewardPools::::get(1).unwrap(); + let mut member = PoolMembers::::get(11).unwrap(); + + assert_noop!( + Pools::do_reward_payout(&11, &mut member, &mut bonded_pool, &mut reward_pool,), + Error::::FullyUnbonding + ); + }); + } + + #[test] + fn calculate_member_payout_works_with_a_pool_of_1() { + let del = |reward_pool_total_earnings| del(10, reward_pool_total_earnings); + + ExtBuilder::default().build_and_execute(|| { + let mut bonded_pool = BondedPool::::get(1).unwrap(); + let mut reward_pool = RewardPools::::get(1).unwrap(); + let mut member = PoolMembers::::get(10).unwrap(); + let ed = Balances::minimum_balance(); + + // Given no rewards have been earned + // When + let payout = + Pools::calculate_member_payout(&mut member, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 0); + assert_eq!(member, del(0)); + assert_eq!(reward_pool, rew(0, 0, 0)); + + // Given the pool has earned some rewards for the first time + Balances::make_free_balance_be(&default_reward_account(), ed + 5); + + // When + let payout = + Pools::calculate_member_payout(&mut member, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 5); // (10 * 5 del virtual points / 10 * 5 pool points) * 5 pool balance + assert_eq!(reward_pool, rew(0, 0, 5)); + assert_eq!(member, del(5)); + + // Given the pool has earned rewards again + Balances::make_free_balance_be(&default_reward_account(), ed + 10); + + // When + let payout = + Pools::calculate_member_payout(&mut member, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 10); // (10 * 10 del virtual points / 10 pool points) * 5 pool balance + assert_eq!(reward_pool, rew(0, 0, 15)); + assert_eq!(member, del(15)); + + // Given the pool has earned no new rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 0); + + // When + let payout = + Pools::calculate_member_payout(&mut member, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 0); + assert_eq!(reward_pool, rew(0, 0, 15)); + assert_eq!(member, del(15)); + }); + } + + #[test] + fn calculate_member_payout_works_with_a_pool_of_3() { + ExtBuilder::default() + .add_members(vec![(40, 40), (50, 50)]) + .build_and_execute(|| { + let mut bonded_pool = BondedPool::::get(1).unwrap(); + let mut reward_pool = RewardPools::::get(1).unwrap(); + let ed = Balances::minimum_balance(); + // PoolMember with 10 points + let mut del_10 = PoolMembers::::get(10).unwrap(); + // PoolMember with 40 points + let mut del_40 = PoolMembers::::get(40).unwrap(); + // PoolMember with 50 points + let mut del_50 = PoolMembers::::get(50).unwrap(); + + // Given we have a total of 100 points split among the members + assert_eq!(del_50.points + del_40.points + del_10.points, 100); + assert_eq!(bonded_pool.points, 100); + // and the reward pool has earned 100 in rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 100); + + // When + let payout = + Pools::calculate_member_payout(&mut del_10, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 10); // (10 del virtual points / 100 pool points) * 100 pool balance + assert_eq!(del_10, del(10, 100)); + assert_eq!(reward_pool, rew(90, 100 * 100 - 100 * 10, 100)); + // Mock the reward pool transferring the payout to del_10 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 10)); + + // When + let payout = + Pools::calculate_member_payout(&mut del_40, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 40); // (400 del virtual points / 900 pool points) * 90 pool balance + assert_eq!(del_40, del(40, 100)); + assert_eq!( + reward_pool, + rew( + 50, + // old pool points - member virtual points + 9_000 - 100 * 40, + 100 + ) + ); + // Mock the reward pool transferring the payout to del_40 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 40)); + + // When + let payout = + Pools::calculate_member_payout(&mut del_50, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 50); // (50 del virtual points / 50 pool points) * 50 pool balance + assert_eq!(del_50, del(50, 100)); + assert_eq!(reward_pool, rew(0, 0, 100)); + // Mock the reward pool transferring the payout to del_50 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 50)); + + // Given the reward pool has some new rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 50); + + // When + let payout = + Pools::calculate_member_payout(&mut del_10, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 5); // (500 del virtual points / 5,000 pool points) * 50 pool balance + assert_eq!(del_10, del(10, 150)); + assert_eq!(reward_pool, rew(45, 5_000 - 50 * 10, 150)); + // Mock the reward pool transferring the payout to del_10 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 5)); + + // When + let payout = + Pools::calculate_member_payout(&mut del_40, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 20); // (2,000 del virtual points / 4,500 pool points) * 45 pool balance + assert_eq!(del_40, del(40, 150)); + assert_eq!(reward_pool, rew(25, 4_500 - 50 * 40, 150)); + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 20)); + + // Given del_50 hasn't claimed and the reward pools has just earned 50 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 50)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 75); + + // When + let payout = + Pools::calculate_member_payout(&mut del_50, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 50); // (5,000 del virtual points / 7,5000 pool points) * 75 pool balance + assert_eq!(del_50, del(50, 200)); + assert_eq!( + reward_pool, + rew( + 25, + // old pool points + points from new earnings - del points. + // + // points from new earnings = new earnings(50) * bonded_pool.points(100) + // del points = member.points(50) * new_earnings_since_last_claim (100) + (2_500 + 50 * 100) - 50 * 100, + 200, + ) + ); + // Mock the reward pool transferring the payout to del_50 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 50)); + + // When + let payout = + Pools::calculate_member_payout(&mut del_10, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 5); + assert_eq!(del_10, del(10, 200)); + assert_eq!(reward_pool, rew(20, 2_500 - 10 * 50, 200)); + // Mock the reward pool transferring the payout to del_10 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 5)); + + // Given del_40 hasn't claimed and the reward pool has just earned 400 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 400)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 420); + + // When + let payout = + Pools::calculate_member_payout(&mut del_10, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 40); + assert_eq!(del_10, del(10, 600)); + assert_eq!( + reward_pool, + rew( + 380, + // old pool points + points from new earnings - del points + // + // points from new earnings = new earnings(400) * bonded_pool.points(100) + // del points = member.points(10) * new_earnings_since_last_claim(400) + (2_000 + 400 * 100) - 10 * 400, + 600 + ) + ); + // Mock the reward pool transferring the payout to del_10 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 40)); + + // Given del_40 + del_50 haven't claimed and the reward pool has earned 20 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 20)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 400); + + // When + let payout = + Pools::calculate_member_payout(&mut del_10, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 2); // (200 del virtual points / 38,000 pool points) * 400 pool balance + assert_eq!(del_10, del(10, 620)); + assert_eq!(reward_pool, rew(398, (38_000 + 20 * 100) - 10 * 20, 620)); + // Mock the reward pool transferring the payout to del_10 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 2)); + + // When + let payout = + Pools::calculate_member_payout(&mut del_40, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 188); // (18,800 del virtual points / 39,800 pool points) * 399 pool balance + assert_eq!(del_40, del(40, 620)); + assert_eq!(reward_pool, rew(210, 39_800 - 40 * 470, 620)); + // Mock the reward pool transferring the payout to del_10 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free -= 188)); + + // When + let payout = + Pools::calculate_member_payout(&mut del_50, &mut bonded_pool, &mut reward_pool) + .unwrap(); + + // Then + assert_eq!(payout, 210); // (21,000 / 21,000) * 210 + assert_eq!(del_50, del(50, 620)); + assert_eq!(reward_pool, rew(0, 21_000 - 50 * 420, 620)); + }); + } + + #[test] + fn do_reward_payout_works() { + ExtBuilder::default() + .add_members(vec![(40, 40), (50, 50)]) + .build_and_execute(|| { + let mut bonded_pool = BondedPool::::get(1).unwrap(); + let mut reward_pool = RewardPools::::get(1).unwrap(); + let ed = Balances::minimum_balance(); + + // Given the bonded pool has 100 points + assert_eq!(bonded_pool.points, 100); + // Each member currently has a free balance of + Balances::make_free_balance_be(&10, 0); + Balances::make_free_balance_be(&40, 0); + Balances::make_free_balance_be(&50, 0); + // and the reward pool has earned 100 in rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 100); + + let mut del_10 = PoolMembers::get(10).unwrap(); + let mut del_40 = PoolMembers::get(40).unwrap(); + let mut del_50 = PoolMembers::get(50).unwrap(); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut del_10, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect a payout of 10: (10 del virtual points / 100 pool points) * 100 pool + // balance + assert_eq!(del_10, del(10, 100)); + assert_eq!(reward_pool, rew(90, 100 * 100 - 100 * 10, 100)); + assert_eq!(Balances::free_balance(&10), 10); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 90); + + // When + assert_ok!(Pools::do_reward_payout( + &40, + &mut del_40, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect payout 40: (400 del virtual points / 900 pool points) * 90 pool balance + assert_eq!(del_40, del(40, 100)); + assert_eq!(reward_pool, rew(50, 9_000 - 100 * 40, 100)); + assert_eq!(Balances::free_balance(&40), 40); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 50); + + // When + assert_ok!(Pools::do_reward_payout( + &50, + &mut del_50, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect payout 50: (50 del virtual points / 50 pool points) * 50 pool balance + assert_eq!(del_50, del(50, 100)); + assert_eq!(reward_pool, rew(0, 0, 100)); + assert_eq!(Balances::free_balance(&50), 50); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 0); + + // Given the reward pool has some new rewards + Balances::make_free_balance_be(&default_reward_account(), ed + 50); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut del_10, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect payout 5: (500 del virtual points / 5,000 pool points) * 50 pool balance + assert_eq!(del_10, del(10, 150)); + assert_eq!(reward_pool, rew(45, 5_000 - 50 * 10, 150)); + assert_eq!(Balances::free_balance(&10), 10 + 5); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 45); + + // When + assert_ok!(Pools::do_reward_payout( + &40, + &mut del_40, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect payout 20: (2,000 del virtual points / 4,500 pool points) * 45 pool + // balance + assert_eq!(del_40, del(40, 150)); + assert_eq!(reward_pool, rew(25, 4_500 - 50 * 40, 150)); + assert_eq!(Balances::free_balance(&40), 40 + 20); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 25); + + // Given del 50 hasn't claimed and the reward pools has just earned 50 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 50)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 75); + + // When + assert_ok!(Pools::do_reward_payout( + &50, + &mut del_50, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // We expect a payout of 50: (5,000 del virtual points / 7,5000 pool points) * 75 + // pool balance + assert_eq!(del_50, del(50, 200)); + assert_eq!( + reward_pool, + rew( + 25, + // old pool points + points from new earnings - del points. + // + // points from new earnings = new earnings(50) * bonded_pool.points(100) + // del points = member.points(50) * new_earnings_since_last_claim (100) + (2_500 + 50 * 100) - 50 * 100, + 200, + ) + ); + assert_eq!(Balances::free_balance(&50), 50 + 50); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 25); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut del_10, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // We expect a payout of 5 + assert_eq!(del_10, del(10, 200)); + assert_eq!(reward_pool, rew(20, 2_500 - 10 * 50, 200)); + assert_eq!(Balances::free_balance(&10), 15 + 5); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 20); + + // Given del 40 hasn't claimed and the reward pool has just earned 400 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 400)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 420); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut del_10, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // We expect a payout of 40 + assert_eq!(del_10, del(10, 600)); + assert_eq!( + reward_pool, + rew( + 380, + // old pool points + points from new earnings - del points + // + // points from new earnings = new earnings(400) * bonded_pool.points(100) + // del points = member.points(10) * new_earnings_since_last_claim(400) + (2_000 + 400 * 100) - 10 * 400, + 600 + ) + ); + assert_eq!(Balances::free_balance(&10), 20 + 40); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 380); + + // Given del 40 + del 50 haven't claimed and the reward pool has earned 20 + assert_ok!(Balances::mutate_account(&default_reward_account(), |a| a.free += 20)); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 400); + + // When + assert_ok!(Pools::do_reward_payout( + &10, + &mut del_10, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect a payout of 2: (200 del virtual points / 38,000 pool points) * 400 pool + // balance + assert_eq!(del_10, del(10, 620)); + assert_eq!(reward_pool, rew(398, (38_000 + 20 * 100) - 10 * 20, 620)); + assert_eq!(Balances::free_balance(&10), 60 + 2); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 398); + + // When + assert_ok!(Pools::do_reward_payout( + &40, + &mut del_40, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect a payout of 188: (18,800 del virtual points / 39,800 pool points) * 399 + // pool balance + assert_eq!(del_40, del(40, 620)); + assert_eq!(reward_pool, rew(210, 39_800 - 40 * 470, 620)); + assert_eq!(Balances::free_balance(&40), 60 + 188); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 210); + + // When + assert_ok!(Pools::do_reward_payout( + &50, + &mut del_50, + &mut bonded_pool, + &mut reward_pool + )); + + // Then + // Expect payout of 210: (21,000 / 21,000) * 210 + assert_eq!(del_50, del(50, 620)); + assert_eq!(reward_pool, rew(0, 21_000 - 50 * 420, 620)); + assert_eq!(Balances::free_balance(&50), 100 + 210); + assert_eq!(Balances::free_balance(&default_reward_account()), ed + 0); + }); + } +} + +mod unbond { + use super::*; + + #[test] + fn unbond_of_1_works() { + ExtBuilder::default().build_and_execute(|| { + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(fully_unbond_permissioned(10)); + + assert_eq!( + SubPoolsStorage::::get(1).unwrap().with_era, + unbonding_pools_with_era! { 0 + 3 => UnbondPool:: { points: 10, balance: 10 }} + ); + + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + state: PoolState::Destroying, + points: 0, + member_counter: 1, + roles: DEFAULT_ROLES, + } + } + ); + + assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 0); + }); + } + + #[test] + fn unbond_of_3_works() { + ExtBuilder::default() + .add_members(vec![(40, 40), (550, 550)]) + .build_and_execute(|| { + let ed = Balances::minimum_balance(); + // Given a slash from 600 -> 100 + StakingMock::set_bonded_balance(default_bonded_account(), 100); + // and unclaimed rewards of 600. + Balances::make_free_balance_be(&default_reward_account(), ed + 600); + + // When + assert_ok!(fully_unbond_permissioned(40)); + + // Then + assert_eq!( + SubPoolsStorage::::get(1).unwrap().with_era, + unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 6, balance: 6 }} + ); + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + state: PoolState::Open, + points: 560, + member_counter: 3, + roles: DEFAULT_ROLES, + } + } + ); + + assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 94); + assert_eq!( + PoolMembers::::get(40).unwrap().unbonding_eras, + member_unbonding_eras!(0 + 3 => 40) + ); + assert_eq!(Balances::free_balance(&40), 40 + 40); // We claim rewards when unbonding + + // When + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(fully_unbond_permissioned(550)); + + // Then + assert_eq!( + SubPoolsStorage::::get(&1).unwrap().with_era, + unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 98, balance: 98 }} + ); + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + state: PoolState::Destroying, + points: 10, + member_counter: 3, + roles: DEFAULT_ROLES + } + } + ); + assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 2); + assert_eq!( + PoolMembers::::get(550).unwrap().unbonding_eras, + member_unbonding_eras!(0 + 3 => 550) + ); + assert_eq!(Balances::free_balance(&550), 550 + 550); + + // When + assert_ok!(fully_unbond_permissioned(10)); + + // Then + assert_eq!( + SubPoolsStorage::::get(1).unwrap().with_era, + unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 100, balance: 100 }} + ); + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + state: PoolState::Destroying, + points: 0, + member_counter: 3, + roles: DEFAULT_ROLES + } + } + ); + assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 0); + assert_eq!( + PoolMembers::::get(550).unwrap().unbonding_eras, + member_unbonding_eras!(0 + 3 => 550) + ); + assert_eq!(Balances::free_balance(&550), 550 + 550); + }); + } + + #[test] + fn unbond_merges_older_pools() { + ExtBuilder::default().with_check(1).build_and_execute(|| { + // Given + assert_eq!(StakingMock::bonding_duration(), 3); + SubPoolsStorage::::insert( + 1, + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 0 + 3 => UnbondPool { balance: 10, points: 100 }, + 1 + 3 => UnbondPool { balance: 20, points: 20 }, + 2 + 3 => UnbondPool { balance: 101, points: 101} + }, + }, + ); + unsafe_set_state(1, PoolState::Destroying).unwrap(); + + // When + let current_era = 1 + TotalUnbondingPools::::get(); + CurrentEra::set(current_era); + + assert_ok!(fully_unbond_permissioned(10)); + + // Then + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: UnbondPool { balance: 10 + 20, points: 100 + 20 }, + with_era: unbonding_pools_with_era! { + 2 + 3 => UnbondPool { balance: 101, points: 101}, + current_era + 3 => UnbondPool { balance: 10, points: 10 }, + }, + }, + ) + }); + } + + #[test] + fn unbond_kick_works() { + // Kick: the pool is blocked and the caller is either the root or state-toggler. + ExtBuilder::default() + .add_members(vec![(100, 100), (200, 200)]) + .build_and_execute(|| { + // Given + unsafe_set_state(1, PoolState::Blocked).unwrap(); + let bonded_pool = BondedPool::::get(1).unwrap(); + assert_eq!(bonded_pool.roles.root, 900); + assert_eq!(bonded_pool.roles.nominator, 901); + assert_eq!(bonded_pool.roles.state_toggler, 902); + + // When the nominator trys to kick, then its a noop + assert_noop!( + Pools::fully_unbond(Origin::signed(901), 100), + Error::::NotKickerOrDestroying + ); + + // When the root kicks then its ok + assert_ok!(Pools::fully_unbond(Origin::signed(900), 100)); + + // When the state toggler kicks then its ok + assert_ok!(Pools::fully_unbond(Origin::signed(902), 200)); + + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + roles: DEFAULT_ROLES, + state: PoolState::Blocked, + points: 10, // Only 10 points because 200 + 100 was unbonded + member_counter: 3, + } + } + ); + assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 10); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 0 + 3 => UnbondPool { points: 100 + 200, balance: 100 + 200 } + }, + } + ); + assert_eq!( + UNBONDING_BALANCE_MAP + .with(|m| *m.borrow_mut().get(&default_bonded_account()).unwrap()), + 100 + 200 + ); + }); + } + + #[test] + fn unbond_permissionless_works() { + // Scenarios where non-admin accounts can unbond others + ExtBuilder::default().add_members(vec![(100, 100)]).build_and_execute(|| { + // Given the pool is blocked + unsafe_set_state(1, PoolState::Blocked).unwrap(); + + // A permissionless unbond attempt errors + assert_noop!( + Pools::fully_unbond(Origin::signed(420), 100), + Error::::NotKickerOrDestroying + ); + + // Given the pool is destroying + unsafe_set_state(1, PoolState::Destroying).unwrap(); + + // The depositor cannot be fully unbonded until they are the last member + assert_noop!( + Pools::fully_unbond(Origin::signed(10), 10), + Error::::NotOnlyPoolMember + ); + + // Any account can unbond a member that is not the depositor + assert_ok!(Pools::fully_unbond(Origin::signed(420), 100)); + + // Given the pool is blocked + unsafe_set_state(1, PoolState::Blocked).unwrap(); + + // The depositor cannot be unbonded + assert_noop!( + Pools::fully_unbond(Origin::signed(420), 10), + Error::::DoesNotHavePermission + ); + + // Given the pools is destroying + unsafe_set_state(1, PoolState::Destroying).unwrap(); + + // The depositor can be unbonded by anyone. + assert_ok!(Pools::fully_unbond(Origin::signed(420), 10)); + + assert_eq!(BondedPools::::get(1).unwrap().points, 0); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 0 + 3 => UnbondPool { points: 110, balance: 110 } + } + } + ); + assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 0); + assert_eq!( + UNBONDING_BALANCE_MAP + .with(|m| *m.borrow_mut().get(&default_bonded_account()).unwrap()), + 110 + ); + }); + } + + #[test] + #[cfg_attr(debug_assertions, should_panic(expected = "Defensive failure has been triggered!"))] + #[cfg_attr(not(debug_assertions), should_panic)] + fn unbond_errors_correctly() { + ExtBuilder::default().build_and_execute(|| { + assert_noop!( + Pools::fully_unbond(Origin::signed(11), 11), + Error::::PoolMemberNotFound + ); + + // Add the member + let member = PoolMember { pool_id: 2, points: 10, ..Default::default() }; + PoolMembers::::insert(11, member); + + let _ = Pools::fully_unbond(Origin::signed(11), 11); + }); + } + + #[test] + #[cfg_attr(debug_assertions, should_panic(expected = "Defensive failure has been triggered!"))] + #[cfg_attr(not(debug_assertions), should_panic)] + fn unbond_panics_when_reward_pool_not_found() { + ExtBuilder::default().build_and_execute(|| { + let member = PoolMember { pool_id: 2, points: 10, ..Default::default() }; + PoolMembers::::insert(11, member); + BondedPool:: { + id: 1, + inner: BondedPoolInner { + state: PoolState::Open, + points: 10, + member_counter: 1, + roles: DEFAULT_ROLES, + }, + } + .put(); + + let _ = Pools::fully_unbond(Origin::signed(11), 11); + }); + } + + #[test] + fn partial_unbond_era_tracking() { + ExtBuilder::default().build_and_execute(|| { + // given + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 10); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 0); + assert_eq!(PoolMembers::::get(10).unwrap().pool_id, 1); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!() + ); + assert_eq!(BondedPool::::get(1).unwrap().points, 10); + assert!(SubPoolsStorage::::get(1).is_none()); + assert_eq!(CurrentEra::get(), 0); + assert_eq!(BondingDuration::get(), 3); + + // so the depositor can leave, just keeps the test simpler. + unsafe_set_state(1, PoolState::Destroying).unwrap(); + + // when: casual unbond + assert_ok!(Pools::unbond(Origin::signed(10), 10, 1)); + + // then + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 9); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 1); + assert_eq!(BondedPool::::get(1).unwrap().points, 9); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 1) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 1, balance: 1 } + } + } + ); + + // when: casual further unbond, same era. + assert_ok!(Pools::unbond(Origin::signed(10), 10, 5)); + + // then + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 4); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 6); + assert_eq!(BondedPool::::get(1).unwrap().points, 4); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 6) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 6, balance: 6 } + } + } + ); + + // when: casual further unbond, next era. + CurrentEra::set(1); + assert_ok!(Pools::unbond(Origin::signed(10), 10, 1)); + + // then + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 3); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 7); + assert_eq!(BondedPool::::get(1).unwrap().points, 3); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 6, 4 => 1) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 6, balance: 6 }, + 4 => UnbondPool { points: 1, balance: 1 } + } + } + ); + + // when: unbonding more than our active: error + assert_noop!( + Pools::unbond(Origin::signed(10), 10, 5), + Error::::NotEnoughPointsToUnbond + ); + // instead: + assert_ok!(Pools::unbond(Origin::signed(10), 10, 3)); + + // then + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 0); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 10); + assert_eq!(BondedPool::::get(1).unwrap().points, 0); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 6, 4 => 4) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 6, balance: 6 }, + 4 => UnbondPool { points: 4, balance: 4 } + } + } + ); + }); + } + + #[test] + fn partial_unbond_max_chunks() { + ExtBuilder::default().ed(1).build_and_execute(|| { + // so the depositor can leave, just keeps the test simpler. + unsafe_set_state(1, PoolState::Destroying).unwrap(); + MaxUnbonding::set(2); + + // given + assert_ok!(Pools::unbond(Origin::signed(10), 10, 2)); + CurrentEra::set(1); + assert_ok!(Pools::unbond(Origin::signed(10), 10, 3)); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 2, 4 => 3) + ); + + // when + CurrentEra::set(2); + assert_noop!( + Pools::unbond(Origin::signed(10), 10, 4), + Error::::MaxUnbondingLimit + ); + + // when + MaxUnbonding::set(3); + assert_ok!(Pools::unbond(Origin::signed(10), 10, 1)); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 2, 4 => 3, 5 => 1) + ); + }) + } + + // depositor can unbond inly up to `MinCreateBond`. + #[test] + fn depositor_permissioned_partial_unbond() { + ExtBuilder::default().ed(1).add_members(vec![(100, 100)]).build_and_execute(|| { + // given + assert_eq!(MinCreateBond::::get(), 2); + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 10); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 0); + + // can unbond a bit.. + assert_ok!(Pools::unbond(Origin::signed(10), 10, 3)); + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 7); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 3); + + // but not less than 2 + assert_noop!( + Pools::unbond(Origin::signed(10), 10, 6), + Error::::NotOnlyPoolMember + ); + }); + } + + // same as above, but the pool is slashed and therefore the depositor cannot partially unbond. + #[test] + fn depositor_permissioned_partial_unbond_slashed() { + ExtBuilder::default().ed(1).add_members(vec![(100, 100)]).build_and_execute(|| { + // given + assert_eq!(MinCreateBond::::get(), 2); + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 10); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 0); + + // slash the default pool + StakingMock::set_bonded_balance(Pools::create_bonded_account(1), 5); + + // cannot unbond even 7, because the value of shares is now less. + assert_noop!( + Pools::unbond(Origin::signed(10), 10, 7), + Error::::NotOnlyPoolMember + ); + }); + } +} + +mod pool_withdraw_unbonded { + use super::*; + + #[test] + fn pool_withdraw_unbonded_works() { + ExtBuilder::default().build_and_execute(|| { + // Given 10 unbond'ed directly against the pool account + assert_ok!(StakingMock::unbond(default_bonded_account(), 5)); + // and the pool account only has 10 balance + assert_eq!(StakingMock::active_stake(&default_bonded_account()), Some(5)); + assert_eq!(StakingMock::total_stake(&default_bonded_account()), Some(10)); + assert_eq!(Balances::free_balance(&default_bonded_account()), 10); + + // When + assert_ok!(Pools::pool_withdraw_unbonded(Origin::signed(10), 1, 0)); + + // Then there unbonding balance is no longer locked + assert_eq!(StakingMock::active_stake(&default_bonded_account()), Some(5)); + assert_eq!(StakingMock::total_stake(&default_bonded_account()), Some(5)); + assert_eq!(Balances::free_balance(&default_bonded_account()), 10); + }); + } +} + +mod withdraw_unbonded { + use super::*; + use frame_support::bounded_btree_map; + + #[test] + fn withdraw_unbonded_works_against_slashed_no_era_sub_pool() { + ExtBuilder::default() + .add_members(vec![(40, 40), (550, 550)]) + .build_and_execute(|| { + // Given + assert_eq!(StakingMock::bonding_duration(), 3); + assert_ok!(Pools::fully_unbond(Origin::signed(550), 550)); + assert_ok!(Pools::fully_unbond(Origin::signed(40), 40)); + assert_eq!(Balances::free_balance(&default_bonded_account()), 600); + + let mut current_era = 1; + CurrentEra::set(current_era); + // In a new era, unbond the depositor + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(Pools::fully_unbond(Origin::signed(10), 10)); + + let mut sub_pools = SubPoolsStorage::::get(1).unwrap(); + let unbond_pool = sub_pools.with_era.get_mut(&(current_era + 3)).unwrap(); + // Sanity check + assert_eq!(*unbond_pool, UnbondPool { points: 10, balance: 10 }); + + // Simulate a slash to the pool with_era(current_era), decreasing the balance by + // half + unbond_pool.balance = 5; + SubPoolsStorage::::insert(1, sub_pools); + // Update the equivalent of the unbonding chunks for the `StakingMock` + UNBONDING_BALANCE_MAP + .with(|m| *m.borrow_mut().get_mut(&default_bonded_account()).unwrap() -= 5); + Balances::make_free_balance_be(&default_bonded_account(), 595); + + // Advance the current_era to ensure all `with_era` pools will be merged into + // `no_era` pool + current_era += TotalUnbondingPools::::get(); + CurrentEra::set(current_era); + + // Simulate some other call to unbond that would merge `with_era` pools into + // `no_era` + let sub_pools = + SubPoolsStorage::::get(1).unwrap().maybe_merge_pools(current_era + 3); + SubPoolsStorage::::insert(1, sub_pools); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: UnbondPool { points: 550 + 40 + 10, balance: 550 + 40 + 5 }, + with_era: Default::default() + } + ); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(550), 550, 0)); + + // Then + assert_eq!( + SubPoolsStorage::::get(1).unwrap().no_era, + UnbondPool { points: 40 + 10, balance: 40 + 5 + 5 } + ); + assert_eq!(Balances::free_balance(&550), 550 + 545); + assert_eq!(Balances::free_balance(&default_bonded_account()), 50); + assert!(!PoolMembers::::contains_key(550)); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(40), 40, 0)); + + // Then + assert_eq!( + SubPoolsStorage::::get(1).unwrap().no_era, + UnbondPool { points: 10, balance: 10 } + ); + assert_eq!(Balances::free_balance(&40), 40 + 40); + assert_eq!(Balances::free_balance(&default_bonded_account()), 50 - 40); + assert!(!PoolMembers::::contains_key(40)); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(10), 10, 0)); + + // Then + assert_eq!(Balances::free_balance(&10), 10 + 10); + assert_eq!(Balances::free_balance(&default_bonded_account()), 0); + assert!(!PoolMembers::::contains_key(10)); + // Pools are removed from storage because the depositor left + assert!(!SubPoolsStorage::::contains_key(1),); + assert!(!RewardPools::::contains_key(1),); + assert!(!BondedPools::::contains_key(1),); + }); + } + + // This test also documents the case when the pools free balance goes below ED before all + // members have unbonded. + #[test] + fn withdraw_unbonded_works_against_slashed_with_era_sub_pools() { + ExtBuilder::default() + .add_members(vec![(40, 40), (550, 550)]) + .build_and_execute(|| { + // Given + StakingMock::set_bonded_balance(default_bonded_account(), 100); // slash bonded balance + Balances::make_free_balance_be(&default_bonded_account(), 100); + assert_eq!(StakingMock::total_stake(&default_bonded_account()), Some(100)); + + assert_ok!(Pools::fully_unbond(Origin::signed(40), 40)); + assert_ok!(Pools::fully_unbond(Origin::signed(550), 550)); + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(Pools::fully_unbond(Origin::signed(10), 10)); + + SubPoolsStorage::::insert( + 1, + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 600, balance: 100 }}, + }, + ); + CurrentEra::set(StakingMock::bonding_duration()); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(40), 40, 0)); + + // Then + assert_eq!( + SubPoolsStorage::::get(&1).unwrap().with_era, + unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 560, balance: 94 }} + ); + assert_eq!(Balances::free_balance(&40), 40 + 6); + assert_eq!(Balances::free_balance(&default_bonded_account()), 94); + assert!(!PoolMembers::::contains_key(40)); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(550), 550, 0)); + + // Then + assert_eq!( + SubPoolsStorage::::get(&1).unwrap().with_era, + unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 10, balance: 2 }} + ); + assert_eq!(Balances::free_balance(&550), 550 + 92); + // The account was dusted because it went below ED(5) + assert_eq!(Balances::free_balance(&default_bonded_account()), 0); + assert!(!PoolMembers::::contains_key(550)); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(10), 10, 0)); + + // Then + assert_eq!(Balances::free_balance(&10), 10 + 0); + assert_eq!(Balances::free_balance(&default_bonded_account()), 0); + assert!(!PoolMembers::::contains_key(10)); + // Pools are removed from storage because the depositor left + assert!(!SubPoolsStorage::::contains_key(1),); + assert!(!RewardPools::::contains_key(1),); + assert!(!BondedPools::::contains_key(1),); + }); + } + + #[test] + fn withdraw_unbonded_handles_faulty_sub_pool_accounting() { + ExtBuilder::default().build_and_execute(|| { + // Given + assert_eq!(Balances::minimum_balance(), 5); + assert_eq!(Balances::free_balance(&10), 5); + assert_eq!(Balances::free_balance(&default_bonded_account()), 10); + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(Pools::fully_unbond(Origin::signed(10), 10)); + + // Simulate a slash that is not accounted for in the sub pools. + Balances::make_free_balance_be(&default_bonded_account(), 5); + assert_eq!( + SubPoolsStorage::::get(1).unwrap().with_era, + //------------------------------balance decrease is not account for + unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 10, balance: 10 } } + ); + + CurrentEra::set(0 + 3); + + // When + assert_ok!(Pools::withdraw_unbonded(Origin::signed(10), 10, 0)); + + // Then + assert_eq!(Balances::free_balance(10), 10 + 5); + assert_eq!(Balances::free_balance(&default_bonded_account()), 0); + }); + } + + #[test] + fn withdraw_unbonded_errors_correctly() { + ExtBuilder::default().with_check(0).build_and_execute(|| { + // Insert the sub-pool + let sub_pools = SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { 0 + 3 => UnbondPool { points: 10, balance: 10 }}, + }; + SubPoolsStorage::::insert(1, sub_pools.clone()); + + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(11), 11, 0), + Error::::PoolMemberNotFound + ); + + let mut member = PoolMember { pool_id: 1, points: 10, ..Default::default() }; + PoolMembers::::insert(11, member.clone()); + + // Simulate calling `unbond` + member.unbonding_eras = member_unbonding_eras!(3 + 0 => 10); + PoolMembers::::insert(11, member.clone()); + + // We are still in the bonding duration + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(11), 11, 0), + Error::::CannotWithdrawAny + ); + + // If we error the member does not get removed + assert_eq!(PoolMembers::::get(&11), Some(member)); + // and the sub pools do not get updated. + assert_eq!(SubPoolsStorage::::get(1).unwrap(), sub_pools) + }); + } + + #[test] + fn withdraw_unbonded_kick() { + ExtBuilder::default() + .add_members(vec![(100, 100), (200, 200)]) + .build_and_execute(|| { + // Given + assert_ok!(Pools::fully_unbond(Origin::signed(100), 100)); + assert_ok!(Pools::fully_unbond(Origin::signed(200), 200)); + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + points: 10, + state: PoolState::Open, + member_counter: 3, + roles: DEFAULT_ROLES + } + } + ); + CurrentEra::set(StakingMock::bonding_duration()); + + // Cannot kick when pool is open + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(902), 100, 0), + Error::::NotKickerOrDestroying + ); + + // Given + unsafe_set_state(1, PoolState::Blocked).unwrap(); + + // Cannot kick as a nominator + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(901), 100, 0), + Error::::NotKickerOrDestroying + ); + + // Can kick as root + assert_ok!(Pools::withdraw_unbonded(Origin::signed(900), 100, 0)); + + // Can kick as state toggler + assert_ok!(Pools::withdraw_unbonded(Origin::signed(900), 200, 0)); + + assert_eq!(Balances::free_balance(100), 100 + 100); + assert_eq!(Balances::free_balance(200), 200 + 200); + assert!(!PoolMembers::::contains_key(100)); + assert!(!PoolMembers::::contains_key(200)); + assert_eq!(SubPoolsStorage::::get(1).unwrap(), Default::default()); + }); + } + + #[test] + fn withdraw_unbonded_destroying_permissionless() { + ExtBuilder::default().add_members(vec![(100, 100)]).build_and_execute(|| { + // Given + assert_ok!(Pools::fully_unbond(Origin::signed(100), 100)); + assert_eq!( + BondedPool::::get(1).unwrap(), + BondedPool { + id: 1, + inner: BondedPoolInner { + points: 10, + state: PoolState::Open, + member_counter: 2, + roles: DEFAULT_ROLES, + } + } + ); + CurrentEra::set(StakingMock::bonding_duration()); + assert_eq!(Balances::free_balance(100), 100); + + // Cannot permissionlessly withdraw + assert_noop!( + Pools::fully_unbond(Origin::signed(420), 100), + Error::::NotKickerOrDestroying + ); + + // Given + unsafe_set_state(1, PoolState::Destroying).unwrap(); + + // Can permissionlesly withdraw a member that is not the depositor + assert_ok!(Pools::withdraw_unbonded(Origin::signed(420), 100, 0)); + + assert_eq!(SubPoolsStorage::::get(1).unwrap(), Default::default(),); + assert_eq!(Balances::free_balance(100), 100 + 100); + assert!(!PoolMembers::::contains_key(100)); + }); + } + + #[test] + fn withdraw_unbonded_depositor_with_era_pool() { + ExtBuilder::default() + .add_members(vec![(100, 100), (200, 200)]) + .build_and_execute(|| { + // Given + assert_ok!(Pools::fully_unbond(Origin::signed(100), 100)); + + let mut current_era = 1; + CurrentEra::set(current_era); + + assert_ok!(Pools::fully_unbond(Origin::signed(200), 200)); + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(Pools::fully_unbond(Origin::signed(10), 10)); + + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 0 + 3 => UnbondPool { points: 100, balance: 100}, + 1 + 3 => UnbondPool { points: 200 + 10, balance: 200 + 10 } + } + } + ); + + // Skip ahead eras to where its valid for the members to withdraw + current_era += StakingMock::bonding_duration(); + CurrentEra::set(current_era); + + // Cannot withdraw the depositor if their is a member in another `with_era` pool. + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(420), 10, 0), + Error::::NotOnlyPoolMember + ); + + // Given + assert_ok!(Pools::withdraw_unbonded(Origin::signed(420), 100, 0)); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + // Note that era 0+3 unbond pool is destroyed because points went to 0 + 1 + 3 => UnbondPool { points: 200 + 10, balance: 200 + 10 } + } + } + ); + + // Cannot withdraw the depositor if their is a member in another `with_era` pool. + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(420), 10, 0), + Error::::NotOnlyPoolMember + ); + + // Given + assert_ok!(Pools::withdraw_unbonded(Origin::signed(420), 200, 0)); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 1 + 3 => UnbondPool { points: 10, balance: 10 } + } + } + ); + + // The depositor can withdraw + assert_ok!(Pools::withdraw_unbonded(Origin::signed(420), 10, 0)); + assert!(!PoolMembers::::contains_key(10)); + assert_eq!(Balances::free_balance(10), 10 + 10); + // Pools are removed from storage because the depositor left + assert!(!SubPoolsStorage::::contains_key(1)); + assert!(!RewardPools::::contains_key(1)); + assert!(!BondedPools::::contains_key(1)); + }); + } + + #[test] + fn withdraw_unbonded_depositor_no_era_pool() { + ExtBuilder::default().add_members(vec![(100, 100)]).build_and_execute(|| { + // Given + assert_ok!(Pools::fully_unbond(Origin::signed(100), 100)); + unsafe_set_state(1, PoolState::Destroying).unwrap(); + assert_ok!(Pools::fully_unbond(Origin::signed(10), 10)); + // Skip ahead to an era where the `with_era` pools can get merged into the `no_era` + // pool. + let current_era = TotalUnbondingPools::::get(); + CurrentEra::set(current_era); + + // Simulate some other withdraw that caused the pool to merge + let sub_pools = + SubPoolsStorage::::get(1).unwrap().maybe_merge_pools(current_era + 3); + SubPoolsStorage::::insert(1, sub_pools); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: UnbondPool { points: 100 + 10, balance: 100 + 10 }, + with_era: Default::default(), + } + ); + + // Cannot withdraw depositor with another member in the `no_era` pool + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(420), 10, 0), + Error::::NotOnlyPoolMember + ); + + // Given + assert_ok!(Pools::withdraw_unbonded(Origin::signed(420), 100, 0)); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: UnbondPool { points: 10, balance: 10 }, + with_era: Default::default(), + } + ); + + // The depositor can withdraw + assert_ok!(Pools::withdraw_unbonded(Origin::signed(420), 10, 0)); + assert!(!PoolMembers::::contains_key(10)); + assert_eq!(Balances::free_balance(10), 10 + 10); + // Pools are removed from storage because the depositor left + assert!(!SubPoolsStorage::::contains_key(1)); + assert!(!RewardPools::::contains_key(1)); + assert!(!BondedPools::::contains_key(1)); + }); + } + + #[test] + fn partial_withdraw_unbonded_depositor() { + ExtBuilder::default().ed(1).build_and_execute(|| { + // so the depositor can leave, just keeps the test simpler. + unsafe_set_state(1, PoolState::Destroying).unwrap(); + + // given + assert_ok!(Pools::unbond(Origin::signed(10), 10, 6)); + CurrentEra::set(1); + assert_ok!(Pools::unbond(Origin::signed(10), 10, 1)); + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 6, 4 => 1) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 6, balance: 6 }, + 4 => UnbondPool { points: 1, balance: 1 } + } + } + ); + assert_eq!(PoolMembers::::get(10).unwrap().active_points(), 3); + assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 7); + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::Created { depositor: 10, pool_id: 1 }, + Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: true }, + Event::PaidOut { member: 10, pool_id: 1, payout: 0 }, + Event::Unbonded { member: 10, pool_id: 1, amount: 6 }, + Event::PaidOut { member: 10, pool_id: 1, payout: 0 }, + Event::Unbonded { member: 10, pool_id: 1, amount: 1 } + ] + ); + + // when + CurrentEra::set(2); + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(10), 10, 0), + Error::::CannotWithdrawAny + ); + + // when + CurrentEra::set(3); + assert_ok!(Pools::withdraw_unbonded(Origin::signed(10), 10, 0)); + + // then + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!(4 => 1) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 4 => UnbondPool { points: 1, balance: 1 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Withdrawn { member: 10, pool_id: 1, amount: 6 }] + ); + + // when + CurrentEra::set(4); + assert_ok!(Pools::withdraw_unbonded(Origin::signed(10), 10, 0)); + + // then + assert_eq!( + PoolMembers::::get(10).unwrap().unbonding_eras, + member_unbonding_eras!() + ); + assert_eq!(SubPoolsStorage::::get(1).unwrap(), Default::default()); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Withdrawn { member: 10, pool_id: 1, amount: 1 },] + ); + + // when repeating: + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(10), 10, 0), + Error::::CannotWithdrawAny + ); + }); + } + + #[test] + fn partial_withdraw_unbonded_non_depositor() { + ExtBuilder::default().add_members(vec![(11, 10)]).build_and_execute(|| { + // given + assert_ok!(Pools::unbond(Origin::signed(11), 11, 6)); + CurrentEra::set(1); + assert_ok!(Pools::unbond(Origin::signed(11), 11, 1)); + assert_eq!( + PoolMembers::::get(11).unwrap().unbonding_eras, + member_unbonding_eras!(3 => 6, 4 => 1) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 3 => UnbondPool { points: 6, balance: 6 }, + 4 => UnbondPool { points: 1, balance: 1 } + } + } + ); + assert_eq!(PoolMembers::::get(11).unwrap().active_points(), 3); + assert_eq!(PoolMembers::::get(11).unwrap().unbonding_points(), 7); + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::Created { depositor: 10, pool_id: 1 }, + Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: true }, + Event::Bonded { member: 11, pool_id: 1, bonded: 10, joined: true }, + Event::PaidOut { member: 11, pool_id: 1, payout: 0 }, + Event::Unbonded { member: 11, pool_id: 1, amount: 6 }, + Event::PaidOut { member: 11, pool_id: 1, payout: 0 }, + Event::Unbonded { member: 11, pool_id: 1, amount: 1 } + ] + ); + + // when + CurrentEra::set(2); + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(11), 11, 0), + Error::::CannotWithdrawAny + ); + + // when + CurrentEra::set(3); + assert_ok!(Pools::withdraw_unbonded(Origin::signed(11), 11, 0)); + + // then + assert_eq!( + PoolMembers::::get(11).unwrap().unbonding_eras, + member_unbonding_eras!(4 => 1) + ); + assert_eq!( + SubPoolsStorage::::get(1).unwrap(), + SubPools { + no_era: Default::default(), + with_era: unbonding_pools_with_era! { + 4 => UnbondPool { points: 1, balance: 1 } + } + } + ); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Withdrawn { member: 11, pool_id: 1, amount: 6 }] + ); + + // when + CurrentEra::set(4); + assert_ok!(Pools::withdraw_unbonded(Origin::signed(11), 11, 0)); + + // then + assert_eq!( + PoolMembers::::get(11).unwrap().unbonding_eras, + member_unbonding_eras!() + ); + assert_eq!(SubPoolsStorage::::get(1).unwrap(), Default::default()); + assert_eq!( + pool_events_since_last_call(), + vec![Event::Withdrawn { member: 11, pool_id: 1, amount: 1 }] + ); + + // when repeating: + assert_noop!( + Pools::withdraw_unbonded(Origin::signed(11), 11, 0), + Error::::CannotWithdrawAny + ); + }); + } +} + +mod create { + use super::*; + + #[test] + fn create_works() { + ExtBuilder::default().build_and_execute(|| { + // next pool id is 2. + let next_pool_stash = Pools::create_bonded_account(2); + let ed = Balances::minimum_balance(); + + assert!(!BondedPools::::contains_key(2)); + assert!(!RewardPools::::contains_key(2)); + assert!(!PoolMembers::::contains_key(11)); + assert_eq!(StakingMock::active_stake(&next_pool_stash), None); + + Balances::make_free_balance_be(&11, StakingMock::minimum_bond() + ed); + assert_ok!(Pools::create( + Origin::signed(11), + StakingMock::minimum_bond(), + 123, + 456, + 789 + )); + + assert_eq!(Balances::free_balance(&11), 0); + assert_eq!( + PoolMembers::::get(11).unwrap(), + PoolMember { + pool_id: 2, + points: StakingMock::minimum_bond(), + ..Default::default() + } + ); + assert_eq!( + BondedPool::::get(2).unwrap(), + BondedPool { + id: 2, + inner: BondedPoolInner { + points: StakingMock::minimum_bond(), + member_counter: 1, + state: PoolState::Open, + roles: PoolRoles { + depositor: 11, + root: 123, + nominator: 456, + state_toggler: 789 + } + } + } + ); + assert_eq!( + StakingMock::active_stake(&next_pool_stash).unwrap(), + StakingMock::minimum_bond() + ); + assert_eq!( + RewardPools::::get(2).unwrap(), + RewardPool { + balance: Zero::zero(), + points: U256::zero(), + total_earnings: Zero::zero(), + } + ); + }); + } + + #[test] + fn create_errors_correctly() { + ExtBuilder::default().with_check(0).build_and_execute(|| { + assert_noop!( + Pools::create(Origin::signed(10), 420, 123, 456, 789), + Error::::AccountBelongsToOtherPool + ); + + // Given + assert_eq!(MinCreateBond::::get(), 2); + assert_eq!(StakingMock::minimum_bond(), 10); + + // Then + assert_noop!( + Pools::create(Origin::signed(11), 9, 123, 456, 789), + Error::::MinimumBondNotMet + ); + + // Given + MinCreateBond::::put(20); + + // Then + assert_noop!( + Pools::create(Origin::signed(11), 19, 123, 456, 789), + Error::::MinimumBondNotMet + ); + + // Given + BondedPool:: { + id: 2, + inner: BondedPoolInner { + state: PoolState::Open, + points: 10, + member_counter: 1, + roles: DEFAULT_ROLES, + }, + } + .put(); + assert_eq!(MaxPools::::get(), Some(2)); + assert_eq!(BondedPools::::count(), 2); + + // Then + assert_noop!( + Pools::create(Origin::signed(11), 20, 123, 456, 789), + Error::::MaxPools + ); + + // Given + assert_eq!(PoolMembers::::count(), 1); + MaxPools::::put(3); + MaxPoolMembers::::put(1); + Balances::make_free_balance_be(&11, 5 + 20); + + // Then + assert_noop!( + Pools::create(Origin::signed(11), 20, 11, 11, 11), + Error::::MaxPoolMembers + ); + }); + } +} + +mod nominate { + use super::*; + + #[test] + fn nominate_works() { + ExtBuilder::default().build_and_execute(|| { + // Depositor can't nominate + assert_noop!( + Pools::nominate(Origin::signed(10), 1, vec![21]), + Error::::NotNominator + ); + + // State toggler can't nominate + assert_noop!( + Pools::nominate(Origin::signed(902), 1, vec![21]), + Error::::NotNominator + ); + + // Root can nominate + assert_ok!(Pools::nominate(Origin::signed(900), 1, vec![21])); + assert_eq!(Nominations::get(), vec![21]); + + // Nominator can nominate + assert_ok!(Pools::nominate(Origin::signed(901), 1, vec![31])); + assert_eq!(Nominations::get(), vec![31]); + + // Can't nominate for a pool that doesn't exist + assert_noop!( + Pools::nominate(Origin::signed(902), 123, vec![21]), + Error::::PoolNotFound + ); + }); + } +} + +mod set_state { + use super::*; + + #[test] + fn set_state_works() { + ExtBuilder::default().build_and_execute(|| { + // Given + assert_ok!(BondedPool::::get(1).unwrap().ok_to_be_open(0)); + + // Only the root and state toggler can change the state when the pool is ok to be open. + assert_noop!( + Pools::set_state(Origin::signed(10), 1, PoolState::Blocked), + Error::::CanNotChangeState + ); + assert_noop!( + Pools::set_state(Origin::signed(901), 1, PoolState::Blocked), + Error::::CanNotChangeState + ); + + // Root can change state + assert_ok!(Pools::set_state(Origin::signed(900), 1, PoolState::Blocked)); + assert_eq!(BondedPool::::get(1).unwrap().state, PoolState::Blocked); + + // State toggler can change state + assert_ok!(Pools::set_state(Origin::signed(902), 1, PoolState::Destroying)); + assert_eq!(BondedPool::::get(1).unwrap().state, PoolState::Destroying); + + // If the pool is destroying, then no one can set state + assert_noop!( + Pools::set_state(Origin::signed(900), 1, PoolState::Blocked), + Error::::CanNotChangeState + ); + assert_noop!( + Pools::set_state(Origin::signed(902), 1, PoolState::Blocked), + Error::::CanNotChangeState + ); + + // If the pool is not ok to be open, then anyone can set it to destroying + + // Given + unsafe_set_state(1, PoolState::Open).unwrap(); + let mut bonded_pool = BondedPool::::get(1).unwrap(); + bonded_pool.points = 100; + bonded_pool.put(); + // When + assert_ok!(Pools::set_state(Origin::signed(11), 1, PoolState::Destroying)); + // Then + assert_eq!(BondedPool::::get(1).unwrap().state, PoolState::Destroying); + + // Given + Balances::make_free_balance_be(&default_bonded_account(), Balance::max_value() / 10); + unsafe_set_state(1, PoolState::Open).unwrap(); + // When + assert_ok!(Pools::set_state(Origin::signed(11), 1, PoolState::Destroying)); + // Then + assert_eq!(BondedPool::::get(1).unwrap().state, PoolState::Destroying); + + // If the pool is not ok to be open, it cannot be permissionleslly set to a state that + // isn't destroying + unsafe_set_state(1, PoolState::Open).unwrap(); + assert_noop!( + Pools::set_state(Origin::signed(11), 1, PoolState::Blocked), + Error::::CanNotChangeState + ); + }); + } +} + +mod set_metadata { + use super::*; + + #[test] + fn set_metadata_works() { + ExtBuilder::default().build_and_execute(|| { + // Root can set metadata + assert_ok!(Pools::set_metadata(Origin::signed(900), 1, vec![1, 1])); + assert_eq!(Metadata::::get(1), vec![1, 1]); + + // State toggler can set metadata + assert_ok!(Pools::set_metadata(Origin::signed(902), 1, vec![2, 2])); + assert_eq!(Metadata::::get(1), vec![2, 2]); + + // Depositor can't set metadata + assert_noop!( + Pools::set_metadata(Origin::signed(10), 1, vec![3, 3]), + Error::::DoesNotHavePermission + ); + + // Nominator can't set metadata + assert_noop!( + Pools::set_metadata(Origin::signed(901), 1, vec![3, 3]), + Error::::DoesNotHavePermission + ); + + // Metadata cannot be longer than `MaxMetadataLen` + assert_noop!( + Pools::set_metadata(Origin::signed(900), 1, vec![1, 1, 1]), + Error::::MetadataExceedsMaxLen + ); + }); + } +} + +mod set_configs { + use super::*; + + #[test] + fn set_configs_works() { + ExtBuilder::default().build_and_execute(|| { + // Setting works + assert_ok!(Pools::set_configs( + Origin::root(), + ConfigOp::Set(1 as Balance), + ConfigOp::Set(2 as Balance), + ConfigOp::Set(3u32), + ConfigOp::Set(4u32), + ConfigOp::Set(5u32), + )); + assert_eq!(MinJoinBond::::get(), 1); + assert_eq!(MinCreateBond::::get(), 2); + assert_eq!(MaxPools::::get(), Some(3)); + assert_eq!(MaxPoolMembers::::get(), Some(4)); + assert_eq!(MaxPoolMembersPerPool::::get(), Some(5)); + + // Noop does nothing + assert_storage_noop!(assert_ok!(Pools::set_configs( + Origin::root(), + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ))); + + // Removing works + assert_ok!(Pools::set_configs( + Origin::root(), + ConfigOp::Remove, + ConfigOp::Remove, + ConfigOp::Remove, + ConfigOp::Remove, + ConfigOp::Remove + )); + assert_eq!(MinJoinBond::::get(), 0); + assert_eq!(MinCreateBond::::get(), 0); + assert_eq!(MaxPools::::get(), None); + assert_eq!(MaxPoolMembers::::get(), None); + assert_eq!(MaxPoolMembersPerPool::::get(), None); + }); + } +} + +mod bond_extra { + use super::*; + use crate::Event; + + #[test] + fn bond_extra_from_free_balance_creator() { + ExtBuilder::default().build_and_execute(|| { + // 10 is the owner and a member in pool 1, give them some more funds. + Balances::make_free_balance_be(&10, 100); + + // given + assert_eq!(PoolMembers::::get(10).unwrap().points, 10); + assert_eq!(BondedPools::::get(1).unwrap().points, 10); + assert_eq!(Balances::free_balance(10), 100); + + // when + assert_ok!(Pools::bond_extra(Origin::signed(10), BondExtra::FreeBalance(10))); + + // then + assert_eq!(Balances::free_balance(10), 90); + assert_eq!(PoolMembers::::get(10).unwrap().points, 20); + assert_eq!(BondedPools::::get(1).unwrap().points, 20); + + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::Created { depositor: 10, pool_id: 1 }, + Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: true }, + Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: false } + ] + ); + + // when + assert_ok!(Pools::bond_extra(Origin::signed(10), BondExtra::FreeBalance(20))); + + // then + assert_eq!(Balances::free_balance(10), 70); + assert_eq!(PoolMembers::::get(10).unwrap().points, 40); + assert_eq!(BondedPools::::get(1).unwrap().points, 40); + + assert_eq!( + pool_events_since_last_call(), + vec![Event::Bonded { member: 10, pool_id: 1, bonded: 20, joined: false }] + ); + }) + } + + #[test] + fn bond_extra_from_rewards_creator() { + ExtBuilder::default().build_and_execute(|| { + // put some money in the reward account, all of which will belong to 10 as the only + // member of the pool. + Balances::make_free_balance_be(&default_reward_account(), 7); + // ... if which only 2 is claimable to make sure the reward account does not die. + let claimable_reward = 7 - ExistentialDeposit::get(); + + // given + assert_eq!(PoolMembers::::get(10).unwrap().points, 10); + assert_eq!(BondedPools::::get(1).unwrap().points, 10); + assert_eq!(Balances::free_balance(10), 5); + + // when + assert_ok!(Pools::bond_extra(Origin::signed(10), BondExtra::Rewards)); + + // then + assert_eq!(Balances::free_balance(10), 5); + assert_eq!(PoolMembers::::get(10).unwrap().points, 10 + claimable_reward); + assert_eq!(BondedPools::::get(1).unwrap().points, 10 + claimable_reward); + + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::Created { depositor: 10, pool_id: 1 }, + Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: true }, + Event::PaidOut { member: 10, pool_id: 1, payout: claimable_reward }, + Event::Bonded { + member: 10, + pool_id: 1, + bonded: claimable_reward, + joined: false + } + ] + ); + }) + } + + #[test] + fn bond_extra_from_rewards_joiner() { + ExtBuilder::default().add_members(vec![(20, 20)]).build_and_execute(|| { + // put some money in the reward account, all of which will belong to 10 as the only + // member of the pool. + Balances::make_free_balance_be(&default_reward_account(), 8); + // ... if which only 3 is claimable to make sure the reward account does not die. + let claimable_reward = 8 - ExistentialDeposit::get(); + // NOTE: easier to read of we use 3, so let's use the number instead of variable. + assert_eq!(claimable_reward, 3, "test is correct if rewards are divisible by 3"); + + // given + assert_eq!(PoolMembers::::get(10).unwrap().points, 10); + assert_eq!(PoolMembers::::get(20).unwrap().points, 20); + assert_eq!(BondedPools::::get(1).unwrap().points, 30); + assert_eq!(Balances::free_balance(10), 5); + assert_eq!(Balances::free_balance(20), 20); + + // when + assert_ok!(Pools::bond_extra(Origin::signed(10), BondExtra::Rewards)); + + // then + assert_eq!(Balances::free_balance(10), 5); + // 10's share of the reward is 1/3, since they gave 10/30 of the total shares. + assert_eq!(PoolMembers::::get(10).unwrap().points, 10 + 1); + assert_eq!(BondedPools::::get(1).unwrap().points, 30 + 1); + + // when + assert_ok!(Pools::bond_extra(Origin::signed(20), BondExtra::Rewards)); + + // then + assert_eq!(Balances::free_balance(20), 20); + // 20's share of the rewards is the other 2/3 of the rewards, since they have 20/30 of + // the shares + assert_eq!(PoolMembers::::get(20).unwrap().points, 20 + 2); + assert_eq!(BondedPools::::get(1).unwrap().points, 30 + 3); + + assert_eq!( + pool_events_since_last_call(), + vec![ + Event::Created { depositor: 10, pool_id: 1 }, + Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: true }, + Event::Bonded { member: 20, pool_id: 1, bonded: 20, joined: true }, + Event::PaidOut { member: 10, pool_id: 1, payout: 1 }, + Event::Bonded { member: 10, pool_id: 1, bonded: 1, joined: false }, + Event::PaidOut { member: 20, pool_id: 1, payout: 2 }, + Event::Bonded { member: 20, pool_id: 1, bonded: 2, joined: false } + ] + ); + }) + } +} diff --git a/frame/nomination-pools/src/weights.rs b/frame/nomination-pools/src/weights.rs new file mode 100644 index 0000000000000..4bd291f5276f8 --- /dev/null +++ b/frame/nomination-pools/src/weights.rs @@ -0,0 +1,482 @@ +// This file is part of Substrate. + +// Copyright (C) 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. + +//! Autogenerated weights for pallet_nomination_pools +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2022-04-22, STEPS: `50`, REPEAT: 20, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 + +// Executed Command: +// target/production/substrate +// benchmark +// pallet +// --chain=dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_nomination_pools +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./frame/nomination-pools/src/weights.rs +// --template=./.maintain/frame-weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use sp_std::marker::PhantomData; + +/// Weight functions needed for pallet_nomination_pools. +pub trait WeightInfo { + fn join() -> Weight; + fn bond_extra_transfer() -> Weight; + fn bond_extra_reward() -> Weight; + fn claim_payout() -> Weight; + fn unbond() -> Weight; + fn pool_withdraw_unbonded(s: u32, ) -> Weight; + fn withdraw_unbonded_update(s: u32, ) -> Weight; + fn withdraw_unbonded_kill(s: u32, ) -> Weight; + fn create() -> Weight; + fn nominate(n: u32, ) -> Weight; + fn set_state() -> Weight; + fn set_metadata(n: u32, ) -> Weight; + fn set_configs() -> Weight; +} + +/// Weights for pallet_nomination_pools using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools MinJoinBond (r:1 w:0) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: Staking Ledger (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:0) + // Storage: System Account (r:2 w:1) + // Storage: NominationPools MaxPoolMembersPerPool (r:1 w:0) + // Storage: NominationPools MaxPoolMembers (r:1 w:0) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + // Storage: Staking Bonded (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:3 w:3) + // Storage: BagsList ListBags (r:2 w:2) + fn join() -> Weight { + (117_870_000 as Weight) + .saturating_add(T::DbWeight::get().reads(18 as Weight)) + .saturating_add(T::DbWeight::get().writes(12 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:2 w:2) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:3 w:3) + // Storage: BagsList ListBags (r:2 w:2) + fn bond_extra_transfer() -> Weight { + (110_176_000 as Weight) + .saturating_add(T::DbWeight::get().reads(14 as Weight)) + .saturating_add(T::DbWeight::get().writes(13 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:3 w:3) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:2 w:2) + // Storage: BagsList ListBags (r:2 w:2) + fn bond_extra_reward() -> Weight { + (122_829_000 as Weight) + .saturating_add(T::DbWeight::get().reads(14 as Weight)) + .saturating_add(T::DbWeight::get().writes(13 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:1 w:1) + fn claim_payout() -> Weight { + (50_094_000 as Weight) + .saturating_add(T::DbWeight::get().reads(5 as Weight)) + .saturating_add(T::DbWeight::get().writes(5 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:2 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Nominators (r:1 w:0) + // Storage: Staking MinNominatorBond (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:3 w:3) + // Storage: Staking Bonded (r:1 w:0) + // Storage: BagsList ListBags (r:2 w:2) + // Storage: NominationPools SubPoolsStorage (r:1 w:1) + // Storage: NominationPools CounterForSubPoolsStorage (r:1 w:1) + fn unbond() -> Weight { + (119_288_000 as Weight) + .saturating_add(T::DbWeight::get().reads(19 as Weight)) + .saturating_add(T::DbWeight::get().writes(14 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:0) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + fn pool_withdraw_unbonded(s: u32, ) -> Weight { + (39_986_000 as Weight) + // Standard Error: 0 + .saturating_add((50_000 as Weight).saturating_mul(s as Weight)) + .saturating_add(T::DbWeight::get().reads(5 as Weight)) + .saturating_add(T::DbWeight::get().writes(3 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools SubPoolsStorage (r:1 w:1) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Balances Locks (r:1 w:1) + // Storage: System Account (r:1 w:1) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + fn withdraw_unbonded_update(s: u32, ) -> Weight { + (76_897_000 as Weight) + // Standard Error: 0 + .saturating_add((48_000 as Weight).saturating_mul(s as Weight)) + .saturating_add(T::DbWeight::get().reads(9 as Weight)) + .saturating_add(T::DbWeight::get().writes(8 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools SubPoolsStorage (r:1 w:1) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:1) + // Storage: Staking SlashingSpans (r:1 w:0) + // Storage: Staking Validators (r:1 w:0) + // Storage: Staking Nominators (r:1 w:0) + // Storage: System Account (r:2 w:2) + // Storage: Balances Locks (r:1 w:1) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + // Storage: NominationPools ReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools CounterForReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: NominationPools CounterForRewardPools (r:1 w:1) + // Storage: NominationPools CounterForSubPoolsStorage (r:1 w:1) + // Storage: NominationPools CounterForBondedPools (r:1 w:1) + // Storage: Staking Payee (r:0 w:1) + fn withdraw_unbonded_kill(_s: u32, ) -> Weight { + (135_837_000 as Weight) + .saturating_add(T::DbWeight::get().reads(20 as Weight)) + .saturating_add(T::DbWeight::get().writes(17 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: Staking MinNominatorBond (r:1 w:0) + // Storage: NominationPools MinCreateBond (r:1 w:0) + // Storage: NominationPools MinJoinBond (r:1 w:0) + // Storage: NominationPools MaxPools (r:1 w:0) + // Storage: NominationPools CounterForBondedPools (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools LastPoolId (r:1 w:1) + // Storage: NominationPools MaxPoolMembersPerPool (r:1 w:0) + // Storage: NominationPools MaxPoolMembers (r:1 w:0) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + // Storage: System Account (r:2 w:2) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Staking HistoryDepth (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: NominationPools CounterForRewardPools (r:1 w:1) + // Storage: NominationPools ReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools CounterForReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: Staking Payee (r:0 w:1) + fn create() -> Weight { + (129_265_000 as Weight) + .saturating_add(T::DbWeight::get().reads(23 as Weight)) + .saturating_add(T::DbWeight::get().writes(16 as Weight)) + } + // Storage: NominationPools BondedPools (r:1 w:0) + // Storage: Staking Ledger (r:1 w:0) + // Storage: Staking MinNominatorBond (r:1 w:0) + // Storage: Staking Nominators (r:1 w:1) + // Storage: Staking MaxNominatorsCount (r:1 w:0) + // Storage: Staking Validators (r:2 w:0) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Staking Bonded (r:1 w:0) + // Storage: BagsList ListNodes (r:1 w:1) + // Storage: BagsList ListBags (r:1 w:1) + // Storage: BagsList CounterForListNodes (r:1 w:1) + // Storage: Staking CounterForNominators (r:1 w:1) + fn nominate(n: u32, ) -> Weight { + (45_546_000 as Weight) + // Standard Error: 11_000 + .saturating_add((2_075_000 as Weight).saturating_mul(n as Weight)) + .saturating_add(T::DbWeight::get().reads(12 as Weight)) + .saturating_add(T::DbWeight::get().reads((1 as Weight).saturating_mul(n as Weight))) + .saturating_add(T::DbWeight::get().writes(5 as Weight)) + } + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: Staking Ledger (r:1 w:0) + fn set_state() -> Weight { + (23_256_000 as Weight) + .saturating_add(T::DbWeight::get().reads(2 as Weight)) + .saturating_add(T::DbWeight::get().writes(1 as Weight)) + } + // Storage: NominationPools BondedPools (r:1 w:0) + // Storage: NominationPools Metadata (r:1 w:1) + // Storage: NominationPools CounterForMetadata (r:1 w:1) + fn set_metadata(n: u32, ) -> Weight { + (10_893_000 as Weight) + // Standard Error: 0 + .saturating_add((1_000 as Weight).saturating_mul(n as Weight)) + .saturating_add(T::DbWeight::get().reads(3 as Weight)) + .saturating_add(T::DbWeight::get().writes(2 as Weight)) + } + // Storage: NominationPools MinJoinBond (r:0 w:1) + // Storage: NominationPools MaxPoolMembers (r:0 w:1) + // Storage: NominationPools MaxPoolMembersPerPool (r:0 w:1) + // Storage: NominationPools MinCreateBond (r:0 w:1) + // Storage: NominationPools MaxPools (r:0 w:1) + fn set_configs() -> Weight { + (2_793_000 as Weight) + .saturating_add(T::DbWeight::get().writes(5 as Weight)) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools MinJoinBond (r:1 w:0) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: Staking Ledger (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:0) + // Storage: System Account (r:2 w:1) + // Storage: NominationPools MaxPoolMembersPerPool (r:1 w:0) + // Storage: NominationPools MaxPoolMembers (r:1 w:0) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + // Storage: Staking Bonded (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:3 w:3) + // Storage: BagsList ListBags (r:2 w:2) + fn join() -> Weight { + (117_870_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(18 as Weight)) + .saturating_add(RocksDbWeight::get().writes(12 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:2 w:2) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:3 w:3) + // Storage: BagsList ListBags (r:2 w:2) + fn bond_extra_transfer() -> Weight { + (110_176_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(14 as Weight)) + .saturating_add(RocksDbWeight::get().writes(13 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:3 w:3) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:2 w:2) + // Storage: BagsList ListBags (r:2 w:2) + fn bond_extra_reward() -> Weight { + (122_829_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(14 as Weight)) + .saturating_add(RocksDbWeight::get().writes(13 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:1 w:1) + fn claim_payout() -> Weight { + (50_094_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(5 as Weight)) + .saturating_add(RocksDbWeight::get().writes(5 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: System Account (r:2 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Nominators (r:1 w:0) + // Storage: Staking MinNominatorBond (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: BagsList ListNodes (r:3 w:3) + // Storage: Staking Bonded (r:1 w:0) + // Storage: BagsList ListBags (r:2 w:2) + // Storage: NominationPools SubPoolsStorage (r:1 w:1) + // Storage: NominationPools CounterForSubPoolsStorage (r:1 w:1) + fn unbond() -> Weight { + (119_288_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(19 as Weight)) + .saturating_add(RocksDbWeight::get().writes(14 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:0) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + fn pool_withdraw_unbonded(s: u32, ) -> Weight { + (39_986_000 as Weight) + // Standard Error: 0 + .saturating_add((50_000 as Weight).saturating_mul(s as Weight)) + .saturating_add(RocksDbWeight::get().reads(5 as Weight)) + .saturating_add(RocksDbWeight::get().writes(3 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools SubPoolsStorage (r:1 w:1) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Balances Locks (r:1 w:1) + // Storage: System Account (r:1 w:1) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + fn withdraw_unbonded_update(s: u32, ) -> Weight { + (76_897_000 as Weight) + // Standard Error: 0 + .saturating_add((48_000 as Weight).saturating_mul(s as Weight)) + .saturating_add(RocksDbWeight::get().reads(9 as Weight)) + .saturating_add(RocksDbWeight::get().writes(8 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: NominationPools SubPoolsStorage (r:1 w:1) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:1) + // Storage: Staking SlashingSpans (r:1 w:0) + // Storage: Staking Validators (r:1 w:0) + // Storage: Staking Nominators (r:1 w:0) + // Storage: System Account (r:2 w:2) + // Storage: Balances Locks (r:1 w:1) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + // Storage: NominationPools ReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools CounterForReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: NominationPools CounterForRewardPools (r:1 w:1) + // Storage: NominationPools CounterForSubPoolsStorage (r:1 w:1) + // Storage: NominationPools CounterForBondedPools (r:1 w:1) + // Storage: Staking Payee (r:0 w:1) + fn withdraw_unbonded_kill(_s: u32, ) -> Weight { + (135_837_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(20 as Weight)) + .saturating_add(RocksDbWeight::get().writes(17 as Weight)) + } + // Storage: unknown [0x3a7472616e73616374696f6e5f6c6576656c3a] (r:1 w:1) + // Storage: Staking MinNominatorBond (r:1 w:0) + // Storage: NominationPools MinCreateBond (r:1 w:0) + // Storage: NominationPools MinJoinBond (r:1 w:0) + // Storage: NominationPools MaxPools (r:1 w:0) + // Storage: NominationPools CounterForBondedPools (r:1 w:1) + // Storage: NominationPools PoolMembers (r:1 w:1) + // Storage: NominationPools LastPoolId (r:1 w:1) + // Storage: NominationPools MaxPoolMembersPerPool (r:1 w:0) + // Storage: NominationPools MaxPoolMembers (r:1 w:0) + // Storage: NominationPools CounterForPoolMembers (r:1 w:1) + // Storage: System Account (r:2 w:2) + // Storage: Staking Ledger (r:1 w:1) + // Storage: Staking Bonded (r:1 w:1) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Staking HistoryDepth (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) + // Storage: NominationPools RewardPools (r:1 w:1) + // Storage: NominationPools CounterForRewardPools (r:1 w:1) + // Storage: NominationPools ReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools CounterForReversePoolIdLookup (r:1 w:1) + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: Staking Payee (r:0 w:1) + fn create() -> Weight { + (129_265_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(23 as Weight)) + .saturating_add(RocksDbWeight::get().writes(16 as Weight)) + } + // Storage: NominationPools BondedPools (r:1 w:0) + // Storage: Staking Ledger (r:1 w:0) + // Storage: Staking MinNominatorBond (r:1 w:0) + // Storage: Staking Nominators (r:1 w:1) + // Storage: Staking MaxNominatorsCount (r:1 w:0) + // Storage: Staking Validators (r:2 w:0) + // Storage: Staking CurrentEra (r:1 w:0) + // Storage: Staking Bonded (r:1 w:0) + // Storage: BagsList ListNodes (r:1 w:1) + // Storage: BagsList ListBags (r:1 w:1) + // Storage: BagsList CounterForListNodes (r:1 w:1) + // Storage: Staking CounterForNominators (r:1 w:1) + fn nominate(n: u32, ) -> Weight { + (45_546_000 as Weight) + // Standard Error: 11_000 + .saturating_add((2_075_000 as Weight).saturating_mul(n as Weight)) + .saturating_add(RocksDbWeight::get().reads(12 as Weight)) + .saturating_add(RocksDbWeight::get().reads((1 as Weight).saturating_mul(n as Weight))) + .saturating_add(RocksDbWeight::get().writes(5 as Weight)) + } + // Storage: NominationPools BondedPools (r:1 w:1) + // Storage: Staking Ledger (r:1 w:0) + fn set_state() -> Weight { + (23_256_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(2 as Weight)) + .saturating_add(RocksDbWeight::get().writes(1 as Weight)) + } + // Storage: NominationPools BondedPools (r:1 w:0) + // Storage: NominationPools Metadata (r:1 w:1) + // Storage: NominationPools CounterForMetadata (r:1 w:1) + fn set_metadata(n: u32, ) -> Weight { + (10_893_000 as Weight) + // Standard Error: 0 + .saturating_add((1_000 as Weight).saturating_mul(n as Weight)) + .saturating_add(RocksDbWeight::get().reads(3 as Weight)) + .saturating_add(RocksDbWeight::get().writes(2 as Weight)) + } + // Storage: NominationPools MinJoinBond (r:0 w:1) + // Storage: NominationPools MaxPoolMembers (r:0 w:1) + // Storage: NominationPools MaxPoolMembersPerPool (r:0 w:1) + // Storage: NominationPools MinCreateBond (r:0 w:1) + // Storage: NominationPools MaxPools (r:0 w:1) + fn set_configs() -> Weight { + (2_793_000 as Weight) + .saturating_add(RocksDbWeight::get().writes(5 as Weight)) + } +} diff --git a/frame/staking/Cargo.toml b/frame/staking/Cargo.toml index 66ac5e0ff1b36..88080df0b5912 100644 --- a/frame/staking/Cargo.toml +++ b/frame/staking/Cargo.toml @@ -71,5 +71,6 @@ runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", "frame-election-provider-support/runtime-benchmarks", "rand_chacha", + "sp-staking/runtime-benchmarks" ] try-runtime = ["frame-support/try-runtime"] diff --git a/frame/staking/src/benchmarking.rs b/frame/staking/src/benchmarking.rs index 6c1589417f15a..7321740cfa79f 100644 --- a/frame/staking/src/benchmarking.rs +++ b/frame/staking/src/benchmarking.rs @@ -49,7 +49,7 @@ type MaxNominators = <::BenchmarkingConfig as BenchmarkingConfig // Add slashing spans to a user account. Not relevant for actual use, only to benchmark // read and write operations. -fn add_slashing_spans(who: &T::AccountId, spans: u32) { +pub fn add_slashing_spans(who: &T::AccountId, spans: u32) { if spans == 0 { return } diff --git a/frame/staking/src/lib.rs b/frame/staking/src/lib.rs index 8003b9814c8e6..037dcc86ab2a8 100644 --- a/frame/staking/src/lib.rs +++ b/frame/staking/src/lib.rs @@ -737,6 +737,18 @@ where } } +impl SessionInterface for () { + fn disable_validator(_: u32) -> bool { + true + } + fn validators() -> Vec { + Vec::new() + } + fn prune_historical_up_to(_: SessionIndex) { + () + } +} + /// Handler for determining how much of a balance should be paid out on the current era. pub trait EraPayout { /// Determine the payout for this era. diff --git a/frame/staking/src/mock.rs b/frame/staking/src/mock.rs index 24572855264c4..bd2d8cdc32ce9 100644 --- a/frame/staking/src/mock.rs +++ b/frame/staking/src/mock.rs @@ -323,7 +323,7 @@ pub struct ExtBuilder { invulnerables: Vec, has_stakers: bool, initialize_first_session: bool, - min_nominator_bond: Balance, + pub min_nominator_bond: Balance, min_validator_bond: Balance, balance_factor: Balance, status: BTreeMap>, diff --git a/frame/staking/src/pallet/impls.rs b/frame/staking/src/pallet/impls.rs index ea4235f274da3..4665d956fdddb 100644 --- a/frame/staking/src/pallet/impls.rs +++ b/frame/staking/src/pallet/impls.rs @@ -29,15 +29,15 @@ use frame_support::{ }, weights::{Weight, WithPostDispatchInfo}, }; -use frame_system::pallet_prelude::BlockNumberFor; +use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; use pallet_session::historical; use sp_runtime::{ - traits::{Bounded, Convert, SaturatedConversion, Saturating, Zero}, + traits::{Bounded, Convert, SaturatedConversion, Saturating, StaticLookup, Zero}, Perbill, }; use sp_staking::{ offence::{DisableStrategy, OffenceDetails, OnOffenceHandler}, - EraIndex, SessionIndex, + EraIndex, SessionIndex, StakingInterface, }; use sp_std::{collections::btree_map::BTreeMap, prelude::*}; @@ -1367,3 +1367,68 @@ impl SortedListProvider for UseNominatorsAndValidatorsM Validators::::remove_all(); } } + +impl StakingInterface for Pallet { + type AccountId = T::AccountId; + type Balance = BalanceOf; + + fn minimum_bond() -> Self::Balance { + MinNominatorBond::::get() + } + + fn bonding_duration() -> EraIndex { + T::BondingDuration::get() + } + + fn current_era() -> EraIndex { + Self::current_era().unwrap_or(Zero::zero()) + } + + fn active_stake(controller: &Self::AccountId) -> Option { + Self::ledger(controller).map(|l| l.active) + } + + fn total_stake(controller: &Self::AccountId) -> Option { + Self::ledger(controller).map(|l| l.total) + } + + fn bond_extra(stash: Self::AccountId, extra: Self::Balance) -> DispatchResult { + Self::bond_extra(RawOrigin::Signed(stash).into(), extra) + } + + fn unbond(controller: Self::AccountId, value: Self::Balance) -> DispatchResult { + Self::unbond(RawOrigin::Signed(controller).into(), value) + } + + fn withdraw_unbonded( + controller: Self::AccountId, + num_slashing_spans: u32, + ) -> Result { + Self::withdraw_unbonded(RawOrigin::Signed(controller).into(), num_slashing_spans) + .map(|post_info| { + post_info + .actual_weight + .unwrap_or(T::WeightInfo::withdraw_unbonded_kill(num_slashing_spans)) + }) + .map_err(|err_with_post_info| err_with_post_info.error) + } + + fn bond( + stash: Self::AccountId, + controller: Self::AccountId, + value: Self::Balance, + payee: Self::AccountId, + ) -> DispatchResult { + Self::bond( + RawOrigin::Signed(stash).into(), + T::Lookup::unlookup(controller), + value, + RewardDestination::Account(payee), + ) + } + + fn nominate(controller: Self::AccountId, targets: Vec) -> DispatchResult { + let targets = targets.into_iter().map(T::Lookup::unlookup).collect::>(); + Self::nominate(RawOrigin::Signed(controller).into(), targets) + } +} diff --git a/frame/staking/src/pallet/mod.rs b/frame/staking/src/pallet/mod.rs index f57d5bd7c7a0a..bf13786d64ee6 100644 --- a/frame/staking/src/pallet/mod.rs +++ b/frame/staking/src/pallet/mod.rs @@ -30,7 +30,7 @@ use frame_support::{ use frame_system::{ensure_root, ensure_signed, offchain::SendTransactionTypes, pallet_prelude::*}; use sp_runtime::{ traits::{CheckedSub, SaturatedConversion, StaticLookup, Zero}, - DispatchError, Perbill, Percent, + Perbill, Percent, }; use sp_staking::{EraIndex, SessionIndex}; use sp_std::{cmp::max, prelude::*}; diff --git a/frame/support/src/lib.rs b/frame/support/src/lib.rs index 4484ff6b6b295..2d2944cc9a44f 100644 --- a/frame/support/src/lib.rs +++ b/frame/support/src/lib.rs @@ -87,6 +87,8 @@ pub use self::{ StorageHasher, Twox128, Twox256, Twox64Concat, }, storage::{ + bounded_btree_map::BoundedBTreeMap, + bounded_btree_set::BoundedBTreeSet, bounded_vec::{BoundedSlice, BoundedVec}, migration, weak_bounded_vec::WeakBoundedVec, @@ -138,6 +140,25 @@ macro_rules! bounded_vec { } } +/// Build a bounded btree-map from the given literals. +/// +/// The type of the outcome must be known. +/// +/// Will not handle any errors and just panic if the given literals cannot fit in the corresponding +/// bounded vec type. Thus, this is only suitable for testing and non-consensus code. +#[macro_export] +#[cfg(feature = "std")] +macro_rules! bounded_btree_map { + ($ ( $key:expr => $value:expr ),* $(,)?) => { + { + $crate::traits::TryCollect::<$crate::BoundedBTreeMap<_, _, _>>::try_collect( + $crate::sp_std::vec![$(($key, $value)),*].into_iter() + ).unwrap() + } + }; + +} + /// Generate a new type alias for [`storage::types::StorageValue`], /// [`storage::types::StorageMap`], [`storage::types::StorageDoubleMap`] /// and [`storage::types::StorageNMap`]. diff --git a/frame/support/src/storage/bounded_btree_map.rs b/frame/support/src/storage/bounded_btree_map.rs index eca4b17821c77..0d589994bcc2c 100644 --- a/frame/support/src/storage/bounded_btree_map.rs +++ b/frame/support/src/storage/bounded_btree_map.rs @@ -74,6 +74,14 @@ where Self(t, Default::default()) } + /// Exactly the same semantics as `BTreeMap::retain`. + /// + /// The is a safe `&mut self` borrow because `retain` can only ever decrease the length of the + /// inner map. + pub fn retain bool>(&mut self, f: F) { + self.0.retain(f) + } + /// Create a new `BoundedBTreeMap`. /// /// Does not allocate. diff --git a/frame/support/src/traits/misc.rs b/frame/support/src/traits/misc.rs index 2a0d6f0523e71..7575aa8c19dc6 100644 --- a/frame/support/src/traits/misc.rs +++ b/frame/support/src/traits/misc.rs @@ -129,9 +129,13 @@ pub trait DefensiveOption { /// if `None`, which should never happen. fn defensive_map_or_else U, F: FnOnce(T) -> U>(self, default: D, f: F) -> U; - /// Defensively transform this option to a result. + /// Defensively transform this option to a result, mapping `None` to the return value of an + /// error closure. fn defensive_ok_or_else E>(self, err: F) -> Result; + /// Defensively transform this option to a result, mapping `None` to a default value. + fn defensive_ok_or(self, err: E) -> Result; + /// Exactly the same as `map`, but it prints the appropriate warnings if the value being mapped /// is `None`. fn defensive_map U>(self, f: F) -> Option; @@ -284,6 +288,13 @@ impl DefensiveOption for Option { }) } + fn defensive_ok_or(self, err: E) -> Result { + self.ok_or_else(|| { + defensive!(); + err + }) + } + fn defensive_map U>(self, f: F) -> Option { match self { Some(inner) => Some(f(inner)), diff --git a/primitives/staking/Cargo.toml b/primitives/staking/Cargo.toml index 7761519dabefb..ac6c9d0e6428c 100644 --- a/primitives/staking/Cargo.toml +++ b/primitives/staking/Cargo.toml @@ -26,3 +26,4 @@ std = [ "sp-runtime/std", "sp-std/std", ] +runtime-benchmarks = [] diff --git a/primitives/staking/src/lib.rs b/primitives/staking/src/lib.rs index 57d858be1e0ef..7ff0808af0a63 100644 --- a/primitives/staking/src/lib.rs +++ b/primitives/staking/src/lib.rs @@ -19,6 +19,7 @@ //! A crate which contains primitives that are useful for implementation that uses staking //! approaches in general. Definitions related to sessions, slashing, etc go here. +use sp_runtime::{DispatchError, DispatchResult}; use sp_std::collections::btree_map::BTreeMap; pub mod offence; @@ -38,7 +39,7 @@ pub trait OnStakerSlash { /// /// * `stash` - The stash of the staker whom the slash was applied to. /// * `slashed_active` - The new bonded balance of the staker after the slash was applied. - /// * `slashed_unlocking` - a map of slashed eras, and the balance of that unlocking chunk after + /// * `slashed_unlocking` - A map of slashed eras, and the balance of that unlocking chunk after /// the slash is applied. Any era not present in the map is not affected at all. fn on_slash( stash: &AccountId, @@ -52,3 +53,80 @@ impl OnStakerSlash for () { // Nothing to do here } } + +/// Trait for communication with the staking pallet. +pub trait StakingInterface { + /// Balance type used by the staking system. + type Balance; + + /// AccountId type used by the staking system + type AccountId; + + /// The minimum amount required to bond in order to be a nominator. This does not necessarily + /// mean the nomination will be counted in an election, but instead just enough to be stored as + /// a nominator. In other words, this is the minimum amount to register the intention to + /// nominate. + fn minimum_bond() -> Self::Balance; + + /// Number of eras that staked funds must remain bonded for. + /// + /// # Note + /// + /// This must be strictly greater than the staking systems slash deffer duration. + fn bonding_duration() -> EraIndex; + + /// The current era index. + /// + /// This should be the latest planned era that the staking system knows about. + fn current_era() -> EraIndex; + + /// The amount of active stake that `controller` has in the staking system. + fn active_stake(controller: &Self::AccountId) -> Option; + + /// The total stake that `controller` has in the staking system. This includes the + /// [`Self::active_stake`], and any funds currently in the process of unbonding via + /// [`Self::unbond`]. + /// + /// # Note + /// + /// This is only guaranteed to reflect the amount locked by the staking system. If there are + /// non-staking locks on the bonded pair's balance this may not be accurate. + fn total_stake(controller: &Self::AccountId) -> Option; + + /// Bond (lock) `value` of `stash`'s balance. `controller` will be set as the account + /// controlling `stash`. This creates what is referred to as "bonded pair". + fn bond( + stash: Self::AccountId, + controller: Self::AccountId, + value: Self::Balance, + payee: Self::AccountId, + ) -> DispatchResult; + + /// Have `controller` nominate `validators`. + fn nominate( + controller: Self::AccountId, + validators: sp_std::vec::Vec, + ) -> DispatchResult; + + /// Bond some extra amount in the _Stash_'s free balance against the active bonded balance of + /// the account. The amount extra actually bonded will never be more than the _Stash_'s free + /// balance. + fn bond_extra(controller: Self::AccountId, extra: Self::Balance) -> DispatchResult; + + /// Schedule a portion of the active bonded balance to be unlocked at era + /// [Self::current_era] + [`Self::bonding_duration`]. + /// + /// Once the unlock era has been reached, [`Self::withdraw_unbonded`] can be called to unlock + /// the funds. + /// + /// The amount of times this can be successfully called is limited based on how many distinct + /// eras funds are schedule to unlock in. Calling [`Self::withdraw_unbonded`] after some unlock + /// schedules have reached their unlocking era should allow more calls to this function. + fn unbond(controller: Self::AccountId, value: Self::Balance) -> DispatchResult; + + /// Unlock any funds schedule to unlock before or at the current era. + fn withdraw_unbonded( + controller: Self::AccountId, + num_slashing_spans: u32, + ) -> Result; +}