diff --git a/frame/society/src/lib.rs b/frame/society/src/lib.rs index 27051e4caf8d3..8c99e5b81a8c6 100644 --- a/frame/society/src/lib.rs +++ b/frame/society/src/lib.rs @@ -15,17 +15,17 @@ // 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. -//! +//! 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. @@ -33,31 +33,31 @@ //! * 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 @@ -72,7 +72,7 @@ //! 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 @@ -80,17 +80,17 @@ //! 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 <----------+ //! | + | @@ -115,40 +115,40 @@ //! | | //! +------------------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 ] //! +----------------------------------+ @@ -163,63 +163,63 @@ //! //! 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. @@ -228,9 +228,9 @@ //! * `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 @@ -305,7 +305,7 @@ pub trait Trait: system::Trait { type MaxLockDuration: Get; /// The origin that is allowed to call `found`. - type FounderOrigin: EnsureOrigin; + type FounderSetOrigin: EnsureOrigin; /// The origin that is allowed to make suspension judgements. type SuspensionJudgementOrigin: EnsureOrigin; @@ -400,6 +400,10 @@ impl BidKind { // This module's storage items. decl_storage! { trait Store for Module, I: Instance=DefaultInstance> as Society { + /// The first member. + pub Founder get(founder) build(|config: &GenesisConfig| config.members.first().cloned()): + Option; + /// The current set of candidates; bidders that are attempting to become members. pub Candidates get(candidates): Vec>>; @@ -444,7 +448,7 @@ decl_storage! { /// The defending member currently being challenged. Defender get(fn defender): Option; - + /// Votes for the defender. DefenderVotes: map hasher(twox_64_concat) T::AccountId => Option; @@ -796,26 +800,26 @@ decl_module! { /// 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_. + /// The dispatch origin for this call must be from the _FounderSetOrigin_. /// /// Parameters: /// - `founder` - The first member and head of the newly founded society. /// /// # - /// - One storage read to check `Head`. O(1) + /// - Two storage mutates to set `Head` and `Founder`. 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)?; + T::FounderSetOrigin::ensure_origin(origin)?; ensure!(!>::exists(), Error::::AlreadyFounded); // This should never fail in the context of this function... Self::add_member(&founder)?; >::put(&founder); + >::put(&founder); Self::deposit_event(RawEvent::Founded(founder)); } /// Allow suspension judgement origin to make judgement on a suspended member. @@ -849,7 +853,7 @@ decl_module! { 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)?; @@ -1010,33 +1014,35 @@ decl_error! { pub enum Error for Module, I: Instance> { /// An incorrect position was provided. BadPosition, - /// User is not a member + /// User is not a member. NotMember, - /// User is already a member + /// User is already a member. AlreadyMember, - /// User is suspended + /// User is suspended. Suspended, - /// User is not suspended + /// User is not suspended. NotSuspended, - /// Nothing to payout + /// Nothing to payout. NoPayout, - /// Society already founded + /// Society already founded. AlreadyFounded, - /// Not enough in pot to accept candidate + /// Not enough in pot to accept candidate. InsufficientPot, - /// Member is already vouching or banned from vouching again + /// Member is already vouching or banned from vouching again. AlreadyVouching, - /// Member is not vouching + /// Member is not vouching. NotVouching, - /// Cannot remove head + /// Cannot remove the head of the chain. Head, - /// User has already made a bid + /// Cannot remove the founder. + Founder, + /// User has already made a bid. AlreadyBid, - /// User is already a candidate + /// User is already a candidate. AlreadyCandidate, - /// User is not a candidate + /// User is not a candidate. NotCandidate, - /// Too many members in the society + /// Too many members in the society. MaxMembers, } } @@ -1081,6 +1087,18 @@ decl_event! { } } +/// Simple ensure origin struct to filter for the founder account. +pub struct EnsureFounder(sp_std::marker::PhantomData); +impl EnsureOrigin for EnsureFounder { + type Success = T::AccountId; + fn try_origin(o: T::Origin) -> Result { + o.into().and_then(|o| match (o, Founder::::get()) { + (system::RawOrigin::Signed(ref who), Some(ref f)) if who == f => Ok(who.clone()), + (r, _) => Err(T::Origin::from(r)), + }) + } +} + /// 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() { @@ -1201,6 +1219,7 @@ impl, I: Instance> Module { /// removes them from the Members storage item. pub fn remove_member(m: &T::AccountId) -> DispatchResult { ensure!(Self::head() != Some(m.clone()), Error::::Head); + ensure!(Self::founder() != Some(m.clone()), Error::::Founder); >::mutate(|members| match members.binary_search(&m) { @@ -1251,7 +1270,7 @@ impl, I: Instance> Module { .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); @@ -1325,7 +1344,7 @@ impl, I: Instance> Module { // if at least one candidate was accepted... if !accepted.is_empty() { - // select one as primary, randomly chosen from the accepted, weighted by approvals. + // select one as primary, randomly chosen from the accepted, weighted by approvals. // Choose a random number between 0 and `total_approvals` let primary_point = pick_usize(&mut rng, total_approvals - 1); // Find the zero bid or the user who falls on that point @@ -1333,7 +1352,7 @@ impl, I: Instance> Module { .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. @@ -1509,8 +1528,10 @@ impl, I: Instance> Module { /// 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>> - { + 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 mut max_selections: usize = 10.min(max_members.saturating_sub(members_len)); @@ -1521,7 +1542,7 @@ impl, I: Instance> Module { // The list of selected candidates let mut selected = Vec::new(); - + if bids.len() > 0 { // Can only select at most the length of bids max_selections = max_selections.min(bids.len()); diff --git a/frame/society/src/mock.rs b/frame/society/src/mock.rs index fc46561fd4fef..3ce8938f95302 100644 --- a/frame/society/src/mock.rs +++ b/frame/society/src/mock.rs @@ -104,7 +104,7 @@ impl Trait for Test { type MembershipChanged = (); type RotationPeriod = RotationPeriod; type MaxLockDuration = MaxLockDuration; - type FounderOrigin = EnsureSignedBy; + type FounderSetOrigin = EnsureSignedBy; type SuspensionJudgementOrigin = EnsureSignedBy; type ChallengePeriod = ChallengePeriod; } @@ -133,6 +133,9 @@ impl EnvBuilder { (40, 50), (50, 50), (60, 50), + (70, 50), + (80, 50), + (90, 50), ], pot: 0, max_members: 100, diff --git a/frame/society/src/tests.rs b/frame/society/src/tests.rs index 7d3ee06a2abeb..886363590d051 100644 --- a/frame/society/src/tests.rs +++ b/frame/society/src/tests.rs @@ -25,6 +25,8 @@ use sp_runtime::traits::BadOrigin; #[test] fn founding_works() { EnvBuilder::new().with_members(vec![]).execute(|| { + // No founder initially. + assert_eq!(Society::founder(), None); // Account 1 is set as the founder origin // Account 5 cannot start a society assert_noop!(Society::found(Origin::signed(5), 20), BadOrigin); @@ -34,6 +36,8 @@ fn founding_works() { assert_eq!(Society::members(), vec![10]); // 10 is the head of the society assert_eq!(Society::head(), Some(10)); + // ...and also the founder + assert_eq!(Society::founder(), Some(10)); // Cannot start another society assert_noop!(Society::found(Origin::signed(1), 20), Error::::AlreadyFounded); }); @@ -264,7 +268,7 @@ fn suspended_member_lifecycle_works() { // 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); @@ -460,10 +464,11 @@ fn unbid_vouch_works() { } #[test] -fn head_cannot_be_removed() { +fn founder_and_head_cannot_be_removed() { EnvBuilder::new().execute(|| { - // 10 is the only member and head + // 10 is the only member, founder, and head assert_eq!(Society::members(), vec![10]); + assert_eq!(Society::founder(), Some(10)); assert_eq!(Society::head(), Some(10)); // 10 can still accumulate strikes assert_ok!(Society::bid(Origin::signed(20), 0)); @@ -485,16 +490,37 @@ fn head_cannot_be_removed() { run_to_block(32); assert_eq!(Society::members(), vec![10, 50]); assert_eq!(Society::head(), Some(50)); + // Founder is unchanged + assert_eq!(Society::founder(), Some(10)); - // 10 can now be suspended for strikes + // 50 can still accumulate 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]); + assert_eq!(Strikes::::get(50), 1); + assert_ok!(Society::bid(Origin::signed(70), 0)); + run_to_block(48); + assert_eq!(Strikes::::get(50), 2); + + // Replace the head + assert_ok!(Society::bid(Origin::signed(80), 0)); + run_to_block(52); + assert_ok!(Society::vote(Origin::signed(10), 80, true)); + assert_ok!(Society::vote(Origin::signed(50), 80, true)); + assert_ok!(Society::defender_vote(Origin::signed(10), true)); // Keep defender around + run_to_block(56); + assert_eq!(Society::members(), vec![10, 50, 80]); + assert_eq!(Society::head(), Some(80)); + assert_eq!(Society::founder(), Some(10)); + + // 50 can now be suspended for strikes + assert_ok!(Society::bid(Origin::signed(90), 0)); + run_to_block(60); + // The candidate is rejected, so voting approve will give a strike + assert_ok!(Society::vote(Origin::signed(50), 90, true)); + run_to_block(64); + assert_eq!(Strikes::::get(50), 0); + assert_eq!(>::get(50), Some(())); + assert_eq!(Society::members(), vec![10, 80]); }); }