diff --git a/Cargo.lock b/Cargo.lock index 7a027be43..efef129ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2161,11 +2161,14 @@ version = "2.4.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", + "cw-denom", "cw-hooks", "cw-multi-test", + "cw-ownable", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", + "cw20 1.1.2", "cw20-base 1.1.2", "dao-dao-core", "dao-hooks", diff --git a/contracts/external/dao-proposal-incentives/src/error.rs b/contracts/external/dao-proposal-incentives/src/error.rs index ee3ba8822..57a1a362c 100644 --- a/contracts/external/dao-proposal-incentives/src/error.rs +++ b/contracts/external/dao-proposal-incentives/src/error.rs @@ -29,4 +29,7 @@ pub enum ContractError { #[error("No reward per proposal given")] NoRewardPerProposal {}, + + #[error("Proposal module is inactive")] + ProposalModuleIsInactive {}, } diff --git a/contracts/external/dao-proposal-incentives/src/execute.rs b/contracts/external/dao-proposal-incentives/src/execute.rs index bafeac650..95267ff27 100644 --- a/contracts/external/dao-proposal-incentives/src/execute.rs +++ b/contracts/external/dao-proposal-incentives/src/execute.rs @@ -2,7 +2,10 @@ use cosmwasm_std::{Attribute, CosmosMsg, DepsMut, Env, MessageInfo, Response}; use cw20::Cw20ReceiveMsg; use cw_ownable::{assert_owner, get_ownership}; use dao_hooks::proposal::ProposalHookMsg; -use dao_interface::{proposal::GenericProposalInfo, state::ProposalModule}; +use dao_interface::{ + proposal::GenericProposalInfo, + state::{ProposalModule, ProposalModuleStatus}, +}; use dao_voting::status::Status; use crate::{msg::ProposalIncentivesUnchecked, state::PROPOSAL_INCENTIVES, ContractError}; @@ -21,15 +24,19 @@ pub fn proposal_hook( if let Some(owner) = ownership.owner { // Validate the message is coming from a proposal module of the owner (DAO) - deps.querier.query_wasm_smart::( + let proposal_module = deps.querier.query_wasm_smart::( owner, &dao_interface::msg::QueryMsg::ProposalModule { address: info.sender.to_string(), }, )?; - // Check prop status and type of hook + // If the proposal module is disabled, then return error + if proposal_module.status == ProposalModuleStatus::Disabled { + return Err(ContractError::ProposalModuleIsInactive {}); + } + // Check prop status and type of hook if let ProposalHookMsg::ProposalStatusChanged { id, new_status, .. } = msg { // If prop status is success, add message to pay out rewards // Otherwise, do nothing @@ -104,6 +111,7 @@ pub fn receive_cw20( info: MessageInfo, _cw20_receive_msg: Cw20ReceiveMsg, ) -> Result { + // We do not check cw20, because the expected fund can change over time Ok(Response::new() .add_attribute("action", "receive_cw20") .add_attribute("cw20", info.sender)) diff --git a/contracts/external/dao-voting-incentives/Cargo.toml b/contracts/external/dao-voting-incentives/Cargo.toml index a64900db1..fbeccc8c0 100644 --- a/contracts/external/dao-voting-incentives/Cargo.toml +++ b/contracts/external/dao-voting-incentives/Cargo.toml @@ -1,6 +1,6 @@ [package] name ="dao-voting-incentives" -authors = ["Jake Hartnell "] +authors = ["Jake Hartnell ", "ismellike"] description = "A contract that implements incentives for voting in a DAO." edition = { workspace = true } license = { workspace = true } @@ -26,6 +26,9 @@ dao-interface = { workspace = true } dao-voting = { workspace = true } thiserror = { workspace = true } cw-utils = { workspace = true } +cw-denom = { workspace = true } +cw-ownable = { workspace = true } +cw20 = { workspace = true } [dev-dependencies] cosmwasm-schema = { workspace = true } diff --git a/contracts/external/dao-voting-incentives/schema/cw-admin-factory.json b/contracts/external/dao-voting-incentives/schema/cw-admin-factory.json deleted file mode 100644 index f1a1e1254..000000000 --- a/contracts/external/dao-voting-incentives/schema/cw-admin-factory.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "contract_name": "cw-admin-factory", - "contract_version": "2.4.0", - "idl_version": "1.0.0", - "instantiate": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "InstantiateMsg", - "type": "object", - "additionalProperties": false - }, - "execute": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ExecuteMsg", - "oneOf": [ - { - "description": "Instantiates the target contract with the provided instantiate message and code id and updates the contract's admin to be itself.", - "type": "object", - "required": [ - "instantiate_contract_with_self_admin" - ], - "properties": { - "instantiate_contract_with_self_admin": { - "type": "object", - "required": [ - "code_id", - "instantiate_msg", - "label" - ], - "properties": { - "code_id": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "instantiate_msg": { - "$ref": "#/definitions/Binary" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - ], - "definitions": { - "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" - } - } - }, - "query": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "QueryMsg", - "type": "string", - "enum": [] - }, - "migrate": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "MigrateMsg", - "type": "object", - "additionalProperties": false - }, - "sudo": null, - "responses": {} -} diff --git a/contracts/external/dao-voting-incentives/schema/dao-voting-incentives.json b/contracts/external/dao-voting-incentives/schema/dao-voting-incentives.json new file mode 100644 index 000000000..0bfbb8c10 --- /dev/null +++ b/contracts/external/dao-voting-incentives/schema/dao-voting-incentives.json @@ -0,0 +1,695 @@ +{ + "contract_name": "dao-voting-incentives", + "contract_version": "2.4.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom", + "expiration", + "owner" + ], + "properties": { + "denom": { + "description": "The denom to distribute", + "allOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + } + ] + }, + "expiration": { + "description": "The expiration of the voting incentives", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "owner": { + "description": "The contract's owner using cw-ownable", + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "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 + } + ] + }, + "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" + } + ] + }, + "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 + } + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Fires when a new vote is cast.", + "type": "object", + "required": [ + "vote_hook" + ], + "properties": { + "vote_hook": { + "$ref": "#/definitions/VoteHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Claim rewards", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Expire the voting incentives period", + "type": "object", + "required": [ + "expire" + ], + "properties": { + "expire": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_ownership" + ], + "properties": { + "update_ownership": { + "$ref": "#/definitions/Action" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Action": { + "description": "Actions that can be taken to alter the contract's ownership", + "oneOf": [ + { + "description": "Propose to transfer the contract's ownership to another account, optionally with an expiry time.\n\nCan only be called by the contract's current owner.\n\nAny existing pending ownership transfer is overwritten.", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "object", + "required": [ + "new_owner" + ], + "properties": { + "expiry": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept the pending ownership transfer.\n\nCan only be called by the pending owner.", + "type": "string", + "enum": [ + "accept_ownership" + ] + }, + { + "description": "Give up the contract's ownership and the possibility of appointing a new owner.\n\nCan only be invoked by the contract's current owner.\n\nAny existing pending ownership transfer is canceled.", + "type": "string", + "enum": [ + "renounce_ownership" + ] + } + ] + }, + "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 + } + ] + }, + "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" + } + ] + }, + "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" + }, + "VoteHookMsg": { + "description": "An enum representing vote hooks, fired when new votes are cast.", + "oneOf": [ + { + "type": "object", + "required": [ + "new_vote" + ], + "properties": { + "new_vote": { + "type": "object", + "required": [ + "proposal_id", + "vote", + "voter" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "type": "string" + }, + "voter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns the config", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the claimable rewards for the given address.", + "type": "object", + "required": [ + "rewards" + ], + "properties": { + "rewards": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the expected rewards for the given address", + "type": "object", + "required": [ + "expected_rewards" + ], + "properties": { + "expected_rewards": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Query the contract's ownership information", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "from_compatible" + ], + "properties": { + "from_compatible": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "Top level config type for core module.", + "type": "object", + "required": [ + "automatically_add_cw20s", + "automatically_add_cw721s", + "description", + "name" + ], + "properties": { + "automatically_add_cw20s": { + "description": "If true the contract will automatically add received cw20 tokens to its treasury.", + "type": "boolean" + }, + "automatically_add_cw721s": { + "description": "If true the contract will automatically add received cw721 tokens to its treasury.", + "type": "boolean" + }, + "dao_uri": { + "description": "The URI for the DAO as defined by the DAOstar standard ", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "A description of the contract.", + "type": "string" + }, + "image_url": { + "description": "An optional image URL for displaying alongside the contract.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The name of the contract.", + "type": "string" + } + }, + "additionalProperties": false + }, + "expected_rewards": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RewardResponse", + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "$ref": "#/definitions/CheckedDenom" + } + }, + "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 + } + ] + }, + "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" + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_String", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "type": [ + "string", + "null" + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "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 + } + ] + }, + "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" + } + ] + }, + "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" + } + } + }, + "rewards": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RewardResponse", + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "$ref": "#/definitions/CheckedDenom" + } + }, + "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 + } + ] + }, + "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" + } + } + } + } +} diff --git a/contracts/external/dao-voting-incentives/src/contract.rs b/contracts/external/dao-voting-incentives/src/contract.rs index f68cbde11..32ca6e86b 100644 --- a/contracts/external/dao-voting-incentives/src/contract.rs +++ b/contracts/external/dao-voting-incentives/src/contract.rs @@ -1,13 +1,15 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; -use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult, Uint128, +}; use cw2::set_contract_version; -use cw_utils::must_pay; -use dao_hooks::vote::VoteHookMsg; +use cw_ownable::get_ownership; use crate::error::ContractError; use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; -use crate::state::{DAO, VOTING_INCENTIVES}; +use crate::state::{Config, CONFIG}; +use crate::{execute, query}; pub(crate) const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -15,30 +17,41 @@ pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( deps: DepsMut, - _env: Env, + env: Env, info: MessageInfo, msg: InstantiateMsg, ) -> Result { set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - // Save DAO, assumes the sender is the DAO - DAO.save(deps.storage, &deps.api.addr_validate(&msg.dao)?)?; + // Save ownership + let ownership = cw_ownable::initialize_owner(deps.storage, deps.api, Some(&msg.owner))?; - // Save voting incentives config - VOTING_INCENTIVES.save(deps.storage, &msg.voting_incentives)?; + // Validate denom + let denom = msg.denom.into_checked(deps.as_ref())?; + + // Validate expiration + if msg.expiration.is_expired(&env.block) { + return Err(ContractError::AlreadyExpired {}); + } - // Check initial deposit is enough to pay out rewards for at least one epoch - let amount = must_pay(&info, &msg.voting_incentives.rewards_per_epoch.denom)?; - if amount < msg.voting_incentives.rewards_per_epoch.amount { - return Err(ContractError::InsufficientInitialDeposit { - expected: msg.voting_incentives.rewards_per_epoch.amount, - actual: amount, - }); - }; + // Save voting incentives config + CONFIG.save( + deps.storage, + &Config { + start_height: env.block.height, + expiration: msg.expiration, + denom: denom.clone(), + total_votes: Uint128::zero(), + expiration_balance: None, + }, + )?; Ok(Response::new() .add_attribute("method", "instantiate") - .add_attribute("creator", info.sender)) + .add_attribute("creator", info.sender) + .add_attribute("expiration", msg.expiration.to_string()) + .add_attribute("denom", denom.to_string()) + .add_attributes(ownership.into_attributes())) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -49,61 +62,25 @@ pub fn execute( msg: ExecuteMsg, ) -> Result { match msg { - ExecuteMsg::Claim {} => execute_claim(deps, env, info), - ExecuteMsg::VoteHook(msg) => execute_vote_hook(deps, env, info, msg), + ExecuteMsg::Claim {} => execute::claim(deps, env, info), + ExecuteMsg::VoteHook(msg) => execute::vote_hook(deps, env, info, msg), + ExecuteMsg::Expire {} => execute::expire(deps, env, info), + ExecuteMsg::UpdateOwnership(action) => execute::update_ownership(deps, env, info, action), + ExecuteMsg::Receive(cw20_receive_msg) => { + execute::receive_cw20(deps, env, info, cw20_receive_msg) + } } } -// TODO how to claim for many epochs efficiently? -pub fn execute_claim( - deps: DepsMut, - _env: Env, - _info: MessageInfo, -) -> Result { - // Check epoch should advance - - // Save last claimed epoch - - // Load user vote count for epoch? - - // Load prop count for epoch - - // Load voting incentives config - let _voting_incentives = VOTING_INCENTIVES.load(deps.storage)?; - - // Need total vote count for epoch - // Rewards = (user vote count / prop count) / total_vote_count * voting incentives - - // Pay out rewards - - Ok(Response::default().add_attribute("action", "claim")) -} - -// TODO support cw20 tokens -// TODO make sure config can't lock DAO -pub fn execute_vote_hook( - _deps: DepsMut, - _env: Env, - _info: MessageInfo, - _msg: VoteHookMsg, -) -> Result { - // Check epoch should advance - - // TODO need some state to handle this - // Check that the vote is not a changed vote (i.e. the user has already voted - // on the prop). - - // Save (user, epoch), vote count - // Update (epoch, prop count) - - Ok(Response::default().add_attribute("action", "vote_hook")) -} - #[cfg_attr(not(feature = "library"), entry_point)] -pub fn query(_deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { - QueryMsg::Rewards { address: _ } => unimplemented!(), - QueryMsg::Config {} => unimplemented!(), + QueryMsg::Rewards { address } => to_json_binary(&query::rewards(deps, address)?), + QueryMsg::ExpectedRewards { address } => { + to_json_binary(&query::expected_rewards(deps, env, address)?) + } + QueryMsg::Config {} => to_json_binary(&query::config(deps)?), + QueryMsg::Ownership {} => to_json_binary(&get_ownership(deps.storage)?), } } diff --git a/contracts/external/dao-voting-incentives/src/error.rs b/contracts/external/dao-voting-incentives/src/error.rs index 2b1469dcb..15b5024ad 100644 --- a/contracts/external/dao-voting-incentives/src/error.rs +++ b/contracts/external/dao-voting-incentives/src/error.rs @@ -1,5 +1,7 @@ -use cosmwasm_std::{StdError, Uint128}; -use cw_utils::{ParseReplyError, PaymentError}; +use cosmwasm_std::{CheckedMultiplyFractionError, OverflowError, StdError}; +use cw_denom::{CheckedDenom, DenomError}; +use cw_ownable::OwnershipError; +use cw_utils::Expiration; use thiserror::Error; #[derive(Error, Debug)] @@ -8,17 +10,32 @@ pub enum ContractError { Std(#[from] StdError), #[error("{0}")] - ParseReplyError(#[from] ParseReplyError), + DenomError(#[from] DenomError), #[error("{0}")] - PaymentError(#[from] PaymentError), + OwnershipError(#[from] OwnershipError), - #[error("You need to deposit enough incentives for at least one epoch of incentives. Expected {expected}, got {actual}.")] - InsufficientInitialDeposit { expected: Uint128, actual: Uint128 }, + #[error("{0}")] + CheckedMultiplyFractionError(#[from] CheckedMultiplyFractionError), + + #[error("{0}")] + OverflowError(#[from] OverflowError), #[error("Unauthorized")] Unauthorized {}, - #[error("An unknown reply ID was received.")] - UnknownReplyID {}, + #[error("NotExpired")] + NotExpired { expiration: Expiration }, + + #[error("AlreadyExpired")] + AlreadyExpired {}, + + #[error("Proposal module is inactive")] + ProposalModuleIsInactive {}, + + #[error("UnexpectedFunds")] + UnexpectedFunds { + expected: CheckedDenom, + received: CheckedDenom, + }, } diff --git a/contracts/external/dao-voting-incentives/src/execute.rs b/contracts/external/dao-voting-incentives/src/execute.rs new file mode 100644 index 000000000..df5a75205 --- /dev/null +++ b/contracts/external/dao-voting-incentives/src/execute.rs @@ -0,0 +1,226 @@ +use cosmwasm_std::{Attribute, DepsMut, Env, MessageInfo, Response, StdResult, Uint128}; +use cw20::Cw20ReceiveMsg; +use cw_denom::CheckedDenom; +use cw_ownable::get_ownership; +use dao_hooks::vote::VoteHookMsg; +use dao_interface::{ + proposal::GenericProposalInfo, + state::{ProposalModule, ProposalModuleStatus}, +}; + +use crate::{ + state::{reward, CONFIG, GENERIC_PROPOSAL_INFO, USER_VOTE_COUNT}, + ContractError, +}; + +pub fn claim(deps: DepsMut, _env: Env, info: MessageInfo) -> Result { + // Get reward information + let reward = reward(deps.as_ref(), &info.sender)?; + + // If the user has rewards, then we should generate a message + let mut msgs = vec![]; + if !reward.amount.is_zero() { + msgs.push( + reward + .denom + .get_transfer_to_message(&info.sender, reward.amount)?, + ); + } + + // Clean state + USER_VOTE_COUNT.remove(deps.storage, &info.sender); + + Ok(Response::new() + .add_attribute("action", "claim") + .add_attribute("denom", reward.denom.to_string()) + .add_attribute("amount", reward.amount) + .add_messages(msgs)) +} + +pub fn update_ownership( + deps: DepsMut, + env: Env, + info: MessageInfo, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + + Ok(Response::new() + .add_attribute("action", "update_ownership") + .add_attributes(ownership.into_attributes())) +} + +pub fn vote_hook( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: VoteHookMsg, +) -> Result { + let mut attrs: Vec = vec![]; + + // Get ownership + let ownership = get_ownership(deps.storage)?; + + if let Some(owner) = ownership.owner { + // Validate the message is coming from a proposal module of the owner (DAO) + let proposal_module = deps.querier.query_wasm_smart::( + owner, + &dao_interface::msg::QueryMsg::ProposalModule { + address: info.sender.to_string(), + }, + )?; + + // If the proposal module is disabled, then return error + if proposal_module.status == ProposalModuleStatus::Disabled { + return Err(ContractError::ProposalModuleIsInactive {}); + } + + // Check type of hook + match msg { + VoteHookMsg::NewVote { + proposal_id, voter, .. + } => { + if let Ok(voter) = deps.api.addr_validate(&voter) { + // Get config + let mut config = CONFIG.load(deps.storage)?; + + // Check if the voting incentives have expired + if config.expiration.is_expired(&env.block) { + return Err(ContractError::AlreadyExpired {}); + } + + // Get the proposal info + // If we have a value in the cache, then return the value + // If we don't have a value, then query for the value and set it in the cache + let proposal_info = if GENERIC_PROPOSAL_INFO + .has(deps.storage, (&info.sender, proposal_id)) + { + GENERIC_PROPOSAL_INFO.load(deps.storage, (&info.sender, proposal_id))? + } else { + let proposal_info: GenericProposalInfo = deps.querier.query_wasm_smart( + info.sender.clone(), + &dao_interface::proposal::Query::GenericProposalInfo { proposal_id }, + )?; + + GENERIC_PROPOSAL_INFO.save( + deps.storage, + (&info.sender, proposal_id), + &proposal_info, + )?; + + proposal_info + }; + + // Check if the vote came from a proposal at or after the start of the voting incentives + if proposal_info.start_height >= config.start_height { + // Increment counts + let user_votes = USER_VOTE_COUNT.update( + deps.storage, + &voter, + |x| -> StdResult { + Ok(x.unwrap_or_default().checked_add(Uint128::one())?) + }, + )?; + config.total_votes = config.total_votes.checked_add(Uint128::one())?; + CONFIG.save(deps.storage, &config)?; + + // Set attributes + attrs = vec![ + Attribute { + key: "total_votes".to_string(), + value: config.total_votes.to_string(), + }, + Attribute { + key: "user_votes".to_string(), + value: user_votes.to_string(), + }, + Attribute { + key: "user".to_string(), + value: voter.to_string(), + }, + ]; + } + } + } + } + } + + Ok(Response::new() + .add_attribute("action", "vote_hook") + .add_attributes(attrs)) +} + +pub fn expire(deps: DepsMut, env: Env, _info: MessageInfo) -> Result { + // Get the config + let mut config = CONFIG.load(deps.storage)?; + + // If already expired, then return an error + if config.expiration_balance.is_some() { + return Err(ContractError::AlreadyExpired {}); + } + + // Ensure the voting incentives period has passed expiration + if !config.expiration.is_expired(&env.block) { + return Err(ContractError::NotExpired { + expiration: config.expiration, + }); + } + + // Get the available balance to distribute + let balance = config + .denom + .query_balance(&deps.querier, &env.contract.address)?; + + // Save the balance + config.expiration_balance = Some(balance); + CONFIG.save(deps.storage, &config)?; + + // If no votes have occurred, then funds should be sent to the owner + let mut msgs = vec![]; + if USER_VOTE_COUNT.is_empty(deps.storage) { + let ownership = get_ownership(deps.storage)?; + + if let Some(owner) = ownership.owner { + msgs.push(config.denom.get_transfer_to_message(&owner, balance)?); + } + } + + // Clean state + GENERIC_PROPOSAL_INFO.clear(deps.storage); + + Ok(Response::new() + .add_attribute("action", "expire") + .add_attribute("balance", balance) + .add_messages(msgs)) +} + +pub fn receive_cw20( + deps: DepsMut, + _env: Env, + info: MessageInfo, + _cw20_receive_msg: Cw20ReceiveMsg, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Do not accept unexpected cw20 + match &config.denom { + CheckedDenom::Native(_) => { + return Err(ContractError::UnexpectedFunds { + expected: config.denom, + received: CheckedDenom::Cw20(info.sender), + }) + } + CheckedDenom::Cw20(expected_cw20) => { + if expected_cw20 != info.sender { + return Err(ContractError::UnexpectedFunds { + expected: config.denom, + received: CheckedDenom::Cw20(info.sender), + }); + } + } + } + + Ok(Response::new() + .add_attribute("action", "receive_cw20") + .add_attribute("cw20", info.sender)) +} diff --git a/contracts/external/dao-voting-incentives/src/lib.rs b/contracts/external/dao-voting-incentives/src/lib.rs index 595daabe0..d2f25785c 100644 --- a/contracts/external/dao-voting-incentives/src/lib.rs +++ b/contracts/external/dao-voting-incentives/src/lib.rs @@ -2,7 +2,9 @@ pub mod contract; mod error; +pub mod execute; pub mod msg; +pub mod query; pub mod state; pub use crate::error::ContractError; diff --git a/contracts/external/dao-voting-incentives/src/msg.rs b/contracts/external/dao-voting-incentives/src/msg.rs index 5298657e6..1937ab74b 100644 --- a/contracts/external/dao-voting-incentives/src/msg.rs +++ b/contracts/external/dao-voting-incentives/src/msg.rs @@ -1,42 +1,56 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw20::Cw20ReceiveMsg; +use cw_denom::{CheckedDenom, UncheckedDenom}; +use cw_ownable::cw_ownable_query; +use cw_utils::Expiration; use dao_hooks::vote::VoteHookMsg; - -use crate::state::VotingIncentives; +use dao_interface::state::Config; #[cw_serde] pub struct InstantiateMsg { - /// DAO address - pub dao: String, - /// Rewards to pay out for voting. - pub voting_incentives: VotingIncentives, + /// The contract's owner using cw-ownable + pub owner: String, + /// The denom to distribute + pub denom: UncheckedDenom, + /// The expiration of the voting incentives + pub expiration: Expiration, } #[cw_serde] pub enum ExecuteMsg { /// Fires when a new vote is cast. VoteHook(VoteHookMsg), - /// Claim rewards. + /// Claim rewards Claim {}, + /// Expire the voting incentives period + Expire {}, + UpdateOwnership(cw_ownable::Action), + Receive(Cw20ReceiveMsg), } +#[cw_ownable_query] #[cw_serde] #[derive(QueryResponses)] pub enum QueryMsg { - /// Returns the config. - #[returns(ConfigResponse)] + /// Returns the config + #[returns(Config)] Config {}, - /// Returns the rewards for the given address. - #[returns(cosmwasm_std::Uint128)] + /// Returns the claimable rewards for the given address. + #[returns(RewardResponse)] Rewards { address: String }, + /// Returns the expected rewards for the given address + #[returns(RewardResponse)] + ExpectedRewards { address: String }, } #[cw_serde] -pub struct ConfigResponse { - /// DAO address - pub dao: String, - /// Rewards to pay out for voting. - pub voting_incentives: VotingIncentives, +pub enum MigrateMsg { + FromCompatible {}, } #[cw_serde] -pub struct MigrateMsg {} +pub struct RewardResponse { + pub denom: CheckedDenom, + pub amount: Uint128, +} diff --git a/contracts/external/dao-voting-incentives/src/query.rs b/contracts/external/dao-voting-incentives/src/query.rs new file mode 100644 index 000000000..96a6877ea --- /dev/null +++ b/contracts/external/dao-voting-incentives/src/query.rs @@ -0,0 +1,23 @@ +use cosmwasm_std::{Deps, Env, StdError, StdResult}; + +use crate::{ + msg::RewardResponse, + state::{self, Config, CONFIG}, +}; + +pub fn rewards(deps: Deps, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + + state::reward(deps, &address).map_err(|x| StdError::GenericErr { msg: x.to_string() }) +} + +pub fn expected_rewards(deps: Deps, env: Env, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + + state::expected_reward(deps, env, &address) + .map_err(|x| StdError::GenericErr { msg: x.to_string() }) +} + +pub fn config(deps: Deps) -> StdResult { + CONFIG.load(deps.storage) +} diff --git a/contracts/external/dao-voting-incentives/src/state.rs b/contracts/external/dao-voting-incentives/src/state.rs index 730cd7fe3..571482860 100644 --- a/contracts/external/dao-voting-incentives/src/state.rs +++ b/contracts/external/dao-voting-incentives/src/state.rs @@ -1,33 +1,88 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, Coin, Timestamp}; +use cosmwasm_std::{Addr, CheckedMultiplyFractionError, Deps, Env, Uint128}; +use cw_denom::CheckedDenom; use cw_storage_plus::{Item, Map}; +use cw_utils::Expiration; +use dao_interface::proposal::GenericProposalInfo; -/// The address of the DAO this contract serves -pub const DAO: Item = Item::new("dao"); +use crate::{msg::RewardResponse, ContractError}; /// Incentives for voting #[cw_serde] -pub struct VotingIncentives { - /// Epoch duration in seconds. Used for reward calculation. - pub epoch_duration: Timestamp, - /// The rewards to pay out per epoch. - pub rewards_per_epoch: Coin, +pub struct Config { + /// The start height of the voting incentives + pub start_height: u64, + /// The expiration of these voting incentives + pub expiration: Expiration, + /// The total rewards to be distributed + pub denom: CheckedDenom, + /// The total number of votes + pub total_votes: Uint128, + /// The balance at expiration + pub expiration_balance: Option, } -/// Holds VotingIncentives state -pub const VOTING_INCENTIVES: Item = Item::new("voting_incentives"); +/// A map of user address to vote count +pub const USER_VOTE_COUNT: Map<&Addr, Uint128> = Map::new("user_vote_count"); +/// The voting incentives config +pub const CONFIG: Item = Item::new("config"); +/// A cache of generic proposal information (proposal_module, proposal_id) +pub const GENERIC_PROPOSAL_INFO: Map<(&Addr, u64), GenericProposalInfo> = + Map::new("generic_proposal_info"); -/// The current epoch -pub const EPOCH: Item = Item::new("epoch"); +/// A method to load reward information +pub fn reward(deps: Deps, addr: &Addr) -> Result { + let config = CONFIG.load(deps.storage)?; -/// A map of addresses to their last claimed epoch -pub const LAST_CLAIMED_EPOCHS: Map = Map::new("last_claimed_epoch"); + match config.expiration_balance { + Some(balance) => { + // Get the user's votes + let user_votes = USER_VOTE_COUNT + .may_load(deps.storage, addr)? + .unwrap_or_default(); -/// A map of epochs to prop count -pub const EPOCH_PROPOSAL_COUNT: Map = Map::new("epoch_proposal_count"); + // Calculate the reward + Ok(RewardResponse { + denom: config.denom, + amount: calculate_reward(config.total_votes, user_votes, balance)?, + }) + } + None => Err(ContractError::NotExpired { + expiration: config.expiration, + }), + } +} + +/// A method to load the expected reward information +/// The expected reward method can differ from the actual reward, because the balance is saved in state after expiration +pub fn expected_reward(deps: Deps, env: Env, addr: &Addr) -> Result { + let config = CONFIG.load(deps.storage)?; -/// A map of epochs to total vote count -pub const EPOCH_TOTAL_VOTE_COUNT: Map = Map::new("epoch_total_vote_count"); + // Get the voting incentives balance + let balance = config + .denom + .query_balance(&deps.querier, &env.contract.address)?; -/// A map of user addresses + epoch to vote count -pub const USER_EPOCH_VOTE_COUNT: Map<(Addr, u64), u64> = Map::new("user_epoch_vote_count"); + // Get the user's votes + let user_votes = USER_VOTE_COUNT + .may_load(deps.storage, addr)? + .unwrap_or_default(); + + // Calculate the reward + Ok(RewardResponse { + denom: config.denom, + amount: calculate_reward(config.total_votes, user_votes, balance)?, + }) +} + +fn calculate_reward( + total_votes: Uint128, + user_votes: Uint128, + balance: Uint128, +) -> Result { + if balance.is_zero() || user_votes.is_zero() || total_votes.is_zero() { + return Ok(Uint128::zero()); + } + + balance.checked_div_floor((user_votes, total_votes)) +}