diff --git a/.circleci/config.yml b/.circleci/config.yml index ad10bff83..47a0f2124 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,6 +6,7 @@ workflows: - contract_cw20_atomic_swap - contract_cw1_subkeys - contract_cw1_whitelist + - contract_cw3_fixed_multisig - contract_cw20_base - contract_cw20_escrow - contract_cw20_staking @@ -135,6 +136,41 @@ jobs: - target key: cargocache-cw1-whitelist-rust:1.44.1-{{ checksum "~/project/Cargo.lock" }} + contract_cw3_fixed_multisig: + docker: + - image: rust:1.44.1 + working_directory: ~/project/contracts/cw3-fixed-multisig + steps: + - checkout: + path: ~/project + - run: + name: Version information + command: rustc --version; cargo --version; rustup --version + - restore_cache: + keys: + - cargocache-cw3-fixed-multisig-rust:1.44.1-{{ checksum "~/project/Cargo.lock" }} + - run: + name: Unit Tests + env: RUST_BACKTRACE=1 + command: cargo unit-test --locked + - run: + name: Build and run schema generator + command: cargo schema --locked + - run: + name: Ensure checked-in schemas are up-to-date + command: | + CHANGES_IN_REPO=$(git status --porcelain) + if [[ -n "$CHANGES_IN_REPO" ]]; then + echo "Repository is dirty. Showing 'git status' and 'git --no-pager diff' for debugging now:" + git status && git --no-pager diff + exit 1 + fi + - save_cache: + paths: + - /usr/local/cargo/registry + - target + key: cargocache-cw3-fixed-multisig-rust:1.44.1-{{ checksum "~/project/Cargo.lock" }} + contract_cw20_base: docker: - image: rust:1.44.1 diff --git a/Cargo.lock b/Cargo.lock index ae8657ddd..a159d05d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,21 @@ dependencies = [ "serde", ] +[[package]] +name = "cw3-fixed-multisig" +version = "0.2.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw0", + "cw2", + "cw3", + "schemars", + "serde", + "snafu", +] + [[package]] name = "cw721" version = "0.2.1" diff --git a/contracts/cw1-subkeys/schema/all_allowances_response.json b/contracts/cw1-subkeys/schema/all_allowances_response.json index e42702559..0a2ed7c3e 100644 --- a/contracts/cw1-subkeys/schema/all_allowances_response.json +++ b/contracts/cw1-subkeys/schema/all_allowances_response.json @@ -49,6 +49,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw1-subkeys/schema/allowance.json b/contracts/cw1-subkeys/schema/allowance.json index af6937e76..63b7ec766 100644 --- a/contracts/cw1-subkeys/schema/allowance.json +++ b/contracts/cw1-subkeys/schema/allowance.json @@ -31,6 +31,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw1-subkeys/schema/handle_msg.json b/contracts/cw1-subkeys/schema/handle_msg.json index 355876c5f..db2e1af39 100644 --- a/contracts/cw1-subkeys/schema/handle_msg.json +++ b/contracts/cw1-subkeys/schema/handle_msg.json @@ -259,6 +259,7 @@ "type": "object" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-atomic-swap/schema/details_response.json b/contracts/cw20-atomic-swap/schema/details_response.json index eab1979bd..3e3d3c94e 100644 --- a/contracts/cw20-atomic-swap/schema/details_response.json +++ b/contracts/cw20-atomic-swap/schema/details_response.json @@ -113,6 +113,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-atomic-swap/schema/handle_msg.json b/contracts/cw20-atomic-swap/schema/handle_msg.json index b62ff7405..af0d76d67 100644 --- a/contracts/cw20-atomic-swap/schema/handle_msg.json +++ b/contracts/cw20-atomic-swap/schema/handle_msg.json @@ -138,6 +138,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-base/schema/all_allowances_response.json b/contracts/cw20-base/schema/all_allowances_response.json index 52d00342e..b8c365188 100644 --- a/contracts/cw20-base/schema/all_allowances_response.json +++ b/contracts/cw20-base/schema/all_allowances_response.json @@ -34,6 +34,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-base/schema/allowance_response.json b/contracts/cw20-base/schema/allowance_response.json index dc0166c3b..596d84464 100644 --- a/contracts/cw20-base/schema/allowance_response.json +++ b/contracts/cw20-base/schema/allowance_response.json @@ -16,6 +16,7 @@ }, "definitions": { "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-base/schema/handle_msg.json b/contracts/cw20-base/schema/handle_msg.json index f0ed29a82..4b8757803 100644 --- a/contracts/cw20-base/schema/handle_msg.json +++ b/contracts/cw20-base/schema/handle_msg.json @@ -269,6 +269,7 @@ "type": "string" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-staking/schema/allowance_response.json b/contracts/cw20-staking/schema/allowance_response.json index dc0166c3b..596d84464 100644 --- a/contracts/cw20-staking/schema/allowance_response.json +++ b/contracts/cw20-staking/schema/allowance_response.json @@ -16,6 +16,7 @@ }, "definitions": { "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw20-staking/schema/handle_msg.json b/contracts/cw20-staking/schema/handle_msg.json index f7825ab93..20ec4cc8c 100644 --- a/contracts/cw20-staking/schema/handle_msg.json +++ b/contracts/cw20-staking/schema/handle_msg.json @@ -313,6 +313,7 @@ "type": "string" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/contracts/cw3-fixed-multisig/.cargo/config b/contracts/cw3-fixed-multisig/.cargo/config new file mode 100644 index 000000000..7c115322a --- /dev/null +++ b/contracts/cw3-fixed-multisig/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib --features backtraces" +integration-test = "test --test integration" +schema = "run --example schema" diff --git a/contracts/cw3-fixed-multisig/Cargo.toml b/contracts/cw3-fixed-multisig/Cargo.toml new file mode 100644 index 000000000..98f07d25c --- /dev/null +++ b/contracts/cw3-fixed-multisig/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cw3-fixed-multisig" +version = "0.2.1" +authors = ["Ethan Frey "] +edition = "2018" +description = "Implementing cw3 with an fixed group multisig" +license = "Apache-2.0" +repository = "https://github.com/CosmWasm/cosmwasm-plus" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all init/handle/query exports +library = [] + +[dependencies] +cosmwasm-std = { version = "0.10.1", features = ["iterator"] } +cosmwasm-storage = { version = "0.10.1", features = ["iterator"] } +cw0 = { path = "../../packages/cw0", version = "0.2.1" } +cw2 = { path = "../../packages/cw2", version = "0.2.1" } +cw3 = { path = "../../packages/cw3", version = "0.2.1" } +schemars = "0.7" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +snafu = { version = "0.6.3" } + +[dev-dependencies] +cosmwasm-schema = { version = "0.10.1" } diff --git a/contracts/cw3-fixed-multisig/NOTICE b/contracts/cw3-fixed-multisig/NOTICE new file mode 100644 index 000000000..e411deead --- /dev/null +++ b/contracts/cw3-fixed-multisig/NOTICE @@ -0,0 +1,14 @@ +CW3-Fixed-Whitelist: Minimal voting contract as fixed group multisig +Copyright (C) 2020 Confio OÜ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/cw3-fixed-multisig/README.md b/contracts/cw3-fixed-multisig/README.md new file mode 100644 index 000000000..592e5bc3e --- /dev/null +++ b/contracts/cw3-fixed-multisig/README.md @@ -0,0 +1,74 @@ +# 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 initialization. +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. + +## Init + +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. + +## Handle 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 (compressed) build, run the following from the +repository root: + +``` +docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="cosmwasm_plus_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/workspace-optimizer:0.10.3 +``` + +The optimized contracts are generated in the `artifacts/` directory. diff --git a/contracts/cw3-fixed-multisig/examples/schema.rs b/contracts/cw3-fixed-multisig/examples/schema.rs new file mode 100644 index 000000000..b251a66df --- /dev/null +++ b/contracts/cw3-fixed-multisig/examples/schema.rs @@ -0,0 +1,17 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; + +use cw3_fixed_multisig::msg::{HandleMsg, InitMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InitMsg), &out_dir); + export_schema_with_title(&mut schema_for!(HandleMsg), &out_dir, "HandleMsg"); + export_schema_with_title(&mut schema_for!(QueryMsg), &out_dir, "QueryMsg"); +} diff --git a/contracts/cw3-fixed-multisig/schema/handle_msg.json b/contracts/cw3-fixed-multisig/schema/handle_msg.json new file mode 100644 index 000000000..86a60233b --- /dev/null +++ b/contracts/cw3-fixed-multisig/schema/handle_msg.json @@ -0,0 +1,472 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandleMsg", + "anyOf": [ + { + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "latest": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "title": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "$ref": "#/definitions/Vote" + } + } + } + } + }, + { + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "type": "object", + "required": [ + "close" + ], + "properties": { + "close": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + } + ], + "definitions": { + "BankMsg": { + "anyOf": [ + { + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "from_address", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "from_address": { + "$ref": "#/definitions/HumanAddr" + }, + "to_address": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "anyOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + } + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + } + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + } + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + } + } + ] + }, + "Empty": { + "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", + "type": "object" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "anyOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + } + } + ] + }, + "HumanAddr": { + "type": "string" + }, + "StakingMsg": { + "anyOf": [ + { + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "recipient": { + "description": "this is the \"withdraw address\", the one that should receive the rewards if None, then use delegator address", + "anyOf": [ + { + "$ref": "#/definitions/HumanAddr" + }, + { + "type": "null" + } + ] + }, + "validator": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "$ref": "#/definitions/HumanAddr" + }, + "src_validator": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + } + ] + }, + "Uint128": { + "type": "string" + }, + "Vote": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "veto" + ] + }, + "WasmMsg": { + "anyOf": [ + { + "description": "this dispatches a call to another contract at a known address (with known ABI)", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "send" + ], + "properties": { + "contract_addr": { + "$ref": "#/definitions/HumanAddr" + }, + "msg": { + "description": "msg is the json-encoded HandleMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "send": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + } + }, + { + "description": "this instantiates a new contracts from previously uploaded wasm code", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "msg", + "send" + ], + "properties": { + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "optional human-readbale label for the contract", + "type": [ + "string", + "null" + ] + }, + "msg": { + "description": "msg is the json-encoded InitMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "send": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + } + } + ] + } + } +} diff --git a/contracts/cw3-fixed-multisig/schema/init_msg.json b/contracts/cw3-fixed-multisig/schema/init_msg.json new file mode 100644 index 000000000..680b7e4d0 --- /dev/null +++ b/contracts/cw3-fixed-multisig/schema/init_msg.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InitMsg", + "type": "object", + "required": [ + "max_voting_period", + "required_weight", + "voters" + ], + "properties": { + "max_voting_period": { + "$ref": "#/definitions/Duration" + }, + "required_weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "voters": { + "type": "array", + "items": { + "$ref": "#/definitions/Voter" + } + } + }, + "definitions": { + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "anyOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + { + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + ] + }, + "HumanAddr": { + "type": "string" + }, + "Voter": { + "type": "object", + "required": [ + "addr", + "weight" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } +} diff --git a/contracts/cw3-fixed-multisig/schema/query_msg.json b/contracts/cw3-fixed-multisig/schema/query_msg.json new file mode 100644 index 000000000..96bad01d8 --- /dev/null +++ b/contracts/cw3-fixed-multisig/schema/query_msg.json @@ -0,0 +1,223 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "anyOf": [ + { + "description": "Return ThresholdResponse", + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "type": "object" + } + } + }, + { + "description": "Returns ProposalResponse", + "type": "object", + "required": [ + "proposal" + ], + "properties": { + "proposal": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Returns ProposalListResponse", + "type": "object", + "required": [ + "list_proposals" + ], + "properties": { + "list_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Returns ProposalListResponse", + "type": "object", + "required": [ + "reverse_proposals" + ], + "properties": { + "reverse_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + } + } + } + }, + { + "description": "Returns VoteResponse", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "voter" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "voter": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "description": "Returns VoteListResponse", + "type": "object", + "required": [ + "list_votes" + ], + "properties": { + "list_votes": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "anyOf": [ + { + "$ref": "#/definitions/HumanAddr" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + { + "description": "Returns VoterResponse", + "type": "object", + "required": [ + "voter" + ], + "properties": { + "voter": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "description": "Returns VoterListResponse", + "type": "object", + "required": [ + "list_voters" + ], + "properties": { + "list_voters": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "anyOf": [ + { + "$ref": "#/definitions/HumanAddr" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "definitions": { + "HumanAddr": { + "type": "string" + } + } +} diff --git a/contracts/cw3-fixed-multisig/src/contract.rs b/contracts/cw3-fixed-multisig/src/contract.rs new file mode 100644 index 000000000..05e4e5495 --- /dev/null +++ b/contracts/cw3-fixed-multisig/src/contract.rs @@ -0,0 +1,1045 @@ +use std::cmp::Ordering; + +use cosmwasm_std::{ + log, to_binary, Api, Binary, CanonicalAddr, CosmosMsg, Empty, Env, Extern, HandleResponse, + HumanAddr, InitResponse, Order, Querier, StdError, StdResult, Storage, +}; + +use cw0::Expiration; +use cw2::set_contract_version; +use cw3::{ + ProposalListResponse, ProposalResponse, Status, ThresholdResponse, Vote, VoteInfo, + VoteListResponse, VoteResponse, VoterListResponse, VoterResponse, +}; + +use crate::msg::{HandleMsg, InitMsg, QueryMsg}; +use crate::state::{ + ballots, ballots_read, config, config_read, next_id, parse_id, proposal, proposal_read, voters, + voters_read, Ballot, Config, Proposal, +}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:cw3-fixed-multisig"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub fn init( + deps: &mut Extern, + _env: Env, + msg: InitMsg, +) -> StdResult { + if msg.required_weight == 0 { + return Err(StdError::generic_err("Required weight cannot be zero")); + } + if msg.voters.is_empty() { + return Err(StdError::generic_err("No voters")); + } + let total_weight = msg.voters.iter().map(|v| v.weight).sum(); + + if total_weight < msg.required_weight { + return Err(StdError::generic_err( + "Not possible to reach required (passing) weight", + )); + } + + set_contract_version(&mut deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let cfg = Config { + required_weight: msg.required_weight, + total_weight, + max_voting_period: msg.max_voting_period, + }; + config(&mut deps.storage).save(&cfg)?; + + // add all voters + let mut bucket = voters(&mut deps.storage); + for voter in msg.voters.iter() { + let key = deps.api.canonical_address(&voter.addr)?; + bucket.save(key.as_slice(), &voter.weight)?; + } + Ok(InitResponse::default()) +} + +pub fn handle( + deps: &mut Extern, + env: Env, + msg: HandleMsg, +) -> StdResult> { + match msg { + HandleMsg::Propose { + title, + description, + msgs, + latest, + } => handle_propose(deps, env, title, description, msgs, latest), + HandleMsg::Vote { proposal_id, vote } => handle_vote(deps, env, proposal_id, vote), + HandleMsg::Execute { proposal_id } => handle_execute(deps, env, proposal_id), + HandleMsg::Close { proposal_id } => handle_close(deps, env, proposal_id), + } +} + +pub fn handle_propose( + deps: &mut Extern, + env: Env, + title: String, + description: String, + msgs: Vec, + // we ignore earliest + latest: Option, +) -> StdResult> { + // only members of the multisig can create a proposal + let raw_sender = deps.api.canonical_address(&env.message.sender)?; + let vote_power = voters_read(&deps.storage) + .may_load(raw_sender.as_slice())? + .ok_or_else(StdError::unauthorized)?; + + let cfg = config_read(&deps.storage).load()?; + + // 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(StdError::generic_err("Wrong expiration option")); + } + + let status = if vote_power < cfg.required_weight { + Status::Open + } else { + Status::Passed + }; + + // create a proposal + let prop = Proposal { + title, + description, + expires, + msgs, + status, + yes_weight: vote_power, + required_weight: cfg.required_weight, + }; + let id = next_id(&mut deps.storage)?; + proposal(&mut deps.storage).save(&id.to_be_bytes(), &prop)?; + + // add the first yes vote from voter + let ballot = Ballot { + weight: vote_power, + vote: Vote::Yes, + }; + ballots(&mut deps.storage, id).save(raw_sender.as_slice(), &ballot)?; + + Ok(HandleResponse { + messages: vec![], + log: vec![ + log("action", "propose"), + log("sender", env.message.sender), + log("proposal_id", id), + log("status", format!("{:?}", prop.status)), + ], + data: None, + }) +} + +pub fn handle_vote( + deps: &mut Extern, + env: Env, + proposal_id: u64, + vote: Vote, +) -> StdResult> { + // only members of the multisig can vote + let raw_sender = deps.api.canonical_address(&env.message.sender)?; + let vote_power = voters_read(&deps.storage) + .may_load(raw_sender.as_slice())? + .ok_or_else(StdError::unauthorized)?; + + // ensure proposal exists and can be voted on + let mut prop = proposal_read(&deps.storage).load(&proposal_id.to_be_bytes())?; + if prop.status != Status::Open { + return Err(StdError::generic_err("Proposal is not open")); + } + if prop.expires.is_expired(&env.block) { + return Err(StdError::generic_err("Proposal voting period has expired")); + } + + // cast vote if no vote previously cast + ballots(&mut deps.storage, proposal_id).update(raw_sender.as_slice(), |bal| match bal { + Some(_) => Err(StdError::generic_err("Already voted on this proposal")), + None => Ok(Ballot { + weight: vote_power, + vote, + }), + })?; + + // if yes vote, update tally + if vote == Vote::Yes { + prop.yes_weight += vote_power; + // update status when the passing vote comes in + if prop.yes_weight >= prop.required_weight { + prop.status = Status::Passed; + } + proposal(&mut deps.storage).save(&proposal_id.to_be_bytes(), &prop)?; + } + + Ok(HandleResponse { + messages: vec![], + log: vec![ + log("action", "vote"), + log("sender", env.message.sender), + log("proposal_id", proposal_id), + log("status", format!("{:?}", prop.status)), + ], + data: None, + }) +} + +pub fn handle_execute( + deps: &mut Extern, + env: Env, + proposal_id: u64, +) -> StdResult> { + // anyone can trigger this if the vote passed + + let mut prop = proposal_read(&deps.storage).load(&proposal_id.to_be_bytes())?; + // 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. + if prop.status != Status::Passed { + return Err(StdError::generic_err( + "Proposal must have passed and not yet been executed", + )); + } + + // set it to executed + prop.status = Status::Executed; + proposal(&mut deps.storage).save(&proposal_id.to_be_bytes(), &prop)?; + + // dispatch all proposed messages + Ok(HandleResponse { + messages: prop.msgs, + log: vec![ + log("action", "execute"), + log("sender", env.message.sender), + log("proposal_id", proposal_id), + ], + data: None, + }) +} + +pub fn handle_close( + deps: &mut Extern, + env: Env, + proposal_id: u64, +) -> StdResult> { + // anyone can trigger this if the vote passed + + let mut prop = proposal_read(&deps.storage).load(&proposal_id.to_be_bytes())?; + if [Status::Executed, Status::Rejected, Status::Passed] + .iter() + .any(|x| *x == prop.status) + { + return Err(StdError::generic_err( + "Cannot close completed or passed proposals", + )); + } + if !prop.expires.is_expired(&env.block) { + return Err(StdError::generic_err( + "Proposal must expire before you can close it", + )); + } + + // set it to failed + prop.status = Status::Rejected; + proposal(&mut deps.storage).save(&proposal_id.to_be_bytes(), &prop)?; + + Ok(HandleResponse { + messages: vec![], + log: vec![ + log("action", "close"), + log("sender", env.message.sender), + log("proposal_id", proposal_id), + ], + data: None, + }) +} + +pub fn query( + deps: &Extern, + msg: QueryMsg, +) -> StdResult { + match msg { + QueryMsg::Threshold {} => to_binary(&query_threshold(deps)?), + QueryMsg::Proposal { proposal_id } => to_binary(&query_proposal(deps, 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, start_after, limit)?) + } + QueryMsg::ReverseProposals { + start_before, + limit, + } => to_binary(&reverse_proposals(deps, 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: &Extern, +) -> StdResult { + let cfg = config_read(&deps.storage).load()?; + Ok(ThresholdResponse::AbsoluteCount { + weight_needed: cfg.required_weight, + total_weight: cfg.total_weight, + }) +} + +fn query_proposal( + deps: &Extern, + id: u64, +) -> StdResult { + let prop = proposal_read(&deps.storage).load(&id.to_be_bytes())?; + let status = prop.current_status(); + Ok(ProposalResponse { + id, + title: prop.title, + description: prop.description, + msgs: prop.msgs, + expires: prop.expires, + status, + }) +} + +// settings for pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +fn list_proposals( + deps: &Extern, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|id| (id + 1).to_be_bytes().to_vec()); + let props: StdResult> = proposal_read(&deps.storage) + .range(start.as_deref(), None, Order::Ascending) + .take(limit) + .map(map_proposal) + .collect(); + + Ok(ProposalListResponse { proposals: props? }) +} + +fn reverse_proposals( + deps: &Extern, + start_before: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let end = start_before.map(|id| id.to_be_bytes().to_vec()); + let props: StdResult> = proposal_read(&deps.storage) + .range(None, end.as_deref(), Order::Descending) + .take(limit) + .map(map_proposal) + .collect(); + + Ok(ProposalListResponse { proposals: props? }) +} + +fn map_proposal(item: StdResult<(Vec, Proposal)>) -> StdResult { + let (key, prop) = item?; + let status = prop.current_status(); + Ok(ProposalResponse { + id: parse_id(&key)?, + title: prop.title, + description: prop.description, + msgs: prop.msgs, + expires: prop.expires, + status, + }) +} + +fn query_vote( + deps: &Extern, + proposal_id: u64, + voter: HumanAddr, +) -> StdResult { + let voter_raw = deps.api.canonical_address(&voter)?; + let prop = ballots_read(&deps.storage, proposal_id).may_load(voter_raw.as_slice())?; + let vote = prop.map(|b| b.vote); + Ok(VoteResponse { vote }) +} + +fn list_votes( + deps: &Extern, + proposal_id: u64, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = calc_range_start(start_after); + let api = &deps.api; + + let votes: StdResult> = ballots_read(&deps.storage, proposal_id) + .range(start.as_deref(), None, Order::Ascending) + .take(limit) + .map(|item| { + let (key, ballot) = item?; + Ok(VoteInfo { + voter: api.human_address(&CanonicalAddr::from(key))?, + vote: ballot.vote, + weight: ballot.weight, + }) + }) + .collect(); + + Ok(VoteListResponse { votes: votes? }) +} + +fn query_voter( + deps: &Extern, + voter: HumanAddr, +) -> StdResult { + let voter_raw = deps.api.canonical_address(&voter)?; + let weight = voters_read(&deps.storage) + .may_load(voter_raw.as_slice())? + .unwrap_or_default(); + Ok(VoterResponse { + addr: voter, + weight, + }) +} + +fn list_voters( + deps: &Extern, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = calc_range_start(start_after); + let api = &deps.api; + + let voters: StdResult> = voters_read(&deps.storage) + .range(start.as_deref(), None, Order::Ascending) + .take(limit) + .map(|item| { + let (key, weight) = item?; + Ok(VoterResponse { + addr: api.human_address(&CanonicalAddr::from(key))?, + weight, + }) + }) + .collect(); + + Ok(VoterListResponse { voters: voters? }) +} + +// this will set the first key after the provided key, by appending a 1 byte +fn calc_range_start(start_after: Option) -> Option> { + start_after.map(|human| { + let mut v = Vec::from(human.0); + v.push(1); + v + }) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + use cosmwasm_std::{coin, from_binary, BankMsg}; + + use cw0::Duration; + use cw2::{get_contract_version, ContractVersion}; + + use crate::msg::Voter; + + use super::*; + + fn mock_env_height>(sender: U, height: u64) -> Env { + let mut env = mock_env(sender, &[]); + env.block.height = height; + env + } + + fn mock_env_time>(sender: U, time: u64) -> Env { + let mut env = mock_env(sender, &[]); + env.block.time = time; + env + } + + 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 voter>(addr: T, weight: u64) -> Voter { + Voter { + addr: addr.into(), + weight, + } + } + + // this will set up the init for other tests + fn setup_test_case( + mut deps: &mut Extern, + env: Env, + required_weight: u64, + max_voting_period: Duration, + ) -> StdResult> { + // Init a contract with voters + let voters = vec![ + voter(&env.message.sender, 0), + voter(VOTER1, 1), + voter(VOTER2, 2), + voter(VOTER3, 3), + voter(VOTER4, 4), + voter(VOTER5, 5), + ]; + + let init_msg = InitMsg { + voters, + required_weight, + max_voting_period, + }; + init(&mut deps, env, init_msg) + } + + fn get_tally(deps: &Extern, 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_binary(&query(&deps, 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_init_works() { + let mut deps = mock_dependencies(20, &[]); + let env = mock_env(OWNER, &[]); + + let max_voting_period = Duration::Time(1234567); + + // No voters fails + let init_msg = InitMsg { + voters: vec![], + required_weight: 1, + max_voting_period, + }; + let res = init(&mut deps, env.clone(), init_msg); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!(&msg, "No voters"), + e => panic!("unexpected error: {}", e), + } + + // Zero required weight fails + let init_msg = InitMsg { + voters: vec![voter(OWNER, 1)], + required_weight: 0, + max_voting_period, + }; + let res = init(&mut deps, env.clone(), init_msg); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!(&msg, "Required weight cannot be zero"), + e => panic!("unexpected error: {}", e), + } + + // Total weight less than required weight not allowed + let required_weight = 100; + let res = setup_test_case(&mut deps, env.clone(), required_weight, max_voting_period); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Not possible to reach required (passing) weight") + } + e => panic!("unexpected error: {}", e), + } + + // All valid + let required_weight = 1; + setup_test_case(&mut deps, env, required_weight, 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 test_propose_works() { + let mut deps = mock_dependencies(20, &[]); + + let required_weight = 4; + let voting_period = Duration::Time(2000000); + + let env = mock_env(OWNER, &[]); + setup_test_case(&mut deps, env.clone(), required_weight, voting_period).unwrap(); + + let bank_msg = BankMsg::Send { + from_address: OWNER.into(), + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + + // Only voters can propose + let env = mock_env(SOMEBODY, &[]); + let proposal = HandleMsg::Propose { + title: "Rewarding somebody".to_string(), + description: "Do we reward her?".to_string(), + msgs: msgs.clone(), + latest: None, + }; + let res = handle(&mut deps, env, proposal.clone()); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::Unauthorized { .. } => {} + e => panic!("unexpected error: {}", e), + } + + // Wrong expiration option fails + let env = mock_env(OWNER, &[]); + let proposal_wrong_exp = HandleMsg::Propose { + title: "Rewarding somebody".to_string(), + description: "Do we reward her?".to_string(), + msgs: msgs.clone(), + latest: Some(Expiration::AtHeight(123456)), + }; + let res = handle(&mut deps, env, proposal_wrong_exp); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!("Wrong expiration option", &msg), + e => panic!("unexpected error: {}", e), + } + + // Proposal from voter works + let env = mock_env(VOTER3, &[]); + let res = handle(&mut deps, env, proposal.clone()).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: vec![], + log: vec![ + log("action", "propose"), + log("sender", VOTER3), + log("proposal_id", 1), + log("status", "Open"), + ], + data: None, + } + ); + + // Proposal from voter with enough vote power directly passes + let env = mock_env(VOTER4, &[]); + let res = handle(&mut deps, env, proposal).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: vec![], + log: vec![ + log("action", "propose"), + log("sender", VOTER4), + log("proposal_id", 2), + log("status", "Passed"), + ], + data: None, + } + ); + } + + #[test] + fn test_vote_works() { + let mut deps = mock_dependencies(20, &[]); + + let required_weight = 3; + let voting_period = Duration::Time(2000000); + + let env = mock_env(OWNER, &[]); + setup_test_case(&mut deps, env.clone(), required_weight, voting_period).unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + from_address: OWNER.into(), + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = HandleMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs, + latest: None, + }; + let res = handle(&mut deps, env.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.log[2].value.parse().unwrap(); + + // Owner cannot vote (again) + let yes_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let res = handle(&mut deps, env, yes_vote.clone()); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!(&msg, "Already voted on this proposal"), + e => panic!("unexpected error: {}", e), + } + + // Only voters can vote + let env = mock_env(SOMEBODY, &[]); + let res = handle(&mut deps, env, yes_vote.clone()); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::Unauthorized { .. } => {} + e => panic!("unexpected error: {}", e), + } + + // But voter1 can + let env = mock_env(VOTER1, &[]); + let res = handle(&mut deps, env, yes_vote.clone()).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: vec![], + log: vec![ + log("action", "vote"), + log("sender", VOTER1), + log("proposal_id", proposal_id), + log("status", "Open"), + ], + data: None, + } + ); + + // No/Veto votes have no effect on the tally + // Get the proposal id from the logs + let proposal_id: u64 = res.log[2].value.parse().unwrap(); + + // Compute the current tally + let tally = get_tally(&deps, proposal_id); + + // Cast a No vote + let no_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::No, + }; + let env = mock_env(VOTER2, &[]); + handle(&mut deps, env, no_vote.clone()).unwrap(); + + // Cast a Veto vote + let veto_vote = HandleMsg::Vote { + proposal_id, + vote: Vote::Veto, + }; + let env = mock_env(VOTER3, &[]); + handle(&mut deps, env.clone(), veto_vote).unwrap(); + + // Verify + assert_eq!(tally, get_tally(&deps, proposal_id)); + + // Once voted, votes cannot be changed + let res = handle(&mut deps, env.clone(), yes_vote.clone()); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!(&msg, "Already voted on this proposal"), + e => panic!("unexpected error: {}", e), + } + assert_eq!(tally, get_tally(&deps, proposal_id)); + + // Expired proposals cannot be voted + let env = match voting_period { + Duration::Time(duration) => mock_env_time(VOTER4, env.block.time + duration + 1), + Duration::Height(duration) => mock_env_height(VOTER4, env.block.height + duration + 1), + }; + let res = handle(&mut deps, env, no_vote); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Proposal voting period has expired") + } + e => panic!("unexpected error: {}", e), + } + + // Vote it again, so it passes + let env = mock_env(VOTER4, &[]); + let res = handle(&mut deps, env, yes_vote.clone()).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: vec![], + log: vec![ + log("action", "vote"), + log("sender", VOTER4), + log("proposal_id", proposal_id), + log("status", "Passed"), + ], + data: None, + } + ); + + // non-Open proposals cannot be voted + let env = mock_env(VOTER5, &[]); + let res = handle(&mut deps, env, yes_vote); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!(&msg, "Proposal is not open"), + e => panic!("unexpected error: {}", e), + } + } + + #[test] + fn test_execute_works() { + let mut deps = mock_dependencies(20, &[]); + + let required_weight = 3; + let voting_period = Duration::Time(2000000); + + let env = mock_env(OWNER, &[]); + setup_test_case(&mut deps, env.clone(), required_weight, voting_period).unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + from_address: OWNER.into(), + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = HandleMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs: msgs.clone(), + latest: None, + }; + let res = handle(&mut deps, env.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.log[2].value.parse().unwrap(); + + // Only Passed can be executed + let execution = HandleMsg::Execute { proposal_id }; + let res = handle(&mut deps, env.clone(), execution.clone()); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Proposal must have passed and not yet been executed") + } + e => panic!("unexpected error: {}", e), + } + + // Vote it, so it passes + let vote = HandleMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let env = mock_env(VOTER3, &[]); + let res = handle(&mut deps, env.clone(), vote).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: vec![], + log: vec![ + log("action", "vote"), + log("sender", VOTER3), + log("proposal_id", proposal_id), + log("status", "Passed"), + ], + data: None, + } + ); + + // In passing: Try to close Passed fails + let closing = HandleMsg::Close { proposal_id }; + let res = handle(&mut deps, env, closing); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Cannot close completed or passed proposals"); + } + e => panic!("unexpected error: {}", e), + } + + // Execute works. Anybody can execute Passed proposals + let env = mock_env(SOMEBODY, &[]); + let res = handle(&mut deps, env.clone(), execution).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: msgs, + log: vec![ + log("action", "execute"), + log("sender", SOMEBODY), + log("proposal_id", proposal_id), + ], + data: None, + } + ); + + // In passing: Try to close Executed fails + let closing = HandleMsg::Close { proposal_id }; + let res = handle(&mut deps, env, closing); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Cannot close completed or passed proposals"); + } + e => panic!("unexpected error: {}", e), + } + } + + #[test] + fn test_close_works() { + let mut deps = mock_dependencies(20, &[]); + + let required_weight = 3; + let voting_period = Duration::Height(2000000); + + let env = mock_env(OWNER, &[]); + setup_test_case(&mut deps, env.clone(), required_weight, voting_period).unwrap(); + + // Propose + let bank_msg = BankMsg::Send { + from_address: OWNER.into(), + to_address: SOMEBODY.into(), + amount: vec![coin(1, "BTC")], + }; + let msgs = vec![CosmosMsg::Bank(bank_msg)]; + let proposal = HandleMsg::Propose { + title: "Pay somebody".to_string(), + description: "Do I pay her?".to_string(), + msgs: msgs.clone(), + latest: None, + }; + let res = handle(&mut deps, env.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.log[2].value.parse().unwrap(); + + let closing = HandleMsg::Close { proposal_id }; + + // Anybody can close + let env = mock_env(SOMEBODY, &[]); + + // Non-expired proposals cannot be closed + let res = handle(&mut deps, env.clone(), closing.clone()); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Proposal must expire before you can close it"); + } + e => panic!("unexpected error: {}", e), + } + + // Expired proposals can be closed + let env = mock_env(OWNER, &[]); + + let proposal = HandleMsg::Propose { + title: "(Try to) pay somebody".to_string(), + description: "Pay somebody after time?".to_string(), + msgs: msgs.clone(), + latest: Some(Expiration::AtHeight(123456)), + }; + let res = handle(&mut deps, env.clone(), proposal).unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.log[2].value.parse().unwrap(); + + let closing = HandleMsg::Close { proposal_id }; + + // Close expired works + let env = mock_env_height(SOMEBODY, 123457); + let res = handle(&mut deps, env.clone(), closing.clone()).unwrap(); + + // Verify + assert_eq!( + res, + HandleResponse { + messages: vec![], + log: vec![ + log("action", "close"), + log("sender", SOMEBODY), + log("proposal_id", proposal_id), + ], + data: None, + } + ); + + // Trying to close it again fails + let res = handle(&mut deps, env, closing); + + // Verify + assert!(res.is_err()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(&msg, "Cannot close completed or passed proposals"); + } + e => panic!("unexpected error: {}", e), + } + } +} diff --git a/contracts/cw3-fixed-multisig/src/lib.rs b/contracts/cw3-fixed-multisig/src/lib.rs new file mode 100644 index 000000000..2546c7677 --- /dev/null +++ b/contracts/cw3-fixed-multisig/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +pub mod msg; +pub mod state; + +#[cfg(all(target_arch = "wasm32", not(feature = "library")))] +cosmwasm_std::create_entry_points!(contract); diff --git a/contracts/cw3-fixed-multisig/src/msg.rs b/contracts/cw3-fixed-multisig/src/msg.rs new file mode 100644 index 000000000..3d7aeda22 --- /dev/null +++ b/contracts/cw3-fixed-multisig/src/msg.rs @@ -0,0 +1,78 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{CosmosMsg, Empty, HumanAddr}; +use cw0::{Duration, Expiration}; +use cw3::Vote; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct InitMsg { + pub voters: Vec, + pub required_weight: u64, + pub max_voting_period: Duration, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Voter { + pub addr: HumanAddr, + pub weight: u64, +} + +// TODO: add some T variants? Maybe good enough as fixed Empty for now +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum HandleMsg { + 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, + }, +} + +// TODO: add a custom query to return the voter list (all potential voters) +// We can also add this as a cw3 extension +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// Return ThresholdResponse + Threshold {}, + /// Returns ProposalResponse + Proposal { proposal_id: u64 }, + /// Returns ProposalListResponse + ListProposals { + start_after: Option, + limit: Option, + }, + /// Returns ProposalListResponse + ReverseProposals { + start_before: Option, + limit: Option, + }, + /// Returns VoteResponse + Vote { proposal_id: u64, voter: HumanAddr }, + /// Returns VoteListResponse + ListVotes { + proposal_id: u64, + start_after: Option, + limit: Option, + }, + /// Returns VoterResponse + Voter { address: HumanAddr }, + /// Returns VoterListResponse + ListVoters { + start_after: Option, + limit: Option, + }, +} diff --git a/contracts/cw3-fixed-multisig/src/state.rs b/contracts/cw3-fixed-multisig/src/state.rs new file mode 100644 index 000000000..e436b43af --- /dev/null +++ b/contracts/cw3-fixed-multisig/src/state.rs @@ -0,0 +1,111 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::convert::TryInto; + +use cosmwasm_std::{CosmosMsg, Empty, ReadonlyStorage, StdError, StdResult, Storage}; +use cosmwasm_storage::{ + bucket, bucket_read, singleton, singleton_read, Bucket, ReadonlyBucket, ReadonlySingleton, + Singleton, +}; +use cw0::{Duration, Expiration}; +use cw3::{Status, Vote}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Config { + pub required_weight: u64, + pub total_weight: u64, + pub max_voting_period: Duration, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Proposal { + pub title: String, + pub description: String, + pub expires: Expiration, + pub msgs: Vec>, + pub status: Status, + /// how many votes have already said yes + pub yes_weight: u64, + /// how many votes needed to pass + pub required_weight: u64, +} + +impl Proposal { + /// TODO: we should get the current BlockInfo and then we can determine this a bit better + pub fn current_status(&self) -> Status { + let mut status = self.status; + + // if open, check if voting is passed on timed out + if status == Status::Open && self.yes_weight >= self.required_weight { + status = Status::Passed + } + + status + } +} + +// we cast a ballot with our chosen vote and a given weight +// stored under the key that voted +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Ballot { + pub weight: u64, + pub vote: Vote, +} + +pub const CONFIG_KEY: &[u8] = b"config"; +pub const PROPOSAL_COUNTER: &[u8] = b"proposal_count"; + +pub const PREFIX_PROPOSAL: &[u8] = b"proposals"; +pub const PREFIX_VOTERS: &[u8] = b"voters"; +pub const PREFIX_VOTES: &[u8] = b"votes"; + +pub fn config(storage: &mut S) -> Singleton { + singleton(storage, CONFIG_KEY) +} + +pub fn config_read(storage: &S) -> ReadonlySingleton { + singleton_read(storage, CONFIG_KEY) +} + +pub fn voters(storage: &mut S) -> Bucket { + bucket(PREFIX_VOTERS, storage) +} + +pub fn voters_read(storage: &S) -> ReadonlyBucket { + bucket_read(PREFIX_VOTERS, storage) +} + +pub fn proposal(storage: &mut S) -> Bucket { + bucket(PREFIX_PROPOSAL, storage) +} + +pub fn proposal_read(storage: &S) -> ReadonlyBucket { + bucket_read(PREFIX_PROPOSAL, storage) +} + +pub fn next_id(storage: &mut S) -> StdResult { + let mut s = singleton(storage, PROPOSAL_COUNTER); + let id: u64 = s.may_load()?.unwrap_or_default() + 1; + s.save(&id)?; + Ok(id) +} + +pub fn parse_id(data: &[u8]) -> StdResult { + match data[0..8].try_into() { + Ok(bytes) => Ok(u64::from_be_bytes(bytes)), + Err(_) => Err(StdError::generic_err( + "Corrupted data found. 8 byte expected.", + )), + } +} + +pub fn ballots(storage: &mut S, proposal_id: u64) -> Bucket { + Bucket::multilevel(&[PREFIX_VOTES, &proposal_id.to_be_bytes()], storage) +} + +pub fn ballots_read( + storage: &S, + proposal_id: u64, +) -> ReadonlyBucket { + ReadonlyBucket::multilevel(&[PREFIX_VOTES, &proposal_id.to_be_bytes()], storage) +} diff --git a/packages/cw0/src/expiration.rs b/packages/cw0/src/expiration.rs index baa33b788..c8b3484a8 100644 --- a/packages/cw0/src/expiration.rs +++ b/packages/cw0/src/expiration.rs @@ -1,11 +1,16 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use cosmwasm_std::BlockInfo; +use cosmwasm_std::{BlockInfo, StdError, StdResult}; +use std::cmp::Ordering; use std::fmt; +use std::ops::Add; -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "snake_case")] +/// Expiration represents a point in time when some event happens. +/// It can compare with a BlockInfo and will return is_expired() == true +/// once the condition is hit (and for every block in the future) pub enum Expiration { /// AtHeight will expire when `env.block.height` >= height AtHeight(u64), @@ -41,3 +46,92 @@ impl Expiration { } } } + +impl Add for Expiration { + type Output = StdResult; + + fn add(self, duration: Duration) -> StdResult { + match (self, duration) { + (Expiration::AtTime(t), Duration::Time(delta)) => Ok(Expiration::AtTime(t + delta)), + (Expiration::AtHeight(h), Duration::Height(delta)) => { + Ok(Expiration::AtHeight(h + delta)) + } + (Expiration::Never {}, _) => Ok(Expiration::Never {}), + _ => Err(StdError::generic_err("Cannot add height and time")), + } + } +} + +// TODO: does this make sense? do we get expected info/error when None is returned??? +impl PartialOrd for Expiration { + fn partial_cmp(&self, other: &Expiration) -> Option { + match (self, other) { + // compare if both height or both time + (Expiration::AtHeight(h1), Expiration::AtHeight(h2)) => Some(h1.cmp(h2)), + (Expiration::AtTime(t1), Expiration::AtTime(t2)) => Some(t1.cmp(t2)), + // if at least one is never, we can compare with anything + (Expiration::Never {}, Expiration::Never {}) => Some(Ordering::Equal), + (Expiration::Never {}, _) => Some(Ordering::Greater), + (_, Expiration::Never {}) => Some(Ordering::Less), + // if they are mis-matched finite ends, no compare possible + _ => None, + } + } +} + +/// Duration is a delta of time. You can add it to a BlockInfo or Expiration to +/// move that further in the future. Note that an height-based Duration and +/// a time-based Expiration cannot be combined +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Duration { + Height(u64), + Time(u64), +} + +impl fmt::Display for Duration { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Duration::Height(height) => write!(f, "height: {}", height), + Duration::Time(time) => write!(f, "time: {}", time), + } + } +} + +impl Duration { + /// Create an expiration for Duration after current block + pub fn after(&self, block: &BlockInfo) -> Expiration { + match self { + Duration::Height(h) => Expiration::AtHeight(block.height + h), + Duration::Time(t) => Expiration::AtTime(block.time + t), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + // TODO: add tests for the logic + #[test] + fn compare_expiration() { + // matching pairs + assert_eq!(true, Expiration::AtHeight(5) < Expiration::AtHeight(10)); + assert_eq!(false, Expiration::AtHeight(8) < Expiration::AtHeight(7)); + assert_eq!(true, Expiration::AtTime(555) < Expiration::AtTime(777)); + assert_eq!(false, Expiration::AtTime(86) > Expiration::AtTime(100)); + + // never as infinity + assert!(Expiration::AtHeight(500000) < Expiration::Never {}); + assert!(Expiration::Never {} > Expiration::AtTime(500000)); + + // what happens for the uncomparables?? all compares are false + assert_eq!( + None, + Expiration::AtTime(1000).partial_cmp(&Expiration::AtHeight(230)) + ); + assert_eq!(false, Expiration::AtTime(1000) < Expiration::AtHeight(230)); + assert_eq!(false, Expiration::AtTime(1000) > Expiration::AtHeight(230)); + assert_eq!(false, Expiration::AtTime(1000) == Expiration::AtHeight(230)); + } +} diff --git a/packages/cw0/src/lib.rs b/packages/cw0/src/lib.rs index 30b91b75a..65d6a0906 100644 --- a/packages/cw0/src/lib.rs +++ b/packages/cw0/src/lib.rs @@ -2,12 +2,4 @@ mod balance; mod expiration; pub use crate::balance::NativeBalance; -pub use crate::expiration::Expiration; - -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - assert_eq!(2 + 2, 4); - } -} +pub use crate::expiration::{Duration, Expiration}; diff --git a/packages/cw20/schema/all_allowances_response.json b/packages/cw20/schema/all_allowances_response.json index 52d00342e..b8c365188 100644 --- a/packages/cw20/schema/all_allowances_response.json +++ b/packages/cw20/schema/all_allowances_response.json @@ -34,6 +34,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/packages/cw20/schema/allowance_response.json b/packages/cw20/schema/allowance_response.json index dc0166c3b..596d84464 100644 --- a/packages/cw20/schema/allowance_response.json +++ b/packages/cw20/schema/allowance_response.json @@ -16,6 +16,7 @@ }, "definitions": { "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/packages/cw20/schema/cw20_handle_msg.json b/packages/cw20/schema/cw20_handle_msg.json index ce9265ae8..92d1b81d4 100644 --- a/packages/cw20/schema/cw20_handle_msg.json +++ b/packages/cw20/schema/cw20_handle_msg.json @@ -269,6 +269,7 @@ "type": "string" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/packages/cw3/README.md b/packages/cw3/README.md index 4d7a4bc1d..dccb35b8e 100644 --- a/packages/cw3/README.md +++ b/packages/cw3/README.md @@ -127,4 +127,6 @@ lexographically ascending order. Information on who can vote is contract dependent. But we will work on a common API to display some of this. -**TODO** \ No newline at end of file +`Voter { address }` - returns voting power (weight) of this address, if any + +`ListVoters { start_after, limit }` - list all eligable voters diff --git a/packages/cw3/examples/schema.rs b/packages/cw3/examples/schema.rs index b58acf917..53a3a0bc9 100644 --- a/packages/cw3/examples/schema.rs +++ b/packages/cw3/examples/schema.rs @@ -5,7 +5,7 @@ use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, s use cw3::{ Cw3HandleMsg, Cw3QueryMsg, ProposalListResponse, ProposalResponse, ThresholdResponse, - VoteListResponse, VoteResponse, + VoteListResponse, VoteResponse, VoterListResponse, VoterResponse, }; fn main() { @@ -16,9 +16,15 @@ fn main() { export_schema_with_title(&mut schema_for!(Cw3HandleMsg), &out_dir, "HandleMsg"); export_schema_with_title(&mut schema_for!(Cw3QueryMsg), &out_dir, "QueryMsg"); - export_schema(&schema_for!(ProposalResponse), &out_dir); + export_schema_with_title( + &mut schema_for!(ProposalResponse), + &out_dir, + "ProposalResponse", + ); export_schema(&schema_for!(ProposalListResponse), &out_dir); export_schema(&schema_for!(VoteResponse), &out_dir); export_schema(&schema_for!(VoteListResponse), &out_dir); + export_schema(&schema_for!(VoterResponse), &out_dir); + export_schema(&schema_for!(VoterListResponse), &out_dir); export_schema(&schema_for!(ThresholdResponse), &out_dir); } diff --git a/packages/cw3/schema/handle_msg.json b/packages/cw3/schema/handle_msg.json index 108342f18..2d52f2bbc 100644 --- a/packages/cw3/schema/handle_msg.json +++ b/packages/cw3/schema/handle_msg.json @@ -227,6 +227,7 @@ "type": "object" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/packages/cw3/schema/proposal_list_response.json b/packages/cw3/schema/proposal_list_response.json index b576d0e34..ee0e9b2c5 100644 --- a/packages/cw3/schema/proposal_list_response.json +++ b/packages/cw3/schema/proposal_list_response.json @@ -120,6 +120,7 @@ "type": "object" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", @@ -174,6 +175,7 @@ "expires", "id", "msgs", + "status", "title" ], "properties": { @@ -194,6 +196,9 @@ "$ref": "#/definitions/CosmosMsg_for_Empty" } }, + "status": { + "$ref": "#/definitions/Status" + }, "title": { "type": "string" } @@ -306,6 +311,16 @@ } ] }, + "Status": { + "type": "string", + "enum": [ + "pending", + "open", + "rejected", + "passed", + "executed" + ] + }, "Uint128": { "type": "string" }, diff --git a/packages/cw3/schema/proposal_response_for__empty.json b/packages/cw3/schema/proposal_response.json similarity index 95% rename from packages/cw3/schema/proposal_response_for__empty.json rename to packages/cw3/schema/proposal_response.json index 178a7dd60..01865c7aa 100644 --- a/packages/cw3/schema/proposal_response_for__empty.json +++ b/packages/cw3/schema/proposal_response.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ProposalResponse_for_Empty", + "title": "ProposalResponse", "description": "Note, if you are storing custom messages in the proposal, the querier needs to know what possible custom message types those are in order to parse the response", "type": "object", "required": [ @@ -8,6 +8,7 @@ "expires", "id", "msgs", + "status", "title" ], "properties": { @@ -28,6 +29,9 @@ "$ref": "#/definitions/CosmosMsg_for_Empty" } }, + "status": { + "$ref": "#/definitions/Status" + }, "title": { "type": "string" } @@ -139,6 +143,7 @@ "type": "object" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", @@ -292,6 +297,16 @@ } ] }, + "Status": { + "type": "string", + "enum": [ + "pending", + "open", + "rejected", + "passed", + "executed" + ] + }, "Uint128": { "type": "string" }, diff --git a/packages/cw3/schema/query_msg.json b/packages/cw3/schema/query_msg.json index 018673763..bebe7cb48 100644 --- a/packages/cw3/schema/query_msg.json +++ b/packages/cw3/schema/query_msg.json @@ -161,6 +161,58 @@ } } } + }, + { + "description": "Voter extension: Returns VoterResponse", + "type": "object", + "required": [ + "voter" + ], + "properties": { + "voter": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "description": "Voter extension: Returns VoterListResponse", + "type": "object", + "required": [ + "list_voters" + ], + "properties": { + "list_voters": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "anyOf": [ + { + "$ref": "#/definitions/HumanAddr" + }, + { + "type": "null" + } + ] + } + } + } + } } ], "definitions": { diff --git a/packages/cw3/schema/vote_list_response.json b/packages/cw3/schema/vote_list_response.json index 1707710fe..3056a34d6 100644 --- a/packages/cw3/schema/vote_list_response.json +++ b/packages/cw3/schema/vote_list_response.json @@ -3,10 +3,10 @@ "title": "VoteListResponse", "type": "object", "required": [ - "proposal" + "votes" ], "properties": { - "proposal": { + "votes": { "type": "array", "items": { "$ref": "#/definitions/VoteInfo" diff --git a/packages/cw3/schema/voter_list_response.json b/packages/cw3/schema/voter_list_response.json new file mode 100644 index 000000000..24497ce31 --- /dev/null +++ b/packages/cw3/schema/voter_list_response.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoterListResponse", + "type": "object", + "required": [ + "voters" + ], + "properties": { + "voters": { + "type": "array", + "items": { + "$ref": "#/definitions/VoterResponse" + } + } + }, + "definitions": { + "HumanAddr": { + "type": "string" + }, + "VoterResponse": { + "type": "object", + "required": [ + "addr", + "weight" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + } +} diff --git a/packages/cw3/schema/voter_response.json b/packages/cw3/schema/voter_response.json new file mode 100644 index 000000000..60af46c63 --- /dev/null +++ b/packages/cw3/schema/voter_response.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoterResponse", + "type": "object", + "required": [ + "addr", + "weight" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "definitions": { + "HumanAddr": { + "type": "string" + } + } +} diff --git a/packages/cw3/src/lib.rs b/packages/cw3/src/lib.rs index e19294e59..0eb49c861 100644 --- a/packages/cw3/src/lib.rs +++ b/packages/cw3/src/lib.rs @@ -4,10 +4,10 @@ mod msg; mod query; pub use crate::helpers::{Cw3CanonicalContract, Cw3Contract}; -pub use crate::msg::Cw3HandleMsg; +pub use crate::msg::{Cw3HandleMsg, Vote}; pub use crate::query::{ - Cw3QueryMsg, ProposalListResponse, ProposalResponse, ThresholdResponse, VoteListResponse, - VoteResponse, + Cw3QueryMsg, ProposalListResponse, ProposalResponse, Status, ThresholdResponse, VoteInfo, + VoteListResponse, VoteResponse, VoterListResponse, VoterResponse, }; #[cfg(test)] diff --git a/packages/cw3/src/msg.rs b/packages/cw3/src/msg.rs index da14a0b01..85acaeffd 100644 --- a/packages/cw3/src/msg.rs +++ b/packages/cw3/src/msg.rs @@ -30,7 +30,7 @@ where }, } -#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)] #[serde(rename_all = "lowercase")] pub enum Vote { Yes, diff --git a/packages/cw3/src/query.rs b/packages/cw3/src/query.rs index d2d69ee34..d6170ca21 100644 --- a/packages/cw3/src/query.rs +++ b/packages/cw3/src/query.rs @@ -32,6 +32,13 @@ pub enum Cw3QueryMsg { start_after: Option, limit: Option, }, + /// Voter extension: Returns VoterResponse + Voter { address: HumanAddr }, + /// Voter extension: Returns VoterListResponse + ListVoters { + start_after: Option, + limit: Option, + }, } /// This defines the different ways tallies can happen. @@ -79,31 +86,58 @@ pub struct ProposalResponse where T: Clone + fmt::Debug + PartialEq + JsonSchema, { - id: u64, - title: String, - description: String, - msgs: Vec>, - expires: Expiration, + pub id: u64, + pub title: String, + pub description: String, + pub msgs: Vec>, + pub expires: Expiration, + pub status: Status, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "lowercase")] +pub enum Status { + /// proposal was created, but voting has not yet begun for whatever reason + Pending, + /// you can vote on this + Open, + /// voting is over and it did not pass + Rejected, + /// voting is over and it did pass, but has not yet executed + Passed, + /// voting is over it passed, and the proposal was executed + Executed, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct ProposalListResponse { - proposals: Vec, + pub proposals: Vec, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct VoteListResponse { - proposal: Vec, + pub votes: Vec, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct VoteInfo { - voter: HumanAddr, - vote: Vote, - weight: u64, + pub voter: HumanAddr, + pub vote: Vote, + pub weight: u64, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] pub struct VoteResponse { - vote: Option, + pub vote: Option, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct VoterResponse { + pub addr: HumanAddr, + pub weight: u64, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct VoterListResponse { + pub voters: Vec, } diff --git a/packages/cw721/schema/all_nft_info_response.json b/packages/cw721/schema/all_nft_info_response.json index 657a60a09..80c545f13 100644 --- a/packages/cw721/schema/all_nft_info_response.json +++ b/packages/cw721/schema/all_nft_info_response.json @@ -52,6 +52,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/packages/cw721/schema/cw721_handle_msg.json b/packages/cw721/schema/cw721_handle_msg.json index 36557ce5b..84e20f63c 100644 --- a/packages/cw721/schema/cw721_handle_msg.json +++ b/packages/cw721/schema/cw721_handle_msg.json @@ -175,6 +175,7 @@ "type": "string" }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/packages/cw721/schema/owner_of_response.json b/packages/cw721/schema/owner_of_response.json index 56d57d55c..8aa467831 100644 --- a/packages/cw721/schema/owner_of_response.json +++ b/packages/cw721/schema/owner_of_response.json @@ -50,6 +50,7 @@ } }, "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "anyOf": [ { "description": "AtHeight will expire when `env.block.height` >= height", diff --git a/scripts/publish.sh b/scripts/publish.sh index cd072dc82..5f8223b92 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -8,7 +8,7 @@ ALL_PACKAGES="cw1 cw2 cw3 cw20 cw721" # these are imported by other contracts BASE_CONTRACTS="cw1-whitelist cw20-base" -ALL_CONTRACTS="cw1-subkeys cw20-atomic-swap cw20-escrow cw20-staking" +ALL_CONTRACTS="cw1-subkeys cw3-fixed-multisig cw20-atomic-swap cw20-escrow cw20-staking" SLEEP_TIME=30