diff --git a/docs/src/0_introduction.md b/docs/src/0_introduction.md index 7500dbc5..a065ff68 100644 --- a/docs/src/0_introduction.md +++ b/docs/src/0_introduction.md @@ -1 +1,17 @@ ## Carrot Savings + +Welcome to the Carrot Savings App documentation! Buckle up, because this isn't your typical product manual. We built the Carrot App with a passion for transparency and want to share the journey with you, step-by-step. It's all part of our mission to push the boundaries on ownership and trust in DeFi. Here's the thing: we don't own the Carrot, there are no shareholders – everyone owns their own Carrot, making it truly MyFi. + +This guide will introduce the Carrot App and take you behind the scenes of its development. + +## The Carrot in a Nutshell + +the Carrot Savings App is a self-hosted interchain yield aggregator designed to offer you the best stablecoin yields available. +Unlike traditional DeFi Apps where users engage with a single or a suite of smart contracts deployed on-chain, the Carrot App is a self-hosted application, allowing you to configure the application to your needs with improved trustlessness. + +### Key Features (the Good Stuff): + +- Effortless Onboarding: Forget the complexities of traditional DeFi manual interactions and micromanagement of your funds. Carrot Savings eliminates the need to understand intricate smart contracts or manage multiple protocols. +- Streamlined Installation: Install the app on your Abstract account with ease, similar to installing apps on your phone. +- Predefined Strategies: Leverage pre-configured, optimized yield strategies for maximum returns. +- Automated Management: Sit back and relax while the app automatically compounds your yield and manages your stablecoins. diff --git a/docs/src/1_how_it_works.md b/docs/src/1_how_it_works.md new file mode 100644 index 00000000..ac31167e --- /dev/null +++ b/docs/src/1_how_it_works.md @@ -0,0 +1,36 @@ +# How it works + +## Version 1 + +The first version of Carrot Savings utilizes a single strategy: managing USDC (USDC - Noble) / USDC (USDC.axl) liquidity on Osmosis. + +Here's the process: + +1. Installation: The user installs the Carrot Savings App on their Abstract account. + ![create account](create_account.png) + +--- + +2. Deposit & Authorization: The user deposits some USDC into the app and grants it authorization rights. These rights allow the app to perform specific actions on their behalf, like swapping tokens or creating liquidity pools. + ![deposit](deposit.png) + +--- + +3. Strategy Execution: The app swaps the deposited USDC (Noble) for the optimal ratio of USDC (USDC) / USDC.axl. Then, it creates a liquidity position on behalf of the user on Osmosis. + ![position](position_osmosis.png) + +--- + +4. Autocompounding: Since autocompounding is permissionless, the Carrot Savings team manages a bot that automatically compounds the yield generated for all users. + ![dashboard](dashboard.png) + +--- + +5. Funds withdrawal: The user can withdraw anytime their funds from the liquidity pool on Osmosis back to their account. + ![withdraw](withdraw.png) + +--- + +## Diagram + +![diagram](version_1.png) diff --git a/docs/src/2_tutorial.md b/docs/src/2_tutorial.md new file mode 100644 index 00000000..2eefa342 --- /dev/null +++ b/docs/src/2_tutorial.md @@ -0,0 +1,595 @@ +# Recreating the carrot app + +This tutorial will walk you through the process of recreating the carrot-app + +## Step by step guide + +1. Go to our App Template on Github and click on the "Use this template" button to create a new repository based on the template. You can name the repository whatever you want, but we recommend using the name of your module. + +![](../resources/get_started/use-this-template.webp) + +2. Let's call the repository `carrot-app-tutorial` and click `Create repository` +3. go to your terminal + +```sh +# replace `YOUR_GITHUB_ID` with your actual github ID +git clone https://github.com/YOUR_GITHUB_ID/carrot-app-tutorial.git +cd carrot-app-tutorial +``` + +4. + +```sh +chmod +x ./template-setup.sh +./template-setup.sh +# press (y) to install tools that we will need as we go +``` + +What we have now is a counter app serving as a template. +By looking at the `handlers/execute.rs` file you can see that the contract allows to incrementent or reset the counter. + +```rust +match msg { + AppExecuteMsg::Increment {} => increment(deps, app), + AppExecuteMsg::Reset { count } => reset(deps, info, count, app), + AppExecuteMsg::UpdateConfig {} => update_config(deps, info, app), + } +``` + +5. Let's replace these messages by what we want to have in the carrot-app + What we want is the possibility to create a position for a specific Liquidity pool, be able to add more funds to it, withdraw from it and autocompound. + +```rust +// in handlers/execute.rs +match msg { + // Create a position for a supercharged liquidity pool + // Example: A user wants to create a liquidity position with 1000USDC and 1000USDT within a specifc range + AppExecuteMsg::CreatePosition(create_position_msg) => { + create_position(deps, env, info, app, create_position_msg) + } + // This adds funds to an already existing position + // Example: A user who has already a position with 1000USDC and 1000USDT would like to add 200USDC and 200USDT + // This function is Permissioned + AppExecuteMsg::Deposit { funds } => deposit(deps, env, info, funds, app), + // This withdraws a share from the LP position + // Example: A user who has already a position with 1000USDC and 1000USDT would like to withdraw 200USDC and 200USDT + // Note: This will not claim the rewards earned from the swapping fees generated by the position + // This function is Permissioned + AppExecuteMsg::Withdraw { amount } => withdraw(deps, env, info, Some(amount), app), + // This withdraws all the amount in the position + // Example: A user who has already a position with 1000USDC and 1000USDT would like to withdraw all the funds + // Note: This will not claim the rewards earned from the swapping fees generated by the position + // This function is Permissioned + AppExecuteMsg::WithdrawAll {} => withdraw(deps, env, info, None, app), + // For a user who has already created a position, this function does in two steps: + // 1- claims all the rewards generated by the position + // 2- adds/deposits the claimed rewards to that same position + // This is meant to be called by a carrot-app bot, that receives rewards upon autocompounding. + // This function is Permissionless + AppExecuteMsg::Autocompound {} => autocompound(deps, env, info, app), + } +``` + +6. Now this `AppExecuteMsg` enum and those arm functions are not yet defined so let's define them + +#### Define `AppExecuteMsg` enum + +Let's replace the `AppExecuteMsg` enum from the Counter code + +```rust +/// App execute messages +#[cosmwasm_schema::cw_serde] +#[cfg_attr(feature = "interface", derive(cw_orch::ExecuteFns))] +#[cfg_attr(feature = "interface", impl_into(ExecuteMsg))] +pub enum AppExecuteMsg { + /// Increment count by 1 + Increment {}, + /// Admin method - reset count + Reset { + /// Count value after reset + count: i32, + }, + UpdateConfig {}, +} +``` + +with this + +```rust +// in msg.rs +/// App execute messages +#[cosmwasm_schema::cw_serde] +#[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 + Deposit { funds: Vec }, + /// Partial withdraw of the funds available on the app + Withdraw { amount: Uint128 }, + /// Withdraw everything that is on the app + WithdrawAll {}, + /// Auto-compounds the pool rewards into the pool + Autocompound {}, +} +``` + +#### Define the Match msg arm functions + +Now we define the functions that hold the logic of what these entrypoints do + +```rust +// in handlers/execute.rs +fn create_position( + deps: DepsMut, + env: Env, + info: MessageInfo, + app: App, + create_position_msg: CreatePositionMessage, +) -> AppResult { + Ok(app.response("create_position")) +} +fn deposit(deps: DepsMut, env: Env, info: MessageInfo, funds: Vec, app: App) -> AppResult { + Ok(app.response("deposit")) +} +fn withdraw(deps: DepsMut, env: Env, info: MessageInfo, app: App) -> AppResult { + Ok(app.response("withdraw")) +} +fn autocompound(deps: DepsMut, app: App) -> AppResult { + Ok(app.response("autocompound")) +} +``` + +### Create position + +7. Let's start with the create_position. + The create position will do the following steps: + 1. check that the caller is authorized + 2. check that the osmosis pool has not been created yet + 3. Swap the indicated funds to match the asset0/asset1 ratio and deposit as much as possible in the pool for the given parameters + 4. Create a new position + 5. Store `position id` from create position response + +In order for the contract to be able to create a position for osmosis supercharged pools, it needs certain data like the liquidity pool for which to open the position and the ticks that define the range in which your assets will be available for swapping. + +> If you want to read more about ticks and how these concentrated liquidity pools work you can check this great medium [article](https://medium.com/@quasar.fi/what-is-concentrated-liquidity-28a42739d3ff) from `quasar` + +```rust +// in msg.rs +// We define the input data needed for the creation of the position +#[cosmwasm_schema::cw_serde] +/// Osmosis create position msg +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, +} +``` + +#### check that the caller is authorized + +The `create_position` is permissioned so let's make sure the caller is the owner or the manager contract of the Abstract account. + +```rust +// in handlers/execute.rs under fn create_position +// This is an Abstract assert_admin function that makes sure the caller is the owner or the manager contract of the Abstract account +// For more info check https://docs.abstract.money/3_framework/4_ownership.html +app.admin.assert_admin(deps.as_ref(), &info.sender)?; +``` + +#### check that the osmosis pool has not been created yet + +```rust +// in handlers/execute.rs +``` + +Let's abstract away all the following logic in a new inner function that we call `_create_position`. +that way from our `create_position` function we just have + +```rust +// 3. Swap the indicated funds to match the asset0/asset1 ratio and deposit as much as possible in the pool for the given parameters + // 4. Create a new position + // 5. Store position id from create position response + let (swap_messages, create_position_msg) = + _create_position(deps.as_ref(), &env, &app, create_position_msg)?; +``` + +The \_create_position function can also be reused in the instantiate msg whenever the instantiator of this contract wants to also create the position within the same message. + +#### Swap the funds + +We need to Swap the indicated funds to match the asset0/asset1 ratio and deposit as much as possible in the pool for the given parameters. +Imagine the user has 700USDC and 1300USDC and the ratio was 50%, Not doing this swap would mean that we could only deposit 700USDC/700USDT in the pool. Instead if we first swap 300USDC against 300 USDT we end up with roughly 1000USDC/1000USDT which allows us to create a position with more funds. +The funds are not deposited by the owner to this endpoint instead the owner grants authz rights to this contract so that it can deposit funds on their behalf. + +```rust +// in handlers/execute.rs +// under fn _create_position add this + +pub(crate) fn _create_position( + deps: Deps, + env: &Env, + app: &App, + create_position_msg: CreatePositionMessage, +) -> AppResult<(Vec, SubMsg)> { + // Here we load + + + 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, + )?; +} +``` + +To make the swap we need to: + +1. Query the price +2. calculate the amount of tokens to swap +3. Execute the swap + +```rust +#[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)> { + // 1. query the price + let price = query_price(deps, &funds, app, max_spread, belief_price0, belief_price1)?; + // 2. calculate the amount of tokens to swap + let (offer_asset, ask_asset, resulting_assets) = + tokens_to_swap(deps, funds, asset0, asset1, price)?; + // 3. Execute the swap + Ok(( + swap_msg(deps, env, offer_asset, ask_asset, app)?, + resulting_assets, + )) +} +``` + +##### Query price + +```rust +pub fn query_price( + deps: Deps, + funds: &[Coin], + app: &App, + max_spread: Option, + belief_price0: Option, + belief_price1: Option, +) -> AppResult { + let config = CONFIG.load(deps.storage)?; + + 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(price) +} +``` + +##### calculate amount of tokens to swap + +The maths here can be a bit challenging and require some good understanding of how concentrated liquidity pool work. we think this is too specific and doesn't serve the tutorial, so we will just redirect to the [code](https://github.com/AbstractSDK/carrot-app/blob/v0.4.0/contracts/carrot-app/src/handlers/swap_helpers.rs#L59C1-L148C1). Feel free to copy it from there. + +#### execute the swap + +Now that we have the equalized pair of asset we can create the position with the maximized amount of funds. + +- Note below that for the execution we are using the Abstract API for accessing the cosmos SDK AuthZ module +- Note that we are using a get_user function that allows us to retrieve the owner account address, this is because the caller of this contract i.e `info.sender` is not necessarily the owner. In fact, this swap is occuring during the create_position stage which could be triggered upon contract instantiation by the manager address which is different from the owner address. +- Note that we are using the Abstract dex adapter API to generate the swapping message specific to osmosis. The format used here in `dex.generate_swap_messages` would remain the same if we would want to generate a swap message on a different dex thanks to this API Aastraction. + +```rust +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![]); + } + // retrieve the owner account address + let sender = get_user(deps, app)?; + + let dex = app.ans_dex(deps, OSMOSIS.to_string()); + let swap_msgs: 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(swap_msgs + .messages + .into_iter() + .map(|m| authz.execute(&env.contract.address, m)) + .collect()) +} +``` + +We also add the `get_user` function that we previously mentioned. + +```rust +// in helpers.rs +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))??) +} +``` + +### Create a new position + +To create the new position we need to know what pool_id the contract was instantiated with. We can store this in the contract's state under CONFIG like this: + +```rust +pub const CONFIG: Item = Item::new("config"); + +#[cw_serde] +pub struct Config { + pub pool_config: PoolConfig, + pub autocompound_cooldown_seconds: Uint64, + pub autocompound_rewards_config: AutocompoundRewardsConfig, +} +``` + +```rust +// in handlers/execute.rs +// under fn _create_position add this + // We load here the pool_id from the config + let config = CONFIG.load(deps.storage)?; + 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), + )) +``` + +#### Store position id from create position response + +```rust +// in replies/create_position.rs +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", + ))); + }; + + let parsed = cw_utils::parse_execute_response_data(&b)?; + + // 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, + }; + + //This is where we persist the Position into the state of the contract + POSITION.save(deps.storage, &position)?; + + Ok(app + .response("create_position_reply") + .add_attribute("initial_position_id", response.position_id.to_string())) +} +``` + +### Compound + +This is what is going to make the installers of the Carrot benefit from an APY that is higher than the APR. + +This step will be executed by the bots. This is a permissionless step that allows a bot to get incentives by making the contract + +1. Collect all the rewards generated from the liquidity position, +2. Swap the rewards for the best ratio, the same way we previously did during the initial creation of the position +3. Deposit the claimed funds into the position + +We have previously added inthe msg type this arm + +```rust +AppExecuteMsg::Autocompound {} => autocompound(deps, env, info, app), +``` + +which calls the `autocompound` function. + +#### Collect rewards + +```rust +fn autocompound(deps: DepsMut, env: Env, info: MessageInfo, app: App) -> AppResult { + // Everyone can autocompound + let config = CONFIG.load(deps.storage)?; + + let position = get_position(deps.as_ref())?; + + let (compound_status, maybe_osmosis_position) = get_position_status( + deps.as_ref(), + &env, + config.autocompound_cooldown_seconds.u64(), + Some(position), + )?; + + let position_details = osmosis_position.position.unwrap(); + + let mut rewards = cosmwasm_std::Coins::default(); + let mut collect_rewards_msgs = vec![]; + + // 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()))?; + + // If there are external incentives, claim them. + if !osmosis_position.claimable_incentives.is_empty() { + let asset0_denom = osmosis_position.asset0.unwrap().denom; + let asset1_denom = osmosis_position.asset1.unwrap().denom; + + for coin in try_proto_to_cosmwasm_coins(osmosis_position.claimable_incentives)? { + if coin.denom == asset0_denom || coin.denom == asset1_denom { + rewards.add(coin)?; + } + } + collect_rewards_msgs.push(authz.execute( + &env.contract.address, + MsgCollectIncentives { + position_ids: vec![position_details.position_id], + sender: user.to_string(), + }, + )); + } + + // If there is income from swap fees, claim them. + if !osmosis_position.claimable_spread_rewards.is_empty() { + for coin in try_proto_to_cosmwasm_coins(osmosis_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(), + }, + )) + } + + // If there are no rewards, we can't do anything + if rewards.is_empty() { + return Err(crate::error::AppError::NoRewards {}); + } +``` + +#### deposit the rewards + +```rust + + // 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: vec![], + }); + + let mut 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. + if !app.admin.is_admin(deps.as_ref(), &info.sender)? && compound_status.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) +} +``` diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0d14db2c..cd509137 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -1,3 +1,5 @@ # Summary [Introduction](./0_introduction.md) +[How it works](./1_how_it_works.md) +[How we developed the Carrot](./2_tutorial.md) diff --git a/docs/src/create_account.png b/docs/src/create_account.png new file mode 100644 index 00000000..d5da3ab2 Binary files /dev/null and b/docs/src/create_account.png differ diff --git a/docs/src/dashboard.png b/docs/src/dashboard.png new file mode 100644 index 00000000..08614cf5 Binary files /dev/null and b/docs/src/dashboard.png differ diff --git a/docs/src/deposit.png b/docs/src/deposit.png new file mode 100644 index 00000000..02d4629a Binary files /dev/null and b/docs/src/deposit.png differ diff --git a/docs/src/position_osmosis.png b/docs/src/position_osmosis.png new file mode 100644 index 00000000..4c23570d Binary files /dev/null and b/docs/src/position_osmosis.png differ diff --git a/docs/src/version_1.png b/docs/src/version_1.png new file mode 100644 index 00000000..53a976a1 Binary files /dev/null and b/docs/src/version_1.png differ diff --git a/docs/src/withdraw.png b/docs/src/withdraw.png new file mode 100644 index 00000000..b8b84556 Binary files /dev/null and b/docs/src/withdraw.png differ