diff --git a/pop-api/examples/dao/Cargo.toml b/pop-api/examples/dao/Cargo.toml new file mode 100644 index 000000000..8f65bcfff --- /dev/null +++ b/pop-api/examples/dao/Cargo.toml @@ -0,0 +1,38 @@ +[package] +authors = ["R0GUE "] +edition = "2021" +name = "dao" +version = "0.1.0" + +[dependencies] +ink = { version = "=5.1.0", default-features = false, features = ["ink-debug"] } +pop-api = { path = "../../../pop-api", default-features = false, features = [ + "fungibles", +] } +sp-io = { version = "23.0.0", default-features = false, features = ["disable_allocator", "disable_oom", "disable_panic_handler"] } +sp-runtime = { version = "24.0.0", default-features = false } + +[dev-dependencies] +drink = { package = "pop-drink", git = "https://github.com/r0gue-io/pop-drink" } +env_logger = { version = "0.11.3" } +serde_json = "1.0.114" + +# TODO: due to compilation issues caused by `sp-runtime`, `frame-support-procedural` and `staging-xcm` this dependency +# (with specific version) has to be added. Will be tackled by #348, please ignore for now. +frame-support-procedural = { version = "=30.0.1", default-features = false } +sp-runtime = { version = "=38.0.0", default-features = false } +staging-xcm = { version = "=14.1.0", default-features = false } + +[lib] +path = "src/lib.rs" + +[features] +default = ["std"] +e2e-tests = [] +ink-as-dependency = [] +std = [ + "ink/std", + "pop-api/std", + "sp-io/std", + "sp-runtime/std", +] diff --git a/pop-api/examples/dao/README.md b/pop-api/examples/dao/README.md new file mode 100644 index 000000000..79f3ae41e --- /dev/null +++ b/pop-api/examples/dao/README.md @@ -0,0 +1,56 @@ +# PSP22 DAO contract example_01 + +## Description +This contract implements a Decentralized Autonomous Organization using Psp22. +The objectivbe of this DAO is to allow its members to vote for funding of projects they would like to see developped. +To become a member, a user needs to pay some amount of the Dao_token to the Dao Treasury(Dao_tokens obtained through airdrops or token purchase...) ⇒ The subscription payment becomes the Member voting_power. +Approved projects are funded by the Dao Treasury. +The key functionalities include: +- **Membership Management**: It maintains a registry of DAO members. +- **Proposal Lifecycle**: The contract manages the creation, voting, and execution of proposals. Each proposal includes details like description, voting period, vote tallies, execution status, beneficiary, and amount to be awarded. +- **Voting Mechanism**: It implements a voting system where members can vote with their balance on proposals. The contract tracks voting periods and maintains vote counts for each proposal. +- **Token Integration**: The DAO is associated with a specific Psp22 token_id. +- **Governance Parameters**: governance parameters such as voting periods are customizable. +- **Vote Tracking**: The contract keeps track of when members last voted. +- **Proposal Execution**: Once a proposal's voting period ends and passes, the contract handles its execution: transferring funds to the chosen beneficiary. + + +## Dao Workflow +- **Subscription**: The first step is membership: users use their funds* to join the Dao, become members, and determine their voting power, as membership gives them the right to vote on the use of the Dao Treasury. +- **Proposal**: Dao members can create spending proposals for the treasury. At the moment, the voting period for the proposal is given by the proposal creator, but this could be a Dao parameter, determined by the creator of the Dao contract. +- **Vote**: Members of the Dao can vote for or against a given proposal, through the selection of the corresponding proposal ID. The vote has to be cast within the voting period of the selected proposal. +- **Proposal enactment**: After the end of the voting period, If the proposal has been accepted by the Dao Members, the proposal can be enacted, i.e. funds can be claimed/transferred to the account specified as the beneficiary in the proposal. Any member can claim the reward for the winning proposal. + +*It is assumed that after Dao & associated token creation, potential members own the Dao Token through airdrop, token purchase, etc... (These mechanisms are not part of the contract). + +### Flowchart + +```mermaid +flowchart LR + A[Subscriber A] -->|Joins DAO| M[Membership Process] + B[Subscriber B] -->|Joins DAO| M + C[Subscriber C] -->|Joins DAO| M + D[Subscriber D] -->|Joins DAO| M + M --> E[Subscriber B creates Proposal] + E --> F[Voting Process] + F -->|Votes by| G[Subscribers A, C, D] + G --> H{Proposal Accepted?} + H -- Yes --> I[Proposal Enactment] + H -- No --> J[End of Process] + I --> K[Subscriber A claims reward for proposal's beneficiary] + K --> L[Funds transferred to beneficiary] + + style A fill:#f9f,stroke:#333,stroke-width:2px + style B fill:#f9f,stroke:#333,stroke-width:2px + style C fill:#f9f,stroke:#333,stroke-width:2px + style D fill:#f9f,stroke:#333,stroke-width:2px + style M fill:#bbf,stroke:#333,stroke-width:2px + style E fill:#bbf,stroke:#333,stroke-width:2px + style F fill:#bbf,stroke:#333,stroke-width:2px + style G fill:#bbf,stroke:#333,stroke-width:2px + style H fill:#ff0,stroke:#333,stroke-width:2px + style I fill:#bfb,stroke:#333,stroke-width:2px + style J fill:#f99,stroke:#333,stroke-width:2px + style K fill:#bfb,stroke:#333,stroke-width:2px + style L fill:#bfb,stroke:#333,stroke-width:2px +``` diff --git a/pop-api/examples/dao/src/lib.rs b/pop-api/examples/dao/src/lib.rs new file mode 100644 index 000000000..88d2d7844 --- /dev/null +++ b/pop-api/examples/dao/src/lib.rs @@ -0,0 +1,460 @@ +#![cfg_attr(not(feature = "std"), no_std, no_main)] + +use ink::{env::Error as EnvError, prelude::vec::Vec, storage::Mapping}; +use pop_api::{ + primitives::TokenId, + v0::fungibles::{ + self as api, + events::{Approval, Created, Transfer}, + Psp22Error, + }, +}; + +#[cfg(test)] +mod tests; + +#[ink::contract] +mod dao { + use super::*; + + #[derive(Debug, Clone, PartialEq)] + #[ink::scale_derive(Encode, Decode, TypeInfo)] + #[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))] + pub enum ProposalStatus { + Submitted, + Approved, + Rejected, + Executed, + } + + #[ink::scale_derive(Encode)] + pub enum RuntimeCall { + /// We can add additional pallets we might want to use here + #[codec(index = 150)] + Fungibles(FungiblesCall), + } + + #[ink::scale_derive(Encode)] + pub enum FungiblesCall { + #[codec(index = 4)] + TransferFrom { token: TokenId, from: AccountId, to: AccountId, value: Balance }, + } + + /// Structure of the proposal used by the Dao governance sysytem + #[derive(Debug, Clone)] + #[ink::scale_derive(Encode, Decode, TypeInfo)] + #[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))] + pub struct Proposal { + // Description of the proposal + pub description: Vec, + + // Flag that indicates if the proposal was Executed + pub status: ProposalStatus, + + // Identifier of the proposal + pub proposal_id: u32, + + // Information relative to voting + pub round: Option, + + // Information relative to proposal execution if approved + pub transaction: Option, + } + + impl Default for Proposal { + fn default() -> Self { + let fetch_dao = ink::env::get_contract_storage::(&0u32) + .expect("The dao should have been created already"); + + // The dao is supposed to exist at this point + let dao = fetch_dao.unwrap_or_default(); + let voting_period = dao.voting_period; + let current_block = ink::env::block_number::(); + let end = current_block.saturating_add(voting_period); + let round = + Some(VoteRound { start: current_block, end, yes_votes: 0, no_votes: 0 }); + Proposal { + description: Vec::new(), + status: ProposalStatus::Submitted, + proposal_id: 0, + round, + transaction: None, + } + } + } + + /// Representation of a member in the voting system + #[derive(Debug, Clone)] + #[ink::scale_derive(Encode, Decode, TypeInfo)] + #[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))] + pub struct Member { + // Stores the member's voting influence by using his balance + pub voting_power: Balance, + + // Keeps track of the last vote casted by the member + pub last_vote: BlockNumber, + } + + #[derive(Debug, Clone)] + #[ink::scale_derive(Encode, Decode, TypeInfo)] + #[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))] + pub struct VoteRound { + // Beginnning of the voting period for this proposal + pub start: BlockNumber, + + // End of the voting period for this proposal + pub end: BlockNumber, + + // Balance representing the total votes for this proposal + pub yes_votes: Balance, + + // Balance representing the total votes against this proposal + pub no_votes: Balance, + } + + impl VoteRound { + fn get_status(&self, mut proposal: Proposal) -> Proposal { + if proposal.status == ProposalStatus::Submitted { + if self.yes_votes > self.no_votes { + proposal.status = ProposalStatus::Approved; + } else { + proposal.status = ProposalStatus::Rejected; + } + }; + proposal + } + + fn update_votes(&mut self, approved: bool, member: Member) { + match approved { + true => { + self.yes_votes = + self.yes_votes.saturating_add(member.voting_power); + }, + false => { + self.no_votes = self.no_votes.saturating_add(member.voting_power); + }, + }; + } + } + + #[derive(Debug, Clone)] + #[ink::scale_derive(Encode, Decode, TypeInfo)] + #[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))] + pub struct Transaction { + // The recipient of the proposal + pub beneficiary: AccountId, + // Amount of tokens to be awarded to the beneficiary + pub amount: Balance, + } + + /// Structure of a DAO (Decentralized Autonomous Organization) + /// that uses Psp22 to manage the Dao treasury and funds projects + /// selected by the members through governance + #[derive(Default)] + #[ink(storage)] + pub struct Dao { + // Funding proposals + proposals: Mapping, + + // Mapping of AccountId to Member structs, representing DAO membership. + members: Mapping, + + // Mapping tracking the last time each account voted. + last_votes: Mapping, + + // Duration of the voting period + voting_period: BlockNumber, + + // Identifier of the Psp22 token associated with this DAO + token_id: TokenId, + + // Proposals created in the history of the Dao + proposal_count: u32, + } + + /// Defines an event that is emitted + /// every time a member voted. + #[derive(Debug)] + #[ink(event)] + pub struct Vote { + pub who: Option, + pub when: Option, + } + + impl Dao { + /// Instantiate a new Dao contract and create the associated token + /// + /// # Parameters: + /// - `token_id` - The identifier of the token to be created + /// - `voting_period` - Amount of blocks during which members can cast their votes + /// - `min_balance` - The minimum balance required for accounts holding this token. + // The `min_balance` ensures accounts hold a minimum amount of tokens, preventing tiny, + // inactive balances from bloating the blockchain state and slowing down the network. + #[ink(constructor, payable)] + pub fn new( + token_id: TokenId, + voting_period: BlockNumber, + min_balance: Balance, + ) -> Result { + let instance = Self { + proposals: Mapping::default(), + members: Mapping::default(), + last_votes: Mapping::default(), + voting_period, + token_id, + proposal_count: 0, + }; + let contract_id = instance.env().account_id(); + api::create(token_id, contract_id, min_balance).map_err(Psp22Error::from)?; + instance.env().emit_event(Created { + id: token_id, + creator: contract_id, + admin: contract_id, + }); + + Ok(instance) + } + + /// Allows members to create new spending proposals + /// + /// # Parameters + /// - `beneficiary` - The account that will receive the payment + /// if the proposal is accepted. + /// - `amount` - Amount requested for this proposal + /// - `description` - Description of the proposal + #[ink(message)] + pub fn create_proposal( + &mut self, + beneficiary: AccountId, + amount: Balance, + mut description: Vec, + ) -> Result<(), Error> { + let caller = self.env().caller(); + let contract = self.env().account_id(); + + + if description.len() >= u8::MAX.into() { + return Err(Error::MaxDescriptionLengthReached); + } + + self.proposal_count = self.proposal_count.saturating_add(1); + let mut proposal = + Proposal { proposal_id: self.proposal_count, ..Default::default() }; + proposal.description.append(&mut description); + let transaction = Transaction { beneficiary, amount }; + proposal.transaction = Some(transaction); + + self.proposals.insert(proposal.proposal_id, &proposal); + + self.env().emit_event(Created { + id: proposal.proposal_id, + creator: caller, + admin: contract, + }); + + Ok(()) + } + + /// Allows Dao's members to vote for a proposal + /// + /// # Parameters + /// - `proposal_id` - Identifier of the proposal + /// - `approve` - Indicates whether the vote is in favor (true) or against (false) the + /// proposal. + #[ink(message)] + pub fn vote(&mut self, proposal_id: u32, approve: bool) -> Result<(), Error> { + let caller = self.env().caller(); + let current_block = self.env().block_number(); + let mut proposal = self.proposals.get(proposal_id).ok_or(Error::ProposalNotFound)?; + let mut round = proposal.round.clone().ok_or(Error::ProblemWithTheContract)?; + + if current_block > round.end { + // Update the Proposal status if needed + proposal = round.get_status(proposal); + self.proposals.insert(proposal.proposal_id, &proposal); + return Err(Error::VotingPeriodEnded); + } + + let member = self.members.get(caller).ok_or(Error::MemberNotFound)?; + + if member.last_vote >= round.start { + return Err(Error::AlreadyVoted); + } + + round.update_votes(approve, member.clone()); + proposal.round = Some(round); + + self.proposals.insert(proposal_id, &proposal); + + self.members.insert( + caller, + &Member { voting_power: member.voting_power, last_vote: current_block }, + ); + self.last_votes.insert(caller, ¤t_block); + + self.env().emit_event(Vote { who: Some(caller), when: Some(current_block) }); + + Ok(()) + } + + /// Enact a proposal Approved by the Dao members + /// + /// # Parameters + /// - `proposal_id` - Identifier of the proposal + #[ink(message)] + pub fn execute_proposal(&mut self, proposal_id: u32) -> Result<(), Error> { + let mut proposal = self.proposals.get(proposal_id).ok_or(Error::ProposalNotFound)?; + let round = proposal.round.clone().ok_or(Error::ProblemWithTheContract)?; + + let transaction = + proposal.transaction.clone().ok_or(Error::ProblemWithTheContract)?; + + // Check the voting period + if self.env().block_number() <= round.end { + return Err(Error::VotingPeriodNotEnded); + } + + if proposal.status == ProposalStatus::Executed { + return Err(Error::ProposalExecuted); + } + + if round.yes_votes > round.no_votes { + let contract = self.env().account_id(); + + // Execute the proposal + let _treasury_balance = match api::balance_of(self.token_id, contract) { + Ok(val) if val > transaction.amount => val, + _ => { + return Err(Error::NotEnoughFundsAvailable); + }, + }; + + // RuntimeCall. + let _ = self.env() + .call_runtime(&RuntimeCall::Fungibles(FungiblesCall::TransferFrom { + token: self.token_id, + from: contract, + to: transaction.beneficiary, + value: transaction.amount, + })) + .map_err(EnvError::from); + + self.env().emit_event(Transfer { + from: Some(contract), + to: Some(transaction.beneficiary), + value: transaction.amount, + }); + self.env().emit_event(Approval { + owner: contract, + spender: contract, + value: transaction.amount, + }); + + proposal.status = ProposalStatus::Executed; + + self.proposals.insert(proposal_id, &proposal); + Ok(()) + } else { + Err(Error::ProposalRejected) + } + } + + /// Allows a user to become a member of the Dao + /// by transferring some tokens to the DAO's treasury. + /// The amount of tokens transferred will be stored as the + /// voting power of this member. + /// + /// # Parameters + /// - `amount` - Balance transferred to the Dao and representing + /// the voting power of the member. + + #[ink(message)] + pub fn join(&mut self, amount: Balance) -> Result<(), Error> { + let caller = self.env().caller(); + let contract = self.env().account_id(); + api::transfer_from(self.token_id, caller, contract, amount) + .map_err(Psp22Error::from)?; + let member = + self.members.get(caller).unwrap_or(Member { voting_power: 0, last_vote: 0 }); + + let voting_power = member.voting_power.saturating_add(amount); + self.members + .insert(caller, &Member { voting_power, last_vote: member.last_vote }); + + self.env().emit_event(Transfer { + from: Some(caller), + to: Some(contract), + value: amount, + }); + + Ok(()) + } + + #[ink(message)] + pub fn get_member(&mut self, account: AccountId) -> Member { + self.members.get(account).unwrap_or(Member { voting_power: 0, last_vote: 0 }) + } + + #[ink(message)] + pub fn get_proposal(&mut self, proposal_id: u32) -> Option { + self.proposals.get(proposal_id) + } + } + + #[derive(Debug, PartialEq, Eq)] + #[ink::scale_derive(Encode, Decode, TypeInfo)] + pub enum Error { + /// This proposal does not exists + ProposalNotFound, + + /// The end of the voting period has been reached + VotingPeriodEnded, + + /// User is not a member of this Dao + MemberNotFound, + + /// User already voted for this proposal + AlreadyVoted, + + /// The voting period for this proposal is still ongoing + VotingPeriodNotEnded, + + /// This proposal has already been Executed + ProposalExecuted, + + /// This proposal has been Rejected + ProposalRejected, + + /// The proposal description is too long + MaxDescriptionLengthReached, + + /// There are not enough funds in the Dao treasury + NotEnoughFundsAvailable, + + /// The contract creation failed, a new contract is needed + ProblemWithTheContract, + + /// The Runtime Call failed + ProposalExecutionFailed, + + /// PSP22 specific error + Psp22(Psp22Error), + } + + impl From for Error { + fn from(error: Psp22Error) -> Self { + Error::Psp22(error) + } + } + + impl From for Error { + fn from(e: EnvError) -> Self { + use ink::env::ReturnErrorCode; + match e { + EnvError::ReturnError(ReturnErrorCode::CallRuntimeFailed) => + Error::ProposalExecutionFailed, + _ => panic!("Unexpected error from `pallet-contracts`."), + } + } + } +} diff --git a/pop-api/examples/dao/src/tests.rs b/pop-api/examples/dao/src/tests.rs new file mode 100644 index 000000000..babbc5d79 --- /dev/null +++ b/pop-api/examples/dao/src/tests.rs @@ -0,0 +1,473 @@ +use drink::{ + assert_last_contract_event, assert_ok, call, + devnet::{account_id_from_slice, AccountId, Balance, Runtime}, + sandbox_api::system_api::SystemAPI, + session::Session, + AssetsAPI, TestExternalities, NO_SALT, +}; +use pop_api::{ + primitives::TokenId, + v0::fungibles::events::{Approval, Created, Transfer}, +}; + +use super::*; +use crate::dao::{Error, Member, Proposal, Vote}; + +const UNIT: Balance = 10_000_000_000; +const INIT_AMOUNT: Balance = 100_000_000 * UNIT; +const INIT_VALUE: Balance = 100 * UNIT; +const ALICE: AccountId = AccountId::new([1u8; 32]); +const BOB: AccountId = AccountId::new([2_u8; 32]); +const CHARLIE: AccountId = AccountId::new([3_u8; 32]); +const NON_MEMBER: AccountId = AccountId::new([4_u8; 32]); +const AMOUNT: Balance = MIN_BALANCE * 4; +const MIN_BALANCE: Balance = 10_000; +const TOKEN: TokenId = 1; +const VOTING_PERIOD: u32 = 10; + +#[drink::contract_bundle_provider] +enum BundleProvider {} + +/// Sandbox environment for Pop Devnet Runtime. +pub struct Pop { + ext: TestExternalities, +} + +impl Default for Pop { + fn default() -> Self { + // Initialising genesis state, providing accounts with an initial balance. + let balances: Vec<(AccountId, u128)> = + vec![(ALICE, INIT_AMOUNT), (BOB, INIT_AMOUNT), (CHARLIE, INIT_AMOUNT)]; + let ext = BlockBuilder::::new_ext(balances); + Self { ext } + } +} + +// Implement core functionalities for the `Pop` sandbox. +drink::impl_sandbox!(Pop, Runtime, ALICE); + +// Deployment and constructor method tests. + +fn deploy_with_default(session: &mut Session) -> Result { + deploy( + session, + "new", + vec![TOKEN.to_string(), VOTING_PERIOD.to_string(), MIN_BALANCE.to_string()], + ) +} + +#[drink::test(sandbox = Pop)] +fn new_constructor_works(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Token exists after the deployment. + assert!(session.sandbox().asset_exists(&TOKEN)); + // Successfully emit event. + assert_last_contract_event!( + &session, + Created { + id: TOKEN, + creator: account_id_from_slice(&contract), + admin: account_id_from_slice(&contract), + } + ); +} + +#[drink::test(sandbox = Pop)] +fn join_dao_works(mut session: Session) { + let _ = env_logger::try_init(); + let value = AMOUNT / 2; + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + session.set_actor(ALICE); + // Mint tokens and approve. + assert_ok!(session.sandbox().mint_into(&TOKEN, &ALICE, AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &ALICE, &contract.clone(), AMOUNT)); + assert_eq!(session.sandbox().allowance(&TOKEN, &ALICE, &contract.clone()), AMOUNT); + assert_eq!(session.sandbox().balance_of(&TOKEN, &ALICE), AMOUNT); + + // Alice joins the dao + assert_ok!(join(&mut session, value)); + + // Successfully emit event. + assert_last_contract_event!( + &session, + Transfer { + from: Some(account_id_from_slice(&ALICE)), + to: Some(account_id_from_slice(&contract)), + value, + } + ); + + // We check that Alice is a member with a voting power of 20000 + if let Ok(member) = members(&mut session, ALICE) { + assert_eq!(member.voting_power, 20000); + } +} + +#[drink::test(sandbox = Pop)] +fn member_create_proposal_works(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = AMOUNT * 3; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + assert_last_contract_event!( + &session, + Created { + id: 1, + creator: account_id_from_slice(&ALICE), + admin: account_id_from_slice(&contract), + } + ); + + // Another proposal created by Bob + let description_bis = + "Funds for creation of another Dao contract".to_string().as_bytes().to_vec(); + session.set_actor(BOB); + assert_ok!(create_proposal(&mut session, ALICE, amount, description_bis)); + assert_last_contract_event!( + &session, + Created { + id: 2, + creator: account_id_from_slice(&BOB), + admin: account_id_from_slice(&contract), + } + ); +} + +#[drink::test(sandbox = Pop)] +fn members_vote_system_works(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = AMOUNT * 3; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + session.set_actor(CHARLIE); + // Charlie vote + let now = session.sandbox().block_number(); + assert_ok!(vote(&mut session, 1, true)); + + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&CHARLIE)), when: Some(now) } + ); + let prop = proposal(&mut session, 1).unwrap(); + let infos = prop.round.unwrap(); + assert_eq!(infos.yes_votes > 0, true); + assert_eq!(infos.no_votes == 0, true); +} + +#[drink::test(sandbox = Pop)] +fn double_vote_fails(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = AMOUNT * 3; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + session.set_actor(CHARLIE); + // Charlie tries to vote twice for the same proposal + assert_ok!(vote(&mut session, 1, true)); + assert_eq!(vote(&mut session, 1, false), Err(Error::AlreadyVoted)); +} + +#[drink::test(sandbox = Pop)] +fn vote_fails_if_voting_period_ended(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = AMOUNT * 3; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + // Moving to blocks beyond voting period + session.sandbox().build_blocks(VOTING_PERIOD + 1); + + session.set_actor(CHARLIE); + // Charlie tries to vote + assert_eq!(vote(&mut session, 1, true), Err(Error::VotingPeriodEnded)); +} + +#[drink::test(sandbox = Pop)] +fn vote_fails_if_not_a_member(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = AMOUNT * 3; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + session.set_actor(NON_MEMBER); + assert_eq!(vote(&mut session, 1, true), Err(Error::MemberNotFound)); +} + +#[drink::test(sandbox = Pop)] +fn proposal_enactment_works(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Mint tokens. + assert_ok!(session.sandbox().mint_into(&TOKEN, &contract.clone(), AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &contract.clone(), &contract.clone(), AMOUNT)); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = MIN_BALANCE; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + let now = session.sandbox().block_number(); + session.set_actor(CHARLIE); + // Charlie vote + assert_ok!(vote(&mut session, 1, true)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&CHARLIE)), when: Some(now) } + ); + // Alice vote + session.set_actor(ALICE); + assert_ok!(vote(&mut session, 1, true)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&ALICE)), when: Some(now) } + ); + // BOB vote + session.set_actor(BOB); + assert_ok!(vote(&mut session, 1, true)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&BOB)), when: Some(now) } + ); + + session.sandbox().build_blocks(VOTING_PERIOD + 1); + + assert_ok!(execute_proposal(&mut session, 1)); + // Successfully emit event. + assert_last_contract_event!( + &session, + Approval { + owner: account_id_from_slice(&contract), + spender: account_id_from_slice(&contract), + value: MIN_BALANCE, + } + ); +} + +#[drink::test(sandbox = Pop)] +fn same_proposal_consecutive_claim_fails(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Mint tokens. + assert_ok!(session.sandbox().mint_into(&TOKEN, &contract.clone(), AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &contract.clone(), &contract.clone(), AMOUNT)); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = MIN_BALANCE; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + let now = session.sandbox().block_number(); + session.set_actor(CHARLIE); + // Charlie vote + assert_ok!(vote(&mut session, 1, true)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&CHARLIE)), when: Some(now) } + ); + // Alice vote + session.set_actor(ALICE); + assert_ok!(vote(&mut session, 1, true)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&ALICE)), when: Some(now) } + ); + // BOB vote + session.set_actor(BOB); + assert_ok!(vote(&mut session, 1, true)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&BOB)), when: Some(now) } + ); + + session.sandbox().build_blocks(VOTING_PERIOD + 1); + + assert_ok!(execute_proposal(&mut session, 1)); + // Successfully emit event. + assert_last_contract_event!( + &session, + Approval { + owner: account_id_from_slice(&contract), + spender: account_id_from_slice(&contract), + value: MIN_BALANCE, + } + ); + session.sandbox().build_block(); + // Second consecutive claim for same proposal fails + assert_eq!(execute_proposal(&mut session, 1), Err(Error::ProposalExecuted)); +} + +#[drink::test(sandbox = Pop)] +fn proposal_enactment_fails_if_proposal_is_rejected(mut session: Session) { + let _ = env_logger::try_init(); + // Deploy a new contract. + let contract = deploy_with_default(&mut session).unwrap(); + // Mint tokens. + assert_ok!(session.sandbox().mint_into(&TOKEN, &contract.clone(), AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &contract.clone(), &contract.clone(), AMOUNT)); + // Prepare voters accounts + let _ = prepare_dao(&mut session, contract.clone()); + + // Alice create a proposal + let description = "Funds for creation of a Dao contract".to_string().as_bytes().to_vec(); + let amount = MIN_BALANCE; + session.set_actor(ALICE); + assert_ok!(create_proposal(&mut session, BOB, amount, description)); + + let now = session.sandbox().block_number(); + session.set_actor(CHARLIE); + // Charlie vote + assert_ok!(vote(&mut session, 1, false)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&CHARLIE)), when: Some(now) } + ); + // Alice vote + session.set_actor(ALICE); + assert_ok!(vote(&mut session, 1, false)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&ALICE)), when: Some(now) } + ); + // BOB vote + session.set_actor(BOB); + assert_ok!(vote(&mut session, 1, false)); + assert_last_contract_event!( + &session, + Vote { who: Some(account_id_from_slice(&BOB)), when: Some(now) } + ); + + session.sandbox().build_blocks(VOTING_PERIOD + 1); + + assert_eq!(execute_proposal(&mut session, 1), Err(Error::ProposalRejected)); +} + +// Deploy the contract with `NO_SALT and `INIT_VALUE`. +fn deploy( + session: &mut Session, + method: &str, + input: Vec, +) -> Result { + drink::deploy::( + session, + // The local contract (i.e. `fungibles`). + BundleProvider::local().unwrap(), + method, + input, + NO_SALT, + Some(INIT_VALUE), + ) +} + +fn join(session: &mut Session, value: Balance) -> Result<(), Error> { + call::(session, "join", vec![value.to_string()], None) +} + +fn members(session: &mut Session, account: AccountId) -> Result { + call::(session, "get_member", vec![account.to_string()], None) +} + +fn create_proposal( + session: &mut Session, + beneficiary: AccountId, + amount: Balance, + description: Vec, +) -> Result<(), Error> { + let desc: &[u8] = &description; + call::( + session, + "create_proposal", + vec![ + beneficiary.to_string(), + amount.to_string(), + serde_json::to_string::<[u8]>(desc).unwrap(), + ], + None, + ) +} + +fn vote(session: &mut Session, proposal_id: u32, approve: bool) -> Result<(), Error> { + call::( + session, + "vote", + vec![proposal_id.to_string(), approve.to_string()], + None, + ) +} + +fn execute_proposal(session: &mut Session, proposal_id: u32) -> Result<(), Error> { + call::(session, "execute_proposal", vec![proposal_id.to_string()], None) +} + +fn proposal(session: &mut Session, proposal_id: u32) -> Option { + call::, Error>( + session, + "get_proposal", + vec![proposal_id.to_string()], + None, + ) + .unwrap() +} + +fn prepare_dao(session: &mut Session, contract: AccountId) -> Result<(), Error> { + assert_ok!(session.sandbox().mint_into(&TOKEN, &ALICE, AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &ALICE, &contract.clone(), AMOUNT)); + assert_ok!(session.sandbox().mint_into(&TOKEN, &BOB, AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &BOB, &contract.clone(), AMOUNT)); + assert_ok!(session.sandbox().mint_into(&TOKEN, &CHARLIE, AMOUNT)); + assert_ok!(session.sandbox().approve(&TOKEN, &CHARLIE, &contract.clone(), AMOUNT)); + session.set_actor(ALICE); + assert_ok!(join(session, AMOUNT / 2)); + session.set_actor(BOB); + assert_ok!(join(session, AMOUNT / 4)); + session.set_actor(CHARLIE); + assert_ok!(join(session, AMOUNT / 3)); + Ok(()) +} diff --git a/runtime/devnet/src/lib.rs b/runtime/devnet/src/lib.rs index f539cbdee..d8784de55 100644 --- a/runtime/devnet/src/lib.rs +++ b/runtime/devnet/src/lib.rs @@ -41,6 +41,7 @@ use frame_system::{ }; use pallet_api::fungibles; use pallet_balances::Call as BalancesCall; +use pallet_fungibles::Call as FungiblesCall; use pallet_ismp::mmr::{Leaf, Proof, ProofKeys}; use pallet_xcm::{EnsureXcm, IsVoiceOfBody}; use parachains_common::message_queue::{NarrowOriginToSibling, ParaIdToSibling};