diff --git a/.cargo/config.toml b/.cargo/config.toml index e5fdda2c5..9fe067ec1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ all-test = "test --workspace" unit-test = "test --lib" integration-test = "test --package integration-tests -- --ignored --test-threads 1 -Z unstable-options --report-time" test-tube = "test --features test-tube" +schema = "run --example schema" [env] RUSTFLAGS = "-C link-arg=-s" diff --git a/Cargo.lock b/Cargo.lock index 7ab222337..486dc1046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "cw-fund-distributor" -version = "0.1.0" +version = "2.4.2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1931,6 +1931,36 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dao-rewards-distributor" +version = "2.4.2" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "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", + "cw20-stake 2.4.2", + "cw4 1.1.2", + "cw4-group 1.1.2", + "cw721-base 0.18.0", + "dao-hooks", + "dao-interface", + "dao-testing", + "dao-voting 2.4.2", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "dao-voting-cw721-staked", + "dao-voting-token-staked", + "thiserror", +] + [[package]] name = "dao-test-custom-factory" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 95915acee..2f2d2ebe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ exclude = ["ci/configs/", "wasmvm/libwasmvm"] members = [ "contracts/dao-dao-core", + "contracts/distribution/*", "contracts/external/*", "contracts/proposal/*", "contracts/pre-propose/*", @@ -82,6 +83,7 @@ cw-ownable = "0.5" cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.4.2" } cw-denom = { path = "./packages/cw-denom", version = "2.4.2" } +cw-fund-distributor = { path = "./contracts/distribution/cw-fund-distributor", version = "2.4.2" } cw-hooks = { path = "./packages/cw-hooks", version = "2.4.2" } cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.4.2" } cw-payroll-factory = { path = "./contracts/external/cw-payroll-factory", version = "2.4.2" } @@ -108,6 +110,7 @@ dao-proposal-hook-counter = { path = "./contracts/test/dao-proposal-hook-counter dao-proposal-multiple = { path = "./contracts/proposal/dao-proposal-multiple", version = "2.4.2" } dao-proposal-single = { path = "./contracts/proposal/dao-proposal-single", version = "2.4.2" } dao-proposal-sudo = { path = "./contracts/test/dao-proposal-sudo", version = "2.4.2" } +dao-rewards-distributor = { path = "./contracts/distribution/dao-rewards-distributor", version = "2.4.2" } dao-test-custom-factory = { path = "./contracts/test/dao-test-custom-factory", version = "2.4.2" } dao-testing = { path = "./packages/dao-testing", version = "2.4.2" } dao-voting = { path = "./packages/dao-voting", version = "2.4.2" } diff --git a/contracts/external/cw-fund-distributor/Cargo.toml b/contracts/distribution/cw-fund-distributor/Cargo.toml similarity index 97% rename from contracts/external/cw-fund-distributor/Cargo.toml rename to contracts/distribution/cw-fund-distributor/Cargo.toml index 558478ae3..f73aba4df 100644 --- a/contracts/external/cw-fund-distributor/Cargo.toml +++ b/contracts/distribution/cw-fund-distributor/Cargo.toml @@ -5,7 +5,7 @@ description = "A CosmWasm contract for distributing funds to DAO members based o edition = { workspace = true } license = { workspace = true } repository = { workspace = true } -version = "0.1.0" +version = { workspace = true } [lib] crate-type = ["cdylib", "rlib"] diff --git a/contracts/external/cw-fund-distributor/README.md b/contracts/distribution/cw-fund-distributor/README.md similarity index 100% rename from contracts/external/cw-fund-distributor/README.md rename to contracts/distribution/cw-fund-distributor/README.md diff --git a/contracts/external/cw-fund-distributor/examples/schema.rs b/contracts/distribution/cw-fund-distributor/examples/schema.rs similarity index 100% rename from contracts/external/cw-fund-distributor/examples/schema.rs rename to contracts/distribution/cw-fund-distributor/examples/schema.rs diff --git a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json b/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json similarity index 99% rename from contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json rename to contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json index 19a0541ca..02b368e07 100644 --- a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json +++ b/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json @@ -1,6 +1,6 @@ { "contract_name": "cw-fund-distributor", - "contract_version": "0.1.0", + "contract_version": "2.4.2", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-fund-distributor/src/contract.rs b/contracts/distribution/cw-fund-distributor/src/contract.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/contract.rs rename to contracts/distribution/cw-fund-distributor/src/contract.rs diff --git a/contracts/external/cw-fund-distributor/src/error.rs b/contracts/distribution/cw-fund-distributor/src/error.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/error.rs rename to contracts/distribution/cw-fund-distributor/src/error.rs diff --git a/contracts/external/cw-fund-distributor/src/lib.rs b/contracts/distribution/cw-fund-distributor/src/lib.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/lib.rs rename to contracts/distribution/cw-fund-distributor/src/lib.rs diff --git a/contracts/external/cw-fund-distributor/src/msg.rs b/contracts/distribution/cw-fund-distributor/src/msg.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/msg.rs rename to contracts/distribution/cw-fund-distributor/src/msg.rs diff --git a/contracts/external/cw-fund-distributor/src/state.rs b/contracts/distribution/cw-fund-distributor/src/state.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/state.rs rename to contracts/distribution/cw-fund-distributor/src/state.rs diff --git a/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs b/contracts/distribution/cw-fund-distributor/src/testing/adversarial_tests.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs rename to contracts/distribution/cw-fund-distributor/src/testing/adversarial_tests.rs diff --git a/contracts/external/cw-fund-distributor/src/testing/mod.rs b/contracts/distribution/cw-fund-distributor/src/testing/mod.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/testing/mod.rs rename to contracts/distribution/cw-fund-distributor/src/testing/mod.rs diff --git a/contracts/external/cw-fund-distributor/src/testing/tests.rs b/contracts/distribution/cw-fund-distributor/src/testing/tests.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/testing/tests.rs rename to contracts/distribution/cw-fund-distributor/src/testing/tests.rs diff --git a/contracts/distribution/dao-rewards-distributor/Cargo.toml b/contracts/distribution/dao-rewards-distributor/Cargo.toml new file mode 100644 index 000000000..0df6ef1d5 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "dao-rewards-distributor" +authors = ["Ben2x4 ", "ekez ", "Jake Hartnell ", "bekauz "] +description = "Distributes rewards based on DAO membership." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +cw4 = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw-controllers = { workspace = true } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +dao-hooks = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } +cw20-stake = { workspace = true, features = ["library"] } +cw4-group = { workspace = true, features = ["library"] } +cw721-base = { workspace = true, features = ["library"] } +dao-voting-cw20-staked = { workspace = true, features = ["library"] } +dao-voting-cw4 = { workspace = true, features = ["library"] } +dao-voting-token-staked = { workspace = true, features = ["library"] } +dao-voting-cw721-staked = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md new file mode 100644 index 000000000..a2e355d14 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -0,0 +1,31 @@ +# DAO Rewards Distributor + +[![dao-rewards-distributor on crates.io](https://img.shields.io/crates/v/dao-rewards-distributor.svg?logo=rust)](https://crates.io/crates/dao-rewards-distributor) +[![docs.rs](https://img.shields.io/docsrs/dao-rewards-distributor?logo=docsdotrs)](https://docs.rs/dao-rewards-distributor/latest/cw20_stake_external_rewards/) + +The `dao-rewards-distributor` works in conjuction with DAO voting modules to provide rewards over time for DAO members. The contract supports both cw20 and native Cosmos SDK tokens. The following voting power modules are supported: +- `dao-voting-cw4`: for membership or group based DAOs +- `dao-voting-cw20-staked`: for cw20 token based DAOs. +- `dao-voting-cw721-staked`: for NFT based DAOs. +- `dao-voting-token-staked`: for native and Token Factory token based DAOs. + +NOTE: this contract is NOT AUDITED and is _experimental_. USE AT YOUR OWN RISK. + +## Instantiation and Setup + +The contract is instantiated with a number of parameters: +- `owner`: The owner of the contract. Is able to fund the contract and update the reward duration. +- `vp_contract`: A DAO DAO voting power module contract address, used to determine membership in the DAO over time. +- `hook_caller`: An optional contract that is allowed to call voting power change hooks. Often, as in `dao-voting-token-staked` and `dao-voting-cw721-staked` the vp_contract calls hooks for power change events, but sometimes they are separate. For example, the `cw4-group` contract is separate from the `dao-voting-cw4` contract and since the `cw4-group` contract fires the membership change events, it's address would be used as the `hook_caller`. +- `reward_denom`: the denomination of the reward token, can be either a cw20 or native token. +- `reward_duration`: the time period over which rewards are to be paid out in blocks. + +After instantiating the contract it is VITAL to setup the required hooks for it to work. This is because to pay out rewards accurately, this contract needs to know about staking or voting power changes in the DAO. + +This can be achieved using the `add_hook` method on contracts that support voting power changes, which are: +- `cw4-group` +- `dao-voting-cw721-staked` +- `dao-voting-token-staked` +- `cw20-stake` + +Finally, the contract needs to be funded with a token matching the denom specified in the `reward_denom` field during instantiation. This can be achieved by calling the `fund` method on the `dao-rewards-distributor` smart contract, and sending along the appropriate funds. diff --git a/contracts/distribution/dao-rewards-distributor/examples/schema.rs b/contracts/distribution/dao-rewards-distributor/examples/schema.rs new file mode 100644 index 000000000..fa0fd072e --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_rewards_distributor::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json new file mode 100644 index 000000000..e7d00a62b --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -0,0 +1,1360 @@ +{ + "contract_name": "dao-rewards-distributor", + "contract_version": "2.4.2", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "properties": { + "owner": { + "description": "The owner of the contract. Is able to fund the contract and update the reward duration.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Called when a member is added or removed to a cw4-groups or cw721-roles contract.", + "type": "object", + "required": [ + "member_changed_hook" + ], + "properties": { + "member_changed_hook": { + "$ref": "#/definitions/MemberChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when NFTs are staked or unstaked.", + "type": "object", + "required": [ + "nft_stake_change_hook" + ], + "properties": { + "nft_stake_change_hook": { + "$ref": "#/definitions/NftStakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when tokens are staked or unstaked.", + "type": "object", + "required": [ + "stake_change_hook" + ], + "properties": { + "stake_change_hook": { + "$ref": "#/definitions/StakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Claims rewards for the sender.", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to fund this contract with cw20 tokens.", + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Used to fund this contract with native tokens.", + "type": "object", + "required": [ + "fund" + ], + "properties": { + "fund": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "shuts down the rewards distributor. withdraws all future staking rewards back to the treasury. members can claim whatever they earned until this point.", + "type": "object", + "required": [ + "shutdown" + ], + "properties": { + "shutdown": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "registers a new reward denom", + "type": "object", + "required": [ + "register_reward_denom" + ], + "properties": { + "register_reward_denom": { + "type": "object", + "required": [ + "denom", + "emission_rate", + "hook_caller", + "vp_contract" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + }, + "emission_rate": { + "$ref": "#/definitions/RewardEmissionRate" + }, + "hook_caller": { + "type": "string" + }, + "vp_contract": { + "type": "string" + }, + "withdraw_destination": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update the contract's ownership. The `action` to be provided can be either to propose transferring ownership to an account, accept a pending ownership transfer, or renounce the ownership permanently.", + "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" + ] + } + ] + }, + "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" + }, + "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" + }, + "Cw20ReceiveMsg": { + "description": "Cw20ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", + "type": "object", + "required": [ + "amount", + "msg", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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": [ + { + "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 + } + ] + }, + "MemberChangedHookMsg": { + "description": "MemberChangedHookMsg should be de/serialized under `MemberChangedHook()` variant in a ExecuteMsg. This contains a list of all diffs on the given transaction.", + "type": "object", + "required": [ + "diffs" + ], + "properties": { + "diffs": { + "type": "array", + "items": { + "$ref": "#/definitions/MemberDiff" + } + } + }, + "additionalProperties": false + }, + "MemberDiff": { + "description": "MemberDiff shows the old and new states for a given cw4 member They cannot both be None. old = None, new = Some -> Insert old = Some, new = Some -> Update old = Some, new = None -> Delete", + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "new": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "old": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "NftStakeChangedHookMsg": { + "description": "An enum representing NFT staking hooks.", + "oneOf": [ + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "addr", + "token_id" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "addr", + "token_ids" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "RewardEmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "additionalProperties": false + }, + "StakeChangedHookMsg": { + "description": "An enum representing staking hooks.", + "oneOf": [ + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "addr", + "amount" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "addr", + "amount" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + ] + }, + "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": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns contract version info", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the state of the registered reward distributions.", + "type": "object", + "required": [ + "rewards_state" + ], + "properties": { + "rewards_state": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the pending rewards for the given address.", + "type": "object", + "required": [ + "get_pending_rewards" + ], + "properties": { + "get_pending_rewards": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns information about the ownership of this contract.", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "denom_reward_state" + ], + "properties": { + "denom_reward_state": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "string", + "enum": [] + }, + "sudo": null, + "responses": { + "denom_reward_state": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DenomRewardState", + "description": "the state of a denom's reward distribution", + "type": "object", + "required": [ + "denom", + "emission_rate", + "ends_at", + "funded_amount", + "hook_caller", + "last_update", + "started_at", + "total_earned_puvp", + "vp_contract", + "withdraw_destination" + ], + "properties": { + "denom": { + "description": "validated denom (native or cw20)", + "allOf": [ + { + "$ref": "#/definitions/Denom" + } + ] + }, + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "funded_amount": { + "description": "total amount of rewards funded", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "last_update": { + "description": "time when total_earned_puvp was last updated for this denom", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "started_at": { + "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "total_earned_puvp": { + "description": "total rewards earned per unit voting power from started_at to last_update", + "allOf": [ + { + "$ref": "#/definitions/Uint256" + } + ] + }, + "vp_contract": { + "description": "address to query the voting power", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "withdraw_destination": { + "description": "optional destination address for reward clawbacks", + "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" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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": [ + { + "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 + } + ] + }, + "RewardEmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "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" + } + ] + }, + "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" + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 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 out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "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" + } + } + }, + "get_pending_rewards": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingRewardsResponse", + "type": "object", + "required": [ + "address", + "pending_rewards" + ], + "properties": { + "address": { + "type": "string" + }, + "pending_rewards": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Uint128" + } + } + }, + "additionalProperties": false, + "definitions": { + "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 + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "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.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "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" + }, + "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_state": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RewardsStateResponse", + "type": "object", + "required": [ + "rewards" + ], + "properties": { + "rewards": { + "type": "array", + "items": { + "$ref": "#/definitions/DenomRewardState" + } + } + }, + "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" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "DenomRewardState": { + "description": "the state of a denom's reward distribution", + "type": "object", + "required": [ + "denom", + "emission_rate", + "ends_at", + "funded_amount", + "hook_caller", + "last_update", + "started_at", + "total_earned_puvp", + "vp_contract", + "withdraw_destination" + ], + "properties": { + "denom": { + "description": "validated denom (native or cw20)", + "allOf": [ + { + "$ref": "#/definitions/Denom" + } + ] + }, + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "funded_amount": { + "description": "total amount of rewards funded", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "last_update": { + "description": "time when total_earned_puvp was last updated for this denom", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "started_at": { + "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "total_earned_puvp": { + "description": "total rewards earned per unit voting power from started_at to last_update", + "allOf": [ + { + "$ref": "#/definitions/Uint256" + } + ] + }, + "vp_contract": { + "description": "address to query the voting power", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "withdraw_destination": { + "description": "optional destination address for reward clawbacks", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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": [ + { + "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 + } + ] + }, + "RewardEmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "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" + } + ] + }, + "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" + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 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 out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "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" + } + } + } + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs new file mode 100644 index 000000000..83d4d868b --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -0,0 +1,648 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, ensure, from_json, to_json_binary, Addr, BankMsg, Binary, CosmosMsg, Decimal, Deps, + DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128, Uint256, WasmMsg, +}; +use cw2::{get_contract_version, set_contract_version}; +use cw20::{Cw20ReceiveMsg, Denom, UncheckedDenom}; +use cw_utils::{one_coin, Duration, Expiration}; +use dao_interface::voting::{ + InfoResponse, Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use std::collections::HashMap; +use std::convert::TryInto; + +use crate::hooks::{ + execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, + subscribe_denom_to_hook, +}; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RewardEmissionRate, + RewardsStateResponse, +}; +use crate::state::{DenomRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::ContractError; + +const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // Intialize the contract owner + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + + Ok(Response::new().add_attribute("owner", msg.owner.unwrap_or_else(|| "None".to_string()))) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), + ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), + ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), + ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), + ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), + ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + ExecuteMsg::Shutdown { denom } => execute_shutdown(deps, info, env, denom), + ExecuteMsg::RegisterRewardDenom { + denom, + emission_rate, + vp_contract, + hook_caller, + withdraw_destination, + } => execute_register_reward_denom( + deps, + info, + denom, + emission_rate, + vp_contract, + hook_caller, + withdraw_destination, + ), + } +} + +/// registers a new denom for rewards distribution. +/// only the owner can register a new denom. +/// a denom can only be registered once; update if you need to change something. +fn execute_register_reward_denom( + deps: DepsMut, + info: MessageInfo, + denom: UncheckedDenom, + emission_rate: RewardEmissionRate, + vp_contract: String, + hook_caller: String, + withdraw_destination: Option, +) -> Result { + // only the owner can register a new denom + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + emission_rate.validate_emission_time_window()?; + + let checked_denom = denom.into_checked(deps.as_ref())?; + let hook_caller = deps.api.addr_validate(&hook_caller)?; + let vp_contract = validate_voting_power_contract(&deps, vp_contract)?; + + let withdraw_destination = match withdraw_destination { + // if withdraw destination is specified, we validate it + Some(addr) => deps.api.addr_validate(&addr)?, + // otherwise default to the owner + None => info.sender, + }; + + // Initialize the reward state + let reward_state = DenomRewardState { + denom: checked_denom, + started_at: Expiration::Never {}, + ends_at: Expiration::Never {}, + emission_rate, + total_earned_puvp: Uint256::zero(), + last_update: Expiration::Never {}, + vp_contract, + hook_caller: hook_caller.clone(), + funded_amount: Uint128::zero(), + withdraw_destination, + }; + let str_denom = reward_state.to_str_denom(); + + // store the new reward denom state or error if it already exists + DENOM_REWARD_STATES.update( + deps.storage, + str_denom.to_string(), + |existing| match existing { + Some(_) => Err(ContractError::DenomAlreadyRegistered {}), + None => Ok(reward_state), + }, + )?; + + // update the registered hooks to include the new denom + subscribe_denom_to_hook(deps, str_denom, hook_caller.clone())?; + + Ok(Response::default()) +} + +/// shutdown the rewards distributor contract. +/// can only be called by the admin and only during the distribution period. +/// this will clawback all (undistributed) future rewards to the admin. +/// updates the period finish expiration to the current block. +fn execute_shutdown( + deps: DepsMut, + info: MessageInfo, + env: Env, + denom: String, +) -> Result { + // only the owner can initiate a shutdown + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; + + // shutdown is only possible during the distribution period + ensure!( + !reward_state.ends_at.is_expired(&env.block), + ContractError::ShutdownError("Reward period already finished".to_string()) + ); + + // we get the start and end scalar values in u64 (seconds/blocks) + let started_at = reward_state.get_started_at_scalar()?; + let ends_at = reward_state.get_ends_at_scalar()?; + let reward_duration = ends_at - started_at; + + // find the % of reward_duration that remains from current block + let passed_units_since_start = match reward_state.emission_rate.duration { + Duration::Height(_) => Uint128::from(env.block.height - started_at), + Duration::Time(_) => Uint128::from(env.block.time.seconds() - started_at), + }; + + // get the fraction of what part of rewards duration is in the past + // and sub from 1 to get the remaining rewards + let remaining_reward_duration_fraction = Decimal::one() + .checked_sub(Decimal::from_ratio( + passed_units_since_start, + reward_duration, + )) + .map_err(|e| ContractError::Std(StdError::overflow(e)))?; + + // to get the clawback msg + let clawback_msg = get_transfer_msg( + reward_state.withdraw_destination.clone(), + reward_state.funded_amount * remaining_reward_duration_fraction, + reward_state.denom.clone(), + )?; + + // shutdown completes the rewards + reward_state.ends_at = match reward_state.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }; + + DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; + + Ok(Response::new() + .add_attribute("action", "shutdown") + .add_message(clawback_msg)) +} + +fn execute_receive( + deps: DepsMut, + env: Env, + info: MessageInfo, + wrapper: Cw20ReceiveMsg, +) -> Result { + // verify msg + let _msg: ReceiveMsg = from_json(&wrapper.msg)?; + + let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; + execute_fund(deps, env, reward_denom_state, wrapper.amount) +} + +fn execute_fund_native( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let fund_coin = one_coin(&info).map_err(|_| ContractError::InvalidFunds {})?; + + let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, fund_coin.denom.clone())?; + + execute_fund(deps, env, reward_denom_state, fund_coin.amount) +} + +fn execute_fund( + deps: DepsMut, + env: Env, + mut denom_reward_state: DenomRewardState, + amount: Uint128, +) -> Result { + // we derive the period for which the rewards are funded + // by looking at the existing reward emission rate and the funded amount + let funded_period_duration = denom_reward_state + .emission_rate + .get_funded_period_duration(amount)?; + let funded_period_value = get_duration_scalar(&funded_period_duration); + + denom_reward_state = denom_reward_state + .bump_funding_date(&env.block) + .bump_last_update(&env.block); + + // the duration of rewards period is extended in different ways, + // depending on the current expiration state and current block + denom_reward_state.ends_at = match denom_reward_state.ends_at { + // if this is the first funding of the denom, the new expiration is the + // funded period duration from the current block + Expiration::Never {} => funded_period_duration.after(&env.block), + // otherwise we add the duration units to the existing expiration + Expiration::AtHeight(h) => { + if h <= env.block.height { + // expiration is the funded duration after current block + Expiration::AtHeight(env.block.height + funded_period_value) + } else { + // if the previous expiration had not yet expired, we extend + // the current rewards period by the newly funded duration + Expiration::AtHeight(h + funded_period_value) + } + } + Expiration::AtTime(t) => { + if t <= env.block.time { + // expiration is the funded duration after current block time + Expiration::AtTime(env.block.time.plus_seconds(funded_period_value)) + } else { + // if the previous expiration had not yet expired, we extend + // the current rewards period by the newly funded duration + Expiration::AtTime(t.plus_seconds(funded_period_value)) + } + } + }; + denom_reward_state.funded_amount += amount; + + DENOM_REWARD_STATES.save( + deps.storage, + denom_reward_state.to_str_denom(), + &denom_reward_state, + )?; + + Ok(Response::default()) +} + +fn execute_claim( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + denom: String, +) -> Result { + // update the rewards information for the sender. + update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; + + // get the denom state for the string-based denom + let denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; + + let mut amount = Uint128::zero(); + + USER_REWARD_STATES.update( + deps.storage, + info.sender.clone(), + |state| -> Result<_, ContractError> { + let mut user_reward_state = state.unwrap_or_default(); + // updating the map returns the previous value if it existed. + // we set the value to zero and store it in the amount defined before the update. + amount = user_reward_state + .pending_denom_rewards + .insert(denom, Uint128::zero()) + .unwrap_or_default(); + Ok(user_reward_state) + }, + )?; + + if amount.is_zero() { + return Err(ContractError::NoRewardsClaimable {}); + } + + Ok(Response::new() + .add_message(get_transfer_msg( + info.sender.clone(), + amount, + denom_reward_state.denom, + )?) + .add_attribute("action", "claim")) +} + +fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + // Update the current contract owner. + // Note, this is a two step process, the new owner must accept this ownership transfer. + // First the owner specifies the new owner, then the new owner must accept. + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) -> StdResult<()> { + let reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; + + // first, we calculate the latest total rewards per unit voting power + // and update them + let total_earned_puvp = get_total_earned_puvp(env, deps.as_ref(), &reward_state)?; + + // update the denom state's total rewards earned and last updated + DENOM_REWARD_STATES.update(deps.storage, denom.clone(), |state| -> StdResult<_> { + match state { + Some(mut rc) => { + rc.total_earned_puvp = total_earned_puvp; + Ok(rc.bump_last_update(&env.block)) + } + None => Err(StdError::generic_err("Denom reward state not found")), + } + })?; + + // then we calculate the rewards earned since last user action + let earned_rewards = get_accrued_rewards_since_last_user_action( + deps.as_ref(), + env, + addr, + total_earned_puvp, + &reward_state.vp_contract, + denom.clone(), + )?; + + // reflect the earned rewards in the user's reward state + USER_REWARD_STATES.update(deps.storage, addr.clone(), |state| -> StdResult<_> { + // if user does not yet have state, create a new one + let mut user_reward_state = state.unwrap_or_default(); + + // get the pre-existing pending reward amount for the denom + let previous_pending_denom_reward_amount = *user_reward_state + .pending_denom_rewards + .get(&denom) + .unwrap_or(&Uint128::zero()); + + // get the amount of newly earned rewards for the denom + let earned_rewards_amount = earned_rewards.get(&denom).cloned().unwrap_or_default(); + + user_reward_state.pending_denom_rewards.insert( + denom.clone(), + previous_pending_denom_reward_amount + earned_rewards_amount, + ); + + // update the user's earned rewards that have been accounted for + user_reward_state + .denom_rewards_puvp + .insert(denom.clone(), total_earned_puvp); + + Ok(user_reward_state) + })?; + Ok(()) +} + +/// Calculate the total rewards earned per unit voting power since the last +/// update. +fn get_total_earned_puvp( + env: &Env, + deps: Deps, + reward_state: &DenomRewardState, +) -> StdResult { + let curr = reward_state.total_earned_puvp; + + // query the total voting power just before this block from the voting power + // contract + let prev_total_power = get_prev_block_total_vp(deps, env, &reward_state.vp_contract)?; + + let last_time_rewards_distributed = + reward_state.get_latest_reward_distribution_time(&env.block); + + // get the duration from the last time rewards were updated to the last time + // rewards were distributed. this will be 0 if the rewards were updated at + // or after the last time rewards were distributed. + let new_reward_distribution_duration = Uint128::from(get_start_end_diff( + last_time_rewards_distributed, + reward_state.last_update, + )?); + + if prev_total_power.is_zero() { + Ok(curr) + } else { + let duration_value = get_duration_scalar(&reward_state.emission_rate.duration); + + // count intervals of the rewards emission that have passed since the + // last update which need to be distributed + let complete_distribution_periods = + new_reward_distribution_duration.checked_div(Uint128::from(duration_value))?; + + // It is impossible for this to overflow as total rewards can never + // exceed max value of Uint128 as total tokens in existence cannot + // exceed Uint128 (because the bank module Coin type uses Uint128). + let new_rewards_distributed = reward_state + .emission_rate + .amount + .full_mul(complete_distribution_periods) + .checked_mul(scale_factor())?; + + // the new rewards per unit voting power that have been distributed + // since the last update + let new_rewards_puvp = new_rewards_distributed.checked_div(prev_total_power.into())?; + Ok(curr + new_rewards_puvp) + } +} + +// get a user's rewards not yet accounted for in their reward state +fn get_accrued_rewards_since_last_user_action( + deps: Deps, + env: &Env, + addr: &Addr, + total_earned_puvp: Uint256, + vp_contract: &Addr, + denom: String, +) -> StdResult> { + // get the user's voting power at the current height + let voting_power = Uint256::from(get_voting_power(deps, env, vp_contract, addr)?); + + let mut accrued_rewards: HashMap = HashMap::new(); + + let user_reward_state = USER_REWARD_STATES + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + + // get previous reward per unit voting power accounted for + let user_last_reward_puvp = user_reward_state + .denom_rewards_puvp + .get(&denom) + .cloned() + .unwrap_or_default(); + + // calculate the difference between the current total reward per unit + // voting power distributed and the user's latest reward per unit voting + // power accounted for + let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; + + // calculate the amount of rewards earned: + // voting_power * reward_factor / scale_factor + let accrued_rewards_amount: Uint128 = voting_power + .checked_mul(reward_factor)? + .checked_div(scale_factor())? + .try_into()?; + + accrued_rewards.insert(denom.to_string(), accrued_rewards_amount); + + Ok(accrued_rewards) +} + +fn get_prev_block_total_vp(deps: Deps, env: &Env, contract_addr: &Addr) -> StdResult { + let msg = VotingQueryMsg::TotalPowerAtHeight { + height: Some(env.block.height.checked_sub(1).unwrap_or_default()), + }; + let resp: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.power) +} + +fn get_voting_power( + deps: Deps, + env: &Env, + contract_addr: &Addr, + addr: &Addr, +) -> StdResult { + let msg = VotingQueryMsg::VotingPowerAtHeight { + address: addr.into(), + height: Some(env.block.height), + }; + let resp: VotingPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.power) +} + +/// returns underlying scalar value for a given duration. +/// if the duration is in blocks, returns the block height. +/// if the duration is in time, returns the time in seconds. +fn get_duration_scalar(duration: &Duration) -> u64 { + match duration { + Duration::Height(h) => *h, + Duration::Time(t) => *t, + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => Ok(to_json_binary(&query_info(deps)?)?), + QueryMsg::RewardsState {} => Ok(to_json_binary(&query_rewards_state(deps, env)?)?), + QueryMsg::GetPendingRewards { address } => { + Ok(to_json_binary(&query_pending_rewards(deps, env, address)?)?) + } + QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), + QueryMsg::DenomRewardState { denom } => { + let state = DENOM_REWARD_STATES.load(deps.storage, denom)?; + Ok(to_json_binary(&state)?) + } + } +} + +fn query_info(deps: Deps) -> StdResult { + let info = get_contract_version(deps.storage)?; + Ok(InfoResponse { info }) +} + +fn query_rewards_state(deps: Deps, _env: Env) -> StdResult { + let rewards = DENOM_REWARD_STATES + .range(deps.storage, None, None, Order::Ascending) + .map(|item| item.map(|(_, v)| v)) + .collect::>>()?; + Ok(RewardsStateResponse { rewards }) +} + +fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult { + let addr = deps.api.addr_validate(&addr)?; + let reward_states = DENOM_REWARD_STATES + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + + let mut pending_rewards: HashMap = HashMap::new(); + + for (denom, reward_state) in reward_states { + let total_earned_puvp = get_total_earned_puvp(&env, deps, &reward_state)?; + + let earned_rewards = get_accrued_rewards_since_last_user_action( + deps, + &env, + &addr, + total_earned_puvp, + &reward_state.vp_contract, + denom.to_string(), + )?; + + let user_reward_state = USER_REWARD_STATES + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + + let default_amt = Uint128::zero(); + let earned_amount = earned_rewards.get(&denom).unwrap_or(&default_amt); + let existing_amount = user_reward_state + .pending_denom_rewards + .get(&denom) + .unwrap_or(&default_amt); + pending_rewards.insert(denom, *earned_amount + *existing_amount); + } + + let pending_rewards_response = PendingRewardsResponse { + address: addr.to_string(), + pending_rewards, + }; + Ok(pending_rewards_response) +} + +/// Returns the appropriate CosmosMsg for transferring the reward token. +fn get_transfer_msg(recipient: Addr, amount: Uint128, denom: Denom) -> StdResult { + match denom { + Denom::Native(denom) => Ok(BankMsg::Send { + to_address: recipient.into_string(), + amount: coins(amount.u128(), denom), + } + .into()), + Denom::Cw20(addr) => { + let cw20_msg = to_json_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: recipient.into_string(), + amount, + })?; + Ok(WasmMsg::Execute { + contract_addr: addr.into_string(), + msg: cw20_msg, + funds: vec![], + } + .into()) + } + } +} + +pub(crate) fn scale_factor() -> Uint256 { + Uint256::from(10u8).pow(39) +} + +/// Calculate the duration from start to end. If the end is at or before the +/// start, return 0. +fn get_start_end_diff(end: Expiration, start: Expiration) -> StdResult { + match (end, start) { + (Expiration::AtHeight(end), Expiration::AtHeight(start)) => { + if end > start { + Ok(end - start) + } else { + Ok(0) + } + } + (Expiration::AtTime(end), Expiration::AtTime(start)) => { + if end > start { + Ok(end.seconds() - start.seconds()) + } else { + Ok(0) + } + } + (Expiration::Never {}, Expiration::Never {}) => Ok(0), + _ => Err(StdError::generic_err(format!( + "incompatible expirations: got end {:?}, start {:?}", + end, start + ))), + } +} + +fn validate_voting_power_contract( + deps: &DepsMut, + vp_contract: String, +) -> Result { + let vp_contract = deps.api.addr_validate(&vp_contract)?; + let _: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart( + &vp_contract, + &VotingQueryMsg::TotalPowerAtHeight { height: None }, + )?; + Ok(vp_contract) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs new file mode 100644 index 000000000..68e661bb2 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -0,0 +1,41 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Ownable(#[from] cw_ownable::OwnershipError), + + #[error(transparent)] + Cw20Error(#[from] cw20_base::ContractError), + + #[error("Invalid Cw20")] + InvalidCw20 {}, + + #[error("Invalid funds")] + InvalidFunds {}, + + #[error("Staking change hook sender is not staking contract")] + InvalidHookSender {}, + + #[error("No rewards claimable")] + NoRewardsClaimable {}, + + #[error("Reward period not finished")] + RewardPeriodNotFinished {}, + + #[error("Reward rate less then one per block")] + RewardRateLessThenOnePerBlock {}, + + #[error("Reward duration can not be zero")] + ZeroRewardDuration {}, + + #[error("Rewards distributor shutdown error: {0}")] + ShutdownError(String), + + #[error("Denom already registered")] + DenomAlreadyRegistered {}, +} diff --git a/contracts/distribution/dao-rewards-distributor/src/hooks.rs b/contracts/distribution/dao-rewards-distributor/src/hooks.rs new file mode 100644 index 000000000..d57185f12 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/hooks.rs @@ -0,0 +1,113 @@ +use cosmwasm_std::{Addr, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw4::MemberChangedHookMsg; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; + +use crate::{contract::update_rewards, state::REGISTERED_HOOK_DENOMS, ContractError}; + +/// Register a hook caller contract for a given denom. +pub(crate) fn subscribe_denom_to_hook( + deps: DepsMut, + denom: String, + hook: Addr, +) -> Result<(), ContractError> { + REGISTERED_HOOK_DENOMS.update(deps.storage, hook, |denoms| -> StdResult<_> { + let mut denoms = denoms.unwrap_or_default(); + denoms.push(denom.to_string()); + Ok(denoms) + })?; + Ok(()) +} + +/// Ensures hooks that update voting power are only called by a designated +/// hook_caller contract. +/// Returns a list of denoms that the hook caller is registered for. +pub(crate) fn get_hook_caller_registered_denoms( + deps: Deps, + info: MessageInfo, +) -> Result, ContractError> { + // only a designated hook_caller contract can call this hook. + // failing to load the registered denoms for a given hook returns an error. + REGISTERED_HOOK_DENOMS + .load(deps.storage, info.sender.clone()) + .map_err(|_| ContractError::InvalidHookSender {}) +} + +pub(crate) fn execute_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: StakeChangedHookMsg, +) -> Result { + // Check that the sender is the vp_contract (or the hook_caller if configured). + let hooked_denoms = get_hook_caller_registered_denoms(deps.as_ref(), info)?; + + match msg { + StakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr, hooked_denoms), + StakeChangedHookMsg::Unstake { addr, .. } => { + execute_unstake(deps, env, addr, hooked_denoms) + } + } +} + +pub(crate) fn execute_membership_changed( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: MemberChangedHookMsg, +) -> Result { + // Check that the sender is the vp_contract (or the hook_caller if configured). + let hooked_denoms = get_hook_caller_registered_denoms(deps.as_ref(), info)?; + + // Get the addresses of members whose voting power has changed. + for member in msg.diffs { + let addr = deps.api.addr_validate(&member.key)?; + for denom in hooked_denoms.clone() { + update_rewards(&mut deps, &env, &addr, denom)?; + } + } + + Ok(Response::new().add_attribute("action", "membership_changed")) +} + +pub(crate) fn execute_nft_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: NftStakeChangedHookMsg, +) -> Result { + // Check that the sender is the vp_contract (or the hook_caller if configured). + let hooked_denoms = get_hook_caller_registered_denoms(deps.as_ref(), info)?; + + match msg { + NftStakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr, hooked_denoms), + NftStakeChangedHookMsg::Unstake { addr, .. } => { + execute_unstake(deps, env, addr, hooked_denoms) + } + } +} + +pub(crate) fn execute_stake( + mut deps: DepsMut, + env: Env, + addr: Addr, + hooked_denoms: Vec, +) -> Result { + // update rewards for every denom that the hook caller is registered for + for denom in hooked_denoms { + update_rewards(&mut deps, &env, &addr, denom)?; + } + Ok(Response::new().add_attribute("action", "stake")) +} + +pub(crate) fn execute_unstake( + mut deps: DepsMut, + env: Env, + addr: Addr, + hooked_denoms: Vec, +) -> Result { + // update rewards for every denom that the hook caller is registered for + for denom in hooked_denoms { + update_rewards(&mut deps, &env, &addr, denom)?; + } + Ok(Response::new().add_attribute("action", "unstake")) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/lib.rs b/contracts/distribution/dao-rewards-distributor/src/lib.rs new file mode 100644 index 000000000..51ae5c619 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/lib.rs @@ -0,0 +1,12 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod hooks; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs new file mode 100644 index 000000000..e2d41c112 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -0,0 +1,141 @@ +use std::collections::HashMap; + +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{StdError, StdResult, Uint128, Uint256}; +use cw20::{Cw20ReceiveMsg, UncheckedDenom}; +use cw4::MemberChangedHookMsg; +use cw_ownable::cw_ownable_execute; +use cw_utils::Duration; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; +use dao_interface::voting::InfoResponse; + +use crate::{state::DenomRewardState, ContractError}; + +// so that consumers don't need a cw_ownable or cw_controllers dependency +// to consume this contract's queries. +pub use cw_controllers::ClaimsResponse; +pub use cw_ownable::Ownership; + +#[cw_serde] +pub struct InstantiateMsg { + /// The owner of the contract. Is able to fund the contract and update + /// the reward duration. + pub owner: Option, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Called when a member is added or removed + /// to a cw4-groups or cw721-roles contract. + MemberChangedHook(MemberChangedHookMsg), + /// Called when NFTs are staked or unstaked. + NftStakeChangeHook(NftStakeChangedHookMsg), + /// Called when tokens are staked or unstaked. + StakeChangeHook(StakeChangedHookMsg), + /// Claims rewards for the sender. + Claim { denom: String }, + /// Used to fund this contract with cw20 tokens. + Receive(Cw20ReceiveMsg), + /// Used to fund this contract with native tokens. + Fund {}, + /// shuts down the rewards distributor. withdraws all future staking rewards + /// back to the treasury. members can claim whatever they earned until this point. + Shutdown { denom: String }, + /// registers a new reward denom + RegisterRewardDenom { + denom: UncheckedDenom, + emission_rate: RewardEmissionRate, + vp_contract: String, + hook_caller: String, + withdraw_destination: Option, + }, +} + +/// defines how many tokens (amount) should be distributed per amount of time +/// (duration). e.g. 5udenom per hour. +#[cw_serde] +pub struct RewardEmissionRate { + pub amount: Uint128, + pub duration: Duration, +} + +impl RewardEmissionRate { + pub fn validate_emission_time_window(&self) -> Result<(), ContractError> { + // Reward duration must be greater than 0 + if let Duration::Height(0) | Duration::Time(0) = self.duration { + return Err(ContractError::ZeroRewardDuration {}); + } + Ok(()) + } + + // find the duration of the funded period given emission config and funded amount + pub fn get_funded_period_duration(&self, funded_amount: Uint128) -> StdResult { + let funded_amount_u256 = Uint256::from(funded_amount); + let amount_u256 = Uint256::from(self.amount); + let amount_to_emission_rate_ratio = funded_amount_u256.checked_div(amount_u256)?; + + let ratio_str = amount_to_emission_rate_ratio.to_string(); + let ratio = ratio_str + .parse::() + .map_err(|e| StdError::generic_err(e.to_string()))?; + + let funded_period_duration = match self.duration { + Duration::Height(h) => { + let duration_height = match ratio.checked_mul(h) { + Some(duration) => duration, + None => return Err(StdError::generic_err("overflow")), + }; + Duration::Height(duration_height) + } + Duration::Time(t) => { + let duration_time = match ratio.checked_mul(t) { + Some(duration) => duration, + None => return Err(StdError::generic_err("overflow")), + }; + Duration::Time(duration_time) + } + }; + + Ok(funded_period_duration) + } +} + +#[cw_serde] +pub enum MigrateMsg {} + +#[cw_serde] +pub enum ReceiveMsg { + /// Used to fund this contract with cw20 tokens. + Fund {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns contract version info + #[returns(InfoResponse)] + Info {}, + /// Returns the state of the registered reward distributions. + #[returns(RewardsStateResponse)] + RewardsState {}, + /// Returns the pending rewards for the given address. + #[returns(PendingRewardsResponse)] + GetPendingRewards { address: String }, + /// Returns information about the ownership of this contract. + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, + #[returns(DenomRewardState)] + DenomRewardState { denom: String }, +} + +#[cw_serde] +pub struct RewardsStateResponse { + pub rewards: Vec, +} + +#[cw_serde] +pub struct PendingRewardsResponse { + pub address: String, + pub pending_rewards: HashMap, +} diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs new file mode 100644 index 000000000..1489266a3 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -0,0 +1,163 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ensure, Addr, BlockInfo, StdError, StdResult, Uint128, Uint256}; +use cw20::{Denom, Expiration}; +use cw_storage_plus::Map; +use cw_utils::Duration; +use std::{cmp::min, collections::HashMap}; + +use crate::{msg::RewardEmissionRate, ContractError}; + +/// map user address to their unique reward state +pub const USER_REWARD_STATES: Map = Map::new("u_r_s"); + +/// map denom string to the state of its reward distribution +pub const DENOM_REWARD_STATES: Map = Map::new("d_r_s"); + +/// map registered hooks to list of denoms they're registered for +pub const REGISTERED_HOOK_DENOMS: Map> = Map::new("r_h_d"); + +#[cw_serde] +#[derive(Default)] +pub struct UserRewardState { + /// map denom to the user's pending rewards + pub pending_denom_rewards: HashMap, + /// map denom string to the user's earned rewards per unit voting power that + /// have already been accounted for in pending rewards and potentially + /// claimed + pub denom_rewards_puvp: HashMap, +} + +/// the state of a denom's reward distribution +#[cw_serde] +pub struct DenomRewardState { + /// validated denom (native or cw20) + pub denom: Denom, + /// the time when the current reward distribution period started. period + /// finishes iff it reaches its end. + pub started_at: Expiration, + /// the time when all funded rewards are allocated to users and thus the + /// distribution period ends. + pub ends_at: Expiration, + /// reward emission rate + pub emission_rate: RewardEmissionRate, + /// total rewards earned per unit voting power from started_at to + /// last_update + pub total_earned_puvp: Uint256, + /// time when total_earned_puvp was last updated for this denom + pub last_update: Expiration, + /// address to query the voting power + pub vp_contract: Addr, + /// address that will update the reward split when the voting power + /// distribution changes + pub hook_caller: Addr, + /// total amount of rewards funded + pub funded_amount: Uint128, + /// optional destination address for reward clawbacks + pub withdraw_destination: Addr, +} + +impl DenomRewardState { + pub fn bump_last_update(mut self, current_block: &BlockInfo) -> Self { + self.last_update = match self.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + self + } + + /// tries to update the last funding date. + /// if distribution expiration is in the future, nothing changes. + /// if distribution expiration is in the past, or had never been set, + /// funding date becomes the current block. + pub fn bump_funding_date(mut self, current_block: &BlockInfo) -> Self { + // if its never been set before, we set it to current block and return + if let Expiration::Never {} = self.started_at { + self.started_at = match self.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + return self; + } + + // if current distribution is expired, we set the funding date + // to the current date + if self.ends_at.is_expired(current_block) { + self.started_at = match self.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + } + + self + } + + pub fn to_str_denom(&self) -> String { + match &self.denom { + Denom::Native(denom) => denom.to_string(), + Denom::Cw20(address) => address.to_string(), + } + } + + /// Returns the ends_at time value as a u64. + /// - If `Never`, returns an error. + /// - If `AtHeight(h)`, the value is `h`. + /// - If `AtTime(t)`, the value is `t`, where t is seconds. + pub fn get_ends_at_scalar(&self) -> StdResult { + match self.ends_at { + Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), + Expiration::AtHeight(h) => Ok(h), + Expiration::AtTime(t) => Ok(t.seconds()), + } + } + + /// Returns the started_at time value as a u64. + /// - If `Never`, returns an error. + /// - If `AtHeight(h)`, the value is `h`. + /// - If `AtTime(t)`, the value is `t`, where t is seconds. + pub fn get_started_at_scalar(&self) -> StdResult { + match self.started_at { + Expiration::AtHeight(h) => Ok(h), + Expiration::AtTime(t) => Ok(t.seconds()), + Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), + } + } + + /// Returns the latest time when rewards were distributed. Works by + /// comparing `current_block` with the distribution end time: + /// - If the end is `Never`, then no rewards are being distributed, thus we + /// return `Never`. + /// - If the end is `AtHeight(h)` or `AtTime(t)`, we compare the current + /// block height or time with `h` or `t` respectively. + /// - If current block respective value is before the end, rewards are still + /// being distributed. We therefore return the current block `height` or + /// `time`, as this block is the most recent time rewards were distributed. + /// - If current block respective value is after the end, rewards are no + /// longer being distributed. We therefore return the end `height` or + /// `time`, as that was the last date where rewards were distributed. + pub fn get_latest_reward_distribution_time(&self, current_block: &BlockInfo) -> Expiration { + match self.ends_at { + Expiration::Never {} => Expiration::Never {}, + Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), + Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), + } + } + + /// Returns `ContractError::RewardPeriodNotFinished` if the period finish + /// expiration is of either `AtHeight` or `AtTime` variant and is earlier + /// than the current block height or time respectively. + pub fn validate_period_finish_expiration_if_set( + &self, + current_block: &BlockInfo, + ) -> Result<(), ContractError> { + match self.ends_at { + Expiration::AtHeight(_) | Expiration::AtTime(_) => { + ensure!( + self.ends_at.is_expired(current_block), + ContractError::RewardPeriodNotFinished {} + ); + Ok(()) + } + Expiration::Never {} => Ok(()), + } + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs new file mode 100644 index 000000000..f0b1d66ab --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs @@ -0,0 +1,298 @@ +use cosmwasm_std::Empty; +use cw_multi_test::{Contract, ContractWrapper}; + +pub mod suite; +pub mod tests; + +pub const DENOM: &str = "ujuno"; +pub const ALT_DENOM: &str = "unotjuno"; +pub const OWNER: &str = "owner"; +pub const ADDR1: &str = "addr0001"; +pub const ADDR2: &str = "addr0002"; +pub const ADDR3: &str = "addr0003"; + +pub fn contract_rewards() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +mod cw4_setup { + use cosmwasm_std::Addr; + use cw4::Member; + use cw_multi_test::{App, Executor}; + use dao_testing::contracts::{cw4_group_contract, dao_voting_cw4_contract}; + + use super::OWNER; + + pub fn setup_cw4_test(app: &mut App, initial_members: Vec) -> (Addr, Addr) { + let cw4_group_code_id = app.store_code(cw4_group_contract()); + let vp_code_id = app.store_code(dao_voting_cw4_contract()); + + let msg = dao_voting_cw4::msg::InstantiateMsg { + group_contract: dao_voting_cw4::msg::GroupContract::New { + cw4_group_code_id, + initial_members, + }, + }; + + let vp_addr = app + .instantiate_contract( + vp_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "cw4-vp", + None, + ) + .unwrap(); + + let cw4_addr: Addr = app + .wrap() + .query_wasm_smart( + vp_addr.clone(), + &dao_voting_cw4::msg::QueryMsg::GroupContract {}, + ) + .unwrap(); + + (vp_addr, cw4_addr) + } +} + +mod native_setup { + use cosmwasm_std::{coins, Addr}; + use cw_multi_test::{App, Executor}; + use dao_testing::contracts::native_staked_balances_voting_contract; + + use super::{DENOM, OWNER}; + + pub fn stake_tokenfactory_tokens( + app: &mut App, + staking_addr: &Addr, + address: &str, + amount: u128, + ) { + let msg = dao_voting_token_staked::msg::ExecuteMsg::Stake {}; + app.execute_contract( + Addr::unchecked(address), + staking_addr.clone(), + &msg, + &coins(amount, DENOM), + ) + .unwrap(); + } + + pub fn unstake_tokenfactory_tokens( + app: &mut App, + staking_addr: &Addr, + address: &str, + amount: u128, + ) { + let msg = dao_voting_token_staked::msg::ExecuteMsg::Unstake { + amount: amount.into(), + }; + app.execute_contract(Addr::unchecked(address), staking_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn setup_native_token_test(app: &mut App) -> Addr { + let vp_code_id = app.store_code(native_staked_balances_voting_contract()); + + let msg = dao_voting_token_staked::msg::InstantiateMsg { + active_threshold: None, + unstaking_duration: None, + token_info: dao_voting_token_staked::msg::TokenInfo::Existing { + denom: DENOM.to_string(), + }, + }; + + app.instantiate_contract( + vp_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "native-vp", + None, + ) + .unwrap() + } +} + +mod cw20_setup { + use cosmwasm_std::{to_json_binary, Addr, Uint128}; + use cw20::Cw20Coin; + use cw_multi_test::{App, Executor}; + use cw_utils::Duration; + use dao_testing::contracts::{ + cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, + }; + + use super::OWNER; + + pub fn instantiate_cw20(app: &mut App, name: &str, initial_balances: Vec) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let msg = cw20_base::msg::InstantiateMsg { + name: name.to_string(), + symbol: name.to_string(), + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }; + + app.instantiate_contract(cw20_id, Addr::unchecked(OWNER), &msg, &[], "cw20", None) + .unwrap() + } + + pub fn instantiate_cw20_staking( + app: &mut App, + cw20: Addr, + unstaking_duration: Option, + ) -> Addr { + let staking_code_id = app.store_code(cw20_stake_contract()); + let msg = cw20_stake::msg::InstantiateMsg { + owner: Some(OWNER.to_string()), + token_address: cw20.to_string(), + unstaking_duration, + }; + app.instantiate_contract( + staking_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "staking", + None, + ) + .unwrap() + } + + pub fn instantiate_cw20_vp_contract(app: &mut App, cw20: Addr, staking_contract: Addr) -> Addr { + let vp_code_id = app.store_code(cw20_staked_balances_voting_contract()); + let msg = dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::Existing { + address: cw20.to_string(), + staking_contract: dao_voting_cw20_staked::msg::StakingInfo::Existing { + staking_contract_address: staking_contract.to_string(), + }, + }, + active_threshold: None, + }; + app.instantiate_contract(vp_code_id, Addr::unchecked(OWNER), &msg, &[], "vp", None) + .unwrap() + } + + pub fn setup_cw20_test(app: &mut App, initial_balances: Vec) -> (Addr, Addr, Addr) { + // Instantiate cw20 contract + let cw20_addr = instantiate_cw20(app, "test", initial_balances.clone()); + + // Instantiate staking contract + let staking_addr = instantiate_cw20_staking(app, cw20_addr.clone(), None); + + // Instantiate vp contract + let vp_addr = instantiate_cw20_vp_contract(app, cw20_addr.clone(), staking_addr.clone()); + + (staking_addr, cw20_addr, vp_addr) + } + + #[allow(dead_code)] + pub fn stake_cw20_tokens>( + app: &mut App, + staking_addr: &Addr, + cw20_addr: &Addr, + sender: T, + amount: u128, + ) { + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(amount), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(sender), cw20_addr.clone(), &msg, &[]) + .unwrap(); + } +} + +mod cw721_setup { + + use cosmwasm_std::{to_json_binary, Addr, Binary, Empty}; + use cw_multi_test::{App, Executor}; + use dao_testing::contracts::{cw721_base_contract, voting_cw721_staked_contract}; + use dao_voting_cw721_staked::state::Config; + + use super::OWNER; + + pub fn stake_cw721( + app: &mut App, + vp_addr: &Addr, + cw721_addr: &Addr, + address: &str, + token_id: &str, + ) { + let msg = cw721_base::msg::ExecuteMsg::::SendNft { + contract: vp_addr.to_string(), + token_id: token_id.to_string(), + msg: Binary::default(), + }; + + app.execute_contract(Addr::unchecked(address), cw721_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn unstake_cw721(app: &mut App, vp_addr: &Addr, address: &str, token_id: &str) { + app.execute_contract( + Addr::unchecked(address), + vp_addr.clone(), + &dao_voting_cw721_staked::msg::ExecuteMsg::Unstake { + token_ids: vec![token_id.to_string()], + }, + &[], + ) + .unwrap(); + } + + pub fn setup_cw721_test(app: &mut App, initial_nfts: Vec) -> (Addr, Addr) { + let cw721_code_id = app.store_code(cw721_base_contract()); + let vp_code_id = app.store_code(voting_cw721_staked_contract()); + + let msg = dao_voting_cw721_staked::msg::InstantiateMsg { + nft_contract: dao_voting_cw721_staked::msg::NftContract::New { + code_id: cw721_code_id, + label: "Test NFT contract".to_string(), + msg: to_json_binary(&cw721_base::msg::InstantiateMsg { + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + minter: OWNER.to_string(), + }) + .unwrap(), + initial_nfts, + }, + active_threshold: None, + unstaking_duration: None, + }; + + let vp_addr = app + .instantiate_contract( + vp_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "cw721-vp", + None, + ) + .unwrap(); + + let cw721_addr = app + .wrap() + .query_wasm_smart::( + vp_addr.clone(), + &dao_voting_cw721_staked::msg::QueryMsg::Config {}, + ) + .unwrap() + .nft_address; + + (vp_addr, cw721_addr) + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs new file mode 100644 index 000000000..2deb7b8f2 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -0,0 +1,720 @@ +use std::borrow::BorrowMut; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{coin, coins, to_json_binary, Addr, Coin, Empty, Timestamp, Uint128}; +use cw20::{Cw20Coin, Expiration, UncheckedDenom}; +use cw20_stake::msg::ReceiveMsg; +use cw4::{Member, MemberListResponse}; +use cw_multi_test::{App, BankSudo, Executor, SudoMsg}; +use cw_ownable::{Action, Ownership}; +use cw_utils::Duration; + +use crate::{ + msg::{ + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RewardEmissionRate, + RewardsStateResponse, + }, + state::DenomRewardState, + testing::cw20_setup::instantiate_cw20, +}; + +use super::{ + contract_rewards, + cw20_setup::{self, setup_cw20_test}, + cw4_setup::setup_cw4_test, + cw721_setup::{setup_cw721_test, stake_cw721, unstake_cw721}, + native_setup::{ + setup_native_token_test, stake_tokenfactory_tokens, unstake_tokenfactory_tokens, + }, + ADDR1, ADDR2, ADDR3, DENOM, OWNER, +}; + +pub enum DaoType { + CW20, + CW721, + Native, + CW4, +} + +#[cw_serde] +pub struct RewardsConfig { + pub amount: u128, + pub denom: UncheckedDenom, + pub duration: Duration, + pub destination: Option, +} + +pub struct SuiteBuilder { + pub _instantiate: InstantiateMsg, + pub dao_type: DaoType, + pub rewards_config: RewardsConfig, +} + +impl SuiteBuilder { + pub fn base(dao_type: DaoType) -> Self { + Self { + _instantiate: InstantiateMsg { + owner: Some(OWNER.to_string()), + }, + dao_type, + rewards_config: RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Height(10), + destination: None, + }, + } + } + + pub fn with_rewards_config(mut self, rewards_config: RewardsConfig) -> Self { + self.rewards_config = rewards_config; + self + } + + pub fn with_withdraw_destination(mut self, withdraw_destination: Option) -> Self { + self.rewards_config.destination = withdraw_destination; + self + } +} + +impl SuiteBuilder { + pub fn build(mut self) -> Suite { + let owner = Addr::unchecked(OWNER); + + let mut suite_built = Suite { + app: App::default(), + owner: Some(owner.clone()), + staking_addr: Addr::unchecked(""), + voting_power_addr: Addr::unchecked(""), + distribution_contract: Addr::unchecked(""), + cw20_addr: Addr::unchecked(""), + reward_denom: DENOM.to_string(), + }; + + // start at 0 height and time + suite_built.app.borrow_mut().update_block(|b| b.height = 0); + suite_built + .app + .borrow_mut() + .update_block(|b| b.time = Timestamp::from_seconds(0)); + + match self.dao_type { + DaoType::CW4 => { + let members = vec![ + Member { + addr: ADDR1.to_string(), + weight: 2, + }, + Member { + addr: ADDR2.to_string(), + weight: 1, + }, + Member { + addr: ADDR3.to_string(), + weight: 1, + }, + ]; + + let (voting_power_addr, dao_voting_addr) = + setup_cw4_test(suite_built.app.borrow_mut(), members); + suite_built.voting_power_addr = voting_power_addr.clone(); + suite_built.staking_addr = dao_voting_addr.clone(); + } + DaoType::CW20 => { + let initial_balances = vec![ + Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(100), + }, + Cw20Coin { + address: ADDR2.to_string(), + amount: Uint128::new(50), + }, + Cw20Coin { + address: ADDR3.to_string(), + amount: Uint128::new(50), + }, + ]; + + let (staking_addr, cw20_addr, vp_addr) = + setup_cw20_test(suite_built.app.borrow_mut(), initial_balances.clone()); + + suite_built.voting_power_addr = vp_addr.clone(); + suite_built.cw20_addr = cw20_addr.clone(); + suite_built.staking_addr = staking_addr.clone(); + + for coin in initial_balances.clone() { + suite_built.stake_cw20_tokens(coin.amount.u128(), coin.address.as_str()); + } + } + DaoType::CW721 => { + let initial_nfts = vec![ + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "1".to_string(), + owner: ADDR1.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "2".to_string(), + owner: ADDR1.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "3".to_string(), + owner: ADDR2.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "4".to_string(), + owner: ADDR3.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + ]; + + let (vp_addr, cw721) = setup_cw721_test(suite_built.app.borrow_mut(), initial_nfts); + + suite_built.voting_power_addr = vp_addr.clone(); + suite_built.staking_addr = cw721.clone(); + + suite_built.stake_nft(ADDR1, 1); + suite_built.stake_nft(ADDR1, 2); + suite_built.stake_nft(ADDR2, 3); + suite_built.stake_nft(ADDR3, 4); + } + DaoType::Native => { + let initial_balances = vec![ + (ADDR1, coins(100, DENOM)), + (ADDR2, coins(50, DENOM)), + (ADDR3, coins(50, DENOM)), + ]; + + // Mint tokens for initial balances + for init_bal in initial_balances { + suite_built + .app + .borrow_mut() + .sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: init_bal.0.to_string(), + amount: init_bal.1, + } + })) + .unwrap(); + } + + // Create Native token staking contract + let vp_addr = setup_native_token_test(suite_built.app.borrow_mut()); + suite_built.voting_power_addr = vp_addr.clone(); + suite_built.staking_addr = vp_addr.clone(); + suite_built.stake_native_tokens(ADDR1, 100); + suite_built.stake_native_tokens(ADDR2, 50); + suite_built.stake_native_tokens(ADDR3, 50); + } + }; + + // initialize the rewards distributor + let reward_code_id = suite_built.app.borrow_mut().store_code(contract_rewards()); + let reward_addr = suite_built + .app + .borrow_mut() + .instantiate_contract( + reward_code_id, + owner.clone(), + &InstantiateMsg { + owner: Some(owner.clone().into_string()), + }, + &[], + "reward", + None, + ) + .unwrap(); + suite_built.distribution_contract = reward_addr.clone(); + + // depending on the dao type we register rewards differently + match self.dao_type { + DaoType::CW721 => { + suite_built.register_hook(suite_built.voting_power_addr.clone()); + suite_built.register_reward_denom( + self.rewards_config.clone(), + suite_built.voting_power_addr.to_string().as_ref(), + ); + match self.rewards_config.denom { + UncheckedDenom::Native(_) => { + suite_built.fund_distributor_native(coin(100_000_000, DENOM.to_string())); + } + UncheckedDenom::Cw20(_) => { + suite_built.fund_distributor_cw20(Cw20Coin { + address: suite_built.cw20_addr.to_string(), + amount: Uint128::new(100_000_000), + }); + } + }; + } + _ => { + self.rewards_config.denom = match self.rewards_config.denom { + UncheckedDenom::Native(denom) => UncheckedDenom::Native(denom), + UncheckedDenom::Cw20(_) => UncheckedDenom::Cw20( + instantiate_cw20( + suite_built.app.borrow_mut(), + "rewardcw", + vec![Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(1_000_000_000), + }], + ) + .to_string(), + ), + }; + suite_built.reward_denom = match self.rewards_config.denom.clone() { + UncheckedDenom::Native(denom) => denom, + UncheckedDenom::Cw20(addr) => addr, + }; + + suite_built.register_hook(suite_built.staking_addr.clone()); + suite_built.register_reward_denom( + self.rewards_config.clone(), + suite_built.staking_addr.to_string().as_ref(), + ); + match &self.rewards_config.denom { + UncheckedDenom::Native(_) => { + suite_built.fund_distributor_native(coin(100_000_000, DENOM.to_string())); + } + UncheckedDenom::Cw20(addr) => { + suite_built.fund_distributor_cw20(Cw20Coin { + address: addr.to_string(), + amount: Uint128::new(100_000_000), + }); + } + }; + } + } + + println!("voting power addr: {}", suite_built.voting_power_addr); + println!("staking addr: {}", suite_built.staking_addr); + suite_built + } +} + +pub struct Suite { + pub app: App, + pub owner: Option, + + pub staking_addr: Addr, + pub voting_power_addr: Addr, + pub reward_denom: String, + + pub distribution_contract: Addr, + + // cw20 type fields + pub cw20_addr: Addr, +} + +// SUITE QUERIES +impl Suite { + pub fn get_time_until_rewards_expiration(&mut self) -> u64 { + let rewards_state_response = self.get_rewards_state_response(); + let current_block = self.app.block_info(); + let (expiration_unit, current_unit) = match rewards_state_response.rewards[0].ends_at { + cw20::Expiration::AtHeight(h) => (h, current_block.height), + cw20::Expiration::AtTime(t) => (t.seconds(), current_block.time.seconds()), + cw20::Expiration::Never {} => return 0, + }; + + if expiration_unit > current_unit { + expiration_unit - current_unit + } else { + 0 + } + } + + pub fn get_balance_native, U: Into>( + &self, + address: T, + denom: U, + ) -> u128 { + self.app + .wrap() + .query_balance(address, denom) + .unwrap() + .amount + .u128() + } + + pub fn get_balance_cw20, U: Into>( + &self, + contract_addr: T, + address: U, + ) -> u128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = self + .app + .wrap() + .query_wasm_smart(contract_addr, &msg) + .unwrap(); + result.balance.u128() + } + + #[allow(dead_code)] + pub fn get_ownership>(&mut self, address: T) -> Ownership { + self.app + .wrap() + .query_wasm_smart(address, &QueryMsg::Ownership {}) + .unwrap() + } + + pub fn get_rewards_state_response(&mut self) -> RewardsStateResponse { + self.app + .wrap() + .query_wasm_smart( + self.distribution_contract.clone(), + &QueryMsg::RewardsState {}, + ) + .unwrap() + } + + pub fn _get_denom_reward_state(&mut self, denom: &str) -> DenomRewardState { + let resp: DenomRewardState = self + .app + .wrap() + .query_wasm_smart( + self.distribution_contract.clone(), + &QueryMsg::DenomRewardState { + denom: denom.to_string(), + }, + ) + .unwrap(); + println!("[{} REWARD STATE] {:?}", denom, resp); + resp + } +} + +// SUITE ASSERTIONS +impl Suite { + pub fn assert_ends_at(&mut self, expected: Expiration) { + let rewards_state_response = self.get_rewards_state_response(); + assert_eq!(rewards_state_response.rewards[0].ends_at, expected); + } + + pub fn assert_started_at(&mut self, expected: Expiration) { + let denom_configs = self.get_rewards_state_response(); + assert_eq!(denom_configs.rewards[0].started_at, expected); + } + + pub fn assert_amount(&mut self, expected: u128) { + let rewards_state_response = self.get_rewards_state_response(); + assert_eq!( + rewards_state_response.rewards[0].emission_rate.amount, + Uint128::new(expected) + ); + } + + pub fn assert_duration(&mut self, expected: u64) { + let rewards_state_response = self.get_rewards_state_response(); + let units = match rewards_state_response.rewards[0].emission_rate.duration { + Duration::Height(h) => h, + Duration::Time(t) => t, + }; + assert_eq!(units, expected); + } + + pub fn get_owner(&mut self) -> Addr { + let ownable_response: cw_ownable::Ownership = self + .app + .borrow_mut() + .wrap() + .query_wasm_smart(self.distribution_contract.clone(), &QueryMsg::Ownership {}) + .unwrap(); + ownable_response.owner.unwrap() + } + + pub fn assert_pending_rewards(&mut self, address: &str, _denom: &str, expected: u128) { + let res: PendingRewardsResponse = self + .app + .borrow_mut() + .wrap() + .query_wasm_smart( + self.distribution_contract.clone(), + &QueryMsg::GetPendingRewards { + address: address.to_string(), + }, + ) + .unwrap(); + + let pending = res.pending_rewards.get(self.reward_denom.as_str()).unwrap(); + + assert_eq!( + pending, + &Uint128::new(expected), + "expected {} pending rewards, got {}", + expected, + pending + ); + } + + pub fn assert_native_balance(&mut self, address: &str, denom: &str, expected: u128) { + let balance = self.get_balance_native(address, denom); + assert_eq!(balance, expected); + } + + pub fn assert_cw20_balance(&mut self, address: &str, expected: u128) { + let balance = self.get_balance_cw20(self.reward_denom.clone(), address); + assert_eq!(balance, expected); + } +} + +// SUITE ACTIONS +impl Suite { + pub fn shutdown_denom_distribution(&mut self, denom: &str) { + let msg = ExecuteMsg::Shutdown { + denom: denom.to_string(), + }; + self.app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn register_hook(&mut self, addr: Addr) { + let msg = cw4_group::msg::ExecuteMsg::AddHook { + addr: self.distribution_contract.to_string(), + }; + // TODO: cw721 check here + self.app + .execute_contract(Addr::unchecked(OWNER), addr, &msg, &[]) + .unwrap(); + } + + pub fn register_reward_denom(&mut self, reward_config: RewardsConfig, hook_caller: &str) { + let register_reward_denom_msg = ExecuteMsg::RegisterRewardDenom { + denom: reward_config.denom.clone(), + emission_rate: RewardEmissionRate { + amount: Uint128::new(reward_config.amount), + duration: reward_config.duration, + }, + hook_caller: hook_caller.to_string(), + vp_contract: self.voting_power_addr.to_string(), + withdraw_destination: reward_config.destination, + }; + + self.app + .borrow_mut() + .execute_contract( + self.owner.clone().unwrap(), + self.distribution_contract.clone(), + ®ister_reward_denom_msg, + &[], + ) + .unwrap(); + } + + pub fn mint_native_coin(&mut self, coin: Coin, dest: &str) { + // mint the tokens to be funded + self.app + .borrow_mut() + .sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: dest.to_string(), + amount: vec![coin.clone()], + } + })) + .unwrap(); + } + + pub fn mint_cw20_coin(&mut self, coin: Cw20Coin, dest: &str, name: &str) -> Addr { + let _msg = cw20::Cw20ExecuteMsg::Mint { + recipient: dest.to_string(), + amount: coin.amount, + }; + cw20_setup::instantiate_cw20(self.app.borrow_mut(), name, vec![coin]) + } + + pub fn fund_distributor_native(&mut self, coin: Coin) { + self.mint_native_coin(coin.clone(), OWNER); + println!("[FUNDING EVENT] native funding: {}", coin); + self.app + .borrow_mut() + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin], + ) + .unwrap(); + } + + pub fn fund_distributor_cw20(&mut self, coin: Cw20Coin) { + println!("[FUNDING EVENT] cw20 funding: {}", coin); + + let fund_sub_msg = to_json_binary(&ReceiveMsg::Fund {}).unwrap(); + self.app + .execute_contract( + Addr::unchecked(OWNER), + Addr::unchecked(coin.address), + &cw20::Cw20ExecuteMsg::Send { + contract: self.distribution_contract.to_string(), + amount: coin.amount, + msg: fund_sub_msg, + }, + &[], + ) + .unwrap(); + } + + pub fn skip_blocks(&mut self, blocks: u64) { + self.app.borrow_mut().update_block(|b| { + println!("skipping blocks {:?} -> {:?}", b.height, b.height + blocks); + b.height += blocks + }); + } + + pub fn skip_seconds(&mut self, seconds: u64) { + self.app.borrow_mut().update_block(|b| { + let new_block_time = b.time.plus_seconds(seconds); + println!( + "skipping seconds {:?} -> {:?}", + b.time.seconds(), + new_block_time.seconds() + ); + b.time = new_block_time; + // this is needed because voting power query only exists based on height. + // for time-based unit tests we assume that 1 block = 1 second. + // only implication I can think of is that during mainnet network downtime, + // rewards would continue to accrue for time-based distributions, whereas + // height-based distributions would not. + b.height += seconds; + }); + } + + pub fn claim_rewards(&mut self, address: &str, denom: &str) { + let msg = ExecuteMsg::Claim { + denom: denom.to_string(), + }; + + self.app + .execute_contract( + Addr::unchecked(address), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + #[allow(dead_code)] + pub fn stake_cw20_tokens(&mut self, amount: u128, sender: &str) { + let msg = cw20::Cw20ExecuteMsg::Send { + contract: self.staking_addr.to_string(), + amount: Uint128::new(amount), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + println!("[STAKING EVENT] {} staked {}", sender, amount); + self.app + .execute_contract(Addr::unchecked(sender), self.cw20_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn unstake_cw20_tokens(&mut self, amount: u128, sender: &str) { + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(amount), + }; + println!("[STAKING EVENT] {} unstaked {}", sender, amount); + self.app + .execute_contract( + Addr::unchecked(sender), + self.staking_addr.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn stake_nft(&mut self, sender: &str, token_id: u64) { + stake_cw721( + self.app.borrow_mut(), + &self.voting_power_addr, + &self.staking_addr, + sender, + &token_id.to_string(), + ) + } + + pub fn unstake_nft(&mut self, sender: &str, token_id: u64) { + unstake_cw721( + self.app.borrow_mut(), + &self.voting_power_addr, + sender, + &token_id.to_string(), + ) + } + + pub fn stake_native_tokens(&mut self, address: &str, amount: u128) { + stake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) + } + + pub fn unstake_native_tokens(&mut self, address: &str, amount: u128) { + unstake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) + } + + pub fn update_members(&mut self, add: Vec, remove: Vec) { + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { remove, add }; + + self.app + .execute_contract(Addr::unchecked(OWNER), self.staking_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn query_members(&mut self) -> Vec { + let members: MemberListResponse = self + .app + .wrap() + .query_wasm_smart( + self.staking_addr.clone(), + &cw4_group::msg::QueryMsg::ListMembers { + start_after: None, + limit: None, + }, + ) + .unwrap(); + println!("[UPDATE CW4] new members: {:?}", members); + members.members + } + + pub fn update_owner(&mut self, new_owner: &str) { + let msg = ExecuteMsg::UpdateOwnership(Action::TransferOwnership { + new_owner: new_owner.to_string(), + expiry: None, + }); + + self.app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + + self.app + .execute_contract( + Addr::unchecked(new_owner), + self.distribution_contract.clone(), + &ExecuteMsg::UpdateOwnership(Action::AcceptOwnership {}), + &[], + ) + .unwrap(); + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs new file mode 100644 index 000000000..764a04d3a --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -0,0 +1,1143 @@ +use std::borrow::BorrowMut; + +use cosmwasm_std::Uint128; +use cosmwasm_std::{coin, to_json_binary, Addr, Timestamp}; +use cw20::{Cw20Coin, Expiration, UncheckedDenom}; +use cw4::Member; +use cw_multi_test::Executor; +use cw_utils::Duration; + +use crate::{ + msg::ExecuteMsg, + testing::{ADDR1, ADDR2, ADDR3, DENOM}, +}; + +use super::{ + suite::{RewardsConfig, SuiteBuilder}, + ALT_DENOM, OWNER, +}; + +// By default, the tests are set up to distribute rewards over 1_000_000 units of time. +// Over that time, 100_000_000 token rewards will be distributed. + +#[test] +fn test_cw20_dao_native_rewards_block_height_based() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their rewards + suite.unstake_cw20_tokens(50, ADDR2); + suite.unstake_cw20_tokens(50, ADDR3); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.stake_cw20_tokens(50, ADDR2); + + // skip 3/10th of the time + suite.skip_blocks(300_000); + + suite.stake_cw20_tokens(50, ADDR3); + + suite.assert_pending_rewards(ADDR1, DENOM, 30_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + let remaining_time = suite.get_time_until_rewards_expiration(); + + suite.skip_blocks(remaining_time - 100_000); + + suite.claim_rewards(ADDR1, DENOM); + suite.unstake_cw20_tokens(100, ADDR1); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + suite.skip_blocks(100_000); + + suite.unstake_cw20_tokens(50, ADDR2); + suite.skip_blocks(100_000); + + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + let addr1_bal = suite.get_balance_native(ADDR1, DENOM); + let addr2_bal = suite.get_balance_native(ADDR2, DENOM); + let addr3_bal = suite.get_balance_native(ADDR3, DENOM); + + println!("Balances: {}, {}, {}", addr1_bal, addr2_bal, addr3_bal); +} + +#[test] +fn test_cw721_dao_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW721).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their nfts + suite.unstake_nft(ADDR2, 3); + suite.unstake_nft(ADDR3, 4); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their nfts + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.stake_nft(ADDR2, 3); + suite.stake_nft(ADDR3, 4); +} + +#[test] +#[should_panic(expected = "No rewards claimable")] +fn test_claim_zero_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + + // ADDR1 attempts to claim again + suite.claim_rewards(ADDR1, DENOM); +} + +#[test] +fn test_native_dao_cw20_rewards_time_based() { + // 1000udenom/10sec = 100udenom/1sec reward emission rate + // given funding of 100_000_000udenom, we have a reward duration of 1_000_000sec + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + suite.assert_amount(1_000); + suite.assert_duration(10); + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(1_000_000))); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, suite.reward_denom.clone().as_str()); + suite.assert_cw20_balance(ADDR1, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + suite.unstake_cw20_tokens(50, ADDR2); + suite.unstake_cw20_tokens(50, ADDR3); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up and claim their rewards + suite.claim_rewards(ADDR2, suite.reward_denom.clone().as_str()); + suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + + suite.assert_cw20_balance(ADDR1, 10_000_000); + suite.assert_cw20_balance(ADDR2, 5_000_000); +} + +#[test] +fn test_native_dao_rewards_time_based() { + // 1000udenom/10sec = 100udenom/1sec reward emission rate + // given funding of 100_000_000udenom, we have a reward duration of 1_000_000sec + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + suite.assert_amount(1_000); + suite.assert_duration(10); + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(1_000_000))); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + let addr1_balance = suite.get_balance_native(ADDR1, DENOM); + let addr2_balance = suite.get_balance_native(ADDR2, DENOM); + + suite.stake_native_tokens(ADDR1, addr1_balance); + suite.stake_native_tokens(ADDR2, addr2_balance); +} + +#[test] +fn test_native_dao_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + let addr1_balance = suite.get_balance_native(ADDR1, DENOM); + let addr2_balance = suite.get_balance_native(ADDR2, DENOM); + + suite.stake_native_tokens(ADDR1, addr1_balance); + suite.stake_native_tokens(ADDR2, addr2_balance); +} + +#[test] +fn test_cw4_dao_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW4).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // remove the second member + suite.update_members(vec![], vec![ADDR2.to_string()]); + suite.query_members(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // now that ADDR2 is no longer a member, ADDR1 and ADDR3 will split the rewards + suite.assert_pending_rewards(ADDR1, DENOM, 11_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // reintroduce the 2nd member with double the vp + let add_member_2 = Member { + addr: ADDR2.to_string(), + weight: 2, + }; + suite.update_members(vec![add_member_2], vec![]); + suite.query_members(); + + // now the vp split is [ADDR1: 40%, ADDR2: 40%, ADDR3: 20%] + // meaning the token reward per 100k blocks is 4mil, 4mil, 2mil + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + + // assert pending rewards are still the same (other than ADDR1) + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 4_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 6_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 7_833_333); + + // skip 1/2 of time, leaving 200k blocks left + suite.skip_blocks(500_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + // remove all members + suite.update_members( + vec![], + vec![ADDR1.to_string(), ADDR2.to_string(), ADDR3.to_string()], + ); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + suite.update_members( + vec![ + Member { + addr: ADDR1.to_string(), + weight: 2, + }, + Member { + addr: ADDR2.to_string(), + weight: 2, + }, + Member { + addr: ADDR3.to_string(), + weight: 1, + }, + ], + vec![], + ); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_native_balance(ADDR1, DENOM, 35_666_666); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 4_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 30_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 19_833_333); + + // at the very expiration block, claim rewards + suite.claim_rewards(ADDR2, DENOM); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_native_balance(ADDR2, DENOM, 30_500_000); + + suite.skip_blocks(100_000); + + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + let contract = suite.distribution_contract.clone(); + + // for 100k blocks there were no members so some rewards are remaining in the contract. + let contract_token_balance = suite.get_balance_native(contract.clone(), DENOM); + assert!(contract_token_balance > 0); +} + +#[test] +#[should_panic(expected = "Invalid funds")] +fn test_fund_multiple_denoms() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let alt_coin = coin(100_000_000, ALT_DENOM); + let coin = coin(100_000_000, DENOM); + suite.mint_native_coin(alt_coin.clone(), OWNER); + suite.mint_native_coin(coin.clone(), OWNER); + let hook_caller = suite.staking_addr.to_string(); + suite.register_reward_denom( + RewardsConfig { + amount: 1000, + denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), + duration: Duration::Height(100), + destination: None, + }, + &hook_caller, + ); + + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin, alt_coin], + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "unknown variant `not_the_fund: {}`")] +fn test_fund_cw20_with_invalid_cw20_receive_msg() { + // attempting to fund a non-registered cw20 token should error + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + let unregistered_cw20_coin = Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(1_000_000), + }; + + let new_cw20_mint = suite.mint_cw20_coin(unregistered_cw20_coin.clone(), ADDR1, "newcoin"); + println!("[FUNDING EVENT] cw20 funding: {}", unregistered_cw20_coin); + + let fund_sub_msg = to_json_binary(&"not_the_fund: {}").unwrap(); + suite + .app + .execute_contract( + Addr::unchecked(ADDR1), + new_cw20_mint.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: suite.distribution_contract.to_string(), + amount: unregistered_cw20_coin.amount, + msg: fund_sub_msg, + }, + &[], + ) + .unwrap(); +} + +#[test] +#[should_panic] +fn test_fund_invalid_cw20_denom() { + // attempting to fund a non-registered cw20 token should error + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + let unregistered_cw20_coin = Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(1_000_000), + }; + + println!("attempting to fund the distributor contract with unregistered cw20 coin"); + suite.fund_distributor_cw20(unregistered_cw20_coin); +} + +#[test] +#[should_panic(expected = "Reward period already finished")] +fn test_shutdown_finished_rewards_period() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip to expiration + suite.skip_blocks(2_000_000); + + suite.shutdown_denom_distribution(DENOM); +} + +#[test] +fn test_shutdown_alternative_destination_address() { + let subdao_addr = "some_subdao_maybe".to_string(); + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_withdraw_destination(Some(subdao_addr.to_string())) + .build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // user 1 and 2 claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + // user 2 unstakes + suite.unstake_native_tokens(ADDR2, 50); + + suite.skip_blocks(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + suite.assert_native_balance(subdao_addr.as_str(), DENOM, 0); + let pre_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.shutdown_denom_distribution(DENOM); + + let post_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_shutdown_subdao_balance = suite.get_balance_native(subdao_addr.to_string(), DENOM); + + // after shutdown the balance of the subdao should be the same + // as pre-shutdown-distributor-bal minus post-shutdown-distributor-bal + assert_eq!( + pre_shutdown_distributor_balance - post_shutdown_distributor_balance, + post_shutdown_subdao_balance + ); +} + +#[test] +fn test_shutdown_block_based() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // user 1 and 2 claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + // user 2 unstakes + suite.unstake_native_tokens(ADDR2, 50); + + suite.skip_blocks(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + let pre_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); + suite.shutdown_denom_distribution(DENOM); + + let post_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_shutdown_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + + // after shutdown the balance of the owner should be the same + // as pre-shutdown-distributor-bal minus post-shutdown-distributor-bal + assert_eq!( + pre_shutdown_distributor_balance - post_shutdown_distributor_balance, + post_shutdown_owner_balance + ); + + suite.skip_blocks(100_000); + + // we assert that pending rewards did not change + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // user 1 can claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + + // user 3 can unstake and claim their rewards + suite.unstake_native_tokens(ADDR3, 50); + suite.skip_blocks(100_000); + suite.assert_native_balance(ADDR3, DENOM, 50); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_native_balance(ADDR3, DENOM, 5_833_333 + 50); + + // TODO: fix this rug of 1 udenom by the distribution contract + suite.assert_native_balance(&distribution_contract, DENOM, 1); +} + +#[test] +fn test_shutdown_time_based() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // user 1 and 2 claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + // user 2 unstakes + suite.unstake_native_tokens(ADDR2, 50); + + suite.skip_seconds(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + let pre_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); + suite.shutdown_denom_distribution(DENOM); + + let post_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_shutdown_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + + // after shutdown the balance of the owner should be the same + // as pre-shutdown-distributor-bal minus post-shutdown-distributor-bal + assert_eq!( + pre_shutdown_distributor_balance - post_shutdown_distributor_balance, + post_shutdown_owner_balance + ); + + suite.skip_seconds(100_000); + + // we assert that pending rewards did not change + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // user 1 can claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + + // user 3 can unstake and claim their rewards + suite.unstake_native_tokens(ADDR3, 50); + suite.skip_seconds(100_000); + suite.assert_native_balance(ADDR3, DENOM, 50); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_native_balance(ADDR3, DENOM, 5_833_333 + 50); + + // TODO: fix this rug of 1 udenom by the distribution contract + suite.assert_native_balance(&distribution_contract, DENOM, 1); +} + +#[test] +#[should_panic(expected = "Caller is not the contract's current owner")] +fn test_shudown_unauthorized() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + suite.distribution_contract.clone(), + &ExecuteMsg::Shutdown { + denom: DENOM.to_string(), + }, + &[], + ) + .unwrap(); +} + +#[test] +#[should_panic] +fn test_shutdown_unregistered_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.skip_blocks(100_000); + + suite.shutdown_denom_distribution("not-the-denom"); +} + +#[test] +#[should_panic(expected = "Denom already registered")] +fn test_register_duplicate_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let hook_caller = suite.staking_addr.to_string(); + let reward_config = RewardsConfig { + amount: 1000, + denom: cw20::UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Height(100), + destination: None, + }; + suite.register_reward_denom(reward_config, &hook_caller); +} + +#[test] +#[should_panic] +fn test_fund_invalid_native_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.mint_native_coin(coin(100_000_000, ALT_DENOM), OWNER); + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin(100_000_000, ALT_DENOM)], + ) + .unwrap(); +} + +#[test] +fn test_fund_unauthorized() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.mint_native_coin(coin(100_000_000, DENOM), ADDR1); + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + suite.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin(100_000_000, DENOM)], + ) + .unwrap(); +} + +#[test] +fn test_fund_native_block_based_post_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let started_at = Expiration::AtHeight(0); + let funded_blocks = 1_000_000; + let expiration_date = Expiration::AtHeight(funded_blocks); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, DENOM); + + // skip to 100_000 blocks past the expiration + suite.skip_blocks(1_000_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 65_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 30_000_000); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + suite.fund_distributor_native(coin(100_000_000, DENOM)); + + let current_block = suite.app.block_info(); + + // funding after the reward period had expired should + // reset the start date to that of the funding. + suite.assert_started_at(Expiration::AtHeight(current_block.height)); + + // funding after the reward period had expired should + // set the distribution expiration to the funded duration + // after current block + suite.assert_ends_at(Expiration::AtHeight(current_block.height + funded_blocks)); +} + +#[test] +fn test_fund_cw20_time_based_post_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + let started_at = Expiration::AtTime(Timestamp::from_seconds(0)); + let funded_timestamp = Timestamp::from_seconds(1_000_000); + let expiration_date = Expiration::AtTime(funded_timestamp); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_cw20_tokens(50, ADDR2); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + suite.assert_cw20_balance(ADDR3, 2_500_000); + + // skip to 100_000 blocks past the expiration + suite.skip_seconds(1_000_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 65_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 30_000_000); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + let funding_denom = Cw20Coin { + address: suite.reward_denom.to_string(), + amount: Uint128::new(100_000_000), + }; + + suite.fund_distributor_cw20(funding_denom.clone()); + + let current_block = suite.app.block_info(); + + // funding after the reward period had expired should + // reset the start date to that of the funding. + suite.assert_started_at(Expiration::AtTime(current_block.time)); + + // funding after the reward period had expired should + // set the distribution expiration to the funded duration + // after current block + suite.assert_ends_at(Expiration::AtTime( + current_block.time.plus_seconds(funded_timestamp.seconds()), + )); +} + +#[test] +fn test_fund_cw20_time_based_pre_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + let started_at = Expiration::AtTime(Timestamp::from_seconds(0)); + let funded_timestamp = Timestamp::from_seconds(1_000_000); + let expiration_date = Expiration::AtTime(funded_timestamp); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_cw20_tokens(50, ADDR2); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + + // skip to 100_000 blocks before the expiration + suite.skip_seconds(800_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 58_333_333); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 26_666_666); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + let funding_denom = Cw20Coin { + address: suite.reward_denom.to_string(), + amount: Uint128::new(100_000_000), + }; + suite.fund_distributor_cw20(funding_denom.clone()); + + // funding before the reward period expires should + // not reset the existing rewards cycle + suite.assert_started_at(started_at); + + // funding before the reward period expires should + // extend the current distribution expiration by the + // newly funded duration + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds( + funded_timestamp.seconds() * 2, + ))); +} + +#[test] +fn test_fund_native_height_based_pre_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let started_at = Expiration::AtHeight(0); + let funded_blocks = 1_000_000; + let expiration_date = Expiration::AtHeight(funded_blocks); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, DENOM); + + // skip to 100_000 blocks before the expiration + suite.skip_blocks(800_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 58_333_333); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 26_666_666); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + suite.fund_distributor_native(coin(100_000_000, DENOM)); + + // funding before the reward period expires should + // not reset the existing rewards cycle + suite.assert_started_at(started_at); + + // funding before the reward period expires should + // extend the current distribution expiration by the + // newly funded duration + suite.assert_ends_at(Expiration::AtHeight(funded_blocks * 2)); +} + +#[test] +fn test_native_dao_rewards_entry_edge_case() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // we start with the following staking power split: + // [ADDR1: 100, ADDR2: 50, ADDR3: 50], or [ADDR1: 50%, ADDR2: 25%, ADDR3: 25% + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // ADDR1 stakes additional 100 tokens, bringing the new staking power split to + // [ADDR1: 200, ADDR2: 50, ADDR3: 50], or [ADDR1: 66.6%, ADDR2: 16.6%, ADDR3: 16.6%] + // this means that per 100_000 blocks, ADDR1 should receive 6_666_666, while + // ADDR2 and ADDR3 should receive 1_666_666 each. + suite.mint_native_coin(coin(100, DENOM), ADDR1); + println!("staking native coins\n"); + suite.stake_native_tokens(ADDR1, 100); + + // rewards here should not be affected by the new stake, + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // here we should see the new stake affecting the rewards split. + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + // new voting power split is [ADDR1: 100%, ADDR2: 0%, ADDR3: 0%] + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // we assert that by unstaking, ADDR2 and ADDR3 do not forfeit their earned but unclaimed rewards + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // skip a block and assert that nothing changes + suite.skip_blocks(1); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // skip the remaining blocks to reach 1/10th of the time + suite.skip_blocks(99_999); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + let addr1_balance = suite.get_balance_native(ADDR1, DENOM); + let addr2_balance = suite.get_balance_native(ADDR2, DENOM); + + suite.stake_native_tokens(ADDR1, addr1_balance); + suite.stake_native_tokens(ADDR2, addr2_balance); +} + +#[test] +fn test_update_owner() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let new_owner = "new_owner"; + suite.update_owner(new_owner); + + let owner = suite.get_owner().to_string(); + assert_eq!(owner, new_owner); +} diff --git a/contracts/proposal/dao-proposal-multiple/src/error.rs b/contracts/proposal/dao-proposal-multiple/src/error.rs index 76fe05724..433103f7d 100644 --- a/contracts/proposal/dao-proposal-multiple/src/error.rs +++ b/contracts/proposal/dao-proposal-multiple/src/error.rs @@ -1,5 +1,3 @@ -use std::u64; - use cosmwasm_std::StdError; use cw_hooks::HookError; use cw_utils::ParseReplyError; diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs index 7a31d74e5..51a0747b8 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs @@ -392,7 +392,7 @@ where vec![TestMultipleChoiceVote { voter: "bluenote".to_string(), position: MultipleChoiceVote { option_id: 0 }, - weight: Uint128::new(u128::max_value()), + weight: Uint128::new(u128::MAX), should_execute: ShouldExecute::Yes, }], VotingStrategy::SingleChoice { @@ -414,7 +414,7 @@ where TestMultipleChoiceVote { voter: "bob".to_string(), position: MultipleChoiceVote { option_id: 1 }, - weight: Uint128::new(u128::max_value() - 1), + weight: Uint128::new(u128::MAX - 1), should_execute: ShouldExecute::Yes, }, ], @@ -463,7 +463,7 @@ where vec![TestMultipleChoiceVote { voter: "bluenote".to_string(), position: MultipleChoiceVote { option_id: 2 }, // the last index is none of the above - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], VotingStrategy::SingleChoice { @@ -479,7 +479,7 @@ where vec![TestMultipleChoiceVote { voter: "bluenote".to_string(), position: MultipleChoiceVote { option_id: 2 }, - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], VotingStrategy::SingleChoice { diff --git a/contracts/proposal/dao-proposal-single/src/error.rs b/contracts/proposal/dao-proposal-single/src/error.rs index 9fc049d21..c52fee31b 100644 --- a/contracts/proposal/dao-proposal-single/src/error.rs +++ b/contracts/proposal/dao-proposal-single/src/error.rs @@ -1,5 +1,3 @@ -use std::u64; - use cosmwasm_std::StdError; use cw_hooks::HookError; use cw_utils::ParseReplyError; diff --git a/packages/dao-testing/src/tests.rs b/packages/dao-testing/src/tests.rs index d57377333..874278bad 100644 --- a/packages/dao-testing/src/tests.rs +++ b/packages/dao-testing/src/tests.rs @@ -67,7 +67,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Yes, - weight: Uint128::new(u128::max_value()), + weight: Uint128::new(u128::MAX), should_execute: ShouldExecute::Yes, }], Threshold::AbsolutePercentage { @@ -86,7 +86,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Yes, - weight: Uint128::new(u128::max_value()), + weight: Uint128::new(u128::MAX), should_execute: ShouldExecute::Yes, }], Threshold::AbsolutePercentage { @@ -107,7 +107,7 @@ where TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Yes, - weight: Uint128::new(u128::max_value() - 1), + weight: Uint128::new(u128::MAX - 1), should_execute: ShouldExecute::Yes, }, ], @@ -148,7 +148,7 @@ where percentage: PercentageThreshold::Percent(Decimal::percent(99)), }, Status::Open, - Some(Uint128::from(u128::max_value())), + Some(Uint128::from(u128::MAX)), ); } @@ -160,7 +160,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Abstain, - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], Threshold::AbsolutePercentage { @@ -177,7 +177,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Abstain, - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], Threshold::ThresholdQuorum { diff --git a/scripts/schema.sh b/scripts/schema.sh index 08fa7373e..1dec7ff02 100755 --- a/scripts/schema.sh +++ b/scripts/schema.sh @@ -13,6 +13,16 @@ cargo run --example schema > /dev/null rm -rf ./schema/raw cd "$START_DIR" +for f in ./contracts/distribution/* +do + echo "generating schema for ${f##*/}" + cd "$f" + CMD="cargo run --example schema" + eval $CMD > /dev/null + rm -rf ./schema/raw + cd "$START_DIR" +done + for f in ./contracts/voting/* do echo "generating schema for ${f##*/}"