diff --git a/contracts/cw3-fixed-multisig/src/contract.rs b/contracts/cw3-fixed-multisig/src/contract.rs index b6cc7323b..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; @@ -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, }) } @@ -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, }) 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 99c954f3b..66e4eb137 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,77 @@ }, "HumanAddr": { "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). See also `ThresholdResponse` in the cw3 spec.", + "anyOf": [ + { + "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" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "weight" + ], + "properties": { + "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. See `ThresholdResponse.AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/Decimal" + } + } + } + } + }, + { + "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" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$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 aea23fb82..ee09113dd 100644 --- a/contracts/cw3-flex-multisig/src/contract.rs +++ b/contracts/cw3-flex-multisig/src/contract.rs @@ -9,14 +9,16 @@ 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; 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, }; @@ -108,23 +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, - yes_weight: vote_power, - required_weight: cfg.required_weight, + status: Status::Open, + votes: Votes::new(vote_power), + threshold: cfg.threshold, + total_weight: cfg.group_addr.total_weight(&deps.querier)?, }; + prop.update_status(&env.block); let id = next_id(deps.storage)?; PROPOSALS.save(deps.storage, id.into(), &prop)?; @@ -167,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)? @@ -186,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.update_status(&env.block); + PROPOSALS.save(deps.storage, proposal_id.into(), &prop)?; Ok(HandleResponse { messages: vec![], @@ -316,22 +303,21 @@ 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 { 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, }) } @@ -379,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 }) } @@ -424,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( @@ -442,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, }) @@ -453,7 +445,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}; @@ -462,6 +454,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"; @@ -515,17 +508,16 @@ mod tests { .unwrap() } - // uploads code and returns address of group contract 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") @@ -535,9 +527,27 @@ 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: 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, @@ -555,7 +565,7 @@ 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(app, group_addr.clone(), threshold, max_voting_period); app.update_block(next_block); // 3. (Optional) Set the multisig as the group owner @@ -575,16 +585,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, } @@ -603,18 +620,21 @@ mod tests { // Zero required weight fails let init_msg = InitMsg { group_addr: group_addr.clone(), - required_weight: 0, + threshold: Threshold::AbsoluteCount { weight: 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: 100 }, max_voting_period, }; let res = app.instantiate_contract(flex_id, OWNER, &init_msg, &[], "high required weight"); @@ -622,13 +642,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: 1 }, max_voting_period, }; let flex_addr = app @@ -658,7 +678,7 @@ mod tests { .unwrap(); assert_eq!( voters.voters, - vec![VoterResponse { + vec![VoterDetail { addr: OWNER.into(), weight: 1 }] @@ -671,7 +691,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, @@ -765,13 +785,109 @@ 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_fixed( + &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, + threshold: ThresholdResponse::AbsoluteCount { + weight: 3, + total_weight: 15, + }, + }; + assert_eq!(&expected, &res.proposals[0]); + } + #[test] fn test_vote_works() { let mut app = mock_app(); 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, @@ -876,6 +992,45 @@ 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.vote.unwrap(), + VoteInfo { + voter: OWNER.into(), + vote: Vote::Yes, + weight: 0 + } + ); + + // 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.vote.unwrap(), + VoteInfo { + voter: VOTER2.into(), + vote: Vote::No, + weight: 2 + } + ); + + // non-voter + let voter = VOTER5.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert!(vote.vote.is_none()); } #[test] @@ -884,7 +1039,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, @@ -969,7 +1124,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, @@ -1022,7 +1177,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, @@ -1049,6 +1204,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: 4, + total_weight: 15, + }; + assert_eq!(expected_thresh, threshold); + // a few blocks later... app.update_block(|block| block.height += 2); @@ -1068,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(); @@ -1116,6 +1282,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: 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 @@ -1127,7 +1306,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, @@ -1224,4 +1403,210 @@ mod tests { .unwrap_err(); assert_eq!(err, ContractError::Unauthorized {}.to_string()); } + + // uses the power from the beginning of the voting period + #[test] + fn percentage_handles_group_changes() { + let mut app = mock_app(); + + // 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: 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); + } + + // 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::ThresholdQuorum { + threshold: Decimal::percent(50), + quorum: 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); + } + + #[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::ThresholdQuorum { + 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/error.rs b/contracts/cw3-flex-multisig/src/error.rs index 1fe890148..df1096d3d 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) threshold")] + 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..36427f4e8 100644 --- a/contracts/cw3-flex-multisig/src/msg.rs +++ b/contracts/cw3-flex-multisig/src/msg.rs @@ -1,19 +1,107 @@ 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 cw3::{ThresholdResponse, Vote}; use cw4::MemberChangedHookMsg; #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] 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, } +/// 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). +/// 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. + /// 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. + /// 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. + /// See `ThresholdResponse.ThresholdQuorum` in the cw3 spec for details. + ThresholdQuorum { threshold: Decimal, quorum: 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: weight_needed, + } => { + if *weight_needed == 0 { + Err(ContractError::ZeroThreshold {}) + } else if *weight_needed > total_weight { + Err(ContractError::UnreachableThreshold {}) + } else { + Ok(()) + } + } + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => valid_percentage(percentage_needed), + Threshold::ThresholdQuorum { + threshold, + quorum: quroum, + } => { + 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 } => ThresholdResponse::AbsoluteCount { + weight, + total_weight, + }, + Threshold::AbsolutePercentage { percentage } => ThresholdResponse::AbsolutePercentage { + percentage, + total_weight, + }, + Threshold::ThresholdQuorum { threshold, quorum } => { + ThresholdResponse::ThresholdQuorum { + threshold, + quorum, + total_weight, + } + } + } + } +} + +/// Asserts that the 0.0 < percent <= 1.0 +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")] @@ -35,7 +123,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), } @@ -74,3 +162,136 @@ 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 + 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 + let err = valid_percentage(&Decimal::percent(101)).unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); + // not 100.1% + 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(); + 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: 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: 6 } + .validate(5) + .unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); + + 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: Decimal::zero(), + } + .validate(5) + .unwrap_err(); + assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); + Threshold::AbsolutePercentage { + percentage: Decimal::percent(51), + } + .validate(5) + .unwrap(); + + // Quorum enforces both valid just enforces valid_percentage (tested above) + Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(40), + } + .validate(5) + .unwrap(); + let err = Threshold::ThresholdQuorum { + threshold: Decimal::percent(101), + quorum: Decimal::percent(40), + } + .validate(5) + .unwrap_err(); + assert_eq!( + err.to_string(), + ContractError::UnreachableThreshold {}.to_string() + ); + let err = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(0), + } + .validate(5) + .unwrap_err(); + assert_eq!(err.to_string(), ContractError::ZeroThreshold {}.to_string()); + } + + #[test] + fn threshold_response() { + let total_weight: u64 = 100; + + let res = Threshold::AbsoluteCount { weight: 42 }.to_response(total_weight); + assert_eq!( + res, + ThresholdResponse::AbsoluteCount { + weight: 42, + total_weight + } + ); + + let res = Threshold::AbsolutePercentage { + percentage: Decimal::percent(51), + } + .to_response(total_weight); + assert_eq!( + res, + ThresholdResponse::AbsolutePercentage { + percentage: Decimal::percent(51), + total_weight + } + ); + + let res = Threshold::ThresholdQuorum { + threshold: Decimal::percent(66), + quorum: Decimal::percent(50), + } + .to_response(total_weight); + assert_eq!( + res, + 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 7a4403c17..14465b815 100644 --- a/contracts/cw3-flex-multisig/src/state.rs +++ b/contracts/cw3-flex-multisig/src/state.rs @@ -2,16 +2,22 @@ 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}; use cw4::Cw4Contract; 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 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,18 +31,57 @@ 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, + } + } + + 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 { + /// 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.yes_weight >= self.required_weight { + if status == Status::Open && self.is_passed(block) { status = Status::Passed; } if status == Status::Open && self.expires.is_expired(block) { @@ -45,6 +90,52 @@ impl Proposal { status } + + /// update_status sets the status of the proposal to current_status. + /// (designed for handler logic) + pub fn update_status(&mut self, block: &BlockInfo) { + self.status = self.current_status(block); + } + + // returns true 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: weight_needed, + } => self.votes.yes >= weight_needed, + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => { + self.votes.yes + >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed) + } + Threshold::ThresholdQuorum { threshold, quorum } => { + // we always require the quorum + if self.votes.total() < votes_needed(self.total_weight, quorum) { + return false; + } + if self.expires.is_expired(block) { + // If expired, we compare 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 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) + } + } + } + } +} + +// this is a helper function so Decimal works with u64 rather than Uint128 +// also, we must *round up* here, as we need 8, not 7 votes to reach 50% of 15 total +fn votes_needed(weight: u64, percentage: Decimal) -> u64 { + let applied = percentage * Uint128(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 @@ -77,3 +168,261 @@ 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: 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: 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 yes >= ceiling(0.5 * (total - abstained)) + // 7 of (15-2) passes + assert_eq!( + true, + check_is_passed(percent.clone(), votes.clone(), 15, false) + ); + assert_eq!( + 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!( + true, + check_is_passed(percent.clone(), votes.clone(), 14, false) + ); + assert_eq!( + true, + check_is_passed(percent.clone(), votes.clone(), 14, true) + ); + } + + #[test] + fn proposal_passed_quorum() { + let quorum = Threshold::ThresholdQuorum { + threshold: Decimal::percent(50), + quorum: Decimal::percent(40), + }; + // all non-yes votes are counted for quorum + let passing = Votes { + yes: 7, + no: 3, + abstain: 2, + veto: 1, + }; + // abstain votes are not counted for threshold => yes / (yes + no + veto) + let passes_ignoring_abstain = Votes { + yes: 6, + no: 4, + abstain: 5, + veto: 2, + }; + // fails any way you look at it + let failing = Votes { + yes: 6, + no: 5, + abstain: 2, + veto: 2, + }; + + // first, expired (voting period over) + // over quorum (40% of 30 = 12), over threshold (7/11 > 50%) + assert_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) + ); + } + + #[test] + fn quorum_edge_cases() { + // when we pass absolute threshold (everyone else voting no, we pass), but still don't hit quorum + let quorum = Threshold::ThresholdQuorum { + threshold: Decimal::percent(60), + quorum: Decimal::percent(80), + }; + + // try 9 yes, 1 no (out of 15) -> 90% voter threshold, 60% absolute threshold, still no quorum + // doesn't matter if expired or not + let missing_voters = Votes { + yes: 9, + no: 1, + abstain: 0, + veto: 0, + }; + assert_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) + ); + } +} 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..093f2678b 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. 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" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage", + "total_weight" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/Decimal" + }, + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "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_quorum" + ], + "properties": { + "threshold_quorum": { + "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..1c9f23291 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. 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" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage", + "total_weight" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/Decimal" + }, + "total_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "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_quorum" + ], + "properties": { + "threshold_quorum": { + "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/threshold_response.json b/packages/cw3/schema/threshold_response.json index 7b1cd89f0..3eed276a5 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" @@ -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 @@ -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. 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" @@ -41,11 +41,11 @@ "absolute_percentage": { "type": "object", "required": [ - "percentage_needed", + "percentage", "total_weight" ], "properties": { - "percentage_needed": { + "percentage": { "$ref": "#/definitions/Decimal" }, "total_weight": { @@ -58,21 +58,21 @@ } }, { - "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": "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": [ - "quroum", + "quorum", "threshold", "total_weight" ], "properties": { - "quroum": { + "quorum": { "$ref": "#/definitions/Decimal" }, "threshold": { 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/msg.rs b/packages/cw3/src/msg.rs index 85acaeffd..31f946973 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 than No in some way. Veto, } diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index e975b2362..d67b918cb 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 { @@ -42,38 +50,70 @@ 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 - AbsoluteCount { - weight_needed: 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 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 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. /// - /// 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 + /// This 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. + /// + /// 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`. AbsolutePercentage { - percentage_needed: Decimal, + 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. - ThresholdQuora { + + /// 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 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% + /// (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 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. + ThresholdQuorum { threshold: Decimal, - quroum: Decimal, + quorum: Decimal, total_weight: u64, }, } @@ -90,8 +130,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)] @@ -120,6 +164,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, @@ -129,21 +175,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, } 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);