diff --git a/.gitignore b/.gitignore index 7aad7a3ccb..15b05219df 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ joystream_runtime.wasm # Vim .*.sw* + +/modules/**/*.lock \ No newline at end of file diff --git a/modules/proposals/codex/Cargo.toml b/modules/proposals/codex/Cargo.toml new file mode 100644 index 0000000000..892f79254b --- /dev/null +++ b/modules/proposals/codex/Cargo.toml @@ -0,0 +1,103 @@ +[package] +name = 'substrate-proposals-codex-module' +version = '2.0.0' +authors = ['Joystream contributors'] +edition = '2018' + +[features] +default = ['std'] +no_std = [] +std = [ + 'sr-staking-primitives/std', + 'staking/std', + 'codec/std', + 'rstd/std', + 'srml-support/std', + 'balances/std', + 'primitives/std', + 'runtime-primitives/std', + 'system/std', + 'timestamp/std', + 'serde', +] + + +[dependencies.num_enum] +default_features = false +version = "0.4.2" + +[dependencies.serde] +features = ['derive'] +optional = true +version = '1.0.101' + +[dependencies.codec] +default-features = false +features = ['derive'] +package = 'parity-scale-codec' +version = '1.0.0' + +[dependencies.primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'substrate-primitives' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.rstd] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-std' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.runtime-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-primitives' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.sr-staking-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-staking-primitives' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.srml-support] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-support' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.system] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-system' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.timestamp] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-timestamp' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.staking] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-staking' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.balances] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-balances' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.proposal_engine] +default_features = false +package = 'substrate-proposals-engine-module' +path = '../engine' + +[dev-dependencies.runtime-io] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-io' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' diff --git a/modules/proposals/codex/src/lib.rs b/modules/proposals/codex/src/lib.rs new file mode 100644 index 0000000000..269fd218b8 --- /dev/null +++ b/modules/proposals/codex/src/lib.rs @@ -0,0 +1,55 @@ +//! Proposals codex module for the Joystream platform. Version 2. +//! Contains preset proposal types +//! +//! Supported extrinsics (proposal type): +//! - create_text_proposal +//! + +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +// Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. +//#![warn(missing_docs)] + +pub use proposal_types::{ProposalType, TextProposalExecutable}; + +mod proposal_types; +#[cfg(test)] +mod tests; + +use codec::Encode; +use proposal_engine::*; +use rstd::clone::Clone; +use rstd::vec::Vec; +use srml_support::decl_module; + +/// 'Proposals codex' substrate module Trait +pub trait Trait: system::Trait + proposal_engine::Trait {} + +decl_module! { + /// 'Proposal codex' substrate module + pub struct Module for enum Call where origin: T::Origin { + /// Create text (signal) proposal type. On approval prints its content. + pub fn create_text_proposal(origin, title: Vec, body: Vec) { + let parameters = crate::ProposalParameters { + voting_period: T::BlockNumber::from(3u32), + approval_quorum_percentage: 49, + }; + + let text_proposal = TextProposalExecutable{ + title: title.clone(), + body: body.clone() + }; + let proposal_code = text_proposal.encode(); + + >::create_proposal( + origin, + parameters, + title, + body, + text_proposal.proposal_type(), + proposal_code + )?; + } + } +} diff --git a/modules/proposals/codex/src/proposal_types.rs b/modules/proposals/codex/src/proposal_types.rs new file mode 100644 index 0000000000..8596bc5b43 --- /dev/null +++ b/modules/proposals/codex/src/proposal_types.rs @@ -0,0 +1,69 @@ +use codec::{Decode, Encode}; +use num_enum::{IntoPrimitive, TryFromPrimitive}; +use rstd::convert::TryFrom; +use rstd::prelude::*; + +use rstd::str::from_utf8; +use srml_support::{dispatch, print}; + +use crate::{ProposalCodeDecoder, ProposalExecutable}; + +/// Defines allowed proposals types. Integer value serves as proposal_type_id. +#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive)] +#[repr(u32)] +pub enum ProposalType { + /// Text(signal) proposal type + Text = 1, +} + +impl ProposalType { + fn compose_executable( + &self, + proposal_data: Vec, + ) -> Result, &'static str> { + match self { + ProposalType::Text => TextProposalExecutable::decode(&mut &proposal_data[..]) + .map_err(|err| err.what()) + .map(|obj| Box::new(obj) as Box), + } + } +} + +impl ProposalCodeDecoder for ProposalType { + fn decode_proposal( + proposal_type: u32, + proposal_code: Vec, + ) -> Result, &'static str> { + Self::try_from(proposal_type) + .map_err(|_| "Unsupported proposal type")? + .compose_executable(proposal_code) + } +} + +/// Text (signal) proposal executable code wrapper. Prints its content on execution. +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug, Default)] +pub struct TextProposalExecutable { + /// Text proposal title + pub title: Vec, + + /// Text proposal body + pub body: Vec, +} + +impl TextProposalExecutable { + /// Converts text proposal type to proposal_type_id + pub fn proposal_type(&self) -> u32 { + ProposalType::Text.into() + } +} + +impl ProposalExecutable for TextProposalExecutable { + fn execute(&self) -> dispatch::Result { + print("Proposal: "); + print(from_utf8(self.title.as_slice()).unwrap()); + print("Description:"); + print(from_utf8(self.body.as_slice()).unwrap()); + + Ok(()) + } +} diff --git a/modules/proposals/codex/src/tests/mock.rs b/modules/proposals/codex/src/tests/mock.rs new file mode 100644 index 0000000000..9b8142a463 --- /dev/null +++ b/modules/proposals/codex/src/tests/mock.rs @@ -0,0 +1,128 @@ +#![cfg(test)] + +pub use system; + +pub use primitives::{Blake2Hasher, H256}; +pub use runtime_primitives::{ + testing::{Digest, DigestItem, Header, UintAuthorityId}, + traits::{BlakeTwo256, Convert, IdentityLookup, OnFinalize}, + weights::Weight, + BuildStorage, Perbill, +}; + +use proposal_engine::VotersParameters; +use srml_support::{impl_outer_dispatch, impl_outer_origin, parameter_types}; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +// Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Test; +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); + pub const MinimumPeriod: u64 = 5; +} + +impl_outer_dispatch! { + pub enum Call for Test where origin: Origin { + codex::ProposalCodex, + proposals::ProposalsEngine, + } +} + +impl proposal_engine::Trait for Test { + type Event = (); + + type ProposalOrigin = system::EnsureSigned; + + type VoteOrigin = system::EnsureSigned; + + type TotalVotersCounter = MockVotersParameters; + + type ProposalCodeDecoder = crate::ProposalType; + + type ProposalId = u32; + + type ProposerId = u64; + + type VoterId = u64; +} + +pub struct MockVotersParameters; +impl VotersParameters for MockVotersParameters { + fn total_voters_count() -> u32 { + 4 + } +} + +impl crate::Trait for Test {} + +impl system::Trait for Test { + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Call = (); + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = (); + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); +} + +impl timestamp::Trait for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; +} + +parameter_types! { + pub const ExistentialDeposit: u32 = 0; + pub const TransferFee: u32 = 0; + pub const CreationFee: u32 = 0; + pub const TransactionBaseFee: u32 = 1; + pub const TransactionByteFee: u32 = 0; + pub const InitialMembersBalance: u32 = 0; +} + +impl balances::Trait for Test { + /// The type for recording an account's balance. + type Balance = u64; + /// What to do if an account's free balance gets zeroed. + type OnFreeBalanceZero = (); + /// What to do if a new account is created. + type OnNewAccount = (); + /// The ubiquitous event type. + type Event = (); + + type DustRemoval = (); + type TransferPayment = (); + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; +} + +// TODO add a Hook type to capture TriggerElection and CouncilElected hooks + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +pub fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() +} + +pub type ProposalCodex = crate::Module; +pub type ProposalsEngine = proposal_engine::Module; diff --git a/modules/proposals/codex/src/tests/mod.rs b/modules/proposals/codex/src/tests/mod.rs new file mode 100644 index 0000000000..2a443f088a --- /dev/null +++ b/modules/proposals/codex/src/tests/mod.rs @@ -0,0 +1,28 @@ +mod mock; + +use mock::*; +use system::RawOrigin; + +#[test] +fn create_text_proposal_codex_call_succeeds() { + initial_test_ext().execute_with(|| { + let origin = RawOrigin::Signed(1).into(); + + assert!( + ProposalCodex::create_text_proposal(origin, b"title".to_vec(), b"body".to_vec(),) + .is_ok() + ); + }); +} + +#[test] +fn create_text_proposal_codex_call_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + let origin = RawOrigin::None.into(); + + assert!( + ProposalCodex::create_text_proposal(origin, b"title".to_vec(), b"body".to_vec(),) + .is_err() + ); + }); +} diff --git a/modules/proposals/engine/Cargo.toml b/modules/proposals/engine/Cargo.toml new file mode 100644 index 0000000000..6656e695ac --- /dev/null +++ b/modules/proposals/engine/Cargo.toml @@ -0,0 +1,98 @@ +[package] +name = 'substrate-proposals-engine-module' +version = '2.0.0' +authors = ['Joystream contributors'] +edition = '2018' + +[features] +default = ['std'] +no_std = [] +std = [ + 'sr-staking-primitives/std', + 'staking/std', + 'codec/std', + 'rstd/std', + 'srml-support/std', + 'balances/std', + 'primitives/std', + 'runtime-primitives/std', + 'system/std', + 'timestamp/std', + 'serde', +] + + +[dependencies.num_enum] +default_features = false +version = "0.4.2" + +[dependencies.serde] +features = ['derive'] +optional = true +version = '1.0.101' + +[dependencies.codec] +default-features = false +features = ['derive'] +package = 'parity-scale-codec' +version = '1.0.0' + +[dependencies.primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'substrate-primitives' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.rstd] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-std' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.runtime-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-primitives' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.sr-staking-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-staking-primitives' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.srml-support] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-support' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.system] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-system' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.timestamp] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-timestamp' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.staking] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-staking' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dependencies.balances] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-balances' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' + +[dev-dependencies.runtime-io] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-io' +rev = '0e3001a1ad6fa3d1ba7da7342a8d0d3b3facb2f3' diff --git a/modules/proposals/engine/src/errors.rs b/modules/proposals/engine/src/errors.rs new file mode 100644 index 0000000000..d4ebd3e44f --- /dev/null +++ b/modules/proposals/engine/src/errors.rs @@ -0,0 +1,16 @@ +pub const MSG_EMPTY_TITLE_PROVIDED: &str = "Proposal cannot have an empty title"; +pub const MSG_EMPTY_BODY_PROVIDED: &str = "Proposal cannot have an empty body"; +pub const MSG_TOO_LONG_TITLE: &str = "Title is too long"; +pub const MSG_TOO_LONG_BODY: &str = "Body is too long"; +pub const MSG_PROPOSAL_NOT_FOUND: &str = "This proposal does not exist"; +pub const MSG_PROPOSAL_FINALIZED: &str = "Proposal is finalized already"; +pub const MSG_YOU_ALREADY_VOTED: &str = "You have already voted on this proposal"; +pub const MSG_YOU_DONT_OWN_THIS_PROPOSAL: &str = "You do not own this proposal"; + +//pub const MSG_STAKE_IS_TOO_LOW: &str = "Stake is too low"; +//pub const MSG_STAKE_IS_GREATER_THAN_BALANCE: &str = "Balance is too low to be staked"; +//pub const MSG_ONLY_MEMBERS_CAN_PROPOSE: &str = "Only members can make a proposal"; +//pub const MSG_ONLY_COUNCILORS_CAN_VOTE: &str = "Only councilors can vote on proposals"; +//pub const MSG_PROPOSAL_STATUS_ALREADY_UPDATED: &str = "Proposal status has been updated already"; +//pub const MSG_EMPTY_WASM_CODE_PROVIDED: &str = "Proposal cannot have an empty WASM code"; +//pub const MSG_TOO_LONG_WASM_CODE: &str = "WASM code is too big"; diff --git a/modules/proposals/engine/src/lib.rs b/modules/proposals/engine/src/lib.rs new file mode 100644 index 0000000000..1324c105d1 --- /dev/null +++ b/modules/proposals/engine/src/lib.rs @@ -0,0 +1,364 @@ +//! Proposals engine module for the Joystream platform. Version 2. +//! Provides methods and extrinsics to create and vote for proposals. +//! +//! Supported extrinsics: +//! - vote +//! - cancel_proposal +//! - veto_proposal +//! +//! Public API (requires root origin): +//! - create_proposal +//! + +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +// Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. +//#![warn(missing_docs)] + +use types::FinalizedProposalData; +pub use types::VotingResults; +pub use types::{Proposal, ProposalParameters, ProposalStatus}; +pub use types::{ProposalCodeDecoder, ProposalExecutable}; +pub use types::{VoteKind, VotersParameters}; + +mod errors; +mod types; + +#[cfg(test)] +mod tests; + +use rstd::prelude::*; +use runtime_primitives::traits::EnsureOrigin; +use srml_support::{ + decl_event, decl_module, decl_storage, dispatch, ensure, Parameter, StorageDoubleMap, +}; +use system::ensure_root; + +// TODO: add maximum allowed active proposals +// TODO: update proposal.finalized_at - update on all proposal finalization points. + +// Max allowed proposal title length. Can be used if config value is not filled. +const DEFAULT_TITLE_MAX_LEN: u32 = 100; +// Max allowed proposal body length. Can be used if config value is not filled. +const DEFAULT_BODY_MAX_LEN: u32 = 10_000; + +/// Proposals engine trait. +pub trait Trait: system::Trait + timestamp::Trait { + /// Engine event type. + type Event: From> + Into<::Event>; + + /// Origin from which proposals must come. + type ProposalOrigin: EnsureOrigin; + + /// Origin from which votes must come. + type VoteOrigin: EnsureOrigin; + + /// Provides data for voting. Defines maximum voters count for the proposal. + type TotalVotersCounter: VotersParameters; + + /// Converts proposal code binary to executable representation + type ProposalCodeDecoder: ProposalCodeDecoder; + + /// Proposal Id type + type ProposalId: From + Parameter + Default + Copy; + + /// Type for the proposer id. Should be authenticated by account id. + type ProposerId: From + Parameter + Default; + + /// Type for the voter id. Should be authenticated by account id. + type VoterId: From + Parameter + Default + Clone; +} + +decl_event!( + pub enum Event + where + ::ProposalId, + ::ProposerId, + ::VoterId, + { + /// Emits on proposal creation. + /// Params: + /// * Account id of a proposer. + /// * Id of a newly created proposal after it was saved in storage. + ProposalCreated(ProposerId, ProposalId), + + /// Emits on proposal cancellation. + /// Params: + /// * Account id of a proposer. + /// * Id of a cancelled proposal. + ProposalCanceled(ProposerId, ProposalId), + + /// Emits on proposal veto. + /// Params: + /// * Id of a vetoed proposal. + ProposalVetoed(ProposalId), + + /// Emits on proposal status change. + /// Params: + /// * Id of a updated proposal. + /// * New proposal status + ProposalStatusUpdated(ProposalId, ProposalStatus), + + /// Emits on voting for the proposal + /// Params: + /// * Voter - an account id of a voter. + /// * Id of a proposal. + /// * Kind of vote. + Voted(VoterId, ProposalId, VoteKind), + } +); + +// Storage for the proposals module +decl_storage! { + trait Store for Module as ProposalsEngine{ + /// Map proposal by its id. + pub Proposals get(fn proposals): map T::ProposalId => + Proposal; + + /// Count of all proposals that have been created. + pub ProposalCount get(fn proposal_count): u32; + + /// Map proposal executable code by proposal id. + ProposalCode get(fn proposal_codes): map T::ProposalId => Vec; + + /// Ids of proposals that are open for voting (have not been finalized yet). + pub ActiveProposalIds get(fn active_proposal_ids): linked_map T::ProposalId => (); + + /// Double map for preventing duplicate votes. Should be cleaned after usage. + pub(crate) VoteExistsByProposalByVoter get(fn vote_by_proposal_by_voter): + double_map T::ProposalId, twox_256(T::VoterId) => VoteKind; + + /// Defines max allowed proposal title length. Can be configured. + TitleMaxLen get(title_max_len) config(): u32 = DEFAULT_TITLE_MAX_LEN; + + /// Defines max allowed proposal body length. Can be configured. + BodyMaxLen get(body_max_len) config(): u32 = DEFAULT_BODY_MAX_LEN; + } +} + +decl_module! { + /// 'Proposal engine' substrate module + pub struct Module for enum Call where origin: T::Origin { + + /// Emits an event. Default substrate implementation. + fn deposit_event() = default; + + /// Vote extrinsic. Conditions: origin must allow votes. + pub fn vote(origin, proposal_id: T::ProposalId, vote: VoteKind) { + let account_id = T::VoteOrigin::ensure_origin(origin)?; + let voter_id = T::VoterId::from(account_id); + + ensure!(>::exists(proposal_id), errors::MSG_PROPOSAL_NOT_FOUND); + let mut proposal = Self::proposals(proposal_id); + + ensure!(proposal.status == ProposalStatus::Active, errors::MSG_PROPOSAL_FINALIZED); + + let did_not_vote_before = !>::exists( + proposal_id, + voter_id.clone(), + ); + + ensure!(did_not_vote_before, errors::MSG_YOU_ALREADY_VOTED); + + proposal.voting_results.add_vote(vote.clone()); + + // mutation + + >::insert(proposal_id, proposal); + >::insert( proposal_id, voter_id.clone(), vote.clone()); + Self::deposit_event(RawEvent::Voted(voter_id, proposal_id, vote)); + } + + /// Cancel a proposal by its original proposer. + pub fn cancel_proposal(origin, proposal_id: T::ProposalId) { + let account_id = T::ProposalOrigin::ensure_origin(origin)?; + let proposer_id = T::ProposerId::from(account_id); + + ensure!(>::exists(proposal_id), errors::MSG_PROPOSAL_NOT_FOUND); + let proposal = Self::proposals(proposal_id); + + ensure!(proposer_id == proposal.proposer_id, errors::MSG_YOU_DONT_OWN_THIS_PROPOSAL); + ensure!(proposal.status == ProposalStatus::Active, errors::MSG_PROPOSAL_FINALIZED); + + // mutation + + Self::update_proposal_status(proposal_id, ProposalStatus::Canceled); + Self::deposit_event(RawEvent::ProposalCanceled(proposer_id, proposal_id)); + } + + /// Veto a proposal. Must be root. + pub fn veto_proposal(origin, proposal_id: T::ProposalId) { + ensure_root(origin)?; + + ensure!(>::exists(proposal_id), errors::MSG_PROPOSAL_NOT_FOUND); + let proposal = Self::proposals(proposal_id); + + ensure!(proposal.status == ProposalStatus::Active, errors::MSG_PROPOSAL_FINALIZED); + + // mutation + + Self::update_proposal_status(proposal_id, ProposalStatus::Vetoed); + Self::deposit_event(RawEvent::ProposalVetoed(proposal_id)); + } + + /// Block finalization. Perform voting period check and vote result tally. + fn on_finalize(_n: T::BlockNumber) { + let finalized_proposals_data = Self::get_finalized_proposals_data(); + + // mutation + for proposal_data in finalized_proposals_data { + >::insert(proposal_data.proposal_id, proposal_data.proposal); + Self::update_proposal_status(proposal_data.proposal_id, proposal_data.status); + } + } + } +} + +impl Module { + /// Create proposal. Requires 'proposal origin' membership. + pub fn create_proposal( + origin: T::Origin, + parameters: ProposalParameters, + title: Vec, + body: Vec, + proposal_type: u32, + proposal_code: Vec, + ) -> dispatch::Result { + let account_id = T::ProposalOrigin::ensure_origin(origin)?; + let proposer_id = T::ProposerId::from(account_id); + + ensure!(!title.is_empty(), errors::MSG_EMPTY_TITLE_PROVIDED); + ensure!( + title.len() as u32 <= Self::title_max_len(), + errors::MSG_TOO_LONG_TITLE + ); + + ensure!(!body.is_empty(), errors::MSG_EMPTY_BODY_PROVIDED); + ensure!( + body.len() as u32 <= Self::body_max_len(), + errors::MSG_TOO_LONG_BODY + ); + + let next_proposal_count_value = Self::proposal_count() + 1; + let new_proposal_id = next_proposal_count_value; + + let new_proposal = Proposal { + created: Self::current_block(), + parameters, + title, + body, + proposer_id: proposer_id.clone(), + proposal_type, + status: ProposalStatus::Active, + voting_results: VotingResults::default(), + finalized_at: None, + }; + + // mutation + let proposal_id = T::ProposalId::from(new_proposal_id); + >::insert(proposal_id, new_proposal); + >::insert(proposal_id, proposal_code); + >::insert(proposal_id, ()); + ProposalCount::put(next_proposal_count_value); + + Self::deposit_event(RawEvent::ProposalCreated(proposer_id, proposal_id)); + + Ok(()) + } +} + +impl Module { + // Wrapper-function over system::block_number() + fn current_block() -> T::BlockNumber { + >::block_number() + } + + // Executes approved proposal code + fn execute_proposal(proposal_id: T::ProposalId) { + //let origin = system::RawOrigin::Root.into(); + let proposal = Self::proposals(proposal_id); + let proposal_code = Self::proposal_codes(proposal_id); + + let proposal_code_result = + T::ProposalCodeDecoder::decode_proposal(proposal.proposal_type, proposal_code); + + let new_proposal_status = match proposal_code_result { + Ok(proposal_code) => { + if let Err(error) = proposal_code.execute() { + ProposalStatus::Failed { + error: error.as_bytes().to_vec(), + } + } else { + ProposalStatus::Executed + } + } + Err(error) => ProposalStatus::Failed { + error: error.as_bytes().to_vec(), + }, + }; + + Self::update_proposal_status(proposal_id, new_proposal_status) + } + + /// Enumerates through active proposals. Tally Voting results. + /// Returns proposals with finalized status and id + fn get_finalized_proposals_data( + ) -> Vec> { + // enumerate active proposals id and gather finalization data + >::enumerate() + .filter_map(|(proposal_id, _)| { + // load current proposal + let proposal = Self::proposals(proposal_id); + + let decision_status = proposal.define_proposal_decision_status( + T::TotalVotersCounter::total_voters_count(), + Self::current_block(), + ); + + if let Some(status) = decision_status { + Some(FinalizedProposalData { + proposal_id, + proposal, + status, + finalized_at: Self::current_block(), + }) + } else { + None + } + }) + .collect() // compose output vector + } + + // TODO: update proposal.finalized_at + // TODO: to be refactored or removed after introducing stakes. Events should be fired on actions + // such as 'rejected' or 'approved'. + /// Updates proposal status and removes proposal id from active id set. + fn update_proposal_status(proposal_id: T::ProposalId, new_status: ProposalStatus) { + if new_status != ProposalStatus::Active { + >::remove(&proposal_id); + } + >::mutate(proposal_id, |p| p.status = new_status.clone()); + + Self::deposit_event(RawEvent::ProposalStatusUpdated( + proposal_id, + new_status.clone(), + )); + + match new_status { + ProposalStatus::Rejected | ProposalStatus::Expired => { + Self::reject_proposal(proposal_id) + } + ProposalStatus::Approved => Self::approve_proposal(proposal_id), + _ => {} // do nothing + } + } + + /// Reject a proposal. The staked deposit will be returned to a proposer. + fn reject_proposal(_proposal_id: T::ProposalId) {} + + /// Approve a proposal. The staked deposit will be returned. + fn approve_proposal(proposal_id: T::ProposalId) { + Self::execute_proposal(proposal_id); + } +} diff --git a/modules/proposals/engine/src/tests/mock.rs b/modules/proposals/engine/src/tests/mock.rs new file mode 100644 index 0000000000..b27310c727 --- /dev/null +++ b/modules/proposals/engine/src/tests/mock.rs @@ -0,0 +1,216 @@ +#![cfg(test)] + +pub use system; + +pub use primitives::{Blake2Hasher, H256}; +pub use runtime_primitives::{ + testing::{Digest, DigestItem, Header, UintAuthorityId}, + traits::{BlakeTwo256, Convert, IdentityLookup, OnFinalize}, + weights::Weight, + BuildStorage, Perbill, +}; + +use crate::VotersParameters; +use srml_support::{impl_outer_dispatch, impl_outer_event, impl_outer_origin, parameter_types}; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +// Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Test; +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); + pub const MinimumPeriod: u64 = 5; +} + +impl_outer_dispatch! { + pub enum Call for Test where origin: Origin { + proposals::ProposalsEngine, + } +} + +mod engine { + pub use crate::Event; +} + +impl_outer_event! { + pub enum TestEvent for Test { + balances, engine, + } +} + +impl crate::Trait for Test { + type Event = TestEvent; + + type ProposalOrigin = system::EnsureSigned; + + type VoteOrigin = system::EnsureSigned; + + type TotalVotersCounter = (); + + type ProposalCodeDecoder = ProposalType; + + type ProposalId = u32; + + type ProposerId = u64; + + type VoterId = u64; +} + +impl VotersParameters for () { + fn total_voters_count() -> u32 { + 4 + } +} + +impl system::Trait for Test { + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Call = (); + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = TestEvent; + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); +} + +impl timestamp::Trait for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; +} + +parameter_types! { + pub const ExistentialDeposit: u32 = 0; + pub const TransferFee: u32 = 0; + pub const CreationFee: u32 = 0; + pub const TransactionBaseFee: u32 = 1; + pub const TransactionByteFee: u32 = 0; + pub const InitialMembersBalance: u32 = 0; +} + +impl balances::Trait for Test { + /// The type for recording an account's balance. + type Balance = u64; + /// What to do if an account's free balance gets zeroed. + type OnFreeBalanceZero = (); + /// What to do if a new account is created. + type OnNewAccount = (); + + type Event = TestEvent; + + type DustRemoval = (); + type TransferPayment = (); + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; +} + +// TODO add a Hook type to capture TriggerElection and CouncilElected hooks + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +pub fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() +} + +pub type ProposalsEngine = crate::Module; +pub type System = system::Module; + +use codec::{Decode, Encode}; +use num_enum::{IntoPrimitive, TryFromPrimitive}; +use rstd::convert::TryFrom; +use rstd::prelude::*; + +use srml_support::dispatch; + +use crate::{ProposalCodeDecoder, ProposalExecutable}; + +/// Defines allowed proposals types. Integer value serves as proposal_type_id. +#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive)] +#[repr(u32)] +pub enum ProposalType { + /// Dummy(Text) proposal type + Dummy = 1, + + /// Testing proposal type for faults + Faulty = 10001, +} + +impl ProposalType { + fn compose_executable( + &self, + proposal_data: Vec, + ) -> Result, &'static str> { + match self { + ProposalType::Dummy => DummyExecutable::decode(&mut &proposal_data[..]) + .map_err(|err| err.what()) + .map(|obj| Box::new(obj) as Box), + ProposalType::Faulty => FaultyExecutable::decode(&mut &proposal_data[..]) + .map_err(|err| err.what()) + .map(|obj| Box::new(obj) as Box), + } + } +} + +impl ProposalCodeDecoder for ProposalType { + fn decode_proposal( + proposal_type: u32, + proposal_code: Vec, + ) -> Result, &'static str> { + Self::try_from(proposal_type) + .map_err(|_| "Unsupported proposal type")? + .compose_executable(proposal_code) + } +} + +/// Testing proposal type +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug, Default)] +pub struct DummyExecutable { + pub title: Vec, + pub body: Vec, +} + +impl DummyExecutable { + pub fn proposal_type(&self) -> u32 { + ProposalType::Dummy.into() + } +} + +impl ProposalExecutable for DummyExecutable { + fn execute(&self) -> dispatch::Result { + Ok(()) + } +} + +/// Faulty proposal executable code wrapper. Used for failed proposal execution tests. +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug, Default)] +pub struct FaultyExecutable; +impl ProposalExecutable for FaultyExecutable { + fn execute(&self) -> dispatch::Result { + Err("Failed") + } +} + +impl FaultyExecutable { + /// Converts faulty proposal type to proposal_type_id + pub fn proposal_type(&self) -> u32 { + ProposalType::Faulty.into() + } +} diff --git a/modules/proposals/engine/src/tests/mod.rs b/modules/proposals/engine/src/tests/mod.rs new file mode 100644 index 0000000000..0eabc6fac5 --- /dev/null +++ b/modules/proposals/engine/src/tests/mod.rs @@ -0,0 +1,782 @@ +mod mock; + +use crate::*; +use mock::*; + +use codec::Encode; +use runtime_primitives::traits::{OnFinalize, OnInitialize}; +use srml_support::{dispatch, StorageMap, StorageValue}; +use system::RawOrigin; +use system::{EventRecord, Phase}; + +struct DummyProposalFixture { + parameters: ProposalParameters, + origin: RawOrigin, + proposal_type: u32, + proposal_code: Vec, + title: Vec, + body: Vec, +} + +impl Default for DummyProposalFixture { + fn default() -> Self { + let dummy_proposal = DummyExecutable { + title: b"title".to_vec(), + body: b"body".to_vec(), + }; + + DummyProposalFixture { + parameters: ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + }, + origin: RawOrigin::Signed(1), + proposal_type: dummy_proposal.proposal_type(), + proposal_code: dummy_proposal.encode(), + title: dummy_proposal.title, + body: dummy_proposal.body, + } + } +} + +impl DummyProposalFixture { + fn with_title_and_body(self, title: Vec, body: Vec) -> Self { + DummyProposalFixture { + title, + body, + ..self + } + } + + fn with_parameters(self, parameters: ProposalParameters) -> Self { + DummyProposalFixture { parameters, ..self } + } + + fn with_origin(self, origin: RawOrigin) -> Self { + DummyProposalFixture { origin, ..self } + } + + fn with_proposal_type_and_code(self, proposal_type: u32, proposal_code: Vec) -> Self { + DummyProposalFixture { + proposal_type, + proposal_code, + ..self + } + } + + fn create_proposal_and_assert(self, result: dispatch::Result) { + assert_eq!( + ProposalsEngine::create_proposal( + self.origin.into(), + self.parameters, + self.title, + self.body, + self.proposal_type, + self.proposal_code, + ), + result + ); + } +} + +struct CancelProposalFixture { + origin: RawOrigin, + proposal_id: u32, +} + +impl CancelProposalFixture { + fn new(proposal_id: u32) -> Self { + CancelProposalFixture { + proposal_id, + origin: RawOrigin::Signed(1), + } + } + + fn with_origin(self, origin: RawOrigin) -> Self { + CancelProposalFixture { origin, ..self } + } + + fn cancel_and_assert(self, expected_result: dispatch::Result) { + assert_eq!( + ProposalsEngine::cancel_proposal(self.origin.into(), self.proposal_id,), + expected_result + ); + } +} +struct VetoProposalFixture { + origin: RawOrigin, + proposal_id: u32, +} + +impl VetoProposalFixture { + fn new(proposal_id: u32) -> Self { + VetoProposalFixture { + proposal_id, + origin: RawOrigin::Root, + } + } + + fn with_origin(self, origin: RawOrigin) -> Self { + VetoProposalFixture { origin, ..self } + } + + fn veto_and_assert(self, expected_result: dispatch::Result) { + assert_eq!( + ProposalsEngine::veto_proposal(self.origin.into(), self.proposal_id,), + expected_result + ); + } +} + +struct VoteGenerator { + proposal_id: u32, + current_account_id: u64, + pub auto_increment_voter_id: bool, +} + +impl VoteGenerator { + fn new(proposal_id: u32) -> Self { + VoteGenerator { + proposal_id, + current_account_id: 0, + auto_increment_voter_id: true, + } + } + fn vote_and_assert_ok(&mut self, vote_kind: VoteKind) { + self.vote_and_assert(vote_kind, Ok(())); + } + + fn vote_and_assert(&mut self, vote_kind: VoteKind, expected_result: dispatch::Result) { + assert_eq!(self.vote(vote_kind.clone()), expected_result); + } + + fn vote(&mut self, vote_kind: VoteKind) -> dispatch::Result { + if self.auto_increment_voter_id { + self.current_account_id += 1; + } + + ProposalsEngine::vote( + system::RawOrigin::Signed(self.current_account_id).into(), + self.proposal_id, + vote_kind, + ) + } +} + +struct EventFixture; +impl EventFixture { + fn assert_events(expected_raw_events: Vec>) { + let expected_events = expected_raw_events + .iter() + .map(|ev| EventRecord { + phase: Phase::ApplyExtrinsic(0), + event: TestEvent::engine(ev.clone()), + topics: vec![], + }) + .collect::>>(); + + assert_eq!(System::events(), expected_events); + } +} + +// Recommendation from Parity on testing on_finalize +// https://substrate.dev/docs/en/next/development/module/tests +fn run_to_block(n: u64) { + while System::block_number() < n { + >::on_finalize(System::block_number()); + >::on_finalize(System::block_number()); + System::set_block_number(System::block_number() + 1); + >::on_initialize(System::block_number()); + >::on_initialize(System::block_number()); + } +} + +fn run_to_block_and_finalize(n: u64) { + run_to_block(n); + >::on_finalize(n); +} + +#[test] +fn create_dummy_proposal_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + + dummy_proposal.create_proposal_and_assert(Ok(())); + }); +} + +#[test] +fn create_dummy_proposal_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default().with_origin(RawOrigin::None); + + dummy_proposal.create_proposal_and_assert(Err("Invalid origin")); + }); +} + +#[test] +fn vote_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + }); +} + +#[test] +fn vote_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + assert_eq!( + ProposalsEngine::vote(system::RawOrigin::None.into(), 1, VoteKind::Approve), + Err("Invalid origin") + ); + }); +} + +#[test] +fn proposal_execution_succeeds() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + }; + + let dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposals_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposals_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposals_id); + + assert_eq!( + proposal, + Proposal { + proposal_type: 1, + parameters, + proposer_id: 1, + created: 1, + status: ProposalStatus::Executed, + title: b"title".to_vec(), + body: b"body".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + }, + finalized_at: None, + } + ) + }); +} + +#[test] +fn proposal_execution_failed() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + }; + + let faulty_proposal = FaultyExecutable; + + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters) + .with_proposal_type_and_code(faulty_proposal.proposal_type(), faulty_proposal.encode()); + + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposals_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposals_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposals_id); + + assert_eq!( + proposal, + Proposal { + proposal_type: faulty_proposal.proposal_type(), + parameters, + proposer_id: 1, + created: 1, + status: ProposalStatus::Failed { + error: "Failed".as_bytes().to_vec() + }, + title: b"title".to_vec(), + body: b"body".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + }, + finalized_at: None, + } + ) + }); +} + +#[test] +fn voting_results_calculation_succeeds() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 49, + }; + + let dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposals_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposals_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposals_id); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 2, + rejections: 1, + } + ) + }); +} + +#[test] +fn rejected_voting_results_and_remove_proposal_id_from_active_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + assert!(>::exists(proposal_id)); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 2, + approvals: 0, + rejections: 2, + } + ); + + assert_eq!(proposal.status, ProposalStatus::Rejected,); + + assert!(!>::exists(proposal_id)); + }); +} + +#[test] +fn create_proposal_fails_with_invalid_body_or_title() { + initial_test_ext().execute_with(|| { + let mut dummy_proposal = + DummyProposalFixture::default().with_title_and_body(Vec::new(), b"body".to_vec()); + dummy_proposal.create_proposal_and_assert(Err("Proposal cannot have an empty title")); + + dummy_proposal = + DummyProposalFixture::default().with_title_and_body(b"title".to_vec(), Vec::new()); + dummy_proposal.create_proposal_and_assert(Err("Proposal cannot have an empty body")); + + let too_long_title = vec![0; 200]; + dummy_proposal = + DummyProposalFixture::default().with_title_and_body(too_long_title, b"body".to_vec()); + dummy_proposal.create_proposal_and_assert(Err("Title is too long")); + + let too_long_body = vec![0; 11000]; + dummy_proposal = + DummyProposalFixture::default().with_title_and_body(b"title".to_vec(), too_long_body); + dummy_proposal.create_proposal_and_assert(Err("Body is too long")); + }); +} + +#[test] +fn vote_fails_with_expired_voting_period() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + run_to_block_and_finalize(6); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert(VoteKind::Approve, Err("Proposal is finalized already")); + }); +} + +#[test] +fn vote_fails_with_not_active_proposal() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + run_to_block_and_finalize(2); + + let mut vote_generator_to_fail = VoteGenerator::new(proposal_id); + vote_generator_to_fail + .vote_and_assert(VoteKind::Approve, Err("Proposal is finalized already")); + }); +} + +#[test] +fn vote_fails_with_absent_proposal() { + initial_test_ext().execute_with(|| { + let mut vote_generator = VoteGenerator::new(2); + vote_generator.vote_and_assert(VoteKind::Approve, Err("This proposal does not exist")); + }); +} + +#[test] +fn vote_fails_on_double_voting() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.auto_increment_voter_id = false; + + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert( + VoteKind::Approve, + Err("You have already voted on this proposal"), + ); + }); +} + +#[test] +fn cancel_proposal_succeeds() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + }; + let dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let cancel_proposal = CancelProposalFixture::new(proposal_id); + cancel_proposal.cancel_and_assert(Ok(())); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + proposal_type: 1, + parameters, + proposer_id: 1, + created: 1, + status: ProposalStatus::Canceled, + title: b"title".to_vec(), + body: b"body".to_vec(), + voting_results: VotingResults::default(), + finalized_at: None, + } + ) + }); +} + +#[test] +fn cancel_proposal_fails_with_not_active_proposal() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + run_to_block_and_finalize(6); + + let cancel_proposal = CancelProposalFixture::new(proposal_id); + cancel_proposal.cancel_and_assert(Err("Proposal is finalized already")); + }); +} + +#[test] +fn cancel_proposal_fails_with_not_existing_proposal() { + initial_test_ext().execute_with(|| { + let cancel_proposal = CancelProposalFixture::new(2); + cancel_proposal.cancel_and_assert(Err("This proposal does not exist")); + }); +} + +#[test] +fn cancel_proposal_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let cancel_proposal = + CancelProposalFixture::new(proposal_id).with_origin(RawOrigin::Signed(2)); + cancel_proposal.cancel_and_assert(Err("You do not own this proposal")); + }); +} + +#[test] +fn veto_proposal_succeeds() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + }; + let dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Ok(())); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + proposal_type: 1, + parameters, + proposer_id: 1, + created: 1, + status: ProposalStatus::Vetoed, + title: b"title".to_vec(), + body: b"body".to_vec(), + voting_results: VotingResults::default(), + finalized_at: None, + } + ) + }); +} + +#[test] +fn veto_proposal_fails_with_not_active_proposal() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + run_to_block_and_finalize(6); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Err("Proposal is finalized already")); + }); +} + +#[test] +fn veto_proposal_fails_with_not_existing_proposal() { + initial_test_ext().execute_with(|| { + let veto_proposal = VetoProposalFixture::new(2); + veto_proposal.veto_and_assert(Err("This proposal does not exist")); + }); +} + +#[test] +fn veto_proposal_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let veto_proposal = VetoProposalFixture::new(proposal_id).with_origin(RawOrigin::Signed(2)); + veto_proposal.veto_and_assert(Err("RequireRootOrigin")); + }); +} + +#[test] +fn create_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + EventFixture::assert_events(vec![RawEvent::ProposalCreated(1, 1)]); + }); +} + +#[test] +fn veto_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Ok(())); + + EventFixture::assert_events(vec![ + RawEvent::ProposalCreated(1, 1), + RawEvent::ProposalStatusUpdated(1, ProposalStatus::Vetoed), + RawEvent::ProposalVetoed(1), + ]); + }); +} + +#[test] +fn cancel_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let cancel_proposal = CancelProposalFixture::new(proposal_id); + cancel_proposal.cancel_and_assert(Ok(())); + + EventFixture::assert_events(vec![ + RawEvent::ProposalCreated(1, 1), + RawEvent::ProposalStatusUpdated(1, ProposalStatus::Canceled), + RawEvent::ProposalCanceled(1, 1), + ]); + }); +} + +#[test] +fn vote_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + EventFixture::assert_events(vec![ + RawEvent::ProposalCreated(1, 1), + RawEvent::Voted(1, 1, VoteKind::Approve), + ]); + }); +} + +#[test] +fn create_proposal_and_expire_it() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 49, + }; + + let dummy_proposal = DummyProposalFixture::default().with_parameters(parameters.clone()); + dummy_proposal.create_proposal_and_assert(Ok(())); + + run_to_block_and_finalize(8); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + proposal_type: 1, + parameters, + proposer_id: 1, + created: 1, + status: ProposalStatus::Expired, + title: b"title".to_vec(), + body: b"body".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 0, + rejections: 0, + }, + finalized_at: None, + } + ) + }); +} + +#[test] +fn voting_internal_cache_exists_after_proposal_finalization() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(())); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + // cache exists + assert!(>::exists( + proposal_id, + 1 + )); + + run_to_block_and_finalize(2); + + // cache still exists and is not cleared + assert!(>::exists( + proposal_id, + 1 + )); + }); +} diff --git a/modules/proposals/engine/src/types.rs b/modules/proposals/engine/src/types.rs new file mode 100644 index 0000000000..943b47ec97 --- /dev/null +++ b/modules/proposals/engine/src/types.rs @@ -0,0 +1,384 @@ +//! Proposals types module for the Joystream platform. Version 2. +//! Provides types for the proposal engine. + +use codec::{Decode, Encode}; +use rstd::cmp::PartialOrd; +use rstd::ops::Add; +use rstd::prelude::*; + +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; +use srml_support::dispatch; + +/// Current status of the proposal +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub enum ProposalStatus { + /// A new proposal that is available for voting. + Active, + + /// To clear the quorum requirement, the percentage of council members with revealed votes + /// must be no less than the quorum value for the given proposal type. + Approved, + + /// A proposal was rejected + Rejected, + + /// Not enough votes and voting period expired. + Expired, + + /// Proposal was successfully executed + Executed, + + /// Proposal was executed and failed with an error + Failed { + /// Fail error + error: Vec, + }, + + /// Proposal was withdrawn by its proposer. + Canceled, + + /// Proposal was vetoed by root. + Vetoed, +} + +impl Default for ProposalStatus { + fn default() -> Self { + ProposalStatus::Active + } +} + +/// Vote kind for the proposal. Sum of all votes defines proposal status. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub enum VoteKind { + /// Pass, an alternative or a ranking, for binary, multiple choice + /// and ranked choice propositions, respectively. + Approve, + + /// Against proposal. + Reject, + + /// Signals presence, but unwillingness to cast judgment on substance of vote. + Abstain, +} + +impl Default for VoteKind { + fn default() -> Self { + VoteKind::Reject + } +} + +/// Proposal parameters required to manage proposal risk. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct ProposalParameters { + /// During this period, votes can be accepted + pub voting_period: BlockNumber, + + /// Quorum percentage of approving voters required to pass a proposal. + pub approval_quorum_percentage: u32, + // /// Temporary field which defines expected threshold to pass the vote. + // /// Will be changed to percentage + // pub temp_threshold_vote_count: u32, + + //pub stake: BalanceOf, // +} + +/// Contains current voting results +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, PartialEq, Eq, Debug)] +pub struct VotingResults { + /// 'Abstain' votes counter + pub abstentions: u32, + + /// 'Approve' votes counter + pub approvals: u32, + + /// 'Reject' votes counter + pub rejections: u32, +} + +impl VotingResults { + /// Add vote to the related counter + pub fn add_vote(&mut self, vote: VoteKind) { + match vote { + VoteKind::Abstain => self.abstentions += 1, + VoteKind::Approve => self.approvals += 1, + VoteKind::Reject => self.rejections += 1, + } + } + + /// Calculates number of votes so far + pub fn votes_number(&self) -> u32 { + self.abstentions + self.approvals + self.rejections + } +} + +/// 'Proposal' contains information necessary for the proposal system functioning. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, PartialEq, Eq, Debug)] +pub struct Proposal { + /// Proposal type id + pub proposal_type: u32, + + /// Proposals parameter, characterize different proposal types. + pub parameters: ProposalParameters, + + /// Identifier of member proposing. + pub proposer_id: ProposerId, + + /// Proposal title + pub title: Vec, + + /// Proposal body + pub body: Vec, + + /// When it was created. + pub created: BlockNumber, + + // Any stake associated with the proposal. + //pub stake: Option> + /// Current proposal status + pub status: ProposalStatus, + + /// Curring voting result for the proposal + pub voting_results: VotingResults, + + // TODO: update proposal.finalized_at + /// Proposal finalization block number + pub finalized_at: Option, +} + +impl Proposal +where + BlockNumber: Add + PartialOrd + Copy, +{ + /// Returns whether voting period expired by now + pub fn is_voting_period_expired(&self, now: BlockNumber) -> bool { + now >= self.created + self.parameters.voting_period + } + + /// Determines the finalized proposal status using voting results tally for current proposal. + /// Parameters: current time, total voters number involved (council size) + /// Returns whether the proposal has finalized status + pub fn define_proposal_decision_status( + &self, + total_voters_count: u32, + now: BlockNumber, + ) -> Option { + let proposal_status_decision = ProposalStatusDecision { + proposal: self, + approvals: self.voting_results.approvals, + now, + votes_count: self.voting_results.votes_number(), + total_voters_count, + }; + + if proposal_status_decision.is_approval_quorum_reached() { + Some(ProposalStatus::Approved) + } else if proposal_status_decision.is_expired() { + Some(ProposalStatus::Expired) + } else if proposal_status_decision.is_voting_completed() { + Some(ProposalStatus::Rejected) + } else { + None + } + } +} + +/// Provides data for voting. +pub trait VotersParameters { + /// Defines maximum voters count for the proposal + fn total_voters_count() -> u32; +} + +// Calculates quorum, votes threshold, expiration status +struct ProposalStatusDecision<'a, BlockNumber, ProposerId> { + proposal: &'a Proposal, + now: BlockNumber, + votes_count: u32, + total_voters_count: u32, + approvals: u32, +} + +impl<'a, BlockNumber, ProposerId> ProposalStatusDecision<'a, BlockNumber, ProposerId> +where + BlockNumber: Add + PartialOrd + Copy, +{ + // Proposal has been expired and quorum not reached. + pub fn is_expired(&self) -> bool { + self.proposal.is_voting_period_expired(self.now) + } + + // Approval quorum reached for the proposal + pub fn is_approval_quorum_reached(&self) -> bool { + let approval_votes_fraction: f32 = self.approvals as f32 / self.total_voters_count as f32; + + let approval_quorum_fraction = + self.proposal.parameters.approval_quorum_percentage as f32 / 100.0; + + approval_votes_fraction >= approval_quorum_fraction + } + + // All voters had voted + pub fn is_voting_completed(&self) -> bool { + self.votes_count == self.total_voters_count + } +} + +/// Proposal executable code wrapper +pub trait ProposalExecutable { + /// Executes proposal code + fn execute(&self) -> dispatch::Result; +} + +/// Proposal code binary converter +pub trait ProposalCodeDecoder { + /// Converts proposal code binary to executable representation + fn decode_proposal( + proposal_type: u32, + proposal_code: Vec, + ) -> Result, &'static str>; +} + +/// Data container for the finalized proposal results +pub(crate) struct FinalizedProposalData { + /// Proposal id + pub proposal_id: ProposalId, + + /// Proposal to be finalized + pub proposal: Proposal, + + /// Proposal finalization status + pub status: ProposalStatus, + + /// Proposal finalization block number + pub finalized_at: BlockNumber, +} + +#[cfg(test)] +mod tests { + use crate::*; + + #[test] + fn proposal_voting_period_expired() { + let mut proposal = Proposal::::default(); + + proposal.created = 1; + proposal.parameters.voting_period = 3; + + assert!(proposal.is_voting_period_expired(4)); + } + + #[test] + fn proposal_voting_period_not_expired() { + let mut proposal = Proposal::::default(); + + proposal.created = 1; + proposal.parameters.voting_period = 3; + + assert!(!proposal.is_voting_period_expired(3)); + } + + #[test] + fn define_proposal_decision_status_returns_expired() { + let mut proposal = Proposal::::default(); + let now = 5; + proposal.created = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 60; + + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 2, + rejections: 1, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(5, now); + assert_eq!(expected_proposal_status, Some(ProposalStatus::Expired)); + } + #[test] + fn define_proposal_decision_status_returns_approved() { + let now = 2; + let mut proposal = Proposal::::default(); + proposal.created = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 60; + + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 3, + rejections: 1, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(5, now); + assert_eq!(expected_proposal_status, Some(ProposalStatus::Approved)); + } + + #[test] + fn define_proposal_decision_status_returns_rejected() { + let mut proposal = Proposal::::default(); + let now = 2; + + proposal.created = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 60; + + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Abstain); + proposal.voting_results.add_vote(VoteKind::Approve); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 1, + rejections: 2, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(4, now); + assert_eq!(expected_proposal_status, Some(ProposalStatus::Rejected)); + } + + #[test] + fn define_proposal_decision_status_returns_none() { + let mut proposal = Proposal::::default(); + let now = 2; + + proposal.created = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 60; + + proposal.voting_results.add_vote(VoteKind::Abstain); + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 0, + rejections: 0, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(5, now); + assert_eq!(expected_proposal_status, None); + } +}