diff --git a/signal/Cargo.toml b/signal/Cargo.toml new file mode 100644 index 000000000..2b259724a --- /dev/null +++ b/signal/Cargo.toml @@ -0,0 +1,68 @@ +# ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +# ███░▄▄▄█░▄▄▀█░▄▀▄░█░▄▄█░▄▀█░▄▄▀█▀▄▄▀██ +# ███░█▄▀█░▀▀░█░█▄█░█░▄▄█░█░█░▀▀░█░██░██ +# ███▄▄▄▄█▄██▄█▄███▄█▄▄▄█▄▄██▄██▄██▄▄███ +# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + +[package] +name = 'pallet-signal' +version = '0.1.0' +authors = ['zero.io','gamedao.co'] +repository = 'https://github.com/gamedaoco/gamedao-protocol' +edition = '2018' +license = 'GPL-3.0-or-later' +description = 'Signal pallet' + +[package.metadata.substrate] +categories = [ + 'zero', + 'core', + 'pallet' +] + +[dependencies] +serde = { version = "1.0.136", optional = true } +codec = { package = "parity-scale-codec", version = "2.3.1", default-features = false } +scale-info = { version = "1.0", default-features = false, features = ["derive"] } +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13", default-features = false } +sp-std = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13", default-features = false } +frame-support = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13", default-features = false } +frame-system = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13", default-features = false } +frame-benchmarking = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13", default-features = false, optional = true } +orml-traits = { path = "../../orml/traits", default-features = false } +zero-primitives = { package = "zero-primitives", path = "../../primitives", default-features = false } +support = { package = "gamedao-protocol-support", path = "../support", default-features = false } + +[dev-dependencies] +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +sp-core = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } +sp-io = { git = "https://github.com/paritytech/substrate.git", branch = "polkadot-v0.9.13" } +frame-support-test = { git = "https://github.com/paritytech/substrate.git", branch = "polkadot-v0.9.13" } +pallet-balances = { default-features = false, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.13" } + +orml-tokens = { path = "../../orml/tokens", default-features = false } +orml-currencies = { path = "../../orml/currencies", default-features = false } + +[features] +default = ['std'] +runtime-benchmarks = ['frame-benchmarking'] +std = [ + 'codec/std', + 'serde/std', + 'scale-info/std', + + 'frame-support/std', + 'frame-system/std', + 'frame-benchmarking/std', + + 'sp-core/std', + 'sp-std/std', + 'sp-runtime/std', + + "orml-traits/std", + "orml-tokens/std", + "orml-currencies/std", + + "support/std" +] +try-runtime = ['frame-support/try-runtime'] diff --git a/signal/src/lib.rs b/signal/src/lib.rs new file mode 100644 index 000000000..177881c99 --- /dev/null +++ b/signal/src/lib.rs @@ -0,0 +1,844 @@ +// +// _______________________________ ________ +// \____ /\_ _____/\______ \\_____ \ +// / / | __)_ | _/ / | \ +// / /_ | \ | | \/ | \ +// /_______ \/_______ / |____|_ /\_______ / +// \/ \/ \/ \/ +// Z E R O . I O N E T W O R K +// © C O P Y R I O T 2 0 7 5 @ Z E R O . I O + +// This file is part of ZERO Network. +// Copyright (C) 2010-2020 ZERO Labs. +// SPDX-License-Identifier: Apache-2.0 + +// Proposals and voting space for organizations and campaigns. +// This pallet provides next features: +// * Allow members of organisations to generate proposals under campaign. +// Each proposal has a lifitime, expiration, details and number of votes. +// Specific type of proposal is withdrawal one. +// It allows (if approved) to release locked campaign balance for further usage. +// * Vote on those proposals. +// * Manage proposal lifetime, close and finalize those proposals once expired. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +pub mod voting_enums; +pub mod voting_structs; + +#[cfg(test)] +pub mod mock; +#[cfg(test)] +mod tests; +// #[cfg(feature = "runtime-benchmarks")] // todo +// mod benchmarking; + + +#[frame_support::pallet] +pub mod pallet { + use frame_system::{ + ensure_signed, + pallet_prelude::{OriginFor, BlockNumberFor}, + WeightInfo + }; + use frame_support::{ + dispatch::DispatchResult, + traits::{Randomness}, + pallet_prelude::*, + transactional + }; + use sp_std::vec::Vec; + use orml_traits::{MultiCurrency, MultiReservableCurrency}; + + use zero_primitives::{Balance, CurrencyId}; + use support::{ + ControlPalletStorage, ControlState, ControlMemberState, + FlowPalletStorage, FlowState + }; + + use super::*; + use voting_enums::{ProposalState, ProposalType, VotingType}; + use voting_structs::{Proposal, ProposalMetadata}; + + + #[pallet::config] + pub trait Config: frame_system::Config { + type Event: From> + IsType<::Event> + Into<::Event>; + type Currency: MultiCurrency + + MultiReservableCurrency; + type Randomness: Randomness; + type Control: ControlPalletStorage; + type Flow: FlowPalletStorage; + type ForceOrigin: EnsureOrigin; + type WeightInfo: WeightInfo; + + #[pallet::constant] + type MaxProposalsPerBlock: Get; // 3 + + #[pallet::constant] + type MaxProposalDuration: Get; // 864000, 60 * 60 * 24 * 30 / 3 + + #[pallet::constant] + type FundingCurrencyId: Get; + } + + + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + pub struct Pallet(_); + + + /// Global status + #[pallet::storage] + pub(super) type Proposals = StorageMap<_, Blake2_128Concat, T::Hash, Proposal, ValueQuery>; + + #[pallet::storage] + pub(super) type Metadata = StorageMap<_, Blake2_128Concat, T::Hash, ProposalMetadata, ValueQuery>; + + #[pallet::storage] + pub(super) type Owners = StorageMap<_, Blake2_128Concat, T::Hash, T::AccountId, OptionQuery>; + + #[pallet::storage] + pub(super) type ProposalStates = StorageMap<_, Blake2_128Concat, T::Hash, ProposalState, ValueQuery, GetDefault>; + + /// Maximum time limit for a proposal + #[pallet::type_value] + pub(super) fn ProposalTimeLimitDefault() -> T::BlockNumber { T::BlockNumber::from(T::MaxProposalDuration::get()) } + #[pallet::storage] + pub(super) type ProposalTimeLimit = StorageValue <_, T::BlockNumber, ValueQuery, ProposalTimeLimitDefault>; + + /// All proposals + #[pallet::storage] + pub(super) type ProposalsArray = StorageMap<_, Blake2_128Concat, u64, T::Hash, ValueQuery>; + + #[pallet::storage] + pub(super) type ProposalsCount = StorageValue<_, u64, ValueQuery>; + + #[pallet::storage] + pub(super) type ProposalsIndex = StorageMap<_, Blake2_128Concat, T::Hash, u64, ValueQuery>; + + /// Proposals by campaign / org + #[pallet::storage] + pub(super) type ProposalsByContextArray = StorageMap<_, Blake2_128Concat, (T::Hash, u64), T::Hash, ValueQuery>; + + #[pallet::storage] + pub(super) type ProposalsByContextCount = StorageMap<_, Blake2_128Concat, T::Hash, u64, ValueQuery>; + + #[pallet::storage] + pub(super) type ProposalsByContextIndex = StorageMap<_, Blake2_128Concat, (T::Hash, T::Hash), u64, ValueQuery>; + + /// all proposals for a given context + #[pallet::storage] + pub(super) type ProposalsByContext = StorageMap<_, Blake2_128Concat, T::Hash, Vec, ValueQuery>; + + /// Proposals by owner + #[pallet::storage] + pub(super) type ProposalsByOwnerArray = StorageMap<_, Blake2_128Concat, (T::AccountId, u64), T::Hash, ValueQuery>; + + #[pallet::storage] + pub(super) type ProposalsByOwnerCount = StorageMap<_, Blake2_128Concat, T::AccountId, u64, ValueQuery>; + + #[pallet::storage] + pub(super) type ProposalsByOwnerIndex = StorageMap<_, Blake2_128Concat, (T::AccountId, T::Hash), u64, ValueQuery>; + + /// Proposals where voter participated + #[pallet::storage] + pub(super) type ProposalsByVoter = StorageMap<_, Blake2_128Concat, T::AccountId, Vec<(T::Hash, bool)>, ValueQuery>; + + /// Proposal voters and votes by proposal + #[pallet::storage] + pub(super) type ProposalVotesByVoters = StorageMap<_, Blake2_128Concat, T::Hash, Vec<(T::AccountId, bool)>, ValueQuery>; + + /// Total proposals voted on by voter + #[pallet::storage] + pub(super) type ProposalsByVoterCount = StorageMap<_, Blake2_128Concat, T::AccountId, u64, ValueQuery>; + + /// Proposals ending in a block + #[pallet::storage] + pub(super) type ProposalsByBlock = StorageMap<_, Blake2_128Concat, T::BlockNumber, Vec, ValueQuery>; + + /// The amount of currency that a project has used + #[pallet::storage] + pub(super) type CampaignBalanceUsed = StorageMap<_, Blake2_128Concat, T::Hash, Balance, ValueQuery>; + + /// The number of people who approve a proposal + #[pallet::storage] + pub(super) type ProposalApprovers = StorageMap<_, Blake2_128Concat, T::Hash, u64, ValueQuery, GetDefault>; + + /// The number of people who deny a proposal + #[pallet::storage] + pub(super) type ProposalDeniers = StorageMap<_, Blake2_128Concat, T::Hash, u64, ValueQuery, GetDefault>; + + /// Voters per proposal + #[pallet::storage] + pub(super) type ProposalVoters = StorageMap<_, Blake2_128Concat, T::Hash, Vec, ValueQuery>; + + /// Voter count per proposal + #[pallet::storage] + pub(super) type ProposalVotes = StorageMap<_, Blake2_128Concat, T::Hash, u64, ValueQuery, GetDefault>; + + /// Ack vs Nack + #[pallet::storage] + pub(super) type ProposalSimpleVotes = StorageMap<_, Blake2_128Concat, T::Hash, (u64, u64), ValueQuery, GetDefault>; + + /// User has voted on a proposal + #[pallet::storage] + pub(super) type VotedBefore = StorageMap<_, Blake2_128Concat, (T::AccountId, T::Hash), bool, ValueQuery, GetDefault>; + + // TODO: ProposalTotalEligibleVoters + // TODO: ProposalApproversWeight + // TODO: ProposalDeniersWeight + // TODO: ProposalTotalEligibleWeight + + /// The total number of proposals + #[pallet::storage] + pub(super) type Nonce = StorageValue<_, u128, ValueQuery>; + + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + Proposal { + sender_id: T::AccountId, + proposal_id: T::Hash + }, + ProposalCreated { + sender_id: T::AccountId, + context_id: T::Hash, + proposal_id: T::Hash, + amount: Balance, + expiry: T::BlockNumber, + }, + ProposalVoted { + sender_id: T::AccountId, + proposal_id: T::Hash, + vote: bool + }, + // ProposalFinalized(T::Hash, u8), + ProposalApproved { + proposal_id: T::Hash + }, + ProposalRejected { + proposal_id: T::Hash + }, + ProposalExpired { + proposal_id: T::Hash + }, + // ProposalAborted(T::Hash), + // ProposalError(T::Hash, Vec), + WithdrawalGranted { + proposal_id: T::Hash, + context_id: T::Hash, + body_id: T::Hash + }, + } + + #[pallet::error] + pub enum Error { + /// Proposal Ended + ProposalEnded, + /// Proposal Exists + ProposalExists, + /// Proposal Expired + ProposalExpired, + /// Already Voted + AlreadyVoted, + /// Proposal Unknown + ProposalUnknown, + /// DAO Inactive + DAOInactive, + /// Authorization Error + AuthorizationError, + /// Tangram Creation Failed + TangramCreationError, + /// Out Of Bounds Error + OutOfBounds, + /// Unknown Error + UnknownError, + ///MemberExists + MemberExists, + /// Unknown Campaign + CampaignUnknown, + /// Campaign Failed + CampaignFailed, + /// Balance Too Low + BalanceInsufficient, + /// Hash Collision + HashCollision, + /// Unknown Account + UnknownAccount, + /// Too Many Proposals for block + TooManyProposals, + /// Overflow Error + OverflowError, + /// Division Error + DivisionError + } + + #[pallet::call] + impl Pallet { + + + // TODO: general proposal for a DAO + #[pallet::weight(5_000_000)] + #[transactional] + pub fn general_proposal( + origin: OriginFor, + context_id: T::Hash, + title: Vec, + cid: Vec, + start: T::BlockNumber, + expiry: T::BlockNumber + ) -> DispatchResult { + + let sender = ensure_signed(origin)?; + + // active/existing dao? + ensure!(T::Control::body_state(&context_id) == ControlState::Active, Error::::DAOInactive); + + // member of body? + let member = T::Control::body_member_state(&context_id, &sender); + ensure!(member == ControlMemberState::Active, Error::::AuthorizationError); + + // ensure that start and expiry are in bounds + let current_block = >::block_number(); + // ensure!(start > current_block, Error::::OutOfBounds ); + ensure!(expiry > current_block, Error::::OutOfBounds ); + ensure!(expiry <= current_block + >::get(), Error::::OutOfBounds ); + + // ensure that number of proposals + // ending in target block + // do not exceed the maximum + let proposals = >::get(expiry); + // ensure!(proposals.len() as u32 < T::MaxProposalsPerBlock::get(), "Maximum number of proposals is reached for the target block, try another block"); + ensure!((proposals.len() as u32) < T::MaxProposalsPerBlock::get(), Error::::TooManyProposals); // todo: was error generated manually on purpose? + + let proposal_type = ProposalType::General; + let proposal_state = ProposalState::Active; + let voting_type = VotingType::Simple; + // ensure!(!>::contains_key(&context_id), "Proposal id already exists"); + ensure!(!>::contains_key(&context_id), Error::::ProposalExists); // todo: was error generated manually on purpose? + + + // check add + let proposals_count = >::get(); + let updated_proposals_count = proposals_count.checked_add(1).ok_or( Error::::OverflowError)?; + let proposals_by_campaign_count = >::get(&context_id); + let updated_proposals_by_campaign_count = proposals_by_campaign_count.checked_add(1).ok_or( Error::::OverflowError )?; + let proposals_by_owner_count = >::get(&sender); + let updated_proposals_by_owner_count = proposals_by_owner_count.checked_add(1).ok_or( Error::::OverflowError )?; + + // proposal + + let nonce = Self::get_and_increment_nonce(); + let (proposal_id, _) = ::random(&nonce); + let new_proposal = Proposal { + proposal_id: proposal_id.clone(), + context_id: context_id.clone(), + proposal_type, + voting_type, + start, + expiry, + }; + + // metadata + + let metadata = ProposalMetadata { + title: title, + cid: cid, + amount: 0 + }; + + // + // + // + + // insert proposals + >::insert(proposal_id.clone(), new_proposal.clone()); + >::insert(proposal_id.clone(), metadata.clone()); + >::insert(proposal_id.clone(), sender.clone()); + >::insert(proposal_id.clone(), proposal_state); + // update max per block + >::mutate(expiry, |proposals| proposals.push(proposal_id.clone())); + // update proposal map + >::insert(&proposals_count, proposal_id.clone()); + >::put(updated_proposals_count); + >::insert(proposal_id.clone(), proposals_count); + // update campaign map + >::insert((context_id.clone(), proposals_by_campaign_count.clone()), proposal_id.clone()); + >::insert(context_id.clone(), updated_proposals_by_campaign_count); + >::insert((context_id.clone(), proposal_id.clone()), proposals_by_campaign_count); + >::mutate( context_id.clone(), |proposals| proposals.push(proposal_id.clone()) ); + // update owner map + >::insert((sender.clone(), proposals_by_owner_count.clone()), proposal_id.clone()); + >::insert(sender.clone(), updated_proposals_by_owner_count); + >::insert((sender.clone(), proposal_id.clone()), proposals_by_owner_count); + // init votes + >::insert(context_id, (0,0)); + + // deposit event + Self::deposit_event( + Event::::Proposal{sender_id: sender, proposal_id} + ); + Ok(()) + } + + + // TODO: membership proposal for a DAO + + #[pallet::weight(5_000_000)] + pub fn membership_proposal( + origin: OriginFor, + context: T::Hash, + _member: T::Hash, + _action: u8, + _start: T::BlockNumber, + _expiry: T::BlockNumber + ) -> DispatchResult { + let sender = ensure_signed(origin)?; + // ensure active + // ensure member + // match action + // action + // deposit event + Self::deposit_event(Event::::Proposal{sender_id: sender, proposal_id: context}); + Ok(()) + } + + + // create a withdrawal proposal + // origin must be controller of the campaign == controller of the dao + // beneficiary must be the treasury of the dao + + #[pallet::weight(5_000_000)] + pub fn withdraw_proposal( + origin: OriginFor, + context_id: T::Hash, + title: Vec, + cid: Vec, + amount: Balance, + start: T::BlockNumber, + expiry: T::BlockNumber, + ) -> DispatchResult { + + let sender = ensure_signed(origin)?; + + // A C C E S S + + // ensure!( T::Flow::campaign_by_id(&context_id), Error::::CampaignUnknown ); + let state = T::Flow::campaign_state(&context_id); + ensure!( state == FlowState::Success, Error::::CampaignFailed ); + // todo: should this checks be performed? + // let owner = T::Flow::campaign_owner(&context_id); + // ensure!( sender == owner, Error::::AuthorizationError ); + + // B O U N D S + + // todo: should this checks be performed or not? + // let current_block = >::block_number(); + // ensure!(start > current_block, Error::::OutOfBounds ); + // ensure!(expiry > start, Error::::OutOfBounds ); + // ensure!(expiry <= current_block + Self::proposal_time_limit(), Error::::OutOfBounds ); + + // B A L A N C E + + let used_balance = >::get(&context_id); + let total_balance = T::Flow::campaign_balance(&context_id); + let remaining_balance = total_balance.checked_sub(used_balance).ok_or(Error::::BalanceInsufficient)? ; + ensure!(remaining_balance >= amount, Error::::BalanceInsufficient ); + + // T R A F F I C + + let proposals = >::get(expiry); + ensure!((proposals.len() as u32) < T::MaxProposalsPerBlock::get(), Error::::TooManyProposals); + ensure!(!>::contains_key(&context_id), Error::::ProposalExists); + + // C O U N T S + + let proposals_count = >::get(); + let updated_proposals_count = proposals_count.checked_add(1).ok_or(Error::::OverflowError)?; + let proposals_by_campaign_count = >::get(&context_id); + let updated_proposals_by_campaign_count = proposals_by_campaign_count.checked_add(1).ok_or(Error::::OverflowError)?; + let proposals_by_owner_count = >::get(&sender); + let updated_proposals_by_owner_count = proposals_by_owner_count.checked_add(1).ok_or(Error::::OverflowError)?; + + // C O N F I G + + let proposal_type = ProposalType::Withdrawal; // treasury + let voting_type = VotingType::Simple; // votes + let nonce = Self::get_and_increment_nonce(); + + let (proposal_id, _) = ::Randomness::random(&nonce); + + let proposal = Proposal { + proposal_id: proposal_id.clone(), + context_id: context_id.clone(), + proposal_type, + voting_type, + start, + expiry + }; + + let metadata = ProposalMetadata { + title, + cid, + amount, + }; + + // W R I T E + + Proposals::::insert(&proposal_id, proposal.clone()); + >::insert(&proposal_id, metadata.clone()); + >::insert(&proposal_id, sender.clone()); + >::insert(proposal_id.clone(), ProposalState::Active); + + >::mutate(expiry, |proposals| proposals.push(proposal_id.clone())); + >::insert(&proposals_count, proposal_id.clone()); + >::put(updated_proposals_count); + >::insert(proposal_id.clone(), proposals_count); + >::insert((context_id.clone(), proposals_by_campaign_count.clone()), proposal_id.clone()); + >::insert(context_id.clone(), updated_proposals_by_campaign_count); + >::insert((context_id.clone(), proposal_id.clone()), proposals_by_campaign_count); + >::insert((sender.clone(), proposals_by_owner_count.clone()), proposal_id.clone()); + >::insert(sender.clone(), updated_proposals_by_owner_count); + >::insert((sender.clone(), proposal_id.clone()), proposals_by_owner_count); + >::mutate( context_id.clone(), |proposals| proposals.push(proposal_id.clone()) ); + + // E V E N T + + Self::deposit_event( + Event::::ProposalCreated { + sender_id: sender, + context_id, + proposal_id, + amount, + expiry + } + ); + Ok(()) + + } + + // TODO: + // voting vs staking, e.g. + // 1. token weighted and democratic voting require yes/no + // 2. conviction voting requires ongoing staking + // 3. quadratic voting + + #[pallet::weight(5_000_000)] + pub fn simple_vote( + origin: OriginFor, + proposal_id: T::Hash, + vote: bool + ) -> DispatchResult { + + let sender = ensure_signed(origin)?; + + // Ensure the proposal exists + ensure!(>::contains_key(&proposal_id), Error::::ProposalUnknown); + + // Ensure the proposal has not ended + let proposal_state = >::get(&proposal_id); + ensure!(proposal_state == ProposalState::Active, Error::::ProposalEnded); + + // Ensure the contributor did not vote before + ensure!(!>::get((sender.clone(), proposal_id.clone())), Error::::AlreadyVoted); + + // Get the proposal + let proposal = >::get(&proposal_id); + // Ensure the proposal is not expired + ensure!(>::block_number() < proposal.expiry, Error::::ProposalExpired); + + // TODO: + // ensure origin is one of: + // a. member when the proposal is general + // b. contributor when the proposal is a withdrawal request + // let sender_balance = >::campaign_contribution(proposal.campaign_id, sender.clone()); + // ensure!( sender_balance > T::Balance::from(0), "You are not a contributor of this Campaign"); + + match &proposal.proposal_type { + // DAO Democratic Proposal + // simply one member one vote yes / no, + // TODO: ratio definable, now > 50% majority wins + ProposalType::General => { + + let (mut yes, mut no) = >::get(&proposal_id); + + match vote { + true => { + yes = yes.checked_add(1).ok_or(Error::::OverflowError)?; + let proposal_approvers = >::get(&proposal_id); + let updated_proposal_approvers = proposal_approvers.checked_add(1).ok_or(Error::::OverflowError)?; + >::insert( + proposal_id.clone(), + updated_proposal_approvers.clone() + ); + }, + false => { + no = no.checked_add(1).ok_or(Error::::OverflowError)?; + let proposal_deniers = >::get(&proposal_id); + let updated_proposal_deniers = proposal_deniers.checked_add(1).ok_or(Error::::OverflowError)?; + >::insert( + proposal_id.clone(), + updated_proposal_deniers.clone() + ); + } + } + + >::insert( + proposal_id.clone(), + (yes,no) + ); + + }, + // 50% majority over total number of campaign contributors + ProposalType::Withdrawal => { + + let (mut yes, mut no) = >::get(&proposal_id); + + match vote { + true => { + yes = yes.checked_add(1).ok_or(Error::::OverflowError)?; + + let current_approvers = >::get(&proposal_id); + let updated_approvers = current_approvers.checked_add(1).ok_or(Error::::OverflowError)?; + >::insert(proposal_id.clone(), updated_approvers.clone()); + + // TODO: make this variable + let contributors = T::Flow::campaign_contributors_count(&proposal.context_id); + let threshold = contributors.checked_div(2).ok_or(Error::::DivisionError)?; + if updated_approvers > threshold { + // todo: should this be called on finalize? + Self::unlock_balance(proposal_id, updated_approvers)?; + } + // remove + let proposal_approvers = >::get(&proposal_id); + let updated_proposal_approvers = proposal_approvers.checked_add(1).ok_or(Error::::OverflowError)?; + >::insert( + proposal_id.clone(), + updated_proposal_approvers.clone() + ); + + }, + false => { + no = no.checked_add(1).ok_or(Error::::OverflowError)?; + // remove + let proposal_deniers = >::get(&proposal_id); + let updated_proposal_deniers = proposal_deniers.checked_add(1).ok_or(Error::::OverflowError)?; + >::insert( + proposal_id.clone(), + updated_proposal_deniers.clone() + ); + } + } + + ProposalSimpleVotes::::insert( + proposal_id.clone(), + (yes,no) + ); + + + }, + + // Campaign Token Weighted Proposal + // total token balance yes vs no + // TODO: ratio definable, now > 50% majority wins + // ProposalType:: => { + // }, + + // Membership Voting + // simply one token one vote yes / no, + // TODO: ratio definable, now simple majority wins + ProposalType::Member => { + // approve + // deny + // kick + // ban + }, + // default + _ => { + }, + } + + VotedBefore::::insert( ( &sender, proposal_id.clone() ), true ); + ProposalsByVoterCount::::mutate( &sender, |v| *v +=1 ); + ProposalVotesByVoters::::mutate(&proposal_id, |votings| votings.push(( sender.clone(), vote.clone() )) ); + ProposalsByVoter::::mutate( &sender, |votings| votings.push((proposal_id.clone(), vote))); + + let mut voters = ProposalVoters::::get(&proposal_id); + match voters.binary_search(&sender) { + Ok(_) => {}, // should never happen + Err(index) => { + voters.insert(index, sender.clone()); + ProposalVoters::::insert( &proposal_id, voters ); + } + } + + // dispatch vote event + Self::deposit_event( + Event::::ProposalVoted { + sender_id: sender, + proposal_id:proposal_id.clone(), + vote + } + ); + Ok(()) + + } + + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_finalize(_n: T::BlockNumber) { + + // i'm still jenny from the block + let block_number = _n.clone(); + let proposal_hashes = >::get(block_number); + + for proposal_id in &proposal_hashes { + + let mut proposal_state = >::get(&proposal_id); + if proposal_state != ProposalState::Active { continue }; + + let proposal = >::get(&proposal_id); + + // TODO: + // a. result( accepted, rejected ) + // b. result( accepted, rejected, total_allowed ) + // c. result( required_majority, staked_accept, staked_reject, slash_amount ) + // d. threshold reached + // e. conviction + + match &proposal.proposal_type { + ProposalType::General => { + // simple vote + let (yes,no) = >::get(&proposal_id); + if yes > no { proposal_state = ProposalState::Accepted; } + if yes < no { proposal_state = ProposalState::Rejected; } + if yes == 0 && no == 0 { proposal_state = ProposalState::Expired; } + // todo: if same amount of yes/no votes? + }, + ProposalType::Withdrawal => { + // treasury + // 50% majority of eligible voters + let (yes,_no) = >::get(&proposal_id); + let context = proposal.context_id.clone(); + let contributors = T::Flow::campaign_contributors_count(&context); + // TODO: dynamic threshold + let threshold = contributors.checked_div(2).ok_or(Error::::DivisionError); + match threshold { + Ok(t) => { + if yes > t { + proposal_state = ProposalState::Accepted; + Self::unlock_balance(proposal.proposal_id, yes); + } else { + proposal_state = ProposalState::Rejected; + } + }, + Err(_err) => { + // todo: logic on error event + } + } + }, + ProposalType::Member => { + // membership + // + }, + _ => { + // no result - fail + proposal_state = ProposalState::Expired; + } + } + + >::insert(&proposal_id, proposal_state.clone()); + + match proposal_state { + ProposalState::Accepted => { + Self::deposit_event( + Event::::ProposalApproved {proposal_id: proposal_id.clone()} + ); + }, + ProposalState::Rejected => { + Self::deposit_event( + Event::::ProposalRejected {proposal_id: proposal_id.clone()} + ); + }, + ProposalState::Expired => { + Self::deposit_event( + Event::::ProposalExpired {proposal_id: proposal_id.clone()} + ); + }, + _ => {} + } + + } + + } + } + + impl Pallet { + + // TODO: DISCUSSION + // withdrawal proposals are accepted + // when the number of approvals is higher + // than the number of rejections + // accepted / denied >= 1 + fn unlock_balance( + proposal_id: T::Hash, + _supported_count: u64 + ) -> DispatchResult { + + // Get proposal and metadata + let proposal = >::get(proposal_id.clone()); + let metadata = >::get(proposal_id.clone()); + + // Ensure sufficient balance + let proposal_balance = metadata.amount; + let total_balance = T::Flow::campaign_balance(&proposal.context_id); + + // let used_balance = Self::balance_used(proposal.context_id); + let used_balance = >::get(proposal.context_id); + let available_balance = total_balance - used_balance.clone(); + ensure!(available_balance >= proposal_balance, Error::::BalanceInsufficient); + + // Get the owner of the campaign + let _owner = >::get(&proposal_id).ok_or("No owner for proposal")?; + + // get treasury account for related body and unlock balance + let body = T::Flow::campaign_org(&proposal.context_id); + let treasury_account = T::Control::body_treasury(&body); + T::Currency::unreserve( + T::FundingCurrencyId::get(), + &treasury_account, + proposal_balance.clone() + ); + + // Change the used amount + let new_used_balance = used_balance + proposal_balance; + >::insert(proposal.context_id, new_used_balance); + + // proposal completed + let proposal_state = ProposalState::Finalized; + >::insert(proposal_id.clone(), proposal_state); + + >::insert(proposal_id.clone(), proposal.clone()); + + Self::deposit_event( + Event::::WithdrawalGranted {proposal_id, context_id: proposal.context_id, body_id: body} + ); + Ok(()) + + } + + fn get_and_increment_nonce() -> Vec { + let nonce = Nonce::::get(); + Nonce::::put(nonce.wrapping_add(1)); + nonce.encode() + } + } +} + +// todo: Check storage fields and remove generices from those, who don't use Config trait diff --git a/signal/src/mock.rs b/signal/src/mock.rs new file mode 100644 index 000000000..a3d599486 --- /dev/null +++ b/signal/src/mock.rs @@ -0,0 +1,203 @@ +#[cfg(test)] + +use crate as pallet_signal; +use frame_support::parameter_types; +use frame_support::traits::{GenesisBuild, Nothing}; +use frame_system; +use frame_support_test::TestRandomness; +use sp_std::cell::RefCell; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, +}; +use orml_traits::parameter_type_with_key; +use support::{ + ControlPalletStorage, ControlState, ControlMemberState, + FlowPalletStorage, FlowState +}; +use zero_primitives::{Amount, Balance, BlockNumber, CurrencyId, Hash, TokenSymbol}; + +pub type AccountId = u64; + +pub const ACC1: AccountId = 1; +pub const ACC2: AccountId = 2; +pub const TREASURY_ACC: AccountId = 3; + +pub struct ControlFixture { + pub body_controller: AccountId, + pub body_treasury: AccountId, + pub body_member_state: ControlMemberState, + pub body_state: ControlState +} + +pub struct FlowFixture { + pub campaign_balance: Balance, + pub campaign_state: FlowState, + pub campaign_contributors_count: u64, + pub campaign_org: Hash +} + +// todo: use actual Control & Flow pallets once they are done +thread_local!( + pub static control_fixture: RefCell = RefCell::new(ControlFixture { + body_controller: ACC1, + body_treasury: TREASURY_ACC, + body_member_state: ControlMemberState::Active, + body_state: ControlState::Active + }); + pub static flow_fixture: RefCell = RefCell::new(FlowFixture { + campaign_balance: 15, + campaign_state: FlowState::Success, + campaign_contributors_count: 0, + campaign_org: H256::random() + }); +); + + +pub struct ControlMock; +impl ControlPalletStorage for ControlMock { + fn body_controller(_org: &Hash) -> AccountId { control_fixture.with(|v| v.borrow().body_controller.clone()) } + fn body_treasury(_org: &Hash) -> AccountId { control_fixture.with(|v| v.borrow().body_treasury.clone()) } + fn body_member_state(_hash: &Hash, _account_id: &AccountId) -> ControlMemberState { control_fixture.with(|v| v.borrow().body_member_state.clone()) } + fn body_state(_hash: &Hash) -> ControlState { control_fixture.with(|v| v.borrow().body_state.clone()) } +} + +pub struct FlowMock; +impl FlowPalletStorage for FlowMock { + fn campaign_balance(_hash: &Hash) -> Balance { flow_fixture.with(|v| v.borrow().campaign_balance.clone()) } + fn campaign_state(_hash: &Hash) -> FlowState { flow_fixture.with(|v| v.borrow().campaign_state.clone()) } + fn campaign_contributors_count(_hash: &Hash) -> u64 { flow_fixture.with(|v| v.borrow().campaign_contributors_count.clone()) } + fn campaign_org(_hash: &Hash) -> Hash { flow_fixture.with(|v| v.borrow().campaign_org.clone()) } +} + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Signal: pallet_signal, + Currencies: orml_currencies::{Pallet, Call, Event}, + Tokens: orml_tokens::{Pallet, Storage, Event, Config}, + PalletBalances: pallet_balances::{Pallet, Call, Storage, Event}, + } +); + +parameter_type_with_key! { + pub ExistentialDeposits: |_currency_id: CurrencyId| -> Balance { + Default::default() + }; +} + +impl orml_tokens::Config for Test { + type Event = Event; + type Balance = Balance; + type Amount = Amount; + type CurrencyId = CurrencyId; + type WeightInfo = (); + type ExistentialDeposits = ExistentialDeposits; + type OnDust = (); + type MaxLocks = (); + type DustRemovalWhitelist = Nothing; +} + +impl pallet_balances::Config for Test { + type Balance = Balance; + type DustRemoval = (); + type Event = Event; + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = frame_system::Pallet; + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} +pub type AdaptedBasicCurrency = orml_currencies::BasicCurrencyAdapter; + +impl orml_currencies::Config for Test { + type Event = Event; + type MultiCurrency = Tokens; + type NativeCurrency = AdaptedBasicCurrency; + type GetNativeCurrencyId = (); + type WeightInfo = (); +} + + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(1024); + + pub const ExistentialDeposit: Balance = 1; + + pub const MaxProposalsPerBlock: u32 = 2; + pub const MaxProposalDuration: u32 = 20; + pub const FundingCurrencyId: CurrencyId = TokenSymbol::GAME as u32; +} + +// impl pallet_randomness_collective_flip::Config for Test {} + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = u64; + type Hash = Hash; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type Event = Event; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); +} + +impl pallet_signal::Config for Test { + type Event = Event; + type ForceOrigin = frame_system::EnsureRoot; + type WeightInfo = (); + type Control = ControlMock; + type Flow = FlowMock; + type MaxProposalsPerBlock = MaxProposalsPerBlock; + type MaxProposalDuration = MaxProposalDuration; + type FundingCurrencyId = FundingCurrencyId; + type Randomness = TestRandomness; + type Currency = Currencies; +} + +#[derive(Default)] +pub struct ExtBuilder; +impl ExtBuilder { + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + let currency_id = TokenSymbol::GAME as u32; + orml_tokens::GenesisConfig:: { + balances: vec![ + (ACC1, currency_id, 100), + (ACC2, currency_id, 100), + (TREASURY_ACC, currency_id, 25) + ], + }.assimilate_storage(&mut t).unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext + } +} diff --git a/signal/src/tests.rs b/signal/src/tests.rs new file mode 100644 index 000000000..a573d5cd6 --- /dev/null +++ b/signal/src/tests.rs @@ -0,0 +1,757 @@ +#[cfg(test)] + +use super::{ + *, + voting_structs::{Proposal, ProposalMetadata}, + voting_enums::{VotingType, ProposalType, ProposalState}, + mock::{ + Test, ExtBuilder, + AccountId, ACC1, ACC2, TREASURY_ACC, + System, Origin, Event, Signal, + control_fixture, flow_fixture + } +}; +use support::{ + ControlState, ControlMemberState, + FlowState +}; +use sp_runtime::traits::BadOrigin; +use sp_core::H256; +use frame_support::{assert_ok, assert_noop, traits::{Randomness, Hooks}}; +use orml_tokens::Event as TokensEvent; +use orml_traits::{MultiReservableCurrency}; + + + +#[test] +fn signal_general_proposal_success() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(3); + let nonce = vec![0]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + assert_ok!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 3, // start + 15 // expiry + ) + ); + let event = System::events().pop() + .expect("No event generated").event; + assert_eq!( + event, + Event::Signal( + crate::Event::Proposal { + sender_id: ACC1, + proposal_id: proposal_id + } + ) + ); + assert_eq!( + >::get(&proposal_id), + Proposal { + proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::General, + voting_type: VotingType::Simple, + start: 3, + expiry: 15 + } + ); + assert_eq!( + >::get(&proposal_id), + ProposalMetadata { + title: vec![1,2,3], + cid: vec![1,2,3], + amount: 0 + } + ); + assert_eq!(>::get(&proposal_id), Some(ACC1)); + assert_eq!(>::get(&proposal_id), ProposalState::Active); + assert_eq!(>::get(15), vec![proposal_id.clone()]); + assert_eq!(>::get(0), proposal_id); + assert_eq!(>::get(), 1); + assert_eq!(>::get(&proposal_id), 0); + assert_eq!(>::get((ctx_id.clone(), 0)), proposal_id); + assert_eq!(>::get(ctx_id), 1); + assert_eq!(>::get((ctx_id, proposal_id)), 0); + assert_eq!(>::get((ACC1, 0)), proposal_id); + assert_eq!(>::get(ACC1), 1); + assert_eq!(>::get((ACC1, proposal_id)), 0); + assert_eq!(>::get(ctx_id), vec![proposal_id.clone()]); + assert_eq!(>::get(), 1); + + + let nonce = vec![1]; + let (new_proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + assert_ok!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![2,3,4], // title + vec![2,3,4], // cid + 3, // start + 15 // expiry + ) + ); + assert_eq!(>::get(15), vec![proposal_id.clone(), new_proposal_id.clone()]); + assert_eq!(>::get(1), new_proposal_id); + assert_eq!(>::get(), 2); + assert_eq!(>::get(&new_proposal_id), 1); + assert_eq!(>::get((ctx_id.clone(), 1)), new_proposal_id); + assert_eq!(>::get(ctx_id), 2); + assert_eq!(>::get((ctx_id, new_proposal_id)), 1); + assert_eq!(>::get((ACC1, 1)), new_proposal_id); + assert_eq!(>::get(ACC1), 2); + assert_eq!(>::get((ACC1, new_proposal_id)), 1); + assert_eq!(>::get(ctx_id), vec![proposal_id.clone(), new_proposal_id.clone()]); + assert_eq!(>::get(), 2); + }); +} + +#[test] +fn signal_general_proposal_error() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(3); + let nonce = vec![0]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + + >::insert(ctx_id, Proposal { + proposal_id: proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::General, + voting_type: VotingType::Simple, + start: 2, + expiry: 13 + }); + assert_noop!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 3, // start + 15 // expiry + ), + Error::::ProposalExists + ); + + + let proposal_ids = vec![H256::random(), H256::random()]; + >::insert(15, proposal_ids); + assert_noop!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 3, // start + 15 // expiry + ), + Error::::TooManyProposals + ); + + assert_noop!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 3, // start + System::block_number() + >::get() + 1 + ), + Error::::OutOfBounds + ); + assert_noop!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + System::block_number(), // start + System::block_number() // expiry + ), + Error::::OutOfBounds + ); + + control_fixture.with(|val|val.borrow_mut().body_member_state = ControlMemberState::Inactive); + assert_noop!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 3, // start + 15 // expiry + ), + Error::::AuthorizationError + ); + + control_fixture.with(|val|val.borrow_mut().body_state = ControlState::Inactive); + assert_noop!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, + vec![1,2,3], + vec![1,2,3], + 3, + 15 + ), + Error::::DAOInactive + ); + + assert_noop!( + Signal::general_proposal( + Origin::none(), + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 3, // start + 15 // expiry + ), + BadOrigin + ); + + + }); +} + + +#[test] +fn signal_withdraw_proposal_success() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(3); + + let nonce = vec![0]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + >::insert(ctx_id, 5); + + assert_ok!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ) + ); + let event = System::events().pop() + .expect("No event generated").event; + assert_eq!( + event, + Event::Signal( + crate::Event::ProposalCreated { + sender_id: ACC1, + context_id: ctx_id, + proposal_id, + amount: 10, + expiry: 15 + } + ) + ); + assert_eq!( + >::get(&proposal_id), + Proposal { + proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::Withdrawal, + voting_type: VotingType::Simple, + start: 3, + expiry: 15 + } + ); + assert_eq!( + >::get(&proposal_id), + ProposalMetadata { + title: vec![1,2,3], + cid: vec![1,2,3], + amount: 10 + } + ); + assert_eq!(>::get(&proposal_id), Some(ACC1)); + assert_eq!(>::get(&proposal_id), ProposalState::Active); + assert_eq!(>::get(15), vec![proposal_id.clone()]); + assert_eq!(>::get(0), proposal_id); + assert_eq!(>::get(), 1); + assert_eq!(>::get(&proposal_id), 0); + assert_eq!(>::get((ctx_id.clone(), 0)), proposal_id); + assert_eq!(>::get(ctx_id), 1); + assert_eq!(>::get((ctx_id, proposal_id)), 0); + assert_eq!(>::get((ACC1, 0)), proposal_id); + assert_eq!(>::get(ACC1), 1); + assert_eq!(>::get((ACC1, proposal_id)), 0); + assert_eq!(>::get(ctx_id), vec![proposal_id.clone()]); + assert_eq!(>::get(), 1); + + let nonce = vec![1]; + let (new_proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + assert_ok!( + Signal::general_proposal( + Origin::signed(ACC1), + ctx_id, // context id + vec![2,3,4], // title + vec![2,3,4], // cid + 3, // start + 15 // expiry + ) + ); + assert_eq!( + >::get(15), + vec![proposal_id.clone(), new_proposal_id.clone()] + ); + assert_eq!(>::get(1), new_proposal_id); + assert_eq!(>::get(), 2); + assert_eq!(>::get(&new_proposal_id), 1); + assert_eq!(>::get((ctx_id.clone(), 1)), new_proposal_id); + assert_eq!(>::get(ctx_id), 2); + assert_eq!(>::get((ctx_id, new_proposal_id)), 1); + assert_eq!(>::get((ACC1, 1)), new_proposal_id); + assert_eq!(>::get(ACC1), 2); + assert_eq!(>::get((ACC1, new_proposal_id)), 1); + assert_eq!(>::get(ctx_id), vec![proposal_id.clone(), new_proposal_id.clone()]); + assert_eq!(>::get(), 2); + + }); +} + +#[test] +fn signal_withdraw_proposal_error() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(3); + + let nonce = vec![0]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + >::insert(ctx_id, 5); + + >::insert(ctx_id, Proposal { + proposal_id: proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::Withdrawal, + voting_type: VotingType::Simple, + start: 2, + expiry: 13 + }); + assert_noop!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ), + Error::::ProposalExists + ); + + let proposal_ids = vec![H256::random(), H256::random()]; + >::insert(15, proposal_ids); + assert_noop!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ), + Error::::TooManyProposals + ); + + >::insert(ctx_id, 5); + flow_fixture.with(|v| v.borrow_mut().campaign_balance = 10 ); + assert_noop!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ), + Error::::BalanceInsufficient + ); + + >::insert(ctx_id, 11); + assert_noop!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ), + Error::::BalanceInsufficient + ); + + >::insert(ctx_id, 5); + flow_fixture.with(|v| v.borrow_mut().campaign_state = FlowState::Failed ); + >::insert(ctx_id, 11); + assert_noop!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ), + Error::::CampaignFailed + ); + + assert_noop!( + Signal::withdraw_proposal( + Origin::none(), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 3, // start + 15 // expiry + ), + BadOrigin + ); + }); +} + +#[test] +fn signal_simple_vote_success() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(3); + + let nonce = vec![0]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + >::insert(proposal_id, Proposal { + proposal_id: proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::General, + voting_type: VotingType::Simple, + start: 2, + expiry: 13 + }); + >::insert(proposal_id, ProposalState::Active); + + assert_ok!( + Signal::simple_vote(Origin::signed(ACC2), proposal_id, true) + ); + let event = System::events().pop() + .expect("No event generated").event; + assert_eq!( + event, + Event::Signal( + crate::Event::ProposalVoted { + sender_id: ACC2, + proposal_id, + vote: true + } + ) + ); + assert_eq!(>::get(&proposal_id), (1, 0)); + assert_eq!(>::get(&proposal_id), 1); + assert_eq!(>::get((ACC2, proposal_id)), true); + assert_eq!(>::get(ACC2), 1); + assert_eq!(>::get(proposal_id), vec![(ACC2, true)]); + assert_eq!(>::get(ACC2), vec![(proposal_id, true)]); + assert_eq!(>::get(proposal_id), vec![ACC2]); + + assert_ok!( + Signal::simple_vote(Origin::signed(ACC1), proposal_id, false) + ); + let event = System::events().pop() + .expect("No event generated").event; + assert_eq!( + event, + Event::Signal( + crate::Event::ProposalVoted { + sender_id: ACC1, + proposal_id, + vote: false + } + ) + ); + assert_eq!(>::get(&proposal_id), (1, 1)); + assert_eq!(>::get(&proposal_id), 1); + assert_eq!(>::get(&proposal_id), 1); + assert_eq!(>::get((ACC1, proposal_id)), true); + assert_eq!(>::get(ACC1), 1); + assert_eq!(>::get(proposal_id), vec![(ACC2, true), (ACC1, false)]); + assert_eq!(>::get(ACC1), vec![(proposal_id, false)]); + assert_eq!(>::get(proposal_id), vec![ACC1, ACC2]); + + + let nonce = vec![1]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + >::insert(proposal_id, Proposal { + proposal_id: proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::Withdrawal, + voting_type: VotingType::Simple, + start: 2, + expiry: 13 + }); + >::insert(proposal_id, ProposalState::Active); + flow_fixture.with(|v| v.borrow_mut().campaign_contributors_count = 1); + + assert_ok!( + Signal::simple_vote(Origin::signed(ACC1), proposal_id, false) + ); + let event = System::events().pop() + .expect("No event generated").event; + assert_eq!( + event, + Event::Signal( + crate::Event::ProposalVoted { + sender_id: ACC1, + proposal_id, + vote: false + } + ) + ); + assert_eq!(>::get(&proposal_id), (0, 1)); + assert_eq!(>::get(&proposal_id), 0); + assert_eq!(>::get(&proposal_id), 1); + + // todo: delete this after `unlock_balance` will be moved out from extrinsic call + // assert_ok!( + // Signal::simple_vote(Origin::signed(ACC2), proposal_id, true) + // ); + // let event = System::events().pop() + // .expect("No event generated").event; + // assert_eq!( + // event, + // Event::Signal( + // crate::Event::ProposalVoted { + // sender_id: ACC2, + // proposal_id, + // vote: true + // } + // ) + // ); + // assert_eq!(>::get(&proposal_id), (1, 0)); + // assert_eq!(>::get(&proposal_id), 1); + + }); +} + + +#[test] +fn signal_simple_vote_error() { + ExtBuilder::default().build().execute_with(|| { + System::set_block_number(3); + + let nonce = vec![0]; + let (proposal_id, _): (H256, _) = ::Randomness::random(&nonce); + let ctx_id = H256::random(); + >::insert(proposal_id, Proposal { + proposal_id: proposal_id, + context_id: ctx_id, + proposal_type: ProposalType::General, + voting_type: VotingType::Simple, + start: 2, + expiry: System::block_number() + }); + >::insert(proposal_id, ProposalState::Active); + assert_noop!( + Signal::simple_vote( + Origin::signed(ACC1), + proposal_id, + true + ), + Error::::ProposalExpired + ); + + >::insert((ACC1, proposal_id), true); + assert_noop!( + Signal::simple_vote( + Origin::signed(ACC1), + proposal_id, + true + ), + Error::::AlreadyVoted + ); + + >::insert(proposal_id, ProposalState::Expired); + assert_noop!( + Signal::simple_vote( + Origin::signed(ACC1), + proposal_id, + true + ), + Error::::ProposalEnded + ); + + assert_noop!( + Signal::simple_vote( + Origin::signed(ACC1), + H256::random(), + true + ), + Error::::ProposalUnknown + ); + + assert_noop!( + Signal::simple_vote( + Origin::none(), + proposal_id, + true + ), + BadOrigin + ); + }); +} + +#[test] +fn signal_on_finalize_success() { + ExtBuilder::default().build().execute_with(|| { + let (start, expiry) = (3, 15); + System::set_block_number(start); + let (proposal_id1, _): (H256, _) = ::Randomness::random(&vec![0]); + let (proposal_id2, _): (H256, _) = ::Randomness::random(&vec![1]); + let (proposal_id3, _): (H256, _) = ::Randomness::random(&vec![2]); + + assert_ok!( + Signal::general_proposal( + Origin::signed(ACC1), + H256::random(), // context id + vec![1,2,3], // title + vec![1,2,3], // cid + start, // start + expiry // expiry + ) + ); + assert_ok!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + H256::random(), // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + start, // start + expiry // expiry + ) + ); + for i in 1..5 { + assert_ok!( + Signal::simple_vote( + Origin::signed(i), + proposal_id1, + i < 4 + ) + ); + } + for i in 1..5 { + assert_ok!( + Signal::simple_vote( + Origin::signed(i), + proposal_id2, + i == 5 + ) + ); + } + + let mut events_before = System::events().len(); + assert_eq!(events_before, 10); + Signal::on_finalize(start); + assert_eq!(System::events().len(), events_before); + + System::set_block_number(expiry); + Signal::on_finalize(expiry); + let mut events = System::events(); + assert_eq!(events.len(), events_before + 2); + + let withdrawal_event = events.pop().unwrap().event; + let general_event = events.pop().unwrap().event; + assert_eq!( + withdrawal_event, + Event::Signal(crate::Event::ProposalRejected {proposal_id: proposal_id2}) + ); + assert_eq!( + >::get(proposal_id2), + ProposalState::Rejected + ); + + assert_eq!( + general_event, + Event::Signal(crate::Event::ProposalApproved {proposal_id: proposal_id1}) + ); + assert_eq!( + >::get(proposal_id1), + ProposalState::Accepted + ); + + events_before = 12; + let ctx_id = H256::random(); + assert_ok!( + Signal::withdraw_proposal( + Origin::signed(ACC1), // origin + ctx_id, // context id + vec![1,2,3], // title + vec![1,2,3], // cid + 10, // amount + 15, // start + 16 // expiry + ) + ); + assert_eq!(System::events().len(), events_before + 1); + + let res = <::Currency as MultiReservableCurrency>:: + reserve(::FundingCurrencyId::get(), &TREASURY_ACC, 25); + match res { + Ok(_) => {}, + Err(_) => panic!("Failed to reserve treasury balance") + } + assert_ok!( + Signal::simple_vote( + Origin::signed(ACC1), + proposal_id3, + true + ) + ); + assert_eq!(System::events().len(), events_before + 5); + System::set_block_number(16); + Signal::on_finalize(16); + let mut events = System::events(); + assert_eq!(events.len(), events_before + 5); + assert_eq!( + events.pop().unwrap().event, + Event::Signal(crate::Event::ProposalVoted {sender_id: ACC1, proposal_id: proposal_id3, vote: true}) + ); + assert_eq!( + events.pop().unwrap().event, + Event::Signal(crate::Event::WithdrawalGranted { + proposal_id: proposal_id3, + context_id: ctx_id, + body_id: flow_fixture.with(|v| v.borrow().campaign_org) + }) + ); + assert_eq!( + events.pop().unwrap().event, + Event::Tokens( + TokensEvent::Unreserved( + ::FundingCurrencyId::get(), + TREASURY_ACC, + 10 + ) + ) + ); + assert_eq!(>::get(ctx_id), 10); + assert_eq!(>::get(proposal_id3), ProposalState::Finalized); + + }); +} diff --git a/signal/src/voting_enums.rs b/signal/src/voting_enums.rs new file mode 100644 index 000000000..cc69d6e03 --- /dev/null +++ b/signal/src/voting_enums.rs @@ -0,0 +1,48 @@ +use frame_support::pallet_prelude::{Encode, Decode}; +use scale_info::TypeInfo; + +// #[derive(Encode, Decode, Clone, PartialEq, Default, Eq, PartialOrd, Ord, TypeInfo)] +#[derive(Encode, Decode, PartialEq, Clone, TypeInfo)] +#[cfg_attr(feature = "std", derive(Debug))] +pub enum ProposalState { + Init = 0, // waiting for start block + Active = 1, // voting is active + Accepted = 2, // voters did approve + Rejected = 3, // voters did not approve + Expired = 4, // ended without votes + Aborted = 5, // sudo abort + Finalized = 6, // accepted withdrawal proposal is processed +} + +#[derive(Encode, Decode, PartialEq, Clone, TypeInfo)] +#[cfg_attr(feature = "std", derive(Debug))] +pub enum ProposalType { + General = 0, + Multiple = 1, + Member = 2, + Withdrawal = 3, + Spending = 4 +} + +#[derive(Encode, Decode, PartialEq, Clone, TypeInfo)] +#[cfg_attr(feature = "std", derive(Debug))] +pub enum VotingType { + Simple = 0, // votes across participating votes + Token = 1, // weight across participating votes + Absolute = 2, // votes vs all eligible voters + Quadratic = 3, + Ranked = 4, + Conviction = 5 +} + +impl Default for ProposalState { + fn default() -> Self { ProposalState::Init } +} + +impl Default for ProposalType { + fn default() -> Self { ProposalType::General } +} + +impl Default for VotingType { + fn default() -> Self { VotingType::Simple } +} \ No newline at end of file diff --git a/signal/src/voting_structs.rs b/signal/src/voting_structs.rs new file mode 100644 index 000000000..b0b108088 --- /dev/null +++ b/signal/src/voting_structs.rs @@ -0,0 +1,23 @@ +use frame_support::pallet_prelude::{Encode, Decode}; +use sp_std::vec::Vec; +use scale_info::TypeInfo; + + +#[derive(Encode, Decode, Default, Clone, PartialEq, TypeInfo)] +#[cfg_attr(feature = "std", derive(Debug))] +pub struct Proposal { + pub proposal_id: Hash, + pub context_id: Hash, + pub proposal_type: ProposalType, + pub voting_type: VotingType, + pub start: BlockNumber, + pub expiry: BlockNumber +} + +#[derive(Encode, Decode, Default, Clone, PartialEq, TypeInfo)] +#[cfg_attr(feature = "std", derive(Debug))] +pub struct ProposalMetadata { + pub title: Vec, + pub cid: Vec, + pub amount: Balance, +} \ No newline at end of file