diff --git a/contracts/proposal/cwd-proposal-multiple/README.md b/contracts/proposal/cwd-proposal-multiple/README.md index e1870e422..957ab2824 100644 --- a/contracts/proposal/cwd-proposal-multiple/README.md +++ b/contracts/proposal/cwd-proposal-multiple/README.md @@ -5,6 +5,11 @@ their voting choice(s) from an array of `MultipleChoiceOption`. Each of the options may have associated messages which are to be executed by the core module upon the proposal being passed and executed. +Votes can be cast for as long as the proposal is not expired. In cases +where the proposal is no longer being evaluated (e.g. met the quorum and +been rejected), this allows voters to reflect their opinion even though +it has no effect on the final proposal's status. + ## Undesired behavior The undesired behavior of this contract is tested under `testing/adversarial_tests.rs`. diff --git a/contracts/proposal/cwd-proposal-multiple/src/contract.rs b/contracts/proposal/cwd-proposal-multiple/src/contract.rs index dcfc8fd0d..5d41b4dfc 100644 --- a/contracts/proposal/cwd-proposal-multiple/src/contract.rs +++ b/contracts/proposal/cwd-proposal-multiple/src/contract.rs @@ -290,8 +290,15 @@ pub fn execute_vote( return Err(ContractError::InvalidVote {}); } - if prop.current_status(&env.block)? != Status::Open { - return Err(ContractError::NotOpen { id: proposal_id }); + // Allow voting on proposals until they expire. + // Voting on a non-open proposal will never change + // their outcome as if an outcome has been determined, + // it is because no possible sequence of votes may + // cause a different one. This then serves to allow + // for better tallies of opinions in the event that a + // proposal passes or is rejected early. + if prop.expiration.is_expired(&env.block) { + return Err(ContractError::Expired { id: proposal_id }); } let vote_power = get_voting_power( diff --git a/contracts/proposal/cwd-proposal-multiple/src/error.rs b/contracts/proposal/cwd-proposal-multiple/src/error.rs index 9ff31c280..dc25262c1 100644 --- a/contracts/proposal/cwd-proposal-multiple/src/error.rs +++ b/contracts/proposal/cwd-proposal-multiple/src/error.rs @@ -29,16 +29,13 @@ pub enum ContractError { #[error("Suggested proposal expiration is larger than the maximum proposal duration")] InvalidExpiration {}, - #[error("No such proposal ({id})")] + #[error("Proposal ({id}) is expired")] NoSuchProposal { id: u64 }, #[error("Proposal is ({size}) bytes, must be <= ({max}) bytes")] ProposalTooLarge { size: u64, max: u64 }, - #[error("Proposal is not open ({id})")] - NotOpen { id: u64 }, - - #[error("Proposal is expired ({id})")] + #[error("Proposal ({id}) is expired")] Expired { id: u64 }, #[error("Not registered to vote (no voting power) at time of proposal creation.")] diff --git a/contracts/proposal/cwd-proposal-multiple/src/testing/adversarial_tests.rs b/contracts/proposal/cwd-proposal-multiple/src/testing/adversarial_tests.rs index 48e10608e..28c75ed5e 100644 --- a/contracts/proposal/cwd-proposal-multiple/src/testing/adversarial_tests.rs +++ b/contracts/proposal/cwd-proposal-multiple/src/testing/adversarial_tests.rs @@ -1,18 +1,24 @@ -use crate::msg::ExecuteMsg; +use crate::msg::{ExecuteMsg, InstantiateMsg}; use crate::testing::execute::{make_proposal, mint_cw20s}; use crate::testing::instantiate::{ _get_default_token_dao_proposal_module_instantiate, instantiate_with_multiple_staked_balances_governance, }; -use crate::testing::queries::{query_dao_token, query_multiple_proposal_module, query_proposal}; -use crate::testing::tests::{ALTERNATIVE_ADDR, CREATOR_ADDR}; +use crate::testing::queries::{ + query_balance_cw20, query_dao_token, query_multiple_proposal_module, query_proposal, +}; +use crate::testing::tests::{get_pre_propose_info, ALTERNATIVE_ADDR, CREATOR_ADDR}; use crate::ContractError; -use cosmwasm_std::{Addr, CosmosMsg}; +use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Uint128, WasmMsg}; +use cw20::Cw20Coin; use cw_multi_test::{next_block, App, Executor}; +use cw_utils::Duration; +use cwd_voting::deposit::{DepositRefundPolicy, UncheckedDepositInfo}; use cwd_voting::multiple_choice::{ - MultipleChoiceOption, MultipleChoiceOptions, MultipleChoiceVote, + MultipleChoiceOption, MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy, }; use cwd_voting::status::Status; +use cwd_voting::threshold::PercentageThreshold; struct CommonTest { app: App, @@ -229,3 +235,138 @@ fn test_execute_proposal_more_than_once() { .unwrap(); assert!(matches!(err, ContractError::NotPassed {})); } + +// Users should be able to submit votes past the proposal +// expiration date. Such votes do not affect the outcome +// of the proposals; instead, they are meant to allow +// voters to voice their opinion. +#[test] +pub fn test_allow_voting_after_proposal_execution_pre_expiration_cw20() { + let mut app = App::default(); + + let instantiate = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(66)), + }, + max_voting_period: Duration::Time(604800), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: cwd_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + }; + + let core_addr = instantiate_with_multiple_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ALTERNATIVE_ADDR.to_string(), + amount: Uint128::new(50_000_000), + }, + ]), + ); + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // Mint some tokens to pay the proposal deposit. + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + + // Option 0 would mint 100_000_000 tokens for CREATOR_ADDR + let msg = cw20::Cw20ExecuteMsg::Mint { + recipient: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }; + let binary_msg = to_binary(&msg).unwrap(); + + let options = vec![ + MultipleChoiceOption { + title: "title 1".to_string(), + description: "multiple choice option 1".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: gov_token.to_string(), + msg: binary_msg, + funds: vec![], + } + .into()], + }, + MultipleChoiceOption { + title: "title 2".to_string(), + description: "multiple choice option 2".to_string(), + msgs: vec![], + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, mc_options); + + // assert initial CREATOR_ADDR address balance is 0 + let balance = query_balance_cw20(&app, gov_token.to_string(), CREATOR_ADDR); + assert_eq!(balance, Uint128::zero()); + + app.update_block(next_block); + + let vote = MultipleChoiceVote { option_id: 0 }; + + // someone votes enough to pass the proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { proposal_id, vote }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + // assert proposal is passed with expected votes + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Passed); + assert_eq!(prop.proposal.votes.vote_weights[0], Uint128::new(100000000)); + assert_eq!(prop.proposal.votes.vote_weights[1], Uint128::new(0)); + + // someone wakes up and casts their vote to express their + // opinion (not affecting the result of proposal) + let vote = MultipleChoiceVote { option_id: 1 }; + app.execute_contract( + Addr::unchecked(ALTERNATIVE_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { proposal_id, vote }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + // assert proposal is passed with expected votes + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Passed); + assert_eq!(prop.proposal.votes.vote_weights[0], Uint128::new(100000000)); + assert_eq!(prop.proposal.votes.vote_weights[1], Uint128::new(50000000)); + + // execute the proposal expecting + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // assert option 0 message executed as expected changed as expected + let balance = query_balance_cw20(&app, gov_token.to_string(), CREATOR_ADDR); + assert_eq!(balance, Uint128::new(110_000_000)); +} diff --git a/contracts/proposal/cwd-proposal-multiple/src/testing/instantiate.rs b/contracts/proposal/cwd-proposal-multiple/src/testing/instantiate.rs index 82ccec296..0d0187f25 100644 --- a/contracts/proposal/cwd-proposal-multiple/src/testing/instantiate.rs +++ b/contracts/proposal/cwd-proposal-multiple/src/testing/instantiate.rs @@ -18,6 +18,7 @@ use cwd_voting::{ threshold::PercentageThreshold, }; use cwd_voting_cw20_staked::msg::ActiveThreshold; +use cwd_voting_cw20_staked::msg::ActiveThreshold::AbsoluteCount; use crate::testing::tests::ALTERNATIVE_ADDR; use crate::{ @@ -582,7 +583,9 @@ pub fn instantiate_with_multiple_staked_balances_governance( voting_module_instantiate_info: ModuleInstantiateInfo { code_id: staked_balances_voting_id, msg: to_binary(&cwd_voting_cw20_staked::msg::InstantiateMsg { - active_threshold: None, + active_threshold: Some(AbsoluteCount { + count: Uint128::one(), + }), token_info: cwd_voting_cw20_staked::msg::TokenInfo::New { code_id: cw20_id, label: "DAO DAO governance token.".to_string(), diff --git a/contracts/proposal/cwd-proposal-multiple/src/testing/tests.rs b/contracts/proposal/cwd-proposal-multiple/src/testing/tests.rs index e94f31b32..c53c9b36a 100644 --- a/contracts/proposal/cwd-proposal-multiple/src/testing/tests.rs +++ b/contracts/proposal/cwd-proposal-multiple/src/testing/tests.rs @@ -107,7 +107,7 @@ fn test_propose() { let mut app = App::default(); let _govmod_id = app.store_code(proposal_multiple_contract()); - let max_voting_period = cw_utils::Duration::Height(6); + let max_voting_period = Duration::Height(6); let quorum = PercentageThreshold::Majority {}; let voting_strategy = VotingStrategy::SingleChoice { quorum }; @@ -3802,3 +3802,92 @@ fn test_no_double_refund_on_execute_fail_and_close() { let balance = query_balance_cw20(&app, token_contract.to_string(), CREATOR_ADDR); assert_eq!(balance, Uint128::new(1)); } + +// Casting votes is only allowed within the proposal expiration timeframe +#[test] +pub fn test_not_allow_voting_on_expired_proposal() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let instantiate = InstantiateMsg { + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: false, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // assert proposal is open + let proposal = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Open); + + // expire the proposal and attempt to vote + app.update_block(|block| block.height += 6); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert the vote got rejected and did not count towards the votes + let proposal = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Rejected); + assert_eq!(proposal.proposal.votes.vote_weights[0], Uint128::zero()); + assert!(matches!(err, ContractError::Expired { id: _proposal_id })); +} diff --git a/contracts/proposal/cwd-proposal-single/README.md b/contracts/proposal/cwd-proposal-single/README.md index 202e3b118..0386ff5b6 100644 --- a/contracts/proposal/cwd-proposal-single/README.md +++ b/contracts/proposal/cwd-proposal-single/README.md @@ -5,6 +5,11 @@ A proposal module for a DAO DAO DAO which supports simple "yes", "no", executed by the core module upon the proposal being passed and executed. +Votes can be cast for as long as the proposal is not expired. In cases +where the proposal is no longer being evaluated (e.g. met the quorum and +been rejected), this allows voters to reflect their opinion even though +it has no effect on the final proposal's status. + For more information about how these modules fit together see [this](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-v1-Contracts-Design) wiki page. diff --git a/contracts/proposal/cwd-proposal-single/src/contract.rs b/contracts/proposal/cwd-proposal-single/src/contract.rs index 1dfd7fb32..0c6e235d0 100644 --- a/contracts/proposal/cwd-proposal-single/src/contract.rs +++ b/contracts/proposal/cwd-proposal-single/src/contract.rs @@ -376,8 +376,16 @@ pub fn execute_vote( let mut prop = PROPOSALS .may_load(deps.storage, proposal_id)? .ok_or(ContractError::NoSuchProposal { id: proposal_id })?; - if prop.current_status(&env.block) != Status::Open { - return Err(ContractError::NotOpen { id: proposal_id }); + + // Allow voting on proposals until they expire. + // Voting on a non-open proposal will never change + // their outcome as if an outcome has been determined, + // it is because no possible sequence of votes may + // cause a different one. This then serves to allow + // for better tallies of opinions in the event that a + // proposal passes or is rejected early. + if prop.expiration.is_expired(&env.block) { + return Err(ContractError::Expired { id: proposal_id }); } let vote_power = get_voting_power( @@ -562,7 +570,6 @@ pub fn execute_update_config( if info.sender != config.dao { return Err(ContractError::Unauthorized {}); } - threshold.validate()?; let dao = deps.api.addr_validate(&dao)?; diff --git a/contracts/proposal/cwd-proposal-single/src/error.rs b/contracts/proposal/cwd-proposal-single/src/error.rs index 9a30116e5..bb7ce4888 100644 --- a/contracts/proposal/cwd-proposal-single/src/error.rs +++ b/contracts/proposal/cwd-proposal-single/src/error.rs @@ -35,10 +35,7 @@ pub enum ContractError { #[error("proposal is ({size}) bytes, must be <= ({max}) bytes")] ProposalTooLarge { size: u64, max: u64 }, - #[error("proposal is not open ({id})")] - NotOpen { id: u64 }, - - #[error("proposal is expired ({id})")] + #[error("Proposal ({id}) is expired")] Expired { id: u64 }, #[error("not registered to vote (no voting power) at time of proposal creation")] diff --git a/contracts/proposal/cwd-proposal-single/src/testing/adversarial_tests.rs b/contracts/proposal/cwd-proposal-single/src/testing/adversarial_tests.rs index 33394a43f..e390acae7 100644 --- a/contracts/proposal/cwd-proposal-single/src/testing/adversarial_tests.rs +++ b/contracts/proposal/cwd-proposal-single/src/testing/adversarial_tests.rs @@ -1,6 +1,5 @@ -use cosmwasm_std::{Addr, CosmosMsg}; -use cw_multi_test::{next_block, App}; - +use crate::msg::InstantiateMsg; +use crate::testing::instantiate::get_pre_propose_info; use crate::testing::{ execute::{ close_proposal, execute_proposal, execute_proposal_should_fail, make_proposal, mint_cw20s, @@ -10,9 +9,18 @@ use crate::testing::{ get_default_token_dao_proposal_module_instantiate, instantiate_with_staked_balances_governance, }, - queries::{query_dao_token, query_proposal, query_single_proposal_module}, + queries::{query_balance_cw20, query_dao_token, query_proposal, query_single_proposal_module}, +}; +use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App}; +use cw_utils::Duration; +use cwd_voting::{ + deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + status::Status, + threshold::{PercentageThreshold, Threshold::AbsolutePercentage}, + voting::Vote, }; -use cwd_voting::{status::Status, voting::Vote}; use super::CREATOR_ADDR; use crate::{query::ProposalResponse, ContractError}; @@ -143,3 +151,227 @@ fn test_execute_proposal_more_than_once() { execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); assert!(matches!(err, ContractError::NotPassed {})); } + +// After proposal is executed, no subsequent votes +// should change the status of the proposal, even if +// the votes should shift to the opposing direction. +#[test] +pub fn test_executed_prop_state_remains_after_vote_swing() { + let mut app = App::default(); + + let instantiate = InstantiateMsg { + threshold: AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(15)), + }, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: cwd_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "threshold".to_string(), + amount: Uint128::new(20), + }, + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(50), + }, + Cw20Coin { + address: "overslept_vote".to_string(), + amount: Uint128::new(30), + }, + ]), + ); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + // someone quickly votes, proposal gets executed + vote_on_proposal( + &mut app, + &proposal_module, + "threshold", + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + app.update_block(next_block); + + // assert prop is executed prior to its expiry + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert!(!proposal.proposal.expiration.is_expired(&app.block_info())); + + // someone wakes up and casts their vote to express their + // opinion (not affecting the result of proposal) + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + vote_on_proposal( + &mut app, + &proposal_module, + "overslept_vote", + proposal_id, + Vote::No, + ); + + app.update_block(next_block); + + // assert that everyone's votes are reflected in the proposal + // and proposal remains in executed state + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::new(80)); +} + +// After reaching a passing state, no subsequent votes +// should change the status of the proposal, even if +// the votes should shift to the opposing direction. +#[test] +pub fn test_passed_prop_state_remains_after_vote_swing() { + let mut app = App::default(); + + let instantiate = InstantiateMsg { + threshold: AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(15)), + }, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: cwd_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "threshold".to_string(), + amount: Uint128::new(20), + }, + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(50), + }, + Cw20Coin { + address: "overslept_vote".to_string(), + amount: Uint128::new(30), + }, + ]), + ); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // if the proposal passes, it should mint 100_000_000 tokens to "threshold" + let msg = cw20::Cw20ExecuteMsg::Mint { + recipient: "threshold".to_string(), + amount: Uint128::new(100_000_000), + }; + let binary_msg = to_binary(&msg).unwrap(); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![WasmMsg::Execute { + contract_addr: gov_token.to_string(), + msg: binary_msg, + funds: vec![], + } + .into()], + ); + + // assert that the initial "threshold" address balance is 0 + let balance = query_balance_cw20(&app, gov_token.to_string(), "threshold"); + assert_eq!(balance, Uint128::zero()); + + // vote enough to pass the proposal + vote_on_proposal( + &mut app, + &proposal_module, + "threshold", + proposal_id, + Vote::Yes, + ); + + // assert proposal is passed with 20 votes in favor and none opposed + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::zero()); + + app.update_block(next_block); + + // the other voters wake up, vote against the proposal + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + vote_on_proposal( + &mut app, + &proposal_module, + "overslept_vote", + proposal_id, + Vote::No, + ); + + app.update_block(next_block); + + // assert that the late votes have been counted and proposal + // is still in passed state before executing it + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::new(80)); + + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + app.update_block(next_block); + + // make sure that the initial "threshold" address balance is + // 100_000_000 and late votes did not make a difference + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::new(80)); + let balance = query_balance_cw20(&app, gov_token.to_string(), "threshold"); + assert_eq!(balance, Uint128::new(100_000_000)); +} diff --git a/contracts/proposal/cwd-proposal-single/src/testing/tests.rs b/contracts/proposal/cwd-proposal-single/src/testing/tests.rs index 2a6a6336c..de53d142e 100644 --- a/contracts/proposal/cwd-proposal-single/src/testing/tests.rs +++ b/contracts/proposal/cwd-proposal-single/src/testing/tests.rs @@ -1102,7 +1102,7 @@ fn test_revoting_playthrough() { proposal_id, Vote::Yes, ); - assert!(matches!(err, ContractError::NotOpen { .. })); + assert!(matches!(err, ContractError::Expired { .. })); } /// Tests that revoting is stored at a per-proposal level. Proposals @@ -1169,16 +1169,6 @@ fn test_allow_revoting_config_changes() { let proposal_resp = query_proposal(&app, &proposal_module, revoting_proposal); assert_eq!(proposal_resp.proposal.status, Status::Open); - // Can not vote again on the no revoting proposal. - let err = vote_on_proposal_should_fail( - &mut app, - &proposal_module, - CREATOR_ADDR, - no_revoting_proposal, - Vote::No, - ); - assert!(matches!(err, ContractError::NotOpen { .. })); - // Can change vote on the revoting proposal. vote_on_proposal( &mut app, @@ -1389,18 +1379,18 @@ fn test_absolute_count_threshold_non_multisig() { fn test_large_absolute_count_threshold() { do_votes_staked_balances( vec![ - // Instant rejection after this. TestSingleChoiceVote { voter: "two".to_string(), position: Vote::No, weight: Uint128::new(1), should_execute: ShouldExecute::Yes, }, + // Can vote up to expiration time. TestSingleChoiceVote { voter: "one".to_string(), position: Vote::Yes, weight: Uint128::new(u128::MAX - 1), - should_execute: ShouldExecute::No, + should_execute: ShouldExecute::Yes, }, ], Threshold::AbsoluteCount { @@ -2556,3 +2546,44 @@ fn test_rational_clobbered_on_revote() { let vote = query_vote(&app, &proposal_module, CREATOR_ADDR, proposal_id); assert_eq!(vote.vote.unwrap().rationale, rationale); } + +// Casting votes is only allowed within the proposal expiration timeframe +#[test] +pub fn test_not_allow_voting_on_expired_proposal() { + let CommonTest { + mut app, + core_addr: _, + proposal_module, + gov_token: _, + proposal_id, + } = setup_test(vec![]); + + // expire the proposal + app.update_block(|mut b| b.time = b.time.plus_seconds(604800)); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); + assert_eq!(proposal.proposal.votes.yes, Uint128::zero()); + + // attempt to vote past the expiration date + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert the vote got rejected and did not count + // towards the votes + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); + assert_eq!(proposal.proposal.votes.yes, Uint128::zero()); + assert!(matches!(err, ContractError::Expired { id: _proposal_id })); +} diff --git a/contracts/proposal/cwd-proposal-single/src/tests.rs b/contracts/proposal/cwd-proposal-single/src/tests.rs deleted file mode 100644 index 3fab13141..000000000 --- a/contracts/proposal/cwd-proposal-single/src/tests.rs +++ /dev/null @@ -1,611 +0,0 @@ -use std::u128; - -use cosmwasm_std::{ - coins, - testing::{mock_dependencies, mock_env}, - to_binary, Addr, Coin, CosmosMsg, Decimal, Empty, Order, Timestamp, Uint128, WasmMsg, -}; -use cw20::Cw20Coin; -use cwd_voting_cw20_staked::msg::ActiveThreshold; - -use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; -use cw_pre_propose_base_proposal_single as cppbps; -use cw_storage_plus::{Item, Map}; -use cw_utils::Duration; -use cw_utils::Expiration; -use cwd_core::state::ProposalModule; -use cwd_interface::{Admin, ModuleInstantiateInfo}; - -use cwd_hooks::HooksResponse; - -use cw_denom::{CheckedDenom, UncheckedDenom}; - -use cwd_voting::{ - deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, - pre_propose::{PreProposeInfo, ProposalCreationPolicy}, - status::Status, - threshold::{PercentageThreshold, Threshold}, - voting::{Vote, Votes}, -}; -use testing::{ShouldExecute, TestSingleChoiceVote}; - -use crate::{ - contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, - msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}, - proposal::SingleChoiceProposal, - query::{ProposalListResponse, ProposalResponse, VoteInfo, VoteResponse}, - state::Config, - ContractError, -}; - -const CREATOR_ADDR: &str = "creator"; - -#[cw_serde] -struct V1Proposal { - pub title: String, - pub description: String, - pub proposer: Addr, - pub start_height: u64, - pub min_voting_period: Option, - pub expiration: Expiration, - pub threshold: Threshold, - pub total_power: Uint128, - pub msgs: Vec>, - pub status: Status, - pub votes: Votes, - pub allow_revoting: bool, - pub deposit_info: Option, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -pub struct V1Config { - pub threshold: Threshold, - pub max_voting_period: Duration, - pub min_voting_period: Option, - pub only_members_execute: bool, - pub allow_revoting: bool, - pub dao: Addr, - pub deposit_info: Option, -} - -#[test] -fn test_migrate_mock() { - let mut deps = mock_dependencies(); - let env = mock_env(); - let current_block = &env.block; - let max_voting_period = cw_utils::Duration::Height(6); - - let threshold = Threshold::AbsolutePercentage { - percentage: PercentageThreshold::Majority {}, - }; - - // Write to storage in old data format - let v1_map: Map = Map::new("proposals"); - let v1_proposal = V1Proposal { - title: "A simple text proposal".to_string(), - description: "This is a simple text proposal".to_string(), - proposer: Addr::unchecked(CREATOR_ADDR), - start_height: env.block.height, - expiration: max_voting_period.after(current_block), - min_voting_period: None, - threshold: threshold.clone(), - allow_revoting: false, - total_power: Uint128::new(100_000_000), - msgs: vec![], - status: Status::Open, - votes: Votes::zero(), - deposit_info: None, - }; - v1_map.save(&mut deps.storage, 0, &v1_proposal).unwrap(); - - let v1_item: Item = Item::new("config"); - let v1_config = V1Config { - threshold: threshold.clone(), - max_voting_period, - min_voting_period: None, - only_members_execute: true, - allow_revoting: false, - dao: Addr::unchecked("simple happy desert"), - deposit_info: None, - }; - v1_item.save(&mut deps.storage, &v1_config).unwrap(); - - let msg = MigrateMsg::FromV1 { - close_proposal_on_execution_failure: true, - }; - migrate(deps.as_mut(), env.clone(), msg).unwrap(); - - // Verify migration. - let new_map: Map = Map::new("proposals_v2"); - let proposals: Vec<(u64, SingleChoiceProposal)> = new_map - .range(&deps.storage, None, None, Order::Ascending) - .collect::, _>>() - .unwrap(); - - let migrated_proposal = &proposals[0]; - assert_eq!(migrated_proposal.0, 0); - - let new_item: Item = Item::new("config_v2"); - let migrated_config = new_item.load(&deps.storage).unwrap(); - // assert_eq!( - // migrated_config, - // Config { - // threshold, - // max_voting_period, - // min_voting_period: None, - // only_members_execute: true, - // allow_revoting: false, - // dao: Addr::unchecked("simple happy desert"), - // deposit_info: None, - // close_proposal_on_execution_failure: true, - // open_proposal_submission: false, - // } - // ); - todo!("(zeke) hmmmmmmmmm") -} - -#[test] -fn test_close_failed_proposal() { - let mut app = App::default(); - let govmod_id = app.store_code(proposal_contract()); - - let threshold = Threshold::AbsolutePercentage { - percentage: PercentageThreshold::Majority {}, - }; - let max_voting_period = cw_utils::Duration::Height(6); - let instantiate = InstantiateMsg { - threshold, - max_voting_period, - min_voting_period: None, - only_members_execute: false, - allow_revoting: false, - pre_propose_info: get_pre_propose_info(&mut app, None, false), - close_proposal_on_execution_failure: true, - }; - - let governance_addr = - instantiate_with_staking_active_threshold(&mut app, govmod_id, instantiate, None, None); - let governance_modules: Vec = app - .wrap() - .query_wasm_smart( - governance_addr, - &cwd_core::msg::QueryMsg::ProposalModules { - start_after: None, - limit: None, - }, - ) - .unwrap(); - - assert_eq!(governance_modules.len(), 1); - let govmod_single = governance_modules.into_iter().next().unwrap().address; - - let govmod_config: Config = app - .wrap() - .query_wasm_smart(govmod_single.clone(), &QueryMsg::Config {}) - .unwrap(); - let dao = govmod_config.dao; - let voting_module: Addr = app - .wrap() - .query_wasm_smart(dao, &cwd_core::msg::QueryMsg::VotingModule {}) - .unwrap(); - let staking_contract: Addr = app - .wrap() - .query_wasm_smart( - voting_module.clone(), - &cwd_voting_cw20_staked::msg::QueryMsg::StakingContract {}, - ) - .unwrap(); - let token_contract: Addr = app - .wrap() - .query_wasm_smart( - voting_module, - &cwd_interface::voting::Query::TokenContract {}, - ) - .unwrap(); - - // Stake some tokens so we can propose - let msg = cw20::Cw20ExecuteMsg::Send { - contract: staking_contract.to_string(), - amount: Uint128::new(2000), - msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), - }; - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - token_contract.clone(), - &msg, - &[], - ) - .unwrap(); - app.update_block(next_block); - - let msg = cw20::Cw20ExecuteMsg::Burn { - amount: Uint128::new(2000), - }; - let binary_msg = to_binary(&msg).unwrap(); - - // Overburn tokens - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Propose { - title: "A simple burn tokens proposal".to_string(), - description: "Burning more tokens, than dao treasury have".to_string(), - msgs: vec![WasmMsg::Execute { - contract_addr: token_contract.to_string(), - msg: binary_msg.clone(), - funds: vec![], - } - .into()], - }, - &[], - ) - .unwrap(); - - // Vote on proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Vote { - proposal_id: 1, - vote: Vote::Yes, - }, - &[], - ) - .unwrap(); - - let timestamp = Timestamp::from_seconds(300_000_000); - app.update_block(|block| block.time = timestamp); - - // Execute proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Execute { proposal_id: 1 }, - &[], - ) - .unwrap(); - - let failed: ProposalResponse = app - .wrap() - .query_wasm_smart( - govmod_single.clone(), - &QueryMsg::Proposal { proposal_id: 1 }, - ) - .unwrap(); - assert_eq!(failed.proposal.status, Status::ExecutionFailed); - - // With disabled feature - // Disable feature first - { - let original: Config = app - .wrap() - .query_wasm_smart(govmod_single.clone(), &QueryMsg::Config {}) - .unwrap(); - - let pre_propose_info = get_pre_propose_info(&mut app, None, false); - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Propose { - title: "Disable closing failed proposals".to_string(), - description: "We want to re-execute failed proposals".to_string(), - msgs: vec![WasmMsg::Execute { - contract_addr: govmod_single.to_string(), - msg: to_binary(&ExecuteMsg::UpdateConfig { - threshold: original.threshold, - max_voting_period: original.max_voting_period, - min_voting_period: original.min_voting_period, - only_members_execute: original.only_members_execute, - allow_revoting: original.allow_revoting, - dao: original.dao.to_string(), - pre_propose_info, - close_proposal_on_execution_failure: false, - }) - .unwrap(), - funds: vec![], - } - .into()], - }, - &[], - ) - .unwrap(); - - // Vote on proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Vote { - proposal_id: 2, - vote: Vote::Yes, - }, - &[], - ) - .unwrap(); - - // Execute proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Execute { proposal_id: 2 }, - &[], - ) - .unwrap(); - } - - // Overburn tokens (again), this time without reverting - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Propose { - title: "A simple burn tokens proposal".to_string(), - description: "Burning more tokens, than dao treasury have".to_string(), - msgs: vec![WasmMsg::Execute { - contract_addr: token_contract.to_string(), - msg: binary_msg, - funds: vec![], - } - .into()], - }, - &[], - ) - .unwrap(); - - // Vote on proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Vote { - proposal_id: 3, - vote: Vote::Yes, - }, - &[], - ) - .unwrap(); - - // Execute proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - govmod_single.clone(), - &ExecuteMsg::Execute { proposal_id: 3 }, - &[], - ) - .expect_err("Should be sub overflow"); - - // Status should still be passed - let updated: ProposalResponse = app - .wrap() - .query_wasm_smart(govmod_single, &QueryMsg::Proposal { proposal_id: 3 }) - .unwrap(); - - // not reverted - assert_eq!(updated.proposal.status, Status::Passed); -} - -#[test] -fn test_no_double_refund_on_execute_fail_and_close() { - let mut app = App::default(); - let proposal_module_id = app.store_code(proposal_contract()); - - let threshold = Threshold::AbsolutePercentage { - percentage: PercentageThreshold::Majority {}, - }; - let max_voting_period = cw_utils::Duration::Height(6); - let instantiate = InstantiateMsg { - threshold, - max_voting_period, - min_voting_period: None, - only_members_execute: false, - allow_revoting: false, - pre_propose_info: get_pre_propose_info( - &mut app, - Some(UncheckedDepositInfo { - denom: DepositToken::VotingModuleToken {}, - amount: Uint128::new(1), - // Important to set to always here as we want to be sure - // that we don't get a second refund on close. Refunds on - // close only happen if Deposity Refund Policy is "Always". - refund_policy: DepositRefundPolicy::Always, - }), - false, - ), - close_proposal_on_execution_failure: true, - }; - - let core_addr = instantiate_with_staking_active_threshold( - &mut app, - proposal_module_id, - instantiate, - Some(vec![Cw20Coin { - address: CREATOR_ADDR.to_string(), - // One token for sending to the DAO treasury, one token - // for staking, one token for paying the proposal deposit. - amount: Uint128::new(3), - }]), - None, - ); - let proposal_modules: Vec = app - .wrap() - .query_wasm_smart( - core_addr, - &cwd_core::msg::QueryMsg::ProposalModules { - start_after: None, - limit: None, - }, - ) - .unwrap(); - - assert_eq!(proposal_modules.len(), 1); - let proposal_single = proposal_modules.into_iter().next().unwrap().address; - - let proposal_config: Config = app - .wrap() - .query_wasm_smart(proposal_single.clone(), &QueryMsg::Config {}) - .unwrap(); - let dao = proposal_config.dao; - let voting_module: Addr = app - .wrap() - .query_wasm_smart(dao, &cwd_core::msg::QueryMsg::VotingModule {}) - .unwrap(); - let staking_contract: Addr = app - .wrap() - .query_wasm_smart( - voting_module.clone(), - &cwd_voting_cw20_staked::msg::QueryMsg::StakingContract {}, - ) - .unwrap(); - let token_contract: Addr = app - .wrap() - .query_wasm_smart( - voting_module, - &cwd_interface::voting::Query::TokenContract {}, - ) - .unwrap(); - - // Stake a token so we can propose. - let msg = cw20::Cw20ExecuteMsg::Send { - contract: staking_contract.to_string(), - amount: Uint128::new(1), - msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), - }; - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - token_contract.clone(), - &msg, - &[], - ) - .unwrap(); - app.update_block(next_block); - - // Send some tokens to the proposal module so it has the ability - // to double refund if the code is buggy. - let msg = cw20::Cw20ExecuteMsg::Transfer { - recipient: proposal_single.to_string(), - amount: Uint128::new(1), - }; - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - token_contract.clone(), - &msg, - &[], - ) - .unwrap(); - - let msg = cw20::Cw20ExecuteMsg::Burn { - amount: Uint128::new(2000), - }; - let binary_msg = to_binary(&msg).unwrap(); - - // Increase allowance to pay the proposal deposit. - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - token_contract.clone(), - &cw20_base::msg::ExecuteMsg::IncreaseAllowance { - spender: proposal_single.to_string(), - amount: Uint128::new(1), - expires: None, - }, - &[], - ) - .unwrap(); - - // proposal to overburn tokens - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - proposal_single.clone(), - &ExecuteMsg::Propose { - title: "A simple burn tokens proposal".to_string(), - description: "Burning more tokens, than dao treasury have".to_string(), - msgs: vec![WasmMsg::Execute { - contract_addr: token_contract.to_string(), - msg: binary_msg, - funds: vec![], - } - .into()], - }, - &[], - ) - .unwrap(); - - // Vote on proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - proposal_single.clone(), - &ExecuteMsg::Vote { - proposal_id: 1, - vote: Vote::Yes, - }, - &[], - ) - .unwrap(); - - let timestamp = Timestamp::from_seconds(300_000_000); - app.update_block(|block| block.time = timestamp); - - // Execute proposal - app.execute_contract( - Addr::unchecked(CREATOR_ADDR), - proposal_single.clone(), - &ExecuteMsg::Execute { proposal_id: 1 }, - &[], - ) - .unwrap(); - - let failed: ProposalResponse = app - .wrap() - .query_wasm_smart( - proposal_single.clone(), - &QueryMsg::Proposal { proposal_id: 1 }, - ) - .unwrap(); - assert_eq!(failed.proposal.status, Status::ExecutionFailed); - - // Check that our deposit has been refunded. - let balance: cw20::BalanceResponse = app - .wrap() - .query_wasm_smart( - token_contract.to_string(), - &cw20::Cw20QueryMsg::Balance { - address: CREATOR_ADDR.to_string(), - }, - ) - .unwrap(); - - assert_eq!(balance.balance, Uint128::new(1)); - - // Close the proposal - this should fail as it was executed. - let err: ContractError = app - .execute_contract( - Addr::unchecked(CREATOR_ADDR), - proposal_single, - &ExecuteMsg::Close { proposal_id: 1 }, - &[], - ) - .unwrap_err() - .downcast() - .unwrap(); - - assert!(matches!(err, ContractError::WrongCloseStatus {})); - - // Check that our deposit was not refunded a second time on close. - let balance: cw20::BalanceResponse = app - .wrap() - .query_wasm_smart( - token_contract.to_string(), - &cw20::Cw20QueryMsg::Balance { - address: CREATOR_ADDR.to_string(), - }, - ) - .unwrap(); - - assert_eq!(balance.balance, Uint128::new(1)); -} - -#[test] -pub fn test_migrate_update_version() { - let mut deps = mock_dependencies(); - cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").unwrap(); - migrate(deps.as_mut(), mock_env(), MigrateMsg::FromCompatible {}).unwrap(); - let version = cw2::get_contract_version(&deps.storage).unwrap(); - assert_eq!(version.version, CONTRACT_VERSION); - assert_eq!(version.contract, CONTRACT_NAME); -} diff --git a/packages/cwd-testing/src/tests.rs b/packages/cwd-testing/src/tests.rs index bb465f828..daaf027f2 100644 --- a/packages/cwd-testing/src/tests.rs +++ b/packages/cwd-testing/src/tests.rs @@ -124,20 +124,12 @@ where F: Fn(Vec, Threshold, Status, Option), { do_votes( - vec![ - TestSingleChoiceVote { - voter: "zeke".to_string(), - position: Vote::No, - weight: Uint128::new(1), - should_execute: ShouldExecute::Yes, - }, - TestSingleChoiceVote { - voter: "ekez".to_string(), - position: Vote::Yes, - weight: Uint128::new(u128::max_value() - 1), - should_execute: ShouldExecute::No, - }, - ], + vec![TestSingleChoiceVote { + voter: "zeke".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], Threshold::AbsolutePercentage { percentage: PercentageThreshold::Percent(Decimal::percent(100)), }, @@ -344,11 +336,34 @@ where weight: Uint128::new(5), should_execute: ShouldExecute::Yes, }, + ], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(50)), + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + // Can vote up to expiration time. TestSingleChoiceVote { voter: "ezek".to_string(), position: Vote::No, weight: Uint128::new(5), - should_execute: ShouldExecute::No, + should_execute: ShouldExecute::Yes, }, ], Threshold::AbsolutePercentage { @@ -377,11 +392,35 @@ where weight: Uint128::new(5), should_execute: ShouldExecute::Yes, }, + ], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(10)), + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + // Can vote up to expiration time. TestSingleChoiceVote { voter: "ezek".to_string(), position: Vote::No, weight: Uint128::new(10), - should_execute: ShouldExecute::No, + should_execute: ShouldExecute::Yes, }, ], Threshold::ThresholdQuorum { @@ -428,11 +467,12 @@ where weight: Uint128::new(10), should_execute: ShouldExecute::Yes, }, + // Can vote up to expiration time, even if it already rejected. TestSingleChoiceVote { voter: "keze".to_string(), position: Vote::Yes, weight: Uint128::new(10), - should_execute: ShouldExecute::No, + should_execute: ShouldExecute::Yes, }, ], Threshold::ThresholdQuorum {