From e4ff7081b834332a889ba97676ff52ee0f1cf7dd Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 10:30:16 +0100 Subject: [PATCH 01/27] Integrate Threshold into msg/state --- contracts/cw3-flex-multisig/src/contract.rs | 19 +++--- contracts/cw3-flex-multisig/src/error.rs | 8 +-- contracts/cw3-flex-multisig/src/msg.rs | 64 ++++++++++++++++++++- contracts/cw3-flex-multisig/src/state.rs | 40 +++++++++++-- 4 files changed, 109 insertions(+), 22 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index aea23fb82..42edbbe09 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -16,7 +16,9 @@ use cw_storage_plus::Bound; use crate::error::ContractError; use crate::msg::{HandleMsg, InitMsg, QueryMsg}; -use crate::state::{next_id, parse_id, Ballot, Config, Proposal, BALLOTS, CONFIG, PROPOSALS}; +use crate::state::{ + next_id, parse_id, Ballot, Config, Proposal, Votes, BALLOTS, CONFIG, PROPOSALS, +}; // version info for migration info const CONTRACT_NAME: &str = "crates.io:cw3-flex-multisig"; @@ -28,9 +30,6 @@ pub fn init( _info: MessageInfo, msg: InitMsg, ) -> Result { - if msg.required_weight == 0 { - return Err(ContractError::ZeroWeight {}); - } // we just convert to canonical to check if this is a valid format if deps.api.canonical_address(&msg.group_addr).is_err() { return Err(ContractError::InvalidGroup { @@ -40,15 +39,12 @@ pub fn init( let group = Cw4Contract(msg.group_addr); let total_weight = group.total_weight(&deps.querier)?; - - if total_weight < msg.required_weight { - return Err(ContractError::UnreachableWeight {}); - } + msg.threshold.validate(total_weight)?; set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; let cfg = Config { - required_weight: msg.required_weight, + threshold: msg.threshold, max_voting_period: msg.max_voting_period, group_addr: group, }; @@ -122,8 +118,9 @@ pub fn handle_propose( expires, msgs, status, - yes_weight: vote_power, - required_weight: cfg.required_weight, + votes: Votes::new(vote_power), + threshold: cfg.threshold, + total_weight: cfg.group_addr.total_weight(&deps.querier)?, }; let id = next_id(deps.storage)?; PROPOSALS.save(deps.storage, id.into(), &prop)?; diff --git a/contracts/cw3-flex-multisig/src/error.rs b/contracts/cw3-flex-multisig/src/error.rs index 1fe890148..ede33a6cd 100644 --- a/contracts/cw3-flex-multisig/src/error.rs +++ b/contracts/cw3-flex-multisig/src/error.rs @@ -6,11 +6,11 @@ pub enum ContractError { #[error("{0}")] Std(#[from] StdError), - #[error("Required weight cannot be zero")] - ZeroWeight {}, + #[error("Required threshold cannot be zero")] + ZeroThreshold {}, - #[error("Not possible to reach required (passing) weight")] - UnreachableWeight {}, + #[error("Not possible to reach required (passing) threshol")] + UnreachableThreshold {}, #[error("Group contract invalid address '{addr}'")] InvalidGroup { addr: HumanAddr }, diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index af7572f53..79d821795 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -1,7 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use cosmwasm_std::{CosmosMsg, Empty, HumanAddr}; +use crate::error::ContractError; +use cosmwasm_std::{CosmosMsg, Decimal, Empty, HumanAddr}; use cw0::{Duration, Expiration}; use cw3::Vote; use cw4::MemberChangedHookMsg; @@ -10,10 +11,69 @@ use cw4::MemberChangedHookMsg; pub struct InitMsg { // this is the group contract that contains the member list pub group_addr: HumanAddr, - pub required_weight: u64, + pub threshold: Threshold, pub max_voting_period: Duration, } +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Threshold { + /// Declares a total weight needed to pass + /// This usually implies that count_needed is stable, even if total_weight changes + /// eg. 3 of 5 multisig -> 3 of 6 multisig + AbsoluteCount { weight_needed: u64 }, + /// Declares a percentage of the total weight needed to pass + /// This implies the percentage is stable, when total_weight changes + /// eg. at 50.1%, we go from needing 51/100 to needing 101/200 + /// + /// Note: percentage_needed = 60% is different than threshold = 60%, quora = 100% + /// as the first will pass with 60% yes votes and 10% no votes, while the second + /// will require the others to vote anything (no, abstain...) to pass + AbsolutePercentage { percentage_needed: Decimal }, + /// Declares a threshold (minimum percentage of votes that must approve) + /// and a quorum (minimum percentage of voter weight that must vote). + /// This allows eg. 25% of total weight YES to pass, if we have quorum of 40% + /// and threshold of 51% and most of the people sit out the election. + /// This is more common in general elections where participation is expected + /// to be low. + ThresholdQuora { threshold: Decimal, quroum: Decimal }, +} + +impl Threshold { + /// returns error if this is an unreachable value, + /// given a total weight of all members in the group + pub fn validate(&self, total_weight: u64) -> Result<(), ContractError> { + match self { + Threshold::AbsoluteCount { weight_needed } => { + if weight_needed == 0 { + Err(ContractError::ZeroThreshold {}) + } else if *weight_needed > total_weight { + Err(ContractError::UnreachableThreshold {}) + } else { + Ok(()) + } + } + Threshold::AbsolutePercentage { percentage_needed } => { + valid_percentage(percentage_needed) + } + Threshold::ThresholdQuora { threshold, quroum } => { + valid_percentage(threshold)?; + valid_percentage(quroum) + } + }; + } +} + +fn valid_percentage(percent: &Decimal) -> Result<(), ContractError> { + if percent.is_zero() { + Err(ContractError::ZeroThreshold {}) + } else if *percent > Decimal::one() { + Err(ContractError::UnreachableThreshold {}) + } else { + Ok(()) + } +} + // TODO: add some T variants? Maybe good enough as fixed Empty for now #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 7a4403c17..7e024fc1d 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -9,9 +9,11 @@ use cw3::{Status, Vote}; use cw4::Cw4Contract; use cw_storage_plus::{Item, Map, U64Key}; +use crate::msg::Threshold; + #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct Config { - pub required_weight: u64, + pub threshold: Threshold, pub max_voting_period: Duration, // Total weight and voters are queried from this contract pub group_addr: Cw4Contract, @@ -25,10 +27,38 @@ pub struct Proposal { pub expires: Expiration, pub msgs: Vec>, pub status: Status, - /// how many votes have already said yes - pub yes_weight: u64, - /// how many votes needed to pass - pub required_weight: u64, + /// 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, +} + +// 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 new(init_weight: u64) -> Self { + Votes { + yes: init_weight, + no: 0, + abstain: 0, + veto: 0, + } + } } impl Proposal { From 185b6abdffc8b2329078cd94d04ad1437dc5add5 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 11:35:07 +0100 Subject: [PATCH 02/27] cw3-flex compiles with threshold --- .../cw3-flex-multisig/schema/init_msg.json | 82 +++++++++++++++++-- contracts/cw3-flex-multisig/src/contract.rs | 31 ++----- contracts/cw3-flex-multisig/src/msg.rs | 27 +++++- contracts/cw3-flex-multisig/src/state.rs | 39 ++++++++- 4 files changed, 147 insertions(+), 32 deletions(-) diff --git a/contracts/cw3-flex-multisig/schema/init_msg.json b/contracts/cw3-flex-multisig/schema/init_msg.json index 99c954f3b..4d7923669 100644 --- a/contracts/cw3-flex-multisig/schema/init_msg.json +++ b/contracts/cw3-flex-multisig/schema/init_msg.json @@ -5,7 +5,7 @@ "required": [ "group_addr", "max_voting_period", - "required_weight" + "threshold" ], "properties": { "group_addr": { @@ -14,13 +14,15 @@ "max_voting_period": { "$ref": "#/definitions/Duration" }, - "required_weight": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 + "threshold": { + "$ref": "#/definitions/Threshold" } }, "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, "Duration": { "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", "anyOf": [ @@ -54,6 +56,76 @@ }, "HumanAddr": { "type": "string" + }, + "Threshold": { + "anyOf": [ + { + "description": "Declares a total weight needed to pass This usually implies that count_needed is stable, even if total_weight changes eg. 3 of 5 multisig -> 3 of 6 multisig", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "weight_needed" + ], + "properties": { + "weight_needed": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Declares a percentage of the total weight needed to pass This implies the percentage is stable, when total_weight changes eg. at 50.1%, we go from needing 51/100 to needing 101/200\n\nNote: percentage_needed = 60% is different than threshold = 60%, quora = 100% as the first will pass with 60% yes votes and 10% no votes, while the second will require the others to vote anything (no, abstain...) to pass", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage_needed" + ], + "properties": { + "percentage_needed": { + "$ref": "#/definitions/Decimal" + } + } + } + } + }, + { + "description": "Declares a threshold (minimum percentage of votes that must approve) and a quorum (minimum percentage of voter weight that must vote). This allows eg. 25% of total weight YES to pass, if we have quorum of 40% and threshold of 51% and most of the people sit out the election. This is more common in general elections where participation is expected to be low.", + "type": "object", + "required": [ + "threshold_quora" + ], + "properties": { + "threshold_quora": { + "type": "object", + "required": [ + "quroum", + "threshold" + ], + "properties": { + "quroum": { + "$ref": "#/definitions/Decimal" + }, + "threshold": { + "$ref": "#/definitions/Decimal" + } + } + } + } + } + ] } } } diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 42edbbe09..dc40e7699 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -104,24 +104,19 @@ pub fn handle_propose( return Err(ContractError::WrongExpiration {}); } - let status = if vote_power < cfg.required_weight { - Status::Open - } else { - Status::Passed - }; - // create a proposal - let prop = Proposal { + let mut prop = Proposal { title, description, start_height: env.block.height, expires, msgs, - status, + status: Status::Open, votes: Votes::new(vote_power), threshold: cfg.threshold, total_weight: cfg.group_addr.total_weight(&deps.querier)?, }; + prop.mark_if_passed(); let id = next_id(deps.storage)?; PROPOSALS.save(deps.storage, id.into(), &prop)?; @@ -164,7 +159,7 @@ pub fn handle_vote( return Err(ContractError::Expired {}); } - // use a snapshot of "start of proposal" if available, otherwise, current group weight + // use a snapshot of "start of proposal" let vote_power = cfg .group_addr .member_at_height(&deps.querier, info.sender.clone(), prop.start_height)? @@ -183,15 +178,10 @@ pub fn handle_vote( }, )?; - // if yes vote, update tally - if vote == Vote::Yes { - prop.yes_weight += vote_power; - // update status when the passing vote comes in - if prop.yes_weight >= prop.required_weight { - prop.status = Status::Passed; - } - PROPOSALS.save(deps.storage, proposal_id.into(), &prop)?; - } + // update vote tally + prop.votes.add_vote(vote, vote_power); + prop.mark_if_passed(); + PROPOSALS.save(deps.storage, proposal_id.into(), &prop)?; Ok(HandleResponse { messages: vec![], @@ -313,10 +303,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { fn query_threshold(deps: Deps) -> StdResult { let cfg = CONFIG.load(deps.storage)?; let total_weight = cfg.group_addr.total_weight(&deps.querier)?; - Ok(ThresholdResponse::AbsoluteCount { - weight_needed: cfg.required_weight, - total_weight, - }) + Ok(cfg.threshold.to_response(total_weight)) } fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index 79d821795..c2780cfe0 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::error::ContractError; use cosmwasm_std::{CosmosMsg, Decimal, Empty, HumanAddr}; use cw0::{Duration, Expiration}; -use cw3::Vote; +use cw3::{ThresholdResponse, Vote}; use cw4::MemberChangedHookMsg; #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] @@ -45,7 +45,7 @@ impl Threshold { pub fn validate(&self, total_weight: u64) -> Result<(), ContractError> { match self { Threshold::AbsoluteCount { weight_needed } => { - if weight_needed == 0 { + if *weight_needed == 0 { Err(ContractError::ZeroThreshold {}) } else if *weight_needed > total_weight { Err(ContractError::UnreachableThreshold {}) @@ -60,7 +60,28 @@ impl Threshold { valid_percentage(threshold)?; valid_percentage(quroum) } - }; + } + } + + /// Creates a response from the saved data, just missing the total_weight info + pub fn to_response(&self, total_weight: u64) -> ThresholdResponse { + match self.clone() { + Threshold::AbsoluteCount { weight_needed } => ThresholdResponse::AbsoluteCount { + weight_needed, + total_weight, + }, + Threshold::AbsolutePercentage { percentage_needed } => { + ThresholdResponse::AbsolutePercentage { + percentage_needed, + total_weight, + } + } + Threshold::ThresholdQuora { threshold, quroum } => ThresholdResponse::ThresholdQuora { + threshold, + quroum, + total_weight, + }, + } } } diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 7e024fc1d..2458d3149 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -2,7 +2,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::convert::TryInto; -use cosmwasm_std::{BlockInfo, CosmosMsg, Empty, StdError, StdResult, Storage}; +use cosmwasm_std::{BlockInfo, CosmosMsg, Decimal, Empty, StdError, StdResult, Storage, Uint128}; use cw0::{Duration, Expiration}; use cw3::{Status, Vote}; @@ -59,6 +59,15 @@ impl Votes { 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, + } + } } impl Proposal { @@ -66,7 +75,7 @@ impl Proposal { let mut status = self.status; // if open, check if voting is passed or timed out - if status == Status::Open && self.yes_weight >= self.required_weight { + if status == Status::Open && self.is_passed() { status = Status::Passed; } if status == Status::Open && self.expires.is_expired(block) { @@ -75,6 +84,32 @@ impl Proposal { status } + + pub fn mark_if_passed(&mut self) { + if self.is_passed() { + self.status = Status::Passed; + } + } + + pub fn is_passed(&self) -> bool { + match self.threshold { + Threshold::AbsoluteCount { weight_needed } => self.votes.yes >= weight_needed, + Threshold::AbsolutePercentage { percentage_needed } => { + self.votes.yes >= apply_percentage(self.total_weight, percentage_needed) + } + Threshold::ThresholdQuora { threshold, quroum } => { + let total = self.votes.total(); + total >= apply_percentage(self.total_weight, quroum) + && self.votes.yes >= apply_percentage(total, threshold) + } + } + } +} + +// this is a helper function so Decimal works with u64 rather than Uint128 +fn apply_percentage(weight: u64, percentage: Decimal) -> u64 { + let applied = percentage * Uint128(weight as u128); + applied.u128() as u64 } // we cast a ballot with our chosen vote and a given weight From 09aff63af7c933e75eb88341eb468a82dcc74baf Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 11:38:25 +0100 Subject: [PATCH 03/27] Existing tests updated and pass --- contracts/cw3-flex-multisig/src/contract.rs | 35 ++++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index dc40e7699..e6ca1e84b 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -446,6 +446,7 @@ mod tests { use cw_multi_test::{next_block, App, Contract, ContractWrapper, SimpleBank}; use super::*; + use crate::msg::Threshold; const OWNER: &str = "admin0001"; const VOTER1: &str = "voter0001"; @@ -500,16 +501,30 @@ mod tests { } // uploads code and returns address of group contract + fn init_flex_static( + app: &mut App, + group: HumanAddr, + weight_needed: u64, + max_voting_period: Duration, + ) -> HumanAddr { + init_flex( + app, + group, + Threshold::AbsoluteCount { weight_needed }, + max_voting_period, + ) + } + fn init_flex( app: &mut App, group: HumanAddr, - required_weight: u64, + threshold: Threshold, max_voting_period: Duration, ) -> HumanAddr { let flex_id = app.store_code(contract_flex()); let msg = crate::msg::InitMsg { group_addr: group, - required_weight, + threshold, max_voting_period, }; app.instantiate_contract(flex_id, OWNER, &msg, &[], "flex") @@ -539,7 +554,8 @@ mod tests { app.update_block(next_block); // 2. Set up Multisig backed by this group - let flex_addr = init_flex(app, group_addr.clone(), required_weight, max_voting_period); + let flex_addr = + init_flex_static(app, group_addr.clone(), required_weight, max_voting_period); app.update_block(next_block); // 3. (Optional) Set the multisig as the group owner @@ -587,18 +603,21 @@ mod tests { // Zero required weight fails let init_msg = InitMsg { group_addr: group_addr.clone(), - required_weight: 0, + threshold: Threshold::AbsoluteCount { weight_needed: 0 }, max_voting_period, }; let res = app.instantiate_contract(flex_id, OWNER, &init_msg, &[], "zero required weight"); // Verify - assert_eq!(res.unwrap_err(), ContractError::ZeroWeight {}.to_string()); + assert_eq!( + res.unwrap_err(), + ContractError::ZeroThreshold {}.to_string() + ); // Total weight less than required weight not allowed let init_msg = InitMsg { group_addr: group_addr.clone(), - required_weight: 100, + threshold: Threshold::AbsoluteCount { weight_needed: 100 }, max_voting_period, }; let res = app.instantiate_contract(flex_id, OWNER, &init_msg, &[], "high required weight"); @@ -606,13 +625,13 @@ mod tests { // Verify assert_eq!( res.unwrap_err(), - ContractError::UnreachableWeight {}.to_string() + ContractError::UnreachableThreshold {}.to_string() ); // All valid let init_msg = InitMsg { group_addr: group_addr.clone(), - required_weight: 1, + threshold: Threshold::AbsoluteCount { weight_needed: 1 }, max_voting_period, }; let flex_addr = app From b1e05c1ba53ce5ba1f78873d8fff5f6eff614ad9 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 12:15:17 +0100 Subject: [PATCH 04/27] Test cases on message validation --- contracts/cw3-flex-multisig/src/msg.rs | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index c2780cfe0..4f1a0a2b8 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -155,3 +155,94 @@ pub enum QueryMsg { limit: Option, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_percentage() { + // TODO: test the error messages + + // 0 is never a valid percentage + assert!(valid_percentage(&Decimal::zero()).is_err()); + + // 100% is + valid_percentage(&Decimal::one()).unwrap(); + + // 101% is not + assert!(valid_percentage(&Decimal::percent(101)).is_err()); + // not 100.1% + assert!(valid_percentage(&Decimal::permille(1001)).is_err()); + + // other values in between 0 and 1 are valid + valid_percentage(&Decimal::permille(1)).unwrap(); + valid_percentage(&Decimal::percent(17)).unwrap(); + valid_percentage(&Decimal::percent(99)).unwrap(); + } + + #[test] + fn validate_threshold() { + // absolute count ensures 0 < required <= total_weight + let err = Threshold::AbsoluteCount { weight_needed: 0 } + .validate(5) + .unwrap_err(); + // TODO: remove to_string() when PartialEq implemented + assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); + let err = Threshold::AbsoluteCount { weight_needed: 6 } + .validate(5) + .unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); + + Threshold::AbsoluteCount { weight_needed: 1 } + .validate(5) + .unwrap(); + Threshold::AbsoluteCount { weight_needed: 5 } + .validate(5) + .unwrap(); + + // AbsolutePercentage just enforces valid_percentage (tested above) + let err = Threshold::AbsolutePercentage { + percentage_needed: Decimal::zero(), + } + .validate(5) + .unwrap_err(); + assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); + Threshold::AbsolutePercentage { + percentage_needed: Decimal::percent(51), + } + .validate(5) + .unwrap(); + + // Quorum enforces both valid just enforces valid_percentage (tested above) + Threshold::ThresholdQuora { + threshold: Decimal::percent(51), + quroum: Decimal::percent(40), + } + .validate(5) + .unwrap(); + let err = Threshold::ThresholdQuora { + threshold: Decimal::percent(101), + quroum: Decimal::percent(40), + } + .validate(5) + .unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); + let err = Threshold::ThresholdQuora { + threshold: Decimal::percent(51), + quroum: Decimal::percent(0), + } + .validate(5) + .unwrap_err(); + assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); + } + + #[test] + fn threshold_response() {} +} From 4e1ec118ba0b3619203c40b825ba08de3a722709 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 12:39:25 +0100 Subject: [PATCH 05/27] All message logic tested --- contracts/cw3-flex-multisig/src/msg.rs | 54 ++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index 4f1a0a2b8..ceea7ebe8 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -165,15 +165,24 @@ mod tests { // TODO: test the error messages // 0 is never a valid percentage - assert!(valid_percentage(&Decimal::zero()).is_err()); + let err = valid_percentage(&Decimal::zero()).unwrap_err(); + assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); // 100% is valid_percentage(&Decimal::one()).unwrap(); // 101% is not - assert!(valid_percentage(&Decimal::percent(101)).is_err()); + let err = valid_percentage(&Decimal::percent(101)).unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); // not 100.1% - assert!(valid_percentage(&Decimal::permille(1001)).is_err()); + let err = valid_percentage(&Decimal::permille(1001)).unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); // other values in between 0 and 1 are valid valid_percentage(&Decimal::permille(1)).unwrap(); @@ -244,5 +253,42 @@ mod tests { } #[test] - fn threshold_response() {} + fn threshold_response() { + let total_weight: u64 = 100; + + let res = Threshold::AbsoluteCount { weight_needed: 42 }.to_response(total_weight); + assert_eq!( + res, + ThresholdResponse::AbsoluteCount { + weight_needed: 42, + total_weight + } + ); + + let res = Threshold::AbsolutePercentage { + percentage_needed: Decimal::percent(51), + } + .to_response(total_weight); + assert_eq!( + res, + ThresholdResponse::AbsolutePercentage { + percentage_needed: Decimal::percent(51), + total_weight + } + ); + + let res = Threshold::ThresholdQuora { + threshold: Decimal::percent(66), + quroum: Decimal::percent(50), + } + .to_response(total_weight); + assert_eq!( + res, + ThresholdResponse::ThresholdQuora { + threshold: Decimal::percent(66), + quroum: Decimal::percent(50), + total_weight + } + ); + } } From a41b1253dc4a7249f2b004f40d39204c95e7b7aa Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 22:12:35 +0100 Subject: [PATCH 06/27] Properly calculate passing for Quorum - different if voting open/closed --- contracts/cw3-flex-multisig/src/contract.rs | 4 +-- contracts/cw3-flex-multisig/src/state.rs | 29 +++++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index e6ca1e84b..2d3041d07 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -116,7 +116,7 @@ pub fn handle_propose( threshold: cfg.threshold, total_weight: cfg.group_addr.total_weight(&deps.querier)?, }; - prop.mark_if_passed(); + prop.update_status(&env.block); let id = next_id(deps.storage)?; PROPOSALS.save(deps.storage, id.into(), &prop)?; @@ -180,7 +180,7 @@ pub fn handle_vote( // update vote tally prop.votes.add_vote(vote, vote_power); - prop.mark_if_passed(); + prop.update_status(&env.block); PROPOSALS.save(deps.storage, proposal_id.into(), &prop)?; Ok(HandleResponse { diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 2458d3149..2064c418a 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -71,11 +71,13 @@ impl 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() { + if status == Status::Open && self.is_passed(block) { status = Status::Passed; } if status == Status::Open && self.expires.is_expired(block) { @@ -85,13 +87,15 @@ impl Proposal { status } - pub fn mark_if_passed(&mut self) { - if self.is_passed() { - self.status = Status::Passed; - } + /// 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); } - pub fn is_passed(&self) -> bool { + // returns true iff this proposal is sure to pass (even before expiration if no future + // sequence of possible votes can cause it to fail) + pub fn is_passed(&self, block: &BlockInfo) -> bool { match self.threshold { Threshold::AbsoluteCount { weight_needed } => self.votes.yes >= weight_needed, Threshold::AbsolutePercentage { percentage_needed } => { @@ -99,8 +103,17 @@ impl Proposal { } Threshold::ThresholdQuora { threshold, quroum } => { let total = self.votes.total(); - total >= apply_percentage(self.total_weight, quroum) - && self.votes.yes >= apply_percentage(total, threshold) + // this one is tricky, as we have two compares: + if self.expires.is_expired(block) { + // * if we have closed yet, we need quorum% of total votes to have voted, + // and threshold% of yes votes (from those who voted) + total >= apply_percentage(self.total_weight, quroum) + && self.votes.yes >= apply_percentage(total, threshold) + } else { + // * if we have not closed yet, we need threshold% of yes votes (from 100% voters) + // as we are sure this cannot change with any possible sequence of future votes + self.votes.yes >= apply_percentage(self.total_weight, threshold) + } } } } From 011f27fb7f2be2e39e4e19a25e266f8feb371a8b Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 22:37:12 +0100 Subject: [PATCH 07/27] Test vote/tally logic, fix rounding issue --- contracts/cw3-flex-multisig/src/state.rs | 137 +++++++++++++++++++++-- 1 file changed, 130 insertions(+), 7 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 2064c418a..e3e49f96f 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -99,7 +99,7 @@ impl Proposal { match self.threshold { Threshold::AbsoluteCount { weight_needed } => self.votes.yes >= weight_needed, Threshold::AbsolutePercentage { percentage_needed } => { - self.votes.yes >= apply_percentage(self.total_weight, percentage_needed) + self.votes.yes >= votes_needed(self.total_weight, percentage_needed) } Threshold::ThresholdQuora { threshold, quroum } => { let total = self.votes.total(); @@ -107,12 +107,12 @@ impl Proposal { if self.expires.is_expired(block) { // * if we have closed yet, we need quorum% of total votes to have voted, // and threshold% of yes votes (from those who voted) - total >= apply_percentage(self.total_weight, quroum) - && self.votes.yes >= apply_percentage(total, threshold) + total >= votes_needed(self.total_weight, quroum) + && self.votes.yes >= votes_needed(total, threshold) } else { // * if we have not closed yet, we need threshold% of yes votes (from 100% voters) // as we are sure this cannot change with any possible sequence of future votes - self.votes.yes >= apply_percentage(self.total_weight, threshold) + self.votes.yes >= votes_needed(self.total_weight, threshold) } } } @@ -120,9 +120,17 @@ impl Proposal { } // this is a helper function so Decimal works with u64 rather than Uint128 -fn apply_percentage(weight: u64, percentage: Decimal) -> u64 { - let applied = percentage * Uint128(weight as u128); - applied.u128() as u64 +// 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 { + // we multiply by 1million to detect rounding issues + const FACTOR: u128 = 1_000_000; + let applied = percentage * Uint128(FACTOR * weight as u128); + let rounded = (applied.u128() / FACTOR) as u64; + if applied.u128() % FACTOR > 0 { + rounded + 1 + } else { + rounded + } } // we cast a ballot with our chosen vote and a given weight @@ -155,3 +163,118 @@ pub fn parse_id(data: &[u8]) -> StdResult { )), } } + +#[cfg(test)] +mod test { + use super::*; + use cosmwasm_std::testing::mock_env; + + #[test] + fn count_votes() { + let mut votes = Votes::new(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 check_is_passed( + threshold: Threshold, + votes: Votes, + total_weight: u64, + is_expired: bool, + ) -> bool { + 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.is_passed(&block) + } + + #[test] + fn proposal_passed_absolute_count() { + let fixed = Threshold::AbsoluteCount { weight_needed: 10 }; + let mut votes = Votes::new(7); + votes.add_vote(Vote::Veto, 4); + // same expired or not, total_weight or whatever + assert_eq!( + false, + check_is_passed(fixed.clone(), votes.clone(), 30, false) + ); + assert_eq!( + false, + 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_eq!( + true, + check_is_passed(fixed.clone(), votes.clone(), 30, false) + ); + assert_eq!( + true, + check_is_passed(fixed.clone(), votes.clone(), 30, true) + ); + } + + #[test] + fn proposal_passed_absolute_percentage() { + let percent = Threshold::AbsolutePercentage { + percentage_needed: Decimal::percent(50), + }; + let mut votes = Votes::new(7); + votes.add_vote(Vote::No, 4); + votes.add_vote(Vote::Abstain, 2); + // same expired or not, if total > 2 * yes + assert_eq!( + false, + check_is_passed(percent.clone(), votes.clone(), 15, false) + ); + assert_eq!( + false, + check_is_passed(percent.clone(), votes.clone(), 15, true) + ); + + // if the total were a bit lower, this would pass + assert_eq!( + true, + check_is_passed(percent.clone(), votes.clone(), 14, false) + ); + assert_eq!( + true, + check_is_passed(percent.clone(), votes.clone(), 14, true) + ); + } +} From f798ddca498ab89cf0300c26a50a404b2d06045a Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Mon, 14 Dec 2020 22:57:02 +0100 Subject: [PATCH 08/27] Extensive tests and fixes on threshold quorum --- contracts/cw3-flex-multisig/src/state.rs | 93 ++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index e3e49f96f..5494763d8 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -102,17 +102,19 @@ impl Proposal { self.votes.yes >= votes_needed(self.total_weight, percentage_needed) } Threshold::ThresholdQuora { threshold, quroum } => { - let total = self.votes.total(); // this one is tricky, as we have two compares: if self.expires.is_expired(block) { - // * if we have closed yet, we need quorum% of total votes to have voted, - // and threshold% of yes votes (from those who voted) + // * if we have closed yet, we need quorum% of total votes to have voted (counting abstain) + // and threshold% of yes votes from those who voted (ignoring abstain) + let total = self.votes.total(); + let opinions = total - self.votes.abstain; total >= votes_needed(self.total_weight, quroum) - && self.votes.yes >= votes_needed(total, threshold) + && self.votes.yes >= votes_needed(opinions, threshold) } else { - // * if we have not closed yet, we need threshold% of yes votes (from 100% voters) + // * if we have not closed yet, we need threshold% of yes votes (from 100% voters - abstain) // as we are sure this cannot change with any possible sequence of future votes - self.votes.yes >= votes_needed(self.total_weight, threshold) + self.votes.yes + >= votes_needed(self.total_weight - self.votes.abstain, threshold) } } } @@ -277,4 +279,83 @@ mod test { check_is_passed(percent.clone(), votes.clone(), 14, true) ); } + + #[test] + fn proposal_passed_quorum() { + let quorum = Threshold::ThresholdQuora { + threshold: Decimal::percent(50), + quroum: 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_eq!( + true, + check_is_passed(quorum.clone(), passing.clone(), 30, true) + ); + // under quorum it is not passing (40% of 33 = 13.2 > 13) + assert_eq!( + false, + 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_eq!( + true, + check_is_passed(quorum.clone(), passes_ignoring_abstain.clone(), 40, true) + ); + // over quorum, but under threshold fails also + assert_eq!( + false, + check_is_passed(quorum.clone(), failing.clone(), 20, true) + ); + + // now, check with open voting period + // would pass if closed, but fail here, as remaining votes no -> fail + assert_eq!( + false, + check_is_passed(quorum.clone(), passing.clone(), 30, false) + ); + assert_eq!( + false, + check_is_passed(quorum.clone(), passes_ignoring_abstain.clone(), 40, false) + ); + // if we have threshold * total_weight as yes votes this must pass + assert_eq!( + true, + check_is_passed(quorum.clone(), passing.clone(), 14, false) + ); + // all votes have been cast, some abstain + assert_eq!( + true, + check_is_passed(quorum.clone(), passes_ignoring_abstain.clone(), 17, false) + ); + // 3 votes uncast, if they all vote no, we have 7 yes, 7 no+veto, 2 abstain (out of 16) + assert_eq!( + true, + check_is_passed(quorum.clone(), passing.clone(), 16, false) + ); + } } From 7dc1c7557e1a7773b258006af4f2a542d6bab98c Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 11:57:35 +0100 Subject: [PATCH 09/27] Add test coverage to list/reverse proposals --- contracts/cw3-flex-multisig/src/contract.rs | 105 +++++++++++++++++++- packages/multi-test/src/app.rs | 5 + packages/multi-test/src/wasm.rs | 5 + 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 2d3041d07..86d6ed60b 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -575,16 +575,23 @@ mod tests { (flex_addr, group_addr) } - fn pay_somebody_proposal(flex_addr: &HumanAddr) -> HandleMsg { + fn proposal_info(flex_addr: &HumanAddr) -> (Vec>, String, String) { let bank_msg = BankMsg::Send { from_address: flex_addr.clone(), to_address: SOMEBODY.into(), amount: coins(1, "BTC"), }; let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let title = "Pay somebody".to_string(); + let description = "Do I pay her?".to_string(); + (msgs, title, description) + } + + fn pay_somebody_proposal(flex_addr: &HumanAddr) -> HandleMsg { + let (msgs, title, description) = proposal_info(flex_addr); HandleMsg::Propose { - title: "Pay somebody".to_string(), - description: "Do I pay her?".to_string(), + title, + description, msgs, latest: None, } @@ -768,6 +775,98 @@ mod tests { } } + #[test] + fn test_proposal_queries() { + let mut app = mock_app(); + + let required_weight = 3; + let voting_period = Duration::Time(2000000); + let (flex_addr, _) = setup_test_case( + &mut app, + required_weight, + voting_period, + coins(10, "BTC"), + false, + ); + + // create proposal with 1 vote power + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(VOTER1, &flex_addr, &proposal, &[]) + .unwrap(); + let proposal_id1: u64 = res.attributes[2].value.parse().unwrap(); + + // another proposal immediately passes + app.update_block(next_block); + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(VOTER3, &flex_addr, &proposal, &[]) + .unwrap(); + let proposal_id2: u64 = res.attributes[2].value.parse().unwrap(); + + // expire them both + app.update_block(expire(voting_period)); + + // add one more open proposal, 2 votes + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(VOTER2, &flex_addr, &proposal, &[]) + .unwrap(); + let proposal_id3: u64 = res.attributes[2].value.parse().unwrap(); + let proposed_at = app.block_info(); + + // next block, let's query them all... make sure status is properly updated (1 should be rejected in query) + app.update_block(next_block); + let list_query = QueryMsg::ListProposals { + start_after: None, + limit: None, + }; + let res: ProposalListResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &list_query) + .unwrap(); + assert_eq!(3, res.proposals.len()); + + // check the id and status are properly set + let info: Vec<_> = res.proposals.iter().map(|p| (p.id, p.status)).collect(); + let expected_info = vec![ + (proposal_id1, Status::Rejected), + (proposal_id2, Status::Passed), + (proposal_id3, Status::Open), + ]; + assert_eq!(expected_info, info); + + // ensure the common features are set + let (expected_msgs, expected_title, expected_description) = proposal_info(&flex_addr); + for prop in res.proposals { + assert_eq!(prop.title, expected_title); + assert_eq!(prop.description, expected_description); + assert_eq!(prop.msgs, expected_msgs); + } + + // reverse query can get just proposal_id3 + let list_query = QueryMsg::ReverseProposals { + start_before: None, + limit: Some(1), + }; + let res: ProposalListResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &list_query) + .unwrap(); + assert_eq!(1, res.proposals.len()); + + let (msgs, title, description) = proposal_info(&flex_addr); + let expected = ProposalResponse { + id: proposal_id3, + title, + description, + msgs, + expires: voting_period.after(&proposed_at), + status: Status::Open, + }; + assert_eq!(&expected, &res.proposals[0]); + } + #[test] fn test_vote_works() { let mut app = mock_app(); diff --git a/packages/multi-test/src/app.rs b/packages/multi-test/src/app.rs index 501e351e2..8cef115b3 100644 --- a/packages/multi-test/src/app.rs +++ b/packages/multi-test/src/app.rs @@ -97,6 +97,11 @@ impl App { self.wasm.update_block(action); } + /// Returns a copy of the current block_info + pub fn block_info(&self) -> BlockInfo { + self.wasm.block_info() + } + /// This is an "admin" function to let us adjust bank accounts pub fn set_bank_balance( &mut self, diff --git a/packages/multi-test/src/wasm.rs b/packages/multi-test/src/wasm.rs index 9b86f86cc..0a69756c0 100644 --- a/packages/multi-test/src/wasm.rs +++ b/packages/multi-test/src/wasm.rs @@ -159,6 +159,11 @@ impl WasmRouter { action(&mut self.block); } + /// Returns a copy of the current block_info + pub fn block_info(&self) -> BlockInfo { + self.block.clone() + } + pub fn store_code(&mut self, code: Box) -> usize { let idx = self.handlers.len() + 1; self.handlers.insert(idx, code); From 31607dcc9ad0729f29350c13b72dbd1df2cae704 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 12:55:43 +0100 Subject: [PATCH 10/27] Test threshold, vote detail queries --- contracts/cw3-flex-multisig/src/contract.rs | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 86d6ed60b..e6924c867 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -978,6 +978,41 @@ mod tests { .execute_contract(VOTER5, &flex_addr, &yes_vote, &[]) .unwrap_err(); assert_eq!(err, ContractError::NotOpen {}.to_string()); + + // query individual votes + // initial (with 0 weight) + let voter = OWNER.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert_eq!( + vote, + VoteResponse { + vote: Some(Vote::Yes) + } + ); + + // nay sayer + let voter = VOTER2.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert_eq!( + vote, + VoteResponse { + vote: Some(Vote::No) + } + ); + + // non-voter + let voter = VOTER5.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert_eq!(vote, VoteResponse { vote: None }); } #[test] @@ -1151,6 +1186,17 @@ mod tests { // 1/4 votes assert_eq!(prop_status(&app, proposal_id), Status::Open); + // check current threshold (global) + let threshold: ThresholdResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Threshold {}) + .unwrap(); + let expected_thresh = ThresholdResponse::AbsoluteCount { + weight_needed: 4, + total_weight: 15, + }; + assert_eq!(expected_thresh, threshold); + // a few blocks later... app.update_block(|block| block.height += 2); @@ -1218,6 +1264,19 @@ mod tests { app.execute_contract(VOTER3, &flex_addr, &yes_vote, &[]) .unwrap(); assert_eq!(prop_status(&app, proposal_id), Status::Passed); + + // check current threshold (global) is updated + let threshold: ThresholdResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Threshold {}) + .unwrap(); + let expected_thresh = ThresholdResponse::AbsoluteCount { + weight_needed: 4, + total_weight: 19, + }; + assert_eq!(expected_thresh, threshold); + + // TODO: check proposal threshold not changed } // uses the power from the beginning of the voting period @@ -1326,4 +1385,11 @@ mod tests { .unwrap_err(); assert_eq!(err, ContractError::Unauthorized {}.to_string()); } + + // TODO: scenario tests + // TODO: query threshold + + // TODO: issue: + // - add threshold to proposal response + // - include weight in VoteResponse } From 65a7ee8f00e5f2f5224191df4f565e09df39873c Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 13:32:19 +0100 Subject: [PATCH 11/27] Scenario test with absolute percentage and dynamic group --- contracts/cw3-flex-multisig/src/contract.rs | 134 +++++++++++++++----- 1 file changed, 104 insertions(+), 30 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index e6924c867..93f5c915d 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -437,7 +437,7 @@ fn list_voters( #[cfg(test)] mod tests { use cosmwasm_std::testing::{mock_env, MockApi, MockStorage}; - use cosmwasm_std::{coin, coins, BankMsg, Coin}; + use cosmwasm_std::{coin, coins, BankMsg, Coin, Decimal}; use cw0::Duration; use cw2::{query_contract_info, ContractVersion}; @@ -500,21 +500,6 @@ mod tests { .unwrap() } - // uploads code and returns address of group contract - fn init_flex_static( - app: &mut App, - group: HumanAddr, - weight_needed: u64, - max_voting_period: Duration, - ) -> HumanAddr { - init_flex( - app, - group, - Threshold::AbsoluteCount { weight_needed }, - max_voting_period, - ) - } - fn init_flex( app: &mut App, group: HumanAddr, @@ -534,9 +519,25 @@ mod tests { // this will set up both contracts, initializing the group with // all voters defined above, and the multisig pointing to it and given threshold criteria. // Returns (multisig address, group address). + fn setup_test_case_fixed( + app: &mut App, + weight_needed: u64, + max_voting_period: Duration, + init_funds: Vec, + multisig_as_group_admin: bool, + ) -> (HumanAddr, HumanAddr) { + setup_test_case( + app, + Threshold::AbsoluteCount { weight_needed }, + max_voting_period, + init_funds, + multisig_as_group_admin, + ) + } + fn setup_test_case( app: &mut App, - required_weight: u64, + threshold: Threshold, max_voting_period: Duration, init_funds: Vec, multisig_as_group_admin: bool, @@ -554,8 +555,7 @@ mod tests { app.update_block(next_block); // 2. Set up Multisig backed by this group - let flex_addr = - init_flex_static(app, group_addr.clone(), required_weight, max_voting_period); + let flex_addr = init_flex(app, group_addr.clone(), threshold, max_voting_period); app.update_block(next_block); // 3. (Optional) Set the multisig as the group owner @@ -681,7 +681,7 @@ mod tests { let required_weight = 4; let voting_period = Duration::Time(2000000); - let (flex_addr, _) = setup_test_case( + let (flex_addr, _) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -781,7 +781,7 @@ mod tests { let required_weight = 3; let voting_period = Duration::Time(2000000); - let (flex_addr, _) = setup_test_case( + let (flex_addr, _) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -873,7 +873,7 @@ mod tests { let required_weight = 3; let voting_period = Duration::Time(2000000); - let (flex_addr, _) = setup_test_case( + let (flex_addr, _) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -1021,7 +1021,7 @@ mod tests { let required_weight = 3; let voting_period = Duration::Time(2000000); - let (flex_addr, _) = setup_test_case( + let (flex_addr, _) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -1106,7 +1106,7 @@ mod tests { let required_weight = 3; let voting_period = Duration::Height(2000000); - let (flex_addr, _) = setup_test_case( + let (flex_addr, _) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -1159,7 +1159,7 @@ mod tests { let required_weight = 4; let voting_period = Duration::Time(20000); - let (flex_addr, group_addr) = setup_test_case( + let (flex_addr, group_addr) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -1288,7 +1288,7 @@ mod tests { let required_weight = 4; let voting_period = Duration::Time(20000); - let (flex_addr, group_addr) = setup_test_case( + let (flex_addr, group_addr) = setup_test_case_fixed( &mut app, required_weight, voting_period, @@ -1387,9 +1387,83 @@ mod tests { } // TODO: scenario tests - // TODO: query threshold + // uses the power from the beginning of the voting period + #[test] + fn percentage_handles_group_changes() { + let mut app = mock_app(); - // TODO: issue: - // - add threshold to proposal response - // - include weight in VoteResponse + // 33% required, which is 5 of the initial 15 + let voting_period = Duration::Time(20000); + let (flex_addr, group_addr) = setup_test_case( + &mut app, + Threshold::AbsolutePercentage { + percentage_needed: Decimal::percent(33), + }, + voting_period, + coins(10, "BTC"), + false, + ); + + // VOTER3 starts a proposal to send some tokens (3/5 votes) + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(VOTER3, &flex_addr, &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + let prop_status = |app: &App| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + + // 3/5 votes + assert_eq!(prop_status(&app), Status::Open); + + // a few blocks later... + app.update_block(|block| block.height += 2); + + // admin changes the group (3 -> 0, 2 -> 7, 0 -> 15) - total = 32, require 11 to pass + let newbie: &str = "newbie"; + let update_msg = cw4_group::msg::HandleMsg::UpdateMembers { + remove: vec![VOTER3.into()], + add: vec![member(VOTER2, 7), member(newbie, 15)], + }; + app.execute_contract(OWNER, &group_addr, &update_msg, &[]) + .unwrap(); + + // a few blocks later... + app.update_block(|block| block.height += 3); + + // VOTER2 votes according to original weights: 3 + 2 = 5 / 5 => Passed + // with updated weights, it would be 3 + 7 = 10 / 11 => Open + let yes_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(VOTER2, &flex_addr, &yes_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app), Status::Passed); + + // new proposal can be passed single-handedly by newbie + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(newbie, &flex_addr, &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id2: u64 = res.attributes[2].value.parse().unwrap(); + + // check proposal2 status + let query_prop = QueryMsg::Proposal { + proposal_id: proposal_id2, + }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + assert_eq!(Status::Passed, prop.status); + } } From 01a77e7230d9c72b1bf4e8705bea3fec2ca7d4e1 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 13:37:23 +0100 Subject: [PATCH 12/27] Add scenario tests for percentage and quorum threshold variants --- contracts/cw3-flex-multisig/src/contract.rs | 70 ++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 93f5c915d..55fb640ac 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -1386,7 +1386,6 @@ mod tests { assert_eq!(err, ContractError::Unauthorized {}.to_string()); } - // TODO: scenario tests // uses the power from the beginning of the voting period #[test] fn percentage_handles_group_changes() { @@ -1466,4 +1465,73 @@ mod tests { .unwrap(); assert_eq!(Status::Passed, prop.status); } + + // uses the power from the beginning of the voting period + #[test] + fn quorum_handles_group_changes() { + let mut app = mock_app(); + + // 33% required for quora, which is 5 of the initial 15 + // 50% yes required to pass early (8 of the initial 15) + let voting_period = Duration::Time(20000); + let (flex_addr, group_addr) = setup_test_case( + &mut app, + Threshold::ThresholdQuora { + threshold: Decimal::percent(50), + quroum: Decimal::percent(33), + }, + voting_period, + coins(10, "BTC"), + false, + ); + + // VOTER3 starts a proposal to send some tokens (3 votes) + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(VOTER3, &flex_addr, &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + let prop_status = |app: &App| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + + // 3/5 votes - not expired + assert_eq!(prop_status(&app), Status::Open); + + // a few blocks later... + app.update_block(|block| block.height += 2); + + // admin changes the group (3 -> 0, 2 -> 7, 0 -> 15) - total = 32, require 11 to pass + let newbie: &str = "newbie"; + let update_msg = cw4_group::msg::HandleMsg::UpdateMembers { + remove: vec![VOTER3.into()], + add: vec![member(VOTER2, 7), member(newbie, 15)], + }; + app.execute_contract(OWNER, &group_addr, &update_msg, &[]) + .unwrap(); + + // a few blocks later... + app.update_block(|block| block.height += 3); + + // VOTER2 votes no, according to original weights: 3 yes, 2 no, 5 total (will pass when expired) + // with updated weights, it would be 3 yes, 7 no, 10 total (will fail when expired) + let yes_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::No, + }; + app.execute_contract(VOTER2, &flex_addr, &yes_vote, &[]) + .unwrap(); + // not expired yet + assert_eq!(prop_status(&app), Status::Open); + + // wait until the vote is over, and see it was passed (met quorum, and threshold of voters) + app.update_block(expire(voting_period)); + assert_eq!(prop_status(&app), Status::Passed); + } } From 478fc3cf172ddd6618ee3e6cdcd5b8bc1b190ede Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 21:31:55 +0100 Subject: [PATCH 13/27] Update typos, rename some fields --- contracts/cw3-fixed-multisig/src/contract.rs | 2 +- .../cw3-flex-multisig/schema/init_msg.json | 16 ++-- contracts/cw3-flex-multisig/src/contract.rs | 18 ++-- contracts/cw3-flex-multisig/src/error.rs | 2 +- contracts/cw3-flex-multisig/src/msg.rs | 82 +++++++++---------- contracts/cw3-flex-multisig/src/state.rs | 21 +++-- packages/cw3/schema/threshold_response.json | 12 +-- packages/cw3/src/query.rs | 9 +- 8 files changed, 83 insertions(+), 79 deletions(-) diff --git a/contracts/cw3-fixed-multisig/src/contract.rs b/contracts/cw3-fixed-multisig/src/contract.rs index b6cc7323b..af246e7b1 100644 --- a/contracts/cw3-fixed-multisig/src/contract.rs +++ b/contracts/cw3-fixed-multisig/src/contract.rs @@ -292,7 +292,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { fn query_threshold(deps: Deps) -> StdResult { let cfg = CONFIG.load(deps.storage)?; Ok(ThresholdResponse::AbsoluteCount { - weight_needed: cfg.required_weight, + weight: cfg.required_weight, total_weight: cfg.total_weight, }) } diff --git a/contracts/cw3-flex-multisig/schema/init_msg.json b/contracts/cw3-flex-multisig/schema/init_msg.json index 4d7923669..8ecd8782e 100644 --- a/contracts/cw3-flex-multisig/schema/init_msg.json +++ b/contracts/cw3-flex-multisig/schema/init_msg.json @@ -60,7 +60,7 @@ "Threshold": { "anyOf": [ { - "description": "Declares a total weight needed to pass This usually implies that count_needed is stable, even if total_weight changes eg. 3 of 5 multisig -> 3 of 6 multisig", + "description": "Declares a total weight needed to pass This usually implies that weight_needed is stable, even if total_weight changes eg. 3 of 5 multisig -> 3 of 6 multisig", "type": "object", "required": [ "absolute_count" @@ -69,10 +69,10 @@ "absolute_count": { "type": "object", "required": [ - "weight_needed" + "weight" ], "properties": { - "weight_needed": { + "weight": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -91,10 +91,10 @@ "absolute_percentage": { "type": "object", "required": [ - "percentage_needed" + "percentage" ], "properties": { - "percentage_needed": { + "percentage": { "$ref": "#/definitions/Decimal" } } @@ -102,7 +102,7 @@ } }, { - "description": "Declares a threshold (minimum percentage of votes that must approve) and a quorum (minimum percentage of voter weight that must vote). This allows eg. 25% of total weight YES to pass, if we have quorum of 40% and threshold of 51% and most of the people sit out the election. This is more common in general elections where participation is expected to be low.", + "description": "Declares a threshold (minimum percentage of votes that must approve) and a quorum (minimum percentage of voter weight that must vote). This allows eg. 20.04% of the total weight yes votes to pass, if we have a quorum of 40% and threshold of 51%, and most of the people sit out the election.\n\nThis is more common in general elections, where participation is expected to be low.", "type": "object", "required": [ "threshold_quora" @@ -111,11 +111,11 @@ "threshold_quora": { "type": "object", "required": [ - "quroum", + "quorum", "threshold" ], "properties": { - "quroum": { + "quorum": { "$ref": "#/definitions/Decimal" }, "threshold": { diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 55fb640ac..0f914349d 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -528,7 +528,9 @@ mod tests { ) -> (HumanAddr, HumanAddr) { setup_test_case( app, - Threshold::AbsoluteCount { weight_needed }, + Threshold::AbsoluteCount { + weight: weight_needed, + }, max_voting_period, init_funds, multisig_as_group_admin, @@ -610,7 +612,7 @@ mod tests { // Zero required weight fails let init_msg = InitMsg { group_addr: group_addr.clone(), - threshold: Threshold::AbsoluteCount { weight_needed: 0 }, + threshold: Threshold::AbsoluteCount { weight: 0 }, max_voting_period, }; let res = app.instantiate_contract(flex_id, OWNER, &init_msg, &[], "zero required weight"); @@ -624,7 +626,7 @@ mod tests { // Total weight less than required weight not allowed let init_msg = InitMsg { group_addr: group_addr.clone(), - threshold: Threshold::AbsoluteCount { weight_needed: 100 }, + threshold: Threshold::AbsoluteCount { weight: 100 }, max_voting_period, }; let res = app.instantiate_contract(flex_id, OWNER, &init_msg, &[], "high required weight"); @@ -638,7 +640,7 @@ mod tests { // All valid let init_msg = InitMsg { group_addr: group_addr.clone(), - threshold: Threshold::AbsoluteCount { weight_needed: 1 }, + threshold: Threshold::AbsoluteCount { weight: 1 }, max_voting_period, }; let flex_addr = app @@ -1192,7 +1194,7 @@ mod tests { .query_wasm_smart(&flex_addr, &QueryMsg::Threshold {}) .unwrap(); let expected_thresh = ThresholdResponse::AbsoluteCount { - weight_needed: 4, + weight: 4, total_weight: 15, }; assert_eq!(expected_thresh, threshold); @@ -1271,7 +1273,7 @@ mod tests { .query_wasm_smart(&flex_addr, &QueryMsg::Threshold {}) .unwrap(); let expected_thresh = ThresholdResponse::AbsoluteCount { - weight_needed: 4, + weight: 4, total_weight: 19, }; assert_eq!(expected_thresh, threshold); @@ -1396,7 +1398,7 @@ mod tests { let (flex_addr, group_addr) = setup_test_case( &mut app, Threshold::AbsolutePercentage { - percentage_needed: Decimal::percent(33), + percentage: Decimal::percent(33), }, voting_period, coins(10, "BTC"), @@ -1478,7 +1480,7 @@ mod tests { &mut app, Threshold::ThresholdQuora { threshold: Decimal::percent(50), - quroum: Decimal::percent(33), + quorum: Decimal::percent(33), }, voting_period, coins(10, "BTC"), diff --git a/contracts/cw3-flex-multisig/src/error.rs b/contracts/cw3-flex-multisig/src/error.rs index ede33a6cd..df1096d3d 100644 --- a/contracts/cw3-flex-multisig/src/error.rs +++ b/contracts/cw3-flex-multisig/src/error.rs @@ -9,7 +9,7 @@ pub enum ContractError { #[error("Required threshold cannot be zero")] ZeroThreshold {}, - #[error("Not possible to reach required (passing) threshol")] + #[error("Not possible to reach required (passing) threshold")] UnreachableThreshold {}, #[error("Group contract invalid address '{addr}'")] diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index ceea7ebe8..ef7f91be8 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -19,9 +19,9 @@ pub struct InitMsg { #[serde(rename_all = "snake_case")] pub enum Threshold { /// Declares a total weight needed to pass - /// This usually implies that count_needed is stable, even if total_weight changes + /// This usually implies that weight_needed is stable, even if total_weight changes /// eg. 3 of 5 multisig -> 3 of 6 multisig - AbsoluteCount { weight_needed: u64 }, + AbsoluteCount { weight: u64 }, /// Declares a percentage of the total weight needed to pass /// This implies the percentage is stable, when total_weight changes /// eg. at 50.1%, we go from needing 51/100 to needing 101/200 @@ -29,14 +29,15 @@ pub enum Threshold { /// Note: percentage_needed = 60% is different than threshold = 60%, quora = 100% /// as the first will pass with 60% yes votes and 10% no votes, while the second /// will require the others to vote anything (no, abstain...) to pass - AbsolutePercentage { percentage_needed: Decimal }, + AbsolutePercentage { percentage: Decimal }, /// Declares a threshold (minimum percentage of votes that must approve) /// and a quorum (minimum percentage of voter weight that must vote). - /// This allows eg. 25% of total weight YES to pass, if we have quorum of 40% - /// and threshold of 51% and most of the people sit out the election. - /// This is more common in general elections where participation is expected + /// This allows eg. 20.04% of the total weight yes votes to pass, if we have + /// a quorum of 40% and threshold of 51%, and most of the people sit out the election. + /// + /// This is more common in general elections, where participation is expected /// to be low. - ThresholdQuora { threshold: Decimal, quroum: Decimal }, + ThresholdQuora { threshold: Decimal, quorum: Decimal }, } impl Threshold { @@ -44,7 +45,9 @@ impl Threshold { /// given a total weight of all members in the group pub fn validate(&self, total_weight: u64) -> Result<(), ContractError> { match self { - Threshold::AbsoluteCount { weight_needed } => { + Threshold::AbsoluteCount { + weight: weight_needed, + } => { if *weight_needed == 0 { Err(ContractError::ZeroThreshold {}) } else if *weight_needed > total_weight { @@ -53,10 +56,13 @@ impl Threshold { Ok(()) } } - Threshold::AbsolutePercentage { percentage_needed } => { - valid_percentage(percentage_needed) - } - Threshold::ThresholdQuora { threshold, quroum } => { + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => valid_percentage(percentage_needed), + Threshold::ThresholdQuora { + threshold, + quorum: quroum, + } => { valid_percentage(threshold)?; valid_percentage(quroum) } @@ -66,19 +72,17 @@ impl Threshold { /// Creates a response from the saved data, just missing the total_weight info pub fn to_response(&self, total_weight: u64) -> ThresholdResponse { match self.clone() { - Threshold::AbsoluteCount { weight_needed } => ThresholdResponse::AbsoluteCount { - weight_needed, + Threshold::AbsoluteCount { weight } => ThresholdResponse::AbsoluteCount { + weight, total_weight, }, - Threshold::AbsolutePercentage { percentage_needed } => { - ThresholdResponse::AbsolutePercentage { - percentage_needed, - total_weight, - } - } - Threshold::ThresholdQuora { threshold, quroum } => ThresholdResponse::ThresholdQuora { + Threshold::AbsolutePercentage { percentage } => ThresholdResponse::AbsolutePercentage { + percentage, + total_weight, + }, + Threshold::ThresholdQuora { threshold, quorum } => ThresholdResponse::ThresholdQuora { threshold, - quroum, + quorum, total_weight, }, } @@ -193,12 +197,12 @@ mod tests { #[test] fn validate_threshold() { // absolute count ensures 0 < required <= total_weight - let err = Threshold::AbsoluteCount { weight_needed: 0 } + let err = Threshold::AbsoluteCount { weight: 0 } .validate(5) .unwrap_err(); // TODO: remove to_string() when PartialEq implemented assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); - let err = Threshold::AbsoluteCount { weight_needed: 6 } + let err = Threshold::AbsoluteCount { weight: 6 } .validate(5) .unwrap_err(); assert_eq!( @@ -206,22 +210,18 @@ mod tests { ContractError::UnreachableThreshold {}.to_string() ); - Threshold::AbsoluteCount { weight_needed: 1 } - .validate(5) - .unwrap(); - Threshold::AbsoluteCount { weight_needed: 5 } - .validate(5) - .unwrap(); + Threshold::AbsoluteCount { weight: 1 }.validate(5).unwrap(); + Threshold::AbsoluteCount { weight: 5 }.validate(5).unwrap(); // AbsolutePercentage just enforces valid_percentage (tested above) let err = Threshold::AbsolutePercentage { - percentage_needed: Decimal::zero(), + percentage: Decimal::zero(), } .validate(5) .unwrap_err(); assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); Threshold::AbsolutePercentage { - percentage_needed: Decimal::percent(51), + percentage: Decimal::percent(51), } .validate(5) .unwrap(); @@ -229,13 +229,13 @@ mod tests { // Quorum enforces both valid just enforces valid_percentage (tested above) Threshold::ThresholdQuora { threshold: Decimal::percent(51), - quroum: Decimal::percent(40), + quorum: Decimal::percent(40), } .validate(5) .unwrap(); let err = Threshold::ThresholdQuora { threshold: Decimal::percent(101), - quroum: Decimal::percent(40), + quorum: Decimal::percent(40), } .validate(5) .unwrap_err(); @@ -245,7 +245,7 @@ mod tests { ); let err = Threshold::ThresholdQuora { threshold: Decimal::percent(51), - quroum: Decimal::percent(0), + quorum: Decimal::percent(0), } .validate(5) .unwrap_err(); @@ -256,37 +256,37 @@ mod tests { fn threshold_response() { let total_weight: u64 = 100; - let res = Threshold::AbsoluteCount { weight_needed: 42 }.to_response(total_weight); + let res = Threshold::AbsoluteCount { weight: 42 }.to_response(total_weight); assert_eq!( res, ThresholdResponse::AbsoluteCount { - weight_needed: 42, + weight: 42, total_weight } ); let res = Threshold::AbsolutePercentage { - percentage_needed: Decimal::percent(51), + percentage: Decimal::percent(51), } .to_response(total_weight); assert_eq!( res, ThresholdResponse::AbsolutePercentage { - percentage_needed: Decimal::percent(51), + percentage: Decimal::percent(51), total_weight } ); let res = Threshold::ThresholdQuora { threshold: Decimal::percent(66), - quroum: Decimal::percent(50), + quorum: Decimal::percent(50), } .to_response(total_weight); assert_eq!( res, ThresholdResponse::ThresholdQuora { threshold: Decimal::percent(66), - quroum: Decimal::percent(50), + quorum: Decimal::percent(50), total_weight } ); diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 5494763d8..9fde918f7 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -97,11 +97,16 @@ impl Proposal { // sequence of possible votes can cause it to fail) pub fn is_passed(&self, block: &BlockInfo) -> bool { match self.threshold { - Threshold::AbsoluteCount { weight_needed } => self.votes.yes >= weight_needed, - Threshold::AbsolutePercentage { percentage_needed } => { - self.votes.yes >= votes_needed(self.total_weight, percentage_needed) - } - Threshold::ThresholdQuora { threshold, quroum } => { + Threshold::AbsoluteCount { + weight: weight_needed, + } => self.votes.yes >= weight_needed, + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => self.votes.yes >= votes_needed(self.total_weight, percentage_needed), + Threshold::ThresholdQuora { + threshold, + quorum: quroum, + } => { // this one is tricky, as we have two compares: if self.expires.is_expired(block) { // * if we have closed yet, we need quorum% of total votes to have voted (counting abstain) @@ -227,7 +232,7 @@ mod test { #[test] fn proposal_passed_absolute_count() { - let fixed = Threshold::AbsoluteCount { weight_needed: 10 }; + let fixed = Threshold::AbsoluteCount { weight: 10 }; let mut votes = Votes::new(7); votes.add_vote(Vote::Veto, 4); // same expired or not, total_weight or whatever @@ -254,7 +259,7 @@ mod test { #[test] fn proposal_passed_absolute_percentage() { let percent = Threshold::AbsolutePercentage { - percentage_needed: Decimal::percent(50), + percentage: Decimal::percent(50), }; let mut votes = Votes::new(7); votes.add_vote(Vote::No, 4); @@ -284,7 +289,7 @@ mod test { fn proposal_passed_quorum() { let quorum = Threshold::ThresholdQuora { threshold: Decimal::percent(50), - quroum: Decimal::percent(40), + quorum: Decimal::percent(40), }; // all non-yes votes are counted for quorum let passing = Votes { diff --git a/packages/cw3/schema/threshold_response.json b/packages/cw3/schema/threshold_response.json index 7b1cd89f0..6ec890706 100644 --- a/packages/cw3/schema/threshold_response.json +++ b/packages/cw3/schema/threshold_response.json @@ -14,7 +14,7 @@ "type": "object", "required": [ "total_weight", - "weight_needed" + "weight" ], "properties": { "total_weight": { @@ -22,7 +22,7 @@ "format": "uint64", "minimum": 0.0 }, - "weight_needed": { + "weight": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -41,11 +41,11 @@ "absolute_percentage": { "type": "object", "required": [ - "percentage_needed", + "percentage", "total_weight" ], "properties": { - "percentage_needed": { + "percentage": { "$ref": "#/definitions/Decimal" }, "total_weight": { @@ -67,12 +67,12 @@ "threshold_quora": { "type": "object", "required": [ - "quroum", + "quorum", "threshold", "total_weight" ], "properties": { - "quroum": { + "quorum": { "$ref": "#/definitions/Decimal" }, "threshold": { diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index e975b2362..653012b4d 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -50,10 +50,7 @@ pub enum ThresholdResponse { /// Declares a total weight needed to pass /// This usually implies that count_needed is stable, even if total_weight changes /// eg. 3 of 5 multisig -> 3 of 6 multisig - AbsoluteCount { - weight_needed: u64, - total_weight: u64, - }, + AbsoluteCount { weight: u64, total_weight: u64 }, /// Declares a percentage of the total weight needed to pass /// This implies the percentage is stable, when total_weight changes /// eg. at 50.1%, we go from needing 51/100 to needing 101/200 @@ -62,7 +59,7 @@ pub enum ThresholdResponse { /// as the first will pass with 60% yes votes and 10% no votes, while the second /// will require the others to vote anything (no, abstain...) to pass AbsolutePercentage { - percentage_needed: Decimal, + percentage: Decimal, total_weight: u64, }, /// Declares a threshold (minimum percentage of votes that must approve) @@ -73,7 +70,7 @@ pub enum ThresholdResponse { /// to be low. ThresholdQuora { threshold: Decimal, - quroum: Decimal, + quorum: Decimal, total_weight: u64, }, } From 3f3b379ce238874ffd01c7ad6b39aa26de915800 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 22:52:44 +0100 Subject: [PATCH 14/27] Properly handle missing quorum and hitting threshold edge case --- contracts/cw3-flex-multisig/src/contract.rs | 57 +++++++++++++++++ contracts/cw3-flex-multisig/src/state.rs | 71 +++++++++++++++++++-- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 0f914349d..016c99912 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -1536,4 +1536,61 @@ mod tests { app.update_block(expire(voting_period)); assert_eq!(prop_status(&app), Status::Passed); } + + #[test] + fn quorum_enforced_even_if_absolute_threshold_met() { + let mut app = mock_app(); + + // 33% required for quora, which is 5 of the initial 15 + // 50% yes required to pass early (8 of the initial 15) + let voting_period = Duration::Time(20000); + let (flex_addr, _) = setup_test_case( + &mut app, + // note that 60% yes is not enough to pass without 20% no as well + Threshold::ThresholdQuora { + threshold: Decimal::percent(60), + quorum: Decimal::percent(80), + }, + voting_period, + coins(10, "BTC"), + false, + ); + + // create proposal + let proposal = pay_somebody_proposal(&flex_addr); + let res = app + .execute_contract(VOTER5, &flex_addr, &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + let prop_status = |app: &App| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + assert_eq!(prop_status(&app), Status::Open); + app.update_block(|block| block.height += 3); + + // reach 60% of yes votes, not enough to pass early (or late) + let yes_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(VOTER4, &flex_addr, &yes_vote, &[]) + .unwrap(); + // 9 of 15 is 60% absolute threshold, but less than 12 (80% quorum needed) + assert_eq!(prop_status(&app), Status::Open); + + // add 3 weight no vote and we hit quorum and this passes + let no_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::No, + }; + app.execute_contract(VOTER3, &flex_addr, &no_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app), Status::Passed); + } } diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 9fde918f7..9ab664af2 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -103,23 +103,22 @@ impl Proposal { Threshold::AbsolutePercentage { percentage: percentage_needed, } => self.votes.yes >= votes_needed(self.total_weight, percentage_needed), - Threshold::ThresholdQuora { - threshold, - quorum: quroum, - } => { + Threshold::ThresholdQuora { threshold, quorum } => { // this one is tricky, as we have two compares: if self.expires.is_expired(block) { // * if we have closed yet, we need quorum% of total votes to have voted (counting abstain) // and threshold% of yes votes from those who voted (ignoring abstain) let total = self.votes.total(); let opinions = total - self.votes.abstain; - total >= votes_needed(self.total_weight, quroum) + total >= votes_needed(self.total_weight, quorum) && self.votes.yes >= votes_needed(opinions, threshold) } else { // * if we have not closed yet, we need threshold% of yes votes (from 100% voters - abstain) // as we are sure this cannot change with any possible sequence of future votes - self.votes.yes - >= votes_needed(self.total_weight - self.votes.abstain, threshold) + // * we also need quorum (which may not always be the case above) + self.votes.total() >= votes_needed(self.total_weight, quorum) + && self.votes.yes + >= votes_needed(self.total_weight - self.votes.abstain, threshold) } } } @@ -363,4 +362,62 @@ mod test { check_is_passed(quorum.clone(), passing.clone(), 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::ThresholdQuora { + 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_eq!( + false, + check_is_passed(quorum.clone(), missing_voters.clone(), 15, false) + ); + assert_eq!( + false, + check_is_passed(quorum.clone(), missing_voters.clone(), 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_eq!( + false, + check_is_passed(quorum.clone(), wait_til_expired.clone(), 15, false) + ); + assert_eq!( + true, + check_is_passed(quorum.clone(), wait_til_expired.clone(), 15, true) + ); + + // 9 yes and 3 nos passes early + let passes_early = Votes { + yes: 9, + no: 3, + abstain: 0, + veto: 0, + }; + assert_eq!( + true, + check_is_passed(quorum.clone(), passes_early.clone(), 15, false) + ); + assert_eq!( + true, + check_is_passed(quorum.clone(), passes_early.clone(), 15, true) + ); + } } From 94af01022ee71a8e66d17503a5169e7722cfd9ed Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 22:01:27 +0100 Subject: [PATCH 15/27] Extensive rustdoc on ThresholdResponse in cw3 spec --- packages/cw3/schema/threshold_response.json | 8 +-- packages/cw3/src/msg.rs | 5 ++ packages/cw3/src/query.rs | 65 +++++++++++++++------ 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/cw3/schema/threshold_response.json b/packages/cw3/schema/threshold_response.json index 6ec890706..f6293814a 100644 --- a/packages/cw3/schema/threshold_response.json +++ b/packages/cw3/schema/threshold_response.json @@ -1,10 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ThresholdResponse", - "description": "This defines the different ways tallies can happen. It can be extended as needed, but once the spec is frozen, these should not be modified. They are designed to be general.", + "description": "This defines the different ways tallies can happen. Every contract should support a subset of these, ideally all.\n\nThe total_weight used for calculating success as well as the weights of each individual voter used in tallying should be snapshotted at the beginning of the block at which the proposal starts (this is likely the responsibility of a correct cw4 implementation).", "anyOf": [ { - "description": "Declares a total weight needed to pass This usually implies that count_needed is stable, even if total_weight changes eg. 3 of 5 multisig -> 3 of 6 multisig", + "description": "Declares that a fixed weight of yes votes is needed to pass. It does not matter how many no votes are cast, or how many do not vote, as long as `weight` yes votes are cast.\n\nThis is the simplest format and usually suitable for small multisigs of trusted parties, like 3 of 5. (weight: 3, total_weight: 5)\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", "type": "object", "required": [ "absolute_count" @@ -32,7 +32,7 @@ } }, { - "description": "Declares a percentage of the total weight needed to pass This implies the percentage is stable, when total_weight changes eg. at 50.1%, we go from needing 51/100 to needing 101/200\n\nNote: percentage_needed = 60% is different than threshold = 60%, quora = 100% as the first will pass with 60% yes votes and 10% no votes, while the second will require the others to vote anything (no, abstain...) to pass", + "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", "type": "object", "required": [ "absolute_percentage" @@ -58,7 +58,7 @@ } }, { - "description": "Declares a threshold (minimum percentage of votes that must approve) and a quorum (minimum percentage of voter weight that must vote). This allows eg. 25% of total weight YES to pass, if we have quorum of 40% and threshold of 51% and most of the people sit out the election. This is more common in general elections where participation is expected to be low.", + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", "type": "object", "required": [ "threshold_quora" diff --git a/packages/cw3/src/msg.rs b/packages/cw3/src/msg.rs index 85acaeffd..cf07e4c84 100644 --- a/packages/cw3/src/msg.rs +++ b/packages/cw3/src/msg.rs @@ -33,9 +33,14 @@ where #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "lowercase")] pub enum Vote { + /// Marks support for the proposal. Yes, + /// Marks opposition to the proposal. No, + /// Marks participation but does not count towards the ratio of support / opposed Abstain, + /// Veto is generally to be treated as a no vote. Some implementations may allow certain + /// voters to be able to Veto, or them to be counted stronger then nos in some way. Veto, } diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index 653012b4d..0d0e35f47 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -42,32 +42,63 @@ pub enum Cw3QueryMsg { } /// This defines the different ways tallies can happen. -/// It can be extended as needed, but once the spec is frozen, -/// these should not be modified. They are designed to be general. +/// Every contract should support a subset of these, ideally all. +/// +/// The total_weight used for calculating success as well as the weights of each +/// individual voter used in tallying should be snapshotted at the beginning of +/// the block at which the proposal starts (this is likely the responsibility of a +/// correct cw4 implementation). #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] pub enum ThresholdResponse { - /// Declares a total weight needed to pass - /// This usually implies that count_needed is stable, even if total_weight changes - /// eg. 3 of 5 multisig -> 3 of 6 multisig + /// Declares that a fixed weight of yes votes is needed to pass. + /// It does not matter how many no votes are cast, or how many do not vote, + /// as long as `weight` yes votes are cast. + /// + /// This is the simplest format and usually suitable for small multisigs of trusted parties, + /// like 3 of 5. (weight: 3, total_weight: 5) + /// + /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. AbsoluteCount { weight: u64, total_weight: u64 }, - /// Declares a percentage of the total weight needed to pass - /// This implies the percentage is stable, when total_weight changes - /// eg. at 50.1%, we go from needing 51/100 to needing 101/200 + + /// Declares a percentage of the total weight that must cast yes votes in order for + /// a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes. + /// + /// This is useful for similar circumstances as `AbsoluteCount`, where we have a relatively + /// small set of voters and participation is required. The advantage here is that if the + /// voting set (group) changes between proposals, the number of votes needed is adjusted + /// accordingly. + /// + /// Example: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. + /// This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the + /// `total_weight` of the group has increased to 9. That proposal will then automatically + /// require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`. /// - /// Note: percentage_needed = 60% is different than threshold = 60%, quora = 100% - /// as the first will pass with 60% yes votes and 10% no votes, while the second - /// will require the others to vote anything (no, abstain...) to pass + /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. AbsolutePercentage { percentage: Decimal, total_weight: u64, }, - /// Declares a threshold (minimum percentage of votes that must approve) - /// and a quorum (minimum percentage of voter weight that must vote). - /// This allows eg. 25% of total weight YES to pass, if we have quorum of 40% - /// and threshold of 51% and most of the people sit out the election. - /// This is more common in general elections where participation is expected - /// to be low. + + /// Declares a `quorum` of the total votes that must participate in the election in order + /// for the vote to be considered at all. Within the votes that were cast, it requires `threshold` + /// in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` + /// but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` + /// and compare that with `threshold` to consider if the proposal was passed. + /// + /// It is rather difficult for a proposal of this type to pass early. That can only happen if + /// the required quorum has been already met, and in the case if all remaining voters were + /// to vote no, the threshold would still be met. + /// + /// 30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% + /// (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting + /// no => 30% yes + 50% no). Once the voting period has passed with no additional votes, + /// that same proposal would be considered successful if quorum <= 60% and threshold <= 75% + /// (percent in favor if we ignore abstain votes). + /// + /// This type is more common in general elections where participation is expected to often + /// be low, and `AbsolutePercentage` would either be too restrictive to pass anything, + /// or allow low percentages to pass if there was high participation in one election. ThresholdQuora { threshold: Decimal, quorum: Decimal, From df6c9d370688de14881855a26829c845c1fa8b61 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 22:17:17 +0100 Subject: [PATCH 16/27] cw3: Update query data types and more rustdoc --- packages/cw3/examples/schema.rs | 4 +- .../cw3/schema/proposal_list_response.json | 102 ++++++++++++++++++ packages/cw3/schema/proposal_response.json | 102 ++++++++++++++++++ packages/cw3/schema/query_msg.json | 14 +-- packages/cw3/schema/vote_list_response.json | 1 + packages/cw3/schema/vote_response.json | 27 ++++- packages/cw3/schema/voter_detail.json | 24 +++++ packages/cw3/schema/voter_info.json | 15 --- packages/cw3/schema/voter_list_response.json | 4 +- packages/cw3/schema/voter_response.json | 17 +-- packages/cw3/src/lib.rs | 2 +- packages/cw3/src/query.rs | 44 +++++--- 12 files changed, 300 insertions(+), 56 deletions(-) create mode 100644 packages/cw3/schema/voter_detail.json delete mode 100644 packages/cw3/schema/voter_info.json diff --git a/packages/cw3/examples/schema.rs b/packages/cw3/examples/schema.rs index 52805db77..2129149d2 100644 --- a/packages/cw3/examples/schema.rs +++ b/packages/cw3/examples/schema.rs @@ -5,7 +5,7 @@ use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, s use cw3::{ Cw3HandleMsg, Cw3QueryMsg, ProposalListResponse, ProposalResponse, ThresholdResponse, - VoteListResponse, VoteResponse, VoterInfo, VoterListResponse, VoterResponse, + VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, }; fn main() { @@ -24,8 +24,8 @@ fn main() { 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!(VoterInfo), &out_dir); export_schema(&schema_for!(VoterResponse), &out_dir); + export_schema(&schema_for!(VoterDetail), &out_dir); export_schema(&schema_for!(VoterListResponse), &out_dir); export_schema(&schema_for!(ThresholdResponse), &out_dir); } diff --git a/packages/cw3/schema/proposal_list_response.json b/packages/cw3/schema/proposal_list_response.json index ee0e9b2c5..256633141 100644 --- a/packages/cw3/schema/proposal_list_response.json +++ b/packages/cw3/schema/proposal_list_response.json @@ -115,6 +115,10 @@ } ] }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, "Empty": { "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", "type": "object" @@ -176,6 +180,7 @@ "id", "msgs", "status", + "threshold", "title" ], "properties": { @@ -199,6 +204,14 @@ "status": { "$ref": "#/definitions/Status" }, + "threshold": { + "description": "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.", + "allOf": [ + { + "$ref": "#/definitions/ThresholdResponse" + } + ] + }, "title": { "type": "string" } @@ -321,6 +334,95 @@ "executed" ] }, + "ThresholdResponse": { + "description": "This defines the different ways tallies can happen. Every contract should support a subset of these, ideally all.\n\nThe total_weight used for calculating success as well as the weights of each individual voter used in tallying should be snapshotted at the beginning of the block at which the proposal starts (this is likely the responsibility of a correct cw4 implementation).", + "anyOf": [ + { + "description": "Declares that a fixed weight of yes votes is needed to pass. It does not matter how many no votes are cast, or how many do not vote, as long as `weight` yes votes are cast.\n\nThis is the simplest format and usually suitable for small multisigs of trusted parties, like 3 of 5. (weight: 3, total_weight: 5)\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "total_weight", + "weight" + ], + "properties": { + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage", + "total_weight" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/Decimal" + }, + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", + "type": "object", + "required": [ + "threshold_quora" + ], + "properties": { + "threshold_quora": { + "type": "object", + "required": [ + "quorum", + "threshold", + "total_weight" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/Decimal" + }, + "threshold": { + "$ref": "#/definitions/Decimal" + }, + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + } + ] + }, "Uint128": { "type": "string" }, diff --git a/packages/cw3/schema/proposal_response.json b/packages/cw3/schema/proposal_response.json index 01865c7aa..703658ff0 100644 --- a/packages/cw3/schema/proposal_response.json +++ b/packages/cw3/schema/proposal_response.json @@ -9,6 +9,7 @@ "id", "msgs", "status", + "threshold", "title" ], "properties": { @@ -32,6 +33,14 @@ "status": { "$ref": "#/definitions/Status" }, + "threshold": { + "description": "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.", + "allOf": [ + { + "$ref": "#/definitions/ThresholdResponse" + } + ] + }, "title": { "type": "string" } @@ -138,6 +147,10 @@ } ] }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, "Empty": { "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", "type": "object" @@ -307,6 +320,95 @@ "executed" ] }, + "ThresholdResponse": { + "description": "This defines the different ways tallies can happen. Every contract should support a subset of these, ideally all.\n\nThe total_weight used for calculating success as well as the weights of each individual voter used in tallying should be snapshotted at the beginning of the block at which the proposal starts (this is likely the responsibility of a correct cw4 implementation).", + "anyOf": [ + { + "description": "Declares that a fixed weight of yes votes is needed to pass. It does not matter how many no votes are cast, or how many do not vote, as long as `weight` yes votes are cast.\n\nThis is the simplest format and usually suitable for small multisigs of trusted parties, like 3 of 5. (weight: 3, total_weight: 5)\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "total_weight", + "weight" + ], + "properties": { + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage", + "total_weight" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/Decimal" + }, + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", + "type": "object", + "required": [ + "threshold_quora" + ], + "properties": { + "threshold_quora": { + "type": "object", + "required": [ + "quorum", + "threshold", + "total_weight" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/Decimal" + }, + "threshold": { + "$ref": "#/definitions/Decimal" + }, + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + } + ] + }, "Uint128": { "type": "string" }, diff --git a/packages/cw3/schema/query_msg.json b/packages/cw3/schema/query_msg.json index ec9215140..2c2e04dc3 100644 --- a/packages/cw3/schema/query_msg.json +++ b/packages/cw3/schema/query_msg.json @@ -3,7 +3,7 @@ "title": "QueryMsg", "anyOf": [ { - "description": "Return ThresholdResponse", + "description": "Returns the threshold rules that would be used for a new proposal that was opened right now. The threshold rules do not change often, but the `total_weight` in the response may easily differ from that used in previously opened proposals. Returns ThresholdResponse.", "type": "object", "required": [ "threshold" @@ -15,7 +15,7 @@ } }, { - "description": "Returns ProposalResponse", + "description": "Returns details of the proposal state. Returns ProposalResponse.", "type": "object", "required": [ "proposal" @@ -37,7 +37,7 @@ } }, { - "description": "Returns ProposalListResponse", + "description": "Iterate over details of all proposals from oldest to newest. Returns ProposalListResponse", "type": "object", "required": [ "list_proposals" @@ -67,7 +67,7 @@ } }, { - "description": "Returns ProposalListResponse", + "description": "Iterate reverse over details of all proposals, this is useful to easily query only the most recent proposals (to get updates). Returns ProposalListResponse", "type": "object", "required": [ "reverse_proposals" @@ -97,7 +97,7 @@ } }, { - "description": "Returns VoteResponse", + "description": "Query the vote made by the given voter on `proposal_id`. This should return an error if there is no such proposal. It will return a None value if the proposal exists but the voter did not vote. Returns VoteResponse", "type": "object", "required": [ "vote" @@ -123,7 +123,7 @@ } }, { - "description": "Returns VoteListResponse", + "description": "Iterate (with pagination) over all votes for this proposal. The ordering is arbitrary, unlikely to be sorted by HumanAddr. But ordering is consistent and pagination from the end of each page will cover all votes for the proposal. Returns VoteListResponse", "type": "object", "required": [ "list_votes" @@ -163,7 +163,7 @@ } }, { - "description": "Voter extension: Returns VoterInfo", + "description": "Voter extension: Returns VoterResponse", "type": "object", "required": [ "voter" diff --git a/packages/cw3/schema/vote_list_response.json b/packages/cw3/schema/vote_list_response.json index 3056a34d6..93f7f9fb6 100644 --- a/packages/cw3/schema/vote_list_response.json +++ b/packages/cw3/schema/vote_list_response.json @@ -27,6 +27,7 @@ ] }, "VoteInfo": { + "description": "Returns the vote (opinion as well as weight counted) as well as the address of the voter who submitted it", "type": "object", "required": [ "vote", diff --git a/packages/cw3/schema/vote_response.json b/packages/cw3/schema/vote_response.json index 71450a748..ea774c58c 100644 --- a/packages/cw3/schema/vote_response.json +++ b/packages/cw3/schema/vote_response.json @@ -6,7 +6,7 @@ "vote": { "anyOf": [ { - "$ref": "#/definitions/Vote" + "$ref": "#/definitions/VoteInfo" }, { "type": "null" @@ -15,6 +15,9 @@ } }, "definitions": { + "HumanAddr": { + "type": "string" + }, "Vote": { "type": "string", "enum": [ @@ -23,6 +26,28 @@ "abstain", "veto" ] + }, + "VoteInfo": { + "description": "Returns the vote (opinion as well as weight counted) as well as the address of the voter who submitted it", + "type": "object", + "required": [ + "vote", + "voter", + "weight" + ], + "properties": { + "vote": { + "$ref": "#/definitions/Vote" + }, + "voter": { + "$ref": "#/definitions/HumanAddr" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } } } } diff --git a/packages/cw3/schema/voter_detail.json b/packages/cw3/schema/voter_detail.json new file mode 100644 index 000000000..ac1c52360 --- /dev/null +++ b/packages/cw3/schema/voter_detail.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoterDetail", + "type": "object", + "required": [ + "addr", + "weight" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "definitions": { + "HumanAddr": { + "type": "string" + } + } +} diff --git a/packages/cw3/schema/voter_info.json b/packages/cw3/schema/voter_info.json deleted file mode 100644 index 67ee35c10..000000000 --- a/packages/cw3/schema/voter_info.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "VoterInfo", - "type": "object", - "properties": { - "weight": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0.0 - } - } -} diff --git a/packages/cw3/schema/voter_list_response.json b/packages/cw3/schema/voter_list_response.json index 24497ce31..b0d80ca28 100644 --- a/packages/cw3/schema/voter_list_response.json +++ b/packages/cw3/schema/voter_list_response.json @@ -9,7 +9,7 @@ "voters": { "type": "array", "items": { - "$ref": "#/definitions/VoterResponse" + "$ref": "#/definitions/VoterDetail" } } }, @@ -17,7 +17,7 @@ "HumanAddr": { "type": "string" }, - "VoterResponse": { + "VoterDetail": { "type": "object", "required": [ "addr", diff --git a/packages/cw3/schema/voter_response.json b/packages/cw3/schema/voter_response.json index 60af46c63..6fefcfb6b 100644 --- a/packages/cw3/schema/voter_response.json +++ b/packages/cw3/schema/voter_response.json @@ -2,23 +2,14 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "VoterResponse", "type": "object", - "required": [ - "addr", - "weight" - ], "properties": { - "addr": { - "$ref": "#/definitions/HumanAddr" - }, "weight": { - "type": "integer", + "type": [ + "integer", + "null" + ], "format": "uint64", "minimum": 0.0 } - }, - "definitions": { - "HumanAddr": { - "type": "string" - } } } diff --git a/packages/cw3/src/lib.rs b/packages/cw3/src/lib.rs index 388dd9dee..3c3b5263b 100644 --- a/packages/cw3/src/lib.rs +++ b/packages/cw3/src/lib.rs @@ -7,7 +7,7 @@ pub use crate::helpers::Cw3Contract; pub use crate::msg::{Cw3HandleMsg, Vote}; pub use crate::query::{ Cw3QueryMsg, ProposalListResponse, ProposalResponse, Status, ThresholdResponse, VoteInfo, - VoteListResponse, VoteResponse, VoterInfo, VoterListResponse, VoterResponse, + VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, }; #[cfg(test)] diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index 0d0e35f47..86c7e9c22 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -10,29 +10,37 @@ use crate::msg::Vote; #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] pub enum Cw3QueryMsg { - /// Return ThresholdResponse + /// Returns the threshold rules that would be used for a new proposal that was + /// opened right now. The threshold rules do not change often, but the `total_weight` + /// in the response may easily differ from that used in previously opened proposals. + /// Returns ThresholdResponse. Threshold {}, - /// Returns ProposalResponse + /// Returns details of the proposal state. Returns ProposalResponse. Proposal { proposal_id: u64 }, - /// Returns ProposalListResponse + /// Iterate over details of all proposals from oldest to newest. Returns ProposalListResponse ListProposals { start_after: Option, limit: Option, }, - /// Returns ProposalListResponse + /// Iterate reverse over details of all proposals, this is useful to easily query + /// only the most recent proposals (to get updates). Returns ProposalListResponse ReverseProposals { start_before: Option, limit: Option, }, - /// Returns VoteResponse + /// Query the vote made by the given voter on `proposal_id`. This should + /// return an error if there is no such proposal. It will return a None value + /// if the proposal exists but the voter did not vote. Returns VoteResponse Vote { proposal_id: u64, voter: HumanAddr }, - /// Returns VoteListResponse + /// Iterate (with pagination) over all votes for this proposal. The ordering is arbitrary, + /// unlikely to be sorted by HumanAddr. But ordering is consistent and pagination from the end + /// of each page will cover all votes for the proposal. Returns VoteListResponse ListVotes { proposal_id: u64, start_after: Option, limit: Option, }, - /// Voter extension: Returns VoterInfo + /// Voter extension: Returns VoterResponse Voter { address: HumanAddr }, /// ListVoters extension: Returns VoterListResponse ListVoters { @@ -118,8 +126,12 @@ where pub title: String, pub description: String, pub msgs: Vec>, - pub expires: Expiration, 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. + pub threshold: ThresholdResponse, } #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)] @@ -148,6 +160,8 @@ pub struct VoteListResponse { pub votes: Vec, } +/// Returns the vote (opinion as well as weight counted) as well as +/// the address of the voter who submitted it #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct VoteInfo { pub voter: HumanAddr, @@ -157,21 +171,21 @@ pub struct VoteInfo { #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct VoteResponse { - pub vote: Option, + pub vote: Option, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] -pub struct VoterInfo { +pub struct VoterResponse { pub weight: Option, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] -pub struct VoterResponse { - pub addr: HumanAddr, - pub weight: u64, +pub struct VoterListResponse { + pub voters: Vec, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] -pub struct VoterListResponse { - pub voters: Vec, +pub struct VoterDetail { + pub addr: HumanAddr, + pub weight: u64, } From 23de1bec8c22c5f2e428656358fb004b18c918d0 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 22:25:16 +0100 Subject: [PATCH 17/27] Update cw3-fixed to match updated cw3 spec --- contracts/cw3-fixed-multisig/src/contract.rs | 46 +++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/contracts/cw3-fixed-multisig/src/contract.rs b/contracts/cw3-fixed-multisig/src/contract.rs index af246e7b1..d81622289 100644 --- a/contracts/cw3-fixed-multisig/src/contract.rs +++ b/contracts/cw3-fixed-multisig/src/contract.rs @@ -9,7 +9,7 @@ use cw0::{maybe_canonical, Expiration}; use cw2::set_contract_version; use cw3::{ ProposalListResponse, ProposalResponse, Status, ThresholdResponse, Vote, VoteInfo, - VoteListResponse, VoteResponse, VoterInfo, VoterListResponse, VoterResponse, + VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, }; use cw_storage_plus::Bound; @@ -300,14 +300,20 @@ fn query_threshold(deps: Deps) -> StdResult { fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { let prop = PROPOSALS.load(deps.storage, id.into())?; let status = prop.current_status(&env.block); + + let cfg = CONFIG.load(deps.storage)?; + let threshold = ThresholdResponse::AbsoluteCount { + weight: cfg.required_weight, + total_weight: cfg.total_weight, + }; Ok(ProposalResponse { id, title: prop.title, description: prop.description, msgs: prop.msgs, - expires: prop.expires, - // TODO: check status, + expires: prop.expires, + threshold, }) } @@ -321,12 +327,18 @@ fn list_proposals( start_after: Option, limit: Option, ) -> StdResult { + let cfg = CONFIG.load(deps.storage)?; + let threshold = ThresholdResponse::AbsoluteCount { + weight: cfg.required_weight, + total_weight: cfg.total_weight, + }; + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; let start = start_after.map(Bound::exclusive_int); let props: StdResult> = PROPOSALS .range(deps.storage, start, None, Order::Ascending) .take(limit) - .map(|p| map_proposal(&env.block, p)) + .map(|p| map_proposal(&env.block, &threshold, p)) .collect(); Ok(ProposalListResponse { proposals: props? }) @@ -338,12 +350,18 @@ fn reverse_proposals( start_before: Option, limit: Option, ) -> StdResult { + let cfg = CONFIG.load(deps.storage)?; + let threshold = ThresholdResponse::AbsoluteCount { + weight: cfg.required_weight, + total_weight: cfg.total_weight, + }; + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; let end = start_before.map(Bound::exclusive_int); let props: StdResult> = PROPOSALS .range(deps.storage, None, end, Order::Descending) .take(limit) - .map(|p| map_proposal(&env.block, p)) + .map(|p| map_proposal(&env.block, &threshold, p)) .collect(); Ok(ProposalListResponse { proposals: props? }) @@ -351,6 +369,7 @@ fn reverse_proposals( fn map_proposal( block: &BlockInfo, + threshold: &ThresholdResponse, item: StdResult<(Vec, Proposal)>, ) -> StdResult { let (key, prop) = item?; @@ -360,15 +379,20 @@ fn map_proposal( title: prop.title, description: prop.description, msgs: prop.msgs, - expires: prop.expires, status, + expires: prop.expires, + threshold: threshold.clone(), }) } fn query_vote(deps: Deps, proposal_id: u64, voter: HumanAddr) -> StdResult { let voter_raw = deps.api.canonical_address(&voter)?; - let prop = BALLOTS.may_load(deps.storage, (proposal_id.into(), &voter_raw))?; - let vote = prop.map(|b| b.vote); + let ballot = BALLOTS.may_load(deps.storage, (proposal_id.into(), &voter_raw))?; + let vote = ballot.map(|b| VoteInfo { + voter, + vote: b.vote, + weight: b.weight, + }); Ok(VoteResponse { vote }) } @@ -400,10 +424,10 @@ fn list_votes( Ok(VoteListResponse { votes: votes? }) } -fn query_voter(deps: Deps, voter: HumanAddr) -> StdResult { +fn query_voter(deps: Deps, voter: HumanAddr) -> StdResult { let voter_raw = deps.api.canonical_address(&voter)?; let weight = VOTERS.may_load(deps.storage, &voter_raw)?; - Ok(VoterInfo { weight }) + Ok(VoterResponse { weight }) } fn list_voters( @@ -421,7 +445,7 @@ fn list_voters( .take(limit) .map(|item| { let (key, weight) = item?; - Ok(VoterResponse { + Ok(VoterDetail { addr: api.human_address(&CanonicalAddr::from(key))?, weight, }) From 09e916386bd94dae09587267d7348c4e0fee7e0d Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Tue, 15 Dec 2020 22:33:52 +0100 Subject: [PATCH 18/27] Update cw3-flex to new cw3 spec --- .../cw3-flex-multisig/schema/handle_msg.json | 2 +- .../cw3-flex-multisig/schema/init_msg.json | 7 +- contracts/cw3-flex-multisig/src/contract.rs | 48 +++++++++----- contracts/cw3-flex-multisig/src/msg.rs | 65 ++++++++++++++----- 4 files changed, 86 insertions(+), 36 deletions(-) diff --git a/contracts/cw3-flex-multisig/schema/handle_msg.json b/contracts/cw3-flex-multisig/schema/handle_msg.json index 1a2c2be90..4e46cad68 100644 --- a/contracts/cw3-flex-multisig/schema/handle_msg.json +++ b/contracts/cw3-flex-multisig/schema/handle_msg.json @@ -110,7 +110,7 @@ } }, { - "description": "handle update hook messages from the group contract", + "description": "Handles update hook messages from the group contract", "type": "object", "required": [ "member_changed_hook" diff --git a/contracts/cw3-flex-multisig/schema/init_msg.json b/contracts/cw3-flex-multisig/schema/init_msg.json index 8ecd8782e..09b64c58d 100644 --- a/contracts/cw3-flex-multisig/schema/init_msg.json +++ b/contracts/cw3-flex-multisig/schema/init_msg.json @@ -58,9 +58,10 @@ "type": "string" }, "Threshold": { + "description": "This defines the different ways tallies can happen.\n\nThe total_weight used for calculating success as well as the weights of each individual voter used in tallying should be snapshotted at the beginning of the block at which the proposal starts (this is likely the responsibility of a correct cw4 implementation).", "anyOf": [ { - "description": "Declares a total weight needed to pass This usually implies that weight_needed is stable, even if total_weight changes eg. 3 of 5 multisig -> 3 of 6 multisig", + "description": "Declares that a fixed weight of yes votes is needed to pass. It does not matter how many no votes are cast, or how many do not vote, as long as `weight` yes votes are cast.\n\nThis is the simplest format and usually suitable for small multisigs of trusted parties, like 3 of 5. (weight: 3, total_weight: 5)\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", "type": "object", "required": [ "absolute_count" @@ -82,7 +83,7 @@ } }, { - "description": "Declares a percentage of the total weight needed to pass This implies the percentage is stable, when total_weight changes eg. at 50.1%, we go from needing 51/100 to needing 101/200\n\nNote: percentage_needed = 60% is different than threshold = 60%, quora = 100% as the first will pass with 60% yes votes and 10% no votes, while the second will require the others to vote anything (no, abstain...) to pass", + "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", "type": "object", "required": [ "absolute_percentage" @@ -102,7 +103,7 @@ } }, { - "description": "Declares a threshold (minimum percentage of votes that must approve) and a quorum (minimum percentage of voter weight that must vote). This allows eg. 20.04% of the total weight yes votes to pass, if we have a quorum of 40% and threshold of 51%, and most of the people sit out the election.\n\nThis is more common in general elections, where participation is expected to be low.", + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", "type": "object", "required": [ "threshold_quora" diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index 016c99912..e4842cba2 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -9,7 +9,7 @@ use cw0::{maybe_canonical, Expiration}; use cw2::set_contract_version; use cw3::{ ProposalListResponse, ProposalResponse, Status, ThresholdResponse, Vote, VoteInfo, - VoteListResponse, VoteResponse, VoterInfo, VoterListResponse, VoterResponse, + VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, VoterResponse, }; use cw4::{Cw4Contract, MemberChangedHookMsg, MemberDiff}; use cw_storage_plus::Bound; @@ -309,13 +309,15 @@ fn query_threshold(deps: Deps) -> StdResult { fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { let prop = PROPOSALS.load(deps.storage, id.into())?; let status = prop.current_status(&env.block); + let threshold = prop.threshold.to_response(prop.total_weight); Ok(ProposalResponse { id, title: prop.title, description: prop.description, msgs: prop.msgs, - expires: prop.expires, status, + expires: prop.expires, + threshold, }) } @@ -363,20 +365,26 @@ fn map_proposal( ) -> StdResult { let (key, prop) = item?; let status = prop.current_status(block); + let threshold = prop.threshold.to_response(prop.total_weight); Ok(ProposalResponse { id: parse_id(&key)?, title: prop.title, description: prop.description, msgs: prop.msgs, - expires: prop.expires, status, + expires: prop.expires, + threshold, }) } fn query_vote(deps: Deps, proposal_id: u64, voter: HumanAddr) -> StdResult { let voter_raw = deps.api.canonical_address(&voter)?; let prop = BALLOTS.may_load(deps.storage, (proposal_id.into(), &voter_raw))?; - let vote = prop.map(|b| b.vote); + let vote = prop.map(|b| VoteInfo { + voter, + vote: b.vote, + weight: b.weight, + }); Ok(VoteResponse { vote }) } @@ -408,12 +416,12 @@ fn list_votes( Ok(VoteListResponse { votes: votes? }) } -fn query_voter(deps: Deps, voter: HumanAddr) -> StdResult { +fn query_voter(deps: Deps, voter: HumanAddr) -> StdResult { let cfg = CONFIG.load(deps.storage)?; let voter_raw = deps.api.canonical_address(&voter)?; let weight = cfg.group_addr.is_member(&deps.querier, &voter_raw)?; - Ok(VoterInfo { weight }) + Ok(VoterResponse { weight }) } fn list_voters( @@ -426,7 +434,7 @@ fn list_voters( .group_addr .list_members(&deps.querier, start_after, limit)? .into_iter() - .map(|member| VoterResponse { + .map(|member| VoterDetail { addr: member.addr, weight: member.weight, }) @@ -670,7 +678,7 @@ mod tests { .unwrap(); assert_eq!( voters.voters, - vec![VoterResponse { + vec![VoterDetail { addr: OWNER.into(), weight: 1 }] @@ -865,6 +873,10 @@ mod tests { msgs, expires: voting_period.after(&proposed_at), status: Status::Open, + threshold: ThresholdResponse::AbsoluteCount { + weight: 3, + total_weight: 15, + }, }; assert_eq!(&expected, &res.proposals[0]); } @@ -989,9 +1001,11 @@ mod tests { .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) .unwrap(); assert_eq!( - vote, - VoteResponse { - vote: Some(Vote::Yes) + vote.vote.unwrap(), + VoteInfo { + voter: OWNER.into(), + vote: Vote::Yes, + weight: 0 } ); @@ -1002,9 +1016,11 @@ mod tests { .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) .unwrap(); assert_eq!( - vote, - VoteResponse { - vote: Some(Vote::No) + vote.vote.unwrap(), + VoteInfo { + voter: VOTER2.into(), + vote: Vote::No, + weight: 2 } ); @@ -1014,7 +1030,7 @@ mod tests { .wrap() .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) .unwrap(); - assert_eq!(vote, VoteResponse { vote: None }); + assert!(vote.vote.is_none()); } #[test] @@ -1218,7 +1234,7 @@ mod tests { let query_voter = QueryMsg::Voter { address: VOTER3.into(), }; - let power: VoterInfo = app + let power: VoterResponse = app .wrap() .query_wasm_smart(&flex_addr, &query_voter) .unwrap(); diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index ef7f91be8..215625d0d 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -15,28 +15,60 @@ pub struct InitMsg { pub max_voting_period: Duration, } +/// This defines the different ways tallies can happen. +/// +/// The total_weight used for calculating success as well as the weights of each +/// individual voter used in tallying should be snapshotted at the beginning of +/// the block at which the proposal starts (this is likely the responsibility of a +/// correct cw4 implementation). #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] pub enum Threshold { - /// Declares a total weight needed to pass - /// This usually implies that weight_needed is stable, even if total_weight changes - /// eg. 3 of 5 multisig -> 3 of 6 multisig + /// Declares that a fixed weight of yes votes is needed to pass. + /// It does not matter how many no votes are cast, or how many do not vote, + /// as long as `weight` yes votes are cast. + /// + /// This is the simplest format and usually suitable for small multisigs of trusted parties, + /// like 3 of 5. (weight: 3, total_weight: 5) + /// + /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. AbsoluteCount { weight: u64 }, - /// Declares a percentage of the total weight needed to pass - /// This implies the percentage is stable, when total_weight changes - /// eg. at 50.1%, we go from needing 51/100 to needing 101/200 + + /// Declares a percentage of the total weight that must cast yes votes in order for + /// a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes. + /// + /// This is useful for similar circumstances as `AbsoluteCount`, where we have a relatively + /// small set of voters and participation is required. The advantage here is that if the + /// voting set (group) changes between proposals, the number of votes needed is adjusted + /// accordingly. /// - /// Note: percentage_needed = 60% is different than threshold = 60%, quora = 100% - /// as the first will pass with 60% yes votes and 10% no votes, while the second - /// will require the others to vote anything (no, abstain...) to pass + /// Example: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. + /// This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the + /// `total_weight` of the group has increased to 9. That proposal will then automatically + /// require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`. + /// + /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. AbsolutePercentage { percentage: Decimal }, - /// Declares a threshold (minimum percentage of votes that must approve) - /// and a quorum (minimum percentage of voter weight that must vote). - /// This allows eg. 20.04% of the total weight yes votes to pass, if we have - /// a quorum of 40% and threshold of 51%, and most of the people sit out the election. + + /// Declares a `quorum` of the total votes that must participate in the election in order + /// for the vote to be considered at all. Within the votes that were cast, it requires `threshold` + /// in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` + /// but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` + /// and compare that with `threshold` to consider if the proposal was passed. + /// + /// It is rather difficult for a proposal of this type to pass early. That can only happen if + /// the required quorum has been already met, and in the case if all remaining voters were + /// to vote no, the threshold would still be met. + /// + /// 30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% + /// (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting + /// no => 30% yes + 50% no). Once the voting period has passed with no additional votes, + /// that same proposal would be considered successful if quorum <= 60% and threshold <= 75% + /// (percent in favor if we ignore abstain votes). /// - /// This is more common in general elections, where participation is expected - /// to be low. + /// This type is more common in general elections where participation is expected to often + /// be low, and `AbsolutePercentage` would either be too restrictive to pass anything, + /// or allow low percentages to pass if there was high participation in one election. ThresholdQuora { threshold: Decimal, quorum: Decimal }, } @@ -89,6 +121,7 @@ impl Threshold { } } +/// Asserts that the 0.0 < percent <= 1.0 fn valid_percentage(percent: &Decimal) -> Result<(), ContractError> { if percent.is_zero() { Err(ContractError::ZeroThreshold {}) @@ -120,7 +153,7 @@ pub enum HandleMsg { Close { proposal_id: u64, }, - /// handle update hook messages from the group contract + /// Handles update hook messages from the group contract MemberChangedHook(MemberChangedHookMsg), } From 382cbf1a08efecedb27dd2cddcb22be4ed338048 Mon Sep 17 00:00:00 2001 From: Mauro Lacy Date: Wed, 16 Dec 2020 14:00:06 +0100 Subject: [PATCH 19/27] Format / fix cw3 spec details --- packages/cw3/src/msg.rs | 4 ++-- packages/cw3/src/query.rs | 43 +++++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/cw3/src/msg.rs b/packages/cw3/src/msg.rs index cf07e4c84..31f946973 100644 --- a/packages/cw3/src/msg.rs +++ b/packages/cw3/src/msg.rs @@ -39,8 +39,8 @@ pub enum Vote { No, /// Marks participation but does not count towards the ratio of support / opposed Abstain, - /// Veto is generally to be treated as a no vote. Some implementations may allow certain - /// voters to be able to Veto, or them to be counted stronger then nos in some way. + /// Veto is generally to be treated as a No vote. Some implementations may allow certain + /// voters to be able to Veto, or them to be counted stronger than No in some way. Veto, } diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index 86c7e9c22..02ec33eb7 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -69,44 +69,47 @@ pub enum ThresholdResponse { /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. AbsoluteCount { weight: u64, total_weight: u64 }, - /// Declares a percentage of the total weight that must cast yes votes in order for - /// a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes. + /// Declares a percentage of the total weight that must cast Yes votes, in order for + /// a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count. /// /// This is useful for similar circumstances as `AbsoluteCount`, where we have a relatively - /// small set of voters and participation is required. The advantage here is that if the - /// voting set (group) changes between proposals, the number of votes needed is adjusted - /// accordingly. + /// small set of voters, and participation is required. + /// It is understood that if the voting set (group) changes between different proposals that + /// refer to the same group, each proposal will work with a different set of voter weights + /// (the ones snapshotted at proposal creation), and the passing weight for each proposal + /// will be computed based on the absolute percentage, times the total weights of the members + /// at the time of each proposal creation. /// /// Example: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. - /// This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the + /// This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the /// `total_weight` of the group has increased to 9. That proposal will then automatically - /// require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`. - /// - /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. + /// require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`. AbsolutePercentage { percentage: Decimal, total_weight: u64, }, - /// Declares a `quorum` of the total votes that must participate in the election in order - /// for the vote to be considered at all. Within the votes that were cast, it requires `threshold` - /// in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` - /// but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` - /// and compare that with `threshold` to consider if the proposal was passed. + /// In addition to a `threshold`, declares a `quorum` of the total votes that must participate + /// in the election in order for the vote to be considered at all. Within the votes that + /// were cast, it requires `threshold` votes in favor. That is calculated by ignoring + /// the Abstain votes (they count towards `quorum`, but do not influence `threshold`). + /// That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider + /// if the proposal was passed. /// /// It is rather difficult for a proposal of this type to pass early. That can only happen if - /// the required quorum has been already met, and in the case if all remaining voters were - /// to vote no, the threshold would still be met. + /// the required quorum has been already met, and there are already enough Yes votes for the + /// proposal to pass. /// - /// 30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% + /// 30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% /// (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting /// no => 30% yes + 50% no). Once the voting period has passed with no additional votes, /// that same proposal would be considered successful if quorum <= 60% and threshold <= 75% /// (percent in favor if we ignore abstain votes). /// - /// This type is more common in general elections where participation is expected to often - /// be low, and `AbsolutePercentage` would either be too restrictive to pass anything, - /// or allow low percentages to pass if there was high participation in one election. + /// This type is more common in general elections, where participation is often expected to + /// be low, and `AbsolutePercentage` would either be too high to pass anything, + /// or allow low percentages to pass, independently of if there was high participation in the + /// election or not. ThresholdQuora { threshold: Decimal, quorum: Decimal, From 8a190f15a271d529eab31842d7997fde99bbd566 Mon Sep 17 00:00:00 2001 From: Mauro Lacy Date: Wed, 16 Dec 2020 14:24:47 +0100 Subject: [PATCH 20/27] Update schemas --- packages/cw3/schema/proposal_list_response.json | 4 ++-- packages/cw3/schema/proposal_response.json | 4 ++-- packages/cw3/schema/threshold_response.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cw3/schema/proposal_list_response.json b/packages/cw3/schema/proposal_list_response.json index 256633141..a263a8302 100644 --- a/packages/cw3/schema/proposal_list_response.json +++ b/packages/cw3/schema/proposal_list_response.json @@ -366,7 +366,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", "type": "object", "required": [ "absolute_percentage" @@ -392,7 +392,7 @@ } }, { - "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", + "description": "In addition to a `threshold`, declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` votes in favor. That is calculated by ignoring the Abstain votes (they count towards `quorum`, but do not influence `threshold`). That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and there are already enough Yes votes for the proposal to pass.\n\n30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections, where participation is often expected to be low, and `AbsolutePercentage` would either be too high to pass anything, or allow low percentages to pass, independently of if there was high participation in the election or not.", "type": "object", "required": [ "threshold_quora" diff --git a/packages/cw3/schema/proposal_response.json b/packages/cw3/schema/proposal_response.json index 703658ff0..bacd6c247 100644 --- a/packages/cw3/schema/proposal_response.json +++ b/packages/cw3/schema/proposal_response.json @@ -352,7 +352,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", "type": "object", "required": [ "absolute_percentage" @@ -378,7 +378,7 @@ } }, { - "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", + "description": "In addition to a `threshold`, declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` votes in favor. That is calculated by ignoring the Abstain votes (they count towards `quorum`, but do not influence `threshold`). That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and there are already enough Yes votes for the proposal to pass.\n\n30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections, where participation is often expected to be low, and `AbsolutePercentage` would either be too high to pass anything, or allow low percentages to pass, independently of if there was high participation in the election or not.", "type": "object", "required": [ "threshold_quora" diff --git a/packages/cw3/schema/threshold_response.json b/packages/cw3/schema/threshold_response.json index f6293814a..61f95ebf0 100644 --- a/packages/cw3/schema/threshold_response.json +++ b/packages/cw3/schema/threshold_response.json @@ -32,7 +32,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", "type": "object", "required": [ "absolute_percentage" @@ -58,7 +58,7 @@ } }, { - "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", + "description": "In addition to a `threshold`, declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` votes in favor. That is calculated by ignoring the Abstain votes (they count towards `quorum`, but do not influence `threshold`). That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and there are already enough Yes votes for the proposal to pass.\n\n30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections, where participation is often expected to be low, and `AbsolutePercentage` would either be too high to pass anything, or allow low percentages to pass, independently of if there was high participation in the election or not.", "type": "object", "required": [ "threshold_quora" From 8290c5794f105ec0c3ddfffef35b4c5630ac5dbc Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Wed, 16 Dec 2020 15:24:53 +0100 Subject: [PATCH 21/27] Update contracts/cw3-flex-multisig/src/state.rs Co-authored-by: Mauro Lacy --- contracts/cw3-flex-multisig/src/state.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 9ab664af2..a276d8ee9 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -131,12 +131,8 @@ fn votes_needed(weight: u64, percentage: Decimal) -> u64 { // we multiply by 1million to detect rounding issues const FACTOR: u128 = 1_000_000; let applied = percentage * Uint128(FACTOR * weight as u128); - let rounded = (applied.u128() / FACTOR) as u64; - if applied.u128() % FACTOR > 0 { - rounded + 1 - } else { - rounded - } + // Divide by factor, rounding up to the nearest integer + ((applied.u128() + FACTOR - 1) / FACTOR) as u64 } // we cast a ballot with our chosen vote and a given weight From 0fd5a882c1b4f2e8bda375cd7cff07a7fd2ab02b Mon Sep 17 00:00:00 2001 From: Mauro Lacy Date: Wed, 16 Dec 2020 16:13:17 +0100 Subject: [PATCH 22/27] Consider abstained votes when computing AbsoluteThreshold passing weight --- contracts/cw3-flex-multisig/src/msg.rs | 3 ++- contracts/cw3-flex-multisig/src/state.rs | 11 +++++++---- packages/cw3/src/query.rs | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index 215625d0d..9dbddb576 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -35,7 +35,8 @@ pub enum Threshold { AbsoluteCount { weight: u64 }, /// Declares a percentage of the total weight that must cast yes votes in order for - /// a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes. + /// a proposal to pass. The percentage is computed over the total weight minus the weight of the + /// abstained votes. /// /// This is useful for similar circumstances as `AbsoluteCount`, where we have a relatively /// small set of voters and participation is required. The advantage here is that if the diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index a276d8ee9..2f9a0b265 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -102,7 +102,10 @@ impl Proposal { } => self.votes.yes >= weight_needed, Threshold::AbsolutePercentage { percentage: percentage_needed, - } => self.votes.yes >= votes_needed(self.total_weight, percentage_needed), + } => { + self.votes.yes + >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed) + } Threshold::ThresholdQuora { threshold, quorum } => { // this one is tricky, as we have two compares: if self.expires.is_expired(block) { @@ -259,13 +262,13 @@ mod test { let mut votes = Votes::new(7); votes.add_vote(Vote::No, 4); votes.add_vote(Vote::Abstain, 2); - // same expired or not, if total > 2 * yes + // same expired or not, if yes >= ceiling(0.5 * (total - abstained)) assert_eq!( - false, + true, check_is_passed(percent.clone(), votes.clone(), 15, false) ); assert_eq!( - false, + true, check_is_passed(percent.clone(), votes.clone(), 15, true) ); diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index 02ec33eb7..c0110d863 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -70,7 +70,8 @@ pub enum ThresholdResponse { AbsoluteCount { weight: u64, total_weight: u64 }, /// Declares a percentage of the total weight that must cast Yes votes, in order for - /// a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count. + /// a proposal to pass. The passing weight is computed over the total weight minus the weight of the + /// abstained votes. /// /// This is useful for similar circumstances as `AbsoluteCount`, where we have a relatively /// small set of voters, and participation is required. From 5ae15c6eaee07978b4ac2b2e6c2c48ac88dae5fc Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Wed, 16 Dec 2020 16:35:01 +0100 Subject: [PATCH 23/27] Simplify branches in ThresholdQuora is_passed --- contracts/cw3-flex-multisig/src/state.rs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 2f9a0b265..5e86c2cf1 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -107,21 +107,19 @@ impl Proposal { >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed) } Threshold::ThresholdQuora { threshold, quorum } => { - // this one is tricky, as we have two compares: + // we always require the quorum + if self.votes.total() < votes_needed(self.total_weight, quorum) { + return false; + } if self.expires.is_expired(block) { - // * if we have closed yet, we need quorum% of total votes to have voted (counting abstain) - // and threshold% of yes votes from those who voted (ignoring abstain) - let total = self.votes.total(); - let opinions = total - self.votes.abstain; - total >= votes_needed(self.total_weight, quorum) - && self.votes.yes >= votes_needed(opinions, threshold) + // If expired, we compare Yes votes 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 we have not closed yet, we need threshold% of yes votes (from 100% voters - abstain) - // as we are sure this cannot change with any possible sequence of future votes - // * we also need quorum (which may not always be the case above) - self.votes.total() >= votes_needed(self.total_weight, quorum) - && self.votes.yes - >= votes_needed(self.total_weight - self.votes.abstain, threshold) + // If not expired, we must assume all non-votes will be cast as No. + // We compare threshold against the total weight (minus abstain). + let possible_opinions = self.total_weight - self.votes.abstain; + self.votes.yes >= votes_needed(possible_opinions, threshold) } } } From 526b0ff2d47d47eff9e2aa08dbff76ebd234f322 Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Wed, 16 Dec 2020 16:36:51 +0100 Subject: [PATCH 24/27] Rename ThresholdQuora to ThresholdQuorum --- .../cw3-flex-multisig/schema/init_msg.json | 4 +-- contracts/cw3-flex-multisig/src/contract.rs | 4 +-- contracts/cw3-flex-multisig/src/msg.rs | 26 ++++++++++--------- contracts/cw3-flex-multisig/src/state.rs | 6 ++--- .../cw3/schema/proposal_list_response.json | 4 +-- packages/cw3/schema/proposal_response.json | 4 +-- packages/cw3/schema/threshold_response.json | 4 +-- packages/cw3/src/query.rs | 2 +- 8 files changed, 28 insertions(+), 26 deletions(-) diff --git a/contracts/cw3-flex-multisig/schema/init_msg.json b/contracts/cw3-flex-multisig/schema/init_msg.json index 09b64c58d..684aa5fb3 100644 --- a/contracts/cw3-flex-multisig/schema/init_msg.json +++ b/contracts/cw3-flex-multisig/schema/init_msg.json @@ -106,10 +106,10 @@ "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", "type": "object", "required": [ - "threshold_quora" + "threshold_quorum" ], "properties": { - "threshold_quora": { + "threshold_quorum": { "type": "object", "required": [ "quorum", diff --git a/contracts/cw3-flex-multisig/src/contract.rs b/contracts/cw3-flex-multisig/src/contract.rs index e4842cba2..ee09113dd 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -1494,7 +1494,7 @@ mod tests { let voting_period = Duration::Time(20000); let (flex_addr, group_addr) = setup_test_case( &mut app, - Threshold::ThresholdQuora { + Threshold::ThresholdQuorum { threshold: Decimal::percent(50), quorum: Decimal::percent(33), }, @@ -1563,7 +1563,7 @@ mod tests { let (flex_addr, _) = setup_test_case( &mut app, // note that 60% yes is not enough to pass without 20% no as well - Threshold::ThresholdQuora { + Threshold::ThresholdQuorum { threshold: Decimal::percent(60), quorum: Decimal::percent(80), }, diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index 9dbddb576..ceaa4442b 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -70,7 +70,7 @@ pub enum Threshold { /// This type is more common in general elections where participation is expected to often /// be low, and `AbsolutePercentage` would either be too restrictive to pass anything, /// or allow low percentages to pass if there was high participation in one election. - ThresholdQuora { threshold: Decimal, quorum: Decimal }, + ThresholdQuorum { threshold: Decimal, quorum: Decimal }, } impl Threshold { @@ -92,7 +92,7 @@ impl Threshold { Threshold::AbsolutePercentage { percentage: percentage_needed, } => valid_percentage(percentage_needed), - Threshold::ThresholdQuora { + Threshold::ThresholdQuorum { threshold, quorum: quroum, } => { @@ -113,11 +113,13 @@ impl Threshold { percentage, total_weight, }, - Threshold::ThresholdQuora { threshold, quorum } => ThresholdResponse::ThresholdQuora { - threshold, - quorum, - total_weight, - }, + Threshold::ThresholdQuorum { threshold, quorum } => { + ThresholdResponse::ThresholdQuorum { + threshold, + quorum, + total_weight, + } + } } } } @@ -261,13 +263,13 @@ mod tests { .unwrap(); // Quorum enforces both valid just enforces valid_percentage (tested above) - Threshold::ThresholdQuora { + Threshold::ThresholdQuorum { threshold: Decimal::percent(51), quorum: Decimal::percent(40), } .validate(5) .unwrap(); - let err = Threshold::ThresholdQuora { + let err = Threshold::ThresholdQuorum { threshold: Decimal::percent(101), quorum: Decimal::percent(40), } @@ -277,7 +279,7 @@ mod tests { err.to_string(), ContractError::UnreachableThreshold {}.to_string() ); - let err = Threshold::ThresholdQuora { + let err = Threshold::ThresholdQuorum { threshold: Decimal::percent(51), quorum: Decimal::percent(0), } @@ -311,14 +313,14 @@ mod tests { } ); - let res = Threshold::ThresholdQuora { + let res = Threshold::ThresholdQuorum { threshold: Decimal::percent(66), quorum: Decimal::percent(50), } .to_response(total_weight); assert_eq!( res, - ThresholdResponse::ThresholdQuora { + ThresholdResponse::ThresholdQuorum { threshold: Decimal::percent(66), quorum: Decimal::percent(50), total_weight diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 5e86c2cf1..bcde057c1 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -106,7 +106,7 @@ impl Proposal { self.votes.yes >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed) } - Threshold::ThresholdQuora { threshold, quorum } => { + Threshold::ThresholdQuorum { threshold, quorum } => { // we always require the quorum if self.votes.total() < votes_needed(self.total_weight, quorum) { return false; @@ -283,7 +283,7 @@ mod test { #[test] fn proposal_passed_quorum() { - let quorum = Threshold::ThresholdQuora { + let quorum = Threshold::ThresholdQuorum { threshold: Decimal::percent(50), quorum: Decimal::percent(40), }; @@ -363,7 +363,7 @@ mod test { #[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::ThresholdQuora { + let quorum = Threshold::ThresholdQuorum { threshold: Decimal::percent(60), quorum: Decimal::percent(80), }; diff --git a/packages/cw3/schema/proposal_list_response.json b/packages/cw3/schema/proposal_list_response.json index a263a8302..ab6ed774c 100644 --- a/packages/cw3/schema/proposal_list_response.json +++ b/packages/cw3/schema/proposal_list_response.json @@ -395,10 +395,10 @@ "description": "In addition to a `threshold`, declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` votes in favor. That is calculated by ignoring the Abstain votes (they count towards `quorum`, but do not influence `threshold`). That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and there are already enough Yes votes for the proposal to pass.\n\n30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections, where participation is often expected to be low, and `AbsolutePercentage` would either be too high to pass anything, or allow low percentages to pass, independently of if there was high participation in the election or not.", "type": "object", "required": [ - "threshold_quora" + "threshold_quorum" ], "properties": { - "threshold_quora": { + "threshold_quorum": { "type": "object", "required": [ "quorum", diff --git a/packages/cw3/schema/proposal_response.json b/packages/cw3/schema/proposal_response.json index bacd6c247..455426e7f 100644 --- a/packages/cw3/schema/proposal_response.json +++ b/packages/cw3/schema/proposal_response.json @@ -381,10 +381,10 @@ "description": "In addition to a `threshold`, declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` votes in favor. That is calculated by ignoring the Abstain votes (they count towards `quorum`, but do not influence `threshold`). That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and there are already enough Yes votes for the proposal to pass.\n\n30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections, where participation is often expected to be low, and `AbsolutePercentage` would either be too high to pass anything, or allow low percentages to pass, independently of if there was high participation in the election or not.", "type": "object", "required": [ - "threshold_quora" + "threshold_quorum" ], "properties": { - "threshold_quora": { + "threshold_quorum": { "type": "object", "required": [ "quorum", diff --git a/packages/cw3/schema/threshold_response.json b/packages/cw3/schema/threshold_response.json index 61f95ebf0..cf2c34753 100644 --- a/packages/cw3/schema/threshold_response.json +++ b/packages/cw3/schema/threshold_response.json @@ -61,10 +61,10 @@ "description": "In addition to a `threshold`, declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` votes in favor. That is calculated by ignoring the Abstain votes (they count towards `quorum`, but do not influence `threshold`). That is, we calculate `Yes / (Yes + No + Veto)` and compare it with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and there are already enough Yes votes for the proposal to pass.\n\n30% Yes votes, 10% No votes, and 20% Abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections, where participation is often expected to be low, and `AbsolutePercentage` would either be too high to pass anything, or allow low percentages to pass, independently of if there was high participation in the election or not.", "type": "object", "required": [ - "threshold_quora" + "threshold_quorum" ], "properties": { - "threshold_quora": { + "threshold_quorum": { "type": "object", "required": [ "quorum", diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index c0110d863..d67b918cb 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -111,7 +111,7 @@ pub enum ThresholdResponse { /// be low, and `AbsolutePercentage` would either be too high to pass anything, /// or allow low percentages to pass, independently of if there was high participation in the /// election or not. - ThresholdQuora { + ThresholdQuorum { threshold: Decimal, quorum: Decimal, total_weight: u64, From e574ff73b58b197eb9f84ddc449b317c407e544d Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Wed, 16 Dec 2020 16:41:00 +0100 Subject: [PATCH 25/27] Pull out PRECISION_FACTOR as mod level const --- contracts/cw3-flex-multisig/src/state.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index bcde057c1..676e12cee 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -11,6 +11,10 @@ use cw_storage_plus::{Item, Map, U64Key}; use crate::msg::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; + #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct Config { pub threshold: Threshold, @@ -129,11 +133,9 @@ impl Proposal { // 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 { - // we multiply by 1million to detect rounding issues - const FACTOR: u128 = 1_000_000; - let applied = percentage * Uint128(FACTOR * weight as u128); - // Divide by factor, rounding up to the nearest integer - ((applied.u128() + FACTOR - 1) / FACTOR) as u64 + let applied = percentage * Uint128(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 From d87e498fb8970b30f2fd11f95f0f0fd116b0391d Mon Sep 17 00:00:00 2001 From: Ethan Frey Date: Wed, 16 Dec 2020 16:47:18 +0100 Subject: [PATCH 26/27] Add one test for AbsolutePercentage and abstain, cargo schema --- contracts/cw3-flex-multisig/schema/init_msg.json | 2 +- contracts/cw3-flex-multisig/src/state.rs | 6 ++++++ packages/cw3/schema/proposal_list_response.json | 2 +- packages/cw3/schema/proposal_response.json | 2 +- packages/cw3/schema/threshold_response.json | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/contracts/cw3-flex-multisig/schema/init_msg.json b/contracts/cw3-flex-multisig/schema/init_msg.json index 684aa5fb3..972db5721 100644 --- a/contracts/cw3-flex-multisig/schema/init_msg.json +++ b/contracts/cw3-flex-multisig/schema/init_msg.json @@ -83,7 +83,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. As with `AbsoluteCount`, it only matters the sum of yes votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. The percentage is computed over the total weight minus the weight of the abstained votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", "type": "object", "required": [ "absolute_percentage" diff --git a/contracts/cw3-flex-multisig/src/state.rs b/contracts/cw3-flex-multisig/src/state.rs index 676e12cee..14465b815 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -263,6 +263,7 @@ mod test { 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_eq!( true, check_is_passed(percent.clone(), votes.clone(), 15, false) @@ -271,6 +272,11 @@ mod test { true, check_is_passed(percent.clone(), votes.clone(), 15, true) ); + // but 7 of (17-2) fails + assert_eq!( + false, + check_is_passed(percent.clone(), votes.clone(), 17, false) + ); // if the total were a bit lower, this would pass assert_eq!( diff --git a/packages/cw3/schema/proposal_list_response.json b/packages/cw3/schema/proposal_list_response.json index ab6ed774c..093f2678b 100644 --- a/packages/cw3/schema/proposal_list_response.json +++ b/packages/cw3/schema/proposal_list_response.json @@ -366,7 +366,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", + "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. The passing weight is computed over the total weight minus the weight of the abstained votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", "type": "object", "required": [ "absolute_percentage" diff --git a/packages/cw3/schema/proposal_response.json b/packages/cw3/schema/proposal_response.json index 455426e7f..1c9f23291 100644 --- a/packages/cw3/schema/proposal_response.json +++ b/packages/cw3/schema/proposal_response.json @@ -352,7 +352,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", + "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. The passing weight is computed over the total weight minus the weight of the abstained votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", "type": "object", "required": [ "absolute_percentage" diff --git a/packages/cw3/schema/threshold_response.json b/packages/cw3/schema/threshold_response.json index cf2c34753..3eed276a5 100644 --- a/packages/cw3/schema/threshold_response.json +++ b/packages/cw3/schema/threshold_response.json @@ -32,7 +32,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. As with `AbsoluteCount`, Yes votes are the only ones that count.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", + "description": "Declares a percentage of the total weight that must cast Yes votes, in order for a proposal to pass. The passing weight is computed over the total weight minus the weight of the abstained votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters, and participation is required. It is understood that if the voting set (group) changes between different proposals that refer to the same group, each proposal will work with a different set of voter weights (the ones snapshotted at proposal creation), and the passing weight for each proposal will be computed based on the absolute percentage, times the total weights of the members at the time of each proposal creation.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of Yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 Yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.", "type": "object", "required": [ "absolute_percentage" From f33e3aac50082e42de57cc61749933be1f5cd464 Mon Sep 17 00:00:00 2001 From: Mauro Lacy Date: Wed, 16 Dec 2020 17:20:12 +0100 Subject: [PATCH 27/27] Remove duplicate descriptions Add reference to cw3 spec docs Update schema --- .../cw3-flex-multisig/schema/init_msg.json | 8 +-- contracts/cw3-flex-multisig/src/msg.rs | 49 +++---------------- 2 files changed, 12 insertions(+), 45 deletions(-) diff --git a/contracts/cw3-flex-multisig/schema/init_msg.json b/contracts/cw3-flex-multisig/schema/init_msg.json index 972db5721..66e4eb137 100644 --- a/contracts/cw3-flex-multisig/schema/init_msg.json +++ b/contracts/cw3-flex-multisig/schema/init_msg.json @@ -58,10 +58,10 @@ "type": "string" }, "Threshold": { - "description": "This defines the different ways tallies can happen.\n\nThe total_weight used for calculating success as well as the weights of each individual voter used in tallying should be snapshotted at the beginning of the block at which the proposal starts (this is likely the responsibility of a correct cw4 implementation).", + "description": "This defines the different ways tallies can happen.\n\nThe total_weight used for calculating success as well as the weights of each individual voter used in tallying should be snapshotted at the beginning of the block at which the proposal starts (this is likely the responsibility of a correct cw4 implementation). See also `ThresholdResponse` in the cw3 spec.", "anyOf": [ { - "description": "Declares that a fixed weight of yes votes is needed to pass. It does not matter how many no votes are cast, or how many do not vote, as long as `weight` yes votes are cast.\n\nThis is the simplest format and usually suitable for small multisigs of trusted parties, like 3 of 5. (weight: 3, total_weight: 5)\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "description": "Declares that a fixed weight of Yes votes is needed to pass. See `ThresholdResponse.AbsoluteCount` in the cw3 spec for details.", "type": "object", "required": [ "absolute_count" @@ -83,7 +83,7 @@ } }, { - "description": "Declares a percentage of the total weight that must cast yes votes in order for a proposal to pass. The percentage is computed over the total weight minus the weight of the abstained votes.\n\nThis is useful for similar circumstances as `AbsoluteCount`, where we have a relatively small set of voters and participation is required. The advantage here is that if the voting set (group) changes between proposals, the number of votes needed is adjusted accordingly.\n\nExample: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the `total_weight` of the group has increased to 9. That proposal will then automatically require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`.\n\nA proposal of this type can pass early as soon as the needed weight of yes votes has been cast.", + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse.AbsolutePercentage` in the cw3 spec for details.", "type": "object", "required": [ "absolute_percentage" @@ -103,7 +103,7 @@ } }, { - "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. Within the votes that were cast, it requires `threshold` in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` and compare that with `threshold` to consider if the proposal was passed.\n\nIt is rather difficult for a proposal of this type to pass early. That can only happen if the required quorum has been already met, and in the case if all remaining voters were to vote no, the threshold would still be met.\n\n30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting no => 30% yes + 50% no). Once the voting period has passed with no additional votes, that same proposal would be considered successful if quorum <= 60% and threshold <= 75% (percent in favor if we ignore abstain votes).\n\nThis type is more common in general elections where participation is expected to often be low, and `AbsolutePercentage` would either be too restrictive to pass anything, or allow low percentages to pass if there was high participation in one election.", + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse.ThresholdQuorum` in the cw3 spec for details.", "type": "object", "required": [ "threshold_quorum" diff --git a/contracts/cw3-flex-multisig/src/msg.rs b/contracts/cw3-flex-multisig/src/msg.rs index ceaa4442b..36427f4e8 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -21,55 +21,22 @@ pub struct InitMsg { /// individual voter used in tallying should be snapshotted at the beginning of /// the block at which the proposal starts (this is likely the responsibility of a /// correct cw4 implementation). +/// See also `ThresholdResponse` in the cw3 spec. #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] pub enum Threshold { - /// Declares that a fixed weight of yes votes is needed to pass. - /// It does not matter how many no votes are cast, or how many do not vote, - /// as long as `weight` yes votes are cast. - /// - /// This is the simplest format and usually suitable for small multisigs of trusted parties, - /// like 3 of 5. (weight: 3, total_weight: 5) - /// - /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. + /// Declares that a fixed weight of Yes votes is needed to pass. + /// See `ThresholdResponse.AbsoluteCount` in the cw3 spec for details. AbsoluteCount { weight: u64 }, - /// Declares a percentage of the total weight that must cast yes votes in order for - /// a proposal to pass. The percentage is computed over the total weight minus the weight of the - /// abstained votes. - /// - /// This is useful for similar circumstances as `AbsoluteCount`, where we have a relatively - /// small set of voters and participation is required. The advantage here is that if the - /// voting set (group) changes between proposals, the number of votes needed is adjusted - /// accordingly. - /// - /// Example: we set `percentage` to 51%. Proposal 1 starts when there is a `total_weight` of 5. - /// This will require 3 weight of yes votes in order to pass. Later, the Proposal 2 starts but the - /// `total_weight` of the group has increased to 9. That proposal will then automatically - /// require 5 yes of 9 to pass, rather than 3 yes of 9 as would be the case with `AbsoluteCount`. - /// - /// A proposal of this type can pass early as soon as the needed weight of yes votes has been cast. + /// Declares a percentage of the total weight that must cast Yes votes in order for + /// a proposal to pass. + /// See `ThresholdResponse.AbsolutePercentage` in the cw3 spec for details. AbsolutePercentage { percentage: Decimal }, /// Declares a `quorum` of the total votes that must participate in the election in order - /// for the vote to be considered at all. Within the votes that were cast, it requires `threshold` - /// in favor. That is calculated by ignoring the abstain votes (they count towards `quorum` - /// but do not influence `threshold`). That is, we calculate `yes / (yes + no + veto)` - /// and compare that with `threshold` to consider if the proposal was passed. - /// - /// It is rather difficult for a proposal of this type to pass early. That can only happen if - /// the required quorum has been already met, and in the case if all remaining voters were - /// to vote no, the threshold would still be met. - /// - /// 30% yes votes, 10% no votes, and 20% abstain would pass early if quorum <= 60% - /// (who has cast votes) and if the threshold is <= 37.5% (the remaining 40% voting - /// no => 30% yes + 50% no). Once the voting period has passed with no additional votes, - /// that same proposal would be considered successful if quorum <= 60% and threshold <= 75% - /// (percent in favor if we ignore abstain votes). - /// - /// This type is more common in general elections where participation is expected to often - /// be low, and `AbsolutePercentage` would either be too restrictive to pass anything, - /// or allow low percentages to pass if there was high participation in one election. + /// for the vote to be considered at all. + /// See `ThresholdResponse.ThresholdQuorum` in the cw3 spec for details. ThresholdQuorum { threshold: Decimal, quorum: Decimal }, }