use std::cmp::Ordering;
#[cfg(not(feature = "library"))]
use cosmwasm_std::entry_point;
use cosmwasm_std::{
to_binary, Binary, BlockInfo, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Order,
Response, StdResult,
};
use cw2::set_contract_version;
use cw3::{
ProposalListResponse, ProposalResponse, Status, Vote, VoteInfo, VoteListResponse, VoteResponse,
VoterDetail, VoterListResponse, VoterResponse,
};
use cw3_fixed_multisig::state::{next_id, Ballot, Proposal, Votes, BALLOTS, PROPOSALS};
use cw4::{Cw4Contract, MemberChangedHookMsg, MemberDiff};
use cw_storage_plus::Bound;
use cw_utils::{maybe_addr, Expiration, ThresholdResponse};
use crate::error::ContractError;
use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
use crate::state::{Config, CONFIG};
const CONTRACT_NAME: &str = "crates.io:cw3-flex-multisig";
const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
let group_addr = Cw4Contract(deps.api.addr_validate(&msg.group_addr).map_err(|_| {
ContractError::InvalidGroup {
addr: msg.group_addr.clone(),
}
})?);
let total_weight = group_addr.total_weight(&deps.querier)?;
msg.threshold.validate(total_weight)?;
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let cfg = Config {
threshold: msg.threshold,
max_voting_period: msg.max_voting_period,
group_addr,
};
CONFIG.save(deps.storage, &cfg)?;
Ok(Response::default())
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response<Empty>, ContractError> {
match msg {
ExecuteMsg::Propose {
title,
description,
msgs,
latest,
} => execute_propose(deps, env, info, title, description, msgs, latest),
ExecuteMsg::Vote { proposal_id, vote } => execute_vote(deps, env, info, proposal_id, vote),
ExecuteMsg::Execute { proposal_id } => execute_execute(deps, env, info, proposal_id),
ExecuteMsg::Close { proposal_id } => execute_close(deps, env, info, proposal_id),
ExecuteMsg::MemberChangedHook(MemberChangedHookMsg { diffs }) => {
execute_membership_hook(deps, env, info, diffs)
}
}
}
pub fn execute_propose(
deps: DepsMut,
env: Env,
info: MessageInfo,
title: String,
description: String,
msgs: Vec<CosmosMsg>,
latest: Option<Expiration>,
) -> Result<Response<Empty>, ContractError> {
let cfg = CONFIG.load(deps.storage)?;
let vote_power = cfg
.group_addr
.is_member(&deps.querier, &info.sender, None)?
.ok_or(ContractError::Unauthorized {})?;
let max_expires = cfg.max_voting_period.after(&env.block);
let mut expires = latest.unwrap_or(max_expires);
let comp = expires.partial_cmp(&max_expires);
if let Some(Ordering::Greater) = comp {
expires = max_expires;
} else if comp.is_none() {
return Err(ContractError::WrongExpiration {});
}
let mut prop = Proposal {
title,
description,
start_height: env.block.height,
expires,
msgs,
status: Status::Open,
votes: Votes::yes(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, &prop)?;
let ballot = Ballot {
weight: vote_power,
vote: Vote::Yes,
};
BALLOTS.save(deps.storage, (id, &info.sender), &ballot)?;
Ok(Response::new()
.add_attribute("action", "propose")
.add_attribute("sender", info.sender)
.add_attribute("proposal_id", id.to_string())
.add_attribute("status", format!("{:?}", prop.status)))
}
pub fn execute_vote(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
vote: Vote,
) -> Result<Response<Empty>, ContractError> {
let cfg = CONFIG.load(deps.storage)?;
let mut prop = PROPOSALS.load(deps.storage, proposal_id)?;
if prop.status != Status::Open {
return Err(ContractError::NotOpen {});
}
if prop.expires.is_expired(&env.block) {
return Err(ContractError::Expired {});
}
let vote_power = cfg
.group_addr
.is_voting_member(&deps.querier, &info.sender, prop.start_height)?
.ok_or(ContractError::Unauthorized {})?;
BALLOTS.update(deps.storage, (proposal_id, &info.sender), |bal| match bal {
Some(_) => Err(ContractError::AlreadyVoted {}),
None => Ok(Ballot {
weight: vote_power,
vote,
}),
})?;
prop.votes.add_vote(vote, vote_power);
prop.update_status(&env.block);
PROPOSALS.save(deps.storage, proposal_id, &prop)?;
Ok(Response::new()
.add_attribute("action", "vote")
.add_attribute("sender", info.sender)
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("status", format!("{:?}", prop.status)))
}
pub fn execute_execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
) -> Result<Response, ContractError> {
let mut prop = PROPOSALS.load(deps.storage, proposal_id)?;
if prop.current_status(&env.block) != Status::Passed {
return Err(ContractError::WrongExecuteStatus {});
}
prop.status = Status::Executed;
PROPOSALS.save(deps.storage, proposal_id, &prop)?;
Ok(Response::new()
.add_messages(prop.msgs)
.add_attribute("action", "execute")
.add_attribute("sender", info.sender)
.add_attribute("proposal_id", proposal_id.to_string()))
}
pub fn execute_close(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
) -> Result<Response<Empty>, ContractError> {
let mut prop = PROPOSALS.load(deps.storage, proposal_id)?;
if [Status::Executed, Status::Rejected, Status::Passed]
.iter()
.any(|x| *x == prop.status)
{
return Err(ContractError::WrongCloseStatus {});
}
if !prop.expires.is_expired(&env.block) {
return Err(ContractError::NotExpired {});
}
prop.status = Status::Rejected;
PROPOSALS.save(deps.storage, proposal_id, &prop)?;
Ok(Response::new()
.add_attribute("action", "close")
.add_attribute("sender", info.sender)
.add_attribute("proposal_id", proposal_id.to_string()))
}
pub fn execute_membership_hook(
deps: DepsMut,
_env: Env,
info: MessageInfo,
_diffs: Vec<MemberDiff>,
) -> Result<Response<Empty>, ContractError> {
let cfg = CONFIG.load(deps.storage)?;
if info.sender != cfg.group_addr.0 {
return Err(ContractError::Unauthorized {});
}
Ok(Response::default())
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Threshold {} => to_binary(&query_threshold(deps)?),
QueryMsg::Proposal { proposal_id } => to_binary(&query_proposal(deps, env, proposal_id)?),
QueryMsg::Vote { proposal_id, voter } => to_binary(&query_vote(deps, proposal_id, voter)?),
QueryMsg::ListProposals { start_after, limit } => {
to_binary(&list_proposals(deps, env, start_after, limit)?)
}
QueryMsg::ReverseProposals {
start_before,
limit,
} => to_binary(&reverse_proposals(deps, env, start_before, limit)?),
QueryMsg::ListVotes {
proposal_id,
start_after,
limit,
} => to_binary(&list_votes(deps, proposal_id, start_after, limit)?),
QueryMsg::Voter { address } => to_binary(&query_voter(deps, address)?),
QueryMsg::ListVoters { start_after, limit } => {
to_binary(&list_voters(deps, start_after, limit)?)
}
}
}
fn query_threshold(deps: Deps) -> StdResult<ThresholdResponse> {
let cfg = CONFIG.load(deps.storage)?;
let total_weight = cfg.group_addr.total_weight(&deps.querier)?;
Ok(cfg.threshold.to_response(total_weight))
}
fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult<ProposalResponse> {
let prop = PROPOSALS.load(deps.storage, id)?;
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,
status,
expires: prop.expires,
threshold,
})
}
const MAX_LIMIT: u32 = 30;
const DEFAULT_LIMIT: u32 = 10;
fn list_proposals(
deps: Deps,
env: Env,
start_after: Option<u64>,
limit: Option<u32>,
) -> StdResult<ProposalListResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let start = start_after.map(Bound::exclusive);
let proposals = PROPOSALS
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|p| map_proposal(&env.block, p))
.collect::<StdResult<_>>()?;
Ok(ProposalListResponse { proposals })
}
fn reverse_proposals(
deps: Deps,
env: Env,
start_before: Option<u64>,
limit: Option<u32>,
) -> StdResult<ProposalListResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let end = start_before.map(Bound::exclusive);
let props: StdResult<Vec<_>> = PROPOSALS
.range(deps.storage, None, end, Order::Descending)
.take(limit)
.map(|p| map_proposal(&env.block, p))
.collect();
Ok(ProposalListResponse { proposals: props? })
}
fn map_proposal(
block: &BlockInfo,
item: StdResult<(u64, Proposal)>,
) -> StdResult<ProposalResponse> {
item.map(|(id, prop)| {
let status = prop.current_status(block);
let threshold = prop.threshold.to_response(prop.total_weight);
ProposalResponse {
id,
title: prop.title,
description: prop.description,
msgs: prop.msgs,
status,
expires: prop.expires,
threshold,
}
})
}
fn query_vote(deps: Deps, proposal_id: u64, voter: String) -> StdResult<VoteResponse> {
let voter_addr = deps.api.addr_validate(&voter)?;
let prop = BALLOTS.may_load(deps.storage, (proposal_id, &voter_addr))?;
let vote = prop.map(|b| VoteInfo {
proposal_id,
voter,
vote: b.vote,
weight: b.weight,
});
Ok(VoteResponse { vote })
}
fn list_votes(
deps: Deps,
proposal_id: u64,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<VoteListResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let addr = maybe_addr(deps.api, start_after)?;
let start = addr.as_ref().map(Bound::exclusive);
let votes = BALLOTS
.prefix(proposal_id)
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|item| {
item.map(|(addr, ballot)| VoteInfo {
proposal_id,
voter: addr.into(),
vote: ballot.vote,
weight: ballot.weight,
})
})
.collect::<StdResult<_>>()?;
Ok(VoteListResponse { votes })
}
fn query_voter(deps: Deps, voter: String) -> StdResult<VoterResponse> {
let cfg = CONFIG.load(deps.storage)?;
let voter_addr = deps.api.addr_validate(&voter)?;
let weight = cfg.group_addr.is_member(&deps.querier, &voter_addr, None)?;
Ok(VoterResponse { weight })
}
fn list_voters(
deps: Deps,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<VoterListResponse> {
let cfg = CONFIG.load(deps.storage)?;
let voters = cfg
.group_addr
.list_members(&deps.querier, start_after, limit)?
.into_iter()
.map(|member| VoterDetail {
addr: member.addr,
weight: member.weight,
})
.collect();
Ok(VoterListResponse { voters })
}
#[cfg(test)]
mod tests {
use cosmwasm_std::{coin, coins, Addr, BankMsg, Coin, Decimal, Timestamp};
use cw2::{query_contract_info, ContractVersion};
use cw4::{Cw4ExecuteMsg, Member};
use cw4_group::helpers::Cw4GroupContract;
use cw_multi_test::{next_block, App, AppBuilder, Contract, ContractWrapper, Executor};
use cw_utils::{Duration, Threshold};
use super::*;
const OWNER: &str = "admin0001";
const VOTER1: &str = "voter0001";
const VOTER2: &str = "voter0002";
const VOTER3: &str = "voter0003";
const VOTER4: &str = "voter0004";
const VOTER5: &str = "voter0005";
const SOMEBODY: &str = "somebody";
fn member<T: Into<String>>(addr: T, weight: u64) -> Member {
Member {
addr: addr.into(),
weight,
}
}
pub fn contract_flex() -> Box<dyn Contract<Empty>> {
let contract = ContractWrapper::new(
crate::contract::execute,
crate::contract::instantiate,
crate::contract::query,
);
Box::new(contract)
}
pub fn contract_group() -> Box<dyn Contract<Empty>> {
let contract = ContractWrapper::new(
cw4_group::contract::execute,
cw4_group::contract::instantiate,
cw4_group::contract::query,
);
Box::new(contract)
}
fn mock_app(init_funds: &[Coin]) -> App {
AppBuilder::new().build(|router, _, storage| {
router
.bank
.init_balance(storage, &Addr::unchecked(OWNER), init_funds.to_vec())
.unwrap();
})
}
fn instantiate_group(app: &mut App, members: Vec<Member>) -> Addr {
let group_id = app.store_code(contract_group());
let msg = cw4_group::msg::InstantiateMsg {
admin: Some(OWNER.into()),
members,
};
app.instantiate_contract(group_id, Addr::unchecked(OWNER), &msg, &[], "group", None)
.unwrap()
}
#[track_caller]
fn instantiate_flex(
app: &mut App,
group: Addr,
threshold: Threshold,
max_voting_period: Duration,
) -> Addr {
let flex_id = app.store_code(contract_flex());
let msg = crate::msg::InstantiateMsg {
group_addr: group.to_string(),
threshold,
max_voting_period,
};
app.instantiate_contract(flex_id, Addr::unchecked(OWNER), &msg, &[], "flex", None)
.unwrap()
}
#[track_caller]
fn setup_test_case_fixed(
app: &mut App,
weight_needed: u64,
max_voting_period: Duration,
init_funds: Vec<Coin>,
multisig_as_group_admin: bool,
) -> (Addr, Addr) {
setup_test_case(
app,
Threshold::AbsoluteCount {
weight: weight_needed,
},
max_voting_period,
init_funds,
multisig_as_group_admin,
)
}
#[track_caller]
fn setup_test_case(
app: &mut App,
threshold: Threshold,
max_voting_period: Duration,
init_funds: Vec<Coin>,
multisig_as_group_admin: bool,
) -> (Addr, Addr) {
let members = vec![
member(OWNER, 0),
member(VOTER1, 1),
member(VOTER2, 2),
member(VOTER3, 3),
member(VOTER4, 12), member(VOTER5, 5),
];
let group_addr = instantiate_group(app, members);
app.update_block(next_block);
let flex_addr = instantiate_flex(app, group_addr.clone(), threshold, max_voting_period);
app.update_block(next_block);
if multisig_as_group_admin {
let update_admin = Cw4ExecuteMsg::UpdateAdmin {
admin: Some(flex_addr.to_string()),
};
app.execute_contract(
Addr::unchecked(OWNER),
group_addr.clone(),
&update_admin,
&[],
)
.unwrap();
app.update_block(next_block);
}
if !init_funds.is_empty() {
app.send_tokens(Addr::unchecked(OWNER), flex_addr.clone(), &init_funds)
.unwrap();
}
(flex_addr, group_addr)
}
fn proposal_info() -> (Vec<CosmosMsg<Empty>>, String, String) {
let bank_msg = BankMsg::Send {
to_address: SOMEBODY.into(),
amount: coins(1, "BTC"),
};
let msgs = vec![bank_msg.into()];
let title = "Pay somebody".to_string();
let description = "Do I pay her?".to_string();
(msgs, title, description)
}
fn pay_somebody_proposal() -> ExecuteMsg {
let (msgs, title, description) = proposal_info();
ExecuteMsg::Propose {
title,
description,
msgs,
latest: None,
}
}
#[test]
fn test_instantiate_works() {
let mut app = mock_app(&[]);
let group_addr = instantiate_group(&mut app, vec![member(OWNER, 1)]);
let flex_id = app.store_code(contract_flex());
let max_voting_period = Duration::Time(1234567);
let instantiate_msg = InstantiateMsg {
group_addr: group_addr.to_string(),
threshold: Threshold::ThresholdQuorum {
threshold: Decimal::zero(),
quorum: Decimal::percent(1),
},
max_voting_period,
};
let err = app
.instantiate_contract(
flex_id,
Addr::unchecked(OWNER),
&instantiate_msg,
&[],
"zero required weight",
None,
)
.unwrap_err();
assert_eq!(
ContractError::Threshold(cw_utils::ThresholdError::InvalidThreshold {}),
err.downcast().unwrap()
);
let instantiate_msg = InstantiateMsg {
group_addr: group_addr.to_string(),
threshold: Threshold::AbsoluteCount { weight: 100 },
max_voting_period,
};
let err = app
.instantiate_contract(
flex_id,
Addr::unchecked(OWNER),
&instantiate_msg,
&[],
"high required weight",
None,
)
.unwrap_err();
assert_eq!(
ContractError::Threshold(cw_utils::ThresholdError::UnreachableWeight {}),
err.downcast().unwrap()
);
let instantiate_msg = InstantiateMsg {
group_addr: group_addr.to_string(),
threshold: Threshold::AbsoluteCount { weight: 1 },
max_voting_period,
};
let flex_addr = app
.instantiate_contract(
flex_id,
Addr::unchecked(OWNER),
&instantiate_msg,
&[],
"all good",
None,
)
.unwrap();
let version = query_contract_info(&app, flex_addr.clone()).unwrap();
assert_eq!(
ContractVersion {
contract: CONTRACT_NAME.to_string(),
version: CONTRACT_VERSION.to_string(),
},
version,
);
let voters: VoterListResponse = app
.wrap()
.query_wasm_smart(
&flex_addr,
&QueryMsg::ListVoters {
start_after: None,
limit: None,
},
)
.unwrap();
assert_eq!(
voters.voters,
vec![VoterDetail {
addr: OWNER.into(),
weight: 1
}]
);
}
#[test]
fn test_propose_works() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let required_weight = 4;
let voting_period = Duration::Time(2000000);
let (flex_addr, _) =
setup_test_case_fixed(&mut app, required_weight, voting_period, init_funds, false);
let proposal = pay_somebody_proposal();
let err = app
.execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &proposal, &[])
.unwrap_err();
assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap());
let msgs = match proposal.clone() {
ExecuteMsg::Propose { msgs, .. } => msgs,
_ => panic!("Wrong variant"),
};
let proposal_wrong_exp = ExecuteMsg::Propose {
title: "Rewarding somebody".to_string(),
description: "Do we reward her?".to_string(),
msgs,
latest: Some(Expiration::AtHeight(123456)),
};
let err = app
.execute_contract(
Addr::unchecked(OWNER),
flex_addr.clone(),
&proposal_wrong_exp,
&[],
)
.unwrap_err();
assert_eq!(ContractError::WrongExpiration {}, err.downcast().unwrap());
let res = app
.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &proposal, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "propose"),
("sender", VOTER3),
("proposal_id", "1"),
("status", "Open"),
],
);
let res = app
.execute_contract(Addr::unchecked(VOTER4), flex_addr, &proposal, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "propose"),
("sender", VOTER4),
("proposal_id", "2"),
("status", "Passed"),
],
);
}
fn get_tally(app: &App, flex_addr: &str, proposal_id: u64) -> u64 {
let voters = QueryMsg::ListVotes {
proposal_id,
start_after: None,
limit: None,
};
let votes: VoteListResponse = app.wrap().query_wasm_smart(flex_addr, &voters).unwrap();
votes
.votes
.iter()
.filter(|&v| v.vote == Vote::Yes)
.map(|v| v.weight)
.sum()
}
fn expire(voting_period: Duration) -> impl Fn(&mut BlockInfo) {
move |block: &mut BlockInfo| {
match voting_period {
Duration::Time(duration) => block.time = block.time.plus_seconds(duration + 1),
Duration::Height(duration) => block.height += duration + 1,
};
}
}
fn unexpire(voting_period: Duration) -> impl Fn(&mut BlockInfo) {
move |block: &mut BlockInfo| {
match voting_period {
Duration::Time(duration) => {
block.time =
Timestamp::from_nanos(block.time.nanos() - (duration * 1_000_000_000));
}
Duration::Height(duration) => block.height -= duration,
};
}
}
#[test]
fn test_proposal_queries() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let voting_period = Duration::Time(2000000);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(80),
quorum: Decimal::percent(20),
};
let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, false);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id1: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
app.update_block(next_block);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id2: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
app.update_block(expire(voting_period));
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id3: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let proposed_at = app.block_info();
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());
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);
let (expected_msgs, expected_title, expected_description) = proposal_info();
for prop in res.proposals {
assert_eq!(prop.title, expected_title);
assert_eq!(prop.description, expected_description);
assert_eq!(prop.msgs, expected_msgs);
}
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();
let expected = ProposalResponse {
id: proposal_id3,
title,
description,
msgs,
expires: voting_period.after(&proposed_at),
status: Status::Open,
threshold: ThresholdResponse::ThresholdQuorum {
total_weight: 23,
threshold: Decimal::percent(80),
quorum: Decimal::percent(20),
},
};
assert_eq!(&expected, &res.proposals[0]);
}
#[test]
fn test_vote_works() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
let voting_period = Duration::Time(2000000);
let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, false);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let yes_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
let err = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap());
let err = app
.execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap());
let res = app
.execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "vote"),
("sender", VOTER1),
("proposal_id", proposal_id.to_string().as_str()),
("status", "Open"),
],
);
let err = app
.execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::AlreadyVoted {}, err.downcast().unwrap());
let tally = get_tally(&app, flex_addr.as_ref(), proposal_id);
assert_eq!(tally, 1);
let no_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::No,
};
let _ = app
.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &no_vote, &[])
.unwrap();
let veto_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Veto,
};
let _ = app
.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &veto_vote, &[])
.unwrap();
assert_eq!(tally, get_tally(&app, flex_addr.as_ref(), proposal_id));
let err = app
.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::AlreadyVoted {}, err.downcast().unwrap());
app.update_block(expire(voting_period));
let err = app
.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::Expired {}, err.downcast().unwrap());
app.update_block(unexpire(voting_period));
let res = app
.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "vote"),
("sender", VOTER4),
("proposal_id", proposal_id.to_string().as_str()),
("status", "Passed"),
],
);
let err = app
.execute_contract(Addr::unchecked(VOTER5), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::NotOpen {}, err.downcast().unwrap());
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 {
proposal_id,
voter: OWNER.into(),
vote: Vote::Yes,
weight: 0
}
);
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 {
proposal_id,
voter: VOTER2.into(),
vote: Vote::No,
weight: 2
}
);
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]
fn test_execute_works() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
let voting_period = Duration::Time(2000000);
let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, true);
let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap();
assert_eq!(contract_bal, coin(10, "BTC"));
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let execution = ExecuteMsg::Execute { proposal_id };
let err = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &execution, &[])
.unwrap_err();
assert_eq!(
ContractError::WrongExecuteStatus {},
err.downcast().unwrap()
);
let vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
let res = app
.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &vote, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "vote"),
("sender", VOTER4),
("proposal_id", proposal_id.to_string().as_str()),
("status", "Passed"),
],
);
let closing = ExecuteMsg::Close { proposal_id };
let err = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &closing, &[])
.unwrap_err();
assert_eq!(ContractError::WrongCloseStatus {}, err.downcast().unwrap());
let res = app
.execute_contract(
Addr::unchecked(SOMEBODY),
flex_addr.clone(),
&execution,
&[],
)
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "execute"),
("sender", SOMEBODY),
("proposal_id", proposal_id.to_string().as_str()),
],
);
let some_bal = app.wrap().query_balance(SOMEBODY, "BTC").unwrap();
assert_eq!(some_bal, coin(1, "BTC"));
let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap();
assert_eq!(contract_bal, coin(9, "BTC"));
let err = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &closing, &[])
.unwrap_err();
assert_eq!(ContractError::WrongCloseStatus {}, err.downcast().unwrap());
let err = app
.execute_contract(Addr::unchecked(SOMEBODY), flex_addr, &execution, &[])
.unwrap_err();
assert_eq!(
ContractError::WrongExecuteStatus {},
err.downcast().unwrap()
);
}
#[test]
fn proposal_pass_on_expiration() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
let voting_period = 2000000;
let (flex_addr, _) = setup_test_case(
&mut app,
threshold,
Duration::Time(voting_period),
init_funds,
true,
);
let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap();
assert_eq!(contract_bal, coin(10, "BTC"));
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
let res = app
.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &vote, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "vote"),
("sender", VOTER3),
("proposal_id", proposal_id.to_string().as_str()),
("status", "Open"),
],
);
app.update_block(|block| {
block.time = block.time.plus_seconds(voting_period);
block.height += std::cmp::max(1, voting_period / 5);
});
let prop: ProposalResponse = app
.wrap()
.query_wasm_smart(&flex_addr, &QueryMsg::Proposal { proposal_id })
.unwrap();
assert_eq!(prop.status, Status::Passed);
let res = app
.execute_contract(
Addr::unchecked(SOMEBODY),
flex_addr,
&ExecuteMsg::Execute { proposal_id },
&[],
)
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "execute"),
("sender", SOMEBODY),
("proposal_id", proposal_id.to_string().as_str()),
],
);
}
#[test]
fn test_close_works() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
let voting_period = Duration::Height(2000000);
let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, true);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let closing = ExecuteMsg::Close { proposal_id };
let err = app
.execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &closing, &[])
.unwrap_err();
assert_eq!(ContractError::NotExpired {}, err.downcast().unwrap());
app.update_block(expire(voting_period));
let res = app
.execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &closing, &[])
.unwrap();
assert_eq!(
res.custom_attrs(1),
[
("action", "close"),
("sender", SOMEBODY),
("proposal_id", proposal_id.to_string().as_str()),
],
);
let closing = ExecuteMsg::Close { proposal_id };
let err = app
.execute_contract(Addr::unchecked(SOMEBODY), flex_addr, &closing, &[])
.unwrap_err();
assert_eq!(ContractError::WrongCloseStatus {}, err.downcast().unwrap());
}
#[test]
fn execute_group_changes_from_external() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
let voting_period = Duration::Time(20000);
let (flex_addr, group_addr) =
setup_test_case(&mut app, threshold, voting_period, init_funds, false);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let prop_status = |app: &App, proposal_id: u64| -> 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, proposal_id), Status::Open);
let threshold: ThresholdResponse = app
.wrap()
.query_wasm_smart(&flex_addr, &QueryMsg::Threshold {})
.unwrap();
let expected_thresh = ThresholdResponse::ThresholdQuorum {
total_weight: 23,
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
assert_eq!(expected_thresh, threshold);
app.update_block(|block| block.height += 2);
let newbie: &str = "newbie";
let update_msg = cw4_group::msg::ExecuteMsg::UpdateMembers {
remove: vec![VOTER3.into()],
add: vec![member(VOTER2, 21), member(newbie, 2)],
};
app.execute_contract(Addr::unchecked(OWNER), group_addr, &update_msg, &[])
.unwrap();
let query_voter = QueryMsg::Voter {
address: VOTER3.into(),
};
let power: VoterResponse = app
.wrap()
.query_wasm_smart(&flex_addr, &query_voter)
.unwrap();
assert_eq!(power.weight, None);
assert_eq!(prop_status(&app, proposal_id), Status::Open);
app.update_block(|block| block.height += 3);
let proposal2 = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &proposal2, &[])
.unwrap();
let proposal_id2: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
let yes_vote = ExecuteMsg::Vote {
proposal_id: proposal_id2,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(prop_status(&app, proposal_id2), Status::Passed);
let yes_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(prop_status(&app, proposal_id), Status::Open);
let err = app
.execute_contract(Addr::unchecked(newbie), flex_addr.clone(), &yes_vote, &[])
.unwrap_err();
assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap());
app.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &yes_vote, &[])
.unwrap();
let threshold: ThresholdResponse = app
.wrap()
.query_wasm_smart(&flex_addr, &QueryMsg::Threshold {})
.unwrap();
let expected_thresh = ThresholdResponse::ThresholdQuorum {
total_weight: 41,
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
assert_eq!(expected_thresh, threshold);
}
#[test]
fn execute_group_changes_from_proposal() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let required_weight = 4;
let voting_period = Duration::Time(20000);
let (flex_addr, group_addr) =
setup_test_case_fixed(&mut app, required_weight, voting_period, init_funds, true);
let update_msg = Cw4GroupContract::new(group_addr)
.update_members(vec![VOTER3.into()], vec![])
.unwrap();
let update_proposal = ExecuteMsg::Propose {
title: "Kick out VOTER3".to_string(),
description: "He's trying to steal our money".to_string(),
msgs: vec![update_msg],
latest: None,
};
let res = app
.execute_contract(
Addr::unchecked(VOTER1),
flex_addr.clone(),
&update_proposal,
&[],
)
.unwrap();
let update_proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
app.update_block(|b| b.height += 1);
let cash_proposal = pay_somebody_proposal();
let res = app
.execute_contract(
Addr::unchecked(VOTER1),
flex_addr.clone(),
&cash_proposal,
&[],
)
.unwrap();
let cash_proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
assert_ne!(cash_proposal_id, update_proposal_id);
let prop_status = |app: &App, proposal_id: u64| -> 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, cash_proposal_id), Status::Open);
assert_eq!(prop_status(&app, update_proposal_id), Status::Open);
app.update_block(|b| b.height += 1);
let yes_vote = ExecuteMsg::Vote {
proposal_id: update_proposal_id,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[])
.unwrap();
let execution = ExecuteMsg::Execute {
proposal_id: update_proposal_id,
};
app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &execution, &[])
.unwrap();
assert_eq!(prop_status(&app, update_proposal_id), Status::Executed);
assert_eq!(prop_status(&app, cash_proposal_id), Status::Open);
app.update_block(|b| b.height += 1);
let yes_vote = ExecuteMsg::Vote {
proposal_id: cash_proposal_id,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(prop_status(&app, cash_proposal_id), Status::Passed);
let cash_proposal = pay_somebody_proposal();
let err = app
.execute_contract(
Addr::unchecked(VOTER3),
flex_addr.clone(),
&cash_proposal,
&[],
)
.unwrap_err();
assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap());
let hook_hack = ExecuteMsg::MemberChangedHook(MemberChangedHookMsg {
diffs: vec![MemberDiff::new(VOTER1, Some(1), None)],
});
let err = app
.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &hook_hack, &[])
.unwrap_err();
assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap());
}
#[test]
fn percentage_handles_group_changes() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let threshold = Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(1),
};
let voting_period = Duration::Time(20000);
let (flex_addr, group_addr) =
setup_test_case(&mut app, threshold, voting_period, init_funds, false);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[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 += 2);
let newbie: &str = "newbie";
let update_msg = cw4_group::msg::ExecuteMsg::UpdateMembers {
remove: vec![VOTER3.into()],
add: vec![member(VOTER2, 9), member(newbie, 29)],
};
app.execute_contract(Addr::unchecked(OWNER), group_addr, &update_msg, &[])
.unwrap();
app.update_block(|block| block.height += 3);
let yes_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(prop_status(&app), Status::Open);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(newbie), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id2: u64 = res.custom_attrs(1)[2].value.parse().unwrap();
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);
}
#[test]
fn quorum_handles_group_changes() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let voting_period = Duration::Time(20000);
let (flex_addr, group_addr) = setup_test_case(
&mut app,
Threshold::ThresholdQuorum {
threshold: Decimal::percent(51),
quorum: Decimal::percent(33),
},
voting_period,
init_funds,
false,
);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[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 += 2);
let newbie: &str = "newbie";
let update_msg = cw4_group::msg::ExecuteMsg::UpdateMembers {
remove: vec![VOTER3.into()],
add: vec![member(VOTER2, 9), member(newbie, 29)],
};
app.execute_contract(Addr::unchecked(OWNER), group_addr, &update_msg, &[])
.unwrap();
app.update_block(|block| block.height += 3);
let yes_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(prop_status(&app), Status::Open);
app.update_block(expire(voting_period));
assert_eq!(prop_status(&app), Status::Rejected);
}
#[test]
fn quorum_enforced_even_if_absolute_threshold_met() {
let init_funds = coins(10, "BTC");
let mut app = mock_app(&init_funds);
let voting_period = Duration::Time(20000);
let (flex_addr, _) = setup_test_case(
&mut app,
Threshold::ThresholdQuorum {
threshold: Decimal::percent(60),
quorum: Decimal::percent(80),
},
voting_period,
init_funds,
false,
);
let proposal = pay_somebody_proposal();
let res = app
.execute_contract(Addr::unchecked(VOTER5), flex_addr.clone(), &proposal, &[])
.unwrap();
let proposal_id: u64 = res.custom_attrs(1)[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);
let yes_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::Yes,
};
app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[])
.unwrap();
assert_eq!(prop_status(&app), Status::Open);
let no_vote = ExecuteMsg::Vote {
proposal_id,
vote: Vote::No,
};
app.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &no_vote, &[])
.unwrap();
assert_eq!(prop_status(&app), Status::Passed);
}
}