diff --git a/Cargo.lock b/Cargo.lock index e0c54d49..d2d2841f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6884,6 +6884,26 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-fellowship" +version = "0.1.0" +dependencies = [ + "common-traits", + "common-types", + "frame-benchmarking", + "frame-support", + "frame-system", + "orml-tokens", + "orml-traits", + "parity-scale-codec 3.6.2", + "scale-info 2.8.0", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-grandpa" version = "4.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index e447f201..ea270ddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "pallets/grants", "pallets/crowdfunding", "pallets/deposits", + "pallets/fellowship", "runtime/integration-tests", "runtime/imbue-kusama", "runtime/common", diff --git a/libs/common-traits/src/lib.rs b/libs/common-traits/src/lib.rs index baf07c25..9576a9b5 100644 --- a/libs/common-traits/src/lib.rs +++ b/libs/common-traits/src/lib.rs @@ -89,3 +89,9 @@ pub trait TokenMetadata { fn decimals(&self) -> u8; } + +/// Fallible conversion trait returning an [Option]. Generic over both source and destination types. +pub trait MaybeConvert { + /// Attempt to make conversion. + fn maybe_convert(a: A) -> Option; +} diff --git a/pallets/fellowship/Cargo.toml b/pallets/fellowship/Cargo.toml new file mode 100644 index 00000000..d9e6c8ea --- /dev/null +++ b/pallets/fellowship/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "pallet-fellowship" +version = "0.1.0" +description = "Used to map the accounts to a fellowship role. Encompasses all the functionality associated with fellowship decisions." +authors = ["Substrate DevHub "] +license = 'Apache 2.0' +homepage = 'https://github.com/ImbueNetwork/imbue' +repository = "https://github.com/ImbueNetwork/imbue" +edition = '2018' +resolver = "2" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.2.2", default-features = false, features = [ + "derive", +] } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +frame-benchmarking = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43", default-features = false, optional = true } +frame-support = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43", default-features = false } +frame-system = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43", default-features = false } +orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", branch = "polkadot-v0.9.43", default-features = false } +common-types = { path = "../../libs/common-types", default-features = false} +common-traits = { path = "../../libs/common-traits", default-features = false } +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43", default-features = false} +sp-std = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43", default-features = false } + +[dev-dependencies] +sp-core = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43"} +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43"} +sp-io = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43"} +common-types = { path = "../../libs/common-types"} +common-traits = { path = "../../libs/common-traits"} +orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", branch = "polkadot-v0.9.43"} +orml-tokens = { git = "https://github.com/open-web3-stack/open-runtime-module-library", branch = "polkadot-v0.9.43"} +sp-std = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43"} +sp-arithmetic = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.43"} + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "scale-info/std", + "sp-runtime/std", + "sp-std/std", +] + +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", +] + +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/fellowship/src/benchmarking.rs b/pallets/fellowship/src/benchmarking.rs new file mode 100644 index 00000000..c08a7e1f --- /dev/null +++ b/pallets/fellowship/src/benchmarking.rs @@ -0,0 +1,143 @@ +#![cfg(feature = "runtime-benchmarks")] + +use super::*; +use crate::Pallet as Fellowship; +use crate::{traits::FellowshipHandle, Config, Role}; +use common_types::CurrencyId; +use frame_benchmarking::v2::*; +use frame_support::assert_ok; +use frame_system::Pallet as System; +use frame_system::RawOrigin; +use orml_traits::MultiCurrency; +use sp_runtime::SaturatedConversion; + +#[benchmarks( where ::AccountId: AsRef<[u8]>, crate::Event::: Into<::RuntimeEvent>)] +#[benchmarks] +mod benchmarks { + use super::*; + #[benchmark] + fn add_to_fellowship() { + let alice: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + let bob: T::AccountId = create_funded_user::("bob", 1, 1_000_000_000_000_000_000u128); + + #[block] + { + as FellowshipHandle<::AccountId>>::add_to_fellowship(&alice, Role::Vetter, 10, Some(&bob), true); + } + } + + #[benchmark] + fn force_add_fellowship() { + let alice: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + #[extrinsic_call] + force_add_fellowship(RawOrigin::Root, alice.clone(), Role::Freelancer, 10); + System::::assert_last_event( + Event::::FellowshipAdded { + who: alice, + role: Role::Freelancer, + } + .into(), + ); + } + + #[benchmark] + fn leave_fellowship() { + let alice: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + let bob: T::AccountId = create_funded_user::("bob", 1, 1_000_000_000_000_000_000u128); + as FellowshipHandle<::AccountId>>::add_to_fellowship(&alice, Role::Vetter, 10, Some(&bob), true); + + #[extrinsic_call] + leave_fellowship(RawOrigin::Signed(alice.clone())); + + System::::assert_last_event(Event::::FellowshipRemoved { who: alice }.into()); + } + + #[benchmark] + fn force_remove_and_slash_fellowship() { + let alice: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + let bob: T::AccountId = create_funded_user::("bob", 1, 1_000_000_000_000_000_000u128); + as FellowshipHandle<::AccountId>>::add_to_fellowship(&alice, Role::Vetter, 10, Some(&bob), true); + + #[extrinsic_call] + force_remove_and_slash_fellowship(RawOrigin::Root, alice.clone()); + System::::assert_last_event(Event::::FellowshipSlashed { who: alice }.into()); + } + + #[benchmark] + fn add_candidate_to_shortlist() { + let alice: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + let bob: T::AccountId = create_funded_user::("bob", 1, 1_000_000_000_000_000_000u128); + as FellowshipHandle<::AccountId>>::add_to_fellowship(&alice, Role::Vetter, 10, Some(&bob), true); + + #[extrinsic_call] + add_candidate_to_shortlist(RawOrigin::Signed(alice), bob.clone(), Role::Vetter, 10); + System::::assert_last_event(Event::::CandidateAddedToShortlist { who: bob }.into()); + } + + #[benchmark] + fn remove_candidate_from_shortlist() { + let alice: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + let bob: T::AccountId = create_funded_user::("bob", 1, 1_000_000_000_000_000_000u128); + as FellowshipHandle<::AccountId>>::add_to_fellowship(&alice, Role::Vetter, 10, Some(&bob), true); + assert_ok!(Fellowship::::add_candidate_to_shortlist( + RawOrigin::Signed(alice.clone()).into(), + bob.clone(), + Role::Vetter, + 10, + )); + + #[extrinsic_call] + remove_candidate_from_shortlist(RawOrigin::Signed(alice), bob.clone()); + System::::assert_last_event( + Event::::CandidateRemovedFromShortlist { who: bob }.into(), + ); + } + + #[benchmark] + fn pay_deposit_to_remove_pending_status() { + let bob: T::AccountId = account("bob", 1, 0); + let charlie: T::AccountId = + create_funded_user::("alice", 1, 1_000_000_000_000_000_000u128); + + as FellowshipHandle<::AccountId>>::add_to_fellowship(&bob, Role::Vetter, 10, Some(&charlie), true); + assert_ok!(::AccountId, + >>::deposit( + CurrencyId::Native, + &bob, + 1_000_000_000_000_000_000u128.saturated_into() + )); + + #[extrinsic_call] + pay_deposit_to_remove_pending_status(RawOrigin::Signed(bob.clone())); + System::::assert_last_event( + Event::::FellowshipAdded { + who: bob, + role: Role::Vetter, + } + .into(), + ); + } + + impl_benchmark_test_suite!(Fellowship, crate::mock::new_test_ext(), crate::mock::Test); +} + +pub fn create_funded_user( + seed: &'static str, + n: u32, + balance_factor: u128, +) -> T::AccountId { + let user = account(seed, n, 0); + assert_ok!(::AccountId, + >>::deposit( + CurrencyId::Native, &user, balance_factor.saturated_into() + )); + user +} diff --git a/pallets/fellowship/src/impls.rs b/pallets/fellowship/src/impls.rs new file mode 100644 index 00000000..4c24192f --- /dev/null +++ b/pallets/fellowship/src/impls.rs @@ -0,0 +1,79 @@ +use crate::traits::EnsureRole; +use crate::*; +use common_traits::MaybeConvert; +use frame_support::{ensure, traits::Get}; +use orml_traits::MultiReservableCurrency; +use sp_runtime::{ + traits::{BadOrigin, Convert}, + DispatchError, Percent, +}; +use sp_std::vec::Vec; + +/// Ensure that a account is of a given role. +/// Used in other pallets like an ensure origin. +pub struct EnsureFellowshipRole(T); +impl EnsureRole, Role> for EnsureFellowshipRole { + type Success = (); + + fn ensure_role( + acc: &AccountIdOf, + role: Role, + rank: Option, + ) -> Result { + let (actual_role, actual_rank) = Roles::::get(acc).ok_or(BadOrigin)?; + ensure!(actual_role == role, BadOrigin); + if let Some(r) = rank { + ensure!(r == actual_rank, BadOrigin); + } + Ok(()) + } + fn ensure_role_in( + acc: &AccountIdOf, + roles: Vec, + ranks: Option>, + ) -> Result { + let (actual_role, actual_rank) = Roles::::get(acc).ok_or(BadOrigin)?; + ensure!(roles.contains(&actual_role), BadOrigin); + if let Some(r) = ranks { + ensure!(r.contains(&actual_rank), BadOrigin); + } + Ok(()) + } +} + +impl MaybeConvert<&AccountIdOf, VetterIdOf> for Pallet { + fn maybe_convert(fellow: &AccountIdOf) -> Option> { + FellowToVetter::::get(fellow) + } +} + +pub struct RoleToPercentFee; +impl Convert for RoleToPercentFee { + fn convert(role: Role) -> Percent { + match role { + Role::Vetter => Percent::from_percent(50u8), + Role::Freelancer => Percent::from_percent(50u8), + Role::BusinessDev => Percent::from_percent(50u8), + Role::Approver => Percent::from_percent(50u8), + } + } +} + +impl Pallet { + /// Try take the membership deposit from who + /// If the deposit was taken, this will return true, else false. + pub(crate) fn try_take_deposit(who: &AccountIdOf) -> bool { + let membership_deposit = ::MembershipDeposit::get(); + if ::MultiCurrency::reserve( + T::DepositCurrencyId::get(), + who, + membership_deposit, + ) + .is_ok() + { + FellowshipReserves::::insert(who, membership_deposit); + return true; + } + false + } +} diff --git a/pallets/fellowship/src/lib.rs b/pallets/fellowship/src/lib.rs new file mode 100644 index 00000000..554be440 --- /dev/null +++ b/pallets/fellowship/src/lib.rs @@ -0,0 +1,402 @@ +// The Imbue fellowship pallet is used as a way of creating a community of internally approved members. +// One must be of a specific role to add accounts to a shortlist, anyone with the correct authority can remove them from the shortlist. +// After T::ShortlistPeriod blocks the shortlist will attempt to take the membership deposit from the accounts on the list. +// If the deposit is taken they are successfully added to the fellowship with a default rank. +// If the deposit is not taken they are added to PendingFellows where they can pay the deposit later to claim their fellowship. + +// Future development: How to deal with ranks + promotions. + +#![cfg_attr(not(feature = "std"), no_std)] +pub use pallet::*; + +pub mod impls; +pub mod traits; +pub mod weights; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +#[frame_support::pallet] +pub mod pallet { + use common_types::CurrencyId; + use frame_support::{pallet_prelude::*, BoundedBTreeMap}; + use frame_system::pallet_prelude::*; + use orml_traits::{MultiCurrency, MultiReservableCurrency}; + use sp_runtime::traits::Zero; + use sp_std::{convert::TryInto, vec}; + + use crate::impls::EnsureFellowshipRole; + use crate::traits::WeightInfoT; + use crate::traits::{EnsureRole, FellowshipHandle}; + + pub(crate) type AccountIdOf = ::AccountId; + pub(crate) type VetterIdOf = AccountIdOf; + pub(crate) type Rank = u16; + + pub(crate) type BalanceOf = + <::MultiCurrency as MultiCurrency>>::Balance; + pub(crate) type ShortlistRoundKey = u32; + pub(crate) type BoundedShortlistPlaces = BoundedBTreeMap< + AccountIdOf, + ((Role, Rank), Option>), + ::MaxCandidatesPerShortlist, + >; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// The overarching currency type. + type MultiCurrency: MultiReservableCurrency, CurrencyId = CurrencyId>; + /// The authority appropriate to do call force extrinsics. + type ForceAuthority: EnsureOrigin<::RuntimeOrigin>; + /// The max number of candidates per wave. + type MaxCandidatesPerShortlist: Get; + /// The amount of time before a shortlist is processed. + type ShortlistPeriod: Get>; + /// The minimum deposit required for a freelancer to hold fellowship status. + type MembershipDeposit: Get>; + /// The deposit currency id that is taken + type DepositCurrencyId: Get; + /// Currently just send all slash deposits to a single account. + type SlashAccount: Get>; + /// The weights generated by the benchmarks. + type WeightInfo: WeightInfoT; + } + + /// Used to map who is a part of the fellowship. + /// Returns the role of the account + #[pallet::storage] + pub type Roles = StorageMap<_, Blake2_128Concat, AccountIdOf, (Role, Rank), OptionQuery>; + + /// Contains the shortlist of candidates to be sent for approval. + #[pallet::storage] + pub type CandidateShortlist = + StorageMap<_, Blake2_128Concat, ShortlistRoundKey, BoundedShortlistPlaces, ValueQuery>; + + /// Keeps track of the round the shortlist is in. + #[pallet::storage] + pub type ShortlistRound = StorageValue<_, ShortlistRoundKey, ValueQuery>; + + /// Holds all the accounts that are able to become fellows that have not given their deposit for membership. + #[pallet::storage] + pub type PendingFellows = + StorageMap<_, Blake2_128Concat, AccountIdOf, (Role, Rank), OptionQuery>; + + /// Keeps track of the deposits taken from a fellow. + /// Needed incase the reserve amount will change. + #[pallet::storage] + pub type FellowshipReserves = + StorageMap<_, Blake2_128Concat, AccountIdOf, BalanceOf, OptionQuery>; + + /// Keeps track of the accounts a fellow has recruited. + /// Can be used to pay out completion fees. + #[pallet::storage] + pub type FellowToVetter = + StorageMap<_, Blake2_128Concat, AccountIdOf, VetterIdOf, OptionQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A member has been added to the fellowship. + FellowshipAdded { who: AccountIdOf, role: Role }, + /// A member has been removed from the fellowship. + FellowshipRemoved { who: AccountIdOf }, + /// A member has been removed from the fellowship and their deposit slashes. + FellowshipSlashed { who: AccountIdOf }, + /// A member has been added to pending fellows awaiting deposit payment. + MemberAddedToPendingFellows { who: AccountIdOf }, + /// A candidate has been added to the shortlist. + CandidateAddedToShortlist { who: AccountIdOf }, + /// A candidate has been removed from the shortlist. + CandidateRemovedFromShortlist { who: AccountIdOf }, + } + + #[pallet::error] + pub enum Error { + /// This account does not have a role in the fellowship. + RoleNotFound, + /// This account is not a fellow. + NotAFellow, + /// This account is not a Vetter. + NotAVetter, + /// Already a fellow. + AlreadyAFellow, + /// The candidate must have the deposit amount to be put on the shortlst. + CandidateDepositRequired, + /// The candidate is already on the shortlist. + CandidateAlreadyOnShortlist, + /// The maximum number of candidates has been reached. + TooManyCandidates, + /// The fellowship deposit has could not be found, contact development. + FellowshipReserveDisapeared, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(n: BlockNumberFor) -> Weight { + let mut weight = Weight::default(); + if n % T::ShortlistPeriod::get() == Zero::zero() { + let round_key = ShortlistRound::::get(); + let shortlist = CandidateShortlist::::get(round_key); + weight = weight.saturating_add(T::DbWeight::get().reads(2)); + + shortlist + .iter() + .for_each(|(acc, ((role, rank), maybe_vetter))| { + weight = weight.saturating_add(T::WeightInfo::add_to_fellowship()); + Self::add_to_fellowship(acc, *role, *rank, maybe_vetter.as_ref(), true); + }); + + weight = weight.saturating_add(T::DbWeight::get().reads_writes(2, 2)); + CandidateShortlist::::remove(round_key); + ShortlistRound::::put(round_key.saturating_add(1)); + } + weight + } + } + + #[pallet::call] + impl Pallet { + /// An origin check wrapping the standard add_to_fellowship call. + /// Force add someone to the fellowship. This is required to be called by the ForceOrigin + /// WARNING: No deposit is taken for force adds. + #[pallet::call_index(0)] + #[pallet::weight(::WeightInfo::force_add_fellowship())] + pub fn force_add_fellowship( + origin: OriginFor, + who: AccountIdOf, + role: Role, + rank: Rank, + ) -> DispatchResult { + ::ForceAuthority::ensure_origin(origin)?; + >>::add_to_fellowship( + &who, role, rank, None, false, + ); + Self::deposit_event(Event::::FellowshipAdded { + who: who.clone(), + role, + }); + Ok(()) + } + + /// Remove the account from the fellowship, + /// Called by the fellow and returns the deposit to them. + #[pallet::call_index(1)] + #[pallet::weight(::WeightInfo::leave_fellowship())] + pub fn leave_fellowship(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + // TODO??: ensure that the fellow is not currently in a dispute. + >>::revoke_fellowship(&who, false)?; + Self::deposit_event(Event::::FellowshipRemoved { who }); + Ok(()) + } + + /// Force remove a fellow and slashed their deposit as defined in the Config. + #[pallet::call_index(2)] + #[pallet::weight(::WeightInfo::force_remove_and_slash_fellowship())] + pub fn force_remove_and_slash_fellowship( + origin: OriginFor, + who: AccountIdOf, + ) -> DispatchResult { + ::ForceAuthority::ensure_origin(origin)?; + >>::revoke_fellowship(&who, true)?; + Self::deposit_event(Event::::FellowshipSlashed { who }); + Ok(()) + } + + /// Add a candidate to a shortlist. + /// The caller must be of type Vetter or Freelancer to add to a shortlist. + /// Also the candidate must already have the minimum deposit required. + #[pallet::call_index(3)] + #[pallet::weight(::WeightInfo::add_candidate_to_shortlist())] + pub fn add_candidate_to_shortlist( + origin: OriginFor, + candidate: AccountIdOf, + role: Role, + rank: Rank, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!( + EnsureFellowshipRole::::ensure_role_in( + &who, + vec![Role::Freelancer, Role::Vetter], + None + ) + .is_ok(), + Error::::NotAVetter + ); + ensure!( + Roles::::get(&candidate).is_none(), + Error::::AlreadyAFellow + ); + ensure!( + T::MultiCurrency::can_reserve( + T::DepositCurrencyId::get(), + &candidate, + ::MembershipDeposit::get() + ), + Error::::CandidateDepositRequired + ); + CandidateShortlist::::try_mutate(ShortlistRound::::get(), |m_shortlist| { + ensure!( + !m_shortlist.contains_key(&candidate), + Error::::CandidateAlreadyOnShortlist + ); + m_shortlist + .try_insert(candidate.clone(), ((role, rank), Some(who))) + .map_err(|_| Error::::TooManyCandidates)?; + Ok::<(), DispatchError>(()) + })?; + + Self::deposit_event(Event::::CandidateAddedToShortlist { who: candidate }); + Ok(()) + } + + /// Remove a candidate from the shortlist. + /// The caller must have a role of either Vetter or Freelancer. + #[pallet::call_index(4)] + #[pallet::weight(::WeightInfo::remove_candidate_from_shortlist())] + pub fn remove_candidate_from_shortlist( + origin: OriginFor, + candidate: AccountIdOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!( + EnsureFellowshipRole::::ensure_role_in( + &who, + vec![Role::Freelancer, Role::Vetter], + None + ) + .is_ok(), + Error::::NotAVetter + ); + CandidateShortlist::::try_mutate(ShortlistRound::::get(), |m_shortlist| { + m_shortlist.remove(&candidate); + Ok::<(), DispatchError>(()) + })?; + + Self::deposit_event(Event::::CandidateRemovedFromShortlist { who: candidate }); + Ok(()) + } + + /// If the freelancer fails to have enough native token at the time of shortlist approval they are + /// added to the PendingFellows, calling this allows them to attempt to take the deposit and + /// become a fellow. + #[pallet::call_index(5)] + #[pallet::weight(::WeightInfo::pay_deposit_to_remove_pending_status())] + pub fn pay_deposit_to_remove_pending_status(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + let (role, rank) = PendingFellows::::get(&who).ok_or(Error::::NotAFellow)?; + let membership_deposit = ::MembershipDeposit::get(); + + ::MultiCurrency::reserve( + T::DepositCurrencyId::get(), + &who, + membership_deposit, + )?; + FellowshipReserves::::insert(&who, membership_deposit); + PendingFellows::::remove(&who); + Roles::::insert(&who, (role, rank)); + + Self::deposit_event(Event::::FellowshipAdded { who, role }); + Ok(()) + } + } + + impl FellowshipHandle> for Pallet { + type Role = crate::pallet::Role; + type Rank = crate::pallet::Rank; + + /// Does no check on the Origin of the call. + /// Add someone to the fellowship the only way this "fails" is when the candidate does not have + /// enough native token for the deposit, this candidate is then added to PendingFellows where they + /// can pay the deposit later to accept the membership. + /// The deposit amount + currency is defined in the Config. + /// To pay the deposit, call pay_deposit_to_remove_pending_status + fn add_to_fellowship( + who: &AccountIdOf, + role: Role, + rank: Rank, + vetter: Option<&VetterIdOf>, + take_membership_deposit: bool, + ) { + // If they aleady have a role then dont reserve as the reservation has already been taken. + // This would only happen if a role was changed. + if !Roles::::contains_key(who) { + if take_membership_deposit { + if Self::try_take_deposit(who) { + Roles::::insert(who, (role, rank)); + } else { + PendingFellows::::insert(who, (role, rank)); + Self::deposit_event(Event::::MemberAddedToPendingFellows { + who: who.clone(), + }); + } + } else { + Roles::::insert(who, (role, rank)); + } + + if let Some(v) = vetter { + FellowToVetter::::insert(who, v); + } + } else { + Roles::::insert(who, (role, rank)); + } + } + + /// Does no check on the Origin of the call. + /// Revoke the fellowship from an account. + /// If they have not paid the deposit but are eligable then they can still be revoked + /// using this method. + fn revoke_fellowship( + who: &AccountIdOf, + slash_deposit: bool, + ) -> Result<(), DispatchError> { + let has_role = Roles::::contains_key(who); + ensure!( + PendingFellows::::contains_key(who) || has_role, + Error::::NotAFellow + ); + PendingFellows::::remove(who); + Roles::::remove(who); + FellowToVetter::::remove(who); + + // Deposits are only taken when a role is assigned + if has_role { + if let Some(deposit_amount) = FellowshipReserves::::get(who) { + ::MultiCurrency::unreserve( + CurrencyId::Native, + who, + deposit_amount, + ); + if slash_deposit { + ::MultiCurrency::transfer( + CurrencyId::Native, + who, + &::SlashAccount::get(), + deposit_amount, + )?; + } + } + } + Ok(()) + } + } + + #[derive(Encode, Decode, PartialEq, Eq, Copy, Clone, Debug, MaxEncodedLen, TypeInfo)] + pub enum Role { + Vetter, + Freelancer, + BusinessDev, + Approver, + } +} diff --git a/pallets/fellowship/src/mock.rs b/pallets/fellowship/src/mock.rs new file mode 100644 index 00000000..b7b9c577 --- /dev/null +++ b/pallets/fellowship/src/mock.rs @@ -0,0 +1,153 @@ +use crate as pallet_fellowship; +use common_types::CurrencyId; +use frame_support::once_cell::sync::Lazy; +use frame_support::traits::{ConstU16, Nothing}; +use frame_system::EnsureRoot; +use orml_traits::MultiCurrency; +use sp_core::sr25519::{Public, Signature}; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{parameter_types, BlakeTwo256, IdentifyAccount, IdentityLookup, Verify}, +}; +use sp_std::convert::{TryFrom, TryInto}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; +type BlockNumber = u64; +pub type Balance = u64; +pub type AccountId = <::Signer as IdentifyAccount>::AccountId; + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Fellowship: pallet_fellowship, + Tokens: orml_tokens, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = u64; + type BlockNumber = BlockNumber; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = orml_tokens::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = ConstU16<42>; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +parameter_types! { + pub MaxCandidatesPerShortlist: u32 = 100; + pub ShortlistPeriod: BlockNumber = 100; + pub MembershipDeposit: Balance = 50_000_000; + pub SlashAccount: AccountId = Public::from_raw([1u8; 32]); + pub BlockHashCount: BlockNumber = 250; + pub DepositCurrencyId: CurrencyId = CurrencyId::Native; +} + +impl pallet_fellowship::Config for Test { + type RuntimeEvent = RuntimeEvent; + type MultiCurrency = Tokens; + type ForceAuthority = EnsureRoot; + type MaxCandidatesPerShortlist = MaxCandidatesPerShortlist; + type ShortlistPeriod = ShortlistPeriod; + type MembershipDeposit = MembershipDeposit; + type DepositCurrencyId = DepositCurrencyId; + type SlashAccount = SlashAccount; + type WeightInfo = (); +} + +orml_traits::parameter_type_with_key! { + pub ExistentialDeposits: |_currency_id: CurrencyId| -> Balance { + 100 + }; +} + +parameter_types! { + pub const MaxReserves: u32 = 50; + pub MaxLocks: u32 = 2; +} + +impl orml_tokens::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Balance = Balance; + type Amount = i128; + type CurrencyId = common_types::CurrencyId; + type CurrencyHooks = (); + type WeightInfo = (); + type ExistentialDeposits = ExistentialDeposits; + type MaxLocks = MaxLocks; + type DustRemovalWhitelist = Nothing; + type MaxReserves = MaxReserves; + type ReserveIdentifier = [u8; 8]; +} + +pub static ALICE: Lazy = Lazy::new(|| Public::from_raw([125u8; 32])); +pub static BOB: Lazy = Lazy::new(|| Public::from_raw([126u8; 32])); +pub static CHARLIE: Lazy = Lazy::new(|| Public::from_raw([127u8; 32])); +pub static EMPTY: Lazy = Lazy::new(|| Public::from_raw([66u8; 32])); +pub static TREASURY: Lazy = Lazy::new(|| Public::from_raw([1u8; 32])); + +pub(crate) fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| { + let initial_balance = 100_000_000_000_000u64; + System::set_block_number(1); + let _ = Tokens::deposit(CurrencyId::Native, &ALICE, initial_balance); + let _ = Tokens::deposit(CurrencyId::Native, &BOB, initial_balance); + let _ = Tokens::deposit(CurrencyId::Native, &CHARLIE, initial_balance); + let _ = Tokens::deposit(CurrencyId::Native, &TREASURY, initial_balance); + }); + ext +} + +use frame_support::pallet_prelude::Weight; +impl crate::traits::WeightInfoT for () { + fn add_to_fellowship() -> Weight { + ::default() + } + fn force_add_fellowship() -> Weight { + ::default() + } + fn leave_fellowship() -> Weight { + ::default() + } + fn force_remove_and_slash_fellowship() -> Weight { + ::default() + } + fn add_candidate_to_shortlist() -> Weight { + ::default() + } + fn remove_candidate_from_shortlist() -> Weight { + ::default() + } + fn pay_deposit_to_remove_pending_status() -> Weight { + ::default() + } +} diff --git a/pallets/fellowship/src/tests.rs b/pallets/fellowship/src/tests.rs new file mode 100644 index 00000000..ec01cd79 --- /dev/null +++ b/pallets/fellowship/src/tests.rs @@ -0,0 +1,735 @@ +use crate::impls::*; +use crate::traits::*; +use crate::*; +use crate::{mock::*, Error, Event, FellowToVetter, Role, Roles}; +use common_traits::MaybeConvert; +use common_types::CurrencyId; +use frame_support::{ + assert_noop, assert_ok, once_cell::sync::Lazy, traits::Hooks, BoundedBTreeMap, +}; +use frame_system::Pallet as System; +use orml_tokens::Error as TokensError; +use orml_traits::{MultiCurrency, MultiReservableCurrency}; +use sp_arithmetic::traits::One; +use sp_core::sr25519::Public; +use sp_runtime::{traits::BadOrigin, DispatchError, Saturating}; +use sp_std::vec; + +// Saves a bit of typing. +pub(crate) static DEP_CURRENCY: Lazy = + Lazy::new(::DepositCurrencyId::get); +fn add_to_fellowship_take_deposit( + who: &AccountIdOf, + role: Role, + rank: Rank, + vetter: Option<&VetterIdOf>, +) -> Result<(), DispatchError> { + >>::add_to_fellowship( + who, role, rank, vetter, true, + ); + Ok(()) +} + +fn revoke_fellowship(who: &AccountIdOf, slash_deposit: bool) -> Result<(), DispatchError> { + >>::revoke_fellowship(who, slash_deposit) +} + +pub fn run_to_block(n: T::BlockNumber) +where + T::BlockNumber: Into, +{ + loop { + let mut block: T::BlockNumber = frame_system::Pallet::::block_number(); + if block >= n { + break; + } + block = block.saturating_add(::one()); + frame_system::Pallet::::set_block_number(block); + frame_system::Pallet::::on_initialize(block); + Fellowship::on_initialize(block.into()); + } +} + +#[test] +fn ensure_role_in_works() { + new_test_ext().execute_with(|| { + Roles::::insert(*ALICE, (Role::Vetter, 10)); + Roles::::insert(*BOB, (Role::Freelancer, 10)); + + assert_ok!(EnsureFellowshipRole::::ensure_role_in( + &ALICE, + vec![Role::Vetter, Role::Freelancer], + None + )); + assert_ok!(EnsureFellowshipRole::::ensure_role_in( + &BOB, + vec![Role::Vetter, Role::Freelancer], + None + )); + assert!( + EnsureFellowshipRole::::ensure_role_in(&BOB, vec![Role::Approver], None).is_err(), + "BOB is not of this Role." + ); + assert!( + EnsureFellowshipRole::::ensure_role_in(&ALICE, vec![Role::Freelancer], None) + .is_err(), + "ALICE is not of this Role." + ); + }); +} + +#[test] +fn ensure_role_in_works_with_rank() { + new_test_ext().execute_with(|| { + Roles::::insert(*ALICE, (Role::Vetter, 10)); + assert_ok!(EnsureFellowshipRole::::ensure_role_in( + &ALICE, + vec![Role::Vetter], + Some(vec![10, 9]) + )); + + assert_noop!( + EnsureFellowshipRole::::ensure_role_in(&ALICE, vec![Role::Vetter], Some(vec![9])), + BadOrigin + ); + }); +} + +#[test] +fn ensure_role_works() { + new_test_ext().execute_with(|| { + Roles::::insert(*ALICE, (Role::Vetter, 0)); + assert_ok!(EnsureFellowshipRole::::ensure_role( + &ALICE, + Role::Vetter, + None + )); + assert!(EnsureFellowshipRole::::ensure_role(&ALICE, Role::Freelancer, None).is_err()); + }); +} + +#[test] +fn ensure_role_works_with_rank() { + new_test_ext().execute_with(|| { + Roles::::insert(*ALICE, (Role::Vetter, 10)); + assert_ok!(EnsureFellowshipRole::::ensure_role( + &ALICE, + Role::Vetter, + Some(10) + )); + + assert_noop!( + EnsureFellowshipRole::::ensure_role(&ALICE, Role::Vetter, Some(9)), + BadOrigin + ); + }); +} + +#[test] +fn freelancer_to_vetter_works() { + new_test_ext().execute_with(|| { + FellowToVetter::::insert(*ALICE, *BOB); + let v = , VetterIdOf>>::maybe_convert( + &ALICE, + ) + .expect("we just inserted so should be there."); + assert_eq!(v, *BOB); + assert!( + , VetterIdOf>>::maybe_convert(&BOB) + .is_none() + ); + }); +} + +#[test] +fn force_add_fellowship_only_force_permitted() { + new_test_ext().execute_with(|| { + assert_noop!( + Fellowship::force_add_fellowship( + RuntimeOrigin::signed(*ALICE), + *BOB, + Role::Freelancer, + 10 + ), + BadOrigin + ); + }); +} + +#[test] +fn force_add_fellowship_ok_event_assert() { + new_test_ext().execute_with(|| { + assert_ok!(Fellowship::force_add_fellowship( + RuntimeOrigin::root(), + *BOB, + Role::Freelancer, + 10 + )); + System::::assert_last_event( + Event::::FellowshipAdded { + who: *BOB, + role: Role::Freelancer, + } + .into(), + ); + }); +} + +#[test] +fn leave_fellowship_not_fellow() { + new_test_ext().execute_with(|| { + assert_noop!( + Fellowship::leave_fellowship(RuntimeOrigin::signed(*ALICE)), + Error::::NotAFellow + ); + }); +} + +#[test] +fn force_add_fellowship_then_leave_fellowship_maintains_fellow_reserve() { + new_test_ext().execute_with(|| { + let alice_reserved_before = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + Fellowship::force_add_fellowship(RuntimeOrigin::root(), *ALICE, Role::Freelancer, 10) + .expect("qed"); + assert_ok!(Fellowship::leave_fellowship(RuntimeOrigin::signed(*ALICE))); + let alice_reserved_after = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert_eq!(alice_reserved_before, alice_reserved_after); + }); +} + +#[test] +fn leave_fellowship_assert_event() { + new_test_ext().execute_with(|| { + Fellowship::force_add_fellowship(RuntimeOrigin::root(), *ALICE, Role::Freelancer, 10) + .expect("qed"); + assert_ok!(Fellowship::leave_fellowship(RuntimeOrigin::signed(*ALICE))); + System::::assert_last_event(Event::::FellowshipRemoved { who: *ALICE }.into()); + }); +} + +#[test] +fn add_to_fellowship_takes_deposit_if_avaliable() { + new_test_ext().execute_with(|| { + let alice_reserved_before = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Freelancer, 10, None).is_ok()); + let alice_reserved_after = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert_eq!( + alice_reserved_after - alice_reserved_before, + ::MembershipDeposit::get() + ); + }); +} + +#[test] +fn add_to_fellowship_adds_to_pending_fellows_where_deposit_fails() { + new_test_ext().execute_with(|| { + let free = ::MultiCurrency::free_balance(*DEP_CURRENCY, &ALICE); + let minimum = ::MultiCurrency::minimum_balance(*DEP_CURRENCY); + assert_ok!(::MultiCurrency::withdraw( + *DEP_CURRENCY, + &ALICE, + free - minimum + minimum + )); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Freelancer, 10, None).is_ok()); + assert_eq!( + PendingFellows::::get(*ALICE) + .expect("Pending fellows should have the account inserted."), + (Role::Freelancer, 10) + ); + }); +} + +#[test] +fn add_to_fellowship_adds_to_pending_fellows_assert_event() { + new_test_ext().execute_with(|| { + let free = ::MultiCurrency::free_balance(*DEP_CURRENCY, &ALICE); + let minimum = ::MultiCurrency::minimum_balance(*DEP_CURRENCY); + ::MultiCurrency::withdraw(*DEP_CURRENCY, &ALICE, free - minimum + minimum) + .unwrap(); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Freelancer, 10, None).is_ok()); + System::::assert_last_event( + Event::::MemberAddedToPendingFellows { who: *ALICE }.into(), + ); + }); +} + +#[test] +fn add_to_fellowship_adds_vetter_if_exists() { + new_test_ext().execute_with(|| { + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Freelancer, 10, Some(&BOB)).is_ok()); + assert_eq!(FellowToVetter::::get(*ALICE).unwrap(), *BOB); + }); +} + +#[test] +fn add_to_fellowship_edits_role_if_exists_already() { + new_test_ext().execute_with(|| { + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Freelancer, 10, Some(&BOB)).is_ok()); + assert_eq!(Roles::::get(*ALICE).unwrap(), (Role::Freelancer, 10)); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Vetter, 5, Some(&BOB)).is_ok()); + assert_eq!(Roles::::get(*ALICE).unwrap(), (Role::Vetter, 5)); + }); +} + +#[test] +fn add_to_fellowship_maintains_vetter_if_exists_already() { + new_test_ext().execute_with(|| { + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Freelancer, 10, Some(&BOB)).is_ok()); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Vetter, 5, Some(&CHARLIE)).is_ok()); + assert_eq!(FellowToVetter::::get(*ALICE).unwrap(), *BOB); + }); +} + +#[test] +fn revoke_fellowship_not_a_fellow() { + new_test_ext().execute_with(|| { + assert_noop!(revoke_fellowship(&ALICE, true), Error::::NotAFellow); + assert_noop!(revoke_fellowship(&ALICE, false), Error::::NotAFellow); + }); +} + +#[test] +fn revoke_fellowship_unreserves_if_deposit_taken_no_slash() { + new_test_ext().execute_with(|| { + let alice_reserved_before = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Vetter, 5, Some(&CHARLIE)).is_ok()); + assert_ok!(revoke_fellowship(&ALICE, false)); + let alice_reserved_after = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert_eq!( + alice_reserved_before, alice_reserved_after, + "deposit should be returned if no slash has occurred." + ) + }); +} + +#[test] +fn revoke_fellowship_slashes_if_deposit_taken() { + new_test_ext().execute_with(|| { + let alice_reserved_before = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Vetter, 5, Some(&CHARLIE)).is_ok()); + assert_ok!(revoke_fellowship(&ALICE, true)); + let alice_reserved_after = + ::MultiCurrency::reserved_balance(*DEP_CURRENCY, &ALICE); + assert_eq!( + alice_reserved_before, + alice_reserved_after.saturating_sub(::MembershipDeposit::get()), + "deposit should have been taken since slash has occurred" + ); + }); +} + +#[test] +fn revoke_fellowship_with_slash_goes_to_slash_account() { + new_test_ext().execute_with(|| { + let slash_before = ::MultiCurrency::free_balance( + *DEP_CURRENCY, + &::SlashAccount::get(), + ); + assert!(add_to_fellowship_take_deposit(&ALICE, Role::Vetter, 5, Some(&CHARLIE)).is_ok()); + assert_ok!(revoke_fellowship(&ALICE, true)); + let slash_after = ::MultiCurrency::free_balance( + *DEP_CURRENCY, + &::SlashAccount::get(), + ); + assert_eq!( + slash_after - slash_before, + ::MembershipDeposit::get(), + "slash account should have increased by membership deposit.", + ) + }); +} + +#[test] +fn add_candidate_to_shortlist_not_a_vetter() { + new_test_ext().execute_with(|| { + assert_noop!( + Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*ALICE), + *BOB, + Role::Freelancer, + 10 + ), + Error::::NotAVetter + ); + }); +} + +#[test] +fn add_candidate_to_shortlist_already_fellow() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit( + &ALICE, + Role::Vetter, + 5, + Some(&CHARLIE) + )); + assert_ok!(add_to_fellowship_take_deposit( + &BOB, + Role::Freelancer, + 5, + Some(&CHARLIE) + )); + assert_noop!( + Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*ALICE), + *BOB, + Role::Freelancer, + 10 + ), + Error::::AlreadyAFellow + ); + }); +} + +#[test] +fn add_candidate_to_shortlist_candidate_lacks_deposit_fails() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit(&BOB, Role::Vetter, 5, None)); + let free = ::MultiCurrency::free_balance(*DEP_CURRENCY, &ALICE); + let minimum = ::MultiCurrency::minimum_balance(*DEP_CURRENCY); + ::MultiCurrency::withdraw(*DEP_CURRENCY, &ALICE, free - minimum + minimum) + .unwrap(); + assert_noop!( + Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE, + Role::Freelancer, + 10 + ), + Error::::CandidateDepositRequired + ); + }); +} + +#[test] +fn add_candidate_to_shortlist_candidate_already_on_shortlist() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit(&BOB, Role::Vetter, 5, None)); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE, + Role::Freelancer, + 10 + )); + assert_noop!( + Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE, + Role::Freelancer, + 10 + ), + Error::::CandidateAlreadyOnShortlist + ); + }); +} + +#[test] +fn add_candidate_to_shortlist_too_many_candidates() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit( + &CHARLIE, + Role::Vetter, + 5, + None + )); + let mut shortlist: BoundedShortlistPlaces = BoundedBTreeMap::new(); + (0..::MaxCandidatesPerShortlist::get()).for_each(|i| { + shortlist + .try_insert(Public::from_raw([i as u8; 32]), ((Role::Vetter, 10), None)) + .unwrap(); + }); + CandidateShortlist::::mutate(ShortlistRound::::get(), |m_shortlist| { + *m_shortlist = shortlist + }); + assert_noop!( + Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*CHARLIE), + *BOB, + Role::Freelancer, + 10 + ), + Error::::TooManyCandidates + ); + }) +} + +#[test] +fn add_candidate_to_shortlist_works_assert_event() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit(&BOB, Role::Vetter, 5, None)); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE, + Role::Freelancer, + 10 + )); + System::::assert_last_event( + Event::::CandidateAddedToShortlist { who: *ALICE }.into(), + ); + }); +} + +#[test] +fn remove_candidate_from_shortlist_not_a_vetter() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit(&BOB, Role::Vetter, 5, None)); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE, + Role::Freelancer, + 10 + )); + + assert_noop!( + Fellowship::remove_candidate_from_shortlist(RuntimeOrigin::signed(*CHARLIE), *ALICE), + Error::::NotAVetter + ); + }); +} + +#[test] +fn remove_candidate_from_shortlist_works_assert_event() { + new_test_ext().execute_with(|| { + assert_ok!(add_to_fellowship_take_deposit(&BOB, Role::Vetter, 5, None)); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE, + Role::Freelancer, + 10 + )); + assert_ok!(Fellowship::remove_candidate_from_shortlist( + RuntimeOrigin::signed(*BOB), + *ALICE + )); + assert!( + CandidateShortlist::::get(ShortlistRound::::get()) + .get(&ALICE) + .is_none() + ); + System::::assert_last_event( + Event::::CandidateRemovedFromShortlist { who: *ALICE }.into(), + ); + }); +} + +#[test] +fn pay_deposit_and_remove_pending_status_not_pending() { + new_test_ext().execute_with(|| { + assert_noop!( + Fellowship::pay_deposit_to_remove_pending_status(RuntimeOrigin::signed(*ALICE)), + Error::::NotAFellow + ); + }); +} + +#[test] +fn pay_deposit_and_remove_pending_status_not_enough_funds_to_reserve() { + new_test_ext().execute_with(|| { + let minimum = ::MultiCurrency::minimum_balance(*DEP_CURRENCY); + let free = ::MultiCurrency::free_balance(*DEP_CURRENCY, &ALICE); + ::MultiCurrency::withdraw(*DEP_CURRENCY, &ALICE, free - minimum + minimum) + .unwrap(); + assert_ok!(add_to_fellowship_take_deposit( + &ALICE, + Role::Freelancer, + 5, + None + )); + assert_noop!( + Fellowship::pay_deposit_to_remove_pending_status(RuntimeOrigin::signed(*ALICE)), + TokensError::::BalanceTooLow + ); + }); +} + +#[test] +fn pay_deposit_and_remove_pending_status_works_assert_event() { + new_test_ext().execute_with(|| { + let minimum = ::MultiCurrency::minimum_balance(*DEP_CURRENCY); + let free = ::MultiCurrency::free_balance(*DEP_CURRENCY, &ALICE); + ::MultiCurrency::withdraw(*DEP_CURRENCY, &ALICE, free - minimum + minimum) + .unwrap(); + assert_ok!(add_to_fellowship_take_deposit( + &ALICE, + Role::Freelancer, + 5, + None + )); + ::MultiCurrency::deposit( + *DEP_CURRENCY, + &ALICE, + ::MembershipDeposit::get() + 100_000, + ) + .unwrap(); + assert_ok!(Fellowship::pay_deposit_to_remove_pending_status( + RuntimeOrigin::signed(*ALICE) + )); + System::::assert_last_event( + Event::::FellowshipAdded { + who: *ALICE, + role: Role::Freelancer, + } + .into(), + ); + }); +} + +#[test] +fn on_initialize_adds_to_fellowship_from_shortlist() { + new_test_ext().execute_with(|| { + assert_ok!(Fellowship::force_add_fellowship( + RuntimeOrigin::root(), + *ALICE, + Role::Freelancer, + 10 + )); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*ALICE), + *CHARLIE, + Role::Vetter, + 10 + )); + run_to_block::( + frame_system::Pallet::::block_number() + ::ShortlistPeriod::get(), + ); + assert_eq!(Roles::::get(*CHARLIE).unwrap(), (Role::Vetter, 10)); + }); +} + +#[test] +fn on_initialize_doesnt_add_removed_shortlist_members() { + new_test_ext().execute_with(|| { + assert_ok!(Fellowship::force_add_fellowship( + RuntimeOrigin::root(), + *ALICE, + Role::Freelancer, + 10 + )); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*ALICE), + *CHARLIE, + Role::Vetter, + 10 + )); + assert_ok!(Fellowship::remove_candidate_from_shortlist( + RuntimeOrigin::signed(*ALICE), + *CHARLIE, + )); + run_to_block::( + frame_system::Pallet::::block_number() + ::ShortlistPeriod::get(), + ); + assert!(Roles::::get(*CHARLIE).is_none()); + }); +} + +#[test] +fn on_initialize_cleans_storage_for_next_round() { + new_test_ext().execute_with(|| { + assert_ok!(Fellowship::force_add_fellowship( + RuntimeOrigin::root(), + *ALICE, + Role::Freelancer, + 10 + )); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*ALICE), + *CHARLIE, + Role::Vetter, + 10 + )); + let pre_shortlist_round_key = ShortlistRound::::get(); + assert!( + CandidateShortlist::::get(pre_shortlist_round_key) + .iter() + .len() + == 1 + ); + run_to_block::( + frame_system::Pallet::::block_number() + ::ShortlistPeriod::get(), + ); + + let post_shortlist_round_key = ShortlistRound::::get(); + assert_eq!(post_shortlist_round_key, pre_shortlist_round_key + 1); + assert!( + CandidateShortlist::::get(post_shortlist_round_key) + .iter() + .len() + == 0 + ); + }); +} + +#[test] +fn e2e() { + new_test_ext().execute_with(|| { + // force add some vetters to for clarity of state + assert_ok!(Fellowship::force_add_fellowship( + RuntimeOrigin::root(), + *ALICE, + Role::Freelancer, + 10 + )); + assert_ok!(Fellowship::force_add_fellowship( + RuntimeOrigin::root(), + *BOB, + Role::Vetter, + 10 + )); + + // Add multiple people to the shortlist using multiple vetters/freelancers + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*ALICE), + *CHARLIE, + Role::Vetter, + 10 + )); + + // Bypass the usual requirement of deposit so we can test the e2e for PendingFellows + assert_ok!(::MultiCurrency::deposit( + CurrencyId::Native, + &*EMPTY, + ::MembershipDeposit::get() * 2 + )); + assert_ok!(Fellowship::add_candidate_to_shortlist( + RuntimeOrigin::signed(*BOB), + *EMPTY, + Role::Freelancer, + 10 + )); + assert_ok!(::MultiCurrency::withdraw( + CurrencyId::Native, + &*EMPTY, + ::MembershipDeposit::get() + 100 + )); + + // wait for blocks to pass + run_to_block::( + frame_system::Pallet::::block_number() + ::ShortlistPeriod::get(), + ); + + // ensure they are part of the fellowships or if without funds the pending fellows. + assert_eq!( + PendingFellows::::get(*EMPTY).unwrap(), + (Role::Freelancer, 10) + ); + assert_eq!(Roles::::get(*CHARLIE).unwrap(), (Role::Vetter, 10)); + + // Deposit the required funds and pay. + assert_ok!(::MultiCurrency::deposit( + CurrencyId::Native, + &*EMPTY, + ::MembershipDeposit::get() * 2 + )); + assert_ok!(Fellowship::pay_deposit_to_remove_pending_status( + RuntimeOrigin::signed(*EMPTY) + )); + assert_eq!(Roles::::get(*EMPTY).unwrap(), (Role::Freelancer, 10)); + }); +} diff --git a/pallets/fellowship/src/traits.rs b/pallets/fellowship/src/traits.rs new file mode 100644 index 00000000..9ce232bc --- /dev/null +++ b/pallets/fellowship/src/traits.rs @@ -0,0 +1,44 @@ +use crate::Rank; +use codec::{FullCodec, FullEncode}; +use frame_support::{pallet_prelude::*, weights::Weight}; +use sp_runtime::DispatchError; +use sp_std::vec::Vec; + +/// Used by external pallets that decide when to add and remove members from the fellowship. +pub trait FellowshipHandle { + type Role: Member + TypeInfo + MaxEncodedLen + FullCodec + FullEncode + Copy; + type Rank: Member + TypeInfo + MaxEncodedLen + FullCodec + FullEncode + Copy; + + fn add_to_fellowship( + who: &AccountId, + role: Self::Role, + rank: Self::Rank, + vetter: Option<&AccountId>, + take_membership_deposit: bool, + ); + fn revoke_fellowship(who: &AccountId, slash_deposit: bool) -> Result<(), DispatchError>; +} + +pub trait EnsureRole { + type Success; + fn ensure_role( + acc: &AccountId, + role: Role, + maybe_rank: Option, + ) -> Result; + fn ensure_role_in( + acc: &AccountId, + roles: Vec, + maybe_rank: Option>, + ) -> Result; +} + +pub trait WeightInfoT { + fn add_to_fellowship() -> Weight; + fn force_add_fellowship() -> Weight; + fn leave_fellowship() -> Weight; + fn force_remove_and_slash_fellowship() -> Weight; + fn add_candidate_to_shortlist() -> Weight; + fn remove_candidate_from_shortlist() -> Weight; + fn pay_deposit_to_remove_pending_status() -> Weight; +} diff --git a/pallets/fellowship/src/weights.rs b/pallets/fellowship/src/weights.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/pallets/fellowship/src/weights.rs @@ -0,0 +1 @@ +