diff --git a/substrate/Cargo.lock b/substrate/Cargo.lock index 35afd37dbb..3afeae8ddc 100644 --- a/substrate/Cargo.lock +++ b/substrate/Cargo.lock @@ -3995,6 +3995,21 @@ dependencies = [ "substrate-primitives 2.0.0", ] +[[package]] +name = "srml-scored-pool" +version = "1.0.0" +dependencies = [ + "parity-scale-codec 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.97 (registry+https://github.com/rust-lang/crates.io-index)", + "sr-io 2.0.0", + "sr-primitives 2.0.0", + "sr-std 2.0.0", + "srml-balances 2.0.0", + "srml-support 2.0.0", + "srml-system 2.0.0", + "substrate-primitives 2.0.0", +] + [[package]] name = "srml-session" version = "2.0.0" diff --git a/substrate/Cargo.toml b/substrate/Cargo.toml index de0abb643d..65ec37073e 100644 --- a/substrate/Cargo.toml +++ b/substrate/Cargo.toml @@ -86,6 +86,7 @@ members = [ "srml/membership", "srml/metadata", "srml/offences", + "srml/scored-pool", "srml/session", "srml/staking", "srml/sudo", diff --git a/substrate/core/sr-std/src/lib.rs b/substrate/core/sr-std/src/lib.rs index 24c137c285..f369d3908b 100644 --- a/substrate/core/sr-std/src/lib.rs +++ b/substrate/core/sr-std/src/lib.rs @@ -68,7 +68,7 @@ include!("../without_std.rs"); pub mod prelude { pub use crate::vec::Vec; pub use crate::boxed::Box; - pub use crate::cmp::{Eq, PartialEq}; + pub use crate::cmp::{Eq, PartialEq, Reverse}; pub use crate::clone::Clone; // Re-export `vec!` macro here, but not in `std` mode, since diff --git a/substrate/node/runtime/src/lib.rs b/substrate/node/runtime/src/lib.rs index 0a7016770d..0ddd747fca 100644 --- a/substrate/node/runtime/src/lib.rs +++ b/substrate/node/runtime/src/lib.rs @@ -81,7 +81,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // implementation changes and behavior does not, then leave spec_version as // is and increment impl_version. spec_version: 147, - impl_version: 147, + impl_version: 148, apis: RUNTIME_API_VERSIONS, }; diff --git a/substrate/srml/scored-pool/Cargo.toml b/substrate/srml/scored-pool/Cargo.toml new file mode 100644 index 0000000000..6494d363fa --- /dev/null +++ b/substrate/srml/scored-pool/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "srml-scored-pool" +version = "1.0.0" +authors = ["Parity Technologies "] +edition = "2018" + +[dependencies] +codec = { package = "parity-scale-codec", version = "1.0.0", default-features = false, features = ["derive"] } +serde = { version = "1.0", optional = true } +sr-io = { path = "../../core/sr-io", default-features = false } +sr-primitives = { path = "../../core/sr-primitives", default-features = false } +sr-std = { path = "../../core/sr-std", default-features = false } +srml-support = { path = "../support", default-features = false } +system = { package = "srml-system", path = "../system", default-features = false } + +[dev-dependencies] +balances = { package = "srml-balances", path = "../balances" } +primitives = { package = "substrate-primitives", path = "../../core/primitives" } + +[features] +default = ["std"] +std = [ + "codec/std", + "serde", + "sr-io/std", + "sr-primitives/std", + "sr-std/std", + "srml-support/std", + "system/std", +] diff --git a/substrate/srml/scored-pool/src/lib.rs b/substrate/srml/scored-pool/src/lib.rs new file mode 100644 index 0000000000..1348b73864 --- /dev/null +++ b/substrate/srml/scored-pool/src/lib.rs @@ -0,0 +1,457 @@ +// Copyright 2019 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 . + +//! # Scored Pool Module +//! +//! The module maintains a scored membership pool. Each entity in the +//! pool can be attributed a `Score`. From this pool a set `Members` +//! is constructed. This set contains the `MemberCount` highest +//! scoring entities. Unscored entities are never part of `Members`. +//! +//! If an entity wants to be part of the pool a deposit is required. +//! The deposit is returned when the entity withdraws or when it +//! is removed by an entity with the appropriate authority. +//! +//! Every `Period` blocks the set of `Members` is refreshed from the +//! highest scoring members in the pool and, no matter if changes +//! occurred, `T::MembershipChanged::set_members_sorted` is invoked. +//! On first load `T::MembershipInitialized::initialize_members` is +//! invoked with the initial `Members` set. +//! +//! It is possible to withdraw candidacy/resign your membership at any +//! time. If an entity is currently a member, this results in removal +//! from the `Pool` and `Members`; the entity is immediately replaced +//! by the next highest scoring candidate in the pool, if available. +//! +//! - [`scored_pool::Trait`](./trait.Trait.html) +//! - [`Call`](./enum.Call.html) +//! - [`Module`](./struct.Module.html) +//! +//! ## Interface +//! +//! ### Public Functions +//! +//! - `submit_candidacy` - Submit candidacy to become a member. Requires a deposit. +//! - `withdraw_candidacy` - Withdraw candidacy. Deposit is returned. +//! - `score` - Attribute a quantitative score to an entity. +//! - `kick` - Remove an entity from the pool and members. Deposit is returned. +//! - `change_member_count` - Changes the amount of candidates taken into `Members`. +//! +//! ## Usage +//! +//! ``` +//! use srml_support::{decl_module, dispatch::Result}; +//! use system::ensure_signed; +//! use srml_scored_pool::{self as scored_pool}; +//! +//! pub trait Trait: scored_pool::Trait {} +//! +//! decl_module! { +//! pub struct Module for enum Call where origin: T::Origin { +//! pub fn candidate(origin) -> Result { +//! let who = ensure_signed(origin)?; +//! +//! let _ = >::submit_candidacy( +//! T::Origin::from(Some(who.clone()).into()) +//! ); +//! Ok(()) +//! } +//! } +//! } +//! +//! # fn main() { } +//! ``` +//! +//! ## Dependencies +//! +//! This module depends on the [System module](../srml_system/index.html). + +// 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 codec::{Encode, Decode}; +use sr_std::prelude::*; +use srml_support::{ + StorageValue, StorageMap, decl_module, decl_storage, decl_event, ensure, + traits::{ChangeMembers, InitializeMembers, Currency, Get, ReservableCurrency}, +}; +use system::{self, ensure_root, ensure_signed}; +use sr_primitives::{ + traits::{EnsureOrigin, SimpleArithmetic, MaybeSerializeDebug, Zero, StaticLookup}, +}; + +type BalanceOf = <>::Currency as Currency<::AccountId>>::Balance; +type PoolT = Vec<(::AccountId, Option<>::Score>)>; + +/// The enum is supplied when refreshing the members set. +/// Depending on the enum variant the corresponding associated +/// type function will be invoked. +enum ChangeReceiver { + /// Should call `T::MembershipInitialized`. + MembershipInitialized, + /// Should call `T::MembershipChanged`. + MembershipChanged, +} + +pub trait Trait: system::Trait { + /// The currency used for deposits. + type Currency: Currency + ReservableCurrency; + + /// The score attributed to a member or candidate. + type Score: SimpleArithmetic + Clone + Copy + Default + Encode + Decode + MaybeSerializeDebug; + + /// The overarching event type. + type Event: From> + Into<::Event>; + + // The deposit which is reserved from candidates if they want to + // start a candidacy. The deposit gets returned when the candidacy is + // withdrawn or when the candidate is kicked. + type CandidateDeposit: Get>; + + /// Every `Period` blocks the `Members` are filled with the highest scoring + /// members in the `Pool`. + type Period: Get; + + /// The receiver of the signal for when the membership has been initialized. + /// This happens pre-genesis and will usually be the same as `MembershipChanged`. + /// If you need to do something different on initialization, then you can change + /// this accordingly. + type MembershipInitialized: InitializeMembers; + + /// The receiver of the signal for when the members have changed. + type MembershipChanged: ChangeMembers; + + /// Allows a configurable origin type to set a score to a candidate in the pool. + type ScoreOrigin: EnsureOrigin; + + /// Required origin for removing a member (though can always be Root). + /// Configurable origin which enables removing an entity. If the entity + /// is part of the `Members` it is immediately replaced by the next + /// highest scoring candidate, if available. + type KickOrigin: EnsureOrigin; +} + +decl_storage! { + trait Store for Module, I: Instance=DefaultInstance> as ScoredPool { + /// The current pool of candidates, stored as an ordered Vec + /// (ordered descending by score, `None` last, highest first). + Pool get(pool) config(): PoolT; + + /// A Map of the candidates. The information in this Map is redundant + /// to the information in the `Pool`. But the Map enables us to easily + /// check if a candidate is already in the pool, without having to + /// iterate over the entire pool (the `Pool` is not sorted by + /// `T::AccountId`, but by `T::Score` instead). + CandidateExists get(candidate_exists): map T::AccountId => bool; + + /// The current membership, stored as an ordered Vec. + Members get(members): Vec; + + /// Size of the `Members` set. + MemberCount get(member_count) config(): u32; + } + add_extra_genesis { + config(members): Vec; + config(phantom): sr_std::marker::PhantomData; + build(| + storage: &mut (sr_primitives::StorageOverlay, sr_primitives::ChildrenStorageOverlay), + config: &Self, + | { + sr_io::with_storage(storage, || { + let mut pool = config.pool.clone(); + + // reserve balance for each candidate in the pool. + // panicking here is ok, since this just happens one time, pre-genesis. + pool + .iter() + .for_each(|(who, _)| { + T::Currency::reserve(&who, T::CandidateDeposit::get()) + .expect("balance too low to create candidacy"); + >::insert(who, true); + }); + + /// Sorts the `Pool` by score in a descending order. Entities which + /// have a score of `None` are sorted to the beginning of the vec. + pool.sort_by_key(|(_, maybe_score)| + Reverse(maybe_score.unwrap_or_default()) + ); + + >::put(&pool); + >::refresh_members(pool, ChangeReceiver::MembershipInitialized); + }); + }) + } +} + +decl_event!( + pub enum Event where + ::AccountId, + { + /// The given member was removed. See the transaction for who. + MemberRemoved, + /// An entity has issued a candidacy. See the transaction for who. + CandidateAdded, + /// An entity withdrew candidacy. See the transaction for who. + CandidateWithdrew, + /// The candidacy was forcefully removed for an entity. + /// See the transaction for who. + CandidateKicked, + /// A score was attributed to the candidate. + /// See the transaction for who. + CandidateScored, + /// Phantom member, never used. + Dummy(sr_std::marker::PhantomData<(AccountId, I)>), + } +); + +decl_module! { + pub struct Module, I: Instance=DefaultInstance> + for enum Call + where origin: T::Origin + { + fn deposit_event() = default; + + /// Every `Period` blocks the `Members` set is refreshed from the + /// highest scoring members in the pool. + fn on_initialize(n: T::BlockNumber) { + if n % T::Period::get() == Zero::zero() { + let pool = >::get(); + >::refresh_members(pool, ChangeReceiver::MembershipChanged); + } + } + + /// Add `origin` to the pool of candidates. + /// + /// This results in `CandidateDeposit` being reserved from + /// the `origin` account. The deposit is returned once + /// candidacy is withdrawn by the candidate or the entity + /// is kicked by `KickOrigin`. + /// + /// The dispatch origin of this function must be signed. + /// + /// The `index` parameter of this function must be set to + /// the index of the transactor in the `Pool`. + pub fn submit_candidacy(origin) { + let who = ensure_signed(origin)?; + ensure!(!>::exists(&who), "already a member"); + + let deposit = T::CandidateDeposit::get(); + T::Currency::reserve(&who, deposit) + .map_err(|_| "balance too low to submit candidacy")?; + + // can be inserted as last element in pool, since entities with + // `None` are always sorted to the end. + if let Err(e) = >::append(&[(who.clone(), None)]) { + T::Currency::unreserve(&who, deposit); + return Err(e); + } + + >::insert(&who, true); + + Self::deposit_event(RawEvent::CandidateAdded); + } + + /// An entity withdraws candidacy and gets its deposit back. + /// + /// If the entity is part of the `Members`, then the highest member + /// of the `Pool` that is not currently in `Members` is immediately + /// placed in the set instead. + /// + /// The dispatch origin of this function must be signed. + /// + /// The `index` parameter of this function must be set to + /// the index of the transactor in the `Pool`. + pub fn withdraw_candidacy( + origin, + index: u32 + ) { + let who = ensure_signed(origin)?; + + let pool = >::get(); + Self::ensure_index(&pool, &who, index)?; + + Self::remove_member(pool, who, index)?; + Self::deposit_event(RawEvent::CandidateWithdrew); + } + + /// Kick a member `who` from the set. + /// + /// May only be called from `KickOrigin` or root. + /// + /// The `index` parameter of this function must be set to + /// the index of `dest` in the `Pool`. + pub fn kick( + origin, + dest: ::Source, + index: u32 + ) { + T::KickOrigin::try_origin(origin) + .map(|_| ()) + .or_else(ensure_root) + .map_err(|_| "bad origin")?; + + let who = T::Lookup::lookup(dest)?; + + let pool = >::get(); + Self::ensure_index(&pool, &who, index)?; + + Self::remove_member(pool, who, index)?; + Self::deposit_event(RawEvent::CandidateKicked); + } + + /// Score a member `who` with `score`. + /// + /// May only be called from `ScoreOrigin` or root. + /// + /// The `index` parameter of this function must be set to + /// the index of the `dest` in the `Pool`. + pub fn score( + origin, + dest: ::Source, + index: u32, + score: T::Score + ) { + T::ScoreOrigin::try_origin(origin) + .map(|_| ()) + .or_else(ensure_root) + .map_err(|_| "bad origin")?; + + let who = T::Lookup::lookup(dest)?; + + let mut pool = >::get(); + Self::ensure_index(&pool, &who, index)?; + + pool.remove(index as usize); + + // we binary search the pool (which is sorted descending by score). + // if there is already an element with `score`, we insert + // right before that. if not, the search returns a location + // where we can insert while maintaining order. + let item = (who.clone(), Some(score.clone())); + let location = pool + .binary_search_by_key( + &Reverse(score), + |(_, maybe_score)| Reverse(maybe_score.unwrap_or_default()) + ) + .unwrap_or_else(|l| l); + pool.insert(location, item); + + >::put(&pool); + Self::deposit_event(RawEvent::CandidateScored); + } + + /// Dispatchable call to change `MemberCount`. + /// + /// This will only have an effect the next time a refresh happens + /// (this happens each `Period`). + /// + /// May only be called from root. + pub fn change_member_count(origin, count: u32) { + ensure_root(origin)?; + >::put(&count); + } + } +} + +impl, I: Instance> Module { + + /// Fetches the `MemberCount` highest scoring members from + /// `Pool` and puts them into `Members`. + /// + /// The `notify` parameter is used to deduct which associated + /// type function to invoke at the end of the method. + fn refresh_members( + pool: PoolT, + notify: ChangeReceiver + ) { + let count = >::get(); + + let mut new_members: Vec = pool + .into_iter() + .filter(|(_, score)| score.is_some()) + .take(count as usize) + .map(|(account_id, _)| account_id) + .collect(); + new_members.sort(); + + let old_members = >::get(); + >::put(&new_members); + + match notify { + ChangeReceiver::MembershipInitialized => + T::MembershipInitialized::initialize_members(&new_members), + ChangeReceiver::MembershipChanged => + T::MembershipChanged::set_members_sorted( + &new_members[..], + &old_members[..], + ), + } + } + + /// Removes an entity `remove` at `index` from the `Pool`. + /// + /// If the entity is a member it is also removed from `Members` and + /// the deposit is returned. + fn remove_member( + mut pool: PoolT, + remove: T::AccountId, + index: u32 + ) -> Result<(), &'static str> { + // all callers of this function in this module also check + // the index for validity before calling this function. + // nevertheless we check again here, to assert that there was + // no mistake when invoking this sensible function. + Self::ensure_index(&pool, &remove, index)?; + + pool.remove(index as usize); + >::put(&pool); + + // remove from set, if it was in there + let members = >::get(); + if members.binary_search(&remove).is_ok() { + Self::refresh_members(pool, ChangeReceiver::MembershipChanged); + } + + >::remove(&remove); + + T::Currency::unreserve(&remove, T::CandidateDeposit::get()); + + Self::deposit_event(RawEvent::MemberRemoved); + Ok(()) + } + + /// Checks if `index` is a valid number and if the element found + /// at `index` in `Pool` is equal to `who`. + fn ensure_index( + pool: &PoolT, + who: &T::AccountId, + index: u32 + ) -> Result<(), &'static str> { + ensure!(index < pool.len() as u32, "index out of bounds"); + + let (index_who, _index_score) = &pool[index as usize]; + ensure!(index_who == who, "index does not match requested account"); + + Ok(()) + } +} + diff --git a/substrate/srml/scored-pool/src/mock.rs b/substrate/srml/scored-pool/src/mock.rs new file mode 100644 index 0000000000..86c8b0d8cf --- /dev/null +++ b/substrate/srml/scored-pool/src/mock.rs @@ -0,0 +1,177 @@ +// Copyright 2019 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 std::cell::RefCell; +use srml_support::{impl_outer_origin, parameter_types}; +use primitives::{H256, Blake2Hasher}; +// 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 sr_primitives::{ + Perbill, traits::{BlakeTwo256, IdentityLookup}, testing::Header, +}; +use 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 Period: u64 = 4; + + pub const KickOrigin: u64 = 2; + pub const ScoreOrigin: u64 = 3; + + 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; + pub const TransactionBaseFee: u64 = 0; + pub const TransactionByteFee: u64 = 0; +} + +impl system::Trait for Test { + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Call = (); + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type WeightMultiplierUpdate = (); + type Event = (); + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); +} + +impl balances::Trait for Test { + type Balance = u64; + type OnFreeBalanceZero = (); + type OnNewAccount = (); + type Event = (); + type TransactionPayment = (); + type TransferPayment = (); + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; + type TransactionBaseFee = TransactionBaseFee; + type TransactionByteFee = TransactionByteFee; + type WeightToFee = (); +} + +thread_local! { + pub static MEMBERS: RefCell> = RefCell::new(vec![]); +} + +pub struct TestChangeMembers; +impl ChangeMembers for TestChangeMembers { + fn change_members_sorted(incoming: &[u64], outgoing: &[u64], new: &[u64]) { + let mut old_plus_incoming = MEMBERS.with(|m| m.borrow().to_vec()); + old_plus_incoming.extend_from_slice(incoming); + old_plus_incoming.sort(); + + let mut new_plus_outgoing = new.to_vec(); + new_plus_outgoing.extend_from_slice(outgoing); + new_plus_outgoing.sort(); + + assert_eq!(old_plus_incoming, new_plus_outgoing); + + MEMBERS.with(|m| *m.borrow_mut() = new.to_vec()); + } +} + +impl InitializeMembers for TestChangeMembers { + fn initialize_members(new_members: &[u64]) { + MEMBERS.with(|m| *m.borrow_mut() = new_members.to_vec()); + } +} + +impl Trait for Test { + type Event = (); + type KickOrigin = EnsureSignedBy; + type MembershipInitialized = TestChangeMembers; + type MembershipChanged = TestChangeMembers; + type Currency = balances::Module; + type CandidateDeposit = CandidateDeposit; + type Period = Period; + type Score = u64; + type ScoreOrigin = EnsureSignedBy; +} + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +pub fn new_test_ext() -> sr_io::TestExternalities { + let mut t = system::GenesisConfig::default().build_storage::().unwrap(); + // We use default for brevity, but you can configure as desired if needed. + balances::GenesisConfig:: { + balances: vec![ + (5, 500_000), + (10, 500_000), + (15, 500_000), + (20, 500_000), + (31, 500_000), + (40, 500_000), + (99, 1), + ], + vesting: vec![], + }.assimilate_storage(&mut t).unwrap(); + GenesisConfig::{ + pool: vec![ + (5, None), + (10, Some(1)), + (20, Some(2)), + (31, Some(2)), + (40, Some(3)), + ], + member_count: 2, + .. Default::default() + }.assimilate_storage(&mut t).unwrap(); + t.into() +} + +/// Fetch an entity from the pool, if existent. +pub fn fetch_from_pool(who: u64) -> Option<(u64, Option)> { + >::pool() + .into_iter() + .find(|item| item.0 == who) +} + +/// Find an entity in the pool. +/// Returns its position in the `Pool` vec, if existent. +pub fn find_in_pool(who: u64) -> Option { + >::pool() + .into_iter() + .position(|item| item.0 == who) +} diff --git a/substrate/srml/scored-pool/src/tests.rs b/substrate/srml/scored-pool/src/tests.rs new file mode 100644 index 0000000000..cec27e66cd --- /dev/null +++ b/substrate/srml/scored-pool/src/tests.rs @@ -0,0 +1,283 @@ +// Copyright 2019 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 srml_support::{assert_ok, assert_noop}; +use sr_io::with_externalities; +use sr_primitives::traits::OnInitialize; + +type ScoredPool = Module; +type System = system::Module; +type Balances = balances::Module; + +const OOB_ERR: &str = "index out of bounds"; +const INDEX_ERR: &str = "index does not match requested account"; + +#[test] +fn query_membership_works() { + with_externalities(&mut new_test_ext(), || { + assert_eq!(ScoredPool::members(), vec![20, 40]); + assert_eq!(Balances::reserved_balance(&31), CandidateDeposit::get()); + assert_eq!(Balances::reserved_balance(&40), CandidateDeposit::get()); + assert_eq!(MEMBERS.with(|m| m.borrow().clone()), vec![20, 40]); + }); +} + +#[test] +fn submit_candidacy_must_not_work() { + with_externalities(&mut new_test_ext(), || { + assert_noop!( + ScoredPool::submit_candidacy(Origin::signed(99)), + "balance too low to submit candidacy" + ); + assert_noop!( + ScoredPool::submit_candidacy(Origin::signed(40)), + "already a member" + ); + }); +} + +#[test] +fn submit_candidacy_works() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 15; + + // when + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(who))); + assert_eq!(fetch_from_pool(15), Some((who, None))); + + // then + assert_eq!(Balances::reserved_balance(&who), CandidateDeposit::get()); + }); +} + +#[test] +fn scoring_works() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 15; + let score = 99; + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(who))); + + // when + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::score(Origin::signed(ScoreOrigin::get()), who, index, score)); + + // then + assert_eq!(fetch_from_pool(who), Some((who, Some(score)))); + assert_eq!(find_in_pool(who), Some(0)); // must be first element, since highest scored + }); +} + +#[test] +fn scoring_same_element_with_same_score_works() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 31; + let index = find_in_pool(who).expect("entity must be in pool") as u32; + let score = 2; + + // when + assert_ok!(ScoredPool::score(Origin::signed(ScoreOrigin::get()), who, index, score)); + + // then + assert_eq!(fetch_from_pool(who), Some((who, Some(score)))); + + // must have been inserted right before the `20` element which is + // of the same score as `31`. so sort order is maintained. + assert_eq!(find_in_pool(who), Some(1)); + }); +} + +#[test] +fn kicking_works_only_for_authorized() { + with_externalities(&mut new_test_ext(), || { + let who = 40; + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_noop!(ScoredPool::kick(Origin::signed(99), who, index), "bad origin"); + }); +} + +#[test] +fn kicking_works() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 40; + assert_eq!(Balances::reserved_balance(&who), CandidateDeposit::get()); + assert_eq!(find_in_pool(who), Some(0)); + + // when + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::kick(Origin::signed(KickOrigin::get()), who, index)); + + // then + assert_eq!(find_in_pool(who), None); + assert_eq!(ScoredPool::members(), vec![20, 31]); + assert_eq!(MEMBERS.with(|m| m.borrow().clone()), ScoredPool::members()); + assert_eq!(Balances::reserved_balance(&who), 0); // deposit must have been returned + }); +} + +#[test] +fn unscored_entities_must_not_be_used_for_filling_members() { + with_externalities(&mut new_test_ext(), || { + // given + // we submit a candidacy, score will be `None` + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(15))); + + // when + // we remove every scored member + ScoredPool::pool() + .into_iter() + .for_each(|(who, score)| { + if let Some(_) = score { + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::kick(Origin::signed(KickOrigin::get()), who, index)); + } + }); + + // then + // the `None` candidates should not have been filled in + assert_eq!(ScoredPool::members(), vec![]); + assert_eq!(MEMBERS.with(|m| m.borrow().clone()), ScoredPool::members()); + }); +} + +#[test] +fn refreshing_works() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 15; + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(who))); + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::score(Origin::signed(ScoreOrigin::get()), who, index, 99)); + + // when + ScoredPool::refresh_members(ScoredPool::pool(), ChangeReceiver::MembershipChanged); + + // then + assert_eq!(ScoredPool::members(), vec![15, 40]); + assert_eq!(MEMBERS.with(|m| m.borrow().clone()), ScoredPool::members()); + }); +} + +#[test] +fn refreshing_happens_every_period() { + with_externalities(&mut new_test_ext(), || { + // given + System::set_block_number(1); + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(15))); + let index = find_in_pool(15).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::score(Origin::signed(ScoreOrigin::get()), 15, index, 99)); + assert_eq!(ScoredPool::members(), vec![20, 40]); + + // when + System::set_block_number(4); + ScoredPool::on_initialize(4); + + // then + assert_eq!(ScoredPool::members(), vec![15, 40]); + assert_eq!(MEMBERS.with(|m| m.borrow().clone()), ScoredPool::members()); + }); +} + +#[test] +fn withdraw_candidacy_must_only_work_for_members() { + with_externalities(&mut new_test_ext(), || { + let who = 77; + let index = 0; + assert_noop!( ScoredPool::withdraw_candidacy(Origin::signed(who), index), INDEX_ERR); + }); +} + +#[test] +fn oob_index_should_abort() { + with_externalities(&mut new_test_ext(), || { + let who = 40; + let oob_index = ScoredPool::pool().len() as u32; + assert_noop!(ScoredPool::withdraw_candidacy(Origin::signed(who), oob_index), OOB_ERR); + assert_noop!(ScoredPool::score(Origin::signed(ScoreOrigin::get()), who, oob_index, 99), OOB_ERR); + assert_noop!(ScoredPool::kick(Origin::signed(KickOrigin::get()), who, oob_index), OOB_ERR); + }); +} + +#[test] +fn index_mismatches_should_abort() { + with_externalities(&mut new_test_ext(), || { + let who = 40; + let index = 3; + assert_noop!(ScoredPool::withdraw_candidacy(Origin::signed(who), index), INDEX_ERR); + assert_noop!(ScoredPool::score(Origin::signed(ScoreOrigin::get()), who, index, 99), INDEX_ERR); + assert_noop!(ScoredPool::kick(Origin::signed(KickOrigin::get()), who, index), INDEX_ERR); + }); +} + +#[test] +fn withdraw_unscored_candidacy_must_work() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 5; + + // when + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::withdraw_candidacy(Origin::signed(who), index)); + + // then + assert_eq!(fetch_from_pool(5), None); + }); +} + +#[test] +fn withdraw_scored_candidacy_must_work() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 40; + assert_eq!(Balances::reserved_balance(&who), CandidateDeposit::get()); + + // when + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::withdraw_candidacy(Origin::signed(who), index)); + + // then + assert_eq!(fetch_from_pool(who), None); + assert_eq!(ScoredPool::members(), vec![20, 31]); + assert_eq!(Balances::reserved_balance(&who), 0); + }); +} + +#[test] +fn candidacy_resubmitting_works() { + with_externalities(&mut new_test_ext(), || { + // given + let who = 15; + + // when + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(who))); + assert_eq!(ScoredPool::candidate_exists(who), true); + let index = find_in_pool(who).expect("entity must be in pool") as u32; + assert_ok!(ScoredPool::withdraw_candidacy(Origin::signed(who), index)); + assert_eq!(ScoredPool::candidate_exists(who), false); + assert_ok!(ScoredPool::submit_candidacy(Origin::signed(who))); + + // then + assert_eq!(ScoredPool::candidate_exists(who), true); + }); +}