Skip to content

Commit

Permalink
Allow voting up to expiration time (#477)
Browse files Browse the repository at this point in the history
* execute_vote checks if the proposal has expired

Deleted NotOpen error, added tests

Changed Expired error

* rebase

* readme update to include voting up until expiration

unit & adversarial tests

* removing root test file from prop-single; cleanup; fmt

* removing ununsed test; fix prop-single tests

* prop-multiple readme update

Co-authored-by: bekauz <[email protected]>
  • Loading branch information
nlipartiia-hacken and bekauz authored Nov 26, 2022
1 parent 99e1c01 commit a6e01ea
Show file tree
Hide file tree
Showing 13 changed files with 610 additions and 667 deletions.
5 changes: 5 additions & 0 deletions contracts/proposal/cwd-proposal-multiple/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
11 changes: 9 additions & 2 deletions contracts/proposal/cwd-proposal-multiple/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 2 additions & 5 deletions contracts/proposal/cwd-proposal-multiple/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.")]
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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(),
Expand Down
91 changes: 90 additions & 1 deletion contracts/proposal/cwd-proposal-multiple/src/testing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 }));
}
5 changes: 5 additions & 0 deletions contracts/proposal/cwd-proposal-single/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions contracts/proposal/cwd-proposal-single/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)?;

Expand Down
5 changes: 1 addition & 4 deletions contracts/proposal/cwd-proposal-single/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading

0 comments on commit a6e01ea

Please sign in to comment.