diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index a2e355d14..cb78b5c62 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -1,31 +1,172 @@ # 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) +[![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: +The `dao-rewards-distributor` works in conjuction with DAO voting modules to +provide rewards streamed over time for DAO members. The contract supports both +native and CW20 Cosmos SDK tokens. Any voting power module that supports the +standard DAO voting module interface is supported for deriving staking reward +allocations, as long it also supports voting power change hooks. This includes, +but is not limited to: + - `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. +The contract is instantiated with a very minimal state. An optional `owner` can +be specified. If it is not, the owner is set to be the address instantiating the +contract. + +### Hooks -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. +After instantiating the contract, it is VITAL to set up 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 as soon as they happen. + +This can be achieved using the `add_hook` method on contracts that support +voting power changes, such as: -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. +### Creating a new distribution + +Only the `owner` can create new distributions. + +Creating a distribution requires the following configuration: + +- `denom`, which can be a native token or CW20 contract +- `emission_rate`, which determines how the rewards are distributed. there are 3 + options: + - `paused`: no rewards are distributed until the emission rate is updated + - `immediate`: funded rewards are distributed immediately to those with + voting power + - `linear`: `amount` of the denom is distributed to all applicable addresses + per `duration` of time, updating throughout based on changing voting power. + `duration` may be declared in either time (seconds) or blocks. if + `continuous` is true, it will backfill if there are funding gaps using + current voting power. some example configurations may be: + - `1000udenom` per `500 blocks` + - `10udenom` per `24 hours` + - `1udenom` per `1 second` +- `vp_contract` address, which will be used to determine the total and relative + address voting power for allocating the rewards on a pro-rata basis +- `hook_caller` address, which will be authorized to call back into this + contract with any voting power event changes. examples of such events may be: + - user staking tokens + - user unstaking tokens + - user cw-721 state change event + - cw-4 membership change event +- optional `withdraw_destination` address to be used when withdrawing (i.e. + unfunding the remainder of a previously funded distribution). this may be a + subDAO, for example. if not provided, the contract owner is used. + +You can fund a distribution at any point after it's been created, or during +creation if it's for a native token. CW20 tokens must be funded after creation. +Simply including native funds in the create message will suffice. For any token, +you can always top up the funds later, which extends the distribution period. + +### Funding a distribution + +Anyone can fund a distribution once it's been created. + +> **WARNING:** Do not transfer funds directly to the contract. You must use the +> `Fund` execution message in order for the contract to properly recognize and +> distribute the tokens. **Funds will be lost if you don't use the execution +> msg.** + +There are a few different emission rates. Below describes the funding behavior +while different emission rates are active. + +#### Linear + +Linear emissions can be continuous or not. + +When a linear emission is **continuous**, it will backfill rewards if there's a gap +between when it finishes distributing everything it's been funded with so far +and when it's funded next. This means that when another funding occurs after a +period of no more rewards being available, it will instantly distribute the +portion of the funds that corresponds with the time that passed in that gap. One +limitation is that it uses the current voting power to backfill. + +When a linear emission is **not continuous**, and a gap in reward funding occurs, it +will simply restart the distribution the next time it receives funding. This may +be less intuitive, but it doesn't suffer from the voting power limitation that +the continuous mode does. + +Upon funding, the start and end are computed based on the funds provided, the +configured emission rate, and whether or not it's set to the continuous mode. If +this is the first funding, or it's not continuous and we're restarting from the +current block, the start block is updated to the current block. The end block is +computed based on the start block and funding duration, calculated from the +emission rate and remaining funds, including any that already existed that have +not yet been distributed. + +Linear emissions can be extended indefinitely by continuously funding them. + +**Example:** if 100_000udenom were funded, and the configured emission rate is +1_000udenom per 100 blocks, we derive that there are 100_000/1_000 = 100 epochs +funded, each of which contain 100 blocks. We therefore funded 10_000 blocks of +rewards. + +#### Immediate + +When set to immediate, funding is immediately distributed based on the voting +power of the block funding occurs on. + +You may fund an immediate distribution as many times as you'd like to distribute +funds instantly to the current members of the DAO. + +#### Paused + +When set to paused, no rewards will be distributed. + +You may fund a paused distribution and accumulate rewards in the contract to be +distributed at a later date, since you can update the emission rate of a +distribution. + +Maybe you want to accumulate rewards in a paused state for a month, and then +distribute them instantly at the end of the month to the DAO. Or maybe you want +to pause an active linear emission, which will hold the funds in the contract +and not distribute any more than have already been distributed. + +### Updating emission rate and other distribution config + +Only the `owner` can update a distribution's config. + +Updating the emission rate preserves all previously distributed rewards and adds +it to a historical value (`historical_earned_puvp`), so updating does not +interfere with users who have not yet claimed their rewards. + +You can also update the `vp_contract`, `hook_caller`, and +`withdraw_destination`. + +> **WARNING:** You probably always want to update `vp_contract` and +> `hook_caller` together. Make sure you know what you're doing. And be sure to +> add/remove hooks on the old and new `hook_caller`s accordingly. + +### Withdrawing + +Only the `owner` can withdraw from a distribution. + +This is effectively the inverse of funding a distribution. If the current +distribution is inactive, meaning its emission rate is `paused`, `immediate`, or +`linear` with an expired distribution period (because the end block is in the +past), then there is nothing to withdraw. + +When rewards are being distributed, withdrawing ends the distribution early, +setting the end block to the current one, and clawing back the undistributed +funds to the specified `withdraw_destination`. Pending funds that have already +been distributed, even if not yet claimed, will remain in the contract to be +claimed. Withdrawing only applies to unallocated funds. + +### Claiming + +You can claim funds from a distribution that you have pending rewards for. diff --git a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json index 37f979a11..6756ba56d 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -8,7 +8,7 @@ "type": "object", "properties": { "owner": { - "description": "The owner of the contract. Is able to fund the contract and update the reward duration.", + "description": "The owner of the contract. Is able to fund the contract and update the reward duration. If not provided, the instantiator is used.", "type": [ "string", "null" @@ -61,20 +61,68 @@ "additionalProperties": false }, { - "description": "Claims rewards for the sender.", + "description": "registers a new distribution", "type": "object", "required": [ - "claim" + "create" ], "properties": { - "claim": { + "create": { + "$ref": "#/definitions/CreateMsg" + } + }, + "additionalProperties": false + }, + { + "description": "updates the config for a distribution", + "type": "object", + "required": [ + "update" + ], + "properties": { + "update": { "type": "object", "required": [ - "denom" + "id" ], "properties": { - "denom": { - "type": "string" + "emission_rate": { + "description": "reward emission rate", + "anyOf": [ + { + "$ref": "#/definitions/EmissionRate" + }, + { + "type": "null" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "distribution ID to update", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vp_contract": { + "description": "address to query the voting power", + "type": [ + "string", + "null" + ] + }, + "withdraw_destination": { + "description": "destination address for reward clawbacks. defaults to owner", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false @@ -103,27 +151,28 @@ ], "properties": { "fund": { - "type": "object", - "additionalProperties": false + "$ref": "#/definitions/FundMsg" } }, "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.", + "description": "Claims rewards for the sender.", "type": "object", "required": [ - "shutdown" + "claim" ], "properties": { - "shutdown": { + "claim": { "type": "object", "required": [ - "denom" + "id" ], "properties": { - "denom": { - "type": "string" + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -132,38 +181,22 @@ "additionalProperties": false }, { - "description": "registers a new reward denom", + "description": "withdraws the undistributed rewards for a distribution. members can claim whatever they earned until this point. this is effectively an inverse to fund and does not affect any already-distributed rewards.", "type": "object", "required": [ - "register_reward_denom" + "withdraw" ], "properties": { - "register_reward_denom": { + "withdraw": { "type": "object", "required": [ - "denom", - "emission_rate", - "hook_caller", - "vp_contract" + "id" ], "properties": { - "denom": { - "$ref": "#/definitions/UncheckedDenom" - }, - "emission_rate": { - "$ref": "#/definitions/RewardEmissionRate" - }, - "hook_caller": { - "type": "string" - }, - "vp_contract": { - "type": "string" - }, - "withdraw_destination": { - "type": [ - "string", - "null" - ] + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -245,6 +278,49 @@ "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" }, + "CreateMsg": { + "type": "object", + "required": [ + "denom", + "emission_rate", + "hook_caller", + "vp_contract" + ], + "properties": { + "denom": { + "description": "denom to distribute", + "allOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + } + ] + }, + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/EmissionRate" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "type": "string" + }, + "vp_contract": { + "description": "address to query the voting power", + "type": "string" + }, + "withdraw_destination": { + "description": "destination address for reward clawbacks. defaults to owner", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "Cw20ReceiveMsg": { "description": "Cw20ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", "type": "object", @@ -300,6 +376,80 @@ } ] }, + "EmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "oneOf": [ + { + "description": "rewards are paused", + "type": "object", + "required": [ + "paused" + ], + "properties": { + "paused": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "rewards are distributed immediately", + "type": "object", + "required": [ + "immediate" + ], + "properties": { + "immediate": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "rewards are distributed at a constant rate", + "type": "object", + "required": [ + "linear" + ], + "properties": { + "linear": { + "type": "object", + "required": [ + "amount", + "continuous", + "duration" + ], + "properties": { + "amount": { + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether future funding after distribution finishes should be applied to the past, or rewards are paused once all funding has been distributed. all continuously backfilled rewards are distributed based on the current voting power.", + "type": "boolean" + }, + "duration": { + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "Expiration": { "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "oneOf": [ @@ -347,6 +497,21 @@ } ] }, + "FundMsg": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "distribution ID to fund", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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", @@ -450,23 +615,6 @@ } ] }, - "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": [ @@ -587,13 +735,13 @@ "additionalProperties": false }, { - "description": "Returns the state of the registered reward distributions.", + "description": "Returns information about the ownership of this contract.", "type": "object", "required": [ - "rewards_state" + "ownership" ], "properties": { - "rewards_state": { + "ownership": { "type": "object", "additionalProperties": false } @@ -604,10 +752,10 @@ "description": "Returns the pending rewards for the given address.", "type": "object", "required": [ - "get_pending_rewards" + "pending_rewards" ], "properties": { - "get_pending_rewards": { + "pending_rewards": { "type": "object", "required": [ "address" @@ -615,6 +763,22 @@ "properties": { "address": { "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -623,33 +787,54 @@ "additionalProperties": false }, { - "description": "Returns information about the ownership of this contract.", + "description": "Returns the state of the given distribution.", "type": "object", "required": [ - "ownership" + "distribution" ], "properties": { - "ownership": { + "distribution": { "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, "additionalProperties": false } }, "additionalProperties": false }, { + "description": "Returns the state of all the distributions.", "type": "object", "required": [ - "denom_reward_state" + "distributions" ], "properties": { - "denom_reward_state": { + "distributions": { "type": "object", - "required": [ - "denom" - ], "properties": { - "denom": { - "type": "string" + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -667,53 +852,51 @@ }, "sudo": null, "responses": { - "denom_reward_state": { + "distribution": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DenomRewardState", - "description": "the state of a denom's reward distribution", + "title": "DistributionState", + "description": "the state of a reward distribution", "type": "object", "required": [ + "active_epoch", "denom", - "emission_rate", - "ends_at", "funded_amount", + "historical_earned_puvp", "hook_caller", - "last_update", - "started_at", - "total_earned_puvp", + "id", "vp_contract", "withdraw_destination" ], "properties": { - "denom": { - "description": "validated denom (native or cw20)", + "active_epoch": { + "description": "current distribution epoch state", "allOf": [ { - "$ref": "#/definitions/Denom" + "$ref": "#/definitions/Epoch" } ] }, - "emission_rate": { - "description": "reward emission rate", + "denom": { + "description": "validated denom (native or cw20)", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/Denom" } ] }, - "ends_at": { - "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "funded_amount": { + "description": "total amount of rewards funded that will be distributed in the active epoch.", "allOf": [ { - "$ref": "#/definitions/Expiration" + "$ref": "#/definitions/Uint128" } ] }, - "funded_amount": { - "description": "total amount of rewards funded", + "historical_earned_puvp": { + "description": "historical rewards earned per unit voting power from past epochs due to changes in the emission rate. each time emission rate is changed, this value is increased by the `active_epoch`'s rewards earned puvp.", "allOf": [ { - "$ref": "#/definitions/Uint128" + "$ref": "#/definitions/Uint256" } ] }, @@ -725,29 +908,11 @@ } ] }, - "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" - } - ] + "id": { + "description": "distribution ID", + "type": "integer", + "format": "uint64", + "minimum": 0.0 }, "vp_contract": { "description": "address to query the voting power", @@ -758,7 +923,7 @@ ] }, "withdraw_destination": { - "description": "optional destination address for reward clawbacks", + "description": "destination address for reward clawbacks", "allOf": [ { "$ref": "#/definitions/Addr" @@ -834,46 +999,73 @@ } ] }, - "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)", + "EmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", "oneOf": [ { - "description": "AtHeight will expire when `env.block.height` >= height", + "description": "rewards are paused", "type": "object", "required": [ - "at_height" + "paused" ], "properties": { - "at_height": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 + "paused": { + "type": "object", + "additionalProperties": false } }, "additionalProperties": false }, { - "description": "AtTime will expire when `env.block.time` >= time", + "description": "rewards are distributed immediately", "type": "object", "required": [ - "at_time" + "immediate" ], "properties": { - "at_time": { - "$ref": "#/definitions/Timestamp" + "immediate": { + "type": "object", + "additionalProperties": false } }, "additionalProperties": false }, { - "description": "Never will never expire. Used to express the empty variant", + "description": "rewards are distributed at a constant rate", "type": "object", "required": [ - "never" + "linear" ], "properties": { - "never": { + "linear": { "type": "object", + "required": [ + "amount", + "continuous", + "duration" + ], + "properties": { + "amount": { + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether future funding after distribution finishes should be applied to the past, or rewards are paused once all funding has been distributed. all continuously backfilled rewards are distributed based on the current voting power.", + "type": "boolean" + }, + "duration": { + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + } + }, "additionalProperties": false } }, @@ -881,151 +1073,58 @@ } ] }, - "RewardEmissionRate": { - "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "Epoch": { "type": "object", "required": [ - "amount", - "duration" + "emission_rate", + "ends_at", + "last_updated_total_earned_puvp", + "started_at", + "total_earned_puvp" ], "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" + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/EmissionRate" + } + ] }, - "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" + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] }, - { - "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" + "last_updated_total_earned_puvp": { + "description": "time when total_earned_puvp was last updated", + "allOf": [ + { + "$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" + "started_at": { + "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] }, - { - "type": "null" + "total_earned_puvp": { + "description": "total rewards earned per unit voting power from started_at to last_updated_total_earned_puvp", + "allOf": [ + { + "$ref": "#/definitions/Uint256" + } + ] } - ] - } - }, - "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" + }, + "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)", @@ -1082,24 +1181,32 @@ } ] }, + "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" } } }, - "rewards_state": { + "distributions": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RewardsStateResponse", + "title": "DistributionsResponse", "type": "object", "required": [ - "rewards" + "distributions" ], "properties": { - "rewards": { + "distributions": { "type": "array", "items": { - "$ref": "#/definitions/DenomRewardState" + "$ref": "#/definitions/DistributionState" } } }, @@ -1137,51 +1244,49 @@ } ] }, - "DenomRewardState": { - "description": "the state of a denom's reward distribution", + "DistributionState": { + "description": "the state of a reward distribution", "type": "object", "required": [ + "active_epoch", "denom", - "emission_rate", - "ends_at", "funded_amount", + "historical_earned_puvp", "hook_caller", - "last_update", - "started_at", - "total_earned_puvp", + "id", "vp_contract", "withdraw_destination" ], "properties": { - "denom": { - "description": "validated denom (native or cw20)", + "active_epoch": { + "description": "current distribution epoch state", "allOf": [ { - "$ref": "#/definitions/Denom" + "$ref": "#/definitions/Epoch" } ] }, - "emission_rate": { - "description": "reward emission rate", + "denom": { + "description": "validated denom (native or cw20)", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/Denom" } ] }, - "ends_at": { - "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "funded_amount": { + "description": "total amount of rewards funded that will be distributed in the active epoch.", "allOf": [ { - "$ref": "#/definitions/Expiration" + "$ref": "#/definitions/Uint128" } ] }, - "funded_amount": { - "description": "total amount of rewards funded", + "historical_earned_puvp": { + "description": "historical rewards earned per unit voting power from past epochs due to changes in the emission rate. each time emission rate is changed, this value is increased by the `active_epoch`'s rewards earned puvp.", "allOf": [ { - "$ref": "#/definitions/Uint128" + "$ref": "#/definitions/Uint256" } ] }, @@ -1193,29 +1298,11 @@ } ] }, - "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" - } - ] + "id": { + "description": "distribution ID", + "type": "integer", + "format": "uint64", + "minimum": 0.0 }, "vp_contract": { "description": "address to query the voting power", @@ -1226,7 +1313,7 @@ ] }, "withdraw_destination": { - "description": "optional destination address for reward clawbacks", + "description": "destination address for reward clawbacks", "allOf": [ { "$ref": "#/definitions/Addr" @@ -1270,6 +1357,133 @@ } ] }, + "EmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "oneOf": [ + { + "description": "rewards are paused", + "type": "object", + "required": [ + "paused" + ], + "properties": { + "paused": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "rewards are distributed immediately", + "type": "object", + "required": [ + "immediate" + ], + "properties": { + "immediate": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "rewards are distributed at a constant rate", + "type": "object", + "required": [ + "linear" + ], + "properties": { + "linear": { + "type": "object", + "required": [ + "amount", + "continuous", + "duration" + ], + "properties": { + "amount": { + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether future funding after distribution finishes should be applied to the past, or rewards are paused once all funding has been distributed. all continuously backfilled rewards are distributed based on the current voting power.", + "type": "boolean" + }, + "duration": { + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Epoch": { + "type": "object", + "required": [ + "emission_rate", + "ends_at", + "last_updated_total_earned_puvp", + "started_at", + "total_earned_puvp" + ], + "properties": { + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/EmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "last_updated_total_earned_puvp": { + "description": "time when total_earned_puvp was last updated", + "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_updated_total_earned_puvp", + "allOf": [ + { + "$ref": "#/definitions/Uint256" + } + ] + } + }, + "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": [ @@ -1317,22 +1531,154 @@ } ] }, - "RewardEmissionRate": { - "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "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" + } + } + }, + "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": [ - "amount", - "duration" + "contract", + "version" ], "properties": { - "amount": { - "$ref": "#/definitions/Uint128" + "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" }, - "duration": { - "$ref": "#/definitions/Duration" + "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); ```", @@ -1342,16 +1688,96 @@ } ] }, - "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); ```", + "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" - }, - "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); ```", + } + } + }, + "pending_rewards": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingRewardsResponse", + "type": "object", + "required": [ + "pending_rewards" + ], + "properties": { + "pending_rewards": { + "type": "array", + "items": { + "$ref": "#/definitions/DistributionPendingRewards" + } + } + }, + "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" }, - "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); ```", + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "DistributionPendingRewards": { + "type": "object", + "required": [ + "denom", + "id", + "pending_rewards" + ], + "properties": { + "denom": { + "description": "denomination of the pending rewards", + "allOf": [ + { + "$ref": "#/definitions/Denom" + } + ] + }, + "id": { + "description": "distribution ID", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "pending_rewards": { + "description": "amount of pending rewards in the denom being distributed", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", "type": "string" } } diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 83d4d868b..3a6e3c6e0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,45 +1,57 @@ #[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, + ensure, from_json, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, + StdResult, Uint128, Uint256, }; 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 cw20::{Cw20ReceiveMsg, Denom}; +use cw_storage_plus::Bound; +use cw_utils::{must_pay, nonpayable, Duration, Expiration}; +use dao_interface::voting::InfoResponse; + +use std::ops::Add; +use crate::helpers::{get_transfer_msg, validate_voting_power_contract}; use crate::hooks::{ execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, - subscribe_denom_to_hook, + subscribe_distribution_to_hook, unsubscribe_distribution_from_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RewardEmissionRate, - RewardsStateResponse, + CreateMsg, DistributionPendingRewards, DistributionsResponse, ExecuteMsg, FundMsg, + InstantiateMsg, MigrateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, +}; +use crate::rewards::{ + get_accrued_rewards_not_yet_accounted_for, get_active_total_earned_puvp, update_rewards, }; -use crate::state::{DenomRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::state::{DistributionState, EmissionRate, Epoch, COUNT, DISTRIBUTIONS, USER_REWARDS}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const DEFAULT_LIMIT: u32 = 10; +pub const MAX_LIMIT: u32 = 50; + #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( deps: DepsMut, _env: Env, - _info: MessageInfo, + 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())?; + // Intialize the contract owner, defaulting to instantiator. + let owner = deps + .api + .addr_validate(&msg.owner.unwrap_or_else(|| info.sender.to_string()))?; + cw_ownable::initialize_owner(deps.storage, deps.api, Some(owner.as_str()))?; + + // initialize count + COUNT.save(deps.storage, &0)?; - Ok(Response::new().add_attribute("owner", msg.owner.unwrap_or_else(|| "None".to_string()))) + Ok(Response::new().add_attribute("owner", owner)) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -53,479 +65,444 @@ pub fn execute( 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, + ExecuteMsg::Receive(msg) => execute_receive_cw20(deps, env, info, msg), + ExecuteMsg::Create(create_msg) => execute_create(deps, env, info, create_msg), + ExecuteMsg::Update { + id, emission_rate, vp_contract, hook_caller, withdraw_destination, - } => execute_register_reward_denom( + } => execute_update( deps, + env, info, - denom, + id, emission_rate, vp_contract, hook_caller, withdraw_destination, ), + ExecuteMsg::Fund(FundMsg { id }) => execute_fund_native(deps, env, info, id), + ExecuteMsg::Claim { id } => execute_claim(deps, env, info, id), + ExecuteMsg::Withdraw { id } => execute_withdraw(deps, info, env, id), } } -/// 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( +fn execute_receive_cw20( deps: DepsMut, + env: Env, info: MessageInfo, - denom: UncheckedDenom, - emission_rate: RewardEmissionRate, - vp_contract: String, - hook_caller: String, - withdraw_destination: Option, + wrapper: Cw20ReceiveMsg, +) -> Result { + nonpayable(&info)?; + + // verify msg + let msg: ReceiveCw20Msg = from_json(&wrapper.msg)?; + + match msg { + ReceiveCw20Msg::Fund(FundMsg { id }) => { + let distribution = DISTRIBUTIONS + .load(deps.storage, id) + .map_err(|_| ContractError::DistributionNotFound { id })?; + + match &distribution.denom { + Denom::Native(_) => return Err(ContractError::InvalidFunds {}), + Denom::Cw20(addr) => { + // ensure funding is coming from the cw20 we are currently + // distributing + if addr != info.sender { + return Err(ContractError::InvalidCw20 {}); + } + } + }; + + execute_fund(deps, env, distribution, wrapper.amount) + } + } +} + +/// creates a new rewards distribution. only the owner can do this. if funds +/// provided when creating a native token distribution, will start distributing +/// rewards immediately. +fn execute_create( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: CreateMsg, ) -> Result { - // only the owner can register a new denom + // only the owner can create a new distribution cw_ownable::assert_owner(deps.storage, &info.sender)?; - emission_rate.validate_emission_time_window()?; + // update count and use as the new distribution's ID + let id = COUNT.update(deps.storage, |count| -> StdResult { Ok(count + 1) })?; - 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 checked_denom = msg.denom.into_checked(deps.as_ref())?; + let hook_caller = deps.api.addr_validate(&msg.hook_caller)?; + let vp_contract = validate_voting_power_contract(&deps, msg.vp_contract)?; - let withdraw_destination = match withdraw_destination { + let withdraw_destination = match msg.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, + None => info.sender.clone(), }; - // Initialize the reward state - let reward_state = DenomRewardState { + msg.emission_rate.validate()?; + + // Initialize the distribution state + let distribution = DistributionState { + id, denom: checked_denom, - started_at: Expiration::Never {}, - ends_at: Expiration::Never {}, - emission_rate, - total_earned_puvp: Uint256::zero(), - last_update: Expiration::Never {}, + active_epoch: Epoch { + started_at: Expiration::Never {}, + ends_at: Expiration::Never {}, + emission_rate: msg.emission_rate, + total_earned_puvp: Uint256::zero(), + last_updated_total_earned_puvp: Expiration::Never {}, + }, vp_contract, hook_caller: hook_caller.clone(), funded_amount: Uint128::zero(), withdraw_destination, + historical_earned_puvp: Uint256::zero(), }; - 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())?; + // store the new distribution state, erroring if it already exists. this + // should never happen, but just in case. + DISTRIBUTIONS.update(deps.storage, id, |existing| match existing { + Some(_) => Err(ContractError::UnexpectedDuplicateDistributionId { id }), + None => Ok(distribution.clone()), + })?; - Ok(Response::default()) + // update the registered hooks to include the new distribution + subscribe_distribution_to_hook(deps.storage, id, hook_caller.clone())?; + + let mut response = Response::new() + .add_attribute("action", "create") + .add_attribute("id", id.to_string()) + .add_attribute("denom", distribution.get_denom_string()); + + // if native funds provided, ensure they are for this denom. if other native + // funds present, return error. if no funds, do nothing and leave registered + // denom with no funding, to be funded later. + if !info.funds.is_empty() { + match &distribution.denom { + Denom::Native(denom) => { + // ensures there is exactly 1 coin passed that matches the denom + let amount = must_pay(&info, denom)?; + + execute_fund(deps, env, distribution, amount)?; + + response = response.add_attribute("amount_funded", amount); + } + Denom::Cw20(_) => return Err(ContractError::NoFundsOnCw20Create {}), + } + } + + Ok(response) } -/// 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( +/// updates the config for a distribution +#[allow(clippy::too_many_arguments)] +fn execute_update( deps: DepsMut, - info: MessageInfo, env: Env, - denom: String, + info: MessageInfo, + id: u64, + emission_rate: Option, + vp_contract: Option, + hook_caller: Option, + withdraw_destination: Option, ) -> Result { - // only the owner can initiate a shutdown - cw_ownable::assert_owner(deps.storage, &info.sender)?; + nonpayable(&info)?; - let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; + // only the owner can update a distribution + cw_ownable::assert_owner(deps.storage, &info.sender)?; - // 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()) - ); + let mut distribution = DISTRIBUTIONS + .load(deps.storage, id) + .map_err(|_| ContractError::DistributionNotFound { id })?; - // 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; + if let Some(emission_rate) = emission_rate { + emission_rate.validate()?; - // 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), - }; + // transition the epoch to the new emission rate + distribution.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; + } - // 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)))?; + if let Some(vp_contract) = vp_contract { + distribution.vp_contract = validate_voting_power_contract(&deps, vp_contract)?; + } - // 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(), - )?; + if let Some(hook_caller) = hook_caller { + // remove existing from registered hooks + unsubscribe_distribution_from_hook(deps.storage, id, distribution.hook_caller)?; - // 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), - }; + distribution.hook_caller = deps.api.addr_validate(&hook_caller)?; - DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; + // add new to registered hooks + subscribe_distribution_to_hook(deps.storage, id, distribution.hook_caller.clone())?; + } - Ok(Response::new() - .add_attribute("action", "shutdown") - .add_message(clawback_msg)) -} + if let Some(withdraw_destination) = withdraw_destination { + distribution.withdraw_destination = deps.api.addr_validate(&withdraw_destination)?; + } -fn execute_receive( - deps: DepsMut, - env: Env, - info: MessageInfo, - wrapper: Cw20ReceiveMsg, -) -> Result { - // verify msg - let _msg: ReceiveMsg = from_json(&wrapper.msg)?; + DISTRIBUTIONS.save(deps.storage, id, &distribution)?; - let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; - execute_fund(deps, env, reward_denom_state, wrapper.amount) + Ok(Response::new() + .add_attribute("action", "update") + .add_attribute("id", id.to_string()) + .add_attribute("denom", distribution.get_denom_string())) } fn execute_fund_native( deps: DepsMut, env: Env, info: MessageInfo, + id: u64, ) -> Result { - let fund_coin = one_coin(&info).map_err(|_| ContractError::InvalidFunds {})?; + let distribution = DISTRIBUTIONS + .load(deps.storage, id) + .map_err(|_| ContractError::DistributionNotFound { id })?; - let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, fund_coin.denom.clone())?; + let amount = match &distribution.denom { + Denom::Native(denom) => { + must_pay(&info, denom).map_err(|_| ContractError::InvalidFunds {})? + } + Denom::Cw20(_) => return Err(ContractError::InvalidFunds {}), + }; - execute_fund(deps, env, reward_denom_state, fund_coin.amount) + execute_fund(deps, env, distribution, amount) } fn execute_fund( deps: DepsMut, env: Env, - mut denom_reward_state: DenomRewardState, + mut distribution: DistributionState, 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 + // will only be true if emission rate is linear and continuous is true + let continuous = + if let EmissionRate::Linear { continuous, .. } = distribution.active_epoch.emission_rate { + continuous + } else { + false + }; + + // restart the distribution from the current block if it hasn't yet started + // (i.e. never been funded), or if it's expired (i.e. all funds have been + // distributed) and not continuous. if it is continuous, treat it as if it + // weren't expired by simply adding the new funds and recomputing the end + // date, keeping start date the same, effectively backfilling rewards. + let restart_distribution = if distribution.funded_amount.is_zero() { + true + } else { + !continuous && distribution.active_epoch.ends_at.is_expired(&env.block) + }; + + // if necessary, restart the distribution from the current block so that the + // new funds start being distributed from now instead of from the past, and + // reset funded_amount to the new amount since we're effectively starting a + // new distribution. otherwise, just add the new amount to the existing + // funded_amount + if restart_distribution { + distribution.funded_amount = amount; + distribution.active_epoch.started_at = match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => Expiration::Never {}, + EmissionRate::Immediate {} => Expiration::Never {}, + EmissionRate::Linear { duration, .. } => match duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }, + }; + } else { + distribution.funded_amount += amount; + } + + let new_funded_duration = distribution + .active_epoch .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)) - } - } + .get_funded_period_duration(distribution.funded_amount)?; + distribution.active_epoch.ends_at = match new_funded_duration { + Some(duration) => distribution.active_epoch.started_at.add(duration)?, + None => Expiration::Never {}, }; - denom_reward_state.funded_amount += amount; - DENOM_REWARD_STATES.save( - deps.storage, - denom_reward_state.to_str_denom(), - &denom_reward_state, - )?; + // if immediate distribution, update total_earned_puvp instantly since we + // need to know the delta in funding_amount to calculate the new + // total_earned_puvp. + if (distribution.active_epoch.emission_rate == EmissionRate::Immediate {}) { + distribution.update_immediate_emission_total_earned_puvp( + deps.as_ref(), + &env.block, + amount, + )?; - Ok(Response::default()) + // if continuous, meaning rewards should have been distributed in the past + // but were not due to lack of sufficient funding, ensure the total rewards + // earned puvp is up to date. + } else if !restart_distribution && continuous { + distribution.active_epoch.total_earned_puvp = + get_active_total_earned_puvp(deps.as_ref(), &env.block, &distribution)?; + } + + distribution.active_epoch.bump_last_updated(&env.block); + + DISTRIBUTIONS.save(deps.storage, distribution.id, &distribution)?; + + Ok(Response::new() + .add_attribute("action", "fund") + .add_attribute("id", distribution.id.to_string()) + .add_attribute("denom", distribution.get_denom_string()) + .add_attribute("amount_funded", amount)) } fn execute_claim( mut deps: DepsMut, env: Env, info: MessageInfo, - denom: String, + id: u64, ) -> 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) - }, - )?; + nonpayable(&info)?; + + // update the distribution for the sender. this updates the distribution + // state and the user reward state. + update_rewards(&mut deps, &env, &info.sender, id)?; + + // load the updated states. previous `update_rewards` call ensures that + // these states exist. + let distribution = DISTRIBUTIONS.load(deps.storage, id)?; + let mut user_reward_state = USER_REWARDS.load(deps.storage, info.sender.clone())?; + + // updating the map returns the previous value if it existed. we set the + // value to zero and get the amount of pending rewards until this point. + let claim_amount = user_reward_state + .pending_rewards + .insert(id, Uint128::zero()) + .unwrap_or_default(); - if amount.is_zero() { + // if there are no rewards to claim, error out + if claim_amount.is_zero() { return Err(ContractError::NoRewardsClaimable {}); } + // otherwise reflect the updated user reward state and transfer out the + // claimed rewards + USER_REWARDS.save(deps.storage, info.sender.clone(), &user_reward_state)?; + + let denom_str = distribution.get_denom_string(); + Ok(Response::new() .add_message(get_transfer_msg( info.sender.clone(), - amount, - denom_reward_state.denom, + claim_amount, + distribution.denom, )?) - .add_attribute("action", "claim")) + .add_attribute("action", "claim") + .add_attribute("id", id.to_string()) + .add_attribute("denom", denom_str) + .add_attribute("amount_claimed", claim_amount)) } -fn execute_update_owner( +/// withdraws the undistributed rewards for a distribution. members can claim +/// whatever they earned until this point. this is effectively an inverse to +/// fund and does not affect any already-distributed rewards. can only be called +/// by the admin and only during the distribution period. updates the period +/// finish expiration to the current block. +fn execute_withdraw( deps: DepsMut, info: MessageInfo, env: Env, - action: cw_ownable::Action, + id: u64, ) -> 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()); + nonpayable(&info)?; - // 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, - ); + // only the owner can initiate a withdraw + cw_ownable::assert_owner(deps.storage, &info.sender)?; - // update the user's earned rewards that have been accounted for - user_reward_state - .denom_rewards_puvp - .insert(denom.clone(), total_earned_puvp); + let mut distribution = DISTRIBUTIONS + .load(deps.storage, id) + .map_err(|_| ContractError::DistributionNotFound { id })?; - 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) - } -} + // withdraw is only possible during the distribution period + ensure!( + !distribution.active_epoch.ends_at.is_expired(&env.block), + ContractError::RewardsAlreadyDistributed {} + ); -// 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(); + // withdraw ends the epoch early + distribution.active_epoch.ends_at = match distribution.active_epoch.started_at { + Expiration::Never {} => Expiration::Never {}, + Expiration::AtHeight(_) => Expiration::AtHeight(env.block.height), + Expiration::AtTime(_) => Expiration::AtTime(env.block.time), + }; - // 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(); + // get total rewards distributed based on newly updated ends_at + let rewards_distributed = distribution.get_total_rewards()?; - // 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)?; + let clawback_amount = distribution.funded_amount - rewards_distributed; - // 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()?; + // remove withdrawn funds from amount funded since they are no longer funded + distribution.funded_amount = rewards_distributed; - accrued_rewards.insert(denom.to_string(), accrued_rewards_amount); + let clawback_msg = get_transfer_msg( + distribution.withdraw_destination.clone(), + clawback_amount, + distribution.denom.clone(), + )?; - Ok(accrued_rewards) -} + DISTRIBUTIONS.save(deps.storage, id, &distribution)?; -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) + Ok(Response::new() + .add_attribute("action", "withdraw") + .add_attribute("id", id.to_string()) + .add_attribute("denom", distribution.get_denom_string()) + .add_attribute("amount_withdrawn", clawback_amount) + .add_attribute("amount_distributed", rewards_distributed) + .add_message(clawback_msg)) } -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) -} +fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + nonpayable(&info)?; -/// 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, - } + // 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::new().add_attributes(ownership.into_attributes())) } #[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)?; + QueryMsg::PendingRewards { + address, + start_after, + limit, + } => Ok(to_json_binary(&query_pending_rewards( + deps, + env, + address, + start_after, + limit, + )?)?), + QueryMsg::Distribution { id } => { + let state = DISTRIBUTIONS.load(deps.storage, id)?; Ok(to_json_binary(&state)?) } + QueryMsg::Distributions { start_after, limit } => Ok(to_json_binary( + &query_distributions(deps, start_after, limit)?, + )?), } } @@ -534,115 +511,87 @@ fn query_info(deps: Deps) -> StdResult { 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 { +/// returns the pending rewards for a given address that are ready to be +/// claimed. +fn query_pending_rewards( + deps: Deps, + env: Env, + addr: String, + start_after: Option, + limit: Option, +) -> StdResult { let addr = deps.api.addr_validate(&addr)?; - let reward_states = DENOM_REWARD_STATES - .range(deps.storage, None, None, Order::Ascending) + + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::::exclusive); + + // user may not have interacted with the contract before this query so we + // potentially return the default user reward state + let user_reward_state = USER_REWARDS + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + + let distributions = DISTRIBUTIONS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) .collect::>>()?; - let mut pending_rewards: HashMap = HashMap::new(); + let mut pending_rewards: Vec = vec![]; + + // iterate over all distributions and calculate pending rewards for the user + for (id, distribution) in distributions { + // first we get the active epoch earned puvp value + let active_total_earned_puvp = + get_active_total_earned_puvp(deps, &env.block, &distribution)?; - for (denom, reward_state) in reward_states { - let total_earned_puvp = get_total_earned_puvp(&env, deps, &reward_state)?; + // then we add that to the historical rewards earned puvp + let total_earned_puvp = + active_total_earned_puvp.checked_add(distribution.historical_earned_puvp)?; - let earned_rewards = get_accrued_rewards_since_last_user_action( + let existing_amount = user_reward_state + .pending_rewards + .get(&id) + .cloned() + .unwrap_or_default(); + + let unaccounted_for_rewards = get_accrued_rewards_not_yet_accounted_for( deps, &env, &addr, total_earned_puvp, - &reward_state.vp_contract, - denom.to_string(), + &distribution, + &user_reward_state, )?; - 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); + pending_rewards.push(DistributionPendingRewards { + id, + denom: distribution.denom, + pending_rewards: unaccounted_for_rewards + existing_amount, + }); } - let pending_rewards_response = PendingRewardsResponse { - address: addr.to_string(), - pending_rewards, - }; - Ok(pending_rewards_response) + Ok(PendingRewardsResponse { pending_rewards }) } -/// 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) -} +fn query_distributions( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::::exclusive); + + let distributions = DISTRIBUTIONS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, v)| v)) + .collect::>>()?; -/// 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 - ))), - } + Ok(DistributionsResponse { distributions }) } -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) +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) } diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index 68e661bb2..d3b9fadb4 100644 --- a/contracts/distribution/dao-rewards-distributor/src/error.rs +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -1,4 +1,5 @@ -use cosmwasm_std::StdError; +use cosmwasm_std::{DivideByZeroError, OverflowError, StdError}; +use cw_utils::PaymentError; use thiserror::Error; #[derive(Error, Debug, PartialEq)] @@ -12,30 +13,42 @@ pub enum ContractError { #[error(transparent)] Cw20Error(#[from] cw20_base::ContractError), - #[error("Invalid Cw20")] + #[error(transparent)] + Overflow(#[from] OverflowError), + + #[error(transparent)] + DivideByZero(#[from] DivideByZeroError), + + #[error(transparent)] + Payment(#[from] PaymentError), + + #[error("Invalid CW20")] InvalidCw20 {}, #[error("Invalid funds")] InvalidFunds {}, - #[error("Staking change hook sender is not staking contract")] + #[error("You cannot send native funds when creating a CW20 distribution")] + NoFundsOnCw20Create {}, + + #[error("Voting power changed hook sender incorrect")] InvalidHookSender {}, #[error("No rewards claimable")] NoRewardsClaimable {}, - #[error("Reward period not finished")] - RewardPeriodNotFinished {}, + #[error("All rewards have already been distributed")] + RewardsAlreadyDistributed {}, - #[error("Reward rate less then one per block")] - RewardRateLessThenOnePerBlock {}, + #[error("Distribution not found with ID {id}")] + DistributionNotFound { id: u64 }, - #[error("Reward duration can not be zero")] - ZeroRewardDuration {}, + #[error("Unexpected duplicate distribution with ID {id}")] + UnexpectedDuplicateDistributionId { id: u64 }, - #[error("Rewards distributor shutdown error: {0}")] - ShutdownError(String), + #[error("Invalid emission rate: {field} cannot be zero")] + InvalidEmissionRateFieldZero { field: String }, - #[error("Denom already registered")] - DenomAlreadyRegistered {}, + #[error("There is no voting power registered, so no one will receive these funds")] + NoVotingPowerNoRewards {}, } diff --git a/contracts/distribution/dao-rewards-distributor/src/helpers.rs b/contracts/distribution/dao-rewards-distributor/src/helpers.rs new file mode 100644 index 000000000..1960845ae --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/helpers.rs @@ -0,0 +1,112 @@ +use cosmwasm_std::{ + coins, to_json_binary, Addr, BankMsg, BlockInfo, CosmosMsg, Deps, DepsMut, StdError, StdResult, + Uint128, Uint256, WasmMsg, +}; +use cw20::{Denom, Expiration}; +use cw_utils::Duration; +use dao_interface::voting::{ + Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; + +use crate::ContractError; + +pub fn get_prev_block_total_vp( + deps: Deps, + block: &BlockInfo, + contract_addr: &Addr, +) -> StdResult { + let msg = VotingQueryMsg::TotalPowerAtHeight { + height: Some(block.height.checked_sub(1).unwrap_or_default()), + }; + let resp: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.power) +} + +pub fn get_voting_power_at_block( + deps: Deps, + block: &BlockInfo, + contract_addr: &Addr, + addr: &Addr, +) -> StdResult { + let msg = VotingQueryMsg::VotingPowerAtHeight { + address: addr.into(), + height: Some(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. +pub fn get_duration_scalar(duration: &Duration) -> u64 { + match duration { + Duration::Height(h) => *h, + Duration::Time(t) => *t, + } +} + +/// Returns the appropriate CosmosMsg for transferring the reward token. +pub 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. The first argument is end, and the second is start. +pub fn get_exp_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 + ))), + } +} + +pub 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/hooks.rs b/contracts/distribution/dao-rewards-distributor/src/hooks.rs index d57185f12..02ad08933 100644 --- a/contracts/distribution/dao-rewards-distributor/src/hooks.rs +++ b/contracts/distribution/dao-rewards-distributor/src/hooks.rs @@ -1,33 +1,54 @@ -use cosmwasm_std::{Addr, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cosmwasm_std::{Addr, Deps, DepsMut, Env, MessageInfo, Response, StdResult, Storage}; use cw4::MemberChangedHookMsg; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; -use crate::{contract::update_rewards, state::REGISTERED_HOOK_DENOMS, ContractError}; +use crate::{rewards::update_rewards, state::REGISTERED_HOOKS, ContractError}; -/// Register a hook caller contract for a given denom. -pub(crate) fn subscribe_denom_to_hook( - deps: DepsMut, - denom: String, +/// Register a hook caller contract for a given distribution ID. +pub(crate) fn subscribe_distribution_to_hook( + storage: &mut dyn Storage, + distribution_id: u64, hook: Addr, ) -> Result<(), ContractError> { - REGISTERED_HOOK_DENOMS.update(deps.storage, hook, |denoms| -> StdResult<_> { + REGISTERED_HOOKS.update(storage, hook, |denoms| -> StdResult<_> { let mut denoms = denoms.unwrap_or_default(); - denoms.push(denom.to_string()); + denoms.push(distribution_id); Ok(denoms) })?; Ok(()) } +/// Unregister a hook caller contract for a given distribution ID. +pub(crate) fn unsubscribe_distribution_from_hook( + storage: &mut dyn Storage, + distribution_id: u64, + hook: Addr, +) -> Result<(), ContractError> { + let mut denoms = REGISTERED_HOOKS + .may_load(storage, hook.clone())? + .unwrap_or_default(); + + denoms.retain(|id| *id != distribution_id); + + if denoms.is_empty() { + REGISTERED_HOOKS.remove(storage, hook); + } else { + REGISTERED_HOOKS.save(storage, hook, &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( +/// Returns a list of distribution IDs that the hook caller is registered for. +pub(crate) fn get_hook_caller_registered_distribution_ids( deps: Deps, info: MessageInfo, -) -> Result, ContractError> { +) -> 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 + REGISTERED_HOOKS .load(deps.storage, info.sender.clone()) .map_err(|_| ContractError::InvalidHookSender {}) } @@ -39,12 +60,14 @@ pub(crate) fn execute_stake_changed( 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)?; + let hooked_distribution_ids = get_hook_caller_registered_distribution_ids(deps.as_ref(), info)?; match msg { - StakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr, hooked_denoms), + StakeChangedHookMsg::Stake { addr, .. } => { + update_for_stake(deps, env, addr, hooked_distribution_ids) + } StakeChangedHookMsg::Unstake { addr, .. } => { - execute_unstake(deps, env, addr, hooked_denoms) + execute_unstake(deps, env, addr, hooked_distribution_ids) } } } @@ -56,13 +79,13 @@ pub(crate) fn execute_membership_changed( 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)?; + let hooked_distribution_ids = get_hook_caller_registered_distribution_ids(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)?; + for id in hooked_distribution_ids.clone() { + update_rewards(&mut deps, &env, &addr, id)?; } } @@ -76,25 +99,28 @@ pub(crate) fn execute_nft_stake_changed( 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)?; + let hooked_distribution_ids = get_hook_caller_registered_distribution_ids(deps.as_ref(), info)?; match msg { - NftStakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr, hooked_denoms), + NftStakeChangedHookMsg::Stake { addr, .. } => { + update_for_stake(deps, env, addr, hooked_distribution_ids) + } NftStakeChangedHookMsg::Unstake { addr, .. } => { - execute_unstake(deps, env, addr, hooked_denoms) + execute_unstake(deps, env, addr, hooked_distribution_ids) } } } -pub(crate) fn execute_stake( +pub(crate) fn update_for_stake( mut deps: DepsMut, env: Env, addr: Addr, - hooked_denoms: Vec, + hooked_distribution_ids: 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)?; + // update rewards for every distribution ID that the hook caller is + // registered for + for id in hooked_distribution_ids { + update_rewards(&mut deps, &env, &addr, id)?; } Ok(Response::new().add_attribute("action", "stake")) } @@ -103,11 +129,12 @@ pub(crate) fn execute_unstake( mut deps: DepsMut, env: Env, addr: Addr, - hooked_denoms: Vec, + hooked_distribution_ids: 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)?; + // update rewards for every distribution ID that the hook caller is + // registered for + for id in hooked_distribution_ids { + update_rewards(&mut deps, &env, &addr, id)?; } 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 index 51ae5c619..8226f57a9 100644 --- a/contracts/distribution/dao-rewards-distributor/src/lib.rs +++ b/contracts/distribution/dao-rewards-distributor/src/lib.rs @@ -2,8 +2,10 @@ pub mod contract; mod error; +pub mod helpers; pub mod hooks; pub mod msg; +pub mod rewards; pub mod state; #[cfg(test)] diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index e2d41c112..b321dfa7f 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -1,25 +1,22 @@ -use std::collections::HashMap; - use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{StdError, StdResult, Uint128, Uint256}; -use cw20::{Cw20ReceiveMsg, UncheckedDenom}; +use cosmwasm_std::Uint128; +use cw20::{Cw20ReceiveMsg, Denom, 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; +use crate::state::{DistributionState, EmissionRate}; + #[cw_serde] pub struct InstantiateMsg { - /// The owner of the contract. Is able to fund the contract and update - /// the reward duration. + /// The owner of the contract. Is able to fund the contract and update the + /// reward duration. If not provided, the instantiator is used. pub owner: Option, } @@ -33,81 +30,59 @@ pub enum ExecuteMsg { NftStakeChangeHook(NftStakeChangedHookMsg), /// Called when tokens are staked or unstaked. StakeChangeHook(StakeChangedHookMsg), - /// Claims rewards for the sender. - Claim { denom: String }, + /// registers a new distribution + Create(CreateMsg), + /// updates the config for a distribution + Update { + /// distribution ID to update + id: u64, + /// reward emission rate + emission_rate: Option, + /// address to query the voting power + vp_contract: Option, + /// address that will update the reward split when the voting power + /// distribution changes + hook_caller: Option, + /// destination address for reward clawbacks. defaults to owner + withdraw_destination: Option, + }, /// 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, - }, + Fund(FundMsg), + /// Claims rewards for the sender. + Claim { id: u64 }, + /// withdraws the undistributed rewards for a distribution. members can + /// claim whatever they earned until this point. this is effectively an + /// inverse to fund and does not affect any already-distributed rewards. + Withdraw { id: u64 }, } -/// 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) - } +pub struct CreateMsg { + /// denom to distribute + pub denom: UncheckedDenom, + /// reward emission rate + pub emission_rate: EmissionRate, + /// address to query the voting power + pub vp_contract: String, + /// address that will update the reward split when the voting power + /// distribution changes + pub hook_caller: String, + /// destination address for reward clawbacks. defaults to owner + pub withdraw_destination: Option, } #[cw_serde] -pub enum MigrateMsg {} +pub struct FundMsg { + /// distribution ID to fund + pub id: u64, +} #[cw_serde] -pub enum ReceiveMsg { +pub enum ReceiveCw20Msg { /// Used to fund this contract with cw20 tokens. - Fund {}, + Fund(FundMsg), } #[cw_serde] @@ -116,26 +91,46 @@ 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 }, + /// Returns the pending rewards for the given address. + #[returns(PendingRewardsResponse)] + PendingRewards { + address: String, + start_after: Option, + limit: Option, + }, + /// Returns the state of the given distribution. + #[returns(DistributionState)] + Distribution { id: u64 }, + /// Returns the state of all the distributions. + #[returns(DistributionsResponse)] + Distributions { + start_after: Option, + limit: Option, + }, } #[cw_serde] -pub struct RewardsStateResponse { - pub rewards: Vec, +pub struct DistributionsResponse { + pub distributions: Vec, } #[cw_serde] pub struct PendingRewardsResponse { - pub address: String, - pub pending_rewards: HashMap, + pub pending_rewards: Vec, +} + +#[cw_serde] +pub struct DistributionPendingRewards { + /// distribution ID + pub id: u64, + /// denomination of the pending rewards + pub denom: Denom, + /// amount of pending rewards in the denom being distributed + pub pending_rewards: Uint128, } + +#[cw_serde] +pub enum MigrateMsg {} diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs new file mode 100644 index 000000000..c44ece1fc --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -0,0 +1,176 @@ +use cosmwasm_std::{Addr, BlockInfo, Deps, DepsMut, Env, StdResult, Uint128, Uint256}; + +use crate::{ + helpers::{ + get_duration_scalar, get_exp_diff, get_prev_block_total_vp, get_voting_power_at_block, + scale_factor, + }, + state::{DistributionState, EmissionRate, UserRewardState, DISTRIBUTIONS, USER_REWARDS}, + ContractError, +}; + +/// updates the user reward state for a given distribution and user address. +/// also syncs the global reward state with the latest puvp values. +pub fn update_rewards( + deps: &mut DepsMut, + env: &Env, + addr: &Addr, + distribution_id: u64, +) -> Result<(), ContractError> { + let mut distribution = DISTRIBUTIONS + .load(deps.storage, distribution_id) + .map_err(|_| ContractError::DistributionNotFound { + id: distribution_id, + })?; + + // user may not have a reward state set yet if that is their first time + // claiming, so we default to an empty state + let mut user_reward_state = USER_REWARDS + .may_load(deps.storage, addr.clone())? + .unwrap_or_default(); + + // first update the active epoch earned puvp value up to the current block + distribution.active_epoch.total_earned_puvp = + get_active_total_earned_puvp(deps.as_ref(), &env.block, &distribution)?; + distribution.active_epoch.bump_last_updated(&env.block); + + // then calculate the total applicable puvp, which is the sum of historical + // rewards earned puvp and the active epoch total earned puvp we just + // updated above based on the current block + let total_applicable_puvp = distribution + .active_epoch + .total_earned_puvp + .checked_add(distribution.historical_earned_puvp)?; + + let unaccounted_for_rewards = get_accrued_rewards_not_yet_accounted_for( + deps.as_ref(), + env, + addr, + total_applicable_puvp, + &distribution, + &user_reward_state, + )?; + + // get the pre-existing pending reward amount for the distribution + let previous_pending_reward_amount = user_reward_state + .pending_rewards + .get(&distribution.id) + .cloned() + .unwrap_or_default(); + + let amount_sum = unaccounted_for_rewards.checked_add(previous_pending_reward_amount)?; + + // get the amount of newly earned rewards for the distribution + user_reward_state + .pending_rewards + .insert(distribution_id, amount_sum); + + // update the accounted for amount to that of the total applicable puvp + user_reward_state + .accounted_for_rewards_puvp + .insert(distribution_id, total_applicable_puvp); + + // reflect the updated state changes + USER_REWARDS.save(deps.storage, addr.clone(), &user_reward_state)?; + DISTRIBUTIONS.save(deps.storage, distribution_id, &distribution)?; + + Ok(()) +} + +/// Calculate the total rewards per unit voting power in the active epoch. +pub fn get_active_total_earned_puvp( + deps: Deps, + block: &BlockInfo, + distribution: &DistributionState, +) -> StdResult { + match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => Ok(Uint256::zero()), + // this is updated manually during funding, so just return it here. + EmissionRate::Immediate {} => Ok(distribution.active_epoch.total_earned_puvp), + EmissionRate::Linear { + amount, duration, .. + } => { + let curr = distribution.active_epoch.total_earned_puvp; + + let last_time_rewards_distributed = + distribution.get_latest_reward_distribution_time(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 = get_exp_diff( + &last_time_rewards_distributed, + &distribution.active_epoch.last_updated_total_earned_puvp, + )? + .into(); + + // no need to query total voting power and do math if distribution + // is already up to date. + if new_reward_distribution_duration.is_zero() { + return Ok(curr); + } + + let prev_total_power = get_prev_block_total_vp(deps, block, &distribution.vp_contract)?; + + // if no voting power is registered, no one should receive rewards. + if prev_total_power.is_zero() { + Ok(curr) + } else { + // 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(get_duration_scalar(&duration).into())?; + + // 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 = 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.checked_add(new_rewards_puvp)?) + } + } + } +} + +// get a user's rewards not yet accounted for in their reward state (not pending +// nor claimed, but available to them due to the passage of time). +pub fn get_accrued_rewards_not_yet_accounted_for( + deps: Deps, + env: &Env, + addr: &Addr, + total_earned_puvp: Uint256, + distribution: &DistributionState, + user_reward_state: &UserRewardState, +) -> StdResult { + // get the user's voting power at the current height + let voting_power: Uint256 = + get_voting_power_at_block(deps, &env.block, &distribution.vp_contract, addr)?.into(); + + // get previous reward per unit voting power accounted for + let user_last_reward_puvp = user_reward_state + .accounted_for_rewards_puvp + .get(&distribution.id) + .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()?; + + Ok(accrued_rewards_amount) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 1489266a3..1ae2f27e3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -1,163 +1,386 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{ensure, Addr, BlockInfo, StdError, StdResult, Uint128, Uint256}; +use cosmwasm_std::{ + ensure, Addr, BlockInfo, Decimal, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, + Uint64, +}; use cw20::{Denom, Expiration}; -use cw_storage_plus::Map; +use cw_storage_plus::{Item, Map}; use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; -use crate::{msg::RewardEmissionRate, ContractError}; +use crate::{ + helpers::{get_duration_scalar, get_exp_diff, get_prev_block_total_vp, scale_factor}, + rewards::get_active_total_earned_puvp, + ContractError, +}; /// map user address to their unique reward state -pub const USER_REWARD_STATES: Map = Map::new("u_r_s"); +pub const USER_REWARDS: Map = Map::new("ur"); -/// map denom string to the state of its reward distribution -pub const DENOM_REWARD_STATES: Map = Map::new("d_r_s"); +/// map distribution ID to the its distribution state +pub const DISTRIBUTIONS: Map = Map::new("d"); -/// map registered hooks to list of denoms they're registered for -pub const REGISTERED_HOOK_DENOMS: Map> = Map::new("r_h_d"); +/// map registered hooks to list of distribution IDs they're registered for +pub const REGISTERED_HOOKS: Map> = Map::new("rh"); + +/// The number of distributions that have been created. +pub const COUNT: Item = Item::new("count"); #[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, + /// map distribution ID to the user's pending rewards that have been + /// accounted for but not yet claimed. + pub pending_rewards: HashMap, + /// map distribution ID to the user's earned rewards per unit voting power + /// that have already been accounted for (added to pending and maybe + /// claimed). + pub accounted_for_rewards_puvp: HashMap, } -/// the state of a denom's reward distribution +/// defines how many tokens (amount) should be distributed per amount of time +/// (duration). e.g. 5udenom per hour. #[cw_serde] -pub struct DenomRewardState { - /// validated denom (native or cw20) - pub denom: Denom, +pub enum EmissionRate { + /// rewards are paused + Paused {}, + /// rewards are distributed immediately + Immediate {}, + /// rewards are distributed at a constant rate + Linear { + /// amount of tokens to distribute per amount of time + amount: Uint128, + /// duration of time to distribute amount + duration: Duration, + /// whether or not reward distribution is continuous: whether future + /// funding after distribution finishes should be applied to the past, + /// or rewards are paused once all funding has been distributed. all + /// continuously backfilled rewards are distributed based on the current + /// voting power. + continuous: bool, + }, +} + +impl EmissionRate { + /// validate non-zero amount and duration if necessary + pub fn validate(&self) -> Result<(), ContractError> { + match self { + EmissionRate::Paused {} => Ok(()), + EmissionRate::Immediate {} => Ok(()), + EmissionRate::Linear { + amount, duration, .. + } => { + if *amount == Uint128::zero() { + return Err(ContractError::InvalidEmissionRateFieldZero { + field: "amount".to_string(), + }); + } + if get_duration_scalar(duration) == 0 { + return Err(ContractError::InvalidEmissionRateFieldZero { + field: "duration".to_string(), + }); + } + Ok(()) + } + } + } + + /// find the duration of the funded period given funded amount. e.g. if the + /// funded amount is twice the emission rate amount, the funded period + /// should be twice the emission rate duration, since the funded amount + /// takes two emission cycles to be distributed. + pub fn get_funded_period_duration( + &self, + funded_amount: Uint128, + ) -> StdResult> { + match self { + // if rewards are paused, return no duration + EmissionRate::Paused {} => Ok(None), + // if rewards are immediate, return no duration + EmissionRate::Immediate {} => Ok(None), + // if rewards are linear, calculate based on funded amount + EmissionRate::Linear { + amount, duration, .. + } => { + let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, *amount); + + let funded_duration = match duration { + Duration::Height(h) => { + let duration_height = Uint128::from(*h) + .checked_mul_floor(amount_to_emission_rate_ratio) + .map_err(|e| StdError::generic_err(e.to_string()))?; + let duration = Uint64::try_from(duration_height)?.u64(); + Duration::Height(duration) + } + Duration::Time(t) => { + let duration_time = Uint128::from(*t) + .checked_mul_floor(amount_to_emission_rate_ratio) + .map_err(|e| StdError::generic_err(e.to_string()))?; + let duration = Uint64::try_from(duration_time)?.u64(); + Duration::Time(duration) + } + }; + + Ok(Some(funded_duration)) + } + } + } +} + +#[cw_serde] +pub struct Epoch { + /// reward emission rate + pub emission_rate: EmissionRate, /// 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 + /// last_updated_total_earned_puvp pub total_earned_puvp: Uint256, - /// time when total_earned_puvp was last updated for this denom - pub last_update: Expiration, + /// time when total_earned_puvp was last updated + pub last_updated_total_earned_puvp: Expiration, +} + +impl Epoch { + /// bump the last_updated_total_earned_puvp field to the minimum of the + /// current block and ends_at since rewards cannot be distributed after + /// ends_at. this is necessary in the case that a future funding backfills + /// rewards after they've finished distributing. in order to compute over + /// the missed space, last_updated can never be greater than ends_at. if + /// ends_at is never, the epoch must be paused, so it should never be + /// updated. + pub fn bump_last_updated(&mut self, current_block: &BlockInfo) { + match self.ends_at { + Expiration::Never {} => { + self.last_updated_total_earned_puvp = Expiration::Never {}; + } + Expiration::AtHeight(ends_at_height) => { + self.last_updated_total_earned_puvp = + Expiration::AtHeight(std::cmp::min(current_block.height, ends_at_height)); + } + Expiration::AtTime(ends_at_time) => { + self.last_updated_total_earned_puvp = + Expiration::AtTime(std::cmp::min(current_block.time, ends_at_time)); + } + } + } +} + +/// the state of a reward distribution +#[cw_serde] +pub struct DistributionState { + /// distribution ID + pub id: u64, + /// validated denom (native or cw20) + pub denom: Denom, + /// current distribution epoch state + pub active_epoch: Epoch, /// 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 + /// total amount of rewards funded that will be distributed in the active + /// epoch. pub funded_amount: Uint128, - /// optional destination address for reward clawbacks + /// destination address for reward clawbacks pub withdraw_destination: Addr, + /// historical rewards earned per unit voting power from past epochs due to + /// changes in the emission rate. each time emission rate is changed, this + /// value is increased by the `active_epoch`'s rewards earned puvp. + pub historical_earned_puvp: Uint256, } -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 { +impl DistributionState { + pub fn get_denom_string(&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 `Never`, then no rewards are currently being + /// distributed, so return the last update. /// - If the end is `AtHeight(h)` or `AtTime(t)`, we compare the current - /// block height or time with `h` or `t` respectively. + /// 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. + /// 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. + /// 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)), + match self.active_epoch.ends_at { + Expiration::Never {} => self.active_epoch.last_updated_total_earned_puvp, + Expiration::AtHeight(ends_at_height) => { + Expiration::AtHeight(min(current_block.height, ends_at_height)) + } + Expiration::AtTime(ends_at_time) => { + Expiration::AtTime(min(current_block.time, ends_at_time)) + } } } - /// 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, + /// get the total rewards to be distributed based on the active epoch's + /// emission rate + pub fn get_total_rewards(&self) -> StdResult { + match self.active_epoch.emission_rate { + EmissionRate::Paused {} => Ok(Uint128::zero()), + EmissionRate::Immediate {} => Ok(self.funded_amount), + EmissionRate::Linear { + amount, duration, .. + } => { + let epoch_duration = + get_exp_diff(&self.active_epoch.ends_at, &self.active_epoch.started_at)?; + + let emission_rate_duration_scalar = match duration { + Duration::Height(h) => h, + Duration::Time(t) => t, + }; + + amount + .checked_multiply_ratio(epoch_duration, emission_rate_duration_scalar) + .map_err(|e| StdError::generic_err(e.to_string())) + } + } + } + + /// Finish current epoch early and start a new one with a new emission rate. + pub fn transition_epoch( + &mut self, + deps: Deps, + new_emission_rate: EmissionRate, current_block: &BlockInfo, ) -> Result<(), ContractError> { - match self.ends_at { - Expiration::AtHeight(_) | Expiration::AtTime(_) => { - ensure!( - self.ends_at.is_expired(current_block), - ContractError::RewardPeriodNotFinished {} - ); - Ok(()) + // if the new emission rate is the same as the active one, do nothing + if self.active_epoch.emission_rate == new_emission_rate { + return Ok(()); + } + + // 1. finish current epoch by updating rewards and setting end to now + self.active_epoch.total_earned_puvp = + get_active_total_earned_puvp(deps, current_block, self)?; + self.active_epoch.ends_at = match self.active_epoch.started_at { + Expiration::Never {} => Expiration::Never {}, + Expiration::AtHeight(_) => Expiration::AtHeight(current_block.height), + Expiration::AtTime(_) => Expiration::AtTime(current_block.time), + }; + + // 2. add current epoch rewards earned to historical rewards + // TODO: what to do on overflow? + self.historical_earned_puvp = self + .historical_earned_puvp + .checked_add(self.active_epoch.total_earned_puvp)?; + + // 3. deduct the distributed rewards amount from total funded amount, as + // those rewards are no longer distributed in the new epoch + let active_epoch_earned_rewards = self.get_total_rewards()?; + self.funded_amount = self + .funded_amount + .checked_sub(active_epoch_earned_rewards)?; + + // 4. start new epoch + + // we get the duration of the funded period and add it to the current + // block height. if the sum overflows, we return u64::MAX, as it + // suggests that the period is infinite or so long that it doesn't + // matter. + let new_ends_at = match new_emission_rate.get_funded_period_duration(self.funded_amount)? { + Some(Duration::Height(h)) => { + if current_block.height.checked_add(h).is_some() { + Expiration::AtHeight(current_block.height + h) + } else { + Expiration::AtHeight(u64::MAX) + } + } + Some(Duration::Time(t)) => { + if current_block.time.seconds().checked_add(t).is_some() { + Expiration::AtTime(current_block.time.plus_seconds(t)) + } else { + Expiration::AtTime(Timestamp::from_seconds(u64::MAX)) + } } - Expiration::Never {} => Ok(()), + // if there is no funded period duration, but the emission rate is + // immediate, set ends_at to the current block height to match + // started_at below, since funds are distributed immediately + None => Expiration::Never {}, + }; + + let new_started_at = match new_emission_rate { + EmissionRate::Paused {} => Expiration::Never {}, + EmissionRate::Immediate {} => Expiration::Never {}, + EmissionRate::Linear { duration, .. } => match duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }, + }; + + self.active_epoch = Epoch { + emission_rate: new_emission_rate.clone(), + started_at: new_started_at, + ends_at: new_ends_at, + // start the new active epoch with zero rewards earned + total_earned_puvp: Uint256::zero(), + last_updated_total_earned_puvp: new_started_at, + }; + + // if new emission rate is immediate, update total_earned_puvp with + // remaining funded_amount right away + if (self.active_epoch.emission_rate == EmissionRate::Immediate {}) { + self.update_immediate_emission_total_earned_puvp( + deps, + current_block, + self.funded_amount, + )?; + } + + Ok(()) + } + + /// Update the total_earned_puvp field in the active epoch for immediate + /// emission. This logic normally lives in get_active_total_earned_puvp, but + /// we need only need to execute this right when funding, and we need to + /// know the delta in funded amount, which is not accessible anywhere other + /// than when being funded or transitioning to a new emission rate. + pub fn update_immediate_emission_total_earned_puvp( + &mut self, + deps: Deps, + block: &BlockInfo, + funded_amount_delta: Uint128, + ) -> Result<(), ContractError> { + // should never happen + ensure!( + self.active_epoch.emission_rate == EmissionRate::Immediate {}, + ContractError::Std(StdError::generic_err(format!( + "expected immediate emission, got {:?}", + self.active_epoch.emission_rate + ))) + ); + + let curr = self.active_epoch.total_earned_puvp; + + let prev_total_power = get_prev_block_total_vp(deps, block, &self.vp_contract)?; + + // if no voting power is registered, error since rewards can't be + // distributed. + if prev_total_power.is_zero() { + Err(ContractError::NoVotingPowerNoRewards {}) + } else { + // the new rewards per unit voting power based on the funded amount + let new_rewards_puvp = Uint256::from(funded_amount_delta) + // this can never overflow since funded_amount is a Uint128 + .checked_mul(scale_factor())? + .checked_div(prev_total_power.into())?; + + self.active_epoch.total_earned_puvp = curr.checked_add(new_rewards_puvp)?; + + Ok(()) } } } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs index 352ed7cec..e84477456 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs @@ -7,9 +7,10 @@ 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 const ADDR1: &str = "addr1"; +pub const ADDR2: &str = "addr2"; +pub const ADDR3: &str = "addr3"; +pub const ADDR4: &str = "addr4"; pub fn contract_rewards() -> Box> { let contract = ContractWrapper::new( diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 2deb7b8f2..82bf15dcd 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -3,19 +3,20 @@ 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_ownable::Action; use cw_utils::Duration; +use dao_interface::voting::InfoResponse; use crate::{ msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RewardEmissionRate, - RewardsStateResponse, + CreateMsg, DistributionsResponse, ExecuteMsg, FundMsg, InstantiateMsg, + PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, }, - state::DenomRewardState, + state::{DistributionState, EmissionRate}, testing::cw20_setup::instantiate_cw20, + ContractError, }; use super::{ @@ -42,12 +43,14 @@ pub struct RewardsConfig { pub denom: UncheckedDenom, pub duration: Duration, pub destination: Option, + pub continuous: bool, } pub struct SuiteBuilder { pub _instantiate: InstantiateMsg, pub dao_type: DaoType, pub rewards_config: RewardsConfig, + pub cw4_members: Vec, } impl SuiteBuilder { @@ -62,7 +65,22 @@ impl SuiteBuilder { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Height(10), destination: None, + continuous: true, }, + cw4_members: vec![ + Member { + addr: ADDR1.to_string(), + weight: 2, + }, + Member { + addr: ADDR2.to_string(), + weight: 1, + }, + Member { + addr: ADDR3.to_string(), + weight: 1, + }, + ], } } @@ -71,6 +89,11 @@ impl SuiteBuilder { self } + pub fn with_cw4_members(mut self, cw4_members: Vec) -> Self { + self.cw4_members = cw4_members; + self + } + pub fn with_withdraw_destination(mut self, withdraw_destination: Option) -> Self { self.rewards_config.destination = withdraw_destination; self @@ -100,23 +123,8 @@ impl SuiteBuilder { 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); + setup_cw4_test(suite_built.app.borrow_mut(), self.cw4_members); suite_built.voting_power_addr = voting_power_addr.clone(); suite_built.staking_addr = dao_voting_addr.clone(); } @@ -242,19 +250,23 @@ impl SuiteBuilder { match self.dao_type { DaoType::CW721 => { suite_built.register_hook(suite_built.voting_power_addr.clone()); - suite_built.register_reward_denom( + suite_built.create( self.rewards_config.clone(), suite_built.voting_power_addr.to_string().as_ref(), + None, ); match self.rewards_config.denom { UncheckedDenom::Native(_) => { - suite_built.fund_distributor_native(coin(100_000_000, DENOM.to_string())); + suite_built.fund_native(1, 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), - }); + suite_built.fund_cw20( + 1, + Cw20Coin { + address: suite_built.cw20_addr.to_string(), + amount: Uint128::new(100_000_000), + }, + ); } }; } @@ -279,19 +291,23 @@ impl SuiteBuilder { }; suite_built.register_hook(suite_built.staking_addr.clone()); - suite_built.register_reward_denom( + suite_built.create( self.rewards_config.clone(), suite_built.staking_addr.to_string().as_ref(), + None, ); match &self.rewards_config.denom { UncheckedDenom::Native(_) => { - suite_built.fund_distributor_native(coin(100_000_000, DENOM.to_string())); + suite_built.fund_native(1, 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), - }); + suite_built.fund_cw20( + 1, + Cw20Coin { + address: addr.to_string(), + amount: Uint128::new(100_000_000), + }, + ); } }; } @@ -320,9 +336,9 @@ pub struct Suite { // SUITE QUERIES impl Suite { pub fn get_time_until_rewards_expiration(&mut self) -> u64 { - let rewards_state_response = self.get_rewards_state_response(); + let distribution = &self.get_distributions().distributions[0]; let current_block = self.app.block_info(); - let (expiration_unit, current_unit) = match rewards_state_response.rewards[0].ends_at { + let (expiration_unit, current_unit) = match distribution.active_epoch.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, @@ -364,93 +380,107 @@ impl Suite { 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 { + pub fn get_distributions(&mut self) -> DistributionsResponse { self.app .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::RewardsState {}, + &QueryMsg::Distributions { + start_after: None, + limit: None, + }, ) .unwrap() } - pub fn _get_denom_reward_state(&mut self, denom: &str) -> DenomRewardState { - let resp: DenomRewardState = self + pub fn get_distribution(&mut self, id: u64) -> DistributionState { + let resp: DistributionState = self .app .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::DenomRewardState { - denom: denom.to_string(), - }, + &QueryMsg::Distribution { id }, ) .unwrap(); - println!("[{} REWARD STATE] {:?}", denom, resp); resp } + + 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 get_info(&mut self) -> InfoResponse { + self.app + .borrow_mut() + .wrap() + .query_wasm_smart(self.distribution_contract.clone(), &QueryMsg::Info {}) + .unwrap() + } } // 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); + let distribution = &self.get_distributions().distributions[0]; + assert_eq!(distribution.active_epoch.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); + let distribution = &self.get_distributions().distributions[0]; + assert_eq!(distribution.active_epoch.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) - ); + let distribution = &self.get_distributions().distributions[0]; + match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => panic!("expected non-paused emission rate"), + EmissionRate::Immediate {} => panic!("expected non-immediate emission rate"), + EmissionRate::Linear { amount, .. } => assert_eq!(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() + let distribution = &self.get_distributions().distributions[0]; + match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => panic!("expected non-paused emission rate"), + EmissionRate::Immediate {} => panic!("expected non-immediate emission rate"), + EmissionRate::Linear { duration, .. } => assert_eq!( + match duration { + Duration::Height(h) => h, + Duration::Time(t) => t, + }, + expected + ), + } } - pub fn assert_pending_rewards(&mut self, address: &str, _denom: &str, expected: u128) { + pub fn assert_pending_rewards(&mut self, address: &str, id: u64, expected: u128) { let res: PendingRewardsResponse = self .app .borrow_mut() .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::GetPendingRewards { + &QueryMsg::PendingRewards { address: address.to_string(), + start_after: None, + limit: None, }, ) .unwrap(); - let pending = res.pending_rewards.get(self.reward_denom.as_str()).unwrap(); + let pending = res + .pending_rewards + .iter() + .find(|p| p.id == id) + .unwrap() + .pending_rewards; assert_eq!( pending, @@ -461,23 +491,21 @@ impl Suite { ); } - pub fn assert_native_balance(&mut self, address: &str, denom: &str, expected: u128) { + pub fn assert_native_balance(&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); + pub fn assert_cw20_balance(&self, cw20: &str, address: &str, expected: u128) { + let balance = self.get_balance_cw20(cw20, 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(), - }; + pub fn withdraw(&mut self, id: u64) { + let msg = ExecuteMsg::Withdraw { id }; self.app .execute_contract( Addr::unchecked(OWNER), @@ -488,26 +516,55 @@ impl Suite { .unwrap(); } + pub fn withdraw_error(&mut self, id: u64) -> ContractError { + let msg = ExecuteMsg::Withdraw { id }; + self.app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap_err() + .downcast() + .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 { + pub fn create( + &mut self, + reward_config: RewardsConfig, + hook_caller: &str, + funds: Option, + ) { + let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: reward_config.denom.clone(), - emission_rate: RewardEmissionRate { + emission_rate: EmissionRate::Linear { amount: Uint128::new(reward_config.amount), duration: reward_config.duration, + continuous: reward_config.continuous, }, hook_caller: hook_caller.to_string(), vp_contract: self.voting_power_addr.to_string(), withdraw_destination: reward_config.destination, + }); + + // include funds if provided + let send_funds = if let Some(funds) = funds { + match reward_config.denom { + UncheckedDenom::Native(denom) => vec![coin(funds.u128(), denom)], + UncheckedDenom::Cw20(_) => vec![], + } + } else { + vec![] }; self.app @@ -515,13 +572,13 @@ impl Suite { .execute_contract( self.owner.clone().unwrap(), self.distribution_contract.clone(), - ®ister_reward_denom_msg, - &[], + &execute_create_msg, + &send_funds, ) .unwrap(); } - pub fn mint_native_coin(&mut self, coin: Coin, dest: &str) { + pub fn mint_native(&mut self, coin: Coin, dest: &str) { // mint the tokens to be funded self.app .borrow_mut() @@ -534,32 +591,25 @@ impl Suite { .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, - }; + pub fn mint_cw20(&mut self, coin: Cw20Coin, name: &str) -> Addr { 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); + pub fn fund_native(&mut self, id: u64, coin: Coin) { + self.mint_native(coin.clone(), OWNER); self.app .borrow_mut() .execute_contract( Addr::unchecked(OWNER), self.distribution_contract.clone(), - &ExecuteMsg::Fund {}, + &ExecuteMsg::Fund(FundMsg { id }), &[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(); + pub fn fund_cw20(&mut self, id: u64, coin: Cw20Coin) { + let fund_sub_msg = to_json_binary(&ReceiveCw20Msg::Fund(FundMsg { id })).unwrap(); self.app .execute_contract( Addr::unchecked(OWNER), @@ -599,11 +649,8 @@ impl Suite { }); } - pub fn claim_rewards(&mut self, address: &str, denom: &str) { - let msg = ExecuteMsg::Claim { - denom: denom.to_string(), - }; - + pub fn claim_rewards(&mut self, address: &str, id: u64) { + let msg = ExecuteMsg::Claim { id }; self.app .execute_contract( Addr::unchecked(address), @@ -621,7 +668,6 @@ impl Suite { 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(); @@ -631,7 +677,6 @@ impl Suite { let msg = cw20_stake::msg::ExecuteMsg::Unstake { amount: Uint128::new(amount), }; - println!("[STAKING EVENT] {} unstaked {}", sender, amount); self.app .execute_contract( Addr::unchecked(sender), @@ -669,6 +714,136 @@ impl Suite { unstake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) } + pub fn update_emission_rate( + &mut self, + id: u64, + epoch_duration: Duration, + epoch_rewards: u128, + continuous: bool, + ) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: Some(EmissionRate::Linear { + amount: Uint128::new(epoch_rewards), + duration: epoch_duration, + continuous, + }), + vp_contract: None, + hook_caller: None, + withdraw_destination: None, + }; + + let _resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn set_immediate_emission(&mut self, id: u64) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: Some(EmissionRate::Immediate {}), + vp_contract: None, + hook_caller: None, + withdraw_destination: None, + }; + + let _resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn pause_emission(&mut self, id: u64) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: Some(EmissionRate::Paused {}), + vp_contract: None, + hook_caller: None, + withdraw_destination: None, + }; + + let _resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn update_vp_contract(&mut self, id: u64, vp_contract: &str) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: None, + vp_contract: Some(vp_contract.to_string()), + hook_caller: None, + withdraw_destination: None, + }; + + let _resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn update_hook_caller(&mut self, id: u64, hook_caller: &str) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: None, + vp_contract: None, + hook_caller: Some(hook_caller.to_string()), + withdraw_destination: None, + }; + + let _resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn update_withdraw_destination(&mut self, id: u64, withdraw_destination: &str) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: None, + vp_contract: None, + hook_caller: None, + withdraw_destination: Some(withdraw_destination.to_string()), + }; + + let _resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + pub fn update_members(&mut self, add: Vec, remove: Vec) { let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { remove, add }; @@ -689,7 +864,7 @@ impl Suite { }, ) .unwrap(); - println!("[UPDATE CW4] new members: {:?}", members); + // println!("[UPDATE CW4] new members: {:?}", members); members.members } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 764a04d3a..af32da1bc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -1,15 +1,21 @@ use std::borrow::BorrowMut; -use cosmwasm_std::Uint128; -use cosmwasm_std::{coin, to_json_binary, Addr, Timestamp}; +use cosmwasm_std::{coin, coins, to_json_binary, Addr, Timestamp}; +use cosmwasm_std::{Uint128, Uint256}; +use cw2::ContractVersion; use cw20::{Cw20Coin, Expiration, UncheckedDenom}; use cw4::Member; use cw_multi_test::Executor; use cw_utils::Duration; +use dao_interface::voting::InfoResponse; +use crate::msg::{CreateMsg, FundMsg}; +use crate::state::{EmissionRate, Epoch}; +use crate::testing::native_setup::setup_native_token_test; +use crate::ContractError; use crate::{ msg::ExecuteMsg, - testing::{ADDR1, ADDR2, ADDR3, DENOM}, + testing::{ADDR1, ADDR2, ADDR3, ADDR4, DENOM}, }; use super::{ @@ -20,6 +26,390 @@ use super::{ // 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] +#[should_panic(expected = "Distribution not found with ID 3")] +fn test_fund_native_404() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let mint_coin = coin(100, DENOM); + + suite.mint_native(mint_coin.clone(), OWNER); + suite.fund_native(3, mint_coin); +} + +#[test] +#[should_panic(expected = "Distribution not found with ID 3")] +fn test_fund_cw20_404() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20("irrelevant".to_string()), + duration: Duration::Height(10), + destination: None, + continuous: true, + }) + .build(); + + let mint_cw20 = Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(100), + }; + + let address = suite.mint_cw20(mint_cw20.clone(), "newcoin").to_string(); + + suite.fund_cw20( + 3, + Cw20Coin { + address, + amount: mint_cw20.amount, + }, + ); +} + +#[test] +fn test_native_dao_rewards_update_reward_rate() { + 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, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + + // set the rewards rate to half of the current one + // now there will be 5_000_000 tokens distributed over 100_000 blocks + suite.update_emission_rate(1, Duration::Height(10), 500, true); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 6_250_000); + suite.assert_pending_rewards(ADDR3, 1, 6_250_000); + + // double the rewards rate + // now there will be 10_000_000 tokens distributed over 100_000 blocks + suite.update_emission_rate(1, Duration::Height(10), 1_000, true); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 7_500_000); + suite.assert_pending_rewards(ADDR2, 1, 8_750_000); + suite.assert_pending_rewards(ADDR3, 1, 8_750_000); + + // skip 2/10ths of the time + suite.skip_blocks(200_000); + + suite.assert_pending_rewards(ADDR1, 1, 17_500_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000); + + // pause the rewards distribution + suite.pause_emission(1); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // assert no pending rewards changed + suite.assert_pending_rewards(ADDR1, 1, 17_500_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000); + + // assert ADDR1 pre-claim balance + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + // ADDR1 claims their rewards + suite.claim_rewards(ADDR1, 1); + // assert ADDR1 post-claim balance to be pre-claim + pending + suite.assert_native_balance(ADDR1, DENOM, 10_000_000 + 17_500_000); + // assert ADDR1 is now entitled to 0 pending rewards + suite.assert_pending_rewards(ADDR1, 1, 0); + + // user 2 unstakes their stake + suite.unstake_native_tokens(ADDR2, 50); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // only the ADDR1 pending rewards should have changed + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000); + + // ADDR2 claims their rewards (has 50 to begin with as they unstaked) + suite.assert_native_balance(ADDR2, DENOM, 50); + suite.claim_rewards(ADDR2, 1); + // assert ADDR2 post-claim balance to be pre-claim + pending and has 0 pending rewards + suite.assert_native_balance(ADDR2, DENOM, 13_750_000 + 50); + suite.assert_pending_rewards(ADDR2, 1, 0); + + // update the reward rate back to 1_000 / 10blocks + // this should now distribute 10_000_000 tokens over 100_000 blocks + // between ADDR1 (2/3rds) and ADDR3 (1/3rd) + suite.update_emission_rate(1, Duration::Height(10), 1000, true); + + // update with the same rate does nothing + suite.update_emission_rate(1, Duration::Height(10), 1000, true); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // assert that rewards are being distributed at the expected rate + suite.assert_pending_rewards(ADDR1, 1, 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000 + 3_333_333); + + // ADDR3 claims their rewards + suite.assert_native_balance(ADDR3, DENOM, 0); + suite.claim_rewards(ADDR3, 1); + suite.assert_pending_rewards(ADDR3, 1, 0); + suite.assert_native_balance(ADDR3, DENOM, 13_750_000 + 3_333_333); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 6_666_666 + 6_666_666 + 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 3_333_333); + + // claim everything so that there are 0 pending rewards + suite.claim_rewards(ADDR3, 1); + suite.claim_rewards(ADDR1, 1); + + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); + + // update the rewards rate to 40_000_000 per 100_000 blocks. + // split is still 2/3rds to ADDR1 and 1/3rd to ADDR3 + suite.update_emission_rate(1, Duration::Height(10), 4000, true); + suite.assert_ends_at(Expiration::AtHeight(1_062_500)); + + suite.skip_blocks(50_000); // allocates 20_000_000 tokens + + let addr1_pending = 20_000_000 * 2 / 3; + let addr3_pending = 20_000_000 / 3; + suite.assert_pending_rewards(ADDR1, 1, addr1_pending); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, addr3_pending); + + // ADDR2 wakes up to the increased staking rate and stakes 50 tokens + // this brings new split to: [ADDR1: 50%, ADDR2: 25%, ADDR3: 25%] + suite.stake_native_tokens(ADDR2, 50); + + suite.skip_blocks(10_000); // allocates 4_000_000 tokens + + suite.assert_pending_rewards(ADDR1, 1, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, 1, 4_000_000 / 4); + suite.assert_pending_rewards(ADDR3, 1, addr3_pending + 4_000_000 / 4); + + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR3, 1); + let addr1_pending = 0; + let addr3_pending = 0; + suite.skip_blocks(10_000); // skips from 1,060,000 to 1,070,000, and the end is 1,062,500, so this allocates only 1_000_000 tokens instead of 4_000_000 + + suite.assert_pending_rewards(ADDR1, 1, addr1_pending + 1_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, 1, 4_000_000 / 4 + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR3, 1, addr3_pending + 1_000_000 / 4); + + suite.claim_rewards(ADDR2, 1); + + // TODO: there's a few denoms remaining here, ensure such cases are handled properly + let remaining_rewards = suite.get_balance_native(suite.distribution_contract.clone(), DENOM); + println!("Remaining rewards: {}", remaining_rewards); +} + +#[test] +fn test_native_dao_rewards_reward_rate_switch_unit() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Height(10), + destination: None, + continuous: true, + }) + .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, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + + // set the rewards rate to time-based rewards + suite.update_emission_rate(1, Duration::Time(10), 500, true); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 6_250_000); + suite.assert_pending_rewards(ADDR3, 1, 6_250_000); + + // double the rewards rate + // now there will be 10_000_000 tokens distributed over 100_000 seconds + suite.update_emission_rate(1, Duration::Time(10), 1_000, true); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 7_500_000); + suite.assert_pending_rewards(ADDR2, 1, 8_750_000); + suite.assert_pending_rewards(ADDR3, 1, 8_750_000); + + // skip 2/10ths of the time + suite.skip_seconds(200_000); + + suite.assert_pending_rewards(ADDR1, 1, 17_500_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000); + + // pause the rewards distribution + suite.pause_emission(1); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // assert no pending rewards changed + suite.assert_pending_rewards(ADDR1, 1, 17_500_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000); + + // assert ADDR1 pre-claim balance + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + // ADDR1 claims their rewards + suite.claim_rewards(ADDR1, 1); + // assert ADDR1 post-claim balance to be pre-claim + pending + suite.assert_native_balance(ADDR1, DENOM, 10_000_000 + 17_500_000); + // assert ADDR1 is now entitled to 0 pending rewards + suite.assert_pending_rewards(ADDR1, 1, 0); + + // user 2 unstakes their stake + suite.unstake_native_tokens(ADDR2, 50); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // only the ADDR1 pending rewards should have changed + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000); + + // ADDR2 claims their rewards (has 50 to begin with as they unstaked) + suite.assert_native_balance(ADDR2, DENOM, 50); + suite.claim_rewards(ADDR2, 1); + // assert ADDR2 post-claim balance to be pre-claim + pending and has 0 pending rewards + suite.assert_native_balance(ADDR2, DENOM, 13_750_000 + 50); + suite.assert_pending_rewards(ADDR2, 1, 0); + + // update the reward rate back to 1_000 / 10blocks + // this should now distribute 10_000_000 tokens over 100_000 blocks + // between ADDR1 (2/3rds) and ADDR3 (1/3rd) + suite.update_emission_rate(1, Duration::Height(10), 1000, true); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // assert that rewards are being distributed at the expected rate + suite.assert_pending_rewards(ADDR1, 1, 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 13_750_000 + 3_333_333); + + // ADDR3 claims their rewards + suite.assert_native_balance(ADDR3, DENOM, 0); + suite.claim_rewards(ADDR3, 1); + suite.assert_pending_rewards(ADDR3, 1, 0); + suite.assert_native_balance(ADDR3, DENOM, 13_750_000 + 3_333_333); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 6_666_666 + 6_666_666 + 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 3_333_333); + + // claim everything so that there are 0 pending rewards + suite.claim_rewards(ADDR3, 1); + suite.claim_rewards(ADDR1, 1); + + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); + + // update the rewards rate to 40_000_000 per 100_000 seconds. + // split is still 2/3rds to ADDR1 and 1/3rd to ADDR3 + suite.update_emission_rate(1, Duration::Time(10), 4000, true); + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(462_500))); + + suite.skip_seconds(50_000); // allocates 20_000_000 tokens + + let addr1_pending = 20_000_000 * 2 / 3; + let addr3_pending = 20_000_000 / 3; + suite.assert_pending_rewards(ADDR1, 1, addr1_pending); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, addr3_pending); + + // ADDR2 wakes up to the increased staking rate and stakes 50 tokens + // this brings new split to: [ADDR1: 50%, ADDR2: 25%, ADDR3: 25%] + suite.stake_native_tokens(ADDR2, 50); + + suite.skip_seconds(10_000); // allocates 4_000_000 tokens + + suite.assert_pending_rewards(ADDR1, 1, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, 1, 4_000_000 / 4); + suite.assert_pending_rewards(ADDR3, 1, addr3_pending + 4_000_000 / 4); + + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR3, 1); + let addr1_pending = 0; + let addr3_pending = 0; + suite.skip_seconds(10_000); // skips from 460,000 to 470,000, and the end is 462,500, so this allocates only 1_000_000 tokens instead of 4_000_000 + + suite.assert_pending_rewards(ADDR1, 1, addr1_pending + 1_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, 1, 4_000_000 / 4 + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR3, 1, addr3_pending + 1_000_000 / 4); + + suite.claim_rewards(ADDR2, 1); + + // TODO: there's a few denoms remaining here, ensure such cases are handled properly + let remaining_rewards = suite.get_balance_native(suite.distribution_contract.clone(), DENOM); + println!("Remaining rewards: {}", remaining_rewards); +} + #[test] fn test_cw20_dao_native_rewards_block_height_based() { let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); @@ -31,21 +421,21 @@ fn test_cw20_dao_native_rewards_block_height_based() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); suite.assert_native_balance(ADDR1, DENOM, 10_000_000); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // ADDR2 and ADDR3 unstake their rewards suite.unstake_cw20_tokens(50, ADDR2); @@ -56,13 +446,13 @@ fn test_cw20_dao_native_rewards_block_height_based() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR2 and ADDR3 wake up, claim and restake their rewards - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); suite.stake_cw20_tokens(50, ADDR2); @@ -71,36 +461,36 @@ fn test_cw20_dao_native_rewards_block_height_based() { 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.assert_pending_rewards(ADDR1, 1, 30_000_000); + suite.assert_pending_rewards(ADDR2, 1, 10_000_000); + suite.assert_pending_rewards(ADDR3, 1, 0); - suite.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); let remaining_time = suite.get_time_until_rewards_expiration(); suite.skip_blocks(remaining_time - 100_000); - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); suite.unstake_cw20_tokens(100, ADDR1); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 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.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); - suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); let addr1_bal = suite.get_balance_native(ADDR1, DENOM); let addr2_bal = suite.get_balance_native(ADDR2, DENOM); @@ -120,21 +510,21 @@ fn test_cw721_dao_rewards() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); suite.assert_native_balance(ADDR1, DENOM, 10_000_000); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // ADDR2 and ADDR3 unstake their nfts suite.unstake_nft(ADDR2, 3); @@ -145,13 +535,13 @@ fn test_cw721_dao_rewards() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR2 and ADDR3 wake up, claim and restake their nfts - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); suite.stake_nft(ADDR2, 3); suite.stake_nft(ADDR3, 4); @@ -165,13 +555,13 @@ fn test_claim_zero_rewards() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); // ADDR1 attempts to claim again - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); } #[test] @@ -184,9 +574,12 @@ fn test_native_dao_cw20_rewards_time_based() { denom: UncheckedDenom::Cw20(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); + let cw20_denom = &suite.reward_denom.clone(); + suite.assert_amount(1_000); suite.assert_duration(10); suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(1_000_000))); @@ -194,21 +587,21 @@ fn test_native_dao_cw20_rewards_time_based() { // 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); + // suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.claim_rewards(ADDR1, 1); + suite.assert_cw20_balance(cw20_denom, ADDR1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 0); // ADDR2 and ADDR3 unstake their stake suite.unstake_cw20_tokens(50, ADDR2); @@ -219,16 +612,16 @@ fn test_native_dao_cw20_rewards_time_based() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 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.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); - suite.assert_cw20_balance(ADDR1, 10_000_000); - suite.assert_cw20_balance(ADDR2, 5_000_000); + suite.assert_cw20_balance(cw20_denom, ADDR1, 10_000_000); + suite.assert_cw20_balance(cw20_denom, ADDR2, 5_000_000); } #[test] @@ -241,6 +634,7 @@ fn test_native_dao_rewards_time_based() { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); @@ -251,21 +645,21 @@ fn test_native_dao_rewards_time_based() { // 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); + // suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); suite.assert_native_balance(ADDR1, DENOM, 10_000_000); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // ADDR2 and ADDR3 unstake their stake suite.unstake_native_tokens(ADDR2, 50); @@ -276,13 +670,13 @@ fn test_native_dao_rewards_time_based() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR2 and ADDR3 wake up, claim and restake their rewards - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); let addr1_balance = suite.get_balance_native(ADDR1, DENOM); let addr2_balance = suite.get_balance_native(ADDR2, DENOM); @@ -291,6 +685,344 @@ fn test_native_dao_rewards_time_based() { suite.stake_native_tokens(ADDR2, addr2_balance); } +// all of the `+1` corrections highlight rounding +#[test] +fn test_native_dao_rewards_time_based_with_rounding() { + // 100udenom/100sec = 1udenom/1sec reward emission rate + // given funding of 100_000_000udenom, we have a reward duration of 100_000_000sec + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW4) + .with_rewards_config(RewardsConfig { + amount: 100, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Time(100), + destination: None, + continuous: true, + }) + .with_cw4_members(vec![ + Member { + addr: ADDR1.to_string(), + weight: 140, + }, + Member { + addr: ADDR2.to_string(), + weight: 40, + }, + Member { + addr: ADDR3.to_string(), + weight: 20, + }, + ]) + .build(); + + suite.assert_amount(100); + suite.assert_duration(100); + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(100_000_000))); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, 1, 70); + suite.assert_pending_rewards(ADDR2, 1, 20); + suite.assert_pending_rewards(ADDR3, 1, 10); + + // change voting power of one of the members and claim + suite.update_members( + vec![Member { + addr: ADDR2.to_string(), + weight: 60, + }], + vec![], + ); + suite.claim_rewards(ADDR2, 1); + suite.assert_native_balance(ADDR2, DENOM, 20); + suite.assert_pending_rewards(ADDR2, 1, 0); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, 1, 70 + 63); + suite.assert_pending_rewards(ADDR2, 1, 27); + suite.assert_pending_rewards(ADDR3, 1, 10 + 9); + + // increase reward rate and claim + suite.update_emission_rate(1, Duration::Time(100), 150, true); + suite.claim_rewards(ADDR3, 1); + suite.assert_native_balance(ADDR3, DENOM, 10 + 9); + suite.assert_pending_rewards(ADDR3, 1, 0); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, 1, 70 + 63 + 95 + 1); + suite.assert_pending_rewards(ADDR2, 1, 27 + 40 + 1); + suite.assert_pending_rewards(ADDR3, 1, 13); + + // claim rewards + suite.claim_rewards(ADDR1, 1); + suite.assert_native_balance(ADDR1, DENOM, 70 + 63 + 95 + 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + + // skip 3 intervals + suite.skip_seconds(300); + + suite.assert_pending_rewards(ADDR1, 1, 3 * 95 + 1); + suite.assert_pending_rewards(ADDR2, 1, 27 + 4 * 40 + 1 + 1 + 1); + suite.assert_pending_rewards(ADDR3, 1, 4 * 13 + 1 + 1); + + // change voting power for all + suite.update_members( + vec![ + Member { + addr: ADDR1.to_string(), + weight: 100, + }, + Member { + addr: ADDR2.to_string(), + weight: 80, + }, + Member { + addr: ADDR3.to_string(), + weight: 40, + }, + ], + vec![], + ); + + suite.claim_rewards(ADDR2, 1); + suite.assert_native_balance(ADDR2, DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, 1, 3 * 95 + 1 + 68); + suite.assert_pending_rewards(ADDR2, 1, 54); + suite.assert_pending_rewards(ADDR3, 1, 4 * 13 + 1 + 1 + 27); + + // claim all + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); + suite.assert_native_balance(ADDR1, DENOM, 70 + 63 + 95 + 1 + 3 * 95 + 1 + 68); + suite.assert_native_balance(ADDR2, DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1 + 54); + suite.assert_native_balance(ADDR3, DENOM, 10 + 9 + 4 * 13 + 1 + 1 + 27); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); + + // TODO: fix this rug of 3 udenom by the distribution contract + suite.assert_native_balance( + suite.distribution_contract.as_str(), + DENOM, + 100_000_000 - (100 * 2 + 150 * 5) + 3, + ); +} + +#[test] +fn test_immediate_emission() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 2 blocks since the contract depends on the previous block's total + // voting power, and voting power takes 1 block to take effect. so if voting + // power is staked on block 0, it takes effect on block 1, so immediate + // distribution is only effective on block 2. + suite.skip_blocks(2); + + suite.mint_native(coin(500_000_000, ALT_DENOM), OWNER); + + let execute_create_msg = ExecuteMsg::Create(CreateMsg { + denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), + emission_rate: EmissionRate::Immediate {}, + hook_caller: suite.staking_addr.to_string(), + vp_contract: suite.voting_power_addr.to_string(), + withdraw_destination: None, + }); + + // create distribution + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &execute_create_msg, + &coins(100_000_000, ALT_DENOM), + ) + .unwrap(); + + // users immediately have access to rewards + suite.assert_pending_rewards(ADDR1, 2, 50_000_000); + suite.assert_pending_rewards(ADDR2, 2, 25_000_000); + suite.assert_pending_rewards(ADDR3, 2, 25_000_000); + + // another fund immediately adds to the pending rewards + suite.fund_native(2, coin(100_000_000, ALT_DENOM)); + + // users immediately have access to new rewards + suite.assert_pending_rewards(ADDR1, 2, 2 * 50_000_000); + suite.assert_pending_rewards(ADDR2, 2, 2 * 25_000_000); + suite.assert_pending_rewards(ADDR3, 2, 2 * 25_000_000); + + // a new user stakes tokens + suite.mint_native(coin(200, DENOM), ADDR4); + suite.stake_native_tokens(ADDR4, 200); + + // skip 2 blocks so stake takes effect + suite.skip_blocks(2); + + // another fund takes into account new voting power + suite.fund_native(2, coin(100_000_000, ALT_DENOM)); + + suite.assert_pending_rewards(ADDR1, 2, 2 * 50_000_000 + 25_000_000); + suite.assert_pending_rewards(ADDR2, 2, 2 * 25_000_000 + 12_500_000); + suite.assert_pending_rewards(ADDR3, 2, 2 * 25_000_000 + 12_500_000); + suite.assert_pending_rewards(ADDR4, 2, 50_000_000); + + suite.claim_rewards(ADDR1, 2); + suite.claim_rewards(ADDR2, 2); + suite.claim_rewards(ADDR3, 2); + suite.claim_rewards(ADDR4, 2); + + suite.unstake_native_tokens(ADDR1, 100); + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // skip 2 blocks so stake takes effect + suite.skip_blocks(2); + + // another fund takes into account new voting power + suite.fund_native(2, coin(100_000_000, ALT_DENOM)); + + suite.assert_pending_rewards(ADDR1, 2, 0); + suite.assert_pending_rewards(ADDR2, 2, 0); + suite.assert_pending_rewards(ADDR3, 2, 0); + suite.assert_pending_rewards(ADDR4, 2, 100_000_000); +} + +#[test] +#[should_panic( + expected = "There is no voting power registered, so no one will receive these funds" +)] +fn test_immediate_emission_fails_if_no_voting_power() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // all users unstake + suite.unstake_native_tokens(ADDR1, 100); + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // skip 2 blocks since the contract depends on the previous block's total + // voting power, and voting power takes 1 block to take effect. so if voting + // power is staked on block 0, it takes effect on block 1, so immediate + // distribution is only effective on block 2. + suite.skip_blocks(2); + + suite.mint_native(coin(200_000_000, ALT_DENOM), OWNER); + + let execute_create_msg = ExecuteMsg::Create(CreateMsg { + denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), + emission_rate: EmissionRate::Immediate {}, + hook_caller: suite.staking_addr.to_string(), + vp_contract: suite.voting_power_addr.to_string(), + withdraw_destination: None, + }); + + // create and fund distribution + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &execute_create_msg, + &coins(100_000_000, ALT_DENOM), + ) + .unwrap(); +} + +#[test] +fn test_transition_to_immediate() { + 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, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, 1); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 0); + + // ADDR2 unstakes their stake + suite.unstake_native_tokens(ADDR2, 50); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 is not staking, ADDR1 and ADDR3 receive the rewards. ADDR2 + // should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, 1, 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000 + 3_333_333); + + // ADDR2 claims their rewards + suite.claim_rewards(ADDR2, 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + + // switching to immediate emission instantly distributes the remaining 70M + suite.set_immediate_emission(1); + + // ADDR1 and ADDR3 split the rewards, and ADDR2 gets none + suite.assert_pending_rewards(ADDR1, 1, 6_666_666 + 46_666_666 + 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000 + 3_333_333 + 23_333_333); + + // claim all rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR3, 1); + + // ADDR3 unstakes their stake, leaving only ADDR1 staked + suite.unstake_native_tokens(ADDR3, 50); + + // skip 2 blocks so unstake takes effect + suite.skip_blocks(2); + + // another fund immediately adds to the pending rewards + suite.mint_native(coin(100_000_000, DENOM), OWNER); + suite.fund_native(1, coin(100_000_000, DENOM)); + + // ADDR1 gets all + suite.assert_pending_rewards(ADDR1, 1, 100_000_000); + + // change back to linear emission + suite.update_emission_rate(1, Duration::Height(10), 1000, true); + + // fund with 100M again + suite.mint_native(coin(100_000_000, DENOM), OWNER); + suite.fund_native(1, coin(100_000_000, DENOM)); + + // ADDR1 has same pending as before + suite.assert_pending_rewards(ADDR1, 1, 100_000_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // ADDR1 has new linearly distributed rewards + suite.assert_pending_rewards(ADDR1, 1, 100_000_000 + 10_000_000); +} + #[test] fn test_native_dao_rewards() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); @@ -302,21 +1034,21 @@ fn test_native_dao_rewards() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); suite.assert_native_balance(ADDR1, DENOM, 10_000_000); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // ADDR2 and ADDR3 unstake their stake suite.unstake_native_tokens(ADDR2, 50); @@ -327,13 +1059,13 @@ fn test_native_dao_rewards() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); + suite.assert_pending_rewards(ADDR3, 1, 5_000_000); // ADDR2 and ADDR3 wake up, claim and restake their rewards - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); let addr1_balance = suite.get_balance_native(ADDR1, DENOM); let addr2_balance = suite.get_balance_native(ADDR2, DENOM); @@ -342,6 +1074,52 @@ fn test_native_dao_rewards() { suite.stake_native_tokens(ADDR2, addr2_balance); } +#[test] +fn test_continuous_backfill_latest_voting_power() { + 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 all of the time + suite.skip_blocks(1_000_000); + + suite.assert_pending_rewards(ADDR1, 1, 50_000_000); + suite.assert_pending_rewards(ADDR2, 1, 25_000_000); + suite.assert_pending_rewards(ADDR3, 1, 25_000_000); + + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // change voting powers (1 = 200, 2 = 50, 3 = 50) + suite.stake_native_tokens(ADDR1, 100); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // change voting powers again (1 = 50, 2 = 100, 3 = 100) + suite.unstake_native_tokens(ADDR1, 150); + suite.stake_native_tokens(ADDR2, 50); + suite.stake_native_tokens(ADDR3, 50); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // fund with 100M + suite.fund_native(1, coin(100_000_000, DENOM)); + + // since this is continuous, rewards should backfill based on the latest + // voting powers. we skipped 30% of the time, so 30M should be distributed + suite.assert_pending_rewards(ADDR1, 1, 6_000_000); + suite.assert_pending_rewards(ADDR2, 1, 12_000_000); + suite.assert_pending_rewards(ADDR3, 1, 12_000_000); +} + #[test] fn test_cw4_dao_rewards() { let mut suite = SuiteBuilder::base(super::suite::DaoType::CW4).build(); @@ -353,9 +1131,9 @@ fn test_cw4_dao_rewards() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); // remove the second member suite.update_members(vec![], vec![ADDR2.to_string()]); @@ -365,9 +1143,9 @@ fn test_cw4_dao_rewards() { 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 3_333_333 + 2_500_000); // reintroduce the 2nd member with double the vp let add_member_2 = Member { @@ -381,27 +1159,27 @@ fn test_cw4_dao_rewards() { // 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); + suite.claim_rewards(ADDR1, 1); + suite.assert_native_balance(ADDR1, DENOM, 5_000_000 + 6_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); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 3_333_333 + 2_500_000); // 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); + suite.assert_pending_rewards(ADDR1, 1, 4_000_000); + suite.assert_pending_rewards(ADDR2, 1, 6_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 24_000_000); + suite.assert_pending_rewards(ADDR2, 1, 26_500_000); + suite.assert_pending_rewards(ADDR3, 1, 17_833_333); // remove all members suite.update_members( @@ -409,16 +1187,16 @@ fn test_cw4_dao_rewards() { 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); + suite.assert_pending_rewards(ADDR1, 1, 24_000_000); + suite.assert_pending_rewards(ADDR2, 1, 26_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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.assert_pending_rewards(ADDR1, 1, 24_000_000); + suite.assert_pending_rewards(ADDR2, 1, 26_500_000); + suite.assert_pending_rewards(ADDR3, 1, 17_833_333); suite.update_members( vec![ @@ -438,34 +1216,34 @@ fn test_cw4_dao_rewards() { 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.assert_pending_rewards(ADDR1, 1, 24_000_000); + suite.assert_pending_rewards(ADDR2, 1, 26_500_000); + suite.assert_pending_rewards(ADDR3, 1, 17_833_333); - suite.claim_rewards(ADDR1, DENOM); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 4_000_000); + suite.assert_pending_rewards(ADDR2, 1, 30_500_000); + suite.assert_pending_rewards(ADDR3, 1, 19_833_333); // at the very expiration block, claim rewards - suite.claim_rewards(ADDR2, DENOM); - suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.claim_rewards(ADDR2, 1); + suite.assert_pending_rewards(ADDR2, 1, 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.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR3, 1); - suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); let contract = suite.distribution_contract.clone(); @@ -481,17 +1259,19 @@ fn test_fund_multiple_denoms() { 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); + suite.mint_native(alt_coin.clone(), OWNER); + suite.mint_native(coin.clone(), OWNER); let hook_caller = suite.staking_addr.to_string(); - suite.register_reward_denom( + suite.create( RewardsConfig { amount: 1000, denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), duration: Duration::Height(100), destination: None, + continuous: true, }, &hook_caller, + None, ); suite @@ -500,12 +1280,41 @@ fn test_fund_multiple_denoms() { .execute_contract( Addr::unchecked(OWNER), suite.distribution_contract.clone(), - &ExecuteMsg::Fund {}, + &ExecuteMsg::Fund(FundMsg { id: 2 }), &[coin, alt_coin], ) .unwrap(); } +#[test] +#[should_panic(expected = "Invalid CW20")] +fn test_fund_cw20_wrong_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20("irrelevant".to_string()), + duration: Duration::Height(10), + destination: None, + continuous: true, + }) + .build(); + + let mint_cw20 = Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(100), + }; + + let address = suite.mint_cw20(mint_cw20.clone(), "newcoin").to_string(); + + suite.fund_cw20( + 1, + Cw20Coin { + address, + amount: mint_cw20.amount, + }, + ); +} + #[test] #[should_panic(expected = "unknown variant `not_the_fund: {}`")] fn test_fund_cw20_with_invalid_cw20_receive_msg() { @@ -517,8 +1326,7 @@ fn test_fund_cw20_with_invalid_cw20_receive_msg() { 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 new_cw20_mint = suite.mint_cw20(unregistered_cw20_coin.clone(), "newcoin"); let fund_sub_msg = to_json_binary(&"not_the_fund: {}").unwrap(); suite @@ -547,23 +1355,22 @@ fn test_fund_invalid_cw20_denom() { amount: Uint128::new(1_000_000), }; - println!("attempting to fund the distributor contract with unregistered cw20 coin"); - suite.fund_distributor_cw20(unregistered_cw20_coin); + suite.fund_cw20(1, unregistered_cw20_coin); } #[test] -#[should_panic(expected = "Reward period already finished")] -fn test_shutdown_finished_rewards_period() { +#[should_panic(expected = "All rewards have already been distributed")] +fn test_withdraw_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); + suite.withdraw(1); } #[test] -fn test_shutdown_alternative_destination_address() { +fn test_withdraw_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())) @@ -572,13 +1379,13 @@ fn test_shutdown_alternative_destination_address() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); // user 1 and 2 claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // user 2 unstakes suite.unstake_native_tokens(ADDR2, 50); @@ -588,37 +1395,37 @@ fn test_shutdown_alternative_destination_address() { let distribution_contract = suite.distribution_contract.to_string(); suite.assert_native_balance(subdao_addr.as_str(), DENOM, 0); - let pre_shutdown_distributor_balance = + let pre_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); - suite.shutdown_denom_distribution(DENOM); + suite.withdraw(1); - let post_shutdown_distributor_balance = + let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); - let post_shutdown_subdao_balance = suite.get_balance_native(subdao_addr.to_string(), DENOM); + let post_withdraw_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 + // after withdraw the balance of the subdao should be the same + // as pre-withdraw-distributor-bal minus post-withdraw-distributor-bal assert_eq!( - pre_shutdown_distributor_balance - post_shutdown_distributor_balance, - post_shutdown_subdao_balance + pre_withdraw_distributor_balance - post_withdraw_distributor_balance, + post_withdraw_subdao_balance ); } #[test] -fn test_shutdown_block_based() { +fn test_withdraw_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); + // suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + // suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + // suite.assert_pending_rewards(ADDR3, 1, 2_500_000); // user 1 and 2 claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // user 2 unstakes suite.unstake_native_tokens(ADDR2, 50); @@ -627,68 +1434,79 @@ fn test_shutdown_block_based() { let distribution_contract = suite.distribution_contract.to_string(); - let pre_shutdown_distributor_balance = + let pre_withdraw_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); + suite.withdraw(1); - let post_shutdown_distributor_balance = + let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); - let post_shutdown_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + let post_withdraw_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 + // after withdraw the balance of the owner should be the same + // as pre-withdraw-distributor-bal minus post-withdraw-distributor-bal assert_eq!( - pre_shutdown_distributor_balance - post_shutdown_distributor_balance, - post_shutdown_owner_balance + pre_withdraw_distributor_balance - post_withdraw_distributor_balance, + post_withdraw_owner_balance ); + assert_eq!(pre_withdraw_distributor_balance, 92_500_000); + assert_eq!(post_withdraw_distributor_balance, 12_500_000); + assert_eq!(post_withdraw_owner_balance, 80_000_000); + suite.skip_blocks(100_000); + // ensure cannot withdraw again + assert_eq!( + suite.withdraw_error(1), + ContractError::RewardsAlreadyDistributed {} + ); + // 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); + suite.assert_pending_rewards(ADDR1, 1, 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 3_333_333 + 2_500_000); // user 1 can claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.claim_rewards(ADDR1, 1); + // suite.assert_pending_rewards(ADDR1, 1, 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); + suite.claim_rewards(ADDR3, 1); + // suite.assert_pending_rewards(ADDR3, 1, 0); + suite.assert_native_balance(ADDR3, DENOM, 3_333_333 + 2_500_000 + 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() { +fn test_withdraw_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, + continuous: true, }) .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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); // user 1 and 2 claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // user 2 unstakes suite.unstake_native_tokens(ADDR2, 50); @@ -697,50 +1515,219 @@ fn test_shutdown_time_based() { let distribution_contract = suite.distribution_contract.to_string(); - let pre_shutdown_distributor_balance = + let pre_withdraw_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); + suite.withdraw(1); - let post_shutdown_distributor_balance = + let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); - let post_shutdown_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + let post_withdraw_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 + // after withdraw the balance of the owner should be the same + // as pre-withdraw-distributor-bal minus post-withdraw-distributor-bal assert_eq!( - pre_shutdown_distributor_balance - post_shutdown_distributor_balance, - post_shutdown_owner_balance + pre_withdraw_distributor_balance - post_withdraw_distributor_balance, + post_withdraw_owner_balance ); + assert_eq!(pre_withdraw_distributor_balance, 92_500_000); + assert_eq!(post_withdraw_distributor_balance, 12_500_000); + assert_eq!(post_withdraw_owner_balance, 80_000_000); + suite.skip_seconds(100_000); + // ensure cannot withdraw again + assert_eq!( + suite.withdraw_error(1), + ContractError::RewardsAlreadyDistributed {} + ); + // 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); + suite.assert_pending_rewards(ADDR1, 1, 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_pending_rewards(ADDR3, 1, 3_333_333 + 2_500_000); // user 1 can claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 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); + suite.claim_rewards(ADDR3, 1); + suite.assert_pending_rewards(ADDR3, 1, 0); + suite.assert_native_balance(ADDR3, DENOM, 3_333_333 + 2_500_000 + 50); // TODO: fix this rug of 1 udenom by the distribution contract suite.assert_native_balance(&distribution_contract, DENOM, 1); } +#[test] +fn test_withdraw_and_restart_with_continuous() { + 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, + continuous: true, + }) + .build(); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + + // users claim their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + let pre_withdraw_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); + suite.withdraw(1); + + let post_withdraw_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_withdraw_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + + // after withdraw the balance of the owner should be the same + // as pre-withdraw-distributor-bal minus post-withdraw-distributor-bal + assert_eq!( + pre_withdraw_distributor_balance - post_withdraw_distributor_balance, + post_withdraw_owner_balance + ); + + assert_eq!(pre_withdraw_distributor_balance, 90_000_000); + assert_eq!(post_withdraw_distributor_balance, 10_000_000); + assert_eq!(post_withdraw_owner_balance, 80_000_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // ensure cannot withdraw again + assert_eq!( + suite.withdraw_error(1), + ContractError::RewardsAlreadyDistributed {} + ); + + // we assert that pending rewards did not change + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); + + // fund again + suite.fund_native(1, coin(100_000_000, DENOM)); + + // check that pending rewards did not restart. since we skipped 1/10th the + // time after the withdraw occurred, everyone should already have 10% of the + // new amount pending. + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); +} + +#[test] +fn test_withdraw_and_restart_not_continuous() { + 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, + continuous: false, + }) + .build(); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + + // users claim their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + let pre_withdraw_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); + suite.withdraw(1); + + let post_withdraw_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_withdraw_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + + // after withdraw the balance of the owner should be the same + // as pre-withdraw-distributor-bal minus post-withdraw-distributor-bal + assert_eq!( + pre_withdraw_distributor_balance - post_withdraw_distributor_balance, + post_withdraw_owner_balance + ); + + assert_eq!(pre_withdraw_distributor_balance, 90_000_000); + assert_eq!(post_withdraw_distributor_balance, 10_000_000); + assert_eq!(post_withdraw_owner_balance, 80_000_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // ensure cannot withdraw again + assert_eq!( + suite.withdraw_error(1), + ContractError::RewardsAlreadyDistributed {} + ); + + // we assert that pending rewards did not change + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); + + // fund again + suite.fund_native(1, coin(100_000_000, DENOM)); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // check that pending rewards restarted from the funding date. since we + // skipped 1/10th the time after the funding occurred, everyone should + // have 10% of the new amount pending + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); +} + #[test] #[should_panic(expected = "Caller is not the contract's current owner")] -fn test_shudown_unauthorized() { +fn test_withdraw_unauthorized() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); // skip 1/10th of the time @@ -752,37 +1739,30 @@ fn test_shudown_unauthorized() { .execute_contract( Addr::unchecked(ADDR1), suite.distribution_contract.clone(), - &ExecuteMsg::Shutdown { - denom: DENOM.to_string(), - }, + &ExecuteMsg::Withdraw { id: 1 }, &[], ) .unwrap(); } #[test] -#[should_panic] -fn test_shutdown_unregistered_denom() { +#[should_panic(expected = "Distribution not found with ID 3")] +fn test_withdraw_404() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); suite.skip_blocks(100_000); - suite.shutdown_denom_distribution("not-the-denom"); + suite.withdraw(3); } #[test] -#[should_panic(expected = "Denom already registered")] -fn test_register_duplicate_denom() { +#[should_panic(expected = "Distribution not found with ID 3")] +fn test_claim_404() { 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); + suite.skip_blocks(100_000); + + suite.claim_rewards(ADDR1, 3); } #[test] @@ -793,42 +1773,30 @@ fn test_fund_invalid_native_denom() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.mint_native_coin(coin(100_000_000, ALT_DENOM), OWNER); + suite.mint_native(coin(100_000_000, ALT_DENOM), OWNER); suite .app .borrow_mut() .execute_contract( Addr::unchecked(OWNER), suite.distribution_contract.clone(), - &ExecuteMsg::Fund {}, + &ExecuteMsg::Fund(FundMsg { id: 1 }), &[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(); +fn test_fund_native_block_based_post_expiration_not_continuous() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Height(10), + destination: None, + continuous: false, + }) + .build(); let started_at = Expiration::AtHeight(0); let funded_blocks = 1_000_000; @@ -841,22 +1809,22 @@ fn test_fund_native_block_based_post_expiration() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); // ADDR2 unstake their stake suite.unstake_native_tokens(ADDR2, 50); // addr3 claims their rewards - suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR3, 1); // 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_pending_rewards(ADDR1, 1, 65_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 30_000_000); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -864,7 +1832,7 @@ fn test_fund_native_block_based_post_expiration() { // 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)); + suite.fund_native(1, coin(100_000_000, DENOM)); let current_block = suite.app.block_info(); @@ -879,16 +1847,19 @@ fn test_fund_native_block_based_post_expiration() { } #[test] -fn test_fund_cw20_time_based_post_expiration() { +fn test_fund_cw20_time_based_post_expiration_not_continuous() { 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, + continuous: false, }) .build(); + let cw20_denom = &suite.reward_denom.clone(); + 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); @@ -900,23 +1871,23 @@ fn test_fund_cw20_time_based_post_expiration() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.claim_rewards(ADDR3, 1); + suite.assert_cw20_balance(cw20_denom, 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_pending_rewards(ADDR1, 1, 65_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 30_000_000); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -929,7 +1900,7 @@ fn test_fund_cw20_time_based_post_expiration() { amount: Uint128::new(100_000_000), }; - suite.fund_distributor_cw20(funding_denom.clone()); + suite.fund_cw20(1, funding_denom.clone()); let current_block = suite.app.block_info(); @@ -953,6 +1924,7 @@ fn test_fund_cw20_time_based_pre_expiration() { denom: UncheckedDenom::Cw20(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); @@ -967,22 +1939,22 @@ fn test_fund_cw20_time_based_pre_expiration() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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.claim_rewards(ADDR3, 1); // 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_pending_rewards(ADDR1, 1, 58_333_333); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 26_666_666); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -994,7 +1966,7 @@ fn test_fund_cw20_time_based_pre_expiration() { address: suite.reward_denom.to_string(), amount: Uint128::new(100_000_000), }; - suite.fund_distributor_cw20(funding_denom.clone()); + suite.fund_cw20(1, funding_denom.clone()); // funding before the reward period expires should // not reset the existing rewards cycle @@ -1023,22 +1995,22 @@ fn test_fund_native_height_based_pre_expiration() { // 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000); // ADDR2 unstake their stake suite.unstake_native_tokens(ADDR2, 50); // addr3 claims their rewards - suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR3, 1); // 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_pending_rewards(ADDR1, 1, 58_333_333); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 26_666_666); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -1046,7 +2018,7 @@ fn test_fund_native_height_based_pre_expiration() { // 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)); + suite.fund_native(1, coin(100_000_000, DENOM)); // funding before the reward period expires should // not reset the existing rewards cycle @@ -1075,27 +2047,26 @@ fn test_native_dao_rewards_entry_edge_case() { // [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.mint_native(coin(100, DENOM), ADDR1); 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, 1, 2_500_000 + 1_666_666); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR1, 1); suite.assert_native_balance(ADDR1, DENOM, 5_000_000 + 6_666_666); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // ADDR2 and ADDR3 unstake their stake // new voting power split is [ADDR1: 100%, ADDR2: 0%, ADDR3: 0%] @@ -1103,26 +2074,26 @@ fn test_native_dao_rewards_entry_edge_case() { 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); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, 1, 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); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR3, 1); let addr1_balance = suite.get_balance_native(ADDR1, DENOM); let addr2_balance = suite.get_balance_native(ADDR2, DENOM); @@ -1131,6 +2102,175 @@ fn test_native_dao_rewards_entry_edge_case() { suite.stake_native_tokens(ADDR2, addr2_balance); } +#[test] +fn test_fund_native_on_create() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let alt_coin = coin(100_000_000, ALT_DENOM); + suite.mint_native(alt_coin.clone(), OWNER); + let hook_caller = suite.staking_addr.to_string(); + + suite.create( + RewardsConfig { + amount: 1000, + denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), + duration: Duration::Height(100), + destination: None, + continuous: true, + }, + &hook_caller, + Some(alt_coin.amount), + ); + + let distribution = suite.get_distribution(2); + assert_eq!(distribution.funded_amount, alt_coin.amount); + assert_eq!( + distribution.active_epoch, + Epoch { + emission_rate: EmissionRate::Linear { + amount: Uint128::new(1000), + duration: Duration::Height(100), + continuous: true, + }, + started_at: Expiration::AtHeight(0), + ends_at: Expiration::AtHeight(10_000_000), + total_earned_puvp: Uint256::zero(), + last_updated_total_earned_puvp: Expiration::AtHeight(0), + } + ); + + suite.skip_blocks(1_000_000); // skip 1/10th of the time + + suite.assert_pending_rewards(ADDR1, 2, 5_000_000); + suite.assert_pending_rewards(ADDR2, 2, 2_500_000); + suite.assert_pending_rewards(ADDR3, 2, 2_500_000); +} + +#[test] +#[should_panic(expected = "Must send reserve token 'ujuno'")] +fn test_fund_native_with_other_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.mint_native(coin(100, ALT_DENOM), OWNER); + + let execute_create_msg = ExecuteMsg::Create(CreateMsg { + denom: cw20::UncheckedDenom::Native(DENOM.to_string()), + emission_rate: EmissionRate::Linear { + amount: Uint128::new(1000), + duration: Duration::Height(100), + continuous: true, + }, + hook_caller: suite.staking_addr.to_string(), + vp_contract: suite.voting_power_addr.to_string(), + withdraw_destination: None, + }); + + // create distribution with other denom provided + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &execute_create_msg, + &coins(100, ALT_DENOM), + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "Sent more than one denomination")] +fn test_fund_native_multiple_denoms() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.mint_native(coin(100, DENOM), OWNER); + suite.mint_native(coin(100, ALT_DENOM), OWNER); + + let execute_create_msg = ExecuteMsg::Create(CreateMsg { + denom: cw20::UncheckedDenom::Native(DENOM.to_string()), + emission_rate: EmissionRate::Linear { + amount: Uint128::new(1000), + duration: Duration::Height(100), + continuous: true, + }, + hook_caller: suite.staking_addr.to_string(), + vp_contract: suite.voting_power_addr.to_string(), + withdraw_destination: None, + }); + + // create distribution with 0 amount + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &execute_create_msg, + &[coin(100, DENOM), coin(100, ALT_DENOM)], + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "You cannot send native funds when creating a CW20 distribution")] +fn test_fund_native_on_create_cw20() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.mint_native(coin(100, DENOM), OWNER); + + let cw20_denom = suite + .mint_cw20( + Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(100), + }, + "newcoin", + ) + .to_string(); + + let execute_create_msg = ExecuteMsg::Create(CreateMsg { + denom: cw20::UncheckedDenom::Cw20(cw20_denom), + emission_rate: EmissionRate::Linear { + amount: Uint128::new(1000), + duration: Duration::Height(100), + continuous: true, + }, + hook_caller: suite.staking_addr.to_string(), + vp_contract: suite.voting_power_addr.to_string(), + withdraw_destination: None, + }); + + // create cw20 distribution with native funds provided + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &execute_create_msg, + &coins(100, DENOM), + ) + .unwrap(); +} + +#[test] +fn test_update_continuous() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.update_emission_rate(1, Duration::Height(100), 1000, true); + + let distribution = suite.get_distribution(1); + match distribution.active_epoch.emission_rate { + EmissionRate::Linear { continuous, .. } => assert!(continuous), + _ => panic!("Invalid emission rate"), + } + + suite.update_emission_rate(1, Duration::Height(100), 1000, false); + + let distribution = suite.get_distribution(1); + match distribution.active_epoch.emission_rate { + EmissionRate::Linear { continuous, .. } => assert!(!continuous), + _ => panic!("Invalid emission rate"), + } +} + #[test] fn test_update_owner() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); @@ -1141,3 +2281,83 @@ fn test_update_owner() { let owner = suite.get_owner().to_string(); assert_eq!(owner, new_owner); } + +#[test] +fn test_update_vp_contract() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let new_vp_contract = setup_native_token_test(suite.app.borrow_mut()); + + suite.update_vp_contract(1, new_vp_contract.as_str()); + + let distribution = suite.get_distribution(1); + assert_eq!(distribution.vp_contract, new_vp_contract); +} + +#[test] +fn test_update_hook_caller() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let new_hook_caller = "new_hook_caller"; + suite.update_hook_caller(1, new_hook_caller); + + let distribution = suite.get_distribution(1); + assert_eq!(distribution.hook_caller, new_hook_caller); +} + +#[test] +fn test_update_withdraw_destination() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let new_withdraw_destination = "new_withdraw_destination"; + suite.update_withdraw_destination(1, new_withdraw_destination); + + let distribution = suite.get_distribution(1); + assert_eq!(distribution.withdraw_destination, new_withdraw_destination); +} + +#[test] +#[should_panic(expected = "Distribution not found with ID 3")] +fn test_update_404() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.update_emission_rate(3, Duration::Height(100), 1000, false); +} + +#[test] +#[should_panic(expected = "Invalid emission rate: amount cannot be zero")] +fn test_validate_emission_rate_amount() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + suite.update_emission_rate(1, Duration::Time(100), 0, true); +} + +#[test] +#[should_panic(expected = "Invalid emission rate: duration cannot be zero")] +fn test_validate_emission_rate_duration_height() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + suite.update_emission_rate(1, Duration::Height(0), 100, true); +} + +#[test] +#[should_panic(expected = "Invalid emission rate: duration cannot be zero")] +fn test_validate_emission_rate_duration_time() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + suite.update_emission_rate(1, Duration::Time(0), 100, true); +} + +#[test] +fn test_query_info() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let info = suite.get_info(); + + assert_eq!( + info, + InfoResponse { + info: ContractVersion { + contract: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + } + } + ); +}