diff --git a/Cargo.toml b/Cargo.toml index 27b7fcd0..2c4ea407 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,42 @@ resolver = "2" [workspace.dependencies] cw-orch = "0.20.1" + +cosmwasm-std = { version = "1.5" } + abstract-app = { version = "0.21.0" } abstract-interface = { version = "0.21.0" } -abstract-dex-adapter = { git = "https://github.com/abstractsdk/abstract.git", tag = "v0.21.0" } +abstract-dex-adapter = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-money-market-adapter = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-money-market-standard = { git = "https://github.com/abstractsdk/abstract.git" } abstract-client = { version = "0.21.0" } +abstract-testing = { version = "0.21.0" } +abstract-sdk = { version = "0.21.0", features = ["stargate"] } + + +[patch.crates-io] +# This is included to avoid recompling too much +osmosis-test-tube = { path = "../test-tube/packages/osmosis-test-tube" } + + +# This was added to account for the fix data forwaring in the proxy contract + moneymarket adapter +abstract-app = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-adapter = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-adapter-utils = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-interface = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-client = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-testing = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-core = { git = "https://github.com/abstractsdk/abstract.git" } +abstract-sdk = { git = "https://github.com/abstractsdk/abstract.git" } + + +[profile.release] +rpath = false +lto = true +overflow-checks = true +opt-level = 3 +debug = false +debug-assertions = false +codegen-units = 1 +panic = 'abort' +incremental = false diff --git a/bot/Cargo.toml b/bot/Cargo.toml index 356765be..26902ac9 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -15,7 +15,7 @@ abstract-client = { workspace = true } osmosis-std = { version = "0.21.0" } cosmos-sdk-proto = { version = "0.20.0" } dotenv = "0.15.0" -env_logger = "0.11.2" +env_logger = { version = "0.11.3", default-features = false } log = "0.4.20" tonic = "0.10.0" carrot-app = { path = "../contracts/carrot-app", features = ["interface"] } @@ -24,3 +24,5 @@ humantime = "2.1.0" prometheus = "0.13.2" tokio = "1.26.0" warp = "0.3.6" +serde_json = "1.0.116" +cosmwasm-std = { workspace = true } diff --git a/bot/src/bot.rs b/bot/src/bot.rs index 97f5a24e..c4f88ee9 100644 --- a/bot/src/bot.rs +++ b/bot/src/bot.rs @@ -1,12 +1,16 @@ use abstract_client::{AbstractClient, AccountSource, Environment}; use carrot_app::{ - msg::{AppExecuteMsg, CompoundStatusResponse, ExecuteMsg}, + msg::{AppExecuteMsg, ExecuteMsg}, AppInterface, }; use cosmos_sdk_proto::{ - cosmwasm::wasm::v1::{query_client::QueryClient, QueryContractsByCodeRequest}, + cosmwasm::wasm::v1::{ + query_client::QueryClient, MsgExecuteContract, QueryContractsByCodeRequest, + }, traits::Message as _, + Any, }; +use cosmwasm_std::Uint128; use cw_orch::{ anyhow, daemon::{queriers::Authz, Daemon}, @@ -208,19 +212,36 @@ fn autocompound_instance(daemon: &Daemon, instance: (&str, &Addr)) -> anyhow::Re let app = AppInterface::new(id, daemon.clone()); app.set_address(address); use carrot_app::AppQueryMsgFns; - let resp: CompoundStatusResponse = app.compound_status()?; + let compound = app.compound_status()?; // TODO: ensure rewards > tx fee - // To discuss if we really need it? - - if resp.rewards_available { - // Execute autocompound - let daemon = daemon.rebuild().authz_granter(address).build()?; - daemon.execute( - &ExecuteMsg::from(AppExecuteMsg::Autocompound {}), - &[], - address, + // Ensure that autocompound is allowed on the contract + if compound.status.is_ready() { + // We simulate the transaction + let msg = ExecuteMsg::from(AppExecuteMsg::Autocompound {}); + let exec_msg: MsgExecuteContract = MsgExecuteContract { + sender: daemon.sender().to_string(), + contract: address.as_str().parse()?, + msg: serde_json::to_vec(&msg)?, + funds: vec![], + }; + let tx_simulation = daemon.rt_handle.block_on( + daemon + .daemon + .sender + .simulate(vec![Any::from_msg(&exec_msg)?], None), )?; + let fee_value = app.funds_value(vec![tx_simulation.1])?; + let profit = compound + .execution_rewards + .total_value + .checked_sub(fee_value.total_value) + .unwrap_or_default(); + if profit > Uint128::zero() { + // If it's worth it, we autocompound + let daemon = daemon.rebuild().authz_granter(address).build()?; + daemon.execute(&msg, &[], address)?; + } } Ok(()) } diff --git a/contracts/carrot-app/Cargo.toml b/contracts/carrot-app/Cargo.toml index cb8e91b4..a017cc17 100644 --- a/contracts/carrot-app/Cargo.toml +++ b/contracts/carrot-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "carrot-app" -version = "0.1.0" +version = "1.0.1" authors = [ "CyberHoward ", "Adair ", @@ -34,12 +34,13 @@ interface = [ "dep:cw-orch", "abstract-app/interface-macro", "abstract-dex-adapter/interface", + "abstract-money-market-adapter/interface", ] schema = ["abstract-app/schema"] [dependencies] cw-utils = { version = "1.0.3" } -cosmwasm-std = { version = "1.2" } +cosmwasm-std = { workspace = true } cosmwasm-schema = { version = "1.2" } cw-controllers = { version = "1.0.1" } cw-storage-plus = "1.2.0" @@ -48,10 +49,13 @@ schemars = "0.8" cw-asset = { version = "3.0" } abstract-app = { workspace = true } -abstract-sdk = { version = "0.21.0", features = ["stargate"] } +abstract-sdk = { workspace = true } + # Dependencies for interface abstract-dex-adapter = { workspace = true, features = ["osmosis"] } +abstract-money-market-adapter = { workspace = true, features = ["mars"] } cw-orch = { workspace = true, optional = true } +abstract-money-market-standard = { workspace = true } osmosis-std = { version = "0.21.0" } prost = { version = "0.12.3" } @@ -63,12 +67,15 @@ prost = { version = "0.12.3" } prost-types = { version = "0.12.3" } log = { version = "0.4.20" } carrot-app = { path = ".", features = ["interface"] } -abstract-testing = { version = "0.21.0" } -abstract-client = { version = "0.21.0" } -abstract-sdk = { version = "0.21.0", features = ["test-utils"] } +abstract-testing = { workspace = true } +abstract-client = { workspace = true } +abstract-sdk = { workspace = true, features = ["test-utils"] } + +# For cw-optimizoor +env_logger = { version = "0.11.3", default-features = false } + speculoos = "0.11.0" semver = "1.0" dotenv = "0.15.0" -env_logger = "0.10.0" cw-orch = { workspace = true, features = ["osmosis-test-tube"] } clap = { version = "4.3.7", features = ["derive"] } diff --git a/contracts/carrot-app/examples/gas-usage.md b/contracts/carrot-app/examples/gas-usage.md new file mode 100644 index 00000000..21b928d3 --- /dev/null +++ b/contracts/carrot-app/examples/gas-usage.md @@ -0,0 +1,31 @@ +Gas usage tests : +1. Create position on carrot app +V1 : 882193 +V2 : 1238259 + +2. Add to position +V1 : 1774990 +V2 : 3172677 + +3. Withdraw all +V1 : 1100482 +V2 : 1189022 + + +For multiple positions : + +2. +Create : 2342438 +Add to position : 5705186 + +3. +Create : 3497234 +Add : 8516095 + +4. +Create : 4673461 +Add: 11344713 + +5. +Create : 5869472 +Add: 14187958 \ No newline at end of file diff --git a/contracts/carrot-app/examples/install_savings_app.rs b/contracts/carrot-app/examples/install_savings_app.rs index 869b3f20..6d879a70 100644 --- a/contracts/carrot-app/examples/install_savings_app.rs +++ b/contracts/carrot-app/examples/install_savings_app.rs @@ -1,7 +1,7 @@ #![allow(unused)] use abstract_app::objects::{AccountId, AssetEntry}; use abstract_client::AbstractClient; -use cosmwasm_std::{Coin, Uint128, Uint256, Uint64}; +use cosmwasm_std::{coins, Coin, Decimal, Uint128, Uint256, Uint64}; use cw_orch::{ anyhow, daemon::{networks::OSMOSIS_1, Daemon, DaemonBuilder}, @@ -11,8 +11,14 @@ use cw_orch::{ use dotenv::dotenv; use carrot_app::{ - msg::{AppInstantiateMsg, CreatePositionMessage}, - state::AutocompoundRewardsConfig, + autocompound::{ + AutocompoundConfig, AutocompoundConfigBase, AutocompoundRewardsConfig, + AutocompoundRewardsConfigBase, + }, + contract::OSMOSIS, + msg::AppInstantiateMsg, + state::ConfigBase, + yield_sources::{Strategy, StrategyBase}, }; use osmosis_std::types::cosmos::authz::v1beta1::MsgGrantResponse; @@ -54,29 +60,20 @@ fn main() -> anyhow::Result<()> { let app_data = usdc_usdc_ax::app_data(funds, 100_000_000_000_000, 100_000_000_000_000); - // Give all authzs and create subaccount with app in single tx - let mut msgs = utils::give_authorizations_msgs(&client, savings_app_addr, &app_data)?; - + let mut msgs = vec![]; let init_msg = AppInstantiateMsg { - pool_id: app_data.pool_id, - autocompound_cooldown_seconds: Uint64::new(AUTOCOMPOUND_COOLDOWN_SECONDS), - autocompound_rewards_config: AutocompoundRewardsConfig { - gas_asset: AssetEntry::new(utils::REWARD_ASSET), - swap_asset: app_data.swap_asset, - reward: Uint128::new(50_000), - min_gas_balance: Uint128::new(1000000), - max_gas_balance: Uint128::new(3000000), + config: ConfigBase { + autocompound_config: AutocompoundConfigBase { + cooldown_seconds: Uint64::new(AUTOCOMPOUND_COOLDOWN_SECONDS), + rewards: AutocompoundRewardsConfigBase { + reward_percent: Decimal::percent(10), + _phantom: std::marker::PhantomData, + }, + }, + dex: OSMOSIS.to_string(), }, - create_position: Some(CreatePositionMessage { - lower_tick: app_data.lower_tick, - upper_tick: app_data.upper_tick, - funds: app_data.funds, - asset0: app_data.asset0, - asset1: app_data.asset1, - max_spread: None, - belief_price0: None, - belief_price1: None, - }), + strategy: StrategyBase(vec![]), + deposit: Some(coins(100, "usdc")), }; let create_sub_account_message = utils::create_account_message(&client, init_msg)?; @@ -200,84 +197,6 @@ mod utils { use prost_types::Any; use std::iter; - pub fn give_authorizations_msgs( - client: &AbstractClient, - savings_app_addr: impl Into, - app_data: &CarrotAppInitData, - ) -> Result, anyhow::Error> { - let dex_fee_account = client.account_from(AccountId::local(0))?; - let dex_fee_addr = dex_fee_account.proxy()?.to_string(); - let chain = client.environment().clone(); - - let authorization_urls = [ - MsgCreatePosition::TYPE_URL, - MsgSwapExactAmountIn::TYPE_URL, - MsgAddToPosition::TYPE_URL, - MsgWithdrawPosition::TYPE_URL, - MsgCollectIncentives::TYPE_URL, - MsgCollectSpreadRewards::TYPE_URL, - ] - .map(ToOwned::to_owned); - let savings_app_addr: String = savings_app_addr.into(); - let granter = chain.sender().to_string(); - let grantee = savings_app_addr.clone(); - - let reward_denom = client - .name_service() - .resolve(&AssetEntry::new(REWARD_ASSET))?; - - let mut dex_spend_limit = vec![ - cw_orch::osmosis_test_tube::osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin { - denom: app_data.denom0.to_string(), - amount: LOTS.to_string(), - }, - cw_orch::osmosis_test_tube::osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin { - denom: app_data.denom1.to_string(), - amount: LOTS.to_string(), - }, - cw_orch::osmosis_test_tube::osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin { - denom: reward_denom.to_string(), - amount: LOTS.to_string(), - }]; - dex_spend_limit.sort_unstable_by(|a, b| a.denom.cmp(&b.denom)); - let dex_fee_authorization = Any { - value: MsgGrant { - granter: chain.sender().to_string(), - grantee: grantee.clone(), - grant: Some(Grant { - authorization: Some( - SendAuthorization { - spend_limit: dex_spend_limit, - allow_list: vec![dex_fee_addr, savings_app_addr], - } - .to_any(), - ), - expiration: None, - }), - } - .encode_to_vec(), - type_url: MsgGrant::TYPE_URL.to_owned(), - }; - - let msgs: Vec = authorization_urls - .into_iter() - .map(|msg| Any { - value: MsgGrant { - granter: granter.clone(), - grantee: grantee.clone(), - grant: Some(Grant { - authorization: Some(GenericAuthorization { msg }.to_any()), - expiration: None, - }), - } - .encode_to_vec(), - type_url: MsgGrant::TYPE_URL.to_owned(), - }) - .chain(iter::once(dex_fee_authorization)) - .collect(); - Ok(msgs) - } - pub fn create_account_message( client: &AbstractClient, init_msg: AppInstantiateMsg, diff --git a/contracts/carrot-app/examples/localnet_deploy.rs b/contracts/carrot-app/examples/localnet_deploy.rs new file mode 100644 index 00000000..e8b10f39 --- /dev/null +++ b/contracts/carrot-app/examples/localnet_deploy.rs @@ -0,0 +1,150 @@ +use abstract_app::objects::{ + namespace::ABSTRACT_NAMESPACE, pool_id::PoolAddressBase, AssetEntry, PoolMetadata, PoolType, +}; +use abstract_client::{AbstractClient, Namespace}; +use cosmwasm_std::Decimal; +use cw_asset::AssetInfoUnchecked; +use cw_orch::{ + anyhow, + daemon::{networks::LOCAL_OSMO, DaemonBuilder}, + prelude::*, + tokio::runtime::Runtime, +}; +use dotenv::dotenv; + +use cw_orch::osmosis_test_tube::osmosis_test_tube::cosmrs::proto::traits::Message; +use osmosis_std::types::{ + cosmos::base::v1beta1, + osmosis::concentratedliquidity::{ + poolmodel::concentrated::v1beta1::{ + MsgCreateConcentratedPool, MsgCreateConcentratedPoolResponse, + }, + v1beta1::{MsgCreatePosition, MsgCreatePositionResponse}, + }, +}; +use prost_types::Any; + +pub const ION: &str = "uion"; +pub const OSMO: &str = "uosmo"; + +pub const TICK_SPACING: u64 = 100; +pub const SPREAD_FACTOR: u64 = 0; + +pub const INITIAL_LOWER_TICK: i64 = -100000; +pub const INITIAL_UPPER_TICK: i64 = 10000; + +pub fn main() -> anyhow::Result<()> { + dotenv().ok(); + env_logger::init(); + let mut chain = LOCAL_OSMO; + chain.grpc_urls = &["http://localhost:9090"]; + chain.chain_id = "osmosis-1"; + + let rt = Runtime::new()?; + let daemon = DaemonBuilder::default() + .chain(chain) + .handle(rt.handle()) + .build()?; + + // We create a CL pool + let pool_id = create_pool(daemon.clone())?; + // We register the ans entries of ion and osmosis balances + register_ans(daemon.clone(), pool_id)?; + + deploy_app(daemon.clone())?; + Ok(()) +} + +pub fn create_pool(chain: Chain) -> anyhow::Result { + let response = chain.commit_any::( + vec![Any { + value: MsgCreateConcentratedPool { + sender: chain.sender().to_string(), + denom0: ION.to_owned(), + denom1: OSMO.to_owned(), + tick_spacing: TICK_SPACING, + spread_factor: SPREAD_FACTOR.to_string(), + } + .encode_to_vec(), + type_url: MsgCreateConcentratedPool::TYPE_URL.to_string(), + }], + None, + )?; + + let pool_id = response + .event_attr_value("pool_created", "pool_id")? + .parse()?; + // Provide liquidity + + chain.commit_any::( + vec![Any { + type_url: MsgCreatePosition::TYPE_URL.to_string(), + value: MsgCreatePosition { + pool_id, + sender: chain.sender().to_string(), + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + tokens_provided: vec![ + v1beta1::Coin { + denom: ION.to_string(), + amount: "1_000_000".to_owned(), + }, + v1beta1::Coin { + denom: OSMO.to_string(), + amount: "1_000_000".to_owned(), + }, + ], + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + } + .encode_to_vec(), + }], + None, + )?; + Ok(pool_id) +} + +pub fn register_ans(chain: Chain, pool_id: u64) -> anyhow::Result<()> { + let asset0 = ION.to_owned(); + let asset1 = OSMO.to_owned(); + // We register the pool inside the Abstract ANS + let _client = AbstractClient::builder(chain.clone()) + .dex("osmosis") + .assets(vec![ + (ION.to_string(), AssetInfoUnchecked::Native(asset0.clone())), + (OSMO.to_string(), AssetInfoUnchecked::Native(asset1.clone())), + ]) + .pools(vec![( + PoolAddressBase::Id(pool_id), + PoolMetadata { + dex: "osmosis".to_owned(), + pool_type: PoolType::ConcentratedLiquidity, + assets: vec![AssetEntry::new(ION), AssetEntry::new(OSMO)], + }, + )]) + .build()?; + + Ok(()) +} + +pub fn deploy_app(chain: Chain) -> anyhow::Result<()> { + let client = abstract_client::AbstractClient::new(chain.clone())?; + // We deploy the carrot_app + let publisher = client + .publisher_builder(Namespace::new(ABSTRACT_NAMESPACE)?) + .install_on_sub_account(false) + .build()?; + + // The dex adapter + publisher.publish_adapter::<_, abstract_dex_adapter::interface::DexAdapter>( + abstract_dex_adapter::msg::DexInstantiateMsg { + swap_fee: Decimal::permille(2), + recipient_account: 0, + }, + )?; + + // The savings app + publisher.publish_app::>()?; + + Ok(()) +} diff --git a/contracts/carrot-app/examples/localnet_install.rs b/contracts/carrot-app/examples/localnet_install.rs new file mode 100644 index 00000000..e3a824f8 --- /dev/null +++ b/contracts/carrot-app/examples/localnet_install.rs @@ -0,0 +1,160 @@ +use abstract_client::Application; +use abstract_dex_adapter::interface::DexAdapter; +use cosmwasm_std::{Decimal, Uint64}; +use cw_orch::{ + anyhow, + daemon::{networks::LOCAL_OSMO, Daemon, DaemonBuilder}, + prelude::*, + tokio::runtime::Runtime, +}; +use dotenv::dotenv; + +use carrot_app::{ + autocompound::{AutocompoundConfigBase, AutocompoundRewardsConfigBase}, + msg::AppInstantiateMsg, + state::ConfigBase, + yield_sources::{ + osmosis_cl_pool::ConcentratedPoolParamsBase, yield_type::YieldParamsBase, AssetShare, + StrategyBase, StrategyElementBase, StrategyElementUnchecked, StrategyUnchecked, + YieldSourceBase, + }, +}; + +pub const ION: &str = "uion"; +pub const OSMO: &str = "uosmo"; + +pub const INITIAL_LOWER_TICK: i64 = -100000; +pub const INITIAL_UPPER_TICK: i64 = 10000; + +pub const POOL_ID: u64 = 1; +pub const USER_NAMESPACE: &str = "usernamespace"; + +fn main() -> anyhow::Result<()> { + dotenv().ok(); + env_logger::init(); + let mut chain = LOCAL_OSMO; + chain.grpc_urls = &["http://localhost:9090"]; + chain.chain_id = "osmosis-1"; + + let rt = Runtime::new()?; + let daemon = DaemonBuilder::default() + .chain(chain) + .handle(rt.handle()) + .build()?; + + let client = abstract_client::AbstractClient::new(daemon.clone())?; + + // Verify modules exist + let account = client + .account_builder() + .install_on_sub_account(false) + .namespace(USER_NAMESPACE.try_into()?) + .build()?; + + let init_msg = AppInstantiateMsg { + config: ConfigBase { + // 5 mins + autocompound_config: AutocompoundConfigBase { + cooldown_seconds: Uint64::new(300), + rewards: AutocompoundRewardsConfigBase { + reward_percent: Decimal::percent(10), + _phantom: std::marker::PhantomData, + }, + }, + dex: "osmosis".to_string(), + }, + strategy: two_strategy(), + deposit: None, + }; + + let carrot_app = account + .install_app_with_dependencies::>( + &init_msg, + Empty {}, + &[], + )?; + + // We update authorized addresses on the adapter for the app + let dex_adapter: Application> = account.application()?; + dex_adapter.execute( + &abstract_dex_adapter::msg::ExecuteMsg::Base( + abstract_app::abstract_core::adapter::BaseExecuteMsg { + proxy_address: Some(carrot_app.account().proxy()?.to_string()), + msg: abstract_app::abstract_core::adapter::AdapterBaseMsg::UpdateAuthorizedAddresses { + to_add: vec![carrot_app.addr_str()?], + to_remove: vec![], + }, + }, + ), + None, + )?; + + Ok(()) +} + +fn one_element(upper_tick: i64, lower_tick: i64, share: Decimal) -> StrategyElementUnchecked { + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: ION.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: OSMO.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id: POOL_ID, + lower_tick, + upper_tick, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share, + } +} + +pub fn single_strategy() -> StrategyUnchecked { + StrategyBase(vec![one_element( + INITIAL_UPPER_TICK, + INITIAL_LOWER_TICK, + Decimal::one(), + )]) +} + +pub fn two_strategy() -> StrategyUnchecked { + StrategyBase(vec![ + one_element(INITIAL_UPPER_TICK, INITIAL_LOWER_TICK, Decimal::percent(50)), + one_element(5000, -5000, Decimal::percent(50)), + ]) +} + +pub fn three_strategy() -> StrategyUnchecked { + StrategyBase(vec![ + one_element(INITIAL_UPPER_TICK, INITIAL_LOWER_TICK, Decimal::percent(33)), + one_element(5000, -5000, Decimal::percent(33)), + one_element(1000, -1000, Decimal::percent(34)), + ]) +} + +pub fn four_strategy() -> StrategyUnchecked { + StrategyBase(vec![ + one_element(INITIAL_UPPER_TICK, INITIAL_LOWER_TICK, Decimal::percent(25)), + one_element(5000, -5000, Decimal::percent(25)), + one_element(1000, -1000, Decimal::percent(25)), + one_element(100, -100, Decimal::percent(25)), + ]) +} + +pub fn five_strategy() -> StrategyUnchecked { + StrategyBase(vec![ + one_element(INITIAL_UPPER_TICK, INITIAL_LOWER_TICK, Decimal::percent(20)), + one_element(5000, -5000, Decimal::percent(20)), + one_element(1000, -1000, Decimal::percent(20)), + one_element(100, -100, Decimal::percent(20)), + one_element(600, -600, Decimal::percent(20)), + ]) +} diff --git a/contracts/carrot-app/examples/localnet_test.rs b/contracts/carrot-app/examples/localnet_test.rs new file mode 100644 index 00000000..76c9b314 --- /dev/null +++ b/contracts/carrot-app/examples/localnet_test.rs @@ -0,0 +1,53 @@ +use abstract_client::Application; +use cosmwasm_std::coins; +use cw_orch::{ + anyhow, + daemon::{networks::LOCAL_OSMO, Daemon, DaemonBuilder}, + tokio::runtime::Runtime, +}; +use dotenv::dotenv; + +use carrot_app::AppExecuteMsgFns; +use localnet_install::{five_strategy, four_strategy, three_strategy, USER_NAMESPACE}; + +mod localnet_install; + +fn main() -> anyhow::Result<()> { + dotenv().ok(); + env_logger::init(); + let mut chain = LOCAL_OSMO; + chain.grpc_urls = &["http://localhost:9090"]; + chain.chain_id = "osmosis-1"; + + let rt = Runtime::new()?; + let daemon = DaemonBuilder::default() + .chain(chain) + .handle(rt.handle()) + .build()?; + + let client = abstract_client::AbstractClient::new(daemon.clone())?; + + // Verify modules exist + let account = client + .account_builder() + .install_on_sub_account(false) + .namespace(USER_NAMESPACE.try_into()?) + .build()?; + + let carrot: Application> = account.application()?; + + daemon.rt_handle.block_on( + daemon + .daemon + .sender + .bank_send(account.proxy()?.as_str(), coins(10_000, "uosmo")), + )?; + + carrot.deposit(coins(10_000, "uosmo"), None)?; + + carrot.update_strategy(coins(10_000, "uosmo"), five_strategy())?; + carrot.withdraw(None)?; + carrot.deposit(coins(10_000, "uosmo"), None)?; + + Ok(()) +} diff --git a/contracts/carrot-app/src/autocompound.rs b/contracts/carrot-app/src/autocompound.rs new file mode 100644 index 00000000..b02af912 --- /dev/null +++ b/contracts/carrot-app/src/autocompound.rs @@ -0,0 +1,123 @@ +use std::marker::PhantomData; + +use abstract_sdk::{Execution, ExecutorMsg, TransferInterface}; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Coin, Decimal, Deps, Env, MessageInfo, Storage, Timestamp, Uint64}; + +use crate::check::{Checked, Unchecked}; +use crate::contract::App; +use crate::contract::AppResult; +use crate::msg::CompoundStatus; +use crate::state::{Config, AUTOCOMPOUND_STATE, CONFIG}; + +pub type AutocompoundConfig = AutocompoundConfigBase; +pub type AutocompoundConfigUnchecked = AutocompoundConfigBase; + +/// General auto-compound parameters. +/// Includes the cool down and the technical funds config +#[cw_serde] +pub struct AutocompoundConfigBase { + /// Seconds to wait before autocompound is incentivized. + /// Allows the user to configure when the auto-compound happens + pub cooldown_seconds: Uint64, + /// Configuration of rewards to the address who helped to execute autocompound + pub rewards: AutocompoundRewardsConfigBase, +} + +impl From for AutocompoundConfigUnchecked { + fn from(value: AutocompoundConfig) -> Self { + Self { + cooldown_seconds: value.cooldown_seconds, + rewards: value.rewards.into(), + } + } +} + +/// Configuration on how rewards should be distributed +/// to the address who helped to execute autocompound +#[cw_serde] +pub struct AutocompoundRewardsConfigBase { + /// Percentage of the withdraw, rewards that will be sent to the auto-compounder + pub reward_percent: Decimal, + pub _phantom: PhantomData, +} + +pub type AutocompoundRewardsConfigUnchecked = AutocompoundRewardsConfigBase; +pub type AutocompoundRewardsConfig = AutocompoundRewardsConfigBase; + +/// Autocompound related methods +impl Config { + pub fn get_executor_reward_messages( + &self, + deps: Deps, + env: &Env, + info: MessageInfo, + rewards: &[Coin], + app: &App, + ) -> AppResult { + let config = CONFIG.load(deps.storage)?; + Ok( + // If called by non-admin and reward cooldown has ended, send rewards to the contract caller. + if !app.admin.is_admin(deps, &info.sender)? + && get_autocompound_status( + deps.storage, + env, + self.autocompound_config.cooldown_seconds.u64(), + )? + .is_ready() + { + let funds: Vec = rewards + .iter() + .flat_map(|a| { + let reward_amount = + a.amount * config.autocompound_config.rewards.reward_percent; + + Some(Coin::new(reward_amount.into(), a.denom.clone())) + }) + .collect(); + ExecutorRewards { + funds: funds.clone(), + msg: Some( + app.executor(deps) + .execute(vec![app.bank(deps).transfer(funds, &info.sender)?])?, + ), + } + } else { + ExecutorRewards { + funds: vec![], + msg: None, + } + }, + ) + } +} + +pub struct ExecutorRewards { + pub funds: Vec, + pub msg: Option, +} + +#[cw_serde] +pub struct AutocompoundState { + pub last_compound: Timestamp, +} + +pub fn get_autocompound_status( + storage: &dyn Storage, + env: &Env, + cooldown_seconds: u64, +) -> AppResult { + let position = AUTOCOMPOUND_STATE.may_load(storage)?; + let status = match position { + Some(position) => { + let ready_on = position.last_compound.plus_seconds(cooldown_seconds); + if env.block.time >= ready_on { + CompoundStatus::Ready {} + } else { + CompoundStatus::Cooldown((env.block.time.seconds() - ready_on.seconds()).into()) + } + } + None => CompoundStatus::NoPosition {}, + }; + Ok(status) +} diff --git a/contracts/carrot-app/src/check.rs b/contracts/carrot-app/src/check.rs new file mode 100644 index 00000000..f8048304 --- /dev/null +++ b/contracts/carrot-app/src/check.rs @@ -0,0 +1,291 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Deps; + +use crate::contract::{App, AppResult}; + +#[cw_serde] +pub struct Checked; +#[cw_serde] +pub struct Unchecked; + +pub trait Checkable { + type CheckOutput; + fn check(self, deps: Deps, app: &App) -> AppResult; +} + +mod config { + use std::marker::PhantomData; + + use cosmwasm_std::{ensure, Decimal, Deps}; + + use crate::{ + autocompound::{ + AutocompoundConfigBase, AutocompoundRewardsConfig, AutocompoundRewardsConfigUnchecked, + }, + contract::{App, AppResult}, + error::AppError, + state::{Config, ConfigUnchecked}, + }; + + use super::Checkable; + impl From for AutocompoundRewardsConfigUnchecked { + fn from(value: AutocompoundRewardsConfig) -> Self { + Self { + reward_percent: value.reward_percent, + _phantom: PhantomData, + } + } + } + + impl AutocompoundRewardsConfigUnchecked { + pub fn check( + self, + _deps: Deps, + _app: &App, + _dex_name: &str, + ) -> AppResult { + ensure!( + self.reward_percent <= Decimal::one(), + AppError::RewardConfigError("reward percents should be lower than 100%".to_owned()) + ); + Ok(AutocompoundRewardsConfig { + reward_percent: self.reward_percent, + _phantom: PhantomData, + }) + } + } + + impl From for ConfigUnchecked { + fn from(value: Config) -> Self { + Self { + autocompound_config: value.autocompound_config.into(), + dex: value.dex, + } + } + } + + impl Checkable for ConfigUnchecked { + type CheckOutput = Config; + + fn check( + self, + deps: cosmwasm_std::Deps, + app: &crate::contract::App, + ) -> crate::contract::AppResult { + Ok(Config { + autocompound_config: AutocompoundConfigBase { + cooldown_seconds: self.autocompound_config.cooldown_seconds, + rewards: self + .autocompound_config + .rewards + .check(deps, app, &self.dex)?, + }, + dex: self.dex, + }) + } + } +} + +mod yield_sources { + use std::marker::PhantomData; + + use cosmwasm_std::{ensure, ensure_eq, Decimal, Deps}; + use cw_asset::AssetInfo; + use osmosis_std::types::osmosis::{ + concentratedliquidity::v1beta1::Pool, poolmanager::v1beta1::PoolmanagerQuerier, + }; + + use crate::{ + contract::{App, AppResult}, + error::AppError, + helpers::close_to, + yield_sources::{ + osmosis_cl_pool::{ + ConcentratedPoolParams, ConcentratedPoolParamsBase, ConcentratedPoolParamsUnchecked, + }, + yield_type::{YieldParamsBase, YieldType, YieldTypeUnchecked}, + Strategy, StrategyElement, StrategyElementUnchecked, StrategyUnchecked, YieldSource, + YieldSourceUnchecked, + }, + }; + + use super::Checkable; + + mod params { + use crate::yield_sources::mars::MarsDepositParams; + + use super::*; + impl Checkable for ConcentratedPoolParamsUnchecked { + type CheckOutput = ConcentratedPoolParams; + fn check(self, deps: Deps, _app: &App) -> AppResult { + let _pool: Pool = PoolmanagerQuerier::new(&deps.querier) + .pool(self.pool_id) + .map_err(|_| AppError::PoolNotFound {})? + .pool + .ok_or(AppError::PoolNotFound {})? + .try_into()?; + Ok(ConcentratedPoolParams { + pool_id: self.pool_id, + lower_tick: self.lower_tick, + upper_tick: self.upper_tick, + position_id: self.position_id, + _phantom: PhantomData, + }) + } + } + + impl Checkable for MarsDepositParams { + type CheckOutput = MarsDepositParams; + + fn check(self, _deps: Deps, _app: &App) -> AppResult { + Ok(self) + } + } + } + mod yield_type { + use super::*; + + impl From for YieldTypeUnchecked { + fn from(value: YieldType) -> Self { + match value { + YieldParamsBase::ConcentratedLiquidityPool(params) => { + YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id: params.pool_id, + lower_tick: params.lower_tick, + upper_tick: params.upper_tick, + position_id: params.position_id, + _phantom: std::marker::PhantomData, + }) + } + YieldParamsBase::Mars(params) => YieldParamsBase::Mars(params), + } + } + } + } + mod yield_source { + use super::*; + use abstract_app::traits::AbstractNameService; + + impl From for YieldSourceUnchecked { + fn from(value: YieldSource) -> Self { + Self { + asset_distribution: value.asset_distribution, + params: value.params.into(), + } + } + } + + impl Checkable for YieldSourceUnchecked { + type CheckOutput = YieldSource; + fn check(self, deps: Deps, app: &App) -> AppResult { + // First we check the share sums the 100 + let share_sum: Decimal = self.asset_distribution.iter().map(|e| e.share).sum(); + ensure!( + close_to(Decimal::one(), share_sum), + AppError::InvalidStrategySum { share_sum } + ); + // We make sure that assets are associated with this strategy + ensure!( + !self.asset_distribution.is_empty(), + AppError::InvalidEmptyStrategy {} + ); + // We ensure all deposited tokens exist in ANS + let all_denoms = self.all_denoms(); + let ans = app.name_service(deps); + ans.host() + .query_assets_reverse( + &deps.querier, + &all_denoms + .iter() + .map(|denom| AssetInfo::native(denom.clone())) + .collect::>(), + ) + .map_err(|_| AppError::AssetsNotRegistered(all_denoms))?; + + let params = match self.params { + YieldParamsBase::ConcentratedLiquidityPool(params) => { + // A valid CL pool strategy is for 2 assets + ensure_eq!( + self.asset_distribution.len(), + 2, + AppError::InvalidStrategy {} + ); + YieldParamsBase::ConcentratedLiquidityPool(params.check(deps, app)?) + } + YieldParamsBase::Mars(params) => { + // We verify there is only one element in the shares vector + ensure_eq!( + self.asset_distribution.len(), + 1, + AppError::InvalidStrategy {} + ); + // We verify the first element correspond to the mars deposit denom + ensure_eq!( + self.asset_distribution[0].denom, + params.denom, + AppError::InvalidStrategy {} + ); + YieldParamsBase::Mars(params.check(deps, app)?) + } + }; + + Ok(YieldSource { + asset_distribution: self.asset_distribution, + params, + }) + } + } + } + + mod strategy { + use super::*; + + impl From for StrategyElementUnchecked { + fn from(value: StrategyElement) -> Self { + Self { + yield_source: value.yield_source.into(), + share: value.share, + } + } + } + impl Checkable for StrategyElementUnchecked { + type CheckOutput = StrategyElement; + fn check(self, deps: Deps, app: &App) -> AppResult { + let yield_source = self.yield_source.check(deps, app)?; + Ok(StrategyElement { + yield_source, + share: self.share, + }) + } + } + + impl From for StrategyUnchecked { + fn from(value: Strategy) -> Self { + Self(value.0.into_iter().map(Into::into).collect()) + } + } + + impl Checkable for StrategyUnchecked { + type CheckOutput = Strategy; + fn check(self, deps: Deps, app: &App) -> AppResult { + // First we check the share sums the 100 + let share_sum: Decimal = self.0.iter().map(|e| e.share).sum(); + ensure!( + close_to(Decimal::one(), share_sum), + AppError::InvalidStrategySum { share_sum } + ); + ensure!(!self.0.is_empty(), AppError::InvalidEmptyStrategy {}); + + // Then we check every yield strategy underneath + + let checked = self + .0 + .into_iter() + .map(|yield_source| yield_source.check(deps, app)) + .collect::, _>>()?; + + Ok(checked.into()) + } + } + } +} diff --git a/contracts/carrot-app/src/contract.rs b/contracts/carrot-app/src/contract.rs index 7b3e918d..dae1f9c1 100644 --- a/contracts/carrot-app/src/contract.rs +++ b/contracts/carrot-app/src/contract.rs @@ -7,10 +7,13 @@ use crate::{ handlers, msg::{AppExecuteMsg, AppInstantiateMsg, AppMigrateMsg, AppQueryMsg}, replies::{ - add_to_position_reply, create_position_reply, ADD_TO_POSITION_ID, CREATE_POSITION_ID, + add_to_position_reply, after_swap_reply, create_position_reply, + OSMOSIS_ADD_TO_POSITION_REPLY_ID, OSMOSIS_CREATE_POSITION_REPLY_ID, REPLY_AFTER_SWAPS_STEP, }, }; +pub const OSMOSIS: &str = "osmosis"; + /// The version of your app pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// The id of the app @@ -22,8 +25,6 @@ pub type AppResult = Result; /// The type of the app that is used to build your app and access the Abstract SDK features. pub type App = AppContract; -pub(crate) const OSMOSIS: &str = "osmosis"; - const DEX_DEPENDENCY: StaticDependency = StaticDependency::new( abstract_dex_adapter::DEX_ADAPTER_ID, &[abstract_dex_adapter::contract::CONTRACT_VERSION], @@ -35,8 +36,9 @@ const APP: App = App::new(APP_ID, APP_VERSION, None) .with_query(handlers::query_handler) .with_migrate(handlers::migrate_handler) .with_replies(&[ - (CREATE_POSITION_ID, create_position_reply), - (ADD_TO_POSITION_ID, add_to_position_reply), + (OSMOSIS_CREATE_POSITION_REPLY_ID, create_position_reply), + (OSMOSIS_ADD_TO_POSITION_REPLY_ID, add_to_position_reply), + (REPLY_AFTER_SWAPS_STEP, after_swap_reply), ]) .with_dependencies(&[DEX_DEPENDENCY]); @@ -63,14 +65,33 @@ impl abstract_app::abstract_interface::Depen as abstract_app::abstract_interface::DependencyCreation>::dependency_install_configs( cosmwasm_std::Empty {}, )?; + // let moneymarket_dependency_install_configs: Vec = + // as abstract_app::abstract_interface::DependencyCreation>::dependency_install_configs( + // cosmwasm_std::Empty {}, + // )?; + + let adapter_install_config = vec![ + abstract_app::abstract_core::manager::ModuleInstallConfig::new( + abstract_app::abstract_core::objects::module::ModuleInfo::from_id( + abstract_dex_adapter::DEX_ADAPTER_ID, + abstract_dex_adapter::contract::CONTRACT_VERSION.into(), + )?, + None, + ), + // abstract_app::abstract_core::manager::ModuleInstallConfig::new( + // abstract_app::abstract_core::objects::module::ModuleInfo::from_id( + // abstract_money_market_adapter::MONEY_MARKET_ADAPTER_ID, + // abstract_money_market_adapter::contract::CONTRACT_VERSION.into(), + // )?, + // None, + // ), + ]; - let adapter_install_config = abstract_app::abstract_core::manager::ModuleInstallConfig::new( - abstract_app::abstract_core::objects::module::ModuleInfo::from_id( - abstract_dex_adapter::DEX_ADAPTER_ID, - abstract_dex_adapter::contract::CONTRACT_VERSION.into(), - )?, - None, - ); - Ok([dex_dependency_install_configs, vec![adapter_install_config]].concat()) + Ok([ + dex_dependency_install_configs, + // moneymarket_dependency_install_configs, + adapter_install_config, + ] + .concat()) } } diff --git a/contracts/carrot-app/src/distribution/deposit.rs b/contracts/carrot-app/src/distribution/deposit.rs new file mode 100644 index 00000000..5c949278 --- /dev/null +++ b/contracts/carrot-app/src/distribution/deposit.rs @@ -0,0 +1,363 @@ +use std::collections::HashMap; + +use cosmwasm_std::{coin, Coin, Coins, Decimal, Deps, Uint128}; + +use crate::{ + contract::{App, AppResult}, + exchange_rate::query_all_exchange_rates, + helpers::{compute_total_value, compute_value}, + yield_sources::{yield_type::YieldType, AssetShare, Strategy, StrategyElement}, +}; + +use cosmwasm_schema::cw_serde; + +use crate::{error::AppError, msg::InternalExecuteMsg}; + +/// This functions creates the current deposit strategy +// /// 1. We query the target strategy in storage (target strategy) +// /// 2. We query the current status of the strategy (current strategy) from all deposits (external queries) +// /// 3. We create a temporary strategy object that allocates the funds from this deposit into the various strategies +// /// 4. We correct the expected token shares of each strategy, in case there are corrections passed to the function +// /// 5. We deposit funds according to that strategy +pub fn generate_deposit_strategy( + deps: Deps, + funds: Vec, + target_strategy: Strategy, + yield_source_params: Option>>>, + app: &App, +) -> AppResult<(Vec<(StrategyElement, Decimal)>, Vec)> { + // This is the current distribution of funds inside the strategies + let current_strategy_status = target_strategy.query_current_status(deps, app)?; + + let mut usable_funds: Coins = funds.try_into()?; + let (withdraw_strategy, mut this_deposit_strategy) = target_strategy.current_deposit_strategy( + deps, + &mut usable_funds, + current_strategy_status, + app, + )?; + + // We correct it if the user asked to correct the share parameters of each strategy + this_deposit_strategy.correct_with(yield_source_params); + + // We fill the strategies with the current deposited funds and get messages to execute those deposits + let deposit_msgs = + this_deposit_strategy.fill_all_and_get_messages(deps, usable_funds.into(), app)?; + + Ok((withdraw_strategy, deposit_msgs)) +} + +impl Strategy { + // We determine the best balance strategy depending on the current deposits and the target strategy. + // This method needs to be called on the stored strategy + // We error if deposit value is non-zero here + pub fn current_deposit_strategy( + &self, + deps: Deps, + funds: &mut Coins, + current_strategy_status: Self, + app: &App, + ) -> AppResult<(Vec<(StrategyElement, Decimal)>, Self)> { + let total_value = self.current_balance(deps, app)?.total_value; + let deposit_value = compute_value(deps, &funds.to_vec(), app)?; + + if (total_value + deposit_value).is_zero() { + return Err(AppError::NoDeposit {}); + } + + // We create the strategy so that he final distribution is as close to the target strategy as possible + // 1. For all strategies, we withdraw some if its value is too high above target_strategy + let mut withdraw_value = Uint128::zero(); + + // All strategies have to be reviewed + // EITHER of those are true : + // - The yield source has too much funds deposited and some should be withdrawn + // OR + // - Some funds need to be deposited into the strategy + + // First we generate the messages for withdrawing strategies that have too much funds + let withdraw_strategy: Vec<(StrategyElement, Decimal)> = current_strategy_status + .0 + .iter() + .zip(self.0.clone()) + .map(|(current, target)| { + // We need to take into account the total value added by the current shares + let value_now = current.share * total_value; + let target_value = target.share * (total_value + deposit_value); + + // If value now is greater than the target value, we need to withdraw some funds from the protocol + if target_value < value_now { + let this_withdraw_value = value_now - target_value; + // In the following line, total_value can't be zero, otherwise the if condition wouldn't be met + let this_withdraw_share = Decimal::from_ratio(this_withdraw_value, total_value); + let this_withdraw_funds = + current.withdraw_preview(deps, Some(this_withdraw_share), app)?; + withdraw_value += this_withdraw_value; + for fund in this_withdraw_funds { + funds.add(fund)?; + } + + // In case there is a withdraw from the strategy, we don't need to deposit into this strategy after ! + Ok::<_, AppError>(Some((current.clone(), this_withdraw_share))) + } else { + Ok(None) + } + }) + .collect::, _>>()? + .into_iter() + .flatten() + .collect::>(); + + let available_value = withdraw_value + deposit_value; + + let this_deposit_strategy: Strategy = current_strategy_status + .0 + .into_iter() + .zip(self.0.clone()) + .map(|(current, target)| { + // We need to take into account the total value added by the current shares + let value_now = current.share * total_value; + let target_value = target.share * (total_value + deposit_value); + + // If value now is smaller than the target value, we need to deposit some funds into the protocol + let share = if target_value < value_now { + Decimal::zero() + } else { + // In case we don't withdraw anything, it means we might deposit. + if available_value.is_zero() { + Decimal::zero() + } else { + Decimal::from_ratio(target_value - value_now, available_value) + } + }; + StrategyElement { + yield_source: current.yield_source.clone(), + share, + } + }) + .collect::>() + .into(); + + Ok((withdraw_strategy, this_deposit_strategy)) + } + + // We dispatch the available funds directly into the Strategies + // This returns : + // 0 : Funds that are used for specific strategies. And remaining amounts to fill those strategies + // 1 : Funds that are still available to fill those strategies + // This is the algorithm that is implemented here + fn fill_sources( + &self, + funds: Vec, + exchange_rates: &HashMap, + ) -> AppResult<(StrategyStatus, Coins)> { + let total_value = compute_total_value(&funds, exchange_rates)?; + let mut remaining_funds = Coins::default(); + + // We create the vector that holds the funds information + let mut yield_source_status = self + .0 + .iter() + .map(|source| { + source + .yield_source + .asset_distribution + .iter() + .map(|AssetShare { denom, share }| { + // Amount to fill this denom completely is value / exchange_rate + // Value we want to put here is share * source.share * total_value + Ok::<_, AppError>(StrategyStatusElement { + denom: denom.clone(), + raw_funds: Uint128::zero(), + remaining_amount: (share * source.share + / exchange_rates + .get(denom) + .ok_or(AppError::NoExchangeRate(denom.clone()))?) + * total_value, + }) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + + for this_coin in funds { + let mut remaining_amount = this_coin.amount; + // We distribute those funds in to the accepting strategies + for (strategy, status) in self.0.iter().zip(yield_source_status.iter_mut()) { + // Find the share for the specific denom inside the strategy + let this_denom_status = strategy + .yield_source + .asset_distribution + .iter() + .zip(status.iter_mut()) + .find(|(AssetShare { denom, share: _ }, _status)| this_coin.denom.eq(denom)) + .map(|(_, status)| status); + + if let Some(status) = this_denom_status { + // We fill the needed value with the remaining_amount + let funds_to_use_here = remaining_amount.min(status.remaining_amount); + + // Those funds are not available for other yield sources + remaining_amount -= funds_to_use_here; + + status.raw_funds += funds_to_use_here; + status.remaining_amount -= funds_to_use_here; + } + } + remaining_funds.add(coin(remaining_amount.into(), this_coin.denom))?; + } + + Ok((yield_source_status.into(), remaining_funds)) + } + + fn fill_all( + &self, + deps: Deps, + funds: Vec, + app: &App, + ) -> AppResult> { + // We determine the value of all tokens that will be used inside this function + let exchange_rates = query_all_exchange_rates( + deps, + funds + .iter() + .map(|f| f.denom.clone()) + .chain(self.all_denoms()), + app, + )?; + let (status, remaining_funds) = self.fill_sources(funds, &exchange_rates)?; + status.fill_with_remaining_funds(remaining_funds, &exchange_rates) + } + + /// Gets the deposit messages from a given strategy by filling all strategies with the associated funds + pub fn fill_all_and_get_messages( + &self, + deps: Deps, + funds: Vec, + app: &App, + ) -> AppResult> { + let deposit_strategies = self.fill_all(deps, funds, app)?; + Ok(deposit_strategies + .iter() + .zip(self.0.iter().map(|s| s.yield_source.params.clone())) + .enumerate() + .map(|(index, (strategy, yield_type))| strategy.deposit_msgs(index, yield_type)) + .collect()) + } + + /// Corrects the current strategy with some parameters passed by the user + pub fn correct_with(&mut self, params: Option>>>) { + // We correct the strategy if specified in parameters + if let Some(params) = params { + self.0.iter_mut().zip(params).for_each(|(strategy, param)| { + if let Some(param) = param { + strategy.yield_source.asset_distribution = param; + } + }) + } + } +} + +#[cw_serde] +struct StrategyStatusElement { + pub denom: String, + pub raw_funds: Uint128, + pub remaining_amount: Uint128, +} + +/// This contains information about the strategy status +/// AFTER filling with unrelated coins +/// Before filling with related coins +#[cw_serde] +struct StrategyStatus(pub Vec>); + +impl From>> for StrategyStatus { + fn from(value: Vec>) -> Self { + Self(value) + } +} + +impl StrategyStatus { + pub fn fill_with_remaining_funds( + &self, + mut funds: Coins, + exchange_rates: &HashMap, + ) -> AppResult> { + self.0 + .iter() + .map(|f| { + f.clone() + .iter_mut() + .map(|status| { + let mut swaps = vec![]; + for fund in funds.to_vec() { + let direct_e_r = exchange_rates + .get(&fund.denom) + .ok_or(AppError::NoExchangeRate(fund.denom.clone()))? + / exchange_rates + .get(&status.denom) + .ok_or(AppError::NoExchangeRate(status.denom.clone()))?; + let available_coin_in_destination_amount = fund.amount * direct_e_r; + + let fill_amount = + available_coin_in_destination_amount.min(status.remaining_amount); + + let swap_in_amount = fill_amount * (Decimal::one() / direct_e_r); + + if swap_in_amount != Uint128::zero() { + status.remaining_amount -= fill_amount; + let swap_funds = coin(swap_in_amount.into(), fund.denom); + funds.sub(swap_funds.clone())?; + swaps.push(DepositStep::Swap { + asset_in: swap_funds, + denom_out: status.denom.clone(), + expected_amount: fill_amount, + }); + } + } + if !status.raw_funds.is_zero() { + swaps.push(DepositStep::UseFunds { + asset: coin(status.raw_funds.into(), status.denom.clone()), + }) + } + + Ok::<_, AppError>(swaps) + }) + .collect::, _>>() + .map(Into::into) + }) + .collect::, _>>() + } +} + +#[cw_serde] +pub enum DepositStep { + Swap { + asset_in: Coin, + denom_out: String, + expected_amount: Uint128, + }, + UseFunds { + asset: Coin, + }, +} + +#[cw_serde] +pub struct OneDepositStrategy(pub Vec>); + +impl From>> for OneDepositStrategy { + fn from(value: Vec>) -> Self { + Self(value) + } +} + +impl OneDepositStrategy { + pub fn deposit_msgs(&self, yield_index: usize, yield_type: YieldType) -> InternalExecuteMsg { + // For each strategy, we send a message on the contract to execute it + InternalExecuteMsg::DepositOneStrategy { + swap_strategy: self.clone(), + yield_type, + yield_index, + } + } +} diff --git a/contracts/carrot-app/src/distribution/mod.rs b/contracts/carrot-app/src/distribution/mod.rs new file mode 100644 index 00000000..f3e36243 --- /dev/null +++ b/contracts/carrot-app/src/distribution/mod.rs @@ -0,0 +1,18 @@ +/// This module handles the strategy distribution. It handles the following cases +/// 1. A user deposits some funds. +/// This modules dispatches the funds into the different strategies according to current status and target strats +pub mod deposit; + +/// 2. A user want to withdraw some funds +/// This module withdraws a share of the funds deposited inside the registered strategies +pub mod withdraw; + +/// 3. A user wants to claim their rewards and autocompound +/// This module compute the available rewards and withdraws the rewards from the registered strategies +pub mod rewards; + +/// 4. Some queries are needed on certain structures for abstraction purposes +pub mod query; + +/// 4. Some queries are needed on certain structures for abstraction purposes +pub mod rebalance; diff --git a/contracts/carrot-app/src/distribution/query.rs b/contracts/carrot-app/src/distribution/query.rs new file mode 100644 index 00000000..67260fb6 --- /dev/null +++ b/contracts/carrot-app/src/distribution/query.rs @@ -0,0 +1,136 @@ +use cosmwasm_std::{Coins, Decimal, Deps, Uint128}; + +use crate::{ + contract::{App, AppResult}, + error::AppError, + exchange_rate::query_exchange_rate, + msg::AssetsBalanceResponse, + yield_sources::{ + yield_type::YieldTypeImplementation, AssetShare, Strategy, StrategyElement, YieldSource, + }, +}; + +impl Strategy { + // Returns the total balance + pub fn current_balance(&self, deps: Deps, app: &App) -> AppResult { + let mut funds = Coins::default(); + let mut total_value = Uint128::zero(); + self.0.iter().try_for_each(|s| { + let deposit_value = s + .yield_source + .params + .user_deposit(deps, app) + .unwrap_or_default(); + for fund in deposit_value { + let exchange_rate = query_exchange_rate(deps, fund.denom.clone(), app)?; + funds.add(fund.clone())?; + total_value += fund.amount * exchange_rate; + } + Ok::<_, AppError>(()) + })?; + + Ok(AssetsBalanceResponse { + balances: funds.into(), + total_value, + }) + } + + /// Returns the current status of the full strategy. It returns shares reflecting the underlying positions + pub fn query_current_status(&self, deps: Deps, app: &App) -> AppResult { + let all_strategy_values = self + .0 + .iter() + .map(|s| s.query_current_value(deps, app)) + .collect::, _>>()?; + + let all_strategies_value: Uint128 = + all_strategy_values.iter().map(|(value, _)| value).sum(); + + // If there is no value, the current status is the stored strategy + if all_strategies_value.is_zero() { + return Ok(self.clone()); + } + + // Finally, we dispatch the total_value to get investment shares + Ok(self + .0 + .iter() + .zip(all_strategy_values) + .map(|(original_strategy, (value, shares))| StrategyElement { + yield_source: YieldSource { + asset_distribution: shares, + params: original_strategy.yield_source.params.clone(), + }, + share: Decimal::from_ratio(value, all_strategies_value), + }) + .collect::>() + .into()) + } + + /// This function applies the underlying shares inside yield sources to each yield source depending on the current strategy state + pub fn apply_current_strategy_shares(&mut self, deps: Deps, app: &App) -> AppResult<()> { + self.0.iter_mut().try_for_each(|yield_source| { + match yield_source.yield_source.params.share_type() { + crate::yield_sources::ShareType::Dynamic => { + let (_total_value, shares) = yield_source.query_current_value(deps, app)?; + yield_source.yield_source.asset_distribution = shares; + } + crate::yield_sources::ShareType::Fixed => {} + }; + + Ok::<_, AppError>(()) + })?; + Ok(()) + } +} + +impl StrategyElement { + /// Queries the current value distribution of a registered strategy. + /// If there is no deposit or the query for the user deposit value fails + /// the function returns 0 value with the registered asset distribution + pub fn query_current_value( + &self, + deps: Deps, + app: &App, + ) -> AppResult<(Uint128, Vec)> { + // If there is no deposit + let user_deposit = match self.yield_source.params.user_deposit(deps, app) { + Ok(deposit) => deposit, + Err(_) => { + return Ok(( + Uint128::zero(), + self.yield_source.asset_distribution.clone(), + )) + } + }; + + // From this, we compute the shares within the investment + let each_value = user_deposit + .iter() + .map(|fund| { + let exchange_rate = query_exchange_rate(deps, fund.denom.clone(), app)?; + + Ok::<_, AppError>((fund.denom.clone(), exchange_rate * fund.amount)) + }) + .collect::, _>>()?; + + let total_value: Uint128 = each_value.iter().map(|(_denom, amount)| amount).sum(); + + // If there is no value, the current status is the stored strategy + if total_value.is_zero() { + return Ok(( + Uint128::zero(), + self.yield_source.asset_distribution.clone(), + )); + } + + let each_shares = each_value + .into_iter() + .map(|(denom, amount)| AssetShare { + denom, + share: Decimal::from_ratio(amount, total_value), + }) + .collect(); + Ok((total_value, each_shares)) + } +} diff --git a/contracts/carrot-app/src/distribution/rebalance.rs b/contracts/carrot-app/src/distribution/rebalance.rs new file mode 100644 index 00000000..a0f5ea6d --- /dev/null +++ b/contracts/carrot-app/src/distribution/rebalance.rs @@ -0,0 +1,8 @@ +use crate::yield_sources::Strategy; + +/// In order to re-balance the strategies, we need in order : +/// 1. Withdraw from the strategies that will be deleted +/// 2. Compute the total value that should land in each strategy +/// 3. Withdraw from strategies that have too much value +/// 4. Deposit all the withdrawn funds into the strategies to match the target. +impl Strategy {} diff --git a/contracts/carrot-app/src/distribution/rewards.rs b/contracts/carrot-app/src/distribution/rewards.rs new file mode 100644 index 00000000..b416c582 --- /dev/null +++ b/contracts/carrot-app/src/distribution/rewards.rs @@ -0,0 +1,34 @@ +use abstract_sdk::{AccountAction, Execution, ExecutorMsg}; +use cosmwasm_std::{Coin, Deps}; + +use crate::{ + contract::{App, AppResult}, + error::AppError, + yield_sources::{yield_type::YieldTypeImplementation, Strategy}, +}; + +impl Strategy { + pub fn withdraw_rewards( + self, + deps: Deps, + app: &App, + ) -> AppResult<(Vec, Vec)> { + let (rewards, msgs): (Vec>, _) = self + .0 + .into_iter() + .map(|s| { + let (rewards, raw_msgs) = s.yield_source.params.withdraw_rewards(deps, app)?; + + Ok::<_, AppError>(( + rewards, + app.executor(deps) + .execute(vec![AccountAction::from_vec(raw_msgs)])?, + )) + }) + .collect::, _>>()? + .into_iter() + .unzip(); + + Ok((rewards.into_iter().flatten().collect(), msgs)) + } +} diff --git a/contracts/carrot-app/src/distribution/withdraw.rs b/contracts/carrot-app/src/distribution/withdraw.rs new file mode 100644 index 00000000..f68eb9c0 --- /dev/null +++ b/contracts/carrot-app/src/distribution/withdraw.rs @@ -0,0 +1,84 @@ +use abstract_sdk::{AccountAction, Execution, ExecutorMsg}; +use cosmwasm_std::{Coin, Coins, Decimal, Deps}; + +use crate::{ + contract::{App, AppResult}, + error::AppError, + yield_sources::{yield_type::YieldTypeImplementation, Strategy, StrategyElement}, +}; + +impl Strategy { + pub fn withdraw( + self, + deps: Deps, + withdraw_share: Option, + app: &App, + ) -> AppResult> { + self.0 + .into_iter() + .map(|s| s.withdraw(deps, withdraw_share, app)) + .collect() + } + pub fn withdraw_preview( + self, + deps: Deps, + withdraw_share: Option, + app: &App, + ) -> AppResult> { + let mut withdraw_result = Coins::default(); + self.0.into_iter().try_for_each(|s| { + let funds = s.withdraw_preview(deps, withdraw_share, app)?; + funds.into_iter().try_for_each(|f| withdraw_result.add(f))?; + Ok::<_, AppError>(()) + })?; + Ok(withdraw_result.into()) + } +} + +impl StrategyElement { + pub fn withdraw( + self, + deps: Deps, + withdraw_share: Option, + app: &App, + ) -> AppResult { + let this_withdraw_amount = withdraw_share + .map(|share| { + let this_amount = self.yield_source.params.user_liquidity(deps, app)?; + let this_withdraw_amount = share * this_amount; + + Ok::<_, AppError>(this_withdraw_amount) + }) + .transpose()?; + let raw_msg = self + .yield_source + .params + .withdraw(deps, this_withdraw_amount, app)?; + + Ok::<_, AppError>( + app.executor(deps) + .execute(vec![AccountAction::from_vec(raw_msg)])?, + ) + } + + pub fn withdraw_preview( + &self, + deps: Deps, + withdraw_share: Option, + app: &App, + ) -> AppResult> { + let current_deposit = self.yield_source.params.user_deposit(deps, app)?; + + if let Some(share) = withdraw_share { + Ok(current_deposit + .into_iter() + .map(|funds| Coin { + denom: funds.denom, + amount: funds.amount * share, + }) + .collect()) + } else { + Ok(current_deposit) + } + } +} diff --git a/contracts/carrot-app/src/error.rs b/contracts/carrot-app/src/error.rs index 1c9f18a0..7232c2f4 100644 --- a/contracts/carrot-app/src/error.rs +++ b/contracts/carrot-app/src/error.rs @@ -42,6 +42,9 @@ pub enum AppError { #[error("Unauthorized")] Unauthorized {}, + #[error("Sender not contract. Only the contract can execute internal calls")] + SenderNotContract {}, + #[error("Wrong denom deposited, expected exactly {expected}, got {got:?}")] DepositError { expected: AssetInfo, got: Vec }, @@ -51,6 +54,12 @@ pub enum AppError { #[error("No position registered in contract, please create a position !")] NoPosition {}, + #[error("Deposit pool was not found")] + PoolNotFound {}, + + #[error("Deposit assets were not found in Abstract ANS : {0:?}")] + AssetsNotRegistered(Vec), + #[error("No swap fund to swap assets into each other")] NoSwapPossibility {}, @@ -73,4 +82,25 @@ pub enum AppError { #[error("Operation exceeds max spread limit, price: {price}")] MaxSpreadAssertion { price: Decimal }, + + #[error( + "The given strategy is not valid, the sum of share : {} is not 1", + share_sum + )] + InvalidStrategySum { share_sum: Decimal }, + + #[error("The given strategy is not valid, there must be at least one element")] + InvalidEmptyStrategy {}, + + #[error("Exchange Rate not given for {0}")] + NoExchangeRate(String), + + #[error("Deposited total value is zero")] + NoDeposit {}, + + #[error("Wrong yield type when executing internal operations")] + WrongYieldType {}, + + #[error("Invalid strategy format, check shares and parameters")] + InvalidStrategy {}, } diff --git a/contracts/carrot-app/src/exchange_rate.rs b/contracts/carrot-app/src/exchange_rate.rs new file mode 100644 index 00000000..649f61f6 --- /dev/null +++ b/contracts/carrot-app/src/exchange_rate.rs @@ -0,0 +1,26 @@ +use std::collections::HashMap; + +use cosmwasm_std::{Decimal, Deps}; + +use crate::contract::{App, AppResult}; + +pub fn query_exchange_rate( + _deps: Deps, + _denom: impl Into, + _app: &App, +) -> AppResult { + // In the first iteration, all deposited tokens are assumed to be equal to 1 + Ok(Decimal::one()) +} + +// Returns a hashmap with all request exchange rates +pub fn query_all_exchange_rates( + deps: Deps, + denoms: impl Iterator, + app: &App, +) -> AppResult> { + denoms + .into_iter() + .map(|denom| Ok((denom.clone(), query_exchange_rate(deps, denom, app)?))) + .collect() +} diff --git a/contracts/carrot-app/src/handlers/execute.rs b/contracts/carrot-app/src/handlers/execute.rs index 9998d021..e05868af 100644 --- a/contracts/carrot-app/src/handlers/execute.rs +++ b/contracts/carrot-app/src/handlers/execute.rs @@ -1,31 +1,22 @@ -use super::swap_helpers::{swap_msg, swap_to_enter_position}; +use super::internal::execute_internal_action; use crate::{ - contract::{App, AppResult, OSMOSIS}, + autocompound::AutocompoundState, + check::Checkable, + contract::{App, AppResult}, + distribution::deposit::generate_deposit_strategy, error::AppError, - helpers::{get_balance, get_user}, - msg::{AppExecuteMsg, CreatePositionMessage, ExecuteMsg}, - replies::{ADD_TO_POSITION_ID, CREATE_POSITION_ID}, - state::{ - assert_contract, get_osmosis_position, get_position, get_position_status, Config, CONFIG, - }, + handlers::query::query_balance, + helpers::assert_contract, + msg::{AppExecuteMsg, ExecuteMsg}, + state::{AUTOCOMPOUND_STATE, CONFIG, STRATEGY_CONFIG}, + yield_sources::{AssetShare, Strategy, StrategyUnchecked}, }; -use abstract_app::abstract_sdk::AuthZInterface; -use abstract_app::{abstract_sdk::features::AbstractResponse, objects::AnsAsset}; -use abstract_dex_adapter::DexInterface; -use abstract_sdk::{features::AbstractNameService, Resolve}; +use abstract_app::abstract_sdk::features::AbstractResponse; +use abstract_sdk::ExecutorMsg; use cosmwasm_std::{ - to_json_binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Env, MessageInfo, SubMsg, Uint128, + to_json_binary, Coin, Coins, CosmosMsg, Decimal, Deps, DepsMut, Env, MessageInfo, Uint128, WasmMsg, }; -use cw_asset::Asset; -use osmosis_std::{ - cosmwasm_to_proto_coins, try_proto_to_cosmwasm_coins, - types::osmosis::concentratedliquidity::v1beta1::{ - MsgAddToPosition, MsgCollectIncentives, MsgCollectSpreadRewards, MsgCreatePosition, - MsgWithdrawPosition, - }, -}; -use std::str::FromStr; pub fn execute_handler( deps: DepsMut, @@ -35,113 +26,54 @@ pub fn execute_handler( msg: AppExecuteMsg, ) -> AppResult { match msg { - AppExecuteMsg::CreatePosition(create_position_msg) => { - create_position(deps, env, info, app, create_position_msg) - } AppExecuteMsg::Deposit { funds, - max_spread, - belief_price0, - belief_price1, - } => deposit( - deps, - env, - info, - funds, - max_spread, - belief_price0, - belief_price1, - app, - ), - AppExecuteMsg::Withdraw { amount } => withdraw(deps, env, info, Some(amount), app), - AppExecuteMsg::WithdrawAll {} => withdraw(deps, env, info, None, app), + yield_sources_params, + } => deposit(deps, env, info, funds, yield_sources_params, app), + AppExecuteMsg::Withdraw { amount } => withdraw(deps, env, info, amount, app), AppExecuteMsg::Autocompound {} => autocompound(deps, env, info, app), + AppExecuteMsg::UpdateStrategy { strategy, funds } => { + update_strategy(deps, env, info, strategy, funds, app) + } + // Endpoints called by the contract directly + AppExecuteMsg::Internal(internal_msg) => { + assert_contract(&info, &env)?; + execute_internal_action(deps, env, internal_msg, app) + } } } -/// In this function, we want to create a new position for the user. -/// This operation happens in multiple steps: -/// 1. Withdraw a potential existing position and add the funds to the current position being created -/// 2. Create a new position using the existing funds (if any) + the funds that the user wishes to deposit additionally -fn create_position( - deps: DepsMut, - env: Env, - info: MessageInfo, - app: App, - create_position_msg: CreatePositionMessage, -) -> AppResult { - // TODO verify authz permissions before creating the position - app.admin.assert_admin(deps.as_ref(), &info.sender)?; - // We start by checking if there is already a position - if get_osmosis_position(deps.as_ref()).is_ok() { - return Err(AppError::PositionExists {}); - // If the position still has incentives to claim, the user is able to override it - }; - - let (swap_messages, create_position_msg) = - _create_position(deps.as_ref(), &env, &app, create_position_msg)?; - - Ok(app - .response("create_position") - .add_messages(swap_messages) - .add_submessage(create_position_msg)) -} - -#[allow(clippy::too_many_arguments)] fn deposit( deps: DepsMut, env: Env, info: MessageInfo, funds: Vec, - max_spread: Option, - belief_price0: Option, - belief_price1: Option, + yield_source_params: Option>>>, app: App, ) -> AppResult { - // Only the admin (manager contracts or account owner) + the smart contract can deposit + // Only the admin (manager contracts or account owner) can deposit as well as the contract itself app.admin .assert_admin(deps.as_ref(), &info.sender) .or(assert_contract(&info, &env))?; - let pool = get_osmosis_position(deps.as_ref())?; - let position = pool.position.unwrap(); - - let asset0 = try_proto_to_cosmwasm_coins(pool.asset0.clone())?[0].clone(); - let asset1 = try_proto_to_cosmwasm_coins(pool.asset1.clone())?[0].clone(); - - // When depositing, we start by adapting the available funds to the expected pool funds ratio - // We do so by computing the swap information - - let (swap_msgs, resulting_assets) = swap_to_enter_position( + let target_strategy = STRATEGY_CONFIG.load(deps.storage)?; + let deposit_msgs = _inner_deposit( deps.as_ref(), &env, funds, + target_strategy, + yield_source_params, &app, - asset0, - asset1, - max_spread, - belief_price0, - belief_price1, )?; - let user = get_user(deps.as_ref(), &app)?; - - let deposit_msg = app.auth_z(deps.as_ref(), Some(user.clone()))?.execute( - &env.contract.address, - MsgAddToPosition { - position_id: position.position_id, - sender: user.to_string(), - amount0: resulting_assets[0].amount.to_string(), - amount1: resulting_assets[1].amount.to_string(), - token_min_amount0: "0".to_string(), - token_min_amount1: "0".to_string(), + AUTOCOMPOUND_STATE.save( + deps.storage, + &AutocompoundState { + last_compound: env.block.time, }, - ); + )?; - Ok(app - .response("deposit") - .add_messages(swap_msgs) - .add_submessage(SubMsg::reply_on_success(deposit_msg, ADD_TO_POSITION_ID))) + Ok(app.response("deposit").add_messages(deposit_msgs)) } fn withdraw( @@ -154,299 +86,172 @@ fn withdraw( // Only the authorized addresses (admin ?) can withdraw app.admin.assert_admin(deps.as_ref(), &info.sender)?; - let (withdraw_msg, withdraw_amount, total_amount, _withdrawn_funds) = - _inner_withdraw(deps, &env, amount, &app)?; + let msgs = _inner_withdraw(deps, &env, amount, &app)?; - Ok(app - .response("withdraw") - .add_attribute("withdraw_amount", withdraw_amount) - .add_attribute("total_amount", total_amount) - .add_message(withdraw_msg)) + Ok(app.response("withdraw").add_messages(msgs)) } -/// Auto-compound the position with earned fees and incentives. +fn update_strategy( + deps: DepsMut, + env: Env, + _info: MessageInfo, + strategy: StrategyUnchecked, + funds: Vec, + app: App, +) -> AppResult { + // We load it raw because we're changing the strategy + let old_strategy = STRATEGY_CONFIG.load(deps.storage)?; + + // We check the new strategy + let strategy = strategy.check(deps.as_ref(), &app)?; + + // We execute operations to rebalance the funds between the strategies + let mut available_funds: Coins = funds.try_into()?; + // 1. We withdraw all yield_sources that are not included in the new strategies + let all_stale_sources: Vec<_> = old_strategy + .0 + .into_iter() + .filter(|x| !strategy.0.contains(x)) + .collect(); + + let (withdrawn_funds, withdraw_msgs): (Vec>, Vec>) = + all_stale_sources + .into_iter() + .map(|s| { + Ok::<_, AppError>(( + s.withdraw_preview(deps.as_ref(), None, &app) + .unwrap_or_default(), + s.withdraw(deps.as_ref(), None, &app).ok(), + )) + }) + .collect::, _>>()? + .into_iter() + .unzip(); -fn autocompound(deps: DepsMut, env: Env, info: MessageInfo, app: App) -> AppResult { - // Everyone can autocompound + withdrawn_funds + .into_iter() + .try_for_each(|f| f.into_iter().try_for_each(|f| available_funds.add(f)))?; - let position = get_osmosis_position(deps.as_ref())?; - let position_details = position.position.unwrap(); + // 2. We replace the strategy with the new strategy + STRATEGY_CONFIG.save(deps.storage, &strategy)?; - let mut rewards = cosmwasm_std::Coins::default(); - let mut collect_rewards_msgs = vec![]; + // 3. We deposit the funds into the new strategy + let deposit_msgs = _inner_deposit( + deps.as_ref(), + &env, + available_funds.into(), + strategy, + None, + &app, + )?; - // Get app's user and set up authz. - let user = get_user(deps.as_ref(), &app)?; - let authz = app.auth_z(deps.as_ref(), Some(user.clone()))?; + Ok(app + .response("rebalance") + .add_messages( + withdraw_msgs + .into_iter() + .flatten() + .collect::>(), + ) + .add_messages(deposit_msgs)) +} - // If there are external incentives, claim them. - if !position.claimable_incentives.is_empty() { - for coin in try_proto_to_cosmwasm_coins(position.claimable_incentives)? { - rewards.add(coin)?; - } - collect_rewards_msgs.push(authz.execute( - &env.contract.address, - MsgCollectIncentives { - position_ids: vec![position_details.position_id], - sender: user.to_string(), - }, - )); - } +// /// Auto-compound the position with earned fees and incentives. - // If there is income from swap fees, claim them. - if !position.claimable_spread_rewards.is_empty() { - for coin in try_proto_to_cosmwasm_coins(position.claimable_spread_rewards)? { - rewards.add(coin)?; - } - collect_rewards_msgs.push(authz.execute( - &env.contract.address, - MsgCollectSpreadRewards { - position_ids: vec![position_details.position_id], - sender: position_details.address.clone(), - }, - )) - } +fn autocompound(deps: DepsMut, env: Env, info: MessageInfo, app: App) -> AppResult { + let config = CONFIG.load(deps.storage)?; + // Everyone can autocompound + let strategy = STRATEGY_CONFIG.load(deps.storage)?; + + // We withdraw all rewards from protocols + let (all_rewards, collect_rewards_msgs) = strategy.withdraw_rewards(deps.as_ref(), &app)?; // If there are no rewards, we can't do anything - if rewards.is_empty() { + if all_rewards.is_empty() { return Err(crate::error::AppError::NoRewards {}); } + // We reward the caller of this endpoint with some funds + let executor_rewards = + config.get_executor_reward_messages(deps.as_ref(), &env, info, &all_rewards, &app)?; + + let mut all_rewards: Coins = all_rewards.try_into()?; + + for f in executor_rewards.funds { + all_rewards.sub(f)?; + } + // Finally we deposit of all rewarded tokens into the position let msg_deposit = CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: env.contract.address.to_string(), msg: to_json_binary(&ExecuteMsg::Module(AppExecuteMsg::Deposit { - funds: rewards.into(), - max_spread: None, - belief_price0: None, - belief_price1: None, + funds: all_rewards.into(), + yield_sources_params: None, }))?, funds: vec![], }); - let mut response = app + let response = app .response("auto-compound") .add_messages(collect_rewards_msgs) .add_message(msg_deposit); - // If called by non-admin and reward cooldown has ended, send rewards to the contract caller. - let config = CONFIG.load(deps.storage)?; - if !app.admin.is_admin(deps.as_ref(), &info.sender)? - && get_position_status( - deps.storage, - &env, - config.autocompound_cooldown_seconds.u64(), - )? - .is_ready() - { - let executor_reward_messages = autocompound_executor_rewards( - deps.as_ref(), - &env, - info.sender.into_string(), - &app, - config, - )?; - - response = response.add_messages(executor_reward_messages); - } - - Ok(response) -} - -fn _inner_withdraw( - deps: DepsMut, - env: &Env, - amount: Option, - app: &App, -) -> AppResult<(CosmosMsg, String, String, Vec)> { - let position = get_osmosis_position(deps.as_ref())?; - let position_details = position.position.unwrap(); - - let total_liquidity = position_details.liquidity.replace('.', ""); - - let liquidity_amount = if let Some(amount) = amount { - amount.to_string() - } else { - // TODO: it's decimals inside contracts - total_liquidity.clone() - }; - let user = get_user(deps.as_ref(), app)?; - - // We need to execute withdraw on the user's behalf - let msg = app.auth_z(deps.as_ref(), Some(user.clone()))?.execute( - &env.contract.address, - MsgWithdrawPosition { - position_id: position_details.position_id, - sender: user.to_string(), - liquidity_amount: liquidity_amount.clone(), + AUTOCOMPOUND_STATE.save( + deps.storage, + &AutocompoundState { + last_compound: env.block.time, }, - ); - - let withdrawn_funds = vec![ - try_proto_to_cosmwasm_coins(position.asset0)? - .first() - .map(|c| { - Ok::<_, AppError>(Coin { - denom: c.denom.clone(), - amount: c.amount * Uint128::from_str(&liquidity_amount)? - / Uint128::from_str(&total_liquidity)?, - }) - }) - .transpose()?, - try_proto_to_cosmwasm_coins(position.asset1)? - .first() - .map(|c| { - Ok::<_, AppError>(Coin { - denom: c.denom.clone(), - amount: c.amount * Uint128::from_str(&liquidity_amount)? - / Uint128::from_str(&total_liquidity)?, - }) - }) - .transpose()?, - ] - .into_iter() - .flatten() - .collect(); - - Ok((msg, liquidity_amount, total_liquidity, withdrawn_funds)) -} - -/// This function creates a position for the user, -/// 1. Swap the indicated funds to match the asset0/asset1 ratio and deposit as much as possible in the pool for the given parameters -/// 2. Create a new position -/// 3. Store position id from create position response -/// -/// * `lower_tick` - Concentrated liquidity pool parameter -/// * `upper_tick` - Concentrated liquidity pool parameter -/// * `funds` - Funds that will be deposited from the user wallet directly into the pool. DO NOT SEND FUNDS TO THIS ENDPOINT -/// * `asset0` - The target amount of asset0.denom that the user will deposit inside the pool -/// * `asset1` - The target amount of asset1.denom that the user will deposit inside the pool -/// -/// asset0 and asset1 are only used in a ratio to each other. They are there to make sure that the deposited funds will ALL land inside the pool. -/// We don't use an asset ratio because either one of the amounts can be zero -/// See https://docs.osmosis.zone/osmosis-core/modules/concentrated-liquidity for more details -/// -pub(crate) fn _create_position( - deps: Deps, - env: &Env, - app: &App, - create_position_msg: CreatePositionMessage, -) -> AppResult<(Vec, SubMsg)> { - let config = CONFIG.load(deps.storage)?; - - let CreatePositionMessage { - lower_tick, - upper_tick, - funds, - asset0, - asset1, - max_spread, - belief_price0, - belief_price1, - } = create_position_msg; - - // 1. Swap the assets - let (swap_msgs, resulting_assets) = swap_to_enter_position( - deps, - env, - funds, - app, - asset0, - asset1, - max_spread, - belief_price0, - belief_price1, )?; - let sender = get_user(deps, app)?; - - // 2. Create a position - let tokens = cosmwasm_to_proto_coins(resulting_assets); - let create_msg = app.auth_z(deps, Some(sender.clone()))?.execute( - &env.contract.address, - MsgCreatePosition { - pool_id: config.pool_config.pool_id, - sender: sender.to_string(), - lower_tick, - upper_tick, - tokens_provided: tokens, - token_min_amount0: "0".to_string(), - token_min_amount1: "0".to_string(), - }, - ); - Ok(( - swap_msgs, - // 3. Use a reply to get the stored position id - SubMsg::reply_on_success(create_msg, CREATE_POSITION_ID), - )) + Ok(response.add_messages(executor_rewards.msg)) } -/// Sends autocompound rewards to the executor. -/// In case user does not have not enough gas token the contract will swap some -/// tokens for gas tokens. -pub fn autocompound_executor_rewards( +pub fn _inner_deposit( deps: Deps, env: &Env, - executor: String, + funds: Vec, + target_strategy: Strategy, + yield_source_params: Option>>>, app: &App, - config: Config, ) -> AppResult> { - let rewards_config = config.autocompound_rewards_config; - let position = get_position(deps)?; - let user = position.owner; - - // Get user balance of gas denom - let gas_denom = rewards_config - .gas_asset - .resolve(&deps.querier, &app.ans_host(deps)?)?; - let user_gas_balance = gas_denom.query_balance(&deps.querier, user.clone())?; - - let mut rewards_messages = vec![]; - - // If not enough gas coins - swap for some amount - if user_gas_balance < rewards_config.min_gas_balance { - // Get asset entries - let dex = app.ans_dex(deps, OSMOSIS.to_string()); - - // Do reverse swap to find approximate amount we need to swap - let need_gas_coins = rewards_config.max_gas_balance - user_gas_balance; - let simulate_swap_response = dex.simulate_swap( - AnsAsset::new(rewards_config.gas_asset.clone(), need_gas_coins), - rewards_config.swap_asset.clone(), - )?; - - // Get user balance of swap denom - let user_swap_balance = - get_balance(rewards_config.swap_asset.clone(), deps, user.clone(), app)?; - - // Swap as much as available if not enough for max_gas_balance - let swap_amount = simulate_swap_response.return_amount.min(user_swap_balance); - - let msgs = swap_msg( - deps, - env, - AnsAsset::new(rewards_config.swap_asset, swap_amount), - rewards_config.gas_asset, - app, - )?; - rewards_messages.extend(msgs); - } - - let reward_asset = Asset::new(gas_denom, rewards_config.reward); - let msg_send = reward_asset.transfer_msg(env.contract.address.to_string())?; - - // To avoid giving general `MsgSend` authorization to any address we do 2 sends here - // 1) From user to the contract - // 2) From contract to the executor - // That way we can limit the `MsgSend` authorization to the contract address only. - let send_reward_to_contract_msg = app - .auth_z(deps, Some(cosmwasm_std::Addr::unchecked(user)))? - .execute(&env.contract.address, msg_send); - rewards_messages.push(send_reward_to_contract_msg); - - let send_reward_to_executor_msg = reward_asset.transfer_msg(executor)?; - - rewards_messages.push(send_reward_to_executor_msg); + let (withdraw_strategy, deposit_msgs) = + generate_deposit_strategy(deps, funds, target_strategy, yield_source_params, app)?; + + let deposit_withdraw_msgs = withdraw_strategy + .into_iter() + .map(|(el, share)| el.withdraw(deps, Some(share), app).map(Into::into)) + .collect::, _>>()?; + let deposit_msgs = deposit_msgs + .into_iter() + .map(|msg| msg.to_cosmos_msg(env)) + .collect::, _>>()?; + + Ok([deposit_withdraw_msgs, deposit_msgs].concat()) +} - Ok(rewards_messages) +fn _inner_withdraw( + deps: DepsMut, + _env: &Env, + value: Option, + app: &App, +) -> AppResult> { + // We need to select the share of each investment that needs to be withdrawn + let withdraw_share = value + .map(|value| { + let total_deposit = query_balance(deps.as_ref(), app)?; + + if total_deposit.total_value.is_zero() { + return Err(AppError::NoDeposit {}); + } + Ok(Decimal::from_ratio(value, total_deposit.total_value)) + }) + .transpose()?; + + // We withdraw the necessary share from all registered investments + let withdraw_msgs = + STRATEGY_CONFIG + .load(deps.storage)? + .withdraw(deps.as_ref(), withdraw_share, app)?; + + Ok(withdraw_msgs.into_iter().collect()) } diff --git a/contracts/carrot-app/src/handlers/instantiate.rs b/contracts/carrot-app/src/handlers/instantiate.rs index a515ce7c..73dd9d00 100644 --- a/contracts/carrot-app/src/handlers/instantiate.rs +++ b/contracts/carrot-app/src/handlers/instantiate.rs @@ -1,19 +1,13 @@ -use abstract_app::abstract_core::ans_host::{AssetPairingFilter, AssetPairingMapEntry}; -use abstract_app::abstract_sdk::{features::AbstractNameService, AbstractResponse}; -use cosmwasm_std::{DepsMut, Env, MessageInfo}; -use cw_asset::AssetInfo; -use osmosis_std::types::osmosis::{ - concentratedliquidity::v1beta1::Pool, poolmanager::v1beta1::PoolmanagerQuerier, -}; - use crate::{ + check::Checkable, contract::{App, AppResult}, - error::AppError, msg::AppInstantiateMsg, - state::{Config, PoolConfig, CONFIG}, + state::{CONFIG, STRATEGY_CONFIG}, }; +use abstract_app::abstract_sdk::AbstractResponse; +use cosmwasm_std::{DepsMut, Env, MessageInfo}; -use super::execute::_create_position; +use super::execute::_inner_deposit; pub fn instantiate_handler( deps: DepsMut, @@ -22,61 +16,20 @@ pub fn instantiate_handler( app: App, msg: AppInstantiateMsg, ) -> AppResult { - let pool: Pool = PoolmanagerQuerier::new(&deps.querier) - .pool(msg.pool_id)? - .pool - .unwrap() - .try_into()?; + // Check validity of registered config + let config = msg.config.check(deps.as_ref(), &app)?; - // We query the ANS for useful information on the tokens and pool - let ans = app.name_service(deps.as_ref()); - // ANS Asset entries to indentify the assets inside Abstract - let asset_entries = ans.query(&vec![ - AssetInfo::Native(pool.token0.clone()), - AssetInfo::Native(pool.token1.clone()), - ])?; - let asset0 = asset_entries[0].clone(); - let asset1 = asset_entries[1].clone(); - let asset_pairing_resp: Vec = ans.pool_list( - Some(AssetPairingFilter { - asset_pair: Some((asset0.clone(), asset1.clone())), - dex: None, - }), - None, - None, - )?; - - let pair = asset_pairing_resp - .into_iter() - .find(|(_, refs)| !refs.is_empty()) - .ok_or(AppError::NoSwapPossibility {})? - .0; - let dex_name = pair.dex(); - - let autocompound_rewards_config = msg.autocompound_rewards_config; - // Check validity of autocompound rewards - autocompound_rewards_config.check(deps.as_ref(), dex_name, ans.host())?; - - let config: Config = Config { - pool_config: PoolConfig { - pool_id: msg.pool_id, - token0: pool.token0.clone(), - token1: pool.token1.clone(), - asset0, - asset1, - }, - autocompound_cooldown_seconds: msg.autocompound_cooldown_seconds, - autocompound_rewards_config, - }; CONFIG.save(deps.storage, &config)?; + let strategy = msg.strategy.check(deps.as_ref(), &app)?; + STRATEGY_CONFIG.save(deps.storage, &strategy)?; let mut response = app.response("instantiate_savings_app"); - // If provided - create position - if let Some(create_position_msg) = msg.create_position { - let (swap_msgs, create_msg) = - _create_position(deps.as_ref(), &env, &app, create_position_msg)?; - response = response.add_messages(swap_msgs).add_submessage(create_msg); + // If provided - do an initial deposit + if let Some(funds) = msg.deposit { + let deposit_msgs = _inner_deposit(deps.as_ref(), &env, funds, strategy, None, &app)?; + + response = response.add_messages(deposit_msgs); } Ok(response) } diff --git a/contracts/carrot-app/src/handlers/internal.rs b/contracts/carrot-app/src/handlers/internal.rs new file mode 100644 index 00000000..95625cf4 --- /dev/null +++ b/contracts/carrot-app/src/handlers/internal.rs @@ -0,0 +1,168 @@ +use crate::{ + contract::{App, AppResult}, + distribution::deposit::{DepositStep, OneDepositStrategy}, + helpers::get_proxy_balance, + msg::{AppExecuteMsg, ExecuteMsg, InternalExecuteMsg}, + replies::REPLY_AFTER_SWAPS_STEP, + state::{ + CONFIG, STRATEGY_CONFIG, TEMP_CURRENT_COIN, TEMP_CURRENT_YIELD, TEMP_DEPOSIT_COINS, + TEMP_EXPECTED_SWAP_COIN, + }, + yield_sources::{ + yield_type::{YieldType, YieldTypeImplementation}, + Strategy, + }, +}; +use abstract_app::{abstract_sdk::features::AbstractResponse, objects::AnsAsset}; +use abstract_dex_adapter::DexInterface; +use abstract_sdk::features::AbstractNameService; +use cosmwasm_std::{wasm_execute, Coin, Coins, DepsMut, Env, SubMsg, Uint128}; +use cw_asset::AssetInfo; + +use crate::exchange_rate::query_exchange_rate; + +pub fn execute_internal_action( + deps: DepsMut, + env: Env, + internal_msg: InternalExecuteMsg, + app: App, +) -> AppResult { + match internal_msg { + InternalExecuteMsg::DepositOneStrategy { + swap_strategy, + yield_type, + yield_index, + } => deposit_one_strategy(deps, env, swap_strategy, yield_index, yield_type, app), + InternalExecuteMsg::ExecuteOneDepositSwapStep { + asset_in, + denom_out, + expected_amount, + } => execute_one_deposit_step(deps, env, asset_in, denom_out, expected_amount, app), + InternalExecuteMsg::FinalizeDeposit { + yield_type, + yield_index, + } => execute_finalize_deposit(deps, yield_type, yield_index, app), + } +} + +fn deposit_one_strategy( + deps: DepsMut, + env: Env, + strategy: OneDepositStrategy, + yield_index: usize, + yield_type: YieldType, + app: App, +) -> AppResult { + let mut temp_deposit_coins = Coins::default(); + + // We go through all deposit steps. + // If the step is a swap, we execute with a reply to catch the amount change and get the exact deposit amount + let msg = strategy + .0 + .into_iter() + .map(|s| { + s.into_iter() + .map(|step| match step { + DepositStep::Swap { + asset_in, + denom_out, + expected_amount, + } => wasm_execute( + env.contract.address.clone(), + &ExecuteMsg::Module(AppExecuteMsg::Internal( + InternalExecuteMsg::ExecuteOneDepositSwapStep { + asset_in, + denom_out, + expected_amount, + }, + )), + vec![], + ) + .map(|msg| Some(SubMsg::reply_on_success(msg, REPLY_AFTER_SWAPS_STEP))), + + DepositStep::UseFunds { asset } => { + temp_deposit_coins.add(asset)?; + Ok(None) + } + }) + .collect::>, _>>() + }) + .collect::, _>>()?; + + TEMP_DEPOSIT_COINS.save(deps.storage, &temp_deposit_coins.into())?; + + let msgs = msg.into_iter().flatten().flatten().collect::>(); + + // Finalize and execute the deposit + let last_step = wasm_execute( + env.contract.address.clone(), + &ExecuteMsg::Module(AppExecuteMsg::Internal( + InternalExecuteMsg::FinalizeDeposit { + yield_type, + yield_index, + }, + )), + vec![], + )?; + + Ok(app + .response("deposit-one") + .add_submessages(msgs) + .add_message(last_step)) +} + +pub fn execute_one_deposit_step( + deps: DepsMut, + _env: Env, + asset_in: Coin, + denom_out: String, + expected_amount: Uint128, + app: App, +) -> AppResult { + let config = CONFIG.load(deps.storage)?; + + let exchange_rate_in = query_exchange_rate(deps.as_ref(), asset_in.denom.clone(), &app)?; + let exchange_rate_out = query_exchange_rate(deps.as_ref(), denom_out.clone(), &app)?; + + let ans = app.name_service(deps.as_ref()); + + let asset_entries = ans.query(&vec![ + AssetInfo::native(asset_in.denom.clone()), + AssetInfo::native(denom_out.clone()), + ])?; + let in_asset = asset_entries[0].clone(); + let out_asset = asset_entries[1].clone(); + + let msg = app.ans_dex(deps.as_ref(), config.dex).swap( + AnsAsset::new(in_asset, asset_in.amount), + out_asset, + None, + Some(exchange_rate_in / exchange_rate_out), + )?; + + let proxy_balance_before = get_proxy_balance(deps.as_ref(), &app, denom_out)?; + TEMP_CURRENT_COIN.save(deps.storage, &proxy_balance_before)?; + TEMP_EXPECTED_SWAP_COIN.save(deps.storage, &expected_amount)?; + + Ok(app.response("one-deposit-step").add_message(msg)) +} + +pub fn execute_finalize_deposit( + deps: DepsMut, + yield_type: YieldType, + yield_index: usize, + app: App, +) -> AppResult { + let available_deposit_coins = TEMP_DEPOSIT_COINS.load(deps.storage)?; + + TEMP_CURRENT_YIELD.save(deps.storage, &yield_index)?; + + let msgs = yield_type.deposit(deps.as_ref(), available_deposit_coins, &app)?; + + Ok(app.response("finalize-deposit").add_submessages(msgs)) +} + +pub fn save_strategy(deps: DepsMut, strategy: Strategy) -> AppResult<()> { + STRATEGY_CONFIG.save(deps.storage, &strategy)?; + Ok(()) +} diff --git a/contracts/carrot-app/src/handlers/mod.rs b/contracts/carrot-app/src/handlers/mod.rs index e9de48a0..fcbd3c7b 100644 --- a/contracts/carrot-app/src/handlers/mod.rs +++ b/contracts/carrot-app/src/handlers/mod.rs @@ -1,8 +1,10 @@ pub mod execute; pub mod instantiate; +pub mod internal; pub mod migrate; +/// Allows to preview the usual operations before executing them +pub mod preview; pub mod query; -pub mod swap_helpers; pub use crate::handlers::{ execute::execute_handler, instantiate::instantiate_handler, migrate::migrate_handler, query::query_handler, diff --git a/contracts/carrot-app/src/handlers/preview.rs b/contracts/carrot-app/src/handlers/preview.rs new file mode 100644 index 00000000..8f26660d --- /dev/null +++ b/contracts/carrot-app/src/handlers/preview.rs @@ -0,0 +1,116 @@ +use abstract_sdk::ExecutorMsg; +use cosmwasm_std::{Coin, Coins, Decimal, Deps, Uint128}; + +use crate::{ + check::Checkable, + contract::{App, AppResult}, + distribution::deposit::generate_deposit_strategy, + error::AppError, + msg::{DepositPreviewResponse, UpdateStrategyPreviewResponse, WithdrawPreviewResponse}, + state::STRATEGY_CONFIG, + yield_sources::{AssetShare, StrategyUnchecked}, +}; + +use super::query::withdraw_share; + +pub fn deposit_preview( + deps: Deps, + funds: Vec, + yield_source_params: Option>>>, + app: &App, +) -> AppResult { + let target_strategy = STRATEGY_CONFIG.load(deps.storage)?; + let (withdraw_strategy, deposit_strategy) = + generate_deposit_strategy(deps, funds, target_strategy, yield_source_params, app)?; + + Ok(DepositPreviewResponse { + withdraw: withdraw_strategy + .into_iter() + .map(|(el, share)| (el.into(), share)) + .collect(), + deposit: deposit_strategy, + }) +} + +pub fn withdraw_preview( + deps: Deps, + amount: Option, + app: &App, +) -> AppResult { + let withdraw_share = withdraw_share(deps, amount, app)?; + let funds = STRATEGY_CONFIG + .load(deps.storage)? + .withdraw_preview(deps, withdraw_share, app)?; + + let msgs = STRATEGY_CONFIG + .load(deps.storage)? + .withdraw(deps, withdraw_share, app)?; + + Ok(WithdrawPreviewResponse { + share: withdraw_share.unwrap_or(Decimal::one()), + funds, + msgs: msgs.into_iter().map(Into::into).collect(), + }) +} + +pub fn update_strategy_preview( + deps: Deps, + funds: Vec, + strategy: StrategyUnchecked, + app: &App, +) -> AppResult { + // We withdraw outstanding strategies + + let old_strategy = STRATEGY_CONFIG.load(deps.storage)?; + + // We check the new strategy + let strategy = strategy.check(deps, app)?; + + // We execute operations to rebalance the funds between the strategies + let mut available_funds: Coins = funds.try_into()?; + // 1. We withdraw all yield_sources that are not included in the new strategies + let all_stale_sources: Vec<_> = old_strategy + .0 + .into_iter() + .filter(|x| !strategy.0.contains(x)) + .collect(); + + let (withdrawn_funds, _withdraw_msgs): (Vec>, Vec>) = + all_stale_sources + .clone() + .into_iter() + .map(|s| { + Ok::<_, AppError>(( + s.withdraw_preview(deps, None, app).unwrap_or_default(), + s.withdraw(deps, None, app).ok(), + )) + }) + .collect::, _>>()? + .into_iter() + .unzip(); + + withdrawn_funds + .into_iter() + .try_for_each(|f| f.into_iter().try_for_each(|f| available_funds.add(f)))?; + + // 3. We deposit the funds into the new strategy + let (withdraw_strategy, deposit_strategy) = + generate_deposit_strategy(deps, available_funds.into(), strategy, None, app)?; + + let withdraw_strategy = [ + all_stale_sources + .into_iter() + .map(|s| (s, Decimal::one())) + .collect(), + withdraw_strategy, + ] + .concat(); + + Ok(UpdateStrategyPreviewResponse { + withdraw: withdraw_strategy + .into_iter() + .map(|(el, share)| (el.into(), share)) + .collect(), + deposit: deposit_strategy, + }) +} diff --git a/contracts/carrot-app/src/handlers/query.rs b/contracts/carrot-app/src/handlers/query.rs index f62ce102..13348640 100644 --- a/contracts/carrot-app/src/handlers/query.rs +++ b/contracts/carrot-app/src/handlers/query.rs @@ -1,31 +1,42 @@ -use abstract_app::{ - abstract_core::objects::AnsAsset, - traits::{AbstractNameService, Resolve}, -}; -use abstract_dex_adapter::DexInterface; -use cosmwasm_std::{ensure, to_json_binary, Binary, Coin, Decimal, Deps, Env}; -use cw_asset::Asset; -use osmosis_std::try_proto_to_cosmwasm_coins; +use cosmwasm_std::{to_json_binary, Binary, Coin, Coins, Decimal, Deps, Env, Uint128}; +use crate::autocompound::get_autocompound_status; +use crate::exchange_rate::query_exchange_rate; +use crate::msg::{PositionResponse, PositionsResponse}; +use crate::state::STRATEGY_CONFIG; +use crate::yield_sources::yield_type::YieldTypeImplementation; use crate::{ - contract::{App, AppResult, OSMOSIS}, + contract::{App, AppResult}, error::AppError, - handlers::swap_helpers::DEFAULT_SLIPPAGE, - helpers::{get_balance, get_user}, msg::{ AppQueryMsg, AssetsBalanceResponse, AvailableRewardsResponse, CompoundStatusResponse, - PositionResponse, + StrategyResponse, }, - state::{get_osmosis_position, get_position_status, Config, CONFIG, POSITION}, + state::{Config, CONFIG}, }; +use super::preview::{deposit_preview, update_strategy_preview, withdraw_preview}; + pub fn query_handler(deps: Deps, env: Env, app: &App, msg: AppQueryMsg) -> AppResult { match msg { AppQueryMsg::Balance {} => to_json_binary(&query_balance(deps, app)?), AppQueryMsg::AvailableRewards {} => to_json_binary(&query_rewards(deps, app)?), AppQueryMsg::Config {} => to_json_binary(&query_config(deps)?), - AppQueryMsg::Position {} => to_json_binary(&query_position(deps)?), + AppQueryMsg::Strategy {} => to_json_binary(&query_strategy(deps)?), AppQueryMsg::CompoundStatus {} => to_json_binary(&query_compound_status(deps, env, app)?), + AppQueryMsg::StrategyStatus {} => to_json_binary(&query_strategy_status(deps, app)?), + AppQueryMsg::Positions {} => to_json_binary(&query_positions(deps, app)?), + AppQueryMsg::DepositPreview { + funds, + yield_sources_params, + } => to_json_binary(&deposit_preview(deps, funds, yield_sources_params, app)?), + AppQueryMsg::WithdrawPreview { amount } => { + to_json_binary(&withdraw_preview(deps, amount, app)?) + } + AppQueryMsg::UpdateStrategyPreview { strategy, funds } => { + to_json_binary(&update_strategy_preview(deps, funds, strategy, app)?) + } + AppQueryMsg::FundsValue { funds } => to_json_binary(&query_funds_value(deps, funds, app)?), } .map_err(Into::into) } @@ -34,139 +45,160 @@ pub fn query_handler(deps: Deps, env: Env, app: &App, msg: AppQueryMsg) -> AppRe /// Accounts for the user's ability to pay for the gas fees of executing the contract. fn query_compound_status(deps: Deps, env: Env, app: &App) -> AppResult { let config = CONFIG.load(deps.storage)?; - let status = get_position_status( + let status = get_autocompound_status( deps.storage, &env, - config.autocompound_cooldown_seconds.u64(), + config.autocompound_config.cooldown_seconds.u64(), )?; - let gas_denom = config - .autocompound_rewards_config - .gas_asset - .resolve(&deps.querier, &app.ans_host(deps)?)?; - - let reward = Asset::new(gas_denom.clone(), config.autocompound_rewards_config.reward); - - let user = get_user(deps, app)?; - - let user_gas_balance = gas_denom.query_balance(&deps.querier, user.clone())?; - - let rewards_available = if user_gas_balance >= reward.amount { - true - } else { - // check if can swap - let rewards_config = config.autocompound_rewards_config; - let dex = app.ans_dex(deps, OSMOSIS.to_string()); - - // Reverse swap to see how many swap coins needed - let required_gas_coins = reward.amount - user_gas_balance; - let response = dex.simulate_swap( - AnsAsset::new(rewards_config.gas_asset, required_gas_coins), - rewards_config.swap_asset.clone(), - )?; + let (all_rewards, _collect_rewards_msgs) = STRATEGY_CONFIG + .load(deps.storage)? + .withdraw_rewards(deps, app)?; - // Check if user has enough of swap coins - let user_swap_balance = get_balance(rewards_config.swap_asset, deps, user, app)?; - let required_swap_amount = response.return_amount; + let funds: Vec = all_rewards + .iter() + .flat_map(|a| { + let reward_amount = a.amount * config.autocompound_config.rewards.reward_percent; - user_swap_balance > required_swap_amount - }; + Some(Coin::new(reward_amount.into(), a.denom.clone())) + }) + .collect(); Ok(CompoundStatusResponse { status, - reward: reward.into(), - rewards_available, + execution_rewards: query_funds_value(deps, funds, app)?, + }) +} + +pub fn query_strategy(deps: Deps) -> AppResult { + let strategy = STRATEGY_CONFIG.load(deps.storage)?; + + Ok(StrategyResponse { + strategy: strategy.into(), }) } -fn query_position(deps: Deps) -> AppResult { - let position = POSITION.may_load(deps.storage)?; +pub fn query_strategy_status(deps: Deps, app: &App) -> AppResult { + let strategy = STRATEGY_CONFIG.load(deps.storage)?; - Ok(PositionResponse { position }) + Ok(StrategyResponse { + strategy: strategy.query_current_status(deps, app)?.into(), + }) } fn query_config(deps: Deps) -> AppResult { Ok(CONFIG.load(deps.storage)?) } -fn query_balance(deps: Deps, _app: &App) -> AppResult { - let pool = get_osmosis_position(deps)?; - let balances = try_proto_to_cosmwasm_coins(vec![pool.asset0.unwrap(), pool.asset1.unwrap()])?; - let liquidity = pool.position.unwrap().liquidity.replace('.', ""); +pub fn query_balance(deps: Deps, app: &App) -> AppResult { + let mut funds = Coins::default(); + let mut total_value = Uint128::zero(); + + let strategy = STRATEGY_CONFIG.load(deps.storage)?; + strategy.0.iter().try_for_each(|s| { + let deposit_value = s + .yield_source + .params + .user_deposit(deps, app) + .unwrap_or_default(); + for fund in deposit_value { + let exchange_rate = query_exchange_rate(deps, fund.denom.clone(), app)?; + funds.add(fund.clone())?; + total_value += fund.amount * exchange_rate; + } + Ok::<_, AppError>(()) + })?; + Ok(AssetsBalanceResponse { - balances, - liquidity, + balances: funds.into(), + total_value, }) } -fn query_rewards(deps: Deps, _app: &App) -> AppResult { - let pool = get_osmosis_position(deps)?; +fn query_rewards(deps: Deps, app: &App) -> AppResult { + let strategy = STRATEGY_CONFIG.load(deps.storage)?; - let mut rewards = cosmwasm_std::Coins::default(); - for coin in try_proto_to_cosmwasm_coins(pool.claimable_incentives)? { - rewards.add(coin)?; - } + let mut rewards = Coins::default(); + strategy.0.into_iter().try_for_each(|s| { + let this_rewards = s.yield_source.params.user_rewards(deps, app)?; + for fund in this_rewards { + rewards.add(fund)?; + } + Ok::<_, AppError>(()) + })?; - for coin in try_proto_to_cosmwasm_coins(pool.claimable_spread_rewards)? { - rewards.add(coin)?; + let mut total_value = Uint128::zero(); + for fund in &rewards { + let exchange_rate = query_exchange_rate(deps, fund.denom.clone(), app)?; + total_value += fund.amount * exchange_rate; } Ok(AvailableRewardsResponse { - available_rewards: rewards.into(), + available_rewards: query_funds_value(deps, rewards.into(), app)?, + }) +} + +pub fn query_positions(deps: Deps, app: &App) -> AppResult { + Ok(PositionsResponse { + positions: STRATEGY_CONFIG + .load(deps.storage)? + .0 + .into_iter() + .map(|s| { + let balance = s.yield_source.params.user_deposit(deps, app)?; + let liquidity = s.yield_source.params.user_liquidity(deps, app)?; + + let total_value = balance + .iter() + .map(|fund| { + let exchange_rate = query_exchange_rate(deps, fund.denom.clone(), app)?; + Ok(fund.amount * exchange_rate) + }) + .sum::>()?; + + Ok::<_, AppError>(PositionResponse { + params: s.yield_source.params.into(), + balance: AssetsBalanceResponse { + balances: balance, + total_value, + }, + liquidity, + }) + }) + .collect::>()?, }) } -pub fn query_price( +pub fn query_funds_value( deps: Deps, - funds: &[Coin], + funds: Vec, app: &App, - max_spread: Option, - belief_price0: Option, - belief_price1: Option, -) -> AppResult { - let config = CONFIG.load(deps.storage)?; +) -> AppResult { + let mut total_value = Uint128::zero(); + for fund in &funds { + let exchange_rate = query_exchange_rate(deps, fund.denom.clone(), app)?; + total_value += fund.amount * exchange_rate; + } - let amount0 = funds - .iter() - .find(|c| c.denom == config.pool_config.token0) - .map(|c| c.amount) - .unwrap_or_default(); - let amount1 = funds - .iter() - .find(|c| c.denom == config.pool_config.token1) - .map(|c| c.amount) - .unwrap_or_default(); - - // We take the biggest amount and simulate a swap for the corresponding asset - let price = if amount0 > amount1 { - let simulation_result = app.ans_dex(deps, OSMOSIS.to_string()).simulate_swap( - AnsAsset::new(config.pool_config.asset0, amount0), - config.pool_config.asset1, - )?; - - let price = Decimal::from_ratio(amount0, simulation_result.return_amount); - if let Some(belief_price) = belief_price1 { - ensure!( - belief_price.abs_diff(price) <= max_spread.unwrap_or(DEFAULT_SLIPPAGE), - AppError::MaxSpreadAssertion { price } - ); - } - price - } else { - let simulation_result = app.ans_dex(deps, OSMOSIS.to_string()).simulate_swap( - AnsAsset::new(config.pool_config.asset1, amount1), - config.pool_config.asset0, - )?; - - let price = Decimal::from_ratio(simulation_result.return_amount, amount1); - if let Some(belief_price) = belief_price0 { - ensure!( - belief_price.abs_diff(price) <= max_spread.unwrap_or(DEFAULT_SLIPPAGE), - AppError::MaxSpreadAssertion { price } - ); - } - price - }; + Ok(AssetsBalanceResponse { + balances: funds, + total_value, + }) +} - Ok(price) +pub fn withdraw_share( + deps: Deps, + amount: Option, + app: &App, +) -> AppResult> { + amount + .map(|value| { + let total_deposit = query_balance(deps, app)?; + + if total_deposit.total_value.is_zero() { + return Err(AppError::NoDeposit {}); + } + Ok(Decimal::from_ratio(value, total_deposit.total_value)) + }) + .transpose() } diff --git a/contracts/carrot-app/src/handlers/swap_helpers.rs b/contracts/carrot-app/src/handlers/swap_helpers.rs deleted file mode 100644 index c14030d7..00000000 --- a/contracts/carrot-app/src/handlers/swap_helpers.rs +++ /dev/null @@ -1,343 +0,0 @@ -use abstract_app::objects::{AnsAsset, AssetEntry}; -use abstract_dex_adapter::{msg::GenerateMessagesResponse, DexInterface}; -use abstract_sdk::AuthZInterface; -use cosmwasm_std::{Coin, CosmosMsg, Decimal, Deps, Env, Uint128}; -const MAX_SPREAD_PERCENT: u64 = 20; -pub const DEFAULT_SLIPPAGE: Decimal = Decimal::permille(5); - -use crate::{ - contract::{App, AppResult, OSMOSIS}, - helpers::get_user, - state::CONFIG, -}; - -use super::query::query_price; - -pub(crate) fn swap_msg( - deps: Deps, - env: &Env, - offer_asset: AnsAsset, - ask_asset: AssetEntry, - app: &App, -) -> AppResult> { - // Don't swap if not required - if offer_asset.amount.is_zero() { - return Ok(vec![]); - } - let sender = get_user(deps, app)?; - - let dex = app.ans_dex(deps, OSMOSIS.to_string()); - let trigger_swap_msg: GenerateMessagesResponse = dex.generate_swap_messages( - offer_asset, - ask_asset, - Some(Decimal::percent(MAX_SPREAD_PERCENT)), - None, - sender.clone(), - )?; - let authz = app.auth_z(deps, Some(sender))?; - - Ok(trigger_swap_msg - .messages - .into_iter() - .map(|m| authz.execute(&env.contract.address, m)) - .collect()) -} - -pub(crate) fn tokens_to_swap( - deps: Deps, - amount_to_swap: Vec, - asset0: Coin, // Represents the amount of Coin 0 we would like the position to handle - asset1: Coin, // Represents the amount of Coin 1 we would like the position to handle, - price: Decimal, // Relative price (when swapping amount0 for amount1, equals amount0/amount1) -) -> AppResult<(AnsAsset, AssetEntry, Vec)> { - let config = CONFIG.load(deps.storage)?; - - let x0 = amount_to_swap - .iter() - .find(|c| c.denom == asset0.denom) - .cloned() - .unwrap_or(Coin { - denom: asset0.denom, - amount: Uint128::zero(), - }); - let x1 = amount_to_swap - .iter() - .find(|c| c.denom == asset1.denom) - .cloned() - .unwrap_or(Coin { - denom: asset1.denom, - amount: Uint128::zero(), - }); - - // We will swap on the pool to get the right coin ratio - - // We have x0 and x1 to deposit. Let p (or price) be the price of asset1 (the number of asset0 you get for 1 unit of asset1) - // In order to deposit, you need to have X0 and X1 such that X0/X1 = A0/A1 where A0 and A1 are the current liquidity inside the position - // That is equivalent to X0*A1 = X1*A0 - // We need to find how much to swap. - // If x0*A1 < x1*A0, we need to have more x0 to balance the swap --> so we need to send some of x1 to swap (lets say we wend y1 to swap) - // So X1 = x1-y1 - // X0 = x0 + price*y1 - // Therefore, the following equation needs to be true - // (x0 + price*y1)*A1 = (x1-y1)*A0 or y1 = (x1*a0 - x0*a1)/(a0 + p*a1) - // If x0*A1 > x1*A0, we need to have more x1 to balance the swap --> so we need to send some of x0 to swap (lets say we wend y0 to swap) - // So X0 = x0-y0 - // X1 = x1 + y0/price - // Therefore, the following equation needs to be true - // (x0-y0)*A1 = (x1 + y0/price)*A0 or y0 = (x0*a1 - x1*a0)/(a1 + a0/p) - - let x0_a1 = x0.amount * asset1.amount; - let x1_a0 = x1.amount * asset0.amount; - - let (offer_asset, ask_asset, mut resulting_balance) = if x0_a1 < x1_a0 { - let numerator = x1_a0 - x0_a1; - let denominator = asset0.amount + price * asset1.amount; - let y1 = numerator / denominator; - - ( - AnsAsset::new(config.pool_config.asset1, y1), - config.pool_config.asset0, - vec![ - Coin { - amount: x0.amount + price * y1, - denom: x0.denom, - }, - Coin { - amount: x1.amount - y1, - denom: x1.denom, - }, - ], - ) - } else { - let numerator = x0_a1 - x1_a0; - let denominator = - asset1.amount + Decimal::from_ratio(asset0.amount, 1u128) / price * Uint128::one(); - let y0 = numerator / denominator; - - ( - AnsAsset::new(config.pool_config.asset0, numerator / denominator), - config.pool_config.asset1, - vec![ - Coin { - amount: x0.amount - y0, - denom: x0.denom, - }, - Coin { - amount: x1.amount + Decimal::from_ratio(y0, 1u128) / price * Uint128::one(), - denom: x1.denom, - }, - ], - ) - }; - - resulting_balance.sort_by(|a, b| a.denom.cmp(&b.denom)); - // TODO, compute the resulting balance to be able to deposit back into the pool - Ok((offer_asset, ask_asset, resulting_balance)) -} - -#[allow(clippy::too_many_arguments)] -pub fn swap_to_enter_position( - deps: Deps, - env: &Env, - funds: Vec, - app: &App, - asset0: Coin, - asset1: Coin, - max_spread: Option, - belief_price0: Option, - belief_price1: Option, -) -> AppResult<(Vec, Vec)> { - let price = query_price(deps, &funds, app, max_spread, belief_price0, belief_price1)?; - let (offer_asset, ask_asset, resulting_assets) = - tokens_to_swap(deps, funds, asset0, asset1, price)?; - - Ok(( - swap_msg(deps, env, offer_asset, ask_asset, app)?, - resulting_assets, - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::state::{AutocompoundRewardsConfig, Config, PoolConfig}; - use cosmwasm_std::{coin, coins, testing::mock_dependencies, DepsMut, Uint64}; - pub const DEPOSIT_TOKEN: &str = "USDC"; - pub const TOKEN0: &str = "USDT"; - pub const TOKEN1: &str = DEPOSIT_TOKEN; - - fn assert_is_around(result: Uint128, expected: impl Into) { - let expected = expected.into().u128(); - let result = result.u128(); - - if expected < result - 1 || expected > result + 1 { - panic!("Results are not close enough") - } - } - - fn setup_config(deps: DepsMut) -> cw_orch::anyhow::Result<()> { - CONFIG.save( - deps.storage, - &Config { - pool_config: PoolConfig { - pool_id: 45, - token0: TOKEN0.to_string(), - token1: TOKEN1.to_string(), - asset0: AssetEntry::new(TOKEN0), - asset1: AssetEntry::new(TOKEN1), - }, - autocompound_cooldown_seconds: Uint64::zero(), - autocompound_rewards_config: AutocompoundRewardsConfig { - gas_asset: "foo".into(), - swap_asset: "bar".into(), - reward: Uint128::zero(), - min_gas_balance: Uint128::zero(), - max_gas_balance: Uint128::new(1), - }, - }, - )?; - Ok(()) - } - - // TODO: more tests on tokens_to_swap - #[test] - fn swap_for_ratio_one_to_one() { - let mut deps = mock_dependencies(); - setup_config(deps.as_mut()).unwrap(); - let (swap, ask_asset, _final_asset) = tokens_to_swap( - deps.as_ref(), - coins(5_000, DEPOSIT_TOKEN), - coin(100_000_000, TOKEN0), - coin(100_000_000, TOKEN1), - Decimal::one(), - ) - .unwrap(); - - assert_eq!( - swap, - AnsAsset { - name: AssetEntry::new("usdc"), - amount: Uint128::new(2500) - } - ); - assert_eq!(ask_asset, AssetEntry::new("usdt")); - } - - #[test] - fn swap_for_ratio_close_to_one() { - let mut deps = mock_dependencies(); - setup_config(deps.as_mut()).unwrap(); - let amount0 = 110_000_000; - let amount1 = 100_000_000; - - let (swap, ask_asset, _final_asset) = tokens_to_swap( - deps.as_ref(), - coins(5_000, DEPOSIT_TOKEN), - coin(amount0, TOKEN0), - coin(amount1, TOKEN1), - Decimal::one(), - ) - .unwrap(); - - assert_is_around(swap.amount, 5_000 - 5_000 * amount1 / (amount1 + amount0)); - assert_eq!(swap.name, AssetEntry::new(TOKEN1)); - assert_eq!(ask_asset, AssetEntry::new(TOKEN0)); - } - - #[test] - fn swap_for_ratio_far_from_one() { - let mut deps = mock_dependencies(); - setup_config(deps.as_mut()).unwrap(); - let amount0 = 90_000_000; - let amount1 = 10_000_000; - let (swap, ask_asset, _final_asset) = tokens_to_swap( - deps.as_ref(), - coins(5_000, DEPOSIT_TOKEN), - coin(amount0, TOKEN0), - coin(amount1, TOKEN1), - Decimal::one(), - ) - .unwrap(); - - assert_eq!( - swap, - AnsAsset { - name: AssetEntry::new(DEPOSIT_TOKEN), - amount: Uint128::new(5_000 - 5_000 * amount1 / (amount1 + amount0)) - } - ); - assert_eq!(ask_asset, AssetEntry::new(TOKEN0)); - } - - #[test] - fn swap_for_ratio_far_from_one_inverse() { - let mut deps = mock_dependencies(); - setup_config(deps.as_mut()).unwrap(); - let amount0 = 10_000_000; - let amount1 = 90_000_000; - let (swap, ask_asset, _final_asset) = tokens_to_swap( - deps.as_ref(), - coins(5_000, DEPOSIT_TOKEN), - coin(amount0, TOKEN0), - coin(amount1, TOKEN1), - Decimal::one(), - ) - .unwrap(); - - assert_is_around(swap.amount, 5_000 - 5_000 * amount1 / (amount1 + amount0)); - assert_eq!(swap.name, AssetEntry::new(TOKEN1)); - assert_eq!(ask_asset, AssetEntry::new(TOKEN0)); - } - - #[test] - fn swap_for_non_unit_price() { - let mut deps = mock_dependencies(); - setup_config(deps.as_mut()).unwrap(); - let amount0 = 10_000_000; - let amount1 = 90_000_000; - let price = Decimal::percent(150); - let (swap, ask_asset, _final_asset) = tokens_to_swap( - deps.as_ref(), - coins(5_000, DEPOSIT_TOKEN), - coin(amount0, TOKEN0), - coin(amount1, TOKEN1), - price, - ) - .unwrap(); - - assert_is_around( - swap.amount, - 5_000 - - 5_000 * amount1 - / (amount1 - + (Decimal::from_ratio(amount0, 1u128) / price * Uint128::one()).u128()), - ); - assert_eq!(swap.name, AssetEntry::new(TOKEN1)); - assert_eq!(ask_asset, AssetEntry::new(TOKEN0)); - } - - #[test] - fn swap_multiple_tokens_for_non_unit_price() { - let mut deps = mock_dependencies(); - setup_config(deps.as_mut()).unwrap(); - let amount0 = 10_000_000; - let amount1 = 10_000_000; - let price = Decimal::percent(150); - let (swap, ask_asset, _final_asset) = tokens_to_swap( - deps.as_ref(), - vec![coin(10_000, TOKEN0), coin(4_000, TOKEN1)], - coin(amount0, TOKEN0), - coin(amount1, TOKEN1), - price, - ) - .unwrap(); - - assert_eq!(swap.name, AssetEntry::new(TOKEN0)); - assert_eq!(ask_asset, AssetEntry::new(TOKEN1)); - assert_eq!( - 10_000 - swap.amount.u128(), - 4_000 + (Decimal::from_ratio(swap.amount, 1u128) / price * Uint128::one()).u128() - ); - } -} diff --git a/contracts/carrot-app/src/helpers.rs b/contracts/carrot-app/src/helpers.rs index 8c60838f..18752e4c 100644 --- a/contracts/carrot-app/src/helpers.rs +++ b/contracts/carrot-app/src/helpers.rs @@ -1,19 +1,19 @@ +use std::collections::HashMap; + +use crate::contract::{App, AppResult}; +use crate::error::AppError; +use crate::exchange_rate::query_exchange_rate; +use abstract_app::traits::AccountIdentification; use abstract_app::{objects::AssetEntry, traits::AbstractNameService}; use abstract_sdk::Resolve; -use cosmwasm_std::{Addr, Deps, Uint128}; - -use crate::{ - contract::{App, AppResult}, - error::AppError, -}; +use cosmwasm_std::{Addr, Coin, Coins, Decimal, Deps, Env, MessageInfo, StdResult, Uint128}; -pub fn get_user(deps: Deps, app: &App) -> AppResult { - Ok(app - .admin - .query_account_owner(deps)? - .admin - .ok_or(AppError::NoTopLevelAccount {}) - .map(|admin| deps.api.addr_validate(&admin))??) +pub fn assert_contract(info: &MessageInfo, env: &Env) -> AppResult<()> { + if info.sender == env.contract.address { + Ok(()) + } else { + Err(AppError::Unauthorized {}) + } } pub fn get_balance(a: AssetEntry, deps: Deps, address: Addr, app: &App) -> AppResult { @@ -21,3 +21,74 @@ pub fn get_balance(a: AssetEntry, deps: Deps, address: Addr, app: &App) -> AppRe let user_gas_balance = denom.query_balance(&deps.querier, address.clone())?; Ok(user_gas_balance) } + +pub fn get_proxy_balance(deps: Deps, app: &App, denom: String) -> AppResult { + Ok(deps + .querier + .query_balance(app.account_base(deps)?.proxy, denom.clone())?) +} + +pub fn add_funds(funds: Vec, to_add: Coin) -> StdResult> { + let mut funds: Coins = funds.try_into()?; + funds.add(to_add)?; + Ok(funds.into()) +} + +pub const CLOSE_COEFF: Decimal = Decimal::permille(1); + +/// Returns wether actual is close to expected within CLOSE_PER_MILLE per mille +pub fn close_to(expected: Decimal, actual: Decimal) -> bool { + if expected == Decimal::zero() { + return actual < CLOSE_COEFF; + } + + actual > expected * (Decimal::one() - CLOSE_COEFF) + && actual < expected * (Decimal::one() + CLOSE_COEFF) +} + +pub fn compute_total_value( + funds: &[Coin], + exchange_rates: &HashMap, +) -> AppResult { + funds + .iter() + .map(|c| { + let exchange_rate = exchange_rates + .get(&c.denom) + .ok_or(AppError::NoExchangeRate(c.denom.clone()))?; + Ok(c.amount * *exchange_rate) + }) + .sum() +} + +pub fn compute_value(deps: Deps, funds: &[Coin], app: &App) -> AppResult { + funds + .iter() + .map(|c| { + let exchange_rate = query_exchange_rate(deps, c.denom.clone(), app)?; + Ok(c.amount * exchange_rate) + }) + .sum() +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use cosmwasm_std::Decimal; + + use crate::helpers::close_to; + + #[test] + fn not_close_to() { + assert!(!close_to(Decimal::percent(99), Decimal::one())) + } + + #[test] + fn actually_close_to() { + assert!(close_to( + Decimal::from_str("0.99999").unwrap(), + Decimal::one() + )); + } +} diff --git a/contracts/carrot-app/src/lib.rs b/contracts/carrot-app/src/lib.rs index 8c33c3b9..90580176 100644 --- a/contracts/carrot-app/src/lib.rs +++ b/contracts/carrot-app/src/lib.rs @@ -1,10 +1,15 @@ +pub mod autocompound; +pub mod check; pub mod contract; +pub mod distribution; pub mod error; +pub mod exchange_rate; mod handlers; pub mod helpers; pub mod msg; mod replies; pub mod state; +pub mod yield_sources; #[cfg(feature = "interface")] pub use contract::interface::AppInterface; diff --git a/contracts/carrot-app/src/msg.rs b/contracts/carrot-app/src/msg.rs index dddb15ea..1233cb17 100644 --- a/contracts/carrot-app/src/msg.rs +++ b/contracts/carrot-app/src/msg.rs @@ -1,10 +1,14 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Coin, Decimal, Uint128, Uint64}; -use cw_asset::AssetBase; +use cosmwasm_std::{wasm_execute, Coin, CosmosMsg, Decimal, Env, Uint128, Uint64}; use crate::{ - contract::App, - state::{AutocompoundRewardsConfig, Position}, + contract::{App, AppResult}, + distribution::deposit::OneDepositStrategy, + state::ConfigUnchecked, + yield_sources::{ + yield_type::{YieldType, YieldTypeUnchecked}, + AssetShare, StrategyElementUnchecked, StrategyUnchecked, + }, }; // This is used for type safety and re-exporting the contract endpoint structs. @@ -13,30 +17,13 @@ abstract_app::app_msg_types!(App, AppExecuteMsg, AppQueryMsg); /// App instantiate message #[cosmwasm_schema::cw_serde] pub struct AppInstantiateMsg { - /// Id of the pool used to get rewards - pub pool_id: u64, - /// Seconds to wait before autocompound is incentivized. - pub autocompound_cooldown_seconds: Uint64, - /// Configuration of rewards to the address who helped to execute autocompound - pub autocompound_rewards_config: AutocompoundRewardsConfig, + /// Future app configuration + pub config: ConfigUnchecked, + /// Future app strategy + pub strategy: StrategyUnchecked, /// Create position with instantiation. /// Will not create position if omitted - pub create_position: Option, -} - -#[cosmwasm_schema::cw_serde] -pub struct CreatePositionMessage { - pub lower_tick: i64, - pub upper_tick: i64, - // Funds to use to deposit on the account - pub funds: Vec, - /// The two next fields indicate the token0/token1 ratio we want to deposit inside the current ticks - pub asset0: Coin, - pub asset1: Coin, - // Slippage - pub max_spread: Option, - pub belief_price0: Option, - pub belief_price1: Option, + pub deposit: Option>, } /// App execute messages @@ -44,21 +31,75 @@ pub struct CreatePositionMessage { #[cfg_attr(feature = "interface", derive(cw_orch::ExecuteFns))] #[cfg_attr(feature = "interface", impl_into(ExecuteMsg))] pub enum AppExecuteMsg { - /// Create the initial liquidity position - CreatePosition(CreatePositionMessage), /// Deposit funds onto the app + /// Those funds will be distributed between yield sources according to the current strategy + /// TODO : for now only send stable coins that have the same value as USD + /// More tokens can be included when the oracle adapter is live Deposit { funds: Vec, - max_spread: Option, - belief_price0: Option, - belief_price1: Option, + /// This is additional paramters used to change the funds repartition when doing an additional deposit + /// This is not used for a first deposit into a strategy that hasn't changed for instance + /// This is an options because this is not mandatory + /// The vector then has option inside of it because we might not want to change parameters for all strategies + /// We might not use a vector but use a (usize, Vec) instead to avoid having to pass a full vector everytime + yield_sources_params: Option>>>, }, /// Partial withdraw of the funds available on the app - Withdraw { amount: Uint128 }, - /// Withdraw everything that is on the app - WithdrawAll {}, + /// If amount is omitted, withdraws everything that is on the app + Withdraw { amount: Option }, /// Auto-compounds the pool rewards into the pool Autocompound {}, + /// Rebalances all investments according to a new balance strategy + UpdateStrategy { + funds: Vec, + strategy: StrategyUnchecked, + }, + + /// Only called by the contract internally + Internal(InternalExecuteMsg), +} + +#[cw_serde] +#[cfg_attr(feature = "interface", derive(cw_orch::ExecuteFns))] +#[cfg_attr(feature = "interface", impl_into(ExecuteMsg))] +pub enum InternalExecuteMsg { + DepositOneStrategy { + swap_strategy: OneDepositStrategy, + yield_index: usize, + yield_type: YieldType, + }, + /// Execute one Deposit Swap Step + ExecuteOneDepositSwapStep { + asset_in: Coin, + denom_out: String, + expected_amount: Uint128, + }, + /// Finalize the deposit after all swaps are executed + FinalizeDeposit { + yield_index: usize, + yield_type: YieldType, + }, +} +impl From + for abstract_app::abstract_core::base::ExecuteMsg< + abstract_app::abstract_core::app::BaseExecuteMsg, + AppExecuteMsg, + > +{ + fn from(value: InternalExecuteMsg) -> Self { + Self::Module(AppExecuteMsg::Internal(value)) + } +} + +impl InternalExecuteMsg { + pub fn to_cosmos_msg(&self, env: &Env) -> AppResult { + Ok(wasm_execute( + env.contract.address.clone(), + &ExecuteMsg::Module(AppExecuteMsg::Internal(self.clone())), + vec![], + )? + .into()) + } } /// App query messages @@ -67,24 +108,54 @@ pub enum AppExecuteMsg { #[cfg_attr(feature = "interface", impl_into(QueryMsg))] #[derive(QueryResponses)] pub enum AppQueryMsg { - #[returns(crate::state::Config)] + #[returns(ConfigUnchecked)] Config {}, #[returns(AssetsBalanceResponse)] Balance {}, + #[returns(PositionsResponse)] + Positions {}, /// Get the claimable rewards that the position has accumulated. /// Returns [`AvailableRewardsResponse`] #[returns(AvailableRewardsResponse)] AvailableRewards {}, - #[returns(PositionResponse)] - Position {}, /// Get the status of the compounding logic of the application /// Returns [`CompoundStatusResponse`] #[returns(CompoundStatusResponse)] CompoundStatus {}, + /// Returns the current strategy as stored in the application + /// Returns [`StrategyResponse`] + #[returns(StrategyResponse)] + Strategy {}, + /// Returns the current funds distribution between all the strategies + /// Returns [`StrategyResponse`] + #[returns(StrategyResponse)] + StrategyStatus {}, + + // **** Simulation Endpoints *****/ + // **** These allow to preview what will happen under the hood for each operation inside the Carrot App *****/ + // Their arguments match the arguments of the corresponding Execute Endpoint + #[returns(DepositPreviewResponse)] + DepositPreview { + funds: Vec, + yield_sources_params: Option>>>, + }, + #[returns(WithdrawPreviewResponse)] + WithdrawPreview { amount: Option }, + + /// Returns a preview of the rebalance distribution + /// Returns [`RebalancePreviewResponse`] + #[returns(UpdateStrategyPreviewResponse)] + UpdateStrategyPreview { + funds: Vec, + strategy: StrategyUnchecked, + }, + + #[returns(AssetsBalanceResponse)] + FundsValue { funds: Vec }, } #[cosmwasm_schema::cw_serde] -pub enum AppMigrateMsg {} +pub struct AppMigrateMsg {} #[cosmwasm_schema::cw_serde] pub struct BalanceResponse { @@ -92,26 +163,36 @@ pub struct BalanceResponse { } #[cosmwasm_schema::cw_serde] pub struct AvailableRewardsResponse { - pub available_rewards: Vec, + pub available_rewards: AssetsBalanceResponse, } #[cw_serde] pub struct AssetsBalanceResponse { pub balances: Vec, - pub liquidity: String, + pub total_value: Uint128, +} + +#[cw_serde] +pub struct StrategyResponse { + pub strategy: StrategyUnchecked, +} + +#[cw_serde] +pub struct PositionsResponse { + pub positions: Vec, } #[cw_serde] pub struct PositionResponse { - pub position: Option, + pub params: YieldTypeUnchecked, + pub balance: AssetsBalanceResponse, + pub liquidity: Uint128, } #[cw_serde] pub struct CompoundStatusResponse { pub status: CompoundStatus, - pub reward: AssetBase, - // Wether user have enough balance to reward or can swap - pub rewards_available: bool, + pub execution_rewards: AssetsBalanceResponse, } #[cw_serde] @@ -130,3 +211,23 @@ impl CompoundStatus { matches!(self, Self::Ready {}) } } + +#[cw_serde] +pub struct DepositPreviewResponse { + pub withdraw: Vec<(StrategyElementUnchecked, Decimal)>, + pub deposit: Vec, +} + +#[cw_serde] +pub struct WithdrawPreviewResponse { + /// Share of the total deposit that will be withdrawn from the app + pub share: Decimal, + pub funds: Vec, + pub msgs: Vec, +} + +#[cw_serde] +pub struct UpdateStrategyPreviewResponse { + pub withdraw: Vec<(StrategyElementUnchecked, Decimal)>, + pub deposit: Vec, +} diff --git a/contracts/carrot-app/src/replies/add_to_position.rs b/contracts/carrot-app/src/replies/add_to_position.rs deleted file mode 100644 index 61f67902..00000000 --- a/contracts/carrot-app/src/replies/add_to_position.rs +++ /dev/null @@ -1,40 +0,0 @@ -use abstract_app::abstract_sdk::AbstractResponse; -use cosmwasm_std::{DepsMut, Env, Reply, StdError, SubMsgResponse, SubMsgResult}; -use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::MsgAddToPositionResponse; - -use crate::{ - contract::{App, AppResult}, - error::AppError, - helpers::get_user, - state::{Position, POSITION}, -}; - -pub fn add_to_position_reply(deps: DepsMut, env: Env, app: App, reply: Reply) -> AppResult { - let SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) = reply.result else { - return Err(AppError::Std(StdError::generic_err( - "Failed to create position", - ))); - }; - - // Parse the msg exec response from the reply - let parsed = cw_utils::parse_execute_response_data(&b)?; - - // Parse the position response from the message - let response: MsgAddToPositionResponse = parsed.data.unwrap_or_default().try_into()?; - - // We get the creator of the position - let creator = get_user(deps.as_ref(), &app)?; - - // We update the position - let position = Position { - owner: creator, - position_id: response.position_id, - last_compound: env.block.time, - }; - - POSITION.save(deps.storage, &position)?; - - Ok(app - .response("create_position_reply") - .add_attribute("updated_position_id", response.position_id.to_string())) -} diff --git a/contracts/carrot-app/src/replies/after_swaps.rs b/contracts/carrot-app/src/replies/after_swaps.rs new file mode 100644 index 00000000..02a24976 --- /dev/null +++ b/contracts/carrot-app/src/replies/after_swaps.rs @@ -0,0 +1,28 @@ +use abstract_sdk::AbstractResponse; +use cosmwasm_std::{coin, DepsMut, Env, Reply}; + +use crate::{ + contract::{App, AppResult}, + helpers::{add_funds, get_proxy_balance}, + state::{TEMP_CURRENT_COIN, TEMP_DEPOSIT_COINS}, +}; + +pub fn after_swap_reply(deps: DepsMut, _env: Env, app: App, _reply: Reply) -> AppResult { + let coins_before = TEMP_CURRENT_COIN.load(deps.storage)?; + let current_coins = get_proxy_balance(deps.as_ref(), &app, coins_before.denom.clone())?; + + // We just update the coins to deposit after the swap + if current_coins.amount > coins_before.amount { + TEMP_DEPOSIT_COINS.update(deps.storage, |f| { + add_funds( + f, + coin( + (current_coins.amount - coins_before.amount).into(), + current_coins.denom, + ), + ) + })?; + } + + Ok(app.response("after_swap_reply")) +} diff --git a/contracts/carrot-app/src/replies/mod.rs b/contracts/carrot-app/src/replies/mod.rs index a45bd96e..4590d673 100644 --- a/contracts/carrot-app/src/replies/mod.rs +++ b/contracts/carrot-app/src/replies/mod.rs @@ -1,8 +1,11 @@ -mod add_to_position; -mod create_position; +mod after_swaps; +mod osmosis; -pub const CREATE_POSITION_ID: u64 = 1; -pub const ADD_TO_POSITION_ID: u64 = 2; +pub const OSMOSIS_CREATE_POSITION_REPLY_ID: u64 = 1; +pub const OSMOSIS_ADD_TO_POSITION_REPLY_ID: u64 = 2; -pub use add_to_position::add_to_position_reply; -pub use create_position::create_position_reply; +pub const REPLY_AFTER_SWAPS_STEP: u64 = 3; + +pub use after_swaps::after_swap_reply; +pub use osmosis::add_to_position::add_to_position_reply; +pub use osmosis::create_position::create_position_reply; diff --git a/contracts/carrot-app/src/replies/osmosis/add_to_position.rs b/contracts/carrot-app/src/replies/osmosis/add_to_position.rs new file mode 100644 index 00000000..e65a6d4e --- /dev/null +++ b/contracts/carrot-app/src/replies/osmosis/add_to_position.rs @@ -0,0 +1,45 @@ +use abstract_app::abstract_sdk::AbstractResponse; +use cosmwasm_std::{DepsMut, Env, Reply, StdError, SubMsgResponse, SubMsgResult}; +use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::MsgAddToPositionResponse; + +use crate::{ + contract::{App, AppResult}, + error::AppError, + handlers::internal::save_strategy, + state::{STRATEGY_CONFIG, TEMP_CURRENT_YIELD}, + yield_sources::yield_type::YieldType, +}; + +pub fn add_to_position_reply(deps: DepsMut, _env: Env, app: App, reply: Reply) -> AppResult { + let SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) = reply.result else { + return Err(AppError::Std(StdError::generic_err( + "Failed to create position", + ))); + }; + + // Parse the msg exec response from the reply. This is because this reply is generated by calling the proxy contract + let parsed = cw_utils::parse_execute_response_data(&b)?; + + // Parse the position response from the message + let response: MsgAddToPositionResponse = parsed.data.unwrap_or_default().try_into()?; + + // We update the position + let current_position_index = TEMP_CURRENT_YIELD.load(deps.storage)?; + let mut strategy = STRATEGY_CONFIG.load(deps.storage)?; + + let current_yield = strategy.0.get_mut(current_position_index).unwrap(); + + current_yield.yield_source.params = match current_yield.yield_source.params.clone() { + YieldType::ConcentratedLiquidityPool(mut position) => { + position.position_id = Some(response.position_id); + YieldType::ConcentratedLiquidityPool(position) + } + YieldType::Mars(_) => return Err(AppError::WrongYieldType {}), + }; + + save_strategy(deps, strategy)?; + + Ok(app + .response("create_position_reply") + .add_attribute("updated_position_id", response.position_id.to_string())) +} diff --git a/contracts/carrot-app/src/replies/create_position.rs b/contracts/carrot-app/src/replies/osmosis/create_position.rs similarity index 51% rename from contracts/carrot-app/src/replies/create_position.rs rename to contracts/carrot-app/src/replies/osmosis/create_position.rs index d7e3e18b..e9ba404c 100644 --- a/contracts/carrot-app/src/replies/create_position.rs +++ b/contracts/carrot-app/src/replies/osmosis/create_position.rs @@ -5,11 +5,12 @@ use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::MsgCreatePositi use crate::{ contract::{App, AppResult}, error::AppError, - helpers::get_user, - state::{Position, POSITION}, + handlers::internal::save_strategy, + state::{STRATEGY_CONFIG, TEMP_CURRENT_YIELD}, + yield_sources::yield_type::YieldType, }; -pub fn create_position_reply(deps: DepsMut, env: Env, app: App, reply: Reply) -> AppResult { +pub fn create_position_reply(deps: DepsMut, _env: Env, app: App, reply: Reply) -> AppResult { let SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) = reply.result else { return Err(AppError::Std(StdError::generic_err( "Failed to create position", @@ -21,17 +22,21 @@ pub fn create_position_reply(deps: DepsMut, env: Env, app: App, reply: Reply) -> // Parse create position response let response: MsgCreatePositionResponse = parsed.data.clone().unwrap_or_default().try_into()?; - // We get the creator of the position - let creator = get_user(deps.as_ref(), &app)?; - // We save the position - let position = Position { - owner: creator, - position_id: response.position_id, - last_compound: env.block.time, + let current_position_index = TEMP_CURRENT_YIELD.load(deps.storage)?; + let mut strategy = STRATEGY_CONFIG.load(deps.storage)?; + + let current_yield = strategy.0.get_mut(current_position_index).unwrap(); + + current_yield.yield_source.params = match current_yield.yield_source.params.clone() { + YieldType::ConcentratedLiquidityPool(mut position) => { + position.position_id = Some(response.position_id); + YieldType::ConcentratedLiquidityPool(position.clone()) + } + YieldType::Mars(_) => return Err(AppError::WrongYieldType {}), }; - POSITION.save(deps.storage, &position)?; + save_strategy(deps, strategy)?; Ok(app .response("create_position_reply") diff --git a/contracts/carrot-app/src/replies/osmosis/mod.rs b/contracts/carrot-app/src/replies/osmosis/mod.rs new file mode 100644 index 00000000..7200e5be --- /dev/null +++ b/contracts/carrot-app/src/replies/osmosis/mod.rs @@ -0,0 +1,2 @@ +pub mod add_to_position; +pub mod create_position; diff --git a/contracts/carrot-app/src/state.rs b/contracts/carrot-app/src/state.rs index d08ba83f..6b432a7f 100644 --- a/contracts/carrot-app/src/state.rs +++ b/contracts/carrot-app/src/state.rs @@ -1,120 +1,27 @@ -use abstract_app::abstract_sdk::{feature_objects::AnsHost, Resolve}; -use abstract_app::{abstract_core::objects::AssetEntry, objects::DexAssetPairing}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{ensure, Addr, Deps, Env, MessageInfo, Storage, Timestamp, Uint128, Uint64}; +use cosmwasm_std::{Addr, Coin, Uint128}; use cw_storage_plus::Item; -use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::{ - ConcentratedliquidityQuerier, FullPositionBreakdown, -}; -use crate::{contract::AppResult, error::AppError, msg::CompoundStatus}; +use crate::autocompound::{AutocompoundConfigBase, AutocompoundState}; +use crate::check::{Checked, Unchecked}; +use crate::yield_sources::Strategy; pub const CONFIG: Item = Item::new("config"); -pub const POSITION: Item = Item::new("position"); +pub const STRATEGY_CONFIG: Item = Item::new("strategy_config"); +pub const AUTOCOMPOUND_STATE: Item = Item::new("position"); pub const CURRENT_EXECUTOR: Item = Item::new("executor"); -#[cw_serde] -pub struct Config { - pub pool_config: PoolConfig, - pub autocompound_cooldown_seconds: Uint64, - pub autocompound_rewards_config: AutocompoundRewardsConfig, -} - -/// Configuration on how rewards should be distributed -/// to the address who helped to execute autocompound -#[cw_serde] -pub struct AutocompoundRewardsConfig { - /// Gas denominator for this chain - pub gas_asset: AssetEntry, - /// Denominator of the asset that will be used for swap to the gas asset - pub swap_asset: AssetEntry, - /// Reward amount - pub reward: Uint128, - /// If gas token balance falls below this bound a swap will be generated - pub min_gas_balance: Uint128, - /// Upper bound of gas tokens expected after the swap - pub max_gas_balance: Uint128, -} - -impl AutocompoundRewardsConfig { - pub fn check(&self, deps: Deps, dex_name: &str, ans_host: &AnsHost) -> AppResult<()> { - ensure!( - self.reward <= self.min_gas_balance, - AppError::RewardConfigError( - "reward should be lower or equal to the min_gas_balance".to_owned() - ) - ); - ensure!( - self.max_gas_balance > self.min_gas_balance, - AppError::RewardConfigError( - "max_gas_balance has to be bigger than min_gas_balance".to_owned() - ) - ); - - // Check swap asset has pairing into gas asset - DexAssetPairing::new(self.gas_asset.clone(), self.swap_asset.clone(), dex_name) - .resolve(&deps.querier, ans_host)?; - - Ok(()) - } -} - -#[cw_serde] -pub struct PoolConfig { - pub pool_id: u64, - pub token0: String, - pub token1: String, - pub asset0: AssetEntry, - pub asset1: AssetEntry, -} +// TEMP VARIABLES FOR DEPOSITING INTO ONE STRATEGY +pub const TEMP_CURRENT_COIN: Item = Item::new("temp_current_coins"); +pub const TEMP_EXPECTED_SWAP_COIN: Item = Item::new("temp_expected_swap_coin"); +pub const TEMP_DEPOSIT_COINS: Item> = Item::new("temp_deposit_coins"); +pub const TEMP_CURRENT_YIELD: Item = Item::new("temp_current_yield_type"); -pub fn assert_contract(info: &MessageInfo, env: &Env) -> AppResult<()> { - if info.sender == env.contract.address { - Ok(()) - } else { - Err(AppError::Unauthorized {}) - } -} +pub type Config = ConfigBase; +pub type ConfigUnchecked = ConfigBase; #[cw_serde] -pub struct Position { - pub owner: Addr, - pub position_id: u64, - pub last_compound: Timestamp, -} - -pub fn get_position(deps: Deps) -> AppResult { - POSITION - .load(deps.storage) - .map_err(|_| AppError::NoPosition {}) -} - -pub fn get_osmosis_position(deps: Deps) -> AppResult { - let position = get_position(deps)?; - - ConcentratedliquidityQuerier::new(&deps.querier) - .position_by_id(position.position_id) - .map_err(|e| AppError::UnableToQueryPosition(position.position_id, e))? - .position - .ok_or(AppError::NoPosition {}) -} - -pub fn get_position_status( - storage: &dyn Storage, - env: &Env, - cooldown_seconds: u64, -) -> AppResult { - let position = POSITION.may_load(storage)?; - let status = match position { - Some(position) => { - let ready_on = position.last_compound.plus_seconds(cooldown_seconds); - if env.block.time >= ready_on { - CompoundStatus::Ready {} - } else { - CompoundStatus::Cooldown((env.block.time.seconds() - ready_on.seconds()).into()) - } - } - None => CompoundStatus::NoPosition {}, - }; - Ok(status) +pub struct ConfigBase { + pub autocompound_config: AutocompoundConfigBase, + pub dex: String, } diff --git a/contracts/carrot-app/src/yield_sources/mars.rs b/contracts/carrot-app/src/yield_sources/mars.rs new file mode 100644 index 00000000..48ccd22c --- /dev/null +++ b/contracts/carrot-app/src/yield_sources/mars.rs @@ -0,0 +1,90 @@ +use crate::contract::{App, AppResult}; +use abstract_app::traits::AccountIdentification; +use abstract_app::{objects::AnsAsset, traits::AbstractNameService}; +use abstract_money_market_adapter::msg::MoneyMarketQueryMsg; +use abstract_money_market_adapter::MoneyMarketInterface; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{coins, Coin, CosmosMsg, Deps, SubMsg, Uint128}; +use cw_asset::AssetInfo; + +use abstract_money_market_standard::query::MoneyMarketAnsQuery; + +use super::yield_type::YieldTypeImplementation; +use super::ShareType; + +pub const MARS_MONEY_MARKET: &str = "mars"; + +#[cw_serde] +pub struct MarsDepositParams { + pub denom: String, +} + +impl YieldTypeImplementation for MarsDepositParams { + fn deposit(&self, deps: Deps, funds: Vec, app: &App) -> AppResult> { + let ans = app.name_service(deps); + let ans_fund = ans.query(&AssetInfo::native(self.denom.clone()))?; + + Ok(vec![SubMsg::new( + app.ans_money_market(deps, MARS_MONEY_MARKET.to_string()) + .deposit(AnsAsset::new(ans_fund, funds[0].amount))?, + )]) + } + + fn withdraw( + &self, + deps: Deps, + amount: Option, + app: &App, + ) -> AppResult> { + let ans = app.name_service(deps); + + let amount = if let Some(amount) = amount { + amount + } else { + self.user_deposit(deps, app)?[0].amount + }; + + let ans_fund = ans.query(&AssetInfo::native(self.denom.clone()))?; + + Ok(vec![app + .ans_money_market(deps, MARS_MONEY_MARKET.to_string()) + .withdraw(AnsAsset::new(ans_fund, amount))?]) + } + + fn withdraw_rewards(&self, _deps: Deps, _app: &App) -> AppResult<(Vec, Vec)> { + // Mars doesn't have rewards, it's automatically auto-compounded + Ok((vec![], vec![])) + } + + fn user_deposit(&self, deps: Deps, app: &App) -> AppResult> { + let ans = app.name_service(deps); + let asset = ans.query(&AssetInfo::native(self.denom.clone()))?; + let user = app.account_base(deps)?.proxy; + + let deposit: Uint128 = app + .ans_money_market(deps, MARS_MONEY_MARKET.to_string()) + .query(MoneyMarketQueryMsg::MoneyMarketAnsQuery { + query: MoneyMarketAnsQuery::UserDeposit { + user: user.to_string(), + asset, + }, + money_market: MARS_MONEY_MARKET.to_string(), + })?; + + Ok(coins(deposit.u128(), self.denom.clone())) + } + + fn user_rewards(&self, _deps: Deps, _app: &App) -> AppResult> { + // No rewards, because mars is already auto-compounding + + Ok(vec![]) + } + + fn user_liquidity(&self, deps: Deps, app: &App) -> AppResult { + Ok(self.user_deposit(deps, app)?[0].amount) + } + + fn share_type(&self) -> super::ShareType { + ShareType::Fixed + } +} diff --git a/contracts/carrot-app/src/yield_sources/mod.rs b/contracts/carrot-app/src/yield_sources/mod.rs new file mode 100644 index 00000000..33d9bea7 --- /dev/null +++ b/contracts/carrot-app/src/yield_sources/mod.rs @@ -0,0 +1,79 @@ +pub mod mars; +pub mod osmosis_cl_pool; +pub mod yield_type; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Decimal; + +use crate::{ + check::{Checked, Unchecked}, + yield_sources::yield_type::YieldParamsBase, +}; + +/// A yield sources has the following elements +/// A vector of tokens that NEED to be deposited inside the yield source with a repartition of tokens +/// A type that allows routing to the right smart-contract integration internally +#[cw_serde] +pub struct YieldSourceBase { + pub asset_distribution: Vec, + pub params: YieldParamsBase, +} + +pub type YieldSourceUnchecked = YieldSourceBase; +pub type YieldSource = YieldSourceBase; + +impl YieldSourceBase { + pub fn all_denoms(&self) -> Vec { + self.asset_distribution + .iter() + .map(|e| e.denom.clone()) + .collect() + } +} + +/// This is used to express a share of tokens inside a strategy +#[cw_serde] +pub struct AssetShare { + pub denom: String, + pub share: Decimal, +} + +#[cw_serde] +pub enum ShareType { + /// This allows using the current distribution of tokens inside the position to compute the distribution on deposit + Dynamic, + /// This forces the position to use the target distribution of tokens when depositing + Fixed, +} + +// This represents a balance strategy +// This object is used for storing the current strategy, retrieving the actual strategy status or expressing a target strategy when depositing +#[cw_serde] +pub struct StrategyBase(pub Vec>); + +pub type StrategyUnchecked = StrategyBase; +pub type Strategy = StrategyBase; + +impl Strategy { + pub fn all_denoms(&self) -> Vec { + self.0 + .clone() + .iter() + .flat_map(|s| s.yield_source.all_denoms()) + .collect() + } +} + +#[cw_serde] +pub struct StrategyElementBase { + pub yield_source: YieldSourceBase, + pub share: Decimal, +} +impl From>> for StrategyBase { + fn from(value: Vec>) -> Self { + Self(value) + } +} + +pub type StrategyElementUnchecked = StrategyElementBase; +pub type StrategyElement = StrategyElementBase; diff --git a/contracts/carrot-app/src/yield_sources/osmosis_cl_pool.rs b/contracts/carrot-app/src/yield_sources/osmosis_cl_pool.rs new file mode 100644 index 00000000..eba7a6d1 --- /dev/null +++ b/contracts/carrot-app/src/yield_sources/osmosis_cl_pool.rs @@ -0,0 +1,258 @@ +use std::{marker::PhantomData, str::FromStr}; + +use crate::{ + check::{Checked, Unchecked}, + contract::{App, AppResult}, + error::AppError, + replies::{OSMOSIS_ADD_TO_POSITION_REPLY_ID, OSMOSIS_CREATE_POSITION_REPLY_ID}, +}; +use abstract_app::traits::AccountIdentification; +use abstract_sdk::Execution; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Coin, Coins, CosmosMsg, Deps, ReplyOn, SubMsg, Uint128}; +use osmosis_std::{ + cosmwasm_to_proto_coins, try_proto_to_cosmwasm_coins, + types::osmosis::concentratedliquidity::v1beta1::{ + ConcentratedliquidityQuerier, FullPositionBreakdown, MsgAddToPosition, + MsgCollectIncentives, MsgCollectSpreadRewards, MsgCreatePosition, MsgWithdrawPosition, + }, +}; + +use super::{yield_type::YieldTypeImplementation, ShareType}; + +#[cw_serde] +pub struct ConcentratedPoolParamsBase { + // This is part of the pool parameters + pub pool_id: u64, + // This is part of the pool parameters + pub lower_tick: i64, + // This is part of the pool parameters + pub upper_tick: i64, + // This is something that is filled after position creation + // This is not actually a parameter but rather state + // This can be used as a parameter for existing positions + pub position_id: Option, + pub _phantom: PhantomData, +} + +pub type ConcentratedPoolParamsUnchecked = ConcentratedPoolParamsBase; +pub type ConcentratedPoolParams = ConcentratedPoolParamsBase; + +impl YieldTypeImplementation for ConcentratedPoolParams { + fn deposit(&self, deps: Deps, funds: Vec, app: &App) -> AppResult> { + // We verify there is a position stored + if let Ok(position) = self.position(deps) { + self.raw_deposit(deps, funds, app, position) + } else { + // We need to create a position + self.create_position(deps, funds, app) + } + } + + fn withdraw( + &self, + deps: Deps, + amount: Option, + app: &App, + ) -> AppResult> { + let position = self.position(deps)?; + let position_details = position.position.unwrap(); + + let total_liquidity = position_details.liquidity.replace('.', ""); + + let liquidity_amount = if let Some(amount) = amount { + amount.to_string() + } else { + // TODO: it's decimals inside contracts + total_liquidity.clone() + }; + let user = app.account_base(deps)?.proxy; + + // We need to execute withdraw on the user's behalf + Ok(vec![MsgWithdrawPosition { + position_id: position_details.position_id, + sender: user.to_string(), + liquidity_amount: liquidity_amount.clone(), + } + .into()]) + } + + fn withdraw_rewards(&self, deps: Deps, app: &App) -> AppResult<(Vec, Vec)> { + let position = self.position(deps)?; + let position_details = position.position.unwrap(); + + let user = app.account_base(deps)?.proxy; + let mut rewards = Coins::default(); + let mut msgs: Vec = vec![]; + // If there are external incentives, claim them. + if !position.claimable_incentives.is_empty() { + for coin in try_proto_to_cosmwasm_coins(position.claimable_incentives)? { + rewards.add(coin)?; + } + msgs.push( + MsgCollectIncentives { + position_ids: vec![position_details.position_id], + sender: user.to_string(), + } + .into(), + ); + } + + // If there is income from swap fees, claim them. + if !position.claimable_spread_rewards.is_empty() { + for coin in try_proto_to_cosmwasm_coins(position.claimable_spread_rewards)? { + rewards.add(coin)?; + } + msgs.push( + MsgCollectSpreadRewards { + position_ids: vec![position_details.position_id], + sender: position_details.address.clone(), + } + .into(), + ) + } + + Ok((rewards.to_vec(), msgs)) + } + + /// This may return 0, 1 or 2 elements depending on the position's status + fn user_deposit(&self, deps: Deps, _app: &App) -> AppResult> { + let position = self.position(deps)?; + + Ok([ + try_proto_to_cosmwasm_coins(position.asset0)?, + try_proto_to_cosmwasm_coins(position.asset1)?, + ] + .into_iter() + .flatten() + .map(|mut fund| { + // This is used because osmosis seems to charge 1 amount for withdrawals on all positions + fund.amount -= Uint128::one(); + fund + }) + .collect()) + } + + fn user_rewards(&self, deps: Deps, _app: &App) -> AppResult> { + let position = self.position(deps)?; + + let mut rewards = cosmwasm_std::Coins::default(); + for coin in try_proto_to_cosmwasm_coins(position.claimable_incentives)? { + rewards.add(coin)?; + } + + for coin in try_proto_to_cosmwasm_coins(position.claimable_spread_rewards)? { + rewards.add(coin)?; + } + + Ok(rewards.into()) + } + + fn user_liquidity(&self, deps: Deps, _app: &App) -> AppResult { + let position = self.position(deps)?; + let total_liquidity = position.position.unwrap().liquidity.replace('.', ""); + + Ok(Uint128::from_str(&total_liquidity)?) + } + + fn share_type(&self) -> super::ShareType { + ShareType::Dynamic + } +} + +impl ConcentratedPoolParams { + /// This function creates a position for the user, + /// 1. Swap the indicated funds to match the asset0/asset1 ratio and deposit as much as possible in the pool for the given parameters + /// 2. Create a new position + /// 3. Store position id from create position response + /// + /// * `lower_tick` - Concentrated liquidity pool parameter + /// * `upper_tick` - Concentrated liquidity pool parameter + /// * `funds` - Funds that will be deposited from the user wallet directly into the pool. DO NOT SEND FUNDS TO THIS ENDPOINT + /// * `asset0` - The target amount of asset0.denom that the user will deposit inside the pool + /// * `asset1` - The target amount of asset1.denom that the user will deposit inside the pool + /// + /// asset0 and asset1 are only used in a ratio to each other. They are there to make sure that the deposited funds will ALL land inside the pool. + /// We don't use an asset ratio because either one of the amounts can be zero + /// See https://docs.osmosis.zone/osmosis-core/modules/concentrated-liquidity for more details + /// + fn create_position( + &self, + deps: Deps, + funds: Vec, + app: &App, + // create_position_msg: CreatePositionMessage, + ) -> AppResult> { + let proxy_addr = app.account_base(deps)?.proxy; + // 2. Create a position + let tokens = cosmwasm_to_proto_coins(funds); + let msg = app.executor(deps).execute_with_reply_and_data( + MsgCreatePosition { + pool_id: self.pool_id, + sender: proxy_addr.to_string(), + lower_tick: self.lower_tick, + upper_tick: self.upper_tick, + tokens_provided: tokens, + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + } + .into(), + ReplyOn::Success, + OSMOSIS_CREATE_POSITION_REPLY_ID, + )?; + + Ok(vec![msg]) + } + + fn raw_deposit( + &self, + deps: Deps, + funds: Vec, + app: &App, + position: FullPositionBreakdown, + ) -> AppResult> { + let position_id = position.position.clone().unwrap().position_id; + + let proxy_addr = app.account_base(deps)?.proxy; + + // We need to make sure the amounts are in the right order + // We assume the funds vector has 2 coins associated + let (amount0, amount1) = match position + .asset0 + .clone() + .map(|c| c.denom == funds[0].denom) + .or(position.asset1.clone().map(|c| c.denom == funds[1].denom)) + { + Some(true) => (funds[0].amount, funds[1].amount), // we already had the right order + Some(false) => (funds[1].amount, funds[0].amount), // we had the wrong order + None => { + return Err(AppError::NoPosition {}); + } // A position has to exist in order to execute this function. This should be unreachable + }; + + let deposit_msg = app.executor(deps).execute_with_reply_and_data( + MsgAddToPosition { + position_id, + sender: proxy_addr.to_string(), + amount0: amount0.to_string(), + amount1: amount1.to_string(), + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + } + .into(), + cosmwasm_std::ReplyOn::Success, + OSMOSIS_ADD_TO_POSITION_REPLY_ID, + )?; + + Ok(vec![deposit_msg]) + } + + fn position(&self, deps: Deps) -> AppResult { + let position_id = self.position_id.ok_or(AppError::NoPosition {})?; + ConcentratedliquidityQuerier::new(&deps.querier) + .position_by_id(position_id) + .map_err(|e| AppError::UnableToQueryPosition(position_id, e))? + .position + .ok_or(AppError::NoPosition {}) + } +} diff --git a/contracts/carrot-app/src/yield_sources/yield_type.rs b/contracts/carrot-app/src/yield_sources/yield_type.rs new file mode 100644 index 00000000..eca0bcec --- /dev/null +++ b/contracts/carrot-app/src/yield_sources/yield_type.rs @@ -0,0 +1,93 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Coin, CosmosMsg, Deps, SubMsg, Uint128}; + +use crate::{ + check::{Checked, Unchecked}, + contract::{App, AppResult}, +}; + +use super::{mars::MarsDepositParams, osmosis_cl_pool::ConcentratedPoolParamsBase, ShareType}; + +// This however is not checkable by itself, because the check also depends on the asset share distribution +#[cw_serde] +pub enum YieldParamsBase { + ConcentratedLiquidityPool(ConcentratedPoolParamsBase), + /// For Mars, you just need to deposit in the RedBank + /// You need to indicate the denom of the funds you want to deposit + Mars(MarsDepositParams), +} + +pub type YieldTypeUnchecked = YieldParamsBase; +pub type YieldType = YieldParamsBase; + +impl YieldTypeImplementation for YieldType { + fn deposit(&self, deps: Deps, funds: Vec, app: &App) -> AppResult> { + if funds.is_empty() { + return Ok(vec![]); + } + self.inner().deposit(deps, funds, app) + } + + fn withdraw( + &self, + deps: Deps, + amount: Option, + app: &App, + ) -> AppResult> { + self.inner().withdraw(deps, amount, app) + } + + fn withdraw_rewards(&self, deps: Deps, app: &App) -> AppResult<(Vec, Vec)> { + self.inner().withdraw_rewards(deps, app) + } + + fn user_deposit(&self, deps: Deps, app: &App) -> AppResult> { + Ok(self.inner().user_deposit(deps, app).unwrap_or_default()) + } + + fn user_rewards(&self, deps: Deps, app: &App) -> AppResult> { + Ok(self.inner().user_rewards(deps, app).unwrap_or_default()) + } + + fn user_liquidity(&self, deps: Deps, app: &App) -> AppResult { + Ok(self.inner().user_liquidity(deps, app).unwrap_or_default()) + } + + /// Indicate the default funds allocation + /// This is specifically useful for auto-compound as we're not able to input target amounts + /// CL pools use that to know the best funds deposit ratio + /// Mars doesn't use that, because the share is fixed to 1 + fn share_type(&self) -> ShareType { + self.inner().share_type() + } +} + +impl YieldType { + fn inner(&self) -> &dyn YieldTypeImplementation { + match self { + YieldType::ConcentratedLiquidityPool(params) => params, + YieldType::Mars(params) => params, + } + } +} + +pub trait YieldTypeImplementation { + fn deposit(&self, deps: Deps, funds: Vec, app: &App) -> AppResult>; + + fn withdraw(&self, deps: Deps, amount: Option, app: &App) + -> AppResult>; + + fn withdraw_rewards(&self, deps: Deps, app: &App) -> AppResult<(Vec, Vec)>; + + fn user_deposit(&self, deps: Deps, app: &App) -> AppResult>; + + fn user_rewards(&self, deps: Deps, app: &App) -> AppResult>; + + fn user_liquidity(&self, deps: Deps, app: &App) -> AppResult; + + /// Indicate the default funds allocation + /// This is specifically useful for auto-compound as we're not able to input target amounts + /// CL pools use that to know the best funds deposit ratio + /// Mars doesn't use that, because the share is fixed to 1 + fn share_type(&self) -> ShareType; +} diff --git a/contracts/carrot-app/tests/autocompound.rs b/contracts/carrot-app/tests/autocompound.rs index a25b2627..bae7e06b 100644 --- a/contracts/carrot-app/tests/autocompound.rs +++ b/contracts/carrot-app/tests/autocompound.rs @@ -1,15 +1,12 @@ mod common; -use crate::common::{ - create_position, setup_test_tube, DEX_NAME, GAS_DENOM, LOTS, REWARD_DENOM, USDC, USDT, -}; +use crate::common::{setup_test_tube, DEX_NAME, EXECUTOR_REWARD, GAS_DENOM, LOTS, USDC, USDT}; use abstract_app::abstract_interface::{Abstract, AbstractAccount}; use carrot_app::msg::{ AppExecuteMsgFns, AppQueryMsgFns, AssetsBalanceResponse, AvailableRewardsResponse, - CompoundStatus, CompoundStatusResponse, + CompoundStatus, }; -use cosmwasm_std::{coin, coins, Uint128}; -use cw_asset::AssetBase; +use cosmwasm_std::{coin, coins}; use cw_orch::osmosis_test_tube::osmosis_test_tube::Account; use cw_orch::{anyhow, prelude::*}; @@ -17,16 +14,19 @@ use cw_orch::{anyhow, prelude::*}; fn check_autocompound() -> anyhow::Result<()> { let (_, carrot_app) = setup_test_tube(false)?; - let chain = carrot_app.get_chain().clone(); + let mut chain = carrot_app.get_chain().clone(); // Create position - create_position( - &carrot_app, - coins(100_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), + let deposit_amount = 5_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), )?; + // Do the deposit + carrot_app.deposit(deposit_coins.clone(), None)?; + // Do some swaps let dex: abstract_dex_adapter::interface::DexAdapter<_> = carrot_app.module()?; let abs = Abstract::load_from(chain.clone())?; @@ -35,20 +35,20 @@ fn check_autocompound() -> anyhow::Result<()> { chain.bank_send( account.proxy.addr_str()?, vec![ - coin(200_000, USDC.to_owned()), - coin(200_000, USDT.to_owned()), + coin(2_000_000, USDC.to_owned()), + coin(2_000_000, USDT.to_owned()), ], )?; for _ in 0..10 { - dex.ans_swap((USDC, 50_000), USDT, DEX_NAME.to_string(), &account)?; - dex.ans_swap((USDT, 50_000), USDC, DEX_NAME.to_string(), &account)?; + dex.ans_swap((USDC, 500_000), USDT, DEX_NAME.to_string(), &account)?; + dex.ans_swap((USDT, 500_000), USDC, DEX_NAME.to_string(), &account)?; } // Check autocompound adds liquidity from the rewards and user balance remain unchanged // Check it has some rewards to autocompound first - let rewards: AvailableRewardsResponse = carrot_app.available_rewards()?; - assert!(!rewards.available_rewards.is_empty()); + let rewards = carrot_app.available_rewards()?; + assert!(!rewards.available_rewards.balances.is_empty()); // Save balances let balance_before_autocompound: AssetsBalanceResponse = carrot_app.balance()?; @@ -81,13 +81,13 @@ fn check_autocompound() -> anyhow::Result<()> { .unwrap(); // Liquidity added - assert!(balance_after_autocompound.liquidity > balance_before_autocompound.liquidity); + assert!(balance_after_autocompound.total_value > balance_before_autocompound.total_value); // Only rewards went in there assert!(balance_usdc_after_autocompound.amount >= balance_usdc_before_autocompound.amount); assert!(balance_usdt_after_autocompound.amount >= balance_usdt_before_autocompound.amount,); // Check it used all of the rewards let rewards: AvailableRewardsResponse = carrot_app.available_rewards()?; - assert!(rewards.available_rewards.is_empty()); + assert!(rewards.available_rewards.balances.is_empty()); Ok(()) } @@ -100,13 +100,16 @@ fn stranger_autocompound() -> anyhow::Result<()> { let stranger = chain.init_account(coins(LOTS, GAS_DENOM))?; // Create position - create_position( - &carrot_app, - coins(100_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), + let deposit_amount = 5_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), )?; + // Do the deposit + carrot_app.deposit(deposit_coins.clone(), None)?; + // Do some swaps let dex: abstract_dex_adapter::interface::DexAdapter<_> = carrot_app.module()?; let abs = Abstract::load_from(chain.clone())?; @@ -115,21 +118,21 @@ fn stranger_autocompound() -> anyhow::Result<()> { chain.bank_send( account.proxy.addr_str()?, vec![ - coin(200_000, USDC.to_owned()), - coin(200_000, USDT.to_owned()), + coin(2_000_000, USDC.to_owned()), + coin(2_000_000, USDT.to_owned()), ], )?; for _ in 0..10 { - dex.ans_swap((USDC, 50_000), USDT, DEX_NAME.to_string(), &account)?; - dex.ans_swap((USDT, 50_000), USDC, DEX_NAME.to_string(), &account)?; + dex.ans_swap((USDC, 500_000), USDT, DEX_NAME.to_string(), &account)?; + dex.ans_swap((USDT, 500_000), USDC, DEX_NAME.to_string(), &account)?; } // Check autocompound adds liquidity from the rewards, user balance remain unchanged // and rewards gets passed to the "stranger" // Check it has some rewards to autocompound first - let rewards: AvailableRewardsResponse = carrot_app.available_rewards()?; - assert!(!rewards.available_rewards.is_empty()); + let available_rewards: AvailableRewardsResponse = carrot_app.available_rewards()?; + assert!(!available_rewards.available_rewards.balances.is_empty()); // Save balances let balance_before_autocompound: AssetsBalanceResponse = carrot_app.balance()?; @@ -137,29 +140,27 @@ fn stranger_autocompound() -> anyhow::Result<()> { // Autocompound by stranger chain.wait_seconds(300)?; // Check query is able to compute rewards, when swap is required - let compound_status: CompoundStatusResponse = carrot_app.compound_status()?; - assert_eq!( - compound_status, - CompoundStatusResponse { - status: CompoundStatus::Ready {}, - reward: AssetBase::native(REWARD_DENOM, 1000u128), - rewards_available: true - } - ); + let compound_status = carrot_app.compound_status()?; + assert_eq!(compound_status.status, CompoundStatus::Ready {},); carrot_app.call_as(&stranger).autocompound()?; // Save new balances let balance_after_autocompound: AssetsBalanceResponse = carrot_app.balance()?; // Liquidity added - assert!(balance_after_autocompound.liquidity > balance_before_autocompound.liquidity); + assert!(balance_after_autocompound.total_value > balance_before_autocompound.total_value); // Check it used all of the rewards let rewards: AvailableRewardsResponse = carrot_app.available_rewards()?; - assert!(rewards.available_rewards.is_empty()); + assert!(rewards.available_rewards.balances.is_empty()); // Check stranger gets rewarded - let stranger_reward_balance = chain.query_balance(stranger.address().as_str(), REWARD_DENOM)?; - assert_eq!(stranger_reward_balance, Uint128::new(1000)); + + for reward in available_rewards.available_rewards.balances { + let stranger_reward_balance = + chain.query_balance(stranger.address().as_str(), &reward.denom)?; + assert_eq!(stranger_reward_balance, reward.amount * EXECUTOR_REWARD); + } + Ok(()) } diff --git a/contracts/carrot-app/tests/common.rs b/contracts/carrot-app/tests/common.rs index 1f301645..c8f4f7b0 100644 --- a/contracts/carrot-app/tests/common.rs +++ b/contracts/carrot-app/tests/common.rs @@ -1,47 +1,37 @@ -use std::iter; +use std::str::FromStr; use abstract_app::abstract_core::objects::{ - pool_id::PoolAddressBase, AccountId, AssetEntry, PoolMetadata, PoolType, + pool_id::PoolAddressBase, AssetEntry, PoolMetadata, PoolType, }; -use abstract_app::objects::module::ModuleInfo; -use abstract_client::{AbstractClient, Application, Environment, Namespace}; -use abstract_dex_adapter::DEX_ADAPTER_ID; -use abstract_sdk::core::manager::{self, ModuleInstallConfig}; -use carrot_app::contract::APP_ID; -use carrot_app::msg::{AppInstantiateMsg, CreatePositionMessage}; -use carrot_app::state::AutocompoundRewardsConfig; -use cosmwasm_std::{coin, coins, to_json_binary, to_json_vec, Decimal, Uint128, Uint64}; +use abstract_client::{AbstractClient, Application, Namespace}; +use carrot_app::autocompound::{AutocompoundConfigBase, AutocompoundRewardsConfigBase}; +use carrot_app::contract::OSMOSIS; +use carrot_app::msg::AppInstantiateMsg; +use carrot_app::state::ConfigBase; +use carrot_app::yield_sources::osmosis_cl_pool::ConcentratedPoolParamsBase; +use carrot_app::yield_sources::yield_type::YieldParamsBase; +use carrot_app::yield_sources::{AssetShare, StrategyBase, StrategyElementBase, YieldSourceBase}; +use cosmwasm_std::{coin, coins, Coins, Decimal, Uint64}; use cw_asset::AssetInfoUnchecked; +use cw_orch::environment::MutCwEnv; use cw_orch::osmosis_test_tube::osmosis_test_tube::Gamm; use cw_orch::{ anyhow, osmosis_test_tube::osmosis_test_tube::{ osmosis_std::types::{ - cosmos::{ - authz::v1beta1::{GenericAuthorization, Grant, MsgGrant, MsgGrantResponse}, - base::v1beta1, - }, - osmosis::{ - concentratedliquidity::v1beta1::{ - MsgCreatePosition, MsgWithdrawPosition, Pool, PoolsRequest, - }, - gamm::v1beta1::MsgSwapExactAmountIn, - }, + cosmos::base::v1beta1, + osmosis::concentratedliquidity::v1beta1::{MsgCreatePosition, Pool, PoolsRequest}, }, ConcentratedLiquidity, GovWithAppAccess, Module, }, prelude::*, }; -use osmosis_std::types::cosmos::bank::v1beta1::SendAuthorization; -use osmosis_std::types::cosmwasm::wasm::v1::MsgExecuteContract; use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::{ - CreateConcentratedLiquidityPoolsProposal, MsgAddToPosition, MsgCollectIncentives, - MsgCollectSpreadRewards, PoolRecord, + CreateConcentratedLiquidityPoolsProposal, PoolRecord, }; use prost::Message; -use prost_types::Any; - pub const LOTS: u128 = 100_000_000_000_000; +pub const LOTS_PROVIDE: u128 = 5_000_000; // Asset 0 pub const USDT: &str = "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB"; @@ -55,16 +45,19 @@ pub const GAS_DENOM: &str = "uosmo"; pub const DEX_NAME: &str = "osmosis"; pub const TICK_SPACING: u64 = 100; -pub const SPREAD_FACTOR: u64 = 1; +pub const SPREAD_FACTOR: &str = "0.01"; pub const INITIAL_LOWER_TICK: i64 = -100000; pub const INITIAL_UPPER_TICK: i64 = 10000; + +pub const EXECUTOR_REWARD: Decimal = Decimal::percent(30); + // Deploys abstract and other contracts -pub fn deploy( - chain: Chain, +pub fn deploy( + mut chain: Chain, pool_id: u64, gas_pool_id: u64, - create_position: Option, + initial_deposit: Option>, ) -> anyhow::Result>> { let asset0 = USDT.to_owned(); let asset1 = USDC.to_owned(); @@ -102,78 +95,72 @@ pub fn deploy( // We deploy the carrot_app let publisher = client .publisher_builder(Namespace::new("abstract")?) + .install_on_sub_account(false) .build()?; // The dex adapter let dex_adapter = publisher .publish_adapter::<_, abstract_dex_adapter::interface::DexAdapter>( abstract_dex_adapter::msg::DexInstantiateMsg { - swap_fee: Decimal::percent(2), + swap_fee: Decimal::permille(2), recipient_account: 0, }, )?; + // // The moneymarket adapter + // let money_market_adapter = publisher + // .publish_adapter::<_, abstract_money_market_adapter::interface::MoneyMarketAdapter< + // Chain, + // >>( + // abstract_money_market_adapter::msg::MoneyMarketInstantiateMsg { + // fee: Decimal::percent(2), + // recipient_account: 0, + // }, + // )?; // The savings app publisher.publish_app::>()?; - let create_position_on_init = create_position.is_some(); + if let Some(deposit) = &initial_deposit { + chain.add_balance(publisher.account().proxy()?.to_string(), deposit.clone())?; + } + let init_msg = AppInstantiateMsg { - pool_id, - // 5 mins - autocompound_cooldown_seconds: Uint64::new(300), - autocompound_rewards_config: AutocompoundRewardsConfig { - gas_asset: AssetEntry::new(REWARD_ASSET), - swap_asset: AssetEntry::new(USDC), - reward: Uint128::new(1000), - min_gas_balance: Uint128::new(2000), - max_gas_balance: Uint128::new(10000), + config: ConfigBase { + // 5 mins + autocompound_config: AutocompoundConfigBase { + cooldown_seconds: Uint64::new(300), + rewards: AutocompoundRewardsConfigBase { + reward_percent: EXECUTOR_REWARD, + _phantom: std::marker::PhantomData, + }, + }, + dex: OSMOSIS.to_string(), }, - create_position, + strategy: StrategyBase(vec![StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::one(), + }]), + deposit: initial_deposit, }; - // If we create position on instantiate - give auth - let carrot_app = if create_position_on_init { - // TODO: We can't get account factory or module factory objects from the client. - // get Account id of the upcoming sub-account - let next_local_account_id = client.next_local_account_id()?; - let savings_app_addr = client - .module_instantiate2_address::>(&AccountId::local( - next_local_account_id, - ))?; - - // Give all authzs and create subaccount with app in single tx - let mut msgs = give_authorizations_msgs(&client, savings_app_addr)?; - let create_sub_account_message = Any { - type_url: MsgExecuteContract::TYPE_URL.to_owned(), - value: MsgExecuteContract { - sender: chain.sender().to_string(), - contract: publisher.account().manager()?.to_string(), - msg: to_json_vec(&manager::ExecuteMsg::CreateSubAccount { - name: "bob".to_owned(), - description: None, - link: None, - base_asset: None, - namespace: None, - install_modules: vec![ - ModuleInstallConfig::new(ModuleInfo::from_id_latest(DEX_ADAPTER_ID)?, None), - ModuleInstallConfig::new( - ModuleInfo::from_id_latest(APP_ID)?, - Some(to_json_binary(&init_msg)?), - ), - ], - account_id: Some(next_local_account_id), - })?, - funds: vec![], - } - .to_proto_bytes(), - }; - msgs.push(create_sub_account_message); - let _ = chain.commit_any::(msgs, None)?; - - // Now get Application struct - let account = client.account_from(AccountId::local(next_local_account_id))?; - account.application::>()? - } else { - // We install the carrot-app - let carrot_app: Application> = + // We install the carrot-app + let carrot_app: Application> = publisher .account() .install_app_with_dependencies::>( @@ -181,8 +168,6 @@ pub fn deploy( Empty {}, &[], )?; - carrot_app - }; // We update authorized addresses on the adapter for the app dex_adapter.execute( &abstract_dex_adapter::msg::ExecuteMsg::Base( @@ -196,36 +181,25 @@ pub fn deploy( ), None, )?; + // money_market_adapter.execute( + // &abstract_money_market_adapter::msg::ExecuteMsg::Base( + // abstract_app::abstract_core::adapter::BaseExecuteMsg { + // proxy_address: Some(carrot_app.account().proxy()?.to_string()), + // msg: abstract_app::abstract_core::adapter::AdapterBaseMsg::UpdateAuthorizedAddresses { + // to_add: vec![carrot_app.addr_str()?], + // to_remove: vec![], + // }, + // }, + // ), + // None, + // )?; Ok(carrot_app) } -pub fn create_position( - app: &Application>, - funds: Vec, - asset0: Coin, - asset1: Coin, -) -> anyhow::Result { - app.execute( - &carrot_app::msg::AppExecuteMsg::CreatePosition(CreatePositionMessage { - lower_tick: INITIAL_LOWER_TICK, - upper_tick: INITIAL_UPPER_TICK, - funds, - asset0, - asset1, - max_spread: None, - belief_price0: None, - belief_price1: None, - }) - .into(), - None, - ) - .map_err(Into::into) -} - pub fn create_pool(mut chain: OsmosisTestTube) -> anyhow::Result<(u64, u64)> { - chain.add_balance(chain.sender(), coins(LOTS, USDC))?; - chain.add_balance(chain.sender(), coins(LOTS, USDT))?; + chain.add_balance(chain.sender(), coins(LOTS_PROVIDE, USDC))?; + chain.add_balance(chain.sender(), coins(LOTS_PROVIDE, USDT))?; let asset0 = USDT.to_owned(); let asset1 = USDC.to_owned(); @@ -244,7 +218,7 @@ pub fn create_pool(mut chain: OsmosisTestTube) -> anyhow::Result<(u64, u64)> { // }], // None, // )?; - let _proposal_response = GovWithAppAccess::new(&chain.app.borrow()) + GovWithAppAccess::new(&chain.app.borrow()) .propose_and_execute( CreateConcentratedLiquidityPoolsProposal::TYPE_URL.to_string(), CreateConcentratedLiquidityPoolsProposal { @@ -255,19 +229,20 @@ pub fn create_pool(mut chain: OsmosisTestTube) -> anyhow::Result<(u64, u64)> { denom0: USDT.to_owned(), denom1: USDC.to_owned(), tick_spacing: TICK_SPACING, - spread_factor: Decimal::percent(SPREAD_FACTOR).atomics().to_string(), + spread_factor: Decimal::from_str(SPREAD_FACTOR)?.atomics().to_string(), }], }, chain.sender().to_string(), &chain.sender, ) .unwrap(); + let test_tube = chain.app.borrow(); let cl = ConcentratedLiquidity::new(&*test_tube); let pools = cl.query_pools(&PoolsRequest { pagination: None }).unwrap(); - let pool = Pool::decode(pools.pools[0].value.as_slice()).unwrap(); + let pool = Pool::decode(pools.pools.last().unwrap().value.as_slice()).unwrap(); let _response = cl .create_position( MsgCreatePosition { @@ -278,11 +253,11 @@ pub fn create_pool(mut chain: OsmosisTestTube) -> anyhow::Result<(u64, u64)> { tokens_provided: vec![ v1beta1::Coin { denom: asset1, - amount: "1_000_000".to_owned(), + amount: (LOTS_PROVIDE / 2).to_string(), }, v1beta1::Coin { denom: asset0.clone(), - amount: "1_000_000".to_owned(), + amount: (LOTS_PROVIDE / 2).to_string(), }, ], token_min_amount0: "0".to_string(), @@ -322,107 +297,16 @@ pub fn setup_test_tube( // We create a usdt-usdc pool let (pool_id, gas_pool_id) = create_pool(chain.clone())?; - let create_position_msg = create_position.then(|| - // TODO: Requires instantiate2 to test it (we need to give authz authorization before instantiating) - CreatePositionMessage { - lower_tick: INITIAL_LOWER_TICK, - upper_tick: INITIAL_UPPER_TICK, - funds: coins(100_000, USDT), - asset0: coin(1_000_000, USDT), - asset1: coin(1_000_000, USDC), - max_spread: None, - belief_price0: None, - belief_price1: None, - }); - let carrot_app = deploy(chain.clone(), pool_id, gas_pool_id, create_position_msg)?; - - // Give authorizations if not given already - if !create_position { - let client = AbstractClient::new(chain)?; - give_authorizations(&client, carrot_app.addr_str()?)?; - } - Ok((pool_id, carrot_app)) -} - -pub fn give_authorizations_msgs( - client: &AbstractClient, - savings_app_addr: impl Into, -) -> Result, anyhow::Error> { - let dex_fee_account = client.account_from(AccountId::local(0))?; - let dex_fee_addr = dex_fee_account.proxy()?.to_string(); - let chain = client.environment().clone(); - - let authorization_urls = [ - MsgCreatePosition::TYPE_URL, - MsgSwapExactAmountIn::TYPE_URL, - MsgAddToPosition::TYPE_URL, - MsgWithdrawPosition::TYPE_URL, - MsgCollectIncentives::TYPE_URL, - MsgCollectSpreadRewards::TYPE_URL, - ] - .map(ToOwned::to_owned); - let savings_app_addr: String = savings_app_addr.into(); - let granter = chain.sender().to_string(); - let grantee = savings_app_addr.clone(); - - let dex_spend_limit = vec![ - cw_orch::osmosis_test_tube::osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin { - denom: USDC.to_owned(), - amount: LOTS.to_string(), - }, - cw_orch::osmosis_test_tube::osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin { - denom: USDT.to_owned(), - amount: LOTS.to_string(), - }, - cw_orch::osmosis_test_tube::osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin { - denom: REWARD_DENOM.to_owned(), - amount: LOTS.to_string(), - }]; - let dex_fee_authorization = Any { - value: MsgGrant { - granter: chain.sender().to_string(), - grantee: grantee.clone(), - grant: Some(Grant { - authorization: Some( - SendAuthorization { - spend_limit: dex_spend_limit, - allow_list: vec![dex_fee_addr, savings_app_addr], - } - .to_any(), - ), - expiration: None, - }), - } - .encode_to_vec(), - type_url: MsgGrant::TYPE_URL.to_owned(), - }; - - let msgs: Vec = authorization_urls - .into_iter() - .map(|msg| Any { - value: MsgGrant { - granter: granter.clone(), - grantee: grantee.clone(), - grant: Some(Grant { - authorization: Some(GenericAuthorization { msg }.to_any()), - expiration: None, - }), - } - .encode_to_vec(), - type_url: MsgGrant::TYPE_URL.to_owned(), + let initial_deposit: Option> = create_position + .then(|| { + // TODO: Requires instantiate2 to test it (we need to give authz authorization before instantiating) + let mut initial_coins = Coins::default(); + initial_coins.add(coin(10_000, USDT))?; + initial_coins.add(coin(10_000, USDC))?; + Ok::<_, anyhow::Error>(initial_coins.into()) }) - .chain(iter::once(dex_fee_authorization)) - .collect(); - Ok(msgs) -} + .transpose()?; + let carrot_app = deploy(chain.clone(), pool_id, gas_pool_id, initial_deposit)?; -pub fn give_authorizations( - client: &AbstractClient, - savings_app_addr: impl Into, -) -> Result<(), anyhow::Error> { - let msgs = give_authorizations_msgs(client, savings_app_addr)?; - client - .environment() - .commit_any::(msgs, None)?; - Ok(()) + Ok((pool_id, carrot_app)) } diff --git a/contracts/carrot-app/tests/config.rs b/contracts/carrot-app/tests/config.rs new file mode 100644 index 00000000..ce9f54c0 --- /dev/null +++ b/contracts/carrot-app/tests/config.rs @@ -0,0 +1,419 @@ +mod common; + +use crate::common::{create_pool, setup_test_tube, USDC, USDT}; +use carrot_app::{ + msg::{AppExecuteMsgFns, AppQueryMsgFns}, + yield_sources::{ + osmosis_cl_pool::ConcentratedPoolParamsBase, yield_type::YieldParamsBase, AssetShare, + StrategyBase, StrategyElementBase, YieldSourceBase, + }, +}; +use common::{INITIAL_LOWER_TICK, INITIAL_UPPER_TICK}; +use cosmwasm_std::{coins, Decimal, Uint128}; +use cw_orch::anyhow; +use cw_orch::prelude::BankSetter; +use cw_orch::prelude::ContractInstance; + +#[test] +fn rebalance_fails() -> anyhow::Result<()> { + let (_, carrot_app) = setup_test_tube(false)?; + + carrot_app + .update_strategy( + vec![], + StrategyBase(vec![ + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool( + ConcentratedPoolParamsBase { + pool_id: 7, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }, + ), + }, + share: Decimal::one(), + }, + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool( + ConcentratedPoolParamsBase { + pool_id: 7, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }, + ), + }, + share: Decimal::one(), + }, + ]), + ) + .unwrap_err(); + + // We query the nex strategy + + Ok(()) +} + +#[test] +fn rebalance_success() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + let mut chain = carrot_app.get_chain().clone(); + + let new_strat = StrategyBase(vec![ + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, // Pool Id needs to exist + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, // Pool Id needs to exist + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + ]); + let strategy = carrot_app.strategy()?; + assert_ne!(strategy.strategy, new_strat); + let deposit_coins = coins(10, USDC); + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + + carrot_app.update_strategy(deposit_coins, new_strat.clone())?; + + // We query the new strategy + let strategy = carrot_app.strategy()?; + assert_eq!(strategy.strategy.0.len(), 2); + + Ok(()) +} + +#[test] +fn rebalance_with_new_pool_success() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + let mut chain = carrot_app.get_chain().clone(); + let (new_pool_id, _) = create_pool(chain.clone())?; + + let deposit_amount = 10_000; + let deposit_coins = coins(deposit_amount, USDT); + + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + + let new_strat = StrategyBase(vec![ + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, // Pool Id needs to exist + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id: new_pool_id, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + ]); + carrot_app.update_strategy(deposit_coins.clone(), new_strat.clone())?; + + carrot_app.strategy()?; + + // We query the balance + let balance = carrot_app.balance()?; + assert!(balance.total_value > Uint128::from(deposit_amount) * Decimal::percent(2)); + + let distribution = carrot_app.positions()?; + + // We make sure the total values are close between the 2 positions + let balance0 = distribution.positions[0].balance.total_value; + let balance1 = distribution.positions[1].balance.total_value; + let balance_diff = balance0 + .checked_sub(balance1) + .or(balance1.checked_sub(balance0))?; + assert!(balance_diff < Uint128::from(deposit_amount) * Decimal::permille(5)); + + Ok(()) +} + +#[test] +fn rebalance_with_stale_strategy_success() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + let mut chain = carrot_app.get_chain().clone(); + let (new_pool_id, _) = create_pool(chain.clone())?; + + let deposit_amount = 10_000; + let deposit_coins = coins(deposit_amount, USDT); + + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + let common_yield_source = YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, // Pool Id needs to exist + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }; + + let strat = StrategyBase(vec![ + StrategyElementBase { + yield_source: common_yield_source.clone(), + share: Decimal::percent(50), + }, + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id: new_pool_id, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + ]); + + carrot_app.update_strategy(deposit_coins.clone(), strat.clone())?; + + let new_strat = StrategyBase(vec![StrategyElementBase { + yield_source: common_yield_source.clone(), + share: Decimal::percent(100), + }]); + let total_value_before = carrot_app.balance()?.total_value; + + // No additional deposit + carrot_app.update_strategy(vec![], new_strat.clone())?; + + carrot_app.strategy()?; + + // We query the balance + let balance = carrot_app.balance()?; + println!( + "Before :{}, after: {}", + total_value_before, balance.total_value + ); + // Make sure the deposit went almost all in + assert!(balance.total_value > Uint128::from(deposit_amount) * Decimal::percent(98)); + + // Make sure the total value has almost not changed when updating the strategy + assert!(balance.total_value > total_value_before * Decimal::percent(99)); + + let distribution = carrot_app.positions()?; + + // We make sure the total values are close between the 2 positions + assert_eq!(distribution.positions.len(), 1); + + Ok(()) +} + +#[test] +fn rebalance_with_current_and_stale_strategy_success() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + let mut chain = carrot_app.get_chain().clone(); + let (new_pool_id, _) = create_pool(chain.clone())?; + + let deposit_amount = 10_000; + let deposit_coins = coins(deposit_amount, USDT); + + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + let moving_strategy = YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id: new_pool_id, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }; + + let strat = StrategyBase(vec![ + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, // Pool Id needs to exist + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + StrategyElementBase { + yield_source: moving_strategy.clone(), + share: Decimal::percent(50), + }, + ]); + + carrot_app.update_strategy(deposit_coins.clone(), strat.clone())?; + + let mut strategies = carrot_app.strategy()?.strategy; + + strategies.0[1].yield_source = moving_strategy; + + let total_value_before = carrot_app.balance()?.total_value; + + // No additional deposit + carrot_app.update_strategy(vec![], strategies.clone())?; + + assert_eq!(carrot_app.strategy()?.strategy.0.len(), 2); + + // We query the balance + let balance = carrot_app.balance()?; + // Make sure the deposit went almost all in + println!( + "Before :{}, after: {}", + total_value_before, balance.total_value + ); + assert!(balance.total_value > Uint128::from(deposit_amount) * Decimal::percent(98)); + + // Make sure the total value has almost not changed when updating the strategy + assert!(balance.total_value > total_value_before * Decimal::permille(997)); + + let distribution = carrot_app.positions()?; + + // We make sure the total values are close between the 2 positions + assert_eq!(distribution.positions.len(), 2); + + Ok(()) +} diff --git a/contracts/carrot-app/tests/deposit_withdraw.rs b/contracts/carrot-app/tests/deposit_withdraw.rs index d472e48a..09a8a378 100644 --- a/contracts/carrot-app/tests/deposit_withdraw.rs +++ b/contracts/carrot-app/tests/deposit_withdraw.rs @@ -1,68 +1,85 @@ mod common; -use crate::common::{create_position, setup_test_tube, USDC, USDT}; -use carrot_app::msg::{AppExecuteMsgFns, AppQueryMsgFns, AssetsBalanceResponse, PositionResponse}; -use cosmwasm_std::{coin, coins, Decimal, Uint128}; -use cw_orch::{ - anyhow, - osmosis_test_tube::osmosis_test_tube::{ - osmosis_std::types::osmosis::concentratedliquidity::v1beta1::MsgWithdrawPosition, - ConcentratedLiquidity, Module, +use crate::common::{setup_test_tube, USDC, USDT}; +use abstract_client::Application; +use carrot_app::{ + msg::{AppExecuteMsgFns, AppQueryMsgFns, AssetsBalanceResponse}, + yield_sources::{ + mars::MarsDepositParams, osmosis_cl_pool::ConcentratedPoolParamsBase, + yield_type::YieldParamsBase, AssetShare, StrategyBase, StrategyElementBase, + YieldSourceBase, }, - prelude::*, + AppInterface, }; -use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::PositionByIdRequest; +use common::{INITIAL_LOWER_TICK, INITIAL_UPPER_TICK}; +use cosmwasm_std::{coin, coins, Decimal, Uint128}; +use cw_orch::{anyhow, prelude::*}; + +fn query_balances( + carrot_app: &Application>, +) -> anyhow::Result { + let balance = carrot_app.balance(); + if balance.is_err() { + return Ok(Uint128::zero()); + } + let sum = balance? + .balances + .iter() + .fold(Uint128::zero(), |acc, e| acc + e.amount); + + Ok(sum) +} #[test] fn deposit_lands() -> anyhow::Result<()> { let (_, carrot_app) = setup_test_tube(false)?; + // We should add funds to the account proxy let deposit_amount = 5_000; - let max_fee = Uint128::new(deposit_amount).mul_floor(Decimal::percent(3)); - // Create position - create_position( - &carrot_app, - coins(deposit_amount, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + let mut chain = carrot_app.get_chain().clone(); + + let balances_before = query_balances(&carrot_app)?; + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), )?; - // Check almost everything landed - let balance: AssetsBalanceResponse = carrot_app.balance()?; - let sum = balance - .balances - .iter() - .fold(Uint128::zero(), |acc, e| acc + e.amount); - assert!(sum.u128() > deposit_amount - max_fee.u128()); // Do the deposit - carrot_app.deposit( - vec![coin(deposit_amount, USDT.to_owned())], - None, - None, - None, - )?; + carrot_app.deposit(deposit_coins.clone(), None)?; // Check almost everything landed - let balance: AssetsBalanceResponse = carrot_app.balance()?; - let sum = balance - .balances - .iter() - .fold(Uint128::zero(), |acc, e| acc + e.amount); - assert!(sum.u128() > (deposit_amount - max_fee.u128()) * 2); - - // Do the second deposit - carrot_app.deposit( - vec![coin(deposit_amount, USDT.to_owned())], - None, - None, - None, + let balances_after = query_balances(&carrot_app)?; + println!( + "Expected deposit amount {}, actual deposit {}, remaining", + deposit_amount, + balances_after - balances_before, + ); + assert!( + balances_after > balances_before + Uint128::from(deposit_amount) * Decimal::percent(98) + ); + + // Add some more funds + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), )?; + // Do the second deposit + let response = carrot_app.deposit(vec![coin(deposit_amount, USDT.to_owned())], None)?; // Check almost everything landed - let balance: AssetsBalanceResponse = carrot_app.balance()?; - let sum = balance - .balances - .iter() - .fold(Uint128::zero(), |acc, e| acc + e.amount); - assert!(sum.u128() > (deposit_amount - max_fee.u128()) * 3); + let balances_after_second = query_balances(&carrot_app)?; + println!( + "Expected deposit amount {}, actual deposit {}, remaining", + deposit_amount, + balances_after_second - balances_after, + ); + assert!( + balances_after_second + > balances_after + Uint128::from(deposit_amount) * Decimal::percent(98) + ); + + // We assert the deposit response is an add to position and not a create position + response.event_attr_value("add_to_position", "new_position_id")?; + Ok(()) } @@ -70,41 +87,40 @@ fn deposit_lands() -> anyhow::Result<()> { fn withdraw_position() -> anyhow::Result<()> { let (_, carrot_app) = setup_test_tube(false)?; - let chain = carrot_app.get_chain().clone(); + let mut chain = carrot_app.get_chain().clone(); - // Create position - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; + // Add some more funds + let deposit_amount = 10_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + let proxy_addr = carrot_app.account().proxy()?; + chain.add_balance(proxy_addr.to_string(), deposit_coins.clone())?; + carrot_app.deposit(deposit_coins, None)?; let balance: AssetsBalanceResponse = carrot_app.balance()?; let balance_usdc_before_withdraw = chain .bank_querier() - .balance(chain.sender(), Some(USDT.to_owned()))? + .balance(&proxy_addr, Some(USDT.to_owned()))? .pop() .unwrap(); let balance_usdt_before_withdraw = chain .bank_querier() - .balance(chain.sender(), Some(USDC.to_owned()))? + .balance(&proxy_addr, Some(USDC.to_owned()))? .pop() .unwrap(); - // Withdraw half of liquidity - let liquidity_amount: Uint128 = balance.liquidity.parse().unwrap(); + // Withdraw some of the value + let liquidity_amount: Uint128 = balance.balances[0].amount; let half_of_liquidity = liquidity_amount / Uint128::new(2); - carrot_app.withdraw(half_of_liquidity)?; + carrot_app.withdraw(Some(half_of_liquidity))?; let balance_usdc_after_half_withdraw = chain .bank_querier() - .balance(chain.sender(), Some(USDT.to_owned()))? + .balance(&proxy_addr, Some(USDT.to_owned()))? .pop() .unwrap(); let balance_usdt_after_half_withdraw = chain .bank_querier() - .balance(chain.sender(), Some(USDC.to_owned()))? + .balance(&proxy_addr, Some(USDC.to_owned()))? .pop() .unwrap(); @@ -112,7 +128,7 @@ fn withdraw_position() -> anyhow::Result<()> { assert!(balance_usdt_after_half_withdraw.amount > balance_usdt_before_withdraw.amount); // Withdraw rest of liquidity - carrot_app.withdraw_all()?; + carrot_app.withdraw(None)?; let balance_usdc_after_full_withdraw = chain .bank_querier() .balance(chain.sender(), Some(USDT.to_owned()))? @@ -130,131 +146,280 @@ fn withdraw_position() -> anyhow::Result<()> { } #[test] -fn deposit_both_assets() -> anyhow::Result<()> { +fn deposit_multiple_assets() -> anyhow::Result<()> { let (_, carrot_app) = setup_test_tube(false)?; - // Create position - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - carrot_app.deposit( - vec![coin(258, USDT.to_owned()), coin(234, USDC.to_owned())], - None, - None, - None, - )?; + let mut chain = carrot_app.get_chain().clone(); + let proxy_addr = carrot_app.account().proxy()?; + let deposit_coins = vec![coin(234, USDC.to_owned()), coin(258, USDT.to_owned())]; + chain.add_balance(proxy_addr.to_string(), deposit_coins.clone())?; + carrot_app.deposit(deposit_coins, None)?; Ok(()) } #[test] -fn create_position_on_instantiation() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(true)?; +fn deposit_multiple_positions() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + + let new_strat = StrategyBase(vec![ + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, + lower_tick: 2 * INITIAL_LOWER_TICK, + upper_tick: 2 * INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + ]); + + let deposit_amount = 5_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + let mut chain = carrot_app.get_chain().clone(); - let position: PositionResponse = carrot_app.position()?; - assert!(position.position.is_some()); + let balances_before = query_balances(&carrot_app)?; + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + carrot_app.update_strategy(deposit_coins, new_strat.clone())?; + let balances_after = query_balances(&carrot_app)?; + + let slippage = Decimal::percent(4); + assert!( + balances_after + > balances_before + (Uint128::from(deposit_amount) * (Decimal::one() - slippage)) + ); Ok(()) } #[test] -fn withdraw_after_user_withdraw_liquidity_manually() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(true)?; - let chain = carrot_app.get_chain().clone(); - - let position: PositionResponse = carrot_app.position()?; - let position_id = position.position.unwrap().position_id; - - let test_tube = chain.app.borrow(); - let cl = ConcentratedLiquidity::new(&*test_tube); - let position_breakdown = cl - .query_position_by_id(&PositionByIdRequest { position_id })? - .position - .unwrap(); - let position = position_breakdown.position.unwrap(); - - cl.withdraw_position( - MsgWithdrawPosition { - position_id: position.position_id, - sender: chain.sender().to_string(), - liquidity_amount: position.liquidity, +fn deposit_multiple_positions_with_empty() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + + let new_strat = StrategyBase(vec![ + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, + lower_tick: INITIAL_LOWER_TICK, + upper_tick: INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), }, - &chain.sender, - )?; + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![ + AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(50), + }, + AssetShare { + denom: USDC.to_string(), + share: Decimal::percent(50), + }, + ], + params: YieldParamsBase::ConcentratedLiquidityPool(ConcentratedPoolParamsBase { + pool_id, + lower_tick: 2 * INITIAL_LOWER_TICK, + upper_tick: 2 * INITIAL_UPPER_TICK, + position_id: None, + _phantom: std::marker::PhantomData, + }), + }, + share: Decimal::percent(50), + }, + StrategyElementBase { + yield_source: YieldSourceBase { + asset_distribution: vec![AssetShare { + denom: USDT.to_string(), + share: Decimal::percent(100), + }], + params: YieldParamsBase::Mars(MarsDepositParams { + denom: USDT.to_string(), + }), + }, + share: Decimal::percent(0), + }, + ]); - // Ensure it errors - carrot_app.withdraw_all().unwrap_err(); + let deposit_amount = 5_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + let mut chain = carrot_app.get_chain().clone(); - // Ensure position deleted - let position_not_found = cl - .query_position_by_id(&PositionByIdRequest { position_id }) - .unwrap_err(); - assert!(position_not_found - .to_string() - .contains("position not found")); + let balances_before = query_balances(&carrot_app)?; + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + carrot_app.update_strategy(deposit_coins, new_strat.clone())?; + let balances_after = query_balances(&carrot_app)?; + + println!("{balances_before} --> {balances_after}"); + let slippage = Decimal::percent(4); + assert!( + balances_after + > balances_before + (Uint128::from(deposit_amount) * (Decimal::one() - slippage)) + ); Ok(()) } #[test] -fn deposit_slippage() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(false)?; - - let deposit_amount = 5_000; - let max_fee = Uint128::new(deposit_amount).mul_floor(Decimal::percent(3)); - // Create position - create_position( - &carrot_app, - coins(deposit_amount, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; +fn create_position_on_instantiation() -> anyhow::Result<()> { + let (_, carrot_app) = setup_test_tube(true)?; - // Do the deposit of asset0 with incorrect belief_price1 - let e = carrot_app - .deposit( - vec![coin(deposit_amount, USDT.to_owned())], - None, - Some(Decimal::zero()), - None, - ) - .unwrap_err(); - assert!(e.to_string().contains("exceeds max spread limit")); - - // Do the deposit of asset1 with incorrect belief_price0 - let e = carrot_app - .deposit( - vec![coin(deposit_amount, USDC.to_owned())], - Some(Decimal::zero()), - None, - None, - ) - .unwrap_err(); - assert!(e.to_string().contains("exceeds max spread limit")); - - // Do the deposits of asset0 with correct belief_price - carrot_app.deposit( - vec![coin(deposit_amount, USDT.to_owned())], - None, - Some(Decimal::one()), - Some(Decimal::percent(10)), - )?; - // Do the deposits of asset1 with correct belief_price - carrot_app.deposit( - vec![coin(deposit_amount, USDT.to_owned())], - Some(Decimal::one()), - None, - Some(Decimal::percent(10)), - )?; + let position = carrot_app.positions()?; + assert!(!position.positions.is_empty()); - // Check almost everything landed - let balance: AssetsBalanceResponse = carrot_app.balance()?; - let sum = balance - .balances - .iter() - .fold(Uint128::zero(), |acc, e| acc + e.amount); - assert!(sum.u128() > (deposit_amount - max_fee.u128()) * 3); + let balance = carrot_app.balance()?; + assert!(balance.total_value > Uint128::from(20_000u128) * Decimal::percent(99)); Ok(()) } + +// #[test] +// fn withdraw_after_user_withdraw_liquidity_manually() -> anyhow::Result<()> { +// let (_, carrot_app) = setup_test_tube(true)?; +// let chain = carrot_app.get_chain().clone(); + +// let position: PositionResponse = carrot_app.position()?; +// let position_id = position.position.unwrap().position_id; + +// let test_tube = chain.app.borrow(); +// let cl = ConcentratedLiquidity::new(&*test_tube); +// let position_breakdown = cl +// .query_position_by_id(&PositionByIdRequest { position_id })? +// .position +// .unwrap(); +// let position = position_breakdown.position.unwrap(); + +// cl.withdraw_position( +// MsgWithdrawPosition { +// position_id: position.position_id, +// sender: chain.sender().to_string(), +// liquidity_amount: position.liquidity, +// }, +// &chain.sender, +// )?; + +// // Ensure it errors +// carrot_app.withdraw_all().unwrap_err(); + +// // Ensure position deleted +// let position_not_found = cl +// .query_position_by_id(&PositionByIdRequest { position_id }) +// .unwrap_err(); +// assert!(position_not_found +// .to_string() +// .contains("position not found")); +// Ok(()) +// } + +// #[test] +// fn deposit_slippage() -> anyhow::Result<()> { +// let (_, carrot_app) = setup_test_tube(false)?; + +// let deposit_amount = 5_000; +// let max_fee = Uint128::new(deposit_amount).mul_floor(Decimal::percent(3)); +// // Create position +// create_position( +// &carrot_app, +// coins(deposit_amount, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// // Do the deposit of asset0 with incorrect belief_price1 +// let e = carrot_app +// .deposit( +// vec![coin(deposit_amount, USDT.to_owned())], +// None, +// Some(Decimal::zero()), +// None, +// ) +// .unwrap_err(); +// assert!(e.to_string().contains("exceeds max spread limit")); + +// // Do the deposit of asset1 with incorrect belief_price0 +// let e = carrot_app +// .deposit( +// vec![coin(deposit_amount, USDC.to_owned())], +// Some(Decimal::zero()), +// None, +// None, +// ) +// .unwrap_err(); +// assert!(e.to_string().contains("exceeds max spread limit")); + +// // Do the deposits of asset0 with correct belief_price +// carrot_app.deposit( +// vec![coin(deposit_amount, USDT.to_owned())], +// None, +// Some(Decimal::one()), +// Some(Decimal::percent(10)), +// )?; +// // Do the deposits of asset1 with correct belief_price +// carrot_app.deposit( +// vec![coin(deposit_amount, USDT.to_owned())], +// Some(Decimal::one()), +// None, +// Some(Decimal::percent(10)), +// )?; + +// // Check almost everything landed +// let balance: AssetsBalanceResponse = carrot_app.balance()?; +// let sum = balance +// .balances +// .iter() +// .fold(Uint128::zero(), |acc, e| acc + e.amount); +// assert!(sum.u128() > (deposit_amount - max_fee.u128()) * 3); +// Ok(()) +// } diff --git a/contracts/carrot-app/tests/pool_inbalance.rs b/contracts/carrot-app/tests/pool_inbalance.rs new file mode 100644 index 00000000..deebf02f --- /dev/null +++ b/contracts/carrot-app/tests/pool_inbalance.rs @@ -0,0 +1,70 @@ +mod common; + +use crate::common::{setup_test_tube, USDC, USDT}; +use carrot_app::msg::AppExecuteMsgFns; +use cosmwasm_std::{coin, coins}; +use cw_orch::{anyhow, prelude::*}; +use osmosis_std::types::osmosis::{ + gamm::v1beta1::{MsgSwapExactAmountIn, MsgSwapExactAmountInResponse}, + poolmanager::v1beta1::SwapAmountInRoute, +}; +use prost_types::Any; + +#[test] +fn deposit_after_inbalance_works() -> anyhow::Result<()> { + let (pool_id, carrot_app) = setup_test_tube(false)?; + + // We should add funds to the account proxy + let deposit_amount = 5_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + let mut chain = carrot_app.get_chain().clone(); + let proxy = carrot_app.account().proxy()?; + chain.add_balance(proxy.to_string(), deposit_coins.clone())?; + + // Do the deposit + carrot_app.deposit(deposit_coins.clone(), None)?; + + // Create a pool inbalance by swapping a lot deposit amount from one to the other. + // All the positions in the pool are centered, so the price doesn't change, just the funds ratio inside the position + + let swap_msg = MsgSwapExactAmountIn { + sender: chain.sender().to_string(), + token_in: Some(osmosis_std::types::cosmos::base::v1beta1::Coin { + denom: USDT.to_string(), + amount: "10_000".to_string(), + }), + token_out_min_amount: "1".to_string(), + routes: vec![SwapAmountInRoute { + pool_id, + token_out_denom: USDC.to_string(), + }], + } + .to_any(); + chain.commit_any::( + vec![Any { + type_url: swap_msg.type_url, + value: swap_msg.value, + }], + None, + )?; + + let proxy_balance_before_second = chain + .bank_querier() + .balance(&proxy, Some(USDT.to_string()))?[0] + .amount; + // Add some more funds + chain.add_balance(proxy.to_string(), deposit_coins.clone())?; + + // // Do the second deposit + carrot_app.deposit(vec![coin(deposit_amount, USDT.to_owned())], None)?; + // Check almost everything landed + let proxy_balance_after_second = chain + .bank_querier() + .balance(&proxy, Some(USDT.to_string()))?[0] + .amount; + + // Assert second deposit is more efficient than the first one + assert!(proxy_balance_after_second - proxy_balance_before_second < proxy_balance_before_second); + + Ok(()) +} diff --git a/contracts/carrot-app/tests/query.rs b/contracts/carrot-app/tests/query.rs new file mode 100644 index 00000000..a6132fb7 --- /dev/null +++ b/contracts/carrot-app/tests/query.rs @@ -0,0 +1,50 @@ +mod common; + +use crate::common::{setup_test_tube, USDT}; +use carrot_app::{ + helpers::close_to, + msg::{AppExecuteMsgFns, AppQueryMsgFns}, +}; +use cosmwasm_std::{coins, Decimal}; +use cw_orch::{anyhow, prelude::*}; + +#[test] +fn query_strategy_status() -> anyhow::Result<()> { + let (_, carrot_app) = setup_test_tube(false)?; + + // We should add funds to the account proxy + let deposit_amount = 5_000; + let deposit_coins = coins(deposit_amount, USDT.to_owned()); + let mut chain = carrot_app.get_chain().clone(); + + chain.add_balance( + carrot_app.account().proxy()?.to_string(), + deposit_coins.clone(), + )?; + + // Do the deposit + carrot_app.deposit(deposit_coins.clone(), None)?; + + let strategy = carrot_app.strategy_status()?.strategy; + + assert_eq!(strategy.0.len(), 1); + let single_strategy = strategy.0[0].clone(); + assert_eq!(single_strategy.share, Decimal::one()); + assert_eq!(single_strategy.yield_source.asset_distribution.len(), 2); + // The strategy shares are a little off 50% + assert_ne!( + single_strategy.yield_source.asset_distribution[0].share, + Decimal::percent(50) + ); + assert_ne!( + single_strategy.yield_source.asset_distribution[1].share, + Decimal::percent(50) + ); + assert!(close_to( + Decimal::one(), + single_strategy.yield_source.asset_distribution[0].share + + single_strategy.yield_source.asset_distribution[1].share + ),); + + Ok(()) +} diff --git a/contracts/carrot-app/tests/recreate_position.rs b/contracts/carrot-app/tests/recreate_position.rs index f4109645..a7cac577 100644 --- a/contracts/carrot-app/tests/recreate_position.rs +++ b/contracts/carrot-app/tests/recreate_position.rs @@ -1,265 +1,265 @@ -mod common; - -use crate::common::{ - create_position, give_authorizations, setup_test_tube, INITIAL_LOWER_TICK, INITIAL_UPPER_TICK, - USDC, USDT, -}; -use abstract_app::objects::{AccountId, AssetEntry}; -use abstract_client::{AbstractClient, Environment}; -use carrot_app::error::AppError; -use carrot_app::msg::{ - AppExecuteMsgFns, AppInstantiateMsg, AppQueryMsgFns, AssetsBalanceResponse, - CreatePositionMessage, PositionResponse, -}; -use carrot_app::state::AutocompoundRewardsConfig; -use common::REWARD_ASSET; -use cosmwasm_std::{coin, coins, Uint128, Uint64}; -use cw_orch::{ - anyhow, - osmosis_test_tube::osmosis_test_tube::{ - osmosis_std::types::osmosis::concentratedliquidity::v1beta1::MsgWithdrawPosition, - ConcentratedLiquidity, Module, - }, - prelude::*, -}; -use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::PositionByIdRequest; - -#[test] -fn create_multiple_positions() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(false)?; - - // Create position - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - // Create position second time, it should fail - let position_err = create_position( - &carrot_app, - coins(5_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - ) - .unwrap_err(); - - assert!(position_err - .to_string() - .contains(&AppError::PositionExists {}.to_string())); - Ok(()) -} - -#[test] -fn create_multiple_positions_after_withdraw() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(false)?; - - // Create position - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - // Withdraw half of liquidity - let balance: AssetsBalanceResponse = carrot_app.balance()?; - let liquidity_amount: Uint128 = balance.liquidity.parse().unwrap(); - let half_of_liquidity = liquidity_amount / Uint128::new(2); - carrot_app.withdraw(half_of_liquidity)?; - - // Create position second time, it should fail - let position_err = create_position( - &carrot_app, - coins(5_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - ) - .unwrap_err(); - - assert!(position_err - .to_string() - .contains(&AppError::PositionExists {}.to_string())); - - // Withdraw whole liquidity - let balance: AssetsBalanceResponse = carrot_app.balance()?; - let liquidity_amount: Uint128 = balance.liquidity.parse().unwrap(); - carrot_app.withdraw(liquidity_amount)?; - - // Create position second time, it should fail - create_position( - &carrot_app, - coins(5_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - Ok(()) -} - -#[test] -fn create_multiple_positions_after_withdraw_all() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(false)?; - - // Create position - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - // Withdraw whole liquidity - carrot_app.withdraw_all()?; - - // Create position second time, it should succeed - create_position( - &carrot_app, - coins(5_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - Ok(()) -} - -#[test] -fn create_position_after_user_withdraw_liquidity_manually() -> anyhow::Result<()> { - let (_, carrot_app) = setup_test_tube(true)?; - let chain = carrot_app.get_chain().clone(); - - let position = carrot_app.position()?; - - let test_tube = chain.app.borrow(); - let cl = ConcentratedLiquidity::new(&*test_tube); - let position_breakdown = cl - .query_position_by_id(&PositionByIdRequest { - position_id: position.position.unwrap().position_id, - })? - .position - .unwrap(); - let position = position_breakdown.position.unwrap(); - - cl.withdraw_position( - MsgWithdrawPosition { - position_id: position.position_id, - sender: chain.sender().to_string(), - liquidity_amount: position.liquidity, - }, - &chain.sender, - )?; - - // Create position, ignoring it was manually withdrawn - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - let position = carrot_app.position()?; - assert!(position.position.is_some()); - Ok(()) -} - -#[test] -fn install_on_sub_account() -> anyhow::Result<()> { - let (pool_id, app) = setup_test_tube(false)?; - let owner_account = app.account(); - let chain = owner_account.environment(); - let client = AbstractClient::new(chain)?; - let next_id = client.next_local_account_id()?; - - let init_msg = AppInstantiateMsg { - pool_id, - // 5 mins - autocompound_cooldown_seconds: Uint64::new(300), - autocompound_rewards_config: AutocompoundRewardsConfig { - gas_asset: AssetEntry::new(REWARD_ASSET), - swap_asset: AssetEntry::new(USDC), - reward: Uint128::new(1000), - min_gas_balance: Uint128::new(2000), - max_gas_balance: Uint128::new(10000), - }, - create_position: None, - }; - - let account = client - .account_builder() - .sub_account(owner_account) - .account_id(next_id) - .name("carrot-sub-acc") - .install_app_with_dependencies::>( - &init_msg, - Empty {}, - )? - .build()?; - - let carrot_app = account.application::>()?; - - give_authorizations(&client, carrot_app.addr_str()?)?; - create_position( - &carrot_app, - coins(10_000, USDT.to_owned()), - coin(1_000_000, USDT.to_owned()), - coin(1_000_000, USDC.to_owned()), - )?; - - let position: PositionResponse = carrot_app.position()?; - assert!(position.position.is_some()); - Ok(()) -} - -#[test] -fn install_on_sub_account_create_position_on_install() -> anyhow::Result<()> { - let (pool_id, app) = setup_test_tube(false)?; - let owner_account = app.account(); - let chain = owner_account.environment(); - let client = AbstractClient::new(chain)?; - let next_id = client.next_local_account_id()?; - let carrot_app_address = client - .module_instantiate2_address::>( - &AccountId::local(next_id), - )?; - - give_authorizations(&client, carrot_app_address)?; - let init_msg = AppInstantiateMsg { - pool_id, - // 5 mins - autocompound_cooldown_seconds: Uint64::new(300), - autocompound_rewards_config: AutocompoundRewardsConfig { - gas_asset: AssetEntry::new(REWARD_ASSET), - swap_asset: AssetEntry::new(USDC), - reward: Uint128::new(500_000), - min_gas_balance: Uint128::new(1_000_000), - max_gas_balance: Uint128::new(3_000_000), - }, - create_position: Some(CreatePositionMessage { - lower_tick: INITIAL_LOWER_TICK, - upper_tick: INITIAL_UPPER_TICK, - funds: coins(100_000, USDC), - asset0: coin(1_000_672_899, USDT), - asset1: coin(10_000_000_000, USDC), - max_spread: None, - belief_price0: None, - belief_price1: None, - }), - }; - - let account = client - .account_builder() - .sub_account(owner_account) - .account_id(next_id) - .name("carrot-sub-acc") - .install_app_with_dependencies::>( - &init_msg, - Empty {}, - )? - .build()?; - - let carrot_app = account.application::>()?; - - let position: PositionResponse = carrot_app.position()?; - assert!(position.position.is_some()); - Ok(()) -} +// mod common; + +// use crate::common::{ +// create_position, give_authorizations, setup_test_tube, INITIAL_LOWER_TICK, INITIAL_UPPER_TICK, +// USDC, USDT, +// }; +// use abstract_app::objects::{AccountId, AssetEntry}; +// use abstract_client::{AbstractClient, Environment}; +// use carrot_app::error::AppError; +// use carrot_app::msg::{ +// AppExecuteMsgFns, AppInstantiateMsg, AppQueryMsgFns, AssetsBalanceResponse, +// CreatePositionMessage, PositionResponse, +// }; +// use carrot_app::state::AutocompoundRewardsConfig; +// use common::REWARD_ASSET; +// use cosmwasm_std::{coin, coins, Uint128, Uint64}; +// use cw_orch::{ +// anyhow, +// osmosis_test_tube::osmosis_test_tube::{ +// osmosis_std::types::osmosis::concentratedliquidity::v1beta1::MsgWithdrawPosition, +// ConcentratedLiquidity, Module, +// }, +// prelude::*, +// }; +// use osmosis_std::types::osmosis::concentratedliquidity::v1beta1::PositionByIdRequest; + +// #[test] +// fn create_multiple_positions() -> anyhow::Result<()> { +// let (_, carrot_app) = setup_test_tube(false)?; + +// // Create position +// create_position( +// &carrot_app, +// coins(10_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// // Create position second time, it should fail +// let position_err = create_position( +// &carrot_app, +// coins(5_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// ) +// .unwrap_err(); + +// assert!(position_err +// .to_string() +// .contains(&AppError::PositionExists {}.to_string())); +// Ok(()) +// } + +// #[test] +// fn create_multiple_positions_after_withdraw() -> anyhow::Result<()> { +// let (_, carrot_app) = setup_test_tube(false)?; + +// // Create position +// create_position( +// &carrot_app, +// coins(10_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// // Withdraw half of liquidity +// let balance: AssetsBalanceResponse = carrot_app.balance()?; +// let liquidity_amount: Uint128 = balance.liquidity.parse().unwrap(); +// let half_of_liquidity = liquidity_amount / Uint128::new(2); +// carrot_app.withdraw(half_of_liquidity)?; + +// // Create position second time, it should fail +// let position_err = create_position( +// &carrot_app, +// coins(5_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// ) +// .unwrap_err(); + +// assert!(position_err +// .to_string() +// .contains(&AppError::PositionExists {}.to_string())); + +// // Withdraw whole liquidity +// let balance: AssetsBalanceResponse = carrot_app.balance()?; +// let liquidity_amount: Uint128 = balance.liquidity.parse().unwrap(); +// carrot_app.withdraw(liquidity_amount)?; + +// // Create position second time, it should fail +// create_position( +// &carrot_app, +// coins(5_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// Ok(()) +// } + +// #[test] +// fn create_multiple_positions_after_withdraw_all() -> anyhow::Result<()> { +// let (_, carrot_app) = setup_test_tube(false)?; + +// // Create position +// create_position( +// &carrot_app, +// coins(10_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// // Withdraw whole liquidity +// carrot_app.withdraw_all()?; + +// // Create position second time, it should succeed +// create_position( +// &carrot_app, +// coins(5_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; +// Ok(()) +// } + +// #[test] +// fn create_position_after_user_withdraw_liquidity_manually() -> anyhow::Result<()> { +// let (_, carrot_app) = setup_test_tube(true)?; +// let chain = carrot_app.get_chain().clone(); + +// let position = carrot_app.position()?; + +// let test_tube = chain.app.borrow(); +// let cl = ConcentratedLiquidity::new(&*test_tube); +// let position_breakdown = cl +// .query_position_by_id(&PositionByIdRequest { +// position_id: position.position.unwrap().position_id, +// })? +// .position +// .unwrap(); +// let position = position_breakdown.position.unwrap(); + +// cl.withdraw_position( +// MsgWithdrawPosition { +// position_id: position.position_id, +// sender: chain.sender().to_string(), +// liquidity_amount: position.liquidity, +// }, +// &chain.sender, +// )?; + +// // Create position, ignoring it was manually withdrawn +// create_position( +// &carrot_app, +// coins(10_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// let position = carrot_app.position()?; +// assert!(position.position.is_some()); +// Ok(()) +// } + +// #[test] +// fn install_on_sub_account() -> anyhow::Result<()> { +// let (pool_id, app) = setup_test_tube(false)?; +// let owner_account = app.account(); +// let chain = owner_account.environment(); +// let client = AbstractClient::new(chain)?; +// let next_id = client.next_local_account_id()?; + +// let init_msg = AppInstantiateMsg { +// pool_id, +// // 5 mins +// autocompound_cooldown_seconds: Uint64::new(300), +// autocompound_rewards_config: AutocompoundRewardsConfig { +// gas_asset: AssetEntry::new(REWARD_ASSET), +// swap_asset: AssetEntry::new(USDC), +// reward: Uint128::new(1000), +// min_gas_balance: Uint128::new(2000), +// max_gas_balance: Uint128::new(10000), +// }, +// create_position: None, +// }; + +// let account = client +// .account_builder() +// .sub_account(owner_account) +// .account_id(next_id) +// .name("carrot-sub-acc") +// .install_app_with_dependencies::>( +// &init_msg, +// Empty {}, +// )? +// .build()?; + +// let carrot_app = account.application::>()?; + +// give_authorizations(&client, carrot_app.addr_str()?)?; +// create_position( +// &carrot_app, +// coins(10_000, USDT.to_owned()), +// coin(1_000_000, USDT.to_owned()), +// coin(1_000_000, USDC.to_owned()), +// )?; + +// let position: PositionResponse = carrot_app.position()?; +// assert!(position.position.is_some()); +// Ok(()) +// } + +// #[test] +// fn install_on_sub_account_create_position_on_install() -> anyhow::Result<()> { +// let (pool_id, app) = setup_test_tube(false)?; +// let owner_account = app.account(); +// let chain = owner_account.environment(); +// let client = AbstractClient::new(chain)?; +// let next_id = client.next_local_account_id()?; +// let carrot_app_address = client +// .module_instantiate2_address::>( +// &AccountId::local(next_id), +// )?; + +// give_authorizations(&client, carrot_app_address)?; +// let init_msg = AppInstantiateMsg { +// pool_id, +// // 5 mins +// autocompound_cooldown_seconds: Uint64::new(300), +// autocompound_rewards_config: AutocompoundRewardsConfig { +// gas_asset: AssetEntry::new(REWARD_ASSET), +// swap_asset: AssetEntry::new(USDC), +// reward: Uint128::new(500_000), +// min_gas_balance: Uint128::new(1_000_000), +// max_gas_balance: Uint128::new(3_000_000), +// }, +// create_position: Some(CreatePositionMessage { +// lower_tick: INITIAL_LOWER_TICK, +// upper_tick: INITIAL_UPPER_TICK, +// funds: coins(100_000, USDC), +// asset0: coin(1_000_672_899, USDT), +// asset1: coin(10_000_000_000, USDC), +// max_spread: None, +// belief_price0: None, +// belief_price1: None, +// }), +// }; + +// let account = client +// .account_builder() +// .sub_account(owner_account) +// .account_id(next_id) +// .name("carrot-sub-acc") +// .install_app_with_dependencies::>( +// &init_msg, +// Empty {}, +// )? +// .build()?; + +// let carrot_app = account.application::>()?; + +// let position: PositionResponse = carrot_app.position()?; +// assert!(position.position.is_some()); +// Ok(()) +// }