From 0d3a5cc97d51a6715b0d85f1f4e01ff1ca9efc69 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 23 Jun 2024 18:41:17 +0200 Subject: [PATCH 01/39] passing RegisterRewardDenomMsg struct to register new denom --- .../dao-rewards-distributor/src/contract.rs | 42 ++++++------------- .../dao-rewards-distributor/src/msg.rs | 17 ++++---- .../src/testing/suite.rs | 8 ++-- 3 files changed, 27 insertions(+), 40 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 83d4d868b..93a52f26d 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -5,7 +5,7 @@ use cosmwasm_std::{ DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128, Uint256, WasmMsg, }; use cw2::{get_contract_version, set_contract_version}; -use cw20::{Cw20ReceiveMsg, Denom, UncheckedDenom}; +use cw20::{Cw20ReceiveMsg, Denom}; use cw_utils::{one_coin, Duration, Expiration}; use dao_interface::voting::{ InfoResponse, Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, @@ -18,8 +18,8 @@ use crate::hooks::{ subscribe_denom_to_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RewardEmissionRate, - RewardsStateResponse, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, + RegisterRewardDenomMsg, RewardsStateResponse, }; use crate::state::{DenomRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}; use crate::ContractError; @@ -58,21 +58,9 @@ pub fn execute( ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), ExecuteMsg::Shutdown { denom } => execute_shutdown(deps, info, env, denom), - ExecuteMsg::RegisterRewardDenom { - denom, - emission_rate, - vp_contract, - hook_caller, - withdraw_destination, - } => execute_register_reward_denom( - deps, - info, - denom, - emission_rate, - vp_contract, - hook_caller, - withdraw_destination, - ), + ExecuteMsg::RegisterRewardDenom(register_msg) => { + execute_register_reward_denom(deps, info, register_msg) + } } } @@ -82,22 +70,18 @@ pub fn execute( fn execute_register_reward_denom( deps: DepsMut, info: MessageInfo, - denom: UncheckedDenom, - emission_rate: RewardEmissionRate, - vp_contract: String, - hook_caller: String, - withdraw_destination: Option, + msg: RegisterRewardDenomMsg, ) -> Result { // only the owner can register a new denom cw_ownable::assert_owner(deps.storage, &info.sender)?; - emission_rate.validate_emission_time_window()?; + msg.emission_rate.validate_emission_time_window()?; - let checked_denom = denom.into_checked(deps.as_ref())?; - let hook_caller = deps.api.addr_validate(&hook_caller)?; - let vp_contract = validate_voting_power_contract(&deps, vp_contract)?; + let 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 @@ -109,7 +93,7 @@ fn execute_register_reward_denom( denom: checked_denom, started_at: Expiration::Never {}, ends_at: Expiration::Never {}, - emission_rate, + emission_rate: msg.emission_rate, total_earned_puvp: Uint256::zero(), last_update: Expiration::Never {}, vp_contract, diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index e2d41c112..5addb908c 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -43,13 +43,16 @@ pub enum ExecuteMsg { /// 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, - }, + RegisterRewardDenom(RegisterRewardDenomMsg), +} + +#[cw_serde] +pub struct RegisterRewardDenomMsg { + pub denom: UncheckedDenom, + pub emission_rate: RewardEmissionRate, + pub vp_contract: String, + pub hook_caller: String, + pub withdraw_destination: Option, } /// defines how many tokens (amount) should be distributed per amount of time diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 2deb7b8f2..fe1aaf709 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -11,8 +11,8 @@ use cw_utils::Duration; use crate::{ msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RewardEmissionRate, - RewardsStateResponse, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RegisterRewardDenomMsg, + RewardEmissionRate, RewardsStateResponse, }, state::DenomRewardState, testing::cw20_setup::instantiate_cw20, @@ -499,7 +499,7 @@ impl Suite { } pub fn register_reward_denom(&mut self, reward_config: RewardsConfig, hook_caller: &str) { - let register_reward_denom_msg = ExecuteMsg::RegisterRewardDenom { + let register_reward_denom_msg = ExecuteMsg::RegisterRewardDenom(RegisterRewardDenomMsg { denom: reward_config.denom.clone(), emission_rate: RewardEmissionRate { amount: Uint128::new(reward_config.amount), @@ -508,7 +508,7 @@ impl Suite { hook_caller: hook_caller.to_string(), vp_contract: self.voting_power_addr.to_string(), withdraw_destination: reward_config.destination, - }; + }); self.app .borrow_mut() From bad4ec1e5f3677349c2858f375d4e20c34f0e147 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sat, 29 Jun 2024 15:48:36 +0200 Subject: [PATCH 02/39] wip: switching DenomRewardState to active/historic epoch configuration --- .../dao-rewards-distributor/src/contract.rs | 123 +++++++++++------ .../dao-rewards-distributor/src/msg.rs | 5 + .../dao-rewards-distributor/src/state.rs | 83 +++++++++--- .../src/testing/suite.rs | 59 +++++++- .../src/testing/tests.rs | 128 ++++++++++++++++++ 5 files changed, 329 insertions(+), 69 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 93a52f26d..dac915ce5 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -19,9 +19,9 @@ use crate::hooks::{ }; use crate::msg::{ ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, - RegisterRewardDenomMsg, RewardsStateResponse, + RegisterRewardDenomMsg, RewardEmissionRate, RewardsStateResponse, }; -use crate::state::{DenomRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::state::{DenomRewardState, EpochConfig, DENOM_REWARD_STATES, USER_REWARD_STATES}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); @@ -61,9 +61,37 @@ pub fn execute( ExecuteMsg::RegisterRewardDenom(register_msg) => { execute_register_reward_denom(deps, info, register_msg) } + ExecuteMsg::UpdateRewardEmissionRate { + denom, + emission_rate, + } => execute_update_reward_rate(deps, env, info, denom, emission_rate), } } +/// updates the reward emission rate for a registered denom +fn execute_update_reward_rate( + deps: DepsMut, + env: Env, + info: MessageInfo, + denom: String, + new_emission_rate: RewardEmissionRate, +) -> Result { + // only the owner can update the reward rate + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + DENOM_REWARD_STATES.update( + deps.storage, + denom.clone(), + |existing| -> Result<_, ContractError> { + let reward_state = existing.unwrap(); + // todo + Ok(reward_state) + }, + )?; + + Ok(Response::new().add_attribute("action", "update_reward_rate")) +} + /// 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. @@ -91,15 +119,18 @@ fn execute_register_reward_denom( // Initialize the reward state let reward_state = DenomRewardState { denom: checked_denom, - started_at: Expiration::Never {}, - ends_at: Expiration::Never {}, - emission_rate: msg.emission_rate, - total_earned_puvp: Uint256::zero(), + active_epoch_config: EpochConfig { + started_at: Expiration::Never {}, + ends_at: Expiration::Never {}, + emission_rate: msg.emission_rate, + total_earned_puvp: Uint256::zero(), + }, last_update: Expiration::Never {}, vp_contract, hook_caller: hook_caller.clone(), funded_amount: Uint128::zero(), withdraw_destination, + historic_epoch_configs: vec![], }; let str_denom = reward_state.to_str_denom(); @@ -136,7 +167,10 @@ fn execute_shutdown( // shutdown is only possible during the distribution period ensure!( - !reward_state.ends_at.is_expired(&env.block), + !reward_state + .active_epoch_config + .ends_at + .is_expired(&env.block), ContractError::ShutdownError("Reward period already finished".to_string()) ); @@ -146,7 +180,7 @@ fn execute_shutdown( let reward_duration = ends_at - started_at; // find the % of reward_duration that remains from current block - let passed_units_since_start = match reward_state.emission_rate.duration { + let passed_units_since_start = match reward_state.active_epoch_config.emission_rate.duration { Duration::Height(_) => Uint128::from(env.block.height - started_at), Duration::Time(_) => Uint128::from(env.block.time.seconds() - started_at), }; @@ -168,10 +202,11 @@ fn execute_shutdown( )?; // 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), - }; + reward_state.active_epoch_config.ends_at = + match reward_state.active_epoch_config.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }; DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; @@ -214,6 +249,7 @@ fn execute_fund( // 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 + .active_epoch_config .emission_rate .get_funded_period_duration(amount)?; let funded_period_value = get_duration_scalar(&funded_period_duration); @@ -224,32 +260,33 @@ fn execute_fund( // 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) + denom_reward_state.active_epoch_config.ends_at = + match denom_reward_state.active_epoch_config.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)) + Expiration::AtTime(t) => { + if t <= env.block.time { + // expiration is the funded duration after current block time + Expiration::AtTime(env.block.time.plus_seconds(funded_period_value)) + } else { + // if the previous expiration had not yet expired, we extend + // the current rewards period by the newly funded duration + Expiration::AtTime(t.plus_seconds(funded_period_value)) + } } - } - }; + }; denom_reward_state.funded_amount += amount; DENOM_REWARD_STATES.save( @@ -327,7 +364,7 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) DENOM_REWARD_STATES.update(deps.storage, denom.clone(), |state| -> StdResult<_> { match state { Some(mut rc) => { - rc.total_earned_puvp = total_earned_puvp; + rc.active_epoch_config.total_earned_puvp = total_earned_puvp; Ok(rc.bump_last_update(&env.block)) } None => Err(StdError::generic_err("Denom reward state not found")), @@ -380,7 +417,7 @@ fn get_total_earned_puvp( deps: Deps, reward_state: &DenomRewardState, ) -> StdResult { - let curr = reward_state.total_earned_puvp; + let curr = reward_state.active_epoch_config.total_earned_puvp; // query the total voting power just before this block from the voting power // contract @@ -400,7 +437,8 @@ fn get_total_earned_puvp( if prev_total_power.is_zero() { Ok(curr) } else { - let duration_value = get_duration_scalar(&reward_state.emission_rate.duration); + let duration_value = + get_duration_scalar(&reward_state.active_epoch_config.emission_rate.duration); // count intervals of the rewards emission that have passed since the // last update which need to be distributed @@ -411,6 +449,7 @@ fn get_total_earned_puvp( // 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 + .active_epoch_config .emission_rate .amount .full_mul(complete_distribution_periods) @@ -533,10 +572,10 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult>>()?; let mut pending_rewards: HashMap = HashMap::new(); - + println!("querying pending rewards for {}", addr); for (denom, reward_state) in reward_states { let total_earned_puvp = get_total_earned_puvp(&env, deps, &reward_state)?; - + println!("[{}] total puvp: {:?}", denom, total_earned_puvp); let earned_rewards = get_accrued_rewards_since_last_user_action( deps, &env, @@ -545,7 +584,7 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult, } -/// the state of a denom's reward distribution #[cw_serde] -pub struct DenomRewardState { - /// validated denom (native or cw20) - pub denom: Denom, +pub struct EpochConfig { + /// reward emission rate + pub emission_rate: RewardEmissionRate, /// the time when the current reward distribution period started. period /// finishes iff it reaches its end. pub started_at: Expiration, /// the time when all funded rewards are allocated to users and thus the /// distribution period ends. pub ends_at: Expiration, - /// reward emission rate - pub emission_rate: RewardEmissionRate, /// total rewards earned per unit voting power from started_at to /// last_update pub total_earned_puvp: Uint256, +} + +/// the state of a denom's reward distribution +#[cw_serde] +pub struct DenomRewardState { + /// validated denom (native or cw20) + pub denom: Denom, + /// current denom distribution epoch configuration + pub active_epoch_config: EpochConfig, /// time when total_earned_puvp was last updated for this denom pub last_update: Expiration, /// address to query the voting power @@ -54,11 +60,42 @@ pub struct DenomRewardState { pub funded_amount: Uint128, /// optional destination address for reward clawbacks pub withdraw_destination: Addr, + /// historic denom distribution epochs + pub historic_epoch_configs: Vec, +} + +impl DenomRewardState { + pub fn transition_epoch( + &mut self, + new_emission_rate: RewardEmissionRate, + funded_amount: Uint128, + current_block: &BlockInfo, + ) -> StdResult<()> { + // todo: + // 1. finish current epoch + // 2. push current epoch to historic configs + // 3. start new epoch + + Ok(()) + } + + fn calculate_ends_at( + &self, + emission_rate: &RewardEmissionRate, + funded_amount: Uint128, + current_block: &BlockInfo, + ) -> StdResult { + let funded_period_duration = emission_rate.get_funded_period_duration(funded_amount)?; + match funded_period_duration { + Duration::Height(h) => Ok(Expiration::AtHeight(current_block.height + h)), + Duration::Time(t) => Ok(Expiration::AtTime(current_block.time.plus_seconds(t))), + } + } } impl DenomRewardState { pub fn bump_last_update(mut self, current_block: &BlockInfo) -> Self { - self.last_update = match self.emission_rate.duration { + self.last_update = match self.active_epoch_config.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), Duration::Time(_) => Expiration::AtTime(current_block.time), }; @@ -71,21 +108,23 @@ impl DenomRewardState { /// 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), - }; + if let Expiration::Never {} = self.active_epoch_config.started_at { + self.active_epoch_config.started_at = + match self.active_epoch_config.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), - }; + if self.active_epoch_config.ends_at.is_expired(current_block) { + self.active_epoch_config.started_at = + match self.active_epoch_config.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; } self @@ -103,7 +142,7 @@ impl DenomRewardState { /// - 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 { + match self.active_epoch_config.ends_at { Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), Expiration::AtHeight(h) => Ok(h), Expiration::AtTime(t) => Ok(t.seconds()), @@ -115,7 +154,7 @@ impl DenomRewardState { /// - 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 { + match self.active_epoch_config.started_at { Expiration::AtHeight(h) => Ok(h), Expiration::AtTime(t) => Ok(t.seconds()), Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), @@ -135,7 +174,7 @@ impl DenomRewardState { /// 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 { + match self.active_epoch_config.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)), @@ -149,10 +188,10 @@ impl DenomRewardState { &self, current_block: &BlockInfo, ) -> Result<(), ContractError> { - match self.ends_at { + match self.active_epoch_config.ends_at { Expiration::AtHeight(_) | Expiration::AtTime(_) => { ensure!( - self.ends_at.is_expired(current_block), + self.active_epoch_config.ends_at.is_expired(current_block), ContractError::RewardPeriodNotFinished {} ); Ok(()) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index fe1aaf709..f1a4bbb57 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -322,7 +322,10 @@ impl Suite { pub fn get_time_until_rewards_expiration(&mut self) -> u64 { let rewards_state_response = self.get_rewards_state_response(); let current_block = self.app.block_info(); - let (expiration_unit, current_unit) = match rewards_state_response.rewards[0].ends_at { + let (expiration_unit, current_unit) = match rewards_state_response.rewards[0] + .active_epoch_config + .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, @@ -402,25 +405,40 @@ impl Suite { 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); + assert_eq!( + rewards_state_response.rewards[0] + .active_epoch_config + .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); + assert_eq!( + denom_configs.rewards[0].active_epoch_config.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, + rewards_state_response.rewards[0] + .active_epoch_config + .emission_rate + .amount, Uint128::new(expected) ); } pub fn assert_duration(&mut self, expected: u64) { let rewards_state_response = self.get_rewards_state_response(); - let units = match rewards_state_response.rewards[0].emission_rate.duration { + let units = match rewards_state_response.rewards[0] + .active_epoch_config + .emission_rate + .duration + { Duration::Height(h) => h, Duration::Time(t) => t, }; @@ -669,6 +687,37 @@ impl Suite { unstake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) } + pub fn update_reward_emission_rate( + &mut self, + denom: &str, + epoch_duration: Duration, + epoch_rewards: u128, + ) { + let msg = ExecuteMsg::UpdateRewardEmissionRate { + denom: denom.to_string(), + emission_rate: RewardEmissionRate { + amount: Uint128::new(epoch_rewards), + duration: epoch_duration, + }, + }; + + println!( + "[UPDATE REWARD EMISSION RATE] denom: {}, epoch_duration: {:?}, epoch_rewards: {}", + denom, epoch_duration, epoch_rewards + ); + let resp = self + .app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + + println!("resp: {:?}", resp); + } + pub fn update_members(&mut self, add: Vec, remove: Vec) { let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { remove, add }; diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 764a04d3a..0adfcabc4 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -20,6 +20,134 @@ 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] +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); + let mut total_allocated = Uint128::zero(); + 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); + + total_allocated = total_allocated + .checked_add(Uint128::new(10_000_000)) + .unwrap(); + // 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); + + total_allocated = total_allocated + .checked_add(Uint128::new(10_000_000)) + .unwrap(); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 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_reward_emission_rate(DENOM, Duration::Height(10), 500); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + total_allocated = total_allocated + .checked_add(Uint128::new(5_000_000)) + .unwrap(); + + suite.assert_pending_rewards(ADDR1, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 6_250_000); + suite.assert_pending_rewards(ADDR3, DENOM, 6_250_000); + + // double the rewards rate + // now there will be 10_000_000 tokens distributed over 100_000 blocks + suite.update_reward_emission_rate(DENOM, Duration::Height(10), 1000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + total_allocated = total_allocated + .checked_add(Uint128::new(10_000_000)) + .unwrap(); + + suite.assert_pending_rewards(ADDR1, DENOM, 7_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 7_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 7_500_000); + + // skip 2/10ths of the time + suite.skip_blocks(200_000); + total_allocated = total_allocated + .checked_add(Uint128::new(20_000_000)) + .unwrap(); + + suite.assert_pending_rewards(ADDR1, DENOM, 12_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000); + + // set the rewards rate to 0, pausing the rewards distribution + suite.update_reward_emission_rate(DENOM, Duration::Height(10), 0); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // assert no pending rewards changed + suite.assert_pending_rewards(ADDR1, DENOM, 12_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000); + + // user 1 claims their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 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, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000); + + // 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_reward_emission_rate(DENOM, Duration::Height(10), 1000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + total_allocated = total_allocated + .checked_add(Uint128::new(10_000_000)) + .unwrap(); + + // assert that rewards are being distributed at the expected rate + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + + // ADDR3 claims their rewards + suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000 + 3_333_333); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + total_allocated = total_allocated + .checked_add(Uint128::new(10_000_000)) + .unwrap(); + + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666 + 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333); +} + #[test] fn test_cw20_dao_native_rewards_block_height_based() { let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); From 61beee264e91f02ec0be2a78437d3defc4bedced Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 30 Jun 2024 17:41:04 +0200 Subject: [PATCH 03/39] wip: transitioning epoch on update --- .../dao-rewards-distributor/src/contract.rs | 74 ++++++++++++------- .../dao-rewards-distributor/src/state.rs | 60 +++++++++++++-- 2 files changed, 101 insertions(+), 33 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index dac915ce5..2dba530fc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,8 +1,9 @@ #[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, + coins, ensure, from_json, to_json_binary, Addr, BankMsg, Binary, BlockInfo, CosmosMsg, Decimal, + Deps, DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128, Uint256, + WasmMsg, }; use cw2::{get_contract_version, set_contract_version}; use cw20::{Cw20ReceiveMsg, Denom}; @@ -10,6 +11,7 @@ use cw_utils::{one_coin, Duration, Expiration}; use dao_interface::voting::{ InfoResponse, Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, }; +use dao_voting::voting::get_total_power; use std::collections::HashMap; use std::convert::TryInto; @@ -83,8 +85,9 @@ fn execute_update_reward_rate( deps.storage, denom.clone(), |existing| -> Result<_, ContractError> { - let reward_state = existing.unwrap(); - // todo + let mut reward_state = existing.unwrap(); + // transition the epoch to the new emission rate and save + reward_state.transition_epoch(new_emission_rate, &env.block)?; Ok(reward_state) }, )?; @@ -124,6 +127,7 @@ fn execute_register_reward_denom( ends_at: Expiration::Never {}, emission_rate: msg.emission_rate, total_earned_puvp: Uint256::zero(), + finish_height: None, }, last_update: Expiration::Never {}, vp_contract, @@ -421,7 +425,7 @@ fn get_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 prev_total_power = get_prev_block_total_vp(deps, &env.block, &reward_state.vp_contract)?; let last_time_rewards_distributed = reward_state.get_latest_reward_distribution_time(&env.block); @@ -430,8 +434,8 @@ fn get_total_earned_puvp( // 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, + &last_time_rewards_distributed, + &reward_state.last_update, )?); if prev_total_power.is_zero() { @@ -471,11 +475,6 @@ fn get_accrued_rewards_since_last_user_action( 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(); @@ -492,6 +491,14 @@ fn get_accrued_rewards_since_last_user_action( // power accounted for let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; + // get the user's voting power at the current height + let voting_power = Uint256::from(get_voting_power_at_block( + deps, + &env.block, + vp_contract, + addr, + )?); + // calculate the amount of rewards earned: // voting_power * reward_factor / scale_factor let accrued_rewards_amount: Uint128 = voting_power @@ -499,28 +506,32 @@ fn get_accrued_rewards_since_last_user_action( .checked_div(scale_factor())? .try_into()?; - accrued_rewards.insert(denom.to_string(), accrued_rewards_amount); + let rewards_map = HashMap::from_iter(vec![(denom.to_string(), accrued_rewards_amount)]); - Ok(accrued_rewards) + Ok(rewards_map) } -fn get_prev_block_total_vp(deps: Deps, env: &Env, contract_addr: &Addr) -> StdResult { +fn get_prev_block_total_vp( + deps: Deps, + block: &BlockInfo, + contract_addr: &Addr, +) -> StdResult { let msg = VotingQueryMsg::TotalPowerAtHeight { - height: Some(env.block.height.checked_sub(1).unwrap_or_default()), + height: Some(block.height.checked_sub(1).unwrap_or_default()), }; let resp: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; Ok(resp.power) } -fn get_voting_power( +fn get_voting_power_at_block( deps: Deps, - env: &Env, + block: &BlockInfo, contract_addr: &Addr, addr: &Addr, ) -> StdResult { let msg = VotingQueryMsg::VotingPowerAtHeight { address: addr.into(), - height: Some(env.block.height), + height: Some(block.height), }; let resp: VotingPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; Ok(resp.power) @@ -567,15 +578,29 @@ fn query_rewards_state(deps: Deps, _env: Env) -> StdResult fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult { let addr = deps.api.addr_validate(&addr)?; + + let user_reward_state = USER_REWARD_STATES + .load(deps.storage, addr.clone()) + .unwrap_or_default(); let reward_states = DENOM_REWARD_STATES .range(deps.storage, None, None, Order::Ascending) .collect::>>()?; + let default_amt = Uint128::zero(); let mut pending_rewards: HashMap = HashMap::new(); - println!("querying pending rewards for {}", addr); + + // we iterate over every registered denom and calculate the pending rewards for the user for (denom, reward_state) in reward_states { + let mut total_rewards = Uint128::zero(); + + // first we go over the historic epochs and evaluate them + for historic_epoch in &reward_state.historic_epoch_configs { + // TODO: implement historic rewards calculation + let earned_rewards = Uint128::zero(); + total_rewards += earned_rewards; + } + let total_earned_puvp = get_total_earned_puvp(&env, deps, &reward_state)?; - println!("[{}] total puvp: {:?}", denom, total_earned_puvp); let earned_rewards = get_accrued_rewards_since_last_user_action( deps, &env, @@ -584,12 +609,7 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult Uint256 { /// 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 { +pub fn get_start_end_diff(end: &Expiration, start: &Expiration) -> StdResult { match (end, start) { (Expiration::AtHeight(end), Expiration::AtHeight(start)) => { if end > start { diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index e3a7a45a4..dddbaeb47 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -1,11 +1,14 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{ensure, Addr, BlockInfo, StdError, StdResult, Uint128, Uint256}; +use cosmwasm_std::{ + ensure, Addr, BlockInfo, CheckedMultiplyRatioError, Decimal, StdError, StdResult, Uint128, + Uint256, +}; use cw20::{Denom, Expiration}; use cw_storage_plus::Map; use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; -use crate::{msg::RewardEmissionRate, ContractError}; +use crate::{contract::get_start_end_diff, msg::RewardEmissionRate, ContractError}; /// map user address to their unique reward state pub const USER_REWARD_STATES: Map = Map::new("u_r_s"); @@ -40,6 +43,24 @@ pub struct EpochConfig { /// total rewards earned per unit voting power from started_at to /// last_update pub total_earned_puvp: Uint256, + /// finish block height + pub finish_height: Option, +} + +impl EpochConfig { + pub fn get_total_distributed_rewards(&self) -> Result { + let epoch_duration = get_start_end_diff(&self.started_at, &self.ends_at)?; + + let emission_rate_duration_scalar = match self.emission_rate.duration { + Duration::Height(h) => h, + Duration::Time(t) => t, + }; + + self.emission_rate + .amount + .checked_multiply_ratio(epoch_duration, emission_rate_duration_scalar) + .map_err(|e| ContractError::Std(StdError::generic_err(e.to_string()))) + } } /// the state of a denom's reward distribution @@ -68,14 +89,41 @@ impl DenomRewardState { pub fn transition_epoch( &mut self, new_emission_rate: RewardEmissionRate, - funded_amount: Uint128, current_block: &BlockInfo, ) -> StdResult<()> { - // todo: + let current_block_scalar = match self.active_epoch_config.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + // 1. finish current epoch + let mut curr_epoch = self.active_epoch_config.clone(); + curr_epoch.ends_at = current_block_scalar.clone(); + curr_epoch.finish_height = Some(current_block.to_owned()); + // 2. push current epoch to historic configs - // 3. start new epoch - + self.historic_epoch_configs.push(curr_epoch); + + // 3. deduct the distributed rewards amount from total funded amount, + // as those rewards are no longer available for distribution + let curr_epoch_earned_rewards = self + .active_epoch_config + .get_total_distributed_rewards() + .map_err(|e| StdError::generic_err(e.to_string()))?; + self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; + + // 4. start new epoch + let new_epoch_end_scalar = + self.calculate_ends_at(&new_emission_rate, self.funded_amount, current_block)?; + self.active_epoch_config = EpochConfig { + emission_rate: new_emission_rate.clone(), + started_at: current_block_scalar.clone(), + ends_at: new_epoch_end_scalar, + // start the new active epoch with zero puvp + total_earned_puvp: Uint256::zero(), + finish_height: None, + }; + Ok(()) } From 575dc9c0d1ab9c49c08a72694b491494284fad39 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 30 Jun 2024 21:58:11 +0200 Subject: [PATCH 04/39] wip: querying historic rewards & claims including multiple epochs --- .../dao-rewards-distributor/src/contract.rs | 203 ++++++++++++------ .../dao-rewards-distributor/src/state.rs | 37 ++-- .../src/testing/suite.rs | 4 +- .../src/testing/tests.rs | 42 ++-- 4 files changed, 180 insertions(+), 106 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 2dba530fc..08c9ce3c7 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -11,7 +11,8 @@ use cw_utils::{one_coin, Duration, Expiration}; use dao_interface::voting::{ InfoResponse, Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, }; -use dao_voting::voting::get_total_power; + +use std::cmp::min; use std::collections::HashMap; use std::convert::TryInto; @@ -81,16 +82,20 @@ fn execute_update_reward_rate( // only the owner can update the reward rate cw_ownable::assert_owner(deps.storage, &info.sender)?; - DENOM_REWARD_STATES.update( - deps.storage, - denom.clone(), - |existing| -> Result<_, ContractError> { - let mut reward_state = existing.unwrap(); - // transition the epoch to the new emission rate and save - reward_state.transition_epoch(new_emission_rate, &env.block)?; - Ok(reward_state) - }, + let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; + reward_state.active_epoch_config.total_earned_puvp = get_total_earned_puvp( + deps.as_ref(), + &env.block, + &reward_state.active_epoch_config, + &reward_state.vp_contract, + &reward_state.last_update, )?; + reward_state = reward_state.bump_last_update(&env.block); + + // transition the epoch to the new emission rate and save + reward_state.transition_epoch(new_emission_rate, &env.block)?; + + DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &reward_state)?; Ok(Response::new().add_attribute("action", "update_reward_rate")) } @@ -316,25 +321,45 @@ fn execute_claim( 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) - }, - )?; + let mut user_reward_state = USER_REWARD_STATES + .may_load(deps.storage, info.sender.clone())? + .unwrap_or_default(); + println!("user reward state during claim: {:?}", user_reward_state); + + // 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.to_string(), Uint128::zero()) + .unwrap_or_default(); + println!("amount: {:?}", amount); + // if active epoch never expires, means rewards are paused. we manually query the rewards. + if let Expiration::Never {} = denom_reward_state.active_epoch_config.ends_at { + let pending_rewards = query_pending_rewards(deps.as_ref(), env, info.sender.to_string())?; + println!("pending rewards: {:?}", pending_rewards); + amount = *pending_rewards + .pending_rewards + .get(&denom) + .unwrap_or(&Uint128::zero()); + let current_denom_reward_puvp = user_reward_state + .denom_rewards_puvp + .get(&denom) + .cloned() + .unwrap_or_default(); + + user_reward_state.denom_rewards_puvp.insert( + denom.clone(), + current_denom_reward_puvp + .checked_add(Uint256::from(amount).checked_mul(scale_factor()).unwrap()) + .unwrap(), + ); + } if amount.is_zero() { return Err(ContractError::NoRewardsClaimable {}); } + USER_REWARD_STATES.save(deps.storage, info.sender.clone(), &user_reward_state)?; Ok(Response::new() .add_message(get_transfer_msg( info.sender.clone(), @@ -362,7 +387,13 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) // 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)?; + let total_earned_puvp = get_total_earned_puvp( + deps.as_ref(), + &env.block, + &reward_state.active_epoch_config, + &reward_state.vp_contract, + &reward_state.last_update, + )?; // update the denom state's total rewards earned and last updated DENOM_REWARD_STATES.update(deps.storage, denom.clone(), |state| -> StdResult<_> { @@ -376,14 +407,20 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) })?; // 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(), - )?; + println!("getting earned rewards for user..."); + let earned_rewards = if total_earned_puvp.is_zero() { + println!("total earned puvp is zero, returning zero rewards"); + HashMap::from_iter(vec![(denom.clone(), Uint128::zero())]) + } else { + 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<_> { @@ -417,51 +454,66 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) /// 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, + block: &BlockInfo, + epoch: &EpochConfig, + vp_contract: &Addr, + last_update: &Expiration, ) -> StdResult { - let curr = reward_state.active_epoch_config.total_earned_puvp; - + let curr = epoch.total_earned_puvp; + println!("[EPOCH EARNED PUVP] epoch.total_earned_puvp: \t\t{curr}",); // 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.block, &reward_state.vp_contract)?; - - let last_time_rewards_distributed = - reward_state.get_latest_reward_distribution_time(&env.block); + let prev_total_power = get_prev_block_total_vp(deps, block, vp_contract)?; + println!("[EPOCH EARNED PUVP] prev_total_power: \t\t\t{prev_total_power}",); + // if epoch is past, we return the epoch end date. otherwise the specified block. + // returns time scalar based on the epoch date config. + let last_time_rewards_distributed = match epoch.ends_at { + Expiration::Never {} => *last_update, + Expiration::AtHeight(h) => Expiration::AtHeight(min(block.height, h)), + Expiration::AtTime(t) => Expiration::AtTime(min(block.time, t)), + }; + println!( + "[EPOCH EARNED PUVP] last_time_rewards_distributed: \t{last_time_rewards_distributed}", + ); // 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, - )?); + let new_reward_distribution_duration: Uint128 = + get_start_end_diff(&last_time_rewards_distributed, last_update)?.into(); + println!( + "[EPOCH EARNED PUVP] new_reward_distribution_duration: \t{new_reward_distribution_duration}" + ); if prev_total_power.is_zero() { Ok(curr) } else { - let duration_value = - get_duration_scalar(&reward_state.active_epoch_config.emission_rate.duration); + let duration_value = get_duration_scalar(&epoch.emission_rate.duration); + println!("[EPOCH EARNED PUVP] duration_value: \t\t\t{duration_value}",); // 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))?; + println!( + "[EPOCH EARNED PUVP] complete_distribution_periods: \t{complete_distribution_periods}", + ); // 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 - .active_epoch_config + let new_rewards_distributed = epoch .emission_rate .amount .full_mul(complete_distribution_periods) .checked_mul(scale_factor())?; + println!("[EPOCH EARNED PUVP] new_rewards_distributed:\t\t{new_rewards_distributed}",); // 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())?; + println!("[EPOCH EARNED PUVP] new_rewards_puvp:\t\t\t{new_rewards_puvp}",); Ok(curr + new_rewards_puvp) } } @@ -486,11 +538,17 @@ fn get_accrued_rewards_since_last_user_action( .cloned() .unwrap_or_default(); + println!("[REWARDS ACCRUED SINCE LAST USER ACTION]addr:\t\t{addr}"); + println!( + "[REWARDS ACCRUED SINCE LAST USER ACTION]user_last_reward_puvp:\t\t{user_last_reward_puvp}", + ); + println!("[REWARDS ACCRUED SINCE LAST USER ACTION]total_earned_puvp:\t\t{total_earned_puvp}",); // 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 + // power accounted for. + // TODO: this may overlap with a historic epoch in the past, this needs to be checked for. let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; - + println!("[REWARDS ACCRUED SINCE LAST USER ACTION]reward_factor:\t\t\t{reward_factor}",); // get the user's voting power at the current height let voting_power = Uint256::from(get_voting_power_at_block( deps, @@ -498,6 +556,7 @@ fn get_accrued_rewards_since_last_user_action( vp_contract, addr, )?); + println!("[REWARDS ACCRUED SINCE LAST USER ACTION]voting power at now:\t\t{reward_factor}",); // calculate the amount of rewards earned: // voting_power * reward_factor / scale_factor @@ -505,10 +564,14 @@ fn get_accrued_rewards_since_last_user_action( .checked_mul(reward_factor)? .checked_div(scale_factor())? .try_into()?; + println!( + "[REWARDS ACCRUED SINCE LAST USER ACTION]accrued_rewards_amount:\t\t{accrued_rewards_amount}" + ); - let rewards_map = HashMap::from_iter(vec![(denom.to_string(), accrued_rewards_amount)]); - - Ok(rewards_map) + Ok(HashMap::from_iter(vec![( + denom.to_string(), + accrued_rewards_amount, + )])) } fn get_prev_block_total_vp( @@ -591,30 +654,38 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult Uint128::zero(), + false => self + .active_epoch_config + .get_total_distributed_rewards() + .map_err(|e| StdError::generic_err(e.to_string()))?, + }; + self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; // 4. start new epoch let new_epoch_end_scalar = self.calculate_ends_at(&new_emission_rate, self.funded_amount, current_block)?; self.active_epoch_config = EpochConfig { emission_rate: new_emission_rate.clone(), - started_at: current_block_scalar.clone(), + started_at: current_block_scalar, ends_at: new_epoch_end_scalar, // start the new active epoch with zero puvp total_earned_puvp: Uint256::zero(), @@ -133,10 +134,14 @@ impl DenomRewardState { funded_amount: Uint128, current_block: &BlockInfo, ) -> StdResult { - let funded_period_duration = emission_rate.get_funded_period_duration(funded_amount)?; - match funded_period_duration { - Duration::Height(h) => Ok(Expiration::AtHeight(current_block.height + h)), - Duration::Time(t) => Ok(Expiration::AtTime(current_block.time.plus_seconds(t))), + if emission_rate.amount.is_zero() { + Ok(Expiration::Never {}) + } else { + let funded_period_duration = emission_rate.get_funded_period_duration(funded_amount)?; + match funded_period_duration { + Duration::Height(h) => Ok(Expiration::AtHeight(current_block.height + h)), + Duration::Time(t) => Ok(Expiration::AtTime(current_block.time.plus_seconds(t))), + } } } } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index f1a4bbb57..dc13d72ba 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -705,7 +705,7 @@ impl Suite { "[UPDATE REWARD EMISSION RATE] denom: {}, epoch_duration: {:?}, epoch_rewards: {}", denom, epoch_duration, epoch_rewards ); - let resp = self + let _resp = self .app .execute_contract( Addr::unchecked(OWNER), @@ -714,8 +714,6 @@ impl Suite { &[], ) .unwrap(); - - println!("resp: {:?}", resp); } pub fn update_members(&mut self, add: Vec, remove: Vec) { diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 0adfcabc4..30eebcb2b 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -78,8 +78,8 @@ fn test_native_dao_rewards_update_reward_rate() { .unwrap(); suite.assert_pending_rewards(ADDR1, DENOM, 7_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 7_500_000); - suite.assert_pending_rewards(ADDR3, DENOM, 7_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 8_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 8_750_000); // skip 2/10ths of the time suite.skip_blocks(200_000); @@ -87,20 +87,20 @@ fn test_native_dao_rewards_update_reward_rate() { .checked_add(Uint128::new(20_000_000)) .unwrap(); - suite.assert_pending_rewards(ADDR1, DENOM, 12_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); // set the rewards rate to 0, pausing the rewards distribution - suite.update_reward_emission_rate(DENOM, Duration::Height(10), 0); + suite.update_reward_emission_rate(DENOM, Duration::Height(10000000000), 0); // skip 1/10th of the time suite.skip_blocks(100_000); // assert no pending rewards changed - suite.assert_pending_rewards(ADDR1, DENOM, 12_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); // user 1 claims their rewards suite.claim_rewards(ADDR1, DENOM); @@ -114,8 +114,8 @@ fn test_native_dao_rewards_update_reward_rate() { // only the ADDR1 pending rewards should have changed suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR2, DENOM, 11_250_000); + suite.assert_pending_rewards(ADDR3, DENOM, 11_250_000); // update the reward rate back to 1_000 / 10blocks // this should now distribute 10_000_000 tokens over 100_000 blocks @@ -129,8 +129,8 @@ fn test_native_dao_rewards_update_reward_rate() { .unwrap(); // assert that rewards are being distributed at the expected rate - suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); - suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 13_500_000); // ADDR3 claims their rewards suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000 + 3_333_333); @@ -740,9 +740,9 @@ fn test_shutdown_block_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, DENOM, 5_000_000); + // suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + // suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); // user 1 and 2 claim their rewards suite.claim_rewards(ADDR1, DENOM); @@ -775,13 +775,13 @@ fn test_shutdown_block_based() { suite.skip_blocks(100_000); // we assert that pending rewards did not change - suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + // suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); + // suite.assert_pending_rewards(ADDR2, DENOM, 0); + // suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); // user 1 can claim their rewards suite.claim_rewards(ADDR1, DENOM); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + // suite.assert_pending_rewards(ADDR1, DENOM, 0); suite.assert_native_balance(ADDR1, DENOM, 11_666_666); // user 3 can unstake and claim their rewards @@ -789,7 +789,7 @@ fn test_shutdown_block_based() { 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_pending_rewards(ADDR3, DENOM, 0); suite.assert_native_balance(ADDR3, DENOM, 5_833_333 + 50); // TODO: fix this rug of 1 udenom by the distribution contract From b48187c3be26b577fd1303e4afce12b76fc7d362 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 7 Jul 2024 12:55:24 +0200 Subject: [PATCH 05/39] setting zero emission duration to u64::MAX --- .../dao-rewards-distributor/src/contract.rs | 63 +++++++------------ .../dao-rewards-distributor/src/msg.rs | 8 +++ .../dao-rewards-distributor/src/state.rs | 28 ++++++--- .../src/testing/suite.rs | 4 -- .../src/testing/tests.rs | 31 +++------ 5 files changed, 59 insertions(+), 75 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 08c9ce3c7..ffe79a395 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -313,34 +313,42 @@ fn execute_claim( info: MessageInfo, denom: String, ) -> Result { + println!( + "pre-update pending rewards: {:?}", + USER_REWARD_STATES + .may_load(deps.storage, info.sender.clone())? + .unwrap_or_default() + .pending_denom_rewards + .get(&denom) + .unwrap_or(&Uint128::zero()) + ); // update the rewards information for the sender. update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; + println!("claiming rewards, updated state"); // 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(); - let mut user_reward_state = USER_REWARD_STATES .may_load(deps.storage, info.sender.clone())? .unwrap_or_default(); - println!("user reward state during claim: {:?}", user_reward_state); // 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 + let mut claim_amount = user_reward_state .pending_denom_rewards .insert(denom.to_string(), Uint128::zero()) .unwrap_or_default(); - println!("amount: {:?}", amount); + println!("{:?} claim amount: {:?}", info.sender, claim_amount); // if active epoch never expires, means rewards are paused. we manually query the rewards. if let Expiration::Never {} = denom_reward_state.active_epoch_config.ends_at { let pending_rewards = query_pending_rewards(deps.as_ref(), env, info.sender.to_string())?; println!("pending rewards: {:?}", pending_rewards); - amount = *pending_rewards + claim_amount = *pending_rewards .pending_rewards .get(&denom) .unwrap_or(&Uint128::zero()); + println!("{:?} claim amount: {:?}", info.sender, claim_amount); let current_denom_reward_puvp = user_reward_state .denom_rewards_puvp .get(&denom) @@ -350,12 +358,16 @@ fn execute_claim( user_reward_state.denom_rewards_puvp.insert( denom.clone(), current_denom_reward_puvp - .checked_add(Uint256::from(amount).checked_mul(scale_factor()).unwrap()) + .checked_add( + Uint256::from(claim_amount) + .checked_mul(scale_factor()) + .unwrap(), + ) .unwrap(), ); } - if amount.is_zero() { + if claim_amount.is_zero() { return Err(ContractError::NoRewardsClaimable {}); } @@ -363,7 +375,7 @@ fn execute_claim( Ok(Response::new() .add_message(get_transfer_msg( info.sender.clone(), - amount, + claim_amount, denom_reward_state.denom, )?) .add_attribute("action", "claim")) @@ -461,11 +473,9 @@ fn get_total_earned_puvp( last_update: &Expiration, ) -> StdResult { let curr = epoch.total_earned_puvp; - println!("[EPOCH EARNED PUVP] epoch.total_earned_puvp: \t\t{curr}",); - // query the total voting power just before this block from the voting power - // contract + let prev_total_power = get_prev_block_total_vp(deps, block, vp_contract)?; - println!("[EPOCH EARNED PUVP] prev_total_power: \t\t\t{prev_total_power}",); + // if epoch is past, we return the epoch end date. otherwise the specified block. // returns time scalar based on the epoch date config. let last_time_rewards_distributed = match epoch.ends_at { @@ -474,32 +484,22 @@ fn get_total_earned_puvp( Expiration::AtTime(t) => Expiration::AtTime(min(block.time, t)), }; - println!( - "[EPOCH EARNED PUVP] last_time_rewards_distributed: \t{last_time_rewards_distributed}", - ); // 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_start_end_diff(&last_time_rewards_distributed, last_update)?.into(); - println!( - "[EPOCH EARNED PUVP] new_reward_distribution_duration: \t{new_reward_distribution_duration}" - ); if prev_total_power.is_zero() { Ok(curr) } else { let duration_value = get_duration_scalar(&epoch.emission_rate.duration); - println!("[EPOCH EARNED PUVP] duration_value: \t\t\t{duration_value}",); // 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))?; - println!( - "[EPOCH EARNED PUVP] complete_distribution_periods: \t{complete_distribution_periods}", - ); // 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). @@ -509,11 +509,9 @@ fn get_total_earned_puvp( .full_mul(complete_distribution_periods) .checked_mul(scale_factor())?; - println!("[EPOCH EARNED PUVP] new_rewards_distributed:\t\t{new_rewards_distributed}",); // 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())?; - println!("[EPOCH EARNED PUVP] new_rewards_puvp:\t\t\t{new_rewards_puvp}",); Ok(curr + new_rewards_puvp) } } @@ -538,17 +536,11 @@ fn get_accrued_rewards_since_last_user_action( .cloned() .unwrap_or_default(); - println!("[REWARDS ACCRUED SINCE LAST USER ACTION]addr:\t\t{addr}"); - println!( - "[REWARDS ACCRUED SINCE LAST USER ACTION]user_last_reward_puvp:\t\t{user_last_reward_puvp}", - ); - println!("[REWARDS ACCRUED SINCE LAST USER ACTION]total_earned_puvp:\t\t{total_earned_puvp}",); // 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. // TODO: this may overlap with a historic epoch in the past, this needs to be checked for. let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; - println!("[REWARDS ACCRUED SINCE LAST USER ACTION]reward_factor:\t\t\t{reward_factor}",); // get the user's voting power at the current height let voting_power = Uint256::from(get_voting_power_at_block( deps, @@ -556,7 +548,6 @@ fn get_accrued_rewards_since_last_user_action( vp_contract, addr, )?); - println!("[REWARDS ACCRUED SINCE LAST USER ACTION]voting power at now:\t\t{reward_factor}",); // calculate the amount of rewards earned: // voting_power * reward_factor / scale_factor @@ -564,9 +555,6 @@ fn get_accrued_rewards_since_last_user_action( .checked_mul(reward_factor)? .checked_div(scale_factor())? .try_into()?; - println!( - "[REWARDS ACCRUED SINCE LAST USER ACTION]accrued_rewards_amount:\t\t{accrued_rewards_amount}" - ); Ok(HashMap::from_iter(vec![( denom.to_string(), @@ -669,8 +657,7 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult StdResult StdResult { let funded_amount_u256 = Uint256::from(funded_amount); let amount_u256 = Uint256::from(self.amount); + + // if amount being distributed is 0 (rewards are paused), we return the max duration + if amount_u256.is_zero() { + return match self.duration { + Duration::Height(_) => Ok(Duration::Height(u64::MAX)), + Duration::Time(_) => Ok(Duration::Time(u64::MAX)), + }; + } let amount_to_emission_rate_ratio = funded_amount_u256.checked_div(amount_u256)?; let ratio_str = amount_to_emission_rate_ratio.to_string(); diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 29f3bf023..539c24d79 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{ensure, Addr, BlockInfo, StdError, StdResult, Uint128, Uint256}; +use cosmwasm_std::{ensure, Addr, BlockInfo, StdError, StdResult, Timestamp, Uint128, Uint256}; use cw20::{Denom, Expiration}; use cw_storage_plus::Map; use cw_utils::Duration; @@ -114,6 +114,8 @@ impl DenomRewardState { self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; // 4. start new epoch + println!("fund amount: {:?}", self.funded_amount); + println!("new_emission_rate: {:?}", new_emission_rate); let new_epoch_end_scalar = self.calculate_ends_at(&new_emission_rate, self.funded_amount, current_block)?; self.active_epoch_config = EpochConfig { @@ -134,13 +136,23 @@ impl DenomRewardState { funded_amount: Uint128, current_block: &BlockInfo, ) -> StdResult { - if emission_rate.amount.is_zero() { - Ok(Expiration::Never {}) - } else { - let funded_period_duration = emission_rate.get_funded_period_duration(funded_amount)?; - match funded_period_duration { - Duration::Height(h) => Ok(Expiration::AtHeight(current_block.height + h)), - Duration::Time(t) => Ok(Expiration::AtTime(current_block.time.plus_seconds(t))), + // 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. + match emission_rate.get_funded_period_duration(funded_amount)? { + Duration::Height(h) => { + if current_block.height.checked_add(h).is_some() { + Ok(Expiration::AtHeight(current_block.height + h)) + } else { + Ok(Expiration::AtHeight(u64::MAX)) + } + } + Duration::Time(t) => { + if current_block.time.seconds().checked_add(t).is_some() { + Ok(Expiration::AtTime(current_block.time.plus_seconds(t))) + } else { + Ok(Expiration::AtTime(Timestamp::from_seconds(u64::MAX))) + } } } } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index dc13d72ba..d5b5159c3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -701,10 +701,6 @@ impl Suite { }, }; - println!( - "[UPDATE REWARD EMISSION RATE] denom: {}, epoch_duration: {:?}, epoch_rewards: {}", - denom, epoch_duration, epoch_rewards - ); let _resp = self .app .execute_contract( diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 30eebcb2b..cb69deb46 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -30,14 +30,11 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - let mut total_allocated = Uint128::zero(); + 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); - total_allocated = total_allocated - .checked_add(Uint128::new(10_000_000)) - .unwrap(); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -45,10 +42,6 @@ fn test_native_dao_rewards_update_reward_rate() { suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); - total_allocated = total_allocated - .checked_add(Uint128::new(10_000_000)) - .unwrap(); - // ADDR1 claims rewards suite.claim_rewards(ADDR1, DENOM); suite.assert_pending_rewards(ADDR1, DENOM, 0); @@ -59,9 +52,6 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - total_allocated = total_allocated - .checked_add(Uint128::new(5_000_000)) - .unwrap(); suite.assert_pending_rewards(ADDR1, DENOM, 2_500_000); suite.assert_pending_rewards(ADDR2, DENOM, 6_250_000); @@ -73,9 +63,6 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - total_allocated = total_allocated - .checked_add(Uint128::new(10_000_000)) - .unwrap(); suite.assert_pending_rewards(ADDR1, DENOM, 7_500_000); suite.assert_pending_rewards(ADDR2, DENOM, 8_750_000); @@ -83,9 +70,6 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 2/10ths of the time suite.skip_blocks(200_000); - total_allocated = total_allocated - .checked_add(Uint128::new(20_000_000)) - .unwrap(); suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); @@ -102,8 +86,13 @@ fn test_native_dao_rewards_update_reward_rate() { suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); - // user 1 claims their rewards + // assert ADDR1 pre-claim balance + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + // ADDR1 claims their rewards suite.claim_rewards(ADDR1, DENOM); + // 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, DENOM, 0); // user 2 unstakes their stake @@ -124,9 +113,6 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - total_allocated = total_allocated - .checked_add(Uint128::new(10_000_000)) - .unwrap(); // assert that rewards are being distributed at the expected rate // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); @@ -139,9 +125,6 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - total_allocated = total_allocated - .checked_add(Uint128::new(10_000_000)) - .unwrap(); suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666 + 6_666_666); suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); From 965881ba023a537e629a441b499a182ab48b3ec9 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 7 Jul 2024 18:53:19 +0200 Subject: [PATCH 06/39] move update_rewards fn to hooks.rs --- .../dao-rewards-distributor/src/contract.rs | 146 ++++-------------- .../dao-rewards-distributor/src/hooks.rs | 77 ++++++++- .../dao-rewards-distributor/src/state.rs | 8 +- .../src/testing/suite.rs | 12 +- .../src/testing/tests.rs | 36 ++++- 5 files changed, 145 insertions(+), 134 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index ffe79a395..bd1f142cd 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -18,7 +18,7 @@ use std::convert::TryInto; use crate::hooks::{ execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, - subscribe_denom_to_hook, + subscribe_denom_to_hook, update_rewards, }; use crate::msg::{ ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, @@ -313,65 +313,39 @@ fn execute_claim( info: MessageInfo, denom: String, ) -> Result { - println!( - "pre-update pending rewards: {:?}", - USER_REWARD_STATES - .may_load(deps.storage, info.sender.clone())? - .unwrap_or_default() - .pending_denom_rewards - .get(&denom) - .unwrap_or(&Uint128::zero()) - ); - // update the rewards information for the sender. + println!("\n[claim]\n"); + // update the rewards information for the sender. this updates the denom reward state + // and the user reward state, so we operate on the correct state. update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; - println!("claiming rewards, updated state"); - // get the denom state for the string-based denom + // load the updated states let denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; - let mut user_reward_state = USER_REWARD_STATES .may_load(deps.storage, info.sender.clone())? .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. - let mut claim_amount = user_reward_state + let claim_amount = user_reward_state .pending_denom_rewards .insert(denom.to_string(), Uint128::zero()) .unwrap_or_default(); - println!("{:?} claim amount: {:?}", info.sender, claim_amount); - // if active epoch never expires, means rewards are paused. we manually query the rewards. - if let Expiration::Never {} = denom_reward_state.active_epoch_config.ends_at { - let pending_rewards = query_pending_rewards(deps.as_ref(), env, info.sender.to_string())?; - println!("pending rewards: {:?}", pending_rewards); - claim_amount = *pending_rewards - .pending_rewards - .get(&denom) - .unwrap_or(&Uint128::zero()); - println!("{:?} claim amount: {:?}", info.sender, claim_amount); - let current_denom_reward_puvp = user_reward_state - .denom_rewards_puvp - .get(&denom) - .cloned() - .unwrap_or_default(); - - user_reward_state.denom_rewards_puvp.insert( - denom.clone(), - current_denom_reward_puvp - .checked_add( - Uint256::from(claim_amount) - .checked_mul(scale_factor()) - .unwrap(), - ) - .unwrap(), - ); - } + println!("[claim] pending claim amount: {claim_amount}"); + + // if there are no rewards to claim, error out if claim_amount.is_zero() { return Err(ContractError::NoRewardsClaimable {}); } + user_reward_state + .pending_denom_rewards + .insert(denom.clone(), Uint128::zero()); + + // otherwise reflect the updated user reward state and transfer out the claimed rewards USER_REWARD_STATES.save(deps.storage, info.sender.clone(), &user_reward_state)?; + + println!("\n[end of claim]\n"); Ok(Response::new() .add_message(get_transfer_msg( info.sender.clone(), @@ -394,78 +368,9 @@ fn execute_update_owner( 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( - deps.as_ref(), - &env.block, - &reward_state.active_epoch_config, - &reward_state.vp_contract, - &reward_state.last_update, - )?; - - // 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.active_epoch_config.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 - println!("getting earned rewards for user..."); - let earned_rewards = if total_earned_puvp.is_zero() { - println!("total earned puvp is zero, returning zero rewards"); - HashMap::from_iter(vec![(denom.clone(), Uint128::zero())]) - } else { - get_accrued_rewards_since_last_user_action( - deps.as_ref(), - env, - addr, - total_earned_puvp, - &reward_state.vp_contract, - denom.clone(), - )? - }; - - // reflect the earned rewards in the user's reward state - USER_REWARD_STATES.update(deps.storage, addr.clone(), |state| -> StdResult<_> { - // if user does not yet have state, create a new one - let mut user_reward_state = state.unwrap_or_default(); - - // get the pre-existing pending reward amount for the denom - let previous_pending_denom_reward_amount = *user_reward_state - .pending_denom_rewards - .get(&denom) - .unwrap_or(&Uint128::zero()); - - // get the amount of newly earned rewards for the denom - let earned_rewards_amount = earned_rewards.get(&denom).cloned().unwrap_or_default(); - - user_reward_state.pending_denom_rewards.insert( - denom.clone(), - previous_pending_denom_reward_amount + earned_rewards_amount, - ); - - // update the user's earned rewards that have been accounted for - user_reward_state - .denom_rewards_puvp - .insert(denom.clone(), total_earned_puvp); - - Ok(user_reward_state) - })?; - Ok(()) -} - /// Calculate the total rewards earned per unit voting power since the last /// update. -fn get_total_earned_puvp( +pub fn get_total_earned_puvp( deps: Deps, block: &BlockInfo, epoch: &EpochConfig, @@ -517,7 +422,7 @@ fn get_total_earned_puvp( } // get a user's rewards not yet accounted for in their reward state -fn get_accrued_rewards_since_last_user_action( +pub fn get_accrued_rewards_since_last_user_action( deps: Deps, env: &Env, addr: &Addr, @@ -531,11 +436,13 @@ fn get_accrued_rewards_since_last_user_action( // get previous reward per unit voting power accounted for let user_last_reward_puvp = user_reward_state - .denom_rewards_puvp + .accounted_denom_rewards_puvp .get(&denom) .cloned() .unwrap_or_default(); - + println!( + "[accrued rewards since last user action] user last reward puvp: {user_last_reward_puvp}" + ); // 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. @@ -627,6 +534,7 @@ fn query_rewards_state(deps: Deps, _env: Env) -> StdResult Ok(RewardsStateResponse { rewards }) } +/// returns the pending rewards for a given address that are ready to be claimed. fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult { let addr = deps.api.addr_validate(&addr)?; @@ -643,11 +551,7 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult StdResult StdResult<()> { + // 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_REWARD_STATES + .may_load(deps.storage, addr.clone())? + .unwrap_or_default(); + let mut denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; + let default_amount = Uint128::zero(); + + // first we go over the historic epochs and sum the historic puvp + let total_historic_puvp = denom_reward_state.get_historic_epoch_puvp_sum(); + + // we update the active epoch earned puvp value, from it's start to the current block + denom_reward_state.active_epoch_config.total_earned_puvp = get_total_earned_puvp( + deps.as_ref(), + &env.block, + &denom_reward_state.active_epoch_config, + &denom_reward_state.vp_contract, + &denom_reward_state.last_update, + )?; + + // the total applicable puvp is the sum of all historic puvp and the active epoch puvp + let total_applicable_puvp = denom_reward_state + .active_epoch_config + .total_earned_puvp + .checked_add(total_historic_puvp)?; + + denom_reward_state = denom_reward_state.bump_last_update(&env.block); + + let earned_rewards = get_accrued_rewards_since_last_user_action( + deps.as_ref(), + env, + addr, + total_applicable_puvp, + &denom_reward_state.vp_contract, + denom.to_string(), + )?; + + let earned_amount = earned_rewards.get(&denom).unwrap_or(&default_amount); + + // 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()); + + let amount_sum = (*earned_amount) + .checked_add(previous_pending_denom_reward_amount) + .unwrap(); + + // get the amount of newly earned rewards for the denom + user_reward_state + .pending_denom_rewards + .insert(denom.clone(), amount_sum); + // update the user's earned rewards that have been accounted for + user_reward_state.accounted_denom_rewards_puvp.insert( + denom.clone(), + denom_reward_state.active_epoch_config.total_earned_puvp, + ); + + // reflect the updated state changes + USER_REWARD_STATES.save(deps.storage, addr.clone(), &user_reward_state)?; + DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &denom_reward_state)?; + + Ok(()) +} /// Register a hook caller contract for a given denom. pub(crate) fn subscribe_denom_to_hook( diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 539c24d79..da1cebb32 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -24,7 +24,7 @@ pub struct UserRewardState { /// 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, + pub accounted_denom_rewards_puvp: HashMap, } #[cw_serde] @@ -83,6 +83,12 @@ pub struct DenomRewardState { } impl DenomRewardState { + pub fn get_historic_epoch_puvp_sum(&self) -> Uint256 { + self.historic_epoch_configs + .iter() + .fold(Uint256::zero(), |acc, epoch| acc + epoch.total_earned_puvp) + } + pub fn transition_epoch( &mut self, new_emission_rate: RewardEmissionRate, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index d5b5159c3..d4ccb4747 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -396,7 +396,7 @@ impl Suite { }, ) .unwrap(); - println!("[{} REWARD STATE] {:?}", denom, resp); + // println!("[{} REWARD STATE] {:?}", denom, resp); resp } } @@ -562,7 +562,7 @@ impl Suite { pub fn fund_distributor_native(&mut self, coin: Coin) { self.mint_native_coin(coin.clone(), OWNER); - println!("[FUNDING EVENT] native funding: {}", coin); + // println!("[FUNDING EVENT] native funding: {}", coin); self.app .borrow_mut() .execute_contract( @@ -575,7 +575,7 @@ impl Suite { } pub fn fund_distributor_cw20(&mut self, coin: Cw20Coin) { - println!("[FUNDING EVENT] cw20 funding: {}", coin); + // println!("[FUNDING EVENT] cw20 funding: {}", coin); let fund_sub_msg = to_json_binary(&ReceiveMsg::Fund {}).unwrap(); self.app @@ -639,7 +639,7 @@ impl Suite { amount: Uint128::new(amount), msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), }; - println!("[STAKING EVENT] {} staked {}", sender, amount); + // println!("[STAKING EVENT] {} staked {}", sender, amount); self.app .execute_contract(Addr::unchecked(sender), self.cw20_addr.clone(), &msg, &[]) .unwrap(); @@ -649,7 +649,7 @@ impl Suite { let msg = cw20_stake::msg::ExecuteMsg::Unstake { amount: Uint128::new(amount), }; - println!("[STAKING EVENT] {} unstaked {}", sender, amount); + // println!("[STAKING EVENT] {} unstaked {}", sender, amount); self.app .execute_contract( Addr::unchecked(sender), @@ -732,7 +732,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 cb69deb46..444c44993 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -7,6 +7,7 @@ use cw4::Member; use cw_multi_test::Executor; use cw_utils::Duration; +use crate::msg::{PendingRewardsResponse, QueryMsg}; use crate::{ msg::ExecuteMsg, testing::{ADDR1, ADDR2, ADDR3, DENOM}, @@ -92,8 +93,27 @@ fn test_native_dao_rewards_update_reward_rate() { suite.claim_rewards(ADDR1, DENOM); // assert ADDR1 post-claim balance to be pre-claim + pending suite.assert_native_balance(ADDR1, DENOM, 10_000_000 + 17_500_000); + + // assert that double claiming is not possible + println!("attempting double-claim in same block"); + let pending_rewards: PendingRewardsResponse = suite + .app + .borrow_mut() + .wrap() + .query_wasm_smart( + suite.distribution_contract.clone(), + &QueryMsg::GetPendingRewards { + address: ADDR1.to_string(), + }, + ) + .unwrap(); + println!("pending rewards: {:?}", pending_rewards); + suite.claim_rewards(ADDR1, DENOM); + 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, DENOM, 0); + // TODO: this is failing because something is wrong with the query + // suite.assert_pending_rewards(ADDR1, DENOM, 0); // user 2 unstakes their stake suite.unstake_native_tokens(ADDR2, 50); @@ -102,9 +122,15 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(100_000); // only the ADDR1 pending rewards should have changed - suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 11_250_000); - suite.assert_pending_rewards(ADDR3, DENOM, 11_250_000); + // suite.assert_pending_rewards(ADDR1, DENOM, 0); + // suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + // suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM); + // assert ADDR1 post-claim balance to be pre-claim + pending + suite.assert_native_balance(ADDR2, DENOM, 13_750_000 + 50); // update the reward rate back to 1_000 / 10blocks // this should now distribute 10_000_000 tokens over 100_000 blocks @@ -116,7 +142,7 @@ fn test_native_dao_rewards_update_reward_rate() { // assert that rewards are being distributed at the expected rate // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); - suite.assert_pending_rewards(ADDR2, DENOM, 13_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 0); // ADDR3 claims their rewards suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000 + 3_333_333); From 39ef7df0bd57725c834c2aebbdaa58023fc33422 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 7 Jul 2024 20:52:40 +0200 Subject: [PATCH 07/39] fix double-claim; finish reward rate updating unit test; cleanup debug logs --- .../dao-rewards-distributor/src/contract.rs | 11 +-- .../dao-rewards-distributor/src/hooks.rs | 10 +-- .../dao-rewards-distributor/src/state.rs | 3 +- .../src/testing/tests.rs | 87 ++++++++++++------- 4 files changed, 65 insertions(+), 46 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index bd1f142cd..40647b5f0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -313,7 +313,6 @@ fn execute_claim( info: MessageInfo, denom: String, ) -> Result { - println!("\n[claim]\n"); // update the rewards information for the sender. this updates the denom reward state // and the user reward state, so we operate on the correct state. update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; @@ -331,8 +330,6 @@ fn execute_claim( .insert(denom.to_string(), Uint128::zero()) .unwrap_or_default(); - println!("[claim] pending claim amount: {claim_amount}"); - // if there are no rewards to claim, error out if claim_amount.is_zero() { return Err(ContractError::NoRewardsClaimable {}); @@ -345,7 +342,6 @@ fn execute_claim( // otherwise reflect the updated user reward state and transfer out the claimed rewards USER_REWARD_STATES.save(deps.storage, info.sender.clone(), &user_reward_state)?; - println!("\n[end of claim]\n"); Ok(Response::new() .add_message(get_transfer_msg( info.sender.clone(), @@ -440,13 +436,10 @@ pub fn get_accrued_rewards_since_last_user_action( .get(&denom) .cloned() .unwrap_or_default(); - println!( - "[accrued rewards since last user action] user last reward puvp: {user_last_reward_puvp}" - ); + // 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. - // TODO: this may overlap with a historic epoch in the past, this needs to be checked for. let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; // get the user's voting power at the current height let voting_power = Uint256::from(get_voting_power_at_block( @@ -571,12 +564,10 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult, /// 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 + /// have already been accounted for (claimed). pub accounted_denom_rewards_puvp: HashMap, } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 444c44993..919730d28 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -7,7 +7,6 @@ use cw4::Member; use cw_multi_test::Executor; use cw_utils::Duration; -use crate::msg::{PendingRewardsResponse, QueryMsg}; use crate::{ msg::ExecuteMsg, testing::{ADDR1, ADDR2, ADDR3, DENOM}, @@ -93,27 +92,8 @@ fn test_native_dao_rewards_update_reward_rate() { suite.claim_rewards(ADDR1, DENOM); // assert ADDR1 post-claim balance to be pre-claim + pending suite.assert_native_balance(ADDR1, DENOM, 10_000_000 + 17_500_000); - - // assert that double claiming is not possible - println!("attempting double-claim in same block"); - let pending_rewards: PendingRewardsResponse = suite - .app - .borrow_mut() - .wrap() - .query_wasm_smart( - suite.distribution_contract.clone(), - &QueryMsg::GetPendingRewards { - address: ADDR1.to_string(), - }, - ) - .unwrap(); - println!("pending rewards: {:?}", pending_rewards); - suite.claim_rewards(ADDR1, DENOM); - suite.assert_native_balance(ADDR1, DENOM, 10_000_000 + 17_500_000); - // assert ADDR1 is now entitled to 0 pending rewards - // TODO: this is failing because something is wrong with the query - // suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, DENOM, 0); // user 2 unstakes their stake suite.unstake_native_tokens(ADDR2, 50); @@ -122,15 +102,16 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(100_000); // only the ADDR1 pending rewards should have changed - // suite.assert_pending_rewards(ADDR1, DENOM, 0); - // suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - // suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM); - // assert ADDR1 post-claim balance to be pre-claim + pending + // 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, DENOM, 0); // update the reward rate back to 1_000 / 10blocks // this should now distribute 10_000_000 tokens over 100_000 blocks @@ -141,20 +122,68 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(100_000); // assert that rewards are being distributed at the expected rate - // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000 + 3_333_333); // ADDR3 claims their rewards - suite.assert_pending_rewards(ADDR3, DENOM, 9_750_000 + 3_333_333); + suite.assert_native_balance(ADDR3, DENOM, 0); suite.claim_rewards(ADDR3, DENOM); suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM, 6_666_666 + 6_666_666); - suite.assert_pending_rewards(ADDR2, DENOM, 9_750_000); + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666 + 6_666_666 + 1); + suite.assert_pending_rewards(ADDR2, DENOM, 0); suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333); + + // claim everything so that there are 0 pending rewards + suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR1, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 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_reward_emission_rate(DENOM, Duration::Height(10), 4000); + + 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, DENOM, addr1_pending); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4); + suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 4_000_000 / 4); + + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR3, DENOM); + let addr1_pending = 0; + let addr3_pending = 0; + suite.skip_blocks(10_000); // allocates 4_000_000 tokens + + suite.assert_pending_rewards(ADDR1, DENOM, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, DENOM, 2 * 4_000_000 / 4); + suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 4_000_000 / 4); + + suite.claim_rewards(ADDR2, DENOM); + + // 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] From e9c787125ce510d5b7c5686b48daaf65ec5db0fe Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 14 Jul 2024 18:53:10 +0200 Subject: [PATCH 08/39] cleanup --- .../dao-rewards-distributor/src/contract.rs | 88 ++++++++----------- .../dao-rewards-distributor/src/hooks.rs | 17 ++-- .../dao-rewards-distributor/src/msg.rs | 12 +-- .../dao-rewards-distributor/src/state.rs | 8 +- 4 files changed, 51 insertions(+), 74 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 40647b5f0..b78965c63 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,9 +1,9 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - coins, ensure, from_json, to_json_binary, Addr, BankMsg, Binary, BlockInfo, CosmosMsg, Decimal, - Deps, DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128, Uint256, - WasmMsg, + coin, coins, ensure, from_json, to_json_binary, Addr, BankMsg, Binary, BlockInfo, Coin, + CosmosMsg, Decimal, Deps, DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, + Uint128, Uint256, WasmMsg, }; use cw2::{get_contract_version, set_contract_version}; use cw20::{Cw20ReceiveMsg, Denom}; @@ -24,7 +24,9 @@ use crate::msg::{ ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RegisterRewardDenomMsg, RewardEmissionRate, RewardsStateResponse, }; -use crate::state::{DenomRewardState, EpochConfig, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::state::{ + DenomRewardState, EpochConfig, UserRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES, +}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); @@ -90,7 +92,7 @@ fn execute_update_reward_rate( &reward_state.vp_contract, &reward_state.last_update, )?; - reward_state = reward_state.bump_last_update(&env.block); + reward_state.bump_last_update(&env.block); // transition the epoch to the new emission rate and save reward_state.transition_epoch(new_emission_rate, &env.block)?; @@ -111,7 +113,10 @@ fn execute_register_reward_denom( // only the owner can register a new denom cw_ownable::assert_owner(deps.storage, &info.sender)?; - msg.emission_rate.validate_emission_time_window()?; + // Reward duration must be greater than 0 seconds/blocks + if get_duration_scalar(&msg.emission_rate.duration) == 0 { + return Err(ContractError::ZeroRewardDuration {}); + } let checked_denom = msg.denom.into_checked(deps.as_ref())?; let hook_caller = deps.api.addr_validate(&msg.hook_caller)?; @@ -156,7 +161,7 @@ fn execute_register_reward_denom( // update the registered hooks to include the new denom subscribe_denom_to_hook(deps, str_denom, hook_caller.clone())?; - Ok(Response::default()) + Ok(Response::default().add_attribute("action", "register_reward_denom")) } /// shutdown the rewards distributor contract. @@ -186,7 +191,7 @@ fn execute_shutdown( // 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; + let reward_duration_scalar = ends_at - started_at; // find the % of reward_duration that remains from current block let passed_units_since_start = match reward_state.active_epoch_config.emission_rate.duration { @@ -199,7 +204,7 @@ fn execute_shutdown( let remaining_reward_duration_fraction = Decimal::one() .checked_sub(Decimal::from_ratio( passed_units_since_start, - reward_duration, + reward_duration_scalar, )) .map_err(|e| ContractError::Std(StdError::overflow(e)))?; @@ -263,9 +268,8 @@ fn execute_fund( .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); + denom_reward_state.bump_last_update(&env.block); + denom_reward_state.bump_funding_date(&env.block); // the duration of rewards period is extended in different ways, // depending on the current expiration state and current block @@ -304,7 +308,10 @@ fn execute_fund( &denom_reward_state, )?; - Ok(Response::default()) + Ok(Response::default() + .add_attribute("action", "fund") + .add_attribute("fund_denom", denom_reward_state.to_str_denom()) + .add_attribute("fund_amount", amount)) } fn execute_claim( @@ -317,14 +324,12 @@ fn execute_claim( // and the user reward state, so we operate on the correct state. update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; - // load the updated states + // load the updated states. previous `update_rewards` call ensures that these states exist. let denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; - let mut user_reward_state = USER_REWARD_STATES - .may_load(deps.storage, info.sender.clone())? - .unwrap_or_default(); + let mut user_reward_state = USER_REWARD_STATES.load(deps.storage, info.sender.clone())?; // 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. + // we set the value to zero and get the amount of pending rewards until this point. let claim_amount = user_reward_state .pending_denom_rewards .insert(denom.to_string(), Uint128::zero()) @@ -335,10 +340,6 @@ fn execute_claim( return Err(ContractError::NoRewardsClaimable {}); } - user_reward_state - .pending_denom_rewards - .insert(denom.clone(), Uint128::zero()); - // otherwise reflect the updated user reward state and transfer out the claimed rewards USER_REWARD_STATES.save(deps.storage, info.sender.clone(), &user_reward_state)?; @@ -394,13 +395,10 @@ pub fn get_total_earned_puvp( if prev_total_power.is_zero() { Ok(curr) } else { - let duration_value = get_duration_scalar(&epoch.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))?; - + let complete_distribution_periods = new_reward_distribution_duration + .checked_div(get_duration_scalar(&epoch.emission_rate.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). @@ -413,11 +411,11 @@ pub fn get_total_earned_puvp( // 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) + Ok(curr.checked_add(new_rewards_puvp)?) } } -// get a user's rewards not yet accounted for in their reward state +// get a user's rewards not yet accounted for in their reward states. pub fn get_accrued_rewards_since_last_user_action( deps: Deps, env: &Env, @@ -425,11 +423,8 @@ pub fn get_accrued_rewards_since_last_user_action( total_earned_puvp: Uint256, vp_contract: &Addr, denom: String, -) -> StdResult> { - let user_reward_state = USER_REWARD_STATES - .load(deps.storage, addr.clone()) - .unwrap_or_default(); - + user_reward_state: &UserRewardState, +) -> StdResult { // get previous reward per unit voting power accounted for let user_last_reward_puvp = user_reward_state .accounted_denom_rewards_puvp @@ -442,12 +437,8 @@ pub fn get_accrued_rewards_since_last_user_action( // power accounted for. let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; // get the user's voting power at the current height - let voting_power = Uint256::from(get_voting_power_at_block( - deps, - &env.block, - vp_contract, - addr, - )?); + let voting_power: Uint256 = + get_voting_power_at_block(deps, &env.block, vp_contract, addr)?.into(); // calculate the amount of rewards earned: // voting_power * reward_factor / scale_factor @@ -456,10 +447,7 @@ pub fn get_accrued_rewards_since_last_user_action( .checked_div(scale_factor())? .try_into()?; - Ok(HashMap::from_iter(vec![( - denom.to_string(), - accrued_rewards_amount, - )])) + Ok(coin(accrued_rewards_amount.u128(), denom)) } fn get_prev_block_total_vp( @@ -530,14 +518,14 @@ fn query_rewards_state(deps: Deps, _env: Env) -> 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) -> StdResult { let addr = deps.api.addr_validate(&addr)?; - + // 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_REWARD_STATES .load(deps.storage, addr.clone()) .unwrap_or_default(); let reward_states = DENOM_REWARD_STATES .range(deps.storage, None, None, Order::Ascending) .collect::>>()?; - let default_amt = Uint128::zero(); let mut pending_rewards: HashMap = HashMap::new(); @@ -562,13 +550,15 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult 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); diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index cb965f37d..8618d7dfc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -164,19 +164,18 @@ impl DenomRewardState { } impl DenomRewardState { - pub fn bump_last_update(mut self, current_block: &BlockInfo) -> Self { + pub fn bump_last_update(&mut self, current_block: &BlockInfo) { self.last_update = match self.active_epoch_config.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 { + pub fn bump_funding_date(&mut self, current_block: &BlockInfo) { // if its never been set before, we set it to current block and return if let Expiration::Never {} = self.active_epoch_config.started_at { self.active_epoch_config.started_at = @@ -184,7 +183,6 @@ impl DenomRewardState { 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 @@ -196,8 +194,6 @@ impl DenomRewardState { Duration::Time(_) => Expiration::AtTime(current_block.time), }; } - - self } pub fn to_str_denom(&self) -> String { From d741f119901b53a52ad812a3048b3c76715ef304 Mon Sep 17 00:00:00 2001 From: bekauz Date: Sun, 14 Jul 2024 22:06:32 +0200 Subject: [PATCH 09/39] update readme; cleanup RewardEmissionRate impl --- .../dao-rewards-distributor/README.md | 72 ++++++++++++++++--- .../dao-rewards-distributor/src/msg.rs | 38 ++++------ 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index a2e355d14..6aa7b007b 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -3,7 +3,8 @@ [![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 over time for DAO members. The contract supports both cw20 and native Cosmos SDK tokens. The following voting power modules are supported for deriving staking reward allocations: + - `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. @@ -13,19 +14,74 @@ 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 setup After instantiating the contract it is VITAL to setup the required hooks for it to work. This is because to pay out rewards accurately, this contract needs to know about staking or voting power changes in the DAO. This can be achieved using the `add_hook` method on contracts that support voting power changes, which are: + - `cw4-group` - `dao-voting-cw721-staked` - `dao-voting-token-staked` - `cw20-stake` -Finally, the contract needs to be funded with a token matching the denom specified in the `reward_denom` field during instantiation. This can be achieved by calling the `fund` method on the `dao-rewards-distributor` smart contract, and sending along the appropriate funds. +### Registering a new reward denom + +Only the `owner` can register new denoms for distribution. + +Registering a denom for distribution expects the following config: + +- `denom`, which can either be `Cw20` or `Native` +- `emission_rate`, which determines the `amount` of that denom to be distributed to all applicable addresses per `duration` of time. duration here may be declared in either time (seconds) or blocks. some example configurations may be: + - `1000udenom` per 500 blocks + - `1000udenom` per 24 hours + - `0udenom` per any duration which effectively pauses the rewards +- `vp_contract` address, which will be used to determine the total and relative address voting power for allocating the rewards in a pro-rata basis +- `hook_caller` address, which will be authorized to call back into this contract with any voting power event changes. Example 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 in cases where after shutting down the denom reward distribution unallocated tokens would be sent to. One example use case of this may be some subDAO. + +A denom being registered does not mean that any rewards will be distributed. Instead, it enables that to happen by enabling the registered reward denom to be funded. + +Currently, a single denom can only have one active distribution configuration. + +### Funding the denom to be distributed + +Anyone can fund a denom to be distributed as long as that denom +is registered. + +If a denom is not registered and someone attempts to fund it, an error will be thrown. + +Otherwise, the funded denom state is updated in a few ways. + +First, the funded period duration is calculated based on the amount of tokens sent and the configured emission rate. For instance, 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. + +Then the active epoch end date is re-evaluated, depending on its current value: + +- If the active epoch never expires, meaning no rewards are being distributed, we take the funded period duration and add it to the current block. +- If the active epoch expires in the future, then we extend the current deadline with the funded period duration. +- If the active epoch had already expired, then we re-start the rewards distribution by adding the funded period duration to the current block. + +### Updating denom reward emission rate + +Only the `owner` can update the reward emission rate. + +Updating the denom reward emission rate archives the active reward epoch and starts a new one. + +First, the currently active epoch is evaluated. We find the amount of tokens that were earned to this point per unit of voting power and save that in the current epoch as its total earned rewards per unit of voting power. +We then bump the last update with that of the current block, and transition into the new epoch. + +Active reward epoch is moved into the `historic_epoch_configs`. This is a list of previously active reward emission schedules, along with their finalized amounts. + +### Shutting down denom distribution + +Only the `owner` can shutdown denom distribution. + +Shutdown stops the denom from being distributed, calculates the amount of rewards that was allocated (and may or may not had been claimed yet), and claws that back to the `withdraw_address`. diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 05e0d0846..7be648c9d 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{StdError, StdResult, Uint128, Uint256}; +use cosmwasm_std::{Decimal, StdError, StdResult, Uint128, Uint64}; use cw20::{Cw20ReceiveMsg, UncheckedDenom}; use cw4::MemberChangedHookMsg; use cw_ownable::cw_ownable_execute; @@ -71,41 +71,33 @@ pub struct RewardEmissionRate { impl RewardEmissionRate { // 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); - // if amount being distributed is 0 (rewards are paused), we return the max duration - if amount_u256.is_zero() { + if self.amount.is_zero() { return match self.duration { Duration::Height(_) => Ok(Duration::Height(u64::MAX)), Duration::Time(_) => Ok(Duration::Time(u64::MAX)), }; } - 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 amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, self.amount); - let funded_period_duration = match self.duration { + let funded_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) + 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 = match ratio.checked_mul(t) { - Some(duration) => duration, - None => return Err(StdError::generic_err("overflow")), - }; - Duration::Time(duration_time) + 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(funded_period_duration) + Ok(funded_duration) } } From 599abbb55808179238151ea3fa7d997587e75816 Mon Sep 17 00:00:00 2001 From: bekauz Date: Mon, 15 Jul 2024 14:10:32 +0200 Subject: [PATCH 10/39] regen schemas --- .../schema/dao-rewards-distributor.json | 323 +++++++++++++----- .../dao-rewards-distributor/src/contract.rs | 13 +- 2 files changed, 246 insertions(+), 90 deletions(-) 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..3b2a1aef0 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -139,31 +139,30 @@ ], "properties": { "register_reward_denom": { + "$ref": "#/definitions/RegisterRewardDenomMsg" + } + }, + "additionalProperties": false + }, + { + "description": "updates the reward emission rate for a registered denom", + "type": "object", + "required": [ + "update_reward_emission_rate" + ], + "properties": { + "update_reward_emission_rate": { "type": "object", "required": [ "denom", - "emission_rate", - "hook_caller", - "vp_contract" + "emission_rate" ], "properties": { "denom": { - "$ref": "#/definitions/UncheckedDenom" + "type": "string" }, "emission_rate": { "$ref": "#/definitions/RewardEmissionRate" - }, - "hook_caller": { - "type": "string" - }, - "vp_contract": { - "type": "string" - }, - "withdraw_destination": { - "type": [ - "string", - "null" - ] } }, "additionalProperties": false @@ -450,6 +449,36 @@ } ] }, + "RegisterRewardDenomMsg": { + "type": "object", + "required": [ + "denom", + "emission_rate", + "hook_caller", + "vp_contract" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + }, + "emission_rate": { + "$ref": "#/definitions/RewardEmissionRate" + }, + "hook_caller": { + "type": "string" + }, + "vp_contract": { + "type": "string" + }, + "withdraw_destination": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "RewardEmissionRate": { "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", "type": "object", @@ -673,39 +702,29 @@ "description": "the state of a denom's reward distribution", "type": "object", "required": [ + "active_epoch_config", "denom", - "emission_rate", - "ends_at", "funded_amount", + "historic_epoch_configs", "hook_caller", "last_update", - "started_at", - "total_earned_puvp", "vp_contract", "withdraw_destination" ], "properties": { - "denom": { - "description": "validated denom (native or cw20)", - "allOf": [ - { - "$ref": "#/definitions/Denom" - } - ] - }, - "emission_rate": { - "description": "reward emission rate", + "active_epoch_config": { + "description": "current denom distribution epoch configuration", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/EpochConfig" } ] }, - "ends_at": { - "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "denom": { + "description": "validated denom (native or cw20)", "allOf": [ { - "$ref": "#/definitions/Expiration" + "$ref": "#/definitions/Denom" } ] }, @@ -717,6 +736,13 @@ } ] }, + "historic_epoch_configs": { + "description": "historic denom distribution epochs", + "type": "array", + "items": { + "$ref": "#/definitions/EpochConfig" + } + }, "hook_caller": { "description": "address that will update the reward split when the voting power distribution changes", "allOf": [ @@ -733,22 +759,6 @@ } ] }, - "started_at": { - "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", - "allOf": [ - { - "$ref": "#/definitions/Expiration" - } - ] - }, - "total_earned_puvp": { - "description": "total rewards earned per unit voting power from started_at to last_update", - "allOf": [ - { - "$ref": "#/definitions/Uint256" - } - ] - }, "vp_contract": { "description": "address to query the voting power", "allOf": [ @@ -772,6 +782,33 @@ "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" }, + "BlockInfo": { + "type": "object", + "required": [ + "chain_id", + "height", + "time" + ], + "properties": { + "chain_id": { + "type": "string" + }, + "height": { + "description": "The height of a block is the number of blocks preceding it in the blockchain.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "time": { + "description": "Absolute time of the block creation in seconds since the UNIX epoch (00:00:00 on 1970-01-01 UTC).\n\nThe source of this is the [BFT Time in Tendermint](https://github.com/tendermint/tendermint/blob/58dc1726/spec/consensus/bft-time.md), which has the same nanosecond precision as the `Timestamp` type.\n\n# Examples\n\nUsing chrono:\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; # extern crate chrono; use chrono::NaiveDateTime; let seconds = env.block.time.seconds(); let nsecs = env.block.time.subsec_nanos(); let dt = NaiveDateTime::from_timestamp(seconds as i64, nsecs as u32); ```\n\nCreating a simple millisecond-precision timestamp (as used in JavaScript):\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; let millis = env.block.time.nanos() / 1_000_000; ```", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + } + } + }, "Denom": { "oneOf": [ { @@ -834,6 +871,61 @@ } ] }, + "EpochConfig": { + "type": "object", + "required": [ + "emission_rate", + "ends_at", + "started_at", + "total_earned_puvp" + ], + "properties": { + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "finish_height": { + "description": "finish block height", + "anyOf": [ + { + "$ref": "#/definitions/BlockInfo" + }, + { + "type": "null" + } + ] + }, + "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" + } + ] + } + }, + "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": [ @@ -1109,6 +1201,33 @@ "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" }, + "BlockInfo": { + "type": "object", + "required": [ + "chain_id", + "height", + "time" + ], + "properties": { + "chain_id": { + "type": "string" + }, + "height": { + "description": "The height of a block is the number of blocks preceding it in the blockchain.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "time": { + "description": "Absolute time of the block creation in seconds since the UNIX epoch (00:00:00 on 1970-01-01 UTC).\n\nThe source of this is the [BFT Time in Tendermint](https://github.com/tendermint/tendermint/blob/58dc1726/spec/consensus/bft-time.md), which has the same nanosecond precision as the `Timestamp` type.\n\n# Examples\n\nUsing chrono:\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; # extern crate chrono; use chrono::NaiveDateTime; let seconds = env.block.time.seconds(); let nsecs = env.block.time.subsec_nanos(); let dt = NaiveDateTime::from_timestamp(seconds as i64, nsecs as u32); ```\n\nCreating a simple millisecond-precision timestamp (as used in JavaScript):\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; let millis = env.block.time.nanos() / 1_000_000; ```", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + } + } + }, "Denom": { "oneOf": [ { @@ -1141,39 +1260,29 @@ "description": "the state of a denom's reward distribution", "type": "object", "required": [ + "active_epoch_config", "denom", - "emission_rate", - "ends_at", "funded_amount", + "historic_epoch_configs", "hook_caller", "last_update", - "started_at", - "total_earned_puvp", "vp_contract", "withdraw_destination" ], "properties": { - "denom": { - "description": "validated denom (native or cw20)", - "allOf": [ - { - "$ref": "#/definitions/Denom" - } - ] - }, - "emission_rate": { - "description": "reward emission rate", + "active_epoch_config": { + "description": "current denom distribution epoch configuration", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/EpochConfig" } ] }, - "ends_at": { - "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "denom": { + "description": "validated denom (native or cw20)", "allOf": [ { - "$ref": "#/definitions/Expiration" + "$ref": "#/definitions/Denom" } ] }, @@ -1185,6 +1294,13 @@ } ] }, + "historic_epoch_configs": { + "description": "historic denom distribution epochs", + "type": "array", + "items": { + "$ref": "#/definitions/EpochConfig" + } + }, "hook_caller": { "description": "address that will update the reward split when the voting power distribution changes", "allOf": [ @@ -1201,22 +1317,6 @@ } ] }, - "started_at": { - "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", - "allOf": [ - { - "$ref": "#/definitions/Expiration" - } - ] - }, - "total_earned_puvp": { - "description": "total rewards earned per unit voting power from started_at to last_update", - "allOf": [ - { - "$ref": "#/definitions/Uint256" - } - ] - }, "vp_contract": { "description": "address to query the voting power", "allOf": [ @@ -1270,6 +1370,61 @@ } ] }, + "EpochConfig": { + "type": "object", + "required": [ + "emission_rate", + "ends_at", + "started_at", + "total_earned_puvp" + ], + "properties": { + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "finish_height": { + "description": "finish block height", + "anyOf": [ + { + "$ref": "#/definitions/BlockInfo" + }, + { + "type": "null" + } + ] + }, + "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" + } + ] + } + }, + "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": [ diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index b78965c63..161013d14 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -194,19 +194,20 @@ fn execute_shutdown( let reward_duration_scalar = ends_at - started_at; // find the % of reward_duration that remains from current block - let passed_units_since_start = match reward_state.active_epoch_config.emission_rate.duration { - Duration::Height(_) => Uint128::from(env.block.height - started_at), - Duration::Time(_) => Uint128::from(env.block.time.seconds() - started_at), - }; + let passed_scalar_units_since_start = + match reward_state.active_epoch_config.emission_rate.duration { + Duration::Height(_) => env.block.height - started_at, + Duration::Time(_) => env.block.time.seconds() - started_at, + }; // get the fraction of what part of rewards duration is in the past // and sub from 1 to get the remaining rewards let remaining_reward_duration_fraction = Decimal::one() .checked_sub(Decimal::from_ratio( - passed_units_since_start, + passed_scalar_units_since_start, reward_duration_scalar, )) - .map_err(|e| ContractError::Std(StdError::overflow(e)))?; + .map_err(|e| ContractError::Std(e.into()))?; // to get the clawback msg let clawback_msg = get_transfer_msg( From 9fedb85fc8dd15dafc870053f81e24a54c18e46f Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 15:16:18 -0400 Subject: [PATCH 11/39] renamed some stuff and cleaned up --- .../schema/dao-rewards-distributor.json | 8 +- .../dao-rewards-distributor/src/contract.rs | 8 +- .../dao-rewards-distributor/src/hooks.rs | 2 +- .../dao-rewards-distributor/src/state.rs | 87 +++++++++---------- 4 files changed, 50 insertions(+), 55 deletions(-) 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 3b2a1aef0..4062bd023 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -896,8 +896,8 @@ } ] }, - "finish_height": { - "description": "finish block height", + "finish_block": { + "description": "finish block set when epoch is over", "anyOf": [ { "$ref": "#/definitions/BlockInfo" @@ -1395,8 +1395,8 @@ } ] }, - "finish_height": { - "description": "finish block height", + "finish_block": { + "description": "finish block set when epoch is over", "anyOf": [ { "$ref": "#/definitions/BlockInfo" diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 161013d14..711344a6e 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -137,7 +137,7 @@ fn execute_register_reward_denom( ends_at: Expiration::Never {}, emission_rate: msg.emission_rate, total_earned_puvp: Uint256::zero(), - finish_height: None, + finish_block: None, }, last_update: Expiration::Never {}, vp_contract, @@ -532,8 +532,8 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult StdResult = Map::new("d_r_s") /// map registered hooks to list of denoms they're registered for pub const REGISTERED_HOOK_DENOMS: Map> = Map::new("r_h_d"); - #[cw_serde] #[derive(Default)] pub struct UserRewardState { @@ -39,12 +38,12 @@ pub struct EpochConfig { /// total rewards earned per unit voting power from started_at to /// last_update pub total_earned_puvp: Uint256, - /// finish block height - pub finish_height: Option, + /// finish block set when epoch is over + pub finish_block: Option, } impl EpochConfig { - pub fn get_total_distributed_rewards(&self) -> Result { + pub fn get_total_distributed_rewards(&self) -> StdResult { let epoch_duration = get_start_end_diff(&self.started_at, &self.ends_at)?; let emission_rate_duration_scalar = match self.emission_rate.duration { @@ -55,7 +54,7 @@ impl EpochConfig { self.emission_rate .amount .checked_multiply_ratio(epoch_duration, emission_rate_duration_scalar) - .map_err(|e| ContractError::Std(StdError::generic_err(e.to_string()))) + .map_err(|e| StdError::generic_err(e.to_string())) } } @@ -82,27 +81,30 @@ pub struct DenomRewardState { } impl DenomRewardState { - pub fn get_historic_epoch_puvp_sum(&self) -> Uint256 { + /// Sum all historical total_earned_puvp values. + pub fn get_historic_rewards_earned_puvp_sum(&self) -> Uint256 { self.historic_epoch_configs .iter() .fold(Uint256::zero(), |acc, epoch| acc + epoch.total_earned_puvp) } + /// Finish current epoch early and start a new one with a new emission rate. pub fn transition_epoch( &mut self, new_emission_rate: RewardEmissionRate, current_block: &BlockInfo, ) -> StdResult<()> { - let current_block_scalar = match self.active_epoch_config.emission_rate.duration { + let current_block_expiration = match self.active_epoch_config.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), Duration::Time(_) => Expiration::AtTime(current_block.time), }; - // 1. finish current epoch + // 1. finish current epoch by changing the end to now let mut curr_epoch = self.active_epoch_config.clone(); - curr_epoch.ends_at = current_block_scalar; - curr_epoch.finish_height = Some(current_block.to_owned()); + curr_epoch.ends_at = current_block_expiration; + curr_epoch.finish_block = Some(current_block.to_owned()); + // TODO: remove println println!("transition_epoch: {:?}", curr_epoch); // 2. push current epoch to historic configs self.historic_epoch_configs.push(curr_epoch.clone()); @@ -111,56 +113,49 @@ impl DenomRewardState { // as those rewards are no longer available for distribution let curr_epoch_earned_rewards = match curr_epoch.emission_rate.amount.is_zero() { true => Uint128::zero(), - false => self - .active_epoch_config - .get_total_distributed_rewards() - .map_err(|e| StdError::generic_err(e.to_string()))?, + false => self.active_epoch_config.get_total_distributed_rewards()?, }; - self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; + // 4. start new epoch + // TODO: remove println println!("fund amount: {:?}", self.funded_amount); + // TODO: remove println println!("new_emission_rate: {:?}", new_emission_rate); + + // 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_epoch_end_scalar = - self.calculate_ends_at(&new_emission_rate, self.funded_amount, current_block)?; + match new_emission_rate.get_funded_period_duration(self.funded_amount)? { + Duration::Height(h) => { + if current_block.height.checked_add(h).is_some() { + Expiration::AtHeight(current_block.height + h) + } else { + Expiration::AtHeight(u64::MAX) + } + } + 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)) + } + } + }; + self.active_epoch_config = EpochConfig { emission_rate: new_emission_rate.clone(), - started_at: current_block_scalar, + started_at: current_block_expiration, ends_at: new_epoch_end_scalar, - // start the new active epoch with zero puvp + // start the new active epoch with zero rewards earned total_earned_puvp: Uint256::zero(), - finish_height: None, + finish_block: None, }; Ok(()) } - - fn calculate_ends_at( - &self, - emission_rate: &RewardEmissionRate, - funded_amount: Uint128, - current_block: &BlockInfo, - ) -> StdResult { - // 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. - match emission_rate.get_funded_period_duration(funded_amount)? { - Duration::Height(h) => { - if current_block.height.checked_add(h).is_some() { - Ok(Expiration::AtHeight(current_block.height + h)) - } else { - Ok(Expiration::AtHeight(u64::MAX)) - } - } - Duration::Time(t) => { - if current_block.time.seconds().checked_add(t).is_some() { - Ok(Expiration::AtTime(current_block.time.plus_seconds(t))) - } else { - Ok(Expiration::AtTime(Timestamp::from_seconds(u64::MAX))) - } - } - } - } } impl DenomRewardState { From edd01766f3d09013b3a9c8afe3b82fa9d32edab1 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 15:24:21 -0400 Subject: [PATCH 12/39] fixed some comments --- .../schema/dao-rewards-distributor.json | 42 ++++++++++++++++--- .../dao-rewards-distributor/src/msg.rs | 8 +++- 2 files changed, 43 insertions(+), 7 deletions(-) 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 4062bd023..101c57b7e 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -488,10 +488,20 @@ ], "properties": { "amount": { - "$ref": "#/definitions/Uint128" + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] }, "duration": { - "$ref": "#/definitions/Duration" + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] } }, "additionalProperties": false @@ -982,10 +992,20 @@ ], "properties": { "amount": { - "$ref": "#/definitions/Uint128" + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] }, "duration": { - "$ref": "#/definitions/Duration" + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] } }, "additionalProperties": false @@ -1481,10 +1501,20 @@ ], "properties": { "amount": { - "$ref": "#/definitions/Uint128" + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] }, "duration": { - "$ref": "#/definitions/Duration" + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] } }, "additionalProperties": false diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 7be648c9d..4d07ae065 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -64,12 +64,17 @@ pub struct RegisterRewardDenomMsg { /// (duration). e.g. 5udenom per hour. #[cw_serde] pub struct RewardEmissionRate { + /// amount of tokens to distribute per amount of time pub amount: Uint128, + /// duration of time to distribute amount pub duration: Duration, } impl RewardEmissionRate { - // find the duration of the funded period given emission config and funded amount + // 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 { // if amount being distributed is 0 (rewards are paused), we return the max duration if self.amount.is_zero() { @@ -78,6 +83,7 @@ impl RewardEmissionRate { Duration::Time(_) => Ok(Duration::Time(u64::MAX)), }; } + let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, self.amount); let funded_duration = match self.duration { From 675055ed01454b297c3773e183f30b8432b9f24d Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 15:27:54 -0400 Subject: [PATCH 13/39] updated comment and renamed function --- contracts/distribution/dao-rewards-distributor/src/state.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 51fbceb87..0679fe27b 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -43,7 +43,9 @@ pub struct EpochConfig { } impl EpochConfig { - pub fn get_total_distributed_rewards(&self) -> StdResult { + /// get the total rewards to be distributed based on the emission rate and + /// duration from start to end + pub fn get_total_rewards(&self) -> StdResult { let epoch_duration = get_start_end_diff(&self.started_at, &self.ends_at)?; let emission_rate_duration_scalar = match self.emission_rate.duration { @@ -113,7 +115,7 @@ impl DenomRewardState { // as those rewards are no longer available for distribution let curr_epoch_earned_rewards = match curr_epoch.emission_rate.amount.is_zero() { true => Uint128::zero(), - false => self.active_epoch_config.get_total_distributed_rewards()?, + false => self.active_epoch_config.get_total_rewards()?, }; self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; From 6605e2775a59760e4afa8a90274a79e6af553ae2 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 15:48:50 -0400 Subject: [PATCH 14/39] fixed epoch calculation --- contracts/distribution/dao-rewards-distributor/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 0679fe27b..8fdb1e813 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -115,7 +115,7 @@ impl DenomRewardState { // as those rewards are no longer available for distribution let curr_epoch_earned_rewards = match curr_epoch.emission_rate.amount.is_zero() { true => Uint128::zero(), - false => self.active_epoch_config.get_total_rewards()?, + false => curr_epoch.get_total_rewards()?, }; self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; From 15b87e8e1554b7b3d016a59db06ed5ace529626d Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 16:37:51 -0400 Subject: [PATCH 15/39] rearranged some code --- .../dao-rewards-distributor/src/contract.rs | 42 ++++------- .../dao-rewards-distributor/src/hooks.rs | 74 +------------------ .../dao-rewards-distributor/src/lib.rs | 1 + .../dao-rewards-distributor/src/rewards.rs | 68 +++++++++++++++++ 4 files changed, 86 insertions(+), 99 deletions(-) create mode 100644 contracts/distribution/dao-rewards-distributor/src/rewards.rs diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 711344a6e..9c60dc0c1 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -18,12 +18,13 @@ use std::convert::TryInto; use crate::hooks::{ execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, - subscribe_denom_to_hook, update_rewards, + subscribe_denom_to_hook, }; use crate::msg::{ ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RegisterRewardDenomMsg, RewardEmissionRate, RewardsStateResponse, }; +use crate::rewards::update_rewards; use crate::state::{ DenomRewardState, EpochConfig, UserRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES, }; @@ -85,13 +86,8 @@ fn execute_update_reward_rate( cw_ownable::assert_owner(deps.storage, &info.sender)?; let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; - reward_state.active_epoch_config.total_earned_puvp = get_total_earned_puvp( - deps.as_ref(), - &env.block, - &reward_state.active_epoch_config, - &reward_state.vp_contract, - &reward_state.last_update, - )?; + reward_state.active_epoch_config.total_earned_puvp = + get_total_earned_puvp(deps.as_ref(), &env.block, &reward_state)?; reward_state.bump_last_update(&env.block); // transition the epoch to the new emission rate and save @@ -371,18 +367,16 @@ fn execute_update_owner( pub fn get_total_earned_puvp( deps: Deps, block: &BlockInfo, - epoch: &EpochConfig, - vp_contract: &Addr, - last_update: &Expiration, + reward_state: &DenomRewardState, ) -> StdResult { - let curr = epoch.total_earned_puvp; + let curr = reward_state.active_epoch_config.total_earned_puvp; - let prev_total_power = get_prev_block_total_vp(deps, block, vp_contract)?; + let prev_total_power = get_prev_block_total_vp(deps, block, &reward_state.vp_contract)?; // if epoch is past, we return the epoch end date. otherwise the specified block. // returns time scalar based on the epoch date config. - let last_time_rewards_distributed = match epoch.ends_at { - Expiration::Never {} => *last_update, + let last_time_rewards_distributed = match reward_state.active_epoch_config.ends_at { + Expiration::Never {} => reward_state.last_update, Expiration::AtHeight(h) => Expiration::AtHeight(min(block.height, h)), Expiration::AtTime(t) => Expiration::AtTime(min(block.time, t)), }; @@ -391,19 +385,21 @@ pub fn get_total_earned_puvp( // 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_start_end_diff(&last_time_rewards_distributed, last_update)?.into(); + get_start_end_diff(&last_time_rewards_distributed, &reward_state.last_update)?.into(); 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(&epoch.emission_rate.duration).into())?; + let complete_distribution_periods = new_reward_distribution_duration.checked_div( + get_duration_scalar(&reward_state.active_epoch_config.emission_rate.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 = epoch + let new_rewards_distributed = reward_state + .active_epoch_config .emission_rate .amount .full_mul(complete_distribution_periods) @@ -536,13 +532,7 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult StdResult<()> { - // 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_REWARD_STATES - .may_load(deps.storage, addr.clone())? - .unwrap_or_default(); - let mut denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; - - // first we go over the historic epochs and sum the historic puvp - let total_historic_puvp = denom_reward_state.get_historic_rewards_earned_puvp_sum(); - - // we update the active epoch earned puvp value, from it's start to the current block - denom_reward_state.active_epoch_config.total_earned_puvp = get_total_earned_puvp( - deps.as_ref(), - &env.block, - &denom_reward_state.active_epoch_config, - &denom_reward_state.vp_contract, - &denom_reward_state.last_update, - )?; - - // the total applicable puvp is the sum of all historic puvp and the active epoch puvp - let total_applicable_puvp = denom_reward_state - .active_epoch_config - .total_earned_puvp - .checked_add(total_historic_puvp)?; - - denom_reward_state.bump_last_update(&env.block); - - let earned_rewards = get_accrued_rewards_since_last_user_action( - deps.as_ref(), - env, - addr, - total_applicable_puvp, - &denom_reward_state.vp_contract, - denom.to_string(), - &user_reward_state, - )?; - - let earned_amount = earned_rewards.amount; - - // get the pre-existing pending reward amount for the denom - let previous_pending_denom_reward_amount = user_reward_state - .pending_denom_rewards - .get(&denom) - .cloned() - .unwrap_or_default(); - - let amount_sum = earned_amount.checked_add(previous_pending_denom_reward_amount)?; - - // get the amount of newly earned rewards for the denom - user_reward_state - .pending_denom_rewards - .insert(denom.clone(), amount_sum); - - // update the accounted for amount to that of the total applicable puvp - user_reward_state - .accounted_denom_rewards_puvp - .insert(denom.clone(), total_applicable_puvp); - - // reflect the updated state changes - USER_REWARD_STATES.save(deps.storage, addr.clone(), &user_reward_state)?; - DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &denom_reward_state)?; - - Ok(()) -} +use crate::{rewards::update_rewards, state::REGISTERED_HOOK_DENOMS, ContractError}; /// Register a hook caller contract for a given denom. pub(crate) fn subscribe_denom_to_hook( diff --git a/contracts/distribution/dao-rewards-distributor/src/lib.rs b/contracts/distribution/dao-rewards-distributor/src/lib.rs index 51ae5c619..a34f8e23c 100644 --- a/contracts/distribution/dao-rewards-distributor/src/lib.rs +++ b/contracts/distribution/dao-rewards-distributor/src/lib.rs @@ -4,6 +4,7 @@ pub mod contract; mod error; pub mod hooks; pub mod msg; +pub mod rewards; pub mod state; #[cfg(test)] 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..5d4310ec4 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -0,0 +1,68 @@ +use cosmwasm_std::{Addr, DepsMut, Env, StdResult}; + +use crate::{ + contract::{get_accrued_rewards_since_last_user_action, get_total_earned_puvp}, + state::{DENOM_REWARD_STATES, USER_REWARD_STATES}, +}; + +/// updates the user reward state for a given denom and user address. +/// also syncs the global denom reward state config with the latest puvp values. +pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) -> StdResult<()> { + // 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_REWARD_STATES + .may_load(deps.storage, addr.clone())? + .unwrap_or_default(); + let mut denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; + + // first we go over the historic epochs and sum the historic rewards earned + let total_historic_puvp = denom_reward_state.get_historic_rewards_earned_puvp_sum(); + + // we update the active epoch earned puvp value, from start to the current block + denom_reward_state.active_epoch_config.total_earned_puvp = + get_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; + denom_reward_state.bump_last_update(&env.block); + + // the total applicable puvp is the sum of all historic puvp and the active epoch puvp + let total_applicable_puvp = denom_reward_state + .active_epoch_config + .total_earned_puvp + .checked_add(total_historic_puvp)?; + + let earned_rewards = get_accrued_rewards_since_last_user_action( + deps.as_ref(), + env, + addr, + total_applicable_puvp, + &denom_reward_state.vp_contract, + denom.to_string(), + &user_reward_state, + )?; + + let earned_amount = earned_rewards.amount; + + // get the pre-existing pending reward amount for the denom + let previous_pending_denom_reward_amount = user_reward_state + .pending_denom_rewards + .get(&denom) + .cloned() + .unwrap_or_default(); + + let amount_sum = earned_amount.checked_add(previous_pending_denom_reward_amount)?; + + // get the amount of newly earned rewards for the denom + user_reward_state + .pending_denom_rewards + .insert(denom.clone(), amount_sum); + + // update the accounted for amount to that of the total applicable puvp + user_reward_state + .accounted_denom_rewards_puvp + .insert(denom.clone(), total_applicable_puvp); + + // reflect the updated state changes + USER_REWARD_STATES.save(deps.storage, addr.clone(), &user_reward_state)?; + DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &denom_reward_state)?; + + Ok(()) +} From 3579170e6d8958f10ceaa6038f2b8e058373f71b Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 17:05:44 -0400 Subject: [PATCH 16/39] reorganized more code --- .../dao-rewards-distributor/src/contract.rs | 219 ++---------------- .../dao-rewards-distributor/src/helpers.rs | 112 +++++++++ .../dao-rewards-distributor/src/lib.rs | 1 + .../dao-rewards-distributor/src/rewards.rs | 99 +++++++- .../dao-rewards-distributor/src/state.rs | 21 +- 5 files changed, 231 insertions(+), 221 deletions(-) create mode 100644 contracts/distribution/dao-rewards-distributor/src/helpers.rs diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 9c60dc0c1..aa0b6e1f3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,21 +1,17 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - coin, coins, ensure, from_json, to_json_binary, Addr, BankMsg, Binary, BlockInfo, Coin, - CosmosMsg, Decimal, Deps, DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, - Uint128, Uint256, WasmMsg, + ensure, from_json, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Uint128, Uint256, }; use cw2::{get_contract_version, set_contract_version}; -use cw20::{Cw20ReceiveMsg, Denom}; +use cw20::Cw20ReceiveMsg; use cw_utils::{one_coin, Duration, Expiration}; -use dao_interface::voting::{ - InfoResponse, Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, -}; +use dao_interface::voting::InfoResponse; -use std::cmp::min; use std::collections::HashMap; -use std::convert::TryInto; +use crate::helpers::{get_duration_scalar, get_transfer_msg, validate_voting_power_contract}; use crate::hooks::{ execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, subscribe_denom_to_hook, @@ -24,10 +20,10 @@ use crate::msg::{ ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RegisterRewardDenomMsg, RewardEmissionRate, RewardsStateResponse, }; -use crate::rewards::update_rewards; -use crate::state::{ - DenomRewardState, EpochConfig, UserRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES, +use crate::rewards::{ + get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, }; +use crate::state::{DenomRewardState, EpochConfig, DENOM_REWARD_STATES, USER_REWARD_STATES}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); @@ -87,7 +83,7 @@ fn execute_update_reward_rate( let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; reward_state.active_epoch_config.total_earned_puvp = - get_total_earned_puvp(deps.as_ref(), &env.block, &reward_state)?; + get_active_total_earned_puvp(deps.as_ref(), &env.block, &reward_state)?; reward_state.bump_last_update(&env.block); // transition the epoch to the new emission rate and save @@ -362,127 +358,6 @@ fn execute_update_owner( Ok(Response::default().add_attributes(ownership.into_attributes())) } -/// Calculate the total rewards earned per unit voting power since the last -/// update. -pub fn get_total_earned_puvp( - deps: Deps, - block: &BlockInfo, - reward_state: &DenomRewardState, -) -> StdResult { - let curr = reward_state.active_epoch_config.total_earned_puvp; - - let prev_total_power = get_prev_block_total_vp(deps, block, &reward_state.vp_contract)?; - - // if epoch is past, we return the epoch end date. otherwise the specified block. - // returns time scalar based on the epoch date config. - let last_time_rewards_distributed = match reward_state.active_epoch_config.ends_at { - Expiration::Never {} => reward_state.last_update, - Expiration::AtHeight(h) => Expiration::AtHeight(min(block.height, h)), - Expiration::AtTime(t) => Expiration::AtTime(min(block.time, t)), - }; - - // 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_start_end_diff(&last_time_rewards_distributed, &reward_state.last_update)?.into(); - - 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(&reward_state.active_epoch_config.emission_rate.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 = reward_state - .active_epoch_config - .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.checked_add(new_rewards_puvp)?) - } -} - -// get a user's rewards not yet accounted for in their reward states. -pub fn get_accrued_rewards_since_last_user_action( - deps: Deps, - env: &Env, - addr: &Addr, - total_earned_puvp: Uint256, - vp_contract: &Addr, - denom: String, - user_reward_state: &UserRewardState, -) -> StdResult { - // get previous reward per unit voting power accounted for - let user_last_reward_puvp = user_reward_state - .accounted_denom_rewards_puvp - .get(&denom) - .cloned() - .unwrap_or_default(); - - // calculate the difference between the current total reward per unit - // voting power distributed and the user's latest reward per unit voting - // power accounted for. - let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; - // get the user's voting power at the current height - let voting_power: Uint256 = - get_voting_power_at_block(deps, &env.block, vp_contract, addr)?.into(); - - // 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(coin(accrued_rewards_amount.u128(), denom)) -} - -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) -} - -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. -fn get_duration_scalar(duration: &Duration) -> u64 { - match duration { - Duration::Height(h) => *h, - Duration::Time(t) => *t, - } -} - #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { @@ -532,24 +407,27 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult StdResult 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. -pub fn get_start_end_diff(end: &Expiration, start: &Expiration) -> StdResult { - match (end, start) { - (Expiration::AtHeight(end), Expiration::AtHeight(start)) => { - if end > start { - Ok(end - start) - } else { - Ok(0) - } - } - (Expiration::AtTime(end), Expiration::AtTime(start)) => { - if end > start { - Ok(end.seconds() - start.seconds()) - } else { - Ok(0) - } - } - (Expiration::Never {}, Expiration::Never {}) => Ok(0), - _ => Err(StdError::generic_err(format!( - "incompatible expirations: got end {:?}, start {:?}", - end, start - ))), - } -} - -fn validate_voting_power_contract( - deps: &DepsMut, - vp_contract: String, -) -> Result { - let vp_contract = deps.api.addr_validate(&vp_contract)?; - let _: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart( - &vp_contract, - &VotingQueryMsg::TotalPowerAtHeight { height: None }, - )?; - Ok(vp_contract) -} diff --git a/contracts/distribution/dao-rewards-distributor/src/helpers.rs b/contracts/distribution/dao-rewards-distributor/src/helpers.rs new file mode 100644 index 000000000..a2af35611 --- /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. +pub 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 + ))), + } +} + +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/lib.rs b/contracts/distribution/dao-rewards-distributor/src/lib.rs index a34f8e23c..8226f57a9 100644 --- a/contracts/distribution/dao-rewards-distributor/src/lib.rs +++ b/contracts/distribution/dao-rewards-distributor/src/lib.rs @@ -2,6 +2,7 @@ pub mod contract; mod error; +pub mod helpers; pub mod hooks; pub mod msg; pub mod rewards; diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index 5d4310ec4..5e2cbc4f3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -1,8 +1,11 @@ -use cosmwasm_std::{Addr, DepsMut, Env, StdResult}; +use cosmwasm_std::{coin, Addr, BlockInfo, Coin, Deps, DepsMut, Env, StdResult, Uint128, Uint256}; use crate::{ - contract::{get_accrued_rewards_since_last_user_action, get_total_earned_puvp}, - state::{DENOM_REWARD_STATES, USER_REWARD_STATES}, + helpers::{ + get_duration_scalar, get_prev_block_total_vp, get_start_end_diff, + get_voting_power_at_block, scale_factor, + }, + state::{DenomRewardState, UserRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}, }; /// updates the user reward state for a given denom and user address. @@ -18,9 +21,9 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) // first we go over the historic epochs and sum the historic rewards earned let total_historic_puvp = denom_reward_state.get_historic_rewards_earned_puvp_sum(); - // we update the active epoch earned puvp value, from start to the current block + // we update the active epoch earned puvp value up to the current block denom_reward_state.active_epoch_config.total_earned_puvp = - get_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; + get_active_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; denom_reward_state.bump_last_update(&env.block); // the total applicable puvp is the sum of all historic puvp and the active epoch puvp @@ -39,8 +42,6 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) &user_reward_state, )?; - let earned_amount = earned_rewards.amount; - // get the pre-existing pending reward amount for the denom let previous_pending_denom_reward_amount = user_reward_state .pending_denom_rewards @@ -48,7 +49,9 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) .cloned() .unwrap_or_default(); - let amount_sum = earned_amount.checked_add(previous_pending_denom_reward_amount)?; + let amount_sum = earned_rewards + .amount + .checked_add(previous_pending_denom_reward_amount)?; // get the amount of newly earned rewards for the denom user_reward_state @@ -66,3 +69,83 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) Ok(()) } + +/// Calculate the total rewards earned per unit voting power in the active epoch +/// since the last update. +pub fn get_active_total_earned_puvp( + deps: Deps, + block: &BlockInfo, + reward_state: &DenomRewardState, +) -> StdResult { + let curr = reward_state.active_epoch_config.total_earned_puvp; + + let prev_total_power = get_prev_block_total_vp(deps, block, &reward_state.vp_contract)?; + + let last_time_rewards_distributed = reward_state.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_start_end_diff(&last_time_rewards_distributed, &reward_state.last_update)?.into(); + + 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(&reward_state.active_epoch_config.emission_rate.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 = reward_state + .active_epoch_config + .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.checked_add(new_rewards_puvp)?) + } +} + +// get a user's rewards not yet accounted for in their reward states. +pub fn get_accrued_rewards_since_last_user_action( + deps: Deps, + env: &Env, + addr: &Addr, + total_earned_puvp: Uint256, + vp_contract: &Addr, + denom: String, + 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, vp_contract, addr)?.into(); + + // get previous reward per unit voting power accounted for + let user_last_reward_puvp = user_reward_state + .accounted_denom_rewards_puvp + .get(&denom) + .cloned() + .unwrap_or_default(); + + // calculate the difference between the current total reward per unit + // voting power distributed and the user's latest reward per unit voting + // power accounted for. + let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; + + // calculate the amount of rewards earned: + // voting_power * reward_factor / scale_factor + let accrued_rewards_amount: Uint128 = voting_power + .checked_mul(reward_factor)? + .checked_div(scale_factor())? + .try_into()?; + + Ok(coin(accrued_rewards_amount.u128(), denom)) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 8fdb1e813..7790a90eb 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -5,7 +5,7 @@ use cw_storage_plus::Map; use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; -use crate::{contract::get_start_end_diff, msg::RewardEmissionRate, ContractError}; +use crate::{helpers::get_start_end_diff, msg::RewardEmissionRate, ContractError}; /// map user address to their unique reward state pub const USER_REWARD_STATES: Map = Map::new("u_r_s"); @@ -21,7 +21,7 @@ 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 (claimed). + /// have already been accounted for (added to pending and maybe claimed). pub accounted_denom_rewards_puvp: HashMap, } @@ -226,19 +226,20 @@ impl DenomRewardState { /// 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.active_epoch_config.ends_at { - Expiration::Never {} => Expiration::Never {}, + Expiration::Never {} => self.last_update, Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), } From 72694c0ec6616cf38be626c12bef3b39631933c3 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 18 Jul 2024 17:07:01 -0400 Subject: [PATCH 17/39] updated README --- contracts/distribution/dao-rewards-distributor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index 6aa7b007b..f0fe33151 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -22,7 +22,7 @@ to be the address instantiating the contract. After instantiating the contract it is VITAL to setup the required hooks for it to work. This is because to pay out rewards accurately, this contract needs to know about staking or voting power changes in the DAO. -This can be achieved using the `add_hook` method on contracts that support voting power changes, which are: +This can be achieved using the `add_hook` method on contracts that support voting power changes, such as: - `cw4-group` - `dao-voting-cw721-staked` From b8e7f6e693439d689234dfdfd42b90621401f940 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 19 Jul 2024 15:09:24 -0400 Subject: [PATCH 18/39] renamed epoch config to epoch --- .../dao-rewards-distributor/README.md | 2 +- .../schema/dao-rewards-distributor.json | 32 ++++---- .../dao-rewards-distributor/src/contract.rs | 82 +++++++++---------- .../dao-rewards-distributor/src/rewards.rs | 10 +-- .../dao-rewards-distributor/src/state.rs | 54 ++++++------ .../src/testing/suite.rs | 27 +++--- 6 files changed, 96 insertions(+), 111 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index f0fe33151..0210ab615 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -78,7 +78,7 @@ Updating the denom reward emission rate archives the active reward epoch and sta First, the currently active epoch is evaluated. We find the amount of tokens that were earned to this point per unit of voting power and save that in the current epoch as its total earned rewards per unit of voting power. We then bump the last update with that of the current block, and transition into the new epoch. -Active reward epoch is moved into the `historic_epoch_configs`. This is a list of previously active reward emission schedules, along with their finalized amounts. +Active reward epoch is moved into the `historic_epochs`. This is a list of previously active reward emission schedules, along with their finalized amounts. ### Shutting down denom distribution 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 101c57b7e..e95426aac 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -712,21 +712,21 @@ "description": "the state of a denom's reward distribution", "type": "object", "required": [ - "active_epoch_config", + "active_epoch", "denom", "funded_amount", - "historic_epoch_configs", + "historic_epochs", "hook_caller", "last_update", "vp_contract", "withdraw_destination" ], "properties": { - "active_epoch_config": { - "description": "current denom distribution epoch configuration", + "active_epoch": { + "description": "current denom distribution epoch state", "allOf": [ { - "$ref": "#/definitions/EpochConfig" + "$ref": "#/definitions/Epoch" } ] }, @@ -746,11 +746,11 @@ } ] }, - "historic_epoch_configs": { + "historic_epochs": { "description": "historic denom distribution epochs", "type": "array", "items": { - "$ref": "#/definitions/EpochConfig" + "$ref": "#/definitions/Epoch" } }, "hook_caller": { @@ -881,7 +881,7 @@ } ] }, - "EpochConfig": { + "Epoch": { "type": "object", "required": [ "emission_rate", @@ -1280,21 +1280,21 @@ "description": "the state of a denom's reward distribution", "type": "object", "required": [ - "active_epoch_config", + "active_epoch", "denom", "funded_amount", - "historic_epoch_configs", + "historic_epochs", "hook_caller", "last_update", "vp_contract", "withdraw_destination" ], "properties": { - "active_epoch_config": { - "description": "current denom distribution epoch configuration", + "active_epoch": { + "description": "current denom distribution epoch state", "allOf": [ { - "$ref": "#/definitions/EpochConfig" + "$ref": "#/definitions/Epoch" } ] }, @@ -1314,11 +1314,11 @@ } ] }, - "historic_epoch_configs": { + "historic_epochs": { "description": "historic denom distribution epochs", "type": "array", "items": { - "$ref": "#/definitions/EpochConfig" + "$ref": "#/definitions/Epoch" } }, "hook_caller": { @@ -1390,7 +1390,7 @@ } ] }, - "EpochConfig": { + "Epoch": { "type": "object", "required": [ "emission_rate", diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index aa0b6e1f3..c8b7a0e48 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -23,7 +23,7 @@ use crate::msg::{ use crate::rewards::{ get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, }; -use crate::state::{DenomRewardState, EpochConfig, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::state::{DenomRewardState, Epoch, DENOM_REWARD_STATES, USER_REWARD_STATES}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); @@ -82,7 +82,7 @@ fn execute_update_reward_rate( cw_ownable::assert_owner(deps.storage, &info.sender)?; let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; - reward_state.active_epoch_config.total_earned_puvp = + reward_state.active_epoch.total_earned_puvp = get_active_total_earned_puvp(deps.as_ref(), &env.block, &reward_state)?; reward_state.bump_last_update(&env.block); @@ -124,7 +124,7 @@ fn execute_register_reward_denom( // Initialize the reward state let reward_state = DenomRewardState { denom: checked_denom, - active_epoch_config: EpochConfig { + active_epoch: Epoch { started_at: Expiration::Never {}, ends_at: Expiration::Never {}, emission_rate: msg.emission_rate, @@ -136,7 +136,7 @@ fn execute_register_reward_denom( hook_caller: hook_caller.clone(), funded_amount: Uint128::zero(), withdraw_destination, - historic_epoch_configs: vec![], + historic_epochs: vec![], }; let str_denom = reward_state.to_str_denom(); @@ -173,10 +173,7 @@ fn execute_shutdown( // shutdown is only possible during the distribution period ensure!( - !reward_state - .active_epoch_config - .ends_at - .is_expired(&env.block), + !reward_state.active_epoch.ends_at.is_expired(&env.block), ContractError::ShutdownError("Reward period already finished".to_string()) ); @@ -186,11 +183,10 @@ fn execute_shutdown( let reward_duration_scalar = ends_at - started_at; // find the % of reward_duration that remains from current block - let passed_scalar_units_since_start = - match reward_state.active_epoch_config.emission_rate.duration { - Duration::Height(_) => env.block.height - started_at, - Duration::Time(_) => env.block.time.seconds() - started_at, - }; + let passed_scalar_units_since_start = match reward_state.active_epoch.emission_rate.duration { + Duration::Height(_) => env.block.height - started_at, + Duration::Time(_) => env.block.time.seconds() - started_at, + }; // get the fraction of what part of rewards duration is in the past // and sub from 1 to get the remaining rewards @@ -209,11 +205,10 @@ fn execute_shutdown( )?; // shutdown completes the rewards - reward_state.active_epoch_config.ends_at = - match reward_state.active_epoch_config.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(env.block.height), - Duration::Time(_) => Expiration::AtTime(env.block.time), - }; + reward_state.active_epoch.ends_at = match reward_state.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }; DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; @@ -256,7 +251,7 @@ fn execute_fund( // 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 - .active_epoch_config + .active_epoch .emission_rate .get_funded_period_duration(amount)?; let funded_period_value = get_duration_scalar(&funded_period_duration); @@ -266,33 +261,32 @@ fn execute_fund( // the duration of rewards period is extended in different ways, // depending on the current expiration state and current block - denom_reward_state.active_epoch_config.ends_at = - match denom_reward_state.active_epoch_config.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) - } + denom_reward_state.active_epoch.ends_at = match denom_reward_state.active_epoch.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)) - } + } + Expiration::AtTime(t) => { + if t <= env.block.time { + // expiration is the funded duration after current block time + Expiration::AtTime(env.block.time.plus_seconds(funded_period_value)) + } else { + // if the previous expiration had not yet expired, we extend + // the current rewards period by the newly funded duration + Expiration::AtTime(t.plus_seconds(funded_period_value)) } - }; + } + }; denom_reward_state.funded_amount += amount; DENOM_REWARD_STATES.save( diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index 5e2cbc4f3..97381126a 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -22,13 +22,13 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) let total_historic_puvp = denom_reward_state.get_historic_rewards_earned_puvp_sum(); // we update the active epoch earned puvp value up to the current block - denom_reward_state.active_epoch_config.total_earned_puvp = + denom_reward_state.active_epoch.total_earned_puvp = get_active_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; denom_reward_state.bump_last_update(&env.block); // the total applicable puvp is the sum of all historic puvp and the active epoch puvp let total_applicable_puvp = denom_reward_state - .active_epoch_config + .active_epoch .total_earned_puvp .checked_add(total_historic_puvp)?; @@ -77,7 +77,7 @@ pub fn get_active_total_earned_puvp( block: &BlockInfo, reward_state: &DenomRewardState, ) -> StdResult { - let curr = reward_state.active_epoch_config.total_earned_puvp; + let curr = reward_state.active_epoch.total_earned_puvp; let prev_total_power = get_prev_block_total_vp(deps, block, &reward_state.vp_contract)?; @@ -95,13 +95,13 @@ pub fn get_active_total_earned_puvp( // 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(&reward_state.active_epoch_config.emission_rate.duration).into(), + get_duration_scalar(&reward_state.active_epoch.emission_rate.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 = reward_state - .active_epoch_config + .active_epoch .emission_rate .amount .full_mul(complete_distribution_periods) diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 7790a90eb..776f7e1e7 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -26,7 +26,7 @@ pub struct UserRewardState { } #[cw_serde] -pub struct EpochConfig { +pub struct Epoch { /// reward emission rate pub emission_rate: RewardEmissionRate, /// the time when the current reward distribution period started. period @@ -42,7 +42,7 @@ pub struct EpochConfig { pub finish_block: Option, } -impl EpochConfig { +impl Epoch { /// get the total rewards to be distributed based on the emission rate and /// duration from start to end pub fn get_total_rewards(&self) -> StdResult { @@ -65,8 +65,8 @@ impl EpochConfig { pub struct DenomRewardState { /// validated denom (native or cw20) pub denom: Denom, - /// current denom distribution epoch configuration - pub active_epoch_config: EpochConfig, + /// current denom distribution epoch state + pub active_epoch: Epoch, /// time when total_earned_puvp was last updated for this denom pub last_update: Expiration, /// address to query the voting power @@ -79,13 +79,13 @@ pub struct DenomRewardState { /// optional destination address for reward clawbacks pub withdraw_destination: Addr, /// historic denom distribution epochs - pub historic_epoch_configs: Vec, + pub historic_epochs: Vec, } impl DenomRewardState { /// Sum all historical total_earned_puvp values. pub fn get_historic_rewards_earned_puvp_sum(&self) -> Uint256 { - self.historic_epoch_configs + self.historic_epochs .iter() .fold(Uint256::zero(), |acc, epoch| acc + epoch.total_earned_puvp) } @@ -96,20 +96,20 @@ impl DenomRewardState { new_emission_rate: RewardEmissionRate, current_block: &BlockInfo, ) -> StdResult<()> { - let current_block_expiration = match self.active_epoch_config.emission_rate.duration { + let current_block_expiration = match self.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), Duration::Time(_) => Expiration::AtTime(current_block.time), }; // 1. finish current epoch by changing the end to now - let mut curr_epoch = self.active_epoch_config.clone(); + let mut curr_epoch = self.active_epoch.clone(); curr_epoch.ends_at = current_block_expiration; curr_epoch.finish_block = Some(current_block.to_owned()); // TODO: remove println println!("transition_epoch: {:?}", curr_epoch); // 2. push current epoch to historic configs - self.historic_epoch_configs.push(curr_epoch.clone()); + self.historic_epochs.push(curr_epoch.clone()); // 3. deduct the distributed rewards amount from total funded amount, // as those rewards are no longer available for distribution @@ -147,7 +147,7 @@ impl DenomRewardState { } }; - self.active_epoch_config = EpochConfig { + self.active_epoch = Epoch { emission_rate: new_emission_rate.clone(), started_at: current_block_expiration, ends_at: new_epoch_end_scalar, @@ -162,7 +162,7 @@ impl DenomRewardState { impl DenomRewardState { pub fn bump_last_update(&mut self, current_block: &BlockInfo) { - self.last_update = match self.active_epoch_config.emission_rate.duration { + self.last_update = match self.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), Duration::Time(_) => Expiration::AtTime(current_block.time), }; @@ -174,22 +174,20 @@ impl DenomRewardState { /// funding date becomes the current block. pub fn bump_funding_date(&mut self, current_block: &BlockInfo) { // if its never been set before, we set it to current block and return - if let Expiration::Never {} = self.active_epoch_config.started_at { - self.active_epoch_config.started_at = - match self.active_epoch_config.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), - }; + if let Expiration::Never {} = self.active_epoch.started_at { + self.active_epoch.started_at = match self.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; } // if current distribution is expired, we set the funding date // to the current date - if self.active_epoch_config.ends_at.is_expired(current_block) { - self.active_epoch_config.started_at = - match self.active_epoch_config.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), - }; + if self.active_epoch.ends_at.is_expired(current_block) { + self.active_epoch.started_at = match self.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; } } @@ -205,7 +203,7 @@ impl DenomRewardState { /// - 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.active_epoch_config.ends_at { + match self.active_epoch.ends_at { Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), Expiration::AtHeight(h) => Ok(h), Expiration::AtTime(t) => Ok(t.seconds()), @@ -217,7 +215,7 @@ impl DenomRewardState { /// - 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.active_epoch_config.started_at { + match self.active_epoch.started_at { Expiration::AtHeight(h) => Ok(h), Expiration::AtTime(t) => Ok(t.seconds()), Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), @@ -238,7 +236,7 @@ impl DenomRewardState { /// 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.active_epoch_config.ends_at { + match self.active_epoch.ends_at { Expiration::Never {} => self.last_update, Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), @@ -252,10 +250,10 @@ impl DenomRewardState { &self, current_block: &BlockInfo, ) -> Result<(), ContractError> { - match self.active_epoch_config.ends_at { + match self.active_epoch.ends_at { Expiration::AtHeight(_) | Expiration::AtTime(_) => { ensure!( - self.active_epoch_config.ends_at.is_expired(current_block), + self.active_epoch.ends_at.is_expired(current_block), ContractError::RewardPeriodNotFinished {} ); Ok(()) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index d4ccb4747..61d3d1aef 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -322,14 +322,12 @@ impl Suite { pub fn get_time_until_rewards_expiration(&mut self) -> u64 { let rewards_state_response = self.get_rewards_state_response(); let current_block = self.app.block_info(); - let (expiration_unit, current_unit) = match rewards_state_response.rewards[0] - .active_epoch_config - .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, - }; + let (expiration_unit, current_unit) = + match rewards_state_response.rewards[0].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, + }; if expiration_unit > current_unit { expiration_unit - current_unit @@ -406,26 +404,21 @@ 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] - .active_epoch_config - .ends_at, + rewards_state_response.rewards[0].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].active_epoch_config.started_at, - expected - ); + assert_eq!(denom_configs.rewards[0].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] - .active_epoch_config + .active_epoch .emission_rate .amount, Uint128::new(expected) @@ -435,7 +428,7 @@ impl Suite { 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] - .active_epoch_config + .active_epoch .emission_rate .duration { From a6a4f8633e02ed125f704f7a12de89a94e512d8a Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 19 Jul 2024 15:38:42 -0400 Subject: [PATCH 19/39] replaced historical epoch vector with just a rewards counter --- .../dao-rewards-distributor/README.md | 2 +- .../dao-rewards-distributor/src/contract.rs | 20 +++--- .../dao-rewards-distributor/src/error.rs | 3 + .../dao-rewards-distributor/src/rewards.rs | 11 ++-- .../dao-rewards-distributor/src/state.rs | 63 ++++++++++--------- 5 files changed, 52 insertions(+), 47 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index 0210ab615..68d636673 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -78,7 +78,7 @@ Updating the denom reward emission rate archives the active reward epoch and sta First, the currently active epoch is evaluated. We find the amount of tokens that were earned to this point per unit of voting power and save that in the current epoch as its total earned rewards per unit of voting power. We then bump the last update with that of the current block, and transition into the new epoch. -Active reward epoch is moved into the `historic_epochs`. This is a list of previously active reward emission schedules, along with their finalized amounts. +The final (partial) amount of rewards distributed during the active reward epoch are added to `historical_earned_puvp` to ensure they can still be claimed. This historical value contains all rewards distributed during past epochs. ### Shutting down denom distribution diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index c8b7a0e48..cbf1345ab 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -81,14 +81,12 @@ fn execute_update_reward_rate( // only the owner can update the reward rate cw_ownable::assert_owner(deps.storage, &info.sender)?; - let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; - reward_state.active_epoch.total_earned_puvp = - get_active_total_earned_puvp(deps.as_ref(), &env.block, &reward_state)?; - reward_state.bump_last_update(&env.block); + let mut reward_state = DENOM_REWARD_STATES + .load(deps.storage, denom.clone()) + .map_err(|_| ContractError::DenomNotRegistered {})?; // transition the epoch to the new emission rate and save - reward_state.transition_epoch(new_emission_rate, &env.block)?; - + reward_state.transition_epoch(deps.as_ref(), new_emission_rate, &env.block)?; DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &reward_state)?; Ok(Response::new().add_attribute("action", "update_reward_rate")) @@ -136,7 +134,7 @@ fn execute_register_reward_denom( hook_caller: hook_caller.clone(), funded_amount: Uint128::zero(), withdraw_destination, - historic_epochs: vec![], + historical_earned_puvp: Uint256::zero(), }; let str_denom = reward_state.to_str_denom(); @@ -397,15 +395,13 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult = Map::new("u_r_s"); @@ -78,52 +83,54 @@ pub struct DenomRewardState { pub funded_amount: Uint128, /// optional destination address for reward clawbacks pub withdraw_destination: Addr, - /// historic denom distribution epochs - pub historic_epochs: Vec, + /// 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 { - /// Sum all historical total_earned_puvp values. - pub fn get_historic_rewards_earned_puvp_sum(&self) -> Uint256 { - self.historic_epochs - .iter() - .fold(Uint256::zero(), |acc, epoch| acc + epoch.total_earned_puvp) - } - /// 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: RewardEmissionRate, current_block: &BlockInfo, ) -> StdResult<()> { + // 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(()); + } + let current_block_expiration = match self.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), Duration::Time(_) => Expiration::AtTime(current_block.time), }; - // 1. finish current epoch by changing the end to now - let mut curr_epoch = self.active_epoch.clone(); - curr_epoch.ends_at = current_block_expiration; - curr_epoch.finish_block = Some(current_block.to_owned()); + // 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 = current_block_expiration; + self.active_epoch.finish_block = Some(current_block.to_owned()); + self.bump_last_update(¤t_block); - // TODO: remove println - println!("transition_epoch: {:?}", curr_epoch); - // 2. push current epoch to historic configs - self.historic_epochs.push(curr_epoch.clone()); + // 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 available for distribution - let curr_epoch_earned_rewards = match curr_epoch.emission_rate.amount.is_zero() { + // 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 = match self.active_epoch.emission_rate.amount.is_zero() { true => Uint128::zero(), - false => curr_epoch.get_total_rewards()?, + false => self.active_epoch.get_total_rewards()?, }; - self.funded_amount = self.funded_amount.checked_sub(curr_epoch_earned_rewards)?; + self.funded_amount = self + .funded_amount + .checked_sub(active_epoch_earned_rewards)?; // 4. start new epoch - // TODO: remove println - println!("fund amount: {:?}", self.funded_amount); - // TODO: remove println - println!("new_emission_rate: {:?}", new_emission_rate); // 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 From 78ce00272ae89114819cd23201e02c5df3460bc8 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 19 Jul 2024 17:04:56 -0400 Subject: [PATCH 20/39] added ability to update config for a registered denom --- .../schema/dao-rewards-distributor.json | 136 ++++++++++++------ .../dao-rewards-distributor/src/contract.rs | 129 +++++++++++------ .../dao-rewards-distributor/src/error.rs | 5 +- .../dao-rewards-distributor/src/hooks.rs | 31 +++- .../dao-rewards-distributor/src/msg.rs | 40 ++++-- .../dao-rewards-distributor/src/state.rs | 6 +- .../src/testing/suite.rs | 96 ++++++++++--- .../src/testing/tests.rs | 37 ++++- 8 files changed, 348 insertions(+), 132 deletions(-) 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 e95426aac..d484f6d96 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -61,20 +61,66 @@ "additionalProperties": false }, { - "description": "Claims rewards for the sender.", + "description": "registers a new reward denom", "type": "object", "required": [ - "claim" + "register_denom" ], "properties": { - "claim": { + "register_denom": { + "$ref": "#/definitions/RegisterDenomMsg" + } + }, + "additionalProperties": false + }, + { + "description": "updates the config for a registered denom", + "type": "object", + "required": [ + "update_denom" + ], + "properties": { + "update_denom": { "type": "object", "required": [ "denom" ], "properties": { "denom": { + "description": "denom to update", "type": "string" + }, + "emission_rate": { + "description": "reward emission rate", + "anyOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + }, + { + "type": "null" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "type": [ + "string", + "null" + ] + }, + "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 @@ -110,13 +156,13 @@ "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" @@ -132,37 +178,20 @@ "additionalProperties": false }, { - "description": "registers a new reward denom", + "description": "shuts down the rewards distributor for a denom. withdraws all future staking rewards back to the treasury. members can claim whatever they earned until this point.", "type": "object", "required": [ - "register_reward_denom" - ], - "properties": { - "register_reward_denom": { - "$ref": "#/definitions/RegisterRewardDenomMsg" - } - }, - "additionalProperties": false - }, - { - "description": "updates the reward emission rate for a registered denom", - "type": "object", - "required": [ - "update_reward_emission_rate" + "shutdown" ], "properties": { - "update_reward_emission_rate": { + "shutdown": { "type": "object", "required": [ - "denom", - "emission_rate" + "denom" ], "properties": { "denom": { "type": "string" - }, - "emission_rate": { - "$ref": "#/definitions/RewardEmissionRate" } }, "additionalProperties": false @@ -449,7 +478,7 @@ } ] }, - "RegisterRewardDenomMsg": { + "RegisterDenomMsg": { "type": "object", "required": [ "denom", @@ -459,18 +488,31 @@ ], "properties": { "denom": { - "$ref": "#/definitions/UncheckedDenom" + "description": "denom to register", + "allOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + } + ] }, "emission_rate": { - "$ref": "#/definitions/RewardEmissionRate" + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] }, "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" @@ -715,7 +757,7 @@ "active_epoch", "denom", "funded_amount", - "historic_epochs", + "historical_earned_puvp", "hook_caller", "last_update", "vp_contract", @@ -746,12 +788,13 @@ } ] }, - "historic_epochs": { - "description": "historic denom distribution epochs", - "type": "array", - "items": { - "$ref": "#/definitions/Epoch" - } + "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/Uint256" + } + ] }, "hook_caller": { "description": "address that will update the reward split when the voting power distribution changes", @@ -778,7 +821,7 @@ ] }, "withdraw_destination": { - "description": "optional destination address for reward clawbacks", + "description": "destination address for reward clawbacks", "allOf": [ { "$ref": "#/definitions/Addr" @@ -1283,7 +1326,7 @@ "active_epoch", "denom", "funded_amount", - "historic_epochs", + "historical_earned_puvp", "hook_caller", "last_update", "vp_contract", @@ -1314,12 +1357,13 @@ } ] }, - "historic_epochs": { - "description": "historic denom distribution epochs", - "type": "array", - "items": { - "$ref": "#/definitions/Epoch" - } + "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/Uint256" + } + ] }, "hook_caller": { "description": "address that will update the reward split when the voting power distribution changes", @@ -1346,7 +1390,7 @@ ] }, "withdraw_destination": { - "description": "optional destination address for reward clawbacks", + "description": "destination address for reward clawbacks", "allOf": [ { "$ref": "#/definitions/Addr" diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index cbf1345ab..84f2d21e7 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -14,11 +14,11 @@ use std::collections::HashMap; use crate::helpers::{get_duration_scalar, 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_denom_to_hook, unsubscribe_denom_from_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, - RegisterRewardDenomMsg, RewardEmissionRate, RewardsStateResponse, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RegisterDenomMsg, + RewardEmissionRate, RewardsStateResponse, }; use crate::rewards::{ get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, @@ -55,50 +55,38 @@ 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(register_msg) => { - execute_register_reward_denom(deps, info, register_msg) - } - ExecuteMsg::UpdateRewardEmissionRate { + ExecuteMsg::RegisterDenom(register_msg) => execute_register_denom(deps, info, register_msg), + ExecuteMsg::UpdateDenom { + denom, + emission_rate, + vp_contract, + hook_caller, + withdraw_destination, + } => execute_update_denom( + deps, + env, + info, denom, emission_rate, - } => execute_update_reward_rate(deps, env, info, denom, emission_rate), + vp_contract, + hook_caller, + withdraw_destination, + ), + ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), + ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), + ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), + ExecuteMsg::Shutdown { denom } => execute_shutdown(deps, info, env, denom), } } -/// updates the reward emission rate for a registered denom -fn execute_update_reward_rate( - deps: DepsMut, - env: Env, - info: MessageInfo, - denom: String, - new_emission_rate: RewardEmissionRate, -) -> Result { - // only the owner can update the reward rate - cw_ownable::assert_owner(deps.storage, &info.sender)?; - - let mut reward_state = DENOM_REWARD_STATES - .load(deps.storage, denom.clone()) - .map_err(|_| ContractError::DenomNotRegistered {})?; - - // transition the epoch to the new emission rate and save - reward_state.transition_epoch(deps.as_ref(), new_emission_rate, &env.block)?; - DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &reward_state)?; - - Ok(Response::new().add_attribute("action", "update_reward_rate")) -} - /// 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_register_denom( deps: DepsMut, info: MessageInfo, - msg: RegisterRewardDenomMsg, + msg: RegisterDenomMsg, ) -> Result { // only the owner can register a new denom cw_ownable::assert_owner(deps.storage, &info.sender)?; @@ -149,15 +137,64 @@ fn execute_register_reward_denom( )?; // update the registered hooks to include the new denom - subscribe_denom_to_hook(deps, str_denom, hook_caller.clone())?; + subscribe_denom_to_hook(deps.storage, str_denom, hook_caller.clone())?; Ok(Response::default().add_attribute("action", "register_reward_denom")) } -/// 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. +/// updates the config for a registered denom +#[allow(clippy::too_many_arguments)] +fn execute_update_denom( + deps: DepsMut, + env: Env, + info: MessageInfo, + denom: String, + emission_rate: Option, + vp_contract: Option, + hook_caller: Option, + withdraw_destination: Option, +) -> Result { + // only the owner can update a denom config + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut reward_state = DENOM_REWARD_STATES + .load(deps.storage, denom.clone()) + .map_err(|_| ContractError::DenomNotRegistered {})?; + + if let Some(emission_rate) = emission_rate { + // transition the epoch to the new emission rate + reward_state.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; + } + + if let Some(vp_contract) = vp_contract { + reward_state.vp_contract = validate_voting_power_contract(&deps, vp_contract)?; + } + + if let Some(hook_caller) = hook_caller { + // remove existing from registered hooks + unsubscribe_denom_from_hook(deps.storage, &denom, reward_state.hook_caller)?; + + reward_state.hook_caller = deps.api.addr_validate(&hook_caller)?; + + // add new to registered hooks + subscribe_denom_to_hook(deps.storage, &denom, reward_state.hook_caller.clone())?; + } + + if let Some(withdraw_destination) = withdraw_destination { + reward_state.withdraw_destination = deps.api.addr_validate(&withdraw_destination)?; + } + + DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &reward_state)?; + + Ok(Response::new() + .add_attribute("action", "update_denom") + .add_attribute("denom", denom)) +} + +/// shutdown the rewards distribution for a specific denom. can only be called +/// by the admin and only during the distribution period. this will clawback all +/// (undistributed) future rewards to the admin. updates the period finish +/// expiration to the current block. fn execute_shutdown( deps: DepsMut, info: MessageInfo, @@ -188,12 +225,10 @@ fn execute_shutdown( // 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_scalar_units_since_start, - reward_duration_scalar, - )) - .map_err(|e| ContractError::Std(e.into()))?; + let remaining_reward_duration_fraction = Decimal::one().checked_sub(Decimal::from_ratio( + passed_scalar_units_since_start, + reward_duration_scalar, + ))?; // to get the clawback msg let clawback_msg = get_transfer_msg( diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index 25ce5638f..d7aea5c06 100644 --- a/contracts/distribution/dao-rewards-distributor/src/error.rs +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::StdError; +use cosmwasm_std::{OverflowError, StdError}; use thiserror::Error; #[derive(Error, Debug, PartialEq)] @@ -12,6 +12,9 @@ pub enum ContractError { #[error(transparent)] Cw20Error(#[from] cw20_base::ContractError), + #[error(transparent)] + Overflow(#[from] OverflowError), + #[error("Invalid Cw20")] InvalidCw20 {}, diff --git a/contracts/distribution/dao-rewards-distributor/src/hooks.rs b/contracts/distribution/dao-rewards-distributor/src/hooks.rs index 1d1020258..3a6e5c7f5 100644 --- a/contracts/distribution/dao-rewards-distributor/src/hooks.rs +++ b/contracts/distribution/dao-rewards-distributor/src/hooks.rs @@ -1,4 +1,4 @@ -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}; @@ -6,18 +6,39 @@ use crate::{rewards::update_rewards, state::REGISTERED_HOOK_DENOMS, ContractErro /// Register a hook caller contract for a given denom. pub(crate) fn subscribe_denom_to_hook( - deps: DepsMut, - denom: String, + storage: &mut dyn Storage, + denom: impl Into, hook: Addr, ) -> Result<(), ContractError> { - REGISTERED_HOOK_DENOMS.update(deps.storage, hook, |denoms| -> StdResult<_> { + REGISTERED_HOOK_DENOMS.update(storage, hook, |denoms| -> StdResult<_> { let mut denoms = denoms.unwrap_or_default(); - denoms.push(denom.to_string()); + denoms.push(denom.into()); Ok(denoms) })?; Ok(()) } +/// Unregister a hook caller contract for a given denom. +pub(crate) fn unsubscribe_denom_from_hook( + storage: &mut dyn Storage, + denom: &str, + hook: Addr, +) -> Result<(), ContractError> { + let mut denoms = REGISTERED_HOOK_DENOMS + .may_load(storage, hook.clone())? + .unwrap_or_default(); + + denoms.retain(|d| d != denom); + + if denoms.is_empty() { + REGISTERED_HOOK_DENOMS.remove(storage, hook); + } else { + REGISTERED_HOOK_DENOMS.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. diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 4d07ae065..c9735a6b8 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -33,30 +33,46 @@ 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 reward denom + RegisterDenom(RegisterDenomMsg), + /// updates the config for a registered denom + UpdateDenom { + /// denom to update + denom: String, + /// 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. + /// Claims rewards for the sender. + Claim { denom: String }, + /// shuts down the rewards distributor for a denom. 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(RegisterRewardDenomMsg), - /// updates the reward emission rate for a registered denom - UpdateRewardEmissionRate { - denom: String, - emission_rate: RewardEmissionRate, - }, } #[cw_serde] -pub struct RegisterRewardDenomMsg { +pub struct RegisterDenomMsg { + /// denom to register pub denom: UncheckedDenom, + /// reward emission rate pub emission_rate: RewardEmissionRate, + /// 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, } diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 695fb8428..bce38ecaa 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -81,7 +81,7 @@ pub struct DenomRewardState { pub hook_caller: Addr, /// total amount of rewards funded 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 @@ -109,10 +109,10 @@ impl DenomRewardState { // 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)?; + get_active_total_earned_puvp(deps, current_block, self)?; self.active_epoch.ends_at = current_block_expiration; self.active_epoch.finish_block = Some(current_block.to_owned()); - self.bump_last_update(¤t_block); + self.bump_last_update(current_block); // 2. add current epoch rewards earned to historical rewards // TODO: what to do on overflow? diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 61d3d1aef..408c815cf 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -11,7 +11,7 @@ use cw_utils::Duration; use crate::{ msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RegisterRewardDenomMsg, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RegisterDenomMsg, RewardEmissionRate, RewardsStateResponse, }, state::DenomRewardState, @@ -383,7 +383,7 @@ impl Suite { .unwrap() } - pub fn _get_denom_reward_state(&mut self, denom: &str) -> DenomRewardState { + pub fn get_denom_reward_state(&mut self, denom: &str) -> DenomRewardState { let resp: DenomRewardState = self .app .wrap() @@ -394,9 +394,18 @@ impl Suite { }, ) .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() + } } // SUITE ASSERTIONS @@ -438,16 +447,6 @@ impl Suite { assert_eq!(units, expected); } - pub fn get_owner(&mut self) -> Addr { - let ownable_response: cw_ownable::Ownership = self - .app - .borrow_mut() - .wrap() - .query_wasm_smart(self.distribution_contract.clone(), &QueryMsg::Ownership {}) - .unwrap(); - ownable_response.owner.unwrap() - } - pub fn assert_pending_rewards(&mut self, address: &str, _denom: &str, expected: u128) { let res: PendingRewardsResponse = self .app @@ -510,7 +509,7 @@ impl Suite { } pub fn register_reward_denom(&mut self, reward_config: RewardsConfig, hook_caller: &str) { - let register_reward_denom_msg = ExecuteMsg::RegisterRewardDenom(RegisterRewardDenomMsg { + let register_reward_denom_msg = ExecuteMsg::RegisterDenom(RegisterDenomMsg { denom: reward_config.denom.clone(), emission_rate: RewardEmissionRate { amount: Uint128::new(reward_config.amount), @@ -686,12 +685,75 @@ impl Suite { epoch_duration: Duration, epoch_rewards: u128, ) { - let msg = ExecuteMsg::UpdateRewardEmissionRate { + let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { denom: denom.to_string(), - emission_rate: RewardEmissionRate { + emission_rate: Some(RewardEmissionRate { amount: Uint128::new(epoch_rewards), duration: epoch_duration, - }, + }), + 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, denom: &str, vp_contract: &str) { + let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + denom: denom.to_string(), + 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, denom: &str, hook_caller: &str) { + let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + denom: denom.to_string(), + 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, denom: &str, withdraw_destination: &str) { + let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + denom: denom.to_string(), + emission_rate: None, + vp_contract: None, + hook_caller: None, + withdraw_destination: Some(withdraw_destination.to_string()), }; let _resp = self diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 919730d28..7d49d9a10 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -7,6 +7,7 @@ use cw4::Member; use cw_multi_test::Executor; use cw_utils::Duration; +use crate::testing::native_setup::setup_native_token_test; use crate::{ msg::ExecuteMsg, testing::{ADDR1, ADDR2, ADDR3, DENOM}, @@ -59,7 +60,7 @@ fn test_native_dao_rewards_update_reward_rate() { // double the rewards rate // now there will be 10_000_000 tokens distributed over 100_000 blocks - suite.update_reward_emission_rate(DENOM, Duration::Height(10), 1000); + suite.update_reward_emission_rate(DENOM, Duration::Height(10), 1_000); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -1307,3 +1308,37 @@ 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(DENOM, new_vp_contract.as_str()); + + let denom = suite.get_denom_reward_state(DENOM); + assert_eq!(denom.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(DENOM, new_hook_caller); + + let denom = suite.get_denom_reward_state(DENOM); + assert_eq!(denom.hook_caller, new_hook_caller); +} + +#[test] +fn update_withdraw_destination() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let new_withdraw_destination = "new_withdraw_destination"; + suite.update_withdraw_destination(DENOM, new_withdraw_destination); + + let denom = suite.get_denom_reward_state(DENOM); + assert_eq!(denom.withdraw_destination, new_withdraw_destination); +} From 025e260a5098f454a4a8cedb347cb3a2591e88cb Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 19 Jul 2024 18:09:20 -0400 Subject: [PATCH 21/39] added test --- .../src/testing/suite.rs | 41 +++--- .../src/testing/tests.rs | 132 ++++++++++++++++++ 2 files changed, 155 insertions(+), 18 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 408c815cf..cacd07dea 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -48,6 +48,7 @@ pub struct SuiteBuilder { pub _instantiate: InstantiateMsg, pub dao_type: DaoType, pub rewards_config: RewardsConfig, + pub cw4_members: Vec, } impl SuiteBuilder { @@ -63,6 +64,20 @@ impl SuiteBuilder { duration: Duration::Height(10), destination: None, }, + 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 +86,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 +120,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(); } @@ -471,12 +476,12 @@ 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) { + pub fn assert_cw20_balance(&self, address: &str, expected: u128) { let balance = self.get_balance_cw20(self.reward_denom.clone(), address); assert_eq!(balance, expected); } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 7d49d9a10..95c3cfdf2 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -458,6 +458,138 @@ 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, + }) + .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, DENOM, 70); + suite.assert_pending_rewards(ADDR2, DENOM, 20); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM); + suite.assert_native_balance(ADDR2, DENOM, 20); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, DENOM, 70 + 63); + suite.assert_pending_rewards(ADDR2, DENOM, 27); + suite.assert_pending_rewards(ADDR3, DENOM, 10 + 9); + + // increase reward rate and claim + suite.update_reward_emission_rate(DENOM, Duration::Time(100), 150); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_native_balance(ADDR3, DENOM, 10 + 9); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, DENOM, 70 + 63 + 95 + 1); + suite.assert_pending_rewards(ADDR2, DENOM, 27 + 40 + 1); + suite.assert_pending_rewards(ADDR3, DENOM, 13); + + // claim rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 70 + 63 + 95 + 1); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // skip 3 intervals + suite.skip_seconds(300); + + suite.assert_pending_rewards(ADDR1, DENOM, 3 * 95 + 1); + suite.assert_pending_rewards(ADDR2, DENOM, 27 + 4 * 40 + 1 + 1 + 1); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM); + suite.assert_native_balance(ADDR2, DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + + // skip 1 interval + suite.skip_seconds(100); + + suite.assert_pending_rewards(ADDR1, DENOM, 3 * 95 + 1 + 68); + suite.assert_pending_rewards(ADDR2, DENOM, 54); + suite.assert_pending_rewards(ADDR3, DENOM, 4 * 13 + 1 + 1 + 27); + + // claim all + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + 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, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 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_native_dao_rewards() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); From 7573397d1e5debebbcd26f5821ee164fa1d53a8b Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 14:08:24 -0400 Subject: [PATCH 22/39] more cleanup --- .../dao-rewards-distributor/src/contract.rs | 58 +++--- .../dao-rewards-distributor/src/error.rs | 4 +- .../dao-rewards-distributor/src/helpers.rs | 4 +- .../dao-rewards-distributor/src/msg.rs | 8 +- .../dao-rewards-distributor/src/rewards.rs | 8 +- .../dao-rewards-distributor/src/state.rs | 46 +---- .../src/testing/suite.rs | 21 +- .../src/testing/tests.rs | 189 ++++++++++++++---- 8 files changed, 210 insertions(+), 128 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 84f2d21e7..2b04a02ec 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,8 +1,8 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - ensure, from_json, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, - Response, StdResult, Uint128, Uint256, + 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; @@ -76,7 +76,7 @@ pub fn execute( ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), - ExecuteMsg::Shutdown { denom } => execute_shutdown(deps, info, env, denom), + ExecuteMsg::Withdraw { denom } => execute_withdraw(deps, info, env, denom), } } @@ -191,62 +191,52 @@ fn execute_update_denom( .add_attribute("denom", denom)) } -/// shutdown the rewards distribution for a specific denom. 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 +/// withdraws the undistributed rewards for a denom. 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_shutdown( +fn execute_withdraw( deps: DepsMut, info: MessageInfo, env: Env, denom: String, ) -> Result { - // only the owner can initiate a shutdown + // only the owner can initiate a withdraw cw_ownable::assert_owner(deps.storage, &info.sender)?; let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; - // shutdown is only possible during the distribution period + // withdraw is only possible during the distribution period ensure!( !reward_state.active_epoch.ends_at.is_expired(&env.block), - ContractError::ShutdownError("Reward period already finished".to_string()) + ContractError::RewardsAlreadyDistributed {} ); - // 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_scalar = ends_at - started_at; - - // find the % of reward_duration that remains from current block - let passed_scalar_units_since_start = match reward_state.active_epoch.emission_rate.duration { - Duration::Height(_) => env.block.height - started_at, - Duration::Time(_) => env.block.time.seconds() - started_at, + // withdraw completes the epoch + reward_state.active_epoch.ends_at = match reward_state.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), }; - // 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_scalar_units_since_start, - reward_duration_scalar, - ))?; + // get total rewards distributed based on newly updated ends_at + let rewards_distributed = reward_state.active_epoch.get_total_rewards()?; + + let clawback_amount = reward_state.funded_amount - rewards_distributed; + + // remove withdrawn funds from amount funded since they are no longer funded + reward_state.funded_amount = rewards_distributed; - // to get the clawback msg let clawback_msg = get_transfer_msg( reward_state.withdraw_destination.clone(), - reward_state.funded_amount * remaining_reward_duration_fraction, + clawback_amount, reward_state.denom.clone(), )?; - // shutdown completes the rewards - reward_state.active_epoch.ends_at = match reward_state.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(env.block.height), - Duration::Time(_) => Expiration::AtTime(env.block.time), - }; - DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; Ok(Response::new() - .add_attribute("action", "shutdown") + .add_attribute("action", "withdraw") .add_message(clawback_msg)) } diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index d7aea5c06..e6f6c51f0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/error.rs +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -36,8 +36,8 @@ pub enum ContractError { #[error("Reward duration can not be zero")] ZeroRewardDuration {}, - #[error("Rewards distributor shutdown error: {0}")] - ShutdownError(String), + #[error("All rewards have already been distributed")] + RewardsAlreadyDistributed {}, #[error("Denom already registered")] DenomAlreadyRegistered {}, diff --git a/contracts/distribution/dao-rewards-distributor/src/helpers.rs b/contracts/distribution/dao-rewards-distributor/src/helpers.rs index a2af35611..1960845ae 100644 --- a/contracts/distribution/dao-rewards-distributor/src/helpers.rs +++ b/contracts/distribution/dao-rewards-distributor/src/helpers.rs @@ -74,8 +74,8 @@ pub(crate) fn scale_factor() -> Uint256 { } /// Calculate the duration from start to end. If the end is at or before the -/// start, return 0. -pub fn get_start_end_diff(end: &Expiration, start: &Expiration) -> StdResult { +/// 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 { diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index c9735a6b8..ef4277384 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -55,10 +55,10 @@ pub enum ExecuteMsg { Fund {}, /// Claims rewards for the sender. Claim { denom: String }, - /// shuts down the rewards distributor for a denom. withdraws all future - /// staking rewards back to the treasury. members can claim whatever they - /// earned until this point. - Shutdown { denom: String }, + /// withdraws the undistributed rewards for a denom. 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 { denom: String }, } #[cw_serde] diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index 8f8de2292..e5bce93b0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -2,8 +2,8 @@ use cosmwasm_std::{coin, Addr, BlockInfo, Coin, Deps, DepsMut, Env, StdResult, U use crate::{ helpers::{ - get_duration_scalar, get_prev_block_total_vp, get_start_end_diff, - get_voting_power_at_block, scale_factor, + get_duration_scalar, get_exp_diff, get_prev_block_total_vp, get_voting_power_at_block, + scale_factor, }, state::{DenomRewardState, UserRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}, }; @@ -86,7 +86,7 @@ pub fn get_active_total_earned_puvp( // 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_start_end_diff(&last_time_rewards_distributed, &reward_state.last_update)?.into(); + get_exp_diff(&last_time_rewards_distributed, &reward_state.last_update)?.into(); if prev_total_power.is_zero() { Ok(curr) @@ -113,7 +113,7 @@ pub fn get_active_total_earned_puvp( } } -// get a user's rewards not yet accounted for in their reward states. +// get a user's rewards not yet accounted for in their reward state. pub fn get_accrued_rewards_since_last_user_action( deps: Deps, env: &Env, diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index bce38ecaa..162492b6d 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -8,7 +8,7 @@ use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; use crate::{ - helpers::get_start_end_diff, msg::RewardEmissionRate, rewards::get_active_total_earned_puvp, + helpers::get_exp_diff, msg::RewardEmissionRate, rewards::get_active_total_earned_puvp, ContractError, }; @@ -51,7 +51,7 @@ impl Epoch { /// get the total rewards to be distributed based on the emission rate and /// duration from start to end pub fn get_total_rewards(&self) -> StdResult { - let epoch_duration = get_start_end_diff(&self.started_at, &self.ends_at)?; + let epoch_duration = get_exp_diff(&self.ends_at, &self.started_at)?; let emission_rate_duration_scalar = match self.emission_rate.duration { Duration::Height(h) => h, @@ -180,17 +180,15 @@ impl DenomRewardState { /// 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) { - // if its never been set before, we set it to current block and return - if let Expiration::Never {} = self.active_epoch.started_at { - self.active_epoch.started_at = match self.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), - }; - } + let reset_start = if let Expiration::Never {} = self.active_epoch.started_at { + true + } else { + self.active_epoch.ends_at.is_expired(current_block) + }; - // if current distribution is expired, we set the funding date - // to the current date - if self.active_epoch.ends_at.is_expired(current_block) { + // if its never been set before, or if current distribution is expired, + // we set the funding date to the current date + if reset_start { self.active_epoch.started_at = match self.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), Duration::Time(_) => Expiration::AtTime(current_block.time), @@ -205,30 +203,6 @@ impl DenomRewardState { } } - /// 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.active_epoch.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.active_epoch.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 currently being diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index cacd07dea..19e56923d 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -16,6 +16,7 @@ use crate::{ }, state::DenomRewardState, testing::cw20_setup::instantiate_cw20, + ContractError, }; use super::{ @@ -489,8 +490,8 @@ impl Suite { // SUITE ACTIONS impl Suite { - pub fn shutdown_denom_distribution(&mut self, denom: &str) { - let msg = ExecuteMsg::Shutdown { + pub fn withdraw_denom_funds(&mut self, denom: &str) { + let msg = ExecuteMsg::Withdraw { denom: denom.to_string(), }; self.app @@ -503,6 +504,22 @@ impl Suite { .unwrap(); } + pub fn withdraw_denom_funds_error(&mut self, denom: &str) -> ContractError { + let msg = ExecuteMsg::Withdraw { + denom: denom.to_string(), + }; + 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(), diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 95c3cfdf2..6dd9b77cd 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -8,6 +8,7 @@ use cw_multi_test::Executor; use cw_utils::Duration; use crate::testing::native_setup::setup_native_token_test; +use crate::ContractError; use crate::{ msg::ExecuteMsg, testing::{ADDR1, ADDR2, ADDR3, DENOM}, @@ -664,9 +665,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(ADDR1, DENOM, 5_000_000 + 6_666_666); suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); - suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333 + 2_500_000); // reintroduce the 2nd member with double the vp let add_member_2 = Member { @@ -681,12 +682,12 @@ fn test_cw4_dao_rewards() { // ADDR1 claims rewards suite.claim_rewards(ADDR1, DENOM); - suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + 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(ADDR3, DENOM, 3_333_333 + 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -851,18 +852,18 @@ fn test_fund_invalid_cw20_denom() { } #[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_denom_funds(DENOM); } #[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())) @@ -887,25 +888,25 @@ 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_denom_funds(DENOM); - 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 @@ -926,29 +927,39 @@ 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_denom_funds(DENOM); - 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_denom_funds_error(DENOM), + 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, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333 + 2_500_000); // user 1 can claim their rewards suite.claim_rewards(ADDR1, DENOM); @@ -961,14 +972,14 @@ fn test_shutdown_block_based() { 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.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, @@ -996,29 +1007,39 @@ 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_denom_funds(DENOM); - 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_denom_funds_error(DENOM), + 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(ADDR3, DENOM, 3_333_333 + 2_500_000); // user 1 can claim their rewards suite.claim_rewards(ADDR1, DENOM); @@ -1031,15 +1052,95 @@ fn test_shutdown_time_based() { 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.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() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // users claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + // 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_denom_funds(DENOM); + + 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_denom_funds_error(DENOM), + ContractError::RewardsAlreadyDistributed {} + ); + + // we assert that pending rewards did not change + 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.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + // fund again + suite.fund_distributor_native(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, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 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 @@ -1051,7 +1152,7 @@ fn test_shudown_unauthorized() { .execute_contract( Addr::unchecked(ADDR1), suite.distribution_contract.clone(), - &ExecuteMsg::Shutdown { + &ExecuteMsg::Withdraw { denom: DENOM.to_string(), }, &[], @@ -1061,12 +1162,12 @@ fn test_shudown_unauthorized() { #[test] #[should_panic] -fn test_shutdown_unregistered_denom() { +fn test_withdraw_unregistered_denom() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); suite.skip_blocks(100_000); - suite.shutdown_denom_distribution("not-the-denom"); + suite.withdraw_denom_funds("not-the-denom"); } #[test] From 458ea1ad7e6933b64538e8efc5dc19348b4e9cad Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 14:45:03 -0400 Subject: [PATCH 23/39] fixed test --- .../dao-rewards-distributor/src/testing/tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 6dd9b77cd..3c772c76a 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -152,6 +152,7 @@ fn test_native_dao_rewards_update_reward_rate() { // 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_reward_emission_rate(DENOM, Duration::Height(10), 4000); + suite.assert_ends_at(Expiration::AtHeight(1_062_500)); suite.skip_blocks(50_000); // allocates 20_000_000 tokens @@ -175,11 +176,11 @@ fn test_native_dao_rewards_update_reward_rate() { suite.claim_rewards(ADDR3, DENOM); let addr1_pending = 0; let addr3_pending = 0; - suite.skip_blocks(10_000); // allocates 4_000_000 tokens + suite.skip_blocks(10_000); // skips from 1060000 to 1070000, and the end is 1062500, so this allocates only 1_000_000 tokens instead of 4_000_000 - suite.assert_pending_rewards(ADDR1, DENOM, addr1_pending + 4_000_000 * 2 / 4); - suite.assert_pending_rewards(ADDR2, DENOM, 2 * 4_000_000 / 4); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 4_000_000 / 4); + suite.assert_pending_rewards(ADDR1, DENOM, addr1_pending + 1_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4 + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 1_000_000 / 4); suite.claim_rewards(ADDR2, DENOM); From 6cda531df1638f4fb1a6df24fba4668271a96441 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 14:47:40 -0400 Subject: [PATCH 24/39] added comment --- contracts/distribution/dao-rewards-distributor/src/msg.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index ef4277384..6e2f8da52 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -138,7 +138,7 @@ pub enum QueryMsg { /// Returns contract version info #[returns(InfoResponse)] Info {}, - /// Returns the state of the registered reward distributions. + /// Returns the state of all the registered reward distributions. #[returns(RewardsStateResponse)] RewardsState {}, /// Returns the pending rewards for the given address. @@ -147,6 +147,7 @@ pub enum QueryMsg { /// Returns information about the ownership of this contract. #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] Ownership {}, + /// Returns the state of the given denom reward distribution. #[returns(DenomRewardState)] DenomRewardState { denom: String }, } From e326fcdd0b1871ddef290179e5ef9410838c68d8 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 14:48:06 -0400 Subject: [PATCH 25/39] removed unused finish_block --- .../schema/dao-rewards-distributor.json | 85 ++----------------- .../dao-rewards-distributor/src/contract.rs | 1 - .../dao-rewards-distributor/src/state.rs | 4 - 3 files changed, 5 insertions(+), 85 deletions(-) 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 d484f6d96..6ada59e9f 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -178,13 +178,13 @@ "additionalProperties": false }, { - "description": "shuts down the rewards distributor for a denom. withdraws all future staking rewards back to the treasury. members can claim whatever they earned until this point.", + "description": "withdraws the undistributed rewards for a denom. 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": [ - "shutdown" + "withdraw" ], "properties": { - "shutdown": { + "withdraw": { "type": "object", "required": [ "denom" @@ -668,7 +668,7 @@ "additionalProperties": false }, { - "description": "Returns the state of the registered reward distributions.", + "description": "Returns the state of all the registered reward distributions.", "type": "object", "required": [ "rewards_state" @@ -718,6 +718,7 @@ "additionalProperties": false }, { + "description": "Returns the state of the given denom reward distribution.", "type": "object", "required": [ "denom_reward_state" @@ -835,33 +836,6 @@ "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" }, - "BlockInfo": { - "type": "object", - "required": [ - "chain_id", - "height", - "time" - ], - "properties": { - "chain_id": { - "type": "string" - }, - "height": { - "description": "The height of a block is the number of blocks preceding it in the blockchain.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "time": { - "description": "Absolute time of the block creation in seconds since the UNIX epoch (00:00:00 on 1970-01-01 UTC).\n\nThe source of this is the [BFT Time in Tendermint](https://github.com/tendermint/tendermint/blob/58dc1726/spec/consensus/bft-time.md), which has the same nanosecond precision as the `Timestamp` type.\n\n# Examples\n\nUsing chrono:\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; # extern crate chrono; use chrono::NaiveDateTime; let seconds = env.block.time.seconds(); let nsecs = env.block.time.subsec_nanos(); let dt = NaiveDateTime::from_timestamp(seconds as i64, nsecs as u32); ```\n\nCreating a simple millisecond-precision timestamp (as used in JavaScript):\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; let millis = env.block.time.nanos() / 1_000_000; ```", - "allOf": [ - { - "$ref": "#/definitions/Timestamp" - } - ] - } - } - }, "Denom": { "oneOf": [ { @@ -949,17 +923,6 @@ } ] }, - "finish_block": { - "description": "finish block set when epoch is over", - "anyOf": [ - { - "$ref": "#/definitions/BlockInfo" - }, - { - "type": "null" - } - ] - }, "started_at": { "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", "allOf": [ @@ -1264,33 +1227,6 @@ "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" }, - "BlockInfo": { - "type": "object", - "required": [ - "chain_id", - "height", - "time" - ], - "properties": { - "chain_id": { - "type": "string" - }, - "height": { - "description": "The height of a block is the number of blocks preceding it in the blockchain.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "time": { - "description": "Absolute time of the block creation in seconds since the UNIX epoch (00:00:00 on 1970-01-01 UTC).\n\nThe source of this is the [BFT Time in Tendermint](https://github.com/tendermint/tendermint/blob/58dc1726/spec/consensus/bft-time.md), which has the same nanosecond precision as the `Timestamp` type.\n\n# Examples\n\nUsing chrono:\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; # extern crate chrono; use chrono::NaiveDateTime; let seconds = env.block.time.seconds(); let nsecs = env.block.time.subsec_nanos(); let dt = NaiveDateTime::from_timestamp(seconds as i64, nsecs as u32); ```\n\nCreating a simple millisecond-precision timestamp (as used in JavaScript):\n\n``` # use cosmwasm_std::{Addr, BlockInfo, ContractInfo, Env, MessageInfo, Timestamp, TransactionInfo}; # let env = Env { # block: BlockInfo { # height: 12_345, # time: Timestamp::from_nanos(1_571_797_419_879_305_533), # chain_id: \"cosmos-testnet-14002\".to_string(), # }, # transaction: Some(TransactionInfo { index: 3 }), # contract: ContractInfo { # address: Addr::unchecked(\"contract\"), # }, # }; let millis = env.block.time.nanos() / 1_000_000; ```", - "allOf": [ - { - "$ref": "#/definitions/Timestamp" - } - ] - } - } - }, "Denom": { "oneOf": [ { @@ -1459,17 +1395,6 @@ } ] }, - "finish_block": { - "description": "finish block set when epoch is over", - "anyOf": [ - { - "$ref": "#/definitions/BlockInfo" - }, - { - "type": "null" - } - ] - }, "started_at": { "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", "allOf": [ diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 2b04a02ec..69580787a 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -115,7 +115,6 @@ fn execute_register_denom( ends_at: Expiration::Never {}, emission_rate: msg.emission_rate, total_earned_puvp: Uint256::zero(), - finish_block: None, }, last_update: Expiration::Never {}, vp_contract, diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 162492b6d..b555a47d2 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -43,8 +43,6 @@ pub struct Epoch { /// total rewards earned per unit voting power from started_at to /// last_update pub total_earned_puvp: Uint256, - /// finish block set when epoch is over - pub finish_block: Option, } impl Epoch { @@ -111,7 +109,6 @@ impl DenomRewardState { self.active_epoch.total_earned_puvp = get_active_total_earned_puvp(deps, current_block, self)?; self.active_epoch.ends_at = current_block_expiration; - self.active_epoch.finish_block = Some(current_block.to_owned()); self.bump_last_update(current_block); // 2. add current epoch rewards earned to historical rewards @@ -160,7 +157,6 @@ impl DenomRewardState { ends_at: new_epoch_end_scalar, // start the new active epoch with zero rewards earned total_earned_puvp: Uint256::zero(), - finish_block: None, }; Ok(()) From 008ae49ce2408a0fef99f58fd58cb668ecc94925 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 15:05:31 -0400 Subject: [PATCH 26/39] fixed transition_epoch so new epochs can change height vs. time duration unit --- .../dao-rewards-distributor/src/state.rs | 24 ++- .../src/testing/tests.rs | 175 +++++++++++++++++- 2 files changed, 188 insertions(+), 11 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index b555a47d2..5c247f57e 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -100,16 +100,13 @@ impl DenomRewardState { return Ok(()); } - let current_block_expiration = match self.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), - }; - // 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 = current_block_expiration; - self.bump_last_update(current_block); + self.active_epoch.ends_at = match self.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; // 2. add current epoch rewards earned to historical rewards // TODO: what to do on overflow? @@ -133,7 +130,7 @@ impl DenomRewardState { // 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_epoch_end_scalar = + let new_ends_at = match new_emission_rate.get_funded_period_duration(self.funded_amount)? { Duration::Height(h) => { if current_block.height.checked_add(h).is_some() { @@ -151,14 +148,21 @@ impl DenomRewardState { } }; + let new_started_at = match new_emission_rate.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: current_block_expiration, - ends_at: new_epoch_end_scalar, + started_at: new_started_at, + ends_at: new_ends_at, // start the new active epoch with zero rewards earned total_earned_puvp: Uint256::zero(), }; + self.bump_last_update(current_block); + Ok(()) } } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 3c772c76a..e675ef20f 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -176,7 +176,180 @@ fn test_native_dao_rewards_update_reward_rate() { suite.claim_rewards(ADDR3, DENOM); let addr1_pending = 0; let addr3_pending = 0; - suite.skip_blocks(10_000); // skips from 1060000 to 1070000, and the end is 1062500, so this allocates only 1_000_000 tokens instead of 4_000_000 + 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, DENOM, addr1_pending + 1_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4 + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 1_000_000 / 4); + + suite.claim_rewards(ADDR2, DENOM); + + // 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, + }) + .build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // set the rewards rate to time-based rewards + suite.update_reward_emission_rate(DENOM, Duration::Time(10), 500); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 6_250_000); + suite.assert_pending_rewards(ADDR3, DENOM, 6_250_000); + + // double the rewards rate + // now there will be 10_000_000 tokens distributed over 100_000 seconds + suite.update_reward_emission_rate(DENOM, Duration::Time(10), 1_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 7_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 8_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 8_750_000); + + // skip 2/10ths of the time + suite.skip_seconds(200_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); + + // set the rewards rate to 0, pausing the rewards distribution + suite.update_reward_emission_rate(DENOM, Duration::Height(10000000000), 0); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // assert no pending rewards changed + suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); + suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM); + // 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, DENOM, 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, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM); + // 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, DENOM, 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_reward_emission_rate(DENOM, Duration::Height(10), 1000); + + // 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, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000 + 3_333_333); + + // ADDR3 claims their rewards + suite.assert_native_balance(ADDR3, DENOM, 0); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM, 6_666_666 + 6_666_666 + 1); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333); + + // claim everything so that there are 0 pending rewards + suite.claim_rewards(ADDR3, DENOM); + suite.claim_rewards(ADDR1, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 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_reward_emission_rate(DENOM, Duration::Time(10), 4000); + 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, DENOM, addr1_pending); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 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, DENOM, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4); + suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 4_000_000 / 4); + + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR3, DENOM); + 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, DENOM, addr1_pending + 1_000_000 * 2 / 4); suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4 + 1_000_000 / 4); From 22e0fe31a8b053e758bff03cf46a3c2690a09d72 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 15:28:50 -0400 Subject: [PATCH 27/39] more cleanup --- .../dao-rewards-distributor/src/contract.rs | 24 ++++++++- .../dao-rewards-distributor/src/state.rs | 50 ++++++------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 69580787a..5e76a6ed4 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -279,7 +279,29 @@ fn execute_fund( let funded_period_value = get_duration_scalar(&funded_period_duration); denom_reward_state.bump_last_update(&env.block); - denom_reward_state.bump_funding_date(&env.block); + + // distribution is inactive if it hasn't yet started (i.e. never been + // funded) or if it's expired (i.e. all funds have been distributed) + let distribution_inactive = + if let Expiration::Never {} = denom_reward_state.active_epoch.started_at { + true + } else { + denom_reward_state + .active_epoch + .ends_at + .is_expired(&env.block) + }; + + // if distribution is inactive, update the distribution start to the current + // block so that the new funds start being distributed from now instead of + // from the past + if distribution_inactive { + denom_reward_state.active_epoch.started_at = + match denom_reward_state.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }; + } // the duration of rewards period is extended in different ways, // depending on the current expiration state and current block diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 5c247f57e..650bca7f0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -130,23 +130,22 @@ impl DenomRewardState { // 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)? { - Duration::Height(h) => { - if current_block.height.checked_add(h).is_some() { - Expiration::AtHeight(current_block.height + h) - } else { - Expiration::AtHeight(u64::MAX) - } + let new_ends_at = match new_emission_rate.get_funded_period_duration(self.funded_amount)? { + Duration::Height(h) => { + if current_block.height.checked_add(h).is_some() { + Expiration::AtHeight(current_block.height + h) + } else { + Expiration::AtHeight(u64::MAX) } - 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)) - } + } + 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)) } - }; + } + }; let new_started_at = match new_emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(current_block.height), @@ -175,27 +174,6 @@ impl DenomRewardState { }; } - /// 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) { - let reset_start = if let Expiration::Never {} = self.active_epoch.started_at { - true - } else { - self.active_epoch.ends_at.is_expired(current_block) - }; - - // if its never been set before, or if current distribution is expired, - // we set the funding date to the current date - if reset_start { - self.active_epoch.started_at = match self.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), - }; - } - } - pub fn to_str_denom(&self) -> String { match &self.denom { Denom::Native(denom) => denom.to_string(), From 3918ebf18486eec56d582d003c1ca2aa87e26db6 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 15:56:20 -0400 Subject: [PATCH 28/39] cleaned up fund logic --- .../dao-rewards-distributor/src/contract.rs | 55 +++++-------------- .../dao-rewards-distributor/src/state.rs | 3 +- 2 files changed, 17 insertions(+), 41 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 5e76a6ed4..f59352985 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -10,6 +10,7 @@ use cw_utils::{one_coin, Duration, Expiration}; use dao_interface::voting::InfoResponse; use std::collections::HashMap; +use std::ops::Add; use crate::helpers::{get_duration_scalar, get_transfer_msg, validate_voting_power_contract}; use crate::hooks::{ @@ -270,16 +271,6 @@ fn execute_fund( mut denom_reward_state: DenomRewardState, amount: Uint128, ) -> Result { - // we derive the period for which the rewards are funded - // by looking at the existing reward emission rate and the funded amount - let funded_period_duration = denom_reward_state - .active_epoch - .emission_rate - .get_funded_period_duration(amount)?; - let funded_period_value = get_duration_scalar(&funded_period_duration); - - denom_reward_state.bump_last_update(&env.block); - // distribution is inactive if it hasn't yet started (i.e. never been // funded) or if it's expired (i.e. all funds have been distributed) let distribution_inactive = @@ -294,44 +285,28 @@ fn execute_fund( // if distribution is inactive, update the distribution start to the current // block so that the new funds start being distributed from now instead of - // from the past + // 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 distribution_inactive { + denom_reward_state.funded_amount = amount; denom_reward_state.active_epoch.started_at = match denom_reward_state.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(env.block.height), Duration::Time(_) => Expiration::AtTime(env.block.time), }; + } else { + denom_reward_state.funded_amount += amount; } - // the duration of rewards period is extended in different ways, - // depending on the current expiration state and current block - denom_reward_state.active_epoch.ends_at = match denom_reward_state.active_epoch.ends_at { - // if this is the first funding of the denom, the new expiration is the - // funded period duration from the current block - Expiration::Never {} => funded_period_duration.after(&env.block), - // otherwise we add the duration units to the existing expiration - Expiration::AtHeight(h) => { - if h <= env.block.height { - // expiration is the funded duration after current block - Expiration::AtHeight(env.block.height + funded_period_value) - } else { - // if the previous expiration had not yet expired, we extend - // the current rewards period by the newly funded duration - Expiration::AtHeight(h + funded_period_value) - } - } - Expiration::AtTime(t) => { - if t <= env.block.time { - // expiration is the funded duration after current block time - Expiration::AtTime(env.block.time.plus_seconds(funded_period_value)) - } else { - // if the previous expiration had not yet expired, we extend - // the current rewards period by the newly funded duration - Expiration::AtTime(t.plus_seconds(funded_period_value)) - } - } - }; - denom_reward_state.funded_amount += amount; + denom_reward_state.active_epoch.ends_at = denom_reward_state.active_epoch.started_at.add( + denom_reward_state + .active_epoch + .emission_rate + .get_funded_period_duration(denom_reward_state.funded_amount)?, + )?; + + denom_reward_state.bump_last_update(&env.block); DENOM_REWARD_STATES.save( deps.storage, diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 650bca7f0..1eb9134a8 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -77,7 +77,8 @@ pub struct DenomRewardState { /// 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, /// destination address for reward clawbacks pub withdraw_destination: Addr, From 91a8420049c8d4d2c9c64ab2bbe0c68ba859fd95 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 17:40:45 -0400 Subject: [PATCH 29/39] added continuous field to denom config deciding how backfilled funding is handled --- .../dao-rewards-distributor/README.md | 3 +- .../schema/dao-rewards-distributor.json | 66 ++++++---- .../dao-rewards-distributor/src/contract.rs | 62 ++++++--- .../dao-rewards-distributor/src/msg.rs | 10 +- .../dao-rewards-distributor/src/rewards.rs | 11 +- .../dao-rewards-distributor/src/state.rs | 46 +++++-- .../src/testing/suite.rs | 28 +++++ .../src/testing/tests.rs | 119 +++++++++++++++++- 8 files changed, 280 insertions(+), 65 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index 68d636673..c5adcc982 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -54,8 +54,7 @@ Currently, a single denom can only have one active distribution configuration. ### Funding the denom to be distributed -Anyone can fund a denom to be distributed as long as that denom -is registered. +Anyone can fund a denom to be distributed as long as that denom is registered. If a denom is not registered and someone attempts to fund it, an error will be thrown. 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 6ada59e9f..f07226f69 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -86,6 +86,13 @@ "denom" ], "properties": { + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": [ + "boolean", + "null" + ] + }, "denom": { "description": "denom to update", "type": "string" @@ -481,12 +488,17 @@ "RegisterDenomMsg": { "type": "object", "required": [ + "continuous", "denom", "emission_rate", "hook_caller", "vp_contract" ], "properties": { + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, "denom": { "description": "denom to register", "allOf": [ @@ -756,11 +768,11 @@ "type": "object", "required": [ "active_epoch", + "continuous", "denom", "funded_amount", "historical_earned_puvp", "hook_caller", - "last_update", "vp_contract", "withdraw_destination" ], @@ -773,6 +785,10 @@ } ] }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, "denom": { "description": "validated denom (native or cw20)", "allOf": [ @@ -782,7 +798,7 @@ ] }, "funded_amount": { - "description": "total amount of rewards funded", + "description": "total amount of rewards funded that will be distributed in the active epoch.", "allOf": [ { "$ref": "#/definitions/Uint128" @@ -805,14 +821,6 @@ } ] }, - "last_update": { - "description": "time when total_earned_puvp was last updated for this denom", - "allOf": [ - { - "$ref": "#/definitions/Expiration" - } - ] - }, "vp_contract": { "description": "address to query the voting power", "allOf": [ @@ -903,6 +911,7 @@ "required": [ "emission_rate", "ends_at", + "last_updated_total_earned_puvp", "started_at", "total_earned_puvp" ], @@ -923,6 +932,14 @@ } ] }, + "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": [ @@ -932,7 +949,7 @@ ] }, "total_earned_puvp": { - "description": "total rewards earned per unit voting power from started_at to last_update", + "description": "total rewards earned per unit voting power from started_at to last_updated_total_earned_puvp", "allOf": [ { "$ref": "#/definitions/Uint256" @@ -1260,11 +1277,11 @@ "type": "object", "required": [ "active_epoch", + "continuous", "denom", "funded_amount", "historical_earned_puvp", "hook_caller", - "last_update", "vp_contract", "withdraw_destination" ], @@ -1277,6 +1294,10 @@ } ] }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, "denom": { "description": "validated denom (native or cw20)", "allOf": [ @@ -1286,7 +1307,7 @@ ] }, "funded_amount": { - "description": "total amount of rewards funded", + "description": "total amount of rewards funded that will be distributed in the active epoch.", "allOf": [ { "$ref": "#/definitions/Uint128" @@ -1309,14 +1330,6 @@ } ] }, - "last_update": { - "description": "time when total_earned_puvp was last updated for this denom", - "allOf": [ - { - "$ref": "#/definitions/Expiration" - } - ] - }, "vp_contract": { "description": "address to query the voting power", "allOf": [ @@ -1375,6 +1388,7 @@ "required": [ "emission_rate", "ends_at", + "last_updated_total_earned_puvp", "started_at", "total_earned_puvp" ], @@ -1395,6 +1409,14 @@ } ] }, + "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": [ @@ -1404,7 +1426,7 @@ ] }, "total_earned_puvp": { - "description": "total rewards earned per unit voting power from started_at to last_update", + "description": "total rewards earned per unit voting power from started_at to last_updated_total_earned_puvp", "allOf": [ { "$ref": "#/definitions/Uint256" diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index f59352985..2f2dfbb74 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -18,7 +18,7 @@ use crate::hooks::{ subscribe_denom_to_hook, unsubscribe_denom_from_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RegisterDenomMsg, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterDenomMsg, RewardEmissionRate, RewardsStateResponse, }; use crate::rewards::{ @@ -61,6 +61,7 @@ pub fn execute( ExecuteMsg::UpdateDenom { denom, emission_rate, + continuous, vp_contract, hook_caller, withdraw_destination, @@ -70,12 +71,13 @@ pub fn execute( info, denom, emission_rate, + continuous, vp_contract, hook_caller, withdraw_destination, ), ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), - ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), + ExecuteMsg::Receive(msg) => execute_receive_cw20(deps, env, info, msg), ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), ExecuteMsg::Withdraw { denom } => execute_withdraw(deps, info, env, denom), } @@ -116,8 +118,9 @@ fn execute_register_denom( ends_at: Expiration::Never {}, emission_rate: msg.emission_rate, total_earned_puvp: Uint256::zero(), + last_updated_total_earned_puvp: Expiration::Never {}, }, - last_update: Expiration::Never {}, + continuous: msg.continuous, vp_contract, hook_caller: hook_caller.clone(), funded_amount: Uint128::zero(), @@ -150,6 +153,7 @@ fn execute_update_denom( info: MessageInfo, denom: String, emission_rate: Option, + continuous: Option, vp_contract: Option, hook_caller: Option, withdraw_destination: Option, @@ -166,6 +170,10 @@ fn execute_update_denom( reward_state.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; } + if let Some(continuous) = continuous { + reward_state.continuous = continuous; + } + if let Some(vp_contract) = vp_contract { reward_state.vp_contract = validate_voting_power_contract(&deps, vp_contract)?; } @@ -213,7 +221,7 @@ fn execute_withdraw( ContractError::RewardsAlreadyDistributed {} ); - // withdraw completes the epoch + // withdraw ends the epoch early reward_state.active_epoch.ends_at = match reward_state.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(env.block.height), Duration::Time(_) => Expiration::AtTime(env.block.time), @@ -240,14 +248,14 @@ fn execute_withdraw( .add_message(clawback_msg)) } -fn execute_receive( +fn execute_receive_cw20( deps: DepsMut, env: Env, info: MessageInfo, wrapper: Cw20ReceiveMsg, ) -> Result { // verify msg - let _msg: ReceiveMsg = from_json(&wrapper.msg)?; + let _msg: ReceiveCw20Msg = from_json(&wrapper.msg)?; let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; execute_fund(deps, env, reward_denom_state, wrapper.amount) @@ -271,24 +279,28 @@ fn execute_fund( mut denom_reward_state: DenomRewardState, amount: Uint128, ) -> Result { - // distribution is inactive if it hasn't yet started (i.e. never been - // funded) or if it's expired (i.e. all funds have been distributed) - let distribution_inactive = + // 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 let Expiration::Never {} = denom_reward_state.active_epoch.started_at { true } else { - denom_reward_state - .active_epoch - .ends_at - .is_expired(&env.block) + !denom_reward_state.continuous + && denom_reward_state + .active_epoch + .ends_at + .is_expired(&env.block) }; - // if distribution is inactive, update the distribution start to 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 distribution_inactive { + // 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 { denom_reward_state.funded_amount = amount; denom_reward_state.active_epoch.started_at = match denom_reward_state.active_epoch.emission_rate.duration { @@ -306,7 +318,17 @@ fn execute_fund( .get_funded_period_duration(denom_reward_state.funded_amount)?, )?; - denom_reward_state.bump_last_update(&env.block); + // if continuous, meaning rewards should have been distributed in the past + // that were not due to lack of sufficient funding, ensure the total rewards + // earned puvp is up to date. + if !restart_distribution && denom_reward_state.continuous { + denom_reward_state.active_epoch.total_earned_puvp = + get_active_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; + } + + denom_reward_state + .active_epoch + .bump_last_updated(&env.block)?; DENOM_REWARD_STATES.save( deps.storage, diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 6e2f8da52..5c31001dc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -41,6 +41,10 @@ pub enum ExecuteMsg { denom: String, /// reward emission rate emission_rate: Option, + /// whether or not reward distribution is continuous: whether rewards + /// should be paused once all funding has been distributed, or if future + /// funding after distribution finishes should be applied to the past. + continuous: Option, /// address to query the voting power vp_contract: Option, /// address that will update the reward split when the voting power @@ -67,6 +71,10 @@ pub struct RegisterDenomMsg { pub denom: UncheckedDenom, /// reward emission rate pub emission_rate: RewardEmissionRate, + /// whether or not reward distribution is continuous: whether rewards should + /// be paused once all funding has been distributed, or if future funding + /// after distribution finishes should be applied to the past. + pub continuous: bool, /// address to query the voting power pub vp_contract: String, /// address that will update the reward split when the voting power @@ -127,7 +135,7 @@ impl RewardEmissionRate { pub enum MigrateMsg {} #[cw_serde] -pub enum ReceiveMsg { +pub enum ReceiveCw20Msg { /// Used to fund this contract with cw20 tokens. Fund {}, } diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index e5bce93b0..b5395f7b0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -21,7 +21,9 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) // first update the active epoch earned puvp value up to the current block denom_reward_state.active_epoch.total_earned_puvp = get_active_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; - denom_reward_state.bump_last_update(&env.block); + denom_reward_state + .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 @@ -85,8 +87,11 @@ pub fn get_active_total_earned_puvp( // 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, &reward_state.last_update)?.into(); + let new_reward_distribution_duration: Uint128 = get_exp_diff( + &last_time_rewards_distributed, + &reward_state.active_epoch.last_updated_total_earned_puvp, + )? + .into(); if prev_total_power.is_zero() { Ok(curr) diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 1eb9134a8..e4d06208b 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -20,6 +20,7 @@ pub const DENOM_REWARD_STATES: Map = Map::new("d_r_s") /// map registered hooks to list of denoms they're registered for pub const REGISTERED_HOOK_DENOMS: Map> = Map::new("r_h_d"); + #[cw_serde] #[derive(Default)] pub struct UserRewardState { @@ -41,8 +42,10 @@ pub struct Epoch { /// distribution period ends. pub ends_at: Expiration, /// 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 + pub last_updated_total_earned_puvp: Expiration, } impl Epoch { @@ -61,6 +64,29 @@ impl Epoch { .checked_multiply_ratio(epoch_duration, emission_rate_duration_scalar) .map_err(|e| StdError::generic_err(e.to_string())) } + + /// 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. + pub fn bump_last_updated(&mut self, current_block: &BlockInfo) -> StdResult<()> { + match (self.emission_rate.duration, self.ends_at) { + (Duration::Height(_), Expiration::AtHeight(ends_at_height)) => { + self.last_updated_total_earned_puvp = + Expiration::AtHeight(std::cmp::min(current_block.height, ends_at_height)); + Ok(()) + } + (Duration::Time(_), Expiration::AtTime(ends_at_time)) => { + self.last_updated_total_earned_puvp = + Expiration::AtTime(std::cmp::min(current_block.time, ends_at_time)); + Ok(()) + } + _ => Err(StdError::generic_err( + "Mismatched emission_rate and ends_at block/time units", + )), + } + } } /// the state of a denom's reward distribution @@ -70,8 +96,10 @@ pub struct DenomRewardState { pub denom: Denom, /// current denom distribution epoch state pub active_epoch: Epoch, - /// time when total_earned_puvp was last updated for this denom - pub last_update: Expiration, + /// whether or not reward distribution is continuous: whether rewards should + /// be paused once all funding has been distributed, or if future funding + /// after distribution finishes should be applied to the past. + pub continuous: bool, /// address to query the voting power pub vp_contract: Addr, /// address that will update the reward split when the voting power @@ -159,22 +187,14 @@ impl DenomRewardState { 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, }; - self.bump_last_update(current_block); - Ok(()) } } impl DenomRewardState { - pub fn bump_last_update(&mut self, current_block: &BlockInfo) { - self.last_update = match self.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), - }; - } - pub fn to_str_denom(&self) -> String { match &self.denom { Denom::Native(denom) => denom.to_string(), @@ -197,7 +217,7 @@ impl DenomRewardState { /// `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.active_epoch.ends_at { - Expiration::Never {} => self.last_update, + Expiration::Never {} => self.active_epoch.last_updated_total_earned_puvp, Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 19e56923d..3c77119cc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -43,6 +43,7 @@ pub struct RewardsConfig { pub denom: UncheckedDenom, pub duration: Duration, pub destination: Option, + pub continuous: bool, } pub struct SuiteBuilder { @@ -64,6 +65,7 @@ impl SuiteBuilder { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Height(10), destination: None, + continuous: true, }, cw4_members: vec![ Member { @@ -537,6 +539,7 @@ impl Suite { 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, @@ -713,6 +716,28 @@ impl Suite { amount: Uint128::new(epoch_rewards), duration: epoch_duration, }), + continuous: None, + 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_continuous(&mut self, denom: &str, continuous: bool) { + let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + denom: denom.to_string(), + emission_rate: None, + continuous: Some(continuous), vp_contract: None, hook_caller: None, withdraw_destination: None, @@ -733,6 +758,7 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { denom: denom.to_string(), emission_rate: None, + continuous: None, vp_contract: Some(vp_contract.to_string()), hook_caller: None, withdraw_destination: None, @@ -753,6 +779,7 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { denom: denom.to_string(), emission_rate: None, + continuous: None, vp_contract: None, hook_caller: Some(hook_caller.to_string()), withdraw_destination: None, @@ -773,6 +800,7 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { denom: denom.to_string(), emission_rate: None, + continuous: None, vp_contract: None, hook_caller: None, withdraw_destination: Some(withdraw_destination.to_string()), diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index e675ef20f..d3ba728e6 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -197,6 +197,7 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Height(10), destination: None, + continuous: true, }) .build(); @@ -526,6 +527,7 @@ fn test_native_dao_cw20_rewards_time_based() { denom: UncheckedDenom::Cw20(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); @@ -583,6 +585,7 @@ fn test_native_dao_rewards_time_based() { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); @@ -644,6 +647,7 @@ fn test_native_dao_rewards_time_based_with_rounding() { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Time(100), destination: None, + continuous: true, }) .with_cw4_members(vec![ Member { @@ -964,6 +968,7 @@ fn test_fund_multiple_denoms() { denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), duration: Duration::Height(100), destination: None, + continuous: true, }, &hook_caller, ); @@ -1160,6 +1165,7 @@ fn test_withdraw_time_based() { denom: UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); @@ -1233,13 +1239,92 @@ fn test_withdraw_time_based() { } #[test] -fn test_withdraw_and_restart() { +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, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // users claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + // 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_denom_funds(DENOM); + + 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_denom_funds_error(DENOM), + ContractError::RewardsAlreadyDistributed {} + ); + + // we assert that pending rewards did not change + 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.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + // fund again + suite.fund_distributor_native(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, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 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(); @@ -1355,6 +1440,7 @@ fn test_register_duplicate_denom() { denom: cw20::UncheckedDenom::Native(DENOM.to_string()), duration: Duration::Height(100), destination: None, + continuous: true, }; suite.register_reward_denom(reward_config, &hook_caller); } @@ -1401,8 +1487,16 @@ fn test_fund_unauthorized() { } #[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; @@ -1453,13 +1547,14 @@ 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(); @@ -1527,6 +1622,7 @@ fn test_fund_cw20_time_based_pre_expiration() { denom: UncheckedDenom::Cw20(DENOM.to_string()), duration: Duration::Time(10), destination: None, + continuous: true, }) .build(); @@ -1705,6 +1801,21 @@ fn test_native_dao_rewards_entry_edge_case() { suite.stake_native_tokens(ADDR2, addr2_balance); } +#[test] +fn test_update_continuous() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.update_continuous(DENOM, true); + + let denom = suite.get_denom_reward_state(DENOM); + assert!(denom.continuous); + + suite.update_continuous(DENOM, false); + + let denom = suite.get_denom_reward_state(DENOM); + assert!(!denom.continuous); +} + #[test] fn test_update_owner() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); From fb994827adcb7e92462b742b68bf918d0edd445b Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 19:27:24 -0400 Subject: [PATCH 30/39] added ability to fund a native denom during registration --- .../schema/dao-rewards-distributor.json | 70 +++--- .../dao-rewards-distributor/src/contract.rs | 123 +++++++---- .../dao-rewards-distributor/src/error.rs | 7 + .../dao-rewards-distributor/src/msg.rs | 60 +---- .../dao-rewards-distributor/src/state.rs | 151 ++++++++----- .../src/testing/suite.rs | 54 +++-- .../src/testing/tests.rs | 206 +++++++++++++++--- 7 files changed, 439 insertions(+), 232 deletions(-) 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 f07226f69..ad39705bc 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -64,11 +64,11 @@ "description": "registers a new reward denom", "type": "object", "required": [ - "register_denom" + "register" ], "properties": { - "register_denom": { - "$ref": "#/definitions/RegisterDenomMsg" + "register": { + "$ref": "#/definitions/RegisterMsg" } }, "additionalProperties": false @@ -77,10 +77,10 @@ "description": "updates the config for a registered denom", "type": "object", "required": [ - "update_denom" + "update" ], "properties": { - "update_denom": { + "update": { "type": "object", "required": [ "denom" @@ -485,7 +485,7 @@ } ] }, - "RegisterDenomMsg": { + "RegisterMsg": { "type": "object", "required": [ "continuous", @@ -697,10 +697,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" @@ -1055,33 +1055,6 @@ } } }, - "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", @@ -1223,6 +1196,33 @@ } } }, + "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" + } + } + }, "rewards_state": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "RewardsStateResponse", diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 2f2dfbb74..e44471113 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,12 +1,12 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - ensure, from_json, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, - StdResult, Uint128, Uint256, + ensure, from_json, to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Uint128, Uint256, }; use cw2::{get_contract_version, set_contract_version}; -use cw20::Cw20ReceiveMsg; -use cw_utils::{one_coin, Duration, Expiration}; +use cw20::{Cw20ReceiveMsg, UncheckedDenom}; +use cw_utils::{must_pay, one_coin, Duration, Expiration}; use dao_interface::voting::InfoResponse; use std::collections::HashMap; @@ -18,13 +18,15 @@ use crate::hooks::{ subscribe_denom_to_hook, unsubscribe_denom_from_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterDenomMsg, - RewardEmissionRate, RewardsStateResponse, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterMsg, + RewardsStateResponse, }; use crate::rewards::{ get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, }; -use crate::state::{DenomRewardState, Epoch, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::state::{ + DenomRewardState, Epoch, RewardEmissionRate, DENOM_REWARD_STATES, USER_REWARD_STATES, +}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); @@ -57,15 +59,18 @@ pub fn execute( ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), - ExecuteMsg::RegisterDenom(register_msg) => execute_register_denom(deps, info, register_msg), - ExecuteMsg::UpdateDenom { + ExecuteMsg::Receive(msg) => execute_receive_cw20(deps, env, info, msg), + ExecuteMsg::Register(register_msg) => { + execute_register_native(deps, env, info, register_msg) + } + ExecuteMsg::Update { denom, emission_rate, continuous, vp_contract, hook_caller, withdraw_destination, - } => execute_update_denom( + } => execute_update( deps, env, info, @@ -77,22 +82,63 @@ pub fn execute( withdraw_destination, ), ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), - ExecuteMsg::Receive(msg) => execute_receive_cw20(deps, env, info, msg), ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), ExecuteMsg::Withdraw { denom } => execute_withdraw(deps, info, env, denom), } } -/// 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_denom( +fn execute_receive_cw20( deps: DepsMut, + env: Env, info: MessageInfo, - msg: RegisterDenomMsg, + wrapper: Cw20ReceiveMsg, +) -> Result { + // verify msg + let msg: ReceiveCw20Msg = from_json(&wrapper.msg)?; + + match msg { + ReceiveCw20Msg::Fund {} => { + let reward_denom_state = + DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; + execute_fund(deps, env, reward_denom_state, wrapper.amount) + } + } +} + +/// registers a new native denom for rewards distribution. +fn execute_register_native( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: RegisterMsg, +) -> Result { + let mut amount: Option = None; + + // if native funds provided, ensure they are for this native 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 &msg.denom { + UncheckedDenom::Native(denom) => amount = Some(must_pay(&info, denom)?), + UncheckedDenom::Cw20(_) => return Err(ContractError::NoFundsOnCw20Register {}), + } + } + + execute_register(deps, env, info.sender, msg, amount) +} + +/// registers a new denom for rewards distribution. only the owner can register +/// a new denom. a denom can only be registered once. if funds provided, will +/// start distributing rewards immediately. +fn execute_register( + deps: DepsMut, + env: Env, + sender: Addr, + msg: RegisterMsg, + funds: Option, ) -> Result { // only the owner can register a new denom - cw_ownable::assert_owner(deps.storage, &info.sender)?; + cw_ownable::assert_owner(deps.storage, &sender)?; // Reward duration must be greater than 0 seconds/blocks if get_duration_scalar(&msg.emission_rate.duration) == 0 { @@ -107,7 +153,7 @@ fn execute_register_denom( // if withdraw destination is specified, we validate it Some(addr) => deps.api.addr_validate(&addr)?, // otherwise default to the owner - None => info.sender, + None => sender, }; // Initialize the reward state @@ -135,19 +181,30 @@ fn execute_register_denom( str_denom.to_string(), |existing| match existing { Some(_) => Err(ContractError::DenomAlreadyRegistered {}), - None => Ok(reward_state), + None => Ok(reward_state.clone()), }, )?; // update the registered hooks to include the new denom - subscribe_denom_to_hook(deps.storage, str_denom, hook_caller.clone())?; + subscribe_denom_to_hook(deps.storage, str_denom.clone(), hook_caller.clone())?; + + let mut response = Response::new() + .add_attribute("action", "register_denom") + .add_attribute("denom", &str_denom); - Ok(Response::default().add_attribute("action", "register_reward_denom")) + // if funds provided, begin distributing rewards. if no funds, do nothing + // and leave registered denom with no funding, to be funded later. + if let Some(funds) = funds { + execute_fund(deps, env, reward_state, funds)?; + response = response.add_attribute("funded_amount", funds); + } + + Ok(response) } /// updates the config for a registered denom #[allow(clippy::too_many_arguments)] -fn execute_update_denom( +fn execute_update( deps: DepsMut, env: Env, info: MessageInfo, @@ -245,22 +302,12 @@ fn execute_withdraw( Ok(Response::new() .add_attribute("action", "withdraw") + .add_attribute("denom", denom) + .add_attribute("amount_withdrawn", clawback_amount) + .add_attribute("amount_distributed", rewards_distributed) .add_message(clawback_msg)) } -fn execute_receive_cw20( - deps: DepsMut, - env: Env, - info: MessageInfo, - wrapper: Cw20ReceiveMsg, -) -> Result { - // verify msg - let _msg: ReceiveCw20Msg = from_json(&wrapper.msg)?; - - let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; - execute_fund(deps, env, reward_denom_state, wrapper.amount) -} - fn execute_fund_native( deps: DepsMut, env: Env, @@ -336,7 +383,7 @@ fn execute_fund( &denom_reward_state, )?; - Ok(Response::default() + Ok(Response::new() .add_attribute("action", "fund") .add_attribute("fund_denom", denom_reward_state.to_str_denom()) .add_attribute("fund_amount", amount)) @@ -390,7 +437,7 @@ fn execute_update_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())) + Ok(Response::new().add_attributes(ownership.into_attributes())) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -398,7 +445,7 @@ 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 } => { + QueryMsg::PendingRewards { address } => { Ok(to_json_binary(&query_pending_rewards(deps, env, address)?)?) } QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index e6f6c51f0..bc81255ab 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::{OverflowError, StdError}; +use cw_utils::PaymentError; use thiserror::Error; #[derive(Error, Debug, PartialEq)] @@ -15,12 +16,18 @@ pub enum ContractError { #[error(transparent)] Overflow(#[from] OverflowError), + #[error(transparent)] + Payment(#[from] PaymentError), + #[error("Invalid Cw20")] InvalidCw20 {}, #[error("Invalid funds")] InvalidFunds {}, + #[error("You cannot send native funds when registering a CW20")] + NoFundsOnCw20Register {}, + #[error("Staking change hook sender is not staking contract")] InvalidHookSender {}, diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 5c31001dc..aab5543bd 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -1,11 +1,10 @@ use std::collections::HashMap; use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Decimal, StdError, StdResult, Uint128, Uint64}; +use cosmwasm_std::Uint128; use cw20::{Cw20ReceiveMsg, UncheckedDenom}; use cw4::MemberChangedHookMsg; use cw_ownable::cw_ownable_execute; -use cw_utils::Duration; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; use dao_interface::voting::InfoResponse; @@ -14,7 +13,7 @@ use dao_interface::voting::InfoResponse; pub use cw_controllers::ClaimsResponse; pub use cw_ownable::Ownership; -use crate::state::DenomRewardState; +use crate::state::{DenomRewardState, RewardEmissionRate}; #[cw_serde] pub struct InstantiateMsg { @@ -34,9 +33,9 @@ pub enum ExecuteMsg { /// Called when tokens are staked or unstaked. StakeChangeHook(StakeChangedHookMsg), /// registers a new reward denom - RegisterDenom(RegisterDenomMsg), + Register(RegisterMsg), /// updates the config for a registered denom - UpdateDenom { + Update { /// denom to update denom: String, /// reward emission rate @@ -66,7 +65,7 @@ pub enum ExecuteMsg { } #[cw_serde] -pub struct RegisterDenomMsg { +pub struct RegisterMsg { /// denom to register pub denom: UncheckedDenom, /// reward emission rate @@ -84,53 +83,6 @@ pub struct RegisterDenomMsg { pub withdraw_destination: Option, } -/// defines how many tokens (amount) should be distributed per amount of time -/// (duration). e.g. 5udenom per hour. -#[cw_serde] -pub struct RewardEmissionRate { - /// amount of tokens to distribute per amount of time - pub amount: Uint128, - /// duration of time to distribute amount - pub duration: Duration, -} - -impl RewardEmissionRate { - // 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 { - // if amount being distributed is 0 (rewards are paused), we return the max duration - if self.amount.is_zero() { - return match self.duration { - Duration::Height(_) => Ok(Duration::Height(u64::MAX)), - Duration::Time(_) => Ok(Duration::Time(u64::MAX)), - }; - } - - let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, self.amount); - - let funded_duration = match self.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(funded_duration) - } -} - #[cw_serde] pub enum MigrateMsg {} @@ -151,7 +103,7 @@ pub enum QueryMsg { RewardsState {}, /// Returns the pending rewards for the given address. #[returns(PendingRewardsResponse)] - GetPendingRewards { address: String }, + PendingRewards { address: String }, /// Returns information about the ownership of this contract. #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] Ownership {}, diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index e4d06208b..4e94c9b84 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -1,16 +1,14 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{ - ensure, Addr, BlockInfo, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, + ensure, Addr, BlockInfo, Decimal, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, + Uint64, }; use cw20::{Denom, Expiration}; use cw_storage_plus::Map; use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; -use crate::{ - helpers::get_exp_diff, msg::RewardEmissionRate, rewards::get_active_total_earned_puvp, - ContractError, -}; +use crate::{helpers::get_exp_diff, 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"); @@ -31,6 +29,53 @@ pub struct UserRewardState { pub accounted_denom_rewards_puvp: HashMap, } +/// defines how many tokens (amount) should be distributed per amount of time +/// (duration). e.g. 5udenom per hour. +#[cw_serde] +pub struct RewardEmissionRate { + /// amount of tokens to distribute per amount of time + pub amount: Uint128, + /// duration of time to distribute amount + pub duration: Duration, +} + +impl RewardEmissionRate { + // 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 { + // if amount being distributed is 0 (rewards are paused), we return the max duration + if self.amount.is_zero() { + return match self.duration { + Duration::Height(_) => Ok(Duration::Height(u64::MAX)), + Duration::Time(_) => Ok(Duration::Time(u64::MAX)), + }; + } + + let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, self.amount); + + let funded_duration = match self.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(funded_duration) + } +} + #[cw_serde] pub struct Epoch { /// reward emission rate @@ -117,6 +162,53 @@ pub struct DenomRewardState { } impl DenomRewardState { + pub fn to_str_denom(&self) -> String { + match &self.denom { + Denom::Native(denom) => denom.to_string(), + Denom::Cw20(address) => address.to_string(), + } + } + + /// 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 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. + /// - If current block respective value is before the end, rewards are still + /// being distributed. We therefore return the current block `height` or + /// `time`, as this block is the most recent time rewards were + /// distributed. + /// - If current block respective value is after the end, rewards are no + /// longer being distributed. We therefore return the end `height` or + /// `time`, as that was the last date where rewards were distributed. + pub fn get_latest_reward_distribution_time(&self, current_block: &BlockInfo) -> Expiration { + match self.active_epoch.ends_at { + Expiration::Never {} => self.active_epoch.last_updated_total_earned_puvp, + Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), + Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), + } + } + + /// Returns `ContractError::RewardPeriodNotFinished` if the period finish + /// expiration is of either `AtHeight` or `AtTime` variant and is earlier + /// than the current block height or time respectively. + pub fn validate_period_finish_expiration_if_set( + &self, + current_block: &BlockInfo, + ) -> Result<(), ContractError> { + match self.active_epoch.ends_at { + Expiration::AtHeight(_) | Expiration::AtTime(_) => { + ensure!( + self.active_epoch.ends_at.is_expired(current_block), + ContractError::RewardPeriodNotFinished {} + ); + Ok(()) + } + Expiration::Never {} => Ok(()), + } + } + /// Finish current epoch early and start a new one with a new emission rate. pub fn transition_epoch( &mut self, @@ -193,52 +285,3 @@ impl DenomRewardState { Ok(()) } } - -impl DenomRewardState { - pub fn to_str_denom(&self) -> String { - match &self.denom { - Denom::Native(denom) => denom.to_string(), - Denom::Cw20(address) => address.to_string(), - } - } - - /// 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 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. - /// - If current block respective value is before the end, rewards are still - /// being distributed. We therefore return the current block `height` or - /// `time`, as this block is the most recent time rewards were - /// distributed. - /// - If current block respective value is after the end, rewards are no - /// longer being distributed. We therefore return the end `height` or - /// `time`, as that was the last date where rewards were distributed. - pub fn get_latest_reward_distribution_time(&self, current_block: &BlockInfo) -> Expiration { - match self.active_epoch.ends_at { - Expiration::Never {} => self.active_epoch.last_updated_total_earned_puvp, - Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), - Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), - } - } - - /// Returns `ContractError::RewardPeriodNotFinished` if the period finish - /// expiration is of either `AtHeight` or `AtTime` variant and is earlier - /// than the current block height or time respectively. - pub fn validate_period_finish_expiration_if_set( - &self, - current_block: &BlockInfo, - ) -> Result<(), ContractError> { - match self.active_epoch.ends_at { - Expiration::AtHeight(_) | Expiration::AtTime(_) => { - ensure!( - self.active_epoch.ends_at.is_expired(current_block), - ContractError::RewardPeriodNotFinished {} - ); - Ok(()) - } - Expiration::Never {} => Ok(()), - } - } -} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 3c77119cc..524230730 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -3,7 +3,6 @@ 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}; @@ -11,10 +10,10 @@ use cw_utils::Duration; use crate::{ msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RegisterDenomMsg, - RewardEmissionRate, RewardsStateResponse, + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterMsg, + RewardsStateResponse, }, - state::DenomRewardState, + state::{DenomRewardState, RewardEmissionRate}, testing::cw20_setup::instantiate_cw20, ContractError, }; @@ -253,6 +252,7 @@ impl SuiteBuilder { suite_built.register_reward_denom( self.rewards_config.clone(), suite_built.voting_power_addr.to_string().as_ref(), + None, ); match self.rewards_config.denom { UncheckedDenom::Native(_) => { @@ -290,6 +290,7 @@ impl SuiteBuilder { suite_built.register_reward_denom( self.rewards_config.clone(), suite_built.staking_addr.to_string().as_ref(), + None, ); match &self.rewards_config.denom { UncheckedDenom::Native(_) => { @@ -455,20 +456,20 @@ impl Suite { assert_eq!(units, expected); } - pub fn assert_pending_rewards(&mut self, address: &str, _denom: &str, expected: u128) { + pub fn assert_pending_rewards(&mut self, address: &str, denom: &str, expected: u128) { let res: PendingRewardsResponse = self .app .borrow_mut() .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::GetPendingRewards { + &QueryMsg::PendingRewards { address: address.to_string(), }, ) .unwrap(); - let pending = res.pending_rewards.get(self.reward_denom.as_str()).unwrap(); + let pending = res.pending_rewards.get(denom).unwrap(); assert_eq!( pending, @@ -532,8 +533,13 @@ impl Suite { .unwrap(); } - pub fn register_reward_denom(&mut self, reward_config: RewardsConfig, hook_caller: &str) { - let register_reward_denom_msg = ExecuteMsg::RegisterDenom(RegisterDenomMsg { + pub fn register_reward_denom( + &mut self, + reward_config: RewardsConfig, + hook_caller: &str, + funds: Option, + ) { + let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { denom: reward_config.denom.clone(), emission_rate: RewardEmissionRate { amount: Uint128::new(reward_config.amount), @@ -545,13 +551,23 @@ impl Suite { 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 .borrow_mut() .execute_contract( self.owner.clone().unwrap(), self.distribution_contract.clone(), ®ister_reward_denom_msg, - &[], + &send_funds, ) .unwrap(); } @@ -569,11 +585,7 @@ 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_coin(&mut self, coin: Cw20Coin, name: &str) -> Addr { cw20_setup::instantiate_cw20(self.app.borrow_mut(), name, vec![coin]) } @@ -594,7 +606,7 @@ impl Suite { 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(); + let fund_sub_msg = to_json_binary(&ReceiveCw20Msg::Fund {}).unwrap(); self.app .execute_contract( Addr::unchecked(OWNER), @@ -710,7 +722,7 @@ impl Suite { epoch_duration: Duration, epoch_rewards: u128, ) { - let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + let msg: ExecuteMsg = ExecuteMsg::Update { denom: denom.to_string(), emission_rate: Some(RewardEmissionRate { amount: Uint128::new(epoch_rewards), @@ -734,7 +746,7 @@ impl Suite { } pub fn update_continuous(&mut self, denom: &str, continuous: bool) { - let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + let msg: ExecuteMsg = ExecuteMsg::Update { denom: denom.to_string(), emission_rate: None, continuous: Some(continuous), @@ -755,7 +767,7 @@ impl Suite { } pub fn update_vp_contract(&mut self, denom: &str, vp_contract: &str) { - let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + let msg: ExecuteMsg = ExecuteMsg::Update { denom: denom.to_string(), emission_rate: None, continuous: None, @@ -776,7 +788,7 @@ impl Suite { } pub fn update_hook_caller(&mut self, denom: &str, hook_caller: &str) { - let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + let msg: ExecuteMsg = ExecuteMsg::Update { denom: denom.to_string(), emission_rate: None, continuous: None, @@ -797,7 +809,7 @@ impl Suite { } pub fn update_withdraw_destination(&mut self, denom: &str, withdraw_destination: &str) { - let msg: ExecuteMsg = ExecuteMsg::UpdateDenom { + let msg: ExecuteMsg = ExecuteMsg::Update { denom: denom.to_string(), emission_rate: None, continuous: None, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index d3ba728e6..009f6baf1 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -1,12 +1,14 @@ 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 cw20::{Cw20Coin, Expiration, UncheckedDenom}; use cw4::Member; use cw_multi_test::Executor; use cw_utils::Duration; +use crate::msg::RegisterMsg; +use crate::state::{Epoch, RewardEmissionRate}; use crate::testing::native_setup::setup_native_token_test; use crate::ContractError; use crate::{ @@ -531,6 +533,8 @@ fn test_native_dao_cw20_rewards_time_based() { }) .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))); @@ -538,21 +542,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, cw20_denom, 5_000_000); + suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 2_500_000); // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); - suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); - suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR1, cw20_denom, 10_000_000); + suite.assert_pending_rewards(ADDR2, cw20_denom, 5_000_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 5_000_000); // ADDR1 claims rewards - suite.claim_rewards(ADDR1, suite.reward_denom.clone().as_str()); + suite.claim_rewards(ADDR1, cw20_denom); suite.assert_cw20_balance(ADDR1, 10_000_000); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, cw20_denom, 0); // ADDR2 and ADDR3 unstake their stake suite.unstake_cw20_tokens(50, ADDR2); @@ -563,13 +567,13 @@ 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, cw20_denom, 10_000_000); + suite.assert_pending_rewards(ADDR2, cw20_denom, 5_000_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 5_000_000); // ADDR2 and ADDR3 wake up and claim their rewards - suite.claim_rewards(ADDR2, suite.reward_denom.clone().as_str()); - suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + suite.claim_rewards(ADDR2, cw20_denom); + suite.claim_rewards(ADDR3, cw20_denom); suite.assert_cw20_balance(ADDR1, 10_000_000); suite.assert_cw20_balance(ADDR2, 5_000_000); @@ -971,6 +975,7 @@ fn test_fund_multiple_denoms() { continuous: true, }, &hook_caller, + None, ); suite @@ -996,7 +1001,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"); + let new_cw20_mint = suite.mint_cw20_coin(unregistered_cw20_coin.clone(), "newcoin"); println!("[FUNDING EVENT] cw20 funding: {}", unregistered_cw20_coin); let fund_sub_msg = to_json_binary(&"not_the_fund: {}").unwrap(); @@ -1442,7 +1447,7 @@ fn test_register_duplicate_denom() { destination: None, continuous: true, }; - suite.register_reward_denom(reward_config, &hook_caller); + suite.register_reward_denom(reward_config, &hook_caller, None); } #[test] @@ -1558,6 +1563,8 @@ fn test_fund_cw20_time_based_post_expiration_not_continuous() { }) .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); @@ -1569,9 +1576,9 @@ fn test_fund_cw20_time_based_post_expiration_not_continuous() { // 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, cw20_denom, 5_000_000); + suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 2_500_000); // ADDR2 unstake their stake suite.unstake_cw20_tokens(50, ADDR2); @@ -1583,9 +1590,9 @@ fn test_fund_cw20_time_based_post_expiration_not_continuous() { // 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, cw20_denom, 65_000_000); + suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 30_000_000); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -1626,6 +1633,8 @@ fn test_fund_cw20_time_based_pre_expiration() { }) .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); @@ -1637,9 +1646,9 @@ 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, cw20_denom, 5_000_000); + suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 2_500_000); // ADDR2 unstake their stake suite.unstake_cw20_tokens(50, ADDR2); @@ -1650,9 +1659,9 @@ fn test_fund_cw20_time_based_pre_expiration() { // 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, cw20_denom, 58_333_333); + suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); + suite.assert_pending_rewards(ADDR3, cw20_denom, 26_666_666); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -1801,6 +1810,143 @@ fn test_native_dao_rewards_entry_edge_case() { suite.stake_native_tokens(ADDR2, addr2_balance); } +#[test] +fn test_fund_native_on_register() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let alt_coin = coin(100_000_000, ALT_DENOM); + suite.mint_native_coin(alt_coin.clone(), OWNER); + let hook_caller = suite.staking_addr.to_string(); + + suite.register_reward_denom( + RewardsConfig { + amount: 1000, + denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), + duration: Duration::Height(100), + destination: None, + continuous: true, + }, + &hook_caller, + Some(alt_coin.amount), + ); + + let denom = suite.get_denom_reward_state(ALT_DENOM); + assert_eq!(denom.funded_amount, alt_coin.amount); + assert_eq!( + denom.active_epoch, + Epoch { + emission_rate: RewardEmissionRate { + amount: Uint128::new(1000), + duration: Duration::Height(100), + }, + 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, ALT_DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, ALT_DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, ALT_DENOM, 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(coin(100, ALT_DENOM), OWNER); + + let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { + denom: cw20::UncheckedDenom::Native(DENOM.to_string()), + emission_rate: RewardEmissionRate { + 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, + }); + + // register native denom with other denom provided + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + ®ister_reward_denom_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(coin(100, DENOM), OWNER); + suite.mint_native_coin(coin(100, ALT_DENOM), OWNER); + + let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { + denom: cw20::UncheckedDenom::Native(DENOM.to_string()), + emission_rate: RewardEmissionRate { + 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, + }); + + // register native denom with 0 amount + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + ®ister_reward_denom_msg, + &[coin(100, DENOM), coin(100, ALT_DENOM)], + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "You cannot send native funds when registering a CW20")] +fn test_fund_native_on_register_cw20() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.mint_native_coin(coin(100, DENOM), OWNER); + + let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { + denom: cw20::UncheckedDenom::Cw20(DENOM.to_string()), + emission_rate: RewardEmissionRate { + 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, + }); + + // register cw20 denom with native funds provided + suite + .app + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + ®ister_reward_denom_msg, + &coins(100, DENOM), + ) + .unwrap(); +} + #[test] fn test_update_continuous() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); @@ -1851,7 +1997,7 @@ fn test_update_hook_caller() { } #[test] -fn update_withdraw_destination() { +fn test_update_withdraw_destination() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); let new_withdraw_destination = "new_withdraw_destination"; From c7c11a898f9dff034da96584b2a2b68a5169faa9 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 19:36:24 -0400 Subject: [PATCH 31/39] improved test coverage --- .../dao-rewards-distributor/src/contract.rs | 10 ++++-- .../dao-rewards-distributor/src/state.rs | 31 ++++--------------- .../src/testing/suite.rs | 19 ++++++------ .../src/testing/tests.rs | 22 +++++++++++++ 4 files changed, 46 insertions(+), 36 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index e44471113..ab0acfe61 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -18,8 +18,8 @@ use crate::hooks::{ subscribe_denom_to_hook, unsubscribe_denom_from_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterMsg, - RewardsStateResponse, + ExecuteMsg, InstantiateMsg, MigrateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, + RegisterMsg, RewardsStateResponse, }; use crate::rewards::{ get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, @@ -516,3 +516,9 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 4e94c9b84..2688456bf 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -1,14 +1,13 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{ - ensure, Addr, BlockInfo, Decimal, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, - Uint64, + Addr, BlockInfo, Decimal, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, Uint64, }; use cw20::{Denom, Expiration}; use cw_storage_plus::Map; use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; -use crate::{helpers::get_exp_diff, rewards::get_active_total_earned_puvp, ContractError}; +use crate::{helpers::get_exp_diff, rewards::get_active_total_earned_puvp}; /// map user address to their unique reward state pub const USER_REWARD_STATES: Map = Map::new("u_r_s"); @@ -127,9 +126,10 @@ impl Epoch { Expiration::AtTime(std::cmp::min(current_block.time, ends_at_time)); Ok(()) } - _ => Err(StdError::generic_err( - "Mismatched emission_rate and ends_at block/time units", - )), + _ => Err(StdError::generic_err(format!( + "incompatible emission_rate.duration ({:?}) and ends_at ({:?}) values", + self.emission_rate.duration, self.ends_at + ))), } } } @@ -190,25 +190,6 @@ impl DenomRewardState { } } - /// Returns `ContractError::RewardPeriodNotFinished` if the period finish - /// expiration is of either `AtHeight` or `AtTime` variant and is earlier - /// than the current block height or time respectively. - pub fn validate_period_finish_expiration_if_set( - &self, - current_block: &BlockInfo, - ) -> Result<(), ContractError> { - match self.active_epoch.ends_at { - Expiration::AtHeight(_) | Expiration::AtTime(_) => { - ensure!( - self.active_epoch.ends_at.is_expired(current_block), - ContractError::RewardPeriodNotFinished {} - ); - Ok(()) - } - Expiration::Never {} => Ok(()), - } - } - /// Finish current epoch early and start a new one with a new emission rate. pub fn transition_epoch( &mut self, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 524230730..2e9fd48d4 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -5,8 +5,9 @@ use cosmwasm_std::{coin, coins, to_json_binary, Addr, Coin, Empty, Timestamp, Ui use cw20::{Cw20Coin, Expiration, UncheckedDenom}; 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::{ @@ -374,14 +375,6 @@ 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 { self.app .wrap() @@ -415,6 +408,14 @@ impl Suite { .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 diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 009f6baf1..313d3f0a3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -2,10 +2,12 @@ use std::borrow::BorrowMut; 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::RegisterMsg; use crate::state::{Epoch, RewardEmissionRate}; @@ -122,6 +124,9 @@ fn test_native_dao_rewards_update_reward_rate() { // between ADDR1 (2/3rds) and ADDR3 (1/3rd) suite.update_reward_emission_rate(DENOM, Duration::Height(10), 1000); + // update with the same rate does nothing + suite.update_reward_emission_rate(DENOM, Duration::Height(10), 1000); + // skip 1/10th of the time suite.skip_blocks(100_000); @@ -2006,3 +2011,20 @@ fn test_update_withdraw_destination() { let denom = suite.get_denom_reward_state(DENOM); assert_eq!(denom.withdraw_destination, new_withdraw_destination); } + +#[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(), + } + } + ); +} From e6511cdec6fd526689ba34c72006cbd90e5dea94 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 19:49:58 -0400 Subject: [PATCH 32/39] added pagination to list queries --- .../schema/dao-rewards-distributor.json | 448 ++++++++++-------- .../dao-rewards-distributor/src/contract.rs | 87 +++- .../dao-rewards-distributor/src/msg.rs | 38 +- .../src/testing/suite.rs | 49 +- .../src/testing/tests.rs | 12 +- 5 files changed, 383 insertions(+), 251 deletions(-) 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 ad39705bc..32425b797 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -680,13 +680,13 @@ "additionalProperties": false }, { - "description": "Returns the state of all 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 } @@ -708,6 +708,20 @@ "properties": { "address": { "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] } }, "additionalProperties": false @@ -716,34 +730,50 @@ "additionalProperties": false }, { - "description": "Returns information about the ownership of this contract.", + "description": "Returns the state of the given denom reward distribution.", "type": "object", "required": [ - "ownership" + "denom" ], "properties": { - "ownership": { + "denom": { "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, "additionalProperties": false } }, "additionalProperties": false }, { - "description": "Returns the state of the given denom reward distribution.", + "description": "Returns the state of all the registered reward distributions.", "type": "object", "required": [ - "denom_reward_state" + "denoms" ], "properties": { - "denom_reward_state": { + "denoms": { "type": "object", - "required": [ - "denom" - ], "properties": { - "denom": { - "type": "string" + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] } }, "additionalProperties": false @@ -761,7 +791,7 @@ }, "sudo": null, "responses": { - "denom_reward_state": { + "denom": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "DenomRewardState", "description": "the state of a denom's reward distribution", @@ -1055,183 +1085,15 @@ } } }, - "info": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "InfoResponse", - "type": "object", - "required": [ - "info" - ], - "properties": { - "info": { - "$ref": "#/definitions/ContractVersion" - } - }, - "additionalProperties": false, - "definitions": { - "ContractVersion": { - "type": "object", - "required": [ - "contract", - "version" - ], - "properties": { - "contract": { - "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", - "type": "string" - }, - "version": { - "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", - "type": "string" - } - }, - "additionalProperties": false - } - } - }, - "ownership": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Ownership_for_Addr", - "description": "The contract's ownership info", - "type": "object", - "properties": { - "owner": { - "description": "The contract's current owner. `None` if the ownership has been renounced.", - "anyOf": [ - { - "$ref": "#/definitions/Addr" - }, - { - "type": "null" - } - ] - }, - "pending_expiry": { - "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", - "anyOf": [ - { - "$ref": "#/definitions/Expiration" - }, - { - "type": "null" - } - ] - }, - "pending_owner": { - "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", - "anyOf": [ - { - "$ref": "#/definitions/Addr" - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false, - "definitions": { - "Addr": { - "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", - "type": "string" - }, - "Expiration": { - "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", - "oneOf": [ - { - "description": "AtHeight will expire when `env.block.height` >= height", - "type": "object", - "required": [ - "at_height" - ], - "properties": { - "at_height": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "additionalProperties": false - }, - { - "description": "AtTime will expire when `env.block.time` >= time", - "type": "object", - "required": [ - "at_time" - ], - "properties": { - "at_time": { - "$ref": "#/definitions/Timestamp" - } - }, - "additionalProperties": false - }, - { - "description": "Never will never expire. Used to express the empty variant", - "type": "object", - "required": [ - "never" - ], - "properties": { - "never": { - "type": "object", - "additionalProperties": false - } - }, - "additionalProperties": false - } - ] - }, - "Timestamp": { - "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", - "allOf": [ - { - "$ref": "#/definitions/Uint64" - } - ] - }, - "Uint64": { - "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", - "type": "string" - } - } - }, - "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" - } - } - }, - "rewards_state": { + "denoms": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RewardsStateResponse", + "title": "DenomsResponse", "type": "object", "required": [ - "rewards" + "denoms" ], "properties": { - "rewards": { + "denoms": { "type": "array", "items": { "$ref": "#/definitions/DenomRewardState" @@ -1531,6 +1393,218 @@ "type": "string" } } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "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/DenomPendingRewards" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "DenomPendingRewards": { + "type": "object", + "required": [ + "denom", + "pending_rewards" + ], + "properties": { + "denom": { + "$ref": "#/definitions/Denom" + }, + "pending_rewards": { + "$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 ab0acfe61..62adb790e 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -6,10 +6,10 @@ use cosmwasm_std::{ }; use cw2::{get_contract_version, set_contract_version}; use cw20::{Cw20ReceiveMsg, UncheckedDenom}; +use cw_storage_plus::Bound; use cw_utils::{must_pay, one_coin, Duration, Expiration}; use dao_interface::voting::InfoResponse; -use std::collections::HashMap; use std::ops::Add; use crate::helpers::{get_duration_scalar, get_transfer_msg, validate_voting_power_contract}; @@ -18,8 +18,8 @@ use crate::hooks::{ subscribe_denom_to_hook, unsubscribe_denom_from_hook, }; use crate::msg::{ - ExecuteMsg, InstantiateMsg, MigrateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, - RegisterMsg, RewardsStateResponse, + DenomPendingRewards, DenomsResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, + PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterMsg, }; use crate::rewards::{ get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, @@ -32,6 +32,9 @@ 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, @@ -444,15 +447,25 @@ fn execute_update_owner( 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::PendingRewards { 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 } => { + QueryMsg::PendingRewards { + address, + start_after, + limit, + } => Ok(to_json_binary(&query_pending_rewards( + deps, + env, + address, + start_after, + limit, + )?)?), + QueryMsg::Denom { denom } => { let state = DENOM_REWARD_STATES.load(deps.storage, denom)?; Ok(to_json_binary(&state)?) } + QueryMsg::Denoms { start_after, limit } => { + Ok(to_json_binary(&query_denoms(deps, start_after, limit)?)?) + } } } @@ -461,30 +474,34 @@ 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 }) -} - /// returns the pending rewards for a given address that are ready to be claimed. -fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult { +fn query_pending_rewards( + deps: Deps, + env: Env, + addr: String, + start_after: Option, + limit: Option, +) -> StdResult { let addr = deps.api.addr_validate(&addr)?; + + 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_REWARD_STATES .load(deps.storage, addr.clone()) .unwrap_or_default(); - let reward_states = DENOM_REWARD_STATES - .range(deps.storage, None, None, Order::Ascending) + + let denoms = DENOM_REWARD_STATES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) .collect::>>()?; - let mut pending_rewards: HashMap = HashMap::new(); + let mut pending_rewards: Vec = vec![]; // we iterate over every registered denom and calculate the pending rewards for the user - for (denom, reward_state) in reward_states { + for (denom, reward_state) in denoms { // first we get the active epoch earned puvp value let active_total_earned_puvp = get_active_total_earned_puvp(deps, &env.block, &reward_state)?; @@ -507,16 +524,34 @@ fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::::exclusive); + + let rewards = DENOM_REWARD_STATES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, v)| v)) + .collect::>>()?; + + Ok(DenomsResponse { denoms: rewards }) +} + #[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)?; diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index aab5543bd..138178d8b 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -1,8 +1,6 @@ -use std::collections::HashMap; - use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::Uint128; -use cw20::{Cw20ReceiveMsg, UncheckedDenom}; +use cw20::{Cw20ReceiveMsg, Denom, UncheckedDenom}; use cw4::MemberChangedHookMsg; use cw_ownable::cw_ownable_execute; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; @@ -98,27 +96,39 @@ pub enum QueryMsg { /// Returns contract version info #[returns(InfoResponse)] Info {}, - /// Returns the state of all the registered reward distributions. - #[returns(RewardsStateResponse)] - RewardsState {}, - /// Returns the pending rewards for the given address. - #[returns(PendingRewardsResponse)] - PendingRewards { address: String }, /// Returns information about the ownership of this contract. #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] Ownership {}, + /// Returns the pending rewards for the given address. + #[returns(PendingRewardsResponse)] + PendingRewards { + address: String, + start_after: Option, + limit: Option, + }, /// Returns the state of the given denom reward distribution. #[returns(DenomRewardState)] - DenomRewardState { denom: String }, + Denom { denom: String }, + /// Returns the state of all the registered reward distributions. + #[returns(DenomsResponse)] + Denoms { + start_after: Option, + limit: Option, + }, } #[cw_serde] -pub struct RewardsStateResponse { - pub rewards: Vec, +pub struct DenomsResponse { + pub denoms: Vec, } #[cw_serde] pub struct PendingRewardsResponse { - pub address: String, - pub pending_rewards: HashMap, + pub pending_rewards: Vec, +} + +#[cw_serde] +pub struct DenomPendingRewards { + pub denom: Denom, + pub pending_rewards: Uint128, } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 2e9fd48d4..f49caaf4d 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -2,7 +2,7 @@ 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::{Cw20Coin, Denom, Expiration, UncheckedDenom}; use cw4::{Member, MemberListResponse}; use cw_multi_test::{App, BankSudo, Executor, SudoMsg}; use cw_ownable::Action; @@ -11,8 +11,8 @@ use dao_interface::voting::InfoResponse; use crate::{ msg::{ - ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterMsg, - RewardsStateResponse, + DenomsResponse, ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, + ReceiveCw20Msg, RegisterMsg, }, state::{DenomRewardState, RewardEmissionRate}, testing::cw20_setup::instantiate_cw20, @@ -330,10 +330,10 @@ 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 rewards_state_response = self.get_denoms(); let current_block = self.app.block_info(); let (expiration_unit, current_unit) = - match rewards_state_response.rewards[0].active_epoch.ends_at { + match rewards_state_response.denoms[0].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, @@ -375,23 +375,26 @@ impl Suite { result.balance.u128() } - pub fn get_rewards_state_response(&mut self) -> RewardsStateResponse { + pub fn get_denoms(&mut self) -> DenomsResponse { self.app .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::RewardsState {}, + &QueryMsg::Denoms { + start_after: None, + limit: None, + }, ) .unwrap() } - pub fn get_denom_reward_state(&mut self, denom: &str) -> DenomRewardState { + pub fn get_denom(&mut self, denom: &str) -> DenomRewardState { let resp: DenomRewardState = self .app .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::DenomRewardState { + &QueryMsg::Denom { denom: denom.to_string(), }, ) @@ -421,22 +424,22 @@ impl Suite { // SUITE ASSERTIONS impl Suite { pub fn assert_ends_at(&mut self, expected: Expiration) { - let rewards_state_response = self.get_rewards_state_response(); + let rewards_state_response = self.get_denoms(); assert_eq!( - rewards_state_response.rewards[0].active_epoch.ends_at, + rewards_state_response.denoms[0].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].active_epoch.started_at, expected); + let denom_configs = self.get_denoms(); + assert_eq!(denom_configs.denoms[0].active_epoch.started_at, expected); } pub fn assert_amount(&mut self, expected: u128) { - let rewards_state_response = self.get_rewards_state_response(); + let rewards_state_response = self.get_denoms(); assert_eq!( - rewards_state_response.rewards[0] + rewards_state_response.denoms[0] .active_epoch .emission_rate .amount, @@ -445,8 +448,8 @@ impl Suite { } 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] + let rewards_state_response = self.get_denoms(); + let units = match rewards_state_response.denoms[0] .active_epoch .emission_rate .duration @@ -466,11 +469,21 @@ impl Suite { self.distribution_contract.clone(), &QueryMsg::PendingRewards { address: address.to_string(), + start_after: None, + limit: None, }, ) .unwrap(); - let pending = res.pending_rewards.get(denom).unwrap(); + let pending = res + .pending_rewards + .iter() + .find(|p| match &p.denom { + Denom::Cw20(addr) => addr.as_str() == denom, + Denom::Native(d) => d == denom, + }) + .unwrap() + .pending_rewards; assert_eq!( pending, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 313d3f0a3..a2972a55e 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -1835,7 +1835,7 @@ fn test_fund_native_on_register() { Some(alt_coin.amount), ); - let denom = suite.get_denom_reward_state(ALT_DENOM); + let denom = suite.get_denom(ALT_DENOM); assert_eq!(denom.funded_amount, alt_coin.amount); assert_eq!( denom.active_epoch, @@ -1958,12 +1958,12 @@ fn test_update_continuous() { suite.update_continuous(DENOM, true); - let denom = suite.get_denom_reward_state(DENOM); + let denom = suite.get_denom(DENOM); assert!(denom.continuous); suite.update_continuous(DENOM, false); - let denom = suite.get_denom_reward_state(DENOM); + let denom = suite.get_denom(DENOM); assert!(!denom.continuous); } @@ -1986,7 +1986,7 @@ fn test_update_vp_contract() { suite.update_vp_contract(DENOM, new_vp_contract.as_str()); - let denom = suite.get_denom_reward_state(DENOM); + let denom = suite.get_denom(DENOM); assert_eq!(denom.vp_contract, new_vp_contract); } @@ -1997,7 +1997,7 @@ fn test_update_hook_caller() { let new_hook_caller = "new_hook_caller"; suite.update_hook_caller(DENOM, new_hook_caller); - let denom = suite.get_denom_reward_state(DENOM); + let denom = suite.get_denom(DENOM); assert_eq!(denom.hook_caller, new_hook_caller); } @@ -2008,7 +2008,7 @@ fn test_update_withdraw_destination() { let new_withdraw_destination = "new_withdraw_destination"; suite.update_withdraw_destination(DENOM, new_withdraw_destination); - let denom = suite.get_denom_reward_state(DENOM); + let denom = suite.get_denom(DENOM); assert_eq!(denom.withdraw_destination, new_withdraw_destination); } From ce8308e17ffde026d588924c4cfc1bbbc2c8d099 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 21 Jul 2024 21:45:11 -0400 Subject: [PATCH 33/39] allow duplicate denoms with different configs by creating unique ID for each distribution --- .../schema/dao-rewards-distributor.json | 249 ++-- .../dao-rewards-distributor/src/contract.rs | 485 ++++---- .../dao-rewards-distributor/src/error.rs | 25 +- .../dao-rewards-distributor/src/hooks.rs | 76 +- .../dao-rewards-distributor/src/msg.rs | 66 +- .../dao-rewards-distributor/src/rewards.rs | 113 +- .../dao-rewards-distributor/src/state.rs | 47 +- .../src/testing/suite.rs | 170 ++- .../src/testing/tests.rs | 1016 +++++++++-------- 9 files changed, 1189 insertions(+), 1058 deletions(-) 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 32425b797..7fd9d0638 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -61,20 +61,20 @@ "additionalProperties": false }, { - "description": "registers a new reward denom", + "description": "registers a new distribution", "type": "object", "required": [ - "register" + "create" ], "properties": { - "register": { - "$ref": "#/definitions/RegisterMsg" + "create": { + "$ref": "#/definitions/CreateMsg" } }, "additionalProperties": false }, { - "description": "updates the config for a registered denom", + "description": "updates the config for a distribution", "type": "object", "required": [ "update" @@ -83,7 +83,7 @@ "update": { "type": "object", "required": [ - "denom" + "id" ], "properties": { "continuous": { @@ -93,10 +93,6 @@ "null" ] }, - "denom": { - "description": "denom to update", - "type": "string" - }, "emission_rate": { "description": "reward emission rate", "anyOf": [ @@ -115,6 +111,12 @@ "null" ] }, + "id": { + "description": "distribution ID to update", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "vp_contract": { "description": "address to query the voting power", "type": [ @@ -156,8 +158,7 @@ ], "properties": { "fund": { - "type": "object", - "additionalProperties": false + "$ref": "#/definitions/FundMsg" } }, "additionalProperties": false @@ -172,11 +173,13 @@ "claim": { "type": "object", "required": [ - "denom" + "id" ], "properties": { - "denom": { - "type": "string" + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -185,7 +188,7 @@ "additionalProperties": false }, { - "description": "withdraws the undistributed rewards for a denom. members can claim whatever they earned until this point. this is effectively an inverse to fund and does not affect any already-distributed rewards.", + "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": [ "withdraw" @@ -194,11 +197,13 @@ "withdraw": { "type": "object", "required": [ - "denom" + "id" ], "properties": { - "denom": { - "type": "string" + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -280,6 +285,54 @@ "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": [ + "continuous", + "denom", + "emission_rate", + "hook_caller", + "vp_contract" + ], + "properties": { + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, + "denom": { + "description": "denom to distribute", + "allOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + } + ] + }, + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "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", @@ -382,6 +435,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", @@ -485,54 +553,6 @@ } ] }, - "RegisterMsg": { - "type": "object", - "required": [ - "continuous", - "denom", - "emission_rate", - "hook_caller", - "vp_contract" - ], - "properties": { - "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", - "type": "boolean" - }, - "denom": { - "description": "denom to register", - "allOf": [ - { - "$ref": "#/definitions/UncheckedDenom" - } - ] - }, - "emission_rate": { - "description": "reward emission rate", - "allOf": [ - { - "$ref": "#/definitions/RewardEmissionRate" - } - ] - }, - "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 - }, "RewardEmissionRate": { "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", "type": "object", @@ -719,9 +739,11 @@ }, "start_after": { "type": [ - "string", + "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -730,20 +752,22 @@ "additionalProperties": false }, { - "description": "Returns the state of the given denom reward distribution.", + "description": "Returns the state of the given distribution.", "type": "object", "required": [ - "denom" + "distribution" ], "properties": { - "denom": { + "distribution": { "type": "object", "required": [ - "denom" + "id" ], "properties": { - "denom": { - "type": "string" + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -752,13 +776,13 @@ "additionalProperties": false }, { - "description": "Returns the state of all the registered reward distributions.", + "description": "Returns the state of all the distributions.", "type": "object", "required": [ - "denoms" + "distributions" ], "properties": { - "denoms": { + "distributions": { "type": "object", "properties": { "limit": { @@ -771,9 +795,11 @@ }, "start_after": { "type": [ - "string", + "integer", "null" - ] + ], + "format": "uint64", + "minimum": 0.0 } }, "additionalProperties": false @@ -791,10 +817,10 @@ }, "sudo": null, "responses": { - "denom": { + "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", @@ -803,12 +829,13 @@ "funded_amount", "historical_earned_puvp", "hook_caller", + "id", "vp_contract", "withdraw_destination" ], "properties": { "active_epoch": { - "description": "current denom distribution epoch state", + "description": "current distribution epoch state", "allOf": [ { "$ref": "#/definitions/Epoch" @@ -851,6 +878,12 @@ } ] }, + "id": { + "description": "distribution ID", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "vp_contract": { "description": "address to query the voting power", "allOf": [ @@ -1085,18 +1118,18 @@ } } }, - "denoms": { + "distributions": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DenomsResponse", + "title": "DistributionsResponse", "type": "object", "required": [ - "denoms" + "distributions" ], "properties": { - "denoms": { + "distributions": { "type": "array", "items": { - "$ref": "#/definitions/DenomRewardState" + "$ref": "#/definitions/DistributionState" } } }, @@ -1134,8 +1167,8 @@ } ] }, - "DenomRewardState": { - "description": "the state of a denom's reward distribution", + "DistributionState": { + "description": "the state of a reward distribution", "type": "object", "required": [ "active_epoch", @@ -1144,12 +1177,13 @@ "funded_amount", "historical_earned_puvp", "hook_caller", + "id", "vp_contract", "withdraw_destination" ], "properties": { "active_epoch": { - "description": "current denom distribution epoch state", + "description": "current distribution epoch state", "allOf": [ { "$ref": "#/definitions/Epoch" @@ -1192,6 +1226,12 @@ } ] }, + "id": { + "description": "distribution ID", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "vp_contract": { "description": "address to query the voting power", "allOf": [ @@ -1546,7 +1586,7 @@ "pending_rewards": { "type": "array", "items": { - "$ref": "#/definitions/DenomPendingRewards" + "$ref": "#/definitions/DistributionPendingRewards" } } }, @@ -1584,18 +1624,35 @@ } ] }, - "DenomPendingRewards": { + "DistributionPendingRewards": { "type": "object", "required": [ "denom", + "id", "pending_rewards" ], "properties": { "denom": { - "$ref": "#/definitions/Denom" + "description": "denomination of the pending rewards", + "allOf": [ + { + "$ref": "#/definitions/Denom" + } + ] + }, + "id": { + "description": "distribution ID", + "type": "integer", + "format": "uint64", + "minimum": 0.0 }, "pending_rewards": { - "$ref": "#/definitions/Uint128" + "description": "amount of pending rewards in the denom being distributed", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] } }, "additionalProperties": false diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 62adb790e..89ae2c486 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -1,31 +1,31 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - ensure, from_json, to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Order, - Response, StdResult, Uint128, Uint256, + 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, UncheckedDenom}; +use cw20::{Cw20ReceiveMsg, Denom}; use cw_storage_plus::Bound; -use cw_utils::{must_pay, one_coin, Duration, Expiration}; +use cw_utils::{must_pay, nonpayable, Duration, Expiration}; use dao_interface::voting::InfoResponse; use std::ops::Add; -use crate::helpers::{get_duration_scalar, get_transfer_msg, validate_voting_power_contract}; +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, unsubscribe_denom_from_hook, + subscribe_distribution_to_hook, unsubscribe_distribution_from_hook, }; use crate::msg::{ - DenomPendingRewards, DenomsResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, - PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, RegisterMsg, + CreateMsg, DistributionPendingRewards, DistributionsResponse, ExecuteMsg, FundMsg, + InstantiateMsg, MigrateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, }; use crate::rewards::{ - get_accrued_rewards_since_last_user_action, get_active_total_earned_puvp, update_rewards, + get_accrued_rewards_not_yet_accounted_for, get_active_total_earned_puvp, update_rewards, }; use crate::state::{ - DenomRewardState, Epoch, RewardEmissionRate, DENOM_REWARD_STATES, USER_REWARD_STATES, + DistributionState, Epoch, RewardEmissionRate, COUNT, DISTRIBUTIONS, USER_REWARDS, }; use crate::ContractError; @@ -47,6 +47,9 @@ pub fn instantiate( // Intialize the contract owner cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + // initialize count + COUNT.save(deps.storage, &0)?; + Ok(Response::new().add_attribute("owner", msg.owner.unwrap_or_else(|| "None".to_string()))) } @@ -63,11 +66,9 @@ pub fn execute( ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), ExecuteMsg::Receive(msg) => execute_receive_cw20(deps, env, info, msg), - ExecuteMsg::Register(register_msg) => { - execute_register_native(deps, env, info, register_msg) - } + ExecuteMsg::Create(create_msg) => execute_create(deps, env, info, create_msg), ExecuteMsg::Update { - denom, + id, emission_rate, continuous, vp_contract, @@ -77,16 +78,16 @@ pub fn execute( deps, env, info, - denom, + id, emission_rate, continuous, vp_contract, hook_caller, withdraw_destination, ), - ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), - ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), - ExecuteMsg::Withdraw { denom } => execute_withdraw(deps, info, env, denom), + 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), } } @@ -96,57 +97,47 @@ fn execute_receive_cw20( info: MessageInfo, wrapper: Cw20ReceiveMsg, ) -> Result { + nonpayable(&info)?; + // verify msg let msg: ReceiveCw20Msg = from_json(&wrapper.msg)?; match msg { - ReceiveCw20Msg::Fund {} => { - let reward_denom_state = - DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; - execute_fund(deps, env, reward_denom_state, wrapper.amount) - } - } -} - -/// registers a new native denom for rewards distribution. -fn execute_register_native( - deps: DepsMut, - env: Env, - info: MessageInfo, - msg: RegisterMsg, -) -> Result { - let mut amount: Option = None; + 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 {}); + } + } + }; - // if native funds provided, ensure they are for this native 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 &msg.denom { - UncheckedDenom::Native(denom) => amount = Some(must_pay(&info, denom)?), - UncheckedDenom::Cw20(_) => return Err(ContractError::NoFundsOnCw20Register {}), + execute_fund(deps, env, distribution, wrapper.amount) } } - - execute_register(deps, env, info.sender, msg, amount) } -/// registers a new denom for rewards distribution. only the owner can register -/// a new denom. a denom can only be registered once. if funds provided, will -/// start distributing rewards immediately. -fn execute_register( +/// 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, - sender: Addr, - msg: RegisterMsg, - funds: Option, + info: MessageInfo, + msg: CreateMsg, ) -> Result { - // only the owner can register a new denom - cw_ownable::assert_owner(deps.storage, &sender)?; + // only the owner can create a new distribution + cw_ownable::assert_owner(deps.storage, &info.sender)?; - // Reward duration must be greater than 0 seconds/blocks - if get_duration_scalar(&msg.emission_rate.duration) == 0 { - return Err(ContractError::ZeroRewardDuration {}); - } + // update count and use as the new distribution's ID + let id = COUNT.update(deps.storage, |count| -> StdResult { Ok(count + 1) })?; let checked_denom = msg.denom.into_checked(deps.as_ref())?; let hook_caller = deps.api.addr_validate(&msg.hook_caller)?; @@ -156,11 +147,12 @@ fn execute_register( // if withdraw destination is specified, we validate it Some(addr) => deps.api.addr_validate(&addr)?, // otherwise default to the owner - None => sender, + None => info.sender.clone(), }; - // Initialize the reward state - let reward_state = DenomRewardState { + // Initialize the distribution state + let distribution = DistributionState { + id, denom: checked_denom, active_epoch: Epoch { started_at: Expiration::Never {}, @@ -176,157 +168,123 @@ fn execute_register( 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.clone()), - }, - )?; - // update the registered hooks to include the new denom - subscribe_denom_to_hook(deps.storage, str_denom.clone(), 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()), + })?; + + // 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", "register_denom") - .add_attribute("denom", &str_denom); - - // if funds provided, begin distributing rewards. if no funds, do nothing - // and leave registered denom with no funding, to be funded later. - if let Some(funds) = funds { - execute_fund(deps, env, reward_state, funds)?; - response = response.add_attribute("funded_amount", funds); + .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) } -/// updates the config for a registered denom +/// updates the config for a distribution #[allow(clippy::too_many_arguments)] fn execute_update( deps: DepsMut, env: Env, info: MessageInfo, - denom: String, + id: u64, emission_rate: Option, continuous: Option, vp_contract: Option, hook_caller: Option, withdraw_destination: Option, ) -> Result { - // only the owner can update a denom config + nonpayable(&info)?; + + // only the owner can update a distribution cw_ownable::assert_owner(deps.storage, &info.sender)?; - let mut reward_state = DENOM_REWARD_STATES - .load(deps.storage, denom.clone()) - .map_err(|_| ContractError::DenomNotRegistered {})?; + let mut distribution = DISTRIBUTIONS + .load(deps.storage, id) + .map_err(|_| ContractError::DistributionNotFound { id })?; if let Some(emission_rate) = emission_rate { // transition the epoch to the new emission rate - reward_state.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; + distribution.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; } if let Some(continuous) = continuous { - reward_state.continuous = continuous; + distribution.continuous = continuous; } if let Some(vp_contract) = vp_contract { - reward_state.vp_contract = validate_voting_power_contract(&deps, vp_contract)?; + distribution.vp_contract = validate_voting_power_contract(&deps, vp_contract)?; } if let Some(hook_caller) = hook_caller { // remove existing from registered hooks - unsubscribe_denom_from_hook(deps.storage, &denom, reward_state.hook_caller)?; + unsubscribe_distribution_from_hook(deps.storage, id, distribution.hook_caller)?; - reward_state.hook_caller = deps.api.addr_validate(&hook_caller)?; + distribution.hook_caller = deps.api.addr_validate(&hook_caller)?; // add new to registered hooks - subscribe_denom_to_hook(deps.storage, &denom, reward_state.hook_caller.clone())?; + subscribe_distribution_to_hook(deps.storage, id, distribution.hook_caller.clone())?; } if let Some(withdraw_destination) = withdraw_destination { - reward_state.withdraw_destination = deps.api.addr_validate(&withdraw_destination)?; + distribution.withdraw_destination = deps.api.addr_validate(&withdraw_destination)?; } - DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &reward_state)?; + DISTRIBUTIONS.save(deps.storage, id, &distribution)?; Ok(Response::new() - .add_attribute("action", "update_denom") - .add_attribute("denom", denom)) -} - -/// withdraws the undistributed rewards for a denom. 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, - denom: String, -) -> Result { - // only the owner can initiate a withdraw - cw_ownable::assert_owner(deps.storage, &info.sender)?; - - let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; - - // withdraw is only possible during the distribution period - ensure!( - !reward_state.active_epoch.ends_at.is_expired(&env.block), - ContractError::RewardsAlreadyDistributed {} - ); - - // withdraw ends the epoch early - reward_state.active_epoch.ends_at = match reward_state.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(env.block.height), - Duration::Time(_) => Expiration::AtTime(env.block.time), - }; - - // get total rewards distributed based on newly updated ends_at - let rewards_distributed = reward_state.active_epoch.get_total_rewards()?; - - let clawback_amount = reward_state.funded_amount - rewards_distributed; - - // remove withdrawn funds from amount funded since they are no longer funded - reward_state.funded_amount = rewards_distributed; - - let clawback_msg = get_transfer_msg( - reward_state.withdraw_destination.clone(), - clawback_amount, - reward_state.denom.clone(), - )?; - - DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; - - Ok(Response::new() - .add_attribute("action", "withdraw") - .add_attribute("denom", denom) - .add_attribute("amount_withdrawn", clawback_amount) - .add_attribute("amount_distributed", rewards_distributed) - .add_message(clawback_msg)) + .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 { // restart the distribution from the current block if it hasn't yet started @@ -334,16 +292,11 @@ fn execute_fund( // 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 let Expiration::Never {} = denom_reward_state.active_epoch.started_at { - true - } else { - !denom_reward_state.continuous - && denom_reward_state - .active_epoch - .ends_at - .is_expired(&env.block) - }; + let restart_distribution = if let Expiration::Never {} = distribution.active_epoch.started_at { + true + } else { + !distribution.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 @@ -351,66 +304,64 @@ fn execute_fund( // new distribution. otherwise, just add the new amount to the existing // funded_amount if restart_distribution { - denom_reward_state.funded_amount = amount; - denom_reward_state.active_epoch.started_at = - match denom_reward_state.active_epoch.emission_rate.duration { + distribution.funded_amount = amount; + distribution.active_epoch.started_at = + match distribution.active_epoch.emission_rate.duration { Duration::Height(_) => Expiration::AtHeight(env.block.height), Duration::Time(_) => Expiration::AtTime(env.block.time), }; } else { - denom_reward_state.funded_amount += amount; + distribution.funded_amount += amount; } - denom_reward_state.active_epoch.ends_at = denom_reward_state.active_epoch.started_at.add( - denom_reward_state + distribution.active_epoch.ends_at = distribution.active_epoch.started_at.add( + distribution .active_epoch .emission_rate - .get_funded_period_duration(denom_reward_state.funded_amount)?, + .get_funded_period_duration(distribution.funded_amount)?, )?; // if continuous, meaning rewards should have been distributed in the past // that were not due to lack of sufficient funding, ensure the total rewards // earned puvp is up to date. - if !restart_distribution && denom_reward_state.continuous { - denom_reward_state.active_epoch.total_earned_puvp = - get_active_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; + if !restart_distribution && distribution.continuous { + distribution.active_epoch.total_earned_puvp = + get_active_total_earned_puvp(deps.as_ref(), &env.block, &distribution)?; } - denom_reward_state - .active_epoch - .bump_last_updated(&env.block)?; + distribution.active_epoch.bump_last_updated(&env.block)?; - DENOM_REWARD_STATES.save( - deps.storage, - denom_reward_state.to_str_denom(), - &denom_reward_state, - )?; + DISTRIBUTIONS.save(deps.storage, distribution.id, &distribution)?; Ok(Response::new() .add_attribute("action", "fund") - .add_attribute("fund_denom", denom_reward_state.to_str_denom()) - .add_attribute("fund_amount", amount)) + .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. this updates the denom reward state - // and the user reward state, so we operate on the correct state. - update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; + 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 denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; - let mut user_reward_state = USER_REWARD_STATES.load(deps.storage, info.sender.clone())?; + // 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. + // 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_denom_rewards - .insert(denom.to_string(), Uint128::zero()) + .pending_rewards + .insert(id, Uint128::zero()) .unwrap_or_default(); // if there are no rewards to claim, error out @@ -418,16 +369,79 @@ fn execute_claim( return Err(ContractError::NoRewardsClaimable {}); } - // otherwise reflect the updated user reward state and transfer out the claimed rewards - USER_REWARD_STATES.save(deps.storage, info.sender.clone(), &user_reward_state)?; + // 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(), claim_amount, - denom_reward_state.denom, + 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)) +} + +/// 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, + id: u64, +) -> Result { + nonpayable(&info)?; + + // only the owner can initiate a withdraw + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut distribution = DISTRIBUTIONS + .load(deps.storage, id) + .map_err(|_| ContractError::DistributionNotFound { id })?; + + // withdraw is only possible during the distribution period + ensure!( + !distribution.active_epoch.ends_at.is_expired(&env.block), + ContractError::RewardsAlreadyDistributed {} + ); + + // withdraw ends the epoch early + distribution.active_epoch.ends_at = match distribution.active_epoch.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }; + + // get total rewards distributed based on newly updated ends_at + let rewards_distributed = distribution.active_epoch.get_total_rewards()?; + + let clawback_amount = distribution.funded_amount - rewards_distributed; + + // remove withdrawn funds from amount funded since they are no longer funded + distribution.funded_amount = rewards_distributed; + + let clawback_msg = get_transfer_msg( + distribution.withdraw_destination.clone(), + clawback_amount, + distribution.denom.clone(), + )?; + + DISTRIBUTIONS.save(deps.storage, id, &distribution)?; + + 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 execute_update_owner( @@ -436,9 +450,11 @@ fn execute_update_owner( env: Env, action: cw_ownable::Action, ) -> Result { - // Update the current contract owner. - // Note, this is a two step process, the new owner must accept this ownership transfer. - // First the owner specifies the new owner, then the new owner must accept. + nonpayable(&info)?; + + // 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())) } @@ -459,13 +475,13 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { start_after, limit, )?)?), - QueryMsg::Denom { denom } => { - let state = DENOM_REWARD_STATES.load(deps.storage, denom)?; + QueryMsg::Distribution { id } => { + let state = DISTRIBUTIONS.load(deps.storage, id)?; Ok(to_json_binary(&state)?) } - QueryMsg::Denoms { start_after, limit } => { - Ok(to_json_binary(&query_denoms(deps, start_after, limit)?)?) - } + QueryMsg::Distributions { start_after, limit } => Ok(to_json_binary( + &query_distributions(deps, start_after, limit)?, + )?), } } @@ -474,82 +490,83 @@ fn query_info(deps: Deps) -> StdResult { Ok(InfoResponse { info }) } -/// returns the pending rewards for a given address that are ready to be claimed. +/// 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, + start_after: Option, limit: Option, ) -> StdResult { let addr = deps.api.addr_validate(&addr)?; let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; - let start = start_after.map(Bound::::exclusive); + 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_REWARD_STATES + let user_reward_state = USER_REWARDS .load(deps.storage, addr.clone()) .unwrap_or_default(); - let denoms = DENOM_REWARD_STATES + let distributions = DISTRIBUTIONS .range(deps.storage, start, None, Order::Ascending) .take(limit) .collect::>>()?; - let mut pending_rewards: Vec = vec![]; + let mut pending_rewards: Vec = vec![]; - // we iterate over every registered denom and calculate the pending rewards for the user - for (denom, reward_state) in denoms { + // 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, &reward_state)?; + get_active_total_earned_puvp(deps, &env.block, &distribution)?; // then we add that to the historical rewards earned puvp let total_earned_puvp = - active_total_earned_puvp.checked_add(reward_state.historical_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 existing_amount = user_reward_state - .pending_denom_rewards - .get(&denom) - .cloned() - .unwrap_or_default(); - pending_rewards.push(DenomPendingRewards { - denom: reward_state.denom, - pending_rewards: earned_rewards.amount + existing_amount, + pending_rewards.push(DistributionPendingRewards { + id, + denom: distribution.denom, + pending_rewards: unaccounted_for_rewards + existing_amount, }); } - let pending_rewards_response = PendingRewardsResponse { pending_rewards }; - Ok(pending_rewards_response) + Ok(PendingRewardsResponse { pending_rewards }) } -fn query_denoms( +fn query_distributions( deps: Deps, - start_after: Option, + start_after: Option, limit: Option, -) -> StdResult { +) -> StdResult { let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; - let start = start_after.map(Bound::::exclusive); + let start = start_after.map(Bound::::exclusive); - let rewards = DENOM_REWARD_STATES + let distributions = DISTRIBUTIONS .range(deps.storage, start, None, Order::Ascending) .take(limit) .map(|item| item.map(|(_, v)| v)) .collect::>>()?; - Ok(DenomsResponse { denoms: rewards }) + Ok(DistributionsResponse { distributions }) } #[cfg_attr(not(feature = "library"), entry_point)] diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index bc81255ab..e2980a78e 100644 --- a/contracts/distribution/dao-rewards-distributor/src/error.rs +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -19,36 +19,27 @@ pub enum ContractError { #[error(transparent)] Payment(#[from] PaymentError), - #[error("Invalid Cw20")] + #[error("Invalid CW20")] InvalidCw20 {}, #[error("Invalid funds")] InvalidFunds {}, - #[error("You cannot send native funds when registering a CW20")] - NoFundsOnCw20Register {}, + #[error("You cannot send native funds when creating a CW20 distribution")] + NoFundsOnCw20Create {}, - #[error("Staking change hook sender is not staking contract")] + #[error("Voting power changed hook sender incorrect")] InvalidHookSender {}, #[error("No rewards claimable")] NoRewardsClaimable {}, - #[error("Reward period not finished")] - RewardPeriodNotFinished {}, - - #[error("Reward rate less then one per block")] - RewardRateLessThenOnePerBlock {}, - - #[error("Reward duration can not be zero")] - ZeroRewardDuration {}, - #[error("All rewards have already been distributed")] RewardsAlreadyDistributed {}, - #[error("Denom already registered")] - DenomAlreadyRegistered {}, + #[error("Distribution not found with ID {id}")] + DistributionNotFound { id: u64 }, - #[error("Denom not registered")] - DenomNotRegistered {}, + #[error("Unexpected duplicate distribution with ID {id}")] + UnexpectedDuplicateDistributionId { id: u64 }, } diff --git a/contracts/distribution/dao-rewards-distributor/src/hooks.rs b/contracts/distribution/dao-rewards-distributor/src/hooks.rs index 3a6e5c7f5..02ad08933 100644 --- a/contracts/distribution/dao-rewards-distributor/src/hooks.rs +++ b/contracts/distribution/dao-rewards-distributor/src/hooks.rs @@ -2,38 +2,38 @@ use cosmwasm_std::{Addr, Deps, DepsMut, Env, MessageInfo, Response, StdResult, S use cw4::MemberChangedHookMsg; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; -use crate::{rewards::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( +/// Register a hook caller contract for a given distribution ID. +pub(crate) fn subscribe_distribution_to_hook( storage: &mut dyn Storage, - denom: impl Into, + distribution_id: u64, hook: Addr, ) -> Result<(), ContractError> { - REGISTERED_HOOK_DENOMS.update(storage, hook, |denoms| -> StdResult<_> { + REGISTERED_HOOKS.update(storage, hook, |denoms| -> StdResult<_> { let mut denoms = denoms.unwrap_or_default(); - denoms.push(denom.into()); + denoms.push(distribution_id); Ok(denoms) })?; Ok(()) } -/// Unregister a hook caller contract for a given denom. -pub(crate) fn unsubscribe_denom_from_hook( +/// Unregister a hook caller contract for a given distribution ID. +pub(crate) fn unsubscribe_distribution_from_hook( storage: &mut dyn Storage, - denom: &str, + distribution_id: u64, hook: Addr, ) -> Result<(), ContractError> { - let mut denoms = REGISTERED_HOOK_DENOMS + let mut denoms = REGISTERED_HOOKS .may_load(storage, hook.clone())? .unwrap_or_default(); - denoms.retain(|d| d != denom); + denoms.retain(|id| *id != distribution_id); if denoms.is_empty() { - REGISTERED_HOOK_DENOMS.remove(storage, hook); + REGISTERED_HOOKS.remove(storage, hook); } else { - REGISTERED_HOOK_DENOMS.save(storage, hook, &denoms)?; + REGISTERED_HOOKS.save(storage, hook, &denoms)?; } Ok(()) @@ -41,14 +41,14 @@ pub(crate) fn unsubscribe_denom_from_hook( /// 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 {}) } @@ -60,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) } } } @@ -77,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)?; } } @@ -97,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")) } @@ -124,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/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 138178d8b..d10ec6ba0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -11,7 +11,7 @@ use dao_interface::voting::InfoResponse; pub use cw_controllers::ClaimsResponse; pub use cw_ownable::Ownership; -use crate::state::{DenomRewardState, RewardEmissionRate}; +use crate::state::{DistributionState, RewardEmissionRate}; #[cw_serde] pub struct InstantiateMsg { @@ -30,12 +30,12 @@ pub enum ExecuteMsg { NftStakeChangeHook(NftStakeChangedHookMsg), /// Called when tokens are staked or unstaked. StakeChangeHook(StakeChangedHookMsg), - /// registers a new reward denom - Register(RegisterMsg), - /// updates the config for a registered denom + /// registers a new distribution + Create(CreateMsg), + /// updates the config for a distribution Update { - /// denom to update - denom: String, + /// distribution ID to update + id: u64, /// reward emission rate emission_rate: Option, /// whether or not reward distribution is continuous: whether rewards @@ -53,18 +53,18 @@ pub enum ExecuteMsg { /// Used to fund this contract with cw20 tokens. Receive(Cw20ReceiveMsg), /// Used to fund this contract with native tokens. - Fund {}, + Fund(FundMsg), /// Claims rewards for the sender. - Claim { denom: String }, - /// withdraws the undistributed rewards for a denom. 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 { denom: String }, + 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 }, } #[cw_serde] -pub struct RegisterMsg { - /// denom to register +pub struct CreateMsg { + /// denom to distribute pub denom: UncheckedDenom, /// reward emission rate pub emission_rate: RewardEmissionRate, @@ -82,12 +82,15 @@ pub struct RegisterMsg { } #[cw_serde] -pub enum MigrateMsg {} +pub struct FundMsg { + /// distribution ID to fund + pub id: u64, +} #[cw_serde] pub enum ReceiveCw20Msg { /// Used to fund this contract with cw20 tokens. - Fund {}, + Fund(FundMsg), } #[cw_serde] @@ -103,32 +106,39 @@ pub enum QueryMsg { #[returns(PendingRewardsResponse)] PendingRewards { address: String, - start_after: Option, + start_after: Option, limit: Option, }, - /// Returns the state of the given denom reward distribution. - #[returns(DenomRewardState)] - Denom { denom: String }, - /// Returns the state of all the registered reward distributions. - #[returns(DenomsResponse)] - Denoms { - start_after: 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 DenomsResponse { - pub denoms: Vec, +pub struct DistributionsResponse { + pub distributions: Vec, } #[cw_serde] pub struct PendingRewardsResponse { - pub pending_rewards: Vec, + pub pending_rewards: Vec, } #[cw_serde] -pub struct DenomPendingRewards { +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 index b5395f7b0..b185b3d08 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -1,72 +1,77 @@ -use cosmwasm_std::{coin, Addr, BlockInfo, Coin, Deps, DepsMut, Env, StdResult, Uint128, Uint256}; +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::{DenomRewardState, UserRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}, + state::{DistributionState, UserRewardState, DISTRIBUTIONS, USER_REWARDS}, + ContractError, }; -/// updates the user reward state for a given denom and user address. -/// also syncs the global denom reward state config with the latest puvp values. -pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) -> StdResult<()> { - // 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_REWARD_STATES +/// 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(); - let mut denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; // first update the active epoch earned puvp value up to the current block - denom_reward_state.active_epoch.total_earned_puvp = - get_active_total_earned_puvp(deps.as_ref(), &env.block, &denom_reward_state)?; - denom_reward_state - .active_epoch - .bump_last_updated(&env.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 = denom_reward_state + let total_applicable_puvp = distribution .active_epoch .total_earned_puvp - .checked_add(denom_reward_state.historical_earned_puvp)?; + .checked_add(distribution.historical_earned_puvp)?; - let earned_rewards = get_accrued_rewards_since_last_user_action( + let unaccounted_for_rewards = get_accrued_rewards_not_yet_accounted_for( deps.as_ref(), env, addr, total_applicable_puvp, - &denom_reward_state.vp_contract, - denom.to_string(), + &distribution, &user_reward_state, )?; - // get the pre-existing pending reward amount for the denom - let previous_pending_denom_reward_amount = user_reward_state - .pending_denom_rewards - .get(&denom) + // 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 = earned_rewards - .amount - .checked_add(previous_pending_denom_reward_amount)?; + let amount_sum = unaccounted_for_rewards.checked_add(previous_pending_reward_amount)?; - // get the amount of newly earned rewards for the denom + // get the amount of newly earned rewards for the distribution user_reward_state - .pending_denom_rewards - .insert(denom.clone(), amount_sum); + .pending_rewards + .insert(distribution_id, amount_sum); // update the accounted for amount to that of the total applicable puvp user_reward_state - .accounted_denom_rewards_puvp - .insert(denom.clone(), total_applicable_puvp); + .accounted_for_rewards_puvp + .insert(distribution_id, total_applicable_puvp); // reflect the updated state changes - USER_REWARD_STATES.save(deps.storage, addr.clone(), &user_reward_state)?; - DENOM_REWARD_STATES.save(deps.storage, denom.clone(), &denom_reward_state)?; + USER_REWARDS.save(deps.storage, addr.clone(), &user_reward_state)?; + DISTRIBUTIONS.save(deps.storage, distribution_id, &distribution)?; Ok(()) } @@ -76,35 +81,43 @@ pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) pub fn get_active_total_earned_puvp( deps: Deps, block: &BlockInfo, - reward_state: &DenomRewardState, + distribution: &DistributionState, ) -> StdResult { - let curr = reward_state.active_epoch.total_earned_puvp; - - let prev_total_power = get_prev_block_total_vp(deps, block, &reward_state.vp_contract)?; + let curr = distribution.active_epoch.total_earned_puvp; - let last_time_rewards_distributed = reward_state.get_latest_reward_distribution_time(block); + 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, - &reward_state.active_epoch.last_updated_total_earned_puvp, + &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(&reward_state.active_epoch.emission_rate.duration).into(), + get_duration_scalar(&distribution.active_epoch.emission_rate.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 = reward_state + let new_rewards_distributed = distribution .active_epoch .emission_rate .amount @@ -118,24 +131,24 @@ pub fn get_active_total_earned_puvp( } } -// get a user's rewards not yet accounted for in their reward state. -pub fn get_accrued_rewards_since_last_user_action( +// 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, - vp_contract: &Addr, - denom: String, + distribution: &DistributionState, user_reward_state: &UserRewardState, -) -> StdResult { +) -> StdResult { // get the user's voting power at the current height let voting_power: Uint256 = - get_voting_power_at_block(deps, &env.block, vp_contract, addr)?.into(); + 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_denom_rewards_puvp - .get(&denom) + .accounted_for_rewards_puvp + .get(&distribution.id) .cloned() .unwrap_or_default(); @@ -151,5 +164,5 @@ pub fn get_accrued_rewards_since_last_user_action( .checked_div(scale_factor())? .try_into()?; - Ok(coin(accrued_rewards_amount.u128(), denom)) + 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 2688456bf..8e1fa3fd4 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -3,29 +3,34 @@ use cosmwasm_std::{ 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::{helpers::get_exp_diff, rewards::get_active_total_earned_puvp}; /// 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 (added to pending and maybe claimed). - pub accounted_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, } /// defines how many tokens (amount) should be distributed per amount of time @@ -134,12 +139,14 @@ impl Epoch { } } -/// the state of a denom's reward distribution +/// the state of a reward distribution #[cw_serde] -pub struct DenomRewardState { +pub struct DistributionState { + /// distribution ID + pub id: u64, /// validated denom (native or cw20) pub denom: Denom, - /// current denom distribution epoch state + /// current distribution epoch state pub active_epoch: Epoch, /// whether or not reward distribution is continuous: whether rewards should /// be paused once all funding has been distributed, or if future funding @@ -161,8 +168,8 @@ pub struct DenomRewardState { pub historical_earned_puvp: Uint256, } -impl DenomRewardState { - 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(), @@ -185,8 +192,12 @@ impl DenomRewardState { pub fn get_latest_reward_distribution_time(&self, current_block: &BlockInfo) -> Expiration { match self.active_epoch.ends_at { Expiration::Never {} => self.active_epoch.last_updated_total_earned_puvp, - Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), - Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), + 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)) + } } } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index f49caaf4d..418673717 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -2,7 +2,7 @@ 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, Denom, Expiration, UncheckedDenom}; +use cw20::{Cw20Coin, Expiration, UncheckedDenom}; use cw4::{Member, MemberListResponse}; use cw_multi_test::{App, BankSudo, Executor, SudoMsg}; use cw_ownable::Action; @@ -11,10 +11,10 @@ use dao_interface::voting::InfoResponse; use crate::{ msg::{ - DenomsResponse, ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, - ReceiveCw20Msg, RegisterMsg, + CreateMsg, DistributionsResponse, ExecuteMsg, FundMsg, InstantiateMsg, + PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, }, - state::{DenomRewardState, RewardEmissionRate}, + state::{DistributionState, RewardEmissionRate}, testing::cw20_setup::instantiate_cw20, ContractError, }; @@ -250,20 +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), + }, + ); } }; } @@ -288,20 +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), + }, + ); } }; } @@ -330,14 +336,13 @@ pub struct Suite { // SUITE QUERIES impl Suite { pub fn get_time_until_rewards_expiration(&mut self) -> u64 { - let rewards_state_response = self.get_denoms(); + let distribution = &self.get_distributions().distributions[0]; let current_block = self.app.block_info(); - let (expiration_unit, current_unit) = - match rewards_state_response.denoms[0].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, - }; + 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, + }; if expiration_unit > current_unit { expiration_unit - current_unit @@ -375,12 +380,12 @@ impl Suite { result.balance.u128() } - pub fn get_denoms(&mut self) -> DenomsResponse { + pub fn get_distributions(&mut self) -> DistributionsResponse { self.app .wrap() .query_wasm_smart( self.distribution_contract.clone(), - &QueryMsg::Denoms { + &QueryMsg::Distributions { start_after: None, limit: None, }, @@ -388,15 +393,13 @@ impl Suite { .unwrap() } - pub fn get_denom(&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::Denom { - denom: denom.to_string(), - }, + &QueryMsg::Distribution { id }, ) .unwrap(); resp @@ -424,43 +427,33 @@ impl Suite { // SUITE ASSERTIONS impl Suite { pub fn assert_ends_at(&mut self, expected: Expiration) { - let rewards_state_response = self.get_denoms(); - assert_eq!( - rewards_state_response.denoms[0].active_epoch.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_denoms(); - assert_eq!(denom_configs.denoms[0].active_epoch.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_denoms(); + let distribution = &self.get_distributions().distributions[0]; assert_eq!( - rewards_state_response.denoms[0] - .active_epoch - .emission_rate - .amount, + distribution.active_epoch.emission_rate.amount, Uint128::new(expected) ); } pub fn assert_duration(&mut self, expected: u64) { - let rewards_state_response = self.get_denoms(); - let units = match rewards_state_response.denoms[0] - .active_epoch - .emission_rate - .duration - { + let distribution = &self.get_distributions().distributions[0]; + let units = match distribution.active_epoch.emission_rate.duration { Duration::Height(h) => h, Duration::Time(t) => t, }; assert_eq!(units, 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() @@ -478,10 +471,7 @@ impl Suite { let pending = res .pending_rewards .iter() - .find(|p| match &p.denom { - Denom::Cw20(addr) => addr.as_str() == denom, - Denom::Native(d) => d == denom, - }) + .find(|p| p.id == id) .unwrap() .pending_rewards; @@ -499,18 +489,16 @@ impl Suite { assert_eq!(balance, expected); } - pub fn assert_cw20_balance(&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 withdraw_denom_funds(&mut self, denom: &str) { - let msg = ExecuteMsg::Withdraw { - denom: denom.to_string(), - }; + pub fn withdraw(&mut self, id: u64) { + let msg = ExecuteMsg::Withdraw { id }; self.app .execute_contract( Addr::unchecked(OWNER), @@ -521,10 +509,8 @@ impl Suite { .unwrap(); } - pub fn withdraw_denom_funds_error(&mut self, denom: &str) -> ContractError { - let msg = ExecuteMsg::Withdraw { - denom: denom.to_string(), - }; + pub fn withdraw_error(&mut self, id: u64) -> ContractError { + let msg = ExecuteMsg::Withdraw { id }; self.app .execute_contract( Addr::unchecked(OWNER), @@ -541,19 +527,18 @@ impl Suite { 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( + pub fn create( &mut self, reward_config: RewardsConfig, hook_caller: &str, funds: Option, ) { - let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { + let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: reward_config.denom.clone(), emission_rate: RewardEmissionRate { amount: Uint128::new(reward_config.amount), @@ -580,13 +565,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() @@ -599,28 +584,25 @@ impl Suite { .unwrap(); } - pub fn mint_cw20_coin(&mut self, coin: Cw20Coin, name: &str) -> Addr { + 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(&ReceiveCw20Msg::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), @@ -660,11 +642,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), @@ -682,7 +661,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(); @@ -692,7 +670,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), @@ -730,14 +707,9 @@ impl Suite { unstake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) } - pub fn update_reward_emission_rate( - &mut self, - denom: &str, - epoch_duration: Duration, - epoch_rewards: u128, - ) { + pub fn update_emission_rate(&mut self, id: u64, epoch_duration: Duration, epoch_rewards: u128) { let msg: ExecuteMsg = ExecuteMsg::Update { - denom: denom.to_string(), + id, emission_rate: Some(RewardEmissionRate { amount: Uint128::new(epoch_rewards), duration: epoch_duration, @@ -759,9 +731,9 @@ impl Suite { .unwrap(); } - pub fn update_continuous(&mut self, denom: &str, continuous: bool) { + pub fn update_continuous(&mut self, id: u64, continuous: bool) { let msg: ExecuteMsg = ExecuteMsg::Update { - denom: denom.to_string(), + id, emission_rate: None, continuous: Some(continuous), vp_contract: None, @@ -780,9 +752,9 @@ impl Suite { .unwrap(); } - pub fn update_vp_contract(&mut self, denom: &str, vp_contract: &str) { + pub fn update_vp_contract(&mut self, id: u64, vp_contract: &str) { let msg: ExecuteMsg = ExecuteMsg::Update { - denom: denom.to_string(), + id, emission_rate: None, continuous: None, vp_contract: Some(vp_contract.to_string()), @@ -801,9 +773,9 @@ impl Suite { .unwrap(); } - pub fn update_hook_caller(&mut self, denom: &str, hook_caller: &str) { + pub fn update_hook_caller(&mut self, id: u64, hook_caller: &str) { let msg: ExecuteMsg = ExecuteMsg::Update { - denom: denom.to_string(), + id, emission_rate: None, continuous: None, vp_contract: None, @@ -822,9 +794,9 @@ impl Suite { .unwrap(); } - pub fn update_withdraw_destination(&mut self, denom: &str, withdraw_destination: &str) { + pub fn update_withdraw_destination(&mut self, id: u64, withdraw_destination: &str) { let msg: ExecuteMsg = ExecuteMsg::Update { - denom: denom.to_string(), + id, emission_rate: None, continuous: None, vp_contract: None, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index a2972a55e..3af7a2a9d 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -9,7 +9,7 @@ use cw_multi_test::Executor; use cw_utils::Duration; use dao_interface::voting::InfoResponse; -use crate::msg::RegisterMsg; +use crate::msg::{CreateMsg, FundMsg}; use crate::state::{Epoch, RewardEmissionRate}; use crate::testing::native_setup::setup_native_token_test; use crate::ContractError; @@ -26,6 +26,46 @@ 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(); @@ -37,69 +77,69 @@ fn test_native_dao_rewards_update_reward_rate() { // 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.assert_pending_rewards(ADDR1, DENOM, 0); + 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_reward_emission_rate(DENOM, Duration::Height(10), 500); + suite.update_emission_rate(1, Duration::Height(10), 500); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(ADDR1, DENOM, 2_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 6_250_000); - suite.assert_pending_rewards(ADDR3, DENOM, 6_250_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_reward_emission_rate(DENOM, Duration::Height(10), 1_000); + suite.update_emission_rate(1, Duration::Height(10), 1_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(ADDR1, DENOM, 7_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 8_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 8_750_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, DENOM, 17_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_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); // set the rewards rate to 0, pausing the rewards distribution - suite.update_reward_emission_rate(DENOM, Duration::Height(10000000000), 0); + suite.update_emission_rate(1, Duration::Height(10000000000), 0); // skip 1/10th of the time suite.skip_blocks(100_000); // assert no pending rewards changed - suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_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); // assert ADDR1 pre-claim balance suite.assert_native_balance(ADDR1, DENOM, 10_000_000); // ADDR1 claims their rewards - suite.claim_rewards(ADDR1, DENOM); + 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, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // user 2 unstakes their stake suite.unstake_native_tokens(ADDR2, 50); @@ -108,66 +148,66 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(100_000); // only the ADDR1 pending rewards should have changed - suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); + 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, DENOM); + 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, DENOM, 0); + 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_reward_emission_rate(DENOM, Duration::Height(10), 1000); + suite.update_emission_rate(1, Duration::Height(10), 1000); // update with the same rate does nothing - suite.update_reward_emission_rate(DENOM, Duration::Height(10), 1000); + suite.update_emission_rate(1, Duration::Height(10), 1000); // 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, DENOM, 6_666_666); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000 + 3_333_333); + 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, DENOM); - suite.assert_pending_rewards(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, DENOM, 6_666_666 + 6_666_666 + 1); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333); + 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, DENOM); - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR3, 1); + suite.claim_rewards(ADDR1, 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); // 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_reward_emission_rate(DENOM, Duration::Height(10), 4000); + suite.update_emission_rate(1, Duration::Height(10), 4000); 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, DENOM, addr1_pending); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending); + 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%] @@ -175,21 +215,21 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(10_000); // allocates 4_000_000 tokens - suite.assert_pending_rewards(ADDR1, DENOM, addr1_pending + 4_000_000 * 2 / 4); - suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 4_000_000 / 4); + 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, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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, DENOM, addr1_pending + 1_000_000 * 2 / 4); - suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4 + 1_000_000 / 4); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 1_000_000 / 4); + 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, DENOM); + 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); @@ -215,68 +255,68 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // 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.assert_pending_rewards(ADDR1, DENOM, 0); + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 0); // set the rewards rate to time-based rewards - suite.update_reward_emission_rate(DENOM, Duration::Time(10), 500); + suite.update_emission_rate(1, Duration::Time(10), 500); // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(ADDR1, DENOM, 2_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 6_250_000); - suite.assert_pending_rewards(ADDR3, DENOM, 6_250_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_reward_emission_rate(DENOM, Duration::Time(10), 1_000); + suite.update_emission_rate(1, Duration::Time(10), 1_000); // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(ADDR1, DENOM, 7_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 8_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 8_750_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, DENOM, 17_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_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); // set the rewards rate to 0, pausing the rewards distribution - suite.update_reward_emission_rate(DENOM, Duration::Height(10000000000), 0); + suite.update_emission_rate(1, Duration::Height(10000000000), 0); // skip 1/10th of the time suite.skip_blocks(100_000); // assert no pending rewards changed - suite.assert_pending_rewards(ADDR1, DENOM, 17_500_000); - suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_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); // assert ADDR1 pre-claim balance suite.assert_native_balance(ADDR1, DENOM, 10_000_000); // ADDR1 claims their rewards - suite.claim_rewards(ADDR1, DENOM); + 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, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // user 2 unstakes their stake suite.unstake_native_tokens(ADDR2, 50); @@ -285,63 +325,63 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { suite.skip_blocks(100_000); // only the ADDR1 pending rewards should have changed - suite.assert_pending_rewards(ADDR1, DENOM, 0); - suite.assert_pending_rewards(ADDR2, DENOM, 13_750_000); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000); + 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, DENOM); + 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, DENOM, 0); + 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_reward_emission_rate(DENOM, Duration::Height(10), 1000); + suite.update_emission_rate(1, Duration::Height(10), 1000); // 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, DENOM, 6_666_666); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 13_750_000 + 3_333_333); + 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, DENOM); - suite.assert_pending_rewards(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, DENOM, 6_666_666 + 6_666_666 + 1); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333); + 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, DENOM); - suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR3, 1); + suite.claim_rewards(ADDR1, 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); // 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_reward_emission_rate(DENOM, Duration::Time(10), 4000); + suite.update_emission_rate(1, Duration::Time(10), 4000); 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, DENOM, addr1_pending); - suite.assert_pending_rewards(ADDR2, DENOM, 0); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending); + 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%] @@ -349,21 +389,21 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { suite.skip_seconds(10_000); // allocates 4_000_000 tokens - suite.assert_pending_rewards(ADDR1, DENOM, addr1_pending + 4_000_000 * 2 / 4); - suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 4_000_000 / 4); + 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, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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, DENOM, addr1_pending + 1_000_000 * 2 / 4); - suite.assert_pending_rewards(ADDR2, DENOM, 4_000_000 / 4 + 1_000_000 / 4); - suite.assert_pending_rewards(ADDR3, DENOM, addr3_pending + 1_000_000 / 4); + 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, DENOM); + 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); @@ -381,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); @@ -406,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); @@ -421,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); @@ -470,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); @@ -495,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); @@ -515,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] @@ -547,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, cw20_denom, 5_000_000); - suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); - suite.assert_pending_rewards(ADDR3, cw20_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, cw20_denom, 10_000_000); - suite.assert_pending_rewards(ADDR2, cw20_denom, 5_000_000); - suite.assert_pending_rewards(ADDR3, cw20_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, cw20_denom); - suite.assert_cw20_balance(ADDR1, 10_000_000); - suite.assert_pending_rewards(ADDR1, cw20_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); @@ -572,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, cw20_denom, 10_000_000); - suite.assert_pending_rewards(ADDR2, cw20_denom, 5_000_000); - suite.assert_pending_rewards(ADDR3, cw20_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, cw20_denom); - suite.claim_rewards(ADDR3, cw20_denom); + 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] @@ -605,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); @@ -630,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); @@ -681,9 +721,9 @@ fn test_native_dao_rewards_time_based_with_rounding() { // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(ADDR1, DENOM, 70); - suite.assert_pending_rewards(ADDR2, DENOM, 20); - suite.assert_pending_rewards(ADDR3, DENOM, 10); + 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( @@ -693,41 +733,41 @@ fn test_native_dao_rewards_time_based_with_rounding() { }], vec![], ); - suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR2, 1); suite.assert_native_balance(ADDR2, DENOM, 20); - suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(ADDR1, DENOM, 70 + 63); - suite.assert_pending_rewards(ADDR2, DENOM, 27); - suite.assert_pending_rewards(ADDR3, DENOM, 10 + 9); + 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_reward_emission_rate(DENOM, Duration::Time(100), 150); - suite.claim_rewards(ADDR3, DENOM); + suite.update_emission_rate(1, Duration::Time(100), 150); + suite.claim_rewards(ADDR3, 1); suite.assert_native_balance(ADDR3, DENOM, 10 + 9); - suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_pending_rewards(ADDR3, 1, 0); // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(ADDR1, DENOM, 70 + 63 + 95 + 1); - suite.assert_pending_rewards(ADDR2, DENOM, 27 + 40 + 1); - suite.assert_pending_rewards(ADDR3, DENOM, 13); + 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, DENOM); + suite.claim_rewards(ADDR1, 1); suite.assert_native_balance(ADDR1, DENOM, 70 + 63 + 95 + 1); - suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); // skip 3 intervals suite.skip_seconds(300); - suite.assert_pending_rewards(ADDR1, DENOM, 3 * 95 + 1); - suite.assert_pending_rewards(ADDR2, DENOM, 27 + 4 * 40 + 1 + 1 + 1); - suite.assert_pending_rewards(ADDR3, DENOM, 4 * 13 + 1 + 1); + 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( @@ -748,27 +788,27 @@ fn test_native_dao_rewards_time_based_with_rounding() { vec![], ); - suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR2, 1); suite.assert_native_balance(ADDR2, DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1); - suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(ADDR1, DENOM, 3 * 95 + 1 + 68); - suite.assert_pending_rewards(ADDR2, DENOM, 54); - suite.assert_pending_rewards(ADDR3, DENOM, 4 * 13 + 1 + 1 + 27); + 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, DENOM); - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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, 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); // TODO: fix this rug of 3 udenom by the distribution contract suite.assert_native_balance( @@ -789,21 +829,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); @@ -814,13 +854,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); @@ -840,9 +880,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()]); @@ -852,9 +892,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, 5_000_000 + 6_666_666); - suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); - suite.assert_pending_rewards(ADDR3, DENOM, 3_333_333 + 2_500_000); + 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 { @@ -868,27 +908,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.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, 3_333_333 + 2_500_000); + 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( @@ -896,16 +936,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![ @@ -925,34 +965,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(); @@ -968,10 +1008,10 @@ 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()), @@ -989,12 +1029,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() { @@ -1006,8 +1075,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(), "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 @@ -1036,8 +1104,7 @@ 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] @@ -1048,7 +1115,7 @@ fn test_withdraw_finished_rewards_period() { // skip to expiration suite.skip_blocks(2_000_000); - suite.withdraw_denom_funds(DENOM); + suite.withdraw(1); } #[test] @@ -1061,13 +1128,13 @@ fn test_withdraw_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); @@ -1080,7 +1147,7 @@ fn test_withdraw_alternative_destination_address() { let pre_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); - suite.withdraw_denom_funds(DENOM); + suite.withdraw(1); let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); @@ -1101,13 +1168,13 @@ fn test_withdraw_block_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); // 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); @@ -1120,7 +1187,7 @@ fn test_withdraw_block_based() { suite.get_balance_native(distribution_contract.clone(), DENOM); suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); - suite.withdraw_denom_funds(DENOM); + suite.withdraw(1); let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); @@ -1141,26 +1208,26 @@ fn test_withdraw_block_based() { // ensure cannot withdraw again assert_eq!( - suite.withdraw_denom_funds_error(DENOM), + 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, 3_333_333 + 2_500_000); + 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.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 @@ -1182,13 +1249,13 @@ fn test_withdraw_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); // 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); @@ -1201,7 +1268,7 @@ fn test_withdraw_time_based() { suite.get_balance_native(distribution_contract.clone(), DENOM); suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); - suite.withdraw_denom_funds(DENOM); + suite.withdraw(1); let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); @@ -1222,26 +1289,26 @@ fn test_withdraw_time_based() { // ensure cannot withdraw again assert_eq!( - suite.withdraw_denom_funds_error(DENOM), + 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, 3_333_333 + 2_500_000); + 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.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 @@ -1263,14 +1330,14 @@ fn test_withdraw_and_restart_with_continuous() { // 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); // users claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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); @@ -1281,7 +1348,7 @@ fn test_withdraw_and_restart_with_continuous() { suite.get_balance_native(distribution_contract.clone(), DENOM); suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); - suite.withdraw_denom_funds(DENOM); + suite.withdraw(1); let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); @@ -1303,27 +1370,27 @@ fn test_withdraw_and_restart_with_continuous() { // ensure cannot withdraw again assert_eq!( - suite.withdraw_denom_funds_error(DENOM), + suite.withdraw_error(1), ContractError::RewardsAlreadyDistributed {} ); // we assert that pending rewards did not change - 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.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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_distributor_native(coin(100_000_000, DENOM)); + 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, 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); } #[test] @@ -1341,14 +1408,14 @@ fn test_withdraw_and_restart_not_continuous() { // 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); // users claim their rewards - suite.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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); @@ -1359,7 +1426,7 @@ fn test_withdraw_and_restart_not_continuous() { suite.get_balance_native(distribution_contract.clone(), DENOM); suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); - suite.withdraw_denom_funds(DENOM); + suite.withdraw(1); let post_withdraw_distributor_balance = suite.get_balance_native(distribution_contract.clone(), DENOM); @@ -1381,20 +1448,20 @@ fn test_withdraw_and_restart_not_continuous() { // ensure cannot withdraw again assert_eq!( - suite.withdraw_denom_funds_error(DENOM), + suite.withdraw_error(1), ContractError::RewardsAlreadyDistributed {} ); // we assert that pending rewards did not change - 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.claim_rewards(ADDR1, DENOM); - suite.claim_rewards(ADDR2, DENOM); - suite.claim_rewards(ADDR3, DENOM); + 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_distributor_native(coin(100_000_000, DENOM)); + suite.fund_native(1, coin(100_000_000, DENOM)); // skip 1/10th of the time suite.skip_seconds(100_000); @@ -1402,9 +1469,9 @@ fn test_withdraw_and_restart_not_continuous() { // 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, 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); } #[test] @@ -1421,38 +1488,30 @@ fn test_withdraw_unauthorized() { .execute_contract( Addr::unchecked(ADDR1), suite.distribution_contract.clone(), - &ExecuteMsg::Withdraw { - denom: DENOM.to_string(), - }, + &ExecuteMsg::Withdraw { id: 1 }, &[], ) .unwrap(); } #[test] -#[should_panic] -fn test_withdraw_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.withdraw_denom_funds("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, - continuous: true, - }; - suite.register_reward_denom(reward_config, &hook_caller, None); + suite.skip_blocks(100_000); + + suite.claim_rewards(ADDR1, 3); } #[test] @@ -1463,39 +1522,19 @@ 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_not_continuous() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) @@ -1519,22 +1558,22 @@ fn test_fund_native_block_based_post_expiration_not_continuous() { // 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); @@ -1542,7 +1581,7 @@ fn test_fund_native_block_based_post_expiration_not_continuous() { // 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(); @@ -1581,23 +1620,23 @@ fn test_fund_cw20_time_based_post_expiration_not_continuous() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(ADDR1, cw20_denom, 5_000_000); - suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); - suite.assert_pending_rewards(ADDR3, cw20_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, cw20_denom, 65_000_000); - suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); - suite.assert_pending_rewards(ADDR3, cw20_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); @@ -1610,7 +1649,7 @@ fn test_fund_cw20_time_based_post_expiration_not_continuous() { 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(); @@ -1638,8 +1677,6 @@ fn test_fund_cw20_time_based_pre_expiration() { }) .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); @@ -1651,22 +1688,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, cw20_denom, 5_000_000); - suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); - suite.assert_pending_rewards(ADDR3, cw20_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, cw20_denom, 58_333_333); - suite.assert_pending_rewards(ADDR2, cw20_denom, 2_500_000); - suite.assert_pending_rewards(ADDR3, cw20_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); @@ -1678,7 +1715,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 @@ -1707,22 +1744,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); @@ -1730,7 +1767,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 @@ -1759,27 +1796,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%] @@ -1787,26 +1823,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); @@ -1816,14 +1852,14 @@ fn test_native_dao_rewards_entry_edge_case() { } #[test] -fn test_fund_native_on_register() { +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_coin(alt_coin.clone(), OWNER); + suite.mint_native(alt_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()), @@ -1835,10 +1871,10 @@ fn test_fund_native_on_register() { Some(alt_coin.amount), ); - let denom = suite.get_denom(ALT_DENOM); - assert_eq!(denom.funded_amount, alt_coin.amount); + let distribution = suite.get_distribution(2); + assert_eq!(distribution.funded_amount, alt_coin.amount); assert_eq!( - denom.active_epoch, + distribution.active_epoch, Epoch { emission_rate: RewardEmissionRate { amount: Uint128::new(1000), @@ -1853,9 +1889,9 @@ fn test_fund_native_on_register() { suite.skip_blocks(1_000_000); // skip 1/10th of the time - suite.assert_pending_rewards(ADDR1, ALT_DENOM, 5_000_000); - suite.assert_pending_rewards(ADDR2, ALT_DENOM, 2_500_000); - suite.assert_pending_rewards(ADDR3, ALT_DENOM, 2_500_000); + 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] @@ -1863,9 +1899,9 @@ fn test_fund_native_on_register() { fn test_fund_native_with_other_denom() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); - suite.mint_native_coin(coin(100, ALT_DENOM), OWNER); + suite.mint_native(coin(100, ALT_DENOM), OWNER); - let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { + let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Native(DENOM.to_string()), emission_rate: RewardEmissionRate { amount: Uint128::new(1000), @@ -1877,13 +1913,13 @@ fn test_fund_native_with_other_denom() { withdraw_destination: None, }); - // register native denom with other denom provided + // create distribution with other denom provided suite .app .execute_contract( Addr::unchecked(OWNER), suite.distribution_contract.clone(), - ®ister_reward_denom_msg, + &execute_create_msg, &coins(100, ALT_DENOM), ) .unwrap(); @@ -1894,10 +1930,10 @@ fn test_fund_native_with_other_denom() { fn test_fund_native_multiple_denoms() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); - suite.mint_native_coin(coin(100, DENOM), OWNER); - suite.mint_native_coin(coin(100, ALT_DENOM), OWNER); + suite.mint_native(coin(100, DENOM), OWNER); + suite.mint_native(coin(100, ALT_DENOM), OWNER); - let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { + let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Native(DENOM.to_string()), emission_rate: RewardEmissionRate { amount: Uint128::new(1000), @@ -1909,27 +1945,37 @@ fn test_fund_native_multiple_denoms() { withdraw_destination: None, }); - // register native denom with 0 amount + // create distribution with 0 amount suite .app .execute_contract( Addr::unchecked(OWNER), suite.distribution_contract.clone(), - ®ister_reward_denom_msg, + &execute_create_msg, &[coin(100, DENOM), coin(100, ALT_DENOM)], ) .unwrap(); } #[test] -#[should_panic(expected = "You cannot send native funds when registering a CW20")] -fn test_fund_native_on_register_cw20() { +#[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(coin(100, DENOM), OWNER); + suite.mint_native(coin(100, DENOM), OWNER); - let register_reward_denom_msg = ExecuteMsg::Register(RegisterMsg { - denom: cw20::UncheckedDenom::Cw20(DENOM.to_string()), + 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: RewardEmissionRate { amount: Uint128::new(1000), duration: Duration::Height(100), @@ -1940,13 +1986,13 @@ fn test_fund_native_on_register_cw20() { withdraw_destination: None, }); - // register cw20 denom with native funds provided + // create cw20 distribution with native funds provided suite .app .execute_contract( Addr::unchecked(OWNER), suite.distribution_contract.clone(), - ®ister_reward_denom_msg, + &execute_create_msg, &coins(100, DENOM), ) .unwrap(); @@ -1956,15 +2002,15 @@ fn test_fund_native_on_register_cw20() { fn test_update_continuous() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); - suite.update_continuous(DENOM, true); + suite.update_continuous(1, true); - let denom = suite.get_denom(DENOM); - assert!(denom.continuous); + let distribution = suite.get_distribution(1); + assert!(distribution.continuous); - suite.update_continuous(DENOM, false); + suite.update_continuous(1, false); - let denom = suite.get_denom(DENOM); - assert!(!denom.continuous); + let distribution = suite.get_distribution(1); + assert!(!distribution.continuous); } #[test] @@ -1984,10 +2030,10 @@ fn test_update_vp_contract() { let new_vp_contract = setup_native_token_test(suite.app.borrow_mut()); - suite.update_vp_contract(DENOM, new_vp_contract.as_str()); + suite.update_vp_contract(1, new_vp_contract.as_str()); - let denom = suite.get_denom(DENOM); - assert_eq!(denom.vp_contract, new_vp_contract); + let distribution = suite.get_distribution(1); + assert_eq!(distribution.vp_contract, new_vp_contract); } #[test] @@ -1995,10 +2041,10 @@ 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(DENOM, new_hook_caller); + suite.update_hook_caller(1, new_hook_caller); - let denom = suite.get_denom(DENOM); - assert_eq!(denom.hook_caller, new_hook_caller); + let distribution = suite.get_distribution(1); + assert_eq!(distribution.hook_caller, new_hook_caller); } #[test] @@ -2006,10 +2052,18 @@ 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(DENOM, 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(); - let denom = suite.get_denom(DENOM); - assert_eq!(denom.withdraw_destination, new_withdraw_destination); + suite.update_continuous(3, false); } #[test] From d71dcba26210dfd37cafc23f710ef4f0bf06ced2 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Jul 2024 00:07:09 -0400 Subject: [PATCH 34/39] added explicit paused emission rate variant --- .../schema/dao-rewards-distributor.json | 254 ++++++++++++------ .../dao-rewards-distributor/src/contract.rs | 41 +-- .../dao-rewards-distributor/src/error.rs | 3 + .../dao-rewards-distributor/src/msg.rs | 6 +- .../dao-rewards-distributor/src/rewards.rs | 98 +++---- .../dao-rewards-distributor/src/state.rs | 175 +++++++----- .../src/testing/suite.rs | 50 +++- .../src/testing/tests.rs | 39 ++- 8 files changed, 423 insertions(+), 243 deletions(-) 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 7fd9d0638..ad32aabe8 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -97,7 +97,7 @@ "description": "reward emission rate", "anyOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/EmissionRate" }, { "type": "null" @@ -311,7 +311,7 @@ "description": "reward emission rate", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/EmissionRate" } ] }, @@ -388,6 +388,61 @@ } ] }, + "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 at a constant rate", + "type": "object", + "required": [ + "linear" + ], + "properties": { + "linear": { + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "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": [ @@ -553,33 +608,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": { - "description": "amount of tokens to distribute per amount of time", - "allOf": [ - { - "$ref": "#/definitions/Uint128" - } - ] - }, - "duration": { - "description": "duration of time to distribute amount", - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ] - } - }, - "additionalProperties": false - }, "StakeChangedHookMsg": { "description": "An enum representing staking hooks.", "oneOf": [ @@ -969,6 +997,61 @@ } ] }, + "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 at a constant rate", + "type": "object", + "required": [ + "linear" + ], + "properties": { + "linear": { + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "duration": { + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "Epoch": { "type": "object", "required": [ @@ -983,7 +1066,7 @@ "description": "reward emission rate", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/EmissionRate" } ] }, @@ -1069,33 +1152,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": { - "description": "amount of tokens to distribute per amount of time", - "allOf": [ - { - "$ref": "#/definitions/Uint128" - } - ] - }, - "duration": { - "description": "duration of time to distribute amount", - "allOf": [ - { - "$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": [ @@ -1285,6 +1341,61 @@ } ] }, + "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 at a constant rate", + "type": "object", + "required": [ + "linear" + ], + "properties": { + "linear": { + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "description": "amount of tokens to distribute per amount of time", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "duration": { + "description": "duration of time to distribute amount", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "Epoch": { "type": "object", "required": [ @@ -1299,7 +1410,7 @@ "description": "reward emission rate", "allOf": [ { - "$ref": "#/definitions/RewardEmissionRate" + "$ref": "#/definitions/EmissionRate" } ] }, @@ -1385,33 +1496,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": { - "description": "amount of tokens to distribute per amount of time", - "allOf": [ - { - "$ref": "#/definitions/Uint128" - } - ] - }, - "duration": { - "description": "duration of time to distribute amount", - "allOf": [ - { - "$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": [ diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 89ae2c486..78a6e7f69 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -24,9 +24,7 @@ use crate::msg::{ use crate::rewards::{ get_accrued_rewards_not_yet_accounted_for, get_active_total_earned_puvp, update_rewards, }; -use crate::state::{ - DistributionState, Epoch, RewardEmissionRate, COUNT, DISTRIBUTIONS, USER_REWARDS, -}; +use crate::state::{DistributionState, EmissionRate, Epoch, COUNT, DISTRIBUTIONS, USER_REWARDS}; use crate::ContractError; const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); @@ -150,6 +148,8 @@ fn execute_create( None => info.sender.clone(), }; + msg.emission_rate.validate()?; + // Initialize the distribution state let distribution = DistributionState { id, @@ -211,7 +211,7 @@ fn execute_update( env: Env, info: MessageInfo, id: u64, - emission_rate: Option, + emission_rate: Option, continuous: Option, vp_contract: Option, hook_caller: Option, @@ -227,6 +227,8 @@ fn execute_update( .map_err(|_| ContractError::DistributionNotFound { id })?; if let Some(emission_rate) = emission_rate { + emission_rate.validate()?; + // transition the epoch to the new emission rate distribution.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; } @@ -305,21 +307,25 @@ fn execute_fund( // funded_amount if restart_distribution { distribution.funded_amount = amount; - distribution.active_epoch.started_at = - match distribution.active_epoch.emission_rate.duration { + distribution.active_epoch.started_at = match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => 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; } - distribution.active_epoch.ends_at = distribution.active_epoch.started_at.add( - distribution - .active_epoch - .emission_rate - .get_funded_period_duration(distribution.funded_amount)?, - )?; + let new_funded_duration = distribution + .active_epoch + .emission_rate + .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 {}, + }; // if continuous, meaning rewards should have been distributed in the past // that were not due to lack of sufficient funding, ensure the total rewards @@ -329,7 +335,7 @@ fn execute_fund( get_active_total_earned_puvp(deps.as_ref(), &env.block, &distribution)?; } - distribution.active_epoch.bump_last_updated(&env.block)?; + distribution.active_epoch.bump_last_updated(&env.block); DISTRIBUTIONS.save(deps.storage, distribution.id, &distribution)?; @@ -414,9 +420,10 @@ fn execute_withdraw( ); // withdraw ends the epoch early - distribution.active_epoch.ends_at = match distribution.active_epoch.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(env.block.height), - Duration::Time(_) => Expiration::AtTime(env.block.time), + 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 total rewards distributed based on newly updated ends_at diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index e2980a78e..27914bf2a 100644 --- a/contracts/distribution/dao-rewards-distributor/src/error.rs +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -42,4 +42,7 @@ pub enum ContractError { #[error("Unexpected duplicate distribution with ID {id}")] UnexpectedDuplicateDistributionId { id: u64 }, + + #[error("Invalid emission rate: {field} cannot be zero")] + InvalidEmissionRateFieldZero { field: String }, } diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index d10ec6ba0..4f9d9dbf3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -11,7 +11,7 @@ use dao_interface::voting::InfoResponse; pub use cw_controllers::ClaimsResponse; pub use cw_ownable::Ownership; -use crate::state::{DistributionState, RewardEmissionRate}; +use crate::state::{DistributionState, EmissionRate}; #[cw_serde] pub struct InstantiateMsg { @@ -37,7 +37,7 @@ pub enum ExecuteMsg { /// distribution ID to update id: u64, /// reward emission rate - emission_rate: Option, + emission_rate: Option, /// whether or not reward distribution is continuous: whether rewards /// should be paused once all funding has been distributed, or if future /// funding after distribution finishes should be applied to the past. @@ -67,7 +67,7 @@ pub struct CreateMsg { /// denom to distribute pub denom: UncheckedDenom, /// reward emission rate - pub emission_rate: RewardEmissionRate, + pub emission_rate: EmissionRate, /// whether or not reward distribution is continuous: whether rewards should /// be paused once all funding has been distributed, or if future funding /// after distribution finishes should be applied to the past. diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index b185b3d08..768394430 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -5,7 +5,7 @@ use crate::{ get_duration_scalar, get_exp_diff, get_prev_block_total_vp, get_voting_power_at_block, scale_factor, }, - state::{DistributionState, UserRewardState, DISTRIBUTIONS, USER_REWARDS}, + state::{DistributionState, EmissionRate, UserRewardState, DISTRIBUTIONS, USER_REWARDS}, ContractError, }; @@ -31,7 +31,7 @@ pub fn update_rewards( // 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)?; + 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 @@ -83,51 +83,55 @@ pub fn get_active_total_earned_puvp( block: &BlockInfo, distribution: &DistributionState, ) -> StdResult { - 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(&distribution.active_epoch.emission_rate.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 = distribution - .active_epoch - .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.checked_add(new_rewards_puvp)?) + match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => Ok(Uint256::zero()), + 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)?) + } + } } } diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 8e1fa3fd4..59fabec28 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -7,7 +7,11 @@ use cw_storage_plus::{Item, Map}; use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; -use crate::{helpers::get_exp_diff, rewards::get_active_total_earned_puvp}; +use crate::{ + helpers::{get_duration_scalar, get_exp_diff}, + rewards::get_active_total_earned_puvp, + ContractError, +}; /// map user address to their unique reward state pub const USER_REWARDS: Map = Map::new("ur"); @@ -36,54 +40,80 @@ pub struct UserRewardState { /// defines how many tokens (amount) should be distributed per amount of time /// (duration). e.g. 5udenom per hour. #[cw_serde] -pub struct RewardEmissionRate { - /// amount of tokens to distribute per amount of time - pub amount: Uint128, - /// duration of time to distribute amount - pub duration: Duration, +pub enum EmissionRate { + /// rewards are paused + Paused {}, + /// 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, + }, } -impl RewardEmissionRate { - // 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 { - // if amount being distributed is 0 (rewards are paused), we return the max duration - if self.amount.is_zero() { - return match self.duration { - Duration::Height(_) => Ok(Duration::Height(u64::MAX)), - Duration::Time(_) => Ok(Duration::Time(u64::MAX)), - }; +impl EmissionRate { + /// validate non-zero amount and duration, if not paused + pub fn validate(&self) -> Result<(), ContractError> { + match self { + EmissionRate::Paused {} => 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(()) + } } + } - let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, self.amount); + /// 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 the max duration + EmissionRate::Paused {} => Ok(None), + EmissionRate::Linear { amount, duration } => { + let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, *amount); - let funded_duration = match self.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) - } - }; + 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(funded_duration) + Ok(Some(funded_duration)) + } + } } } #[cw_serde] pub struct Epoch { /// reward emission rate - pub emission_rate: RewardEmissionRate, + pub emission_rate: EmissionRate, /// the time when the current reward distribution period started. period /// finishes iff it reaches its end. pub started_at: Expiration, @@ -101,40 +131,43 @@ impl Epoch { /// get the total rewards to be distributed based on the emission rate and /// duration from start to end pub fn get_total_rewards(&self) -> StdResult { - let epoch_duration = get_exp_diff(&self.ends_at, &self.started_at)?; + match self.emission_rate { + EmissionRate::Paused {} => Ok(Uint128::zero()), + EmissionRate::Linear { amount, duration } => { + let epoch_duration = get_exp_diff(&self.ends_at, &self.started_at)?; - let emission_rate_duration_scalar = match self.emission_rate.duration { - Duration::Height(h) => h, - Duration::Time(t) => t, - }; + let emission_rate_duration_scalar = match duration { + Duration::Height(h) => h, + Duration::Time(t) => t, + }; - self.emission_rate - .amount - .checked_multiply_ratio(epoch_duration, emission_rate_duration_scalar) - .map_err(|e| StdError::generic_err(e.to_string())) + amount + .checked_multiply_ratio(epoch_duration, emission_rate_duration_scalar) + .map_err(|e| StdError::generic_err(e.to_string())) + } + } } /// 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. - pub fn bump_last_updated(&mut self, current_block: &BlockInfo) -> StdResult<()> { - match (self.emission_rate.duration, self.ends_at) { - (Duration::Height(_), Expiration::AtHeight(ends_at_height)) => { + /// 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)); - Ok(()) } - (Duration::Time(_), Expiration::AtTime(ends_at_time)) => { + Expiration::AtTime(ends_at_time) => { self.last_updated_total_earned_puvp = Expiration::AtTime(std::cmp::min(current_block.time, ends_at_time)); - Ok(()) } - _ => Err(StdError::generic_err(format!( - "incompatible emission_rate.duration ({:?}) and ends_at ({:?}) values", - self.emission_rate.duration, self.ends_at - ))), } } } @@ -205,7 +238,7 @@ impl DistributionState { pub fn transition_epoch( &mut self, deps: Deps, - new_emission_rate: RewardEmissionRate, + new_emission_rate: EmissionRate, current_block: &BlockInfo, ) -> StdResult<()> { // if the new emission rate is the same as the active one, do nothing @@ -216,9 +249,10 @@ impl DistributionState { // 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.emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), + 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 @@ -229,10 +263,7 @@ impl DistributionState { // 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 = match self.active_epoch.emission_rate.amount.is_zero() { - true => Uint128::zero(), - false => self.active_epoch.get_total_rewards()?, - }; + let active_epoch_earned_rewards = self.active_epoch.get_total_rewards()?; self.funded_amount = self .funded_amount .checked_sub(active_epoch_earned_rewards)?; @@ -244,25 +275,29 @@ impl DistributionState { // 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)? { - Duration::Height(h) => { + Some(Duration::Height(h)) => { if current_block.height.checked_add(h).is_some() { Expiration::AtHeight(current_block.height + h) } else { Expiration::AtHeight(u64::MAX) } } - Duration::Time(t) => { + 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)) } } + None => Expiration::Never {}, }; - let new_started_at = match new_emission_rate.duration { - Duration::Height(_) => Expiration::AtHeight(current_block.height), - Duration::Time(_) => Expiration::AtTime(current_block.time), + let new_started_at = match new_emission_rate { + EmissionRate::Paused {} => 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 { diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 418673717..5fae000e5 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -14,7 +14,7 @@ use crate::{ CreateMsg, DistributionsResponse, ExecuteMsg, FundMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveCw20Msg, }, - state::{DistributionState, RewardEmissionRate}, + state::{DistributionState, EmissionRate}, testing::cw20_setup::instantiate_cw20, ContractError, }; @@ -438,19 +438,24 @@ impl Suite { pub fn assert_amount(&mut self, expected: u128) { let distribution = &self.get_distributions().distributions[0]; - assert_eq!( - distribution.active_epoch.emission_rate.amount, - Uint128::new(expected) - ); + match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => panic!("expected non-paused emission rate"), + EmissionRate::Linear { amount, .. } => assert_eq!(amount, Uint128::new(expected)), + } } pub fn assert_duration(&mut self, expected: u64) { let distribution = &self.get_distributions().distributions[0]; - let units = match distribution.active_epoch.emission_rate.duration { - Duration::Height(h) => h, - Duration::Time(t) => t, - }; - assert_eq!(units, expected); + match distribution.active_epoch.emission_rate { + EmissionRate::Paused {} => panic!("expected non-paused 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, id: u64, expected: u128) { @@ -540,7 +545,7 @@ impl Suite { ) { 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, }, @@ -710,7 +715,7 @@ impl Suite { pub fn update_emission_rate(&mut self, id: u64, epoch_duration: Duration, epoch_rewards: u128) { let msg: ExecuteMsg = ExecuteMsg::Update { id, - emission_rate: Some(RewardEmissionRate { + emission_rate: Some(EmissionRate::Linear { amount: Uint128::new(epoch_rewards), duration: epoch_duration, }), @@ -731,6 +736,27 @@ impl Suite { .unwrap(); } + pub fn pause_emission(&mut self, id: u64) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: Some(EmissionRate::Paused {}), + continuous: None, + 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_continuous(&mut self, id: u64, continuous: bool) { let msg: ExecuteMsg = ExecuteMsg::Update { id, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 3af7a2a9d..9d34c9198 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -10,7 +10,7 @@ use cw_utils::Duration; use dao_interface::voting::InfoResponse; use crate::msg::{CreateMsg, FundMsg}; -use crate::state::{Epoch, RewardEmissionRate}; +use crate::state::{EmissionRate, Epoch}; use crate::testing::native_setup::setup_native_token_test; use crate::ContractError; use crate::{ @@ -121,8 +121,8 @@ fn test_native_dao_rewards_update_reward_rate() { suite.assert_pending_rewards(ADDR2, 1, 13_750_000); suite.assert_pending_rewards(ADDR3, 1, 13_750_000); - // set the rewards rate to 0, pausing the rewards distribution - suite.update_emission_rate(1, Duration::Height(10000000000), 0); + // pause the rewards distribution + suite.pause_emission(1); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -298,8 +298,8 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { suite.assert_pending_rewards(ADDR2, 1, 13_750_000); suite.assert_pending_rewards(ADDR3, 1, 13_750_000); - // set the rewards rate to 0, pausing the rewards distribution - suite.update_emission_rate(1, Duration::Height(10000000000), 0); + // pause the rewards distribution + suite.pause_emission(1); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -1876,7 +1876,7 @@ fn test_fund_native_on_create() { assert_eq!( distribution.active_epoch, Epoch { - emission_rate: RewardEmissionRate { + emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), }, @@ -1903,7 +1903,7 @@ fn test_fund_native_with_other_denom() { let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Native(DENOM.to_string()), - emission_rate: RewardEmissionRate { + emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), }, @@ -1935,7 +1935,7 @@ fn test_fund_native_multiple_denoms() { let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Native(DENOM.to_string()), - emission_rate: RewardEmissionRate { + emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), }, @@ -1976,7 +1976,7 @@ fn test_fund_native_on_create_cw20() { let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Cw20(cw20_denom), - emission_rate: RewardEmissionRate { + emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), }, @@ -2066,6 +2066,27 @@ fn test_update_404() { suite.update_continuous(3, 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); +} + +#[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); +} + +#[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); +} + #[test] fn test_query_info() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); From cdba640a5d756c0e0cd6a57173752592b33d1389 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Jul 2024 02:19:18 -0400 Subject: [PATCH 35/39] added explicit immediate emission rate variant --- .../schema/dao-rewards-distributor.json | 42 ++++ .../dao-rewards-distributor/src/contract.rs | 19 +- .../dao-rewards-distributor/src/error.rs | 8 +- .../dao-rewards-distributor/src/rewards.rs | 6 +- .../dao-rewards-distributor/src/state.rs | 125 ++++++++--- .../src/testing/mod.rs | 7 +- .../src/testing/suite.rs | 23 ++ .../src/testing/tests.rs | 209 +++++++++++++++++- 8 files changed, 401 insertions(+), 38 deletions(-) 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 ad32aabe8..e0952d51a 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -405,6 +405,20 @@ }, "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", @@ -1014,6 +1028,20 @@ }, "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", @@ -1358,6 +1386,20 @@ }, "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", diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 78a6e7f69..f9dc9652f 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -294,7 +294,7 @@ fn execute_fund( // 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 let Expiration::Never {} = distribution.active_epoch.started_at { + let restart_distribution = if distribution.funded_amount.is_zero() { true } else { !distribution.continuous && distribution.active_epoch.ends_at.is_expired(&env.block) @@ -309,6 +309,7 @@ fn execute_fund( 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), @@ -327,10 +328,20 @@ fn execute_fund( None => Expiration::Never {}, }; + // 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, + )?; + // if continuous, meaning rewards should have been distributed in the past - // that were not due to lack of sufficient funding, ensure the total rewards + // but were not due to lack of sufficient funding, ensure the total rewards // earned puvp is up to date. - if !restart_distribution && distribution.continuous { + } else if !restart_distribution && distribution.continuous { distribution.active_epoch.total_earned_puvp = get_active_total_earned_puvp(deps.as_ref(), &env.block, &distribution)?; } @@ -427,7 +438,7 @@ fn execute_withdraw( }; // get total rewards distributed based on newly updated ends_at - let rewards_distributed = distribution.active_epoch.get_total_rewards()?; + let rewards_distributed = distribution.get_total_rewards()?; let clawback_amount = distribution.funded_amount - rewards_distributed; diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs index 27914bf2a..d3b9fadb4 100644 --- a/contracts/distribution/dao-rewards-distributor/src/error.rs +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{OverflowError, StdError}; +use cosmwasm_std::{DivideByZeroError, OverflowError, StdError}; use cw_utils::PaymentError; use thiserror::Error; @@ -16,6 +16,9 @@ pub enum ContractError { #[error(transparent)] Overflow(#[from] OverflowError), + #[error(transparent)] + DivideByZero(#[from] DivideByZeroError), + #[error(transparent)] Payment(#[from] PaymentError), @@ -45,4 +48,7 @@ pub enum ContractError { #[error("Invalid emission rate: {field} cannot be zero")] InvalidEmissionRateFieldZero { field: String }, + + #[error("There is no voting power registered, so no one will receive these funds")] + NoVotingPowerNoRewards {}, } diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index 768394430..86a998d31 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -22,6 +22,7 @@ pub fn update_rewards( .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 @@ -76,8 +77,7 @@ pub fn update_rewards( Ok(()) } -/// Calculate the total rewards earned per unit voting power in the active epoch -/// since the last update. +/// Calculate the total rewards per unit voting power in the active epoch. pub fn get_active_total_earned_puvp( deps: Deps, block: &BlockInfo, @@ -85,6 +85,8 @@ pub fn get_active_total_earned_puvp( ) -> 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; diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 59fabec28..4eb0fec2c 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -1,6 +1,7 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{ - Addr, BlockInfo, Decimal, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, Uint64, + ensure, Addr, BlockInfo, Decimal, Deps, StdError, StdResult, Timestamp, Uint128, Uint256, + Uint64, }; use cw20::{Denom, Expiration}; use cw_storage_plus::{Item, Map}; @@ -8,7 +9,7 @@ use cw_utils::Duration; use std::{cmp::min, collections::HashMap}; use crate::{ - helpers::{get_duration_scalar, get_exp_diff}, + helpers::{get_duration_scalar, get_exp_diff, get_prev_block_total_vp, scale_factor}, rewards::get_active_total_earned_puvp, ContractError, }; @@ -43,6 +44,8 @@ pub struct UserRewardState { 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 @@ -53,10 +56,11 @@ pub enum EmissionRate { } impl EmissionRate { - /// validate non-zero amount and duration, if not paused + /// 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 { @@ -82,8 +86,11 @@ impl EmissionRate { funded_amount: Uint128, ) -> StdResult> { match self { - // if rewards are paused, return the max duration + // 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); @@ -128,26 +135,6 @@ pub struct Epoch { } impl Epoch { - /// get the total rewards to be distributed based on the emission rate and - /// duration from start to end - pub fn get_total_rewards(&self) -> StdResult { - match self.emission_rate { - EmissionRate::Paused {} => Ok(Uint128::zero()), - EmissionRate::Linear { amount, duration } => { - let epoch_duration = get_exp_diff(&self.ends_at, &self.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())) - } - } - } - /// 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 @@ -158,7 +145,14 @@ impl Epoch { pub fn bump_last_updated(&mut self, current_block: &BlockInfo) { match self.ends_at { Expiration::Never {} => { - self.last_updated_total_earned_puvp = Expiration::Never {}; + self.last_updated_total_earned_puvp = + // for immediate emission, there is no ends_at. always + // update to current block height. + if (self.emission_rate == EmissionRate::Immediate {}) { + Expiration::AtHeight(current_block.height) + } else { + Expiration::Never {} + }; } Expiration::AtHeight(ends_at_height) => { self.last_updated_total_earned_puvp = @@ -234,13 +228,35 @@ impl DistributionState { } } + /// 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, - ) -> StdResult<()> { + ) -> Result<(), ContractError> { // 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(()); @@ -263,7 +279,7 @@ impl DistributionState { // 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.active_epoch.get_total_rewards()?; + let active_epoch_earned_rewards = self.get_total_rewards()?; self.funded_amount = self .funded_amount .checked_sub(active_epoch_earned_rewards)?; @@ -289,11 +305,15 @@ impl DistributionState { Expiration::AtTime(Timestamp::from_seconds(u64::MAX)) } } + // 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), @@ -309,6 +329,57 @@ impl DistributionState { 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 5fae000e5..60ded68a1 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -440,6 +440,7 @@ impl Suite { 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)), } } @@ -448,6 +449,7 @@ impl Suite { 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, @@ -736,6 +738,27 @@ impl Suite { .unwrap(); } + pub fn set_immediate_emission(&mut self, id: u64) { + let msg: ExecuteMsg = ExecuteMsg::Update { + id, + emission_rate: Some(EmissionRate::Immediate {}), + continuous: None, + 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, diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 9d34c9198..59d1c4960 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -15,7 +15,7 @@ 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::{ @@ -818,6 +818,213 @@ fn test_native_dao_rewards_time_based_with_rounding() { ); } +#[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 {}, + continuous: true, + 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 {}, + continuous: true, + 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); + + // 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(); From 2e365ec4c46613bfc9d01f3faa5e9c70147d130d Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Jul 2024 02:30:22 -0400 Subject: [PATCH 36/39] moved continuous to linear emission rate enum --- .../schema/dao-rewards-distributor.json | 37 ++++++------- .../dao-rewards-distributor/src/contract.rs | 20 +++---- .../dao-rewards-distributor/src/msg.rs | 8 --- .../dao-rewards-distributor/src/rewards.rs | 4 +- .../dao-rewards-distributor/src/state.rs | 20 ++++--- .../src/testing/suite.rs | 38 ++++--------- .../src/testing/tests.rs | 53 ++++++++++--------- 7 files changed, 79 insertions(+), 101 deletions(-) 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 e0952d51a..7ab6c886c 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -86,13 +86,6 @@ "id" ], "properties": { - "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", - "type": [ - "boolean", - "null" - ] - }, "emission_rate": { "description": "reward emission rate", "anyOf": [ @@ -288,17 +281,12 @@ "CreateMsg": { "type": "object", "required": [ - "continuous", "denom", "emission_rate", "hook_caller", "vp_contract" ], "properties": { - "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", - "type": "boolean" - }, "denom": { "description": "denom to distribute", "allOf": [ @@ -430,6 +418,7 @@ "type": "object", "required": [ "amount", + "continuous", "duration" ], "properties": { @@ -441,6 +430,10 @@ } ] }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, "duration": { "description": "duration of time to distribute amount", "allOf": [ @@ -866,7 +859,6 @@ "type": "object", "required": [ "active_epoch", - "continuous", "denom", "funded_amount", "historical_earned_puvp", @@ -884,10 +876,6 @@ } ] }, - "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", - "type": "boolean" - }, "denom": { "description": "validated denom (native or cw20)", "allOf": [ @@ -1053,6 +1041,7 @@ "type": "object", "required": [ "amount", + "continuous", "duration" ], "properties": { @@ -1064,6 +1053,10 @@ } ] }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, "duration": { "description": "duration of time to distribute amount", "allOf": [ @@ -1256,7 +1249,6 @@ "type": "object", "required": [ "active_epoch", - "continuous", "denom", "funded_amount", "historical_earned_puvp", @@ -1274,10 +1266,6 @@ } ] }, - "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", - "type": "boolean" - }, "denom": { "description": "validated denom (native or cw20)", "allOf": [ @@ -1411,6 +1399,7 @@ "type": "object", "required": [ "amount", + "continuous", "duration" ], "properties": { @@ -1422,6 +1411,10 @@ } ] }, + "continuous": { + "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "type": "boolean" + }, "duration": { "description": "duration of time to distribute amount", "allOf": [ diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index f9dc9652f..8af1968b8 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -68,7 +68,6 @@ pub fn execute( ExecuteMsg::Update { id, emission_rate, - continuous, vp_contract, hook_caller, withdraw_destination, @@ -78,7 +77,6 @@ pub fn execute( info, id, emission_rate, - continuous, vp_contract, hook_caller, withdraw_destination, @@ -161,7 +159,6 @@ fn execute_create( total_earned_puvp: Uint256::zero(), last_updated_total_earned_puvp: Expiration::Never {}, }, - continuous: msg.continuous, vp_contract, hook_caller: hook_caller.clone(), funded_amount: Uint128::zero(), @@ -212,7 +209,6 @@ fn execute_update( info: MessageInfo, id: u64, emission_rate: Option, - continuous: Option, vp_contract: Option, hook_caller: Option, withdraw_destination: Option, @@ -233,10 +229,6 @@ fn execute_update( distribution.transition_epoch(deps.as_ref(), emission_rate, &env.block)?; } - if let Some(continuous) = continuous { - distribution.continuous = continuous; - } - if let Some(vp_contract) = vp_contract { distribution.vp_contract = validate_voting_power_contract(&deps, vp_contract)?; } @@ -289,6 +281,14 @@ fn execute_fund( mut distribution: DistributionState, amount: Uint128, ) -> Result { + // 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 @@ -297,7 +297,7 @@ fn execute_fund( let restart_distribution = if distribution.funded_amount.is_zero() { true } else { - !distribution.continuous && distribution.active_epoch.ends_at.is_expired(&env.block) + !continuous && distribution.active_epoch.ends_at.is_expired(&env.block) }; // if necessary, restart the distribution from the current block so that the @@ -341,7 +341,7 @@ fn execute_fund( // 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 && distribution.continuous { + } else if !restart_distribution && continuous { distribution.active_epoch.total_earned_puvp = get_active_total_earned_puvp(deps.as_ref(), &env.block, &distribution)?; } diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 4f9d9dbf3..04f134048 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -38,10 +38,6 @@ pub enum ExecuteMsg { id: u64, /// reward emission rate emission_rate: Option, - /// whether or not reward distribution is continuous: whether rewards - /// should be paused once all funding has been distributed, or if future - /// funding after distribution finishes should be applied to the past. - continuous: Option, /// address to query the voting power vp_contract: Option, /// address that will update the reward split when the voting power @@ -68,10 +64,6 @@ pub struct CreateMsg { pub denom: UncheckedDenom, /// reward emission rate pub emission_rate: EmissionRate, - /// whether or not reward distribution is continuous: whether rewards should - /// be paused once all funding has been distributed, or if future funding - /// after distribution finishes should be applied to the past. - pub continuous: bool, /// address to query the voting power pub vp_contract: String, /// address that will update the reward split when the voting power diff --git a/contracts/distribution/dao-rewards-distributor/src/rewards.rs b/contracts/distribution/dao-rewards-distributor/src/rewards.rs index 86a998d31..c44ece1fc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/rewards.rs +++ b/contracts/distribution/dao-rewards-distributor/src/rewards.rs @@ -87,7 +87,9 @@ pub fn get_active_total_earned_puvp( 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 } => { + EmissionRate::Linear { + amount, duration, .. + } => { let curr = distribution.active_epoch.total_earned_puvp; let last_time_rewards_distributed = diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 4eb0fec2c..90a44ed51 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -52,6 +52,10 @@ pub enum EmissionRate { amount: Uint128, /// duration of time to distribute amount duration: Duration, + /// whether or not reward distribution is continuous: whether rewards + /// should be paused once all funding has been distributed, or if future + /// funding after distribution finishes should be applied to the past. + continuous: bool, }, } @@ -61,7 +65,9 @@ impl EmissionRate { match self { EmissionRate::Paused {} => Ok(()), EmissionRate::Immediate {} => Ok(()), - EmissionRate::Linear { amount, duration } => { + EmissionRate::Linear { + amount, duration, .. + } => { if *amount == Uint128::zero() { return Err(ContractError::InvalidEmissionRateFieldZero { field: "amount".to_string(), @@ -91,7 +97,9 @@ impl EmissionRate { // if rewards are immediate, return no duration EmissionRate::Immediate {} => Ok(None), // if rewards are linear, calculate based on funded amount - EmissionRate::Linear { amount, duration } => { + EmissionRate::Linear { + amount, duration, .. + } => { let amount_to_emission_rate_ratio = Decimal::from_ratio(funded_amount, *amount); let funded_duration = match duration { @@ -175,10 +183,6 @@ pub struct DistributionState { pub denom: Denom, /// current distribution epoch state pub active_epoch: Epoch, - /// whether or not reward distribution is continuous: whether rewards should - /// be paused once all funding has been distributed, or if future funding - /// after distribution finishes should be applied to the past. - pub continuous: bool, /// address to query the voting power pub vp_contract: Addr, /// address that will update the reward split when the voting power @@ -234,7 +238,9 @@ impl DistributionState { match self.active_epoch.emission_rate { EmissionRate::Paused {} => Ok(Uint128::zero()), EmissionRate::Immediate {} => Ok(self.funded_amount), - EmissionRate::Linear { amount, duration } => { + EmissionRate::Linear { + amount, duration, .. + } => { let epoch_duration = get_exp_diff(&self.active_epoch.ends_at, &self.active_epoch.started_at)?; diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index 60ded68a1..82bf15dcd 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -550,8 +550,8 @@ impl Suite { emission_rate: EmissionRate::Linear { amount: Uint128::new(reward_config.amount), duration: reward_config.duration, + continuous: reward_config.continuous, }, - continuous: reward_config.continuous, hook_caller: hook_caller.to_string(), vp_contract: self.voting_power_addr.to_string(), withdraw_destination: reward_config.destination, @@ -714,14 +714,20 @@ 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) { + 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, }), - continuous: None, vp_contract: None, hook_caller: None, withdraw_destination: None, @@ -742,7 +748,6 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::Update { id, emission_rate: Some(EmissionRate::Immediate {}), - continuous: None, vp_contract: None, hook_caller: None, withdraw_destination: None, @@ -763,28 +768,6 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::Update { id, emission_rate: Some(EmissionRate::Paused {}), - continuous: None, - 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_continuous(&mut self, id: u64, continuous: bool) { - let msg: ExecuteMsg = ExecuteMsg::Update { - id, - emission_rate: None, - continuous: Some(continuous), vp_contract: None, hook_caller: None, withdraw_destination: None, @@ -805,7 +788,6 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::Update { id, emission_rate: None, - continuous: None, vp_contract: Some(vp_contract.to_string()), hook_caller: None, withdraw_destination: None, @@ -826,7 +808,6 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::Update { id, emission_rate: None, - continuous: None, vp_contract: None, hook_caller: Some(hook_caller.to_string()), withdraw_destination: None, @@ -847,7 +828,6 @@ impl Suite { let msg: ExecuteMsg = ExecuteMsg::Update { id, emission_rate: None, - continuous: None, vp_contract: None, hook_caller: None, withdraw_destination: Some(withdraw_destination.to_string()), diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 59d1c4960..120fd994e 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -94,7 +94,7 @@ fn test_native_dao_rewards_update_reward_rate() { // 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); + suite.update_emission_rate(1, Duration::Height(10), 500, true); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -105,7 +105,7 @@ fn test_native_dao_rewards_update_reward_rate() { // 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); + suite.update_emission_rate(1, Duration::Height(10), 1_000, true); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -162,10 +162,10 @@ fn test_native_dao_rewards_update_reward_rate() { // 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); + 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); + suite.update_emission_rate(1, Duration::Height(10), 1000, true); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -198,7 +198,7 @@ fn test_native_dao_rewards_update_reward_rate() { // 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); + 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 @@ -271,7 +271,7 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { suite.assert_pending_rewards(ADDR1, 1, 0); // set the rewards rate to time-based rewards - suite.update_emission_rate(1, Duration::Time(10), 500); + suite.update_emission_rate(1, Duration::Time(10), 500, true); // skip 1/10th of the time suite.skip_seconds(100_000); @@ -282,7 +282,7 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // 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); + suite.update_emission_rate(1, Duration::Time(10), 1_000, true); // skip 1/10th of the time suite.skip_seconds(100_000); @@ -339,7 +339,7 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // 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); + suite.update_emission_rate(1, Duration::Height(10), 1000, true); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -372,7 +372,7 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // 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); + 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 @@ -745,7 +745,7 @@ fn test_native_dao_rewards_time_based_with_rounding() { suite.assert_pending_rewards(ADDR3, 1, 10 + 9); // increase reward rate and claim - suite.update_emission_rate(1, Duration::Time(100), 150); + 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); @@ -833,7 +833,6 @@ fn test_immediate_emission() { let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), emission_rate: EmissionRate::Immediate {}, - continuous: true, hook_caller: suite.staking_addr.to_string(), vp_contract: suite.voting_power_addr.to_string(), withdraw_destination: None, @@ -922,7 +921,6 @@ fn test_immediate_emission_fails_if_no_voting_power() { let execute_create_msg = ExecuteMsg::Create(CreateMsg { denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), emission_rate: EmissionRate::Immediate {}, - continuous: true, hook_caller: suite.staking_addr.to_string(), vp_contract: suite.voting_power_addr.to_string(), withdraw_destination: None, @@ -1009,7 +1007,7 @@ fn test_transition_to_immediate() { suite.assert_pending_rewards(ADDR1, 1, 100_000_000); // change back to linear emission - suite.update_emission_rate(1, Duration::Height(10), 1000); + suite.update_emission_rate(1, Duration::Height(10), 1000, true); // fund with 100M again suite.mint_native(coin(100_000_000, DENOM), OWNER); @@ -2086,6 +2084,7 @@ fn test_fund_native_on_create() { 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), @@ -2113,8 +2112,8 @@ fn test_fund_native_with_other_denom() { emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), + continuous: true, }, - continuous: true, hook_caller: suite.staking_addr.to_string(), vp_contract: suite.voting_power_addr.to_string(), withdraw_destination: None, @@ -2145,8 +2144,8 @@ fn test_fund_native_multiple_denoms() { emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), + continuous: true, }, - continuous: true, hook_caller: suite.staking_addr.to_string(), vp_contract: suite.voting_power_addr.to_string(), withdraw_destination: None, @@ -2186,8 +2185,8 @@ fn test_fund_native_on_create_cw20() { emission_rate: EmissionRate::Linear { amount: Uint128::new(1000), duration: Duration::Height(100), + continuous: true, }, - continuous: true, hook_caller: suite.staking_addr.to_string(), vp_contract: suite.voting_power_addr.to_string(), withdraw_destination: None, @@ -2209,15 +2208,21 @@ fn test_fund_native_on_create_cw20() { fn test_update_continuous() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); - suite.update_continuous(1, true); + suite.update_emission_rate(1, Duration::Height(100), 1000, true); let distribution = suite.get_distribution(1); - assert!(distribution.continuous); + match distribution.active_epoch.emission_rate { + EmissionRate::Linear { continuous, .. } => assert!(continuous), + _ => panic!("Invalid emission rate"), + } - suite.update_continuous(1, false); + suite.update_emission_rate(1, Duration::Height(100), 1000, false); let distribution = suite.get_distribution(1); - assert!(!distribution.continuous); + match distribution.active_epoch.emission_rate { + EmissionRate::Linear { continuous, .. } => assert!(!continuous), + _ => panic!("Invalid emission rate"), + } } #[test] @@ -2270,28 +2275,28 @@ fn test_update_withdraw_destination() { fn test_update_404() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); - suite.update_continuous(3, false); + 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); + 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); + 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); + suite.update_emission_rate(1, Duration::Time(0), 100, true); } #[test] From 6064a5dbf69889a63f5a8ecb94f32d7946209c84 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Jul 2024 02:35:06 -0400 Subject: [PATCH 37/39] default owner to instantiator if not provided --- .../schema/dao-rewards-distributor.json | 2 +- .../dao-rewards-distributor/src/contract.rs | 11 +++++++---- .../distribution/dao-rewards-distributor/src/msg.rs | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) 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 7ab6c886c..a7278d341 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" diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs index 8af1968b8..3a6e3c6e0 100644 --- a/contracts/distribution/dao-rewards-distributor/src/contract.rs +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -37,18 +37,21 @@ pub const MAX_LIMIT: u32 = 50; 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)] diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs index 04f134048..b321dfa7f 100644 --- a/contracts/distribution/dao-rewards-distributor/src/msg.rs +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -15,8 +15,8 @@ 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, } From a383174d436dd1ea615302236cf447e243dde15b Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Jul 2024 02:59:48 -0400 Subject: [PATCH 38/39] continuous cleanup --- .../dao-rewards-distributor/src/state.rs | 17 +++---- .../src/testing/tests.rs | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs index 90a44ed51..1ae2f27e3 100644 --- a/contracts/distribution/dao-rewards-distributor/src/state.rs +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -52,9 +52,11 @@ pub enum EmissionRate { amount: Uint128, /// duration of time to distribute amount duration: Duration, - /// whether or not reward distribution is continuous: whether rewards - /// should be paused once all funding has been distributed, or if future - /// funding after distribution finishes should be applied to the past. + /// 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, }, } @@ -153,14 +155,7 @@ impl Epoch { pub fn bump_last_updated(&mut self, current_block: &BlockInfo) { match self.ends_at { Expiration::Never {} => { - self.last_updated_total_earned_puvp = - // for immediate emission, there is no ends_at. always - // update to current block height. - if (self.emission_rate == EmissionRate::Immediate {}) { - Expiration::AtHeight(current_block.height) - } else { - Expiration::Never {} - }; + self.last_updated_total_earned_puvp = Expiration::Never {}; } Expiration::AtHeight(ends_at_height) => { self.last_updated_total_earned_puvp = diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 120fd994e..af32da1bc 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -1074,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(); From 7143fcc3f121d0b426c6326e979bfdff28539a48 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 22 Jul 2024 03:24:39 -0400 Subject: [PATCH 39/39] updated README --- .../dao-rewards-distributor/README.md | 174 +++++++++++++----- .../schema/dao-rewards-distributor.json | 6 +- 2 files changed, 133 insertions(+), 47 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md index c5adcc982..cb78b5c62 100644 --- a/contracts/distribution/dao-rewards-distributor/README.md +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -1,86 +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 for deriving staking reward allocations: +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 very minimal state. -An optional `owner` can be specified. If it is not, the owner is set -to be the address instantiating the contract. +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 setup +### 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, such as: - `cw4-group` - `dao-voting-cw721-staked` - `dao-voting-token-staked` - `cw20-stake` -### Registering a new reward denom - -Only the `owner` can register new denoms for distribution. - -Registering a denom for distribution expects the following config: - -- `denom`, which can either be `Cw20` or `Native` -- `emission_rate`, which determines the `amount` of that denom to be distributed to all applicable addresses per `duration` of time. duration here may be declared in either time (seconds) or blocks. some example configurations may be: - - `1000udenom` per 500 blocks - - `1000udenom` per 24 hours - - `0udenom` per any duration which effectively pauses the rewards -- `vp_contract` address, which will be used to determine the total and relative address voting power for allocating the rewards in a pro-rata basis -- `hook_caller` address, which will be authorized to call back into this contract with any voting power event changes. Example of such events may be: +### 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 in cases where after shutting down the denom reward distribution unallocated tokens would be sent to. One example use case of this may be some subDAO. +- 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 -A denom being registered does not mean that any rewards will be distributed. Instead, it enables that to happen by enabling the registered reward denom to be funded. +When set to immediate, funding is immediately distributed based on the voting +power of the block funding occurs on. -Currently, a single denom can only have one active distribution configuration. +You may fund an immediate distribution as many times as you'd like to distribute +funds instantly to the current members of the DAO. -### Funding the denom to be distributed +#### Paused -Anyone can fund a denom to be distributed as long as that denom is registered. +When set to paused, no rewards will be distributed. -If a denom is not registered and someone attempts to fund it, an error will be thrown. +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. -Otherwise, the funded denom state is updated in a few ways. +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. -First, the funded period duration is calculated based on the amount of tokens sent and the configured emission rate. For instance, 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. +### Updating emission rate and other distribution config -Then the active epoch end date is re-evaluated, depending on its current value: +Only the `owner` can update a distribution's config. -- If the active epoch never expires, meaning no rewards are being distributed, we take the funded period duration and add it to the current block. -- If the active epoch expires in the future, then we extend the current deadline with the funded period duration. -- If the active epoch had already expired, then we re-start the rewards distribution by adding the funded period duration to the current block. +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. -### Updating denom reward emission rate +You can also update the `vp_contract`, `hook_caller`, and +`withdraw_destination`. -Only the `owner` can update the reward emission rate. +> **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. -Updating the denom reward emission rate archives the active reward epoch and starts a new one. +### Withdrawing -First, the currently active epoch is evaluated. We find the amount of tokens that were earned to this point per unit of voting power and save that in the current epoch as its total earned rewards per unit of voting power. -We then bump the last update with that of the current block, and transition into the new epoch. +Only the `owner` can withdraw from a distribution. -The final (partial) amount of rewards distributed during the active reward epoch are added to `historical_earned_puvp` to ensure they can still be claimed. This historical value contains all rewards distributed during past epochs. +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. -### Shutting down denom distribution +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. -Only the `owner` can shutdown denom distribution. +### Claiming -Shutdown stops the denom from being distributed, calculates the amount of rewards that was allocated (and may or may not had been claimed yet), and claws that back to the `withdraw_address`. +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 a7278d341..6756ba56d 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -431,7 +431,7 @@ ] }, "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "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": { @@ -1054,7 +1054,7 @@ ] }, "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "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": { @@ -1412,7 +1412,7 @@ ] }, "continuous": { - "description": "whether or not reward distribution is continuous: whether rewards should be paused once all funding has been distributed, or if future funding after distribution finishes should be applied to the past.", + "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": {