diff --git a/contracts/oraiswap_staking/src/contract.rs b/contracts/oraiswap_staking/src/contract.rs index ed121177..5b5a83b4 100644 --- a/contracts/oraiswap_staking/src/contract.rs +++ b/contracts/oraiswap_staking/src/contract.rs @@ -13,9 +13,9 @@ use crate::rewards::{ use crate::staking::{auto_stake, auto_stake_hook, bond, unbond}; use crate::state::{ read_all_pool_infos, read_config, read_finish_migrate_store_status, read_pool_info, - read_rewards_per_sec, remove_pool_info, stakers_read, store_config, - store_finish_migrate_store_status, store_pool_info, store_rewards_per_sec, Config, - MigrationParams, PoolInfo, + read_rewards_per_sec, read_user_lock_info, remove_pool_info, stakers_read, store_config, + store_finish_migrate_store_status, store_pool_info, store_rewards_per_sec, + store_unbonding_period, Config, MigrationParams, PoolInfo, }; use cosmwasm_std::{ @@ -24,8 +24,9 @@ use cosmwasm_std::{ }; use oraiswap::asset::{Asset, AssetRaw, ORAI_DENOM}; use oraiswap::staking::{ - ConfigResponse, Cw20HookMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, OldStoreType, - PoolInfoResponse, QueryMsg, QueryPoolInfoResponse, RewardsPerSecResponse, + ConfigResponse, Cw20HookMsg, ExecuteMsg, InstantiateMsg, LockInfoResponse, LockInfosResponse, + MigrateMsg, OldStoreType, PoolInfoResponse, QueryMsg, QueryPoolInfoResponse, + RewardsPerSecResponse, }; use cw20::Cw20ReceiveMsg; @@ -70,7 +71,10 @@ pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> S assets, } => update_rewards_per_sec(deps, info, staking_token, assets), ExecuteMsg::DepositReward { rewards } => deposit_reward(deps, info, rewards), - ExecuteMsg::RegisterAsset { staking_token } => register_asset(deps, info, staking_token), + ExecuteMsg::RegisterAsset { + staking_token, + unbonding_period, + } => register_asset(deps, info, staking_token, unbonding_period), ExecuteMsg::DeprecateStakingToken { staking_token, new_staking_token, @@ -217,7 +221,12 @@ fn update_rewards_per_sec( Ok(Response::new().add_attribute("action", "update_rewards_per_sec")) } -fn register_asset(deps: DepsMut, info: MessageInfo, staking_token: Addr) -> StdResult { +fn register_asset( + deps: DepsMut, + info: MessageInfo, + staking_token: Addr, + unbonding_period: Option, +) -> StdResult { validate_migrate_store_status(deps.storage)?; let config: Config = read_config(deps.storage)?; @@ -243,9 +252,19 @@ fn register_asset(deps: DepsMut, info: MessageInfo, staking_token: Addr) -> StdR }, )?; + if let Some(unbonding_period) = unbonding_period { + if unbonding_period > 0 { + store_unbonding_period(deps.storage, staking_token.as_bytes(), unbonding_period)?; + } + } + Ok(Response::new().add_attributes([ ("action", "register_asset"), ("staking_token", staking_token.as_str()), + ( + "unbonding_period", + &unbonding_period.unwrap_or(0).to_string(), + ), ])) } @@ -324,10 +343,55 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { order, )?), QueryMsg::GetPoolsInformation {} => to_binary(&query_get_pools_infomation(deps)?), + QueryMsg::LockInfos { + staker_addr, + staking_token, + start_after, + limit, + order, + } => to_binary(&query_lock_infos( + deps, + _env, + staker_addr, + staking_token, + start_after, + limit, + order, + )?), QueryMsg::QueryOldStore { store_type } => query_old_store(deps, store_type), } } +pub fn query_lock_infos( + deps: Deps, + _env: Env, + staker_addr: Addr, + staking_token: Addr, + start_after: Option, + limit: Option, + order: Option, +) -> StdResult { + let lock_infos = read_user_lock_info( + deps.storage, + staking_token.as_bytes(), + staker_addr.as_bytes(), + start_after, + limit, + order, + )?; + Ok(LockInfosResponse { + staker_addr, + staking_token, + lock_infos: lock_infos + .into_iter() + .map(|lock| LockInfoResponse { + amount: lock.amount, + unlock_time: lock.unlock_time.seconds(), + }) + .collect(), + }) +} + pub fn query_config(deps: Deps) -> StdResult { let state = read_config(deps.storage)?; let resp = ConfigResponse { diff --git a/contracts/oraiswap_staking/src/rewards.rs b/contracts/oraiswap_staking/src/rewards.rs index d964934f..673d5089 100644 --- a/contracts/oraiswap_staking/src/rewards.rs +++ b/contracts/oraiswap_staking/src/rewards.rs @@ -3,7 +3,7 @@ use std::convert::TryFrom; use crate::contract::validate_migrate_store_status; use crate::state::{ read_config, read_is_migrated, read_pool_info, read_rewards_per_sec, rewards_read, - rewards_store, stakers_read, store_pool_info, PoolInfo, RewardInfo, + rewards_store, stakers_read, store_pool_info, PoolInfo, RewardInfo, DEFAULT_LIMIT, MAX_LIMIT, }; use cosmwasm_std::{ Addr, Api, CanonicalAddr, CosmosMsg, Decimal, Deps, DepsMut, Env, MessageInfo, Order, Response, @@ -13,9 +13,6 @@ use oraiswap::asset::{Asset, AssetRaw}; use oraiswap::querier::calc_range_start; use oraiswap::staking::{RewardInfoResponse, RewardInfoResponseItem, RewardMsg}; -const DEFAULT_LIMIT: u32 = 10; -const MAX_LIMIT: u32 = 30; - // deposit_reward must be from reward token contract pub fn deposit_reward( deps: DepsMut, diff --git a/contracts/oraiswap_staking/src/staking.rs b/contracts/oraiswap_staking/src/staking.rs index e23d5652..dbbcdc93 100644 --- a/contracts/oraiswap_staking/src/staking.rs +++ b/contracts/oraiswap_staking/src/staking.rs @@ -1,8 +1,9 @@ use crate::contract::validate_migrate_store_status; use crate::rewards::before_share_change; use crate::state::{ - read_config, read_is_migrated, read_pool_info, rewards_read, rewards_store, stakers_store, - store_is_migrated, store_pool_info, Config, PoolInfo, RewardInfo, + insert_lock_info, read_config, read_is_migrated, read_pool_info, read_unbonding_period, + remove_and_accumulate_lock_info, rewards_read, rewards_store, stakers_store, store_is_migrated, + store_pool_info, Config, PoolInfo, RewardInfo, }; use cosmwasm_std::{ attr, to_binary, Addr, Api, CanonicalAddr, Coin, CosmosMsg, Decimal, DepsMut, Env, MessageInfo, @@ -12,7 +13,7 @@ use cw20::Cw20ExecuteMsg; use oraiswap::asset::{Asset, AssetInfo, PairInfo}; use oraiswap::pair::ExecuteMsg as PairExecuteMsg; use oraiswap::querier::{query_pair_info, query_token_balance}; -use oraiswap::staking::ExecuteMsg; +use oraiswap::staking::{ExecuteMsg, LockInfo}; pub fn bond( deps: DepsMut, @@ -39,46 +40,79 @@ pub fn bond( pub fn unbond( deps: DepsMut, - _env: Env, + env: Env, staker_addr: Addr, staking_token: Addr, amount: Uint128, ) -> StdResult { validate_migrate_store_status(deps.storage)?; let staker_addr_raw: CanonicalAddr = deps.api.addr_canonicalize(staker_addr.as_str())?; - let (staking_token, reward_assets) = _decrease_bond_amount( - deps.storage, - deps.api, - &staker_addr_raw, - &staking_token, - amount, - )?; + let mut messages = vec![]; + let mut response = Response::new(); - let staking_token_addr = deps.api.addr_humanize(&staking_token)?; - let mut messages = vec![WasmMsg::Execute { - contract_addr: staking_token_addr.to_string(), - msg: to_binary(&Cw20ExecuteMsg::Transfer { - recipient: staker_addr.to_string(), - amount, - })?, - funds: vec![], - } - .into()]; + // withdraw_avaiable_lock + let withdraw_response = _withdraw_lock(deps.storage, &env, &staker_addr, &staking_token)?; - // withdraw pending_withdraw assets (accumulated when changing reward_per_sec) messages.extend( - reward_assets + withdraw_response + .clone() + .messages .into_iter() - .map(|ra| Ok(ra.into_msg(None, &deps.querier, staker_addr.clone())?)) - .collect::>>()?, + .map(|msg| msg.msg) + .collect::>(), ); - Ok(Response::new().add_messages(messages).add_attributes([ - attr("action", "unbond"), - attr("staker_addr", staker_addr.as_str()), - attr("amount", &amount.to_string()), - attr("staking_token", staking_token_addr.as_str()), - ])) + let withdraw_attrs = withdraw_response.attributes; + if !amount.is_zero() { + let (_, reward_assets) = _decrease_bond_amount( + deps.storage, + deps.api, + &staker_addr_raw, + &staking_token, + amount, + )?; + // withdraw pending_withdraw assets (accumulated when changing reward_per_sec) + messages.extend( + reward_assets + .into_iter() + .map(|ra| ra.into_msg(None, &deps.querier, staker_addr.clone())) + .collect::>>()?, + ); + // checking bonding period + if let Ok(period) = read_unbonding_period(deps.storage, staking_token.as_bytes()) { + let unlock_time = env.block.time.plus_seconds(period); + insert_lock_info( + deps.storage, + staking_token.as_bytes(), + staker_addr.as_bytes(), + LockInfo { + amount, + unlock_time, + }, + )?; + + response = response.add_attributes([ + attr("action", "unbonding"), + attr("staker_addr", staker_addr.as_str()), + attr("amount", amount.to_string()), + attr("staking_token", staking_token.as_str()), + attr("unlock_time", unlock_time.seconds().to_string()), + ]) + } else { + let unbond_response = _unbond(&staker_addr, &staking_token, amount)?; + messages.extend( + unbond_response + .messages + .into_iter() + .map(|msg| msg.msg) + .collect::>(), + ); + response = response.add_attributes(unbond_response.attributes); + } + } + Ok(response + .add_messages(messages) + .add_attributes(withdraw_attrs)) } pub fn auto_stake( @@ -205,6 +239,29 @@ pub fn auto_stake_hook( bond(deps, staker_addr, staking_token, amount_to_stake) } +pub fn _withdraw_lock( + storage: &mut dyn Storage, + env: &Env, + staker_addr: &Addr, + staking_token: &Addr, +) -> StdResult { + // execute 10 lock a time + let unlock_amount = remove_and_accumulate_lock_info( + storage, + staking_token.as_bytes(), + staker_addr.as_bytes(), + env.block.time, + )?; + + if unlock_amount.is_zero() { + return Ok(Response::new()); + } + + let unbond_response = _unbond(staker_addr, staking_token, unlock_amount)?; + + Ok(unbond_response) +} + fn _increase_bond_amount( storage: &mut dyn Storage, api: &dyn Api, @@ -304,19 +361,34 @@ fn _decrease_bond_amount( // if pending_withdraw is not empty, then return reward_assets to withdraw money reward_assets = reward_info .pending_withdraw - .into_iter() - .map(|ra| Ok(ra.to_normal(api)?)) + .iter() + .map(|ra| ra.to_normal(api)) .collect::>>()?; - - rewards_store(storage, staker_addr).remove(&asset_key); - // remove staker from the pool - stakers_store(storage, &asset_key).remove(staker_addr); - } else { - rewards_store(storage, staker_addr).save(&asset_key, &reward_info)?; + reward_info.pending_withdraw = vec![]; } + rewards_store(storage, staker_addr).save(&asset_key, &reward_info)?; // Update pool info store_pool_info(storage, &asset_key, &pool_info)?; Ok((staking_token, reward_assets)) } + +fn _unbond(staker_addr: &Addr, staking_token_addr: &Addr, amount: Uint128) -> StdResult { + let messages: Vec = vec![WasmMsg::Execute { + contract_addr: staking_token_addr.to_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: staker_addr.to_string(), + amount, + })?, + funds: vec![], + } + .into()]; + + Ok(Response::new().add_messages(messages).add_attributes([ + attr("action", "unbond"), + attr("staker_addr", staker_addr.as_str()), + attr("amount", amount.to_string()), + attr("staking_token", staking_token_addr.as_str()), + ])) +} diff --git a/contracts/oraiswap_staking/src/state.rs b/contracts/oraiswap_staking/src/state.rs index 4e0bc34c..cd237812 100644 --- a/contracts/oraiswap_staking/src/state.rs +++ b/contracts/oraiswap_staking/src/state.rs @@ -1,7 +1,9 @@ use cosmwasm_schema::cw_serde; -use oraiswap::asset::AssetRaw; +use oraiswap::{asset::AssetRaw, querier::calc_range_start, staking::LockInfo}; -use cosmwasm_std::{CanonicalAddr, Decimal, StdResult, Storage, Uint128}; +use cosmwasm_std::{ + CanonicalAddr, Decimal, Order, StdError, StdResult, Storage, Timestamp, Uint128, +}; use cosmwasm_storage::{singleton, singleton_read, Bucket, ReadonlyBucket}; pub static KEY_CONFIG: &[u8] = b"config_v2"; @@ -13,6 +15,13 @@ pub static PREFIX_REWARDS_PER_SEC: &[u8] = b"rewards_per_sec_v3"; // a key to validate if we have finished migrating the store. Only allow staking functionalities when we have finished migrating pub static KEY_MIGRATE_STORE_CHECK: &[u8] = b"migrate_store_check"; +// Unbonded +pub static UNBONDING_PERIOD: &[u8] = b"unbonding_period"; +pub static LOCK_INFO: &[u8] = b"locking_users"; + +pub const DEFAULT_LIMIT: u32 = 10; +pub const MAX_LIMIT: u32 = 30; + #[cw_serde] pub struct Config { pub owner: CanonicalAddr, @@ -138,3 +147,91 @@ pub fn read_rewards_per_sec(storage: &dyn Storage, asset_key: &[u8]) -> StdResul ReadonlyBucket::new(storage, PREFIX_REWARDS_PER_SEC); weight_bucket.load(asset_key) } + +pub fn store_unbonding_period( + storage: &mut dyn Storage, + asset_key: &[u8], + period: u64, +) -> StdResult<()> { + Bucket::new(storage, UNBONDING_PERIOD).save(asset_key, &period) +} + +pub fn read_unbonding_period(storage: &dyn Storage, asset_key: &[u8]) -> StdResult { + ReadonlyBucket::new(storage, UNBONDING_PERIOD).load(asset_key) +} + +pub fn insert_lock_info( + storage: &mut dyn Storage, + asset_key: &[u8], + user: &[u8], + lock_info: LockInfo, +) -> StdResult<()> { + Bucket::multilevel(storage, &[LOCK_INFO, asset_key, user]).save( + &lock_info.unlock_time.seconds().to_be_bytes(), + &lock_info.amount, + ) +} + +pub fn read_user_lock_info( + storage: &dyn Storage, + asset_key: &[u8], + user: &[u8], + start_after: Option, + limit: Option, + order: Option, +) -> StdResult> { + let order_by = Order::try_from(order.unwrap_or(1))?; + + let start_after = start_after.map(|a| a.to_be_bytes().to_vec()); + + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + + let (start, end) = match order_by { + Order::Ascending => (calc_range_start(start_after), None), + Order::Descending => (None, start_after), + }; + ReadonlyBucket::multilevel(storage, &[LOCK_INFO, asset_key, user]) + .range(start.as_deref(), end.as_deref(), order_by) + .take(limit) + .map(|item| { + let (time, amount) = item?; + Ok(LockInfo { + unlock_time: Timestamp::from_seconds(u64::from_be_bytes( + <[u8; 8]>::try_from(time) + .map_err(|_| StdError::generic_err("Casting u64 to timestamp fail"))?, + )), + amount, + }) + }) + .collect() +} + +pub fn remove_and_accumulate_lock_info( + storage: &mut dyn Storage, + asset_key: &[u8], + user: &[u8], + timestamp: Timestamp, +) -> StdResult { + let mut bucket = Bucket::::multilevel(storage, &[LOCK_INFO, asset_key, user]); + let mut remove_timestamps = vec![]; + let mut accumulate_amount = Uint128::zero(); + for item in bucket.range(None, None, Order::Ascending) { + let (time, amount) = item?; + let seconds = u64::from_be_bytes( + <[u8; 8]>::try_from(time.clone()) + .map_err(|_| StdError::generic_err("Casting vec to be_bytes fail"))?, + ); + if seconds > timestamp.seconds() { + break; + } + remove_timestamps.push(time); + accumulate_amount += amount; + } + + // remove timestamp + for time in remove_timestamps { + bucket.remove(&time); + } + + Ok(accumulate_amount) +} diff --git a/contracts/oraiswap_staking/src/testing/contract_test.rs b/contracts/oraiswap_staking/src/testing/contract_test.rs index 7281a8cc..fea3c909 100644 --- a/contracts/oraiswap_staking/src/testing/contract_test.rs +++ b/contracts/oraiswap_staking/src/testing/contract_test.rs @@ -119,6 +119,7 @@ fn test_register() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; // failed with unauthorized error @@ -136,6 +137,7 @@ fn test_register() { vec![ attr("action", "register_asset"), attr("staking_token", "staking"), + attr("unbonding_period", "0"), ] ); @@ -193,6 +195,7 @@ fn test_query_staker_pagination() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; let info = mock_info("owner", &[]); diff --git a/contracts/oraiswap_staking/src/testing/deprecate_test.rs b/contracts/oraiswap_staking/src/testing/deprecate_test.rs index 4b72f3b7..e0907a22 100644 --- a/contracts/oraiswap_staking/src/testing/deprecate_test.rs +++ b/contracts/oraiswap_staking/src/testing/deprecate_test.rs @@ -31,6 +31,7 @@ fn test_deprecate() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; let info = mock_info("owner", &[]); @@ -236,7 +237,13 @@ fn test_deprecate() { res, RewardInfoResponse { staker_addr: Addr::unchecked("addr"), - reward_infos: vec![], + reward_infos: vec![RewardInfoResponseItem { + staking_token: Addr::unchecked("new_staking"), + bond_amount: Uint128::from(0u128), + pending_reward: Uint128::from(0u128), + pending_withdraw: vec![], + should_migrate: None + }], } ); diff --git a/contracts/oraiswap_staking/src/testing/migrate_test.rs b/contracts/oraiswap_staking/src/testing/migrate_test.rs index 7d94478c..f5762e01 100644 --- a/contracts/oraiswap_staking/src/testing/migrate_test.rs +++ b/contracts/oraiswap_staking/src/testing/migrate_test.rs @@ -210,7 +210,8 @@ fn test_validate_migrate_store_status_with_execute_msg() { mock_env(), owner.clone(), ExecuteMsg::RegisterAsset { - staking_token: empty_addr.clone() + staking_token: empty_addr.clone(), + unbonding_period: None } ), Err(StdError::generic_err( diff --git a/contracts/oraiswap_staking/src/testing/reward_test.rs b/contracts/oraiswap_staking/src/testing/reward_test.rs index 1a743190..8ed9d68e 100644 --- a/contracts/oraiswap_staking/src/testing/reward_test.rs +++ b/contracts/oraiswap_staking/src/testing/reward_test.rs @@ -54,6 +54,7 @@ fn test_deposit_reward() { let msg = ExecuteMsg::RegisterAsset { staking_token: staking_token.clone(), + unbonding_period: None, }; let info = mock_info("owner", &[]); @@ -184,6 +185,7 @@ fn test_deposit_reward_when_no_bonding() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; let info = mock_info("owner", &[]); @@ -302,6 +304,7 @@ fn test_before_share_changes() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; let info = mock_info("owner", &[]); @@ -511,6 +514,7 @@ fn test_withdraw() { let msg = ExecuteMsg::RegisterAsset { staking_token: lp_addr.clone(), + unbonding_period: None, }; let _res = app @@ -621,6 +625,7 @@ fn test_update_rewards_per_sec() { let msg = ExecuteMsg::RegisterAsset { staking_token: staking_token.clone(), + unbonding_period: None, }; let info = mock_info("owner", &[]); @@ -765,6 +770,7 @@ fn test_update_rewards_per_sec_with_multiple_bond() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; let info = mock_info("owner", &[]); diff --git a/contracts/oraiswap_staking/src/testing/staking_test.rs b/contracts/oraiswap_staking/src/testing/staking_test.rs index 1a9f399c..490ee1bd 100644 --- a/contracts/oraiswap_staking/src/testing/staking_test.rs +++ b/contracts/oraiswap_staking/src/testing/staking_test.rs @@ -1,19 +1,20 @@ use crate::contract::{execute, instantiate, query, query_get_pools_infomation}; -use crate::state::{store_pool_info, PoolInfo}; +use crate::state::{store_pool_info, PoolInfo, MAX_LIMIT}; use cosmwasm_std::testing::{ - mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info, + mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info, MockApi, MockQuerier, + MockStorage, }; use cosmwasm_std::{ - attr, coin, from_binary, to_binary, Addr, Api, BankMsg, Coin, CosmosMsg, Decimal, StdError, - SubMsg, Uint128, WasmMsg, + attr, coin, from_binary, to_binary, Addr, Api, BankMsg, Coin, CosmosMsg, Decimal, OwnedDeps, + StdError, SubMsg, Uint128, WasmMsg, }; use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg}; use oraiswap::asset::{Asset, AssetInfo, ORAI_DENOM}; use oraiswap::create_entry_points_testing; use oraiswap::pair::PairResponse; use oraiswap::staking::{ - Cw20HookMsg, ExecuteMsg, InstantiateMsg, PoolInfoResponse, QueryMsg, RewardInfoResponse, - RewardInfoResponseItem, RewardMsg, + Cw20HookMsg, ExecuteMsg, InstantiateMsg, LockInfosResponse, PoolInfoResponse, QueryMsg, + RewardInfoResponse, RewardInfoResponseItem, RewardMsg, }; use oraiswap::testing::{AttributeUtil, MockApp, ATOM_DENOM}; @@ -82,6 +83,7 @@ fn test_bond_tokens() { let msg = ExecuteMsg::RegisterAsset { staking_token: Addr::unchecked("staking"), + unbonding_period: None, }; let info = mock_info("owner", &[]); @@ -174,90 +176,7 @@ fn test_bond_tokens() { #[test] fn test_unbond() { - let mut deps = mock_dependencies_with_balance(&[ - coin(10000000000u128, ORAI_DENOM), - coin(20000000000u128, ATOM_DENOM), - ]); - - let msg = InstantiateMsg { - owner: Some(Addr::unchecked("owner")), - rewarder: Addr::unchecked("rewarder"), - minter: Some(Addr::unchecked("mint")), - oracle_addr: Addr::unchecked("oracle"), - factory_addr: Addr::unchecked("factory"), - base_denom: None, - }; - - let info = mock_info("addr", &[]); - let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); - - // will also add to the index the pending rewards from before the migration - let msg = ExecuteMsg::UpdateRewardsPerSec { - staking_token: Addr::unchecked("staking"), - assets: vec![ - Asset { - info: AssetInfo::NativeToken { - denom: ORAI_DENOM.to_string(), - }, - amount: 100u128.into(), - }, - Asset { - info: AssetInfo::NativeToken { - denom: ATOM_DENOM.to_string(), - }, - amount: 200u128.into(), - }, - ], - }; - let info = mock_info("owner", &[]); - let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - - // register asset - let msg = ExecuteMsg::RegisterAsset { - staking_token: Addr::unchecked("staking"), - }; - - let info = mock_info("owner", &[]); - let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - - // bond 100 tokens - let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { - sender: "addr".to_string(), - amount: Uint128::from(100u128), - msg: to_binary(&Cw20HookMsg::Bond {}).unwrap(), - }); - let info = mock_info("staking", &[]); - let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - - let msg = ExecuteMsg::DepositReward { - rewards: vec![RewardMsg { - staking_token: Addr::unchecked("staking"), - total_accumulation_amount: Uint128::from(300u128), - }], - }; - let info = mock_info("rewarder", &[]); - let _res = execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap(); - - // will also add to the index the pending rewards from before the migration - let msg = ExecuteMsg::UpdateRewardsPerSec { - staking_token: Addr::unchecked("staking"), - assets: vec![ - Asset { - info: AssetInfo::NativeToken { - denom: ORAI_DENOM.to_string(), - }, - amount: 100u128.into(), - }, - Asset { - info: AssetInfo::NativeToken { - denom: ATOM_DENOM.to_string(), - }, - amount: 100u128.into(), - }, - ], - }; - let info = mock_info("owner", &[]); - let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + let mut deps = _setup_staking(None); // unbond 150 tokens; failed let msg = ExecuteMsg::Unbond { @@ -285,6 +204,14 @@ fn test_unbond() { assert_eq!( res.messages, vec![ + SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "addr".to_string(), + amount: vec![coin(99u128, ORAI_DENOM)], + })), + SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "addr".to_string(), + amount: vec![coin(199u128, ATOM_DENOM)], + })), SubMsg::new(WasmMsg::Execute { contract_addr: "staking".to_string(), msg: to_binary(&Cw20ExecuteMsg::Transfer { @@ -294,14 +221,6 @@ fn test_unbond() { .unwrap(), funds: vec![], }), - SubMsg::new(CosmosMsg::Bank(BankMsg::Send { - to_address: "addr".to_string(), - amount: vec![coin(99u128, ORAI_DENOM)], - })), - SubMsg::new(CosmosMsg::Bank(BankMsg::Send { - to_address: "addr".to_string(), - amount: vec![coin(199u128, ATOM_DENOM)], - })) ] ); @@ -340,7 +259,13 @@ fn test_unbond() { res, RewardInfoResponse { staker_addr: Addr::unchecked("addr"), - reward_infos: vec![], + reward_infos: vec![RewardInfoResponseItem { + staking_token: Addr::unchecked("staking"), + bond_amount: Uint128::from(0u128), + pending_reward: Uint128::from(0u128), + pending_withdraw: vec![], + should_migrate: None + }], } ); } @@ -348,11 +273,8 @@ fn test_unbond() { #[test] fn test_auto_stake() { let mut app = MockApp::new(&[(&"addr".to_string(), &[coin(10000000000u128, ORAI_DENOM)])]); - app.set_oracle_contract(Box::new(create_entry_points_testing!(oraiswap_oracle))); - app.set_token_contract(Box::new(create_entry_points_testing!(oraiswap_token))); - app.set_factory_and_pair_contract( Box::new( create_entry_points_testing!(oraiswap_factory) @@ -469,6 +391,7 @@ fn test_auto_stake() { let msg = ExecuteMsg::RegisterAsset { staking_token: pair_info.liquidity_token.clone(), + unbonding_period: None, }; let _res = app @@ -619,3 +542,370 @@ fn test_auto_stake() { } ); } + +#[test] +fn test_unbonding_period_happy_case() { + let unbonding_period = 100; + let mut deps = _setup_staking(Some(unbonding_period)); + + let msg = ExecuteMsg::Unbond { + staking_token: Addr::unchecked("staking"), + amount: Uint128::from(50u128), + }; + let info = mock_info("addr", &[]); + let mut unbond_env = mock_env(); + + let _res = execute(deps.as_mut(), unbond_env.clone(), info.clone(), msg).unwrap(); + + assert_eq!( + _res.attributes, + vec![ + attr("action", "unbonding"), + attr("staker_addr", "addr"), + attr("amount", Uint128::from(50u128).to_string()), + attr("staking_token", "staking"), + attr( + "unlock_time", + unbond_env + .clone() + .block + .time + .plus_seconds(unbonding_period) + .seconds() + .to_string() + ), + ] + ); + + let res = query( + deps.as_ref(), + unbond_env.clone(), + QueryMsg::LockInfos { + staker_addr: Addr::unchecked("addr"), + staking_token: Addr::unchecked("staking"), + start_after: None, + limit: None, + order: None, + }, + ) + .unwrap(); + let lock_ids = from_binary::(&res).unwrap(); + + assert_eq!(lock_ids.lock_infos.len(), 1); + assert_eq!(lock_ids.lock_infos[0].amount, Uint128::from(50u128)); + assert_eq!( + lock_ids.lock_infos[0].unlock_time, + unbond_env + .clone() + .block + .time + .plus_seconds(unbonding_period) + .seconds() + ); + assert_eq!(lock_ids.staking_token, Addr::unchecked("staking")); + assert_eq!(lock_ids.staker_addr, Addr::unchecked("addr")); + + // increase block.time + unbond_env.block.time = unbond_env.block.time.plus_seconds(unbonding_period + 1); + // Unbond and withdraw_lock + let msg = ExecuteMsg::Unbond { + staking_token: Addr::unchecked("staking"), + amount: Uint128::from(50u128), + }; + let _res = execute(deps.as_mut(), unbond_env.clone(), info.clone(), msg).unwrap(); + + let res = query( + deps.as_ref(), + unbond_env.clone(), + QueryMsg::LockInfos { + staker_addr: Addr::unchecked("addr"), + staking_token: Addr::unchecked("staking"), + start_after: None, + limit: None, + order: None, + }, + ) + .unwrap(); + let lock_ids = from_binary::(&res).unwrap(); + + assert_eq!(lock_ids.staking_token, Addr::unchecked("staking")); + assert_eq!(lock_ids.staker_addr, Addr::unchecked("addr")); + assert_eq!( + _res.attributes, + vec![ + attr("action", "unbonding"), + attr("staker_addr", "addr"), + attr("amount", Uint128::from(50u128).to_string()), + attr("staking_token", "staking"), + attr( + "unlock_time", + unbond_env + .clone() + .block + .time + .plus_seconds(unbonding_period) + .seconds() + .to_string() + ), + attr("action", "unbond"), + attr("staker_addr", "addr"), + attr("amount", Uint128::from(50u128).to_string()), + attr("staking_token", "staking"), + ] + ); + assert_eq!( + _res.messages, + vec![ + SubMsg::new(WasmMsg::Execute { + contract_addr: "staking".to_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "addr".to_string(), + amount: Uint128::from(50u128), + }) + .unwrap(), + funds: vec![], + }), + SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "addr".to_string(), + amount: vec![coin(99u128, ORAI_DENOM)], + })), + SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "addr".to_string(), + amount: vec![coin(199u128, ATOM_DENOM)], + })), + ] + ); + + unbond_env.block.time = unbond_env.block.time.plus_seconds(unbonding_period + 1); + + let msg = ExecuteMsg::Unbond { + staking_token: Addr::unchecked("staking"), + amount: Uint128::from(0u128), + }; + let _res = execute(deps.as_mut(), unbond_env.clone(), info, msg).unwrap(); + + let res = query( + deps.as_ref(), + unbond_env.clone(), + QueryMsg::LockInfos { + staker_addr: Addr::unchecked("addr"), + staking_token: Addr::unchecked("staking"), + start_after: None, + limit: None, + order: None, + }, + ) + .unwrap(); + + let lock_ids = from_binary::(&res).unwrap(); + assert_eq!(lock_ids.lock_infos.len(), 0); + + assert_eq!( + _res.attributes, + vec![ + attr("action", "unbond"), + attr("staker_addr", "addr"), + attr("amount", Uint128::from(50u128).to_string()), + attr("staking_token", "staking"), + ] + ); + assert_eq!( + _res.messages, + vec![SubMsg::new(WasmMsg::Execute { + contract_addr: "staking".to_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "addr".to_string(), + amount: Uint128::from(50u128), + }) + .unwrap(), + funds: vec![], + }),] + ) +} + +#[test] +pub fn test_multiple_lock() { + let unbonding_period = 10000; + let mut deps = _setup_staking(Some(unbonding_period)); + let info = mock_info("addr", &[]); + let mut unbond_env = mock_env(); + + for i in 0..MAX_LIMIT { + let msg = ExecuteMsg::Unbond { + staking_token: Addr::unchecked("staking"), + amount: Uint128::from(1u128), + }; + let mut clone_unbonded = unbond_env.clone(); + clone_unbonded.block.time = clone_unbonded + .block + .time + .plus_seconds((i as u64) * unbonding_period / 50); + let _res = execute(deps.as_mut(), clone_unbonded, info.clone(), msg).unwrap(); + } + let binary_response = query( + deps.as_ref(), + unbond_env.clone(), + QueryMsg::LockInfos { + staker_addr: Addr::unchecked("addr"), + staking_token: Addr::unchecked("staking"), + start_after: None, + limit: Some(30), + order: None, + }, + ) + .unwrap(); + let lock_infos = from_binary::(&binary_response).unwrap(); + assert_eq!(lock_infos.lock_infos.len(), MAX_LIMIT as usize); + + // Since we anchor the timestamp by unbond_env, so we must add the unbonding_period to the + // block_time to get the first unlock timestamp. Then, we plus another unbonding_period to get to the rest + // of lock + unbond_env.block.time = unbond_env.block.time.plus_seconds(unbonding_period); + unbond_env.block.time = unbond_env.block.time.plus_seconds(unbonding_period); + + let msg = ExecuteMsg::Unbond { + staking_token: Addr::unchecked("staking"), + amount: Uint128::from(0u128), + }; + + let res = execute(deps.as_mut(), unbond_env.clone(), info.clone(), msg).unwrap(); + + assert_eq!( + res.attributes, + vec![ + attr("action", "unbond"), + attr("staker_addr", "addr"), + attr("amount", Uint128::from(MAX_LIMIT as u128).to_string()), + attr("staking_token", "staking"), + ] + ); + + assert_eq!( + res.messages, + vec![SubMsg::new(WasmMsg::Execute { + contract_addr: "staking".to_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "addr".to_string(), + amount: Uint128::from(MAX_LIMIT as u128), + }) + .unwrap(), + funds: vec![], + }),] + ); + + // assert after we withdraw all_lock + let binary_response = query( + deps.as_ref(), + unbond_env.clone(), + QueryMsg::LockInfos { + staker_addr: Addr::unchecked("addr"), + staking_token: Addr::unchecked("staking"), + start_after: None, + limit: None, + order: None, + }, + ) + .unwrap(); + let lock_infos = from_binary::(&binary_response).unwrap(); + assert_eq!(lock_infos.lock_infos.len(), 0); +} + +fn _setup_staking(unbonding_period: Option) -> OwnedDeps { + let mut deps = mock_dependencies_with_balance(&[ + coin(10000000000u128, ORAI_DENOM), + coin(20000000000u128, ATOM_DENOM), + ]); + let msg = InstantiateMsg { + owner: Some(Addr::unchecked("owner")), + rewarder: Addr::unchecked("rewarder"), + minter: Some(Addr::unchecked("mint")), + oracle_addr: Addr::unchecked("oracle"), + factory_addr: Addr::unchecked("factory"), + base_denom: None, + }; + + let info = mock_info("addr", &[]); + let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + // will also add to the index the pending rewards from before the migration + let msg = ExecuteMsg::UpdateRewardsPerSec { + staking_token: Addr::unchecked("staking"), + assets: vec![ + Asset { + info: AssetInfo::NativeToken { + denom: ORAI_DENOM.to_string(), + }, + amount: 100u128.into(), + }, + Asset { + info: AssetInfo::NativeToken { + denom: ATOM_DENOM.to_string(), + }, + amount: 200u128.into(), + }, + ], + }; + + let info = mock_info("owner", &[]); + let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + + // register asset + let msg = ExecuteMsg::RegisterAsset { + staking_token: Addr::unchecked("staking"), + unbonding_period, + }; + + let info = mock_info("owner", &[]); + let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + + assert_eq!( + res.attributes, + vec![ + attr("action", "register_asset"), + attr("staking_token", "staking"), + attr( + "unbonding_period", + unbonding_period.unwrap_or(0).to_string() + ) + ] + ); + // bond 100 tokens + let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { + sender: "addr".to_string(), + amount: Uint128::from(100u128), + msg: to_binary(&Cw20HookMsg::Bond {}).unwrap(), + }); + let info = mock_info("staking", &[]); + let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let msg = ExecuteMsg::DepositReward { + rewards: vec![RewardMsg { + staking_token: Addr::unchecked("staking"), + total_accumulation_amount: Uint128::from(300u128), + }], + }; + let info = mock_info("rewarder", &[]); + let _res = execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap(); + + // will also add to the index the pending rewards from before the migration + let msg = ExecuteMsg::UpdateRewardsPerSec { + staking_token: Addr::unchecked("staking"), + assets: vec![ + Asset { + info: AssetInfo::NativeToken { + denom: ORAI_DENOM.to_string(), + }, + amount: 100u128.into(), + }, + Asset { + info: AssetInfo::NativeToken { + denom: ATOM_DENOM.to_string(), + }, + amount: 100u128.into(), + }, + ], + }; + let info = mock_info("owner", &[]); + let _res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + deps +} diff --git a/packages/oraiswap/src/staking.rs b/packages/oraiswap/src/staking.rs index f1543637..f0611d7a 100644 --- a/packages/oraiswap/src/staking.rs +++ b/packages/oraiswap/src/staking.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use crate::asset::{Asset, AssetInfo}; -use cosmwasm_std::{Addr, Binary, Decimal, Uint128}; +use cosmwasm_std::{Addr, Binary, Decimal, Timestamp, Uint128}; use cw20::Cw20ReceiveMsg; #[cw_serde] @@ -29,6 +29,7 @@ pub enum ExecuteMsg { }, RegisterAsset { staking_token: Addr, + unbonding_period: Option, }, DeprecateStakingToken { staking_token: Addr, @@ -121,6 +122,15 @@ pub enum QueryMsg { GetPoolsInformation {}, #[returns(Binary)] QueryOldStore { store_type: OldStoreType }, + #[returns(LockInfosResponse)] + LockInfos { + staker_addr: Addr, + staking_token: Addr, + start_after: Option, + limit: Option, + // so can convert or throw error + order: Option, + }, } // We define a custom struct for each query response @@ -187,3 +197,22 @@ pub enum OldStoreType { IsMigrated { staker: String }, RewardsPerSec {}, } + +#[cw_serde] +pub struct LockInfo { + pub amount: Uint128, + pub unlock_time: Timestamp, +} + +#[cw_serde] +pub struct LockInfoResponse { + pub amount: Uint128, + pub unlock_time: u64, +} + +#[cw_serde] +pub struct LockInfosResponse { + pub staker_addr: Addr, + pub staking_token: Addr, + pub lock_infos: Vec, +}