diff --git a/Cargo.lock b/Cargo.lock index 1651a5355..b82a0ff86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "cosmwasm-std", "cw-controllers", "cw-storage-plus", + "cw-utils", "cw2", "schemars", "semver", @@ -729,6 +730,30 @@ dependencies = [ "white-whale-std", ] +[[package]] +name = "incentive-manager" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw20", + "cw20-base", + "epoch-manager", + "schemars", + "semver", + "serde", + "thiserror", + "whale-lair", + "white-whale-std", + "white-whale-testing", +] + [[package]] name = "indoc" version = "1.0.9" diff --git a/Cargo.toml b/Cargo.toml index e100f997b..90e62afcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,16 +2,17 @@ resolver = "2" members = [ - "packages/*", - "contracts/liquidity_hub/pool-network/*", - "contracts/liquidity_hub/fee_collector", - "contracts/liquidity_hub/fee_distributor", - "contracts/liquidity_hub/whale_lair", - "contracts/liquidity_hub/vault-network/*", - "contracts/liquidity_hub/pool-manager", - "contracts/liquidity_hub/epoch-manager", - "contracts/liquidity_hub/vault-manager", - "xtask", + "packages/*", + "contracts/liquidity_hub/pool-network/*", + "contracts/liquidity_hub/fee_collector", + "contracts/liquidity_hub/fee_distributor", + "contracts/liquidity_hub/whale_lair", + "contracts/liquidity_hub/vault-network/*", + "contracts/liquidity_hub/pool-manager", + "contracts/liquidity_hub/epoch-manager", + "contracts/liquidity_hub/vault-manager", + "contracts/liquidity_hub/incentive-manager", + "xtask", ] [workspace.package] @@ -26,9 +27,9 @@ authors = ["White Whale Defi"] [workspace.dependencies] cosmwasm-schema = { version = "1.5.3" } cosmwasm-std = { version = "1.1.4", features = [ - "iterator", - "cosmwasm_1_2", - "stargate", + "iterator", + "cosmwasm_1_2", + "stargate", ] } cw2 = { version = "1.0.1" } cw20 = { version = "1.0.1" } @@ -54,7 +55,7 @@ sha2 = { version = "=0.10.8" } sha256 = { version = "1.4.0" } protobuf = { version = "=3.2.0", features = ["with-bytes"] } prost = { version = "0.11.9", default-features = false, features = [ - "prost-derive", + "prost-derive", ] } test-case = { version = "3.3.1" } @@ -66,6 +67,7 @@ fee-distributor-mock = { path = "./contracts/liquidity_hub/fee-distributor-mock" incentive-factory = { path = "./contracts/liquidity_hub/pool-network/incentive_factory" } terraswap-token = { path = "./contracts/liquidity_hub/pool-network/terraswap_token" } terraswap-pair = { path = "./contracts/liquidity_hub/pool-network/terraswap_pair" } +epoch-manager = { path = "./contracts/liquidity_hub/epoch-manager" } [workspace.metadata.dylint] libraries = [{ git = "https://github.com/0xFable/cw-lint" }] diff --git a/contracts/liquidity_hub/epoch-manager/Cargo.toml b/contracts/liquidity_hub/epoch-manager/Cargo.toml index 74ceaaace..f2930c96a 100644 --- a/contracts/liquidity_hub/epoch-manager/Cargo.toml +++ b/contracts/liquidity_hub/epoch-manager/Cargo.toml @@ -5,9 +5,9 @@ authors = ["Kerber0x "] edition = "2021" exclude = [ - # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. - "contract.wasm", - "hash.txt", + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -34,3 +34,4 @@ semver.workspace = true thiserror.workspace = true white-whale-std.workspace = true cw-controllers.workspace = true +cw-utils.workspace = true diff --git a/contracts/liquidity_hub/epoch-manager/src/commands.rs b/contracts/liquidity_hub/epoch-manager/src/commands.rs index f0163f365..534e565bb 100644 --- a/contracts/liquidity_hub/epoch-manager/src/commands.rs +++ b/contracts/liquidity_hub/epoch-manager/src/commands.rs @@ -27,7 +27,9 @@ pub(crate) fn remove_hook( } /// Creates a new epoch. -pub fn create_epoch(deps: DepsMut, env: Env) -> Result { +pub fn create_epoch(deps: DepsMut, env: Env, info: MessageInfo) -> Result { + cw_utils::nonpayable(&info)?; + let mut current_epoch = query_current_epoch(deps.as_ref())?.epoch; let config = CONFIG.load(deps.storage)?; diff --git a/contracts/liquidity_hub/epoch-manager/src/contract.rs b/contracts/liquidity_hub/epoch-manager/src/contract.rs index 06e530cdf..82075319d 100644 --- a/contracts/liquidity_hub/epoch-manager/src/contract.rs +++ b/contracts/liquidity_hub/epoch-manager/src/contract.rs @@ -13,7 +13,7 @@ use crate::state::{ADMIN, CONFIG, EPOCHS}; use crate::{commands, queries}; // version info for migration info -const CONTRACT_NAME: &str = "white_whale-epoch-manager"; +const CONTRACT_NAME: &str = "white-whale_epoch-manager"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[entry_point] @@ -69,7 +69,7 @@ pub fn execute( ExecuteMsg::RemoveHook { contract_addr } => { commands::remove_hook(deps, info, api, &contract_addr) } - ExecuteMsg::CreateEpoch {} => commands::create_epoch(deps, env), + ExecuteMsg::CreateEpoch {} => commands::create_epoch(deps, env, info), ExecuteMsg::UpdateConfig { owner, epoch_config, diff --git a/contracts/liquidity_hub/epoch-manager/src/error.rs b/contracts/liquidity_hub/epoch-manager/src/error.rs index d3eda4481..bb6e61aa3 100644 --- a/contracts/liquidity_hub/epoch-manager/src/error.rs +++ b/contracts/liquidity_hub/epoch-manager/src/error.rs @@ -1,5 +1,6 @@ use cosmwasm_std::StdError; use cw_controllers::{AdminError, HookError}; +use cw_utils::PaymentError; use semver::Version; use thiserror::Error; @@ -17,6 +18,9 @@ pub enum ContractError { #[error("The epoch id has overflowed.")] EpochOverflow, + #[error("{0}")] + PaymentError(#[from] PaymentError), + #[error("Semver parsing error: {0}")] SemVer(String), diff --git a/contracts/liquidity_hub/epoch-manager/src/state.rs b/contracts/liquidity_hub/epoch-manager/src/state.rs index 923b467b7..ec9010c39 100644 --- a/contracts/liquidity_hub/epoch-manager/src/state.rs +++ b/contracts/liquidity_hub/epoch-manager/src/state.rs @@ -1,8 +1,8 @@ use cw_controllers::{Admin, Hooks}; use cw_storage_plus::{Item, Map}; -use white_whale_std::epoch_manager::epoch_manager::{Config, EpochV2}; +use white_whale_std::epoch_manager::epoch_manager::{Config, Epoch}; pub const CONFIG: Item = Item::new("config"); pub const ADMIN: Admin = Admin::new("admin"); pub const HOOKS: Hooks = Hooks::new("hooks"); -pub const EPOCHS: Map<&[u8], EpochV2> = Map::new("epochs"); +pub const EPOCHS: Map<&[u8], Epoch> = Map::new("epochs"); diff --git a/contracts/liquidity_hub/epoch-manager/tests/common.rs b/contracts/liquidity_hub/epoch-manager/tests/common.rs index ae48d517c..7ac9721a6 100644 --- a/contracts/liquidity_hub/epoch-manager/tests/common.rs +++ b/contracts/liquidity_hub/epoch-manager/tests/common.rs @@ -4,7 +4,7 @@ use cosmwasm_std::{DepsMut, MessageInfo, Response, Uint64}; use epoch_manager::contract::{execute, instantiate}; use epoch_manager::ContractError; use white_whale_std::epoch_manager::epoch_manager::{ - EpochConfig, EpochV2, ExecuteMsg, InstantiateMsg, + Epoch, EpochConfig, ExecuteMsg, InstantiateMsg, }; /// Mocks contract instantiation. @@ -14,7 +14,7 @@ pub(crate) fn mock_instantiation( ) -> Result { let current_time = mock_env().block.time; let msg = InstantiateMsg { - start_epoch: EpochV2 { + start_epoch: Epoch { id: 123, start_time: current_time, }, diff --git a/contracts/liquidity_hub/epoch-manager/tests/epoch.rs b/contracts/liquidity_hub/epoch-manager/tests/epoch.rs index 934b73dba..00f6f04b9 100644 --- a/contracts/liquidity_hub/epoch-manager/tests/epoch.rs +++ b/contracts/liquidity_hub/epoch-manager/tests/epoch.rs @@ -3,7 +3,7 @@ use cosmwasm_std::testing::{mock_env, mock_info}; use epoch_manager::contract::{execute, query}; use epoch_manager::ContractError; -use white_whale_std::epoch_manager::epoch_manager::{EpochResponse, EpochV2, ExecuteMsg, QueryMsg}; +use white_whale_std::epoch_manager::epoch_manager::{Epoch, EpochResponse, ExecuteMsg, QueryMsg}; use white_whale_std::epoch_manager::hooks::EpochChangedHookMsg; use white_whale_std::pool_network::mock_querier::mock_dependencies; @@ -29,7 +29,7 @@ fn create_new_epoch_successfully() { let query_res = query(deps.as_ref(), mock_env(), QueryMsg::CurrentEpoch {}).unwrap(); let epoch_response: EpochResponse = from_json(query_res).unwrap(); - let current_epoch = EpochV2 { + let current_epoch = Epoch { id: 124, start_time: next_epoch_time, }; @@ -55,7 +55,7 @@ fn create_new_epoch_successfully() { assert_eq!( epoch_response.epoch, - EpochV2 { + Epoch { id: 123, start_time: next_epoch_time.minus_nanos(86400), } diff --git a/contracts/liquidity_hub/epoch-manager/tests/instantiate.rs b/contracts/liquidity_hub/epoch-manager/tests/instantiate.rs index 922108cb3..2870911f1 100644 --- a/contracts/liquidity_hub/epoch-manager/tests/instantiate.rs +++ b/contracts/liquidity_hub/epoch-manager/tests/instantiate.rs @@ -4,7 +4,7 @@ use cosmwasm_std::{from_json, Addr, Uint64}; use epoch_manager::contract::{instantiate, query}; use epoch_manager::ContractError; use white_whale_std::epoch_manager::epoch_manager::{ - ConfigResponse, EpochConfig, EpochV2, InstantiateMsg, QueryMsg, + ConfigResponse, Epoch, EpochConfig, InstantiateMsg, QueryMsg, }; use white_whale_std::pool_network::mock_querier::mock_dependencies; @@ -17,7 +17,7 @@ fn instantiation_successful() { let current_time = mock_env().block.time; let info = mock_info("owner", &[]); let msg = InstantiateMsg { - start_epoch: EpochV2 { + start_epoch: Epoch { id: 123, start_time: current_time, }, @@ -48,7 +48,7 @@ fn instantiation_unsuccessful() { let current_time = mock_env().block.time; let info = mock_info("owner", &[]); let msg = InstantiateMsg { - start_epoch: EpochV2 { + start_epoch: Epoch { id: 123, start_time: current_time.minus_days(1), }, @@ -65,7 +65,7 @@ fn instantiation_unsuccessful() { } let msg = InstantiateMsg { - start_epoch: EpochV2 { + start_epoch: Epoch { id: 123, start_time: current_time.plus_days(1), }, diff --git a/contracts/liquidity_hub/fee-distributor-mock/Cargo.toml b/contracts/liquidity_hub/fee-distributor-mock/Cargo.toml index 4c37e5ca7..a15b9f060 100644 --- a/contracts/liquidity_hub/fee-distributor-mock/Cargo.toml +++ b/contracts/liquidity_hub/fee-distributor-mock/Cargo.toml @@ -5,9 +5,9 @@ authors = ["Kerber0x "] edition = "2021" exclude = [ - # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. - "contract.wasm", - "hash.txt", + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/contracts/liquidity_hub/fee_collector/Cargo.toml b/contracts/liquidity_hub/fee_collector/Cargo.toml index 4bf871d5e..5ca272e80 100644 --- a/contracts/liquidity_hub/fee_collector/Cargo.toml +++ b/contracts/liquidity_hub/fee_collector/Cargo.toml @@ -11,9 +11,9 @@ documentation.workspace = true publish.workspace = true exclude = [ - # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. - "contract.wasm", - "hash.txt", + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/contracts/liquidity_hub/incentive-manager/.cargo/config b/contracts/liquidity_hub/incentive-manager/.cargo/config new file mode 100644 index 000000000..4f96ce061 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/.cargo/config @@ -0,0 +1,3 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" diff --git a/contracts/liquidity_hub/incentive-manager/.editorconfig b/contracts/liquidity_hub/incentive-manager/.editorconfig new file mode 100644 index 000000000..3d36f20b1 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.rs] +indent_size = 4 diff --git a/contracts/liquidity_hub/incentive-manager/.gitignore b/contracts/liquidity_hub/incentive-manager/.gitignore new file mode 100644 index 000000000..9095deaa4 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/.gitignore @@ -0,0 +1,16 @@ +# Build results +/target +/schema + +# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) +.cargo-ok + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea diff --git a/contracts/liquidity_hub/incentive-manager/Cargo.toml b/contracts/liquidity_hub/incentive-manager/Cargo.toml new file mode 100644 index 000000000..e9ff898d3 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "incentive-manager" +version = "0.1.0" +authors = ["Kerber0x "] +edition.workspace = true +description = "The Incentive Manager is a contract that allows to manage multiple pool incentives in a single contract." +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +publish.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +injective = ["white-whale-std/injective"] +token_factory = ["white-whale-std/token_factory"] +osmosis_token_factory = ["white-whale-std/osmosis_token_factory"] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] + +[dependencies] +cosmwasm-schema.workspace = true +cosmwasm-std.workspace = true +cw-storage-plus.workspace = true +cw2.workspace = true +cw20.workspace = true +cw20-base.workspace = true +schemars.workspace = true +semver.workspace = true +serde.workspace = true +thiserror.workspace = true +white-whale-std.workspace = true +cw-utils.workspace = true +cw-ownable.workspace = true + +[dev-dependencies] +cw-multi-test.workspace = true +white-whale-testing.workspace = true +epoch-manager.workspace = true +whale-lair.workspace = true +anyhow.workspace = true diff --git a/contracts/liquidity_hub/incentive-manager/README.md b/contracts/liquidity_hub/incentive-manager/README.md new file mode 100644 index 000000000..6cada5e4c --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/README.md @@ -0,0 +1,4 @@ +# Incentive Manager + +The Incentive Manager is the V2 iteration of the original incentives. This is a monolithic contract that handles all +the incentives-related logic. \ No newline at end of file diff --git a/contracts/liquidity_hub/incentive-manager/src/contract.rs b/contracts/liquidity_hub/incentive-manager/src/contract.rs new file mode 100644 index 000000000..f50e8a643 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/contract.rs @@ -0,0 +1,216 @@ +use cosmwasm_std::{ + ensure, entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, +}; +use cw2::{get_contract_version, set_contract_version}; +use semver::Version; + +use white_whale_std::incentive_manager::{ + Config, ExecuteMsg, IncentiveAction, InstantiateMsg, PositionAction, QueryMsg, +}; +use white_whale_std::vault_manager::MigrateMsg; + +use crate::error::ContractError; +use crate::helpers::validate_emergency_unlock_penalty; +use crate::state::{CONFIG, INCENTIVE_COUNTER}; +use crate::{incentive, manager, position, queries}; + +const CONTRACT_NAME: &str = "white-whale_incentive-manager"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // ensure that max_concurrent_incentives is non-zero + ensure!( + msg.max_concurrent_incentives > 0, + ContractError::UnspecifiedConcurrentIncentives + ); + + // ensure the unlocking duration range is valid + ensure!( + msg.max_unlocking_duration > msg.min_unlocking_duration, + ContractError::InvalidUnlockingRange { + min: msg.min_unlocking_duration, + max: msg.max_unlocking_duration, + } + ); + + let config = Config { + epoch_manager_addr: deps.api.addr_validate(&msg.epoch_manager_addr)?, + whale_lair_addr: deps.api.addr_validate(&msg.whale_lair_addr)?, + create_incentive_fee: msg.create_incentive_fee, + max_concurrent_incentives: msg.max_concurrent_incentives, + max_incentive_epoch_buffer: msg.max_incentive_epoch_buffer, + min_unlocking_duration: msg.min_unlocking_duration, + max_unlocking_duration: msg.max_unlocking_duration, + emergency_unlock_penalty: validate_emergency_unlock_penalty(msg.emergency_unlock_penalty)?, + }; + + CONFIG.save(deps.storage, &config)?; + INCENTIVE_COUNTER.save(deps.storage, &0)?; + cw_ownable::initialize_owner(deps.storage, deps.api, Some(msg.owner.as_str()))?; + + Ok(Response::default().add_attributes(vec![ + ("action", "instantiate".to_string()), + ("owner", msg.owner), + ("epoch_manager_addr", config.epoch_manager_addr.to_string()), + ("whale_lair_addr", config.whale_lair_addr.to_string()), + ("create_flow_fee", config.create_incentive_fee.to_string()), + ( + "max_concurrent_flows", + config.max_concurrent_incentives.to_string(), + ), + ( + "max_flow_epoch_buffer", + config.max_incentive_epoch_buffer.to_string(), + ), + ( + "min_unbonding_duration", + config.min_unlocking_duration.to_string(), + ), + ( + "max_unbonding_duration", + config.max_unlocking_duration.to_string(), + ), + ( + "emergency_unlock_penalty", + config.emergency_unlock_penalty.to_string(), + ), + ])) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::ManageIncentive { action } => match action { + IncentiveAction::Fill { params } => { + manager::commands::fill_incentive(deps, info, params) + } + IncentiveAction::Close { + incentive_identifier, + } => manager::commands::close_incentive(deps, info, incentive_identifier), + }, + ExecuteMsg::UpdateOwnership(action) => { + cw_utils::nonpayable(&info)?; + white_whale_std::common::update_ownership(deps, env, info, action).map_err(Into::into) + } + ExecuteMsg::EpochChangedHook(msg) => { + manager::commands::on_epoch_changed(deps, env, info, msg) + } + ExecuteMsg::Claim => incentive::commands::claim(deps, env, info), + ExecuteMsg::ManagePosition { action } => match action { + PositionAction::Fill { + identifier, + unlocking_duration, + receiver, + } => position::commands::fill_position( + deps, + &env, + info, + identifier, + unlocking_duration, + receiver, + ), + PositionAction::Close { + identifier, + lp_asset, + } => position::commands::close_position(deps, env, info, identifier, lp_asset), + PositionAction::Withdraw { + identifier, + emergency_unlock, + } => { + position::commands::withdraw_position(deps, env, info, identifier, emergency_unlock) + } + }, + ExecuteMsg::UpdateConfig { + whale_lair_addr, + epoch_manager_addr, + create_incentive_fee, + max_concurrent_incentives, + max_incentive_epoch_buffer, + min_unlocking_duration, + max_unlocking_duration, + emergency_unlock_penalty, + } => { + cw_utils::nonpayable(&info)?; + manager::commands::update_config( + deps, + info, + whale_lair_addr, + epoch_manager_addr, + create_incentive_fee, + max_concurrent_incentives, + max_incentive_epoch_buffer, + min_unlocking_duration, + max_unlocking_duration, + emergency_unlock_penalty, + ) + } + } +} + +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::Config => Ok(to_json_binary(&queries::query_manager_config(deps)?)?), + QueryMsg::Ownership {} => Ok(to_json_binary(&cw_ownable::get_ownership(deps.storage)?)?), + QueryMsg::Incentives { + filter_by, + start_after, + limit, + } => Ok(to_json_binary(&queries::query_incentives( + deps, + filter_by, + start_after, + limit, + )?)?), + QueryMsg::Positions { + address, + open_state, + } => Ok(to_json_binary(&queries::query_positions( + deps, address, open_state, + )?)?), + QueryMsg::Rewards { address } => Ok(to_json_binary(&queries::query_rewards( + deps, &env, address, + )?)?), + QueryMsg::LPWeight { + address, + denom, + epoch_id, + } => Ok(to_json_binary(&queries::query_lp_weight( + deps, address, denom, epoch_id, + )?)?), + } +} + +#[cfg(not(tarpaulin_include))] +#[entry_point] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + use white_whale_std::migrate_guards::check_contract_name; + + check_contract_name(deps.storage, CONTRACT_NAME.to_string())?; + + let version: Version = CONTRACT_VERSION.parse()?; + let storage_version: Version = get_contract_version(deps.storage)?.version.parse()?; + + if storage_version >= version { + return Err(ContractError::MigrateInvalidVersion { + current_version: storage_version, + new_version: version, + }); + } + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/error.rs b/contracts/liquidity_hub/incentive-manager/src/error.rs new file mode 100644 index 000000000..261d38ffa --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/error.rs @@ -0,0 +1,163 @@ +use cosmwasm_std::{ + CheckedFromRatioError, CheckedMultiplyFractionError, ConversionOverflowError, + DivideByZeroError, OverflowError, StdError, Uint128, +}; +use cw_ownable::OwnershipError; +use cw_utils::PaymentError; +use semver::Version; +use thiserror::Error; + +use white_whale_std::incentive_manager::EpochId; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Semver parsing error: {0}")] + SemVer(String), + + #[error("Unauthorized")] + Unauthorized, + + #[error("{0}")] + PaymentError(#[from] PaymentError), + + #[error("{0}")] + OwnershipError(#[from] OwnershipError), + + #[error("{0}")] + OverflowError(#[from] OverflowError), + + #[error("{0}")] + CheckedFromRatioError(#[from] CheckedFromRatioError), + + #[error("{0}")] + CheckedMultiplyFractionError(#[from] CheckedMultiplyFractionError), + + #[error("{0}")] + ConversionOverflowError(#[from] ConversionOverflowError), + + #[error("{0}")] + DivideByZeroError(#[from] DivideByZeroError), + + #[error("An incentive with the given identifier already exists")] + IncentiveAlreadyExists, + + #[error("max_concurrent_flows cannot be set to zero")] + UnspecifiedConcurrentIncentives, + + #[error("Incentive doesn't exist")] + NonExistentIncentive {}, + + #[error("Attempt to create a new incentive with a small incentive_asset amount, which is less than the minimum of {min}")] + InvalidIncentiveAmount { + /// The minimum amount of an asset to create an incentive with + min: u128, + }, + + #[error("Incentive creation fee was not included")] + IncentiveFeeMissing, + + #[error("Incentive end timestamp was set to a time in the past")] + IncentiveEndsInPast, + + #[error("The incentive you are intending to create doesn't meet the minimum required of {min} after taking the fee")] + EmptyIncentiveAfterFee { min: u128 }, + + #[error( + "Incentive creation fee was not fulfilled, only {paid_amount} / {required_amount} present" + )] + IncentiveFeeNotPaid { + /// The amount that was paid + paid_amount: Uint128, + /// The amount that needed to be paid + required_amount: Uint128, + }, + + #[error("Incentive start timestamp is after the end timestamp")] + IncentiveStartTimeAfterEndTime, + + #[error("Incentive start timestamp is too far into the future")] + IncentiveStartTooFar, + + #[error("The incentive has already expired, can't be expanded")] + IncentiveAlreadyExpired, + + #[error("The incentive doesn't have enough funds to pay out the reward")] + IncentiveExhausted, + + #[error("The asset sent doesn't match the asset expected")] + AssetMismatch, + + #[error("Attempt to create a new incentive, which exceeds the maximum of {max} incentives allowed per LP at a time")] + TooManyIncentives { + /// The maximum amount of incentives that can exist + max: u32, + }, + + #[error("The end epoch for this incentive is invalid")] + InvalidEndEpoch, + + #[error("The sender doesn't have open positions")] + NoOpenPositions, + + #[error("No position found with the given identifier: {identifier}")] + NoPositionFound { identifier: String }, + + #[error("The position has not expired yet")] + PositionNotExpired, + + #[error("The position with the identifier {identifier} is already closed")] + PositionAlreadyClosed { identifier: String }, + + #[error( + "Invalid unlocking duration of {specified} specified, must be between {min} and {max}" + )] + InvalidUnlockingDuration { + /// The minimum amount of seconds that a user must lock for. + min: u64, + /// The maximum amount of seconds that a user can lock for. + max: u64, + /// The amount of seconds the user attempted to lock for. + specified: u64, + }, + + #[error("Invalid unlocking range, specified min as {min} and max as {max}")] + InvalidUnlockingRange { + /// The minimum unlocking time + min: u64, + /// The maximum unlocking time + max: u64, + }, + + #[error("Attempt to compute the weight of a duration of {unlocking_duration} which is outside the allowed bounds")] + InvalidWeight { unlocking_duration: u64 }, + + #[error("The emergency unlock penalty provided is invalid")] + InvalidEmergencyUnlockPenalty, + + #[error("There are pending rewards to be claimed before this action can be executed")] + PendingRewards, + + #[error("The incentive expansion amount must be a multiple of the emission rate, which is {emission_rate}")] + InvalidExpansionAmount { + /// The emission rate of the incentive + emission_rate: Uint128, + }, + + #[error("There's no snapshot of the LP weight in the contract for the epoch {epoch_id}")] + LpWeightNotFound { epoch_id: EpochId }, + + #[error("Attempt to migrate to version {new_version}, but contract is on a higher version {current_version}")] + MigrateInvalidVersion { + new_version: Version, + current_version: Version, + }, +} + +impl From for ContractError { + fn from(err: semver::Error) -> Self { + Self::SemVer(err.to_string()) + } +} diff --git a/contracts/liquidity_hub/incentive-manager/src/helpers.rs b/contracts/liquidity_hub/incentive-manager/src/helpers.rs new file mode 100644 index 000000000..997bf7bc9 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/helpers.rs @@ -0,0 +1,166 @@ +use std::cmp::Ordering; + +use cosmwasm_std::{ + ensure, BankMsg, Coin, CosmosMsg, Decimal, MessageInfo, OverflowError, OverflowOperation, + Uint128, +}; + +use white_whale_std::incentive_manager::{Config, IncentiveParams, DEFAULT_INCENTIVE_DURATION}; + +use crate::ContractError; + +/// Processes the incentive creation fee and returns the appropriate messages to be sent +pub(crate) fn process_incentive_creation_fee( + config: &Config, + info: &MessageInfo, + incentive_creation_fee: &Coin, + params: &IncentiveParams, +) -> Result, ContractError> { + let mut messages: Vec = vec![]; + + // verify the fee to create an incentive is being paid + let paid_fee_amount = info + .funds + .iter() + .find(|coin| coin.denom == incentive_creation_fee.denom) + .ok_or(ContractError::IncentiveFeeMissing)? + .amount; + + match paid_fee_amount.cmp(&incentive_creation_fee.amount) { + Ordering::Equal => (), // do nothing if user paid correct amount, + Ordering::Less => { + // user underpaid + return Err(ContractError::IncentiveFeeNotPaid { + paid_amount: paid_fee_amount, + required_amount: incentive_creation_fee.amount, + }); + } + Ordering::Greater => { + // if the user is paying more than the incentive_creation_fee, check if it's trying to create + // an incentive with the same asset as the incentive_creation_fee. + // otherwise, refund the difference + if incentive_creation_fee.denom == params.incentive_asset.denom { + // check if the amounts add up, i.e. the fee + incentive asset = paid amount. That is because the incentive asset + // and the creation fee asset are the same, all go in the info.funds of the transaction + + ensure!( + params + .incentive_asset + .amount + .checked_add(incentive_creation_fee.amount)? + == paid_fee_amount, + ContractError::AssetMismatch + ); + } else { + let refund_amount = paid_fee_amount.saturating_sub(incentive_creation_fee.amount); + + if refund_amount > Uint128::zero() { + messages.push( + BankMsg::Send { + to_address: info.sender.clone().into_string(), + amount: vec![Coin { + amount: refund_amount, + denom: incentive_creation_fee.denom.clone(), + }], + } + .into(), + ); + } + } + } + } + + // send incentive creation fee to whale lair for distribution + messages.push(white_whale_std::whale_lair::fill_rewards_msg_coin( + config.whale_lair_addr.clone().into_string(), + vec![incentive_creation_fee.to_owned()], + )?); + + Ok(messages) +} + +/// Asserts the incentive asset was sent correctly, considering the incentive creation fee if applicable. +pub(crate) fn assert_incentive_asset( + info: &MessageInfo, + incentive_creation_fee: &Coin, + params: &IncentiveParams, +) -> Result<(), ContractError> { + let coin_sent = info + .funds + .iter() + .find(|sent| sent.denom == params.incentive_asset.denom) + .ok_or(ContractError::AssetMismatch)?; + + if incentive_creation_fee.denom != params.incentive_asset.denom { + ensure!( + coin_sent.amount == params.incentive_asset.amount, + ContractError::AssetMismatch + ); + } else { + ensure!( + params + .incentive_asset + .amount + .checked_add(incentive_creation_fee.amount)? + == coin_sent.amount, + ContractError::AssetMismatch + ); + } + + Ok(()) +} + +/// Validates the incentive epochs. Returns a tuple of (start_epoch, end_epoch) for the incentive. +pub(crate) fn validate_incentive_epochs( + params: &IncentiveParams, + current_epoch: u64, + max_incentive_epoch_buffer: u64, +) -> Result<(u64, u64), ContractError> { + // assert epoch params are correctly set + let start_epoch = params.start_epoch.unwrap_or(current_epoch); + + let preliminary_end_epoch = params.preliminary_end_epoch.unwrap_or( + start_epoch + .checked_add(DEFAULT_INCENTIVE_DURATION) + .ok_or(ContractError::InvalidEndEpoch)?, + ); + + // ensure that start date is before end date + ensure!( + start_epoch < preliminary_end_epoch, + ContractError::IncentiveStartTimeAfterEndTime + ); + + // ensure the incentive is set to end in a future epoch + ensure!( + preliminary_end_epoch > current_epoch, + ContractError::IncentiveEndsInPast + ); + + // ensure that start date is set within buffer + ensure!( + start_epoch + <= current_epoch + .checked_add(max_incentive_epoch_buffer) + .ok_or(ContractError::OverflowError(OverflowError { + operation: OverflowOperation::Add, + operand1: current_epoch.to_string(), + operand2: max_incentive_epoch_buffer.to_string(), + }))?, + ContractError::IncentiveStartTooFar + ); + + Ok((start_epoch, preliminary_end_epoch)) +} + +/// Validates the emergency unlock penalty is within the allowed range (0-100%). Returns value it's validating, i.e. the penalty. +pub(crate) fn validate_emergency_unlock_penalty( + emergency_unlock_penalty: Decimal, +) -> Result { + ensure!( + emergency_unlock_penalty <= Decimal::percent(100), + ContractError::InvalidEmergencyUnlockPenalty + ); + + Ok(emergency_unlock_penalty) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/incentive/commands.rs b/contracts/liquidity_hub/incentive-manager/src/incentive/commands.rs new file mode 100644 index 000000000..8e0cfd85b --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/incentive/commands.rs @@ -0,0 +1,327 @@ +use std::collections::HashMap; + +use cosmwasm_std::{ + ensure, Addr, BankMsg, Coin, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Response, Storage, + Uint128, +}; + +use white_whale_std::coin::aggregate_coins; +use white_whale_std::incentive_manager::{EpochId, Incentive, Position, RewardsResponse}; + +use crate::state::{ + get_earliest_address_lp_weight, get_incentives_by_lp_denom, get_latest_address_lp_weight, + get_positions_by_receiver, CONFIG, INCENTIVES, LAST_CLAIMED_EPOCH, LP_WEIGHT_HISTORY, +}; +use crate::ContractError; + +/// Claims pending rewards for incentives where the user has LP +pub(crate) fn claim(deps: DepsMut, env: Env, info: MessageInfo) -> Result { + cw_utils::nonpayable(&info)?; + + // check if the user has any open LP positions + let open_positions = + get_positions_by_receiver(deps.storage, info.sender.clone().into_string(), Some(true))?; + ensure!(!open_positions.is_empty(), ContractError::NoOpenPositions); + + let config = CONFIG.load(deps.storage)?; + let current_epoch = white_whale_std::epoch_manager::common::get_current_epoch( + deps.as_ref(), + config.epoch_manager_addr.into_string(), + )?; + + let mut total_rewards = vec![]; + + for position in &open_positions { + // calculate the rewards for the position + let rewards_response = + calculate_rewards(deps.as_ref(), &env, position, current_epoch.id, true)?; + + match rewards_response { + RewardsResponse::ClaimRewards { + rewards, + modified_incentives, + } => { + total_rewards.append(&mut rewards.clone()); + + // update the incentives with the claimed rewards + for (incentive_identifier, claimed_reward) in modified_incentives { + INCENTIVES.update( + deps.storage, + &incentive_identifier, + |incentive| -> Result<_, ContractError> { + let mut incentive = incentive.unwrap(); + incentive.last_epoch_claimed = current_epoch.id; + incentive.claimed_amount = + incentive.claimed_amount.checked_add(claimed_reward)?; + + // sanity check to make sure an incentive doesn't get drained + ensure!( + incentive.claimed_amount <= incentive.incentive_asset.amount, + ContractError::IncentiveExhausted + ); + + Ok(incentive) + }, + )?; + } + + // sync the address lp weight history for the user + sync_address_lp_weight_history( + deps.storage, + &info.sender, + &position.lp_asset.denom, + ¤t_epoch.id, + )?; + } + _ => return Err(ContractError::Unauthorized), + } + } + + // update the last claimed epoch for the user + LAST_CLAIMED_EPOCH.save(deps.storage, &info.sender, ¤t_epoch.id)?; + + let mut messages = vec![]; + + // don't send any bank message if there's nothing to send + if !total_rewards.is_empty() { + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: aggregate_coins(total_rewards)?, + })); + } + + Ok(Response::default() + .add_messages(messages) + .add_attributes(vec![("action", "claim".to_string())])) +} + +/// Calculates the rewards for a position +/// ### Returns +/// A [RewardsResponse] with the rewards for the position. If is_claim is true, the RewardsResponse type is +/// ClaimRewards, which contains the rewards and the modified incentives (this is to modify the +/// incentives in the claim function afterwards). If is_claim is false, the RewardsResponse only returns +/// the rewards. +pub(crate) fn calculate_rewards( + deps: Deps, + env: &Env, + position: &Position, + current_epoch_id: EpochId, + is_claim: bool, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + let incentives = get_incentives_by_lp_denom( + deps.storage, + &position.lp_asset.denom, + None, + Some(config.max_concurrent_incentives), + )?; + + let last_claimed_epoch_for_user = + LAST_CLAIMED_EPOCH.may_load(deps.storage, &position.receiver)?; + + // Check if the user ever claimed before + if let Some(last_claimed_epoch) = last_claimed_epoch_for_user { + // if the last claimed epoch is the same as the current epoch, then there is nothing to claim + if current_epoch_id == last_claimed_epoch { + return if is_claim { + Ok(RewardsResponse::ClaimRewards { + rewards: vec![], + modified_incentives: HashMap::new(), + }) + } else { + Ok(RewardsResponse::RewardsResponse { rewards: vec![] }) + }; + } + } + + let mut rewards: Vec = vec![]; + // what incentives are going to mutate when claiming rewards. Not used/returned when querying rewards. + let mut modified_incentives: HashMap = HashMap::new(); + + for incentive in incentives { + // skip expired incentives + if incentive.is_expired(current_epoch_id) || incentive.start_epoch > current_epoch_id { + continue; + } + + // compute where the user can start claiming rewards for the incentive + let start_from_epoch = compute_start_from_epoch_for_user( + deps.storage, + &incentive.lp_denom, + last_claimed_epoch_for_user, + &position.receiver, + )?; + + // compute the weights of the user for the epochs between start_from_epoch and current_epoch_id + let user_weights = + compute_user_weights(deps.storage, position, &start_from_epoch, ¤t_epoch_id)?; + + // compute the incentive emissions for the epochs between start_from_epoch and current_epoch_id + let (incentive_emissions, until_epoch) = + compute_incentive_emissions(&incentive, &start_from_epoch, ¤t_epoch_id)?; + + for epoch_id in start_from_epoch..=until_epoch { + if incentive.start_epoch > epoch_id { + continue; + } + + let user_weight = user_weights[&epoch_id]; + let total_lp_weight = LP_WEIGHT_HISTORY + .may_load( + deps.storage, + (&env.contract.address, &incentive.lp_denom, epoch_id), + )? + .ok_or(ContractError::LpWeightNotFound { epoch_id })?; + + let user_share = (user_weight, total_lp_weight); + + let reward = incentive_emissions + .get(&epoch_id) + .unwrap_or(&Uint128::zero()) + .to_owned() + .checked_mul_floor(user_share)?; + + // sanity check + ensure!( + reward.checked_add(incentive.claimed_amount)? <= incentive.incentive_asset.amount, + ContractError::IncentiveExhausted + ); + + if reward > Uint128::zero() { + rewards.push(Coin { + denom: incentive.incentive_asset.denom.clone(), + amount: reward, + }); + } + + if is_claim { + // compound the rewards for the incentive + let maybe_reward = modified_incentives + .get(&incentive.identifier) + .unwrap_or(&Uint128::zero()) + .to_owned(); + + modified_incentives.insert( + incentive.identifier.clone(), + reward.checked_add(maybe_reward)?, + ); + } + } + } + + rewards = aggregate_coins(rewards)?; + + if is_claim { + Ok(RewardsResponse::ClaimRewards { + rewards, + modified_incentives, + }) + } else { + Ok(RewardsResponse::RewardsResponse { rewards }) + } +} + +/// Computes the epoch from which the user can start claiming rewards for a given incentive +pub(crate) fn compute_start_from_epoch_for_user( + storage: &dyn Storage, + lp_denom: &str, + last_claimed_epoch: Option, + receiver: &Addr, +) -> Result { + let first_claimable_epoch_for_user = if let Some(last_claimed_epoch) = last_claimed_epoch { + // if the user has claimed before, then the next epoch is the one after the last claimed epoch + last_claimed_epoch + 1u64 + } else { + // if the user has never claimed before but has a weight, get the epoch at which the user + // first had a weight in the system + get_earliest_address_lp_weight(storage, receiver, lp_denom)?.0 + }; + + Ok(first_claimable_epoch_for_user) +} + +/// Computes the user weights for a given LP asset. This assumes that [compute_start_from_epoch_for_user] +/// was called before this function, computing the start_from_epoch for the user with either the last_claimed_epoch +/// or the first epoch the user had a weight in the system. +pub(crate) fn compute_user_weights( + storage: &dyn Storage, + position: &Position, + start_from_epoch: &EpochId, + current_epoch_id: &EpochId, +) -> Result, ContractError> { + let mut user_weights = HashMap::new(); + let mut last_weight_seen = Uint128::zero(); + + // starts from start_from_epoch - 1 in case the user has a last_claimed_epoch, which means the user + // has a weight for the last_claimed_epoch. [compute_start_from_epoch_for_incentive] would return + // last_claimed_epoch + 1 in that case, which is correct, and if the user has not modified its + // position, the weight will be the same for start_from_epoch as it is for last_claimed_epoch. + for epoch_id in *start_from_epoch - 1..=*current_epoch_id { + let weight = LP_WEIGHT_HISTORY.may_load( + storage, + (&position.receiver, &position.lp_asset.denom, epoch_id), + )?; + + if let Some(weight) = weight { + last_weight_seen = weight; + user_weights.insert(epoch_id, weight); + } else { + user_weights.insert(epoch_id, last_weight_seen); + } + } + Ok(user_weights) +} + +/// Computes the incentive emissions for a given incentive. Let's assume for now that the incentive +/// is expanded by a multiple of the original emission rate. todo revise this +/// ### Returns +/// A pair with the incentive emissions for each epoch between start_from_epoch and the current_epoch_id in a hashmap +/// and the last epoch for which the incentive emissions were computed +fn compute_incentive_emissions( + incentive: &Incentive, + start_from_epoch: &EpochId, + current_epoch_id: &EpochId, +) -> Result<(HashMap, EpochId), ContractError> { + let mut incentive_emissions = HashMap::new(); + + let until_epoch = if incentive.preliminary_end_epoch <= *current_epoch_id { + // the preliminary_end_eopch is not inclusive, so we subtract 1 + incentive.preliminary_end_epoch - 1u64 + } else { + *current_epoch_id + }; + + for epoch in *start_from_epoch..=until_epoch { + incentive_emissions.insert(epoch, incentive.emission_rate); + } + + Ok((incentive_emissions, until_epoch)) +} + +/// Syncs the address lp weight history for the given address and epoch_id, removing all the previous +/// entries as the user has already claimed those epochs, and setting the weight for the current epoch. +fn sync_address_lp_weight_history( + storage: &mut dyn Storage, + address: &Addr, + lp_denom: &str, + current_epoch_id: &u64, +) -> Result<(), ContractError> { + let (earliest_epoch_id, _) = get_earliest_address_lp_weight(storage, address, lp_denom)?; + let (latest_epoch_id, latest_address_lp_weight) = + get_latest_address_lp_weight(storage, address, lp_denom, current_epoch_id)?; + + // remove previous entries + for epoch_id in earliest_epoch_id..=latest_epoch_id { + LP_WEIGHT_HISTORY.remove(storage, (address, lp_denom, epoch_id)); + } + + // save the latest weight for the current epoch + LP_WEIGHT_HISTORY.save( + storage, + (address, lp_denom, *current_epoch_id), + &latest_address_lp_weight, + )?; + + Ok(()) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/incentive/mod.rs b/contracts/liquidity_hub/incentive-manager/src/incentive/mod.rs new file mode 100644 index 000000000..1225d166b --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/incentive/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +#[cfg(test)] +mod tests; diff --git a/contracts/liquidity_hub/incentive-manager/src/incentive/tests/mod.rs b/contracts/liquidity_hub/incentive-manager/src/incentive/tests/mod.rs new file mode 100644 index 000000000..2850608c4 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/incentive/tests/mod.rs @@ -0,0 +1 @@ +mod rewards; diff --git a/contracts/liquidity_hub/incentive-manager/src/incentive/tests/rewards.rs b/contracts/liquidity_hub/incentive-manager/src/incentive/tests/rewards.rs new file mode 100644 index 000000000..4913ba6dc --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/incentive/tests/rewards.rs @@ -0,0 +1,189 @@ +use crate::incentive::commands::{compute_start_from_epoch_for_user, compute_user_weights}; +use crate::state::LP_WEIGHT_HISTORY; +use cosmwasm_std::{Addr, Coin, Uint128}; +use white_whale_std::incentive_manager::{Curve, Incentive, Position}; +use white_whale_std::pool_network::mock_querier::mock_dependencies; + +#[test] +fn compute_start_from_epoch_for_user_successfully() { + let mut deps = mock_dependencies(&[]); + let user = Addr::unchecked("user"); + + let mut incentive = Incentive { + identifier: "incentive".to_string(), + owner: user.clone(), + lp_denom: "lp".to_string(), + incentive_asset: Coin { + denom: "incentive".to_string(), + amount: Uint128::new(1_000), + }, + claimed_amount: Default::default(), + emission_rate: Default::default(), + curve: Curve::Linear, + start_epoch: 10, + preliminary_end_epoch: 20, + last_epoch_claimed: 9, + }; + + // Mimics the scenario where the user has never claimed before, but opened a position before the incentive + // went live + let first_user_weight_epoch_id = 8; + LP_WEIGHT_HISTORY + .save( + &mut deps.storage, + (&user, "lp", first_user_weight_epoch_id), + &Uint128::one(), + ) + .unwrap(); + + let start_from_epoch = + compute_start_from_epoch_for_user(&deps.storage, &incentive.lp_denom, None, &user).unwrap(); + + // the function should return the start epoch of the incentive + assert_eq!(start_from_epoch, first_user_weight_epoch_id); + + // Mimics the scenario where the user has never claimed before, but opened a position after the incentive + // went live + incentive.start_epoch = 5u64; + let start_from_epoch = + compute_start_from_epoch_for_user(&deps.storage, &incentive.lp_denom, None, &user).unwrap(); + + // the function should return the first epoch the user has a weight + assert_eq!(start_from_epoch, first_user_weight_epoch_id); + + // Mimics the scenario where the user has claimed already, after the incentive went live, i.e. the user + // has already partially claimed this incentive + incentive.start_epoch = 10u64; + let start_from_epoch = + compute_start_from_epoch_for_user(&deps.storage, &incentive.lp_denom, Some(12u64), &user) + .unwrap(); + + // the function should return the next epoch after the last claimed one + assert_eq!(start_from_epoch, 13); + + // Mimics the scenario where the user has claimed already, before the incentive went live, i.e. the user + // has not claimed this incentive at all + incentive.start_epoch = 15u64; + let start_from_epoch = + compute_start_from_epoch_for_user(&deps.storage, &incentive.lp_denom, Some(12u64), &user) + .unwrap(); + + // the function should return the start epoch of the incentive + assert_eq!(start_from_epoch, 13); + + // Mimics the scenario where the user has claimed the epoch the incentives went live + incentive.start_epoch = 15u64; + let start_from_epoch = + compute_start_from_epoch_for_user(&deps.storage, &incentive.lp_denom, Some(15u64), &user) + .unwrap(); + + // the function should return the next epoch after the last claimed one + assert_eq!(start_from_epoch, 16); +} + +#[test] +fn compute_user_weights_successfully() { + let mut deps = mock_dependencies(&[]); + + let user = Addr::unchecked("user"); + + let mut start_from_epoch = 1u64; + let current_epoch_id = 10u64; + + // fill the lp_weight_history for the address with + // [(1,2), (2,4), (3,6), (4,8), (5,10), (6,12), (7,14), (8,16), (9,18), (10,20)] + for epoch in 1u64..=10u64 { + let weight = Uint128::new(epoch as u128 * 2u128); + LP_WEIGHT_HISTORY + .save(&mut deps.storage, (&user, "lp", epoch), &weight) + .unwrap(); + } + + let position = Position { + identifier: "1".to_string(), + lp_asset: Coin { + denom: "lp".to_string(), + amount: Default::default(), + }, + unlocking_duration: 86_400, + open: true, + expiring_at: None, + receiver: user.clone(), + }; + + let weights = compute_user_weights( + &deps.storage, + &position, + &start_from_epoch, + ¤t_epoch_id, + ) + .unwrap(); + assert_eq!(weights.len(), 11); + + for epoch in 1u64..=10u64 { + assert_eq!( + weights.get(&epoch).unwrap(), + &Uint128::new(epoch as u128 * 2u128) + ); + + // reset the weight for epochs + LP_WEIGHT_HISTORY.remove(&mut deps.storage, (&user, &position.lp_asset.denom, epoch)); + } + + // fill the lp_weight_history for the address with + // [(1,2), (5,10), (7,14)] + for epoch in 1u64..=10u64 { + if epoch % 2 == 0 || epoch % 3 == 0 { + continue; + } + + let weight = Uint128::new(epoch as u128 * 2u128); + LP_WEIGHT_HISTORY + .save( + &mut deps.storage, + (&user, &position.lp_asset.denom, epoch), + &weight, + ) + .unwrap(); + } + + // The result should be [(1,2), (5,10), (10,14)], with the skipped valued in between having the same + // value as the previous, most recent value, i.e. epoch 2 3 4 having the value of 1 (latest weight seen in epoch 1) + // then 5..7 having the value of 10 (latest weight seen in epoch 5) + // then 8..=10 having the value of 14 (latest weight seen in epoch 7) + let weights = compute_user_weights( + &deps.storage, + &position, + &start_from_epoch, + ¤t_epoch_id, + ) + .unwrap(); + assert_eq!(weights.len(), 11); + + assert_eq!(weights.get(&1).unwrap(), &Uint128::new(2)); + assert_eq!(weights.get(&4).unwrap(), &Uint128::new(2)); + assert_eq!(weights.get(&5).unwrap(), &Uint128::new(10)); + assert_eq!(weights.get(&6).unwrap(), &Uint128::new(10)); + assert_eq!(weights.get(&7).unwrap(), &Uint128::new(14)); + assert_eq!(weights.get(&10).unwrap(), &Uint128::new(14)); + + start_from_epoch = 6u64; + let weights = compute_user_weights( + &deps.storage, + &position, + &start_from_epoch, + ¤t_epoch_id, + ) + .unwrap(); + assert_eq!(weights.len(), 6); + + assert_eq!(weights.get(&5).unwrap(), &Uint128::new(10)); + assert_eq!(weights.get(&6).unwrap(), &Uint128::new(10)); + assert_eq!(weights.get(&7).unwrap(), &Uint128::new(14)); + assert_eq!(weights.get(&10).unwrap(), &Uint128::new(14)); + + for epoch in 1u64..=10u64 { + // reset the weight for epochs + LP_WEIGHT_HISTORY.remove(&mut deps.storage, (&user, &position.lp_asset.denom, epoch)); + } +} diff --git a/contracts/liquidity_hub/incentive-manager/src/lib.rs b/contracts/liquidity_hub/incentive-manager/src/lib.rs new file mode 100644 index 000000000..b3b6cb40e --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/lib.rs @@ -0,0 +1,10 @@ +pub mod contract; +mod error; +pub mod helpers; +pub mod incentive; +mod manager; +pub mod position; +mod queries; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/liquidity_hub/incentive-manager/src/manager/commands.rs b/contracts/liquidity_hub/incentive-manager/src/manager/commands.rs new file mode 100644 index 000000000..04c568831 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/manager/commands.rs @@ -0,0 +1,450 @@ +use cosmwasm_std::{ + ensure, BankMsg, Coin, CosmosMsg, Decimal, DepsMut, Env, MessageInfo, Response, StdError, + Storage, Uint128, Uint64, +}; + +use white_whale_std::coin::{get_factory_token_subdenom, is_factory_token}; +use white_whale_std::epoch_manager::hooks::EpochChangedHookMsg; +use white_whale_std::incentive_manager::MIN_INCENTIVE_AMOUNT; +use white_whale_std::incentive_manager::{Curve, Incentive, IncentiveParams}; +use white_whale_std::lp_common::LP_SYMBOL; + +use crate::helpers::{ + assert_incentive_asset, process_incentive_creation_fee, validate_emergency_unlock_penalty, + validate_incentive_epochs, +}; +use crate::state::{ + get_incentive_by_identifier, get_incentives_by_lp_denom, get_latest_address_lp_weight, CONFIG, + INCENTIVES, INCENTIVE_COUNTER, LP_WEIGHT_HISTORY, +}; +use crate::ContractError; + +pub(crate) fn fill_incentive( + deps: DepsMut, + info: MessageInfo, + params: IncentiveParams, +) -> Result { + // if an incentive_identifier was passed in the params, check if an incentive with such identifier + // exists and if the sender is allow to refill it, otherwise create a new incentive + if let Some(incentive_indentifier) = params.clone().incentive_identifier { + let incentive_result = get_incentive_by_identifier(deps.storage, &incentive_indentifier); + + if let Ok(incentive) = incentive_result { + // the incentive exists, try to expand it + return expand_incentive(deps, info, incentive, params); + } + // the incentive does not exist, try to create it + } + + // if no identifier was passed in the params or if the incentive does not exist, try to create the incentive + create_incentive(deps, info, params) +} + +/// Creates an incentive with the given params +fn create_incentive( + deps: DepsMut, + info: MessageInfo, + params: IncentiveParams, +) -> Result { + // check if there are any expired incentives for this LP asset + let config = CONFIG.load(deps.storage)?; + let incentives = get_incentives_by_lp_denom( + deps.storage, + ¶ms.lp_denom, + None, + Some(config.max_concurrent_incentives), + )?; + + let current_epoch = white_whale_std::epoch_manager::common::get_current_epoch( + deps.as_ref(), + config.epoch_manager_addr.clone().into_string(), + )?; + + let (expired_incentives, incentives): (Vec<_>, Vec<_>) = incentives + .into_iter() + .partition(|incentive| incentive.is_expired(current_epoch.id)); + + let mut messages: Vec = vec![]; + + // close expired incentives if there are any + if !expired_incentives.is_empty() { + messages.append(&mut close_incentives(deps.storage, expired_incentives)?); + } + + // check if more incentives can be created for this particular LP asset + ensure!( + incentives.len() < config.max_concurrent_incentives as usize, + ContractError::TooManyIncentives { + max: config.max_concurrent_incentives, + } + ); + + // check the incentive is being created with a valid amount + ensure!( + params.incentive_asset.amount >= MIN_INCENTIVE_AMOUNT, + ContractError::InvalidIncentiveAmount { + min: MIN_INCENTIVE_AMOUNT.u128() + } + ); + + let incentive_creation_fee = config.clone().create_incentive_fee; + + if incentive_creation_fee.amount != Uint128::zero() { + // verify the fee to create an incentive is being paid + messages.append(&mut process_incentive_creation_fee( + &config, + &info, + &incentive_creation_fee, + ¶ms, + )?); + } + + // verify the incentive asset was sent + assert_incentive_asset(&info, &incentive_creation_fee, ¶ms)?; + + // assert epoch params are correctly set + let (start_epoch, preliminary_end_epoch) = validate_incentive_epochs( + ¶ms, + current_epoch.id, + u64::from(config.max_incentive_epoch_buffer), + )?; + + // create incentive identifier + let incentive_id = INCENTIVE_COUNTER + .update::<_, StdError>(deps.storage, |current_id| Ok(current_id + 1u64))?; + let incentive_identifier = params + .incentive_identifier + .unwrap_or(incentive_id.to_string()); + + // sanity check. Make sure another incentive with the same identifier doesn't exist. Theoretically this should + // never happen, since the fill_incentive function would try to expand the incentive if a user tries + // filling an incentive with an identifier that already exists + ensure!( + get_incentive_by_identifier(deps.storage, &incentive_identifier).is_err(), + ContractError::IncentiveAlreadyExists + ); + // the incentive does not exist, all good, continue + + // calculates the emission rate. The way it's calculated, it makes the last epoch to be + // non-inclusive, i.e. the last epoch is not counted in the emission + let emission_rate = params + .incentive_asset + .amount + .checked_div_floor((preliminary_end_epoch.saturating_sub(start_epoch), 1u64))?; + + // create the incentive + let incentive = Incentive { + identifier: incentive_identifier, + start_epoch, + preliminary_end_epoch, + curve: params.curve.unwrap_or(Curve::Linear), + incentive_asset: params.incentive_asset, + lp_denom: params.lp_denom, + owner: info.sender, + claimed_amount: Uint128::zero(), + emission_rate, + last_epoch_claimed: start_epoch - 1, + }; + + INCENTIVES.save(deps.storage, &incentive.identifier, &incentive)?; + + Ok(Response::default() + .add_messages(messages) + .add_attributes(vec![ + ("action", "create_incentive".to_string()), + ("incentive_creator", incentive.owner.to_string()), + ("incentive_identifier", incentive.identifier), + ("start_epoch", incentive.start_epoch.to_string()), + ( + "preliminary_end_epoch", + incentive.preliminary_end_epoch.to_string(), + ), + ("emission_rate", emission_rate.to_string()), + ("curve", incentive.curve.to_string()), + ("incentive_asset", incentive.incentive_asset.to_string()), + ("lp_denom", incentive.lp_denom), + ])) +} + +/// Closes an incentive. If the incentive has expired, anyone can close it. Otherwise, only the +/// incentive creator or the owner of the contract can close an incentive. +pub(crate) fn close_incentive( + deps: DepsMut, + info: MessageInfo, + incentive_identifier: String, +) -> Result { + cw_utils::nonpayable(&info)?; + + // validate that user is allowed to close the incentive. Only the incentive creator or the owner + // of the contract can close an incentive + let incentive = get_incentive_by_identifier(deps.storage, &incentive_identifier)?; + + ensure!( + incentive.owner == info.sender || cw_ownable::is_owner(deps.storage, &info.sender)?, + ContractError::Unauthorized + ); + + Ok(Response::default() + .add_messages(close_incentives(deps.storage, vec![incentive])?) + .add_attributes(vec![ + ("action", "close_incentive".to_string()), + ("incentive_identifier", incentive_identifier), + ])) +} + +/// Closes a list of incentives. Does not validate the sender, do so before calling this function. +fn close_incentives( + storage: &mut dyn Storage, + incentives: Vec, +) -> Result, ContractError> { + let mut messages: Vec = vec![]; + + for mut incentive in incentives { + // remove the incentive from the storage + INCENTIVES.remove(storage, &incentive.identifier)?; + + // return the available asset, i.e. the amount that hasn't been claimed + incentive.incentive_asset.amount = incentive + .incentive_asset + .amount + .saturating_sub(incentive.claimed_amount); + + messages.push( + BankMsg::Send { + to_address: incentive.owner.into_string(), + amount: vec![incentive.incentive_asset], + } + .into(), + ); + } + + Ok(messages) +} + +/// Expands an incentive with the given params +fn expand_incentive( + deps: DepsMut, + info: MessageInfo, + mut incentive: Incentive, + params: IncentiveParams, +) -> Result { + // only the incentive owner can expand it + ensure!(incentive.owner == info.sender, ContractError::Unauthorized); + + let config = CONFIG.load(deps.storage)?; + let current_epoch = white_whale_std::epoch_manager::common::get_current_epoch( + deps.as_ref(), + config.epoch_manager_addr.into_string(), + )?; + + // check if the incentive has already expired, can't be expanded + ensure!( + !incentive.is_expired(current_epoch.id), + ContractError::IncentiveAlreadyExpired + ); + + // check that the asset sent matches the asset expected + ensure!( + incentive.incentive_asset.denom == params.incentive_asset.denom, + ContractError::AssetMismatch + ); + + // make sure the expansion is a multiple of the emission rate + ensure!( + params.incentive_asset.amount % incentive.emission_rate == Uint128::zero(), + ContractError::InvalidExpansionAmount { + emission_rate: incentive.emission_rate + } + ); + + // increase the total amount of the incentive + incentive.incentive_asset.amount = incentive + .incentive_asset + .amount + .checked_add(params.incentive_asset.amount)?; + + let additional_epochs = params + .incentive_asset + .amount + .checked_div(incentive.emission_rate)?; + + // adjust the preliminary end_epoch + incentive.preliminary_end_epoch = incentive + .preliminary_end_epoch + .checked_add(Uint64::try_from(additional_epochs)?.u64()) + .ok_or(ContractError::InvalidEndEpoch)?; + + INCENTIVES.save(deps.storage, &incentive.identifier, &incentive)?; + + Ok(Response::default().add_attributes(vec![ + ("action", "expand_incentive".to_string()), + ("incentive_identifier", incentive.identifier), + ("expanded_by", params.incentive_asset.to_string()), + ("total_incentive", incentive.incentive_asset.to_string()), + ])) +} + +/// EpochChanged hook implementation. Updates the LP_WEIGHTS. +pub(crate) fn on_epoch_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: EpochChangedHookMsg, +) -> Result { + let config = CONFIG.load(deps.storage)?; + // only the epoch manager can trigger this + ensure!( + info.sender == config.epoch_manager_addr, + ContractError::Unauthorized + ); + + // get all LP tokens and update the LP_WEIGHTS_HISTORY + let lp_denoms = deps + .querier + .query_all_balances(env.contract.address.clone())? + .into_iter() + .filter(|asset| { + if is_factory_token(asset.denom.as_str()) { + match get_factory_token_subdenom(asset.denom.as_str()) { + Ok(subdenom) => subdenom == LP_SYMBOL, + Err(_) => false, + } + } else { + false + } + }) + .map(|asset| asset.denom) + .collect::>(); + + for lp_denom in &lp_denoms { + let lp_weight_option = LP_WEIGHT_HISTORY.may_load( + deps.storage, + (&env.contract.address, lp_denom, msg.current_epoch.id), + )?; + + // if the weight for this LP token at this epoch has already been recorded, i.e. someone + // opened or closed positions in the previous epoch, skip it + if lp_weight_option.is_some() { + continue; + } else { + // if the weight for this LP token at this epoch has not been recorded, i.e. no one + // opened or closed positions in the previous epoch, get the last recorded weight + let (_, latest_lp_weight_record) = get_latest_address_lp_weight( + deps.storage, + &env.contract.address, + lp_denom, + &msg.current_epoch.id, + )?; + + LP_WEIGHT_HISTORY.save( + deps.storage, + (&env.contract.address, lp_denom, msg.current_epoch.id), + &latest_lp_weight_record, + )?; + } + } + + Ok(Response::default().add_attributes(vec![ + ("action", "on_epoch_changed".to_string()), + ("epoch", msg.current_epoch.to_string()), + ])) +} + +#[allow(clippy::too_many_arguments)] +/// Updates the configuration of the contract +pub(crate) fn update_config( + deps: DepsMut, + info: MessageInfo, + whale_lair_addr: Option, + epoch_manager_addr: Option, + create_incentive_fee: Option, + max_concurrent_incentives: Option, + max_incentive_epoch_buffer: Option, + min_unlocking_duration: Option, + max_unlocking_duration: Option, + emergency_unlock_penalty: Option, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut config = CONFIG.load(deps.storage)?; + + if let Some(whale_lair_addr) = whale_lair_addr { + config.whale_lair_addr = deps.api.addr_validate(&whale_lair_addr)?; + } + + if let Some(epoch_manager_addr) = epoch_manager_addr { + config.epoch_manager_addr = deps.api.addr_validate(&epoch_manager_addr)?; + } + + if let Some(create_incentive_fee) = create_incentive_fee { + config.create_incentive_fee = create_incentive_fee; + } + + if let Some(max_concurrent_incentives) = max_concurrent_incentives { + if max_concurrent_incentives == 0u32 { + return Err(ContractError::UnspecifiedConcurrentIncentives); + } + + config.max_concurrent_incentives = max_concurrent_incentives; + } + + if let Some(max_incentive_epoch_buffer) = max_incentive_epoch_buffer { + config.max_incentive_epoch_buffer = max_incentive_epoch_buffer; + } + + if let Some(max_unlocking_duration) = max_unlocking_duration { + if max_unlocking_duration < config.min_unlocking_duration { + return Err(ContractError::InvalidUnlockingRange { + min: config.min_unlocking_duration, + max: max_unlocking_duration, + }); + } + + config.max_unlocking_duration = max_unlocking_duration; + } + + if let Some(min_unlocking_duration) = min_unlocking_duration { + if config.max_unlocking_duration < min_unlocking_duration { + return Err(ContractError::InvalidUnlockingRange { + min: min_unlocking_duration, + max: config.max_unlocking_duration, + }); + } + + config.min_unlocking_duration = min_unlocking_duration; + } + + if let Some(emergency_unlock_penalty) = emergency_unlock_penalty { + config.emergency_unlock_penalty = + validate_emergency_unlock_penalty(emergency_unlock_penalty)?; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default().add_attributes(vec![ + ("action", "update_config".to_string()), + ("whale_lair_addr", config.whale_lair_addr.to_string()), + ("epoch_manager_addr", config.epoch_manager_addr.to_string()), + ("create_flow_fee", config.create_incentive_fee.to_string()), + ( + "max_concurrent_flows", + config.max_concurrent_incentives.to_string(), + ), + ( + "max_flow_epoch_buffer", + config.max_incentive_epoch_buffer.to_string(), + ), + ( + "min_unbonding_duration", + config.min_unlocking_duration.to_string(), + ), + ( + "max_unbonding_duration", + config.max_unlocking_duration.to_string(), + ), + ( + "emergency_unlock_penalty", + config.emergency_unlock_penalty.to_string(), + ), + ])) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/manager/mod.rs b/contracts/liquidity_hub/incentive-manager/src/manager/mod.rs new file mode 100644 index 000000000..82b6da3c0 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/manager/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/contracts/liquidity_hub/incentive-manager/src/position/commands.rs b/contracts/liquidity_hub/incentive-manager/src/position/commands.rs new file mode 100644 index 000000000..0b382d345 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/position/commands.rs @@ -0,0 +1,361 @@ +use cosmwasm_std::{ + ensure, BankMsg, Coin, CosmosMsg, DepsMut, Env, MessageInfo, Response, StdError, +}; + +use white_whale_std::incentive_manager::{Position, RewardsResponse}; + +use crate::position::helpers::validate_unlocking_duration; +use crate::position::helpers::{calculate_weight, get_latest_address_weight}; +use crate::queries::query_rewards; +use crate::state::{get_position, CONFIG, LP_WEIGHT_HISTORY, POSITIONS, POSITION_ID_COUNTER}; +use crate::ContractError; + +/// Fills a position. If the position already exists, it will be expanded. Otherwise, a new position is created. +pub(crate) fn fill_position( + deps: DepsMut, + env: &Env, + info: MessageInfo, + identifier: Option, + unlocking_duration: u64, + receiver: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + let lp_asset = cw_utils::one_coin(&info)?; + + // validate unlocking duration + validate_unlocking_duration(&config, unlocking_duration)?; + + // if receiver was not specified, default to the sender of the message. + let receiver = receiver + .map(|r| deps.api.addr_validate(&r)) + .transpose()? + .map(|receiver| MessageInfo { + funds: info.funds.clone(), + sender: receiver, + }) + .unwrap_or_else(|| info.clone()); + + // check if there's an existing open position with the given `identifier` + let mut position = get_position(deps.storage, identifier.clone())?; + + if let Some(ref mut position) = position { + // there is a position, refill it + ensure!( + position.lp_asset.denom == lp_asset.denom, + ContractError::AssetMismatch + ); + + // if the position is found, ignore if there's a change in the unlocking_duration as it is + // considered the same position, so use the existing unlocking_duration and only update the + // amount of the LP asset + + position.lp_asset.amount = position.lp_asset.amount.checked_add(lp_asset.amount)?; + POSITIONS.save(deps.storage, &position.identifier, position)?; + } else { + // No position found, create a new one + let position_id_counter = POSITION_ID_COUNTER + .may_load(deps.storage)? + .unwrap_or_default() + + 1u64; + + POSITION_ID_COUNTER.save(deps.storage, &position_id_counter)?; + + // if no identifier was provided, use the counter as the identifier + let identifier = identifier.unwrap_or(position_id_counter.to_string()); + + POSITIONS.save( + deps.storage, + &identifier, + &Position { + identifier: identifier.clone(), + lp_asset: lp_asset.clone(), + unlocking_duration, + open: true, + expiring_at: None, + receiver: receiver.sender.clone(), + }, + )?; + } + + // Update weights for the LP and the user + update_weights(deps, env, &receiver, &lp_asset, unlocking_duration, true)?; + + let action = match position { + Some(_) => "expand_position", + None => "open_position", + }; + + Ok(Response::default().add_attributes(vec![ + ("action", action.to_string()), + ("receiver", receiver.sender.to_string()), + ("lp_asset", lp_asset.to_string()), + ("unlocking_duration", unlocking_duration.to_string()), + ])) +} + +/// Closes an existing position +pub(crate) fn close_position( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + identifier: String, + lp_asset: Option, +) -> Result { + cw_utils::nonpayable(&info)?; + + // check if the user has pending rewards. Can't close a position without claiming pending rewards first + let rewards_response = query_rewards(deps.as_ref(), &env, info.sender.clone().into_string())?; + match rewards_response { + RewardsResponse::RewardsResponse { rewards } => { + ensure!(rewards.is_empty(), ContractError::PendingRewards) + } + _ => return Err(ContractError::Unauthorized), + } + + let mut position = get_position(deps.storage, Some(identifier.clone()))?.ok_or( + ContractError::NoPositionFound { + identifier: identifier.clone(), + }, + )?; + + ensure!( + position.receiver == info.sender, + ContractError::Unauthorized + ); + + ensure!( + position.open, + ContractError::PositionAlreadyClosed { identifier } + ); + + let mut attributes = vec![ + ("action", "close_position".to_string()), + ("receiver", info.sender.to_string()), + ("identifier", identifier.to_string()), + ]; + + let expires_at = env + .block + .time + .plus_seconds(position.unlocking_duration) + .seconds(); + + // check if it's going to be closed in full or partially + if let Some(lp_asset) = lp_asset { + // close position partially + + // make sure the lp_asset requested to close matches the lp_asset of the position, and since + // this is a partial close, the amount requested to close should be less than the amount in the position + ensure!( + lp_asset.denom == position.lp_asset.denom && lp_asset.amount < position.lp_asset.amount, + ContractError::AssetMismatch + ); + + position.lp_asset.amount = position.lp_asset.amount.saturating_sub(lp_asset.amount); + + // add the partial closing position to the storage + let identifier = POSITION_ID_COUNTER + .may_load(deps.storage)? + .unwrap_or_default() + + 1u64; + POSITION_ID_COUNTER.save(deps.storage, &identifier)?; + + let partial_position = Position { + identifier: identifier.to_string(), + lp_asset, + unlocking_duration: position.unlocking_duration, + open: false, + expiring_at: Some(expires_at), + receiver: position.receiver.clone(), + }; + + POSITIONS.save(deps.storage, &identifier.to_string(), &partial_position)?; + } else { + // close position in full + position.open = false; + position.expiring_at = Some(expires_at); + } + let close_in_full = !position.open; + attributes.push(("close_in_full", close_in_full.to_string())); + + update_weights( + deps.branch(), + &env, + &info, + &position.lp_asset, + position.unlocking_duration, + false, + )?; + + POSITIONS.save(deps.storage, &identifier, &position)?; + + Ok(Response::default().add_attributes(attributes)) +} + +/// Withdraws the given position. The position needs to have expired. +pub(crate) fn withdraw_position( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + identifier: String, + emergency_unlock: Option, +) -> Result { + cw_utils::nonpayable(&info)?; + + let mut position = get_position(deps.storage, Some(identifier.clone()))?.ok_or( + ContractError::NoPositionFound { + identifier: identifier.clone(), + }, + )?; + + ensure!( + position.receiver == info.sender, + ContractError::Unauthorized + ); + + // check if the user has pending rewards. Can't withdraw a position without claiming pending rewards first + let rewards_response = query_rewards(deps.as_ref(), &env, info.sender.clone().into_string())?; + match rewards_response { + RewardsResponse::RewardsResponse { rewards } => { + ensure!(rewards.is_empty(), ContractError::PendingRewards) + } + _ => return Err(ContractError::Unauthorized), + } + + let mut messages: Vec = vec![]; + + // check if the emergency unlock is requested, will pull the whole position out whether it's open, closed or expired, paying the penalty + if emergency_unlock.is_some() && emergency_unlock.unwrap() { + let emergency_unlock_penalty = CONFIG.load(deps.storage)?.emergency_unlock_penalty; + + let penalty_fee = position.lp_asset.amount * emergency_unlock_penalty; + + // sanity check + ensure!( + penalty_fee < position.lp_asset.amount, + ContractError::InvalidEmergencyUnlockPenalty + ); + + let penalty = Coin { + denom: position.lp_asset.denom.to_string(), + amount: penalty_fee, + }; + + let whale_lair_addr = CONFIG.load(deps.storage)?.whale_lair_addr; + + // send penalty to whale lair for distribution + messages.push(white_whale_std::whale_lair::fill_rewards_msg_coin( + whale_lair_addr.into_string(), + vec![penalty], + )?); + + // if the position is open, update the weights when doing the emergency withdrawal + // otherwise not, as the weights have already being updated when the position was closed + if position.open { + update_weights( + deps.branch(), + &env, + &info, + &position.lp_asset, + position.unlocking_duration, + false, + )?; + } + + // subtract the penalty from the original position + position.lp_asset.amount = position.lp_asset.amount.saturating_sub(penalty_fee); + } else { + // check if this position is eligible for withdrawal + if position.open || position.expiring_at.is_none() { + return Err(ContractError::Unauthorized); + } + + if position.expiring_at.unwrap() > env.block.time.seconds() { + return Err(ContractError::PositionNotExpired); + } + } + + // sanity check + if !position.lp_asset.amount.is_zero() { + // withdraw the remaining LP tokens + messages.push( + BankMsg::Send { + to_address: position.receiver.to_string(), + amount: vec![position.lp_asset], + } + .into(), + ); + } + + POSITIONS.remove(deps.storage, &identifier)?; + + Ok(Response::default() + .add_attributes(vec![ + ("action", "withdraw_position".to_string()), + ("receiver", info.sender.to_string()), + ("identifier", identifier), + ]) + .add_messages(messages)) +} + +/// Updates the weights when managing a position. Computes what the weight is gonna be in the next epoch. +fn update_weights( + deps: DepsMut, + env: &Env, + receiver: &MessageInfo, + lp_asset: &Coin, + unlocking_duration: u64, + fill: bool, +) -> Result<(), ContractError> { + let config = CONFIG.load(deps.storage)?; + let current_epoch = white_whale_std::epoch_manager::common::get_current_epoch( + deps.as_ref(), + config.epoch_manager_addr.into_string(), + )?; + + let weight = calculate_weight(lp_asset, unlocking_duration)?; + + let (_, mut lp_weight) = + get_latest_address_weight(deps.storage, &env.contract.address, &lp_asset.denom)?; + + if fill { + // filling position + lp_weight = lp_weight.checked_add(weight)?; + } else { + // closing position + lp_weight = lp_weight.saturating_sub(weight); + } + + // update the LP weight for the contract + LP_WEIGHT_HISTORY.update::<_, StdError>( + deps.storage, + ( + &env.contract.address, + &lp_asset.denom, + current_epoch.id + 1u64, + ), + |_| Ok(lp_weight), + )?; + + // update the user's weight for this LP + let (_, mut address_lp_weight) = + get_latest_address_weight(deps.storage, &receiver.sender, &lp_asset.denom)?; + + if fill { + // filling position + address_lp_weight = address_lp_weight.checked_add(weight)?; + } else { + // closing position + address_lp_weight = address_lp_weight.saturating_sub(weight); + } + + //todo if the address weight is zero, remove it from the storage? + LP_WEIGHT_HISTORY.update::<_, StdError>( + deps.storage, + (&receiver.sender, &lp_asset.denom, current_epoch.id + 1u64), + |_| Ok(address_lp_weight), + )?; + + Ok(()) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/position/helpers.rs b/contracts/liquidity_hub/incentive-manager/src/position/helpers.rs new file mode 100644 index 000000000..1b1d1d7b9 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/position/helpers.rs @@ -0,0 +1,105 @@ +use cosmwasm_std::{Addr, Coin, Decimal256, Order, StdError, Storage, Uint128}; + +use white_whale_std::incentive_manager::{Config, EpochId}; + +use crate::state::LP_WEIGHT_HISTORY; +use crate::ContractError; + +const SECONDS_IN_DAY: u64 = 86400; +const SECONDS_IN_YEAR: u64 = 31556926; + +/// Calculates the weight size for a user filling a position +pub fn calculate_weight( + lp_asset: &Coin, + unlocking_duration: u64, +) -> Result { + if !(SECONDS_IN_DAY..=SECONDS_IN_YEAR).contains(&unlocking_duration) { + return Err(ContractError::InvalidWeight { unlocking_duration }); + } + + // store in Uint128 form for later + let amount_uint = lp_asset.amount; + + // interpolate between [(86400, 1), (15778463, 5), (31556926, 16)] + // note that 31556926 is not exactly one 365-day year, but rather one Earth rotation year + // similarly, 15778463 is not 1/2 a 365-day year, but rather 1/2 a one Earth rotation year + + // first we need to convert into decimals + let unlocking_duration = Decimal256::from_atomics(unlocking_duration, 0).unwrap(); + let amount = Decimal256::from_atomics(lp_asset.amount, 0).unwrap(); + + let unlocking_duration_squared = unlocking_duration.checked_pow(2)?; + let unlocking_duration_mul = + unlocking_duration_squared.checked_mul(Decimal256::raw(109498841))?; + let unlocking_duration_part = + unlocking_duration_mul.checked_div(Decimal256::raw(7791996353100889432894))?; + + let next_part = unlocking_duration + .checked_mul(Decimal256::raw(249042009202369))? + .checked_div(Decimal256::raw(7791996353100889432894))?; + + let final_part = Decimal256::from_ratio(246210981355969u64, 246918738317569u64); + + let weight: Uint128 = amount + .checked_mul( + unlocking_duration_part + .checked_add(next_part)? + .checked_add(final_part)?, + )? + .atomics() + .checked_div(10u128.pow(18).into())? + .try_into()?; + + // we must clamp it to max(computed_value, amount) as + // otherwise we might get a multiplier of 0.999999999999999998 when + // computing the final_part decimal value, which is over 200 digits. + Ok(weight.max(amount_uint)) +} + +/// Gets the latest available weight snapshot recorded for the given address. +pub fn get_latest_address_weight( + storage: &dyn Storage, + address: &Addr, + lp_denom: &str, +) -> Result<(EpochId, Uint128), ContractError> { + let result = LP_WEIGHT_HISTORY + .prefix((address, lp_denom)) + .range(storage, None, None, Order::Descending) + .take(1usize) + // take only one item, the last item. Since it's being sorted in descending order, it's the latest one. + .next() + .transpose(); + + return_latest_weight(result) +} + +/// Helper function to return the weight from the result. If the result is None, i.e. the weight +/// was not found in the map, it returns (0, 0). +fn return_latest_weight( + weight_result: Result, StdError>, +) -> Result<(EpochId, Uint128), ContractError> { + match weight_result { + Ok(Some(item)) => Ok(item), + Ok(None) => Ok((0u64, Uint128::zero())), + Err(std_err) => Err(std_err.into()), + } +} + +/// Validates the `unlocking_duration` specified in the position params is within the range specified +/// in the config. +pub(crate) fn validate_unlocking_duration( + config: &Config, + unlocking_duration: u64, +) -> Result<(), ContractError> { + if unlocking_duration < config.min_unlocking_duration + || unlocking_duration > config.max_unlocking_duration + { + return Err(ContractError::InvalidUnlockingDuration { + min: config.min_unlocking_duration, + max: config.max_unlocking_duration, + specified: unlocking_duration, + }); + } + + Ok(()) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/position/mod.rs b/contracts/liquidity_hub/incentive-manager/src/position/mod.rs new file mode 100644 index 000000000..a0fd1dab5 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/position/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +mod helpers; +mod tests; diff --git a/contracts/liquidity_hub/incentive-manager/src/position/tests/mod.rs b/contracts/liquidity_hub/incentive-manager/src/position/tests/mod.rs new file mode 100644 index 000000000..c625f2cb6 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/position/tests/mod.rs @@ -0,0 +1 @@ +mod weight_calculation; diff --git a/contracts/liquidity_hub/incentive-manager/src/position/tests/weight_calculation.rs b/contracts/liquidity_hub/incentive-manager/src/position/tests/weight_calculation.rs new file mode 100644 index 000000000..3cff1fe33 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/position/tests/weight_calculation.rs @@ -0,0 +1,25 @@ +#[test] +fn test_calculate_weight() { + use crate::position::helpers::calculate_weight; + use cosmwasm_std::coin; + use cosmwasm_std::Uint128; + + let weight = calculate_weight(&coin(100, "uwhale"), 86400u64).unwrap(); + assert_eq!(weight, Uint128::new(100)); + + // 1 month + let weight = calculate_weight(&coin(100, "uwhale"), 2629746).unwrap(); + assert_eq!(weight, Uint128::new(117)); + + // 3 months + let weight = calculate_weight(&coin(100, "uwhale"), 7889238).unwrap(); + assert_eq!(weight, Uint128::new(212)); + + // 6 months + let weight = calculate_weight(&coin(100, "uwhale"), 15778476).unwrap(); + assert_eq!(weight, Uint128::new(500)); + + // 1 year + let weight = calculate_weight(&coin(100, "uwhale"), 31556926).unwrap(); + assert_eq!(weight, Uint128::new(1599)); +} diff --git a/contracts/liquidity_hub/incentive-manager/src/queries.rs b/contracts/liquidity_hub/incentive-manager/src/queries.rs new file mode 100644 index 000000000..8f231b9a1 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/queries.rs @@ -0,0 +1,121 @@ +use cosmwasm_std::{Deps, Env}; + +use white_whale_std::coin::aggregate_coins; +use white_whale_std::incentive_manager::{ + Config, EpochId, IncentivesBy, IncentivesResponse, LpWeightResponse, PositionsResponse, + RewardsResponse, +}; + +use crate::incentive::commands::calculate_rewards; +use crate::state::{ + get_incentive_by_identifier, get_incentives, get_incentives_by_incentive_asset, + get_incentives_by_lp_denom, get_positions_by_receiver, CONFIG, LP_WEIGHT_HISTORY, +}; +use crate::ContractError; + +/// Queries the manager config +pub(crate) fn query_manager_config(deps: Deps) -> Result { + Ok(CONFIG.load(deps.storage)?) +} + +/// Queries all incentives. If `lp_asset` is provided, it will return all incentives for that +/// particular lp. +pub(crate) fn query_incentives( + deps: Deps, + filter_by: Option, + start_after: Option, + limit: Option, +) -> Result { + let incentives = if let Some(filter_by) = filter_by { + match filter_by { + IncentivesBy::Identifier(identifier) => { + vec![get_incentive_by_identifier(deps.storage, &identifier)?] + } + IncentivesBy::LPDenom(lp_denom) => { + get_incentives_by_lp_denom(deps.storage, lp_denom.as_str(), start_after, limit)? + } + IncentivesBy::IncentiveAsset(incentive_asset) => get_incentives_by_incentive_asset( + deps.storage, + incentive_asset.as_str(), + start_after, + limit, + )?, + } + } else { + get_incentives(deps.storage, start_after, limit)? + }; + + Ok(IncentivesResponse { incentives }) +} + +/// Queries all positions. If `open_state` is provided, it will return all positions that match that +/// open state, i.e. open positions if true, closed positions if false. +pub(crate) fn query_positions( + deps: Deps, + address: String, + open_state: Option, +) -> Result { + let positions = get_positions_by_receiver(deps.storage, address, open_state)?; + + Ok(PositionsResponse { positions }) +} + +/// Queries the rewards for a given address. +pub(crate) fn query_rewards( + deps: Deps, + env: &Env, + address: String, +) -> Result { + let receiver = deps.api.addr_validate(&address)?; + // check if the user has any open LP positions + let open_positions = + get_positions_by_receiver(deps.storage, receiver.into_string(), Some(true))?; + + if open_positions.is_empty() { + // if the user has no open LP positions, return an empty rewards list + return Ok(RewardsResponse::RewardsResponse { rewards: vec![] }); + } + + let config = CONFIG.load(deps.storage)?; + let current_epoch = white_whale_std::epoch_manager::common::get_current_epoch( + deps, + config.epoch_manager_addr.into_string(), + )?; + + let mut total_rewards = vec![]; + + for position in &open_positions { + // calculate the rewards for the position + let rewards_response = calculate_rewards(deps, env, position, current_epoch.id, false)?; + match rewards_response { + RewardsResponse::RewardsResponse { rewards } => { + total_rewards.append(&mut rewards.clone()) + } + _ => return Err(ContractError::Unauthorized), + } + } + + Ok(RewardsResponse::RewardsResponse { + rewards: aggregate_coins(total_rewards)?, + }) +} + +/// Queries the total lp weight for the given denom on the given epoch, i.e. the lp weight snapshot. +pub(crate) fn query_lp_weight( + deps: Deps, + address: String, + denom: String, + epoch_id: EpochId, +) -> Result { + let lp_weight = LP_WEIGHT_HISTORY + .may_load( + deps.storage, + (&deps.api.addr_validate(&address)?, denom.as_str(), epoch_id), + )? + .ok_or(ContractError::LpWeightNotFound { epoch_id })?; + + Ok(LpWeightResponse { + lp_weight, + epoch_id, + }) +} diff --git a/contracts/liquidity_hub/incentive-manager/src/state.rs b/contracts/liquidity_hub/incentive-manager/src/state.rs new file mode 100644 index 000000000..d6430e945 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/src/state.rs @@ -0,0 +1,247 @@ +use std::clone::Clone; +use std::string::ToString; + +use cosmwasm_std::{Addr, Order, StdResult, Storage, Uint128}; +use cw_storage_plus::{Bound, Index, IndexList, IndexedMap, Item, Map, MultiIndex}; + +use white_whale_std::incentive_manager::{Config, EpochId, Incentive, Position}; + +use crate::ContractError; + +/// Contract's config +pub const CONFIG: Item = Item::new("config"); + +/// An monotonically increasing counter to generate unique position identifiers. +pub const POSITION_ID_COUNTER: Item = Item::new("position_id_counter"); + +/// The positions that a user has. Positions can be open or closed. +/// The key is the position identifier +pub const POSITIONS: IndexedMap<&str, Position, PositionIndexes> = IndexedMap::new( + "positions", + PositionIndexes { + receiver: MultiIndex::new( + |_pk, p| p.receiver.to_string(), + "positions", + "positions__receiver", + ), + }, +); + +pub struct PositionIndexes<'a> { + pub receiver: MultiIndex<'a, String, Position, String>, +} + +impl<'a> IndexList for PositionIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.receiver]; + Box::new(v.into_iter()) + } +} + +/// The last epoch an address claimed rewards +pub const LAST_CLAIMED_EPOCH: Map<&Addr, EpochId> = Map::new("last_claimed_epoch"); + +/// The lp weight history for addresses, including the contract. i.e. how much lp weight an address +/// or contract has at a given epoch. +/// Key is a tuple of (address, lp_denom, epoch_id), value is the lp weight. +pub const LP_WEIGHT_HISTORY: Map<(&Addr, &str, EpochId), Uint128> = + Map::new("address_lp_weight_history"); + +/// An monotonically increasing counter to generate unique incentive identifiers. +pub const INCENTIVE_COUNTER: Item = Item::new("incentive_counter"); + +/// Incentives map +pub const INCENTIVES: IndexedMap<&str, Incentive, IncentiveIndexes> = IndexedMap::new( + "incentives", + IncentiveIndexes { + lp_denom: MultiIndex::new( + |_pk, i| i.lp_denom.to_string(), + "incentives", + "incentives__lp_asset", + ), + incentive_asset: MultiIndex::new( + |_pk, i| i.incentive_asset.denom.clone(), + "incentives", + "incentives__incentive_asset", + ), + }, +); + +pub struct IncentiveIndexes<'a> { + pub lp_denom: MultiIndex<'a, String, Incentive, String>, + pub incentive_asset: MultiIndex<'a, String, Incentive, String>, +} + +impl<'a> IndexList for IncentiveIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.lp_denom, &self.incentive_asset]; + Box::new(v.into_iter()) + } +} + +// settings for pagination +pub(crate) const MAX_LIMIT: u32 = 100; +const DEFAULT_LIMIT: u32 = 10; + +/// Gets the incentives in the contract +pub fn get_incentives( + storage: &dyn Storage, + start_after: Option, + limit: Option, +) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = cw_utils::calc_range_start_string(start_after).map(Bound::ExclusiveRaw); + + INCENTIVES + .range(storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + let (_, incentive) = item?; + + Ok(incentive) + }) + .collect() +} + +/// Gets incentives given an lp denom. +pub fn get_incentives_by_lp_denom( + storage: &dyn Storage, + lp_denom: &str, + start_after: Option, + limit: Option, +) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = cw_utils::calc_range_start_string(start_after).map(Bound::ExclusiveRaw); + + INCENTIVES + .idx + .lp_denom + .prefix(lp_denom.to_owned()) + .range(storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + let (_, incentive) = item?; + + Ok(incentive) + }) + .collect() +} + +/// Gets all the incentives that are offering the given incentive_asset as a reward. +pub fn get_incentives_by_incentive_asset( + storage: &dyn Storage, + incentive_asset: &str, + start_after: Option, + limit: Option, +) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = cw_utils::calc_range_start_string(start_after).map(Bound::ExclusiveRaw); + + INCENTIVES + .idx + .incentive_asset + .prefix(incentive_asset.to_owned()) + .range(storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + let (_, incentive) = item?; + + Ok(incentive) + }) + .collect() +} + +/// Gets the incentive given its identifier +pub fn get_incentive_by_identifier( + storage: &dyn Storage, + incentive_identifier: &String, +) -> Result { + INCENTIVES + .may_load(storage, incentive_identifier)? + .ok_or(ContractError::NonExistentIncentive {}) +} + +/// Gets a position given its identifier. If the position is not found with the given identifier, it returns None. +pub fn get_position( + storage: &dyn Storage, + identifier: Option, +) -> StdResult> { + if let Some(identifier) = identifier { + // there is a position + POSITIONS.may_load(storage, &identifier) + } else { + // there is no position + Ok(None) + } +} + +/// Gets all the positions of the given receiver. +pub fn get_positions_by_receiver( + storage: &dyn Storage, + receiver: String, + open_state: Option, +) -> StdResult> { + let limit = MAX_LIMIT as usize; + + let mut positions_by_receiver = POSITIONS + .idx + .receiver + .prefix(receiver) + .range(storage, None, None, Order::Ascending) + .take(limit) + .map(|item| { + let (_, position) = item?; + Ok(position) + }) + .collect::>>()?; + + if let Some(open) = open_state { + positions_by_receiver = positions_by_receiver + .into_iter() + .filter(|position| position.open == open) + .collect::>(); + } + + Ok(positions_by_receiver) +} + +/// Gets the earliest entry of an address in the address lp weight history. +/// If the address has no open positions, it returns an error. +pub fn get_earliest_address_lp_weight( + storage: &dyn Storage, + address: &Addr, + lp_denom: &str, +) -> Result<(EpochId, Uint128), ContractError> { + let earliest_weight_history_result = LP_WEIGHT_HISTORY + .prefix((address, lp_denom)) + .range(storage, None, None, Order::Ascending) + .next() + .transpose(); + + match earliest_weight_history_result { + Ok(Some(item)) => Ok(item), + Ok(None) => Err(ContractError::NoOpenPositions), + Err(std_err) => Err(std_err.into()), + } +} + +/// Gets the latest entry of an address in the address lp weight history. +/// If the address has no open positions, returns 0 for the weight. +pub fn get_latest_address_lp_weight( + storage: &dyn Storage, + address: &Addr, + lp_denom: &str, + epoch_id: &EpochId, +) -> Result<(EpochId, Uint128), ContractError> { + let latest_weight_history_result = LP_WEIGHT_HISTORY + .prefix((address, lp_denom)) + .range(storage, None, None, Order::Descending) + .next() + .transpose(); + + match latest_weight_history_result { + Ok(Some(item)) => Ok(item), + Ok(None) => Ok((epoch_id.to_owned(), Uint128::zero())), + Err(std_err) => Err(std_err.into()), + } +} diff --git a/contracts/liquidity_hub/incentive-manager/tests/common/mod.rs b/contracts/liquidity_hub/incentive-manager/tests/common/mod.rs new file mode 100644 index 000000000..9987f33e4 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/tests/common/mod.rs @@ -0,0 +1,4 @@ +pub mod suite; +mod suite_contracts; + +pub(crate) const MOCK_CONTRACT_ADDR: &str = "migaloo1wrv0vap0sdpxt3xrdy0yg9ppsx3ppxrfhm6m3s"; diff --git a/contracts/liquidity_hub/incentive-manager/tests/common/suite.rs b/contracts/liquidity_hub/incentive-manager/tests/common/suite.rs new file mode 100644 index 000000000..9b0bc2b40 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/tests/common/suite.rs @@ -0,0 +1,598 @@ +use cosmwasm_std::testing::MockStorage; +use cosmwasm_std::{coin, Addr, Coin, Decimal, Empty, StdResult, Timestamp, Uint128, Uint64}; +use cw_multi_test::addons::{MockAddressGenerator, MockApiBech32}; +use cw_multi_test::{ + App, AppBuilder, AppResponse, BankKeeper, DistributionKeeper, Executor, FailingModule, + GovFailingModule, IbcFailingModule, StakeKeeper, WasmKeeper, +}; + +use white_whale_std::epoch_manager::epoch_manager::{Epoch, EpochConfig, EpochResponse}; +use white_whale_std::epoch_manager::hooks::EpochChangedHookMsg; +use white_whale_std::incentive_manager::{ + Config, IncentiveAction, IncentivesBy, IncentivesResponse, InstantiateMsg, LpWeightResponse, + PositionAction, PositionsResponse, RewardsResponse, +}; +use white_whale_std::pool_network::asset::AssetInfo; +use white_whale_testing::multi_test::stargate_mock::StargateMock; + +use crate::common::suite_contracts::{ + epoch_manager_contract, incentive_manager_contract, whale_lair_contract, +}; + +type OsmosisTokenFactoryApp = App< + BankKeeper, + MockApiBech32, + MockStorage, + FailingModule, + WasmKeeper, + StakeKeeper, + DistributionKeeper, + IbcFailingModule, + GovFailingModule, + StargateMock, +>; + +pub struct TestingSuite { + app: OsmosisTokenFactoryApp, + pub senders: [Addr; 3], + pub incentive_manager_addr: Addr, + pub whale_lair_addr: Addr, + pub epoch_manager_addr: Addr, + pub pools: Vec, +} + +/// TestingSuite helpers +impl TestingSuite { + pub(crate) fn creator(&mut self) -> Addr { + self.senders.first().unwrap().clone() + } + + pub(crate) fn set_time(&mut self, timestamp: Timestamp) -> &mut Self { + let mut block_info = self.app.block_info(); + block_info.time = timestamp; + self.app.set_block(block_info); + + self + } + pub(crate) fn add_one_day(&mut self) -> &mut Self { + let mut block_info = self.app.block_info(); + block_info.time = block_info.time.plus_days(1); + self.app.set_block(block_info); + + self + } + + pub(crate) fn add_one_epoch(&mut self) -> &mut Self { + let creator = self.creator(); + + self.add_one_day().create_epoch(creator, |res| { + res.unwrap(); + }); + + self + } +} + +/// Instantiate +impl TestingSuite { + pub(crate) fn default_with_balances(initial_balance: Vec) -> Self { + let sender_1 = Addr::unchecked("migaloo1h3s5np57a8cxaca3rdjlgu8jzmr2d2zz55s5y3"); + let sender_2 = Addr::unchecked("migaloo193lk767456jhkzddnz7kf5jvuzfn67gyfvhc40"); + let sender_3 = Addr::unchecked("migaloo1ludaslnu24p5eftw499f7ngsc2jkzqdsrvxt75"); + + let bank = BankKeeper::new(); + + let balances = vec![ + (sender_1.clone(), initial_balance.clone()), + (sender_2.clone(), initial_balance.clone()), + (sender_3.clone(), initial_balance.clone()), + ]; + + let app = AppBuilder::new() + .with_api(MockApiBech32::new("migaloo")) + .with_wasm(WasmKeeper::default().with_address_generator(MockAddressGenerator)) + .with_bank(bank) + .with_stargate(StargateMock {}) + .build(|router, _api, storage| { + balances.into_iter().for_each(|(account, amount)| { + router.bank.init_balance(storage, &account, amount).unwrap() + }); + }); + + Self { + app, + senders: [sender_1, sender_2, sender_3], + incentive_manager_addr: Addr::unchecked(""), + whale_lair_addr: Addr::unchecked(""), + epoch_manager_addr: Addr::unchecked(""), + pools: vec![], + } + } + + #[track_caller] + pub(crate) fn instantiate_default(&mut self) -> &mut Self { + self.create_whale_lair(); + self.create_epoch_manager(); + + // April 4th 2024 15:00:00 UTC + let timestamp = Timestamp::from_seconds(1712242800u64); + self.set_time(timestamp); + + // instantiates the incentive manager contract + self.instantiate( + self.whale_lair_addr.to_string(), + self.epoch_manager_addr.to_string(), + Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(1_000u128), + }, + 2, + 14, + 86_400, + 31_536_000, + Decimal::percent(10), //10% penalty + ) + } + + fn create_whale_lair(&mut self) { + let whale_lair_id = self.app.store_code(whale_lair_contract()); + + // create whale lair + let msg = white_whale_std::whale_lair::InstantiateMsg { + unbonding_period: Uint64::new(86400u64), + growth_rate: Decimal::one(), + bonding_assets: vec![ + AssetInfo::NativeToken { + denom: "bWHALE".to_string(), + }, + AssetInfo::NativeToken { + denom: "ampWHALE".to_string(), + }, + ], + }; + + let creator = self.creator().clone(); + + self.whale_lair_addr = self + .app + .instantiate_contract( + whale_lair_id, + creator.clone(), + &msg, + &[], + "White Whale Lair".to_string(), + Some(creator.to_string()), + ) + .unwrap(); + } + + fn create_epoch_manager(&mut self) { + let epoch_manager_contract = self.app.store_code(epoch_manager_contract()); + + // create epoch manager + let msg = white_whale_std::epoch_manager::epoch_manager::InstantiateMsg { + start_epoch: Epoch { + id: 10, + start_time: Timestamp::from_nanos(1712242800_000000000u64), + }, + epoch_config: EpochConfig { + duration: Uint64::new(86400_000000000u64), + genesis_epoch: Uint64::new(1712242800_000000000u64), // April 4th 2024 15:00:00 UTC + }, + }; + + let creator = self.creator().clone(); + + self.epoch_manager_addr = self + .app + .instantiate_contract( + epoch_manager_contract, + creator.clone(), + &msg, + &[], + "Epoch Manager".to_string(), + Some(creator.to_string()), + ) + .unwrap(); + } + + #[track_caller] + pub(crate) fn instantiate( + &mut self, + whale_lair_addr: String, + epoch_manager_addr: String, + create_incentive_fee: Coin, + max_concurrent_incentives: u32, + max_incentive_epoch_buffer: u32, + min_unlocking_duration: u64, + max_unlocking_duration: u64, + emergency_unlock_penalty: Decimal, + ) -> &mut Self { + let msg = InstantiateMsg { + owner: self.creator().to_string(), + epoch_manager_addr, + whale_lair_addr, + create_incentive_fee, + max_concurrent_incentives, + max_incentive_epoch_buffer, + min_unlocking_duration, + max_unlocking_duration, + emergency_unlock_penalty, + }; + + let incentive_manager_id = self.app.store_code(incentive_manager_contract()); + + let creator = self.creator().clone(); + + self.incentive_manager_addr = self + .app + .instantiate_contract( + incentive_manager_id, + creator.clone(), + &msg, + &[], + "WW Incentive Manager", + Some(creator.into_string()), + ) + .unwrap(); + self + } + + #[track_caller] + pub(crate) fn instantiate_err( + &mut self, + whale_lair_addr: String, + epoch_manager_addr: String, + create_incentive_fee: Coin, + max_concurrent_incentives: u32, + max_incentive_epoch_buffer: u32, + min_unlocking_duration: u64, + max_unlocking_duration: u64, + emergency_unlock_penalty: Decimal, + result: impl Fn(anyhow::Result), + ) -> &mut Self { + let msg = InstantiateMsg { + owner: self.creator().to_string(), + epoch_manager_addr, + whale_lair_addr, + create_incentive_fee, + max_concurrent_incentives, + max_incentive_epoch_buffer, + min_unlocking_duration, + max_unlocking_duration, + emergency_unlock_penalty, + }; + + let incentive_manager_id = self.app.store_code(incentive_manager_contract()); + + let creator = self.creator().clone(); + + result(self.app.instantiate_contract( + incentive_manager_id, + creator.clone(), + &msg, + &[], + "WW Incentive Manager", + Some(creator.into_string()), + )); + + self + } +} + +/// execute messages +impl TestingSuite { + #[track_caller] + pub(crate) fn update_ownership( + &mut self, + sender: Addr, + action: cw_ownable::Action, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::incentive_manager::ExecuteMsg::UpdateOwnership(action); + + result( + self.app + .execute_contract(sender, self.incentive_manager_addr.clone(), &msg, &[]), + ); + + self + } + + #[track_caller] + pub(crate) fn update_config( + &mut self, + sender: Addr, + whale_lair_addr: Option, + epoch_manager_addr: Option, + create_incentive_fee: Option, + max_concurrent_incentives: Option, + max_incentive_epoch_buffer: Option, + min_unlocking_duration: Option, + max_unlocking_duration: Option, + emergency_unlock_penalty: Option, + funds: Vec, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::incentive_manager::ExecuteMsg::UpdateConfig { + whale_lair_addr, + epoch_manager_addr, + create_incentive_fee, + max_concurrent_incentives, + max_incentive_epoch_buffer, + min_unlocking_duration, + max_unlocking_duration, + emergency_unlock_penalty, + }; + + result(self.app.execute_contract( + sender, + self.incentive_manager_addr.clone(), + &msg, + &funds, + )); + + self + } + + #[track_caller] + pub(crate) fn manage_incentive( + &mut self, + sender: Addr, + action: IncentiveAction, + funds: Vec, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::incentive_manager::ExecuteMsg::ManageIncentive { action }; + + result(self.app.execute_contract( + sender, + self.incentive_manager_addr.clone(), + &msg, + &funds, + )); + + self + } + + #[track_caller] + pub(crate) fn manage_position( + &mut self, + sender: Addr, + action: PositionAction, + funds: Vec, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::incentive_manager::ExecuteMsg::ManagePosition { action }; + + result(self.app.execute_contract( + sender, + self.incentive_manager_addr.clone(), + &msg, + &funds, + )); + + self + } + + #[track_caller] + pub(crate) fn claim( + &mut self, + sender: Addr, + funds: Vec, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::incentive_manager::ExecuteMsg::Claim; + + result(self.app.execute_contract( + sender, + self.incentive_manager_addr.clone(), + &msg, + &funds, + )); + + self + } + + #[track_caller] + pub(crate) fn on_epoch_changed( + &mut self, + sender: Addr, + funds: Vec, + result: impl Fn(Result), + ) -> &mut Self { + let msg = + white_whale_std::incentive_manager::ExecuteMsg::EpochChangedHook(EpochChangedHookMsg { + current_epoch: Epoch { + id: 0, + start_time: Default::default(), + }, + }); + + result(self.app.execute_contract( + sender, + self.incentive_manager_addr.clone(), + &msg, + &funds, + )); + + self + } +} + +/// queries +impl TestingSuite { + pub(crate) fn query_ownership( + &mut self, + result: impl Fn(StdResult>), + ) -> &mut Self { + let ownership_response: StdResult> = + self.app.wrap().query_wasm_smart( + &self.incentive_manager_addr, + &white_whale_std::incentive_manager::QueryMsg::Ownership {}, + ); + + result(ownership_response); + + self + } + + #[track_caller] + pub(crate) fn query_config(&mut self, result: impl Fn(StdResult)) -> &mut Self { + let response: StdResult = self.app.wrap().query_wasm_smart( + &self.incentive_manager_addr, + &white_whale_std::incentive_manager::QueryMsg::Config {}, + ); + + result(response); + + self + } + + #[track_caller] + pub(crate) fn query_incentives( + &mut self, + filter_by: Option, + start_after: Option, + limit: Option, + result: impl Fn(StdResult), + ) -> &mut Self { + let incentives_response: StdResult = self.app.wrap().query_wasm_smart( + &self.incentive_manager_addr, + &white_whale_std::incentive_manager::QueryMsg::Incentives { + filter_by, + start_after, + limit, + }, + ); + + result(incentives_response); + + self + } + + #[track_caller] + pub(crate) fn query_positions( + &mut self, + address: Addr, + open_state: Option, + result: impl Fn(StdResult), + ) -> &mut Self { + let positions_response: StdResult = self.app.wrap().query_wasm_smart( + &self.incentive_manager_addr, + &white_whale_std::incentive_manager::QueryMsg::Positions { + address: address.to_string(), + open_state, + }, + ); + + result(positions_response); + + self + } + #[track_caller] + pub(crate) fn query_rewards( + &mut self, + address: Addr, + result: impl Fn(StdResult), + ) -> &mut Self { + let rewards_response: StdResult = self.app.wrap().query_wasm_smart( + &self.incentive_manager_addr, + &white_whale_std::incentive_manager::QueryMsg::Rewards { + address: address.to_string(), + }, + ); + + result(rewards_response); + + self + } + + #[track_caller] + pub(crate) fn query_lp_weight( + &mut self, + denom: &str, + epoch_id: u64, + result: impl Fn(StdResult), + ) -> &mut Self { + let rewards_response: StdResult = self.app.wrap().query_wasm_smart( + &self.incentive_manager_addr, + &white_whale_std::incentive_manager::QueryMsg::LPWeight { + address: self.incentive_manager_addr.to_string(), + denom: denom.to_string(), + epoch_id, + }, + ); + + result(rewards_response); + + self + } + + #[track_caller] + pub(crate) fn query_balance( + &mut self, + denom: String, + address: Addr, + result: impl Fn(Uint128), + ) -> &mut Self { + let balance_response = self.app.wrap().query_balance(address, denom.clone()); + result(balance_response.unwrap_or(coin(0, denom)).amount); + + self + } +} + +/// Epoch manager actions +impl TestingSuite { + #[track_caller] + pub(crate) fn create_epoch( + &mut self, + sender: Addr, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::epoch_manager::epoch_manager::ExecuteMsg::CreateEpoch {}; + + result( + self.app + .execute_contract(sender, self.epoch_manager_addr.clone(), &msg, &vec![]), + ); + + self + } + + #[track_caller] + pub(crate) fn add_hook( + &mut self, + sender: Addr, + contract_addr: Addr, + funds: Vec, + result: impl Fn(Result), + ) -> &mut Self { + let msg = white_whale_std::epoch_manager::epoch_manager::ExecuteMsg::AddHook { + contract_addr: contract_addr.to_string(), + }; + + result( + self.app + .execute_contract(sender, self.epoch_manager_addr.clone(), &msg, &funds), + ); + + self + } + + #[track_caller] + pub(crate) fn query_current_epoch( + &mut self, + result: impl Fn(StdResult), + ) -> &mut Self { + let current_epoch_response: StdResult = self.app.wrap().query_wasm_smart( + &self.epoch_manager_addr, + &white_whale_std::epoch_manager::epoch_manager::QueryMsg::CurrentEpoch {}, + ); + + result(current_epoch_response); + + self + } +} diff --git a/contracts/liquidity_hub/incentive-manager/tests/common/suite_contracts.rs b/contracts/liquidity_hub/incentive-manager/tests/common/suite_contracts.rs new file mode 100644 index 000000000..cd4191494 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/tests/common/suite_contracts.rs @@ -0,0 +1,38 @@ +use cosmwasm_std::Empty; +use cw_multi_test::{Contract, ContractWrapper}; + +/// Creates the incentive manager contract +pub fn incentive_manager_contract() -> Box> { + let contract = ContractWrapper::new( + incentive_manager::contract::execute, + incentive_manager::contract::instantiate, + incentive_manager::contract::query, + ) + .with_migrate(incentive_manager::contract::migrate); + + Box::new(contract) +} + +/// Creates the whale lair contract +pub fn whale_lair_contract() -> Box> { + let contract = ContractWrapper::new( + whale_lair::contract::execute, + whale_lair::contract::instantiate, + whale_lair::contract::query, + ) + .with_migrate(whale_lair::contract::migrate); + + Box::new(contract) +} + +/// Creates the epoch manager contract +pub fn epoch_manager_contract() -> Box> { + let contract = ContractWrapper::new( + epoch_manager::contract::execute, + epoch_manager::contract::instantiate, + epoch_manager::contract::query, + ) + .with_migrate(epoch_manager::contract::migrate); + + Box::new(contract) +} diff --git a/contracts/liquidity_hub/incentive-manager/tests/integration.rs b/contracts/liquidity_hub/incentive-manager/tests/integration.rs new file mode 100644 index 000000000..65980c556 --- /dev/null +++ b/contracts/liquidity_hub/incentive-manager/tests/integration.rs @@ -0,0 +1,3239 @@ +extern crate core; + +use std::cell::RefCell; + +use cosmwasm_std::{coin, Addr, Coin, Decimal, Uint128}; + +use incentive_manager::ContractError; +use white_whale_std::incentive_manager::{ + Config, Curve, EpochId, Incentive, IncentiveAction, IncentiveParams, IncentivesBy, + LpWeightResponse, Position, PositionAction, RewardsResponse, +}; + +use crate::common::suite::TestingSuite; +use crate::common::MOCK_CONTRACT_ADDR; + +mod common; + +#[test] +fn instantiate_incentive_manager() { + let mut suite = + TestingSuite::default_with_balances(vec![coin(1_000_000_000u128, "uwhale".to_string())]); + + suite.instantiate_err( + MOCK_CONTRACT_ADDR.to_string(), + MOCK_CONTRACT_ADDR.to_string(), + Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(1_000u128), + }, + 0, + 14, + 86_400, + 31_536_000, + Decimal::percent(10), + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::UnspecifiedConcurrentIncentives { .. } => {} + _ => panic!("Wrong error type, should return ContractError::UnspecifiedConcurrentIncentives"), + } + }, + ).instantiate_err( + MOCK_CONTRACT_ADDR.to_string(), + MOCK_CONTRACT_ADDR.to_string(), + Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(1_000u128), + }, + 1, + 14, + 86_400, + 86_399, + Decimal::percent(10), + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::InvalidUnlockingRange { .. } => {} + _ => panic!("Wrong error type, should return ContractError::InvalidUnbondingRange"), + } + }, + ).instantiate_err( + MOCK_CONTRACT_ADDR.to_string(), + MOCK_CONTRACT_ADDR.to_string(), + Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(1_000u128), + }, + 1, + 14, + 86_400, + 86_500, + Decimal::percent(101), + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::InvalidEmergencyUnlockPenalty { .. } => {} + _ => panic!("Wrong error type, should return ContractError::InvalidEmergencyUnlockPenalty"), + } + }, + ).instantiate( + MOCK_CONTRACT_ADDR.to_string(), + MOCK_CONTRACT_ADDR.to_string(), + Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(1_000u128), + }, + 7, + 14, + 86_400, + 31_536_000, + Decimal::percent(10), //10% penalty + ); +} + +#[test] +fn create_incentives() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + // try all misconfigurations when creating an incentive + suite + .instantiate_default() + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Default::default(), + }, + incentive_identifier: None, + }, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::InvalidIncentiveAmount { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::InvalidIncentiveAmount" + ), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(2_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(2_000, "ulab")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::IncentiveFeeMissing { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::IncentiveFeeMissing") + } + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(5_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(8_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(2_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(2_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(5_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(5_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(25), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::IncentiveStartTooFar { .. } => {} + _ => panic!("Wrong error type, should return ContractError::IncentiveStartTooFar"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(8), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::IncentiveStartTimeAfterEndTime { .. } => {} + _ => panic!("Wrong error type, should return ContractError::IncentiveStartTimeAfterEndTime"), + } + }, + ).manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(15), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::IncentiveStartTimeAfterEndTime { .. } => {} + _ => panic!("Wrong error type, should return ContractError::IncentiveStartTimeAfterEndTime"), + } + }, + ).manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(3), + preliminary_end_epoch: Some(5), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::IncentiveEndsInPast { .. } => {} + _ => panic!("Wrong error type, should return ContractError::IncentiveEndsInPast"), + } + }, + ) + + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(20), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::IncentiveStartTimeAfterEndTime { .. } => {} + _ => panic!("Wrong error type, should return ContractError::IncentiveStartTimeAfterEndTime"), + } + }, + ).manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(30), + preliminary_end_epoch: Some(35), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::IncentiveStartTooFar { .. } => {} + _ => panic!("Wrong error type, should return ContractError::IncentiveStartTooFar"), + } + }, + ); + + // create an incentive properly + suite + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(10_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(10_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + // should fail, max incentives per lp_denom was set to 2 in the instantiate_default + // function + match err { + ContractError::TooManyIncentives { .. } => {} + _ => panic!("Wrong error type, should return ContractError::TooManyIncentives"), + } + }, + ) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 2); + }) + .query_incentives( + Some(IncentivesBy::Identifier("incentive_1".to_string())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 1); + assert_eq!( + incentives_response.incentives[0].incentive_asset, + Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000), + } + ); + }, + ) + .query_incentives( + Some(IncentivesBy::Identifier("2".to_string())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 1); + assert_eq!( + incentives_response.incentives[0].incentive_asset, + Coin { + denom: "ulab".to_string(), + amount: Uint128::new(10_000), + } + ); + }, + ) + .query_incentives( + Some(IncentivesBy::IncentiveAsset("ulab".to_string())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 2); + }, + ) + .query_incentives( + Some(IncentivesBy::LPDenom(lp_denom.clone())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 2); + }, + ); +} + +#[test] +fn expand_incentives() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + suite + .instantiate_default() + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(8_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(4_000, "ulab")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(8_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(8_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_100u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(4_100, "ulab")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::InvalidExpansionAmount { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::InvalidExpansionAmount" + ), + } + }, + ) + .query_incentives( + Some(IncentivesBy::Identifier("incentive_1".to_string())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + let incentive = incentives_response.incentives[0].clone(); + assert_eq!( + incentive.incentive_asset, + Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000), + } + ); + + assert_eq!(incentive.preliminary_end_epoch, 28); + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(5_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(5_000u128, "ulab")], + |result| { + result.unwrap(); + }, + ) + .query_incentives( + Some(IncentivesBy::Identifier("incentive_1".to_string())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + let incentive = incentives_response.incentives[0].clone(); + assert_eq!( + incentive.incentive_asset, + Coin { + denom: "ulab".to_string(), + amount: Uint128::new(9_000), + } + ); + + assert_eq!(incentive.preliminary_end_epoch, 38); + }, + ); +} + +#[test] +fn close_incentives() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + let another = suite.senders[2].clone(); + + suite + .instantiate_default() + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Close { + incentive_identifier: "incentive_1".to_string(), + }, + vec![coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PaymentError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PaymentError"), + } + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Close { + incentive_identifier: "incentive_2".to_string(), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::NonExistentIncentive { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::NonExistentIncentive" + ), + } + }, + ) + .manage_incentive( + another.clone(), + IncentiveAction::Close { + incentive_identifier: "incentive_1".to_string(), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }, + ) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(99_999_6000)); + }) + .manage_incentive( + other.clone(), + IncentiveAction::Close { + incentive_identifier: "incentive_1".to_string(), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(100_000_0000)); + }); + + suite + .instantiate_default() + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(28), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(99_999_6000)); + }) + // the owner of the contract can also close incentives + .manage_incentive( + creator.clone(), + IncentiveAction::Close { + incentive_identifier: "incentive_1".to_string(), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(100_000_0000)); + }); +} + +#[test] +fn verify_ownership() { + let mut suite = TestingSuite::default_with_balances(vec![]); + let creator = suite.creator(); + let other = suite.senders[1].clone(); + let unauthorized = suite.senders[2].clone(); + + suite + .instantiate_default() + .query_ownership(|result| { + let ownership = result.unwrap(); + assert_eq!(Addr::unchecked(ownership.owner.unwrap()), creator); + }) + .update_ownership( + unauthorized, + cw_ownable::Action::TransferOwnership { + new_owner: other.to_string(), + expiry: None, + }, + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + + match err { + ContractError::OwnershipError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::OwnershipError"), + } + }, + ) + .update_ownership( + creator, + cw_ownable::Action::TransferOwnership { + new_owner: other.to_string(), + expiry: None, + }, + |result| { + result.unwrap(); + }, + ) + .update_ownership( + other.clone(), + cw_ownable::Action::AcceptOwnership, + |result| { + result.unwrap(); + }, + ) + .query_ownership(|result| { + let ownership = result.unwrap(); + assert_eq!(Addr::unchecked(ownership.owner.unwrap()), other); + }) + .update_ownership( + other.clone(), + cw_ownable::Action::RenounceOwnership, + |result| { + result.unwrap(); + }, + ) + .query_ownership(|result| { + let ownership = result.unwrap(); + assert!(ownership.owner.is_none()); + }); +} + +#[test] +fn test_epoch_change_hook() {} + +#[test] +pub fn update_config() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + suite.instantiate_default(); + + let whale_lair = suite.whale_lair_addr.clone(); + let epoch_manager = suite.epoch_manager_addr.clone(); + + let expected_config = Config { + whale_lair_addr: whale_lair, + epoch_manager_addr: epoch_manager, + create_incentive_fee: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(1_000u128), + }, + max_concurrent_incentives: 2u32, + max_incentive_epoch_buffer: 14u32, + min_unlocking_duration: 86_400u64, + max_unlocking_duration: 31_536_000u64, + emergency_unlock_penalty: Decimal::percent(10), + }; + + suite.query_config(|result| { + let config = result.unwrap(); + assert_eq!(config, expected_config); + }) + .update_config( + other.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(3u32), + Some(15u32), + Some(172_800u64), + Some(864_000u64), + Some(Decimal::percent(50)), + vec![coin(1_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PaymentError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PaymentError"), + } + }, + ).update_config( + other.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(0u32), + Some(15u32), + Some(172_800u64), + Some(864_000u64), + Some(Decimal::percent(50)), + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::OwnershipError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::OwnershipError"), + } + }, + ).update_config( + creator.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(0u32), + Some(15u32), + Some(172_800u64), + Some(864_000u64), + Some(Decimal::percent(50)), + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::UnspecifiedConcurrentIncentives { .. } => {} + _ => panic!("Wrong error type, should return ContractError::UnspecifiedConcurrentIncentives"), + } + }, + ).update_config( + creator.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(5u32), + Some(15u32), + Some(80_800u64), + Some(80_000u64), + Some(Decimal::percent(50)), + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::InvalidUnlockingRange { .. } => {} + _ => panic!("Wrong error type, should return ContractError::InvalidUnbondingRange"), + } + }, + ).update_config( + creator.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(5u32), + Some(15u32), + Some(300_000u64), + Some(200_000u64), + Some(Decimal::percent(50)), + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::InvalidUnlockingRange { .. } => {} + _ => panic!("Wrong error type, should return ContractError::InvalidUnbondingRange"), + } + }, + ).update_config( + creator.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(5u32), + Some(15u32), + Some(100_000u64), + Some(200_000u64), + Some(Decimal::percent(105)), + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::InvalidEmergencyUnlockPenalty { .. } => {} + _ => panic!("Wrong error type, should return ContractError::InvalidEmergencyUnlockPenalty"), + } + }, + ).update_config( + creator.clone(), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(MOCK_CONTRACT_ADDR.to_string()), + Some(Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }), + Some(5u32), + Some(15u32), + Some(100_000u64), + Some(200_000u64), + Some(Decimal::percent(20)), + vec![], + |result| { + result.unwrap(); + }, + ); + + let expected_config = Config { + whale_lair_addr: Addr::unchecked(MOCK_CONTRACT_ADDR), + epoch_manager_addr: Addr::unchecked(MOCK_CONTRACT_ADDR), + create_incentive_fee: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(2_000u128), + }, + max_concurrent_incentives: 5u32, + max_incentive_epoch_buffer: 15u32, + min_unlocking_duration: 100_000u64, + max_unlocking_duration: 200_000u64, + emergency_unlock_penalty: Decimal::percent(20), + }; + + suite.query_config(|result| { + let config = result.unwrap(); + assert_eq!(config, expected_config); + }); +} + +#[test] +pub fn test_manage_position() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + coin(1_000_000_000u128, "invalid_lp".clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + let another = suite.senders[2].clone(); + + suite.instantiate_default(); + + let incentive_manager = suite.incentive_manager_addr.clone(); + let whale_lair = suite.whale_lair_addr.clone(); + + suite + .add_hook(creator.clone(), incentive_manager, vec![], |result| { + result.unwrap(); + }) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(16), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(8_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(8_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .query_lp_weight(&lp_denom, 10, |result| { + let err = result.unwrap_err().to_string(); + + assert_eq!( + err, + "Generic error: Querier contract error: There's no snapshot of the LP \ + weight in the contract for the epoch 10" + ); + }) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 80_400, + receiver: None, + }, + vec![coin(1_000, lp_denom.clone())], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::InvalidUnlockingDuration { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::InvalidUnlockingDuration" + ), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 32_536_000, + receiver: None, + }, + vec![coin(1_000, lp_denom.clone())], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::InvalidUnlockingDuration { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::InvalidUnlockingDuration" + ), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 32_536_000, + receiver: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PaymentError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PaymentError"), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(1_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .query_lp_weight(&lp_denom, 11, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(1_000), + epoch_id: 11, + } + ); + }) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(1_000, "invalid_lp".to_string())], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .query_positions(creator.clone(), Some(true), |result| { + let positions = result.unwrap(); + assert_eq!(positions.positions.len(), 1); + assert_eq!( + positions.positions[0], + Position { + identifier: "creator_position".to_string(), + lp_asset: Coin { + denom: "factory/pool/uLP".to_string(), + amount: Uint128::new(1_000), + }, + unlocking_duration: 86400, + open: true, + expiring_at: None, + receiver: Addr::unchecked("migaloo1h3s5np57a8cxaca3rdjlgu8jzmr2d2zz55s5y3"), + } + ); + }) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(5_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .manage_position( + creator.clone(), + PositionAction::Withdraw { + identifier: "creator_position".to_string(), + emergency_unlock: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + // the position is not closed or hasn't expired yet + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }, + ) + .query_lp_weight(&lp_denom, 11, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(6_000), + epoch_id: 11, + } + ); + }) + .query_positions(creator.clone(), Some(true), |result| { + let positions = result.unwrap(); + assert_eq!(positions.positions.len(), 1); + assert_eq!( + positions.positions[0], + Position { + identifier: "creator_position".to_string(), + lp_asset: Coin { + denom: "factory/pool/uLP".to_string(), + amount: Uint128::new(6_000), + }, + unlocking_duration: 86400, + open: true, + expiring_at: None, + receiver: Addr::unchecked("migaloo1h3s5np57a8cxaca3rdjlgu8jzmr2d2zz55s5y3"), + } + ); + }) + .query_lp_weight(&lp_denom, 11, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(6_000), + epoch_id: 11, + } + ); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 11); + }); + + // make sure snapshots are working correctly + suite + .query_lp_weight(&lp_denom, 15, |result| { + let err = result.unwrap_err().to_string(); + + assert_eq!( + err, + "Generic error: Querier contract error: There's no snapshot of the LP weight in the \ + contract for the epoch 15" + ); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 12); + }) + .query_lp_weight(&lp_denom, 12, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(6_000), //snapshot taken from the previous epoch + epoch_id: 12, + } + ); + }) + .manage_position( + creator.clone(), + PositionAction::Fill { + //refill position + identifier: Some("creator_position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(1_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .query_lp_weight(&lp_denom, 12, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + // should be the same for epoch 12, as the weight for new positions is added + // to the next epoch + lp_weight: Uint128::new(6_000), + epoch_id: 12, + } + ); + }); + + suite.query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 12); + }); + + suite + .manage_position( + creator.clone(), + PositionAction::Close { + identifier: "creator_position".to_string(), + lp_asset: Some(Coin { + denom: lp_denom.clone(), + amount: Uint128::new(4_000), + }), + }, + vec![coin(4_000, lp_denom.clone())], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PaymentError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PaymentError"), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Close { + // remove 4_000 from the 7_000 position + identifier: "creator_position".to_string(), + lp_asset: Some(Coin { + denom: lp_denom.clone(), + amount: Uint128::new(4_000), + }), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PendingRewards { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PendingRewards"), + } + }, + ) + .claim( + creator.clone(), + vec![coin(4_000, lp_denom.clone())], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PaymentError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PaymentError"), + } + }, + ) + .claim(other.clone(), vec![], |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::NoOpenPositions { .. } => {} + _ => panic!("Wrong error type, should return ContractError::NoOpenPositions"), + } + }) + .query_balance("ulab".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_992_000)); + }) + .claim(creator.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_994_000)); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 1); + assert_eq!( + incentives_response.incentives[0].claimed_amount, + Uint128::new(2_000), + ); + }) + .manage_position( + creator.clone(), + PositionAction::Close { + identifier: "non_existent__position".to_string(), + lp_asset: Some(Coin { + denom: lp_denom.clone(), + amount: Uint128::new(4_000), + }), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::NoPositionFound { .. } => {} + _ => panic!("Wrong error type, should return ContractError::NoPositionFound"), + } + }, + ) + .manage_position( + other.clone(), + PositionAction::Close { + identifier: "creator_position".to_string(), + lp_asset: Some(Coin { + denom: lp_denom.clone(), + amount: Uint128::new(4_000), + }), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Close { + identifier: "creator_position".to_string(), + lp_asset: Some(Coin { + denom: "invalid_lp".to_string(), + amount: Uint128::new(4_000), + }), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_position( + creator.clone(), // someone tries to close the creator's position + PositionAction::Close { + identifier: "creator_position".to_string(), + lp_asset: Some(Coin { + denom: lp_denom.to_string(), + amount: Uint128::new(10_000), + }), + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => panic!("Wrong error type, should return ContractError::AssetMismatch"), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Close { + // remove 5_000 from the 7_000 position + identifier: "creator_position".to_string(), + lp_asset: Some(Coin { + denom: lp_denom.clone(), + amount: Uint128::new(5_000), + }), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .manage_position( + creator.clone(), + PositionAction::Withdraw { + identifier: "2".to_string(), + emergency_unlock: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PositionNotExpired { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::PositionNotExpired") + } + } + }, + ) + .query_lp_weight(&lp_denom, 12, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + // should be the same for epoch 12, as the weight for new positions is added + // to the next epoch + lp_weight: Uint128::new(6_000), + epoch_id: 12, + } + ); + }) + .query_lp_weight(&lp_denom, 13, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + // should be the same for epoch 12, as the weight for new positions is added + // to the next epoch + lp_weight: Uint128::new(5_000), + epoch_id: 13, + } + ); + }) + // create a few epochs without any changes in the weight + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + //after a day the closed position should be able to be withdrawn + .manage_position( + other.clone(), + PositionAction::Withdraw { + identifier: "creator_position".to_string(), + emergency_unlock: None, + }, + vec![coin(5_000, lp_denom.clone())], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PaymentError { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PaymentError"), + } + }, + ) + .manage_position( + creator.clone(), + PositionAction::Withdraw { + identifier: "non_existent_position".to_string(), + emergency_unlock: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::NoPositionFound { .. } => {} + _ => panic!("Wrong error type, should return ContractError::NoPositionFound"), + } + }, + ) + .manage_position( + other.clone(), + PositionAction::Withdraw { + identifier: "2".to_string(), + emergency_unlock: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }, + ) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .query_lp_weight(&lp_denom, 14, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + // should be the same for epoch 13, as nobody changed their positions + lp_weight: Uint128::new(5_000), + epoch_id: 14, + } + ); + }) + .query_lp_weight(&lp_denom, 15, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + // should be the same for epoch 13, as nobody changed their positions + lp_weight: Uint128::new(5_000), + epoch_id: 15, + } + ); + }) + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 15); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .query_rewards(creator.clone(), |result| { + let rewards_response = result.unwrap(); + match rewards_response { + RewardsResponse::RewardsResponse { rewards } => { + assert_eq!(rewards.len(), 1); + assert_eq!( + rewards[0], + Coin { + denom: "ulab".to_string(), + amount: Uint128::new(6_000), + } + ); + } + RewardsResponse::ClaimRewards { .. } => { + panic!("shouldn't return this but RewardsResponse") + } + } + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0].claimed_amount, + Uint128::new(2_000) + ); + }) + .manage_position( + creator.clone(), + PositionAction::Withdraw { + identifier: "2".to_string(), + emergency_unlock: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PendingRewards { .. } => {} + _ => panic!("Wrong error type, should return ContractError::PendingRewards"), + } + }, + ) + .claim(creator.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(1000_000_000)); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0].incentive_asset.amount, + incentives_response.incentives[0].claimed_amount + ); + assert!(incentives_response.incentives[0].is_expired(15)); + }) + .query_rewards(creator.clone(), |result| { + let rewards_response = result.unwrap(); + match rewards_response { + RewardsResponse::RewardsResponse { rewards } => { + assert!(rewards.is_empty()); + } + RewardsResponse::ClaimRewards { .. } => { + panic!("shouldn't return this but RewardsResponse") + } + } + }) + .claim(creator.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(1000_000_000)); + }) + .manage_position( + creator.clone(), + PositionAction::Withdraw { + identifier: "2".to_string(), + emergency_unlock: None, + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_positions(other.clone(), Some(false), |result| { + let positions = result.unwrap(); + assert!(positions.positions.is_empty()); + }) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: None, + unlocking_duration: 86_400, + receiver: Some(another.clone().to_string()), + }, + vec![coin(5_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .query_positions(another.clone(), Some(true), |result| { + let positions = result.unwrap(); + assert_eq!(positions.positions.len(), 1); + assert_eq!( + positions.positions[0], + Position { + identifier: "3".to_string(), + lp_asset: Coin { + denom: "factory/pool/uLP".to_string(), + amount: Uint128::new(5_000), + }, + unlocking_duration: 86400, + open: true, + expiring_at: None, + receiver: another.clone(), + } + ); + }) + .manage_position( + creator.clone(), + PositionAction::Close { + identifier: "3".to_string(), + lp_asset: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }, + ) + .manage_position( + another.clone(), + PositionAction::Close { + identifier: "3".to_string(), + lp_asset: None, //close in full + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_positions(another.clone(), Some(true), |result| { + let positions = result.unwrap(); + assert!(positions.positions.is_empty()); + }) + .query_positions(another.clone(), Some(false), |result| { + let positions = result.unwrap(); + assert_eq!(positions.positions.len(), 1); + assert_eq!( + positions.positions[0], + Position { + identifier: "3".to_string(), + lp_asset: Coin { + denom: "factory/pool/uLP".to_string(), + amount: Uint128::new(5_000), + }, + unlocking_duration: 86400, + open: false, + expiring_at: Some(1712847600), + receiver: another.clone(), + } + ); + }); + + suite + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 18); + }); + + // try emergency exit a position that is closed + suite + .manage_position( + another.clone(), + PositionAction::Fill { + identifier: Some("special_position".to_string()), + unlocking_duration: 100_000, + receiver: None, + }, + vec![coin(5_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .query_lp_weight(&lp_denom, 18, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(5_000), + epoch_id: 18, + } + ); + }) + .query_lp_weight(&lp_denom, 19, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(10_002), + epoch_id: 19, + } + ); + }); + + suite.add_one_epoch().query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 19); + }); + + // close the position + suite + .manage_position( + another.clone(), + PositionAction::Close { + identifier: "special_position".to_string(), + lp_asset: None, + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_lp_weight(&lp_denom, 20, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + // the weight went back to what it was before the position was opened + lp_weight: Uint128::new(5_000), + epoch_id: 20, + } + ); + }); + + // emergency exit + suite + .query_balance( + lp_denom.clone().to_string(), + whale_lair.clone(), + |balance| { + assert_eq!(balance, Uint128::zero()); + }, + ) + .manage_position( + another.clone(), + PositionAction::Close { + identifier: "special_position".to_string(), + lp_asset: None, + }, + vec![], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::PositionAlreadyClosed { .. } => {} + _ => panic!( + "Wrong error type, should return ContractError::PositionAlreadyClosed" + ), + } + }, + ) + .manage_position( + another.clone(), + PositionAction::Withdraw { + identifier: "special_position".to_string(), + emergency_unlock: Some(true), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_balance( + lp_denom.clone().to_string(), + whale_lair.clone(), + |balance| { + assert_eq!(balance, Uint128::new(500)); + }, + ); +} + +#[test] +fn claim_expired_incentive_returns_nothing() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + coin(1_000_000_000u128, "invalid_lp".clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + suite.instantiate_default(); + + let incentive_manager = suite.incentive_manager_addr.clone(); + + suite + .add_hook(creator.clone(), incentive_manager, vec![], |result| { + result.unwrap(); + }) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(16), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(8_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(8_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_position( + other.clone(), + PositionAction::Fill { + identifier: Some("creator_position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(5_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .query_lp_weight(&lp_denom, 11, |result| { + let lp_weight = result.unwrap(); + assert_eq!( + lp_weight, + LpWeightResponse { + lp_weight: Uint128::new(5_000), + epoch_id: 11, + } + ); + }) + .query_positions(other.clone(), Some(true), |result| { + let positions = result.unwrap(); + assert_eq!(positions.positions.len(), 1); + assert_eq!( + positions.positions[0], + Position { + identifier: "creator_position".to_string(), + lp_asset: Coin { + denom: "factory/pool/uLP".to_string(), + amount: Uint128::new(5_000), + }, + unlocking_duration: 86400, + open: true, + expiring_at: None, + receiver: Addr::unchecked("migaloo193lk767456jhkzddnz7kf5jvuzfn67gyfvhc40"), + } + ); + }); + + // create a couple of epochs to make the incentive active + + suite + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .add_one_day() + .create_epoch(creator.clone(), |result| { + result.unwrap(); + }) + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 14); + }) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(1_000_000_000u128)); + }) + .claim(other.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(1_000_006_000u128)); + }); + + // create a bunch of epochs to make the incentive expire + for _ in 0..15 { + suite.add_one_day().create_epoch(creator.clone(), |result| { + result.unwrap(); + }); + } + + // there shouldn't be anything to claim as the incentive has expired, even though it still has some funds + suite + .query_rewards(creator.clone(), |result| { + let rewards_response = result.unwrap(); + match rewards_response { + RewardsResponse::RewardsResponse { rewards } => { + assert!(rewards.is_empty()); + } + RewardsResponse::ClaimRewards { .. } => { + panic!("shouldn't return this but RewardsResponse") + } + } + }) + .claim(other.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), other.clone(), |balance| { + // the balance hasn't changed + assert_eq!(balance, Uint128::new(1_000_006_000u128)); + }); +} + +#[test] +fn test_close_expired_incentives() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + coin(1_000_000_000u128, "invalid_lp".clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + suite.instantiate_default(); + + let incentive_manager = suite.incentive_manager_addr.clone(); + + suite + .add_hook(creator.clone(), incentive_manager, vec![], |result| { + result.unwrap(); + }) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(16), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(8_000u128), + }, + incentive_identifier: None, + }, + }, + vec![coin(8_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ); + + // create a bunch of epochs to make the incentive expire + for _ in 0..20 { + suite.add_one_day().create_epoch(creator.clone(), |result| { + result.unwrap(); + }); + } + + let current_id: RefCell = RefCell::new(0u64); + + // try opening another incentive for the same lp denom, the expired incentive should get closed + suite + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + *current_id.borrow_mut() = epoch_response.epoch.id; + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 1); + assert!(incentives_response.incentives[0].is_expired(current_id.borrow().clone())); + }) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(10_000u128), + }, + incentive_identifier: Some("new_incentive".to_string()), + }, + }, + vec![coin(10_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!(incentives_response.incentives.len(), 1); + assert_eq!( + incentives_response.incentives[0], + Incentive { + identifier: "new_incentive".to_string(), + owner: other.clone(), + lp_denom: lp_denom.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(10_000u128), + }, + claimed_amount: Uint128::zero(), + emission_rate: Uint128::new(714), + curve: Curve::Linear, + start_epoch: 30u64, + preliminary_end_epoch: 44u64, + last_epoch_claimed: 29u64, + } + ); + }); +} + +#[test] +fn on_epoch_changed_unauthorized() { + let mut suite = TestingSuite::default_with_balances(vec![]); + let creator = suite.creator(); + + suite + .instantiate_default() + .on_epoch_changed(creator, vec![], |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::Unauthorized { .. } => {} + _ => panic!("Wrong error type, should return ContractError::Unauthorized"), + } + }); +} + +#[test] +fn expand_expired_incentive() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + suite.instantiate_default(); + + suite.manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive".to_string()), + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ); + + // create a bunch of epochs to make the incentive expire + for _ in 0..15 { + suite.add_one_day().create_epoch(creator.clone(), |result| { + result.unwrap(); + }); + } + + suite.manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(8_000u128), + }, + incentive_identifier: Some("incentive".to_string()), + }, + }, + vec![coin(8_000u128, "ulab")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::IncentiveAlreadyExpired { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::IncentiveAlreadyExpired") + } + } + }, + ); +} + +#[test] +fn test_emergency_withdrawal() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let other = suite.senders[1].clone(); + + suite.instantiate_default(); + + let whale_lair_addr = suite.whale_lair_addr.clone(); + + suite + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive".to_string()), + }, + }, + vec![coin(4_000, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_position( + other.clone(), + PositionAction::Fill { + identifier: Some("other_position".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(1_000, lp_denom.clone())], + |result| { + result.unwrap(); + }, + ) + .query_positions(other.clone(), Some(true), |result| { + let positions = result.unwrap(); + assert_eq!(positions.positions.len(), 1); + assert_eq!( + positions.positions[0], + Position { + identifier: "other_position".to_string(), + lp_asset: Coin { + denom: "factory/pool/uLP".to_string(), + amount: Uint128::new(1_000), + }, + unlocking_duration: 86400, + open: true, + expiring_at: None, + receiver: other.clone(), + } + ); + }) + .query_balance(lp_denom.clone().to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_999_000)); + }) + .query_balance( + lp_denom.clone().to_string(), + whale_lair_addr.clone(), + |balance| { + assert_eq!(balance, Uint128::zero()); + }, + ) + .manage_position( + other.clone(), + PositionAction::Withdraw { + identifier: "other_position".to_string(), + emergency_unlock: Some(true), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_balance(lp_denom.clone().to_string(), other.clone(), |balance| { + //emergency unlock penalty is 10% of the position amount, so the user gets 1000 - 100 = 900 + assert_eq!(balance, Uint128::new(999_999_900)); + }) + .query_balance( + lp_denom.clone().to_string(), + whale_lair_addr.clone(), + |balance| { + assert_eq!(balance, Uint128::new(100)); + }, + ); +} + +#[test] +fn test_incentive_helper() { + let lp_denom = "factory/pool/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + + suite.instantiate_default(); + + let incentive_manager_addr = suite.incentive_manager_addr.clone(); + let whale_lair_addr = suite.whale_lair_addr.clone(); + + suite + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(4_000u128), + }, + incentive_identifier: Some("incentive".to_string()), + }, + }, + vec![coin(3_000, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::AssetMismatch { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::AssetMismatch") + } + } + }, + ) + .query_balance("uwhale".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(1_000_000_000)); + }) + .query_balance("uwhale".to_string(), whale_lair_addr.clone(), |balance| { + assert_eq!(balance, Uint128::zero()); + }) + .query_balance( + "uwhale".to_string(), + incentive_manager_addr.clone(), + |balance| { + assert_eq!(balance, Uint128::zero()); + }, + ) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(2_000u128), + }, + incentive_identifier: Some("incentive".to_string()), + }, + }, + vec![coin(2_000, "ulab"), coin(3_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .query_balance("uwhale".to_string(), whale_lair_addr.clone(), |balance| { + assert_eq!(balance, Uint128::new(1_000)); + }) + .query_balance( + "uwhale".to_string(), + incentive_manager_addr.clone(), + |balance| { + assert_eq!(balance, Uint128::zero()); + }, + ) + .query_balance("uwhale".to_string(), creator.clone(), |balance| { + // got the excess of whale back + assert_eq!(balance, Uint128::new(999_999_000)); + }); + + suite.manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom.clone(), + start_epoch: None, + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(2_000u128), + }, + incentive_identifier: Some("underpaid_incentive".to_string()), + }, + }, + vec![coin(2_000, "ulab"), coin(500, "uwhale")], + |result| { + let err = result.unwrap_err().downcast::().unwrap(); + match err { + ContractError::IncentiveFeeNotPaid { .. } => {} + _ => { + panic!("Wrong error type, should return ContractError::IncentiveFeeNotPaid") + } + } + }, + ); +} + +/// Complex test case with 4 incentives for 2 different LPs somewhat overlapping in time +/// Incentive 1 -> runs from epoch 12 to 16 +/// Incentive 2 -> run from epoch 14 to 25 +/// Incentive 3 -> runs from epoch 20 to 23 +/// Incentive 4 -> runs from epoch 23 to 37 +/// +/// There are 3 users, creator, other and another +/// +/// Locking tokens: +/// creator locks 35% of the LP tokens before incentive 1 starts +/// other locks 40% of the LP tokens before after incentive 1 starts and before incentive 2 starts +/// another locks 25% of the LP tokens after incentive 3 starts, before incentive 3 ends +/// +/// Unlocking tokens: +/// creator never unlocks +/// other emergency unlocks mid-way through incentive 2 +/// another partially unlocks mid-way through incentive 4 +/// +/// Verify users got rewards pro rata to their locked tokens +#[test] +fn test_multiple_incentives_and_positions() { + let lp_denom_1 = "factory/pool1/uLP".to_string(); + let lp_denom_2 = "factory/pool2/uLP".to_string(); + + let mut suite = TestingSuite::default_with_balances(vec![ + coin(1_000_000_000u128, "uwhale".to_string()), + coin(1_000_000_000u128, "ulab".to_string()), + coin(1_000_000_000u128, "uosmo".to_string()), + coin(1_000_000_000u128, lp_denom_1.clone()), + coin(1_000_000_000u128, lp_denom_2.clone()), + ]); + + let creator = suite.creator(); + let other = suite.senders[1].clone(); + let another = suite.senders[2].clone(); + + suite.instantiate_default(); + + let incentive_manager_addr = suite.incentive_manager_addr.clone(); + let whale_lair_addr = suite.whale_lair_addr.clone(); + + // create 4 incentives with 2 different LPs + suite + .add_hook(creator.clone(), incentive_manager_addr, vec![], |result| { + result.unwrap(); + }) + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 10); + }) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom_1.clone(), + start_epoch: Some(12), + preliminary_end_epoch: Some(16), + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(80_000u128), + }, + incentive_identifier: Some("incentive_1".to_string()), + }, + }, + vec![coin(80_000u128, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + creator.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom_1.clone(), + start_epoch: Some(14), + preliminary_end_epoch: Some(24), + curve: None, + incentive_asset: Coin { + denom: "uosmo".to_string(), + amount: Uint128::new(10_000u128), + }, + incentive_identifier: Some("incentive_2".to_string()), + }, + }, + vec![coin(10_000u128, "uosmo"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom_2.clone(), + start_epoch: Some(20), + preliminary_end_epoch: Some(23), + curve: None, + incentive_asset: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(30_000u128), + }, + incentive_identifier: Some("incentive_3".to_string()), + }, + }, + vec![coin(31_000u128, "uwhale")], + |result| { + result.unwrap(); + }, + ) + .manage_incentive( + other.clone(), + IncentiveAction::Fill { + params: IncentiveParams { + lp_denom: lp_denom_2.clone(), + start_epoch: Some(23), + preliminary_end_epoch: None, + curve: None, + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(70_000u128), + }, + incentive_identifier: Some("incentive_4".to_string()), + }, + }, + vec![coin(70_000u128, "ulab"), coin(1_000, "uwhale")], + |result| { + result.unwrap(); + }, + ); + + // creator fills a position + suite + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_pos_1".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(35_000, lp_denom_1.clone())], + |result| { + result.unwrap(); + }, + ) + .manage_position( + creator.clone(), + PositionAction::Fill { + identifier: Some("creator_pos_2".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(70_000, lp_denom_2.clone())], + |result| { + result.unwrap(); + }, + ); + + suite + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 13); + }); + + // other fills a position + suite + .manage_position( + other.clone(), + PositionAction::Fill { + identifier: Some("other_pos_1".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(40_000, lp_denom_1.clone())], + |result| { + result.unwrap(); + }, + ) + .manage_position( + other.clone(), + PositionAction::Fill { + identifier: Some("other_pos_2".to_string()), + unlocking_duration: 86_400, + receiver: None, + }, + vec![coin(80_000, lp_denom_2.clone())], + |result| { + result.unwrap(); + }, + ); + + suite + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 15); + }); + + suite + .query_incentives( + Some(IncentivesBy::Identifier("incentive_1".to_string())), + None, + None, + |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0], + Incentive { + identifier: "incentive_1".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(80_000u128), + }, + claimed_amount: Uint128::zero(), + emission_rate: Uint128::new(20_000), + curve: Curve::Linear, + start_epoch: 12u64, + preliminary_end_epoch: 16u64, + last_epoch_claimed: 11u64, + } + ); + }, + ) + .query_balance("ulab".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_920_000)); + }) + .claim(creator.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), creator.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_978_666)); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0], + Incentive { + identifier: "incentive_1".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(80_000u128), + }, + claimed_amount: Uint128::new(58_666), + emission_rate: Uint128::new(20_000), + curve: Curve::Linear, + start_epoch: 12u64, + preliminary_end_epoch: 16u64, + last_epoch_claimed: 15u64, + } + ); + assert_eq!( + incentives_response.incentives[1], + Incentive { + identifier: "incentive_2".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "uosmo".to_string(), + amount: Uint128::new(10_000u128), + }, + claimed_amount: Uint128::new(932), + emission_rate: Uint128::new(1_000), + curve: Curve::Linear, + start_epoch: 14u64, + preliminary_end_epoch: 24u64, + last_epoch_claimed: 15u64, + } + ); + }); + + suite + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 19); + }); + + // other emergency unlocks mid-way incentive 2 + suite + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_930_000)); + }) + .query_balance("uosmo".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(1_000_000_000)); + }) + .claim(other.clone(), vec![], |result| { + result.unwrap(); + }) + .query_balance("ulab".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(999_951_332)); + }) + .query_balance("uosmo".to_string(), other.clone(), |balance| { + assert_eq!(balance, Uint128::new(1_000_003_198)); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0], + Incentive { + identifier: "incentive_1".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(80_000u128), + }, + claimed_amount: Uint128::new(79_998u128), // exhausted + emission_rate: Uint128::new(20_000), + curve: Curve::Linear, + start_epoch: 12u64, + preliminary_end_epoch: 16u64, + last_epoch_claimed: 19u64, + } + ); + assert_eq!( + incentives_response.incentives[1], + Incentive { + identifier: "incentive_2".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "uosmo".to_string(), + amount: Uint128::new(10_000u128), + }, + claimed_amount: Uint128::new(4_130), + emission_rate: Uint128::new(1_000), + curve: Curve::Linear, + start_epoch: 14u64, + preliminary_end_epoch: 24u64, + last_epoch_claimed: 19u64, + } + ); + }) + .manage_position( + other.clone(), + PositionAction::Withdraw { + identifier: "other_pos_1".to_string(), + emergency_unlock: Some(true), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .manage_position( + other.clone(), + PositionAction::Withdraw { + identifier: "other_pos_2".to_string(), + emergency_unlock: Some(true), + }, + vec![], + |result| { + result.unwrap(); + }, + ) + .query_balance( + lp_denom_1.clone().to_string(), + whale_lair_addr.clone(), + |balance| { + // 10% of the lp the user input initially + assert_eq!(balance, Uint128::new(4_000)); + }, + ) + .query_balance( + lp_denom_2.clone().to_string(), + whale_lair_addr.clone(), + |balance| { + // 10% of the lp the user input initially + assert_eq!(balance, Uint128::new(8_000)); + }, + ); + + // at this point, other doesn't have any positions, and creator owns 100% of the weight + + suite.add_one_epoch().query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 20); + }); + + // another fills a position + suite.manage_position( + another.clone(), + PositionAction::Fill { + identifier: Some("another_pos_1".to_string()), + unlocking_duration: 15_778_476, // 6 months, should give him 5x multiplier + receiver: None, + }, + vec![coin(6_000, lp_denom_2.clone())], + |result| { + result.unwrap(); + }, + ); + + // creator that had 100% now has ~70% of the weight, while another has ~30% + suite + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 30); + }); + + suite + .claim(creator.clone(), vec![], |result| { + // creator claims from epoch 16 to 30 + // There's nothing to claim on incentive 1 + // On incentive 2, creator has a portion of the total weight until the epoch where other + // triggered the emergency withdrawal. From that point (epoch 20) it has 100% of the weight + // for lp_denom_1. + // another never locked for lp_denom_1, so creator gets all the rewards for the incentive 2 + // from epoch 20 till it finishes at epoch 23 + result.unwrap(); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0], + Incentive { + identifier: "incentive_1".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(80_000u128), + }, + claimed_amount: Uint128::new(79_998u128), // exhausted + emission_rate: Uint128::new(20_000), + curve: Curve::Linear, + start_epoch: 12u64, + preliminary_end_epoch: 16u64, + last_epoch_claimed: 19u64, + } + ); + assert_eq!( + incentives_response.incentives[1], + Incentive { + identifier: "incentive_2".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "uosmo".to_string(), + amount: Uint128::new(10_000u128), + }, + claimed_amount: Uint128::new(9_994), // exhausted + emission_rate: Uint128::new(1_000), + curve: Curve::Linear, + start_epoch: 14u64, + preliminary_end_epoch: 24u64, + last_epoch_claimed: 30u64, + } + ); + assert_eq!( + incentives_response.incentives[2], + Incentive { + identifier: "incentive_3".to_string(), + owner: other.clone(), + lp_denom: lp_denom_2.clone(), + incentive_asset: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(30_000u128), + }, + claimed_amount: Uint128::new(24_000), + emission_rate: Uint128::new(10_000), + curve: Curve::Linear, + start_epoch: 20u64, + preliminary_end_epoch: 23u64, + last_epoch_claimed: 30u64, + } + ); + assert_eq!( + incentives_response.incentives[3], + Incentive { + identifier: "incentive_4".to_string(), + owner: other.clone(), + lp_denom: lp_denom_2.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(70_000u128), + }, + claimed_amount: Uint128::new(28_000), + emission_rate: Uint128::new(5_000), + curve: Curve::Linear, + start_epoch: 23u64, + preliminary_end_epoch: 37u64, + last_epoch_claimed: 30u64, + } + ); + }) + .claim(another.clone(), vec![], |result| { + result.unwrap(); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[0], + Incentive { + identifier: "incentive_1".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(80_000u128), + }, + claimed_amount: Uint128::new(79_998u128), // exhausted + emission_rate: Uint128::new(20_000), + curve: Curve::Linear, + start_epoch: 12u64, + preliminary_end_epoch: 16u64, + last_epoch_claimed: 19u64, + } + ); + assert_eq!( + incentives_response.incentives[1], + Incentive { + identifier: "incentive_2".to_string(), + owner: creator.clone(), + lp_denom: lp_denom_1.clone(), + incentive_asset: Coin { + denom: "uosmo".to_string(), + amount: Uint128::new(10_000u128), + }, + claimed_amount: Uint128::new(9_994), // exhausted + emission_rate: Uint128::new(1_000), + curve: Curve::Linear, + start_epoch: 14u64, + preliminary_end_epoch: 24u64, + last_epoch_claimed: 30u64, + } + ); + assert_eq!( + incentives_response.incentives[2], + Incentive { + identifier: "incentive_3".to_string(), + owner: other.clone(), + lp_denom: lp_denom_2.clone(), + incentive_asset: Coin { + denom: "uwhale".to_string(), + amount: Uint128::new(30_000u128), + }, + claimed_amount: Uint128::new(30_000), // exhausted + emission_rate: Uint128::new(10_000), + curve: Curve::Linear, + start_epoch: 20u64, + preliminary_end_epoch: 23u64, + last_epoch_claimed: 30u64, + } + ); + assert_eq!( + incentives_response.incentives[3], + Incentive { + identifier: "incentive_4".to_string(), + owner: other.clone(), + lp_denom: lp_denom_2.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(70_000u128), + }, + claimed_amount: Uint128::new(40_000), + emission_rate: Uint128::new(5_000), + curve: Curve::Linear, + start_epoch: 23u64, + preliminary_end_epoch: 37u64, + last_epoch_claimed: 30u64, + } + ); + }); + + // another closes part of his position mid-way through incentive 4. + // since the total weight was 100k and he unlocked 50% of his position, + // the new total weight is 85k, so he gets 15k/85k of the rewards while creator gets the rest + suite.manage_position( + another.clone(), + PositionAction::Close { + identifier: "another_pos_1".to_string(), + lp_asset: Some(coin(3_000, lp_denom_2.clone())), + }, + vec![], + |result| { + result.unwrap(); + }, + ); + + suite + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 35); + }); + + suite + .claim(creator.clone(), vec![], |result| { + result.unwrap(); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[3], + Incentive { + identifier: "incentive_4".to_string(), + owner: other.clone(), + lp_denom: lp_denom_2.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(70_000u128), + }, + claimed_amount: Uint128::new(60_585), + emission_rate: Uint128::new(5_000), + curve: Curve::Linear, + start_epoch: 23u64, + preliminary_end_epoch: 37u64, + last_epoch_claimed: 35u64, + } + ); + }) + .claim(another.clone(), vec![], |result| { + result.unwrap(); + }) + .query_incentives(None, None, None, |result| { + let incentives_response = result.unwrap(); + assert_eq!( + incentives_response.incentives[3], + Incentive { + identifier: "incentive_4".to_string(), + owner: other.clone(), + lp_denom: lp_denom_2.clone(), + incentive_asset: Coin { + denom: "ulab".to_string(), + amount: Uint128::new(70_000u128), + }, + claimed_amount: Uint128::new(64_995), + emission_rate: Uint128::new(5_000), + curve: Curve::Linear, + start_epoch: 23u64, + preliminary_end_epoch: 37u64, + last_epoch_claimed: 35u64, + } + ); + }); + + // now the epochs go by, the incentive expires and the creator withdraws the rest of the incentive + + suite + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .add_one_epoch() + .query_current_epoch(|result| { + let epoch_response = result.unwrap(); + assert_eq!(epoch_response.epoch.id, 40); + }); + + suite.manage_incentive( + creator.clone(), + IncentiveAction::Close { + incentive_identifier: "incentive_4".to_string(), + }, + vec![], + |result| { + result.unwrap(); + }, + ); +} diff --git a/contracts/liquidity_hub/pool-manager/Cargo.toml b/contracts/liquidity_hub/pool-manager/Cargo.toml index ff54e7477..de0bd39c1 100644 --- a/contracts/liquidity_hub/pool-manager/Cargo.toml +++ b/contracts/liquidity_hub/pool-manager/Cargo.toml @@ -2,8 +2,8 @@ name = "pool-manager" version = "0.1.0" authors = [ - "0xFable <0xfable@protonmail.com>", - "kaimen-sano ", + "0xFable <0xfable@protonmail.com>", + "kaimen-sano ", ] edition.workspace = true description = "The Pool Manager is a contract that allows to manage multiple pools in a single contract." @@ -14,9 +14,9 @@ documentation.workspace = true publish.workspace = true exclude = [ - # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. - "contract.wasm", - "hash.txt", + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", ] [lib] diff --git a/contracts/liquidity_hub/pool-manager/src/manager/commands.rs b/contracts/liquidity_hub/pool-manager/src/manager/commands.rs index a00f7abad..46925677f 100644 --- a/contracts/liquidity_hub/pool-manager/src/manager/commands.rs +++ b/contracts/liquidity_hub/pool-manager/src/manager/commands.rs @@ -176,6 +176,7 @@ pub fn create_pair( let lp_symbol = format!("{pair_label}.pool.{identifier}.{LP_SYMBOL}"); let lp_asset = format!("{}/{}/{}", "factory", env.contract.address, lp_symbol); + #[allow(clippy::redundant_clone)] PAIRS.save( deps.storage, &identifier, diff --git a/contracts/liquidity_hub/pool-manager/src/tests/gas/mod.rs b/contracts/liquidity_hub/pool-manager/src/tests/gas/mod.rs index e69de29bb..8b1378917 100644 --- a/contracts/liquidity_hub/pool-manager/src/tests/gas/mod.rs +++ b/contracts/liquidity_hub/pool-manager/src/tests/gas/mod.rs @@ -0,0 +1 @@ + diff --git a/contracts/liquidity_hub/pool-manager/src/tests/temp_mock_api.rs b/contracts/liquidity_hub/pool-manager/src/tests/temp_mock_api.rs index d29c60370..fb5a7b1ea 100644 --- a/contracts/liquidity_hub/pool-manager/src/tests/temp_mock_api.rs +++ b/contracts/liquidity_hub/pool-manager/src/tests/temp_mock_api.rs @@ -1,7 +1,7 @@ use cosmwasm_std::{ Addr, Api, CanonicalAddr, RecoverPubkeyError, StdError, StdResult, VerificationError, }; -// Reworked mock api to work with instantiate2 in mock_querier, can eventually be removed +// Reworked mock api to work with instantiate2 in mock_querier, can eventually be removed #[derive(Copy, Clone, Default)] pub struct MockSimpleApi {} diff --git a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/pairs.rs b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/pairs.rs index af892525b..03ca151ab 100644 --- a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/pairs.rs +++ b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/pairs.rs @@ -8,7 +8,9 @@ use cosmwasm_std::{ use white_whale_std::fee::Fee; use white_whale_std::pool_network; -use white_whale_std::pool_network::asset::{AssetInfo, AssetInfoRaw, PairInfo, PairInfoRaw, PairType}; +use white_whale_std::pool_network::asset::{ + AssetInfo, AssetInfoRaw, PairInfo, PairInfoRaw, PairType, +}; use white_whale_std::pool_network::factory::{ ConfigResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, NativeTokenDecimalsResponse, QueryMsg, }; @@ -24,27 +26,27 @@ use white_whale_std::pool_network::trio::{ use crate::contract::{execute, instantiate, query}; use crate::error::ContractError; -use white_whale_std::pool_manager::InstantiateMsg as SingleSwapInstantiateMsg; use crate::state::{pair_key, PAIRS}; use test_case::test_case; +use white_whale_std::pool_manager::InstantiateMsg as SingleSwapInstantiateMsg; #[cfg(test)] mod pair_creation_tests { use super::*; + use crate::tests::mock_querier::mock_dependencies; use cosmwasm_std::testing::{mock_env, mock_info}; use cosmwasm_std::{coin, coins, Binary, Decimal, DepsMut, Uint128}; use cw20::MinterResponse; use white_whale_std::pool_network::asset::Asset; - use crate::tests::mock_querier::mock_dependencies; // use crate::msg::{AssetInfo, ExecuteMsg, Fee, PairType, PoolFee}; - use white_whale_std::pool_manager::ExecuteMsg; - use white_whale_std::pool_network::pair; - use crate::state::{add_allow_native_token}; + use crate::state::add_allow_native_token; use crate::token::InstantiateMsg as TokenInstantiateMsg; use cosmwasm_std::attr; use cosmwasm_std::SubMsg; use cosmwasm_std::WasmMsg; use test_case::test_case; + use white_whale_std::pool_manager::ExecuteMsg; + use white_whale_std::pool_network::pair; // Constants for testing const MOCK_CONTRACT_ADDR: &str = "contract_addr"; @@ -84,7 +86,6 @@ mod pair_creation_tests { }, ]; - let msg = ExecuteMsg::CreatePair { asset_infos: asset_infos.to_vec(), pool_fees: PoolFee { @@ -215,10 +216,13 @@ mod pair_creation_tests { }; let env = mock_env(); - let info = mock_info("addr0000", &[Coin { - denom: "uusd".to_string(), - amount: Uint128::new(1000000u128), - }]); + let info = mock_info( + "addr0000", + &[Coin { + denom: "uusd".to_string(), + amount: Uint128::new(1000000u128), + }], + ); let res = execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); let seed = format!( "{}{}{}", @@ -351,10 +355,13 @@ mod pair_creation_tests { }; let env = mock_env(); - let info = mock_info("addr0000", &[Coin { - denom: "uusd".to_string(), - amount: Uint128::new(1000000u128), - }]); + let info = mock_info( + "addr0000", + &[Coin { + denom: "uusd".to_string(), + amount: Uint128::new(1000000u128), + }], + ); let res = execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); assert_eq!( res.attributes, @@ -417,11 +424,9 @@ mod pair_creation_tests { amount: Uint128::new(1000000u128), info: AssetInfo::NativeToken { denom: "uusd".to_string(), - } + }, }; - - // Instantiate contract let msg = SingleSwapInstantiateMsg { fee_collector_addr: "fee_collector_addr".to_string(), @@ -480,7 +485,7 @@ mod pair_creation_tests { token_factory_lp: false, pair_identifier: None, }; - + let env = mock_env(); let info = mock_info("addr0000", &[coin(1000000u128, "uusd".to_string())]); let res = execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); @@ -593,10 +598,13 @@ mod pair_creation_tests { }; let env = mock_env(); - let info = mock_info("addr0000", &[Coin { - denom: "uusd".to_string(), - amount: Uint128::new(1000000u128), - }]); + let info = mock_info( + "addr0000", + &[Coin { + denom: "uusd".to_string(), + amount: Uint128::new(1000000u128), + }], + ); if let ContractError::ExistingPair { .. } = expected_error { // Create the pair so when we try again below we get ExistingPair provided the error checking is behaving properly diff --git a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/provide_liquidity.rs b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/provide_liquidity.rs index 854f7fed3..a740287d4 100644 --- a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/provide_liquidity.rs +++ b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/provide_liquidity.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "token_factory")] -use white_whale_std::lp_common::LP_SYMBOL; use cosmwasm_std::testing::{mock_env, mock_info, MOCK_CONTRACT_ADDR}; use cosmwasm_std::{ attr, to_binary, Coin, CosmosMsg, Decimal, Reply, Response, StdError, SubMsg, SubMsgResponse, @@ -9,13 +7,15 @@ use cosmwasm_std::{ use cosmwasm_std::{coin, BankMsg}; use cw20::Cw20ExecuteMsg; use white_whale_std::fee::Fee; +#[cfg(feature = "token_factory")] +use white_whale_std::lp_common::LP_SYMBOL; +use crate::tests::mock_querier::mock_dependencies; #[cfg(feature = "token_factory")] use white_whale_std::pool_network; use white_whale_std::pool_network::asset::{Asset, AssetInfo, PairType, MINIMUM_LIQUIDITY_AMOUNT}; #[cfg(feature = "token_factory")] use white_whale_std::pool_network::denom::MsgMint; -use crate::tests::mock_querier::mock_dependencies; use white_whale_std::pool_network::pair::PoolFee; // use white_whale_std::pool_network::pair::{ExecuteMsg, InstantiateMsg, PoolFee}; use crate::contract::{execute, instantiate}; @@ -695,7 +695,8 @@ fn provide_liquidity_zero_amount() { Ok(_) => panic!("should return ContractError::InvalidZeroAmount"), Err(ContractError::InvalidZeroAmount {}) => {} _ => { - panic!("should return ContractError::InvalidZeroAmount")}, + panic!("should return ContractError::InvalidZeroAmount") + } } } diff --git a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/swap.rs b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/swap.rs index c08e70fb5..1d2a50608 100644 --- a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/swap.rs +++ b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/swap.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "token_factory")] -use white_whale_std::lp_common::LP_SYMBOL; use cosmwasm_std::testing::{mock_env, mock_info, MOCK_CONTRACT_ADDR}; use cosmwasm_std::{ attr, to_binary, Coin, CosmosMsg, Decimal, Reply, ReplyOn, Response, StdError, SubMsg, @@ -9,13 +7,15 @@ use cosmwasm_std::{ use cosmwasm_std::{coin, BankMsg}; use cw20::Cw20ExecuteMsg; use white_whale_std::fee::Fee; +#[cfg(feature = "token_factory")] +use white_whale_std::lp_common::LP_SYMBOL; +use crate::tests::mock_querier::mock_dependencies; #[cfg(feature = "token_factory")] use white_whale_std::pool_network; use white_whale_std::pool_network::asset::{Asset, AssetInfo, PairType, MINIMUM_LIQUIDITY_AMOUNT}; #[cfg(feature = "token_factory")] use white_whale_std::pool_network::denom::MsgMint; -use crate::tests::mock_querier::mock_dependencies; use white_whale_std::pool_network::pair::PoolFee; // use white_whale_std::pool_network::pair::{ExecuteMsg, InstantiateMsg, PoolFee}; use crate::contract::{execute, instantiate}; diff --git a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/withdrawals.rs b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/withdrawals.rs index dbaf7d933..681b9e68d 100644 --- a/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/withdrawals.rs +++ b/contracts/liquidity_hub/pool-manager/src/tests/unit_tests/withdrawals.rs @@ -5,12 +5,12 @@ use cosmwasm_std::{ }; use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg}; -#[cfg(feature = "token_factory")] -use white_whale_std::lp_common::LP_SYMBOL; #[cfg(feature = "token_factory")] use cosmwasm_std::coin; use white_whale_std::fee::Fee; #[cfg(feature = "token_factory")] +use white_whale_std::lp_common::LP_SYMBOL; +#[cfg(feature = "token_factory")] use white_whale_std::pool_network; use white_whale_std::pool_network::asset::{AssetInfo, PairType}; #[cfg(feature = "token_factory")] diff --git a/contracts/liquidity_hub/pool-network/terraswap_pair/src/migrations.rs b/contracts/liquidity_hub/pool-network/terraswap_pair/src/migrations.rs index 4c004bb62..276ee5ffa 100644 --- a/contracts/liquidity_hub/pool-network/terraswap_pair/src/migrations.rs +++ b/contracts/liquidity_hub/pool-network/terraswap_pair/src/migrations.rs @@ -282,7 +282,6 @@ pub fn migrate_to_v13x(deps: DepsMut) -> Result<(), StdError> { Ok(()) } - /// This migration adds the `cosmwasm_pool_interface` to the config, so we can see if the swap is coming from /// the osmosis pool manager or not in order to pay the osmosis taker fee. #[cfg(feature = "osmosis")] diff --git a/contracts/liquidity_hub/vault-manager/README.md b/contracts/liquidity_hub/vault-manager/README.md index 054ea4814..6460cda1f 100644 --- a/contracts/liquidity_hub/vault-manager/README.md +++ b/contracts/liquidity_hub/vault-manager/README.md @@ -1,99 +1,4 @@ -# CosmWasm Starter Pack +# Vault Manager -This is a template to build smart contracts in Rust to run inside a -[Cosmos SDK](https://github.com/cosmos/cosmos-sdk) module on all chains that enable it. -To understand the framework better, please read the overview in the -[cosmwasm repo](https://github.com/CosmWasm/cosmwasm/blob/master/README.md), -and dig into the [cosmwasm docs](https://www.cosmwasm.com). -This assumes you understand the theory and just want to get coding. - -## Creating a new repo from template - -Assuming you have a recent version of Rust and Cargo installed -(via [rustup](https://rustup.rs/)), -then the following should get you a new repo to start a contract: - -Install [cargo-generate](https://github.com/ashleygwilliams/cargo-generate) and cargo-run-script. -Unless you did that before, run this line now: - -```sh -cargo install cargo-generate --features vendored-openssl -cargo install cargo-run-script -``` - -Now, use it to create your new contract. -Go to the folder in which you want to place it and run: - -**Latest** - -```sh -cargo generate --git https://github.com/CosmWasm/cw-template.git --name PROJECT_NAME -``` - -For cloning minimal code repo: - -```sh -cargo generate --git https://github.com/CosmWasm/cw-template.git --name PROJECT_NAME -d minimal=true -``` - -**Older Version** - -Pass version as branch flag: - -```sh -cargo generate --git https://github.com/CosmWasm/cw-template.git --branch --name PROJECT_NAME -``` - -Example: - -```sh -cargo generate --git https://github.com/CosmWasm/cw-template.git --branch 0.16 --name PROJECT_NAME -``` - -You will now have a new folder called `PROJECT_NAME` (I hope you changed that to something else) -containing a simple working contract and build system that you can customize. - -## Create a Repo - -After generating, you have a initialized local git repo, but no commits, and no remote. -Go to a server (eg. github) and create a new upstream repo (called `YOUR-GIT-URL` below). -Then run the following: - -```sh -# this is needed to create a valid Cargo.lock file (see below) -cargo check -git branch -M main -git add . -git commit -m 'Initial Commit' -git remote add origin YOUR-GIT-URL -git push -u origin main -``` - -## CI Support - -We have template configurations for both [GitHub Actions](.github/workflows/Basic.yml) -and [Circle CI](.circleci/config.yml) in the generated project, so you can -get up and running with CI right away. - -One note is that the CI runs all `cargo` commands -with `--locked` to ensure it uses the exact same versions as you have locally. This also means -you must have an up-to-date `Cargo.lock` file, which is not auto-generated. -The first time you set up the project (or after adding any dep), you should ensure the -`Cargo.lock` file is updated, so the CI will test properly. This can be done simply by -running `cargo check` or `cargo unit-test`. - -## Using your project - -Once you have your custom repo, you should check out [Developing](./Developing.md) to explain -more on how to run tests and develop code. Or go through the -[online tutorial](https://docs.cosmwasm.com/) to get a better feel -of how to develop. - -[Publishing](./Publishing.md) contains useful information on how to publish your contract -to the world, once you are ready to deploy it on a running blockchain. And -[Importing](./Importing.md) contains information about pulling in other contracts or crates -that have been published. - -Please replace this README file with information about your specific project. You can keep -the `Developing.md` and `Publishing.md` files as useful referenced, but please set some -proper description in the README. +The Vault Manager is the V2 iteration of the original WW vault network. This is a monolithic contract that handles all +the vaults and flashloans in the White Whale DEX. \ No newline at end of file diff --git a/contracts/liquidity_hub/vault-manager/src/contract.rs b/contracts/liquidity_hub/vault-manager/src/contract.rs index 13282db51..48a724e01 100644 --- a/contracts/liquidity_hub/vault-manager/src/contract.rs +++ b/contracts/liquidity_hub/vault-manager/src/contract.rs @@ -12,7 +12,7 @@ use crate::state::{CONFIG, ONGOING_FLASHLOAN, VAULT_COUNTER}; use crate::{manager, queries, router, vault}; // version info for migration info -const CONTRACT_NAME: &str = "ww-vault-manager"; +const CONTRACT_NAME: &str = "white-whale_vault-manager"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[entry_point] @@ -88,15 +88,8 @@ pub fn execute( } => router::commands::flash_loan(deps, env, info, asset, vault_identifier, payload), ExecuteMsg::Callback(msg) => router::commands::callback(deps, env, info, msg), ExecuteMsg::UpdateOwnership(action) => { - Ok( - cw_ownable::update_ownership(deps, &env.block, &info.sender, action).map( - |ownership| { - Response::default() - .add_attribute("action", "update_ownership") - .add_attributes(ownership.into_attributes()) - }, - )?, - ) + cw_utils::nonpayable(&info)?; + white_whale_std::common::update_ownership(deps, env, info, action).map_err(Into::into) } } } diff --git a/contracts/liquidity_hub/vault-manager/src/manager/commands.rs b/contracts/liquidity_hub/vault-manager/src/manager/commands.rs index e3a297f21..e6108b94d 100644 --- a/contracts/liquidity_hub/vault-manager/src/manager/commands.rs +++ b/contracts/liquidity_hub/vault-manager/src/manager/commands.rs @@ -113,6 +113,7 @@ pub fn update_config( deposit_enabled: Option, withdraw_enabled: Option, ) -> Result { + cw_utils::nonpayable(&info)?; cw_ownable::assert_owner(deps.storage, &info.sender)?; let new_config = CONFIG.update::<_, ContractError>(deps.storage, |mut config| { diff --git a/contracts/liquidity_hub/vault-manager/src/state.rs b/contracts/liquidity_hub/vault-manager/src/state.rs index 7109e5fec..be971f3f4 100644 --- a/contracts/liquidity_hub/vault-manager/src/state.rs +++ b/contracts/liquidity_hub/vault-manager/src/state.rs @@ -70,8 +70,8 @@ pub fn get_vaults( /// Calculates the item at which to start the range fn calc_range_start(start_after: Option>) -> Option> { - start_after.map(|asset_info| { - let mut v = asset_info; + start_after.map(|item| { + let mut v = item; v.push(1); v }) diff --git a/contracts/liquidity_hub/vault-manager/tests/units.rs b/contracts/liquidity_hub/vault-manager/tests/units.rs new file mode 100644 index 000000000..67b0e8f01 --- /dev/null +++ b/contracts/liquidity_hub/vault-manager/tests/units.rs @@ -0,0 +1,47 @@ +use cosmwasm_std::{Addr, Coin, Uint128}; +use white_whale_std::incentive_manager::{Curve, Incentive}; + +#[test] +fn incentive_expiration() { + let incentive = Incentive { + identifier: "identifier".to_string(), + owner: Addr::unchecked("owner"), + lp_denom: "lp_denom".to_string(), + incentive_asset: Coin { + denom: "asset".to_string(), + amount: Uint128::new(5_000), + }, + claimed_amount: Uint128::zero(), + emission_rate: Uint128::new(1_000), + curve: Curve::Linear, + start_epoch: 10, + preliminary_end_epoch: 14, + last_epoch_claimed: 9, + }; + + assert!(!incentive.is_expired(9)); + assert!(!incentive.is_expired(12)); + + // expired already after 14 days from the last epoch claimed after the incentive started + assert!(incentive.is_expired(23)); + assert!(incentive.is_expired(33)); + + let incentive = Incentive { + identifier: "identifier".to_string(), + owner: Addr::unchecked("owner"), + lp_denom: "lp_denom".to_string(), + incentive_asset: Coin { + denom: "asset".to_string(), + amount: Uint128::new(5_000), + }, + claimed_amount: Uint128::new(4_001), + emission_rate: Uint128::new(1_000), + curve: Curve::Linear, + start_epoch: 10, + preliminary_end_epoch: 14, + last_epoch_claimed: 9, + }; + + // expired already as incentive_asset - claimed is lower than the MIN_INCENTIVE_AMOUNT + assert!(incentive.is_expired(13)); +} diff --git a/contracts/liquidity_hub/vault-network/vault_factory/Cargo.toml b/contracts/liquidity_hub/vault-network/vault_factory/Cargo.toml index 846712256..0a0495142 100644 --- a/contracts/liquidity_hub/vault-network/vault_factory/Cargo.toml +++ b/contracts/liquidity_hub/vault-network/vault_factory/Cargo.toml @@ -2,7 +2,7 @@ name = "vault_factory" version = "1.1.3" authors = [ - "kaimen-sano , Kerber0x ", + "kaimen-sano , Kerber0x ", ] edition.workspace = true description = "Contract to facilitate the vault network" diff --git a/contracts/liquidity_hub/vault-network/vault_router/Cargo.toml b/contracts/liquidity_hub/vault-network/vault_router/Cargo.toml index 710383f1f..500d9e124 100644 --- a/contracts/liquidity_hub/vault-network/vault_router/Cargo.toml +++ b/contracts/liquidity_hub/vault-network/vault_router/Cargo.toml @@ -2,7 +2,7 @@ name = "vault_router" version = "1.1.6" authors = [ - "kaimen-sano , Kerber0x ", + "kaimen-sano , Kerber0x ", ] edition.workspace = true description = "Contract to facilitate flash-loans in the vault network" diff --git a/contracts/liquidity_hub/whale_lair/Cargo.toml b/contracts/liquidity_hub/whale_lair/Cargo.toml index fc66f04c9..26f4ffaf9 100644 --- a/contracts/liquidity_hub/whale_lair/Cargo.toml +++ b/contracts/liquidity_hub/whale_lair/Cargo.toml @@ -11,9 +11,9 @@ documentation.workspace = true publish.workspace = true exclude = [ - # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. - "contract.wasm", - "hash.txt", + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/packages/white-whale-std/Cargo.toml b/packages/white-whale-std/Cargo.toml index 8792f7698..7ad29f12c 100644 --- a/packages/white-whale-std/Cargo.toml +++ b/packages/white-whale-std/Cargo.toml @@ -3,10 +3,10 @@ name = "white-whale-std" version = "1.1.3" edition.workspace = true authors = [ - "Kerber0x ", - "0xFable <0xfabledev@gmail.com>", - "kaimen-sano ", - "White Whale ", + "Kerber0x ", + "0xFable <0xfabledev@gmail.com>", + "kaimen-sano ", + "White Whale ", ] description = "Common White Whale types and utils" license.workspace = true @@ -20,7 +20,7 @@ injective = ["token_factory"] osmosis = ["osmosis_token_factory"] token_factory = [] osmosis_token_factory = [ - "token_factory", + "token_factory", ] # this is for the osmosis token factory proto definitions, which defer from the standard token factory :) backtraces = ["cosmwasm-std/backtraces"] diff --git a/packages/white-whale-std/src/coin.rs b/packages/white-whale-std/src/coin.rs index 818d89571..5ca1a1dd1 100644 --- a/packages/white-whale-std/src/coin.rs +++ b/packages/white-whale-std/src/coin.rs @@ -1,4 +1,6 @@ -use cosmwasm_std::{StdError, StdResult}; +use std::collections::HashMap; + +use cosmwasm_std::{Coin, StdError, StdResult, Uint128}; #[cfg(feature = "injective")] pub const PEGGY_PREFIX: &str = "peggy"; @@ -99,6 +101,20 @@ pub fn is_factory_token(denom: &str) -> bool { true } +/// Gets the subdenom of a factory token. To be called after [is_factory_token] has been successful. +pub fn get_factory_token_subdenom(denom: &str) -> StdResult<&str> { + let subdenom = denom.splitn(3, '/').nth(2); + + subdenom.map_or_else( + || { + Err(StdError::generic_err( + "Splitting factory token subdenom failed", + )) + }, + Ok, + ) +} + /// Builds the label for a factory token denom in such way that it returns a label like "factory/mig...xyz/123...456". /// Call after [crate::pool_network::asset::is_factory_token] has been successful fn get_factory_token_label(denom: &str) -> StdResult { @@ -124,3 +140,25 @@ fn get_factory_token_label(denom: &str) -> StdResult { } //todo test these functions in isolation + +/// Aggregates coins from two vectors, summing up the amounts of coins that are the same. +pub fn aggregate_coins(coins: Vec) -> StdResult> { + let mut aggregation_map: HashMap = HashMap::new(); + + // aggregate coins by denom + for coin in coins { + if let Some(existing_amount) = aggregation_map.get_mut(&coin.denom) { + *existing_amount = existing_amount.checked_add(coin.amount)?; + } else { + aggregation_map.insert(coin.denom.clone(), coin.amount); + } + } + + // create a new vector from the aggregation map + let mut aggregated_coins: Vec = Vec::new(); + for (denom, amount) in aggregation_map { + aggregated_coins.push(Coin { denom, amount }); + } + + Ok(aggregated_coins) +} diff --git a/packages/white-whale-std/src/common.rs b/packages/white-whale-std/src/common.rs index 6a9235762..9e971497a 100644 --- a/packages/white-whale-std/src/common.rs +++ b/packages/white-whale-std/src/common.rs @@ -1,4 +1,5 @@ -use cosmwasm_std::{Addr, StdError, StdResult, Storage}; +use cosmwasm_std::{Addr, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Storage}; +use cw_ownable::{Action, OwnershipError}; use cw_storage_plus::Item; /// Validates that the given address matches the address stored in the given `owner_item`. @@ -16,3 +17,17 @@ pub fn validate_owner( Ok(()) } + +/// Updates the ownership of a contract using the cw_ownable package, which needs to be implemented by the contract. +pub fn update_ownership( + deps: DepsMut, + env: Env, + info: MessageInfo, + action: Action, +) -> Result { + cw_ownable::update_ownership(deps, &env.block, &info.sender, action).map(|ownership| { + Response::default() + .add_attribute("action", "update_ownership") + .add_attributes(ownership.into_attributes()) + }) +} diff --git a/packages/white-whale-std/src/constants.rs b/packages/white-whale-std/src/constants.rs new file mode 100644 index 000000000..c8b46adba --- /dev/null +++ b/packages/white-whale-std/src/constants.rs @@ -0,0 +1,2 @@ +pub const LP_SYMBOL: &str = "uLP"; +pub const DAY_SECONDS: u64 = 86400u64; diff --git a/packages/white-whale-std/src/epoch_manager/common.rs b/packages/white-whale-std/src/epoch_manager/common.rs new file mode 100644 index 000000000..7e065d108 --- /dev/null +++ b/packages/white-whale-std/src/epoch_manager/common.rs @@ -0,0 +1,28 @@ +use cosmwasm_std::{Deps, StdError, StdResult, Timestamp}; + +use crate::constants::DAY_SECONDS; +use crate::epoch_manager::epoch_manager::{Epoch, EpochResponse, QueryMsg}; + +/// Queries the current epoch from the epoch manager contract +pub fn get_current_epoch(deps: Deps, epoch_manager_addr: String) -> StdResult { + let epoch_response: EpochResponse = deps + .querier + .query_wasm_smart(epoch_manager_addr, &QueryMsg::CurrentEpoch {})?; + + Ok(epoch_response.epoch) +} + +/// Validates that the given epoch has not expired, i.e. not more than 24 hours have passed since the start of the epoch. +pub fn validate_epoch(epoch: &Epoch, current_time: Timestamp) -> StdResult<()> { + if current_time + .minus_seconds(epoch.start_time.seconds()) + .seconds() + < DAY_SECONDS + { + return Err(StdError::generic_err( + "Current epoch has expired, please wait for the next epoch to start.", + )); + } + + Ok(()) +} diff --git a/packages/white-whale-std/src/epoch_manager/epoch_manager.rs b/packages/white-whale-std/src/epoch_manager/epoch_manager.rs index acaf6a8ab..744d61000 100644 --- a/packages/white-whale-std/src/epoch_manager/epoch_manager.rs +++ b/packages/white-whale-std/src/epoch_manager/epoch_manager.rs @@ -8,7 +8,7 @@ use cw_controllers::HooksResponse; #[cw_serde] pub struct InstantiateMsg { - pub start_epoch: EpochV2, + pub start_epoch: Epoch, pub epoch_config: EpochConfig, } @@ -76,12 +76,12 @@ pub struct ConfigResponse { #[cw_serde] pub struct EpochResponse { - pub epoch: EpochV2, + pub epoch: Epoch, } #[cw_serde] pub struct ClaimableEpochsResponse { - pub epochs: Vec, + pub epochs: Vec, } #[cw_serde] @@ -104,20 +104,20 @@ impl Display for EpochConfig { #[cw_serde] #[derive(Default)] -pub struct EpochV2 { +pub struct Epoch { // Epoch identifier pub id: u64, // Epoch start time pub start_time: Timestamp, } -impl EpochV2 { +impl Epoch { pub fn to_epoch_response(self) -> EpochResponse { EpochResponse { epoch: self } } } -impl Display for EpochV2 { +impl Display for Epoch { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, diff --git a/packages/white-whale-std/src/epoch_manager/hooks.rs b/packages/white-whale-std/src/epoch_manager/hooks.rs index 74a6b66ca..911c95ca1 100644 --- a/packages/white-whale-std/src/epoch_manager/hooks.rs +++ b/packages/white-whale-std/src/epoch_manager/hooks.rs @@ -1,11 +1,11 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{to_json_binary, Binary, CosmosMsg, StdResult, WasmMsg}; -use crate::epoch_manager::epoch_manager::EpochV2; +use crate::epoch_manager::epoch_manager::Epoch; #[cw_serde] pub struct EpochChangedHookMsg { - pub current_epoch: EpochV2, + pub current_epoch: Epoch, } impl EpochChangedHookMsg { diff --git a/packages/white-whale-std/src/epoch_manager/mod.rs b/packages/white-whale-std/src/epoch_manager/mod.rs index 66d975625..39c5d2ecf 100644 --- a/packages/white-whale-std/src/epoch_manager/mod.rs +++ b/packages/white-whale-std/src/epoch_manager/mod.rs @@ -1,2 +1,3 @@ +pub mod common; pub mod epoch_manager; pub mod hooks; diff --git a/packages/white-whale-std/src/incentive_manager.rs b/packages/white-whale-std/src/incentive_manager.rs new file mode 100644 index 000000000..ce3f4cad0 --- /dev/null +++ b/packages/white-whale-std/src/incentive_manager.rs @@ -0,0 +1,324 @@ +use std::collections::HashMap; + +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Coin, Decimal, Uint128}; +use cw_ownable::{cw_ownable_execute, cw_ownable_query}; + +use crate::epoch_manager::hooks::EpochChangedHookMsg; + +/// The instantiation message +#[cw_serde] +pub struct InstantiateMsg { + /// The owner of the contract + pub owner: String, + /// The epoch manager address, where the epochs are managed + pub epoch_manager_addr: String, + /// The whale lair address, where protocol fees are distributed + pub whale_lair_addr: String, + /// The fee that must be paid to create an incentive. + pub create_incentive_fee: Coin, + /// The maximum amount of incentives that can exist for a single LP token at a time. + pub max_concurrent_incentives: u32, + /// New incentives are allowed to start up to `current_epoch + start_epoch_buffer` into the future. + pub max_incentive_epoch_buffer: u32, + /// The minimum amount of time that a user can lock their tokens for. In seconds. + pub min_unlocking_duration: u64, + /// The maximum amount of time that a user can lock their tokens for. In seconds. + pub max_unlocking_duration: u64, + /// The penalty for unlocking a position before the unlocking duration finishes. In percentage. + pub emergency_unlock_penalty: Decimal, +} + +/// The execution messages +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Manages an incentive based on the action, which can be: + /// - Fill: Creates or expands an incentive. + /// - Close: Closes an existing incentive. + ManageIncentive { action: IncentiveAction }, + /// Manages a position based on the action, which can be: + /// - Fill: Creates or expands a position. + /// - Close: Closes an existing position. + ManagePosition { action: PositionAction }, + /// Gets triggered by the epoch manager when a new epoch is created + EpochChangedHook(EpochChangedHookMsg), + /// Claims the rewards for the user + Claim, + /// Updates the config of the contract + UpdateConfig { + /// The address to of the whale lair, to send fees to. + whale_lair_addr: Option, + /// The epoch manager address, where the epochs are managed + epoch_manager_addr: Option, + /// The fee that must be paid to create an incentive. + create_incentive_fee: Option, + /// The maximum amount of incentives that can exist for a single LP token at a time. + max_concurrent_incentives: Option, + /// The maximum amount of epochs in the future a new incentive is allowed to start in. + max_incentive_epoch_buffer: Option, + /// The minimum amount of time that a user can lock their tokens for. In seconds. + min_unlocking_duration: Option, + /// The maximum amount of time that a user can lock their tokens for. In seconds. + max_unlocking_duration: Option, + /// The penalty for unlocking a position before the unlocking duration finishes. In percentage. + emergency_unlock_penalty: Option, + }, +} + +/// The migrate message +#[cw_serde] +pub struct MigrateMsg {} + +/// The query messages +#[cw_ownable_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Retrieves the configuration of the manager. + #[returns(Config)] + Config, + /// Retrieves the configuration of the manager. + #[returns(IncentivesResponse)] + Incentives { + /// An optional parameter specifying what to filter incentives by. + /// Can be either the incentive identifier, lp denom or the incentive asset. + filter_by: Option, + /// An optional parameter specifying what incentive (identifier) to start searching after. + start_after: Option, + /// The amount of incentives to return. + /// If unspecified, will default to a value specified by the contract. + limit: Option, + }, + /// Retrieves the positions for an address. + #[returns(PositionsResponse)] + Positions { + /// The address to get positions for. + address: String, + /// An optional parameter specifying to return only positions that match the given open state. + /// if true, it will return open positions. If false, it will return closed positions. + open_state: Option, + }, + /// Retrieves the rewards for an address. + #[returns(RewardsResponse)] + Rewards { + /// The address to get all the incentive rewards for. + address: String, + }, + /// Retrieves the total LP weight in the contract for a given denom on a given epoch. + #[returns(LpWeightResponse)] + LPWeight { + /// The address to get the LP weight for. + address: String, + /// The denom to get the total LP weight for. + denom: String, + /// The epoch id to get the LP weight for. + epoch_id: EpochId, + }, +} + +/// Enum to filter incentives by identifier, lp denom or the incentive asset. Used in the Incentives query. +#[cw_serde] +pub enum IncentivesBy { + Identifier(String), + LPDenom(String), + IncentiveAsset(String), +} + +/// Configuration for the contract (manager) +#[cw_serde] +pub struct Config { + /// The address to of the whale lair, to send fees to. + pub whale_lair_addr: Addr, + /// The epoch manager address, where the epochs are managed + pub epoch_manager_addr: Addr, + /// The fee that must be paid to create an incentive. + pub create_incentive_fee: Coin, + /// The maximum amount of incentives that can exist for a single LP token at a time. + pub max_concurrent_incentives: u32, + /// The maximum amount of epochs in the future a new incentive is allowed to start in. + pub max_incentive_epoch_buffer: u32, + /// The minimum amount of time that a user can lock their tokens for. In seconds. + pub min_unlocking_duration: u64, + /// The maximum amount of time that a user can lock their tokens for. In seconds. + pub max_unlocking_duration: u64, + /// The penalty for unlocking a position before the unlocking duration finishes. In percentage. + pub emergency_unlock_penalty: Decimal, +} + +/// Parameters for creating incentive +#[cw_serde] +pub struct IncentiveParams { + /// The LP asset denom to create the incentive for. + pub lp_denom: String, + /// The epoch at which the incentive will start. If unspecified, it will start at the + /// current epoch. + pub start_epoch: Option, + /// The epoch at which the incentive should preliminarily end (if it's not expanded). If + /// unspecified, the incentive will default to end at 14 epochs from the current one. + pub preliminary_end_epoch: Option, + /// The type of distribution curve. If unspecified, the distribution will be linear. + pub curve: Option, + /// The asset to be distributed in this incentive. + pub incentive_asset: Coin, + /// If set, it will be used to identify the incentive. + pub incentive_identifier: Option, +} + +#[cw_serde] +pub enum IncentiveAction { + /// Fills an incentive. If the incentive doesn't exist, it creates a new one. If it exists already, + /// it expands it given the sender created the original incentive and the params are correct. + Fill { + /// The parameters for the incentive to fill. + params: IncentiveParams, + }, + //// Closes an incentive with the given identifier. If the incentive has expired, anyone can + // close it. Otherwise, only the incentive creator or the owner of the contract can close an incentive. + Close { + /// The incentive identifier to close. + incentive_identifier: String, + }, +} + +#[cw_serde] +pub enum PositionAction { + /// Fills a position. If the position doesn't exist, it opens it. If it exists already, + /// it expands it given the sender opened the original position and the params are correct. + Fill { + /// The identifier of the position. + identifier: Option, + /// The time it takes in seconds to unlock this position. This is used to identify the position to fill. + unlocking_duration: u64, + /// The receiver for the position. + /// If left empty, defaults to the message sender. + receiver: Option, + }, + /// Closes an existing position. The position stops earning incentive rewards. + Close { + /// The identifier of the position. + identifier: String, + /// The asset to add to the position. If not set, the position will be closed in full. If not, it could be partially closed. + lp_asset: Option, + }, + /// Withdraws the LP tokens from a position after the position has been closed and the unlocking duration has passed. + Withdraw { + /// The identifier of the position. + identifier: String, + /// Whether to unlock the position in an emergency. If set to true, the position will be unlocked immediately, but with a penalty. + emergency_unlock: Option, + }, +} + +// type for the epoch id +pub type EpochId = u64; + +/// Represents an incentive. +#[cw_serde] +pub struct Incentive { + /// The ID of the incentive. + pub identifier: String, + /// The account which opened the incentive and can manage it. + pub owner: Addr, + /// The LP asset denom to create the incentive for. + pub lp_denom: String, + /// The asset the incentive was created to distribute. + pub incentive_asset: Coin, + /// The amount of the `incentive_asset` that has been claimed so far. + pub claimed_amount: Uint128, + /// The amount of the `incentive_asset` that is to be distributed every epoch. + pub emission_rate: Uint128, + /// The type of curve the incentive has. + pub curve: Curve, + /// The epoch at which the incentive starts. + pub start_epoch: EpochId, + /// The epoch at which the incentive will preliminary end (in case it's not expanded). + pub preliminary_end_epoch: EpochId, + /// The last epoch this incentive was claimed. + pub last_epoch_claimed: EpochId, +} + +impl Incentive { + /// Returns true if the incentive is expired + pub fn is_expired(&self, epoch_id: EpochId) -> bool { + self.incentive_asset + .amount + .saturating_sub(self.claimed_amount) + < MIN_INCENTIVE_AMOUNT + || (epoch_id > self.start_epoch + && epoch_id >= self.last_epoch_claimed + DEFAULT_INCENTIVE_DURATION) + } +} + +#[cw_serde] +pub enum Curve { + /// A linear curve that releases assets uniformly over time. + Linear, +} + +impl std::fmt::Display for Curve { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Curve::Linear => write!(f, "linear"), + } + } +} + +/// Represents an LP position. +#[cw_serde] +pub struct Position { + /// The identifier of the position. + pub identifier: String, + /// The amount of LP tokens that are put up to earn incentives. + pub lp_asset: Coin, + /// Represents the amount of time in seconds the user must wait after unlocking for the LP tokens to be released. + pub unlocking_duration: u64, + /// If true, the position is open. If false, the position is closed. + pub open: bool, + /// The block height at which the position, after being closed, can be withdrawn. + pub expiring_at: Option, + /// The owner of the position. + pub receiver: Addr, +} + +#[cw_serde] +pub enum RewardsResponse { + RewardsResponse { + /// The rewards that is available to a user if they executed the `claim` function at this point. + rewards: Vec, + }, + ClaimRewards { + /// The rewards that is available to a user if they executed the `claim` function at this point. + rewards: Vec, + /// The rewards that were claimed on each incentive, if any. + modified_incentives: HashMap, + }, +} + +/// Minimum amount of an asset to create an incentive with +pub const MIN_INCENTIVE_AMOUNT: Uint128 = Uint128::new(1_000u128); + +/// Default incentive duration in epochs +pub const DEFAULT_INCENTIVE_DURATION: u64 = 14u64; + +/// The response for the incentives query +#[cw_serde] +pub struct IncentivesResponse { + /// The list of incentives + pub incentives: Vec, +} + +#[cw_serde] +pub struct PositionsResponse { + /// All the positions a user has. + pub positions: Vec, +} + +/// The response for the LP weight query +#[cw_serde] +pub struct LpWeightResponse { + /// The total lp weight in the contract + pub lp_weight: Uint128, + /// The epoch id corresponding to the lp weight in the contract + pub epoch_id: EpochId, +} diff --git a/packages/white-whale-std/src/lib.rs b/packages/white-whale-std/src/lib.rs index 51dad2736..b2baca35b 100644 --- a/packages/white-whale-std/src/lib.rs +++ b/packages/white-whale-std/src/lib.rs @@ -1,8 +1,11 @@ pub mod common; + +pub mod constants; pub mod epoch_manager; pub mod fee; pub mod fee_collector; pub mod fee_distributor; +pub mod incentive_manager; pub mod lp_common; pub mod migrate_guards; pub mod pool_manager; diff --git a/packages/white-whale-std/src/pool_network/asset.rs b/packages/white-whale-std/src/pool_network/asset.rs index 68fae153a..592d0d697 100644 --- a/packages/white-whale-std/src/pool_network/asset.rs +++ b/packages/white-whale-std/src/pool_network/asset.rs @@ -132,6 +132,26 @@ impl Asset { AssetInfo::NativeToken { denom } => denom, } } + + /// Builds a native asset + pub fn native_asset>(denom: S, amount: Uint128) -> Asset { + Asset { + info: AssetInfo::NativeToken { + denom: denom.into(), + }, + amount, + } + } + + /// Builds a cw20 token asset + pub fn token_asset>(contract_addr: S, amount: Uint128) -> Asset { + Asset { + info: AssetInfo::Token { + contract_addr: contract_addr.into(), + }, + amount, + } + } } /// AssetInfo contract_addr is usually passed from the cw20 hook @@ -164,6 +184,13 @@ impl AssetInfo { } } + pub fn as_bytes(&self) -> &[u8] { + match self { + AssetInfo::NativeToken { denom } => denom.as_bytes(), + AssetInfo::Token { contract_addr } => contract_addr.as_bytes(), + } + } + pub fn is_native_token(&self) -> bool { match self { AssetInfo::NativeToken { .. } => true, diff --git a/scripts/deployment/deploy_env/base.env b/scripts/deployment/deploy_env/base.env deleted file mode 100644 index e69de29bb..000000000 diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 60540c5af..2864d6e63 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -63,6 +63,7 @@ pub mod tasks { generate_schema!("fee_collector", fee_collector), generate_schema!("fee_distributor", fee_distributor), generate_schema!("pool-manager", pool_manager), + generate_schema!("incentive-manager", incentive_manager), generate_schema!("frontend-helper", frontend_helper), generate_schema!("incentive", incentive), generate_schema!("incentive-factory", incentive_factory),