diff --git a/substrate/Cargo.lock b/substrate/Cargo.lock index eb8d93ddba..d649721e43 100644 --- a/substrate/Cargo.lock +++ b/substrate/Cargo.lock @@ -3930,6 +3930,22 @@ dependencies = [ "sp-trie 2.0.0", ] +[[package]] +name = "pallet-society" +version = "2.0.0" +dependencies = [ + "frame-support 2.0.0", + "frame-system 2.0.0", + "pallet-balances 2.0.0", + "parity-scale-codec 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_chacha 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.103 (registry+https://github.com/rust-lang/crates.io-index)", + "sp-core 2.0.0", + "sp-io 2.0.0", + "sp-runtime 2.0.0", + "sp-std 2.0.0", +] + [[package]] name = "pallet-staking" version = "2.0.0" diff --git a/substrate/Cargo.toml b/substrate/Cargo.toml index 7404744a41..ade5cd24e5 100644 --- a/substrate/Cargo.toml +++ b/substrate/Cargo.toml @@ -80,6 +80,7 @@ members = [ "frame/randomness-collective-flip", "frame/scored-pool", "frame/session", + "frame/society", "frame/staking", "frame/staking/reward-curve", "frame/sudo", diff --git a/substrate/frame/society/Cargo.toml b/substrate/frame/society/Cargo.toml new file mode 100644 index 0000000000..94000e898d --- /dev/null +++ b/substrate/frame/society/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pallet-society" +version = "2.0.0" +authors = ["Parity Technologies "] +edition = "2018" + +[dependencies] +serde = { version = "1.0.101", optional = true } +codec = { package = "parity-scale-codec", version = "1.0.0", default-features = false, features = ["derive"] } +sp-io ={ path = "../../primitives/io", default-features = false } +sp-runtime = { version = "2.0.0", default-features = false, path = "../../primitives/runtime" } +sp-std = { version = "2.0.0", default-features = false, path = "../../primitives/std" } +frame-support = { version = "2.0.0", default-features = false, path = "../support" } +frame-system = { version = "2.0.0", default-features = false, path = "../system" } +rand_chacha = { version = "0.2", default-features = false } + +[dev-dependencies] +sp-core = { version = "2.0.0", path = "../../primitives/core" } +pallet-balances = { version = "2.0.0", path = "../balances" } + +[features] +default = ["std"] +std = [ + "codec/std", + "serde", + "sp-io/std", + "sp-runtime/std", + "rand_chacha/std", + "sp-std/std", + "frame-support/std", + "frame-system/std", +] diff --git a/substrate/frame/society/src/lib.rs b/substrate/frame/society/src/lib.rs new file mode 100644 index 0000000000..d5bf0bcf4e --- /dev/null +++ b/substrate/frame/society/src/lib.rs @@ -0,0 +1,1507 @@ +// Copyright 2020 Parity Technologies (UK) Ltd. +// This file is part of Substrate. + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! # Society Module +//! +//! - [`society::Trait`](./trait.Trait.html) +//! - [`Call`](./enum.Call.html) +//! +//! ## Overview +//! +//! The Society module is an economic game which incentivizes users to participate +//! and maintain a membership society. +//! +//! ### User Types +//! +//! 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. +//! +//! Of the non-suspended members, there is always a: +//! * Head - A member who is exempt from suspension. +//! * Defender - A member whose membership is under question and voted on again. +//! +//! Of the non-suspended members of the society, a random set of them are chosen as +//! "skeptics". The mechanics of skeptics is explained in the +//! [member phase](#member-phase) below. +//! +//! ### Mechanics +//! +//! #### Rewards +//! +//! Members are incentivized to participate in the society through rewards paid +//! by the Society treasury. These payments have a maturity period that the user +//! must wait before they are able to access the funds. +//! +//! #### Punishments +//! +//! Members can be punished by slashing the reward payouts that have not been +//! collected. Additionally, members can accumulate "strikes", and when they +//! reach a max strike limit, they become suspended. +//! +//! #### Skeptics +//! +//! During the voting period, a random set of members are selected as "skeptics". +//! These skeptics are expected to vote on the current candidates. If they do not vote, +//! their skeptic status is treated as a rejection vote, the member is deemed +//! "lazy", and are given a strike per missing vote. +//! +//! #### Membership Challenges +//! +//! Every challenge rotation period, an existing member will be randomly selected +//! to defend their membership into society. Then, other members can vote whether +//! this defender should stay in society. A simple majority wins vote will determine +//! the outcome of the user. Ties are treated as a failure of the challenge, but +//! assuming no one else votes, the defender always get a free vote on their +//! own challenge keeping them in the society. The Head member is exempt from the +//! negative outcome of a membership challenge. +//! +//! #### Society Treasury +//! +//! The membership society is independently funded by a treasury managed by this +//! module. Some subset of this treasury is placed in a Society Pot, which is used +//! to determine the number of accepted bids. +//! +//! #### Rate of Growth +//! +//! The membership society can grow at a rate of 10 accepted candidates per rotation period up +//! to the max membership threshold. Once this threshold is met, candidate selections +//! are stalled until there is space for new members to join. This can be resolved by +//! voting out existing members through the random challenges or by using governance +//! to increase the maximum membership count. +//! +//! ### User Life Cycle +//! +//! A user can go through the following phases: +//! +//! ```ignore +//! +-------> User <----------+ +//! | + | +//! | | | +//! +----------------------------------------------+ +//! | | | | | +//! | | v | | +//! | | Bidder <-----------+ | +//! | | + | | +//! | | | + | +//! | | v Suspended | +//! | | Candidate +----> Candidate | +//! | | + + | +//! | | | | | +//! | + | | | +//! | Suspended +------>| | | +//! | Member | | | +//! | ^ | | | +//! | | v | | +//! | +-------+ Member <----------+ | +//! | | +//! | | +//! +------------------Society---------------------+ +//! ``` +//! +//! #### Initialization +//! +//! The society is initialized with a single member who is automatically chosen as the Head. +//! +//! #### Bid Phase +//! +//! New users must have a bid to join the society. +//! +//! A user can make a bid by reserving a deposit. Alternatively, an already existing member +//! can create a bid on a user's behalf by "vouching" for them. +//! +//! A bid includes reward information that the user would like to receive for joining +//! the society. A vouching bid can additionally request some portion of that reward as a tip +//! to the voucher for vouching for the prospective candidate. +//! +//! Every rotation period, Bids are ordered by reward amount, and the module +//! selects as many bids the Society Pot can support for that period. +//! +//! These selected bids become candidates and move on to the Candidate phase. +//! Bids that were not selected stay in the bidder pool until they are selected or +//! a user chooses to "unbid". +//! +//! #### Candidate Phase +//! +//! Once a bidder becomes a candidate, members vote whether to approve or reject +//! that candidate into society. This voting process also happens during a rotation period. +//! +//! The approval and rejection criteria for candidates are not set on chain, +//! and may change for different societies. +//! +//! At the end of the rotation period, we collect the votes for a candidate +//! and randomly select a vote as the final outcome. +//! +//! ```ignore +//! [ a-accept, r-reject, s-skeptic ] +//! +----------------------------------+ +//! | | +//! | Member |0|1|2|3|4|5|6|7|8|9| | +//! | ----------------------------- | +//! | Vote |a|a|a|r|s|r|a|a|s|a| | +//! | ----------------------------- | +//! | Selected | | | |x| | | | | | | | +//! | | +//! +----------------------------------+ +//! +//! Result: Rejected +//! ``` +//! +//! Each member that voted opposite to this randomly selected vote is punished by +//! slashing their unclaimed payouts and increasing the number of strikes they have. +//! +//! These slashed funds are given to a random user who voted the same as the +//! selected vote as a reward for participating in the vote. +//! +//! If the candidate wins the vote, they receive their bid reward as a future payout. +//! If the bid was placed by a voucher, they will receive their portion of the reward, +//! before the rest is paid to the winning candidate. +//! +//! One winning candidate is selected as the Head of the members. This is randomly +//! chosen, weighted by the number of approvals the winning candidates accumulated. +//! +//! If the candidate loses the vote, they are suspended and it is up to the Suspension +//! Judgement origin to determine if the candidate should go through the bidding process +//! again, should be accepted into the membership society, or rejected and their deposit +//! slashed. +//! +//! #### Member Phase +//! +//! Once a candidate becomes a member, their role is to participate in society. +//! +//! Regular participation involves voting on candidates who want to join the membership +//! society, and by voting in the right way, a member will accumulate future payouts. +//! When a payout matures, members are able to claim those payouts. +//! +//! Members can also vouch for users to join the society, and request a "tip" from +//! the fees the new member would collect by joining the society. This vouching +//! process is useful in situations where a user may not have enough balance to +//! satisfy the bid deposit. A member can only vouch one user at a time. +//! +//! During rotation periods, a random group of members are selected as "skeptics". +//! These skeptics are expected to vote on the current candidates. If they do not vote, +//! their skeptic status is treated as a rejection vote, the member is deemed +//! "lazy", and are given a strike per missing vote. +//! +//! There is a challenge period in parallel to the rotation period. During a challenge period, +//! a random member is selected to defend their membership to the society. Other members +//! make a traditional majority-wins vote to determine if the member should stay in the society. +//! Ties are treated as a failure of the challenge. +//! +//! If a member accumulates too many strikes or fails their membership challenge, +//! they will become suspended. While a member is suspended, they are unable to +//! claim matured payouts. It is up to the Suspension Judgement origin to determine +//! if the member should re-enter society or be removed from society with all their +//! future payouts slashed. +//! +//! ## Interface +//! +//! ### Dispatchable Functions +//! +//! #### For General Users +//! +//! * `bid` - A user can make a bid to join the membership society by reserving a deposit. +//! * `unbid` - A user can withdraw their bid for entry, the deposit is returned. +//! +//! #### For Members +//! +//! * `vouch` - A member can place a bid on behalf of a user to join the membership society. +//! * `unvouch` - A member can revoke their vouch for a user. +//! * `vote` - A member can vote to approve or reject a candidate's request to join the society. +//! * `defender_vote` - A member can vote to approve or reject a defender's continued membership +//! to the society. +//! * `payout` - A member can claim their first matured payment. +//! +//! #### For Super Users +//! +//! * `found` - The founder origin can initiate this society. Useful for bootstrapping the Society +//! pallet on an already running chain. +//! * `judge_suspended_member` - The suspension judgement origin is able to make +//! judgement on a suspended member. +//! * `judge_suspended_candidate` - The suspension judgement origin is able to +//! make judgement on a suspended candidate. +//! * `set_max_membership` - The ROOT origin can update the maximum member count for the society. +//! The max membership count must be greater than 1. + +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +use rand_chacha::{rand_core::{RngCore, SeedableRng}, ChaChaRng}; +use sp_std::prelude::*; +use codec::{Encode, Decode}; +use sp_runtime::{Percent, ModuleId, RuntimeDebug, + traits::{ + StaticLookup, AccountIdConversion, Saturating, Zero, IntegerSquareRoot, + TrailingZeroInput, CheckedSub, EnsureOrigin + } +}; +use frame_support::{decl_error, decl_module, decl_storage, decl_event, ensure, dispatch::DispatchResult}; +use frame_support::weights::SimpleDispatchInfo; +use frame_support::traits::{ + Currency, ReservableCurrency, Randomness, Get, ChangeMembers, + ExistenceRequirement::{KeepAlive, AllowDeath}, +}; +use frame_system::{self as system, ensure_signed, ensure_root}; + +type BalanceOf = <>::Currency as Currency<::AccountId>>::Balance; + +const MODULE_ID: ModuleId = ModuleId(*b"py/socie"); + +/// The module's configuration trait. +pub trait Trait: system::Trait { + /// The overarching event type. + type Event: From> + Into<::Event>; + + /// The currency type used for bidding. + type Currency: ReservableCurrency; + + /// Something that provides randomness in the runtime. + type Randomness: Randomness; + + /// The minimum amount of a deposit required for a bid to be made. + 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. + 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. + type MaxStrikes: Get; + + /// The amount of incentive paid within each period. Doesn't include VoterTip. + type PeriodSpend: Get>; + + /// The receiver of the signal for when the members have changed. + type MembershipChanged: ChangeMembers; + + /// The number of blocks between candidate/membership rotation periods. + type RotationPeriod: Get; + + /// The maximum duration of the payout lock. + type MaxLockDuration: Get; + + /// The origin that is allowed to call `found`. + type FounderOrigin: EnsureOrigin; + + /// The origin that is allowed to make suspension judgements. + type SuspensionJudgementOrigin: EnsureOrigin; + + /// The number of blocks between membership challenges. + type ChallengePeriod: Get; +} + +/// A vote by a member on a candidate application. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug)] +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, +} + +/// A judgement by the suspension judgement origin on a suspended candidate. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug)] +pub enum Judgement { + /// The suspension judgement origin takes no direct judgment + /// and places the candidate back into the bid pool. + Rebid, + /// The suspension judgement origin has rejected the candidate's application. + Reject, + /// The suspension judgement origin approves of the candidate's application. + Approve, +} + +/// Details of a payout given as a per-block linear "trickle". +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug, Default)] +pub struct Payout { + /// Total value of the payout. + value: Balance, + /// Block number at which the payout begins. + begin: BlockNumber, + /// Total number of blocks over which the payout is spread. + duration: BlockNumber, + /// Total value paid out so far. + paid: Balance, +} + +/// Status of a vouching member. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug)] +pub enum VouchingStatus { + /// Member is currently vouching for a user. + Vouching, + /// Member is banned from vouching for other members. + Banned, +} + +/// Number of strikes that a member has against them. +pub type StrikeCount = u32; + +/// A bid for entry into society. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug,)] +pub struct Bid { + /// The bidder/candidate trying to enter society + who: AccountId, + /// 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. + value: Balance, +} + +/// A vote by a member on a candidate application. +#[derive(Encode, Decode, Copy, Clone, PartialEq, Eq, RuntimeDebug)] +pub enum BidKind { + /// The CandidateDeposit 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). + Vouch(AccountId, Balance), +} + +impl BidKind { + fn check_voucher(&self, v: &AccountId) -> DispatchResult { + if let BidKind::Vouch(ref a, _) = self { + if a == v { + Ok(()) + } else { + Err("incorrect identity")? + } + } else { + Err("not vouched")? + } + } +} + +// This module's storage items. +decl_storage! { + trait Store for Module, I: Instance=DefaultInstance> as Society { + /// The current set of candidates; bidders that are attempting to become members. + pub Candidates get(candidates): Vec>>; + + /// The set of suspended candidates. + pub SuspendedCandidates get(suspended_candidate): + map T::AccountId => Option<(BalanceOf, BidKind>)>; + + /// Amount of our account balance that is specifically for the next round's bid(s). + pub Pot get(fn pot) config(): BalanceOf; + + /// The most primary from the most recently approved members. + pub Head get(head) build(|config: &GenesisConfig| config.members.first().cloned()): + Option; + + /// The current set of members, ordered. + pub Members get(fn members) build(|config: &GenesisConfig| { + let mut m = config.members.clone(); + m.sort(); + m + }): Vec; + + /// The set of suspended members. + pub SuspendedMembers get(fn suspended_member): map T::AccountId => Option<()>; + + /// The current bids, stored ordered by the value of the bid. + Bids: Vec>>; + + /// Members currently vouching or banned from vouching again + Vouching get(fn vouching): map T::AccountId => Option; + + /// Pending payouts; ordered by block number, with the amount that should be paid out. + Payouts: map T::AccountId => Vec<(T::BlockNumber, BalanceOf)>; + + /// The ongoing number of losing votes cast by the member. + Strikes: map T::AccountId => StrikeCount; + + /// Double map from Candidate -> Voter -> (Maybe) Vote. + Votes: double_map + hasher(twox_64_concat) T::AccountId, + twox_64_concat(T::AccountId) + => Option; + + /// The defending member currently being challenged. + Defender get(fn defender): Option; + + /// Votes for the defender. + DefenderVotes: map hasher(twox_64_concat) T::AccountId => Option; + + /// The max number of members for the society at one time. + MaxMembers get(fn max_members) config(): u32; + } + add_extra_genesis { + config(members): Vec; + } +} + +// The module's dispatchable functions. +decl_module! { + /// The module declaration. + pub struct Module, I: Instance=DefaultInstance> for enum Call where origin: T::Origin { + type Error = Error; + /// The minimum amount of a deposit required for a bid to be made. + const CandidateDeposit: BalanceOf = T::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. + const WrongSideDeduction: BalanceOf = T::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. + const MaxStrikes: u32 = T::MaxStrikes::get(); + + /// The amount of incentive paid within each period. Doesn't include VoterTip. + const PeriodSpend: BalanceOf = T::PeriodSpend::get(); + + /// The number of blocks between candidate/membership rotation periods. + const RotationPeriod: T::BlockNumber = T::RotationPeriod::get(); + + /// The number of blocks between membership challenges. + const ChallengePeriod: T::BlockNumber = T::ChallengePeriod::get(); + + // Used for handling module events. + fn deposit_event() = default; + + /// A user outside of the society can make a bid for entry. + /// + /// Payment: `CandidateDeposit` 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. + /// + /// # + /// Key: B (len of bids), C (len of candidates), M (len of members), X (balance reserve) + /// - Storage Reads: + /// - One storage read to check for suspended candidate. O(1) + /// - One storage read to check for suspended member. O(1) + /// - One storage read to retrieve all current bids. O(B) + /// - One storage read to retrieve all current candidates. O(C) + /// - One storage read to retrieve all members. O(M) + /// - Storage Writes: + /// - One storage mutate to add a new bid to the vector O(B) (TODO: possible optimization w/ read) + /// - Up to one storage removal if bid.len() > MAX_BID_COUNT. O(1) + /// - Notable Computation: + /// - O(B + C + log M) search to check user is not already a part of society. + /// - O(log B) search to insert the new bid sorted. + /// - External Module Operations: + /// - One balance reserve operation. O(X) + /// - Up to one balance unreserve operation if bids.len() > MAX_BID_COUNT. + /// - Events: + /// - One event for new bid. + /// - Up to one event for AutoUnbid if bid.len() > MAX_BID_COUNT. + /// + /// Total Complexity: O(M + B + C + logM + logB + X) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(50_000)] + pub fn bid(origin, value: BalanceOf) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(!>::exists(&who), Error::::Suspended); + ensure!(!>::exists(&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(); + T::Currency::reserve(&who, deposit)?; + + Self::put_bid(bids, &who, value.clone(), BidKind::Deposit(deposit)); + Self::deposit_event(RawEvent::Bid(who, value)); + Ok(()) + } + + /// A bidder can remove their bid for entry into society. + /// By doing so, they will have their candidate deposit returned or + /// they will unvouch their voucher. + /// + /// 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. + /// + /// # + /// Key: B (len of bids), X (balance unreserve) + /// - One storage read and write to retrieve and update the bids. O(B) + /// - Either one unreserve balance action O(X) or one vouching storage removal. O(1) + /// - One event. + /// + /// Total Complexity: O(B + X) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(20_000)] + pub fn unbid(origin, pos: u32) -> 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 _ = T::Currency::unreserve(&who, deposit); + } + BidKind::Vouch(voucher, _) => { + >::remove(&voucher); + } + } + Self::deposit_event(RawEvent::Unbid(who)); + Ok(()) + } else { + Err(Error::::BadPosition)? + } + ) + } + + /// As a member, vouch for someone to join society by placing a bid on their behalf. + /// + /// There is no deposit required to vouch for a new bid, but a member can only vouch for + /// one bid at a time. If the bid becomes a suspended candidate and ultimately rejected by + /// the suspension judgement origin, the member will be banned from vouching again. + /// + /// As a vouching member, you can claim a tip if the candidate is accepted. This tip will + /// be paid as a portion of the reward the member will receive for joining the society. + /// + /// The dispatch origin for this call must be _Signed_ and a member. + /// + /// Parameters: + /// - `who`: The user who you would like to vouch for. + /// - `value`: The total reward to be paid between you and the candidate if they become + /// 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. + /// + /// # + /// Key: B (len of bids), C (len of candidates), M (len of members) + /// - Storage Reads: + /// - One storage read to retrieve all members. O(M) + /// - One storage read to check member is not already vouching. O(1) + /// - One storage read to check for suspended candidate. O(1) + /// - One storage read to check for suspended member. O(1) + /// - One storage read to retrieve all current bids. O(B) + /// - One storage read to retrieve all current candidates. O(C) + /// - Storage Writes: + /// - One storage write to insert vouching status to the member. O(1) + /// - One storage mutate to add a new bid to the vector O(B) (TODO: possible optimization w/ read) + /// - Up to one storage removal if bid.len() > MAX_BID_COUNT. O(1) + /// - Notable Computation: + /// - O(log M) search to check sender is a member. + /// - O(B + C + log M) search to check user is not already a part of society. + /// - O(log B) search to insert the new bid sorted. + /// - External Module Operations: + /// - One balance reserve operation. O(X) + /// - Up to one balance unreserve operation if bids.len() > MAX_BID_COUNT. + /// - Events: + /// - One event for vouch. + /// - Up to one event for AutoUnbid if bid.len() > MAX_BID_COUNT. + /// + /// Total Complexity: O(M + B + C + logM + logB + X) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(50_000)] + pub fn vouch(origin, who: T::AccountId, value: BalanceOf, tip: BalanceOf) -> DispatchResult { + let voucher = ensure_signed(origin)?; + // Check user is not suspended. + ensure!(!>::exists(&who), Error::::Suspended); + ensure!(!>::exists(&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); + // Check sender can vouch. + ensure!(Self::is_member(&members, &voucher), Error::::NotMember); + ensure!(!>::exists(&voucher), Error::::AlreadyVouching); + + >::insert(&voucher, VouchingStatus::Vouching); + Self::put_bid(bids, &who, value.clone(), BidKind::Vouch(voucher.clone(), tip)); + Self::deposit_event(RawEvent::Vouch(who, value, voucher)); + Ok(()) + } + + /// As a vouching member, unvouch a bid. This only works while vouched user is + /// only a bidder (and not a candidate). + /// + /// The dispatch origin for this call must be _Signed_ and a vouching member. + /// + /// Parameters: + /// - `pos`: Position in the `Bids` vector of the bid who should be unvouched. + /// + /// # + /// Key: B (len of bids) + /// - One storage read O(1) to check the signer is a vouching member. + /// - One storage mutate to retrieve and update the bids. O(B) + /// - One vouching storage removal. O(1) + /// - One event. + /// + /// Total Complexity: O(B) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(20_000)] + pub fn unvouch(origin, pos: u32) -> 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(RawEvent::Unvouch(who)); + Ok(()) + } else { + Err(Error::::BadPosition)? + } + ) + } + + /// As a member, vote on a candidate. + /// + /// The dispatch origin for this call must be _Signed_ and a member. + /// + /// Parameters: + /// - `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`). + /// + /// # + /// Key: C (len of candidates), M (len of members) + /// - One storage read O(M) and O(log M) search to check user is a member. + /// - One account lookup. + /// - One storage read O(C) and O(C) search to check that user is a candidate. + /// - One storage write to add vote to votes. O(1) + /// - One event. + /// + /// Total Complexity: O(M + logM + C) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(30_000)] + pub fn vote(origin, candidate: ::Source, approve: bool) { + 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); + + Self::deposit_event(RawEvent::Vote(candidate, voter, approve)); + } + + /// As a member, vote on the defender. + /// + /// The dispatch origin for this call must be _Signed_ and a member. + /// + /// Parameters: + /// - `approve`: A boolean which says if the candidate should be + /// approved (`true`) or rejected (`false`). + /// + /// # + /// - Key: M (len of members) + /// - One storage read O(M) and O(log M) search to check user is a member. + /// - One storage write to add vote to votes. O(1) + /// - One event. + /// + /// Total Complexity: O(M + logM) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(20_000)] + pub fn defender_vote(origin, approve: bool) { + 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); + + Self::deposit_event(RawEvent::DefenderVote(voter, approve)); + } + + /// Transfer the first matured payout for the sender and remove it from the records. + /// + /// NOTE: This extrinsic needs to be called multiple times to claim multiple matured payouts. + /// + /// Payment: The member will receive a payment equal to their first matured + /// payout to their free balance. + /// + /// The dispatch origin for this call must be _Signed_ and a member with + /// payouts remaining. + /// + /// # + /// Key: M (len of members), P (number of payouts for a particular member) + /// - One storage read O(M) and O(log M) search to check signer is a member. + /// - One storage read O(P) to get all payouts for a member. + /// - One storage read O(1) to get the current block number. + /// - One currency transfer call. O(X) + /// - One storage write or removal to update the member's payouts. O(P) + /// + /// Total Complexity: O(M + logM + P + X) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(30_000)] + pub fn payout(origin) { + let who = ensure_signed(origin)?; + + let members = >::get(); + ensure!(Self::is_member(&members, &who), Error::::NotMember); + + let mut payouts = >::get(&who); + if let Some((when, amount)) = payouts.first() { + if when <= &>::block_number() { + T::Currency::transfer(&Self::payouts(), &who, *amount, KeepAlive)?; + payouts.remove(0); + if payouts.is_empty() { + >::remove(&who); + } else { + >::insert(&who, payouts); + } + return Ok(()) + } + } + Err(Error::::NoPayout)? + } + + /// Found the society. + /// + /// This is done as a discrete action in order to allow for the + /// module to be included into a running chain and can only be done once. + /// + /// The dispatch origin for this call must be from the _FounderOrigin_. + /// + /// Parameters: + /// - `founder` - The first member and head of the newly founded society. + /// + /// # + /// - One storage read to check `Head`. O(1) + /// - One storage write to add the first member to society. O(1) + /// - One storage write to add new Head. O(1) + /// - One event. + /// + /// Total Complexity: O(1) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(10_000)] + fn found(origin, founder: T::AccountId) { + T::FounderOrigin::ensure_origin(origin)?; + ensure!(!>::exists(), Error::::AlreadyFounded); + // This should never fail in the context of this function... + Self::add_member(&founder)?; + >::put(&founder); + Self::deposit_event(RawEvent::Founded(founder)); + } + /// Allow suspension judgement origin to make judgement on a suspended member. + /// + /// If a suspended member is forgiven, we simply add them back as a member, not affecting + /// any of the existing storage items for that member. + /// + /// 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_. + /// + /// 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. + /// + /// # + /// Key: B (len of bids), M (len of members) + /// - One storage read to check `who` is a suspended member. O(1) + /// - Up to one storage write O(M) with O(log M) binary search to add a member back to society. + /// - Up to 3 storage removals O(1) to clean up a removed member. + /// - Up to one storage write O(B) with O(B) search to remove vouched bid from bids. + /// - Up to one additional event if unvouch takes place. + /// - One storage removal. O(1) + /// - One event for the judgement. + /// + /// Total Complexity: O(M + logM + B) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(30_000)] + fn judge_suspended_member(origin, who: T::AccountId, forgive: bool) { + T::SuspensionJudgementOrigin::ensure_origin(origin)?; + ensure!(>::exists(&who), Error::::NotSuspended); + + if forgive { + // Try to add member back to society. Can fail with `MaxMembers` limit. + Self::add_member(&who)?; + } 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(RawEvent::Unvouch(vouched)); + } + ); + } + } + + >::remove(&who); + Self::deposit_event(RawEvent::SuspendedMemberJudgement(who, forgive)); + } + + /// 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. + /// + /// 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_. + /// + /// Parameters: + /// - `who` - The suspended candidate to be judged. + /// - `judgement` - `Approve`, `Reject`, or `Rebid`. + /// + /// # + /// Key: B (len of bids), M (len of members), X (balance action) + /// - One storage read to check `who` is a suspended candidate. + /// - One storage removal of the suspended candidate. + /// - Approve Logic + /// - One storage read to get the available pot to pay users with. O(1) + /// - One storage write to update the available pot. O(1) + /// - One storage read to get the current block number. O(1) + /// - One storage read to get all members. O(M) + /// - Up to one unreserve currency action. + /// - Up to two new storage writes to payouts. + /// - Up to one storage write with O(log M) binary search to add a member to society. + /// - Reject Logic + /// - Up to one repatriate reserved currency action. O(X) + /// - Up to one storage write to ban the vouching member from vouching again. + /// - Rebid Logic + /// - Storage mutate with O(log B) binary search to place the user back into bids. + /// - Up to one additional event if unvouch takes place. + /// - One storage removal. + /// - One event for the judgement. + /// + /// Total Complexity: O(M + logM + B + X) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(50_000)] + fn judge_suspended_candidate(origin, who: T::AccountId, judgement: Judgement) { + T::SuspensionJudgementOrigin::ensure_origin(origin)?; + 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 _ = T::Currency::repatriate_reserved(&who, &Self::account_id(), deposit); + } + 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); + } + } + + // Remove suspended candidate + >::remove(who); + } else { + Err(Error::::NotSuspended)? + } + } + + /// Allows root origin to change the maximum number of members in society. + /// Max membership count must be greater than 1. + /// + /// The dispatch origin for this call must be from _ROOT_. + /// + /// Parameters: + /// - `max` - The maximum number of members for the society. + /// + /// # + /// - One storage write to update the max. O(1) + /// - One event. + /// + /// Total Complexity: O(1) + /// # + #[weight = SimpleDispatchInfo::FixedNormal(10_000)] + fn set_max_members(origin, max: u32) { + ensure_root(origin)?; + ensure!(max > 1, Error::::MaxMembers); + MaxMembers::::put(max); + Self::deposit_event(RawEvent::NewMaxMembers(max)); + } + + fn on_initialize(n: T::BlockNumber) { + let mut members = vec![]; + + // Run a candidate/membership rotation + if (n % T::RotationPeriod::get()).is_zero() { + members = >::get(); + Self::rotate_period(&mut members); + } + + // 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); + } + } + } +} + +decl_error! { + /// Errors for this module. + pub enum Error for Module, I: Instance> { + /// An incorrect position was provided. + BadPosition, + /// User is not a member + NotMember, + /// User is already a member + AlreadyMember, + /// User is suspended + Suspended, + /// User is not suspended + NotSuspended, + /// Nothing to payout + NoPayout, + /// Society already founded + AlreadyFounded, + /// Not enough in pot to accept candidate + InsufficientPot, + /// Member is already vouching or banned from vouching again + AlreadyVouching, + /// Member is not vouching + NotVouching, + /// Cannot remove head + Head, + /// User has already made a bid + AlreadyBid, + /// User is already a candidate + AlreadyCandidate, + /// User is not a candidate + NotCandidate, + /// Too many members in the society + MaxMembers, + } +} + +decl_event! { + /// Events for this module. + pub enum Event where + AccountId = ::AccountId, + Balance = BalanceOf + { + /// The society is founded by the given identity. + Founded(AccountId), + /// A membership bid just happened. The given account is the candidate's ID and their offer + /// is the second. + Bid(AccountId, Balance), + /// A membership bid just happened by vouching. The given account is the candidate's ID and + /// their offer is the second. The vouching party is the third. + Vouch(AccountId, Balance, AccountId), + /// A candidate was dropped (due to an excess of bids in the system). + AutoUnbid(AccountId), + /// A candidate was dropped (by their request). + Unbid(AccountId), + /// A candidate was dropped (by request of who vouched for them). + Unvouch(AccountId), + /// A group of candidates have been inducted. The batch's primary is the first value, the + /// batch in full is the second. + Inducted(AccountId, Vec), + /// A suspended member has been judged + SuspendedMemberJudgement(AccountId, bool), + /// A candidate has been suspended + CandidateSuspended(AccountId), + /// A member has been suspended + MemberSuspended(AccountId), + /// A member has been challenged + Challenged(AccountId), + /// A vote has been placed (candidate, voter, vote) + Vote(AccountId, AccountId, bool), + /// A vote has been placed for a defending member (voter, vote) + DefenderVote(AccountId, bool), + /// A new max member count has been set + NewMaxMembers(u32), + } +} + +/// 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)]) + } +} + +/// Pick a new PRN, in the range [0, `max`] (inclusive). +fn pick_usize<'a, R: RngCore>(rng: &mut R, max: usize) -> usize { + + (rng.next_u32() % (max as u32 + 1)) as usize +} + +impl, I: Instance> Module { + /// 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)) { + Ok(pos) | Err(pos) => bids.insert(pos, Bid { + value, + who: who.clone(), + kind: bid_kind, + }), + } + // 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 _ = T::Currency::unreserve(&popped, deposit); + } + BidKind::Vouch(voucher, _) => { + >::remove(&voucher); + } + } + Self::deposit_event(RawEvent::AutoUnbid(popped)); + } + + >::put(bids); + } + + /// 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().find(|bid| bid.who == *who).is_some() + } + + /// 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) + } + + /// Check a user is a member. + fn is_member(members: &Vec, who: &T::AccountId) -> bool { + members.binary_search(who).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 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(()) + }, + // User is already a member, do nothing. + Ok(_) => Ok(()), + } + } + + /// Remove a member from the members list, except the Head. + /// + /// 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); + + >::mutate(|members| + match members.binary_search(&m) { + Err(_) => Err(Error::::NotMember)?, + Ok(i) => { + members.remove(i); + T::MembershipChanged::change_members_sorted(&[], &[m.clone()], members); + Ok(()) + } + } + ) + } + + /// 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. + 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)) + } else { + // Suspend Candidate + >::insert(&candidate, (value, kind)); + Self::deposit_event(RawEvent::CandidateSuspended(candidate)); + None + } + }).collect::>(); + + // 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 _ = T::Currency::transfer(&Self::payouts(), &Self::account_id(), total_slash, AllowDeath); + } + } + + // 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 _ = T::Currency::transfer(&Self::account_id(), &Self::payouts(), total_payouts, AllowDeath); + } + + // 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 user who falls on that point + let primary = accepted.iter().find(|e| 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(RawEvent::Inducted(primary, accounts)); + } + + // 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()); + + >::put(&pot); + } + + // 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"); + for skeptic in (0..members.len().integer_sqrt()).map(pick_member) { + for Bid{ who: c, .. } in candidates.iter() { + >::insert(c, skeptic, Vote::Skeptic); + } + } + } + + /// 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; + } else { + // whole slash is accounted for. + *amount -= rest; + rest = Zero::zero(); + break; + } + } + >::insert(who, &payouts[dropped..]); + } + 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)), + }); + } + } + + /// Suspend a user, removing them from the member list. + fn suspend_member(who: &T::AccountId) { + if Self::remove_member(&who).is_ok() { + >::insert(who, ()); + >::remove(who); + Self::deposit_event(RawEvent::MemberSuspended(who.clone())); + } + } + + /// Pay an accepted candidate their bid value. + fn pay_accepted_candidate( + candidate: &T::AccountId, + value: BalanceOf, + kind: BidKind>, + maturity: T::BlockNumber, + ) { + let value = match kind { + BidKind::Deposit(deposit) => { + // In the case that a normal deposit bid is accepted we unreserve + // the deposit. + let _ = T::Currency::unreserve(candidate, deposit); + value + } + 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) + } else { + value + } + } + }; + + 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(|m| >::get(m)) + .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(); + } + } + + // Start a new defender rotation + let phrase = b"society_challenge"; + // we'll need a random seed here. + 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).expect("exited if members empty; qed"); + + >::put(&chosen); + Self::deposit_event(RawEvent::Challenged(chosen.clone())); + } + } + + /// The account ID of the treasury pot. + /// + /// This actually does computation. If you need to keep using it, then make sure you cache the + /// value and only call this once. + pub fn account_id() -> T::AccountId { + MODULE_ID.into_account() + } + + /// The account ID of the payouts pot. This is where payouts are made from. + /// + /// This actually does computation. If you need to keep using it, then make sure you cache the + /// value and only call this once. + pub fn payouts() -> T::AccountId { + MODULE_ID.into_sub_account(b"payouts") + } + + /// Return the duration of the lock, in blocks, with the given number of members. + /// + /// This is a rather opaque calculation based on the formula here: + /// https://www.desmos.com/calculator/9itkal1tce + fn lock_duration(x: u32) -> T::BlockNumber { + 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; + // No more than 10 will be returned. + let max_selections: usize = 10.min(max_members.saturating_sub(members_len)); + + // Get the number of left-most bidders whose bids add up to less than `pot`. + let mut bids = >::get(); + let taken = bids.iter() + .scan(>::zero(), |total, bid| { + *total = total.saturating_add(bid.value); + Some((*total, bid.who.clone(), bid.kind.clone())) + }) + .take(max_selections) + .take_while(|x| pot >= x.0) + .count(); + + // No need to reset Bids if we're not taking anything. + if taken > 0 { + >::put(&bids[taken..]); + } + bids.truncate(taken); + bids + } +} diff --git a/substrate/frame/society/src/mock.rs b/substrate/frame/society/src/mock.rs new file mode 100644 index 0000000000..24eac4c5af --- /dev/null +++ b/substrate/frame/society/src/mock.rs @@ -0,0 +1,204 @@ +// Copyright 2020 Parity Technologies (UK) Ltd. +// This file is part of Substrate. + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! Test utilities + +use super::*; + +use frame_support::{impl_outer_origin, parameter_types}; +use sp_core::H256; +// The testing primitives are very useful for avoiding having to work with signatures +// or public keys. `u64` is used as the `AccountId` and no `Signature`s are requried. +use sp_runtime::{ + Perbill, traits::{BlakeTwo256, IdentityLookup, OnInitialize, OnFinalize}, testing::Header, +}; +use frame_system::EnsureSignedBy; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +// For testing the module, we construct most of a mock runtime. This means +// first constructing a configuration type (`Test`) which `impl`s each of the +// configuration traits of modules we want to use. +#[derive(Clone, Eq, PartialEq)] +pub struct Test; +parameter_types! { + pub const CandidateDeposit: u64 = 25; + pub const WrongSideDeduction: u64 = 2; + pub const MaxStrikes: u32 = 2; + pub const RotationPeriod: u64 = 4; + pub const PeriodSpend: u64 = 1000; + pub const MaxLockDuration: u64 = 100; + pub const FounderSetAccount: u64 = 1; + pub const SuspensionJudgementSetAccount: u64 = 2; + pub const ChallengePeriod: u64 = 8; + pub const MaxMembers: u32 = 100; + + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); + + pub const ExistentialDeposit: u64 = 0; + pub const TransferFee: u64 = 0; + pub const CreationFee: u64 = 0; +} + +impl frame_system::Trait for Test { + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Call = (); + type Hashing = BlakeTwo256; + type AccountId = u128; + type Lookup = IdentityLookup; + type Header = Header; + type Event = (); + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); + type ModuleToIndex = (); +} + +impl pallet_balances::Trait for Test { + type Balance = u64; + type OnFreeBalanceZero = (); + type OnNewAccount = (); + type Event = (); + type TransferPayment = (); + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; +} + +impl Trait for Test { + type Event = (); + type Currency = pallet_balances::Module; + type Randomness = (); + type CandidateDeposit = CandidateDeposit; + type WrongSideDeduction = WrongSideDeduction; + type MaxStrikes = MaxStrikes; + type PeriodSpend = PeriodSpend; + type MembershipChanged = (); + type RotationPeriod = RotationPeriod; + type MaxLockDuration = MaxLockDuration; + type FounderOrigin = EnsureSignedBy; + type SuspensionJudgementOrigin = EnsureSignedBy; + type ChallengePeriod = ChallengePeriod; +} + +pub type Society = Module; +pub type System = frame_system::Module; +pub type Balances = pallet_balances::Module; + +pub struct EnvBuilder { + members: Vec, + balance: u64, + balances: Vec<(u128, u64)>, + pot: u64, + max_members: u32, +} + +impl EnvBuilder { + pub fn new() -> Self { + Self { + members: vec![10], + balance: 10_000, + balances: vec![ + (10, 50), + (20, 50), + (30, 50), + (40, 50), + (50, 50), + (60, 50), + ], + pot: 0, + max_members: 100, + } + } + + pub fn execute R>(mut self, f: F) -> R { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + self.balances.push((Society::account_id(), self.balance.max(self.pot))); + pallet_balances::GenesisConfig:: { + balances: self.balances, + vesting: vec![], + }.assimilate_storage(&mut t).unwrap(); + GenesisConfig::{ + members: self.members, + pot: self.pot, + max_members: self.max_members, + }.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 + } + #[allow(dead_code)] + pub fn with_max_members(mut self, n: u32) -> Self { + self.max_members = n; + self + } +} + +/// Run until a particular block. +pub fn run_to_block(n: u64) { + while System::block_number() < n { + if System::block_number() > 1 { + System::on_finalize(System::block_number()); + } + System::set_block_number(System::block_number() + 1); + System::on_initialize(System::block_number()); + Society::on_initialize(System::block_number()); + } +} + +/// Creates a bid struct using input parameters. +pub fn create_bid( + value: Balance, + who: AccountId, + kind: BidKind +) -> Bid +{ + Bid { + who, + kind, + value + } +} diff --git a/substrate/frame/society/src/tests.rs b/substrate/frame/society/src/tests.rs new file mode 100644 index 0000000000..61bb1fd232 --- /dev/null +++ b/substrate/frame/society/src/tests.rs @@ -0,0 +1,744 @@ +// Copyright 2020 Parity Technologies (UK) Ltd. +// This file is part of Substrate. + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! Tests for the module. + +use super::*; +use mock::*; + +use frame_support::{assert_ok, assert_noop}; +use sp_runtime::traits::BadOrigin; + +#[test] +fn founding_works() { + EnvBuilder::new().with_members(vec![]).execute(|| { + // Account 1 is set as the founder origin + // Account 5 cannot start a society + assert_noop!(Society::found(Origin::signed(5), 20), BadOrigin); + // Account 1 can start a society, where 10 is the founding member + assert_ok!(Society::found(Origin::signed(1), 10)); + // Society members only include 10 + assert_eq!(Society::members(), vec![10]); + // 10 is the head of the society + assert_eq!(Society::head(), Some(10)); + // Cannot start another society + assert_noop!(Society::found(Origin::signed(1), 20), Error::::AlreadyFounded); + }); +} + +#[test] +fn basic_new_member_works() { + EnvBuilder::new().execute(|| { + assert_eq!(Balances::free_balance(20), 50); + // Bid causes Candidate Deposit to be reserved. + assert_ok!(Society::bid(Origin::signed(20), 0)); + assert_eq!(Balances::free_balance(20), 25); + assert_eq!(Balances::reserved_balance(20), 25); + // Rotate period every 4 blocks + run_to_block(4); + // 20 is now a candidate + assert_eq!(Society::candidates(), vec![create_bid(0, 20, BidKind::Deposit(25))]); + // 10 (a member) can vote for the candidate + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + // Rotate period every 4 blocks + run_to_block(8); + // 20 is now a member of the society + assert_eq!(Society::members(), vec![10, 20]); + // Reserved balance is returned + assert_eq!(Balances::free_balance(20), 50); + assert_eq!(Balances::reserved_balance(20), 0); + }); +} + +#[test] +fn bidding_works() { + EnvBuilder::new().execute(|| { + // Users make bids of various amounts + assert_ok!(Society::bid(Origin::signed(60), 1900)); + assert_ok!(Society::bid(Origin::signed(50), 500)); + assert_ok!(Society::bid(Origin::signed(40), 400)); + assert_ok!(Society::bid(Origin::signed(30), 300)); + // Rotate period + run_to_block(4); + // Pot is 1000 after "PeriodSpend" + assert_eq!(Society::pot(), 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(), vec![ + create_bid(300, 30, BidKind::Deposit(25)), + create_bid(400, 40, BidKind::Deposit(25)), + ]); + // A member votes for these candidates to join the society + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + assert_ok!(Society::vote(Origin::signed(10), 40, true)); + run_to_block(8); + // Candidates become members after a period rotation + assert_eq!(Society::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); + // 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)) ]); + // 40, now a member, can vote for 50 + assert_ok!(Society::vote(Origin::signed(40), 50, true)); + run_to_block(12); + // 50 is now a member + assert_eq!(Society::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!(Balances::free_balance(Society::account_id()), 8_800); + // No more candidates satisfy the requirements + assert_eq!(Society::candidates(), 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]); + // Pot is increased by 1000 again + assert_eq!(Society::pot(), 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)) ]); + // Candidate 60 is voted in. + assert_ok!(Society::vote(Origin::signed(50), 60, true)); + run_to_block(20); + // 60 joins as a member + assert_eq!(Society::members(), vec![10, 30, 40, 50, 60]); + // Pay them + assert_eq!(Society::pot(), 1_900); + assert_eq!(Balances::free_balance(Society::account_id()), 6_900); + }); +} + +#[test] +fn unbidding_works() { + EnvBuilder::new().execute(|| { + // 20 and 30 make bids + assert_ok!(Society::bid(Origin::signed(20), 1000)); + assert_ok!(Society::bid(Origin::signed(30), 0)); + // 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(Origin::signed(30), 1), Error::::BadPosition); + // Can unbid themselves with the right position + assert_ok!(Society::unbid(Origin::signed(30), 0)); + // 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)) ]); + }); +} + +#[test] +fn payout_works() { + EnvBuilder::new().execute(|| { + // Original balance of 50 + assert_eq!(Balances::free_balance(20), 50); + assert_ok!(Society::bid(Origin::signed(20), 1000)); + run_to_block(4); + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + run_to_block(8); + // payout not ready + assert_noop!(Society::payout(Origin::signed(20)), Error::::NoPayout); + run_to_block(9); + // payout should be here + assert_ok!(Society::payout(Origin::signed(20))); + assert_eq!(Balances::free_balance(20), 1050); + }); +} + +#[test] +fn basic_new_member_skeptic_works() { + EnvBuilder::new().execute(|| { + assert_eq!(Strikes::::get(10), 0); + assert_ok!(Society::bid(Origin::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); + }); +} + +#[test] +fn basic_new_member_reject_works() { + EnvBuilder::new().execute(|| { + // Starting Balance + assert_eq!(Balances::free_balance(20), 50); + // 20 makes a bid + assert_ok!(Society::bid(Origin::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(Origin::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); + }); +} + +#[test] +fn slash_payout_works() { + EnvBuilder::new().execute(|| { + assert_eq!(Balances::free_balance(20), 50); + assert_ok!(Society::bid(Origin::signed(20), 1000)); + run_to_block(4); + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + run_to_block(8); + // payout in queue + assert_eq!(Payouts::::get(20), vec![(9, 1000)]); + 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); + // payout should be here, but 500 less + assert_ok!(Society::payout(Origin::signed(20))); + assert_eq!(Balances::free_balance(20), 550); + }); +} + +#[test] +fn slash_payout_multi_works() { + EnvBuilder::new().execute(|| { + assert_eq!(Balances::free_balance(20), 50); + // 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)]); + // slash payout + assert_eq!(Society::slash_payout(&20, 250), 250); + assert_eq!(Payouts::::get(20), vec![(15, 50), (20, 100)]); + // slash again + assert_eq!(Society::slash_payout(&20, 50), 50); + assert_eq!(Payouts::::get(20), vec![(20, 100)]); + }); +} + +#[test] +fn suspended_member_lifecycle_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), None); + + // Let's suspend account 20 by giving them 2 strikes by not voting + assert_ok!(Society::bid(Origin::signed(30), 0)); + run_to_block(8); + assert_eq!(Strikes::::get(20), 1); + assert_ok!(Society::bid(Origin::signed(40), 0)); + run_to_block(16); + + // Strike 2 is accumulated, and 20 is suspended :( + assert_eq!(>::get(20), Some(())); + assert_eq!(>::get(), vec![10]); + + // Suspended members cannot get payout + Society::bump_payout(&20, 10, 100); + assert_noop!(Society::payout(Origin::signed(20)), Error::::NotMember); + + // Normal people cannot make judgement + assert_noop!(Society::judge_suspended_member(Origin::signed(20), 20, true), BadOrigin); + + // Suspension judgment origin can judge thee + // Suspension judgement origin forgives the suspended member + assert_ok!(Society::judge_suspended_member(Origin::signed(2), 20, true)); + assert_eq!(>::get(20), None); + assert_eq!(>::get(), vec![10, 20]); + + // Let's suspend them again, directly + Society::suspend_member(&20); + assert_eq!(>::get(20), Some(())); + // Suspension judgement origin does not forgive the suspended member + assert_ok!(Society::judge_suspended_member(Origin::signed(2), 20, false)); + // Cleaned up + assert_eq!(>::get(20), None); + assert_eq!(>::get(), vec![10]); + assert_eq!(>::get(20), vec![]); + }); +} + +#[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(Origin::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(Origin::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); + + // Normal user cannot make judgement on suspended candidate + assert_noop!(Society::judge_suspended_candidate(Origin::signed(20), 20, Judgement::Approve), BadOrigin); + + // Suspension judgement origin makes no direct judgement + assert_ok!(Society::judge_suspended_candidate(Origin::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(Origin::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(Origin::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); + }); +} + +#[test] +fn vouch_works() { + EnvBuilder::new().execute(|| { + // 10 is the only member + assert_eq!(Society::members(), vec![10]); + // A non-member cannot vouch + assert_noop!(Society::vouch(Origin::signed(1), 20, 1000, 100), Error::::NotMember); + // A member can though + assert_ok!(Society::vouch(Origin::signed(10), 20, 1000, 100)); + assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); + // A member cannot vouch twice at the same time + assert_noop!(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))]); + // Vouched user can become candidate + run_to_block(4); + assert_eq!(Society::candidates(), vec![create_bid(1000, 20, BidKind::Vouch(10, 100))]); + // Vote yes + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + // Vouched user can win + run_to_block(8); + assert_eq!(Society::members(), vec![10, 20]); + // Voucher wins a portion of the payment + assert_eq!(>::get(10), vec![(9, 100)]); + // Vouched user wins the rest + assert_eq!(>::get(20), vec![(9, 900)]); + // 10 is no longer vouching + assert_eq!(>::get(10), 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(Origin::signed(10), 20, 100, 1000)); + // Vouching creates the right kind of bid + assert_eq!(>::get(), vec![create_bid(100, 20, BidKind::Vouch(10, 1000))]); + // Vouched user can become candidate + run_to_block(4); + assert_eq!(Society::candidates(), vec![create_bid(100, 20, BidKind::Vouch(10, 1000))]); + // Vote yes + assert_ok!(Society::vote(Origin::signed(10), 20, true)); + // Vouched user can win + run_to_block(8); + assert_eq!(Society::members(), vec![10, 20]); + // Voucher wins as much as the bid + assert_eq!(>::get(10), vec![(9, 100)]); + // Vouched user gets nothing + assert_eq!(>::get(20), vec![]); + }); +} + +#[test] +fn unvouch_works() { + EnvBuilder::new().execute(|| { + // 10 is the only member + assert_eq!(Society::members(), vec![10]); + // 10 vouches for 20 + assert_ok!(Society::vouch(Origin::signed(10), 20, 100, 0)); + // 20 has a bid + assert_eq!(>::get(), vec![create_bid(100, 20, BidKind::Vouch(10, 0))]); + // 10 is vouched + assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); + // To unvouch, you must know the right bid position + assert_noop!(Society::unvouch(Origin::signed(10), 2), Error::::BadPosition); + // 10 can unvouch with the right position + assert_ok!(Society::unvouch(Origin::signed(10), 0)); + // 20 no longer has a bid + assert_eq!(>::get(), vec![]); + // 10 is no longer vouching + assert_eq!(>::get(10), None); + + // Cannot unvouch after they become candidate + assert_ok!(Society::vouch(Origin::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(Origin::signed(10), 0), Error::::BadPosition); + // 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(Origin::signed(2), 20, Judgement::Reject)); + // 10 is banned from vouching + assert_eq!(>::get(10), Some(VouchingStatus::Banned)); + assert_eq!(Society::members(), vec![10]); + + // 10 cannot vouch again + assert_noop!(Society::vouch(Origin::signed(10), 30, 100, 0), Error::::AlreadyVouching); + // 10 cannot unvouch either, so they are banned forever. + assert_noop!(Society::unvouch(Origin::signed(10), 0), Error::::NotVouching); + }); +} + +#[test] +fn unbid_vouch_works() { + EnvBuilder::new().execute(|| { + // 10 is the only member + assert_eq!(Society::members(), vec![10]); + // 10 vouches for 20 + assert_ok!(Society::vouch(Origin::signed(10), 20, 100, 0)); + // 20 has a bid + assert_eq!(>::get(), vec![create_bid(100, 20, BidKind::Vouch(10, 0))]); + // 10 is vouched + assert_eq!(>::get(10), Some(VouchingStatus::Vouching)); + // 20 doesn't want to be a member and can unbid themselves. + assert_ok!(Society::unbid(Origin::signed(20), 0)); + // Everything is cleaned up + assert_eq!(>::get(10), None); + assert_eq!(>::get(), vec![]); + }); +} + +#[test] +fn head_cannot_be_removed() { + EnvBuilder::new().execute(|| { + // 10 is the only member and head + assert_eq!(Society::members(), vec![10]); + assert_eq!(Society::head(), Some(10)); + // 10 can still accumulate strikes + assert_ok!(Society::bid(Origin::signed(20), 0)); + run_to_block(8); + assert_eq!(Strikes::::get(10), 1); + assert_ok!(Society::bid(Origin::signed(30), 0)); + run_to_block(16); + assert_eq!(Strikes::::get(10), 2); + // Awkwardly they can obtain more than MAX_STRIKES... + assert_ok!(Society::bid(Origin::signed(40), 0)); + run_to_block(24); + assert_eq!(Strikes::::get(10), 3); + + // Replace the head + assert_ok!(Society::bid(Origin::signed(50), 0)); + run_to_block(28); + assert_ok!(Society::vote(Origin::signed(10), 50, true)); + assert_ok!(Society::defender_vote(Origin::signed(10), true)); // Keep defender around + run_to_block(32); + assert_eq!(Society::members(), vec![10, 50]); + assert_eq!(Society::head(), Some(50)); + + // 10 can now be suspended for strikes + assert_ok!(Society::bid(Origin::signed(60), 0)); + run_to_block(36); + // The candidate is rejected, so voting approve will give a strike + assert_ok!(Society::vote(Origin::signed(10), 60, true)); + run_to_block(40); + assert_eq!(Strikes::::get(10), 0); + assert_eq!(>::get(10), Some(())); + assert_eq!(Society::members(), vec![50]); + }); +} + +#[test] +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)); + // 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(20)); + // They can always free vote for themselves + assert_ok!(Society::defender_vote(Origin::signed(20), true)); + // If no one else votes, nothing happens + run_to_block(16); + assert_eq!(Society::members(), vec![10, 20, 30, 40]); + // New challenge period + assert_eq!(Society::defender(), Some(20)); + // Non-member cannot challenge + assert_noop!(Society::defender_vote(Origin::signed(1), true), Error::::NotMember); + // 3 people say accept, 1 reject + 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)); + run_to_block(24); + // 20 survives + assert_eq!(Society::members(), vec![10, 20, 30, 40]); + // One more time + assert_eq!(Society::defender(), Some(20)); + // 2 people say accept, 2 reject + 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)); + run_to_block(32); + // 20 is suspended + assert_eq!(Society::members(), vec![10, 30, 40]); + assert_eq!(Society::suspended_member(20), Some(())); + // New defender is chosen + assert_eq!(Society::defender(), Some(40)); + }); +} + +#[test] +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)); + // 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); + // 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)]); + // Create a new bid + assert_ok!(Society::bid(Origin::signed(50), 1000)); + run_to_block(4); + assert_ok!(Society::vote(Origin::signed(10), 50, false)); + assert_ok!(Society::vote(Origin::signed(20), 50, true)); + assert_ok!(Society::vote(Origin::signed(30), 50, false)); + assert_ok!(Society::vote(Origin::signed(40), 50, false)); + run_to_block(8); + // 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); + // 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)]); + }); +} + +#[test] +fn user_cannot_bid_twice() { + EnvBuilder::new().execute(|| { + // Cannot bid twice + 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(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(Origin::signed(50), 20, 100, 100), Error::::AlreadyBid); + }); +} + +#[test] +fn vouching_handles_removed_member_with_bid() { + EnvBuilder::new().execute(|| { + // Add a member + assert_ok!(Society::add_member(&20)); + // Have that member vouch for a user + assert_ok!(Society::vouch(Origin::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)); + // Suspend that member + Society::suspend_member(&20); + assert_eq!(>::get(20), Some(())); + // 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(Origin::signed(2), 20, false)); + // Bid is removed, vouching status is removed + assert_eq!(>::get(), vec![]); + assert_eq!(>::get(20), None); + }); +} + +#[test] +fn vouching_handles_removed_member_with_candidate() { + EnvBuilder::new().execute(|| { + // Add a member + assert_ok!(Society::add_member(&20)); + // Have that member vouch for a user + assert_ok!(Society::vouch(Origin::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)); + // Make that bid a candidate + run_to_block(4); + assert_eq!(Society::candidates(), vec![create_bid(1000, 30, BidKind::Vouch(20, 100))]); + // Suspend that member + Society::suspend_member(&20); + assert_eq!(>::get(20), Some(())); + // 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(Origin::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))]); + // Candidate wins + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + run_to_block(8); + assert_eq!(Society::members(), vec![10, 30]); + // Payout does not go to removed member + assert_eq!(>::get(20), vec![]); + assert_eq!(>::get(30), vec![(9, 1000)]); + }); +} + +#[test] +fn votes_are_working() { + EnvBuilder::new().execute(|| { + // Users make bids of various amounts + assert_ok!(Society::bid(Origin::signed(50), 500)); + assert_ok!(Society::bid(Origin::signed(40), 400)); + assert_ok!(Society::bid(Origin::signed(30), 300)); + // Rotate period + run_to_block(4); + // A member votes for these candidates to join the society + assert_ok!(Society::vote(Origin::signed(10), 30, true)); + assert_ok!(Society::vote(Origin::signed(10), 40, true)); + // You cannot vote for a non-candidate + 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); + // Candidates become members after a period rotation + assert_eq!(Society::members(), vec![10, 30, 40]); + // Votes are cleaned up + assert_eq!(>::get(30, 10), None); + assert_eq!(>::get(40, 10), None); + }); +} + +#[test] +fn max_limits_work() { + EnvBuilder::new().with_pot(100000).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(Origin::signed(i as u128), i)); + } + let bids = >::get(); + // Length is 1000 + assert_eq!(bids.len(), 1000); + // First bid is smallest number (100) + assert_eq!(bids[0], create_bid(100, 100, BidKind::Deposit(25))); + // 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(Origin::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(Origin::signed(*i), 110, true)); + assert_ok!(Society::vote(Origin::signed(*i), 111, true)); + assert_ok!(Society::vote(Origin::signed(*i), 112, true)); + } + // 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(Origin::ROOT, 200)); + // Rotate period + run_to_block(16); + // Candidates are back! + assert_eq!(Society::candidates().len(), 10); + }); +} diff --git a/substrate/frame/support/src/traits.rs b/substrate/frame/support/src/traits.rs index eaaad7b8c4..cb22283924 100644 --- a/substrate/frame/support/src/traits.rs +++ b/substrate/frame/support/src/traits.rs @@ -23,7 +23,7 @@ use codec::{FullCodec, Codec, Encode, Decode}; use sp_core::u32_trait::Value as U32; use sp_runtime::{ ConsensusEngineId, DispatchResult, DispatchError, - traits::{MaybeSerializeDeserialize, SimpleArithmetic, Saturating}, + traits::{MaybeSerializeDeserialize, SimpleArithmetic, Saturating, TrailingZeroInput}, }; use crate::dispatch::Parameter; @@ -777,6 +777,12 @@ pub trait Randomness { } } +impl Randomness for () { + fn random(subject: &[u8]) -> Output { + Output::decode(&mut TrailingZeroInput::new(subject)).unwrap_or_default() + } +} + /// Implementors of this trait provide information about whether or not some validator has /// been registered with them. The [Session module](../../pallet_session/index.html) is an implementor. pub trait ValidatorRegistration {