diff --git a/Cargo.lock b/Cargo.lock index b7cb3c849..67aa367b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -536,7 +536,9 @@ version = "0.13.4" dependencies = [ "cosmwasm-schema", "cosmwasm-std", + "cw-multi-test", "cw-utils", + "cw20-base", "schemars", "serde", ] @@ -580,8 +582,10 @@ dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-utils", + "cw20", "schemars", "serde", + "thiserror", ] [[package]] @@ -612,6 +616,8 @@ dependencies = [ "cw-storage-plus", "cw-utils", "cw2", + "cw20", + "cw20-base", "cw3", "cw3-fixed-multisig", "cw4", diff --git a/contracts/cw3-fixed-multisig/src/contract.rs b/contracts/cw3-fixed-multisig/src/contract.rs index 629544bda..f4dc7cae8 100644 --- a/contracts/cw3-fixed-multisig/src/contract.rs +++ b/contracts/cw3-fixed-multisig/src/contract.rs @@ -9,15 +9,15 @@ use cosmwasm_std::{ use cw2::set_contract_version; use cw3::{ - ProposalListResponse, ProposalResponse, Status, Vote, VoteInfo, VoteListResponse, VoteResponse, - VoterDetail, VoterListResponse, VoterResponse, + Ballot, Proposal, ProposalListResponse, ProposalResponse, Status, Vote, VoteInfo, + VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, Votes, }; use cw_storage_plus::Bound; use cw_utils::{Expiration, ThresholdResponse}; use crate::error::ContractError; use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; -use crate::state::{next_id, Ballot, Config, Proposal, Votes, BALLOTS, CONFIG, PROPOSALS, VOTERS}; +use crate::state::{next_id, Config, BALLOTS, CONFIG, PROPOSALS, VOTERS}; // version info for migration info const CONTRACT_NAME: &str = "crates.io:cw3-fixed-multisig"; @@ -112,6 +112,8 @@ pub fn execute_propose( votes: Votes::yes(vote_power), threshold: cfg.threshold, total_weight: cfg.total_weight, + proposer: info.sender.clone(), + deposit: None, }; prop.update_status(&env.block); let id = next_id(deps.storage)?; @@ -272,6 +274,8 @@ fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult msgs: prop.msgs, status, expires: prop.expires, + deposit: prop.deposit, + proposer: prop.proposer, threshold, }) } @@ -327,6 +331,8 @@ fn map_proposal( description: prop.description, msgs: prop.msgs, status, + deposit: prop.deposit, + proposer: prop.proposer, expires: prop.expires, threshold, } diff --git a/contracts/cw3-fixed-multisig/src/state.rs b/contracts/cw3-fixed-multisig/src/state.rs index 11d49daf1..8f43a2205 100644 --- a/contracts/cw3-fixed-multisig/src/state.rs +++ b/contracts/cw3-fixed-multisig/src/state.rs @@ -1,15 +1,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use cosmwasm_std::{Addr, BlockInfo, CosmosMsg, Decimal, Empty, StdResult, Storage, Uint128}; +use cosmwasm_std::{Addr, StdResult, Storage}; -use cw3::{Status, Vote}; +use cw3::{Ballot, Proposal}; use cw_storage_plus::{Item, Map}; -use cw_utils::{Duration, Expiration, Threshold}; - -// we multiply by this when calculating needed_votes in order to round up properly -// Note: `10u128.pow(9)` fails as "u128::pow` is not yet stable as a const fn" -const PRECISION_FACTOR: u128 = 1_000_000_000; +use cw_utils::{Duration, Threshold}; #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct Config { @@ -18,164 +14,6 @@ pub struct Config { pub max_voting_period: Duration, } -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] -pub struct Proposal { - pub title: String, - pub description: String, - pub start_height: u64, - pub expires: Expiration, - pub msgs: Vec>, - pub status: Status, - /// pass requirements - pub threshold: Threshold, - // the total weight when the proposal started (used to calculate percentages) - pub total_weight: u64, - // summary of existing votes - pub votes: Votes, -} - -impl Proposal { - /// current_status is non-mutable and returns what the status should be. - /// (designed for queries) - pub fn current_status(&self, block: &BlockInfo) -> Status { - let mut status = self.status; - - // if open, check if voting is passed or timed out - if status == Status::Open && self.is_passed(block) { - status = Status::Passed; - } - if status == Status::Open && (self.is_rejected(block) || self.expires.is_expired(block)) { - status = Status::Rejected; - } - - status - } - - /// update_status sets the status of the proposal to current_status. - /// (designed for handler logic) - pub fn update_status(&mut self, block: &BlockInfo) { - self.status = self.current_status(block); - } - - /// Returns true if this proposal is sure to pass (even before expiration, if no future - /// sequence of possible votes could cause it to fail). - pub fn is_passed(&self, block: &BlockInfo) -> bool { - match self.threshold { - Threshold::AbsoluteCount { - weight: weight_needed, - } => self.votes.yes >= weight_needed, - Threshold::AbsolutePercentage { - percentage: percentage_needed, - } => { - self.votes.yes - >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed) - } - Threshold::ThresholdQuorum { threshold, quorum } => { - // we always require the quorum - if self.votes.total() < votes_needed(self.total_weight, quorum) { - return false; - } - if self.expires.is_expired(block) { - // If expired, we compare vote_count against the total number of votes (minus abstain). - let opinions = self.votes.total() - self.votes.abstain; - self.votes.yes >= votes_needed(opinions, threshold) - } else { - // If not expired, we must assume all non-votes will be cast against - let possible_opinions = self.total_weight - self.votes.abstain; - self.votes.yes >= votes_needed(possible_opinions, threshold) - } - } - } - } - - /// Returns true if this proposal is sure to be rejected (even before expiration, if - /// no future sequence of possible votes could cause it to pass). - pub fn is_rejected(&self, block: &BlockInfo) -> bool { - match self.threshold { - Threshold::AbsoluteCount { - weight: weight_needed, - } => { - let weight = self.total_weight - weight_needed; - self.votes.no > weight - } - Threshold::AbsolutePercentage { - percentage: percentage_needed, - } => { - self.votes.no - > votes_needed( - self.total_weight - self.votes.abstain, - Decimal::one() - percentage_needed, - ) - } - Threshold::ThresholdQuorum { - threshold, - quorum: _, - } => { - if self.expires.is_expired(block) { - // If expired, we compare vote_count against the total number of votes (minus abstain). - let opinions = self.votes.total() - self.votes.abstain; - self.votes.no > votes_needed(opinions, Decimal::one() - threshold) - } else { - // If not expired, we must assume all non-votes will be cast for - let possible_opinions = self.total_weight - self.votes.abstain; - self.votes.no > votes_needed(possible_opinions, Decimal::one() - threshold) - } - } - } - } -} - -// weight of votes for each option -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] -pub struct Votes { - pub yes: u64, - pub no: u64, - pub abstain: u64, - pub veto: u64, -} - -impl Votes { - /// sum of all votes - pub fn total(&self) -> u64 { - self.yes + self.no + self.abstain + self.veto - } - - /// create it with a yes vote for this much - pub fn yes(init_weight: u64) -> Self { - Votes { - yes: init_weight, - no: 0, - abstain: 0, - veto: 0, - } - } - - pub fn add_vote(&mut self, vote: Vote, weight: u64) { - match vote { - Vote::Yes => self.yes += weight, - Vote::Abstain => self.abstain += weight, - Vote::No => self.no += weight, - Vote::Veto => self.veto += weight, - } - } -} - -// this is a helper function so Decimal works with u64 rather than Uint128 -// also, we must *round up* here, as we need 8, not 7 votes to reach 50% of 15 total -fn votes_needed(weight: u64, percentage: Decimal) -> u64 { - let applied = percentage * Uint128::new(PRECISION_FACTOR * weight as u128); - // Divide by PRECISION_FACTOR, rounding up to the nearest integer - ((applied.u128() + PRECISION_FACTOR - 1) / PRECISION_FACTOR) as u64 -} - -// we cast a ballot with our chosen vote and a given weight -// stored under the key that voted -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] -pub struct Ballot { - pub weight: u64, - pub vote: Vote, -} - // unique items pub const CONFIG: Item = Item::new("config"); pub const PROPOSAL_COUNT: Item = Item::new("proposal_count"); @@ -192,406 +30,3 @@ pub fn next_id(store: &mut dyn Storage) -> StdResult { PROPOSAL_COUNT.save(store, &id)?; Ok(id) } - -#[cfg(test)] -mod test { - use super::*; - use cosmwasm_std::testing::mock_env; - - #[test] - fn count_votes() { - let mut votes = Votes::yes(5); - votes.add_vote(Vote::No, 10); - votes.add_vote(Vote::Veto, 20); - votes.add_vote(Vote::Yes, 30); - votes.add_vote(Vote::Abstain, 40); - - assert_eq!(votes.total(), 105); - assert_eq!(votes.yes, 35); - assert_eq!(votes.no, 10); - assert_eq!(votes.veto, 20); - assert_eq!(votes.abstain, 40); - } - - #[test] - // we ensure this rounds up (as it calculates needed votes) - fn votes_needed_rounds_properly() { - // round up right below 1 - assert_eq!(1, votes_needed(3, Decimal::permille(333))); - // round up right over 1 - assert_eq!(2, votes_needed(3, Decimal::permille(334))); - assert_eq!(11, votes_needed(30, Decimal::permille(334))); - - // exact matches don't round - assert_eq!(17, votes_needed(34, Decimal::percent(50))); - assert_eq!(12, votes_needed(48, Decimal::percent(25))); - } - - fn setup_prop( - threshold: Threshold, - votes: Votes, - total_weight: u64, - is_expired: bool, - ) -> (Proposal, BlockInfo) { - let block = mock_env().block; - let expires = match is_expired { - true => Expiration::AtHeight(block.height - 5), - false => Expiration::AtHeight(block.height + 100), - }; - let prop = Proposal { - title: "Demo".to_string(), - description: "Info".to_string(), - start_height: 100, - expires, - msgs: vec![], - status: Status::Open, - threshold, - total_weight, - votes, - }; - - (prop, block) - } - - fn check_is_passed( - threshold: Threshold, - votes: Votes, - total_weight: u64, - is_expired: bool, - ) -> bool { - let (prop, block) = setup_prop(threshold, votes, total_weight, is_expired); - prop.is_passed(&block) - } - - fn check_is_rejected( - threshold: Threshold, - votes: Votes, - total_weight: u64, - is_expired: bool, - ) -> bool { - let (prop, block) = setup_prop(threshold, votes, total_weight, is_expired); - prop.is_rejected(&block) - } - - #[test] - fn proposal_passed_absolute_count() { - let fixed = Threshold::AbsoluteCount { weight: 10 }; - let mut votes = Votes::yes(7); - votes.add_vote(Vote::Veto, 4); - // same expired or not, total_weight or whatever - assert!(!check_is_passed(fixed.clone(), votes.clone(), 30, false)); - assert!(!check_is_passed(fixed.clone(), votes.clone(), 30, true)); - // a few more yes votes and we are good - votes.add_vote(Vote::Yes, 3); - assert!(check_is_passed(fixed.clone(), votes.clone(), 30, false)); - assert!(check_is_passed(fixed, votes, 30, true)); - } - - #[test] - fn proposal_rejected_absolute_count() { - let fixed = Threshold::AbsoluteCount { weight: 10 }; - let mut votes = Votes::yes(0); - votes.add_vote(Vote::Veto, 4); - votes.add_vote(Vote::No, 7); - // In order to reject the proposal we need no votes > 30 - 10, currently it is not rejected - assert!(!check_is_rejected(fixed.clone(), votes.clone(), 30, false)); - assert!(!check_is_rejected(fixed.clone(), votes.clone(), 30, true)); - // 7 + 14 = 21 > 20, we can now reject - votes.add_vote(Vote::No, 14); - assert!(check_is_rejected(fixed.clone(), votes.clone(), 30, false)); - assert!(check_is_rejected(fixed, votes, 30, true)); - } - - #[test] - fn proposal_passed_absolute_percentage() { - let percent = Threshold::AbsolutePercentage { - percentage: Decimal::percent(50), - }; - let mut votes = Votes::yes(7); - votes.add_vote(Vote::No, 4); - votes.add_vote(Vote::Abstain, 2); - // same expired or not, if yes >= ceiling(0.5 * (total - abstained)) - // 7 of (15-2) passes - assert!(check_is_passed(percent.clone(), votes.clone(), 15, false)); - assert!(check_is_passed(percent.clone(), votes.clone(), 15, true)); - // but 7 of (17-2) fails - assert!(!check_is_passed(percent.clone(), votes.clone(), 17, false)); - - // if the total were a bit lower, this would pass - assert!(check_is_passed(percent.clone(), votes.clone(), 14, false)); - assert!(check_is_passed(percent, votes, 14, true)); - } - - #[test] - fn proposal_rejected_absolute_percentage() { - let percent = Threshold::AbsolutePercentage { - percentage: Decimal::percent(60), - }; - - // 4 YES, 7 NO, 2 ABSTAIN - let mut votes = Votes::yes(4); - votes.add_vote(Vote::No, 7); - votes.add_vote(Vote::Abstain, 2); - - // 15 total voting power - // we need no votes > 0.4 * 15, no votes > 6 - assert!(check_is_rejected(percent.clone(), votes.clone(), 15, false)); - assert!(check_is_rejected(percent.clone(), votes.clone(), 15, true)); - - // 17 total voting power - // we need no votes > 0.4 * 17, no votes > 6.8 - // still rejected - assert!(check_is_rejected(percent.clone(), votes.clone(), 17, false)); - assert!(check_is_rejected(percent.clone(), votes.clone(), 17, true)); - - // Not rejected if total weight is 20 - // as no votes > 0.4 * 18, no votes > 8 - assert!(!check_is_rejected( - percent.clone(), - votes.clone(), - 20, - false - )); - assert!(!check_is_rejected(percent, votes.clone(), 20, true)); - } - - #[test] - fn proposal_passed_quorum() { - let quorum = Threshold::ThresholdQuorum { - threshold: Decimal::percent(50), - quorum: Decimal::percent(40), - }; - // all non-yes votes are counted for quorum - let passing = Votes { - yes: 7, - no: 3, - abstain: 2, - veto: 1, - }; - // abstain votes are not counted for threshold => yes / (yes + no + veto) - let passes_ignoring_abstain = Votes { - yes: 6, - no: 4, - abstain: 5, - veto: 2, - }; - // fails any way you look at it - let failing = Votes { - yes: 6, - no: 5, - abstain: 2, - veto: 2, - }; - - // first, expired (voting period over) - // over quorum (40% of 30 = 12), over threshold (7/11 > 50%) - assert!(check_is_passed(quorum.clone(), passing.clone(), 30, true)); - // under quorum it is not passing (40% of 33 = 13.2 > 13) - assert!(!check_is_passed(quorum.clone(), passing.clone(), 33, true)); - // over quorum, threshold passes if we ignore abstain - // 17 total votes w/ abstain => 40% quorum of 40 total - // 6 yes / (6 yes + 4 no + 2 votes) => 50% threshold - assert!(check_is_passed( - quorum.clone(), - passes_ignoring_abstain.clone(), - 40, - true - )); - // over quorum, but under threshold fails also - assert!(!check_is_passed(quorum.clone(), failing, 20, true)); - - // now, check with open voting period - // would pass if closed, but fail here, as remaining votes no -> fail - assert!(!check_is_passed(quorum.clone(), passing.clone(), 30, false)); - assert!(!check_is_passed( - quorum.clone(), - passes_ignoring_abstain.clone(), - 40, - false - )); - // if we have threshold * total_weight as yes votes this must pass - assert!(check_is_passed(quorum.clone(), passing.clone(), 14, false)); - // all votes have been cast, some abstain - assert!(check_is_passed( - quorum.clone(), - passes_ignoring_abstain, - 17, - false - )); - // 3 votes uncast, if they all vote no, we have 7 yes, 7 no+veto, 2 abstain (out of 16) - assert!(check_is_passed(quorum, passing, 16, false)); - } - - #[test] - fn proposal_rejected_quorum() { - let quorum = Threshold::ThresholdQuorum { - threshold: Decimal::percent(60), - quorum: Decimal::percent(40), - }; - // all non-yes votes are counted for quorum - let rejecting = Votes { - yes: 3, - no: 7, - abstain: 2, - veto: 1, - }; - // abstain votes are not counted for threshold => yes / (yes + no + veto) - let rejected_ignoring_abstain = Votes { - yes: 4, - no: 6, - abstain: 5, - veto: 2, - }; - // fails any way you look at it - let failing = Votes { - yes: 5, - no: 5, - abstain: 2, - veto: 3, - }; - - // first, expired (voting period over) - // over quorum (40% of 30 = 12, 13 votes casted) - // 13 - 2 abstains = 11 - // we need no votes > 0.4 * 11, no votes > 4.4 - // We can reject this - assert!(check_is_rejected( - quorum.clone(), - rejecting.clone(), - 30, - true - )); - - // Under quorum and cannot reject as it is not expired - assert!(!check_is_rejected( - quorum.clone(), - rejecting.clone(), - 50, - false - )); - // Can reject when expired. - assert!(check_is_rejected( - quorum.clone(), - rejecting.clone(), - 50, - true - )); - - // Check edgecase where quorum is not met but we can reject - // 35% vote no - let quorum_edgecase = Threshold::ThresholdQuorum { - threshold: Decimal::percent(67), - quorum: Decimal::percent(40), - }; - assert!(check_is_rejected( - quorum_edgecase, - Votes { - yes: 15, - no: 35, - abstain: 0, - veto: 10 - }, - 100, - true - )); - - // over quorum, threshold passes if we ignore abstain - // 17 total votes > 40% quorum - // 6 no > 0.4 * (6 no + 4 yes + 2 votes) - // 6 > 4.8 - // we can reject - assert!(check_is_rejected( - quorum.clone(), - rejected_ignoring_abstain.clone(), - 40, - true - )); - - // over quorum - // total opinions due to abstains: 13 - // no votes > 0.4 * 13, no votes > 5 to reject, we have 5 exactly so cannot reject - assert!(!check_is_rejected(quorum.clone(), failing, 20, true)); - - // voting period on going - // over quorum (40% of 14 = 5, 13 votes casted) - // 13 - 2 abstains = 11 - // we need no votes > 0.4 * 11, no votes > 4.4 - // We can reject this even when it hasn't expired - assert!(check_is_rejected( - quorum.clone(), - rejecting.clone(), - 14, - false - )); - // all votes have been cast, some abstain - // voting period on going - // over quorum (40% of 17 = 7, 17 casted_ - // 17 - 5 = 12 total opinions - // we need no votes > 0.4 * 12, no votes > 4.8 - // We can reject this even when it hasn't expired - assert!(check_is_rejected( - quorum.clone(), - rejected_ignoring_abstain, - 17, - false - )); - - // 3 votes uncast, if they all vote yes, we have 7 no, 7 yes+veto, 2 abstain (out of 16) - assert!(check_is_rejected(quorum, rejecting, 16, false)); - } - - #[test] - fn quorum_edge_cases() { - // when we pass absolute threshold (everyone else voting no, we pass), but still don't hit quorum - let quorum = Threshold::ThresholdQuorum { - threshold: Decimal::percent(60), - quorum: Decimal::percent(80), - }; - - // try 9 yes, 1 no (out of 15) -> 90% voter threshold, 60% absolute threshold, still no quorum - // doesn't matter if expired or not - let missing_voters = Votes { - yes: 9, - no: 1, - abstain: 0, - veto: 0, - }; - assert!(!check_is_passed( - quorum.clone(), - missing_voters.clone(), - 15, - false - )); - assert!(!check_is_passed(quorum.clone(), missing_voters, 15, true)); - - // 1 less yes, 3 vetos and this passes only when expired - let wait_til_expired = Votes { - yes: 8, - no: 1, - abstain: 0, - veto: 3, - }; - assert!(!check_is_passed( - quorum.clone(), - wait_til_expired.clone(), - 15, - false - )); - assert!(check_is_passed(quorum.clone(), wait_til_expired, 15, true)); - - // 9 yes and 3 nos passes early - let passes_early = Votes { - yes: 9, - no: 3, - abstain: 0, - veto: 0, - }; - assert!(check_is_passed( - quorum.clone(), - passes_early.clone(), - 15, - false - )); - assert!(check_is_passed(quorum, passes_early, 15, true)); - } -} diff --git a/contracts/cw3-flex-multisig/Cargo.toml b/contracts/cw3-flex-multisig/Cargo.toml index d829ae454..c7bcf1ea8 100644 --- a/contracts/cw3-flex-multisig/Cargo.toml +++ b/contracts/cw3-flex-multisig/Cargo.toml @@ -23,6 +23,7 @@ cw2 = { path = "../../packages/cw2", version = "0.13.4" } cw3 = { path = "../../packages/cw3", version = "0.13.4" } cw3-fixed-multisig = { path = "../cw3-fixed-multisig", version = "0.13.4", features = ["library"] } cw4 = { path = "../../packages/cw4", version = "0.13.4" } +cw20 = { path = "../../packages/cw20", version = "0.13.4" } cw-storage-plus = { path = "../../packages/storage-plus", version = "0.13.4" } cosmwasm-std = { version = "1.0.0" } schemars = "0.8.1" @@ -32,4 +33,5 @@ thiserror = { version = "1.0.23" } [dev-dependencies] cosmwasm-schema = { version = "1.0.0" } cw4-group = { path = "../cw4-group", version = "0.13.4" } +cw20-base = { path = "../cw20-base", version = "0.13.4" } cw-multi-test = { path = "../../packages/multi-test", version = "0.13.4" } diff --git a/contracts/cw3-flex-multisig/examples/schema.rs b/contracts/cw3-flex-multisig/examples/schema.rs index dd4e52de2..3c48997e0 100644 --- a/contracts/cw3-flex-multisig/examples/schema.rs +++ b/contracts/cw3-flex-multisig/examples/schema.rs @@ -3,7 +3,15 @@ use std::fs::create_dir_all; use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; +use cw3::ProposalListResponse; +use cw3::ProposalResponse; +use cw3::VoteListResponse; +use cw3::VoteResponse; +use cw3::VoterListResponse; +use cw3::VoterResponse; use cw3_flex_multisig::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use cw3_flex_multisig::state::Config; +use cw_utils::ThresholdResponse; fn main() { let mut out_dir = current_dir().unwrap(); @@ -14,4 +22,13 @@ fn main() { export_schema(&schema_for!(InstantiateMsg), &out_dir); export_schema_with_title(&schema_for!(ExecuteMsg), &out_dir, "ExecuteMsg"); export_schema_with_title(&schema_for!(QueryMsg), &out_dir, "QueryMsg"); + + export_schema_with_title(&schema_for!(Config), &out_dir, "GetConfigResponse"); + export_schema(&schema_for!(ThresholdResponse), &out_dir); + export_schema(&schema_for!(ProposalResponse), &out_dir); + export_schema(&schema_for!(ProposalListResponse), &out_dir); + export_schema(&schema_for!(VoteResponse), &out_dir); + export_schema(&schema_for!(VoteListResponse), &out_dir); + export_schema(&schema_for!(VoterResponse), &out_dir); + export_schema(&schema_for!(VoterListResponse), &out_dir); } diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 79ac0fc27..9b0054b93 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -8,11 +8,12 @@ use cosmwasm_std::{ }; use cw2::set_contract_version; + use cw3::{ - ProposalListResponse, ProposalResponse, Status, Vote, VoteInfo, VoteListResponse, VoteResponse, - VoterDetail, VoterListResponse, VoterResponse, + Ballot, Proposal, ProposalListResponse, ProposalResponse, Status, Vote, VoteInfo, + VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, Votes, }; -use cw3_fixed_multisig::state::{next_id, Ballot, Proposal, Votes, BALLOTS, PROPOSALS}; +use cw3_fixed_multisig::state::{next_id, BALLOTS, PROPOSALS}; use cw4::{Cw4Contract, MemberChangedHookMsg, MemberDiff}; use cw_storage_plus::Bound; use cw_utils::{maybe_addr, Expiration, ThresholdResponse}; @@ -40,6 +41,11 @@ pub fn instantiate( let total_weight = group_addr.total_weight(&deps.querier)?; msg.threshold.validate(total_weight)?; + let proposal_deposit = msg + .proposal_deposit + .map(|deposit| deposit.into_checked(deps.as_ref())) + .transpose()?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; let cfg = Config { @@ -47,6 +53,7 @@ pub fn instantiate( max_voting_period: msg.max_voting_period, group_addr, executor: msg.executor, + proposal_deposit, }; CONFIG.save(deps.storage, &cfg)?; @@ -89,6 +96,11 @@ pub fn execute_propose( // only members of the multisig can create a proposal let cfg = CONFIG.load(deps.storage)?; + // Check that the native deposit was paid (as needed). + if let Some(deposit) = cfg.proposal_deposit.as_ref() { + deposit.check_native_deposit_paid(&info)?; + } + // Only members of the multisig can create a proposal // Non-voting members are special - they are allowed to create a proposal and // therefore "vote", but they aren't allowed to vote otherwise. @@ -109,6 +121,15 @@ pub fn execute_propose( return Err(ContractError::WrongExpiration {}); } + // Take the cw20 token deposit, if required. We do this before + // creating the proposal struct below so that we can avoid a clone + // and move the loaded deposit info into it. + let take_deposit_msg = if let Some(deposit_info) = cfg.proposal_deposit.as_ref() { + deposit_info.get_take_deposit_messages(&info.sender, &env.contract.address)? + } else { + vec![] + }; + // create a proposal let mut prop = Proposal { title, @@ -120,6 +141,8 @@ pub fn execute_propose( votes: Votes::yes(vote_power), threshold: cfg.threshold, total_weight: cfg.group_addr.total_weight(&deps.querier)?, + proposer: info.sender.clone(), + deposit: cfg.proposal_deposit, }; prop.update_status(&env.block); let id = next_id(deps.storage)?; @@ -133,6 +156,7 @@ pub fn execute_propose( BALLOTS.save(deps.storage, (id, &info.sender), &ballot)?; Ok(Response::new() + .add_messages(take_deposit_msg) .add_attribute("action", "propose") .add_attribute("sender", info.sender) .add_attribute("proposal_id", id.to_string()) @@ -207,8 +231,16 @@ pub fn execute_execute( prop.status = Status::Executed; PROPOSALS.save(deps.storage, proposal_id, &prop)?; + // Unconditionally refund here. + let response = match prop.deposit { + Some(deposit) => { + Response::new().add_message(deposit.get_return_deposit_message(&prop.proposer)?) + } + None => Response::new(), + }; + // dispatch all proposed messages - Ok(Response::new() + Ok(response .add_messages(prop.msgs) .add_attribute("action", "execute") .add_attribute("sender", info.sender) @@ -238,7 +270,19 @@ pub fn execute_close( prop.status = Status::Rejected; PROPOSALS.save(deps.storage, proposal_id, &prop)?; - Ok(Response::new() + // Refund the deposit if we have been configured to do so. + let response = match prop.deposit { + Some(deposit) => { + if deposit.refund_failed_proposals { + Response::new().add_message(deposit.get_return_deposit_message(&prop.proposer)?) + } else { + Response::new() + } + } + None => Response::new(), + }; + + Ok(response .add_attribute("action", "close") .add_attribute("sender", info.sender) .add_attribute("proposal_id", proposal_id.to_string())) @@ -282,6 +326,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { QueryMsg::ListVoters { start_after, limit } => { to_binary(&list_voters(deps, start_after, limit)?) } + QueryMsg::GetConfig {} => to_binary(&query_config(deps)?), } } @@ -291,6 +336,10 @@ fn query_threshold(deps: Deps) -> StdResult { Ok(cfg.threshold.to_response(total_weight)) } +fn query_config(deps: Deps) -> StdResult { + CONFIG.load(deps.storage) +} + fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { let prop = PROPOSALS.load(deps.storage, id)?; let status = prop.current_status(&env.block); @@ -302,6 +351,8 @@ fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult msgs: prop.msgs, status, expires: prop.expires, + proposer: prop.proposer, + deposit: prop.deposit, threshold, }) } @@ -358,6 +409,8 @@ fn map_proposal( msgs: prop.msgs, status, expires: prop.expires, + deposit: prop.deposit, + proposer: prop.proposer, threshold, } }) @@ -430,12 +483,16 @@ fn list_voters( #[cfg(test)] mod tests { - use cosmwasm_std::{coin, coins, Addr, BankMsg, Coin, Decimal, Timestamp}; + use cosmwasm_std::{coin, coins, Addr, BankMsg, Coin, Decimal, Timestamp, Uint128}; use cw2::{query_contract_info, ContractVersion}; + use cw20::{Cw20Coin, UncheckedDenom}; + use cw3::{DepositError, UncheckedDepositInfo}; use cw4::{Cw4ExecuteMsg, Member}; use cw4_group::helpers::Cw4GroupContract; - use cw_multi_test::{next_block, App, AppBuilder, Contract, ContractWrapper, Executor}; + use cw_multi_test::{ + next_block, App, AppBuilder, BankSudo, Contract, ContractWrapper, Executor, SudoMsg, + }; use cw_utils::{Duration, Threshold}; use super::*; @@ -473,6 +530,15 @@ mod tests { Box::new(contract) } + fn contract_cw20() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) + } + fn mock_app(init_funds: &[Coin]) -> App { AppBuilder::new().build(|router, _, storage| { router @@ -500,6 +566,7 @@ mod tests { threshold: Threshold, max_voting_period: Duration, executor: Option, + proposal_deposit: Option, ) -> Addr { let flex_id = app.store_code(contract_flex()); let msg = crate::msg::InstantiateMsg { @@ -507,6 +574,7 @@ mod tests { threshold, max_voting_period, executor, + proposal_deposit, }; app.instantiate_contract(flex_id, Addr::unchecked(OWNER), &msg, &[], "flex", None) .unwrap() @@ -532,6 +600,7 @@ mod tests { init_funds, multisig_as_group_admin, None, + None, ) } @@ -543,6 +612,7 @@ mod tests { init_funds: Vec, multisig_as_group_admin: bool, executor: Option, + proposal_deposit: Option, ) -> (Addr, Addr) { // 1. Instantiate group contract with members (and OWNER as admin) let members = vec![ @@ -563,6 +633,7 @@ mod tests { threshold, max_voting_period, executor, + proposal_deposit, ); app.update_block(next_block); @@ -610,6 +681,16 @@ mod tests { } } + fn text_proposal() -> ExecuteMsg { + let (_, title, description) = proposal_info(); + ExecuteMsg::Propose { + title, + description, + msgs: vec![], + latest: None, + } + } + #[test] fn test_instantiate_works() { let mut app = mock_app(&[]); @@ -629,6 +710,7 @@ mod tests { }, max_voting_period, executor: None, + proposal_deposit: None, }; let err = app .instantiate_contract( @@ -651,6 +733,7 @@ mod tests { threshold: Threshold::AbsoluteCount { weight: 100 }, max_voting_period, executor: None, + proposal_deposit: None, }; let err = app .instantiate_contract( @@ -673,6 +756,7 @@ mod tests { threshold: Threshold::AbsoluteCount { weight: 1 }, max_voting_period, executor: None, + proposal_deposit: None, }; let flex_addr = app .instantiate_contract( @@ -830,8 +914,15 @@ mod tests { threshold: Decimal::percent(80), quorum: Decimal::percent(20), }; - let (flex_addr, _) = - setup_test_case(&mut app, threshold, voting_period, init_funds, false, None); + let (flex_addr, _) = setup_test_case( + &mut app, + threshold, + voting_period, + init_funds, + false, + None, + None, + ); // create proposal with 1 vote power let proposal = pay_somebody_proposal(); @@ -912,6 +1003,8 @@ mod tests { threshold: Decimal::percent(80), quorum: Decimal::percent(20), }, + proposer: Addr::unchecked(VOTER2), + deposit: None, }; assert_eq!(&expected, &res.proposals[0]); } @@ -926,8 +1019,15 @@ mod tests { quorum: Decimal::percent(1), }; let voting_period = Duration::Time(2000000); - let (flex_addr, _) = - setup_test_case(&mut app, threshold, voting_period, init_funds, false, None); + let (flex_addr, _) = setup_test_case( + &mut app, + threshold, + voting_period, + init_funds, + false, + None, + None, + ); // create proposal with 0 vote power let proposal = pay_somebody_proposal(); @@ -1118,8 +1218,15 @@ mod tests { quorum: Decimal::percent(1), }; let voting_period = Duration::Time(2000000); - let (flex_addr, _) = - setup_test_case(&mut app, threshold, voting_period, init_funds, true, None); + let (flex_addr, _) = setup_test_case( + &mut app, + threshold, + voting_period, + init_funds, + true, + None, + None, + ); // ensure we have cash to cover the proposal let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap(); @@ -1226,6 +1333,7 @@ mod tests { init_funds, true, Some(crate::state::Executor::Member), // set executor as Member of voting group + None, ); // create proposal with 0 vote power @@ -1282,6 +1390,7 @@ mod tests { init_funds, true, Some(crate::state::Executor::Only(Addr::unchecked(VOTER3))), // only VOTER3 can execute proposal + None, ); // create proposal with 0 vote power @@ -1348,6 +1457,7 @@ mod tests { init_funds, true, None, + None, ); // ensure we have cash to cover the proposal @@ -1423,8 +1533,15 @@ mod tests { quorum: Decimal::percent(1), }; let voting_period = Duration::Height(2000000); - let (flex_addr, _) = - setup_test_case(&mut app, threshold, voting_period, init_funds, true, None); + let (flex_addr, _) = setup_test_case( + &mut app, + threshold, + voting_period, + init_funds, + true, + None, + None, + ); // create proposal with 0 vote power let proposal = pay_somebody_proposal(); @@ -1475,8 +1592,15 @@ mod tests { quorum: Decimal::percent(1), }; let voting_period = Duration::Time(20000); - let (flex_addr, group_addr) = - setup_test_case(&mut app, threshold, voting_period, init_funds, false, None); + let (flex_addr, group_addr) = setup_test_case( + &mut app, + threshold, + voting_period, + init_funds, + false, + None, + None, + ); // VOTER1 starts a proposal to send some tokens (1/4 votes) let proposal = pay_somebody_proposal(); @@ -1721,8 +1845,15 @@ mod tests { quorum: Decimal::percent(1), }; let voting_period = Duration::Time(20000); - let (flex_addr, group_addr) = - setup_test_case(&mut app, threshold, voting_period, init_funds, false, None); + let (flex_addr, group_addr) = setup_test_case( + &mut app, + threshold, + voting_period, + init_funds, + false, + None, + None, + ); // VOTER3 starts a proposal to send some tokens (3/12 votes) let proposal = pay_somebody_proposal(); @@ -1806,6 +1937,7 @@ mod tests { init_funds, false, None, + None, ); // VOTER3 starts a proposal to send some tokens (3 votes) @@ -1877,6 +2009,7 @@ mod tests { init_funds, false, None, + None, ); // create proposal @@ -1916,4 +2049,451 @@ mod tests { .unwrap(); assert_eq!(prop_status(&app), Status::Passed); } + + #[test] + fn test_instantiate_with_invalid_deposit() { + let mut app = App::default(); + + let flex_id = app.store_code(contract_flex()); + + let group_addr = instantiate_group( + &mut app, + vec![Member { + addr: OWNER.to_string(), + weight: 10, + }], + ); + + // Instantiate with an invalid cw20 token. + let instantiate = InstantiateMsg { + group_addr: group_addr.to_string(), + threshold: Threshold::AbsoluteCount { weight: 10 }, + max_voting_period: Duration::Time(10), + executor: None, + proposal_deposit: Some(UncheckedDepositInfo { + amount: Uint128::new(1), + refund_failed_proposals: true, + denom: UncheckedDenom::Cw20(group_addr.to_string()), + }), + }; + + let err: ContractError = app + .instantiate_contract( + flex_id, + Addr::unchecked(OWNER), + &instantiate, + &[], + "Bad cw20", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::Deposit(DepositError::InvalidCw20 {})); + + // Instantiate with a zero amount. + let instantiate = InstantiateMsg { + group_addr: group_addr.to_string(), + threshold: Threshold::AbsoluteCount { weight: 10 }, + max_voting_period: Duration::Time(10), + executor: None, + proposal_deposit: Some(UncheckedDepositInfo { + amount: Uint128::zero(), + refund_failed_proposals: true, + denom: UncheckedDenom::Native("native".to_string()), + }), + }; + + let err: ContractError = app + .instantiate_contract( + flex_id, + Addr::unchecked(OWNER), + &instantiate, + &[], + "Bad cw20", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::Deposit(DepositError::ZeroDeposit {})) + } + + #[test] + fn test_cw20_proposal_deposit() { + let mut app = App::default(); + + let cw20_id = app.store_code(contract_cw20()); + + let cw20_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked(OWNER), + &cw20_base::msg::InstantiateMsg { + name: "Token".to_string(), + symbol: "TOKEN".to_string(), + decimals: 6, + initial_balances: vec![ + Cw20Coin { + address: VOTER4.to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(10), + }, + ], + mint: None, + marketing: None, + }, + &[], + "Token", + None, + ) + .unwrap(); + + let (flex_addr, _) = setup_test_case( + &mut app, + Threshold::AbsoluteCount { weight: 10 }, + Duration::Height(10), + vec![], + true, + None, + Some(UncheckedDepositInfo { + amount: Uint128::new(10), + denom: UncheckedDenom::Cw20(cw20_addr.to_string()), + refund_failed_proposals: true, + }), + ); + + app.execute_contract( + Addr::unchecked(VOTER4), + cw20_addr.clone(), + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: flex_addr.to_string(), + amount: Uint128::new(10), + expires: None, + }, + &[], + ) + .unwrap(); + + // Make a proposal that will pass. + let proposal = text_proposal(); + app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Make sure the deposit was transfered. + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: VOTER4.to_string(), + }, + ) + .unwrap(); + assert_eq!(balance.balance, Uint128::zero()); + + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: flex_addr.to_string(), + }, + ) + .unwrap(); + assert_eq!(balance.balance, Uint128::new(10)); + + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Make sure the deposit was returned. + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: VOTER4.to_string(), + }, + ) + .unwrap(); + assert_eq!(balance.balance, Uint128::new(10)); + + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: flex_addr.to_string(), + }, + ) + .unwrap(); + assert_eq!(balance.balance, Uint128::zero()); + + app.execute_contract( + Addr::unchecked(OWNER), + cw20_addr.clone(), + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: flex_addr.to_string(), + amount: Uint128::new(10), + expires: None, + }, + &[], + ) + .unwrap(); + + // Make a proposal that fails. + let proposal = text_proposal(); + app.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Check that the deposit was transfered. + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: flex_addr.to_string(), + }, + ) + .unwrap(); + assert_eq!(balance.balance, Uint128::new(10)); + + // Fail the proposal. + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr.clone(), + &ExecuteMsg::Vote { + proposal_id: 2, + vote: Vote::No, + }, + &[], + ) + .unwrap(); + + // Expire the proposal. + app.update_block(|b| b.height += 10); + + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr, + &ExecuteMsg::Close { proposal_id: 2 }, + &[], + ) + .unwrap(); + + // Make sure the deposit was returned despite the proposal failing. + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr, + &cw20::Cw20QueryMsg::Balance { + address: VOTER4.to_string(), + }, + ) + .unwrap(); + assert_eq!(balance.balance, Uint128::new(10)); + } + + #[test] + fn proposal_deposit_no_failed_refunds() { + let mut app = App::default(); + + let (flex_addr, _) = setup_test_case( + &mut app, + Threshold::AbsoluteCount { weight: 10 }, + Duration::Height(10), + vec![], + true, + None, + Some(UncheckedDepositInfo { + amount: Uint128::new(10), + denom: UncheckedDenom::Native("TOKEN".to_string()), + refund_failed_proposals: false, + }), + ); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: OWNER.to_string(), + amount: vec![Coin { + amount: Uint128::new(10), + denom: "TOKEN".to_string(), + }], + })) + .unwrap(); + + // Make a proposal that fails. + let proposal = text_proposal(); + app.execute_contract( + Addr::unchecked(OWNER), + flex_addr.clone(), + &proposal, + &[Coin { + amount: Uint128::new(10), + denom: "TOKEN".to_string(), + }], + ) + .unwrap(); + + // Check that the deposit was transfered. + let balance = app + .wrap() + .query_balance(OWNER, "TOKEN".to_string()) + .unwrap(); + assert_eq!(balance.amount, Uint128::zero()); + + // Fail the proposal. + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: Vote::No, + }, + &[], + ) + .unwrap(); + + // Expire the proposal. + app.update_block(|b| b.height += 10); + + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr, + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Check that the deposit wasn't returned. + let balance = app + .wrap() + .query_balance(OWNER, "TOKEN".to_string()) + .unwrap(); + assert_eq!(balance.amount, Uint128::zero()); + } + + #[test] + fn test_native_proposal_deposit() { + let mut app = App::default(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: VOTER4.to_string(), + amount: vec![Coin { + amount: Uint128::new(10), + denom: "TOKEN".to_string(), + }], + })) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: OWNER.to_string(), + amount: vec![Coin { + amount: Uint128::new(10), + denom: "TOKEN".to_string(), + }], + })) + .unwrap(); + + let (flex_addr, _) = setup_test_case( + &mut app, + Threshold::AbsoluteCount { weight: 10 }, + Duration::Height(10), + vec![], + true, + None, + Some(UncheckedDepositInfo { + amount: Uint128::new(10), + denom: UncheckedDenom::Native("TOKEN".to_string()), + refund_failed_proposals: true, + }), + ); + + // Make a proposal that will pass. + let proposal = text_proposal(); + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr.clone(), + &proposal, + &[Coin { + amount: Uint128::new(10), + denom: "TOKEN".to_string(), + }], + ) + .unwrap(); + + // Make sure the deposit was transfered. + let balance = app + .wrap() + .query_balance(flex_addr.clone(), "TOKEN") + .unwrap(); + assert_eq!(balance.amount, Uint128::new(10)); + + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Make sure the deposit was returned. + let balance = app.wrap().query_balance(VOTER4, "TOKEN").unwrap(); + assert_eq!(balance.amount, Uint128::new(10)); + + // Make a proposal that fails. + let proposal = text_proposal(); + app.execute_contract( + Addr::unchecked(OWNER), + flex_addr.clone(), + &proposal, + &[Coin { + amount: Uint128::new(10), + denom: "TOKEN".to_string(), + }], + ) + .unwrap(); + + let balance = app + .wrap() + .query_balance(flex_addr.clone(), "TOKEN") + .unwrap(); + assert_eq!(balance.amount, Uint128::new(10)); + + // Fail the proposal. + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr.clone(), + &ExecuteMsg::Vote { + proposal_id: 2, + vote: Vote::No, + }, + &[], + ) + .unwrap(); + + // Expire the proposal. + app.update_block(|b| b.height += 10); + + app.execute_contract( + Addr::unchecked(VOTER4), + flex_addr, + &ExecuteMsg::Close { proposal_id: 2 }, + &[], + ) + .unwrap(); + + // Make sure the deposit was returned despite the proposal failing. + let balance = app.wrap().query_balance(OWNER, "TOKEN").unwrap(); + assert_eq!(balance.amount, Uint128::new(10)); + } } diff --git a/contracts/cw3-flex-multisig/src/error.rs b/contracts/cw3-flex-multisig/src/error.rs index 4936a8923..3463a6ec8 100644 --- a/contracts/cw3-flex-multisig/src/error.rs +++ b/contracts/cw3-flex-multisig/src/error.rs @@ -1,5 +1,6 @@ use cosmwasm_std::StdError; -use cw_utils::ThresholdError; +use cw3::DepositError; +use cw_utils::{PaymentError, ThresholdError}; use thiserror::Error; @@ -37,4 +38,10 @@ pub enum ContractError { #[error("Cannot close completed or passed proposals")] WrongCloseStatus {}, + + #[error("{0}")] + Payment(#[from] PaymentError), + + #[error("{0}")] + Deposit(#[from] DepositError), } diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index 99437b26b..357b53115 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -2,7 +2,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use cosmwasm_std::{CosmosMsg, Empty}; -use cw3::Vote; +use cw3::{UncheckedDepositInfo, Vote}; use cw4::MemberChangedHookMsg; use cw_utils::{Duration, Expiration, Threshold}; @@ -17,6 +17,8 @@ pub struct InstantiateMsg { // who is able to execute passed proposals // None means that anyone can execute pub executor: Option, + /// The cost of creating a proposal (if any). + pub proposal_deposit: Option, } // TODO: add some T variants? Maybe good enough as fixed Empty for now @@ -45,7 +47,7 @@ pub enum ExecuteMsg { } // We can also add this as a cw3 extension -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] pub enum QueryMsg { /// Return ThresholdResponse @@ -70,11 +72,13 @@ pub enum QueryMsg { start_after: Option, limit: Option, }, - /// Returns VoterInfo + /// Returns VoterResponse. Voter { address: String }, - /// Returns VoterListResponse + /// Returns VoterListResponse. ListVoters { start_after: Option, limit: Option, }, + /// Gets the current configuration. Returns state::Config. + GetConfig {}, } diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index a1e388777..b014b5bc6 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -2,6 +2,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use cosmwasm_std::{Addr, QuerierWrapper}; +use cw3::DepositInfo; use cw4::Cw4Contract; use cw_storage_plus::Item; use cw_utils::{Duration, Threshold}; @@ -9,7 +10,7 @@ use cw_utils::{Duration, Threshold}; use crate::error::ContractError; /// Defines who is able to execute proposals once passed -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)] pub enum Executor { /// Any member of the voting group, even with 0 points Member, @@ -26,6 +27,8 @@ pub struct Config { // who is able to execute passed proposals // None means that anyone can execute pub executor: Option, + /// The price, if any, of creating a new proposal. + pub proposal_deposit: Option, } impl Config { diff --git a/packages/cw20/Cargo.toml b/packages/cw20/Cargo.toml index 8a3181632..77930a416 100644 --- a/packages/cw20/Cargo.toml +++ b/packages/cw20/Cargo.toml @@ -17,3 +17,5 @@ serde = { version = "1.0.103", default-features = false, features = ["derive"] } [dev-dependencies] cosmwasm-schema = { version = "1.0.0" } +cw20-base = { version = "0.13.4", path = "../../contracts/cw20-base" } +cw-multi-test = { version = "0.13.4", path = "../multi-test" } diff --git a/packages/cw20/src/denom.rs b/packages/cw20/src/denom.rs index 1b4e001d2..ca31ff97a 100644 --- a/packages/cw20/src/denom.rs +++ b/packages/cw20/src/denom.rs @@ -1,7 +1,9 @@ -use cosmwasm_std::Addr; +use cosmwasm_std::{Addr, Deps, StdResult, Uint128}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::{Cw20QueryMsg, TokenInfoResponse}; + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum Denom { @@ -9,6 +11,34 @@ pub enum Denom { Cw20(Addr), } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum UncheckedDenom { + Native(String), + Cw20(String), +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct DepositInfo { + amount: Uint128, + denom: UncheckedDenom, +} + +impl UncheckedDenom { + pub fn into_checked(self, deps: Deps) -> StdResult { + match self { + Self::Native(denom) => Ok(Denom::Native(denom)), + Self::Cw20(addr) => { + let addr = deps.api.addr_validate(&addr)?; + let _info: TokenInfoResponse = deps + .querier + .query_wasm_smart(addr.clone(), &Cw20QueryMsg::TokenInfo {})?; + Ok(Denom::Cw20(addr)) + } + } + } +} + // TODO: remove or figure out where needed impl Default for Denom { fn default() -> Denom { diff --git a/packages/cw20/src/lib.rs b/packages/cw20/src/lib.rs index f5a142b27..c860642e4 100644 --- a/packages/cw20/src/lib.rs +++ b/packages/cw20/src/lib.rs @@ -2,7 +2,7 @@ pub use cw_utils::Expiration; pub use crate::balance::Balance; pub use crate::coin::{Cw20Coin, Cw20CoinVerified}; -pub use crate::denom::Denom; +pub use crate::denom::{Denom, UncheckedDenom}; pub use crate::helpers::Cw20Contract; pub use crate::logo::{EmbeddedLogo, Logo, LogoInfo}; pub use crate::msg::Cw20ExecuteMsg; diff --git a/packages/cw3/Cargo.toml b/packages/cw3/Cargo.toml index 7e585a17d..297732a70 100644 --- a/packages/cw3/Cargo.toml +++ b/packages/cw3/Cargo.toml @@ -11,9 +11,11 @@ documentation = "https://docs.cosmwasm.com" [dependencies] cw-utils = { path = "../../packages/utils", version = "0.13.4" } +cw20 = { path = "../../packages/cw20", version = "0.13.4" } cosmwasm-std = { version = "1.0.0" } schemars = "0.8.1" serde = { version = "1.0.103", default-features = false, features = ["derive"] } +thiserror = { version = "1.0.21" } [dev-dependencies] cosmwasm-schema = { version = "1.0.0" } diff --git a/packages/cw3/src/deposit.rs b/packages/cw3/src/deposit.rs new file mode 100644 index 000000000..0da724f3d --- /dev/null +++ b/packages/cw3/src/deposit.rs @@ -0,0 +1,139 @@ +use cw_utils::{must_pay, PaymentError}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use cosmwasm_std::{ + to_binary, Addr, BankMsg, Coin, CosmosMsg, Deps, MessageInfo, StdResult, Uint128, WasmMsg, +}; +use cw20::{Denom, UncheckedDenom}; + +/// Information about the deposit required to create a proposal. +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct DepositInfo { + /// The number tokens required for payment. + pub amount: Uint128, + /// The denom of the deposit payment. + pub denom: Denom, + /// Should failed proposals have their deposits refunded? + pub refund_failed_proposals: bool, +} + +/// Information about the deposit required to create a proposal. For +/// use in messages. To validate, transform into `DepositInfo` via +/// `into_checked()`. +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct UncheckedDepositInfo { + /// The number tokens required for payment. + pub amount: Uint128, + /// The denom of the deposit payment. + pub denom: UncheckedDenom, + /// Should failed proposals have their deposits refunded? + pub refund_failed_proposals: bool, +} + +#[derive(Error, Debug, PartialEq)] +pub enum DepositError { + #[error("Invalid zero deposit. Set the deposit to None to have no deposit.")] + ZeroDeposit {}, + #[error("Invalid cw20")] + InvalidCw20 {}, + #[error("{0}")] + Payment(#[from] PaymentError), + #[error("Invalid native deposit amount")] + InvalidDeposit {}, +} + +impl UncheckedDepositInfo { + /// Checks deposit info. + pub fn into_checked(self, deps: Deps) -> Result { + if self.amount.is_zero() { + Err(DepositError::ZeroDeposit {}) + } else { + Ok(DepositInfo { + amount: self.amount, + denom: self + .denom + .into_checked(deps) + .map_err(|_| DepositError::InvalidCw20 {})?, + refund_failed_proposals: self.refund_failed_proposals, + }) + } + } +} + +impl DepositInfo { + pub fn check_native_deposit_paid(&self, info: &MessageInfo) -> Result<(), DepositError> { + if let Self { + amount, + denom: Denom::Native(denom), + .. + } = self + { + let paid = must_pay(&info, denom)?; + if paid != *amount { + Err(DepositError::InvalidDeposit {}) + } else { + Ok(()) + } + } else { + Ok(()) + } + } + + pub fn get_take_deposit_messages( + &self, + depositor: &Addr, + contract: &Addr, + ) -> StdResult> { + let take_deposit_msg: Vec = if let DepositInfo { + amount, + denom: Denom::Cw20(address), + .. + } = self + { + // into_checked() makes sure this isn't the case, but just for + // posterity. + if amount.is_zero() { + vec![] + } else { + vec![WasmMsg::Execute { + contract_addr: address.to_string(), + funds: vec![], + msg: to_binary(&cw20::Cw20ExecuteMsg::TransferFrom { + owner: depositor.to_string(), + recipient: contract.to_string(), + amount: *amount, + })?, + } + .into()] + } + } else { + vec![] + }; + Ok(take_deposit_msg) + } + + pub fn get_return_deposit_message(&self, depositor: &Addr) -> StdResult { + let message = match &self.denom { + Denom::Native(denom) => BankMsg::Send { + to_address: depositor.to_string(), + amount: vec![Coin { + amount: self.amount, + denom: denom.to_string(), + }], + } + .into(), + Denom::Cw20(address) => WasmMsg::Execute { + contract_addr: address.to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: depositor.to_string(), + amount: self.amount, + })?, + funds: vec![], + } + .into(), + }; + Ok(message) + } +} diff --git a/packages/cw3/src/lib.rs b/packages/cw3/src/lib.rs index bda9d2d93..17d3c1b6f 100644 --- a/packages/cw3/src/lib.rs +++ b/packages/cw3/src/lib.rs @@ -1,10 +1,14 @@ // mod helpers; +mod deposit; mod helpers; mod msg; +mod proposal; mod query; +pub use crate::deposit::{DepositError, DepositInfo, UncheckedDepositInfo}; pub use crate::helpers::Cw3Contract; pub use crate::msg::{Cw3ExecuteMsg, Vote}; +pub use crate::proposal::{Ballot, Proposal, Votes}; pub use crate::query::{ Cw3QueryMsg, ProposalListResponse, ProposalResponse, Status, VoteInfo, VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, diff --git a/packages/cw3/src/proposal.rs b/packages/cw3/src/proposal.rs new file mode 100644 index 000000000..2f0b4a49a --- /dev/null +++ b/packages/cw3/src/proposal.rs @@ -0,0 +1,579 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{Addr, BlockInfo, CosmosMsg, Decimal, Empty, Uint128}; +use cw_utils::{Expiration, Threshold}; + +use crate::{DepositInfo, Status, Vote}; + +// we multiply by this when calculating needed_votes in order to round up properly +// Note: `10u128.pow(9)` fails as "u128::pow` is not yet stable as a const fn" +const PRECISION_FACTOR: u128 = 1_000_000_000; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Proposal { + pub title: String, + pub description: String, + pub start_height: u64, + pub expires: Expiration, + pub msgs: Vec>, + pub status: Status, + /// pass requirements + pub threshold: Threshold, + // the total weight when the proposal started (used to calculate percentages) + pub total_weight: u64, + // summary of existing votes + pub votes: Votes, + /// The address that created the proposal. + pub proposer: Addr, + /// The deposit that was paid along with this proposal. This may + /// be refunded upon proposal completion. + pub deposit: Option, +} + +impl Proposal { + /// current_status is non-mutable and returns what the status should be. + /// (designed for queries) + pub fn current_status(&self, block: &BlockInfo) -> Status { + let mut status = self.status; + + // if open, check if voting is passed or timed out + if status == Status::Open && self.is_passed(block) { + status = Status::Passed; + } + if status == Status::Open && (self.is_rejected(block) || self.expires.is_expired(block)) { + status = Status::Rejected; + } + + status + } + + /// update_status sets the status of the proposal to current_status. + /// (designed for handler logic) + pub fn update_status(&mut self, block: &BlockInfo) { + self.status = self.current_status(block); + } + + /// Returns true if this proposal is sure to pass (even before expiration, if no future + /// sequence of possible votes could cause it to fail). + pub fn is_passed(&self, block: &BlockInfo) -> bool { + match self.threshold { + Threshold::AbsoluteCount { + weight: weight_needed, + } => self.votes.yes >= weight_needed, + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => { + self.votes.yes + >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed) + } + Threshold::ThresholdQuorum { threshold, quorum } => { + // we always require the quorum + if self.votes.total() < votes_needed(self.total_weight, quorum) { + return false; + } + if self.expires.is_expired(block) { + // If expired, we compare vote_count against the total number of votes (minus abstain). + let opinions = self.votes.total() - self.votes.abstain; + self.votes.yes >= votes_needed(opinions, threshold) + } else { + // If not expired, we must assume all non-votes will be cast against + let possible_opinions = self.total_weight - self.votes.abstain; + self.votes.yes >= votes_needed(possible_opinions, threshold) + } + } + } + } + + /// Returns true if this proposal is sure to be rejected (even before expiration, if + /// no future sequence of possible votes could cause it to pass). + pub fn is_rejected(&self, block: &BlockInfo) -> bool { + match self.threshold { + Threshold::AbsoluteCount { + weight: weight_needed, + } => { + let weight = self.total_weight - weight_needed; + self.votes.no > weight + } + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => { + self.votes.no + > votes_needed( + self.total_weight - self.votes.abstain, + Decimal::one() - percentage_needed, + ) + } + Threshold::ThresholdQuorum { + threshold, + quorum: _, + } => { + if self.expires.is_expired(block) { + // If expired, we compare vote_count against the total number of votes (minus abstain). + let opinions = self.votes.total() - self.votes.abstain; + self.votes.no > votes_needed(opinions, Decimal::one() - threshold) + } else { + // If not expired, we must assume all non-votes will be cast for + let possible_opinions = self.total_weight - self.votes.abstain; + self.votes.no > votes_needed(possible_opinions, Decimal::one() - threshold) + } + } + } + } +} + +// weight of votes for each option +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Votes { + pub yes: u64, + pub no: u64, + pub abstain: u64, + pub veto: u64, +} + +impl Votes { + /// sum of all votes + pub fn total(&self) -> u64 { + self.yes + self.no + self.abstain + self.veto + } + + /// create it with a yes vote for this much + pub fn yes(init_weight: u64) -> Self { + Votes { + yes: init_weight, + no: 0, + abstain: 0, + veto: 0, + } + } + + pub fn add_vote(&mut self, vote: Vote, weight: u64) { + match vote { + Vote::Yes => self.yes += weight, + Vote::Abstain => self.abstain += weight, + Vote::No => self.no += weight, + Vote::Veto => self.veto += weight, + } + } +} + +// this is a helper function so Decimal works with u64 rather than Uint128 +// also, we must *round up* here, as we need 8, not 7 votes to reach 50% of 15 total +fn votes_needed(weight: u64, percentage: Decimal) -> u64 { + let applied = percentage * Uint128::new(PRECISION_FACTOR * weight as u128); + // Divide by PRECISION_FACTOR, rounding up to the nearest integer + ((applied.u128() + PRECISION_FACTOR - 1) / PRECISION_FACTOR) as u64 +} + +// we cast a ballot with our chosen vote and a given weight +// stored under the key that voted +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Ballot { + pub weight: u64, + pub vote: Vote, +} + +#[cfg(test)] +mod test { + use super::*; + use cosmwasm_std::testing::mock_env; + + #[test] + fn count_votes() { + let mut votes = Votes::yes(5); + votes.add_vote(Vote::No, 10); + votes.add_vote(Vote::Veto, 20); + votes.add_vote(Vote::Yes, 30); + votes.add_vote(Vote::Abstain, 40); + + assert_eq!(votes.total(), 105); + assert_eq!(votes.yes, 35); + assert_eq!(votes.no, 10); + assert_eq!(votes.veto, 20); + assert_eq!(votes.abstain, 40); + } + + #[test] + // we ensure this rounds up (as it calculates needed votes) + fn votes_needed_rounds_properly() { + // round up right below 1 + assert_eq!(1, votes_needed(3, Decimal::permille(333))); + // round up right over 1 + assert_eq!(2, votes_needed(3, Decimal::permille(334))); + assert_eq!(11, votes_needed(30, Decimal::permille(334))); + + // exact matches don't round + assert_eq!(17, votes_needed(34, Decimal::percent(50))); + assert_eq!(12, votes_needed(48, Decimal::percent(25))); + } + + fn setup_prop( + threshold: Threshold, + votes: Votes, + total_weight: u64, + is_expired: bool, + ) -> (Proposal, BlockInfo) { + let block = mock_env().block; + let expires = match is_expired { + true => Expiration::AtHeight(block.height - 5), + false => Expiration::AtHeight(block.height + 100), + }; + let prop = Proposal { + title: "Demo".to_string(), + description: "Info".to_string(), + start_height: 100, + expires, + msgs: vec![], + status: Status::Open, + proposer: Addr::unchecked("Proposer"), + deposit: None, + threshold, + total_weight, + votes, + }; + + (prop, block) + } + + fn check_is_passed( + threshold: Threshold, + votes: Votes, + total_weight: u64, + is_expired: bool, + ) -> bool { + let (prop, block) = setup_prop(threshold, votes, total_weight, is_expired); + prop.is_passed(&block) + } + + fn check_is_rejected( + threshold: Threshold, + votes: Votes, + total_weight: u64, + is_expired: bool, + ) -> bool { + let (prop, block) = setup_prop(threshold, votes, total_weight, is_expired); + prop.is_rejected(&block) + } + + #[test] + fn proposal_passed_absolute_count() { + let fixed = Threshold::AbsoluteCount { weight: 10 }; + let mut votes = Votes::yes(7); + votes.add_vote(Vote::Veto, 4); + // same expired or not, total_weight or whatever + assert!(!check_is_passed(fixed.clone(), votes.clone(), 30, false)); + assert!(!check_is_passed(fixed.clone(), votes.clone(), 30, true)); + // a few more yes votes and we are good + votes.add_vote(Vote::Yes, 3); + assert!(check_is_passed(fixed.clone(), votes.clone(), 30, false)); + assert!(check_is_passed(fixed, votes, 30, true)); + } + + #[test] + fn proposal_rejected_absolute_count() { + let fixed = Threshold::AbsoluteCount { weight: 10 }; + let mut votes = Votes::yes(0); + votes.add_vote(Vote::Veto, 4); + votes.add_vote(Vote::No, 7); + // In order to reject the proposal we need no votes > 30 - 10, currently it is not rejected + assert!(!check_is_rejected(fixed.clone(), votes.clone(), 30, false)); + assert!(!check_is_rejected(fixed.clone(), votes.clone(), 30, true)); + // 7 + 14 = 21 > 20, we can now reject + votes.add_vote(Vote::No, 14); + assert!(check_is_rejected(fixed.clone(), votes.clone(), 30, false)); + assert!(check_is_rejected(fixed, votes, 30, true)); + } + + #[test] + fn proposal_passed_absolute_percentage() { + let percent = Threshold::AbsolutePercentage { + percentage: Decimal::percent(50), + }; + let mut votes = Votes::yes(7); + votes.add_vote(Vote::No, 4); + votes.add_vote(Vote::Abstain, 2); + // same expired or not, if yes >= ceiling(0.5 * (total - abstained)) + // 7 of (15-2) passes + assert!(check_is_passed(percent.clone(), votes.clone(), 15, false)); + assert!(check_is_passed(percent.clone(), votes.clone(), 15, true)); + // but 7 of (17-2) fails + assert!(!check_is_passed(percent.clone(), votes.clone(), 17, false)); + + // if the total were a bit lower, this would pass + assert!(check_is_passed(percent.clone(), votes.clone(), 14, false)); + assert!(check_is_passed(percent, votes, 14, true)); + } + + #[test] + fn proposal_rejected_absolute_percentage() { + let percent = Threshold::AbsolutePercentage { + percentage: Decimal::percent(60), + }; + + // 4 YES, 7 NO, 2 ABSTAIN + let mut votes = Votes::yes(4); + votes.add_vote(Vote::No, 7); + votes.add_vote(Vote::Abstain, 2); + + // 15 total voting power + // we need no votes > 0.4 * 15, no votes > 6 + assert!(check_is_rejected(percent.clone(), votes.clone(), 15, false)); + assert!(check_is_rejected(percent.clone(), votes.clone(), 15, true)); + + // 17 total voting power + // we need no votes > 0.4 * 17, no votes > 6.8 + // still rejected + assert!(check_is_rejected(percent.clone(), votes.clone(), 17, false)); + assert!(check_is_rejected(percent.clone(), votes.clone(), 17, true)); + + // Not rejected if total weight is 20 + // as no votes > 0.4 * 18, no votes > 8 + assert!(!check_is_rejected( + percent.clone(), + votes.clone(), + 20, + false + )); + assert!(!check_is_rejected(percent, votes.clone(), 20, true)); + } + + #[test] + fn proposal_passed_quorum() { + let quorum = Threshold::ThresholdQuorum { + threshold: Decimal::percent(50), + quorum: Decimal::percent(40), + }; + // all non-yes votes are counted for quorum + let passing = Votes { + yes: 7, + no: 3, + abstain: 2, + veto: 1, + }; + // abstain votes are not counted for threshold => yes / (yes + no + veto) + let passes_ignoring_abstain = Votes { + yes: 6, + no: 4, + abstain: 5, + veto: 2, + }; + // fails any way you look at it + let failing = Votes { + yes: 6, + no: 5, + abstain: 2, + veto: 2, + }; + + // first, expired (voting period over) + // over quorum (40% of 30 = 12), over threshold (7/11 > 50%) + assert!(check_is_passed(quorum.clone(), passing.clone(), 30, true)); + // under quorum it is not passing (40% of 33 = 13.2 > 13) + assert!(!check_is_passed(quorum.clone(), passing.clone(), 33, true)); + // over quorum, threshold passes if we ignore abstain + // 17 total votes w/ abstain => 40% quorum of 40 total + // 6 yes / (6 yes + 4 no + 2 votes) => 50% threshold + assert!(check_is_passed( + quorum.clone(), + passes_ignoring_abstain.clone(), + 40, + true + )); + // over quorum, but under threshold fails also + assert!(!check_is_passed(quorum.clone(), failing, 20, true)); + + // now, check with open voting period + // would pass if closed, but fail here, as remaining votes no -> fail + assert!(!check_is_passed(quorum.clone(), passing.clone(), 30, false)); + assert!(!check_is_passed( + quorum.clone(), + passes_ignoring_abstain.clone(), + 40, + false + )); + // if we have threshold * total_weight as yes votes this must pass + assert!(check_is_passed(quorum.clone(), passing.clone(), 14, false)); + // all votes have been cast, some abstain + assert!(check_is_passed( + quorum.clone(), + passes_ignoring_abstain, + 17, + false + )); + // 3 votes uncast, if they all vote no, we have 7 yes, 7 no+veto, 2 abstain (out of 16) + assert!(check_is_passed(quorum, passing, 16, false)); + } + + #[test] + fn proposal_rejected_quorum() { + let quorum = Threshold::ThresholdQuorum { + threshold: Decimal::percent(60), + quorum: Decimal::percent(40), + }; + // all non-yes votes are counted for quorum + let rejecting = Votes { + yes: 3, + no: 7, + abstain: 2, + veto: 1, + }; + // abstain votes are not counted for threshold => yes / (yes + no + veto) + let rejected_ignoring_abstain = Votes { + yes: 4, + no: 6, + abstain: 5, + veto: 2, + }; + // fails any way you look at it + let failing = Votes { + yes: 5, + no: 5, + abstain: 2, + veto: 3, + }; + + // first, expired (voting period over) + // over quorum (40% of 30 = 12, 13 votes casted) + // 13 - 2 abstains = 11 + // we need no votes > 0.4 * 11, no votes > 4.4 + // We can reject this + assert!(check_is_rejected( + quorum.clone(), + rejecting.clone(), + 30, + true + )); + + // Under quorum and cannot reject as it is not expired + assert!(!check_is_rejected( + quorum.clone(), + rejecting.clone(), + 50, + false + )); + // Can reject when expired. + assert!(check_is_rejected( + quorum.clone(), + rejecting.clone(), + 50, + true + )); + + // Check edgecase where quorum is not met but we can reject + // 35% vote no + let quorum_edgecase = Threshold::ThresholdQuorum { + threshold: Decimal::percent(67), + quorum: Decimal::percent(40), + }; + assert!(check_is_rejected( + quorum_edgecase, + Votes { + yes: 15, + no: 35, + abstain: 0, + veto: 10 + }, + 100, + true + )); + + // over quorum, threshold passes if we ignore abstain + // 17 total votes > 40% quorum + // 6 no > 0.4 * (6 no + 4 yes + 2 votes) + // 6 > 4.8 + // we can reject + assert!(check_is_rejected( + quorum.clone(), + rejected_ignoring_abstain.clone(), + 40, + true + )); + + // over quorum + // total opinions due to abstains: 13 + // no votes > 0.4 * 13, no votes > 5 to reject, we have 5 exactly so cannot reject + assert!(!check_is_rejected(quorum.clone(), failing, 20, true)); + + // voting period on going + // over quorum (40% of 14 = 5, 13 votes casted) + // 13 - 2 abstains = 11 + // we need no votes > 0.4 * 11, no votes > 4.4 + // We can reject this even when it hasn't expired + assert!(check_is_rejected( + quorum.clone(), + rejecting.clone(), + 14, + false + )); + // all votes have been cast, some abstain + // voting period on going + // over quorum (40% of 17 = 7, 17 casted_ + // 17 - 5 = 12 total opinions + // we need no votes > 0.4 * 12, no votes > 4.8 + // We can reject this even when it hasn't expired + assert!(check_is_rejected( + quorum.clone(), + rejected_ignoring_abstain, + 17, + false + )); + + // 3 votes uncast, if they all vote yes, we have 7 no, 7 yes+veto, 2 abstain (out of 16) + assert!(check_is_rejected(quorum, rejecting, 16, false)); + } + + #[test] + fn quorum_edge_cases() { + // when we pass absolute threshold (everyone else voting no, we pass), but still don't hit quorum + let quorum = Threshold::ThresholdQuorum { + threshold: Decimal::percent(60), + quorum: Decimal::percent(80), + }; + + // try 9 yes, 1 no (out of 15) -> 90% voter threshold, 60% absolute threshold, still no quorum + // doesn't matter if expired or not + let missing_voters = Votes { + yes: 9, + no: 1, + abstain: 0, + veto: 0, + }; + assert!(!check_is_passed( + quorum.clone(), + missing_voters.clone(), + 15, + false + )); + assert!(!check_is_passed(quorum.clone(), missing_voters, 15, true)); + + // 1 less yes, 3 vetos and this passes only when expired + let wait_til_expired = Votes { + yes: 8, + no: 1, + abstain: 0, + veto: 3, + }; + assert!(!check_is_passed( + quorum.clone(), + wait_til_expired.clone(), + 15, + false + )); + assert!(check_is_passed(quorum.clone(), wait_til_expired, 15, true)); + + // 9 yes and 3 nos passes early + let passes_early = Votes { + yes: 9, + no: 3, + abstain: 0, + veto: 0, + }; + assert!(check_is_passed( + quorum.clone(), + passes_early.clone(), + 15, + false + )); + assert!(check_is_passed(quorum, passes_early, 15, true)); + } +} diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index 27850d757..92421318e 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -2,10 +2,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::fmt; -use cosmwasm_std::{CosmosMsg, Empty}; +use cosmwasm_std::{Addr, CosmosMsg, Empty}; use cw_utils::{Expiration, ThresholdResponse}; -use crate::msg::Vote; +use crate::{msg::Vote, DepositInfo}; #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] @@ -63,10 +63,14 @@ where pub msgs: Vec>, pub status: Status, pub expires: Expiration, - /// This is the threshold that is applied to this proposal. Both the rules of the voting contract, - /// as well as the total_weight of the voting group may have changed since this time. That means - /// that the generic `Threshold{}` query does not provide valid information for existing proposals. + /// This is the threshold that is applied to this proposal. Both + /// the rules of the voting contract, as well as the total_weight + /// of the voting group may have changed since this time. That + /// means that the generic `Threshold{}` query does not provide + /// valid information for existing proposals. pub threshold: ThresholdResponse, + pub proposer: Addr, + pub deposit: Option, } #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)]