From 5cf640e8706c02f170f00f761837b9d173e67c6d Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 8 Jul 2024 11:43:29 -0500 Subject: [PATCH 1/5] chore: publish nibiru-ownable and nibiru-ownable-derive --- Cargo.lock | 27 +++++++++---------- Cargo.toml | 3 ++- packages/cw-address-like/Cargo.toml | 4 +-- .../Cargo.toml | 6 ++--- .../README.md | 0 .../src/lib.rs | 0 packages/nibiru-ownable/Cargo.toml | 6 ++--- packages/nibiru-ownable/README.md | 6 ++--- packages/nibiru-ownable/src/address_like.rs | 18 +++++++++++++ packages/nibiru-ownable/src/lib.rs | 8 +++--- 10 files changed, 48 insertions(+), 30 deletions(-) rename packages/{nibiru-ownable/derive => nibiru-ownable-derive}/Cargo.toml (85%) rename packages/{nibiru-ownable/derive => nibiru-ownable-derive}/README.md (100%) rename packages/{nibiru-ownable/derive => nibiru-ownable-derive}/src/lib.rs (100%) create mode 100644 packages/nibiru-ownable/src/address_like.rs diff --git a/Cargo.lock b/Cargo.lock index 3bbebff..fa8bb9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1902,13 +1902,24 @@ version = "0.1.0" dependencies = [ "cosmwasm-schema 2.0.2", "cosmwasm-std 2.0.2", - "cw-address-like", "cw-storage-plus 2.0.0", "cw-utils 2.0.0", - "ownable-derive", + "nibiru-ownable-derive", "thiserror", ] +[[package]] +name = "nibiru-ownable-derive" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema 2.0.2", + "cosmwasm-std 2.0.2", + "nibiru-ownable", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "nibiru-std" version = "0.1.0" @@ -2005,18 +2016,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "ownable-derive" -version = "0.6.0" -dependencies = [ - "cosmwasm-schema 2.0.2", - "cosmwasm-std 2.0.2", - "nibiru-ownable", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "parking_lot_core" version = "0.9.9" diff --git a/Cargo.toml b/Cargo.toml index 4688121..6f1f7d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,8 @@ prost-types = "0.12.3" bash-rs = { path = "packages/bash-rs" } # Macros for controlling ownership of CosmWasm smart contracts nibiru-ownable = { path = "packages/nibiru-ownable" } -ownable-derive = { path = "packages/nibiru-ownable/derive" } +nibiru-ownable-derive = { version = "0.1.0" } +# nibiru-ownable-derive = { path = "packages/nibiru-ownable-derive" } cw-address-like = { path = "packages/cw-address-like" } easy-addr = { path = "packages/easy-addr" } diff --git a/packages/cw-address-like/Cargo.toml b/packages/cw-address-like/Cargo.toml index 51e2abf..2392011 100644 --- a/packages/cw-address-like/Cargo.toml +++ b/packages/cw-address-like/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cw-address-like" version = "2.0.0" - description = "A trait that marks unchecked or checked CosmWasm address strings" -authors = ["larry "] +description = "A trait that marks unchecked or checked CosmWasm address strings" +authors = ["larry ", "Unique Divine "] edition = { workspace = true } homepage = { workspace = true } repository = { workspace = true } diff --git a/packages/nibiru-ownable/derive/Cargo.toml b/packages/nibiru-ownable-derive/Cargo.toml similarity index 85% rename from packages/nibiru-ownable/derive/Cargo.toml rename to packages/nibiru-ownable-derive/Cargo.toml index 466bb9c..0866b77 100644 --- a/packages/nibiru-ownable/derive/Cargo.toml +++ b/packages/nibiru-ownable-derive/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "ownable-derive" -version = "0.6.0" +name = "nibiru-ownable-derive" +version = { workspace = true } description = "Macros for generating code used by the `nibiru-ownable` crate" authors = ["larry ", "Unique Divine "] -# edition = { workspace = true } +edition = { workspace = true } homepage = { workspace = true } repository = { workspace = true } license = "MIT" # only one of license, license-file is needed. diff --git a/packages/nibiru-ownable/derive/README.md b/packages/nibiru-ownable-derive/README.md similarity index 100% rename from packages/nibiru-ownable/derive/README.md rename to packages/nibiru-ownable-derive/README.md diff --git a/packages/nibiru-ownable/derive/src/lib.rs b/packages/nibiru-ownable-derive/src/lib.rs similarity index 100% rename from packages/nibiru-ownable/derive/src/lib.rs rename to packages/nibiru-ownable-derive/src/lib.rs diff --git a/packages/nibiru-ownable/Cargo.toml b/packages/nibiru-ownable/Cargo.toml index 74b58da..f9b66e4 100644 --- a/packages/nibiru-ownable/Cargo.toml +++ b/packages/nibiru-ownable/Cargo.toml @@ -6,8 +6,7 @@ authors = ["larry ", "Unique Divine Date: Tue, 24 Sep 2024 08:32:46 -0500 Subject: [PATCH 2/5] fix(warning): deprecated clippy warning for CosmosMsg::Stargate --- nibiru-std/src/proto/traits.rs | 6 ++++++ packages/nibiru-ownable/src/address_like.rs | 2 +- packages/nibiru-ownable/src/lib.rs | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/nibiru-std/src/proto/traits.rs b/nibiru-std/src/proto/traits.rs index 4178807..bef13da 100644 --- a/nibiru-std/src/proto/traits.rs +++ b/nibiru-std/src/proto/traits.rs @@ -1,6 +1,12 @@ //! nibiru-std::proto - traits.rs : Implements extensions for prost::Message //! types for easy conversion to types needed for CosmWasm smart contracts. +// Allow deprecated variant `cosmwasm_std::CosmosMsg::Stargate` for compatibility +// with CosmWasm v1. Once we upgrade everything to v2 on Nibiru, we can remove +// this deprecate statement. +#![allow(deprecated)] +// TODO: remove allow(deprevated) ↑ + use cosmwasm_std::{Binary, CosmosMsg, QueryRequest}; use crate::errors::{NibiruError, NibiruResult}; diff --git a/packages/nibiru-ownable/src/address_like.rs b/packages/nibiru-ownable/src/address_like.rs index 6a3aca2..9b163fb 100644 --- a/packages/nibiru-ownable/src/address_like.rs +++ b/packages/nibiru-ownable/src/address_like.rs @@ -15,4 +15,4 @@ pub trait AddressLike: } impl AddressLike for String {} -impl AddressLike for cosmwasm_std::Addr {} \ No newline at end of file +impl AddressLike for cosmwasm_std::Addr {} diff --git a/packages/nibiru-ownable/src/lib.rs b/packages/nibiru-ownable/src/lib.rs index fbf5b76..9056b9f 100644 --- a/packages/nibiru-ownable/src/lib.rs +++ b/packages/nibiru-ownable/src/lib.rs @@ -587,4 +587,4 @@ mod tests { ], ); } -} \ No newline at end of file +} From 6181ee58ae4df76649e3e6376477ec33eb4b927d Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 24 Sep 2024 11:05:29 -0500 Subject: [PATCH 3/5] chore: linter, fix 'just tidy' --- Cargo.lock | 23 ++++++++++++++++++----- Cargo.toml | 2 +- contracts/broker-staking/src/contract.rs | 2 +- contracts/broker-staking/src/testing.rs | 4 ++-- contracts/core-cw3-flex-msig/Cargo.toml | 4 ++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa8bb9d..fe79fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,16 +961,18 @@ dependencies = [ [[package]] name = "cw3-fixed-multisig" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8233125653e61e898eaade6c6fdb3bd9c48aceb2ad97e84eada2c9bf5bff46" +version = "2.0.1" dependencies = [ "cosmwasm-schema 2.0.2", "cosmwasm-std 2.0.2", + "cw-multi-test", "cw-storage-plus 2.0.0", "cw-utils 2.0.0", "cw2 2.0.0", + "cw20 2.0.0", + "cw20-base", "cw3", + "easy-addr", "schemars", "serde", "thiserror", @@ -978,7 +980,7 @@ dependencies = [ [[package]] name = "cw3-flex-multisig" -version = "1.0.1" +version = "2.0.1" dependencies = [ "cosmwasm-schema 2.0.2", "cosmwasm-std 2.0.2", @@ -1904,7 +1906,7 @@ dependencies = [ "cosmwasm-std 2.0.2", "cw-storage-plus 2.0.0", "cw-utils 2.0.0", - "nibiru-ownable-derive", + "nibiru-ownable-derive 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "thiserror", ] @@ -1920,6 +1922,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "nibiru-ownable-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268c084c537b4baf71eab9bcb7c651723e1475b77cbe7b1a8502612784c4057" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "nibiru-std" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6f1f7d8..58e17c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ cw-utils = { version = "2.0.0" } # deps: cw-plus cw2 = { version = "2.0.0" } cw3 = { version = "2.0.0" } -cw3-fixed-multisig = { version = "2.0.0", features = ["library"] } +cw3-fixed-multisig = { path = "contracts/core-cw3-fixed-msig", version = "2.0.0", features = ["library"] } cw4 = { version = "2.0.0" } cw20 = { version = "2.0.0" } cw4-group = { version = "2.0" } diff --git a/contracts/broker-staking/src/contract.rs b/contracts/broker-staking/src/contract.rs index d1c54b8..fbd1f0b 100644 --- a/contracts/broker-staking/src/contract.rs +++ b/contracts/broker-staking/src/contract.rs @@ -75,7 +75,7 @@ pub fn claim_rewards( // query current delegations let delegations = - deps.querier.query_all_delegations(&env.contract.address)?; + deps.querier.query_all_delegations(env.contract.address)?; let mut messages: Vec = vec![]; for delegation in delegations.iter() { diff --git a/contracts/broker-staking/src/testing.rs b/contracts/broker-staking/src/testing.rs index ad1c628..cd3c8a4 100644 --- a/contracts/broker-staking/src/testing.rs +++ b/contracts/broker-staking/src/testing.rs @@ -4,8 +4,8 @@ use crate::contract::{execute, query}; use crate::msg::{ExecuteMsg, StakeMsg, UnstakeMsg}; use cosmwasm_std::{self as cw_std}; use cw_std::{ - coin, from_json, testing, BankMsg, Coin, CosmosMsg, DistributionMsg, - Response, StakingMsg, Uint128, + coin, from_json, testing, BankMsg, Coin, CosmosMsg, Response, StakingMsg, + Uint128, }; use nibiru_std::errors::TestResult; use serde::Serialize; diff --git a/contracts/core-cw3-flex-msig/Cargo.toml b/contracts/core-cw3-flex-msig/Cargo.toml index 3119b77..47a0ef0 100644 --- a/contracts/core-cw3-flex-msig/Cargo.toml +++ b/contracts/core-cw3-flex-msig/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "cw3-flex-multisig" -version = "1.0.1" +version = "2.0.1" authors = ["Ethan Frey "] edition = "2021" description = "Implementing cw3 with multiple voting patterns and dynamic groups" license = "Apache-2.0" -repository = "https://github.com/CosmWasm/cw-plus" +repository = "https://github.com/NibiruChain/cw-nibiru" homepage = "https://cosmwasm.com" documentation = "https://docs.cosmwasm.com" From 8aa2f8625e1b55427276166bfa159ae82cbb361f Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 24 Sep 2024 11:09:40 -0500 Subject: [PATCH 4/5] chore(github): Add project automation for https://tinyurl.com/25uty9w5 --- .github/issue-labeler-config.yml | 3 +++ .github/workflows/gh-issues.yml | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 .github/issue-labeler-config.yml create mode 100644 .github/workflows/gh-issues.yml diff --git a/.github/issue-labeler-config.yml b/.github/issue-labeler-config.yml new file mode 100644 index 0000000..1d7046b --- /dev/null +++ b/.github/issue-labeler-config.yml @@ -0,0 +1,3 @@ +# Adds the "S-triage" label ot any issue that gets opened. +S-triage: + - '/.*/' diff --git a/.github/workflows/gh-issues.yml b/.github/workflows/gh-issues.yml new file mode 100644 index 0000000..6905464 --- /dev/null +++ b/.github/workflows/gh-issues.yml @@ -0,0 +1,38 @@ +name: "Auto-add GH issues to project" +# Add all issues opened to the issue board for triage and assignment +# GitHub Org and Project Automation +# https://www.notion.so/nibiru/GitHub-Org-and-Project-Automation-c771d671109849ee9fda7c8b741cd66a?pvs=4 + +on: + issues: + types: ["opened", "labeled"] + +permissions: + issues: write + contents: read + +jobs: + # https://github.com/actions/add-to-project + add-to-project: + name: "Add GH ticket to project" + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v1.0.2 + with: + project-url: https://github.com/orgs/NibiruChain/projects/8 + github-token: ${{ secrets.NIBIRU_PM }} + + label-triage: + name: "Add GH ticket to project" + runs-on: ubuntu-latest + # The action comes from the "Activty types" for the "issues" webhook event + # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#issues + if: "github.event.action == 'opened'" + steps: + - uses: github/issue-labeler@v3.4 + if: join(github.event.issue.labels) == '' + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + configuration-path: ".github/issue-labeler-config.yml" + enable-versioned-regex: 0 + not-before: "2024-05-01T00:00:00Z" From 2ddfecfcd8d6e0ee8b35dd35002c4200b258e991 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 24 Sep 2024 11:10:28 -0500 Subject: [PATCH 5/5] chore(contracts): add local copy of cw3-fixed-multisig that compiles on v2 --- contracts/core-cw3-fixed-msig/.cargo/config | 6 + contracts/core-cw3-fixed-msig/Cargo.toml | 34 + contracts/core-cw3-fixed-msig/README.md | 65 + .../core-cw3-fixed-msig/src/bin/schema.rs | 11 + contracts/core-cw3-fixed-msig/src/contract.rs | 1198 +++++++++++++++++ contracts/core-cw3-fixed-msig/src/error.rs | 46 + .../src/integration_tests.rs | 153 +++ contracts/core-cw3-fixed-msig/src/lib.rs | 25 + contracts/core-cw3-fixed-msig/src/msg.rs | 74 + contracts/core-cw3-fixed-msig/src/state.rs | 30 + 10 files changed, 1642 insertions(+) create mode 100644 contracts/core-cw3-fixed-msig/.cargo/config create mode 100644 contracts/core-cw3-fixed-msig/Cargo.toml create mode 100644 contracts/core-cw3-fixed-msig/README.md create mode 100644 contracts/core-cw3-fixed-msig/src/bin/schema.rs create mode 100644 contracts/core-cw3-fixed-msig/src/contract.rs create mode 100644 contracts/core-cw3-fixed-msig/src/error.rs create mode 100644 contracts/core-cw3-fixed-msig/src/integration_tests.rs create mode 100644 contracts/core-cw3-fixed-msig/src/lib.rs create mode 100644 contracts/core-cw3-fixed-msig/src/msg.rs create mode 100644 contracts/core-cw3-fixed-msig/src/state.rs diff --git a/contracts/core-cw3-fixed-msig/.cargo/config b/contracts/core-cw3-fixed-msig/.cargo/config new file mode 100644 index 0000000..f517478 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +wasm-debug = "build --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +integration-test = "test --test integration" +schema = "run --bin schema" diff --git a/contracts/core-cw3-fixed-msig/Cargo.toml b/contracts/core-cw3-fixed-msig/Cargo.toml new file mode 100644 index 0000000..a350d71 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "cw3-fixed-multisig" +version = "2.0.1" # Should match contracts/core-cw3-flex-msig +authors = ["Ethan Frey "] +edition = "2021" +description = "Implementing cw3 with an fixed group multisig" +license = "Apache-2.0" +repository = "https://github.com/NibiruChain/cw-nibiru" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw3 = { workspace = true } +cw-storage-plus = { workspace = true } +cosmwasm-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw20 = { workspace = true } +cw20-base = { workspace = true } +cw-multi-test = { workspace = true } +easy-addr = { workspace = true } \ No newline at end of file diff --git a/contracts/core-cw3-fixed-msig/README.md b/contracts/core-cw3-fixed-msig/README.md new file mode 100644 index 0000000..6fabd6a --- /dev/null +++ b/contracts/core-cw3-fixed-msig/README.md @@ -0,0 +1,65 @@ +# CW3 Fixed Multisig + +This is a simple implementation of the [cw3 spec](../../packages/cw3/README.md). +It is a multisig with a fixed set of addresses created upon instatiation. +Each address may have the same weight (K of N), or some may have extra voting +power. This works much like the native Cosmos SDK multisig, except that rather +than aggregating the signatures off chain and submitting the final result, +we aggregate the approvals on-chain. + +This is usable as is, and probably the most secure implementation of cw3 +(as it is the simplest), but we will be adding more complex cases, such +as updating the multisig set, different voting rules for the same group +with different permissions, and even allow token-weighted voting. All through +the same client interface. + +## Instantiation + +To create the multisig, you must pass in a set of `HumanAddr` with a weight +for each one, as well as a required weight to pass a proposal. To create +a 2 of 3 multisig, pass 3 voters with weight 1 and a `required_weight` of 2. + +Note that 0 *is an allowed weight*. This doesn't give any voting rights, but +it does allow that key to submit proposals that can later be approved by the +voters. Any address not in the voter set cannot submit a proposal. + +## Execution Process + +First, a registered voter must submit a proposal. This also includes the +first "Yes" vote on the proposal by the proposer. The proposer can set +an expiration time for the voting process, or it defaults to the limit +provided when creating the contract (so proposals can be closed after several +days). + +Before the proposal has expired, any voter with non-zero weight can add their +vote. Only "Yes" votes are tallied. If enough "Yes" votes were submitted before +the proposal expiration date, the status is set to "Passed". + +Once a proposal is "Passed", anyone may submit an "Execute" message. This will +trigger the proposal to send all stored messages from the proposal and update +it's state to "Executed", so it cannot run again. (Note if the execution fails +for any reason - out of gas, insufficient funds, etc - the state update will +be reverted, and it will remain "Passed", so you can try again). + +Once a proposal has expired without passing, anyone can submit a "Close" +message to mark it closed. This has no effect beyond cleaning up the UI/database. + +## Running this contract + +You will need Rust 1.44.1+ with `wasm32-unknown-unknown` target installed. + +You can run unit tests on this via: + +`cargo test` + +Once you are happy with the content, you can compile it to wasm via: + +``` +RUSTFLAGS='-C link-arg=-s' cargo wasm +cp ../../target/wasm32-unknown-unknown/release/cw3_fixed_multisig.wasm . +ls -l cw3_fixed_multisig.wasm +sha256sum cw3_fixed_multisig.wasm +``` + +Or for a production-ready (optimized) build, run a build command in the +repository root: https://github.com/CosmWasm/cw-plus#compiling. diff --git a/contracts/core-cw3-fixed-msig/src/bin/schema.rs b/contracts/core-cw3-fixed-msig/src/bin/schema.rs new file mode 100644 index 0000000..8081025 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use cw3_fixed_multisig::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/core-cw3-fixed-msig/src/contract.rs b/contracts/core-cw3-fixed-msig/src/contract.rs new file mode 100644 index 0000000..025d478 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/contract.rs @@ -0,0 +1,1198 @@ +use std::cmp::Ordering; + +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Binary, BlockInfo, CosmosMsg, Deps, DepsMut, Empty, Env, + MessageInfo, Order, Response, StdResult, +}; + +use cw2::set_contract_version; +use cw3::{ + Ballot, Proposal, ProposalListResponse, ProposalResponse, Status, Vote, + VoteInfo, VoteListResponse, VoteResponse, VoterDetail, VoterListResponse, + VoterResponse, Votes, +}; +use cw_storage_plus::Bound; +use cw_utils::{Expiration, ThresholdResponse}; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{next_id, Config, BALLOTS, CONFIG, PROPOSALS, VOTERS}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:cw3-fixed-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 { + if msg.voters.is_empty() { + return Err(ContractError::NoVoters {}); + } + let total_weight = msg.voters.iter().map(|v| v.weight).sum(); + + msg.threshold.validate(total_weight)?; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let cfg = Config { + threshold: msg.threshold, + total_weight, + max_voting_period: msg.max_voting_period, + }; + CONFIG.save(deps.storage, &cfg)?; + + // add all voters + for voter in msg.voters.iter() { + let key = deps.api.addr_validate(&voter.addr)?; + VOTERS.save(deps.storage, &key, &voter.weight)?; + } + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, 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) + } + } +} + +pub fn execute_propose( + deps: DepsMut, + env: Env, + info: MessageInfo, + title: String, + description: String, + msgs: Vec, + // we ignore earliest + latest: Option, +) -> Result, ContractError> { + // only members of the multisig can create a proposal + let vote_power = VOTERS + .may_load(deps.storage, &info.sender)? + .ok_or(ContractError::Unauthorized {})?; + + let cfg = CONFIG.load(deps.storage)?; + + // max expires also used as default + 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 {}); + } + + // create a proposal + 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.total_weight, + proposer: info.sender.clone(), + deposit: None, + }; + prop.update_status(&env.block); + let id = next_id(deps.storage)?; + PROPOSALS.save(deps.storage, id, &prop)?; + + // add the first yes vote from voter + 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, ContractError> { + // only members of the multisig with weight >= 1 can vote + let voter_power = VOTERS.may_load(deps.storage, &info.sender)?; + let vote_power = match voter_power { + Some(power) if power >= 1 => power, + _ => return Err(ContractError::Unauthorized {}), + }; + + // ensure proposal exists and can be voted on + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + // Allow voting on Passed and Rejected proposals too, + if ![Status::Open, Status::Passed, Status::Rejected].contains(&prop.status) { + return Err(ContractError::NotOpen {}); + } + // if they are not expired + if prop.expires.is_expired(&env.block) { + return Err(ContractError::Expired {}); + } + + // cast vote if no vote previously cast + BALLOTS.update( + deps.storage, + (proposal_id, &info.sender), + |bal| match bal { + Some(_) => Err(ContractError::AlreadyVoted {}), + None => Ok(Ballot { + weight: vote_power, + vote, + }), + }, + )?; + + // update vote tally + 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 { + // anyone can trigger this if the vote passed + + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + // we allow execution even after the proposal "expiration" as long as all vote come in before + // that point. If it was approved on time, it can be executed any time. + prop.update_status(&env.block); + if prop.status != Status::Passed { + return Err(ContractError::WrongExecuteStatus {}); + } + + // set it to executed + prop.status = Status::Executed; + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + // dispatch all proposed messages + 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, ContractError> { + // anyone can trigger this if the vote passed + + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + if [Status::Executed, Status::Rejected, Status::Passed] + .contains(&prop.status) + { + return Err(ContractError::WrongCloseStatus {}); + } + // Avoid closing of Passed due to expiration proposals + if prop.current_status(&env.block) == Status::Passed { + return Err(ContractError::WrongCloseStatus {}); + } + if !prop.expires.is_expired(&env.block) { + return Err(ContractError::NotExpired {}); + } + + // set it to failed + 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())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Threshold {} => to_json_binary(&query_threshold(deps)?), + QueryMsg::Proposal { proposal_id } => { + to_json_binary(&query_proposal(deps, env, proposal_id)?) + } + QueryMsg::Vote { proposal_id, voter } => { + to_json_binary(&query_vote(deps, proposal_id, voter)?) + } + QueryMsg::ListProposals { start_after, limit } => { + to_json_binary(&list_proposals(deps, env, start_after, limit)?) + } + QueryMsg::ReverseProposals { + start_before, + limit, + } => to_json_binary(&reverse_proposals(deps, env, start_before, limit)?), + QueryMsg::ListVotes { + proposal_id, + start_after, + limit, + } => to_json_binary(&list_votes(deps, proposal_id, start_after, limit)?), + QueryMsg::Voter { address } => { + to_json_binary(&query_voter(deps, address)?) + } + QueryMsg::ListVoters { start_after, limit } => { + to_json_binary(&list_voters(deps, start_after, limit)?) + } + } +} + +fn query_threshold(deps: Deps) -> StdResult { + let cfg = CONFIG.load(deps.storage)?; + Ok(cfg.threshold.to_response(cfg.total_weight)) +} + +fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { + 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, + deposit: prop.deposit, + proposer: prop.proposer, + threshold, + }) +} + +// settings for pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +fn list_proposals( + deps: Deps, + env: Env, + start_after: Option, + limit: Option, +) -> StdResult { + 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::>()?; + + Ok(ProposalListResponse { proposals }) +} + +fn reverse_proposals( + deps: Deps, + env: Env, + start_before: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let end = start_before.map(Bound::exclusive); + let props: StdResult> = 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 { + 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, + deposit: prop.deposit, + proposer: prop.proposer, + expires: prop.expires, + threshold, + } + }) +} + +fn query_vote( + deps: Deps, + proposal_id: u64, + voter: String, +) -> StdResult { + let voter = deps.api.addr_validate(&voter)?; + let ballot = BALLOTS.may_load(deps.storage, (proposal_id, &voter))?; + let vote = ballot.map(|b| VoteInfo { + proposal_id, + voter: voter.into(), + vote: b.vote, + weight: b.weight, + }); + Ok(VoteResponse { vote }) +} + +fn list_votes( + deps: Deps, + proposal_id: u64, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|s| Bound::ExclusiveRaw(s.into())); + + 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::>()?; + + Ok(VoteListResponse { votes }) +} + +fn query_voter(deps: Deps, voter: String) -> StdResult { + let voter = deps.api.addr_validate(&voter)?; + let weight = VOTERS.may_load(deps.storage, &voter)?; + Ok(VoterResponse { weight }) +} + +fn list_voters( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|s| Bound::ExclusiveRaw(s.into())); + + let voters = VOTERS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, weight)| VoterDetail { + addr: addr.into(), + weight, + }) + }) + .collect::>()?; + + Ok(VoterListResponse { voters }) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{coin, from_json, BankMsg, Decimal}; + + use cw2::{get_contract_version, ContractVersion}; + use cw_utils::{Duration, Threshold}; + + use easy_addr::addr; + + use crate::msg::Voter; + + use super::*; + + fn mock_env_height(height_delta: u64) -> Env { + let mut env = mock_env(); + env.block.height += height_delta; + env + } + + fn mock_env_time(time_delta: u64) -> Env { + let mut env = mock_env(); + env.block.time = env.block.time.plus_seconds(time_delta); + env + } + + const OWNER: &str = addr!("admin0001"); + const VOTER1: &str = addr!("voter0001"); + const VOTER2: &str = addr!("voter0002"); + const VOTER3: &str = addr!("voter0003"); + const VOTER4: &str = addr!("voter0004"); + const VOTER5: &str = addr!("voter0005"); + const VOTER6: &str = addr!("voter0006"); + const NOWEIGHT_VOTER: &str = addr!("voterxxxx"); + const SOMEBODY: &str = addr!("somebody"); + + fn voter>(addr: T, weight: u64) -> Voter { + Voter { + addr: addr.into(), + weight, + } + } + + // this will set up the instantiation for other tests + #[track_caller] + fn setup_test_case( + deps: DepsMut, + info: MessageInfo, + threshold: Threshold, + max_voting_period: Duration, + ) -> Result, ContractError> { + // Instantiate a contract with voters + let voters = vec![ + voter(&info.sender, 1), + voter(VOTER1, 1), + voter(VOTER2, 2), + voter(VOTER3, 3), + voter(VOTER4, 4), + voter(VOTER5, 5), + voter(VOTER6, 1), + voter(NOWEIGHT_VOTER, 0), + ]; + + let instantiate_msg = InstantiateMsg { + voters, + threshold, + max_voting_period, + }; + instantiate(deps, mock_env(), info, instantiate_msg) + } + + fn get_tally(deps: Deps, proposal_id: u64) -> u64 { + // Get all the voters on the proposal + let voters = QueryMsg::ListVotes { + proposal_id, + start_after: None, + limit: None, + }; + let votes: VoteListResponse = + from_json(query(deps, mock_env(), voters).unwrap()).unwrap(); + // Sum the weights of the Yes votes to get the tally + votes + .votes + .iter() + .filter(|&v| v.vote == Vote::Yes) + .map(|v| v.weight) + .sum() + } + + #[test] + fn test_instantiate_works() { + let mut deps = mock_dependencies(); + let info = mock_info(OWNER, &[]); + + let max_voting_period = Duration::Time(1234567); + + // No voters fails + let instantiate_msg = InstantiateMsg { + voters: vec![], + threshold: Threshold::ThresholdQuorum { + threshold: Decimal::zero(), + quorum: Decimal::percent(1), + }, + max_voting_period, + }; + let err = instantiate( + deps.as_mut(), + mock_env(), + info.clone(), + instantiate_msg.clone(), + ) + .unwrap_err(); + assert_eq!(err, ContractError::NoVoters {}); + + // Zero required weight fails + let instantiate_msg = InstantiateMsg { + voters: vec![voter(OWNER, 1)], + ..instantiate_msg + }; + let err = instantiate( + deps.as_mut(), + mock_env(), + info.clone(), + instantiate_msg, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::Threshold( + cw_utils::ThresholdError::InvalidThreshold {} + ) + ); + + // Total weight less than required weight not allowed + let threshold = Threshold::AbsoluteCount { weight: 100 }; + let err = setup_test_case( + deps.as_mut(), + info.clone(), + threshold, + max_voting_period, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::Threshold( + cw_utils::ThresholdError::UnreachableWeight {} + ) + ); + + // All valid + let threshold = Threshold::AbsoluteCount { weight: 1 }; + setup_test_case(deps.as_mut(), info, threshold, max_voting_period) + .unwrap(); + + // Verify + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string(), + }, + get_contract_version(&deps.storage).unwrap() + ) + } + + // TODO: query() tests + + #[test] + fn zero_weight_member_cant_vote() { + let mut deps = mock_dependencies(); + + let threshold = Threshold::AbsoluteCount { weight: 4 }; + let voting_period = Duration::Time(2000000); + + let info = mock_info(OWNER, &[]); + setup_test_case(deps.as_mut(), info, threshold, voting_period).unwrap(); + + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + + // Voter without voting power still can create proposal + let info = mock_info(NOWEIGHT_VOTER, &[]); + let proposal = ExecuteMsg::Propose { + title: "Rewarding somebody".to_string(), + description: "Do we reward her?".to_string(), + msgs, + latest: None, + }; + let res = execute(deps.as_mut(), mock_env(), info, proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + // Cast a No vote + let no_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::No, + }; + // Only voters with weight can vote + let info = mock_info(NOWEIGHT_VOTER, &[]); + let err = execute(deps.as_mut(), mock_env(), info, no_vote).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn test_propose_works() { + let mut deps = mock_dependencies(); + + let threshold = Threshold::AbsoluteCount { weight: 4 }; + let voting_period = Duration::Time(2000000); + + let info = mock_info(OWNER, &[]); + setup_test_case(deps.as_mut(), info, threshold, voting_period).unwrap(); + + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + + // Only voters can propose + let info = mock_info(SOMEBODY, &[]); + let proposal = ExecuteMsg::Propose { + title: "Rewarding somebody".to_string(), + description: "Do we reward her?".to_string(), + msgs: msgs.clone(), + latest: None, + }; + let err = execute(deps.as_mut(), mock_env(), info, proposal.clone()) + .unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Wrong expiration option fails + let info = mock_info(OWNER, &[]); + 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 = execute(deps.as_mut(), mock_env(), info, proposal_wrong_exp) + .unwrap_err(); + assert_eq!(err, ContractError::WrongExpiration {}); + + // Proposal from voter works + let info = mock_info(VOTER3, &[]); + let res = + execute(deps.as_mut(), mock_env(), info, proposal.clone()).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "propose") + .add_attribute("sender", VOTER3) + .add_attribute("proposal_id", 1.to_string()) + .add_attribute("status", "Open") + ); + + // Proposal from voter with enough vote power directly passes + let info = mock_info(VOTER4, &[]); + let res = execute(deps.as_mut(), mock_env(), info, proposal).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "propose") + .add_attribute("sender", VOTER4) + .add_attribute("proposal_id", 2.to_string()) + .add_attribute("status", "Passed") + ); + } + + #[test] + fn test_vote_works() { + let mut deps = mock_dependencies(); + + let threshold = Threshold::AbsoluteCount { weight: 3 }; + let voting_period = Duration::Time(2000000); + + let info = mock_info(OWNER, &[]); + setup_test_case(deps.as_mut(), info.clone(), threshold, voting_period) + .unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = ExecuteMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs, + latest: None, + }; + let res = + execute(deps.as_mut(), mock_env(), info.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + // Owner cannot vote (again) + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let err = execute(deps.as_mut(), mock_env(), info, yes_vote.clone()) + .unwrap_err(); + assert_eq!(err, ContractError::AlreadyVoted {}); + + // Only voters can vote + let info = mock_info(SOMEBODY, &[]); + let err = execute(deps.as_mut(), mock_env(), info, yes_vote.clone()) + .unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + + // But voter1 can + let info = mock_info(VOTER1, &[]); + let res = + execute(deps.as_mut(), mock_env(), info, yes_vote.clone()).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER1) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Open") + ); + + // No/Veto votes have no effect on the tally + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + // Compute the current tally + let tally = get_tally(deps.as_ref(), proposal_id); + + // Cast a No vote + let no_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::No, + }; + let info = mock_info(VOTER2, &[]); + execute(deps.as_mut(), mock_env(), info, no_vote.clone()).unwrap(); + + // Cast a Veto vote + let veto_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Veto, + }; + let info = mock_info(VOTER3, &[]); + execute(deps.as_mut(), mock_env(), info.clone(), veto_vote).unwrap(); + + // Verify + assert_eq!(tally, get_tally(deps.as_ref(), proposal_id)); + + // Once voted, votes cannot be changed + let err = + execute(deps.as_mut(), mock_env(), info.clone(), yes_vote.clone()) + .unwrap_err(); + assert_eq!(err, ContractError::AlreadyVoted {}); + assert_eq!(tally, get_tally(deps.as_ref(), proposal_id)); + + // Expired proposals cannot be voted + let env = match voting_period { + Duration::Time(duration) => mock_env_time(duration + 1), + Duration::Height(duration) => mock_env_height(duration + 1), + }; + let err = execute(deps.as_mut(), env, info, no_vote).unwrap_err(); + assert_eq!(err, ContractError::Expired {}); + + // Vote it again, so it passes + let info = mock_info(VOTER4, &[]); + let res = + execute(deps.as_mut(), mock_env(), info, yes_vote.clone()).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER4) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Passed") + ); + + // Passed proposals can still be voted (while they are not expired or executed) + let info = mock_info(VOTER5, &[]); + let res = execute(deps.as_mut(), mock_env(), info, yes_vote).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER5) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Passed") + ); + + // Propose + let info = mock_info(OWNER, &[]); + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = ExecuteMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs, + latest: None, + }; + let res = execute(deps.as_mut(), mock_env(), info, proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + // Cast a No vote + let no_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::No, + }; + // Voter1 vote no, weight 1 + let info = mock_info(VOTER1, &[]); + let res = + execute(deps.as_mut(), mock_env(), info, no_vote.clone()).unwrap(); + + // Verify it is not enough to reject yet + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER1) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Open") + ); + + // Voter 4 votes no, weight 4, total weight for no so far 5, need 14 to reject + let info = mock_info(VOTER4, &[]); + let res = + execute(deps.as_mut(), mock_env(), info, no_vote.clone()).unwrap(); + + // Verify it is still open as we actually need no votes > 17 - 3 + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER4) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Open") + ); + + // Voter 3 votes no, weight 3, total weight for no far 8, need 14 + let info = mock_info(VOTER3, &[]); + let _res = + execute(deps.as_mut(), mock_env(), info, no_vote.clone()).unwrap(); + + // Voter 5 votes no, weight 5, total weight for no far 13, need 14 + let info = mock_info(VOTER5, &[]); + let res = + execute(deps.as_mut(), mock_env(), info, no_vote.clone()).unwrap(); + + // Verify it is still open as we actually need no votes > 17 - 3 + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER5) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Open") + ); + + // Voter 2 votes no, weight 2, total weight for no so far 15, need 14. + // Can now reject + let info = mock_info(VOTER2, &[]); + let res = execute(deps.as_mut(), mock_env(), info, no_vote).unwrap(); + + // Verify it is rejected as, 15 no votes > 17 - 3 + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER2) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Rejected") + ); + + // Rejected proposals can still be voted (while they are not expired) + let info = mock_info(VOTER6, &[]); + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let res = execute(deps.as_mut(), mock_env(), info, yes_vote).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER6) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Rejected") + ); + } + + #[test] + fn test_execute_works() { + let mut deps = mock_dependencies(); + + let threshold = Threshold::AbsoluteCount { weight: 3 }; + let voting_period = Duration::Time(2000000); + + let info = mock_info(OWNER, &[]); + setup_test_case(deps.as_mut(), info.clone(), threshold, voting_period) + .unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = ExecuteMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs: msgs.clone(), + latest: None, + }; + let res = + execute(deps.as_mut(), mock_env(), info.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + // Only Passed can be executed + let execution = ExecuteMsg::Execute { proposal_id }; + let err = execute(deps.as_mut(), mock_env(), info, execution.clone()) + .unwrap_err(); + assert_eq!(err, ContractError::WrongExecuteStatus {}); + + // Vote it, so it passes + let vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let info = mock_info(VOTER3, &[]); + let res = + execute(deps.as_mut(), mock_env(), info.clone(), vote).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER3) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Passed") + ); + + // In passing: Try to close Passed fails + let closing = ExecuteMsg::Close { proposal_id }; + let err = execute(deps.as_mut(), mock_env(), info, closing).unwrap_err(); + assert_eq!(err, ContractError::WrongCloseStatus {}); + + // Execute works. Anybody can execute Passed proposals + let info = mock_info(SOMEBODY, &[]); + let res = + execute(deps.as_mut(), mock_env(), info.clone(), execution).unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_messages(msgs) + .add_attribute("action", "execute") + .add_attribute("sender", SOMEBODY) + .add_attribute("proposal_id", proposal_id.to_string()) + ); + + // In passing: Try to close Executed fails + let closing = ExecuteMsg::Close { proposal_id }; + let err = execute(deps.as_mut(), mock_env(), info, closing).unwrap_err(); + assert_eq!(err, ContractError::WrongCloseStatus {}); + } + + #[test] + fn proposal_pass_on_expiration() { + let mut deps = mock_dependencies(); + + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = Duration::Time(2000000); + + let info = mock_info(OWNER, &[]); + setup_test_case(deps.as_mut(), info.clone(), threshold, voting_period) + .unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = ExecuteMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs, + latest: None, + }; + let res = execute(deps.as_mut(), mock_env(), info, proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + // Vote it, so it passes after voting period is over + let vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let info = mock_info(VOTER3, &[]); + let res = execute(deps.as_mut(), mock_env(), info, vote).unwrap(); + assert_eq!( + res, + Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", VOTER3) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", "Open") + ); + + // Wait until the voting period is over + let env = match voting_period { + Duration::Time(duration) => mock_env_time(duration + 1), + Duration::Height(duration) => mock_env_height(duration + 1), + }; + + // Proposal should now be passed + let prop: ProposalResponse = from_json( + query( + deps.as_ref(), + env.clone(), + QueryMsg::Proposal { proposal_id }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(prop.status, Status::Passed); + + // Closing should NOT be possible + let info = mock_info(SOMEBODY, &[]); + let err = execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::Close { proposal_id }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::WrongCloseStatus {}); + + // Execution should now be possible + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::Execute { proposal_id }, + ) + .unwrap(); + assert_eq!( + res.attributes, + Response::::new() + .add_attribute("action", "execute") + .add_attribute("sender", SOMEBODY) + .add_attribute("proposal_id", proposal_id.to_string()) + .attributes + ) + } + + #[test] + fn test_close_works() { + let mut deps = mock_dependencies(); + + let threshold = Threshold::AbsoluteCount { weight: 3 }; + let voting_period = Duration::Height(2000000); + + let info = mock_info(OWNER, &[]); + setup_test_case(deps.as_mut(), info.clone(), threshold, voting_period) + .unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = ExecuteMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs: msgs.clone(), + latest: None, + }; + let res = execute(deps.as_mut(), mock_env(), info, proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + let closing = ExecuteMsg::Close { proposal_id }; + + // Anybody can close + let info = mock_info(SOMEBODY, &[]); + + // Non-expired proposals cannot be closed + let err = execute(deps.as_mut(), mock_env(), info, closing).unwrap_err(); + assert_eq!(err, ContractError::NotExpired {}); + + // Expired proposals can be closed + let info = mock_info(OWNER, &[]); + + let proposal = ExecuteMsg::Propose { + title: "(Try to) pay somebody".to_string(), + description: "Pay somebody after time?".to_string(), + msgs, + latest: Some(Expiration::AtHeight(123456)), + }; + let res = + execute(deps.as_mut(), mock_env(), info.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.attributes[2].value.parse().unwrap(); + + let closing = ExecuteMsg::Close { proposal_id }; + + // Close expired works + let env = mock_env_height(1234567); + let res = execute( + deps.as_mut(), + env, + mock_info(SOMEBODY, &[]), + closing.clone(), + ) + .unwrap(); + + // Verify + assert_eq!( + res, + Response::new() + .add_attribute("action", "close") + .add_attribute("sender", SOMEBODY) + .add_attribute("proposal_id", proposal_id.to_string()) + ); + + // Trying to close it again fails + let err = execute(deps.as_mut(), mock_env(), info, closing).unwrap_err(); + assert_eq!(err, ContractError::WrongCloseStatus {}); + } +} diff --git a/contracts/core-cw3-fixed-msig/src/error.rs b/contracts/core-cw3-fixed-msig/src/error.rs new file mode 100644 index 0000000..ff0b523 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/error.rs @@ -0,0 +1,46 @@ +use cosmwasm_std::StdError; +use cw_utils::ThresholdError; + +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + Threshold(#[from] ThresholdError), + + #[error("Required weight cannot be zero")] + ZeroWeight {}, + + #[error("Not possible to reach required (passing) weight")] + UnreachableWeight {}, + + #[error("No voters")] + NoVoters {}, + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Proposal is not open")] + NotOpen {}, + + #[error("Proposal voting period has expired")] + Expired {}, + + #[error("Proposal must expire before you can close it")] + NotExpired {}, + + #[error("Wrong expiration option")] + WrongExpiration {}, + + #[error("Already voted on this proposal")] + AlreadyVoted {}, + + #[error("Proposal must have passed and not yet been executed")] + WrongExecuteStatus {}, + + #[error("Cannot close completed or passed proposals")] + WrongCloseStatus {}, +} diff --git a/contracts/core-cw3-fixed-msig/src/integration_tests.rs b/contracts/core-cw3-fixed-msig/src/integration_tests.rs new file mode 100644 index 0000000..dff97d0 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/integration_tests.rs @@ -0,0 +1,153 @@ +#![cfg(test)] + +use cosmwasm_std::{to_json_binary, Empty, Uint128, WasmMsg}; +use cw20::{BalanceResponse, MinterResponse}; +use cw20_base::msg::QueryMsg; +use cw3::Vote; +use cw_multi_test::{App, Contract, ContractWrapper, Executor}; +use cw_utils::{Duration, Threshold}; + +use crate::contract::{execute, instantiate, query}; +use crate::msg::{ExecuteMsg, InstantiateMsg, Voter}; + +fn mock_app() -> App { + App::default() +} + +pub fn contract_cw3_fixed_multisig() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query); + Box::new(contract) +} + +pub fn contract_cw20() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +#[test] +// cw3 multisig account can control cw20 admin actions +fn cw3_controls_cw20() { + let mut router = mock_app(); + + // setup cw3 multisig with 3 accounts + let cw3_id = router.store_code(contract_cw3_fixed_multisig()); + + let addr1 = router.api().addr_make("addr1"); + let addr2 = router.api().addr_make("addr2"); + let addr3 = router.api().addr_make("addr3"); + let cw3_instantiate_msg = InstantiateMsg { + voters: vec![ + Voter { + addr: addr1.to_string(), + weight: 1, + }, + Voter { + addr: addr2.to_string(), + weight: 1, + }, + Voter { + addr: addr3.to_string(), + weight: 1, + }, + ], + threshold: Threshold::AbsoluteCount { weight: 2 }, + max_voting_period: Duration::Height(3), + }; + + let multisig_addr = router + .instantiate_contract( + cw3_id, + addr1.clone(), + &cw3_instantiate_msg, + &[], + "Consortium", + None, + ) + .unwrap(); + + // setup cw20 as cw3 multisig admin + let cw20_id = router.store_code(contract_cw20()); + + let cw20_instantiate_msg = cw20_base::msg::InstantiateMsg { + name: "Consortium Token".parse().unwrap(), + symbol: "CST".parse().unwrap(), + decimals: 6, + initial_balances: vec![], + mint: Some(MinterResponse { + minter: multisig_addr.to_string(), + cap: None, + }), + marketing: None, + }; + let cw20_addr = router + .instantiate_contract( + cw20_id, + multisig_addr.clone(), + &cw20_instantiate_msg, + &[], + "Consortium", + None, + ) + .unwrap(); + + // mint some cw20 tokens according to proposal result + let mint_recipient = router.api().addr_make("mint_recipient"); + let mint_amount = Uint128::new(1000); + let cw20_mint_msg = cw20_base::msg::ExecuteMsg::Mint { + recipient: mint_recipient.to_string(), + amount: mint_amount, + }; + + let execute_mint_msg = WasmMsg::Execute { + contract_addr: cw20_addr.to_string(), + msg: to_json_binary(&cw20_mint_msg).unwrap(), + funds: vec![], + }; + let propose_msg = ExecuteMsg::Propose { + title: "Mint tokens".to_string(), + description: "Need to mint tokens".to_string(), + msgs: vec![execute_mint_msg.into()], + latest: None, + }; + // propose mint + router + .execute_contract( + addr1.clone(), + multisig_addr.clone(), + &propose_msg, + &[], + ) + .unwrap(); + + // second votes + let vote2_msg = ExecuteMsg::Vote { + proposal_id: 1, + vote: Vote::Yes, + }; + router + .execute_contract(addr2, multisig_addr.clone(), &vote2_msg, &[]) + .unwrap(); + + // only 1 vote and msg mint fails + let execute_proposal_msg = ExecuteMsg::Execute { proposal_id: 1 }; + // execute mint + router + .execute_contract(addr1, multisig_addr, &execute_proposal_msg, &[]) + .unwrap(); + + // check the mint is successful + let cw20_balance_query = QueryMsg::Balance { + address: mint_recipient.to_string(), + }; + let balance: BalanceResponse = router + .wrap() + .query_wasm_smart(&cw20_addr, &cw20_balance_query) + .unwrap(); + + // compare minted amount + assert_eq!(balance.balance, mint_amount); +} diff --git a/contracts/core-cw3-fixed-msig/src/lib.rs b/contracts/core-cw3-fixed-msig/src/lib.rs new file mode 100644 index 0000000..9053ec9 --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/lib.rs @@ -0,0 +1,25 @@ +/*! +This is a simple implementation of the [cw3 spec](https://github.com/CosmWasm/cw-plus/blob/main/packages/cw3/README.md). +It is a multisig with a fixed set of addresses created upon instatiation. +Each address may have the same weight (K of N), or some may have extra voting +power. This works much like the native Cosmos SDK multisig, except that rather +than aggregating the signatures off chain and submitting the final result, +we aggregate the approvals on-chain. + +This is usable as is, and probably the most secure implementation of cw3 +(as it is the simplest), but we will be adding more complex cases, such +as updating the multisig set, different voting rules for the same group +with different permissions, and even allow token-weighted voting. All through +the same client interface. + +For more information on this contract, please check out the +[README](https://github.com/CosmWasm/cw-plus/blob/main/contracts/cw3-fixed-multisig/README.md). +*/ + +pub mod contract; +mod error; +mod integration_tests; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/core-cw3-fixed-msig/src/msg.rs b/contracts/core-cw3-fixed-msig/src/msg.rs new file mode 100644 index 0000000..c21908c --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/msg.rs @@ -0,0 +1,74 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; +use cw3::Vote; +use cw_utils::{Duration, Expiration, Threshold}; + +#[cw_serde] +pub struct InstantiateMsg { + pub voters: Vec, + pub threshold: Threshold, + pub max_voting_period: Duration, +} + +#[cw_serde] +pub struct Voter { + pub addr: String, + pub weight: u64, +} + +// TODO: add some T variants? Maybe good enough as fixed Empty for now +#[cw_serde] +pub enum ExecuteMsg { + Propose { + title: String, + description: String, + msgs: Vec>, + // note: we ignore API-spec'd earliest if passed, always opens immediately + latest: Option, + }, + Vote { + proposal_id: u64, + vote: Vote, + }, + Execute { + proposal_id: u64, + }, + Close { + proposal_id: u64, + }, +} + +// We can also add this as a cw3 extension +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(cw_utils::ThresholdResponse)] + Threshold {}, + #[returns(cw3::ProposalResponse)] + Proposal { proposal_id: u64 }, + #[returns(cw3::ProposalListResponse)] + ListProposals { + start_after: Option, + limit: Option, + }, + #[returns(cw3::ProposalListResponse)] + ReverseProposals { + start_before: Option, + limit: Option, + }, + #[returns(cw3::VoteResponse)] + Vote { proposal_id: u64, voter: String }, + #[returns(cw3::VoteListResponse)] + ListVotes { + proposal_id: u64, + start_after: Option, + limit: Option, + }, + #[returns(cw3::VoterResponse)] + Voter { address: String }, + #[returns(cw3::VoterListResponse)] + ListVoters { + start_after: Option, + limit: Option, + }, +} diff --git a/contracts/core-cw3-fixed-msig/src/state.rs b/contracts/core-cw3-fixed-msig/src/state.rs new file mode 100644 index 0000000..c37775a --- /dev/null +++ b/contracts/core-cw3-fixed-msig/src/state.rs @@ -0,0 +1,30 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, StdResult, Storage}; + +use cw3::{Ballot, Proposal}; +use cw_storage_plus::{Item, Map}; +use cw_utils::{Duration, Threshold}; + +#[cw_serde] +pub struct Config { + pub threshold: Threshold, + pub total_weight: u64, + pub max_voting_period: Duration, +} + +// unique items +pub const CONFIG: Item = Item::new("config"); +pub const PROPOSAL_COUNT: Item = Item::new("proposal_count"); + +// multiple-item map +pub const BALLOTS: Map<(u64, &Addr), Ballot> = Map::new("votes"); +pub const PROPOSALS: Map = Map::new("proposals"); + +// multiple-item maps +pub const VOTERS: Map<&Addr, u64> = Map::new("voters"); + +pub fn next_id(store: &mut dyn Storage) -> StdResult { + let id: u64 = PROPOSAL_COUNT.may_load(store)?.unwrap_or_default() + 1; + PROPOSAL_COUNT.save(store, &id)?; + Ok(id) +}