From fe0209a9624d0934944e78c2c1a2cbf2b7b7cdfc Mon Sep 17 00:00:00 2001 From: noah Date: Thu, 31 Oct 2024 10:31:41 -0400 Subject: [PATCH] Add pre-propose-approval support for multiple choice proposals, and freeze approver (#883) * moved common pre-propose-approval types to dao-voting package * added dao-pre-propose-approval-multiple contract * added multiple choice approver tests * lock approver in pending proposal on propose instead of using most recent approver * updated schema --- Cargo.lock | 30 + Cargo.toml | 1 + .../Cargo.toml | 44 + .../README.md | 78 + .../examples/schema.rs | 11 + .../dao-pre-propose-approval-multiple.json | 2850 +++++++++++++++++ .../src/contract.rs | 418 +++ .../src/lib.rs | 13 + .../src/msg.rs | 89 + .../src/state.rs | 21 + .../src/tests.rs | 2640 +++++++++++++++ .../Cargo.toml | 9 +- .../dao-pre-propose-approval-single/README.md | 8 +- .../dao-pre-propose-approval-single.json | 151 +- .../src/contract.rs | 283 +- .../src/msg.rs | 23 +- .../src/state.rs | 33 +- .../src/tests.rs | 111 +- .../dao-pre-propose-approver/Cargo.toml | 9 +- .../dao-pre-propose-approver/src/contract.rs | 22 +- .../dao-pre-propose-approver/src/msg.rs | 2 +- .../dao-pre-propose-approver/src/tests/mod.rs | 2 + .../src/tests/multiple.rs | 1914 +++++++++++ .../src/{tests.rs => tests/single.rs} | 2 +- packages/dao-testing/src/helpers.rs | 4 +- packages/dao-voting/src/approval.rs | 54 + packages/dao-voting/src/lib.rs | 1 + 27 files changed, 8643 insertions(+), 180 deletions(-) create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/Cargo.toml create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/README.md create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/examples/schema.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/schema/dao-pre-propose-approval-multiple.json create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/src/contract.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/src/lib.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/src/msg.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/src/state.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approver/src/tests/mod.rs create mode 100644 contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs rename contracts/pre-propose/dao-pre-propose-approver/src/{tests.rs => tests/single.rs} (99%) create mode 100644 packages/dao-voting/src/approval.rs diff --git a/Cargo.lock b/Cargo.lock index 967f86504..9304021ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1824,6 +1824,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dao-pre-propose-approval-multiple" +version = "2.5.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom 2.5.1", + "cw-multi-test", + "cw-paginate-storage 2.5.1", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "cw20-base 1.1.2", + "cw4 1.1.2", + "cw4-group 1.1.2", + "dao-dao-core 2.5.1", + "dao-hooks 2.5.1", + "dao-interface 2.5.1", + "dao-pre-propose-base 2.5.1", + "dao-proposal-multiple 2.5.1", + "dao-testing", + "dao-voting 2.5.1", + "dao-voting-cw20-staked", + "dao-voting-cw4 2.5.1", + "thiserror", +] + [[package]] name = "dao-pre-propose-approval-single" version = "2.4.1" @@ -1892,8 +1920,10 @@ dependencies = [ "dao-dao-core 2.5.1", "dao-hooks 2.5.1", "dao-interface 2.5.1", + "dao-pre-propose-approval-multiple", "dao-pre-propose-approval-single 2.5.1", "dao-pre-propose-base 2.5.1", + "dao-proposal-multiple 2.5.1", "dao-proposal-single 2.5.1", "dao-testing", "dao-voting 2.5.1", diff --git a/Cargo.toml b/Cargo.toml index cc005ca1e..8ccbe6e0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ dao-dao-core = { path = "./contracts/dao-dao-core", version = "2.5.1" } dao-dao-macros = { path = "./packages/dao-dao-macros", version = "2.5.1" } dao-hooks = { path = "./packages/dao-hooks", version = "2.5.1" } dao-interface = { path = "./packages/dao-interface", version = "2.5.1" } +dao-pre-propose-approval-multiple = { path = "./contracts/pre-propose/dao-pre-propose-approval-multiple", version = "2.5.1" } dao-migrator = { path = "./contracts/external/dao-migrator", version = "2.5.1" } dao-pre-propose-approval-single = { path = "./contracts/pre-propose/dao-pre-propose-approval-single", version = "2.5.1" } dao-pre-propose-approver = { path = "./contracts/pre-propose/dao-pre-propose-approver", version = "2.5.1" } diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/Cargo.toml b/contracts/pre-propose/dao-pre-propose-approval-multiple/Cargo.toml new file mode 100644 index 000000000..711c5dfec --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "dao-pre-propose-approval-multiple" +authors = ["ekez ", "Jake Hartnell ", "noah "] +description = "A DAO DAO pre-propose module handling a proposal approval flow for for dao-proposal-multiple." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-paginate-storage = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-voting = { workspace = true } +thiserror = { workspace = true } +dao-interface = { workspace = true } + +[dev-dependencies] +cw-denom = { workspace = true } +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +dao-dao-core = { workspace = true } +dao-hooks = { workspace = true } +dao-testing = { workspace = true } +dao-voting = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-proposal-multiple = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/README.md b/contracts/pre-propose/dao-pre-propose-approval-multiple/README.md new file mode 100644 index 000000000..56b39b647 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/README.md @@ -0,0 +1,78 @@ +# Multi choice proposal approval contract + +[![dao-pre-propose-approval-multiple on crates.io](https://img.shields.io/crates/v/dao-pre-propose-approval-multiple.svg?logo=rust)](https://crates.io/crates/dao-pre-propose-approval-multiple) +[![docs.rs](https://img.shields.io/docsrs/dao-pre-propose-approval-multiple?logo=docsdotrs)](https://docs.rs/dao-pre-propose-approval-multiple/latest/dao_pre_propose_approval_multiple/) + +This contract implements an approval flow for proposals, it also handles deposit logic. It works with the `dao-proposal-multiple` proposal module. + +## Approval Logic + +This contract is instantatied with an `approver` address. This address is +allowed to approve or reject the proposal. An approved proposal opens for voting +immediately, whereas a rejected proposal is simply discarded. + +```text + ┌──────────┐ + │ │ + │ Account │ + │ │ + └─────┬────┘ + │ + │ Makes prop + ▼ +┌────────────────────────┐ ┌────────────────────────┐ +│ │ │ │ +│ Pre-propose Approval │ ◄─────────────┤ Approver Address │ +│ │ Approves │ │ +└───────────┬────────────┘ or rejects └────────────────────────┘ + │ + │ Creates prop + │ on approval + ▼ +┌──────────────────────────┐ +│ │ +│ Proposal Multiple │ +│ │ +└───────────┬──────────────┘ + │ + │ Normal voting + │ + ▼ +┌────────────────────────┐ +│ │ +│ Main DAO │ +│ │ +└────────────────────────┘ +``` + +The `approver` may also register a `ProposalSubmitHook`, which fires every time a proposal is submitted to the `dao-pre-propose-approval-multiple` contract. + +## Deposit Logic + +It may accept either native ([bank +module](https://docs.cosmos.network/main/modules/bank/)), +[cw20](https://github.com/CosmWasm/cw-plus/tree/bc339368b1ee33c97c55a19d4cff983c7708ce36/packages/cw20) +tokens, or no tokens as a deposit. If a proposal deposit is enabled +the following refund strategies are avaliable: + +1. Never refund deposits. All deposits are sent to the DAO on proposal + completion. +2. Always refund deposits. Deposits are returned to the proposer on + proposal completion and even rejection by the `approver`. +3. Only refund passed proposals. Deposits are only returned to the + proposer if the proposal is approved and passes. Otherwise, they + are sent to the DAO. + +This module may also be configured to only accept proposals from +members (addresses with voting power) of the DAO. + +Here is a flowchart showing the proposal creation process using this +module: + +![](https://bafkreig42cxswefi2ks7vhrwyvkcnumbnwdk7ov643yaafm7loi6vh2gja.ipfs.nftstorage.link) + +### Resources + +More about the [pre-propose design](https://github.com/DA0-DA0/dao-contracts/wiki/Pre-propose-module-design). + +More about [pre-propose modules](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#pre-propose-modules). diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/examples/schema.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/examples/schema.rs new file mode 100644 index 000000000..219c4647d --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_pre_propose_approval_multiple::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/schema/dao-pre-propose-approval-multiple.json b/contracts/pre-propose/dao-pre-propose-approval-multiple/schema/dao-pre-propose-approval-multiple.json new file mode 100644 index 000000000..bfcff069a --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/schema/dao-pre-propose-approval-multiple.json @@ -0,0 +1,2850 @@ +{ + "contract_name": "dao-pre-propose-approval-multiple", + "contract_version": "2.5.1", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "extension", + "submission_policy" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit requirements for this module. None if no deposit.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "extension": { + "description": "Extension for instantiation. The default implementation will do nothing with this data.", + "allOf": [ + { + "$ref": "#/definitions/InstantiateExt" + } + ] + }, + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token native denom or cw20 contract address of the associated DAO's voting module. NOTE: in order to retrieve the token automatically via this variant, the voting module must either (1) use a native token and implement the `Denom {}` query type defined by `dao_dao_macros::native_token_query` OR (2) use a cw20 token and implement the `TokenContract {}` query type defined by `dao_dao_macros::cw20_token_query`. Failing to implement correctly will cause this option to fail to instantiate.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "required": [ + "token_type" + ], + "properties": { + "token_type": { + "$ref": "#/definitions/VotingModuleTokenType" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "InstantiateExt": { + "type": "object", + "required": [ + "approver" + ], + "properties": { + "approver": { + "type": "string" + } + }, + "additionalProperties": false + }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "required": [ + "denylist" + ], + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "allowlist", + "dao_members", + "denylist" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "UncheckedDenom": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "VotingModuleTokenType": { + "type": "string", + "enum": [ + "native", + "cw20" + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a new proposal in the pre-propose module. MSG will be serialized and used as the proposal creation message.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ProposeMessage" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the configuration of this module. This will completely override the existing configuration. This new configuration will only apply to proposals created after the config is updated. Only the DAO may execute this message.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "deposit_info": { + "description": "If None, will remove the deposit. Backwards compatible.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "submission_policy": { + "description": "If None, will leave the submission policy in the config as-is.", + "anyOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Perform more granular submission policy updates to allow for atomic operations that don't override others.", + "type": "object", + "required": [ + "update_submission_policy" + ], + "properties": { + "update_submission_policy": { + "type": "object", + "properties": { + "allowlist_add": { + "description": "If using specific policy, optionally add to the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowlist_remove": { + "description": "If using specific policy, optionally remove from the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_add": { + "description": "Optionally add to the denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_remove": { + "description": "Optionally remove from denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "set_dao_members": { + "description": "If using specific policy, optionally update the `dao_members` flag.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Withdraws funds inside of this contract to the message sender. The contracts entire balance for the specifed DENOM is withdrawn to the message sender. Only the DAO may call this method.\n\nThis is intended only as an escape hatch in the event of a critical bug in this contract or it's proposal module. Withdrawing funds will cause future attempts to return proposal deposits to fail their transactions as the contract will have insufficent balance to return them. In the case of `cw-proposal-single` this transaction failure will cause the module to remove the pre-propose module from its proposal hook receivers.\n\nMore likely than not, this should NEVER BE CALLED unless a bug in this contract or the proposal module it is associated with has caused it to stop receiving proposal hook messages, or if a critical security vulnerability has been found that allows an attacker to drain proposal deposits.", + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "properties": { + "denom": { + "description": "The denom to withdraw funds for. If no denom is specified, the denomination currently configured for proposal deposits will be used.\n\nYou may want to specify a denomination here if you are withdrawing funds that were previously accepted for proposal deposits but are not longer used due to an `UpdateConfig` message being executed on the contract.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension message. Contracts that extend this one should put their custom execute logic here. The default implementation will do nothing if this variant is executed.", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ApprovalExecuteExt" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds a proposal submitted hook. Fires when a new proposal is submitted to the pre-propose contract. Only the DAO may call this method.", + "type": "object", + "required": [ + "add_proposal_submitted_hook" + ], + "properties": { + "add_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a proposal submitted hook. Only the DAO may call this method.", + "type": "object", + "required": [ + "remove_proposal_submitted_hook" + ], + "properties": { + "remove_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Handles proposal hook fired by the associated proposal module when a proposal is completed (ie executed or rejected). By default, the base contract will return deposits proposals, when they are closed, when proposals are executed, or, if it is refunding failed.", + "type": "object", + "required": [ + "proposal_completed_hook" + ], + "properties": { + "proposal_completed_hook": { + "type": "object", + "required": [ + "new_status", + "proposal_id" + ], + "properties": { + "new_status": { + "$ref": "#/definitions/Status" + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "ApprovalExecuteExt": { + "oneOf": [ + { + "description": "Approve a proposal, only callable by approver", + "type": "object", + "required": [ + "approve" + ], + "properties": { + "approve": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a proposal, only callable by approver", + "type": "object", + "required": [ + "reject" + ], + "properties": { + "reject": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the approver, can only be called the current approver", + "type": "object", + "required": [ + "update_approver" + ], + "properties": { + "update_approver": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "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. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "additionalProperties": false + } + ] + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token native denom or cw20 contract address of the associated DAO's voting module. NOTE: in order to retrieve the token automatically via this variant, the voting module must either (1) use a native token and implement the `Denom {}` query type defined by `dao_dao_macros::native_token_query` OR (2) use a cw20 token and implement the `TokenContract {}` query type defined by `dao_dao_macros::cw20_token_query`. Failing to implement correctly will cause this option to fail to instantiate.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "required": [ + "token_type" + ], + "properties": { + "token_type": { + "$ref": "#/definitions/VotingModuleTokenType" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "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)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This maps directly to [MsgVoteWeighted](https://github.com/cosmos/cosmos-sdk/blob/v0.45.8/proto/cosmos/gov/v1beta1/tx.proto#L66-L78) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote_weighted" + ], + "properties": { + "vote_weighted": { + "type": "object", + "required": [ + "options", + "proposal_id" + ], + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/WeightedVoteOption" + } + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "existing channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (e.g. after resetting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "MultipleChoiceAutoVote": { + "type": "object", + "required": [ + "vote" + ], + "properties": { + "rationale": { + "description": "An optional rationale for why this vote was cast. This can be updated, set, or removed later by the address casting the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "The proposer's position on the proposal.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVote" + } + ] + } + }, + "additionalProperties": false + }, + "MultipleChoiceOption": { + "description": "Unchecked multiple choice option", + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "MultipleChoiceOptions": { + "description": "Represents unchecked multiple choice options", + "type": "object", + "required": [ + "options" + ], + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/MultipleChoiceOption" + } + } + }, + "additionalProperties": false + }, + "MultipleChoiceVote": { + "description": "A multiple choice vote, picking the desired option", + "type": "object", + "required": [ + "option_id" + ], + "properties": { + "option_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "required": [ + "denylist" + ], + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "allowlist", + "dao_members", + "denylist" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "ProposeMessage": { + "oneOf": [ + { + "description": "The propose message used to make a proposal to this module. Note that this is identical to the propose message used by dao-proposal-multiple, except that it omits the `proposer` field which it fills in for the sender.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "choices", + "description", + "title" + ], + "properties": { + "choices": { + "$ref": "#/definitions/MultipleChoiceOptions" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "vote": { + "anyOf": [ + { + "$ref": "#/definitions/MultipleChoiceAutoVote" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + }, + { + "description": "The proposal is timelocked. Only the configured vetoer can execute or veto until the timelock expires.", + "type": "object", + "required": [ + "veto_timelock" + ], + "properties": { + "veto_timelock": { + "type": "object", + "required": [ + "expiration" + ], + "properties": { + "expiration": { + "$ref": "#/definitions/Expiration" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The proposal has been vetoed.", + "type": "string", + "enum": [ + "vetoed" + ] + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + }, + "UncheckedDenom": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "VotingModuleTokenType": { + "type": "string", + "enum": [ + "native", + "cw20" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readable label for the contract.\n\nValid values should: - not be empty - not be bigger than 128 bytes (or some chain-specific limit) - not start / end with whitespace", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code using a predictable address derivation algorithm implemented in [`cosmwasm_std::instantiate2_address`].\n\nThis is translated to a [MsgInstantiateContract2](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L73-L96). `sender` is automatically filled with the current contract's address. `fix_msg` is automatically set to false.", + "type": "object", + "required": [ + "instantiate2" + ], + "properties": { + "instantiate2": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg", + "salt" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readable label for the contract.\n\nValid values should: - not be empty - not be bigger than 128 bytes (or some chain-specific limit) - not start / end with whitespace", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "salt": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "WeightedVoteOption": { + "type": "object", + "required": [ + "option", + "weight" + ], + "properties": { + "option": { + "$ref": "#/definitions/VoteOption" + }, + "weight": { + "$ref": "#/definitions/Decimal" + } + } + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module that this pre propose module is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "proposal_module" + ], + "properties": { + "proposal_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the DAO (dao-dao-core) module this contract is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns contract version info.", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the module's configuration.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the deposit info for the proposal identified by PROPOSAL_ID.", + "type": "object", + "required": [ + "deposit_info" + ], + "properties": { + "deposit_info": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns whether or not the address can submit proposals.", + "type": "object", + "required": [ + "can_propose" + ], + "properties": { + "can_propose": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of proposal submitted hooks.", + "type": "object", + "required": [ + "proposal_submitted_hooks" + ], + "properties": { + "proposal_submitted_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension for queries. The default implementation will do nothing if queried for will return `Binary::default()`.", + "type": "object", + "required": [ + "query_extension" + ], + "properties": { + "query_extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/QueryExt" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "QueryExt": { + "oneOf": [ + { + "description": "List the approver address", + "type": "object", + "required": [ + "approver" + ], + "properties": { + "approver": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Return whether or not the proposal is pending", + "type": "object", + "required": [ + "is_pending" + ], + "properties": { + "is_pending": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A proposal, pending or completed.", + "type": "object", + "required": [ + "proposal" + ], + "properties": { + "proposal": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A pending proposal", + "type": "object", + "required": [ + "pending_proposal" + ], + "properties": { + "pending_proposal": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "List of proposals awaiting approval", + "type": "object", + "required": [ + "pending_proposals" + ], + "properties": { + "pending_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reverse_pending_proposals" + ], + "properties": { + "reverse_pending_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A completed proposal", + "type": "object", + "required": [ + "completed_proposal" + ], + "properties": { + "completed_proposal": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "List of completed proposals", + "type": "object", + "required": [ + "completed_proposals" + ], + "properties": { + "completed_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reverse_completed_proposals" + ], + "properties": { + "reverse_completed_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The completed approval ID for a created proposal ID.", + "type": "object", + "required": [ + "completed_proposal_id_for_created_proposal_id" + ], + "properties": { + "completed_proposal_id_for_created_proposal_id": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "from_under_v250" + ], + "properties": { + "from_under_v250": { + "type": "object", + "properties": { + "policy": { + "description": "Optionally set a new submission policy with more granular controls. If not set, the current policy will remain.", + "anyOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "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" + }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "required": [ + "denylist" + ], + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "allowlist", + "dao_members", + "denylist" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "sudo": null, + "responses": { + "can_propose": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Boolean", + "type": "boolean" + }, + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "submission_policy" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit required to create a proposal. If `None`, no deposit is required.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "required": [ + "denylist" + ], + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "allowlist", + "dao_members", + "denylist" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "dao": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Addr", + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "deposit_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositInfoResponse", + "type": "object", + "required": [ + "proposer" + ], + "properties": { + "deposit_info": { + "description": "The deposit that has been paid for the specified proposal.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created the proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "proposal_module": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Addr", + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "proposal_submitted_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "query_extension": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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. See also .", + "type": "string" + } + } +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/contract.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/contract.rs new file mode 100644 index 000000000..eb5a08bb1 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/contract.rs @@ -0,0 +1,418 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Order, Response, StdResult, + SubMsg, WasmMsg, +}; +use cw2::set_contract_version; +use cw_paginate_storage::paginate_map_values; +use dao_pre_propose_base::{ + error::PreProposeError, msg::ExecuteMsg as ExecuteBase, state::PreProposeContract, +}; +use dao_voting::approval::{ApprovalProposalStatus, ApproverProposeMessage}; +use dao_voting::deposit::DepositRefundPolicy; +use dao_voting::proposal::MultipleChoiceProposeMsg as ProposeMsg; + +use crate::msg::{ + ExecuteExt, ExecuteMsg, InstantiateExt, InstantiateMsg, MigrateMsg, ProposeMessage, + ProposeMessageInternal, QueryExt, QueryMsg, +}; +use crate::state::{ + advance_approval_id, Proposal, APPROVER, COMPLETED_PROPOSALS, + CREATED_PROPOSAL_TO_COMPLETED_PROPOSAL, PENDING_PROPOSALS, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-pre-propose-approval-multiple"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +type PrePropose = PreProposeContract; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let approver = deps.api.addr_validate(&msg.extension.approver)?; + APPROVER.save(deps.storage, &approver)?; + + let resp = PrePropose::default().instantiate(deps.branch(), env, info, msg)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(resp.add_attribute("approver", approver.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Propose { msg } => execute_propose(deps, env, info, msg), + + ExecuteMsg::AddProposalSubmittedHook { address } => { + execute_add_approver_hook(deps, info, address) + } + ExecuteMsg::RemoveProposalSubmittedHook { address } => { + execute_remove_approver_hook(deps, info, address) + } + + ExecuteMsg::Extension { msg } => match msg { + ExecuteExt::Approve { id } => execute_approve(deps, info, id), + ExecuteExt::Reject { id } => execute_reject(deps, info, id), + ExecuteExt::UpdateApprover { address } => execute_update_approver(deps, info, address), + }, + // Default pre-propose-base behavior for all other messages + _ => PrePropose::default().execute(deps, env, info, msg), + } +} + +pub fn execute_propose( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ProposeMessage, +) -> Result { + let pre_propose_base = PrePropose::default(); + let config = pre_propose_base.config.load(deps.storage)?; + + pre_propose_base.check_can_submit(deps.as_ref(), info.sender.clone())?; + + // Take deposit, if configured. + let deposit_messages = if let Some(ref deposit_info) = config.deposit_info { + deposit_info.check_native_deposit_paid(&info)?; + deposit_info.get_take_deposit_messages(&info.sender, &env.contract.address)? + } else { + vec![] + }; + + let approval_id = advance_approval_id(deps.storage)?; + + let propose_msg_internal = match msg { + ProposeMessage::Propose { + title, + description, + choices, + vote, + } => ProposeMsg { + title, + description, + choices, + proposer: Some(info.sender.to_string()), + vote, + }, + }; + + // Prepare proposal submitted hooks msg to notify approver. Make + // a proposal on the approver DAO to approve this pre-proposal + let hooks_msgs = + pre_propose_base + .proposal_submitted_hooks + .prepare_hooks(deps.storage, |a| { + let execute_msg = WasmMsg::Execute { + contract_addr: a.into_string(), + msg: to_json_binary(&ExecuteBase::::Propose { + msg: ApproverProposeMessage::Propose { + title: propose_msg_internal.title.clone(), + description: propose_msg_internal.description.clone(), + approval_id, + }, + })?, + funds: vec![], + }; + Ok(SubMsg::new(execute_msg)) + })?; + + let approver = APPROVER.load(deps.storage)?; + + // Save the proposal and its information as pending. + PENDING_PROPOSALS.save( + deps.storage, + approval_id, + &Proposal { + status: ApprovalProposalStatus::Pending {}, + approval_id, + approver: approver.clone(), + proposer: info.sender, + msg: propose_msg_internal, + deposit: config.deposit_info, + }, + )?; + + Ok(Response::default() + .add_messages(deposit_messages) + .add_submessages(hooks_msgs) + .add_attribute("method", "pre-propose") + .add_attribute("id", approval_id.to_string()) + .add_attribute("approver", approver.to_string())) +} + +pub fn execute_approve( + deps: DepsMut, + info: MessageInfo, + id: u64, +) -> Result { + // Load proposal and send propose message to the proposal module + let proposal = PENDING_PROPOSALS.may_load(deps.storage, id)?; + match proposal { + Some(proposal) => { + // Check sender is the approver + if proposal.approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + let proposal_module = PrePropose::default().proposal_module.load(deps.storage)?; + + // Snapshot the deposit for the proposal that we're about + // to create. + let proposal_id = deps.querier.query_wasm_smart( + &proposal_module, + &dao_interface::proposal::Query::NextProposalId {}, + )?; + PrePropose::default().deposits.save( + deps.storage, + proposal_id, + &(proposal.deposit.clone(), proposal.proposer.clone()), + )?; + + let propose_messsage = WasmMsg::Execute { + contract_addr: proposal_module.into_string(), + msg: to_json_binary(&ProposeMessageInternal::Propose(proposal.msg.clone()))?, + funds: vec![], + }; + + COMPLETED_PROPOSALS.save( + deps.storage, + id, + &Proposal { + status: ApprovalProposalStatus::Approved { + created_proposal_id: proposal_id, + }, + approval_id: proposal.approval_id, + approver: proposal.approver, + proposer: proposal.proposer, + msg: proposal.msg, + deposit: proposal.deposit, + }, + )?; + CREATED_PROPOSAL_TO_COMPLETED_PROPOSAL.save(deps.storage, proposal_id, &id)?; + PENDING_PROPOSALS.remove(deps.storage, id); + + Ok(Response::default() + .add_message(propose_messsage) + .add_attribute("method", "proposal_approved") + .add_attribute("approval_id", id.to_string()) + .add_attribute("proposal_id", proposal_id.to_string())) + } + None => Err(PreProposeError::ProposalNotFound {}), + } +} + +pub fn execute_reject( + deps: DepsMut, + info: MessageInfo, + id: u64, +) -> Result { + let Proposal { + approval_id, + approver, + proposer, + msg, + deposit, + .. + } = PENDING_PROPOSALS + .may_load(deps.storage, id)? + .ok_or(PreProposeError::ProposalNotFound {})?; + + // Check sender is the approver + if approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + COMPLETED_PROPOSALS.save( + deps.storage, + id, + &Proposal { + status: ApprovalProposalStatus::Rejected {}, + approval_id, + approver, + proposer: proposer.clone(), + msg: msg.clone(), + deposit: deposit.clone(), + }, + )?; + PENDING_PROPOSALS.remove(deps.storage, id); + + let messages = if let Some(ref deposit_info) = deposit { + // Refund can be issued if proposal if deposits are always + // refunded. `OnlyPassed` and `Never` refund deposit policies + // do not apply here. + if deposit_info.refund_policy == DepositRefundPolicy::Always { + deposit_info.get_return_deposit_message(&proposer)? + } else { + // If the proposer doesn't get the deposit, the DAO does. + let dao = PrePropose::default().dao.load(deps.storage)?; + deposit_info.get_return_deposit_message(&dao)? + } + } else { + vec![] + }; + + Ok(Response::default() + .add_attribute("method", "proposal_rejected") + .add_attribute("proposal", id.to_string()) + .add_attribute("deposit_info", to_json_binary(&deposit)?.to_string()) + .add_messages(messages)) +} + +pub fn execute_update_approver( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + // Check sender is the approver + let approver = APPROVER.load(deps.storage)?; + if approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + // Validate address and save new approver + let addr = deps.api.addr_validate(&address)?; + APPROVER.save(deps.storage, &addr)?; + + Ok(Response::default()) +} + +pub fn execute_add_approver_hook( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + let pre_propose_base = PrePropose::default(); + + let dao = pre_propose_base.dao.load(deps.storage)?; + let approver = APPROVER.load(deps.storage)?; + + // Check sender is the approver or the parent DAO + if approver != info.sender && dao != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + let addr = deps.api.addr_validate(&address)?; + pre_propose_base + .proposal_submitted_hooks + .add_hook(deps.storage, addr)?; + + Ok(Response::default()) +} + +pub fn execute_remove_approver_hook( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + let pre_propose_base = PrePropose::default(); + + let dao = pre_propose_base.dao.load(deps.storage)?; + let approver = APPROVER.load(deps.storage)?; + + // Check sender is the approver or the parent DAO + if approver != info.sender && dao != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + // Validate address + let addr = deps.api.addr_validate(&address)?; + + // remove hook + pre_propose_base + .proposal_submitted_hooks + .remove_hook(deps.storage, addr)?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::QueryExtension { msg } => match msg { + QueryExt::Approver {} => to_json_binary(&APPROVER.load(deps.storage)?), + QueryExt::IsPending { id } => { + let pending = PENDING_PROPOSALS.may_load(deps.storage, id)?.is_some(); + // Force load completed proposal if not pending, throwing error + // if not found. + if !pending { + COMPLETED_PROPOSALS.load(deps.storage, id)?; + } + + to_json_binary(&pending) + } + QueryExt::Proposal { id } => { + if let Some(pending) = PENDING_PROPOSALS.may_load(deps.storage, id)? { + to_json_binary(&pending) + } else { + // Force load completed proposal if not pending, throwing + // error if not found. + to_json_binary(&COMPLETED_PROPOSALS.load(deps.storage, id)?) + } + } + QueryExt::PendingProposal { id } => { + to_json_binary(&PENDING_PROPOSALS.load(deps.storage, id)?) + } + QueryExt::PendingProposals { start_after, limit } => { + to_json_binary(&paginate_map_values( + deps, + &PENDING_PROPOSALS, + start_after, + limit, + Order::Ascending, + )?) + } + QueryExt::ReversePendingProposals { + start_before, + limit, + } => to_json_binary(&paginate_map_values( + deps, + &PENDING_PROPOSALS, + start_before, + limit, + Order::Descending, + )?), + QueryExt::CompletedProposal { id } => { + to_json_binary(&COMPLETED_PROPOSALS.load(deps.storage, id)?) + } + QueryExt::CompletedProposals { start_after, limit } => { + to_json_binary(&paginate_map_values( + deps, + &COMPLETED_PROPOSALS, + start_after, + limit, + Order::Ascending, + )?) + } + QueryExt::ReverseCompletedProposals { + start_before, + limit, + } => to_json_binary(&paginate_map_values( + deps, + &COMPLETED_PROPOSALS, + start_before, + limit, + Order::Descending, + )?), + QueryExt::CompletedProposalIdForCreatedProposalId { id } => { + to_json_binary(&CREATED_PROPOSAL_TO_COMPLETED_PROPOSAL.may_load(deps.storage, id)?) + } + }, + _ => PrePropose::default().query(deps, env, msg), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(mut deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + let res = PrePropose::default().migrate(deps.branch(), msg); + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + res +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/lib.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/lib.rs new file mode 100644 index 000000000..47ba41700 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +// Exporting these means that contracts interacting with this one don't +// need an explicit dependency on the base contract to read queries. +pub use dao_pre_propose_base::msg::DepositInfoResponse; +pub use dao_pre_propose_base::state::Config; diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/msg.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/msg.rs new file mode 100644 index 000000000..f0d9d50a6 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/msg.rs @@ -0,0 +1,89 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Empty; +use dao_pre_propose_base::msg::{ + ExecuteMsg as ExecuteBase, InstantiateMsg as InstantiateBase, MigrateMsg as MigrateBase, + QueryMsg as QueryBase, +}; +use dao_voting::{ + multiple_choice::{MultipleChoiceAutoVote, MultipleChoiceOptions}, + proposal::MultipleChoiceProposeMsg as ProposeMsg, +}; + +pub use dao_voting::approval::ApprovalExecuteExt as ExecuteExt; + +#[cw_serde] +pub enum ProposeMessage { + /// The propose message used to make a proposal to this + /// module. Note that this is identical to the propose message + /// used by dao-proposal-multiple, except that it omits the + /// `proposer` field which it fills in for the sender. + Propose { + title: String, + description: String, + choices: MultipleChoiceOptions, + vote: Option, + }, +} + +#[cw_serde] +pub struct InstantiateExt { + pub approver: String, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryExt { + /// List the approver address + #[returns(cosmwasm_std::Addr)] + Approver {}, + /// Return whether or not the proposal is pending + #[returns(bool)] + IsPending { id: u64 }, + /// A proposal, pending or completed. + #[returns(crate::state::Proposal)] + Proposal { id: u64 }, + /// A pending proposal + #[returns(crate::state::Proposal)] + PendingProposal { id: u64 }, + /// List of proposals awaiting approval + #[returns(Vec)] + PendingProposals { + start_after: Option, + limit: Option, + }, + #[returns(Vec)] + ReversePendingProposals { + start_before: Option, + limit: Option, + }, + /// A completed proposal + #[returns(crate::state::Proposal)] + CompletedProposal { id: u64 }, + /// List of completed proposals + #[returns(Vec)] + CompletedProposals { + start_after: Option, + limit: Option, + }, + #[returns(Vec)] + ReverseCompletedProposals { + start_before: Option, + limit: Option, + }, + /// The completed approval ID for a created proposal ID. + #[returns(::std::option::Option)] + CompletedProposalIdForCreatedProposalId { id: u64 }, +} + +pub type InstantiateMsg = InstantiateBase; +pub type ExecuteMsg = ExecuteBase; +pub type QueryMsg = QueryBase; +pub type MigrateMsg = MigrateBase; + +/// Internal version of the propose message that includes the +/// `proposer` field. The module will fill this in based on the sender +/// of the external message. +#[cw_serde] +pub(crate) enum ProposeMessageInternal { + Propose(ProposeMsg), +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/state.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/state.rs new file mode 100644 index 000000000..2a87b07db --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/state.rs @@ -0,0 +1,21 @@ +use cosmwasm_std::{Addr, StdResult, Storage}; +use cw_storage_plus::{Item, Map}; + +use dao_voting::{approval::ApprovalProposal, proposal::MultipleChoiceProposeMsg}; + +pub type Proposal = ApprovalProposal; + +pub const APPROVER: Item = Item::new("approver"); +pub const PENDING_PROPOSALS: Map = Map::new("pending_proposals"); +pub const COMPLETED_PROPOSALS: Map = Map::new("completed_proposals"); +pub const CREATED_PROPOSAL_TO_COMPLETED_PROPOSAL: Map = + Map::new("created_to_completed_proposal"); + +/// Used internally to track the current approval_id. +const CURRENT_ID: Item = Item::new("current_id"); + +pub(crate) fn advance_approval_id(store: &mut dyn Storage) -> StdResult { + let id: u64 = CURRENT_ID.may_load(store)?.unwrap_or_default() + 1; + CURRENT_ID.save(store, &id)?; + Ok(id) +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs new file mode 100644 index 000000000..308f781b1 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs @@ -0,0 +1,2640 @@ +use cosmwasm_std::{coins, from_json, to_json_binary, Addr, Coin, Empty, Uint128}; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; +use cw_utils::Duration; +use dao_interface::proposal::InfoResponse; +use dao_interface::state::ProposalModule; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; +use dao_proposal_multiple::query::ProposalResponse; +use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::multiple_choice::{ + MultipleChoiceOption, MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy, +}; +use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; +use dao_voting::{ + approval::ApprovalProposalStatus, + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + status::Status, + threshold::PercentageThreshold, +}; + +use crate::state::Proposal; +use crate::{contract::*, msg::*}; + +fn dao_proposal_multiple_contract() -> Box> { + let contract = ContractWrapper::new( + dao_proposal_multiple::contract::execute, + dao_proposal_multiple::contract::instantiate, + dao_proposal_multiple::contract::query, + ) + .with_migrate(dao_proposal_multiple::contract::migrate) + .with_reply(dao_proposal_multiple::contract::reply); + Box::new(contract) +} + +fn dao_pre_propose_approval_multiple_contract() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query).with_migrate(migrate); + Box::new(contract) +} + +fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn get_default_proposal_module_instantiate( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> dao_proposal_multiple::msg::InstantiateMsg { + let pre_propose_id = app.store_code(dao_pre_propose_approval_multiple_contract()); + + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: vec![] } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + } + }; + + dao_proposal_multiple::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_json_binary(&InstantiateMsg { + deposit_info, + submission_policy, + extension: InstantiateExt { + approver: "approver".to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + veto: None, + } +} + +fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +struct DefaultTestSetup { + core_addr: Addr, + proposal_multiple: Addr, + pre_propose: Addr, +} + +fn setup_default_test( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> DefaultTestSetup { + let dao_proposal_multiple_id = app.store_code(dao_proposal_multiple_contract()); + + let proposal_module_instantiate = + get_default_proposal_module_instantiate(app, deposit_info, open_proposal_submission); + + let core_addr = instantiate_with_cw4_groups_governance( + app, + dao_proposal_multiple_id, + to_json_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_multiple = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_multiple.clone(), + &dao_proposal_multiple::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + + // Make sure things were set up correctly. + assert_eq!( + proposal_multiple, + get_proposal_module(app, pre_propose.clone()) + ); + assert_eq!(core_addr, get_dao(app, pre_propose.clone())); + assert_eq!( + InfoResponse { + info: ContractVersion { + contract: "crates.io:dao-pre-propose-approval-multiple".to_string(), + version: env!("CARGO_PKG_VERSION").to_string() + } + }, + get_info(app, pre_propose.clone()) + ); + + DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + } +} + +fn make_pre_proposal(app: &mut App, pre_propose: Addr, proposer: &str, funds: &[Coin]) -> u64 { + app.execute_contract( + Addr::unchecked(proposer), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + funds, + ) + .unwrap(); + + // Query for pending proposal and return latest id. + let mut pending: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + + // Return last item in ascending list, id is first element of tuple + pending.pop().unwrap().approval_id +} + +fn mint_natives(app: &mut App, receiver: &str, coins: Vec) { + // Mint some ekez tokens for ekez so we can pay the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount: coins, + })) + .unwrap(); +} + +fn increase_allowance(app: &mut App, sender: &str, receiver: &Addr, cw20: Addr, amount: Uint128) { + app.execute_contract( + Addr::unchecked(sender), + cw20, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: receiver.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +fn vote(app: &mut App, module: Addr, sender: &str, id: u64, option_id: u32) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &dao_proposal_multiple::msg::ExecuteMsg::Vote { + proposal_id: id, + vote: MultipleChoiceVote { option_id }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart( + module, + &dao_proposal_multiple::msg::QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(); + + proposal.proposal.status +} + +fn get_config(app: &App, module: Addr) -> Config { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Config {}) + .unwrap() +} + +fn get_dao(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Dao {}) + .unwrap() +} + +fn get_info(app: &App, module: Addr) -> InfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Info {}) + .unwrap() +} + +fn get_proposal_module(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalModule {}) + .unwrap() +} + +fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::DepositInfo { proposal_id: id }) + .unwrap() +} + +fn query_can_propose(app: &App, module: Addr, address: impl Into) -> bool { + app.wrap() + .query_wasm_smart( + module, + &QueryMsg::CanPropose { + address: address.into(), + }, + ) + .unwrap() +} + +fn update_config( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + submission_policy: PreProposeSubmissionPolicy, +) -> Config { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info, + submission_policy: Some(submission_policy), + }, + &[], + ) + .unwrap(); + + get_config(app, module) +} + +fn update_config_should_fail( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + submission_policy: PreProposeSubmissionPolicy, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::UpdateConfig { + deposit_info, + submission_policy: Some(submission_policy), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn withdraw(app: &mut App, module: Addr, sender: &str, denom: Option) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap(); +} + +fn withdraw_should_fail( + app: &mut App, + module: Addr, + sender: &str, + denom: Option, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn close_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &dao_proposal_multiple::msg::ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +fn execute_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &dao_proposal_multiple::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +fn approve_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) -> u64 { + let res = app + .execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: proposal_id }, + }, + &[], + ) + .unwrap(); + + // Parse attrs from approve_proposal response + let attrs = res.custom_attrs(res.events.len() - 1); + // Return ID + attrs[attrs.len() - 2].value.parse().unwrap() +} + +fn reject_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: proposal_id }, + }, + &[], + ) + .unwrap(); +} + +enum ApprovalStatus { + Approved, + Rejected, +} + +enum EndStatus { + PassedA, + PassedB, + Failed, +} + +enum RefundReceiver { + Proposer, + Dao, +} + +fn test_native_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Make sure it went away. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Approver approves, new proposal id is returned + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::PassedA => (0, Status::Passed, execute_proposal), + EndStatus::PassedB => (1, Status::Passed, execute_proposal), + EndStatus::Failed => (2, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_multiple.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_multiple, "ekez", id); + } + ApprovalStatus::Rejected => { + // Proposal is rejected by approver + // No proposal is created so there is no voting + reject_proposal(&mut app, pre_propose, "approver", pre_propose_id); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_native(&app, "ekez", "ujuno"); + let dao_balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +fn test_cw20_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + let cw20_address = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Make sure it went await. + let balance = get_balance_cw20(&app, cw20_address.clone(), "ekez"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Approver approves, new proposal id is returned + let id = approve_proposal(&mut app, pre_propose.clone(), "approver", pre_propose_id); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::PassedA => (0, Status::Passed, execute_proposal), + EndStatus::PassedB => (1, Status::Passed, execute_proposal), + EndStatus::Failed => (2, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_multiple.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_multiple, "ekez", id); + } + ApprovalStatus::Rejected => { + // Proposal is rejected by approver + // No proposal is created so there is no voting + reject_proposal(&mut app, pre_propose.clone(), "approver", pre_propose_id); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_cw20(&app, &cw20_address, "ekez"); + let dao_balance = get_balance_cw20(&app, &cw20_address, core_addr); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +#[test] +fn test_native_failed_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_always_refund() { + test_native_permutation( + EndStatus::PassedA, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_always_refund() { + test_cw20_permutation( + EndStatus::PassedB, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_passed_never_refund() { + test_native_permutation( + EndStatus::PassedB, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_never_refund() { + test_cw20_permutation( + EndStatus::PassedA, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_passed_refund() { + test_native_permutation( + EndStatus::PassedA, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} +#[test] +fn test_cw20_passed_passed_refund() { + test_cw20_permutation( + EndStatus::PassedA, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +// See: +#[test] +fn test_multiple_open_proposals() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let first_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Approver approves prop, balance remains the same + let first_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + first_pre_propose_id, + ); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + let second_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Approver approves prop, balance remains the same + let second_id = approve_proposal(&mut app, pre_propose, "approver", second_pre_propose_id); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Finish up the first proposal. + let new_status = vote(&mut app, proposal_multiple.clone(), "ekez", first_id, 0); + assert_eq!(Status::Passed, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", first_id); + + // First proposal refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Finish up the second proposal. + let new_status = vote(&mut app, proposal_multiple.clone(), "ekez", second_id, 2); + assert_eq!(Status::Rejected, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + close_proposal(&mut app, proposal_multiple, "ekez", second_id); + + // All deposits have been refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(20, balance.u128()); +} + +#[test] +fn test_pending_proposal_queries() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Query for individual proposal + let prop1: Proposal = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposal { id: 1 }, + }, + ) + .unwrap(); + assert_eq!(prop1.approval_id, 1); + assert_eq!(prop1.status, ApprovalProposalStatus::Pending {}); + + let prop1: Proposal = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::Proposal { id: 1 }, + }, + ) + .unwrap(); + assert_eq!(prop1.approval_id, 1); + assert_eq!(prop1.status, ApprovalProposalStatus::Pending {}); + + // Query for the pre-propose proposals + let pre_propose_props: Vec = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + assert_eq!(pre_propose_props.len(), 2); + assert_eq!(pre_propose_props[0].approval_id, 1); + + // Query props in reverse + let reverse_pre_propose_props: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::ReversePendingProposals { + start_before: None, + limit: None, + }, + }, + ) + .unwrap(); + + assert_eq!(reverse_pre_propose_props.len(), 2); + assert_eq!(reverse_pre_propose_props[0].approval_id, 2); +} + +#[test] +fn test_completed_proposal_queries() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let approve_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let reject_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + let is_pending: bool = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::IsPending { id: approve_id }, + }, + ) + .unwrap(); + assert!(is_pending); + + let created_approved_id = + approve_proposal(&mut app, pre_propose.clone(), "approver", approve_id); + reject_proposal(&mut app, pre_propose.clone(), "approver", reject_id); + + let is_pending: bool = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::IsPending { id: approve_id }, + }, + ) + .unwrap(); + assert!(!is_pending); + + // Query for individual proposals + let prop1: Proposal = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::CompletedProposal { id: approve_id }, + }, + ) + .unwrap(); + assert_eq!( + prop1.status, + ApprovalProposalStatus::Approved { + created_proposal_id: created_approved_id + } + ); + let prop1: Proposal = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::Proposal { id: approve_id }, + }, + ) + .unwrap(); + assert_eq!( + prop1.status, + ApprovalProposalStatus::Approved { + created_proposal_id: created_approved_id + } + ); + + let prop1_id: Option = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::CompletedProposalIdForCreatedProposalId { + id: created_approved_id, + }, + }, + ) + .unwrap(); + assert_eq!(prop1_id, Some(approve_id)); + + let prop2: Proposal = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::CompletedProposal { id: reject_id }, + }, + ) + .unwrap(); + assert_eq!(prop2.status, ApprovalProposalStatus::Rejected {}); + + // Query for the pre-propose proposals + let pre_propose_props: Vec = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::CompletedProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + assert_eq!(pre_propose_props.len(), 2); + assert_eq!(pre_propose_props[0].approval_id, approve_id); + assert_eq!(pre_propose_props[1].approval_id, reject_id); + + // Query props in reverse + let reverse_pre_propose_props: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::ReverseCompletedProposals { + start_before: None, + limit: None, + }, + }, + ) + .unwrap(); + + assert_eq!(reverse_pre_propose_props.len(), 2); + assert_eq!(reverse_pre_propose_props[0].approval_id, reject_id); + assert_eq!(reverse_pre_propose_props[1].approval_id, approve_id); +} + +#[test] +fn test_set_version() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let info: ContractVersion = from_json( + app.wrap() + .query_wasm_raw(pre_propose, "contract_info".as_bytes()) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + }, + info + ) +} + +#[test] +fn test_permissions() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_multiple: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, // no open proposal submission. + ); + + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Closed, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotModule {}); + + // Non-members may not propose when open_propose_submission is + // disabled. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); +} + +#[test] +fn test_approval_and_rejection_permissions() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Only approver can approve + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonapprover"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Only approver can reject + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonapprover"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Updating approver after proposal created does not change old proposal's + // approver + app.execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::UpdateApprover { + address: "newapprover".to_string(), + }, + }, + &[], + ) + .unwrap(); + + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("newapprover"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Old approver can still approve. + app.execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap(); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Old approver cannot approve nor reject. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // New approver can now approve. + app.execute_contract( + Addr::unchecked("newapprover"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap(); +} + +#[test] +fn test_propose_open_proposal_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_multiple, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + // Member votes. + let new_status = vote(&mut app, proposal_multiple, "ekez", id, 0); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_open_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_multiple, + pre_propose, + } = setup_default_test( + &mut app, None, true, // yes, open proposal submission. + ); + + // Non-member proposes. + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "nonmember", &[]); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + // Member votes. + let new_status = vote(&mut app, proposal_multiple, "ekez", id, 0); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_members_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_multiple, + pre_propose, + } = setup_default_test( + &mut app, None, false, // no open proposal submission. + ); + + // Non-member proposes and this fails. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + let new_status = vote(&mut app, proposal_multiple, "ekez", id, 0); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_anyone_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + ); + + let rando = "rando"; + + // Proposal succeeds when anyone can propose. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_pre_proposal(&mut app, pre_propose.clone(), rando, &[]); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { + denylist: vec![Addr::unchecked(rando)], + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposing succeeds if not on denylist. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_pre_proposal(&mut app, pre_propose, "ekez", &[]); +} + +#[test] +fn test_specific_allowlist_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + ); + + // Proposal succeeds for member. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + let rando = "rando"; + + // Proposing fails for non-member. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![Addr::unchecked(rando)], + denylist: vec![], + }, + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_pre_proposal(&mut app, pre_propose.clone(), rando, &[]); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![Addr::unchecked(rando)], + denylist: vec![Addr::unchecked("ekez")], + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![Addr::unchecked(rando)], + denylist: vec![], + }, + ); + + // Proposing fails if members not allowed. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_pre_proposal(&mut app, pre_propose.clone(), rando, &[]); +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_native_deposit() { + let mut app = App::default(); + + let dao_proposal_multiple_id = app.store_code(dao_proposal_multiple_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(dao_pre_propose_approval_multiple_contract()); + + dao_proposal_multiple::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_json_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + extension: InstantiateExt { + approver: "approver".to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + veto: None, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + dao_proposal_multiple_id, + to_json_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_cw20_deposit() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20_base_default(&mut app); + + let dao_proposal_multiple_id = app.store_code(dao_proposal_multiple_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(dao_pre_propose_approval_multiple_contract()); + + dao_proposal_multiple::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_json_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_addr.into_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + extension: InstantiateExt { + approver: "approver".to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + veto: None, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + dao_proposal_multiple_id, + to_json_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![] + } + } + ); + + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose.clone(), "approver", pre_propose_id); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never, + }), + PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + ); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + } + ); + + // Old proposal should still have same deposit info. + let info = get_deposit_info(&app, pre_propose.clone(), id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: None, + proposer: Addr::unchecked("ekez"), + } + ); + + // New proposals should have the new deposit info. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let new_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver approves + let new_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + new_pre_propose_id, + ); + + let info = get_deposit_info(&app, pre_propose.clone(), new_id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + proposer: Addr::unchecked("ekez"), + } + ); + + // Both proposals should be allowed to complete. + vote(&mut app, proposal_multiple.clone(), "ekez", id, 0); + vote(&mut app, proposal_multiple.clone(), "ekez", new_id, 0); + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", id); + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", new_id); + // Deposit should not have been refunded (never policy in use). + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::new(0)); + + // Only the core module can update the config. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + proposal_multiple.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + ); + assert_eq!(err, PreProposeError::NotDao {}); + + // Errors when no one is authorized to create proposals. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![], + denylist: vec![], + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Errors when allowlist and denylist overlap. + let err = update_config_should_fail( + &mut app, + pre_propose, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![Addr::unchecked("ekez")], + denylist: vec![Addr::unchecked("ekez")], + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); +} + +#[test] +fn test_update_submission_policy() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, true); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + } + ); + + // Only the core module can update the submission policy. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotDao {}); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: vec![Addr::unchecked("ekez")], + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: vec![Addr::unchecked("else"), Addr::unchecked("someone")], + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + } + ); + + // Error if try to change Specific fields when set to Anyone. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(true), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + + // Change to Specific policy. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info: None, + submission_policy: Some(PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + } + ); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![Addr::unchecked("ekez")], + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![Addr::unchecked("else"), Addr::unchecked("someone")], + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![] + }, + } + ); + + // Append to allowlist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![Addr::unchecked("ekez")], + denylist: vec![], + }, + } + ); + + // Add and remove to/from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["someone".to_string(), "else".to_string()]), + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![Addr::unchecked("else"), Addr::unchecked("someone")], + denylist: vec![], + }, + } + ); + + // Remove from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![] + }, + } + ); + + // Setting dao_members to false fails if allowlist is empty. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Set dao_members to false and add allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![Addr::unchecked("ekez")], + denylist: vec![] + }, + } + ); + + // Errors when allowlist and denylist overlap. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + proposal_multiple.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NotDao {}); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NothingToWithdraw {}); + + let err = withdraw_should_fail(&mut app, pre_propose.clone(), core_addr.as_str(), None); + assert_eq!(err, PreProposeError::NoWithdrawalDenom {}); + + // Turn on native deposits. + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + ); + + // Withdraw with no specified denom - should fall back to the one + // in the config. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(10)); + + // Withdraw again, this time specifying a native denomination. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(20)); + + // Make a proposal with the native tokens to put some in the system. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let native_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver approves + let native_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + native_pre_propose_id, + ); + + // Update the config to use a cw20 token. + let cw20_address = instantiate_cw20_base_default(&mut app); + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let cw20_pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver approves + let cw20_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + cw20_pre_propose_id, + ); + + // There is now a pending proposal and cw20 tokens in the + // pre-propose module that should be returned on that proposal's + // completion. To make things interesting, we withdraw those + // tokens which should cause the status change hook on the + // proposal's execution to fail as we don't have sufficent balance + // to return the deposit. + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_cw20(&app, &cw20_address, core_addr.as_str()); + assert_eq!(balance, Uint128::new(10)); + + // Proposal should still be executable! We just get removed from + // the proposal module's hook receiver list. + vote(&mut app, proposal_multiple.clone(), "ekez", cw20_id, 1); + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", cw20_id); + + // Make sure the proposal module has fallen back to anyone can + // propose becuase of our malfunction. + let proposal_creation_policy: ProposalCreationPolicy = app + .wrap() + .query_wasm_smart( + proposal_multiple.clone(), + &dao_proposal_multiple::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + assert_eq!(proposal_creation_policy, ProposalCreationPolicy::Anyone {}); + + // Close out the native proposal and it's deposit as well. + vote(&mut app, proposal_multiple.clone(), "ekez", native_id, 2); + close_proposal(&mut app, proposal_multiple.clone(), "ekez", native_id); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(30)); +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/Cargo.toml b/contracts/pre-propose/dao-pre-propose-approval-single/Cargo.toml index 0b44a0621..2d0b6b902 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/Cargo.toml +++ b/contracts/pre-propose/dao-pre-propose-approval-single/Cargo.toml @@ -1,6 +1,9 @@ [package] name = "dao-pre-propose-approval-single" -authors = ["ekez ", "Jake Hartnell "] +authors = [ + "ekez ", + "Jake Hartnell ", +] description = "A DAO DAO pre-propose module handling a proposal approval flow for for dao-proposal-single." edition = { workspace = true } license = { workspace = true } @@ -21,6 +24,7 @@ cosmwasm-std = { workspace = true } cosmwasm-schema = { workspace = true } cw-storage-plus = { workspace = true } cw2 = { workspace = true } +cw-denom = { workspace = true } cw-paginate-storage = { workspace = true } dao-pre-propose-base = { workspace = true } dao-voting = { workspace = true } @@ -28,7 +32,6 @@ thiserror = { workspace = true } dao-interface = { workspace = true } [dev-dependencies] -cw-denom = { workspace = true } cw-multi-test = { workspace = true } cw-utils = { workspace = true } cw4 = { workspace = true } @@ -47,6 +50,6 @@ dao-proposal-single = { workspace = true } dao-dao-core-v241 = { workspace = true } dao-interface-v241 = { workspace = true } dao-pre-propose-approval-single-v241 = { workspace = true } -dao-proposal-single-v241 = { workspace = true } +dao-proposal-single-v241 = { workspace = true } dao-voting-cw4-v241 = { workspace = true } dao-voting-v241 = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/README.md b/contracts/pre-propose/dao-pre-propose-approval-single/README.md index 62cc82e44..5a0850aaa 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/README.md +++ b/contracts/pre-propose/dao-pre-propose-approval-single/README.md @@ -3,11 +3,13 @@ [![dao-pre-propose-approval-single on crates.io](https://img.shields.io/crates/v/dao-pre-propose-approval-single.svg?logo=rust)](https://crates.io/crates/dao-pre-propose-approval-single) [![docs.rs](https://img.shields.io/docsrs/dao-pre-propose-approval-single?logo=docsdotrs)](https://docs.rs/dao-pre-propose-approval-single/latest/dao_pre_propose_approval_single/) -This contract implements an approval flow for proposals, it also handles deposit logic. It works with the `cwd-proposal-single` proposal module. +This contract implements an approval flow for proposals, it also handles deposit logic. It works with the `dao-proposal-single` proposal module. ## Approval Logic -This contract is instantatied with an `approver` address. This address is allowed to approve or reject the proposal. +This contract is instantatied with an `approver` address. This address is +allowed to approve or reject the proposal. An approved proposal opens for voting +immediately, whereas a rejected proposal is simply discarded. ```text ┌──────────┐ @@ -43,7 +45,7 @@ This contract is instantatied with an `approver` address. This address is allowe └────────────────────────┘ ``` -The `approver` may also register a `ProposalSubmitHook`, which fires every time a proposal is submitted to the `cwd-pre-propose-approval-single` contract. +The `approver` may also register a `ProposalSubmitHook`, which fires every time a proposal is submitted to the `dao-pre-propose-approval-single` contract. ## Deposit Logic diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json b/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json index 2c30b26e8..ef0e213b4 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json +++ b/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json @@ -449,7 +449,7 @@ ], "properties": { "msg": { - "$ref": "#/definitions/ExecuteExt" + "$ref": "#/definitions/ApprovalExecuteExt" } }, "additionalProperties": false @@ -535,6 +535,80 @@ "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", "type": "string" }, + "ApprovalExecuteExt": { + "oneOf": [ + { + "description": "Approve a proposal, only callable by approver", + "type": "object", + "required": [ + "approve" + ], + "properties": { + "approve": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a proposal, only callable by approver", + "type": "object", + "required": [ + "reject" + ], + "properties": { + "reject": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the approver, can only be called the current approver", + "type": "object", + "required": [ + "update_approver" + ], + "properties": { + "update_approver": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "BankMsg": { "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", "oneOf": [ @@ -855,80 +929,6 @@ "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" }, - "ExecuteExt": { - "oneOf": [ - { - "description": "Approve a proposal, only callable by approver", - "type": "object", - "required": [ - "approve" - ], - "properties": { - "approve": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - { - "description": "Reject a proposal, only callable by approver", - "type": "object", - "required": [ - "reject" - ], - "properties": { - "reject": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - { - "description": "Updates the approver, can only be called the current approver", - "type": "object", - "required": [ - "update_approver" - ], - "properties": { - "update_approver": { - "type": "object", - "required": [ - "address" - ], - "properties": { - "address": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - ] - }, "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)", "oneOf": [ @@ -1269,6 +1269,7 @@ "ProposeMessage": { "oneOf": [ { + "description": "The propose message used to make a proposal to this module. Note that this is identical to the propose message used by dao-proposal-single, except that it omits the `proposer` field which it fills in for the sender.", "type": "object", "required": [ "propose" diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs index 091e91e2e..4e9d47230 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs @@ -1,23 +1,28 @@ +use cosmwasm_schema::cw_serde; #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - to_json_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Order, Response, StdResult, - SubMsg, WasmMsg, + to_json_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Order, + Response, StdError, StdResult, SubMsg, Uint128, WasmMsg, }; use cw2::set_contract_version; +use cw_denom::CheckedDenom; use cw_paginate_storage::paginate_map_values; +use cw_storage_plus::Map; use dao_pre_propose_base::{ error::PreProposeError, msg::ExecuteMsg as ExecuteBase, state::PreProposeContract, }; -use dao_voting::deposit::DepositRefundPolicy; +use dao_voting::approval::{ApprovalProposalStatus, ApproverProposeMessage}; +use dao_voting::deposit::{CheckedDepositInfo, DepositRefundPolicy}; use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; +use dao_voting::voting::{SingleChoiceAutoVote, Vote}; use crate::msg::{ - ApproverProposeMessage, ExecuteExt, ExecuteMsg, InstantiateExt, InstantiateMsg, MigrateMsg, - ProposeMessage, ProposeMessageInternal, QueryExt, QueryMsg, + ExecuteExt, ExecuteMsg, InstantiateExt, InstantiateMsg, MigrateMsg, ProposeMessage, + ProposeMessageInternal, QueryExt, QueryMsg, }; use crate::state::{ - advance_approval_id, Proposal, ProposalStatus, APPROVER, COMPLETED_PROPOSALS, + advance_approval_id, Proposal, APPROVER, COMPLETED_PROPOSALS, CREATED_PROPOSAL_TO_COMPLETED_PROPOSAL, PENDING_PROPOSALS, }; @@ -124,13 +129,16 @@ pub fn execute_propose( Ok(SubMsg::new(execute_msg)) })?; + let approver = APPROVER.load(deps.storage)?; + // Save the proposal and its information as pending. PENDING_PROPOSALS.save( deps.storage, approval_id, &Proposal { - status: ProposalStatus::Pending {}, + status: ApprovalProposalStatus::Pending {}, approval_id, + approver: approver.clone(), proposer: info.sender, msg: propose_msg_internal, deposit: config.deposit_info, @@ -141,7 +149,8 @@ pub fn execute_propose( .add_messages(deposit_messages) .add_submessages(hooks_msgs) .add_attribute("method", "pre-propose") - .add_attribute("id", approval_id.to_string())) + .add_attribute("id", approval_id.to_string()) + .add_attribute("approver", approver.to_string())) } pub fn execute_approve( @@ -149,16 +158,15 @@ pub fn execute_approve( info: MessageInfo, id: u64, ) -> Result { - // Check sender is the approver - let approver = APPROVER.load(deps.storage)?; - if approver != info.sender { - return Err(PreProposeError::Unauthorized {}); - } - // Load proposal and send propose message to the proposal module let proposal = PENDING_PROPOSALS.may_load(deps.storage, id)?; match proposal { Some(proposal) => { + // Check sender is the approver + if proposal.approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + let proposal_module = PrePropose::default().proposal_module.load(deps.storage)?; // Snapshot the deposit for the proposal that we're about @@ -183,10 +191,11 @@ pub fn execute_approve( deps.storage, id, &Proposal { - status: ProposalStatus::Approved { + status: ApprovalProposalStatus::Approved { created_proposal_id: proposal_id, }, approval_id: proposal.approval_id, + approver: proposal.approver, proposer: proposal.proposer, msg: proposal.msg, deposit: proposal.deposit, @@ -210,14 +219,9 @@ pub fn execute_reject( info: MessageInfo, id: u64, ) -> Result { - // Check sender is the approver - let approver = APPROVER.load(deps.storage)?; - if approver != info.sender { - return Err(PreProposeError::Unauthorized {}); - } - let Proposal { approval_id, + approver, proposer, msg, deposit, @@ -226,12 +230,18 @@ pub fn execute_reject( .may_load(deps.storage, id)? .ok_or(PreProposeError::ProposalNotFound {})?; + // Check sender is the approver + if approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + COMPLETED_PROPOSALS.save( deps.storage, id, &Proposal { - status: ProposalStatus::Rejected {}, + status: ApprovalProposalStatus::Rejected {}, approval_id, + approver, proposer: proposer.clone(), msg: msg.clone(), deposit: deposit.clone(), @@ -406,7 +416,234 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { #[cfg_attr(not(feature = "library"), entry_point)] pub fn migrate(mut deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { - let res = PrePropose::default().migrate(deps.branch(), msg); + let res: Result = + PrePropose::default().migrate(deps.branch(), msg.clone()); + match msg { + MigrateMsg::FromUnderV250 { .. } => { + // the default migrate function above ensures >= v2.4.1 and < v2.5.0 + + #[cw_serde] + struct ProposalV241 { + /// The status of a completed proposal. + pub status: ProposalStatusV241, + /// The approval ID used to identify this pending proposal. + pub approval_id: u64, + /// The address that created the proposal. + pub proposer: Addr, + /// The propose message that ought to be executed on the + /// proposal message if this proposal is approved. + pub msg: SingleChoiceProposeMsgV241, + /// Snapshot of the deposit info at the time of proposal + /// submission. + pub deposit: Option, + } + + #[cw_serde] + enum ProposalStatusV241 { + /// The proposal is pending approval. + Pending {}, + /// The proposal has been approved. + Approved { + /// The created proposal ID. + created_proposal_id: u64, + }, + /// The proposal has been rejected. + Rejected {}, + } + + #[cw_serde] + struct SingleChoiceProposeMsgV241 { + /// The title of the proposal. + pub title: String, + /// A description of the proposal. + pub description: String, + /// The messages that should be executed in response to this + /// proposal passing. + pub msgs: Vec>, + /// The address creating the proposal. If no pre-propose + /// module is attached to this module this must always be None + /// as the proposer is the sender of the propose message. If a + /// pre-propose module is attached, this must be Some and will + /// set the proposer of the proposal it creates. + pub proposer: Option, + /// An optional vote cast by the proposer. + pub vote: Option, + } + + #[cw_serde] + #[derive(Copy)] + #[repr(u8)] + enum VoteV241 { + /// Marks support for the proposal. + Yes, + /// Marks opposition to the proposal. + No, + /// Marks participation but does not count towards the ratio of + /// support / opposed. + Abstain, + } + + #[cw_serde] + struct SingleChoiceAutoVoteV241 { + /// The proposer's position on the proposal. + pub vote: VoteV241, + /// An optional rationale for why this vote was cast. This can + /// be updated, set, or removed later by the address casting + /// the vote. + pub rationale: Option, + } + + #[cw_serde] + enum DepositRefundPolicyV241 { + /// Deposits should always be refunded. + Always, + /// Deposits should only be refunded for passed proposals. + OnlyPassed, + /// Deposits should never be refunded. + Never, + } + + /// Counterpart to the `DepositInfo` struct which has been + /// processed. This type should never be constructed literally and + /// should always by built by calling `into_checked` on a + /// `DepositInfo` instance. + #[cw_serde] + struct CheckedDepositInfoV241 { + /// The address of the cw20 token to be used for proposal + /// deposits. + pub denom: CheckedDenomV241, + /// The number of tokens that must be deposited to create a + /// proposal. This is validated to be non-zero if this struct is + /// constructed by converted via the `into_checked` method on + /// `DepositInfo`. + pub amount: Uint128, + /// The policy used for refunding proposal deposits. + pub refund_policy: DepositRefundPolicyV241, + } + + #[cw_serde] + enum CheckedDenomV241 { + /// A native (bank module) asset. + Native(String), + /// A cw20 asset. + Cw20(Addr), + } + + let pending_proposals_v241: Map = Map::new("pending_proposals"); + let completed_proposals_v241: Map = Map::new("completed_proposals"); + + // migrate proposals to add approver + + let approver = APPROVER.load(deps.storage)?; + + let pending_proposals = pending_proposals_v241 + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + for (id, proposal) in pending_proposals { + PENDING_PROPOSALS.save( + deps.storage, + id, + &Proposal { + status: ApprovalProposalStatus::Pending {}, + approval_id: proposal.approval_id, + approver: approver.clone(), + proposer: proposal.proposer, + msg: ProposeMsg { + title: proposal.msg.title, + description: proposal.msg.description, + msgs: proposal.msg.msgs, + proposer: proposal.msg.proposer, + vote: proposal.msg.vote.map(|vote| SingleChoiceAutoVote { + vote: match vote.vote { + VoteV241::Yes => Vote::Yes, + VoteV241::No => Vote::No, + VoteV241::Abstain => Vote::Abstain, + }, + rationale: vote.rationale, + }), + }, + deposit: proposal.deposit.map(|deposit| CheckedDepositInfo { + denom: match deposit.denom { + CheckedDenomV241::Native(denom) => CheckedDenom::Native(denom), + CheckedDenomV241::Cw20(addr) => CheckedDenom::Cw20(addr), + }, + amount: deposit.amount, + refund_policy: match deposit.refund_policy { + DepositRefundPolicyV241::Always => DepositRefundPolicy::Always, + DepositRefundPolicyV241::OnlyPassed => { + DepositRefundPolicy::OnlyPassed + } + DepositRefundPolicyV241::Never => DepositRefundPolicy::Never, + }, + }), + }, + )?; + } + + let completed_proposals = completed_proposals_v241 + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + for (id, proposal) in completed_proposals { + COMPLETED_PROPOSALS.save( + deps.storage, + id, + &Proposal { + status: match proposal.status { + ProposalStatusV241::Approved { + created_proposal_id, + } => ApprovalProposalStatus::Approved { + created_proposal_id, + }, + ProposalStatusV241::Rejected {} => ApprovalProposalStatus::Rejected {}, + // should not be possible since these are completed + // proposals only + ProposalStatusV241::Pending {} => { + return Err(PreProposeError::Std(StdError::generic_err( + "unexpected proposal status", + ))) + } + }, + approval_id: proposal.approval_id, + approver: approver.clone(), + proposer: proposal.proposer, + msg: ProposeMsg { + title: proposal.msg.title, + description: proposal.msg.description, + msgs: proposal.msg.msgs, + proposer: proposal.msg.proposer, + vote: proposal.msg.vote.map(|vote| SingleChoiceAutoVote { + vote: match vote.vote { + VoteV241::Yes => Vote::Yes, + VoteV241::No => Vote::No, + VoteV241::Abstain => Vote::Abstain, + }, + rationale: vote.rationale, + }), + }, + deposit: proposal.deposit.map(|deposit| CheckedDepositInfo { + denom: match deposit.denom { + CheckedDenomV241::Native(denom) => CheckedDenom::Native(denom), + CheckedDenomV241::Cw20(addr) => CheckedDenom::Cw20(addr), + }, + amount: deposit.amount, + refund_policy: match deposit.refund_policy { + DepositRefundPolicyV241::Always => DepositRefundPolicy::Always, + DepositRefundPolicyV241::OnlyPassed => { + DepositRefundPolicy::OnlyPassed + } + DepositRefundPolicyV241::Never => DepositRefundPolicy::Never, + }, + }), + }, + )?; + } + } + _ => { + return Err(PreProposeError::Std(StdError::generic_err( + "not implemented", + ))) + } + } set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; res } diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs index 381606d1d..f8239ddde 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs @@ -6,17 +6,14 @@ use dao_pre_propose_base::msg::{ }; use dao_voting::{proposal::SingleChoiceProposeMsg as ProposeMsg, voting::SingleChoiceAutoVote}; -#[cw_serde] -pub enum ApproverProposeMessage { - Propose { - title: String, - description: String, - approval_id: u64, - }, -} +pub use dao_voting::approval::ApprovalExecuteExt as ExecuteExt; #[cw_serde] pub enum ProposeMessage { + /// The propose message used to make a proposal to this + /// module. Note that this is identical to the propose message + /// used by dao-proposal-single, except that it omits the + /// `proposer` field which it fills in for the sender. Propose { title: String, description: String, @@ -30,16 +27,6 @@ pub struct InstantiateExt { pub approver: String, } -#[cw_serde] -pub enum ExecuteExt { - /// Approve a proposal, only callable by approver - Approve { id: u64 }, - /// Reject a proposal, only callable by approver - Reject { id: u64 }, - /// Updates the approver, can only be called the current approver - UpdateApprover { address: String }, -} - #[cw_serde] #[derive(QueryResponses)] pub enum QueryExt { diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs index 5ceb766dc..e277c61f8 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs @@ -1,38 +1,9 @@ -use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, StdResult, Storage}; use cw_storage_plus::{Item, Map}; -use dao_voting::deposit::CheckedDepositInfo; -use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; +use dao_voting::{approval::ApprovalProposal, proposal::SingleChoiceProposeMsg}; -#[cw_serde] -pub enum ProposalStatus { - /// The proposal is pending approval. - Pending {}, - /// The proposal has been approved. - Approved { - /// The created proposal ID. - created_proposal_id: u64, - }, - /// The proposal has been rejected. - Rejected {}, -} - -#[cw_serde] -pub struct Proposal { - /// The status of a completed proposal. - pub status: ProposalStatus, - /// The approval ID used to identify this pending proposal. - pub approval_id: u64, - /// The address that created the proposal. - pub proposer: Addr, - /// The propose message that ought to be executed on the proposal - /// message if this proposal is approved. - pub msg: ProposeMsg, - /// Snapshot of the deposit info at the time of proposal - /// submission. - pub deposit: Option, -} +pub type Proposal = ApprovalProposal; pub const APPROVER: Item = Item::new("approver"); pub const PENDING_PROPOSALS: Map = Map::new("pending_proposals"); diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs index fe4cacd04..1df4ac65a 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs @@ -22,6 +22,7 @@ use dao_testing::{ }; use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; use dao_voting::{ + approval::ApprovalProposalStatus, deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, pre_propose::{PreProposeInfo, ProposalCreationPolicy}, status::Status, @@ -36,7 +37,7 @@ use dao_proposal_single_v241 as dps_v241; use dao_voting_cw4_v241 as dvcw4_v241; use dao_voting_v241 as dv_v241; -use crate::state::{Proposal, ProposalStatus}; +use crate::state::Proposal; use crate::{contract::*, msg::*}; fn get_default_proposal_module_instantiate( @@ -915,7 +916,7 @@ fn test_pending_proposal_queries() { ) .unwrap(); assert_eq!(prop1.approval_id, 1); - assert_eq!(prop1.status, ProposalStatus::Pending {}); + assert_eq!(prop1.status, ApprovalProposalStatus::Pending {}); let prop1: Proposal = app .wrap() @@ -927,7 +928,7 @@ fn test_pending_proposal_queries() { ) .unwrap(); assert_eq!(prop1.approval_id, 1); - assert_eq!(prop1.status, ProposalStatus::Pending {}); + assert_eq!(prop1.status, ApprovalProposalStatus::Pending {}); // Query for the pre-propose proposals let pre_propose_props: Vec = app @@ -1025,7 +1026,7 @@ fn test_completed_proposal_queries() { .unwrap(); assert_eq!( prop1.status, - ProposalStatus::Approved { + ApprovalProposalStatus::Approved { created_proposal_id: created_approved_id } ); @@ -1040,7 +1041,7 @@ fn test_completed_proposal_queries() { .unwrap(); assert_eq!( prop1.status, - ProposalStatus::Approved { + ApprovalProposalStatus::Approved { created_proposal_id: created_approved_id } ); @@ -1067,7 +1068,7 @@ fn test_completed_proposal_queries() { }, ) .unwrap(); - assert_eq!(prop2.status, ProposalStatus::Rejected {}); + assert_eq!(prop2.status, ApprovalProposalStatus::Rejected {}); // Query for the pre-propose proposals let pre_propose_props: Vec = app @@ -1229,10 +1230,10 @@ fn test_approval_and_rejection_permissions() { &coins(10, "ujuno"), ); - // Only approver can propose + // Only approver can approve let err: PreProposeError = app .execute_contract( - Addr::unchecked("nonmember"), + Addr::unchecked("nonapprover"), pre_propose.clone(), &ExecuteMsg::Extension { msg: ExecuteExt::Approve { id: pre_propose_id }, @@ -1244,11 +1245,11 @@ fn test_approval_and_rejection_permissions() { .unwrap(); assert_eq!(err, PreProposeError::Unauthorized {}); - // Only approver can propose + // Only approver can reject let err: PreProposeError = app .execute_contract( - Addr::unchecked("nonmember"), - pre_propose, + Addr::unchecked("nonapprover"), + pre_propose.clone(), &ExecuteMsg::Extension { msg: ExecuteExt::Reject { id: pre_propose_id }, }, @@ -1258,6 +1259,94 @@ fn test_approval_and_rejection_permissions() { .downcast() .unwrap(); assert_eq!(err, PreProposeError::Unauthorized {}); + + // Updating approver after proposal created does not change old proposal's + // approver + app.execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::UpdateApprover { + address: "newapprover".to_string(), + }, + }, + &[], + ) + .unwrap(); + + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("newapprover"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Old approver can still approve. + app.execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap(); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Old approver cannot approve nor reject. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("approver"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // New approver can now approve. + app.execute_contract( + Addr::unchecked("newapprover"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap(); } #[test] diff --git a/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml b/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml index 1f6e594fc..e152382ae 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml +++ b/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml @@ -1,6 +1,9 @@ [package] name = "dao-pre-propose-approver" -authors = ["ekez ", "Jake Hartnell "] +authors = [ + "ekez ", + "Jake Hartnell ", +] description = "A DAO DAO pre-propose module for automatically making approval proposals for dao-pre-propose-approval-single." edition = { workspace = true } license = { workspace = true } @@ -24,7 +27,6 @@ cw-utils = { workspace = true } cw2 = { workspace = true } dao-interface = { workspace = true } dao-pre-propose-base = { workspace = true } -dao-pre-propose-approval-single = { workspace = true, features = ["library"] } dao-voting = { workspace = true } [dev-dependencies] @@ -36,6 +38,9 @@ cw20 = { workspace = true } cw20-base = { workspace = true } dao-dao-core = { workspace = true } dao-hooks = { workspace = true } +dao-pre-propose-approval-multiple = { workspace = true, features = ["library"] } +dao-pre-propose-approval-single = { workspace = true, features = ["library"] } +dao-proposal-multiple = { workspace = true, features = ["library"] } dao-proposal-single = { workspace = true, features = ["library"] } dao-testing = { workspace = true } dao-voting = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs b/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs index 268d3bae8..4e4aaa102 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs @@ -7,10 +7,10 @@ use cosmwasm_std::{ use cw2::set_contract_version; use dao_interface::state::ModuleInstantiateCallback; -use dao_pre_propose_approval_single::msg::{ - ApproverProposeMessage, ExecuteExt as ApprovalExt, ExecuteMsg as PreProposeApprovalExecuteMsg, +use dao_pre_propose_base::{ + error::PreProposeError, msg::ExecuteMsg as PreProposeExecuteBase, state::PreProposeContract, }; -use dao_pre_propose_base::{error::PreProposeError, state::PreProposeContract}; +use dao_voting::approval::{ApprovalExecuteExt, ApproverProposeMessage}; use dao_voting::pre_propose::PreProposeSubmissionPolicy; use dao_voting::status::Status; @@ -26,6 +26,8 @@ pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-pre-propose-approver"; pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); type PrePropose = PreProposeContract; +type PreProposeApprovalExecuteMsg = + PreProposeExecuteBase; #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( @@ -35,8 +37,8 @@ pub fn instantiate( msg: InstantiateMsg, ) -> Result { // This contract does not handle deposits or allow submission permissions - // since only the approval-single contract can create proposals. Just - // hardcode the pre-propose-base instantiate message. + // since only the approval-* contract can create proposals. Just hardcode + // the pre-propose-base instantiate message. let base_instantiate_msg = BaseInstantiateMsg { deposit_info: None, submission_policy: PreProposeSubmissionPolicy::Specific { @@ -55,7 +57,7 @@ pub fn instantiate( )?; set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - // Validate and save the address of the pre-propose-approval-single contract + // Validate and save the address of the pre-propose-approval-* contract let addr = deps.api.addr_validate(&msg.pre_propose_approval_contract)?; PRE_PROPOSE_APPROVAL_CONTRACT.save(deps.storage, &addr)?; @@ -71,7 +73,7 @@ pub fn instantiate( CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: addr.to_string(), msg: to_json_binary(&PreProposeApprovalExecuteMsg::Extension { - msg: ApprovalExt::UpdateApprover { + msg: ApprovalExecuteExt::UpdateApprover { address: env.contract.address.to_string(), }, })?, @@ -173,14 +175,14 @@ pub fn execute_proposal_completed( Status::Closed => Some(WasmMsg::Execute { contract_addr: approval_contract.into_string(), msg: to_json_binary(&PreProposeApprovalExecuteMsg::Extension { - msg: ApprovalExt::Reject { id: pre_propose_id }, + msg: ApprovalExecuteExt::Reject { id: pre_propose_id }, })?, funds: vec![], }), Status::Executed => Some(WasmMsg::Execute { contract_addr: approval_contract.into_string(), msg: to_json_binary(&PreProposeApprovalExecuteMsg::Extension { - msg: ApprovalExt::Approve { id: pre_propose_id }, + msg: ApprovalExecuteExt::Approve { id: pre_propose_id }, })?, funds: vec![], }), @@ -223,7 +225,7 @@ pub fn execute_reset_approver( CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: pre_propose_approval_contract.to_string(), msg: to_json_binary(&PreProposeApprovalExecuteMsg::Extension { - msg: ApprovalExt::UpdateApprover { + msg: ApprovalExecuteExt::UpdateApprover { address: dao.to_string(), }, })?, diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs b/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs index 5482fad8c..3a12f0907 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs @@ -1,10 +1,10 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{CosmosMsg, Empty}; -use dao_pre_propose_approval_single::msg::ApproverProposeMessage; use dao_pre_propose_base::msg::{ ExecuteMsg as ExecuteBase, InstantiateMsg as InstantiateBase, MigrateMsg as MigrateBase, QueryMsg as QueryBase, }; +use dao_voting::approval::ApproverProposeMessage; #[cw_serde] pub struct InstantiateMsg { diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests/mod.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests/mod.rs new file mode 100644 index 000000000..4cb2b429d --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests/mod.rs @@ -0,0 +1,2 @@ +pub mod multiple; +pub mod single; diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs new file mode 100644 index 000000000..1cc637182 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs @@ -0,0 +1,1914 @@ +use cosmwasm_std::{coins, from_json, to_json_binary, Addr, Coin, Empty, Uint128}; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; +use dao_interface::proposal::InfoResponse; +use dao_proposal_multiple::query::{ProposalListResponse, ProposalResponse}; +use dao_voting::multiple_choice::{ + MultipleChoiceOption, MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy, +}; +use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; + +use dao_interface::state::ProposalModule; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_pre_propose_approval_multiple::{ + msg::{ + ExecuteExt, ExecuteMsg, InstantiateExt, InstantiateMsg, ProposeMessage, QueryExt, QueryMsg, + }, + state::Proposal, +}; +use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; +use dao_proposal_multiple as dpm; +use dao_proposal_single as dps; +use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::threshold::Threshold; +use dao_voting::{ + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + status::Status, + threshold::PercentageThreshold, + voting::Vote, +}; + +use crate::contract::{CONTRACT_NAME, CONTRACT_VERSION}; +use crate::msg::InstantiateMsg as ApproverInstantiateMsg; +use crate::msg::{ + ExecuteExt as ApproverExecuteExt, ExecuteMsg as ApproverExecuteMsg, + QueryExt as ApproverQueryExt, QueryMsg as ApproverQueryMsg, +}; + +// The approver dao contract is the 6th contract instantiated +const APPROVER: &str = "contract6"; + +fn dao_proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + dps::contract::execute, + dps::contract::instantiate, + dps::contract::query, + ) + .with_migrate(dps::contract::migrate) + .with_reply(dps::contract::reply); + Box::new(contract) +} + +fn dao_proposal_multiple_contract() -> Box> { + let contract = ContractWrapper::new( + dpm::contract::execute, + dpm::contract::instantiate, + dpm::contract::query, + ) + .with_migrate(dpm::contract::migrate) + .with_reply(dpm::contract::reply); + Box::new(contract) +} + +fn cw_pre_propose_base_proposal_multiple() -> Box> { + let contract = ContractWrapper::new( + dao_pre_propose_approval_multiple::contract::execute, + dao_pre_propose_approval_multiple::contract::instantiate, + dao_pre_propose_approval_multiple::contract::query, + ); + Box::new(contract) +} + +fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn pre_propose_approver_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn get_proposal_module_approval_multiple_instantiate( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> dpm::msg::InstantiateMsg { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_multiple()); + + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: vec![] } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + } + }; + + dpm::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_json_binary(&InstantiateMsg { + deposit_info, + submission_policy, + extension: InstantiateExt { + approver: APPROVER.to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "baby's first pre-propose module, needs supervision".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + veto: None, + } +} + +fn get_proposal_module_approver_instantiate( + app: &mut App, + _deposit_info: Option, + _open_proposal_submission: bool, + pre_propose_approval_contract: String, +) -> dps::msg::InstantiateMsg { + let pre_propose_id = app.store_code(pre_propose_approver_contract()); + + dps::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_json_binary(&ApproverInstantiateMsg { + pre_propose_approval_contract, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "approver module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + veto: None, + } +} + +fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +struct DefaultTestSetup { + core_addr: Addr, + proposal_multiple: Addr, + pre_propose: Addr, + approver_core_addr: Addr, + pre_propose_approver: Addr, + proposal_single_approver: Addr, +} + +fn setup_default_test( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> DefaultTestSetup { + let dps_id = app.store_code(dao_proposal_single_contract()); + let dpm_id = app.store_code(dao_proposal_multiple_contract()); + + // Instantiate SubDAO with pre-propose-approval-multiple + let proposal_module_instantiate = get_proposal_module_approval_multiple_instantiate( + app, + deposit_info.clone(), + open_proposal_submission, + ); + let core_addr = instantiate_with_cw4_groups_governance( + app, + dpm_id, + to_json_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + // Make sure things were set up correctly. + assert_eq!(proposal_modules.len(), 1); + let proposal_multiple = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_multiple.clone(), + &dpm::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + assert_eq!( + proposal_multiple, + get_proposal_module(app, pre_propose.clone()) + ); + assert_eq!(core_addr, get_dao(app, pre_propose.clone())); + + // Instantiate SubDAO with pre-propose-approver + let proposal_module_instantiate = get_proposal_module_approver_instantiate( + app, + deposit_info, + open_proposal_submission, + pre_propose.to_string(), + ); + + let approver_core_addr = instantiate_with_cw4_groups_governance( + app, + dps_id, + to_json_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + approver_core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + // Make sure things were set up correctly. + assert_eq!(proposal_modules.len(), 1); + let proposal_single_approver = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_single_approver.clone(), + &dps::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + let pre_propose_approver = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + assert_eq!( + proposal_single_approver, + get_proposal_module(app, pre_propose_approver.clone()) + ); + assert_eq!( + approver_core_addr, + get_dao(app, pre_propose_approver.clone()) + ); + assert_eq!( + InfoResponse { + info: ContractVersion { + contract: "crates.io:dao-pre-propose-approver".to_string(), + version: env!("CARGO_PKG_VERSION").to_string() + } + }, + get_info(app, pre_propose_approver.clone()) + ); + + DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + approver_core_addr, + proposal_single_approver, + pre_propose_approver, + } +} + +fn make_pre_proposal(app: &mut App, pre_propose: Addr, proposer: &str, funds: &[Coin]) -> u64 { + app.execute_contract( + Addr::unchecked(proposer), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + funds, + ) + .unwrap(); + + // Query for pending proposal and return latest id + let mut pending: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + + // Return last item in list, id is first element of tuple + pending.pop().unwrap().approval_id +} + +fn mint_natives(app: &mut App, receiver: &str, coins: Vec) { + // Mint some ekez tokens for ekez so we can pay the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount: coins, + })) + .unwrap(); +} + +fn increase_allowance(app: &mut App, sender: &str, receiver: &Addr, cw20: Addr, amount: Uint128) { + app.execute_contract( + Addr::unchecked(sender), + cw20, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: receiver.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +fn vote_single(app: &mut App, module: Addr, sender: &str, id: u64, position: Vote) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &dps::msg::ExecuteMsg::Vote { + proposal_id: id, + vote: position, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: dps::query::ProposalResponse = app + .wrap() + .query_wasm_smart(module, &dps::msg::QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + proposal.proposal.status +} + +fn vote_multiple(app: &mut App, module: Addr, sender: &str, id: u64, position: u32) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &dpm::msg::ExecuteMsg::Vote { + proposal_id: id, + vote: MultipleChoiceVote { + option_id: position, + }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(module, &dpm::msg::QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + proposal.proposal.status +} + +fn get_config(app: &App, module: Addr) -> Config { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Config {}) + .unwrap() +} + +fn get_dao(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Dao {}) + .unwrap() +} + +fn get_info(app: &App, module: Addr) -> InfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Info {}) + .unwrap() +} + +fn get_proposal_module(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalModule {}) + .unwrap() +} + +fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::DepositInfo { proposal_id: id }) + .unwrap() +} + +fn get_multiple_proposals(app: &App, module: Addr) -> ProposalListResponse { + app.wrap() + .query_wasm_smart( + module, + &dpm::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap() +} + +fn get_latest_single_proposal_id(app: &App, module: Addr) -> u64 { + // Check prop was created in the main DAO + let props: dps::query::ProposalListResponse = app + .wrap() + .query_wasm_smart( + module, + &dps::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap(); + props.proposals[props.proposals.len() - 1].id +} + +fn get_latest_multiple_proposal_id(app: &App, module: Addr) -> u64 { + // Check prop was created in the main DAO + let props: ProposalListResponse = app + .wrap() + .query_wasm_smart( + module, + &dpm::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap(); + props.proposals[props.proposals.len() - 1].id +} + +fn query_can_propose(app: &App, module: Addr, address: impl Into) -> bool { + app.wrap() + .query_wasm_smart( + module, + &QueryMsg::CanPropose { + address: address.into(), + }, + ) + .unwrap() +} + +fn update_config( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + submission_policy: PreProposeSubmissionPolicy, +) -> Config { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info, + submission_policy: Some(submission_policy), + }, + &[], + ) + .unwrap(); + + get_config(app, module) +} + +fn update_config_should_fail( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + submission_policy: PreProposeSubmissionPolicy, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::UpdateConfig { + deposit_info, + submission_policy: Some(submission_policy), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn withdraw(app: &mut App, module: Addr, sender: &str, denom: Option) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap(); +} + +fn withdraw_should_fail( + app: &mut App, + module: Addr, + sender: &str, + denom: Option, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn close_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &dpm::msg::ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +fn execute_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &dpm::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +fn approve_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + // Approver votes on prop + vote_single(app, module.clone(), sender, proposal_id, Vote::Yes); + // Approver executes prop + execute_proposal(app, module, sender, proposal_id); +} + +enum ApprovalStatus { + Approved, + Rejected, +} + +enum EndStatus { + Passed, + Failed, +} + +enum RefundReceiver { + Proposer, + Dao, +} + +fn test_native_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let _pre_propose_id = make_pre_proposal(&mut app, pre_propose, "ekez", &coins(10, "ujuno")); + + // Check no props created on main DAO yet + let props = get_multiple_proposals(&app, proposal_multiple.clone()); + assert_eq!(props.proposals.len(), 0); + + // Make sure it went away. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Get approver proposal id + let id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + + // Approver votes on prop + vote_single( + &mut app, + proposal_single_approver.clone(), + "ekez", + id, + Vote::Yes, + ); + // Approver executes prop + execute_proposal(&mut app, proposal_single_approver, "ekez", id); + + // Check prop was created in the main DAO + let id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + let props = get_multiple_proposals(&app, proposal_multiple.clone()); + assert_eq!(props.proposals.len(), 1); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (0, Status::Passed, execute_proposal), + EndStatus::Failed => (2, Status::Rejected, close_proposal), + }; + let new_status = + vote_multiple(&mut app, proposal_multiple.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_multiple, "ekez", id); + } + ApprovalStatus::Rejected => { + // Approver votes on prop + // No proposal is created so there is no voting + vote_single( + &mut app, + proposal_single_approver.clone(), + "ekez", + 1, + Vote::No, + ); + // Approver executes prop + close_proposal(&mut app, proposal_single_approver, "ekez", 1); + + // No prop created + let props = get_multiple_proposals(&app, proposal_multiple); + assert_eq!(props.proposals.len(), 0); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_native(&app, "ekez", "ujuno"); + let dao_balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +fn test_cw20_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + let cw20_address = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let _pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Check no props created on main DAO yet + let props = get_multiple_proposals(&app, proposal_multiple.clone()); + assert_eq!(props.proposals.len(), 0); + + // Make sure it went await. + let balance = get_balance_cw20(&app, cw20_address.clone(), "ekez"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Get approver proposal id + let id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + + // Approver votes on prop + vote_single( + &mut app, + proposal_single_approver.clone(), + "ekez", + id, + Vote::Yes, + ); + // Approver executes prop + execute_proposal(&mut app, proposal_single_approver, "ekez", id); + + // Check prop was created in the main DAO + let id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + let props = get_multiple_proposals(&app, proposal_multiple.clone()); + assert_eq!(props.proposals.len(), 1); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (1, Status::Passed, execute_proposal), + EndStatus::Failed => (2, Status::Rejected, close_proposal), + }; + let new_status = + vote_multiple(&mut app, proposal_multiple.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_multiple, "ekez", id); + } + ApprovalStatus::Rejected => { + // Approver votes on prop + // No proposal is created so there is no voting + vote_single( + &mut app, + proposal_single_approver.clone(), + "ekez", + 1, + Vote::No, + ); + // Approver executes prop + close_proposal(&mut app, proposal_single_approver, "ekez", 1); + + // No prop created + let props = get_multiple_proposals(&app, proposal_multiple); + assert_eq!(props.proposals.len(), 0); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_cw20(&app, &cw20_address, "ekez"); + let dao_balance = get_balance_cw20(&app, &cw20_address, core_addr); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +#[test] +fn test_native_failed_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_always_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_always_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_passed_never_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_never_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_passed_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_passed_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +// See: +#[test] +fn test_multiple_open_proposals() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple, + pre_propose, + approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let _first_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Approver DAO approves prop, balance remains the same + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let first_id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + let _second_pre_propose_id = + make_pre_proposal(&mut app, pre_propose, "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Approver DAO votes to approves, balance remains the same + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal(&mut app, proposal_single_approver, "ekez", approver_prop_id); + let second_id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Finish up the first proposal. + let new_status = vote_multiple(&mut app, proposal_multiple.clone(), "ekez", first_id, 0); + assert_eq!(Status::Passed, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", first_id); + + // First proposal refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Finish up the second proposal. + let new_status = vote_multiple(&mut app, proposal_multiple.clone(), "ekez", second_id, 2); + assert_eq!(Status::Rejected, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + close_proposal(&mut app, proposal_multiple, "ekez", second_id); + + // All deposits have been refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(20, balance.u128()); +} + +#[test] +fn test_set_version() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose: _, + approver_core_addr: _, + proposal_single_approver: _, + pre_propose_approver, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let info: ContractVersion = from_json( + app.wrap() + .query_wasm_raw(pre_propose_approver, "contract_info".as_bytes()) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + }, + info + ) +} + +#[test] +fn test_permissions() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_multiple: _, + pre_propose, + approver_core_addr: _, + proposal_single_approver: _, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, // no open proposal submission. + ); + + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Closed, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotModule {}); + + // Non-members may not propose when open_propose_submission is + // disabled. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + title: "A".to_string(), + description: "A".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "B".to_string(), + description: "B".to_string(), + msgs: vec![], + }, + ], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); +} + +#[test] +fn test_approval_and_rejection_permissions() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose, + approver_core_addr: _, + proposal_single_approver: _, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Only approver can propose + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Only approver can propose + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); +} + +#[test] +fn test_propose_open_proposal_submission() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple, + pre_propose, + approver_core_addr: _, + proposal_single_approver, + pre_propose_approver, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal(&mut app, pre_propose, "nonmember", &coins(10, "ujuno")); + + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + let pre_propose_id_from_proposal: u64 = app + .wrap() + .query_wasm_smart( + pre_propose_approver.clone(), + &ApproverQueryMsg::QueryExtension { + msg: ApproverQueryExt::PreProposeApprovalIdForApproverProposalId { + id: approver_prop_id, + }, + }, + ) + .unwrap(); + assert_eq!(pre_propose_id_from_proposal, pre_propose_id); + + let proposal_id_from_pre_propose: u64 = app + .wrap() + .query_wasm_smart( + pre_propose_approver.clone(), + &ApproverQueryMsg::QueryExtension { + msg: ApproverQueryExt::ApproverProposalIdForPreProposeApprovalId { + id: pre_propose_id, + }, + }, + ) + .unwrap(); + assert_eq!(proposal_id_from_pre_propose, approver_prop_id); + + // Approver DAO votes to approves + approve_proposal(&mut app, proposal_single_approver, "ekez", approver_prop_id); + let id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + + // Member votes. + let new_status = vote_multiple(&mut app, proposal_multiple, "ekez", id, 1); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test(&mut app, None, false); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![] + } + } + ); + + let _pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver DAO votes to approves + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never, + }), + PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + ); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + } + ); + + // Old proposal should still have same deposit info. + let info = get_deposit_info(&app, pre_propose.clone(), id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: None, + proposer: Addr::unchecked("ekez"), + } + ); + + // New proposals should have the new deposit info. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let _new_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver DAO votes to approve prop + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let new_id = get_latest_single_proposal_id(&app, proposal_single_approver); + + let info = get_deposit_info(&app, pre_propose.clone(), new_id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + proposer: Addr::unchecked("ekez"), + } + ); + + // Both proposals should be allowed to complete. + vote_multiple(&mut app, proposal_multiple.clone(), "ekez", id, 0); + vote_multiple(&mut app, proposal_multiple.clone(), "ekez", new_id, 1); + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", id); + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", new_id); + // Deposit should not have been refunded (never policy in use). + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::new(0)); + + // Only the core module can update the config. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + proposal_multiple.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: vec![] }, + ); + assert_eq!(err, PreProposeError::NotDao {}); + + // Errors when no one is authorized to create proposals. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![], + denylist: vec![], + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Errors when allowlist and denylist overlap. + let err = update_config_should_fail( + &mut app, + pre_propose, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![Addr::unchecked("ekez")], + denylist: vec![Addr::unchecked("ekez")], + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); +} + +#[test] +fn test_approver_unsupported_update_config() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + pre_propose_approver, + .. + } = setup_default_test(&mut app, None, true); + + // Should fail because config is not supported for the approver pre-propose + // contract. + let err = update_config_should_fail( + &mut app, + pre_propose_approver, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: vec![Addr::unchecked("ekez")], + denylist: vec![], + }, + ); + assert_eq!(err, PreProposeError::Unsupported {}); +} + +#[test] +fn test_approver_unsupported_update_submission_policy() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + pre_propose_approver, + .. + } = setup_default_test(&mut app, None, true); + + // Should fail because submission policy is not supported for the approver + // pre-propose contract. + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose_approver, + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unsupported {}); +} + +#[test] +fn test_approver_can_propose() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + pre_propose, + pre_propose_approver, + .. + } = setup_default_test(&mut app, None, true); + + // Only the pre-propose-approval-multiple contract can propose. + assert!(query_can_propose( + &app, + pre_propose_approver.clone(), + pre_propose + )); + assert!(!query_can_propose( + &app, + pre_propose_approver, + "someone_else" + )); +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_multiple, + pre_propose, + approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test(&mut app, None, false); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + proposal_multiple.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NotDao {}); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NothingToWithdraw {}); + + let err = withdraw_should_fail(&mut app, pre_propose.clone(), core_addr.as_str(), None); + assert_eq!(err, PreProposeError::NoWithdrawalDenom {}); + + // Turn on native deposits. + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + ); + + // Withdraw with no specified denom - should fall back to the one + // in the config. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(10)); + + // Withdraw again, this time specifying a native denomination. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(20)); + + // Make a proposal with the native tokens to put some in the system. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let _native_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver DAO votes to approve + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let native_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + + // Update the config to use a cw20 token. + let cw20_address = instantiate_cw20_base_default(&mut app); + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: vec![], + denylist: vec![], + }, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let _cw20_pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver DAO votes to approve + let approver_prop_id = get_latest_single_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal(&mut app, proposal_single_approver, "ekez", approver_prop_id); + let cw20_id = get_latest_multiple_proposal_id(&app, proposal_multiple.clone()); + + // There is now a pending proposal and cw20 tokens in the + // pre-propose module that should be returned on that proposal's + // completion. To make things interesting, we withdraw those + // tokens which should cause the status change hook on the + // proposal's execution to fail as we don't have sufficent balance + // to return the deposit. + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_cw20(&app, &cw20_address, core_addr.as_str()); + assert_eq!(balance, Uint128::new(10)); + + // Proposal should still be executable! We just get removed from + // the proposal module's hook receiver list. + vote_multiple(&mut app, proposal_multiple.clone(), "ekez", cw20_id, 0); + execute_proposal(&mut app, proposal_multiple.clone(), "ekez", cw20_id); + + // Make sure the proposal module has fallen back to anyone can + // propose becuase of our malfunction. + let proposal_creation_policy: ProposalCreationPolicy = app + .wrap() + .query_wasm_smart( + proposal_multiple.clone(), + &dpm::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + assert_eq!(proposal_creation_policy, ProposalCreationPolicy::Anyone {}); + + // Close out the native proposal and it's deposit as well. + vote_multiple(&mut app, proposal_multiple.clone(), "ekez", native_id, 2); + close_proposal(&mut app, proposal_multiple.clone(), "ekez", native_id); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(30)); +} + +#[test] +fn test_reset_approver() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr: _, + proposal_multiple: _, + pre_propose, + approver_core_addr, + proposal_single_approver: _, + pre_propose_approver, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + // Ensure approver is set to the pre_propose_approver + let approver: Addr = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::Approver {}, + }, + ) + .unwrap(); + assert_eq!(approver, pre_propose_approver); + + // Fail to change approver by non-approver. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("someone"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::UpdateApprover { + address: "someone".to_string(), + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Fail to reset approver back to approver DAO by non-approver. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("someone"), + pre_propose_approver.clone(), + &ApproverExecuteMsg::Extension { + msg: ApproverExecuteExt::ResetApprover {}, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Reset approver back to approver DAO. + app.execute_contract( + approver_core_addr.clone(), + pre_propose_approver.clone(), + &ApproverExecuteMsg::Extension { + msg: ApproverExecuteExt::ResetApprover {}, + }, + &[], + ) + .unwrap(); + + // Ensure approver is reset back to the approver DAO + let approver: Addr = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::Approver {}, + }, + ) + .unwrap(); + assert_eq!(approver, approver_core_addr); +} diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs similarity index 99% rename from contracts/pre-propose/dao-pre-propose-approver/src/tests.rs rename to contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs index 8dc569995..45b5aa9c5 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs @@ -4,8 +4,8 @@ use cw20::Cw20Coin; use cw_denom::UncheckedDenom; use cw_multi_test::{App, BankSudo, Executor}; use dao_interface::proposal::InfoResponse; +use dao_proposal_single::query::{ProposalListResponse, ProposalResponse}; use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; -use dps::query::{ProposalListResponse, ProposalResponse}; use dao_interface::state::ProposalModule; use dao_interface::state::{Admin, ModuleInstantiateInfo}; diff --git a/packages/dao-testing/src/helpers.rs b/packages/dao-testing/src/helpers.rs index e44c0a787..0a36640f0 100644 --- a/packages/dao-testing/src/helpers.rs +++ b/packages/dao-testing/src/helpers.rs @@ -301,7 +301,7 @@ pub fn instantiate_with_staking_active_threshold( pub fn instantiate_with_cw4_groups_governance( app: &mut App, - core_code_id: u64, + proposal_module_code_id: u64, proposal_module_instantiate: Binary, initial_weights: Option>, ) -> Addr { @@ -353,7 +353,7 @@ pub fn instantiate_with_cw4_groups_governance( label: "DAO DAO voting module".to_string(), }, proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { - code_id: core_code_id, + code_id: proposal_module_code_id, msg: proposal_module_instantiate, admin: Some(Admin::CoreModule {}), funds: vec![], diff --git a/packages/dao-voting/src/approval.rs b/packages/dao-voting/src/approval.rs new file mode 100644 index 000000000..68587e2ac --- /dev/null +++ b/packages/dao-voting/src/approval.rs @@ -0,0 +1,54 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; + +use crate::deposit::CheckedDepositInfo; + +#[cw_serde] +pub enum ApproverProposeMessage { + Propose { + title: String, + description: String, + approval_id: u64, + }, +} + +#[cw_serde] +pub enum ApprovalExecuteExt { + /// Approve a proposal, only callable by approver + Approve { id: u64 }, + /// Reject a proposal, only callable by approver + Reject { id: u64 }, + /// Updates the approver, can only be called the current approver + UpdateApprover { address: String }, +} + +#[cw_serde] +pub enum ApprovalProposalStatus { + /// The proposal is pending approval. + Pending {}, + /// The proposal has been approved. + Approved { + /// The created proposal ID. + created_proposal_id: u64, + }, + /// The proposal has been rejected. + Rejected {}, +} + +#[cw_serde] +pub struct ApprovalProposal { + /// The status of an approval proposal. + pub status: ApprovalProposalStatus, + /// The approval ID used to identify this pending proposal. + pub approval_id: u64, + /// The address that can approve/reject this proposal. + pub approver: Addr, + /// The address that created the proposal. + pub proposer: Addr, + /// The propose message that ought to be executed on the proposal + /// module if this proposal is approved. + pub msg: ProposeMsg, + /// Snapshot of the deposit info at the time of proposal + /// submission. + pub deposit: Option, +} diff --git a/packages/dao-voting/src/lib.rs b/packages/dao-voting/src/lib.rs index 747274849..ad2ff0d3a 100644 --- a/packages/dao-voting/src/lib.rs +++ b/packages/dao-voting/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] +pub mod approval; pub mod deposit; pub mod duration; pub mod error;