From 03b492428cec2609d2b56252050f26e5e686d0da Mon Sep 17 00:00:00 2001 From: Gavin Wood Date: Sun, 18 Jun 2023 18:22:17 +0200 Subject: [PATCH] Society v2 (#11324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New Society * More logic drafting * More work * Building * Some tests * Fixes * Improvements to the voting process * More tests * Test number 20 * Tests * 30 tests * Another test] * All tests enabled * Minor stuff * generate_storage_alias: Rewrite as proc macro attribute This rewrites the `generate_storage_alias!` declarative macro as proc-macro attribute. While doing this the name is changed to `storage_alias`. The prefix can now also be the name of a pallet. This makes storage aliases work in migrations for all kind of chains and not just for the ones that use predefined prefixes. * Maintenance operations don't pay fee * Fix compilation and FMT * Moare fixes * Migrations * Fix tests and add migration testing * Introduce lazy-cleanup and avoid unbounded prefix removal * Fixes * Fixes * [WIP][Society] Adding benchmarking to the v2. (#11776) * [Society] Adding benchmarking to the v2. * [Society] Code review. * [Society] Better code. * Using clear() + clear_prefix() and adding more tests. * Benchmarking again... * Fix Cargo * Fixes * Fixes * Spelling * Fix benchmarks * Another fix * Remove println --------- Co-authored-by: Bastian Köcher Co-authored-by: Artur Gontijo --- Cargo.lock | 8 +- bin/node/cli/src/chain_spec.rs | 10 +- bin/node/runtime/src/lib.rs | 24 +- bin/node/testing/src/genesis.rs | 2 +- frame/asset-conversion/src/benchmarking.rs | 2 +- frame/core-fellowship/src/benchmarking.rs | 2 +- frame/democracy/src/vote.rs | 2 +- .../nft-fractionalization/src/benchmarking.rs | 2 +- frame/salary/src/benchmarking.rs | 2 +- frame/society/Cargo.toml | 20 +- frame/society/src/benchmarking.rs | 376 ++++ frame/society/src/lib.rs | 1982 ++++++++++------- frame/society/src/migrations.rs | 329 +++ frame/society/src/mock.rs | 191 +- frame/society/src/tests.rs | 1326 +++++++---- frame/society/src/weights.rs | 375 ++++ frame/support/src/storage/mod.rs | 12 + frame/support/src/storage/types/map.rs | 28 + frame/support/src/traits/storage.rs | 6 + primitives/core/Cargo.toml | 2 +- primitives/core/src/crypto.rs | 46 + primitives/core/src/ecdsa.rs | 8 + primitives/core/src/ed25519.rs | 12 +- primitives/core/src/sr25519.rs | 13 +- primitives/runtime/src/lib.rs | 12 +- 25 files changed, 3382 insertions(+), 1410 deletions(-) create mode 100644 frame/society/src/benchmarking.rs create mode 100644 frame/society/src/migrations.rs create mode 100644 frame/society/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 8564a4198d4bc..37f18f2cd2d77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,9 +979,9 @@ checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" [[package]] name = "bounded-collections" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fbd1d11282a1eb134d3c3b7cf8ce213b5161c6e5f73fb1b98618482c606b64" +checksum = "eb5b05133427c07c4776906f673ccf36c21b102c9829c641a5b56bd151d44fd6" dependencies = [ "log", "parity-scale-codec", @@ -7300,13 +7300,17 @@ dependencies = [ name = "pallet-society" version = "4.0.0-dev" dependencies = [ + "frame-benchmarking", "frame-support", "frame-support-test", "frame-system", + "hex-literal", + "log", "pallet-balances", "parity-scale-codec", "rand_chacha 0.2.2", "scale-info", + "sp-arithmetic", "sp-core", "sp-io", "sp-runtime", diff --git a/bin/node/cli/src/chain_spec.rs b/bin/node/cli/src/chain_spec.rs index 3ade205031f0a..ebbda8baabd8b 100644 --- a/bin/node/cli/src/chain_spec.rs +++ b/bin/node/cli/src/chain_spec.rs @@ -348,15 +348,7 @@ pub fn testnet_genesis( grandpa: GrandpaConfig { authorities: vec![] }, technical_membership: Default::default(), treasury: Default::default(), - society: SocietyConfig { - members: endowed_accounts - .iter() - .take((num_endowed_accounts + 1) / 2) - .cloned() - .collect(), - pot: 0, - max_members: 999, - }, + society: SocietyConfig { pot: 0 }, vesting: Default::default(), assets: pallet_assets::GenesisConfig { // This asset is used by the NIS pallet as counterpart currency. diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index f59d1c96b8da1..7d16a1afa1f2d 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -1398,14 +1398,14 @@ impl pallet_recovery::Config for Runtime { } parameter_types! { - pub const CandidateDeposit: Balance = 10 * DOLLARS; - pub const WrongSideDeduction: Balance = 2 * DOLLARS; - pub const MaxStrikes: u32 = 10; - pub const RotationPeriod: BlockNumber = 80 * HOURS; + pub const GraceStrikes: u32 = 10; + pub const SocietyVotingPeriod: BlockNumber = 80 * HOURS; + pub const ClaimPeriod: BlockNumber = 80 * HOURS; pub const PeriodSpend: Balance = 500 * DOLLARS; pub const MaxLockDuration: BlockNumber = 36 * 30 * DAYS; pub const ChallengePeriod: BlockNumber = 7 * DAYS; - pub const MaxCandidateIntake: u32 = 10; + pub const MaxPayouts: u32 = 10; + pub const MaxBids: u32 = 10; pub const SocietyPalletId: PalletId = PalletId(*b"py/socie"); } @@ -1414,18 +1414,17 @@ impl pallet_society::Config for Runtime { type PalletId = SocietyPalletId; type Currency = Balances; type Randomness = RandomnessCollectiveFlip; - type CandidateDeposit = CandidateDeposit; - type WrongSideDeduction = WrongSideDeduction; - type MaxStrikes = MaxStrikes; + type GraceStrikes = GraceStrikes; type PeriodSpend = PeriodSpend; - type MembershipChanged = (); - type RotationPeriod = RotationPeriod; + type VotingPeriod = SocietyVotingPeriod; + type ClaimPeriod = ClaimPeriod; type MaxLockDuration = MaxLockDuration; type FounderSetOrigin = pallet_collective::EnsureProportionMoreThan; - type SuspensionJudgementOrigin = pallet_society::EnsureFounder; - type MaxCandidateIntake = MaxCandidateIntake; type ChallengePeriod = ChallengePeriod; + type MaxPayouts = MaxPayouts; + type MaxBids = MaxBids; + type WeightInfo = pallet_society::weights::SubstrateWeight; } parameter_types! { @@ -2048,6 +2047,7 @@ mod benches { [pallet_scheduler, Scheduler] [pallet_glutton, Glutton] [pallet_session, SessionBench::] + [pallet_society, Society] [pallet_staking, Staking] [pallet_state_trie_migration, StateTrieMigration] [pallet_sudo, Sudo] diff --git a/bin/node/testing/src/genesis.rs b/bin/node/testing/src/genesis.rs index 10910811fe4c6..0514899dfbab8 100644 --- a/bin/node/testing/src/genesis.rs +++ b/bin/node/testing/src/genesis.rs @@ -87,7 +87,7 @@ pub fn config_endowed(code: Option<&[u8]>, extra_endowed: Vec) -> Run elections: Default::default(), sudo: Default::default(), treasury: Default::default(), - society: SocietyConfig { members: vec![alice(), bob()], pot: 0, max_members: 999 }, + society: SocietyConfig { pot: 0 }, vesting: Default::default(), assets: AssetsConfig { assets: vec![(9, alice(), true, 1)], ..Default::default() }, pool_assets: Default::default(), diff --git a/frame/asset-conversion/src/benchmarking.rs b/frame/asset-conversion/src/benchmarking.rs index 0563a8543f087..0a212fb5ceeb4 100644 --- a/frame/asset-conversion/src/benchmarking.rs +++ b/frame/asset-conversion/src/benchmarking.rs @@ -1,6 +1,6 @@ // This file is part of Substrate. -// Copyright (C) 2020-2022 Parity Technologies (UK) Ltd. +// Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/frame/core-fellowship/src/benchmarking.rs b/frame/core-fellowship/src/benchmarking.rs index 551ec30c19f01..c49f50d4cc115 100644 --- a/frame/core-fellowship/src/benchmarking.rs +++ b/frame/core-fellowship/src/benchmarking.rs @@ -1,6 +1,6 @@ // This file is part of Substrate. -// Copyright (C) 2020-2022 Parity Technologies (UK) Ltd. +// Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/frame/democracy/src/vote.rs b/frame/democracy/src/vote.rs index c1b626fd9b7b9..b3fe9aa28e1ac 100644 --- a/frame/democracy/src/vote.rs +++ b/frame/democracy/src/vote.rs @@ -202,7 +202,7 @@ impl< .rejig(now); } - /// The amount of this account's balance that much currently be locked due to voting. + /// The amount of this account's balance that must currently be locked due to voting. pub fn locked_balance(&self) -> Balance { match self { Voting::Direct { votes, prior, .. } => diff --git a/frame/nft-fractionalization/src/benchmarking.rs b/frame/nft-fractionalization/src/benchmarking.rs index 1600ae78c4bdf..50bb6039eb6ec 100644 --- a/frame/nft-fractionalization/src/benchmarking.rs +++ b/frame/nft-fractionalization/src/benchmarking.rs @@ -1,6 +1,6 @@ // This file is part of Substrate. -// Copyright (C) 2020-2022 Parity Technologies (UK) Ltd. +// Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/frame/salary/src/benchmarking.rs b/frame/salary/src/benchmarking.rs index e19834f64ff73..7528293506aec 100644 --- a/frame/salary/src/benchmarking.rs +++ b/frame/salary/src/benchmarking.rs @@ -1,6 +1,6 @@ // This file is part of Substrate. -// Copyright (C) 2020-2022 Parity Technologies (UK) Ltd. +// Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/frame/society/Cargo.toml b/frame/society/Cargo.toml index d8fdf083c83c0..ec5ab04e35ec4 100644 --- a/frame/society/Cargo.toml +++ b/frame/society/Cargo.toml @@ -13,13 +13,19 @@ readme = "README.md" targets = ["x86_64-unknown-linux-gnu"] [dependencies] -codec = { package = "parity-scale-codec", version = "3.2.2", default-features = false, features = ["derive"] } +hex-literal = "0.3.4" +log = { version = "0.4.17", default-features = false } rand_chacha = { version = "0.2", default-features = false } scale-info = { version = "2.5.0", default-features = false, features = ["derive"] } +codec = { package = "parity-scale-codec", version = "3.2.2", default-features = false, features = ["derive"] } + +sp-std = { version = "8.0.0", default-features = false, path = "../../primitives/std" } +sp-io = { version = "23.0.0", default-features = false, path = "../../primitives/io" } +sp-arithmetic = { version = "16.0.0", default-features = false, path = "../../primitives/arithmetic" } +sp-runtime = { version = "24.0.0", default-features = false, path = "../../primitives/runtime" } +frame-benchmarking = { version = "4.0.0-dev", default-features = false, optional = true, path = "../benchmarking" } 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 = "24.0.0", default-features = false, path = "../../primitives/runtime" } -sp-std = { version = "8.0.0", default-features = false, path = "../../primitives/std" } [dev-dependencies] frame-support-test = { version = "3.0.0", path = "../support/test" } @@ -31,15 +37,21 @@ sp-io = { version = "23.0.0", path = "../../primitives/io" } default = ["std"] std = [ "codec/std", + "frame-benchmarking?/std", "frame-support/std", "frame-system/std", "rand_chacha/std", "scale-info/std", "sp-runtime/std", "sp-std/std", + "sp-io/std", ] runtime-benchmarks = [ - "frame-system/runtime-benchmarks", + "frame-benchmarking", "sp-runtime/runtime-benchmarks", + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", ] try-runtime = ["frame-support/try-runtime"] diff --git a/frame/society/src/benchmarking.rs b/frame/society/src/benchmarking.rs new file mode 100644 index 0000000000000..f9cd378b97d3d --- /dev/null +++ b/frame/society/src/benchmarking.rs @@ -0,0 +1,376 @@ +// This file is part of Substrate. + +// Copyright (C) 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. + +//! Society pallet benchmarking. + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; + +use frame_benchmarking::{account, benchmarks_instance_pallet, whitelisted_caller}; +use frame_system::RawOrigin; + +use sp_runtime::traits::Bounded; + +use crate::Pallet as Society; + +fn mock_balance_deposit, I: 'static>() -> BalanceOf { + T::Currency::minimum_balance().saturating_mul(1_000u32.into()) +} + +fn make_deposit, I: 'static>(who: &T::AccountId) -> BalanceOf { + let amount = mock_balance_deposit::(); + let required = amount.saturating_add(T::Currency::minimum_balance()); + if T::Currency::free_balance(who) < required { + T::Currency::make_free_balance_be(who, required); + } + T::Currency::reserve(who, amount).expect("Pre-funded account; qed"); + amount +} + +fn make_bid, I: 'static>( + who: &T::AccountId, +) -> BidKind> { + BidKind::Deposit(make_deposit::(who)) +} + +fn fund_society, I: 'static>() { + T::Currency::make_free_balance_be( + &Society::::account_id(), + BalanceOf::::max_value(), + ); + Pot::::put(&BalanceOf::::max_value()); +} + +// Set up Society +fn setup_society, I: 'static>() -> Result { + let origin = T::FounderSetOrigin::try_successful_origin().map_err(|_| "No origin")?; + let founder: T::AccountId = account("founder", 0, 0); + let founder_lookup: ::Source = T::Lookup::unlookup(founder.clone()); + let max_members = 5u32; + let max_intake = 3u32; + let max_strikes = 3u32; + Society::::found_society( + origin, + founder_lookup, + max_members, + max_intake, + max_strikes, + mock_balance_deposit::(), + b"benchmarking-society".to_vec(), + )?; + T::Currency::make_free_balance_be( + &Society::::account_id(), + T::Currency::minimum_balance(), + ); + T::Currency::make_free_balance_be(&Society::::payouts(), T::Currency::minimum_balance()); + Ok(founder) +} + +fn setup_funded_society, I: 'static>() -> Result { + let founder = setup_society::()?; + fund_society::(); + Ok(founder) +} + +fn add_candidate, I: 'static>( + name: &'static str, + tally: Tally, + skeptic_struck: bool, +) -> T::AccountId { + let candidate: T::AccountId = account(name, 0, 0); + let candidacy = Candidacy { + round: RoundCount::::get(), + kind: make_bid::(&candidate), + bid: 0u32.into(), + tally, + skeptic_struck, + }; + Candidates::::insert(&candidate, &candidacy); + candidate +} + +fn increment_round, I: 'static>() { + let mut round_count = RoundCount::::get(); + round_count.saturating_inc(); + RoundCount::::put(round_count); +} + +benchmarks_instance_pallet! { + bid { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + }: _(RawOrigin::Signed(caller.clone()), 10u32.into()) + verify { + let first_bid: Bid> = Bid { + who: caller.clone(), + kind: BidKind::Deposit(mock_balance_deposit::()), + value: 10u32.into(), + }; + assert_eq!(Bids::::get(), vec![first_bid]); + } + + unbid { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + let mut bids = Bids::::get(); + Society::::insert_bid(&mut bids, &caller, 10u32.into(), make_bid::(&caller)); + Bids::::put(bids); + }: _(RawOrigin::Signed(caller.clone())) + verify { + assert_eq!(Bids::::get(), vec![]); + } + + vouch { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + let vouched: T::AccountId = account("vouched", 0, 0); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + let _ = Society::::insert_member(&caller, 1u32.into()); + let vouched_lookup: ::Source = T::Lookup::unlookup(vouched.clone()); + }: _(RawOrigin::Signed(caller.clone()), vouched_lookup, 0u32.into(), 0u32.into()) + verify { + let bids = Bids::::get(); + let vouched_bid: Bid> = Bid { + who: vouched.clone(), + kind: BidKind::Vouch(caller.clone(), 0u32.into()), + value: 0u32.into(), + }; + assert_eq!(bids, vec![vouched_bid]); + } + + unvouch { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + let vouched: T::AccountId = account("vouched", 0, 0); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + let mut bids = Bids::::get(); + Society::::insert_bid(&mut bids, &caller, 10u32.into(), BidKind::Vouch(caller.clone(), 0u32.into())); + Bids::::put(bids); + }: _(RawOrigin::Signed(caller.clone())) + verify { + assert_eq!(Bids::::get(), vec![]); + } + + vote { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + let _ = Society::::insert_member(&caller, 1u32.into()); + let candidate = add_candidate::("candidate", Default::default(), false); + let candidate_lookup: ::Source = T::Lookup::unlookup(candidate.clone()); + }: _(RawOrigin::Signed(caller.clone()), candidate_lookup, true) + verify { + let maybe_vote: Vote = >::get(candidate.clone(), caller).unwrap(); + assert_eq!(maybe_vote.approve, true); + } + + defender_vote { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + let _ = Society::::insert_member(&caller, 1u32.into()); + let defender: T::AccountId = account("defender", 0, 0); + Defending::::put((defender, caller.clone(), Tally::default())); + }: _(RawOrigin::Signed(caller.clone()), false) + verify { + let round = RoundCount::::get(); + let skeptic_vote: Vote = DefenderVotes::::get(round, &caller).unwrap(); + assert_eq!(skeptic_vote.approve, false); + } + + payout { + let founder = setup_funded_society::()?; + // Payee's account already exists and is a member. + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, mock_balance_deposit::()); + let _ = Society::::insert_member(&caller, 0u32.into()); + // Introduce payout. + Society::::bump_payout(&caller, 0u32.into(), 1u32.into()); + }: _(RawOrigin::Signed(caller.clone())) + verify { + let record = Payouts::::get(caller); + assert!(record.payouts.is_empty()); + } + + waive_repay { + let founder = setup_funded_society::()?; + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + let _ = Society::::insert_member(&caller, 0u32.into()); + Society::::bump_payout(&caller, 0u32.into(), 1u32.into()); + }: _(RawOrigin::Signed(caller.clone()), 1u32.into()) + verify { + let record = Payouts::::get(caller); + assert!(record.payouts.is_empty()); + } + + found_society { + let founder: T::AccountId = whitelisted_caller(); + let can_found = T::FounderSetOrigin::try_successful_origin().map_err(|_| "No origin")?; + let founder_lookup: ::Source = T::Lookup::unlookup(founder.clone()); + }: _(can_found, founder_lookup, 5, 3, 3, mock_balance_deposit::(), b"benchmarking-society".to_vec()) + verify { + assert_eq!(Founder::::get(), Some(founder.clone())); + } + + dissolve { + let founder = setup_society::()?; + let members_and_candidates = vec![("m1", "c1"), ("m2", "c2"), ("m3", "c3"), ("m4", "c4")]; + let members_count = members_and_candidates.clone().len() as u32; + for (m, c) in members_and_candidates { + let member: T::AccountId = account(m, 0, 0); + let _ = Society::::insert_member(&member, 100u32.into()); + let candidate = add_candidate::(c, Tally { approvals: 1u32.into(), rejections: 1u32.into() }, false); + let candidate_lookup: ::Source = T::Lookup::unlookup(candidate); + let _ = Society::::vote(RawOrigin::Signed(member).into(), candidate_lookup, true); + } + // Leaving only Founder member. + MemberCount::::mutate(|i| { i.saturating_reduce(members_count) }); + }: _(RawOrigin::Signed(founder)) + verify { + assert_eq!(Founder::::get(), None); + } + + judge_suspended_member { + let founder = setup_society::()?; + let caller: T::AccountId = whitelisted_caller(); + let caller_lookup: ::Source = T::Lookup::unlookup(caller.clone()); + let _ = Society::::insert_member(&caller, 0u32.into()); + let _ = Society::::suspend_member(&caller); + }: _(RawOrigin::Signed(founder), caller_lookup, false) + verify { + assert_eq!(SuspendedMembers::::contains_key(&caller), false); + } + + set_parameters { + let founder = setup_society::()?; + let max_members = 10u32; + let max_intake = 10u32; + let max_strikes = 10u32; + let candidate_deposit: BalanceOf = 10u32.into(); + let params = GroupParams { max_members, max_intake, max_strikes, candidate_deposit }; + }: _(RawOrigin::Signed(founder), max_members, max_intake, max_strikes, candidate_deposit) + verify { + assert_eq!(Parameters::::get(), Some(params)); + } + + punish_skeptic { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Default::default(), false); + let skeptic: T::AccountId = account("skeptic", 0, 0); + let _ = Society::::insert_member(&skeptic, 0u32.into()); + Skeptic::::put(&skeptic); + if let Period::Voting { more, .. } = Society::::period() { + frame_system::Pallet::::set_block_number(frame_system::Pallet::::block_number() + more); + } + }: _(RawOrigin::Signed(candidate.clone())) + verify { + let candidacy = Candidates::::get(&candidate).unwrap(); + assert_eq!(candidacy.skeptic_struck, true); + } + + claim_membership { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Tally { approvals: 3u32.into(), rejections: 0u32.into() }, false); + increment_round::(); + }: _(RawOrigin::Signed(candidate.clone())) + verify { + assert!(!Candidates::::contains_key(&candidate)); + assert!(Members::::contains_key(&candidate)); + } + + bestow_membership { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Tally { approvals: 3u32.into(), rejections: 1u32.into() }, false); + increment_round::(); + }: _(RawOrigin::Signed(founder), candidate.clone()) + verify { + assert!(!Candidates::::contains_key(&candidate)); + assert!(Members::::contains_key(&candidate)); + } + + kick_candidate { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Tally { approvals: 1u32.into(), rejections: 1u32.into() }, false); + increment_round::(); + }: _(RawOrigin::Signed(founder), candidate.clone()) + verify { + assert!(!Candidates::::contains_key(&candidate)); + } + + resign_candidacy { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Tally { approvals: 0u32.into(), rejections: 0u32.into() }, false); + }: _(RawOrigin::Signed(candidate.clone())) + verify { + assert!(!Candidates::::contains_key(&candidate)); + } + + drop_candidate { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Tally { approvals: 0u32.into(), rejections: 3u32.into() }, false); + let caller: T::AccountId = whitelisted_caller(); + let _ = Society::::insert_member(&caller, 0u32.into()); + let mut round_count = RoundCount::::get(); + round_count = round_count.saturating_add(2u32); + RoundCount::::put(round_count); + }: _(RawOrigin::Signed(caller), candidate.clone()) + verify { + assert!(!Candidates::::contains_key(&candidate)); + } + + cleanup_candidacy { + let founder = setup_society::()?; + let candidate = add_candidate::("candidate", Tally { approvals: 0u32.into(), rejections: 0u32.into() }, false); + let member_one: T::AccountId = account("one", 0, 0); + let member_two: T::AccountId = account("two", 0, 0); + let _ = Society::::insert_member(&member_one, 0u32.into()); + let _ = Society::::insert_member(&member_two, 0u32.into()); + let candidate_lookup: ::Source = T::Lookup::unlookup(candidate.clone()); + let _ = Society::::vote(RawOrigin::Signed(member_one.clone()).into(), candidate_lookup.clone(), true); + let _ = Society::::vote(RawOrigin::Signed(member_two.clone()).into(), candidate_lookup, true); + Candidates::::remove(&candidate); + }: _(RawOrigin::Signed(member_one), candidate.clone(), 5) + verify { + assert_eq!(Votes::::get(&candidate, &member_two), None); + } + + cleanup_challenge { + let founder = setup_society::()?; + ChallengeRoundCount::::put(1u32); + let member: T::AccountId = whitelisted_caller(); + let _ = Society::::insert_member(&member, 0u32.into()); + let defender: T::AccountId = account("defender", 0, 0); + Defending::::put((defender.clone(), member.clone(), Tally::default())); + let _ = Society::::defender_vote(RawOrigin::Signed(member.clone()).into(), true); + ChallengeRoundCount::::put(2u32); + let mut challenge_round = ChallengeRoundCount::::get(); + challenge_round = challenge_round.saturating_sub(1u32); + }: _(RawOrigin::Signed(member.clone()), challenge_round, 1u32) + verify { + assert_eq!(DefenderVotes::::get(challenge_round, &defender), None); + } + + impl_benchmark_test_suite!( + Society, + sp_io::TestExternalities::from(frame_system::GenesisConfig::default().build_storage::().unwrap()), + crate::mock::Test + ); +} diff --git a/frame/society/src/lib.rs b/frame/society/src/lib.rs index 4ac853ce8e89a..6f42ae00f287d 100644 --- a/frame/society/src/lib.rs +++ b/frame/society/src/lib.rs @@ -30,7 +30,6 @@ //! At any point, a user in the society can be one of a: //! * Bidder - A user who has submitted intention of joining the society. //! * Candidate - A user who will be voted on to join the society. -//! * Suspended Candidate - A user who failed to win a vote. //! * Member - A user who is a member of the society. //! * Suspended Member - A member of the society who has accumulated too many strikes //! or failed their membership challenge. @@ -251,12 +250,21 @@ mod mock; #[cfg(test)] mod tests; +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +pub mod weights; + +pub mod migrations; + use frame_support::{ impl_ensure_origin_with_arg_ignoring_arg, pallet_prelude::*, + storage::KeyLenOf, traits::{ - BalanceStatus, ChangeMembers, Currency, EnsureOrigin, EnsureOriginWithArg, + BalanceStatus, Currency, EnsureOrigin, EnsureOriginWithArg, ExistenceRequirement::AllowDeath, Imbalance, OnUnbalanced, Randomness, ReservableCurrency, + StorageVersion, }, PalletId, }; @@ -268,13 +276,16 @@ use rand_chacha::{ use scale_info::TypeInfo; use sp_runtime::{ traits::{ - AccountIdConversion, CheckedSub, Hash, IntegerSquareRoot, Saturating, StaticLookup, + AccountIdConversion, CheckedAdd, CheckedSub, Hash, Saturating, StaticLookup, TrailingZeroInput, Zero, }, + ArithmeticError::Overflow, Percent, RuntimeDebug, }; use sp_std::prelude::*; +pub use weights::WeightInfo; + pub use pallet::*; type BalanceOf = @@ -284,15 +295,10 @@ type NegativeImbalanceOf = <>::Currency as Currency< >>::NegativeImbalance; type AccountIdLookupOf = <::Lookup as StaticLookup>::Source; -/// A vote by a member on a candidate application. #[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] -pub enum Vote { - /// The member has been chosen to be skeptic and has not yet taken any action. - Skeptic, - /// The member has rejected the candidate's application. - Reject, - /// The member approves of the candidate's application. - Approve, +pub struct Vote { + approve: bool, + weight: u32, } /// A judgement by the suspension judgement origin on a suspended candidate. @@ -343,10 +349,61 @@ pub struct Bid { value: Balance, } +/// The index of a round of candidates. +pub type RoundIndex = u32; + +/// The rank of a member. +pub type Rank = u32; + +/// The number of votes. +pub type VoteCount = u32; + +/// Tally of votes. +#[derive(Default, Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] +pub struct Tally { + /// The approval votes. + approvals: VoteCount, + /// The rejection votes. + rejections: VoteCount, +} + +impl Tally { + fn more_approvals(&self) -> bool { + self.approvals > self.rejections + } + + fn more_rejections(&self) -> bool { + self.rejections > self.approvals + } + + fn clear_approval(&self) -> bool { + self.approvals >= (2 * self.rejections).max(1) + } + + fn clear_rejection(&self) -> bool { + self.rejections >= (2 * self.approvals).max(1) + } +} + +/// A bid for entry into society. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] +pub struct Candidacy { + /// The index of the round where the candidacy began. + round: RoundIndex, + /// The kind of bid placed for this bidder/candidate. See `BidKind`. + kind: BidKind, + /// The reward that the bidder has requested for successfully joining the society. + bid: Balance, + /// The tally of votes so far. + tally: Tally, + /// True if the skeptic was already punished for note voting. + skeptic_struck: bool, +} + /// A vote by a member on a candidate application. #[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] pub enum BidKind { - /// The CandidateDeposit was paid for this bid. + /// The given deposit was paid for this bid. Deposit(Balance), /// A member vouched for this bid. The account should be reinstated into `Members` once the /// bid is successful (or if it is rescinded prior to launch). @@ -354,24 +411,69 @@ pub enum BidKind { } impl BidKind { - fn check_voucher(&self, v: &AccountId) -> DispatchResult { - if let BidKind::Vouch(ref a, _) = self { - if a == v { - Ok(()) - } else { - Err("incorrect identity".into()) - } - } else { - Err("not vouched".into()) - } + fn is_vouch(&self, v: &AccountId) -> bool { + matches!(self, BidKind::Vouch(ref a, _) if a == v) } } +pub type PayoutsFor = BoundedVec< + (::BlockNumber, BalanceOf), + >::MaxPayouts, +>; + +/// Information concerning a member. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] +pub struct MemberRecord { + rank: Rank, + strikes: StrikeCount, + vouching: Option, + index: u32, +} + +/// Information concerning a member. +#[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, Default)] +pub struct PayoutRecord { + paid: Balance, + payouts: PayoutsVec, +} + +pub type PayoutRecordFor = PayoutRecord< + BalanceOf, + BoundedVec< + (::BlockNumber, BalanceOf), + >::MaxPayouts, + >, +>; + +/// Record for an individual new member who was elevated from a candidate recently. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] +pub struct IntakeRecord { + who: AccountId, + bid: Balance, + round: RoundIndex, +} + +pub type IntakeRecordFor = + IntakeRecord<::AccountId, BalanceOf>; + +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] +pub struct GroupParams { + max_members: u32, + max_intake: u32, + max_strikes: u32, + candidate_deposit: Balance, +} + +pub type GroupParamsFor = GroupParams>; + #[frame_support::pallet] pub mod pallet { use super::*; + const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] #[pallet::without_storage_info] pub struct Pallet(_); @@ -391,30 +493,23 @@ pub mod pallet { /// Something that provides randomness in the runtime. type Randomness: Randomness; - /// The minimum amount of a deposit required for a bid to be made. - #[pallet::constant] - type CandidateDeposit: Get>; - - /// The amount of the unpaid reward that gets deducted in the case that either a skeptic - /// doesn't vote or someone votes in the wrong way. - #[pallet::constant] - type WrongSideDeduction: Get>; - - /// The number of times a member may vote the wrong way (or not at all, when they are a - /// skeptic) before they become suspended. + /// The maximum number of strikes before a member gets funds slashed. #[pallet::constant] - type MaxStrikes: Get; + type GraceStrikes: Get; /// The amount of incentive paid within each period. Doesn't include VoterTip. #[pallet::constant] type PeriodSpend: Get>; - /// The receiver of the signal for when the members have changed. - type MembershipChanged: ChangeMembers; + /// The number of blocks on which new candidates should be voted on. Together with + /// `ClaimPeriod`, this sums to the number of blocks between candidate intake periods. + #[pallet::constant] + type VotingPeriod: Get; - /// The number of blocks between candidate/membership rotation periods. + /// The number of blocks on which new candidates can claim their membership and be the + /// named head. #[pallet::constant] - type RotationPeriod: Get; + type ClaimPeriod: Get; /// The maximum duration of the payout lock. #[pallet::constant] @@ -423,22 +518,24 @@ pub mod pallet { /// The origin that is allowed to call `found`. type FounderSetOrigin: EnsureOrigin; - /// The origin that is allowed to make suspension judgements. - type SuspensionJudgementOrigin: EnsureOrigin; - /// The number of blocks between membership challenges. #[pallet::constant] type ChallengePeriod: Get; - /// The maximum number of candidates that we accept per round. + /// The maximum number of payouts a member may have waiting unclaimed. #[pallet::constant] - type MaxCandidateIntake: Get; + type MaxPayouts: Get; + + /// The maximum number of bids at once. + #[pallet::constant] + type MaxBids: Get; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; } #[pallet::error] pub enum Error { - /// An incorrect position was provided. - BadPosition, /// User is not a member. NotMember, /// User is already a member. @@ -456,7 +553,7 @@ pub mod pallet { /// Member is already vouching or banned from vouching again. AlreadyVouching, /// Member is not vouching. - NotVouching, + NotVouchingOnBidder, /// Cannot remove the head of the chain. Head, /// Cannot remove the founder. @@ -473,6 +570,36 @@ pub mod pallet { NotFounder, /// The caller is not the head. NotHead, + /// The membership cannot be claimed as the candidate was not clearly approved. + NotApproved, + /// The candidate cannot be kicked as the candidate was not clearly rejected. + NotRejected, + /// The candidacy cannot be dropped as the candidate was clearly approved. + Approved, + /// The candidacy cannot be bestowed as the candidate was clearly rejected. + Rejected, + /// The candidacy cannot be concluded as the voting is still in progress. + InProgress, + /// The candidacy cannot be pruned until a full additional intake period has passed. + TooEarly, + /// The skeptic already voted. + Voted, + /// The skeptic need not vote on candidates from expired rounds. + Expired, + /// User is not a bidder. + NotBidder, + /// There is no defender currently. + NoDefender, + /// Group doesn't exist. + NotGroup, + /// The member is already elevated to this rank. + AlreadyElevated, + /// The skeptic has already been punished for this offence. + AlreadyPunished, + /// Funds are insufficient to pay off society debts. + InsufficientFunds, + /// The candidate/defender has no stale votes to remove. + NoVotes, } #[pallet::event] @@ -507,136 +634,157 @@ pub mod pallet { Vote { candidate: T::AccountId, voter: T::AccountId, vote: bool }, /// A vote has been placed for a defending member DefenderVote { voter: T::AccountId, vote: bool }, - /// A new \[max\] member count has been set - NewMaxMembers { max: u32 }, + /// A new set of \[params\] has been set for the group. + NewParams { params: GroupParamsFor }, /// Society is unfounded. Unfounded { founder: T::AccountId }, /// Some funds were deposited into the society account. Deposit { value: BalanceOf }, - /// A group of members has been choosen as Skeptics - SkepticsChosen { skeptics: Vec }, + /// A \[member\] got elevated to \[rank\]. + Elevated { member: T::AccountId, rank: Rank }, } + /// Old name generated by `decl_event`. + #[deprecated(note = "use `Event` instead")] + pub type RawEvent = Event; + + /// The max number of members for the society at one time. + #[pallet::storage] + pub(super) type Parameters, I: 'static = ()> = + StorageValue<_, GroupParamsFor, OptionQuery>; + + /// Amount of our account balance that is specifically for the next round's bid(s). + #[pallet::storage] + pub type Pot, I: 'static = ()> = StorageValue<_, BalanceOf, ValueQuery>; + /// The first member. #[pallet::storage] - #[pallet::getter(fn founder)] pub type Founder, I: 'static = ()> = StorageValue<_, T::AccountId>; + /// The most primary from the most recently approved rank 0 members in the society. + #[pallet::storage] + pub type Head, I: 'static = ()> = StorageValue<_, T::AccountId>; + /// A hash of the rules of this society concerning membership. Can only be set once and /// only by the founder. #[pallet::storage] - #[pallet::getter(fn rules)] pub type Rules, I: 'static = ()> = StorageValue<_, T::Hash>; - /// The current set of candidates; bidders that are attempting to become members. + /// The current members and their rank. Doesn't include `SuspendedMembers`. #[pallet::storage] - #[pallet::getter(fn candidates)] - pub type Candidates, I: 'static = ()> = - StorageValue<_, Vec>>, ValueQuery>; + pub type Members, I: 'static = ()> = + StorageMap<_, Twox64Concat, T::AccountId, MemberRecord, OptionQuery>; - /// The set of suspended candidates. + /// Information regarding rank-0 payouts, past and future. #[pallet::storage] - #[pallet::getter(fn suspended_candidate)] - pub type SuspendedCandidates, I: 'static = ()> = StorageMap< - _, - Twox64Concat, - T::AccountId, - (BalanceOf, BidKind>), - >; + pub type Payouts, I: 'static = ()> = + StorageMap<_, Twox64Concat, T::AccountId, PayoutRecordFor, ValueQuery>; - /// Amount of our account balance that is specifically for the next round's bid(s). + /// The number of items in `Members` currently. (Doesn't include `SuspendedMembers`.) #[pallet::storage] - #[pallet::getter(fn pot)] - pub type Pot, I: 'static = ()> = StorageValue<_, BalanceOf, ValueQuery>; + pub type MemberCount, I: 'static = ()> = StorageValue<_, u32, ValueQuery>; - /// The most primary from the most recently approved members. + /// The current items in `Members` keyed by their unique index. Keys are densely populated + /// `0..MemberCount` (does not include `MemberCount`). #[pallet::storage] - #[pallet::getter(fn head)] - pub type Head, I: 'static = ()> = StorageValue<_, T::AccountId>; + pub type MemberByIndex, I: 'static = ()> = + StorageMap<_, Twox64Concat, u32, T::AccountId, OptionQuery>; - /// The current set of members, ordered. + /// The set of suspended members, with their old membership record. #[pallet::storage] - #[pallet::getter(fn members)] - pub type Members, I: 'static = ()> = - StorageValue<_, Vec, ValueQuery>; + pub type SuspendedMembers, I: 'static = ()> = + StorageMap<_, Twox64Concat, T::AccountId, MemberRecord, OptionQuery>; - /// The set of suspended members. + /// The number of rounds which have passed. #[pallet::storage] - #[pallet::getter(fn suspended_member)] - pub type SuspendedMembers, I: 'static = ()> = - StorageMap<_, Twox64Concat, T::AccountId, bool, ValueQuery>; + pub type RoundCount, I: 'static = ()> = StorageValue<_, RoundIndex, ValueQuery>; /// The current bids, stored ordered by the value of the bid. #[pallet::storage] pub(super) type Bids, I: 'static = ()> = - StorageValue<_, Vec>>, ValueQuery>; + StorageValue<_, BoundedVec>, T::MaxBids>, ValueQuery>; - /// Members currently vouching or banned from vouching again #[pallet::storage] - #[pallet::getter(fn vouching)] - pub(super) type Vouching, I: 'static = ()> = - StorageMap<_, Twox64Concat, T::AccountId, VouchingStatus>; + pub type Candidates, I: 'static = ()> = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + Candidacy>, + OptionQuery, + >; + + /// The current skeptic. + #[pallet::storage] + pub type Skeptic, I: 'static = ()> = StorageValue<_, T::AccountId, OptionQuery>; - /// Pending payouts; ordered by block number, with the amount that should be paid out. + /// Double map from Candidate -> Voter -> (Maybe) Vote. #[pallet::storage] - pub(super) type Payouts, I: 'static = ()> = StorageMap< + pub(super) type Votes, I: 'static = ()> = StorageDoubleMap< _, Twox64Concat, T::AccountId, - Vec<(T::BlockNumber, BalanceOf)>, - ValueQuery, + Twox64Concat, + T::AccountId, + Vote, + OptionQuery, >; - /// The ongoing number of losing votes cast by the member. + /// Clear-cursor for Vote, map from Candidate -> (Maybe) Cursor. #[pallet::storage] - pub(super) type Strikes, I: 'static = ()> = - StorageMap<_, Twox64Concat, T::AccountId, StrikeCount, ValueQuery>; + pub(super) type VoteClearCursor, I: 'static = ()> = + StorageMap<_, Twox64Concat, T::AccountId, BoundedVec>>>; - /// Double map from Candidate -> Voter -> (Maybe) Vote. + /// At the end of the claim period, this contains the most recently approved members (along with + /// their bid and round ID) who is from the most recent round with the lowest bid. They will + /// become the new `Head`. #[pallet::storage] - pub(super) type Votes, I: 'static = ()> = - StorageDoubleMap<_, Twox64Concat, T::AccountId, Twox64Concat, T::AccountId, Vote>; + pub type NextHead, I: 'static = ()> = + StorageValue<_, IntakeRecordFor, OptionQuery>; - /// The defending member currently being challenged. + /// The number of challenge rounds there have been. Used to identify stale DefenderVotes. #[pallet::storage] - #[pallet::getter(fn defender)] - pub(super) type Defender, I: 'static = ()> = StorageValue<_, T::AccountId>; + pub(super) type ChallengeRoundCount, I: 'static = ()> = + StorageValue<_, RoundIndex, ValueQuery>; - /// Votes for the defender. + /// The defending member currently being challenged, along with a running tally of votes. #[pallet::storage] - pub(super) type DefenderVotes, I: 'static = ()> = - StorageMap<_, Twox64Concat, T::AccountId, Vote>; + pub(super) type Defending, I: 'static = ()> = + StorageValue<_, (T::AccountId, T::AccountId, Tally)>; - /// The max number of members for the society at one time. + /// Votes for the defender, keyed by challenge round. #[pallet::storage] - #[pallet::getter(fn max_members)] - pub(super) type MaxMembers, I: 'static = ()> = StorageValue<_, u32, ValueQuery>; + pub(super) type DefenderVotes, I: 'static = ()> = + StorageDoubleMap<_, Twox64Concat, RoundIndex, Twox64Concat, T::AccountId, Vote>; #[pallet::hooks] impl, I: 'static> Hooks> for Pallet { fn on_initialize(n: T::BlockNumber) -> Weight { - let mut members = vec![]; - let mut weight = Weight::zero(); let weights = T::BlockWeights::get(); - // Run a candidate/membership rotation - if (n % T::RotationPeriod::get()).is_zero() { - members = >::get(); - Self::rotate_period(&mut members); + let phrase = b"society_rotation"; + // we'll need a random seed here. + // TODO: deal with randomness freshness + // https://github.com/paritytech/substrate/issues/8312 + let (seed, _) = T::Randomness::random(phrase); + // seed needs to be guaranteed to be 32 bytes. + let seed = <[u8; 32]>::decode(&mut TrailingZeroInput::new(seed.as_ref())) + .expect("input is padded with zeroes; qed"); + let mut rng = ChaChaRng::from_seed(seed); - weight += weights.max_block / 20; + // Run a candidate/membership rotation + match Self::period() { + Period::Voting { elapsed, .. } if elapsed.is_zero() => { + Self::rotate_intake(&mut rng); + weight.saturating_accrue(weights.max_block / 20); + }, + _ => {}, } // Run a challenge rotation if (n % T::ChallengePeriod::get()).is_zero() { - // Only read members if not already read. - if members.is_empty() { - members = >::get(); - } - Self::rotate_challenge(&mut members); - - weight += weights.max_block / 20; + Self::rotate_challenge(&mut rng); + weight.saturating_accrue(weights.max_block / 20); } weight @@ -647,23 +795,12 @@ pub mod pallet { #[derive(frame_support::DefaultNoBound)] pub struct GenesisConfig, I: 'static = ()> { pub pot: BalanceOf, - pub members: Vec, - pub max_members: u32, } #[pallet::genesis_build] impl, I: 'static> GenesisBuild for GenesisConfig { fn build(&self) { Pot::::put(self.pot); - MaxMembers::::put(self.max_members); - let first_member = self.members.first(); - if let Some(member) = first_member { - Founder::::put(member.clone()); - Head::::put(member.clone()); - }; - let mut m = self.members.clone(); - m.sort(); - Members::::put(m); } } @@ -671,37 +808,31 @@ pub mod pallet { impl, I: 'static> Pallet { /// A user outside of the society can make a bid for entry. /// - /// Payment: `CandidateDeposit` will be reserved for making a bid. It is returned + /// Payment: The group's Candidate Deposit will be reserved for making a bid. It is returned /// when the bid becomes a member, or if the bid calls `unbid`. /// /// The dispatch origin for this call must be _Signed_. /// /// Parameters: /// - `value`: A one time payment the bid would like to receive when joining the society. - /// - /// ## Complexity - /// - O(M + B + C + logM + logB + X) - /// - B (len of bids) - /// - C (len of candidates) - /// - M (len of members) - /// - X (balance reserve) #[pallet::call_index(0)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] + #[pallet::weight(T::WeightInfo::bid())] pub fn bid(origin: OriginFor, value: BalanceOf) -> DispatchResult { let who = ensure_signed(origin)?; - ensure!(!>::contains_key(&who), Error::::Suspended); - ensure!(!>::contains_key(&who), Error::::Suspended); - let bids = >::get(); - ensure!(!Self::is_bid(&bids, &who), Error::::AlreadyBid); - let candidates = >::get(); - ensure!(!Self::is_candidate(&candidates, &who), Error::::AlreadyCandidate); - let members = >::get(); - ensure!(!Self::is_member(&members, &who), Error::::AlreadyMember); - - let deposit = T::CandidateDeposit::get(); + + let mut bids = Bids::::get(); + ensure!(!Self::has_bid(&bids, &who), Error::::AlreadyBid); + ensure!(!Candidates::::contains_key(&who), Error::::AlreadyCandidate); + ensure!(!Members::::contains_key(&who), Error::::AlreadyMember); + ensure!(!SuspendedMembers::::contains_key(&who), Error::::Suspended); + + let params = Parameters::::get().ok_or(Error::::NotGroup)?; + let deposit = params.candidate_deposit; + // NOTE: Reserve must happen before `insert_bid` since that could end up unreserving. T::Currency::reserve(&who, deposit)?; + Self::insert_bid(&mut bids, &who, value, BidKind::Deposit(deposit)); - Self::put_bid(bids, &who, value, BidKind::Deposit(deposit)); + Bids::::put(bids); Self::deposit_event(Event::::Bid { candidate_id: who, offer: value }); Ok(()) } @@ -713,40 +844,17 @@ pub mod pallet { /// Payment: The bid deposit is unreserved if the user made a bid. /// /// The dispatch origin for this call must be _Signed_ and a bidder. - /// - /// Parameters: - /// - `pos`: Position in the `Bids` vector of the bid who wants to unbid. - /// - /// ## Complexity - /// - O(B + X) - /// - B (len of bids) - /// - X (balance unreserve) #[pallet::call_index(1)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn unbid(origin: OriginFor, pos: u32) -> DispatchResult { + #[pallet::weight(T::WeightInfo::unbid())] + pub fn unbid(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; - let pos = pos as usize; - >::mutate(|b| { - if pos < b.len() && b[pos].who == who { - // Either unreserve the deposit or free up the vouching member. - // In neither case can we do much if the action isn't completable, but there's - // no reason that either should fail. - match b.remove(pos).kind { - BidKind::Deposit(deposit) => { - let err_amount = T::Currency::unreserve(&who, deposit); - debug_assert!(err_amount.is_zero()); - }, - BidKind::Vouch(voucher, _) => { - >::remove(&voucher); - }, - } - Self::deposit_event(Event::::Unbid { candidate: who }); - Ok(()) - } else { - Err(Error::::BadPosition.into()) - } - }) + let mut bids = Bids::::get(); + let pos = bids.iter().position(|bid| bid.who == who).ok_or(Error::::NotBidder)?; + Self::clean_bid(&bids.remove(pos)); + Bids::::put(bids); + Self::deposit_event(Event::::Unbid { candidate: who }); + Ok(()) } /// As a member, vouch for someone to join society by placing a bid on their behalf. @@ -766,15 +874,8 @@ pub mod pallet { /// a member in the society. /// - `tip`: Your cut of the total `value` payout when the candidate is inducted into /// the society. Tips larger than `value` will be saturated upon payout. - /// - /// ## Complexity - /// - O(M + B + C + logM + logB + X) - /// - B (len of bids) - /// - C (len of candidates) - /// - M (len of members) - /// - X (balance reserve) #[pallet::call_index(2)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] + #[pallet::weight(T::WeightInfo::vouch())] pub fn vouch( origin: OriginFor, who: AccountIdLookupOf, @@ -783,23 +884,28 @@ pub mod pallet { ) -> DispatchResult { let voucher = ensure_signed(origin)?; let who = T::Lookup::lookup(who)?; - // Check user is not suspended. - ensure!(!>::contains_key(&who), Error::::Suspended); - ensure!(!>::contains_key(&who), Error::::Suspended); - // Check user is not a bid or candidate. - let bids = >::get(); - ensure!(!Self::is_bid(&bids, &who), Error::::AlreadyBid); - let candidates = >::get(); - ensure!(!Self::is_candidate(&candidates, &who), Error::::AlreadyCandidate); - // Check user is not already a member. - let members = >::get(); - ensure!(!Self::is_member(&members, &who), Error::::AlreadyMember); + + // Get bids and check user is not bidding. + let mut bids = Bids::::get(); + ensure!(!Self::has_bid(&bids, &who), Error::::AlreadyBid); + + // Check user is not already a candidate, member or suspended member. + ensure!(!Candidates::::contains_key(&who), Error::::AlreadyCandidate); + ensure!(!Members::::contains_key(&who), Error::::AlreadyMember); + ensure!(!SuspendedMembers::::contains_key(&who), Error::::Suspended); + // Check sender can vouch. - ensure!(Self::is_member(&members, &voucher), Error::::NotMember); - ensure!(!>::contains_key(&voucher), Error::::AlreadyVouching); + let mut record = Members::::get(&voucher).ok_or(Error::::NotMember)?; + ensure!(record.vouching.is_none(), Error::::AlreadyVouching); + + // Update voucher record. + record.vouching = Some(VouchingStatus::Vouching); + // Update bids + Self::insert_bid(&mut bids, &who, value, BidKind::Vouch(voucher.clone(), tip)); - >::insert(&voucher, VouchingStatus::Vouching); - Self::put_bid(bids, &who, value, BidKind::Vouch(voucher.clone(), tip)); + // Write new state. + Members::::insert(&voucher, &record); + Bids::::put(bids); Self::deposit_event(Event::::Vouch { candidate_id: who, offer: value, @@ -815,31 +921,22 @@ pub mod pallet { /// /// Parameters: /// - `pos`: Position in the `Bids` vector of the bid who should be unvouched. - /// - /// ## Complexity - /// - O(B) - /// - B (len of bids) #[pallet::call_index(3)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn unvouch(origin: OriginFor, pos: u32) -> DispatchResult { + #[pallet::weight(T::WeightInfo::unvouch())] + pub fn unvouch(origin: OriginFor) -> DispatchResult { let voucher = ensure_signed(origin)?; - ensure!( - Self::vouching(&voucher) == Some(VouchingStatus::Vouching), - Error::::NotVouching - ); - let pos = pos as usize; - >::mutate(|b| { - if pos < b.len() { - b[pos].kind.check_voucher(&voucher)?; - >::remove(&voucher); - let who = b.remove(pos).who; - Self::deposit_event(Event::::Unvouch { candidate: who }); - Ok(()) - } else { - Err(Error::::BadPosition.into()) - } - }) + let mut bids = Bids::::get(); + let pos = bids + .iter() + .position(|bid| bid.kind.is_vouch(&voucher)) + .ok_or(Error::::NotVouchingOnBidder)?; + let bid = bids.remove(pos); + Self::clean_bid(&bid); + + Bids::::put(bids); + Self::deposit_event(Event::::Unvouch { candidate: bid.who }); + Ok(()) } /// As a member, vote on a candidate. @@ -850,30 +947,29 @@ pub mod pallet { /// - `candidate`: The candidate that the member would like to bid on. /// - `approve`: A boolean which says if the candidate should be approved (`true`) or /// rejected (`false`). - /// - /// ## Complexity - /// - O(M + logM + C) - /// - C (len of candidates) - /// - M (len of members) #[pallet::call_index(4)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] + #[pallet::weight(T::WeightInfo::vote())] pub fn vote( origin: OriginFor, candidate: AccountIdLookupOf, approve: bool, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { let voter = ensure_signed(origin)?; let candidate = T::Lookup::lookup(candidate)?; - let candidates = >::get(); - ensure!(Self::is_candidate(&candidates, &candidate), Error::::NotCandidate); - let members = >::get(); - ensure!(Self::is_member(&members, &voter), Error::::NotMember); - let vote = if approve { Vote::Approve } else { Vote::Reject }; - >::insert(&candidate, &voter, vote); + let mut candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + let record = Members::::get(&voter).ok_or(Error::::NotMember)?; + + let first_time = Votes::::mutate(&candidate, &voter, |v| { + let first_time = v.is_none(); + *v = Some(Self::do_vote(*v, approve, record.rank, &mut candidacy.tally)); + first_time + }); + Candidates::::insert(&candidate, &candidacy); Self::deposit_event(Event::::Vote { candidate, voter, vote: approve }); - Ok(()) + Ok(if first_time { Pays::No } else { Pays::Yes }.into()) } /// As a member, vote on the defender. @@ -883,22 +979,24 @@ pub mod pallet { /// Parameters: /// - `approve`: A boolean which says if the candidate should be /// approved (`true`) or rejected (`false`). - /// - /// ## Complexity - /// - O(M + logM) - /// - M (len of members) #[pallet::call_index(5)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn defender_vote(origin: OriginFor, approve: bool) -> DispatchResult { + #[pallet::weight(T::WeightInfo::defender_vote())] + pub fn defender_vote(origin: OriginFor, approve: bool) -> DispatchResultWithPostInfo { let voter = ensure_signed(origin)?; - let members = >::get(); - ensure!(Self::is_member(&members, &voter), Error::::NotMember); - let vote = if approve { Vote::Approve } else { Vote::Reject }; - >::insert(&voter, vote); + let mut defending = Defending::::get().ok_or(Error::::NoDefender)?; + let record = Members::::get(&voter).ok_or(Error::::NotMember)?; + let round = ChallengeRoundCount::::get(); + let first_time = DefenderVotes::::mutate(round, &voter, |v| { + let first_time = v.is_none(); + *v = Some(Self::do_vote(*v, approve, record.rank, &mut defending.2)); + first_time + }); + + Defending::::put(defending); Self::deposit_event(Event::::DefenderVote { voter, vote: approve }); - Ok(()) + Ok(if first_time { Pays::No } else { Pays::Yes }.into()) } /// Transfer the first matured payout for the sender and remove it from the records. @@ -911,34 +1009,48 @@ pub mod pallet { /// /// The dispatch origin for this call must be _Signed_ and a member with /// payouts remaining. - /// - /// ## Complexity - /// - O(M + logM + P + X) - /// - M (len of members) - /// - P (number of payouts for a particular member) - /// - X (currency transfer call) #[pallet::call_index(6)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] + #[pallet::weight(T::WeightInfo::payout())] pub fn payout(origin: OriginFor) -> DispatchResult { let who = ensure_signed(origin)?; + ensure!( + Members::::get(&who).ok_or(Error::::NotMember)?.rank == 0, + Error::::NoPayout + ); + let mut record = Payouts::::get(&who); - let members = >::get(); - ensure!(Self::is_member(&members, &who), Error::::NotMember); - - let mut payouts = >::get(&who); - if let Some((when, amount)) = payouts.first() { + if let Some((when, amount)) = record.payouts.first() { if when <= &>::block_number() { + record.paid = record.paid.checked_add(amount).ok_or(Overflow)?; T::Currency::transfer(&Self::payouts(), &who, *amount, AllowDeath)?; - payouts.remove(0); - if payouts.is_empty() { - >::remove(&who); - } else { - >::insert(&who, payouts); - } + record.payouts.remove(0); + Payouts::::insert(&who, record); return Ok(()) } } - Err(Error::::NoPayout.into()) + Err(Error::::NoPayout)? + } + + /// Repay the payment previously given to the member with the signed origin, remove any + /// pending payments, and elevate them from rank 0 to rank 1. + #[pallet::call_index(7)] + #[pallet::weight(T::WeightInfo::waive_repay())] + pub fn waive_repay(origin: OriginFor, amount: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + let mut record = Members::::get(&who).ok_or(Error::::NotMember)?; + let mut payout_record = Payouts::::get(&who); + ensure!(record.rank == 0, Error::::AlreadyElevated); + ensure!(amount >= payout_record.paid, Error::::InsufficientFunds); + + T::Currency::transfer(&who, &Self::account_id(), payout_record.paid, AllowDeath)?; + payout_record.paid = Zero::zero(); + payout_record.payouts.clear(); + record.rank = 1; + Members::::insert(&who, record); + Payouts::::insert(&who, payout_record); + Self::deposit_event(Event::::Elevated { member: who, rank: 1 }); + + Ok(()) } /// Found the society. @@ -951,54 +1063,71 @@ pub mod pallet { /// Parameters: /// - `founder` - The first member and head of the newly founded society. /// - `max_members` - The initial max number of members for the society. + /// - `max_intake` - The maximum number of candidates per intake period. + /// - `max_strikes`: The maximum number of strikes a member may get before they become + /// suspended and may only be reinstated by the founder. + /// - `candidate_deposit`: The deposit required to make a bid for membership of the group. /// - `rules` - The rules of this society concerning membership. /// - /// ## Complexity - /// - O(1) - #[pallet::call_index(7)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn found( + /// Complexity: O(1) + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::found_society())] + pub fn found_society( origin: OriginFor, founder: AccountIdLookupOf, max_members: u32, + max_intake: u32, + max_strikes: u32, + candidate_deposit: BalanceOf, rules: Vec, ) -> DispatchResult { T::FounderSetOrigin::ensure_origin(origin)?; let founder = T::Lookup::lookup(founder)?; - ensure!(!>::exists(), Error::::AlreadyFounded); + ensure!(!Head::::exists(), Error::::AlreadyFounded); ensure!(max_members > 1, Error::::MaxMembers); // This should never fail in the context of this function... - >::put(max_members); - Self::add_member(&founder)?; - >::put(&founder); - >::put(&founder); + let params = GroupParams { max_members, max_intake, max_strikes, candidate_deposit }; + Parameters::::put(params); + Self::insert_member(&founder, 1)?; + Head::::put(&founder); + Founder::::put(&founder); Rules::::put(T::Hashing::hash(&rules)); Self::deposit_event(Event::::Founded { founder }); Ok(()) } - /// Annul the founding of the society. + /// Dissolve the society and remove all members. /// /// The dispatch origin for this call must be Signed, and the signing account must be both /// the `Founder` and the `Head`. This implies that it may only be done when there is one /// member. - /// - /// ## Complexity - /// - O(1) - #[pallet::call_index(8)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn unfound(origin: OriginFor) -> DispatchResult { + #[pallet::call_index(9)] + #[pallet::weight(T::WeightInfo::dissolve())] + pub fn dissolve(origin: OriginFor) -> DispatchResult { let founder = ensure_signed(origin)?; - ensure!(Founder::::get() == Some(founder.clone()), Error::::NotFounder); - ensure!(Head::::get() == Some(founder.clone()), Error::::NotHead); - - Members::::kill(); + ensure!(Founder::::get().as_ref() == Some(&founder), Error::::NotFounder); + ensure!(MemberCount::::get() == 1, Error::::NotHead); + + let _ = Members::::clear(u32::MAX, None); + MemberCount::::kill(); + let _ = MemberByIndex::::clear(u32::MAX, None); + let _ = SuspendedMembers::::clear(u32::MAX, None); + let _ = Payouts::::clear(u32::MAX, None); + let _ = Votes::::clear(u32::MAX, None); + let _ = VoteClearCursor::::clear(u32::MAX, None); Head::::kill(); + NextHead::::kill(); Founder::::kill(); Rules::::kill(); - Candidates::::kill(); - #[allow(deprecated)] - SuspendedCandidates::::remove_all(None); + Parameters::::kill(); + Pot::::kill(); + RoundCount::::kill(); + Bids::::kill(); + Skeptic::::kill(); + ChallengeRoundCount::::kill(); + Defending::::kill(); + let _ = DefenderVotes::::clear(u32::MAX, None); + let _ = Candidates::::clear(u32::MAX, None); Self::deposit_event(Event::::Unfounded { founder }); Ok(()) } @@ -1011,162 +1140,235 @@ pub mod pallet { /// If a suspended member is rejected, remove all associated storage items, including /// their payouts, and remove any vouched bids they currently have. /// - /// The dispatch origin for this call must be from the _SuspensionJudgementOrigin_. + /// The dispatch origin for this call must be Signed from the Founder. /// /// Parameters: /// - `who` - The suspended member to be judged. /// - `forgive` - A boolean representing whether the suspension judgement origin forgives /// (`true`) or rejects (`false`) a suspended member. - /// - /// ## Complexity - /// - O(M + logM + B) - /// - B (len of bids) - /// - M (len of members) - #[pallet::call_index(9)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] + #[pallet::call_index(10)] + #[pallet::weight(T::WeightInfo::judge_suspended_member())] pub fn judge_suspended_member( origin: OriginFor, who: AccountIdLookupOf, forgive: bool, - ) -> DispatchResult { - T::SuspensionJudgementOrigin::ensure_origin(origin)?; + ) -> DispatchResultWithPostInfo { + ensure!( + Some(ensure_signed(origin)?) == Founder::::get(), + Error::::NotFounder + ); let who = T::Lookup::lookup(who)?; - ensure!(>::contains_key(&who), Error::::NotSuspended); - + let record = SuspendedMembers::::get(&who).ok_or(Error::::NotSuspended)?; if forgive { // Try to add member back to society. Can fail with `MaxMembers` limit. - Self::add_member(&who)?; + Self::reinstate_member(&who, record.rank)?; } else { - // Cancel a suspended member's membership, remove their payouts. - >::remove(&who); - >::remove(&who); - // Remove their vouching status, potentially unbanning them in the future. - if >::take(&who) == Some(VouchingStatus::Vouching) { - // Try to remove their bid if they are vouching. - // If their vouch is already a candidate, do nothing. - >::mutate(|bids| - // Try to find the matching bid - if let Some(pos) = bids.iter().position(|b| b.kind.check_voucher(&who).is_ok()) { - // Remove the bid, and emit an event - let vouched = bids.remove(pos).who; - Self::deposit_event(Event::::Unvouch { candidate: vouched }); - } - ); - } + let payout_record = Payouts::::take(&who); + let total = payout_record + .payouts + .into_iter() + .map(|x| x.1) + .fold(Zero::zero(), |acc: BalanceOf, x| acc.saturating_add(x)); + Self::unreserve_payout(total); } - - >::remove(&who); + SuspendedMembers::::remove(&who); Self::deposit_event(Event::::SuspendedMemberJudgement { who, judged: forgive }); - Ok(()) + Ok(Pays::No.into()) } - /// Allow suspended judgement origin to make judgement on a suspended candidate. - /// - /// If the judgement is `Approve`, we add them to society as a member with the appropriate - /// payment for joining society. + /// Change the maximum number of members in society and the maximum number of new candidates + /// in a single intake period. /// - /// If the judgement is `Reject`, we either slash the deposit of the bid, giving it back - /// to the society treasury, or we ban the voucher from vouching again. - /// - /// If the judgement is `Rebid`, we put the candidate back in the bid pool and let them go - /// through the induction process again. - /// - /// The dispatch origin for this call must be from the _SuspensionJudgementOrigin_. + /// The dispatch origin for this call must be Signed by the Founder. /// /// Parameters: - /// - `who` - The suspended candidate to be judged. - /// - `judgement` - `Approve`, `Reject`, or `Rebid`. - /// - /// ## Complexity - /// - O(M + logM + B + X) - /// - B (len of bids) - /// - M (len of members) - /// - X (balance action) - #[pallet::call_index(10)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn judge_suspended_candidate( + /// - `max_members` - The maximum number of members for the society. This must be no less + /// than the current number of members. + /// - `max_intake` - The maximum number of candidates per intake period. + /// - `max_strikes`: The maximum number of strikes a member may get before they become + /// suspended and may only be reinstated by the founder. + /// - `candidate_deposit`: The deposit required to make a bid for membership of the group. + #[pallet::call_index(11)] + #[pallet::weight(T::WeightInfo::set_parameters())] + pub fn set_parameters( origin: OriginFor, - who: AccountIdLookupOf, - judgement: Judgement, + max_members: u32, + max_intake: u32, + max_strikes: u32, + candidate_deposit: BalanceOf, ) -> DispatchResult { - T::SuspensionJudgementOrigin::ensure_origin(origin)?; - let who = T::Lookup::lookup(who)?; - if let Some((value, kind)) = >::get(&who) { - match judgement { - Judgement::Approve => { - // Suspension Judgement origin has approved this candidate - // Make sure we can pay them - let pot = Self::pot(); - ensure!(pot >= value, Error::::InsufficientPot); - // Try to add user as a member! Can fail with `MaxMember` limit. - Self::add_member(&who)?; - // Reduce next pot by payout - >::put(pot - value); - // Add payout for new candidate - let maturity = >::block_number() + - Self::lock_duration(Self::members().len() as u32); - Self::pay_accepted_candidate(&who, value, kind, maturity); - }, - Judgement::Reject => { - // Founder has rejected this candidate - match kind { - BidKind::Deposit(deposit) => { - // Slash deposit and move it to the society account - let res = T::Currency::repatriate_reserved( - &who, - &Self::account_id(), - deposit, - BalanceStatus::Free, - ); - debug_assert!(res.is_ok()); - }, - BidKind::Vouch(voucher, _) => { - // Ban the voucher from vouching again - >::insert(&voucher, VouchingStatus::Banned); - }, - } - }, - Judgement::Rebid => { - // Founder has taken no judgement, and candidate is placed back into the - // pool. - let bids = >::get(); - Self::put_bid(bids, &who, value, kind); - }, - } + ensure!( + Some(ensure_signed(origin)?) == Founder::::get(), + Error::::NotFounder + ); + ensure!(max_members >= MemberCount::::get(), Error::::MaxMembers); + let params = GroupParams { max_members, max_intake, max_strikes, candidate_deposit }; + Parameters::::put(¶ms); + Self::deposit_event(Event::::NewParams { params }); + Ok(()) + } - // Remove suspended candidate - >::remove(who); - } else { - return Err(Error::::NotSuspended.into()) + /// Punish the skeptic with a strike if they did not vote on a candidate. Callable by the + /// candidate. + #[pallet::call_index(12)] + #[pallet::weight(T::WeightInfo::punish_skeptic())] + pub fn punish_skeptic(origin: OriginFor) -> DispatchResultWithPostInfo { + let candidate = ensure_signed(origin)?; + let mut candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + ensure!(!candidacy.skeptic_struck, Error::::AlreadyPunished); + ensure!(!Self::in_progress(candidacy.round), Error::::InProgress); + let punished = Self::check_skeptic(&candidate, &mut candidacy); + Candidates::::insert(&candidate, candidacy); + Ok(if punished { Pays::No } else { Pays::Yes }.into()) + } + + /// Transform an approved candidate into a member. Callable only by the + /// the candidate, and only after the period for voting has ended. + #[pallet::call_index(13)] + #[pallet::weight(T::WeightInfo::claim_membership())] + pub fn claim_membership(origin: OriginFor) -> DispatchResultWithPostInfo { + let candidate = ensure_signed(origin)?; + let candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + ensure!(candidacy.tally.clear_approval(), Error::::NotApproved); + ensure!(!Self::in_progress(candidacy.round), Error::::InProgress); + Self::induct_member(candidate, candidacy, 0)?; + Ok(Pays::No.into()) + } + + /// Transform an approved candidate into a member. Callable only by the Signed origin of the + /// Founder, only after the period for voting has ended and only when the candidate is not + /// clearly rejected. + #[pallet::call_index(14)] + #[pallet::weight(T::WeightInfo::bestow_membership())] + pub fn bestow_membership( + origin: OriginFor, + candidate: T::AccountId, + ) -> DispatchResultWithPostInfo { + ensure!( + Some(ensure_signed(origin)?) == Founder::::get(), + Error::::NotFounder + ); + let candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + ensure!(!candidacy.tally.clear_rejection(), Error::::Rejected); + ensure!(!Self::in_progress(candidacy.round), Error::::InProgress); + Self::induct_member(candidate, candidacy, 0)?; + Ok(Pays::No.into()) + } + + /// Remove the candidate's application from the society. Callable only by the Signed origin + /// of the Founder, only after the period for voting has ended, and only when they do not + /// have a clear approval. + /// + /// Any bid deposit is lost and voucher is banned. + #[pallet::call_index(15)] + #[pallet::weight(T::WeightInfo::kick_candidate())] + pub fn kick_candidate( + origin: OriginFor, + candidate: T::AccountId, + ) -> DispatchResultWithPostInfo { + ensure!( + Some(ensure_signed(origin)?) == Founder::::get(), + Error::::NotFounder + ); + let mut candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + ensure!(!Self::in_progress(candidacy.round), Error::::InProgress); + ensure!(!candidacy.tally.clear_approval(), Error::::Approved); + Self::check_skeptic(&candidate, &mut candidacy); + Self::reject_candidate(&candidate, &candidacy.kind); + Candidates::::remove(&candidate); + Ok(Pays::No.into()) + } + + /// Remove the candidate's application from the society. Callable only by the candidate. + /// + /// Any bid deposit is lost and voucher is banned. + #[pallet::call_index(16)] + #[pallet::weight(T::WeightInfo::resign_candidacy())] + pub fn resign_candidacy(origin: OriginFor) -> DispatchResultWithPostInfo { + let candidate = ensure_signed(origin)?; + let mut candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + if !Self::in_progress(candidacy.round) { + Self::check_skeptic(&candidate, &mut candidacy); } - Ok(()) + Self::reject_candidate(&candidate, &candidacy.kind); + Candidates::::remove(&candidate); + Ok(Pays::No.into()) } - /// Allows root origin to change the maximum number of members in society. - /// Max membership count must be greater than 1. + /// Remove a `candidate`'s failed application from the society. Callable by any + /// signed origin but only at the end of the subsequent round and only for + /// a candidate with more rejections than approvals. /// - /// The dispatch origin for this call must be from _ROOT_. + /// The bid deposit is lost and the voucher is banned. + #[pallet::call_index(17)] + #[pallet::weight(T::WeightInfo::drop_candidate())] + pub fn drop_candidate( + origin: OriginFor, + candidate: T::AccountId, + ) -> DispatchResultWithPostInfo { + ensure_signed(origin)?; + let candidacy = + Candidates::::get(&candidate).ok_or(Error::::NotCandidate)?; + ensure!(candidacy.tally.clear_rejection(), Error::::NotRejected); + ensure!(RoundCount::::get() > candidacy.round + 1, Error::::TooEarly); + Self::reject_candidate(&candidate, &candidacy.kind); + Candidates::::remove(&candidate); + Ok(Pays::No.into()) + } + + /// Remove up to `max` stale votes for the given `candidate`. /// - /// Parameters: - /// - `max` - The maximum number of members for the society. + /// May be called by any Signed origin, but only after the candidate's candidacy is ended. + #[pallet::call_index(18)] + #[pallet::weight(T::WeightInfo::cleanup_candidacy())] + pub fn cleanup_candidacy( + origin: OriginFor, + candidate: T::AccountId, + max: u32, + ) -> DispatchResultWithPostInfo { + ensure_signed(origin)?; + ensure!(!Candidates::::contains_key(&candidate), Error::::InProgress); + let maybe_cursor = VoteClearCursor::::get(&candidate); + let r = + Votes::::clear_prefix(&candidate, max, maybe_cursor.as_ref().map(|x| &x[..])); + if let Some(cursor) = r.maybe_cursor { + VoteClearCursor::::insert(&candidate, BoundedVec::truncate_from(cursor)); + } + Ok(if r.loops == 0 { Pays::Yes } else { Pays::No }.into()) + } + + /// Remove up to `max` stale votes for the defender in the given `challenge_round`. /// - /// ## Complexity - /// - O(1) - #[pallet::call_index(11)] - #[pallet::weight(T::BlockWeights::get().max_block / 10)] - pub fn set_max_members(origin: OriginFor, max: u32) -> DispatchResult { - ensure_root(origin)?; - ensure!(max > 1, Error::::MaxMembers); - MaxMembers::::put(max); - Self::deposit_event(Event::::NewMaxMembers { max }); - Ok(()) + /// May be called by any Signed origin, but only after the challenge round is ended. + #[pallet::call_index(19)] + #[pallet::weight(T::WeightInfo::cleanup_challenge())] + pub fn cleanup_challenge( + origin: OriginFor, + challenge_round: RoundIndex, + max: u32, + ) -> DispatchResultWithPostInfo { + ensure_signed(origin)?; + ensure!( + challenge_round < ChallengeRoundCount::::get(), + Error::::InProgress + ); + let _ = DefenderVotes::::clear_prefix(challenge_round, max, None); + // clear_prefix() v2 is always returning backend = 0, ignoring it till v3. + // let (_, backend, _, _) = r.deconstruct(); + // if backend == 0 { return Err(Error::::NoVotes.into()); }; + Ok(Pays::No.into()) } } } /// Simple ensure origin struct to filter for the founder account. pub struct EnsureFounder(sp_std::marker::PhantomData); -impl EnsureOrigin for EnsureFounder { +impl EnsureOrigin<::RuntimeOrigin> for EnsureFounder { type Success = T::AccountId; fn try_origin(o: T::RuntimeOrigin) -> Result { o.into().and_then(|o| match (o, Founder::::get()) { @@ -1188,376 +1390,515 @@ impl_ensure_origin_with_arg_ignoring_arg! { {} } -/// Pick an item at pseudo-random from the slice, given the `rng`. `None` iff the slice is empty. -fn pick_item<'a, R: RngCore, T>(rng: &mut R, items: &'a [T]) -> Option<&'a T> { - if items.is_empty() { - None - } else { - Some(&items[pick_usize(rng, items.len() - 1)]) +struct InputFromRng<'a, T>(&'a mut T); +impl<'a, T: RngCore> codec::Input for InputFromRng<'a, T> { + fn remaining_len(&mut self) -> Result, codec::Error> { + return Ok(None) + } + + fn read(&mut self, into: &mut [u8]) -> Result<(), codec::Error> { + self.0.fill_bytes(into); + Ok(()) } } -/// Pick a new PRN, in the range [0, `max`] (inclusive). -fn pick_usize(rng: &mut R, max: usize) -> usize { - (rng.next_u32() % (max as u32 + 1)) as usize +pub enum Period { + Voting { elapsed: BlockNumber, more: BlockNumber }, + Claim { elapsed: BlockNumber, more: BlockNumber }, } impl, I: 'static> Pallet { - /// Puts a bid into storage ordered by smallest to largest value. - /// Allows a maximum of 1000 bids in queue, removing largest value people first. - fn put_bid( - mut bids: Vec>>, - who: &T::AccountId, - value: BalanceOf, - bid_kind: BidKind>, - ) { - const MAX_BID_COUNT: usize = 1000; - - match bids.binary_search_by(|bid| bid.value.cmp(&value)) { - // Insert new elements after the existing ones. This ensures new bids - // with the same bid value are further down the list than existing ones. - Ok(pos) => { - let different_bid = bids - .iter() - // Easily extract the index we are on - .enumerate() - // Skip ahead to the suggested position - .skip(pos) - // Keep skipping ahead until the position changes - // Get the element when things changed - .find(|(_, x)| x.value > bids[pos].value); - - // If the element is not at the end of the list, insert the new element - // in the spot. - if let Some((p, _)) = different_bid { - bids.insert(p, Bid { value, who: who.clone(), kind: bid_kind }); - // If the element is at the end of the list, push the element on the end. + /// Get the period we are currently in. + fn period() -> Period { + let claim_period = T::ClaimPeriod::get(); + let voting_period = T::VotingPeriod::get(); + let rotation_period = voting_period + claim_period; + let now = frame_system::Pallet::::block_number(); + let phase = now % rotation_period; + if phase < voting_period { + Period::Voting { elapsed: phase, more: voting_period - phase } + } else { + Period::Claim { elapsed: phase - voting_period, more: rotation_period - phase } + } + } + + /// Returns true if the given `target_round` is still in its initial voting phase. + fn in_progress(target_round: RoundIndex) -> bool { + let round = RoundCount::::get(); + target_round == round && matches!(Self::period(), Period::Voting { .. }) + } + + /// Returns the new vote. + fn do_vote(maybe_old: Option, approve: bool, rank: Rank, tally: &mut Tally) -> Vote { + match maybe_old { + Some(Vote { approve: true, weight }) => tally.approvals.saturating_reduce(weight), + Some(Vote { approve: false, weight }) => tally.rejections.saturating_reduce(weight), + _ => {}, + } + let weight_root = rank + 1; + let weight = weight_root * weight_root; + match approve { + true => tally.approvals.saturating_accrue(1), + false => tally.rejections.saturating_accrue(1), + } + Vote { approve, weight } + } + + /// Returns `true` if a punishment was given. + fn check_skeptic( + candidate: &T::AccountId, + candidacy: &mut Candidacy>, + ) -> bool { + if RoundCount::::get() != candidacy.round || candidacy.skeptic_struck { + return false + } + // We expect the skeptic to have voted. + let skeptic = match Skeptic::::get() { + Some(s) => s, + None => return false, + }; + let maybe_vote = Votes::::get(&candidate, &skeptic); + let approved = candidacy.tally.clear_approval(); + let rejected = candidacy.tally.clear_rejection(); + match (maybe_vote, approved, rejected) { + (None, _, _) | + (Some(Vote { approve: true, .. }), false, true) | + (Some(Vote { approve: false, .. }), true, false) => { + // Can't do much if the punishment doesn't work out. + if Self::strike_member(&skeptic).is_ok() { + candidacy.skeptic_struck = true; + true } else { - bids.push(Bid { value, who: who.clone(), kind: bid_kind }); + false } }, - Err(pos) => bids.insert(pos, Bid { value, who: who.clone(), kind: bid_kind }), + _ => false, } - // Keep it reasonably small. - if bids.len() > MAX_BID_COUNT { - let Bid { who: popped, kind, .. } = bids.pop().expect("b.len() > 1000; qed"); - match kind { - BidKind::Deposit(deposit) => { - let err_amount = T::Currency::unreserve(&popped, deposit); - debug_assert!(err_amount.is_zero()); - }, - BidKind::Vouch(voucher, _) => { - >::remove(&voucher); + } + + /// End the current challenge period and start a new one. + fn rotate_challenge(rng: &mut impl RngCore) { + let mut next_defender = None; + let mut round = ChallengeRoundCount::::get(); + + // End current defender rotation + if let Some((defender, skeptic, tally)) = Defending::::get() { + // We require strictly more approvals, since the member should be voting for themselves. + if !tally.more_approvals() { + // Member has failed the challenge: Suspend them. This will fail if they are Head + // or Founder, in which case we ignore. + let _ = Self::suspend_member(&defender); + } + + // Check defender skeptic voted and that their vote was with the majority. + let skeptic_vote = DefenderVotes::::get(round, &skeptic); + match (skeptic_vote, tally.more_approvals(), tally.more_rejections()) { + (None, _, _) | + (Some(Vote { approve: true, .. }), false, true) | + (Some(Vote { approve: false, .. }), true, false) => { + // Punish skeptic and challenge them next. + let _ = Self::strike_member(&skeptic); + let founder = Founder::::get(); + let head = Head::::get(); + if Some(&skeptic) != founder.as_ref() && Some(&skeptic) != head.as_ref() { + next_defender = Some(skeptic); + } }, + _ => {}, } - Self::deposit_event(Event::::AutoUnbid { candidate: popped }); + round.saturating_inc(); + ChallengeRoundCount::::put(round); } - >::put(bids); + // Avoid challenging if there's only two members since we never challenge the Head or + // the Founder. + if MemberCount::::get() > 2 { + let defender = next_defender + .or_else(|| Self::pick_defendent(rng)) + .expect("exited if members empty; qed"); + let skeptic = + Self::pick_member_except(rng, &defender).expect("exited if members empty; qed"); + Self::deposit_event(Event::::Challenged { member: defender.clone() }); + Defending::::put((defender, skeptic, Tally::default())); + } else { + Defending::::kill(); + } } - /// Check a user is a bid. - fn is_bid(bids: &Vec>>, who: &T::AccountId) -> bool { - // Bids are ordered by `value`, so we cannot binary search for a user. - bids.iter().any(|bid| bid.who == *who) + /// End the current intake period and begin a new one. + /// + /// --------------------------------------------- + /// #10 || #11 _ || #12 + /// || Voting | Claiming || + /// --------------------------------------------- + fn rotate_intake(rng: &mut impl RngCore) { + // We assume there's at least one member or this logic won't work. + let member_count = MemberCount::::get(); + if member_count < 1 { + return + } + let maybe_head = NextHead::::take(); + if let Some(head) = maybe_head { + Head::::put(&head.who); + } + + // Bump the pot by at most `PeriodSpend`, but less if there's not very much left in our + // account. + let mut pot = Pot::::get(); + let unaccounted = T::Currency::free_balance(&Self::account_id()).saturating_sub(pot); + pot.saturating_accrue(T::PeriodSpend::get().min(unaccounted / 2u8.into())); + Pot::::put(&pot); + + // Bump round and create the new intake. + let mut round_count = RoundCount::::get(); + round_count.saturating_inc(); + let candidate_count = Self::select_new_candidates(round_count, member_count, pot); + if candidate_count > 0 { + // Select a member at random and make them the skeptic for this round. + let skeptic = Self::pick_member(rng).expect("exited if members empty; qed"); + Skeptic::::put(skeptic); + } + RoundCount::::put(round_count); } - /// Check a user is a candidate. - fn is_candidate( - candidates: &Vec>>, - who: &T::AccountId, - ) -> bool { - // Looking up a candidate is the same as looking up a bid - Self::is_bid(candidates, who) + /// Remove a selection of bidding accounts such that the total bids is no greater than `Pot` and + /// the number of bids would not surpass `MaxMembers` if all were accepted. At most one bid may + /// be zero. + /// + /// Candidates are inserted from each bidder. + /// + /// The number of candidates inserted are returned. + pub fn select_new_candidates( + round: RoundIndex, + member_count: u32, + pot: BalanceOf, + ) -> u32 { + // Get the number of left-most bidders whose bids add up to less than `pot`. + let mut bids = Bids::::get(); + let params = match Parameters::::get() { + Some(params) => params, + None => return 0, + }; + let max_selections: u32 = params + .max_intake + .min(params.max_members.saturating_sub(member_count)) + .min(bids.len() as u32); + + let mut selections = 0; + // A running total of the cost to onboard these bids + let mut total_cost: BalanceOf = Zero::zero(); + + bids.retain(|bid| { + // We only accept a zero bid as the first selection. + total_cost.saturating_accrue(bid.value); + let accept = selections < max_selections && + (!bid.value.is_zero() || selections == 0) && + total_cost <= pot; + if accept { + let candidacy = Candidacy { + round, + kind: bid.kind.clone(), + bid: bid.value, + tally: Default::default(), + skeptic_struck: false, + }; + Candidates::::insert(&bid.who, candidacy); + selections.saturating_inc(); + } + !accept + }); + + // No need to reset Bids if we're not taking anything. + Bids::::put(&bids); + selections } - /// Check a user is a member. - fn is_member(members: &Vec, who: &T::AccountId) -> bool { - members.binary_search(who).is_ok() + /// Puts a bid into storage ordered by smallest to largest value. + /// Allows a maximum of 1000 bids in queue, removing largest value people first. + fn insert_bid( + bids: &mut BoundedVec>, T::MaxBids>, + who: &T::AccountId, + value: BalanceOf, + bid_kind: BidKind>, + ) { + let pos = bids.iter().position(|bid| bid.value > value).unwrap_or(bids.len()); + let r = bids.force_insert_keep_left(pos, Bid { value, who: who.clone(), kind: bid_kind }); + let maybe_discarded = match r { + Ok(x) => x, + Err(x) => Some(x), + }; + if let Some(discarded) = maybe_discarded { + Self::clean_bid(&discarded); + Self::deposit_event(Event::::AutoUnbid { candidate: discarded.who }); + } } - /// Add a member to the sorted members list. If the user is already a member, do nothing. - /// Can fail when `MaxMember` limit is reached, but has no side-effects. - fn add_member(who: &T::AccountId) -> DispatchResult { - let mut members = >::get(); - ensure!(members.len() < MaxMembers::::get() as usize, Error::::MaxMembers); - match members.binary_search(who) { - // Add the new member - Err(i) => { - members.insert(i, who.clone()); - T::MembershipChanged::change_members_sorted(&[who.clone()], &[], &members); - >::put(members); - Ok(()) + /// Either unreserve the deposit or free up the vouching member. + /// + /// In neither case can we do much if the action isn't completable, but there's + /// no reason that either should fail. + /// + /// WARNING: This alters the voucher item of `Members`. You must ensure that you do not + /// accidentally overwrite it with an older value after calling this. + fn clean_bid(bid: &Bid>) { + match &bid.kind { + BidKind::Deposit(deposit) => { + let err_amount = T::Currency::unreserve(&bid.who, *deposit); + debug_assert!(err_amount.is_zero()); + }, + BidKind::Vouch(voucher, _) => { + Members::::mutate_extant(voucher, |record| record.vouching = None); }, - // User is already a member, do nothing. - Ok(_) => Ok(()), } } - /// Remove a member from the members list, except the Head. + /// Either repatriate the deposit into the Society account or ban the vouching member. + /// + /// In neither case can we do much if the action isn't completable, but there's + /// no reason that either should fail. /// - /// NOTE: This does not correctly clean up a member from storage. It simply - /// removes them from the Members storage item. - pub fn remove_member(m: &T::AccountId) -> DispatchResult { - ensure!(Self::head() != Some(m.clone()), Error::::Head); - ensure!(Self::founder() != Some(m.clone()), Error::::Founder); - - let mut members = >::get(); - match members.binary_search(m) { - Err(_) => Err(Error::::NotMember.into()), - Ok(i) => { - members.remove(i); - T::MembershipChanged::change_members_sorted(&[], &[m.clone()], &members[..]); - >::put(members); - Ok(()) + /// WARNING: This alters the voucher item of `Members`. You must ensure that you do not + /// accidentally overwrite it with an older value after calling this. + fn reject_candidate(who: &T::AccountId, kind: &BidKind>) { + match kind { + BidKind::Deposit(deposit) => { + let pot = Self::account_id(); + let free = BalanceStatus::Free; + let r = T::Currency::repatriate_reserved(&who, &pot, *deposit, free); + debug_assert!(r.is_ok()); + }, + BidKind::Vouch(voucher, _) => { + Members::::mutate_extant(voucher, |record| { + record.vouching = Some(VouchingStatus::Banned) + }); }, } } - /// End the current period and begin a new one. - fn rotate_period(members: &mut Vec) { - let phrase = b"society_rotation"; - - let mut pot = >::get(); - - // we'll need a random seed here. - // TODO: deal with randomness freshness - // https://github.com/paritytech/substrate/issues/8312 - let (seed, _) = T::Randomness::random(phrase); - // seed needs to be guaranteed to be 32 bytes. - let seed = <[u8; 32]>::decode(&mut TrailingZeroInput::new(seed.as_ref())) - .expect("input is padded with zeroes; qed"); - let mut rng = ChaChaRng::from_seed(seed); - - // we assume there's at least one member or this logic won't work. - if !members.is_empty() { - let candidates = >::take(); - // NOTE: This may cause member length to surpass `MaxMembers`, but results in no - // consensus critical issues or side-effects. This is auto-correcting as members fall - // out of society. - members.reserve(candidates.len()); - - let maturity = >::block_number() + - Self::lock_duration(members.len() as u32); - - let mut rewardees = Vec::new(); - let mut total_approvals = 0; - let mut total_slash = >::zero(); - let mut total_payouts = >::zero(); - - let accepted = candidates - .into_iter() - .filter_map(|Bid { value, who: candidate, kind }| { - let mut approval_count = 0; - - // Creates a vector of (vote, member) for the given candidate - // and tallies total number of approve votes for that candidate. - let votes = members - .iter() - .filter_map(|m| >::take(&candidate, m).map(|v| (v, m))) - .inspect(|&(v, _)| { - if v == Vote::Approve { - approval_count += 1 - } - }) - .collect::>(); - - // Select one of the votes at random. - // Note that `Vote::Skeptical` and `Vote::Reject` both reject the candidate. - let is_accepted = - pick_item(&mut rng, &votes).map(|x| x.0) == Some(Vote::Approve); - - let matching_vote = if is_accepted { Vote::Approve } else { Vote::Reject }; - - let bad_vote = |m: &T::AccountId| { - // Voter voted wrong way (or was just a lazy skeptic) then reduce their - // payout and increase their strikes. after MaxStrikes then they go into - // suspension. - let amount = Self::slash_payout(m, T::WrongSideDeduction::get()); - - let strikes = >::mutate(m, |s| { - *s += 1; - *s - }); - if strikes >= T::MaxStrikes::get() { - Self::suspend_member(m); - } - amount - }; - - // Collect the voters who had a matching vote. - rewardees.extend( - votes - .into_iter() - .filter_map(|(v, m)| { - if v == matching_vote { - Some(m) - } else { - total_slash += bad_vote(m); - None - } - }) - .cloned(), - ); - - if is_accepted { - total_approvals += approval_count; - total_payouts += value; - members.push(candidate.clone()); - - Self::pay_accepted_candidate(&candidate, value, kind, maturity); - - // We track here the total_approvals so that every candidate has a unique - // range of numbers from 0 to `total_approvals` with length `approval_count` - // so each candidate is proportionally represented when selecting a - // "primary" below. - Some((candidate, total_approvals, value)) - } else { - // Suspend Candidate - >::insert(&candidate, (value, kind)); - Self::deposit_event(Event::::CandidateSuspended { candidate }); - None - } - }) - .collect::>(); - - // Clean up all votes. - #[allow(deprecated)] - >::remove_all(None); - - // Reward one of the voters who voted the right way. - if !total_slash.is_zero() { - if let Some(winner) = pick_item(&mut rng, &rewardees) { - // If we can't reward them, not much that can be done. - Self::bump_payout(winner, maturity, total_slash); - } else { - // Move the slashed amount back from payouts account to local treasury. - let res = T::Currency::transfer( - &Self::payouts(), - &Self::account_id(), - total_slash, - AllowDeath, - ); - debug_assert!(res.is_ok()); - } - } + /// Check a user has a bid. + fn has_bid(bids: &Vec>>, who: &T::AccountId) -> bool { + // Bids are ordered by `value`, so we cannot binary search for a user. + bids.iter().any(|bid| bid.who == *who) + } - // Fund the total payouts from the local treasury. - if !total_payouts.is_zero() { - // remove payout from pot and shift needed funds to the payout account. - pot = pot.saturating_sub(total_payouts); - - // this should never fail since we ensure we can afford the payouts in a previous - // block, but there's not much we can do to recover if it fails anyway. - let res = T::Currency::transfer( - &Self::account_id(), - &Self::payouts(), - total_payouts, - AllowDeath, - ); - debug_assert!(res.is_ok()); - } + /// Add a member to the sorted members list. If the user is already a member, do nothing. + /// Can fail when `MaxMember` limit is reached, but in that case it has no side-effects. + /// + /// Set the `payouts` for the member. NOTE: This *WILL NOT RESERVE THE FUNDS TO MAKE THE + /// PAYOUT*. Only set this to be non-empty if you already have the funds reserved in the Payouts + /// account. + /// + /// NOTE: Generally you should not use this, and instead use `add_new_member` or + /// `reinstate_member`, whose names clearly match the desired intention. + fn insert_member(who: &T::AccountId, rank: Rank) -> DispatchResult { + let params = Parameters::::get().ok_or(Error::::NotGroup)?; + ensure!(MemberCount::::get() < params.max_members, Error::::MaxMembers); + let index = MemberCount::::mutate(|i| { + i.saturating_accrue(1); + *i - 1 + }); + let record = MemberRecord { rank, strikes: 0, vouching: None, index }; + Members::::insert(who, record); + MemberByIndex::::insert(index, who); + Ok(()) + } - // if at least one candidate was accepted... - if !accepted.is_empty() { - // select one as primary, randomly chosen from the accepted, weighted by approvals. - // Choose a random number between 0 and `total_approvals` - let primary_point = pick_usize(&mut rng, total_approvals - 1); - // Find the zero bid or the user who falls on that point - let primary = accepted - .iter() - .find(|e| e.2.is_zero() || e.1 > primary_point) - .expect( - "e.1 of final item == total_approvals; \ - worst case find will always return that item; qed", - ) - .0 - .clone(); - - let accounts = accepted.into_iter().map(|x| x.0).collect::>(); - - // Then write everything back out, signal the changed membership and leave an event. - members.sort(); - // NOTE: This may cause member length to surpass `MaxMembers`, but results in no - // consensus critical issues or side-effects. This is auto-correcting as members - // fall out of society. - >::put(&members[..]); - >::put(&primary); - - T::MembershipChanged::change_members_sorted(&accounts, &[], members); - Self::deposit_event(Event::::Inducted { primary, candidates: accounts }); - } + /// Add a member back to the sorted members list, setting their `rank` and `payouts`. + /// + /// Can fail when `MaxMember` limit is reached, but in that case it has no side-effects. + /// + /// The `payouts` value must be exactly as it was prior to suspension since no further funds + /// will be reserved. + fn reinstate_member(who: &T::AccountId, rank: Rank) -> DispatchResult { + Self::insert_member(who, rank) + } + + /// Add a member to the sorted members list. If the user is already a member, do nothing. + /// Can fail when `MaxMember` limit is reached, but in that case it has no side-effects. + fn add_new_member(who: &T::AccountId, rank: Rank) -> DispatchResult { + Self::insert_member(who, rank) + } + + /// Induct a new member into the set. + fn induct_member( + candidate: T::AccountId, + mut candidacy: Candidacy>, + rank: Rank, + ) -> DispatchResult { + Self::add_new_member(&candidate, rank)?; + Self::check_skeptic(&candidate, &mut candidacy); + + let next_head = NextHead::::get() + .filter(|old| { + old.round > candidacy.round || + old.round == candidacy.round && old.bid < candidacy.bid + }) + .unwrap_or_else(|| IntakeRecord { + who: candidate.clone(), + bid: candidacy.bid, + round: candidacy.round, + }); + NextHead::::put(next_head); + + let now = >::block_number(); + let maturity = now + Self::lock_duration(MemberCount::::get()); + Self::reward_bidder(&candidate, candidacy.bid, candidacy.kind, maturity); - // Bump the pot by at most PeriodSpend, but less if there's not very much left in our - // account. - let unaccounted = T::Currency::free_balance(&Self::account_id()).saturating_sub(pot); - pot += T::PeriodSpend::get().min(unaccounted / 2u8.into()); + Candidates::::remove(&candidate); + Ok(()) + } - >::put(&pot); + fn strike_member(who: &T::AccountId) -> DispatchResult { + let mut record = Members::::get(who).ok_or(Error::::NotMember)?; + record.strikes.saturating_inc(); + Members::::insert(who, &record); + // ^^^ Keep the member record mutation self-contained as we might be suspending them later + // in this function. + + if record.strikes >= T::GraceStrikes::get() { + // Too many strikes: slash the payout in half. + let total_payout = Payouts::::get(who) + .payouts + .iter() + .fold(BalanceOf::::zero(), |acc, x| acc.saturating_add(x.1)); + Self::slash_payout(who, total_payout / 2u32.into()); } - // Setup the candidates for the new intake - let candidates = Self::take_selected(members.len(), pot); - >::put(&candidates); - - // Select sqrt(n) random members from the society and make them skeptics. - let pick_member = - |_| pick_item(&mut rng, &members[..]).expect("exited if members empty; qed").clone(); - let skeptics = (0..members.len().integer_sqrt()).map(pick_member).collect::>(); - skeptics.iter().for_each(|skeptic| { - for Bid { who: c, .. } in candidates.iter() { - >::insert(c, skeptic, Vote::Skeptic); - } - }); - Self::deposit_event(Event::::SkepticsChosen { skeptics }); + let params = Parameters::::get().ok_or(Error::::NotGroup)?; + if record.strikes >= params.max_strikes { + // Way too many strikes: suspend. + let _ = Self::suspend_member(who); + } + Ok(()) } - /// Attempt to slash the payout of some member. Return the total amount that was deducted. - fn slash_payout(who: &T::AccountId, value: BalanceOf) -> BalanceOf { - let mut rest = value; - let mut payouts = >::get(who); - if !payouts.is_empty() { - let mut dropped = 0; - for (_, amount) in payouts.iter_mut() { - if let Some(new_rest) = rest.checked_sub(amount) { - // not yet totally slashed after this one; drop it completely. - rest = new_rest; - dropped += 1; + /// Remove a member from the members list and return the candidacy. + /// + /// If the member was vouching, then this will be reset. Any bidders that the member was + /// vouching for will be cancelled unless they are already selected as candidates (in which case + /// they will be able to stand). + /// + /// If the member has existing payouts, they will be retained in the resultant `MemberRecord` + /// and the funds will remain reserved. + /// + /// The Head and the Founder may never be removed. + pub fn remove_member(m: &T::AccountId) -> Result { + ensure!(Head::::get().as_ref() != Some(m), Error::::Head); + ensure!(Founder::::get().as_ref() != Some(m), Error::::Founder); + if let Some(mut record) = Members::::get(m) { + let index = record.index; + let last_index = MemberCount::::mutate(|i| { + i.saturating_reduce(1); + *i + }); + if index != last_index { + // Move the member with the last index down to the index of the member to be + // removed. + if let Some(other) = MemberByIndex::::get(last_index) { + MemberByIndex::::insert(index, &other); + Members::::mutate(other, |m_r| { + if let Some(r) = m_r { + r.index = index + } + }); } else { - // whole slash is accounted for. - *amount -= rest; - rest = Zero::zero(); - break + debug_assert!(false, "ERROR: No member at the last index position?"); } } - >::insert(who, &payouts[dropped..]); + + MemberByIndex::::remove(last_index); + Members::::remove(m); + // Remove their vouching status, potentially unbanning them in the future. + if record.vouching.take() == Some(VouchingStatus::Vouching) { + // Try to remove their bid if they are vouching. + // If their vouch is already a candidate, do nothing. + Bids::::mutate(|bids| + // Try to find the matching bid + if let Some(pos) = bids.iter().position(|b| b.kind.is_vouch(&m)) { + // Remove the bid, and emit an event + let vouched = bids.remove(pos).who; + Self::deposit_event(Event::::Unvouch { candidate: vouched }); + } + ); + } + Ok(record) + } else { + Err(Error::::NotMember.into()) } - value - rest } - /// Bump the payout amount of `who`, to be unlocked at the given block number. - fn bump_payout(who: &T::AccountId, when: T::BlockNumber, value: BalanceOf) { - if !value.is_zero() { - >::mutate(who, |payouts| { - match payouts.binary_search_by_key(&when, |x| x.0) { - Ok(index) => payouts[index].1 += value, - Err(index) => payouts.insert(index, (when, value)), - } - }); + /// Remove a member from the members set and add them to the suspended members. + /// + /// If the member was vouching, then this will be reset. Any bidders that the member was + /// vouching for will be cancelled unless they are already selected as candidates (in which case + /// they will be able to stand). + fn suspend_member(who: &T::AccountId) -> DispatchResult { + let record = Self::remove_member(&who)?; + SuspendedMembers::::insert(who, record); + Self::deposit_event(Event::::MemberSuspended { member: who.clone() }); + Ok(()) + } + + /// Select a member at random, given the RNG `rng`. + /// + /// If no members exist (or the state is inconsistent), then `None` may be returned. + fn pick_member(rng: &mut impl RngCore) -> Option { + let member_count = MemberCount::::get(); + if member_count == 0 { + return None } + let random_index = rng.next_u32() % member_count; + MemberByIndex::::get(random_index) } - /// Suspend a user, removing them from the member list. - fn suspend_member(who: &T::AccountId) { - if Self::remove_member(who).is_ok() { - >::insert(who, true); - >::remove(who); - Self::deposit_event(Event::::MemberSuspended { member: who.clone() }); + /// Select a member at random except `exception`, given the RNG `rng`. + /// + /// If `exception` is the only member (or the state is inconsistent), then `None` may be + /// returned. + fn pick_member_except( + rng: &mut impl RngCore, + exception: &T::AccountId, + ) -> Option { + let member_count = MemberCount::::get(); + if member_count <= 1 { + return None + } + let random_index = rng.next_u32() % (member_count - 1); + let pick = MemberByIndex::::get(random_index); + if pick.as_ref() == Some(exception) { + MemberByIndex::::get(member_count - 1) + } else { + pick + } + } + + /// Select a member who is able to defend at random, given the RNG `rng`. + /// + /// If only the Founder and Head members exist (or the state is inconsistent), then `None` + /// may be returned. + fn pick_defendent(rng: &mut impl RngCore) -> Option { + let member_count = MemberCount::::get(); + if member_count <= 2 { + return None + } + // Founder is always at index 0, so we should never pick that one. + // Head will typically but not always be the highest index. We assume it is for now and + // fix it up later if not. + let head = Head::::get(); + let pickable_count = member_count - if head.is_some() { 2 } else { 1 }; + let random_index = rng.next_u32() % pickable_count + 1; + let pick = MemberByIndex::::get(random_index); + if pick == head && head.is_some() { + // Turns out that head was not the last index since we managed to pick it. Exchange our + // pick for the last index. + MemberByIndex::::get(member_count - 1) + } else { + pick } } /// Pay an accepted candidate their bid value. - fn pay_accepted_candidate( + fn reward_bidder( candidate: &T::AccountId, value: BalanceOf, kind: BidKind>, @@ -1574,11 +1915,17 @@ impl, I: 'static> Pallet { BidKind::Vouch(voucher, tip) => { // Check that the voucher is still vouching, else some other logic may have removed // their status. - if >::take(&voucher) == Some(VouchingStatus::Vouching) { - // In the case that a vouched-for bid is accepted we unset the - // vouching status and transfer the tip over to the voucher. - Self::bump_payout(&voucher, maturity, tip.min(value)); - value.saturating_sub(tip) + if let Some(mut record) = Members::::get(&voucher) { + if let Some(VouchingStatus::Vouching) = record.vouching { + // In the case that a vouched-for bid is accepted we unset the + // vouching status and transfer the tip over to the voucher. + record.vouching = None; + Self::bump_payout(&voucher, maturity, tip.min(value)); + Members::::insert(&voucher, record); + value.saturating_sub(tip) + } else { + value + } } else { value } @@ -1588,52 +1935,71 @@ impl, I: 'static> Pallet { Self::bump_payout(candidate, maturity, value); } - /// End the current challenge period and start a new one. - fn rotate_challenge(members: &mut Vec) { - // Assume there are members, else don't run this logic. - if !members.is_empty() { - // End current defender rotation - if let Some(defender) = Self::defender() { - let mut approval_count = 0; - let mut rejection_count = 0; - // Tallies total number of approve and reject votes for the defender. - members.iter().filter_map(>::take).for_each(|v| match v { - Vote::Approve => approval_count += 1, - _ => rejection_count += 1, - }); - - if approval_count <= rejection_count { - // User has failed the challenge - Self::suspend_member(&defender); - *members = Self::members(); + /// Bump the payout amount of `who`, to be unlocked at the given block number. + /// + /// It is the caller's duty to ensure that `who` is already a member. This does nothing if `who` + /// is not a member or if `value` is zero. + fn bump_payout(who: &T::AccountId, when: T::BlockNumber, value: BalanceOf) { + if value.is_zero() { + return + } + if let Some(MemberRecord { rank: 0, .. }) = Members::::get(who) { + Payouts::::mutate(who, |record| { + // Members of rank 1 never get payouts. + match record.payouts.binary_search_by_key(&when, |x| x.0) { + Ok(index) => record.payouts[index].1.saturating_accrue(value), + Err(index) => { + // If they have too many pending payouts, then we take discard the payment. + let _ = record.payouts.try_insert(index, (when, value)); + }, } + }); + Self::reserve_payout(value); + } + } - // Clean up all votes. - #[allow(deprecated)] - >::remove_all(None); - } - - // Avoid challenging if there's only two members since we never challenge the Head or - // the Founder. - if members.len() > 2 { - // Start a new defender rotation - let phrase = b"society_challenge"; - // we'll need a random seed here. - // TODO: deal with randomness freshness - // https://github.com/paritytech/substrate/issues/8312 - let (seed, _) = T::Randomness::random(phrase); - // seed needs to be guaranteed to be 32 bytes. - let seed = <[u8; 32]>::decode(&mut TrailingZeroInput::new(seed.as_ref())) - .expect("input is padded with zeroes; qed"); - let mut rng = ChaChaRng::from_seed(seed); - let chosen = pick_item(&mut rng, &members[1..members.len() - 1]) - .expect("exited if members empty; qed"); - >::put(&chosen); - Self::deposit_event(Event::::Challenged { member: chosen.clone() }); + /// Attempt to slash the payout of some member. Return the total amount that was deducted. + fn slash_payout(who: &T::AccountId, value: BalanceOf) -> BalanceOf { + let mut record = Payouts::::get(who); + let mut rest = value; + while !record.payouts.is_empty() { + if let Some(new_rest) = rest.checked_sub(&record.payouts[0].1) { + // not yet totally slashed after this one; drop it completely. + rest = new_rest; + record.payouts.remove(0); } else { - >::kill(); + // whole slash is accounted for. + record.payouts[0].1.saturating_reduce(rest); + rest = Zero::zero(); + break } } + Payouts::::insert(who, record); + value - rest + } + + /// Transfer some `amount` from the main account into the payouts account and reduce the Pot + /// by this amount. + fn reserve_payout(amount: BalanceOf) { + // Tramsfer payout from the Pot into the payouts account. + Pot::::mutate(|pot| pot.saturating_reduce(amount)); + + // this should never fail since we ensure we can afford the payouts in a previous + // block, but there's not much we can do to recover if it fails anyway. + let res = T::Currency::transfer(&Self::account_id(), &Self::payouts(), amount, AllowDeath); + debug_assert!(res.is_ok()); + } + + /// Transfer some `amount` from the main account into the payouts account and increase the Pot + /// by this amount. + fn unreserve_payout(amount: BalanceOf) { + // Tramsfer payout from the Pot into the payouts account. + Pot::::mutate(|pot| pot.saturating_accrue(amount)); + + // this should never fail since we ensure we can afford the payouts in a previous + // block, but there's not much we can do to recover if it fails anyway. + let res = T::Currency::transfer(&Self::payouts(), &Self::account_id(), amount, AllowDeath); + debug_assert!(res.is_ok()); } /// The account ID of the treasury pot. @@ -1660,70 +2026,6 @@ impl, I: 'static> Pallet { let lock_pc = 100 - 50_000 / (x + 500); Percent::from_percent(lock_pc as u8) * T::MaxLockDuration::get() } - - /// Get a selection of bidding accounts such that the total bids is no greater than `Pot` and - /// the number of bids would not surpass `MaxMembers` if all were accepted. - /// - /// May be empty. - pub fn take_selected( - members_len: usize, - pot: BalanceOf, - ) -> Vec>> { - let max_members = MaxMembers::::get() as usize; - let mut max_selections: usize = - (T::MaxCandidateIntake::get() as usize).min(max_members.saturating_sub(members_len)); - - if max_selections > 0 { - // Get the number of left-most bidders whose bids add up to less than `pot`. - let mut bids = >::get(); - - // The list of selected candidates - let mut selected = Vec::new(); - - if bids.len() > 0 { - // Can only select at most the length of bids - max_selections = max_selections.min(bids.len()); - // Number of selected bids so far - let mut count = 0; - // Check if we have already selected a candidate with zero bid - let mut zero_selected = false; - // A running total of the cost to onboard these bids - let mut total_cost: BalanceOf = Zero::zero(); - - bids.retain(|bid| { - if count < max_selections { - // Handle zero bids. We only want one of them. - if bid.value.is_zero() { - // Select only the first zero bid - if !zero_selected { - selected.push(bid.clone()); - zero_selected = true; - count += 1; - return false - } - } else { - total_cost += bid.value; - // Select only as many users as the pot can support. - if total_cost <= pot { - selected.push(bid.clone()); - count += 1; - return false - } - } - } - true - }); - - // No need to reset Bids if we're not taking anything. - if count > 0 { - >::put(bids); - } - } - selected - } else { - vec![] - } - } } impl, I: 'static> OnUnbalanced> for Pallet { diff --git a/frame/society/src/migrations.rs b/frame/society/src/migrations.rs new file mode 100644 index 0000000000000..bd590f9b18770 --- /dev/null +++ b/frame/society/src/migrations.rs @@ -0,0 +1,329 @@ +// This file is part of Substrate. + +// Copyright (C) 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. + +//! # Migrations for Society Pallet + +use super::*; +use codec::{Decode, Encode}; +use frame_support::traits::{Instance, OnRuntimeUpgrade}; + +#[cfg(feature = "try-runtime")] +use sp_runtime::TryRuntimeError; + +/// The log target. +const TARGET: &'static str = "runtime::society::migration"; + +/// This migration moves all the state to v2 of Society. +pub struct MigrateToV2, I: 'static, PastPayouts>( + sp_std::marker::PhantomData<(T, I, PastPayouts)>, +); + +impl< + T: Config, + I: Instance + 'static, + PastPayouts: Get::AccountId, BalanceOf)>>, + > OnRuntimeUpgrade for MigrateToV2 +{ + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, TryRuntimeError> { + ensure!(can_migrate::(), "pallet_society: already upgraded"); + + let current = Pallet::::current_storage_version(); + let onchain = Pallet::::on_chain_storage_version(); + ensure!(onchain == 0 && current == 2, "pallet_society: invalid version"); + + Ok((old::Candidates::::get(), old::Members::::get()).encode()) + } + + fn on_runtime_upgrade() -> Weight { + let current = Pallet::::current_storage_version(); + let onchain = Pallet::::on_chain_storage_version(); + if current == 2 && onchain == 0 { + from_original::(&mut PastPayouts::get()) + } else { + log::info!( + "Running migration with current storage version {:?} / onchain {:?}", + current, + onchain + ); + T::DbWeight::get().reads(1) + } + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(data: Vec) -> Result<(), TryRuntimeError> { + let old: ( + Vec::AccountId, BalanceOf>>, + Vec<::AccountId>, + ) = Decode::decode(&mut &data[..]).expect("Bad data"); + let mut old_candidates = + old.0.into_iter().map(|x| (x.who, x.kind, x.value)).collect::>(); + let mut old_members = old.1; + let mut candidates = + Candidates::::iter().map(|(k, v)| (k, v.kind, v.bid)).collect::>(); + let mut members = Members::::iter_keys().collect::>(); + + old_candidates.sort_by_key(|x| x.0.clone()); + candidates.sort_by_key(|x| x.0.clone()); + assert_eq!(candidates, old_candidates); + + members.sort(); + old_members.sort(); + assert_eq!(members, old_members); + + ensure!( + Pallet::::on_chain_storage_version() == 2, + "The onchain version must be updated after the migration." + ); + + assert_internal_consistency::(); + Ok(()) + } +} + +pub(crate) mod old { + use super::*; + use frame_support::storage_alias; + + /// A vote by a member on a candidate application. + #[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo)] + pub enum Vote { + /// The member has been chosen to be skeptic and has not yet taken any action. + Skeptic, + /// The member has rejected the candidate's application. + Reject, + /// The member approves of the candidate's application. + Approve, + } + + #[storage_alias] + pub type Bids, I: 'static> = StorageValue< + Pallet, + Vec::AccountId, BalanceOf>>, + ValueQuery, + >; + #[storage_alias] + pub type Candidates, I: 'static> = StorageValue< + Pallet, + Vec::AccountId, BalanceOf>>, + ValueQuery, + >; + #[storage_alias] + pub type Votes, I: 'static> = StorageDoubleMap< + Pallet, + Twox64Concat, + ::AccountId, + Twox64Concat, + ::AccountId, + Vote, + >; + #[storage_alias] + pub type SuspendedCandidates, I: 'static> = StorageMap< + Pallet, + Twox64Concat, + ::AccountId, + (BalanceOf, BidKind<::AccountId, BalanceOf>), + >; + #[storage_alias] + pub type Members, I: 'static> = + StorageValue, Vec<::AccountId>, ValueQuery>; + #[storage_alias] + pub type Vouching, I: 'static> = StorageMap< + Pallet, + Twox64Concat, + ::AccountId, + VouchingStatus, + >; + #[storage_alias] + pub type Strikes, I: 'static> = StorageMap< + Pallet, + Twox64Concat, + ::AccountId, + StrikeCount, + ValueQuery, + >; + #[storage_alias] + pub type Payouts, I: 'static> = StorageMap< + Pallet, + Twox64Concat, + ::AccountId, + Vec<(::BlockNumber, BalanceOf)>, + ValueQuery, + >; + #[storage_alias] + pub type SuspendedMembers, I: 'static> = StorageMap< + Pallet, + Twox64Concat, + ::AccountId, + bool, + ValueQuery, + >; + #[storage_alias] + pub type Defender, I: 'static> = + StorageValue, ::AccountId>; + #[storage_alias] + pub type DefenderVotes, I: 'static> = + StorageMap, Twox64Concat, ::AccountId, Vote>; +} + +pub fn can_migrate, I: Instance + 'static>() -> bool { + old::Members::::exists() +} + +/// Will panic if there are any inconsistencies in the pallet's state or old keys remaining. +pub fn assert_internal_consistency, I: Instance + 'static>() { + // Check all members are valid data. + let mut members = vec![]; + for m in Members::::iter_keys() { + let r = Members::::get(&m).expect("Member data must be valid"); + members.push((m, r)); + } + assert_eq!(MemberCount::::get(), members.len() as u32); + for (who, record) in members.iter() { + assert_eq!(MemberByIndex::::get(record.index).as_ref(), Some(who)); + } + if let Some(founder) = Founder::::get() { + assert_eq!(Members::::get(founder).expect("founder is member").index, 0); + } + if let Some(head) = Head::::get() { + assert!(Members::::contains_key(head)); + } + // Check all votes are valid data. + for (k1, k2) in Votes::::iter_keys() { + assert!(Votes::::get(k1, k2).is_some()); + } + // Check all defender votes are valid data. + for (k1, k2) in DefenderVotes::::iter_keys() { + assert!(DefenderVotes::::get(k1, k2).is_some()); + } + // Check all candidates are valid data. + for k in Candidates::::iter_keys() { + assert!(Candidates::::get(k).is_some()); + } + // Check all suspended members are valid data. + for m in SuspendedMembers::::iter_keys() { + assert!(SuspendedMembers::::get(m).is_some()); + } + // Check all payouts are valid data. + for p in Payouts::::iter_keys() { + let k = Payouts::::hashed_key_for(&p); + let v = frame_support::storage::unhashed::get_raw(&k[..]).expect("value is in map"); + assert!(PayoutRecordFor::::decode(&mut &v[..]).is_ok()); + } + + // We don't use these - make sure they don't exist. + assert_eq!(old::SuspendedCandidates::::iter().count(), 0); + assert_eq!(old::Strikes::::iter().count(), 0); + assert_eq!(old::Vouching::::iter().count(), 0); + assert!(!old::Defender::::exists()); + assert!(!old::Members::::exists()); +} + +pub fn from_original, I: Instance + 'static>( + past_payouts: &mut [(::AccountId, BalanceOf)], +) -> Weight { + // First check that this is the original state layout. This is easy since the original layout + // contained the Members value, and this value no longer exists in the new layout. + if !old::Members::::exists() { + log::warn!(target: TARGET, "Skipping MigrateToV2 migration since it appears unapplicable"); + // Already migrated or no data to migrate: Bail. + return T::DbWeight::get().reads(1) + } + + // Migrate Bids from old::Bids (just a trunctation). + Bids::::put(BoundedVec::<_, T::MaxBids>::truncate_from(old::Bids::::take())); + + // Initialise round counter. + RoundCount::::put(0); + + // Migrate Candidates from old::Candidates + for Bid { who: candidate, kind, value } in old::Candidates::::take().into_iter() { + let mut tally = Tally::default(); + // Migrate Votes from old::Votes + // No need to drain, since we're overwriting values. + for (voter, vote) in old::Votes::::iter_prefix(&candidate) { + Votes::::insert( + &candidate, + &voter, + Vote { approve: vote == old::Vote::Approve, weight: 1 }, + ); + match vote { + old::Vote::Approve => tally.approvals.saturating_inc(), + old::Vote::Reject => tally.rejections.saturating_inc(), + old::Vote::Skeptic => Skeptic::::put(&voter), + } + } + Candidates::::insert( + &candidate, + Candidacy { round: 0, kind, tally, skeptic_struck: false, bid: value }, + ); + } + + // Migrate Members from old::Members old::Strikes old::Vouching + let mut member_count = 0; + for member in old::Members::::take() { + let strikes = old::Strikes::::take(&member); + let vouching = old::Vouching::::take(&member); + let record = MemberRecord { index: member_count, rank: 0, strikes, vouching }; + Members::::insert(&member, record); + MemberByIndex::::insert(member_count, &member); + member_count.saturating_inc(); + } + MemberCount::::put(member_count); + + // Migrate Payouts from: old::Payouts and raw info (needed since we can't query old chain + // state). + past_payouts.sort(); + for (who, mut payouts) in old::Payouts::::iter() { + payouts.truncate(T::MaxPayouts::get() as usize); + // ^^ Safe since we already truncated. + let paid = past_payouts + .binary_search_by_key(&&who, |x| &x.0) + .ok() + .map(|p| past_payouts[p].1) + .unwrap_or(Zero::zero()); + match BoundedVec::try_from(payouts) { + Ok(payouts) => Payouts::::insert(who, PayoutRecord { paid, payouts }), + Err(_) => debug_assert!(false, "Truncation of Payouts ineffective??"), + } + } + + // Migrate SuspendedMembers from old::SuspendedMembers old::Strikes old::Vouching. + for who in old::SuspendedMembers::::iter_keys() { + let strikes = old::Strikes::::take(&who); + let vouching = old::Vouching::::take(&who); + let record = MemberRecord { index: 0, rank: 0, strikes, vouching }; + SuspendedMembers::::insert(&who, record); + } + + // Any suspended candidates remaining are rejected. + let _ = old::SuspendedCandidates::::clear(u32::MAX, None); + + // We give the current defender the benefit of the doubt. + old::Defender::::kill(); + let _ = old::DefenderVotes::::clear(u32::MAX, None); + + T::BlockWeights::get().max_block +} + +pub fn from_raw_past_payouts, I: Instance + 'static>( + past_payouts_raw: impl Iterator, +) -> Vec<(::AccountId, BalanceOf)> { + past_payouts_raw + .filter_map(|(x, y)| Some((Decode::decode(&mut &x[..]).ok()?, y.try_into().ok()?))) + .collect() +} diff --git a/frame/society/src/mock.rs b/frame/society/src/mock.rs index 6075f14da1248..ed04aa181e161 100644 --- a/frame/society/src/mock.rs +++ b/frame/society/src/mock.rs @@ -21,7 +21,7 @@ use super::*; use crate as pallet_society; use frame_support::{ - ord_parameter_types, parameter_types, + assert_noop, assert_ok, ord_parameter_types, parameter_types, traits::{ConstU32, ConstU64}, }; use frame_support_test::TestRandomness; @@ -32,6 +32,8 @@ use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, }; +use RuntimeOrigin as Origin; + type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; type Block = frame_system::mocking::MockBlock; @@ -52,8 +54,12 @@ parameter_types! { } ord_parameter_types! { + pub const ChallengePeriod: u64 = 8; + pub const ClaimPeriod: u64 = 1; pub const FounderSetAccount: u128 = 1; pub const SuspensionJudgementSetAccount: u128 = 2; + pub const MaxPayouts: u32 = 10; + pub const MaxBids: u32 = 10; } impl frame_system::Config for Test { @@ -101,34 +107,31 @@ impl pallet_balances::Config for Test { impl Config for Test { type RuntimeEvent = RuntimeEvent; + type PalletId = SocietyPalletId; type Currency = pallet_balances::Pallet; type Randomness = TestRandomness; - type CandidateDeposit = ConstU64<25>; - type WrongSideDeduction = ConstU64<2>; - type MaxStrikes = ConstU32<2>; + type GraceStrikes = ConstU32<1>; type PeriodSpend = ConstU64<1000>; - type MembershipChanged = (); - type RotationPeriod = ConstU64<4>; + type VotingPeriod = ConstU64<3>; + type ClaimPeriod = ClaimPeriod; type MaxLockDuration = ConstU64<100>; type FounderSetOrigin = EnsureSignedBy; - type SuspensionJudgementOrigin = EnsureSignedBy; - type ChallengePeriod = ConstU64<8>; - type MaxCandidateIntake = ConstU32<10>; - type PalletId = SocietyPalletId; + type ChallengePeriod = ChallengePeriod; + type MaxPayouts = MaxPayouts; + type MaxBids = MaxBids; + type WeightInfo = (); } pub struct EnvBuilder { - members: Vec, balance: u64, balances: Vec<(u128, u64)>, pot: u64, - max_members: u32, + founded: bool, } impl EnvBuilder { pub fn new() -> Self { Self { - members: vec![10], balance: 10_000, balances: vec![ (10, 50), @@ -142,7 +145,7 @@ impl EnvBuilder { (90, 50), ], pot: 0, - max_members: 100, + founded: true, } } @@ -152,39 +155,22 @@ impl EnvBuilder { pallet_balances::GenesisConfig:: { balances: self.balances } .assimilate_storage(&mut t) .unwrap(); - pallet_society::GenesisConfig:: { - members: self.members, - pot: self.pot, - max_members: self.max_members, - } - .assimilate_storage(&mut t) - .unwrap(); + pallet_society::GenesisConfig:: { pot: self.pot } + .assimilate_storage(&mut t) + .unwrap(); let mut ext: sp_io::TestExternalities = t.into(); - ext.execute_with(f) - } - #[allow(dead_code)] - pub fn with_members(mut self, m: Vec) -> Self { - self.members = m; - self - } - #[allow(dead_code)] - pub fn with_balances(mut self, b: Vec<(u128, u64)>) -> Self { - self.balances = b; - self - } - #[allow(dead_code)] - pub fn with_pot(mut self, p: u64) -> Self { - self.pot = p; - self - } - #[allow(dead_code)] - pub fn with_balance(mut self, b: u64) -> Self { - self.balance = b; - self + ext.execute_with(|| { + if self.founded { + let r = b"be cool".to_vec(); + assert!(Society::found_society(Origin::signed(1), 10, 10, 8, 2, 25, r).is_ok()); + } + let r = f(); + migrations::assert_internal_consistency::(); + r + }) } - #[allow(dead_code)] - pub fn with_max_members(mut self, n: u32) -> Self { - self.max_members = n; + pub fn founded(mut self, f: bool) -> Self { + self.founded = f; self } } @@ -202,10 +188,121 @@ pub fn run_to_block(n: u64) { } /// Creates a bid struct using input parameters. -pub fn create_bid( - value: Balance, +pub fn bid( who: AccountId, kind: BidKind, + value: Balance, ) -> Bid { Bid { who, kind, value } } + +/// Creates a candidate struct using input parameters. +pub fn candidacy( + round: RoundIndex, + bid: Balance, + kind: BidKind, + approvals: VoteCount, + rejections: VoteCount, +) -> Candidacy { + Candidacy { round, kind, bid, tally: Tally { approvals, rejections }, skeptic_struck: false } +} + +pub fn next_challenge() { + let challenge_period: u64 = ::ChallengePeriod::get(); + let now = System::block_number(); + run_to_block(now + challenge_period - now % challenge_period); +} + +pub fn next_voting() { + if let Period::Voting { more, .. } = Society::period() { + run_to_block(System::block_number() + more); + } +} + +pub fn conclude_intake(allow_resignation: bool, judge_intake: Option) { + next_voting(); + let round = RoundCount::::get(); + for (who, candidacy) in Candidates::::iter() { + if candidacy.tally.clear_approval() { + assert_ok!(Society::claim_membership(Origin::signed(who))); + assert_noop!( + Society::claim_membership(Origin::signed(who)), + Error::::NotCandidate + ); + continue + } + if candidacy.tally.clear_rejection() && allow_resignation { + assert_noop!( + Society::claim_membership(Origin::signed(who)), + Error::::NotApproved + ); + assert_ok!(Society::resign_candidacy(Origin::signed(who))); + continue + } + if let (Some(founder), Some(approve)) = (Founder::::get(), judge_intake) { + if !candidacy.tally.clear_approval() && !approve { + // can be rejected by founder + assert_ok!(Society::kick_candidate(Origin::signed(founder), who)); + continue + } + if !candidacy.tally.clear_rejection() && approve { + // can be rejected by founder + assert_ok!(Society::bestow_membership(Origin::signed(founder), who)); + continue + } + } + if candidacy.tally.clear_rejection() && round > candidacy.round + 1 { + assert_noop!( + Society::claim_membership(Origin::signed(who)), + Error::::NotApproved + ); + assert_ok!(Society::drop_candidate(Origin::signed(0), who)); + assert_noop!( + Society::drop_candidate(Origin::signed(0), who), + Error::::NotCandidate + ); + continue + } + if !candidacy.skeptic_struck { + assert_ok!(Society::punish_skeptic(Origin::signed(who))); + } + } +} + +pub fn next_intake() { + let claim_period: u64 = ::ClaimPeriod::get(); + match Society::period() { + Period::Voting { more, .. } => run_to_block(System::block_number() + more + claim_period), + Period::Claim { more, .. } => run_to_block(System::block_number() + more), + } +} + +pub fn place_members(members: impl AsRef<[u128]>) { + for who in members.as_ref() { + assert_ok!(Society::insert_member(who, 0)); + } +} + +pub fn members() -> Vec { + let mut r = Members::::iter_keys().collect::>(); + r.sort(); + r +} + +pub fn membership() -> Vec<(u128, MemberRecord)> { + let mut r = Members::::iter().collect::>(); + r.sort_by_key(|x| x.0); + r +} + +pub fn candidacies() -> Vec<(u128, Candidacy)> { + let mut r = Candidates::::iter().collect::>(); + r.sort_by_key(|x| x.0); + r +} + +pub fn candidates() -> Vec { + let mut r = Candidates::::iter_keys().collect::>(); + r.sort(); + r +} diff --git a/frame/society/src/tests.rs b/frame/society/src/tests.rs index 8a5c626dea5b7..4a90ad52112d8 100644 --- a/frame/society/src/tests.rs +++ b/frame/society/src/tests.rs @@ -18,65 +18,196 @@ //! Tests for the module. use super::*; +use migrations::old; use mock::*; use frame_support::{assert_noop, assert_ok}; use sp_core::blake2_256; use sp_runtime::traits::BadOrigin; +use BidKind::*; +use VouchingStatus::*; + +use RuntimeOrigin as Origin; + +#[test] +fn migration_works() { + EnvBuilder::new().founded(false).execute(|| { + use old::Vote::*; + + // Initialise the old storage items. + Founder::::put(10); + Head::::put(30); + old::Members::::put(vec![10, 20, 30]); + old::Vouching::::insert(30, Vouching); + old::Vouching::::insert(40, Banned); + old::Strikes::::insert(20, 1); + old::Strikes::::insert(30, 2); + old::Strikes::::insert(40, 5); + old::Payouts::::insert(20, vec![(1, 1)]); + old::Payouts::::insert( + 30, + (0..=::MaxPayouts::get()) + .map(|i| (i as u64, i as u64)) + .collect::>(), + ); + old::SuspendedMembers::::insert(40, true); + + old::Defender::::put(20); + old::DefenderVotes::::insert(10, Approve); + old::DefenderVotes::::insert(20, Approve); + old::DefenderVotes::::insert(30, Reject); + + old::SuspendedCandidates::::insert(50, (10, Deposit(100))); + + old::Candidates::::put(vec![ + Bid { who: 60, kind: Deposit(100), value: 200 }, + Bid { who: 70, kind: Vouch(30, 30), value: 100 }, + ]); + old::Votes::::insert(60, 10, Approve); + old::Votes::::insert(70, 10, Reject); + old::Votes::::insert(70, 20, Approve); + old::Votes::::insert(70, 30, Approve); + + let bids = (0..=::MaxBids::get()) + .map(|i| Bid { + who: 100u128 + i as u128, + kind: Deposit(20u64 + i as u64), + value: 10u64 + i as u64, + }) + .collect::>(); + old::Bids::::put(bids); + + migrations::from_original::(&mut [][..]); + migrations::assert_internal_consistency::(); + + assert_eq!( + membership(), + vec![ + (10, MemberRecord { rank: 0, strikes: 0, vouching: None, index: 0 }), + (20, MemberRecord { rank: 0, strikes: 1, vouching: None, index: 1 }), + (30, MemberRecord { rank: 0, strikes: 2, vouching: Some(Vouching), index: 2 }), + ] + ); + assert_eq!(Payouts::::get(10), PayoutRecord::default()); + let payouts = vec![(1, 1)].try_into().unwrap(); + assert_eq!(Payouts::::get(20), PayoutRecord { paid: 0, payouts }); + let payouts = (0..::MaxPayouts::get()) + .map(|i| (i as u64, i as u64)) + .collect::>() + .try_into() + .unwrap(); + assert_eq!(Payouts::::get(30), PayoutRecord { paid: 0, payouts }); + assert_eq!( + SuspendedMembers::::iter().collect::>(), + vec![(40, MemberRecord { rank: 0, strikes: 5, vouching: Some(Banned), index: 0 }),] + ); + let bids: BoundedVec<_, ::MaxBids> = (0..::MaxBids::get()) + .map(|i| Bid { + who: 100u128 + i as u128, + kind: Deposit(20u64 + i as u64), + value: 10u64 + i as u64, + }) + .collect::>() + .try_into() + .unwrap(); + assert_eq!(Bids::::get(), bids); + assert_eq!(RoundCount::::get(), 0); + assert_eq!( + candidacies(), + vec![ + ( + 60, + Candidacy { + round: 0, + kind: Deposit(100), + bid: 200, + tally: Tally { approvals: 1, rejections: 0 }, + skeptic_struck: false, + } + ), + ( + 70, + Candidacy { + round: 0, + kind: Vouch(30, 30), + bid: 100, + tally: Tally { approvals: 2, rejections: 1 }, + skeptic_struck: false, + } + ), + ] + ); + assert_eq!(Votes::::get(60, 10), Some(Vote { approve: true, weight: 1 })); + assert_eq!(Votes::::get(70, 10), Some(Vote { approve: false, weight: 1 })); + assert_eq!(Votes::::get(70, 20), Some(Vote { approve: true, weight: 1 })); + assert_eq!(Votes::::get(70, 30), Some(Vote { approve: true, weight: 1 })); + }); +} #[test] fn founding_works() { - EnvBuilder::new().with_max_members(0).with_members(vec![]).execute(|| { + EnvBuilder::new().founded(false).execute(|| { // Not set up initially. - assert_eq!(Society::founder(), None); - assert_eq!(Society::max_members(), 0); - assert_eq!(Society::pot(), 0); + assert_eq!(Founder::::get(), None); + assert_eq!(Parameters::::get(), None); + assert_eq!(Pot::::get(), 0); // Account 1 is set as the founder origin // Account 5 cannot start a society - assert_noop!(Society::found(RuntimeOrigin::signed(5), 20, 100, vec![]), BadOrigin); + assert_noop!( + Society::found_society(Origin::signed(5), 20, 100, 10, 2, 25, vec![]), + BadOrigin + ); // Account 1 can start a society, where 10 is the founding member - assert_ok!(Society::found(RuntimeOrigin::signed(1), 10, 100, b"be cool".to_vec())); + assert_ok!(Society::found_society( + Origin::signed(1), + 10, + 100, + 10, + 2, + 25, + b"be cool".to_vec() + )); // Society members only include 10 - assert_eq!(Society::members(), vec![10]); + assert_eq!(members(), vec![10]); // 10 is the head of the society - assert_eq!(Society::head(), Some(10)); + assert_eq!(Head::::get(), Some(10)); // ...and also the founder - assert_eq!(Society::founder(), Some(10)); + assert_eq!(Founder::::get(), Some(10)); // 100 members max - assert_eq!(Society::max_members(), 100); + assert_eq!(Parameters::::get().unwrap().max_members, 100); // rules are correct - assert_eq!(Society::rules(), Some(blake2_256(b"be cool").into())); + assert_eq!(Rules::::get(), Some(blake2_256(b"be cool").into())); // Pot grows after first rotation period - run_to_block(4); - assert_eq!(Society::pot(), 1000); + next_intake(); + assert_eq!(Pot::::get(), 1000); // Cannot start another society assert_noop!( - Society::found(RuntimeOrigin::signed(1), 20, 100, vec![]), - Error::::AlreadyFounded + Society::found_society(Origin::signed(1), 20, 100, 10, 2, 25, vec![]), + Error::::AlreadyFounded ); }); } #[test] fn unfounding_works() { - EnvBuilder::new().with_max_members(0).with_members(vec![]).execute(|| { + EnvBuilder::new().founded(false).execute(|| { // Account 1 sets the founder... - assert_ok!(Society::found(RuntimeOrigin::signed(1), 10, 100, vec![])); + assert_ok!(Society::found_society(Origin::signed(1), 10, 100, 10, 2, 25, vec![])); // Account 2 cannot unfound it as it's not the founder. - assert_noop!(Society::unfound(RuntimeOrigin::signed(2)), Error::::NotFounder); + assert_noop!(Society::dissolve(Origin::signed(2)), Error::::NotFounder); // Account 10 can, though. - assert_ok!(Society::unfound(RuntimeOrigin::signed(10))); + assert_ok!(Society::dissolve(Origin::signed(10))); // 1 sets the founder to 20 this time - assert_ok!(Society::found(RuntimeOrigin::signed(1), 20, 100, vec![])); + assert_ok!(Society::found_society(Origin::signed(1), 20, 100, 10, 2, 25, vec![])); // Bring in a new member... - assert_ok!(Society::bid(RuntimeOrigin::signed(10), 0)); - run_to_block(4); - assert_ok!(Society::vote(RuntimeOrigin::signed(20), 10, true)); - run_to_block(8); + assert_ok!(Society::bid(Origin::signed(10), 0)); + next_intake(); + assert_ok!(Society::vote(Origin::signed(20), 10, true)); + conclude_intake(true, None); // Unfounding won't work now, even though it's from 20. - assert_noop!(Society::unfound(RuntimeOrigin::signed(20)), Error::::NotHead); + assert_noop!(Society::dissolve(Origin::signed(20)), Error::::NotHead); }); } @@ -89,15 +220,16 @@ fn basic_new_member_works() { assert_eq!(Balances::free_balance(20), 25); assert_eq!(Balances::reserved_balance(20), 25); // Rotate period every 4 blocks - run_to_block(4); + next_intake(); // 20 is now a candidate - assert_eq!(Society::candidates(), vec![create_bid(0, 20, BidKind::Deposit(25))]); + assert_eq!(candidacies(), vec![(20, candidacy(1, 0, Deposit(25), 0, 0))]); // 10 (a member) can vote for the candidate - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, true)); + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + conclude_intake(true, None); // Rotate period every 4 blocks - run_to_block(8); + next_intake(); // 20 is now a member of the society - assert_eq!(Society::members(), vec![10, 20]); + assert_eq!(members(), vec![10, 20]); // Reserved balance is returned assert_eq!(Balances::free_balance(20), 50); assert_eq!(Balances::reserved_balance(20), 0); @@ -113,57 +245,60 @@ fn bidding_works() { assert_ok!(Society::bid(RuntimeOrigin::signed(40), 400)); assert_ok!(Society::bid(RuntimeOrigin::signed(30), 300)); // Rotate period - run_to_block(4); + next_intake(); // Pot is 1000 after "PeriodSpend" - assert_eq!(Society::pot(), 1000); + assert_eq!(Pot::::get(), 1000); assert_eq!(Balances::free_balance(Society::account_id()), 10_000); // Choose smallest bidding users whose total is less than pot assert_eq!( - Society::candidates(), + candidacies(), vec![ - create_bid(300, 30, BidKind::Deposit(25)), - create_bid(400, 40, BidKind::Deposit(25)), + (30, candidacy(1, 300, Deposit(25), 0, 0)), + (40, candidacy(1, 400, Deposit(25), 0, 0)), ] ); // A member votes for these candidates to join the society - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 30, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 40, true)); - run_to_block(8); + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + assert_ok!(Society::vote(Origin::signed(10), 40, true)); + conclude_intake(true, None); + next_intake(); // Candidates become members after a period rotation - assert_eq!(Society::members(), vec![10, 30, 40]); + assert_eq!(members(), vec![10, 30, 40]); // Pot is increased by 1000, but pays out 700 to the members assert_eq!(Balances::free_balance(Society::account_id()), 9_300); - assert_eq!(Society::pot(), 1_300); + assert_eq!(Pot::::get(), 1_300); // Left over from the original bids is 50 who satisfies the condition of bid less than pot. - assert_eq!(Society::candidates(), vec![create_bid(500, 50, BidKind::Deposit(25))]); + assert_eq!(candidacies(), vec![(50, candidacy(2, 500, Deposit(25), 0, 0))]); // 40, now a member, can vote for 50 - assert_ok!(Society::vote(RuntimeOrigin::signed(40), 50, true)); + assert_ok!(Society::vote(Origin::signed(40), 50, true)); + conclude_intake(true, None); run_to_block(12); // 50 is now a member - assert_eq!(Society::members(), vec![10, 30, 40, 50]); + assert_eq!(members(), vec![10, 30, 40, 50]); // Pot is increased by 1000, and 500 is paid out. Total payout so far is 1200. - assert_eq!(Society::pot(), 1_800); + assert_eq!(Pot::::get(), 1_800); assert_eq!(Balances::free_balance(Society::account_id()), 8_800); // No more candidates satisfy the requirements - assert_eq!(Society::candidates(), vec![]); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(10), true)); // Keep defender around - // Next period + assert_eq!(candidacies(), vec![]); + assert_ok!(Society::defender_vote(Origin::signed(10), true)); // Keep defender around + // Next period run_to_block(16); // Same members - assert_eq!(Society::members(), vec![10, 30, 40, 50]); + assert_eq!(members(), vec![10, 30, 40, 50]); // Pot is increased by 1000 again - assert_eq!(Society::pot(), 2_800); + assert_eq!(Pot::::get(), 2_800); // No payouts assert_eq!(Balances::free_balance(Society::account_id()), 8_800); // Candidate 60 now qualifies based on the increased pot size. - assert_eq!(Society::candidates(), vec![create_bid(1900, 60, BidKind::Deposit(25))]); + assert_eq!(candidacies(), vec![(60, candidacy(4, 1900, Deposit(25), 0, 0))]); // Candidate 60 is voted in. - assert_ok!(Society::vote(RuntimeOrigin::signed(50), 60, true)); + assert_ok!(Society::vote(Origin::signed(50), 60, true)); + conclude_intake(true, None); run_to_block(20); // 60 joins as a member - assert_eq!(Society::members(), vec![10, 30, 40, 50, 60]); + assert_eq!(members(), vec![10, 30, 40, 50, 60]); // Pay them - assert_eq!(Society::pot(), 1_900); + assert_eq!(Pot::::get(), 1_900); assert_eq!(Balances::free_balance(Society::account_id()), 6_900); }); } @@ -177,16 +312,15 @@ fn unbidding_works() { // Balances are reserved assert_eq!(Balances::free_balance(30), 25); assert_eq!(Balances::reserved_balance(30), 25); - // Must know right position to unbid + cannot unbid someone else - assert_noop!(Society::unbid(RuntimeOrigin::signed(30), 1), Error::::BadPosition); // Can unbid themselves with the right position - assert_ok!(Society::unbid(RuntimeOrigin::signed(30), 0)); + assert_ok!(Society::unbid(Origin::signed(30))); + assert_noop!(Society::unbid(Origin::signed(30)), Error::::NotBidder); // Balance is returned assert_eq!(Balances::free_balance(30), 50); assert_eq!(Balances::reserved_balance(30), 0); // 20 wins candidacy - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(1000, 20, BidKind::Deposit(25))]); + next_intake(); + assert_eq!(candidacies(), vec![(20, candidacy(1, 1000, Deposit(25), 0, 0))]); }); } @@ -195,13 +329,13 @@ fn payout_works() { EnvBuilder::new().execute(|| { // Original balance of 50 assert_eq!(Balances::free_balance(20), 50); - assert_ok!(Society::bid(RuntimeOrigin::signed(20), 1000)); - run_to_block(4); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, true)); - run_to_block(8); + assert_ok!(Society::bid(Origin::signed(20), 1000)); + next_intake(); + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + conclude_intake(true, None); // payout not ready - assert_noop!(Society::payout(RuntimeOrigin::signed(20)), Error::::NoPayout); - run_to_block(9); + assert_noop!(Society::payout(Origin::signed(20)), Error::::NoPayout); + next_intake(); // payout should be here assert_ok!(Society::payout(RuntimeOrigin::signed(20))); assert_eq!(Balances::free_balance(20), 1050); @@ -209,22 +343,34 @@ fn payout_works() { } #[test] -fn basic_new_member_skeptic_works() { +fn non_voting_skeptic_is_punished() { EnvBuilder::new().execute(|| { - // NOTE: events are not deposited in the genesis event - System::set_block_number(1); - - assert_eq!(Strikes::::get(10), 0); - assert_ok!(Society::bid(RuntimeOrigin::signed(20), 0)); - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(0, 20, BidKind::Deposit(25))]); - run_to_block(8); - assert_eq!(Society::members(), vec![10]); - assert_eq!(Strikes::::get(10), 1); + assert_eq!(Members::::get(10).unwrap().strikes, 0); + assert_ok!(Society::bid(Origin::signed(20), 0)); + next_intake(); + assert_eq!(candidacies(), vec![(20, candidacy(1, 0, Deposit(25), 0, 0))]); + conclude_intake(true, None); + next_intake(); + assert_eq!(members(), vec![10]); + assert_eq!(Members::::get(10).unwrap().strikes, 1); + }); +} - System::assert_last_event(mock::RuntimeEvent::Society(crate::Event::SkepticsChosen { - skeptics: vec![10], - })); +#[test] +fn rejecting_skeptic_on_approved_is_punished() { + EnvBuilder::new().execute(|| { + place_members([20, 30]); + assert_ok!(Society::bid(Origin::signed(40), 0)); + next_intake(); + let skeptic = Skeptic::::get().unwrap(); + for &i in &[10, 20, 30][..] { + assert_ok!(Society::vote(Origin::signed(i), 40, i != skeptic)); + } + conclude_intake(true, None); + assert_eq!(Members::::get(10).unwrap().strikes, 0); + run_to_block(12); + assert_eq!(members(), vec![10, 20, 30, 40]); + assert_eq!(Members::::get(skeptic).unwrap().strikes, 1); }); } @@ -238,16 +384,17 @@ fn basic_new_member_reject_works() { assert_eq!(Balances::free_balance(20), 25); assert_eq!(Balances::reserved_balance(20), 25); // Rotation Period - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(0, 20, BidKind::Deposit(25))]); + next_intake(); + assert_eq!(candidacies(), vec![(20, candidacy(1, 0, Deposit(25), 0, 0))]); // We say no - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, false)); - run_to_block(8); + assert_ok!(Society::vote(Origin::signed(10), 20, false)); + conclude_intake(true, None); + next_intake(); // User is not added as member - assert_eq!(Society::members(), vec![10]); - // User is suspended - assert_eq!(Society::candidates(), vec![]); - assert_eq!(Society::suspended_candidate(20).is_some(), true); + assert_eq!(members(), vec![10]); + // User is rejected. + assert_eq!(candidacies(), vec![]); + assert_eq!(Bids::::get().into_inner(), vec![]); }); } @@ -255,20 +402,30 @@ fn basic_new_member_reject_works() { fn slash_payout_works() { EnvBuilder::new().execute(|| { assert_eq!(Balances::free_balance(20), 50); - assert_ok!(Society::bid(RuntimeOrigin::signed(20), 1000)); - run_to_block(4); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, true)); - run_to_block(8); + assert_ok!(Society::bid(Origin::signed(20), 1000)); + next_intake(); + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + conclude_intake(true, None); // payout in queue - assert_eq!(Payouts::::get(20), vec![(9, 1000)]); - assert_noop!(Society::payout(RuntimeOrigin::signed(20)), Error::::NoPayout); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(8, 1000)].try_into().unwrap() } + ); + assert_noop!(Society::payout(Origin::signed(20)), Error::::NoPayout); // slash payout assert_eq!(Society::slash_payout(&20, 500), 500); - assert_eq!(Payouts::::get(20), vec![(9, 500)]); - run_to_block(9); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(8, 500)].try_into().unwrap() } + ); + run_to_block(8); // payout should be here, but 500 less assert_ok!(Society::payout(RuntimeOrigin::signed(20))); assert_eq!(Balances::free_balance(20), 550); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 500, payouts: Default::default() } + ); }); } @@ -276,19 +433,32 @@ fn slash_payout_works() { fn slash_payout_multi_works() { EnvBuilder::new().execute(|| { assert_eq!(Balances::free_balance(20), 50); + place_members([20]); // create a few payouts Society::bump_payout(&20, 5, 100); Society::bump_payout(&20, 10, 100); Society::bump_payout(&20, 15, 100); Society::bump_payout(&20, 20, 100); // payouts in queue - assert_eq!(Payouts::::get(20), vec![(5, 100), (10, 100), (15, 100), (20, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { + paid: 0, + payouts: vec![(5, 100), (10, 100), (15, 100), (20, 100)].try_into().unwrap() + } + ); // slash payout assert_eq!(Society::slash_payout(&20, 250), 250); - assert_eq!(Payouts::::get(20), vec![(15, 50), (20, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(15, 50), (20, 100)].try_into().unwrap() } + ); // slash again assert_eq!(Society::slash_payout(&20, 50), 50); - assert_eq!(Payouts::::get(20), vec![(20, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(20, 100)].try_into().unwrap() } + ); }); } @@ -296,172 +466,226 @@ fn slash_payout_multi_works() { fn suspended_member_life_cycle_works() { EnvBuilder::new().execute(|| { // Add 20 to members, who is not the head and can be suspended/removed. - assert_ok!(Society::add_member(&20)); - assert_eq!(>::get(), vec![10, 20]); - assert_eq!(Strikes::::get(20), 0); - assert_eq!(>::get(20), false); + place_members([20]); + assert_eq!(members(), vec![10, 20]); + assert_eq!(Members::::get(20).unwrap().strikes, 0); + assert!(!SuspendedMembers::::contains_key(20)); // Let's suspend account 20 by giving them 2 strikes by not voting - assert_ok!(Society::bid(RuntimeOrigin::signed(30), 0)); - run_to_block(8); - assert_eq!(Strikes::::get(20), 1); - assert_ok!(Society::bid(RuntimeOrigin::signed(40), 0)); - run_to_block(16); + assert_ok!(Society::bid(Origin::signed(30), 0)); + assert_ok!(Society::bid(Origin::signed(40), 1)); + next_intake(); + conclude_intake(false, None); - // Strike 2 is accumulated, and 20 is suspended :( - assert_eq!(>::get(20), true); - assert_eq!(>::get(), vec![10]); + // 2 strikes are accumulated, and 20 is suspended :( + assert!(SuspendedMembers::::contains_key(20)); + assert_eq!(members(), vec![10]); // Suspended members cannot get payout Society::bump_payout(&20, 10, 100); - assert_noop!(Society::payout(RuntimeOrigin::signed(20)), Error::::NotMember); + assert_noop!(Society::payout(Origin::signed(20)), Error::::NotMember); // Normal people cannot make judgement assert_noop!( - Society::judge_suspended_member(RuntimeOrigin::signed(20), 20, true), - BadOrigin + Society::judge_suspended_member(Origin::signed(20), 20, true), + Error::::NotFounder ); // Suspension judgment origin can judge thee // Suspension judgement origin forgives the suspended member - assert_ok!(Society::judge_suspended_member(RuntimeOrigin::signed(2), 20, true)); - assert_eq!(>::get(20), false); - assert_eq!(>::get(), vec![10, 20]); + assert_ok!(Society::judge_suspended_member(Origin::signed(10), 20, true)); + assert!(!SuspendedMembers::::contains_key(20)); + assert_eq!(members(), vec![10, 20]); // Let's suspend them again, directly - Society::suspend_member(&20); - assert_eq!(>::get(20), true); + assert_ok!(Society::suspend_member(&20)); + assert!(SuspendedMembers::::contains_key(20)); // Suspension judgement origin does not forgive the suspended member - assert_ok!(Society::judge_suspended_member(RuntimeOrigin::signed(2), 20, false)); + assert_ok!(Society::judge_suspended_member(Origin::signed(10), 20, false)); // Cleaned up - assert_eq!(>::get(20), false); - assert_eq!(>::get(), vec![10]); - assert_eq!(>::get(20), vec![]); + assert!(!SuspendedMembers::::contains_key(20)); + assert_eq!(members(), vec![10]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![].try_into().unwrap() } + ); }); } #[test] fn suspended_candidate_rejected_works() { EnvBuilder::new().execute(|| { - // Starting Balance - assert_eq!(Balances::free_balance(20), 50); - assert_eq!(Balances::free_balance(Society::account_id()), 10000); - // 20 makes a bid - assert_ok!(Society::bid(RuntimeOrigin::signed(20), 0)); - assert_eq!(Balances::free_balance(20), 25); - assert_eq!(Balances::reserved_balance(20), 25); - // Rotation Period - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(0, 20, BidKind::Deposit(25))]); - // We say no - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, false)); - run_to_block(8); - // User is not added as member - assert_eq!(Society::members(), vec![10]); - // User is suspended - assert_eq!(Society::candidates(), vec![]); - assert_eq!(Society::suspended_candidate(20).is_some(), true); + place_members([20, 30]); + // 40, 50, 60, 70, 80 make bids + for &x in &[40u128, 50, 60, 70] { + assert_ok!(Society::bid(Origin::signed(x), 10)); + assert_eq!(Balances::free_balance(x), 25); + assert_eq!(Balances::reserved_balance(x), 25); + } - // Normal user cannot make judgement on suspended candidate - assert_noop!( - Society::judge_suspended_candidate(RuntimeOrigin::signed(20), 20, Judgement::Approve), - BadOrigin + // Rotation Period + next_intake(); + assert_eq!( + candidacies(), + vec![ + (40, candidacy(1, 10, Deposit(25), 0, 0)), + (50, candidacy(1, 10, Deposit(25), 0, 0)), + (60, candidacy(1, 10, Deposit(25), 0, 0)), + (70, candidacy(1, 10, Deposit(25), 0, 0)), + ] ); - // Suspension judgement origin makes no direct judgement - assert_ok!(Society::judge_suspended_candidate( - RuntimeOrigin::signed(2), - 20, - Judgement::Rebid - )); - // They are placed back in bid pool, repeat suspension process - // Rotation Period - run_to_block(12); - assert_eq!(Society::candidates(), vec![create_bid(0, 20, BidKind::Deposit(25))]); - // We say no - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, false)); - run_to_block(16); - // User is not added as member - assert_eq!(Society::members(), vec![10]); - // User is suspended - assert_eq!(Society::candidates(), vec![]); - assert_eq!(Society::suspended_candidate(20).is_some(), true); - - // Suspension judgement origin rejects the candidate - assert_ok!(Society::judge_suspended_candidate( - RuntimeOrigin::signed(2), - 20, - Judgement::Reject - )); - // User is slashed - assert_eq!(Balances::free_balance(20), 25); - assert_eq!(Balances::reserved_balance(20), 0); - // Funds are deposited to society account - assert_eq!(Balances::free_balance(Society::account_id()), 10025); - // Cleaned up - assert_eq!(Society::candidates(), vec![]); - assert_eq!(>::get(20), None); + // Split vote over all. + for &x in &[40, 50, 60, 70] { + assert_ok!(Society::vote(Origin::signed(20), x, false)); + assert_ok!(Society::vote(Origin::signed(30), x, true)); + } + + // Voting continues, as no canidate is clearly accepted yet and the founder chooses not to + // act. + conclude_intake(false, None); + assert_eq!(members(), vec![10, 20, 30]); + assert_eq!(candidates(), vec![40, 50, 60, 70]); + + // 40 gets approved after founder weighs in giving it a clear approval. + // but the founder's rejection of 60 doesn't do much for now. + assert_ok!(Society::vote(Origin::signed(10), 40, true)); + assert_ok!(Society::vote(Origin::signed(10), 60, false)); + conclude_intake(false, None); + assert_eq!(members(), vec![10, 20, 30, 40]); + assert_eq!(candidates(), vec![50, 60, 70]); + assert_eq!(Balances::free_balance(40), 50); + assert_eq!(Balances::reserved_balance(40), 0); + assert_eq!(Balances::free_balance(Society::account_id()), 9990); + + // Founder manually bestows membership on 50 and and kicks 70. + assert_ok!(Society::bestow_membership(Origin::signed(10), 50)); + assert_eq!(members(), vec![10, 20, 30, 40, 50]); + assert_eq!(candidates(), vec![60, 70]); + assert_eq!(Balances::free_balance(50), 50); + assert_eq!(Balances::reserved_balance(50), 0); + assert_eq!(Balances::free_balance(Society::account_id()), 9980); + + assert_eq!(Balances::free_balance(70), 25); + assert_eq!(Balances::reserved_balance(70), 25); + + assert_ok!(Society::kick_candidate(Origin::signed(10), 70)); + assert_eq!(members(), vec![10, 20, 30, 40, 50]); + assert_eq!(candidates(), vec![60]); + assert_eq!(Balances::free_balance(70), 25); + assert_eq!(Balances::reserved_balance(70), 0); + assert_eq!(Balances::free_balance(Society::account_id()), 10005); + + // Next round doesn't make much difference. + next_intake(); + conclude_intake(false, None); + assert_eq!(members(), vec![10, 20, 30, 40, 50]); + assert_eq!(candidates(), vec![60]); + assert_eq!(Balances::free_balance(Society::account_id()), 10005); + + // But after two rounds, the clearly rejected 60 gets dropped and slashed. + next_intake(); + conclude_intake(false, None); + assert_eq!(members(), vec![10, 20, 30, 40, 50]); + assert_eq!(candidates(), vec![]); + assert_eq!(Balances::free_balance(60), 25); + assert_eq!(Balances::reserved_balance(60), 0); + assert_eq!(Balances::free_balance(Society::account_id()), 10030); }); } #[test] -fn vouch_works() { +fn unpaid_vouch_works() { EnvBuilder::new().execute(|| { // 10 is the only member - assert_eq!(Society::members(), vec![10]); + assert_eq!(members(), vec![10]); // A non-member cannot vouch - assert_noop!( - Society::vouch(RuntimeOrigin::signed(1), 20, 1000, 100), - Error::::NotMember - ); + assert_noop!(Society::vouch(Origin::signed(1), 20, 1000, 100), Error::::NotMember); // A member can though - assert_ok!(Society::vouch(RuntimeOrigin::signed(10), 20, 1000, 100)); - assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); + assert_ok!(Society::vouch(Origin::signed(10), 20, 1000, 100)); + assert_eq!(Members::::get(10).unwrap().vouching, Some(VouchingStatus::Vouching)); // A member cannot vouch twice at the same time assert_noop!( - Society::vouch(RuntimeOrigin::signed(10), 30, 100, 0), - Error::::AlreadyVouching + Society::vouch(Origin::signed(10), 30, 100, 0), + Error::::AlreadyVouching ); // Vouching creates the right kind of bid - assert_eq!(>::get(), vec![create_bid(1000, 20, BidKind::Vouch(10, 100))]); + assert_eq!(Bids::::get().into_inner(), vec![bid(20, Vouch(10, 100), 1000)]); // Vouched user can become candidate - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(1000, 20, BidKind::Vouch(10, 100))]); + next_intake(); + assert_eq!(candidacies(), vec![(20, candidacy(1, 1000, Vouch(10, 100), 0, 0))]); // Vote yes assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, true)); // Vouched user can win - run_to_block(8); - assert_eq!(Society::members(), vec![10, 20]); + conclude_intake(true, None); + assert_eq!(members(), vec![10, 20]); + // Vouched user gets whatever remains after the voucher's reservation. + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(8, 900)].try_into().unwrap() } + ); + // 10 is no longer vouching + assert_eq!(Members::::get(10).unwrap().vouching, None); + }); +} + +#[test] +fn paid_vouch_works() { + EnvBuilder::new().execute(|| { + place_members([20]); + assert_eq!(members(), vec![10, 20]); + + assert_ok!(Society::vouch(Origin::signed(20), 30, 1000, 100)); + assert_eq!(Members::::get(20).unwrap().vouching, Some(VouchingStatus::Vouching)); + assert_eq!(Bids::::get().into_inner(), vec![bid(30, Vouch(20, 100), 1000)]); + + next_intake(); + assert_eq!(candidacies(), vec![(30, candidacy(1, 1000, Vouch(20, 100), 0, 0))]); + assert_ok!(Society::vote(Origin::signed(20), 30, true)); + conclude_intake(true, None); + + assert_eq!(members(), vec![10, 20, 30]); // Voucher wins a portion of the payment - assert_eq!(>::get(10), vec![(9, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(8, 100)].try_into().unwrap() } + ); // Vouched user wins the rest - assert_eq!(>::get(20), vec![(9, 900)]); - // 10 is no longer vouching - assert_eq!(>::get(10), None); + assert_eq!( + Payouts::::get(30), + PayoutRecord { paid: 0, payouts: vec![(8, 900)].try_into().unwrap() } + ); + // 20 is no longer vouching + assert_eq!(Members::::get(20).unwrap().vouching, None); }); } #[test] fn voucher_cannot_win_more_than_bid() { EnvBuilder::new().execute(|| { - // 10 is the only member - assert_eq!(Society::members(), vec![10]); - // 10 vouches, but asks for more than the bid - assert_ok!(Society::vouch(RuntimeOrigin::signed(10), 20, 100, 1000)); + place_members([20]); + // 20 vouches, but asks for more than the bid + assert_ok!(Society::vouch(Origin::signed(20), 30, 100, 1000)); // Vouching creates the right kind of bid - assert_eq!(>::get(), vec![create_bid(100, 20, BidKind::Vouch(10, 1000))]); + assert_eq!(Bids::::get().into_inner(), vec![bid(30, Vouch(20, 1000), 100)]); // Vouched user can become candidate - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(100, 20, BidKind::Vouch(10, 1000))]); + next_intake(); + assert_eq!(candidacies(), vec![(30, candidacy(1, 100, Vouch(20, 1000), 0, 0))]); // Vote yes - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 20, true)); + assert_ok!(Society::vote(Origin::signed(20), 30, true)); // Vouched user can win - run_to_block(8); - assert_eq!(Society::members(), vec![10, 20]); + conclude_intake(true, None); + assert_eq!(members(), vec![10, 20, 30]); // Voucher wins as much as the bid - assert_eq!(>::get(10), vec![(9, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(8, 100)].try_into().unwrap() } + ); // Vouched user gets nothing - assert_eq!(>::get(20), vec![]); + assert_eq!( + Payouts::::get(30), + PayoutRecord { paid: 0, payouts: vec![].try_into().unwrap() } + ); }); } @@ -469,52 +693,49 @@ fn voucher_cannot_win_more_than_bid() { fn unvouch_works() { EnvBuilder::new().execute(|| { // 10 is the only member - assert_eq!(Society::members(), vec![10]); + assert_eq!(members(), vec![10]); // 10 vouches for 20 assert_ok!(Society::vouch(RuntimeOrigin::signed(10), 20, 100, 0)); // 20 has a bid - assert_eq!(>::get(), vec![create_bid(100, 20, BidKind::Vouch(10, 0))]); + assert_eq!(Bids::::get().into_inner(), vec![bid(20, Vouch(10, 0), 100)]); // 10 is vouched - assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); - // To unvouch, you must know the right bid position - assert_noop!(Society::unvouch(RuntimeOrigin::signed(10), 2), Error::::BadPosition); - // 10 can unvouch with the right position - assert_ok!(Society::unvouch(RuntimeOrigin::signed(10), 0)); + assert_eq!(Members::::get(10).unwrap().vouching, Some(VouchingStatus::Vouching)); + // 10 can unvouch + assert_ok!(Society::unvouch(Origin::signed(10))); // 20 no longer has a bid - assert_eq!(>::get(), vec![]); + assert_eq!(Bids::::get().into_inner(), vec![]); // 10 is no longer vouching - assert_eq!(>::get(10), None); + assert_eq!(Members::::get(10).unwrap().vouching, None); // Cannot unvouch after they become candidate - assert_ok!(Society::vouch(RuntimeOrigin::signed(10), 20, 100, 0)); - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(100, 20, BidKind::Vouch(10, 0))]); - assert_noop!(Society::unvouch(RuntimeOrigin::signed(10), 0), Error::::BadPosition); + assert_ok!(Society::vouch(Origin::signed(10), 20, 100, 0)); + next_intake(); + assert_eq!(candidacies(), vec![(20, candidacy(1, 100, Vouch(10, 0), 0, 0))]); + assert_noop!(Society::unvouch(Origin::signed(10)), Error::::NotVouchingOnBidder); + // 10 is still vouching until candidate is approved or rejected - assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); - run_to_block(8); - // In this case candidate is denied and suspended - assert!(Society::suspended_candidate(&20).is_some()); - assert_eq!(Society::members(), vec![10]); - // User is stuck vouching until judgement origin resolves suspended candidate - assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); - // Judge denies candidate - assert_ok!(Society::judge_suspended_candidate( - RuntimeOrigin::signed(2), - 20, - Judgement::Reject - )); - // 10 is banned from vouching - assert_eq!(>::get(10), Some(VouchingStatus::Banned)); - assert_eq!(Society::members(), vec![10]); + assert_eq!(Members::::get(10).unwrap().vouching, Some(VouchingStatus::Vouching)); + // Voucher inexplicably votes against their pick. + assert_ok!(Society::vote(Origin::signed(10), 20, false)); + // But their pick doesn't resign (yet). + conclude_intake(false, None); + // Voting still happening and voucher cannot unvouch. + assert_eq!(candidacies(), vec![(20, candidacy(1, 100, Vouch(10, 0), 0, 1))]); + assert_eq!(Members::::get(10).unwrap().vouching, Some(VouchingStatus::Vouching)); + + // Candidate gives in and resigns. + conclude_intake(true, None); + // Vouxher (10) is banned from vouching. + assert_eq!(Members::::get(10).unwrap().vouching, Some(VouchingStatus::Banned)); + assert_eq!(members(), vec![10]); // 10 cannot vouch again assert_noop!( - Society::vouch(RuntimeOrigin::signed(10), 30, 100, 0), - Error::::AlreadyVouching + Society::vouch(Origin::signed(10), 30, 100, 0), + Error::::AlreadyVouching ); // 10 cannot unvouch either, so they are banned forever. - assert_noop!(Society::unvouch(RuntimeOrigin::signed(10), 0), Error::::NotVouching); + assert_noop!(Society::unvouch(Origin::signed(10)), Error::::NotVouchingOnBidder); }); } @@ -522,18 +743,18 @@ fn unvouch_works() { fn unbid_vouch_works() { EnvBuilder::new().execute(|| { // 10 is the only member - assert_eq!(Society::members(), vec![10]); + assert_eq!(members(), vec![10]); // 10 vouches for 20 assert_ok!(Society::vouch(RuntimeOrigin::signed(10), 20, 100, 0)); // 20 has a bid - assert_eq!(>::get(), vec![create_bid(100, 20, BidKind::Vouch(10, 0))]); + assert_eq!(Bids::::get().into_inner(), vec![bid(20, Vouch(10, 0), 100)]); // 10 is vouched - assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); + assert_eq!(Members::::get(10).unwrap().vouching, Some(VouchingStatus::Vouching)); // 20 doesn't want to be a member and can unbid themselves. - assert_ok!(Society::unbid(RuntimeOrigin::signed(20), 0)); + assert_ok!(Society::unbid(Origin::signed(20))); // Everything is cleaned up - assert_eq!(>::get(10), None); - assert_eq!(>::get(), vec![]); + assert_eq!(Members::::get(10).unwrap().vouching, None); + assert_eq!(Bids::::get().into_inner(), vec![]); }); } @@ -541,60 +762,73 @@ fn unbid_vouch_works() { fn founder_and_head_cannot_be_removed() { EnvBuilder::new().execute(|| { // 10 is the only member, founder, and head - assert_eq!(Society::members(), vec![10]); - assert_eq!(Society::founder(), Some(10)); - assert_eq!(Society::head(), Some(10)); + assert_eq!(members(), vec![10]); + assert_eq!(Founder::::get(), Some(10)); + assert_eq!(Head::::get(), Some(10)); // 10 can still accumulate strikes - assert_ok!(Society::bid(RuntimeOrigin::signed(20), 0)); - run_to_block(8); - assert_eq!(Strikes::::get(10), 1); - assert_ok!(Society::bid(RuntimeOrigin::signed(30), 0)); - run_to_block(16); - assert_eq!(Strikes::::get(10), 2); + assert_ok!(Society::bid(Origin::signed(20), 0)); + next_intake(); + conclude_intake(false, None); + assert_eq!(Members::::get(10).unwrap().strikes, 1); + assert_ok!(Society::bid(Origin::signed(30), 0)); + next_intake(); + conclude_intake(false, None); + assert_eq!(Members::::get(10).unwrap().strikes, 2); // Awkwardly they can obtain more than MAX_STRIKES... - assert_ok!(Society::bid(RuntimeOrigin::signed(40), 0)); - run_to_block(24); - assert_eq!(Strikes::::get(10), 3); + assert_ok!(Society::bid(Origin::signed(40), 0)); + next_intake(); + conclude_intake(false, None); + assert_eq!(Members::::get(10).unwrap().strikes, 3); // Replace the head - assert_ok!(Society::bid(RuntimeOrigin::signed(50), 0)); - run_to_block(28); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 50, true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(10), true)); // Keep defender around - run_to_block(32); - assert_eq!(Society::members(), vec![10, 50]); - assert_eq!(Society::head(), Some(50)); + assert_ok!(Society::bid(Origin::signed(50), 0)); + next_intake(); + assert_ok!(Society::vote(Origin::signed(10), 50, true)); + conclude_intake(false, None); + assert_eq!(members(), vec![10, 50]); + assert_eq!(Head::::get(), Some(10)); + next_intake(); + assert_eq!(Head::::get(), Some(50)); // Founder is unchanged - assert_eq!(Society::founder(), Some(10)); + assert_eq!(Founder::::get(), Some(10)); // 50 can still accumulate strikes - assert_ok!(Society::bid(RuntimeOrigin::signed(60), 0)); - run_to_block(40); - assert_eq!(Strikes::::get(50), 1); - assert_ok!(Society::bid(RuntimeOrigin::signed(70), 0)); - run_to_block(48); - assert_eq!(Strikes::::get(50), 2); + assert_ok!(Society::bid(Origin::signed(60), 0)); + next_intake(); + // Force 50 to be Skeptic so it gets a strike. + Skeptic::::put(50); + conclude_intake(false, None); + assert_eq!(Members::::get(50).unwrap().strikes, 1); + assert_ok!(Society::bid(Origin::signed(70), 0)); + next_intake(); + // Force 50 to be Skeptic so it gets a strike. + Skeptic::::put(50); + conclude_intake(false, None); + assert_eq!(Members::::get(50).unwrap().strikes, 2); // Replace the head - assert_ok!(Society::bid(RuntimeOrigin::signed(80), 0)); - run_to_block(52); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 80, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(50), 80, true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(10), true)); // Keep defender around - run_to_block(56); - assert_eq!(Society::members(), vec![10, 50, 80]); - assert_eq!(Society::head(), Some(80)); - assert_eq!(Society::founder(), Some(10)); + assert_ok!(Society::bid(Origin::signed(80), 0)); + next_intake(); + assert_ok!(Society::vote(Origin::signed(10), 80, true)); + assert_ok!(Society::vote(Origin::signed(50), 80, true)); + conclude_intake(false, None); + next_intake(); + assert_eq!(members(), vec![10, 50, 80]); + assert_eq!(Head::::get(), Some(80)); + assert_eq!(Founder::::get(), Some(10)); // 50 can now be suspended for strikes - assert_ok!(Society::bid(RuntimeOrigin::signed(90), 0)); - run_to_block(60); - // The candidate is rejected, so voting approve will give a strike - assert_ok!(Society::vote(RuntimeOrigin::signed(50), 90, true)); - run_to_block(64); - assert_eq!(Strikes::::get(50), 0); - assert_eq!(>::get(50), true); - assert_eq!(Society::members(), vec![10, 80]); + assert_ok!(Society::bid(Origin::signed(90), 0)); + next_intake(); + // Force 50 to be Skeptic and get it a strike. + Skeptic::::put(50); + conclude_intake(false, None); + next_intake(); + assert_eq!( + SuspendedMembers::::get(50), + Some(MemberRecord { rank: 0, strikes: 3, vouching: None, index: 1 }) + ); + assert_eq!(members(), vec![10, 80]); }); } @@ -602,63 +836,72 @@ fn founder_and_head_cannot_be_removed() { fn challenges_work() { EnvBuilder::new().execute(|| { // Add some members - assert_ok!(Society::add_member(&20)); - assert_ok!(Society::add_member(&30)); - assert_ok!(Society::add_member(&40)); + place_members([20, 30, 40]); // Votes are empty - assert_eq!(>::get(10), None); - assert_eq!(>::get(20), None); - assert_eq!(>::get(30), None); - assert_eq!(>::get(40), None); + assert_eq!(DefenderVotes::::get(0, 10), None); + assert_eq!(DefenderVotes::::get(0, 20), None); + assert_eq!(DefenderVotes::::get(0, 30), None); + assert_eq!(DefenderVotes::::get(0, 40), None); // Check starting point - assert_eq!(Society::members(), vec![10, 20, 30, 40]); - assert_eq!(Society::defender(), None); - // 20 will be challenged during the challenge rotation - run_to_block(8); - assert_eq!(Society::defender(), Some(30)); + assert_eq!(members(), vec![10, 20, 30, 40]); + assert_eq!(Defending::::get(), None); + + // 30 will be challenged during the challenge rotation + next_challenge(); + assert_eq!(Defending::::get().unwrap().0, 30); // They can always free vote for themselves - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(30), true)); + assert_ok!(Society::defender_vote(Origin::signed(30), true)); + // If no one else votes, nothing happens - run_to_block(16); - assert_eq!(Society::members(), vec![10, 20, 30, 40]); + next_challenge(); + assert_eq!(members(), vec![10, 20, 30, 40]); + // Reset votes for last challenge + assert_ok!(Society::cleanup_challenge(Origin::signed(0), 0, 10)); // New challenge period - assert_eq!(Society::defender(), Some(30)); - // Non-member cannot challenge - assert_noop!( - Society::defender_vote(RuntimeOrigin::signed(1), true), - Error::::NotMember - ); + assert_eq!(Defending::::get().unwrap().0, 30); + // Non-member cannot vote + assert_noop!(Society::defender_vote(Origin::signed(1), true), Error::::NotMember); // 3 people say accept, 1 reject - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(10), true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(20), true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(30), true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(40), false)); - run_to_block(24); - // 20 survives - assert_eq!(Society::members(), vec![10, 20, 30, 40]); + assert_ok!(Society::defender_vote(Origin::signed(10), true)); + assert_ok!(Society::defender_vote(Origin::signed(20), true)); + assert_ok!(Society::defender_vote(Origin::signed(30), true)); + assert_ok!(Society::defender_vote(Origin::signed(40), false)); + + next_challenge(); + // 30 survives + assert_eq!(members(), vec![10, 20, 30, 40]); + // Reset votes for last challenge + assert_ok!(Society::cleanup_challenge(Origin::signed(0), 1, 10)); // Votes are reset - assert_eq!(>::get(10), None); - assert_eq!(>::get(20), None); - assert_eq!(>::get(30), None); - assert_eq!(>::get(40), None); + assert_eq!(DefenderVotes::::get(0, 10), None); + assert_eq!(DefenderVotes::::get(0, 20), None); + assert_eq!(DefenderVotes::::get(0, 30), None); + assert_eq!(DefenderVotes::::get(0, 40), None); + // One more time - assert_eq!(Society::defender(), Some(30)); + assert_eq!(Defending::::get().unwrap().0, 30); // 2 people say accept, 2 reject - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(10), true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(20), true)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(30), false)); - assert_ok!(Society::defender_vote(RuntimeOrigin::signed(40), false)); - run_to_block(32); - // 20 is suspended - assert_eq!(Society::members(), vec![10, 20, 40]); - assert_eq!(Society::suspended_member(30), true); + assert_ok!(Society::defender_vote(Origin::signed(10), true)); + assert_ok!(Society::defender_vote(Origin::signed(20), true)); + assert_ok!(Society::defender_vote(Origin::signed(30), false)); + assert_ok!(Society::defender_vote(Origin::signed(40), false)); + + next_challenge(); + // 30 is suspended + assert_eq!(members(), vec![10, 20, 40]); + assert_eq!( + SuspendedMembers::::get(30), + Some(MemberRecord { rank: 0, strikes: 0, vouching: None, index: 2 }) + ); + // Reset votes for last challenge + assert_ok!(Society::cleanup_challenge(Origin::signed(0), 2, 10)); // New defender is chosen - assert_eq!(Society::defender(), Some(20)); + assert_eq!(Defending::::get().unwrap().0, 20); // Votes are reset - assert_eq!(>::get(10), None); - assert_eq!(>::get(20), None); - assert_eq!(>::get(30), None); - assert_eq!(>::get(40), None); + assert_eq!(DefenderVotes::::get(0, 10), None); + assert_eq!(DefenderVotes::::get(0, 20), None); + assert_eq!(DefenderVotes::::get(0, 30), None); + assert_eq!(DefenderVotes::::get(0, 40), None); }); } @@ -666,38 +909,62 @@ fn challenges_work() { fn bad_vote_slash_works() { EnvBuilder::new().execute(|| { // Add some members - assert_ok!(Society::add_member(&20)); - assert_ok!(Society::add_member(&30)); - assert_ok!(Society::add_member(&40)); + place_members([20, 30, 40, 50]); + assert_eq!(members(), vec![10, 20, 30, 40, 50]); // Create some payouts - Society::bump_payout(&10, 5, 100); Society::bump_payout(&20, 5, 100); Society::bump_payout(&30, 5, 100); Society::bump_payout(&40, 5, 100); + Society::bump_payout(&50, 5, 100); // Check starting point - assert_eq!(Society::members(), vec![10, 20, 30, 40]); - assert_eq!(>::get(10), vec![(5, 100)]); - assert_eq!(>::get(20), vec![(5, 100)]); - assert_eq!(>::get(30), vec![(5, 100)]); - assert_eq!(>::get(40), vec![(5, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(30), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(40), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(50), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); // Create a new bid - assert_ok!(Society::bid(RuntimeOrigin::signed(50), 1000)); - run_to_block(4); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 50, false)); - assert_ok!(Society::vote(RuntimeOrigin::signed(20), 50, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(30), 50, false)); - assert_ok!(Society::vote(RuntimeOrigin::signed(40), 50, false)); - run_to_block(8); + assert_ok!(Society::bid(Origin::signed(60), 1000)); + next_intake(); + // Force 20 to be the skeptic, and make it vote against the settled majority. + Skeptic::::put(20); + assert_ok!(Society::vote(Origin::signed(20), 60, true)); + assert_ok!(Society::vote(Origin::signed(30), 60, false)); + assert_ok!(Society::vote(Origin::signed(40), 60, false)); + assert_ok!(Society::vote(Origin::signed(50), 60, false)); + conclude_intake(false, None); // Wrong voter gained a strike - assert_eq!(>::get(10), 0); - assert_eq!(>::get(20), 1); - assert_eq!(>::get(30), 0); - assert_eq!(>::get(40), 0); + assert_eq!(Members::::get(20).unwrap().strikes, 1); + assert_eq!(Members::::get(30).unwrap().strikes, 0); + assert_eq!(Members::::get(40).unwrap().strikes, 0); + assert_eq!(Members::::get(50).unwrap().strikes, 0); // Their payout is slashed, a random person is rewarded - assert_eq!(>::get(10), vec![(5, 100), (9, 2)]); - assert_eq!(>::get(20), vec![(5, 98)]); - assert_eq!(>::get(30), vec![(5, 100)]); - assert_eq!(>::get(40), vec![(5, 100)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(5, 50)].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(30), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(40), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(50), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); }); } @@ -705,17 +972,14 @@ fn bad_vote_slash_works() { fn user_cannot_bid_twice() { EnvBuilder::new().execute(|| { // Cannot bid twice - assert_ok!(Society::bid(RuntimeOrigin::signed(20), 100)); - assert_noop!(Society::bid(RuntimeOrigin::signed(20), 100), Error::::AlreadyBid); + assert_ok!(Society::bid(Origin::signed(20), 100)); + assert_noop!(Society::bid(Origin::signed(20), 100), Error::::AlreadyBid); // Cannot bid when vouched - assert_ok!(Society::vouch(RuntimeOrigin::signed(10), 30, 100, 100)); - assert_noop!(Society::bid(RuntimeOrigin::signed(30), 100), Error::::AlreadyBid); + assert_ok!(Society::vouch(Origin::signed(10), 30, 100, 100)); + assert_noop!(Society::bid(Origin::signed(30), 100), Error::::AlreadyBid); // Cannot vouch when already bid - assert_ok!(Society::add_member(&50)); - assert_noop!( - Society::vouch(RuntimeOrigin::signed(50), 20, 100, 100), - Error::::AlreadyBid - ); + place_members([50]); + assert_noop!(Society::vouch(Origin::signed(50), 20, 100, 100), Error::::AlreadyBid); }); } @@ -723,23 +987,19 @@ fn user_cannot_bid_twice() { fn vouching_handles_removed_member_with_bid() { EnvBuilder::new().execute(|| { // Add a member - assert_ok!(Society::add_member(&20)); + place_members([20]); // Have that member vouch for a user assert_ok!(Society::vouch(RuntimeOrigin::signed(20), 30, 1000, 100)); // That user is now a bid and the member is vouching - assert_eq!(>::get(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); - assert_eq!(>::get(20), Some(VouchingStatus::Vouching)); + assert_eq!(Bids::::get().into_inner(), vec![bid(30, Vouch(20, 100), 1000)]); + assert_eq!(Members::::get(20).unwrap().vouching, Some(VouchingStatus::Vouching)); // Suspend that member - Society::suspend_member(&20); - assert_eq!(>::get(20), true); - // Nothing changes yet - assert_eq!(>::get(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); - assert_eq!(>::get(20), Some(VouchingStatus::Vouching)); - // Remove member - assert_ok!(Society::judge_suspended_member(RuntimeOrigin::signed(2), 20, false)); + assert_ok!(Society::suspend_member(&20)); // Bid is removed, vouching status is removed - assert_eq!(>::get(), vec![]); - assert_eq!(>::get(20), None); + let r = MemberRecord { rank: 0, strikes: 0, vouching: None, index: 1 }; + assert_eq!(SuspendedMembers::::get(20), Some(r)); + assert_eq!(Bids::::get().into_inner(), vec![]); + assert_eq!(Members::::get(20), None); }); } @@ -747,127 +1007,153 @@ fn vouching_handles_removed_member_with_bid() { fn vouching_handles_removed_member_with_candidate() { EnvBuilder::new().execute(|| { // Add a member - assert_ok!(Society::add_member(&20)); + place_members([20]); // Have that member vouch for a user assert_ok!(Society::vouch(RuntimeOrigin::signed(20), 30, 1000, 100)); // That user is now a bid and the member is vouching - assert_eq!(>::get(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); - assert_eq!(>::get(20), Some(VouchingStatus::Vouching)); + assert_eq!(Bids::::get().into_inner(), vec![bid(30, Vouch(20, 100), 1000)]); + assert_eq!(Members::::get(20).unwrap().vouching, Some(VouchingStatus::Vouching)); + // Make that bid a candidate - run_to_block(4); - assert_eq!(Society::candidates(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); + next_intake(); + assert_eq!(candidacies(), vec![(30, candidacy(1, 1000, Vouch(20, 100), 0, 0))]); // Suspend that member - Society::suspend_member(&20); - assert_eq!(>::get(20), true); - // Nothing changes yet - assert_eq!(Society::candidates(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); - assert_eq!(>::get(20), Some(VouchingStatus::Vouching)); - // Remove member - assert_ok!(Society::judge_suspended_member(RuntimeOrigin::signed(2), 20, false)); - // Vouching status is removed, but candidate is still in the queue - assert_eq!(>::get(20), None); - assert_eq!(Society::candidates(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); + assert_ok!(Society::suspend_member(&20)); + assert_eq!(SuspendedMembers::::contains_key(20), true); + + // Nothing changes yet in the candidacy, though the member now forgets. + assert_eq!(candidacies(), vec![(30, candidacy(1, 1000, Vouch(20, 100), 0, 0))]); + // Candidate wins - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 30, true)); - run_to_block(8); - assert_eq!(Society::members(), vec![10, 30]); + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + conclude_intake(false, None); + assert_eq!(members(), vec![10, 30]); // Payout does not go to removed member - assert_eq!(>::get(20), vec![]); - assert_eq!(>::get(30), vec![(9, 1000)]); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![].try_into().unwrap() } + ); + assert_eq!( + Payouts::::get(30), + PayoutRecord { paid: 0, payouts: vec![(8, 1000)].try_into().unwrap() } + ); }); } #[test] fn votes_are_working() { EnvBuilder::new().execute(|| { + place_members([20]); // Users make bids of various amounts assert_ok!(Society::bid(RuntimeOrigin::signed(50), 500)); assert_ok!(Society::bid(RuntimeOrigin::signed(40), 400)); assert_ok!(Society::bid(RuntimeOrigin::signed(30), 300)); // Rotate period - run_to_block(4); + next_intake(); // A member votes for these candidates to join the society - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 30, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 40, true)); + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + assert_ok!(Society::vote(Origin::signed(20), 30, true)); + assert_ok!(Society::vote(Origin::signed(10), 40, true)); // You cannot vote for a non-candidate - assert_noop!( - Society::vote(RuntimeOrigin::signed(10), 50, true), - Error::::NotCandidate - ); + assert_noop!(Society::vote(Origin::signed(10), 50, true), Error::::NotCandidate); // Votes are stored - assert_eq!(>::get(30, 10), Some(Vote::Approve)); - assert_eq!(>::get(40, 10), Some(Vote::Approve)); - assert_eq!(>::get(50, 10), None); - run_to_block(8); + assert_eq!(Votes::::get(30, 10), Some(Vote { approve: true, weight: 4 })); + assert_eq!(Votes::::get(30, 20), Some(Vote { approve: true, weight: 1 })); + assert_eq!(Votes::::get(40, 10), Some(Vote { approve: true, weight: 4 })); + assert_eq!(Votes::::get(50, 10), None); + conclude_intake(false, None); + // Cleanup the candidacy + assert_ok!(Society::cleanup_candidacy(Origin::signed(0), 30, 10)); + assert_ok!(Society::cleanup_candidacy(Origin::signed(0), 40, 10)); // Candidates become members after a period rotation - assert_eq!(Society::members(), vec![10, 30, 40]); + assert_eq!(members(), vec![10, 20, 30, 40]); // Votes are cleaned up - assert_eq!(>::get(30, 10), None); - assert_eq!(>::get(40, 10), None); + assert_eq!(Votes::::get(30, 10), None); + assert_eq!(Votes::::get(30, 20), None); + assert_eq!(Votes::::get(40, 10), None); }); } #[test] -fn max_limits_work() { - EnvBuilder::new().with_pot(100000).execute(|| { +fn max_bids_work() { + EnvBuilder::new().execute(|| { // Max bids is 1000, when extra bids come in, it pops the larger ones off the stack. // Try to put 1010 users into the bid pool - for i in (100..1110).rev() { - // Give them some funds - let _ = Balances::make_free_balance_be(&(i as u128), 1000); - assert_ok!(Society::bid(RuntimeOrigin::signed(i as u128), i)); + for i in (0..=10).rev() { + // Give them some funds and bid + let _ = Balances::make_free_balance_be(&((i + 100) as u128), 1000); + assert_ok!(Society::bid(Origin::signed((i + 100) as u128), i)); } - let bids = >::get(); + let bids = Bids::::get(); // Length is 1000 - assert_eq!(bids.len(), 1000); + assert_eq!(bids.len(), 10); // First bid is smallest number (100) - assert_eq!(bids[0], create_bid(100, 100, BidKind::Deposit(25))); + assert_eq!(bids[0], bid(100, Deposit(25), 0)); // Last bid is smallest number + 99 (1099) - assert_eq!(bids[999], create_bid(1099, 1099, BidKind::Deposit(25))); - // Rotate period - run_to_block(4); - // Max of 10 candidates - assert_eq!(Society::candidates().len(), 10); - // Fill up membership, max 100, we will do just 95 - for i in 2000..2095 { - assert_ok!(Society::add_member(&(i as u128))); - } - // Remember there was 1 original member, so 96 total - assert_eq!(Society::members().len(), 96); - // Rotate period - run_to_block(8); - // Only of 4 candidates possible now - assert_eq!(Society::candidates().len(), 4); - // Fill up members with suspended candidates from the first rotation - for i in 100..104 { - assert_ok!(Society::judge_suspended_candidate( - RuntimeOrigin::signed(2), - i, - Judgement::Approve - )); - } - assert_eq!(Society::members().len(), 100); - // Can't add any more members - assert_noop!(Society::add_member(&98), Error::::MaxMembers); - // However, a fringe scenario allows for in-progress candidates to increase the membership - // pool, but it has no real after-effects. - for i in Society::members().iter() { - assert_ok!(Society::vote(RuntimeOrigin::signed(*i), 110, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(*i), 111, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(*i), 112, true)); + assert_eq!(bids[9], bid(109, Deposit(25), 9)); + }); +} + +#[test] +fn candidates_are_limited_by_membership_size() { + EnvBuilder::new().execute(|| { + // Fill up some membership + place_members([1, 2, 3, 4, 5, 6, 7, 8]); + // One place left from 10 + assert_eq!(members().len(), 9); + + assert_ok!(Society::bid(Origin::signed(20), 0)); + assert_ok!(Society::bid(Origin::signed(30), 1)); + next_intake(); + assert_eq!(candidates().len(), 1); + }); +} + +#[test] +fn candidates_are_limited_by_maximum() { + EnvBuilder::new().execute(|| { + // Nine places left from 10 + assert_eq!(members().len(), 1); + + // Nine bids + for i in (1..=9).rev() { + // Give them some funds and bid + let _ = Balances::make_free_balance_be(&((i + 100) as u128), 1000); + assert_ok!(Society::bid(Origin::signed((i + 100) as u128), i)); } - // Rotate period - run_to_block(12); - // Members length is over 100, no problem... - assert_eq!(Society::members().len(), 103); - // No candidates because full - assert_eq!(Society::candidates().len(), 0); - // Increase member limit - assert_ok!(Society::set_max_members(RuntimeOrigin::root(), 200)); - // Rotate period - run_to_block(16); - // Candidates are back! - assert_eq!(Society::candidates().len(), 10); + next_intake(); + + // Still only 8 candidates. + assert_eq!(candidates().len(), 8); + }); +} + +#[test] +fn too_many_candidates_cannot_overflow_membership() { + EnvBuilder::new().execute(|| { + // One place left + place_members([1, 2, 3, 4, 5, 6, 7, 8]); + assert_ok!(Society::bid(Origin::signed(20), 0)); + assert_ok!(Society::bid(Origin::signed(30), 1)); + next_intake(); + // Candidate says a candidate. + next_intake(); + // Another candidate taken. + // Both approved. + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + next_voting(); + assert_ok!(Society::claim_membership(Origin::signed(20))); + assert_noop!(Society::claim_membership(Origin::signed(30)), Error::::MaxMembers); + + // Maximum members. + assert_eq!(members().len(), 10); + // Still 1 candidate. + assert_eq!(candidates().len(), 1); + + // Increase max-members and the candidate can get in. + assert_ok!(Society::set_parameters(Origin::signed(10), 11, 8, 3, 25)); + assert_ok!(Society::claim_membership(Origin::signed(30))); }); } @@ -885,32 +1171,30 @@ fn zero_bid_works() { assert_ok!(Society::bid(RuntimeOrigin::signed(40), 0)); // Rotate period - run_to_block(4); + next_intake(); // Pot is 1000 after "PeriodSpend" - assert_eq!(Society::pot(), 1000); + assert_eq!(Pot::::get(), 1000); assert_eq!(Balances::free_balance(Society::account_id()), 10_000); // Choose smallest bidding users whose total is less than pot, with only one zero bid. assert_eq!( - Society::candidates(), + candidacies(), vec![ - create_bid(0, 30, BidKind::Deposit(25)), - create_bid(300, 50, BidKind::Deposit(25)), - create_bid(400, 60, BidKind::Deposit(25)), + (30, candidacy(1, 0, Deposit(25), 0, 0)), + (50, candidacy(1, 300, Deposit(25), 0, 0)), + (60, candidacy(1, 400, Deposit(25), 0, 0)), ] ); - assert_eq!( - >::get(), - vec![create_bid(0, 20, BidKind::Deposit(25)), create_bid(0, 40, BidKind::Deposit(25)),] - ); + assert_eq!(Bids::::get(), vec![bid(20, Deposit(25), 0), bid(40, Deposit(25), 0),],); // A member votes for these candidates to join the society - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 30, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 50, true)); - assert_ok!(Society::vote(RuntimeOrigin::signed(10), 60, true)); - run_to_block(8); + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + assert_ok!(Society::vote(Origin::signed(10), 50, true)); + assert_ok!(Society::vote(Origin::signed(10), 60, true)); + conclude_intake(false, None); // Candidates become members after a period rotation - assert_eq!(Society::members(), vec![10, 30, 50, 60]); + assert_eq!(members(), vec![10, 30, 50, 60]); + next_intake(); // The zero bid is selected as head - assert_eq!(Society::head(), Some(30)); + assert_eq!(Head::::get(), Some(30)); }); } @@ -922,8 +1206,9 @@ fn bids_ordered_correctly() { for i in 0..5 { for j in 0..5 { // Give them some funds - let _ = Balances::make_free_balance_be(&(100 + (i * 5 + j) as u128), 1000); - assert_ok!(Society::bid(RuntimeOrigin::signed(100 + (i * 5 + j) as u128), j)); + let who = 100 + (i * 5 + j) as u128; + let _ = Balances::make_free_balance_be(&who, 1000); + assert_ok!(Society::bid(Origin::signed(who), j)); } } @@ -931,10 +1216,79 @@ fn bids_ordered_correctly() { for j in 0..5 { for i in 0..5 { - final_list.push(create_bid(j, 100 + (i * 5 + j) as u128, BidKind::Deposit(25))); + final_list.push(bid(100 + (i * 5 + j) as u128, Deposit(25), j)); } } + let max_bids: u32 = ::MaxBids::get(); + final_list.truncate(max_bids as usize); + assert_eq!(Bids::::get(), final_list); + }); +} + +#[test] +fn waive_repay_works() { + EnvBuilder::new().execute(|| { + place_members([20, 30]); + Society::bump_payout(&20, 5, 100); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![(5, 100)].try_into().unwrap() } + ); + assert_eq!(Members::::get(20).unwrap().rank, 0); + assert_ok!(Society::waive_repay(Origin::signed(20), 100)); + assert_eq!( + Payouts::::get(20), + PayoutRecord { paid: 0, payouts: vec![].try_into().unwrap() } + ); + assert_eq!(Members::::get(10).unwrap().rank, 1); + assert_eq!(Balances::free_balance(20), 50); + }); +} + +#[test] +fn punish_skeptic_works() { + EnvBuilder::new().execute(|| { + place_members([20]); + assert_ok!(Society::bid(Origin::signed(30), 0)); + next_intake(); + // Force 20 to be Skeptic so it gets a strike. + Skeptic::::put(20); + next_voting(); + // 30 decides to punish the skeptic (20). + assert_ok!(Society::punish_skeptic(Origin::signed(30))); + // 20 gets 1 strike. + assert_eq!(Members::::get(20).unwrap().strikes, 1); + let candidacy = Candidates::::get(&30).unwrap(); + // 30 candidacy has changed. + assert_eq!(candidacy.skeptic_struck, true); + }); +} - assert_eq!(>::get(), final_list); +#[test] +fn resign_candidacy_works() { + EnvBuilder::new().execute(|| { + assert_ok!(Society::bid(Origin::signed(30), 45)); + next_intake(); + assert_eq!(candidates(), vec![30]); + assert_ok!(Society::resign_candidacy(Origin::signed(30))); + // 30 candidacy has gone. + assert_eq!(candidates(), vec![]); + }); +} + +#[test] +fn drop_candidate_works() { + EnvBuilder::new().execute(|| { + place_members([20, 30]); + assert_ok!(Society::bid(Origin::signed(40), 45)); + next_intake(); + assert_eq!(candidates(), vec![40]); + assert_ok!(Society::vote(Origin::signed(10), 40, false)); + assert_ok!(Society::vote(Origin::signed(20), 40, false)); + assert_ok!(Society::vote(Origin::signed(30), 40, false)); + run_to_block(12); + assert_ok!(Society::drop_candidate(Origin::signed(50), 40)); + // 40 candidacy has gone. + assert_eq!(candidates(), vec![]); }); } diff --git a/frame/society/src/weights.rs b/frame/society/src/weights.rs new file mode 100644 index 0000000000000..d113f617c886c --- /dev/null +++ b/frame/society/src/weights.rs @@ -0,0 +1,375 @@ +// 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_society +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2022-09-13, 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_society +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --template=./.maintain/frame-weight-template.hbs +// --header=./HEADER-APACHE2 +// --output=./frame/society/src/weights.rs + +#![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_society. +pub trait WeightInfo { + fn bid() -> Weight; + fn unbid() -> Weight; + fn vouch() -> Weight; + fn unvouch() -> Weight; + fn vote() -> Weight; + fn defender_vote() -> Weight; + fn payout() -> Weight; + fn waive_repay() -> Weight; + fn found_society() -> Weight; + fn dissolve() -> Weight; + fn judge_suspended_member() -> Weight; + fn set_parameters() -> Weight; + fn punish_skeptic() -> Weight; + fn claim_membership() -> Weight; + fn bestow_membership() -> Weight; + fn kick_candidate() -> Weight; + fn resign_candidacy() -> Weight; + fn drop_candidate() -> Weight; + fn cleanup_candidacy() -> Weight; + fn cleanup_challenge() -> Weight; +} + +/// Weights for pallet_society using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + // Storage: Society Bids (r:1 w:1) + // Storage: Society Candidates (r:1 w:0) + // Storage: Society Members (r:1 w:0) + // Storage: Society SuspendedMembers (r:1 w:0) + // Storage: Society Parameters (r:1 w:0) + fn bid() -> Weight { + Weight::zero() + } + // Storage: Society Bids (r:1 w:1) + fn unbid() -> Weight { + Weight::zero() + } + // Storage: Society Bids (r:1 w:1) + // Storage: Society Candidates (r:1 w:0) + // Storage: Society Members (r:2 w:1) + // Storage: Society SuspendedMembers (r:1 w:0) + fn vouch() -> Weight { + Weight::zero() + } + // Storage: Society Bids (r:1 w:1) + // Storage: Society Members (r:1 w:1) + fn unvouch() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society Members (r:1 w:0) + // Storage: Society Votes (r:1 w:1) + fn vote() -> Weight { + Weight::zero() + } + // Storage: Society Defending (r:1 w:1) + // Storage: Society Members (r:1 w:0) + // Storage: Society ChallengeRoundCount (r:1 w:0) + // Storage: Society DefenderVotes (r:1 w:1) + fn defender_vote() -> Weight { + Weight::zero() + } + // Storage: Society Members (r:1 w:0) + // Storage: Society Payouts (r:1 w:1) + // Storage: System Account (r:1 w:1) + fn payout() -> Weight { + Weight::zero() + } + // Storage: Society Members (r:1 w:1) + // Storage: Society Payouts (r:1 w:1) + fn waive_repay() -> Weight { + Weight::zero() + } + // Storage: Society Head (r:1 w:1) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society MemberByIndex (r:0 w:1) + // Storage: Society Founder (r:0 w:1) + // Storage: Society Rules (r:0 w:1) + // Storage: Society Members (r:0 w:1) + // Storage: Society Parameters (r:0 w:1) + fn found_society() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:1) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society Head (r:0 w:1) + // Storage: Society Defending (r:0 w:1) + // Storage: Society ChallengeRoundCount (r:0 w:1) + // Storage: Society MemberByIndex (r:0 w:5) + // Storage: Society Skeptic (r:0 w:1) + // Storage: Society Candidates (r:0 w:4) + // Storage: Society Pot (r:0 w:1) + // Storage: Society Rules (r:0 w:1) + // Storage: Society Votes (r:0 w:4) + // Storage: Society Members (r:0 w:5) + // Storage: Society RoundCount (r:0 w:1) + // Storage: Society Bids (r:0 w:1) + // Storage: Society Parameters (r:0 w:1) + // Storage: Society NextHead (r:0 w:1) + fn dissolve() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society SuspendedMembers (r:1 w:1) + // Storage: Society Payouts (r:1 w:0) + // Storage: Society Pot (r:1 w:1) + fn judge_suspended_member() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society MemberCount (r:1 w:0) + // Storage: Society Parameters (r:0 w:1) + fn set_parameters() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + // Storage: Society Skeptic (r:1 w:0) + // Storage: Society Votes (r:1 w:0) + // Storage: Society Members (r:1 w:1) + // Storage: Society Parameters (r:1 w:0) + fn punish_skeptic() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + // Storage: Society Parameters (r:1 w:0) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society NextHead (r:1 w:1) + // Storage: System Account (r:1 w:1) + // Storage: Society MemberByIndex (r:0 w:1) + // Storage: Society Members (r:0 w:1) + fn claim_membership() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + // Storage: Society Parameters (r:1 w:0) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society NextHead (r:1 w:1) + // Storage: System Account (r:1 w:1) + // Storage: Society MemberByIndex (r:0 w:1) + // Storage: Society Members (r:0 w:1) + fn bestow_membership() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + fn kick_candidate() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + fn resign_candidacy() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + fn drop_candidate() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:0) + // Storage: Society VoteClearCursor (r:1 w:0) + // Storage: Society Votes (r:0 w:2) + fn cleanup_candidacy() -> Weight { + Weight::zero() + } + // Storage: Society ChallengeRoundCount (r:1 w:0) + // Storage: Society DefenderVotes (r:0 w:1) + fn cleanup_challenge() -> Weight { + Weight::zero() + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + // Storage: Society Bids (r:1 w:1) + // Storage: Society Candidates (r:1 w:0) + // Storage: Society Members (r:1 w:0) + // Storage: Society SuspendedMembers (r:1 w:0) + // Storage: Society Parameters (r:1 w:0) + fn bid() -> Weight { + Weight::zero() + } + // Storage: Society Bids (r:1 w:1) + fn unbid() -> Weight { + Weight::zero() + } + // Storage: Society Bids (r:1 w:1) + // Storage: Society Candidates (r:1 w:0) + // Storage: Society Members (r:2 w:1) + // Storage: Society SuspendedMembers (r:1 w:0) + fn vouch() -> Weight { + Weight::zero() + } + // Storage: Society Bids (r:1 w:1) + // Storage: Society Members (r:1 w:1) + fn unvouch() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society Members (r:1 w:0) + // Storage: Society Votes (r:1 w:1) + fn vote() -> Weight { + Weight::zero() + } + // Storage: Society Defending (r:1 w:1) + // Storage: Society Members (r:1 w:0) + // Storage: Society ChallengeRoundCount (r:1 w:0) + // Storage: Society DefenderVotes (r:1 w:1) + fn defender_vote() -> Weight { + Weight::zero() + } + // Storage: Society Members (r:1 w:0) + // Storage: Society Payouts (r:1 w:1) + // Storage: System Account (r:1 w:1) + fn payout() -> Weight { + Weight::zero() + } + // Storage: Society Members (r:1 w:1) + // Storage: Society Payouts (r:1 w:1) + fn waive_repay() -> Weight { + Weight::zero() + } + // Storage: Society Head (r:1 w:1) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society MemberByIndex (r:0 w:1) + // Storage: Society Founder (r:0 w:1) + // Storage: Society Rules (r:0 w:1) + // Storage: Society Members (r:0 w:1) + // Storage: Society Parameters (r:0 w:1) + fn found_society() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:1) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society Head (r:0 w:1) + // Storage: Society Defending (r:0 w:1) + // Storage: Society ChallengeRoundCount (r:0 w:1) + // Storage: Society MemberByIndex (r:0 w:5) + // Storage: Society Skeptic (r:0 w:1) + // Storage: Society Candidates (r:0 w:4) + // Storage: Society Pot (r:0 w:1) + // Storage: Society Rules (r:0 w:1) + // Storage: Society Votes (r:0 w:4) + // Storage: Society Members (r:0 w:5) + // Storage: Society RoundCount (r:0 w:1) + // Storage: Society Bids (r:0 w:1) + // Storage: Society Parameters (r:0 w:1) + // Storage: Society NextHead (r:0 w:1) + fn dissolve() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society SuspendedMembers (r:1 w:1) + // Storage: Society Payouts (r:1 w:0) + // Storage: Society Pot (r:1 w:1) + fn judge_suspended_member() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society MemberCount (r:1 w:0) + // Storage: Society Parameters (r:0 w:1) + fn set_parameters() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + // Storage: Society Skeptic (r:1 w:0) + // Storage: Society Votes (r:1 w:0) + // Storage: Society Members (r:1 w:1) + // Storage: Society Parameters (r:1 w:0) + fn punish_skeptic() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + // Storage: Society Parameters (r:1 w:0) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society NextHead (r:1 w:1) + // Storage: System Account (r:1 w:1) + // Storage: Society MemberByIndex (r:0 w:1) + // Storage: Society Members (r:0 w:1) + fn claim_membership() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + // Storage: Society Parameters (r:1 w:0) + // Storage: Society MemberCount (r:1 w:1) + // Storage: Society NextHead (r:1 w:1) + // Storage: System Account (r:1 w:1) + // Storage: Society MemberByIndex (r:0 w:1) + // Storage: Society Members (r:0 w:1) + fn bestow_membership() -> Weight { + Weight::zero() + } + // Storage: Society Founder (r:1 w:0) + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + fn kick_candidate() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + fn resign_candidacy() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:1) + // Storage: Society RoundCount (r:1 w:0) + fn drop_candidate() -> Weight { + Weight::zero() + } + // Storage: Society Candidates (r:1 w:0) + // Storage: Society VoteClearCursor (r:1 w:0) + // Storage: Society Votes (r:0 w:2) + fn cleanup_candidacy() -> Weight { + Weight::zero() + } + // Storage: Society ChallengeRoundCount (r:1 w:0) + // Storage: Society DefenderVotes (r:0 w:1) + fn cleanup_challenge() -> Weight { + Weight::zero() + } +} diff --git a/frame/support/src/storage/mod.rs b/frame/support/src/storage/mod.rs index 6cb8b5915ca85..470af0a1d5520 100644 --- a/frame/support/src/storage/mod.rs +++ b/frame/support/src/storage/mod.rs @@ -202,6 +202,18 @@ pub trait StorageMap { f: F, ) -> Result; + /// Mutate the value under a key if the value already exists. Do nothing and return the default + /// value if not. + fn mutate_extant, R: Default, F: FnOnce(&mut V) -> R>( + key: KeyArg, + f: F, + ) -> R { + Self::mutate_exists(key, |maybe_v| match maybe_v { + Some(ref mut value) => f(value), + None => R::default(), + }) + } + /// Mutate the value under a key. /// /// Deletes the item if mutated to a `None`. diff --git a/frame/support/src/storage/types/map.rs b/frame/support/src/storage/types/map.rs index ba6615bb8d381..c919dc6745d78 100644 --- a/frame/support/src/storage/types/map.rs +++ b/frame/support/src/storage/types/map.rs @@ -186,6 +186,14 @@ where >::try_mutate(key, f) } + /// Mutate the value under a key iff it exists. Do nothing and return the default value if not. + pub fn mutate_extant, R: Default, F: FnOnce(&mut Value) -> R>( + key: KeyArg, + f: F, + ) -> R { + >::mutate_extant(key, f) + } + /// Mutate the value under a key. Deletes the item if mutated to a `None`. pub fn mutate_exists, R, F: FnOnce(&mut Option) -> R>( key: KeyArg, @@ -366,6 +374,16 @@ where >::iter_from(starting_raw_key) } + /// Enumerate all elements in the map after a specified `starting_key` in no + /// particular order. + /// + /// If you alter the map while doing this, you'll get undefined results. + pub fn iter_from_key( + starting_key: impl EncodeLike, + ) -> crate::storage::PrefixIterator<(Key, Value)> { + Self::iter_from(Self::hashed_key_for(starting_key)) + } + /// Enumerate all keys in the map in no particular order. /// /// If you alter the map while doing this, you'll get undefined results. @@ -381,6 +399,16 @@ where >::iter_keys_from(starting_raw_key) } + /// Enumerate all keys in the map after a specified `starting_key` in no particular + /// order. + /// + /// If you alter the map while doing this, you'll get undefined results. + pub fn iter_keys_from_key( + starting_key: impl EncodeLike, + ) -> crate::storage::KeyPrefixIterator { + Self::iter_keys_from(Self::hashed_key_for(starting_key)) + } + /// Remove all elements from the map and iterate through them in no particular order. /// /// If you add elements to the map while doing this, you'll get undefined results. diff --git a/frame/support/src/traits/storage.rs b/frame/support/src/traits/storage.rs index dcd0843b88da9..5947be57ae1c6 100644 --- a/frame/support/src/traits/storage.rs +++ b/frame/support/src/traits/storage.rs @@ -37,6 +37,12 @@ pub trait Instance: 'static { const INDEX: u8; } +// Dummy implementation for `()`. +impl Instance for () { + const PREFIX: &'static str = ""; + const INDEX: u8 = 0; +} + /// An instance of a storage in a pallet. /// /// Define an instance for an individual storage inside a pallet. diff --git a/primitives/core/Cargo.toml b/primitives/core/Cargo.toml index 98df775d289c1..6c043d5a21499 100644 --- a/primitives/core/Cargo.toml +++ b/primitives/core/Cargo.toml @@ -17,7 +17,7 @@ codec = { package = "parity-scale-codec", version = "3.2.2", default-features = scale-info = { version = "2.5.0", default-features = false, features = ["derive"] } log = { version = "0.4.17", default-features = false } serde = { version = "1.0.163", optional = true, default-features = false, features = ["derive", "alloc"] } -bounded-collections = { version = "0.1.7", default-features = false } +bounded-collections = { version = "0.1.8", default-features = false } primitive-types = { version = "0.12.0", default-features = false, features = ["codec", "scale-info"] } impl-serde = { version = "0.4.0", default-features = false, optional = true } hash-db = { version = "0.16.0", default-features = false } diff --git a/primitives/core/src/crypto.rs b/primitives/core/src/crypto.rs index 5947603cd0942..800942065f558 100644 --- a/primitives/core/src/crypto.rs +++ b/primitives/core/src/crypto.rs @@ -1158,6 +1158,52 @@ pub mod key_types { pub const DUMMY: KeyTypeId = KeyTypeId(*b"dumy"); } +/// Create random values of `Self` given a stream of entropy. +pub trait FromEntropy: Sized { + /// Create a random value of `Self` given a stream of random bytes on `input`. May only fail if + /// `input` has an error. + fn from_entropy(input: &mut impl codec::Input) -> Result; +} + +impl FromEntropy for bool { + fn from_entropy(input: &mut impl codec::Input) -> Result { + Ok(input.read_byte()? % 2 == 1) + } +} + +macro_rules! impl_from_entropy { + ($type:ty , $( $others:tt )*) => { + impl_from_entropy!($type); + impl_from_entropy!($( $others )*); + }; + ($type:ty) => { + impl FromEntropy for $type { + fn from_entropy(input: &mut impl codec::Input) -> Result { + ::decode(input) + } + } + } +} + +macro_rules! impl_from_entropy_base { + ($type:ty , $( $others:tt )*) => { + impl_from_entropy_base!($type); + impl_from_entropy_base!($( $others )*); + }; + ($type:ty) => { + impl_from_entropy!($type, + [$type; 1], [$type; 2], [$type; 3], [$type; 4], [$type; 5], [$type; 6], [$type; 7], [$type; 8], + [$type; 9], [$type; 10], [$type; 11], [$type; 12], [$type; 13], [$type; 14], [$type; 15], [$type; 16], + [$type; 17], [$type; 18], [$type; 19], [$type; 20], [$type; 21], [$type; 22], [$type; 23], [$type; 24], + [$type; 25], [$type; 26], [$type; 27], [$type; 28], [$type; 29], [$type; 30], [$type; 31], [$type; 32], + [$type; 36], [$type; 40], [$type; 44], [$type; 48], [$type; 56], [$type; 64], [$type; 72], [$type; 80], + [$type; 96], [$type; 112], [$type; 128], [$type; 160], [$type; 192], [$type; 224], [$type; 256] + ); + } +} + +impl_from_entropy_base!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); + #[cfg(test)] mod tests { use super::*; diff --git a/primitives/core/src/ecdsa.rs b/primitives/core/src/ecdsa.rs index 0c997bc290008..872447122bbb8 100644 --- a/primitives/core/src/ecdsa.rs +++ b/primitives/core/src/ecdsa.rs @@ -73,6 +73,14 @@ type Seed = [u8; 32]; )] pub struct Public(pub [u8; 33]); +impl crate::crypto::FromEntropy for Public { + fn from_entropy(input: &mut impl codec::Input) -> Result { + let mut result = Self([0u8; 33]); + input.read(&mut result.0[..])?; + Ok(result) + } +} + impl Public { /// A new instance from the given 33-byte `data`. /// diff --git a/primitives/core/src/ed25519.rs b/primitives/core/src/ed25519.rs index 29da78626f850..ba947990e1c0b 100644 --- a/primitives/core/src/ed25519.rs +++ b/primitives/core/src/ed25519.rs @@ -31,7 +31,9 @@ use scale_info::TypeInfo; #[cfg(feature = "serde")] use crate::crypto::Ss58Codec; -use crate::crypto::{CryptoType, CryptoTypeId, Derive, Public as TraitPublic, UncheckedFrom}; +use crate::crypto::{ + CryptoType, CryptoTypeId, Derive, FromEntropy, Public as TraitPublic, UncheckedFrom, +}; #[cfg(feature = "full_crypto")] use crate::crypto::{DeriveError, DeriveJunction, Pair as TraitPair, SecretStringError}; #[cfg(feature = "full_crypto")] @@ -79,6 +81,14 @@ pub struct Pair { secret: SigningKey, } +impl FromEntropy for Public { + fn from_entropy(input: &mut impl codec::Input) -> Result { + let mut result = Self([0u8; 32]); + input.read(&mut result.0[..])?; + Ok(result) + } +} + impl AsRef<[u8; 32]> for Public { fn as_ref(&self) -> &[u8; 32] { &self.0 diff --git a/primitives/core/src/sr25519.rs b/primitives/core/src/sr25519.rs index cfcdd6a9fdd41..963ca1c3fedc3 100644 --- a/primitives/core/src/sr25519.rs +++ b/primitives/core/src/sr25519.rs @@ -37,7 +37,10 @@ use schnorrkel::{ use sp_std::vec::Vec; use crate::{ - crypto::{ByteArray, CryptoType, CryptoTypeId, Derive, Public as TraitPublic, UncheckedFrom}, + crypto::{ + ByteArray, CryptoType, CryptoTypeId, Derive, FromEntropy, Public as TraitPublic, + UncheckedFrom, + }, hash::{H256, H512}, }; use codec::{Decode, Encode, MaxEncodedLen}; @@ -91,6 +94,14 @@ impl Clone for Pair { } } +impl FromEntropy for Public { + fn from_entropy(input: &mut impl codec::Input) -> Result { + let mut result = Self([0u8; 32]); + input.read(&mut result.0[..])?; + Ok(result) + } +} + impl AsRef<[u8; 32]> for Public { fn as_ref(&self) -> &[u8; 32] { &self.0 diff --git a/primitives/runtime/src/lib.rs b/primitives/runtime/src/lib.rs index 56e4efcad2c05..d29938d0a8610 100644 --- a/primitives/runtime/src/lib.rs +++ b/primitives/runtime/src/lib.rs @@ -43,7 +43,7 @@ pub use sp_core::storage::StateVersion; pub use sp_core::storage::{Storage, StorageChild}; use sp_core::{ - crypto::{self, ByteArray}, + crypto::{self, ByteArray, FromEntropy}, ecdsa, ed25519, hash::{H256, H512}, sr25519, @@ -311,6 +311,16 @@ pub enum MultiSigner { Ecdsa(ecdsa::Public), } +impl FromEntropy for MultiSigner { + fn from_entropy(input: &mut impl codec::Input) -> Result { + Ok(match input.read_byte()? % 3 { + 0 => Self::Ed25519(FromEntropy::from_entropy(input)?), + 1 => Self::Sr25519(FromEntropy::from_entropy(input)?), + 2.. => Self::Ecdsa(FromEntropy::from_entropy(input)?), + }) + } +} + /// NOTE: This implementations is required by `SimpleAddressDeterminer`, /// we convert the hash into some AccountId, it's fine to use any scheme. impl> crypto::UncheckedFrom for MultiSigner {