diff --git a/Cargo.toml b/Cargo.toml index 9433296744..75080094ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,11 +75,11 @@ rev = 'df5e65927780b323482e2e8b5031822f423a032d' [dependencies.parity-codec] default-features = false -version = '3.0' +version = '3.1' [dependencies.parity-codec-derive] default-features = false -version = '3.0' +version = '3.1' [dependencies.primitives] default_features = false diff --git a/src/governance/election.rs b/src/governance/election.rs index 282247e59f..e6d0f109e7 100644 --- a/src/governance/election.rs +++ b/src/governance/election.rs @@ -17,10 +17,14 @@ use super::sealed_vote::SealedVote; pub use super::{ GovernanceCurrency, BalanceOf }; use super::council; +use crate::traits::{IsActiveMember}; + pub trait Trait: system::Trait + council::Trait + GovernanceCurrency { type Event: From> + Into<::Event>; type CouncilElected: CouncilElected>, Self::BlockNumber>; + + type IsActiveMember: IsActiveMember; } #[derive(Clone, Copy, Encode, Decode)] @@ -148,9 +152,9 @@ impl Module { >::block_number() + length } - // TODO This method should be moved to Membership module once it's created. - fn is_member(sender: T::AccountId) -> bool { - !T::Currency::free_balance(&sender).is_zero() + + fn can_participate(sender: &T::AccountId) -> bool { + !T::Currency::free_balance(sender).is_zero() && T::IsActiveMember::is_active_member(sender) } // PUBLIC IMMUTABLES @@ -651,7 +655,7 @@ decl_module! { // Member can make subsequent calls during announcing stage to increase their stake. fn apply(origin, stake: BalanceOf) { let sender = ensure_signed(origin)?; - ensure!(Self::is_member(sender.clone()), "Only members can apply to be on council"); + ensure!(Self::can_participate(&sender), "Only members can apply to be on council"); let stage = Self::stage(); ensure!(Self::stage().is_some(), "election not running"); @@ -674,7 +678,7 @@ decl_module! { fn vote(origin, commitment: T::Hash, stake: BalanceOf) { let sender = ensure_signed(origin)?; - ensure!(Self::is_member(sender.clone()), "Only members can vote for an applicant"); + ensure!(Self::can_participate(&sender), "Only members can vote for an applicant"); let stage = Self::stage(); ensure!(Self::stage().is_some(), "election not running"); diff --git a/src/governance/mock.rs b/src/governance/mock.rs index 5cb193eddc..ce5bb9bd4a 100644 --- a/src/governance/mock.rs +++ b/src/governance/mock.rs @@ -3,6 +3,7 @@ use rstd::prelude::*; pub use super::{election, council, proposals, GovernanceCurrency}; pub use system; +use crate::traits; pub use primitives::{H256, Blake2Hasher}; pub use runtime_primitives::{ @@ -17,6 +18,16 @@ impl_outer_origin! { pub enum Origin for Test {} } +pub struct AnyAccountIsMember {} +impl traits::IsActiveMember for AnyAccountIsMember { + fn is_active_member(who: &T::AccountId) -> bool { + true + } +} + +// default trait implementation - any account is not a member +// impl traits::IsActiveMember for () {} + // 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. @@ -53,10 +64,10 @@ impl election::Trait for Test { type Event = (); type CouncilElected = (Council,); + + type IsActiveMember = AnyAccountIsMember; } -impl proposals::Trait for Test { - type Event = (); -} + impl balances::Trait for Test { type Event = (); @@ -92,6 +103,5 @@ pub fn initial_test_ext() -> runtime_io::TestExternalities { pub type Election = election::Module; pub type Council = council::Module; -pub type Proposals = proposals::Module; pub type System = system::Module; pub type Balances = balances::Module; diff --git a/src/governance/proposals.rs b/src/governance/proposals.rs index 2a08fe19c9..58878dc9e9 100644 --- a/src/governance/proposals.rs +++ b/src/governance/proposals.rs @@ -8,6 +8,7 @@ use rstd::prelude::*; use super::council; pub use super::{ GovernanceCurrency, BalanceOf }; +use crate::traits::{IsActiveMember}; const DEFAULT_APPROVAL_QUORUM: u32 = 60; const DEFAULT_MIN_STAKE: u64 = 100; @@ -115,6 +116,8 @@ pub struct TallyResult { pub trait Trait: timestamp::Trait + council::Trait + GovernanceCurrency { /// The overarching event type. type Event: From> + Into<::Event>; + + type IsActiveMember: IsActiveMember; } decl_event!( @@ -221,7 +224,7 @@ decl_module! { ) { let proposer = ensure_signed(origin)?; - ensure!(Self::is_member(proposer.clone()), MSG_ONLY_MEMBERS_CAN_PROPOSE); + ensure!(Self::can_participate(proposer.clone()), MSG_ONLY_MEMBERS_CAN_PROPOSE); ensure!(stake >= Self::min_stake(), MSG_STAKE_IS_TOO_LOW); ensure!(!name.is_empty(), MSG_EMPTY_NAME_PROVIDED); @@ -345,9 +348,8 @@ impl Module { >::block_number() } - // TODO This method should be moved to Membership module once it's created. - fn is_member(sender: T::AccountId) -> bool { - !T::Currency::free_balance(&sender).is_zero() + fn can_participate(sender: T::AccountId) -> bool { + !T::Currency::free_balance(&sender).is_zero() && T::IsActiveMember::is_active_member(&sender) } fn is_councilor(sender: &T::AccountId) -> bool { @@ -613,6 +615,14 @@ mod tests { impl Trait for Test { type Event = (); + type IsActiveMember = AnyAccountIsMember; + } + + pub struct AnyAccountIsMember {} + impl IsActiveMember for AnyAccountIsMember { + fn is_active_member(who: &T::AccountId) -> bool { + true + } } type System = system::Module; diff --git a/src/lib.rs b/src/lib.rs index 77adf189ae..99fef0a8a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,10 @@ extern crate parity_codec_derive; pub mod governance; use governance::{election, council, proposals}; mod memo; +mod membership; +use membership::members; +mod traits; +mod migration; use rstd::prelude::*; #[cfg(feature = "std")] @@ -24,7 +28,7 @@ use primitives::bytes; use primitives::{Ed25519AuthorityId, OpaqueMetadata}; use runtime_primitives::{ ApplyResult, transaction_validity::TransactionValidity, Ed25519Signature, generic, - traits::{self, Convert, BlakeTwo256, Block as BlockT, StaticLookup}, create_runtime_str + traits::{self as runtime_traits, Convert, BlakeTwo256, Block as BlockT, StaticLookup}, create_runtime_str }; use client::{ block_builder::api::{CheckInherentsResult, InherentData, self as block_builder_api}, @@ -67,7 +71,7 @@ pub mod opaque { #[derive(PartialEq, Eq, Clone, Default, Encode, Decode)] #[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] pub struct UncheckedExtrinsic(#[cfg_attr(feature = "std", serde(with="bytes"))] pub Vec); - impl traits::Extrinsic for UncheckedExtrinsic { + impl runtime_traits::Extrinsic for UncheckedExtrinsic { fn is_signed(&self) -> Option { None } @@ -87,7 +91,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("joystream-node"), impl_name: create_runtime_str!("joystream-node"), authoring_version: 3, - spec_version: 4, + spec_version: 5, impl_version: 0, apis: RUNTIME_API_VERSIONS, }; @@ -208,11 +212,13 @@ impl governance::GovernanceCurrency for Runtime { impl governance::proposals::Trait for Runtime { type Event = Event; + type IsActiveMember = Members; } impl governance::election::Trait for Runtime { type Event = Event; type CouncilElected = (Council,); + type IsActiveMember = Members; } impl governance::council::Trait for Runtime { @@ -224,6 +230,17 @@ impl memo::Trait for Runtime { type Event = Event; } +impl members::Trait for Runtime { + type Event = Event; + type MemberId = u64; + type PaidTermId = u64; + type SubscriptionId = u64; +} + +impl migration::Trait for Runtime { + type Event = Event; +} + construct_runtime!( pub enum Runtime with Log(InternalLog: DigestItem) where Block = Block, @@ -244,6 +261,8 @@ construct_runtime!( CouncilElection: election::{Module, Call, Storage, Event, Config}, Council: council::{Module, Call, Storage, Event, Config}, Memo: memo::{Module, Call, Storage, Event}, + Members: members::{Module, Call, Storage, Event, Config}, + Migration: migration::{Module, Call, Storage, Event}, } ); diff --git a/src/membership/members.rs b/src/membership/members.rs new file mode 100644 index 0000000000..68faed9917 --- /dev/null +++ b/src/membership/members.rs @@ -0,0 +1,411 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +use rstd::prelude::*; +use parity_codec::Codec; +use parity_codec_derive::{Encode, Decode}; +use srml_support::{StorageMap, StorageValue, dispatch, decl_module, decl_storage, decl_event, ensure, Parameter}; +use srml_support::traits::{Currency}; +use runtime_primitives::traits::{Zero, SimpleArithmetic, As, Member, MaybeSerializeDebug}; +use system::{self, ensure_signed}; +use crate::governance::{GovernanceCurrency, BalanceOf }; +use {timestamp}; +use crate::traits; + +pub trait Trait: system::Trait + GovernanceCurrency + timestamp::Trait { + type Event: From> + Into<::Event>; + + type MemberId: Parameter + Member + SimpleArithmetic + Codec + Default + Copy + + As + As + MaybeSerializeDebug + PartialEq; + + type PaidTermId: Parameter + Member + SimpleArithmetic + Codec + Default + Copy + + As + As + MaybeSerializeDebug + PartialEq; + + type SubscriptionId: Parameter + Member + SimpleArithmetic + Codec + Default + Copy + + As + As + MaybeSerializeDebug + PartialEq; +} + +const DEFAULT_FIRST_MEMBER_ID: u64 = 1; +const FIRST_PAID_TERMS_ID: u64 = 1; + +// Default paid membership terms +const DEFAULT_PAID_TERM_ID: u64 = 0; +const DEFAULT_PAID_TERM_FEE: u64 = 100; // Can be overidden in genesis config +const DEFAULT_PAID_TERM_TEXT: &str = "Default Paid Term TOS..."; + +// Default user info constraints +const DEFAULT_MIN_HANDLE_LENGTH: u32 = 5; +const DEFAULT_MAX_HANDLE_LENGTH: u32 = 40; +const DEFAULT_MAX_AVATAR_URI_LENGTH: u32 = 1024; +const DEFAULT_MAX_ABOUT_TEXT_LENGTH: u32 = 2048; + +//#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode)] +/// Stored information about a registered user +pub struct Profile { + pub id: T::MemberId, + pub handle: Vec, + pub avatar_uri: Vec, + pub about: Vec, + pub registered_at_block: T::BlockNumber, + pub registered_at_time: T::Moment, + pub entry: EntryMethod, + pub suspended: bool, + pub subscription: Option, +} + +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +/// Structure used to batch user configurable profile information when registering or updating info +pub struct UserInfo { + pub handle: Option>, + pub avatar_uri: Option>, + pub about: Option>, +} + +struct CheckedUserInfo { + handle: Vec, + avatar_uri: Vec, + about: Vec, +} + +//#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode, Debug, PartialEq)] +pub enum EntryMethod { + Paid(T::PaidTermId), + Screening(T::AccountId), +} + +//#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode, Eq, PartialEq)] +pub struct PaidMembershipTerms { + /// Unique identifier - the term id + pub id: T::PaidTermId, + /// Quantity of native tokens which must be provably burned + pub fee: BalanceOf, + /// String of capped length describing human readable conditions which are being agreed upon + pub text: Vec +} + +impl Default for PaidMembershipTerms { + fn default() -> Self { + PaidMembershipTerms { + id: T::PaidTermId::sa(DEFAULT_PAID_TERM_ID), + fee: BalanceOf::::sa(DEFAULT_PAID_TERM_FEE), + text: DEFAULT_PAID_TERM_TEXT.as_bytes().to_vec() + } + } +} + +decl_storage! { + trait Store for Module as Membership { + /// MemberId's start at this value + pub FirstMemberId get(first_member_id) config(first_member_id): T::MemberId = T::MemberId::sa(DEFAULT_FIRST_MEMBER_ID); + + /// MemberId to assign to next member that is added to the registry + pub NextMemberId get(next_member_id) build(|config: &GenesisConfig| config.first_member_id): T::MemberId = T::MemberId::sa(DEFAULT_FIRST_MEMBER_ID); + + /// Mapping of member ids to their corresponding primary accountid + pub AccountIdByMemberId get(account_id_by_member_id) : map T::MemberId => T::AccountId; + + /// Mapping of members' account ids to their member id. + pub MemberIdByAccountId get(member_id_by_account_id) : map T::AccountId => Option; + + /// Mapping of member's id to their membership profile + // Value is Option because it is not meaningful to have a Default value for Profile + pub MemberProfile get(member_profile) : map T::MemberId => Option>; + + /// Registered unique handles and their mapping to their owner + pub Handles get(handles) : map Vec => Option; + + /// Next paid membership terms id + pub NextPaidMembershipTermsId get(next_paid_membership_terms_id) : T::PaidTermId = T::PaidTermId::sa(FIRST_PAID_TERMS_ID); + + /// Paid membership terms record + // Remember to add _genesis_phantom_data: std::marker::PhantomData{} to membership + // genesis config if not providing config() or extra_genesis + pub PaidMembershipTermsById get(paid_membership_terms_by_id) build(|config: &GenesisConfig| { + // This method only gets called when initializing storage, and is + // compiled as native code. (Will be called when building `raw` chainspec) + // So it can't be relied upon to initialize storage for runtimes updates. + // Initialization for updated runtime is done in run_migration() + let mut terms: PaidMembershipTerms = Default::default(); + terms.fee = config.default_paid_membership_fee; + vec![(terms.id, terms)] + }) : map T::PaidTermId => Option>; + + /// Active Paid membership terms + pub ActivePaidMembershipTerms get(active_paid_membership_terms) : Vec = vec![T::PaidTermId::sa(DEFAULT_PAID_TERM_ID)]; + + /// Is the platform is accepting new members or not + pub NewMembershipsAllowed get(new_memberships_allowed) : bool = true; + + pub ScreeningAuthority get(screening_authority) : Option; + + // User Input Validation parameters - do these really need to be state variables + // I don't see a need to adjust these in future? + pub MinHandleLength get(min_handle_length) : u32 = DEFAULT_MIN_HANDLE_LENGTH; + pub MaxHandleLength get(max_handle_length) : u32 = DEFAULT_MAX_HANDLE_LENGTH; + pub MaxAvatarUriLength get(max_avatar_uri_length) : u32 = DEFAULT_MAX_AVATAR_URI_LENGTH; + pub MaxAboutTextLength get(max_about_text_length) : u32 = DEFAULT_MAX_ABOUT_TEXT_LENGTH; + } + add_extra_genesis { + config(default_paid_membership_fee): BalanceOf; + } +} + +decl_event! { + pub enum Event where + ::AccountId, + ::MemberId { + MemberRegistered(MemberId, AccountId), + MemberUpdatedAboutText(MemberId), + MemberUpdatedAvatar(MemberId), + MemberUpdatedHandle(MemberId), + } +} + +/// Initialization step that runs when the runtime is installed as a runtime upgrade +/// This will and should ONLY be called from the migration module that keeps track of +/// the store version! +impl Module { + pub fn initialize_storage() { + let default_terms: PaidMembershipTerms = Default::default(); + >::insert(default_terms.id, default_terms); + } +} + +impl traits::IsActiveMember for Module { + fn is_active_member(who: &T::AccountId) -> bool { + match Self::ensure_is_member(who) + .and_then(|member_id| Self::ensure_profile(member_id)) + { + Ok(profile) => !profile.suspended, + Err(err) => false + } + } +} + +decl_module! { + pub struct Module for enum Call where origin: T::Origin { + fn deposit_event() = default; + + /// Non-members can buy membership + pub fn buy_membership(origin, paid_terms_id: T::PaidTermId, user_info: UserInfo) { + let who = ensure_signed(origin)?; + + // make sure we are accepting new memberships + ensure!(Self::new_memberships_allowed(), "new members not allowed"); + + // ensure key not associated with an existing membership + Self::ensure_not_member(&who)?; + + // ensure paid_terms_id is active + let terms = Self::ensure_active_terms_id(paid_terms_id)?; + + // ensure enough free balance to cover terms fees + ensure!(T::Currency::can_slash(&who, terms.fee), "not enough balance to buy membership"); + + let user_info = Self::check_user_registration_info(user_info)?; + + // ensure handle is not already registered + Self::ensure_unique_handle(&user_info.handle)?; + + let _ = T::Currency::slash(&who, terms.fee); + let member_id = Self::insert_member(&who, &user_info, EntryMethod::Paid(paid_terms_id)); + + Self::deposit_event(RawEvent::MemberRegistered(member_id, who.clone())); + } + + /// Change member's about text + pub fn change_member_about_text(origin, text: Vec) { + let who = ensure_signed(origin)?; + let member_id = Self::ensure_is_member_primary_account(who.clone())?; + Self::_change_member_about_text(member_id, &text)?; + } + + /// Change member's avatar + pub fn change_member_avatar(origin, uri: Vec) { + let who = ensure_signed(origin)?; + let member_id = Self::ensure_is_member_primary_account(who.clone())?; + Self::_change_member_avatar(member_id, &uri)?; + } + + /// Change member's handle. Will ensure new handle is unique and old one will be available + /// for other members to use. + pub fn change_member_handle(origin, handle: Vec) { + let who = ensure_signed(origin)?; + let member_id = Self::ensure_is_member_primary_account(who.clone())?; + Self::_change_member_handle(member_id, handle)?; + } + + /// Update member's all or some of handle, avatar and about text. + pub fn update_profile(origin, user_info: UserInfo) { + let who = ensure_signed(origin)?; + let member_id = Self::ensure_is_member_primary_account(who.clone())?; + + if let Some(uri) = user_info.avatar_uri { + Self::_change_member_avatar(member_id, &uri)?; + } + if let Some(about) = user_info.about { + Self::_change_member_about_text(member_id, &about)?; + } + if let Some(handle) = user_info.handle { + Self::_change_member_handle(member_id, handle)?; + } + } + + pub fn add_screened_member(origin, new_member: T::AccountId, user_info: UserInfo) { + // ensure sender is screening authority + let sender = ensure_signed(origin)?; + + if let Some(screening_authority) = Self::screening_authority() { + ensure!(sender == screening_authority, "not screener"); + } else { + // no screening authority defined. Cannot accept this request + return Err("no screening authority defined"); + } + + // make sure we are accepting new memberships + ensure!(Self::new_memberships_allowed(), "new members not allowed"); + + // ensure key not associated with an existing membership + Self::ensure_not_member(&new_member)?; + + let user_info = Self::check_user_registration_info(user_info)?; + + // ensure handle is not already registered + Self::ensure_unique_handle(&user_info.handle)?; + + let member_id = Self::insert_member(&new_member, &user_info, EntryMethod::Screening(sender)); + + Self::deposit_event(RawEvent::MemberRegistered(member_id, new_member.clone())); + } + + pub fn set_screening_authority(authority: T::AccountId) { + >::put(authority); + } + } +} + +impl Module { + fn ensure_not_member(who: &T::AccountId) -> dispatch::Result { + ensure!(!>::exists(who), "account already associated with a membership"); + Ok(()) + } + + fn ensure_is_member(who: &T::AccountId) -> Result { + let member_id = Self::member_id_by_account_id(who).ok_or("no member id found for accountid")?; + Ok(member_id) + } + + fn ensure_is_member_primary_account(who: T::AccountId) -> Result { + let member_id = Self::ensure_is_member(&who)?; + ensure!(Self::account_id_by_member_id(member_id) == who, "not primary account"); + Ok(member_id) + } + + fn ensure_profile(id: T::MemberId) -> Result, &'static str> { + let profile = Self::member_profile(&id).ok_or("member profile not found")?; + Ok(profile) + } + + fn ensure_active_terms_id(terms_id: T::PaidTermId) -> Result, &'static str> { + let active_terms = Self::active_paid_membership_terms(); + ensure!(active_terms.iter().any(|&id| id == terms_id), "paid terms id not active"); + let terms = Self::paid_membership_terms_by_id(terms_id).ok_or("paid membership term id does not exist")?; + Ok(terms) + } + + fn ensure_unique_handle(handle: &Vec ) -> dispatch::Result { + ensure!(!>::exists(handle), "handle already registered"); + Ok(()) + } + + fn validate_handle(handle: &Vec) -> dispatch::Result { + ensure!(handle.len() >= Self::min_handle_length() as usize, "handle too short"); + ensure!(handle.len() <= Self::max_handle_length() as usize, "handle too long"); + Ok(()) + } + + fn validate_text(text: &Vec) -> Vec { + let mut text = text.clone(); + text.truncate(Self::max_about_text_length() as usize); + text + } + + fn validate_avatar(uri: &Vec) -> dispatch::Result { + ensure!(uri.len() <= Self::max_avatar_uri_length() as usize, "avatar uri too long"); + Ok(()) + } + + /// Basic user input validation + fn check_user_registration_info(user_info: UserInfo) -> Result { + // Handle is required during registration + let handle = user_info.handle.ok_or("handle must be provided during registration")?; + Self::validate_handle(&handle)?; + + let about = Self::validate_text(&user_info.about.unwrap_or_default()); + let avatar_uri = user_info.avatar_uri.unwrap_or_default(); + Self::validate_avatar(&avatar_uri)?; + + Ok(CheckedUserInfo { + handle, + avatar_uri, + about, + }) + } + + // Mutating methods + fn insert_member(who: &T::AccountId, user_info: &CheckedUserInfo, entry_method: EntryMethod) -> T::MemberId { + let new_member_id = Self::next_member_id(); + + let profile: Profile = Profile { + id: new_member_id, + handle: user_info.handle.clone(), + avatar_uri: user_info.avatar_uri.clone(), + about: user_info.about.clone(), + registered_at_block: >::block_number(), + registered_at_time: >::now(), + entry: entry_method, + suspended: false, + subscription: None, + }; + + >::insert(who.clone(), new_member_id); + >::insert(new_member_id, who.clone()); + >::insert(new_member_id, profile); + >::insert(user_info.handle.clone(), new_member_id); + >::mutate(|n| { *n += T::MemberId::sa(1); }); + + new_member_id + } + + fn _change_member_about_text (id: T::MemberId, text: &Vec) -> dispatch::Result { + let mut profile = Self::ensure_profile(id)?; + let text = Self::validate_text(text); + profile.about = text; + Self::deposit_event(RawEvent::MemberUpdatedAboutText(id)); + >::insert(id, profile); + Ok(()) + } + + fn _change_member_avatar(id: T::MemberId, uri: &Vec) -> dispatch::Result { + let mut profile = Self::ensure_profile(id)?; + Self::validate_avatar(uri)?; + profile.avatar_uri = uri.clone(); + Self::deposit_event(RawEvent::MemberUpdatedAvatar(id)); + >::insert(id, profile); + Ok(()) + } + + fn _change_member_handle(id: T::MemberId, handle: Vec) -> dispatch::Result { + let mut profile = Self::ensure_profile(id)?; + Self::validate_handle(&handle)?; + Self::ensure_unique_handle(&handle)?; + >::remove(&profile.handle); + >::insert(handle.clone(), id); + profile.handle = handle; + Self::deposit_event(RawEvent::MemberUpdatedHandle(id)); + >::insert(id, profile); + Ok(()) + } +} \ No newline at end of file diff --git a/src/membership/mock.rs b/src/membership/mock.rs new file mode 100644 index 0000000000..b4e42c1be2 --- /dev/null +++ b/src/membership/mock.rs @@ -0,0 +1,115 @@ +#![cfg(test)] + +use rstd::prelude::*; +pub use crate::governance::{GovernanceCurrency}; +pub use super::{members}; +pub use system; + +pub use primitives::{H256, Blake2Hasher}; +pub use runtime_primitives::{ + BuildStorage, + traits::{BlakeTwo256, OnFinalise, IdentityLookup}, + testing::{Digest, DigestItem, Header, UintAuthorityId} +}; + +use srml_support::impl_outer_origin; + +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, Debug)] +pub struct Test; +impl system::Trait for Test { + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type Digest = Digest; + type AccountId = u64; + type Header = Header; + type Event = (); + type Log = DigestItem; + type Lookup = IdentityLookup; +} +impl timestamp::Trait for Test { + type Moment = u64; + type OnTimestampSet = (); +} +impl consensus::Trait for Test { + type SessionKey = UintAuthorityId; + type InherentOfflineReport = (); + type Log = DigestItem; +} + +impl balances::Trait for Test { + type Event = (); + + /// The balance of an account. + type Balance = u32; + + /// A function which is invoked when the free-balance has fallen below the existential deposit and + /// has been reduced to zero. + /// + /// Gives a chance to clean up resources associated with the given account. + type OnFreeBalanceZero = (); + + /// Handler for when a new account is created. + type OnNewAccount = (); + + /// A function that returns true iff a given account can transfer its funds to another account. + type EnsureAccountLiquid = (); +} + +impl GovernanceCurrency for Test { + type Currency = balances::Module; +} + +impl members::Trait for Test { + type Event = (); + type MemberId = u32; + type PaidTermId = u32; + type SubscriptionId = u32; +} + +pub struct ExtBuilder { + first_member_id: u32, + default_paid_membership_fee: u32, +} +impl Default for ExtBuilder { + fn default() -> Self { + Self { + first_member_id: 1, + default_paid_membership_fee: 100, + } + } +} + +impl ExtBuilder { + pub fn first_member_id(mut self, first_member_id: u32) -> Self { + self.first_member_id = first_member_id; + self + } + pub fn default_paid_membership_fee(mut self, default_paid_membership_fee: u32) -> Self { + self.default_paid_membership_fee = default_paid_membership_fee; + self + } + pub fn build(self) -> runtime_io::TestExternalities { + let mut t = system::GenesisConfig::::default().build_storage().unwrap().0; + + t.extend(members::GenesisConfig::{ + first_member_id: self.first_member_id, + default_paid_membership_fee: self.default_paid_membership_fee, + }.build_storage().unwrap().0); + + t.into() + } +} + +pub type System = system::Module; +pub type Balances = balances::Module; +pub type Members = members::Module; diff --git a/src/membership/mod.rs b/src/membership/mod.rs new file mode 100644 index 0000000000..35c272c7ad --- /dev/null +++ b/src/membership/mod.rs @@ -0,0 +1,6 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +pub mod members; + +mod mock; +mod tests; \ No newline at end of file diff --git a/src/membership/tests.rs b/src/membership/tests.rs new file mode 100644 index 0000000000..5b159d433e --- /dev/null +++ b/src/membership/tests.rs @@ -0,0 +1,206 @@ +#![cfg(test)] + +use super::*; +use super::mock::*; + +use parity_codec::Encode; +use runtime_io::with_externalities; +use srml_support::*; + +fn assert_ok_unwrap(value: Option, err: &'static str) -> T { + match value { + None => { assert!(false, err); value.unwrap() }, + Some(v) => v + } +} + +fn get_alice_info() -> members::UserInfo { + members::UserInfo { + handle: Some(String::from("alice").as_bytes().to_vec()), + avatar_uri: Some(String::from("http://avatar-url.com/alice").as_bytes().to_vec()), + about: Some(String::from("my name is alice").as_bytes().to_vec()), + } +} + +fn get_bob_info() -> members::UserInfo { + members::UserInfo { + handle: Some(String::from("bobby").as_bytes().to_vec()), + avatar_uri: Some(String::from("http://avatar-url.com/bob").as_bytes().to_vec()), + about: Some(String::from("my name is bob").as_bytes().to_vec()), + } +} + +const ALICE_ACCOUNT_ID: u64 = 1; +const DEFAULT_TERMS_ID: u32 = 0; + +fn buy_default_membership_as_alice() -> dispatch::Result { + Members::buy_membership(Origin::signed(ALICE_ACCOUNT_ID), DEFAULT_TERMS_ID, get_alice_info()) +} + +fn set_alice_free_balance(balance: u32) { + Balances::set_free_balance(&ALICE_ACCOUNT_ID, balance); +} + +#[test] +fn initial_state() { + const DEFAULT_FEE: u32 = 500; + const DEFAULT_FIRST_ID: u32 = 1000; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE) + .first_member_id(DEFAULT_FIRST_ID).build(), || + { + assert_eq!(Members::first_member_id(), DEFAULT_FIRST_ID); + assert_eq!(Members::next_member_id(), DEFAULT_FIRST_ID); + + let default_terms = assert_ok_unwrap(Members::paid_membership_terms_by_id(DEFAULT_TERMS_ID), "default terms not initialized"); + + assert_eq!(default_terms.id, DEFAULT_TERMS_ID); + assert_eq!(default_terms.fee, DEFAULT_FEE); + }); +} + +#[test] +fn buy_membership() { + const DEFAULT_FEE: u32 = 500; + const DEFAULT_FIRST_ID: u32 = 1000; + const SURPLUS_BALANCE: u32 = 500; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE) + .first_member_id(DEFAULT_FIRST_ID).build(), || + { + let initial_balance = DEFAULT_FEE + SURPLUS_BALANCE; + set_alice_free_balance(initial_balance); + + assert_ok!(buy_default_membership_as_alice()); + + let member_id = assert_ok_unwrap(Members::member_id_by_account_id(&ALICE_ACCOUNT_ID), "member id not assigned"); + + let profile = assert_ok_unwrap(Members::member_profile(&member_id), "member profile not created"); + + assert_eq!(Some(profile.handle), get_alice_info().handle); + assert_eq!(Some(profile.avatar_uri), get_alice_info().avatar_uri); + assert_eq!(Some(profile.about), get_alice_info().about); + + assert_eq!(Balances::free_balance(&ALICE_ACCOUNT_ID), SURPLUS_BALANCE); + + }); +} + +#[test] +fn buy_membership_fails_without_enough_balance() { + const DEFAULT_FEE: u32 = 500; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE).build(), || + { + let initial_balance = DEFAULT_FEE - 1; + set_alice_free_balance(initial_balance); + + assert!(buy_default_membership_as_alice().is_err()); + }); +} + +#[test] +fn new_memberships_allowed_flag() { + const DEFAULT_FEE: u32 = 500; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE).build(), || + { + let initial_balance = DEFAULT_FEE + 1; + set_alice_free_balance(initial_balance); + + >::put(false); + + assert!(buy_default_membership_as_alice().is_err()); + }); +} + +#[test] +fn account_cannot_create_multiple_memberships() { + const DEFAULT_FEE: u32 = 500; + const SURPLUS_BALANCE: u32 = 500; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE).build(), || + { + let initial_balance = DEFAULT_FEE + SURPLUS_BALANCE; + set_alice_free_balance(initial_balance); + + // First time it works + assert_ok!(buy_default_membership_as_alice()); + + // second attempt should fail + assert!(buy_default_membership_as_alice().is_err()); + + }); +} + +#[test] +fn unique_handles() { + const DEFAULT_FEE: u32 = 500; + const SURPLUS_BALANCE: u32 = 500; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE).build(), || + { + let initial_balance = DEFAULT_FEE + SURPLUS_BALANCE; + set_alice_free_balance(initial_balance); + + // alice's handle already taken + >::insert(get_alice_info().handle.unwrap(), 1); + + // should not be allowed to buy membership with that handle + assert!(buy_default_membership_as_alice().is_err()); + + }); +} + +#[test] +fn update_profile() { + const DEFAULT_FEE: u32 = 500; + const SURPLUS_BALANCE: u32 = 500; + + with_externalities(&mut ExtBuilder::default() + .default_paid_membership_fee(DEFAULT_FEE).build(), || + { + let initial_balance = DEFAULT_FEE + SURPLUS_BALANCE; + set_alice_free_balance(initial_balance); + + assert_ok!(buy_default_membership_as_alice()); + + assert_ok!(Members::update_profile(Origin::signed(ALICE_ACCOUNT_ID), get_bob_info())); + + let member_id = assert_ok_unwrap(Members::member_id_by_account_id(&ALICE_ACCOUNT_ID), "member id not assigned"); + + let profile = assert_ok_unwrap(Members::member_profile(&member_id), "member profile created"); + + assert_eq!(Some(profile.handle), get_bob_info().handle); + assert_eq!(Some(profile.avatar_uri), get_bob_info().avatar_uri); + assert_eq!(Some(profile.about), get_bob_info().about); + + }); +} + +#[test] +fn add_screened_member() { + with_externalities(&mut ExtBuilder::default().build(), || + { + let screening_authority = 5; + >::put(&screening_authority); + + assert_ok!(Members::add_screened_member(Origin::signed(screening_authority), ALICE_ACCOUNT_ID, get_alice_info())); + + let member_id = assert_ok_unwrap(Members::member_id_by_account_id(&ALICE_ACCOUNT_ID), "member id not assigned"); + + let profile = assert_ok_unwrap(Members::member_profile(&member_id), "member profile created"); + + assert_eq!(Some(profile.handle), get_alice_info().handle); + assert_eq!(Some(profile.avatar_uri), get_alice_info().avatar_uri); + assert_eq!(Some(profile.about), get_alice_info().about); + assert_eq!(members::EntryMethod::Screening(screening_authority), profile.entry); + + }); +} diff --git a/src/migration.rs b/src/migration.rs new file mode 100644 index 0000000000..d835e0a17d --- /dev/null +++ b/src/migration.rs @@ -0,0 +1,65 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +use srml_support::{StorageValue, dispatch::Result, decl_module, decl_storage, decl_event, ensure}; +use system; +use rstd::prelude::*; +use runtime_io::print; +use crate::{VERSION}; +use crate::membership::members; + +pub trait Trait: system::Trait + members::Trait { + type Event: From> + Into<::Event>; +} + +decl_storage! { + trait Store for Module as Migration { + /// Records at what runtime spec version the store was initialized. This allows the runtime + /// to know when to run initialize code if it was installed as an update. + pub SpecVersion get(spec_version) build(|_| Some(VERSION.spec_version)) : Option; + } +} + +decl_event! { + pub enum Event where ::BlockNumber { + Migrated(BlockNumber, u32), + } +} + +// When preparing a new major runtime release version bump this value to match it and update +// the initialization code in runtime_initialization(). Because of the way substrate runs runtime code +// the runtime doesn't need to maintain any logic for old migrations. All knowledge about state of the chain and runtime +// prior to the new runtime taking over is implicit in the migration code implementation. If assumptions are incorrect +// behaviour is undefined. +const MIGRATION_FOR_SPEC_VERSION: u32 = 5; + +impl Module { + fn runtime_initialization() { + if VERSION.spec_version != MIGRATION_FOR_SPEC_VERSION { return } + + print("running runtime initializers"); + + >::initialize_storage(); + + // ... + // add initialization of other modules introduced in this runtime + // ... + + Self::deposit_event(RawEvent::Migrated(>::block_number(), VERSION.spec_version)); + } +} + +decl_module! { + pub struct Module for enum Call where origin: T::Origin { + fn deposit_event() = default; + + fn on_initialise(_now: T::BlockNumber) { + if Self::spec_version().map_or(true, |spec_version| VERSION.spec_version > spec_version) { + // mark store version with current version of the runtime + >::put(VERSION.spec_version); + + // run migrations and store initializers + Self::runtime_initialization(); + } + } + } +} diff --git a/src/traits.rs b/src/traits.rs new file mode 100644 index 0000000000..8b24b830da --- /dev/null +++ b/src/traits.rs @@ -0,0 +1,9 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +use system; + +pub trait IsActiveMember { + fn is_active_member(account_id: &T::AccountId) -> bool { + false + } +} \ No newline at end of file