diff --git a/.gitignore b/.gitignore index 8df2bc3..e3776c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ deploy_mainnet.sh .env -scripts/ \ No newline at end of file +scripts/ + +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..17d041c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +## Project Overview + +CosmWasm DEX aggregator smart contract for the Injective blockchain. Routes swaps through multiple AMM pools, orderbook contracts, and CLMM (Concentrated Liquidity) pools in parallel, multi-hop paths with automatic CW20/native token conversion. Cargo workspace with two members: `dex_aggregator` (main contract) and `mock_swap` (test helper). Deployed on Injective mainnet (Code ID 1892, address `inj1a4qvqym6ajewepa7v8y2rtxuz9f92kyq2zsg26`). + +## Build, Test, and Deploy Commands + +```bash +# Development build +cargo build + +# Production WASM build (uses cosmwasm/workspace-optimizer:0.17.0 Docker image) +# Outputs to ./artifacts/dex_aggregator.wasm and ./artifacts/mock_swap.wasm +./build_release.sh + +# Run tests (MUST run ./build_release.sh first — see note below) +cargo test + +# Run a single test +cargo test -- --nocapture + +# Lint +cargo clippy --all-targets + +# Generate JSON schemas to contracts/dex_aggregator/schema/ +cd contracts/dex_aggregator && cargo run --example schema +``` + +**CRITICAL: Run `./build_release.sh` before `cargo test`.** Integration tests use `include_bytes!` to embed WASM artifacts at compile time. Tests will fail to compile or test stale code if artifacts aren't rebuilt after source changes. + +### Deployment (uses `injectived` CLI) + +```bash +./scripts/upload_code_mainnet.sh # Upload new code to mainnet +./scripts/deploy_mainnet.sh # Instantiate on mainnet (edit CODE_ID first) +./scripts/deploy_testnet.sh # Deploy on testnet (chain ID: injective-888) +``` + +## Architecture + +### Key Files (contracts/dex_aggregator/src/) + +| File | Purpose | +|------|---------| +| `contract.rs` | Entry points: `instantiate`, `execute`, `query`, `reply`. Routes `ExecuteMsg` variants to handlers. | +| `msg.rs` | All message types. Submodules: `amm`, `orderbook`, `clmm`, `cw20_adapter`, `reflection`. Defines `Stage > Split > Operation` route structure. | +| `execute.rs` | Core swap logic (`execute_aggregate_swaps_internal`, `create_swap_cosmos_msg`). Admin functions: `set_fee`, `remove_fee`, `update_fee_collector`, `update_admin`, `emergency_withdraw`, `register_tax_token`, `deregister_tax_token`. | +| `reply.rs` | Submessage reply state machine. Manages `Awaiting` states. Core function `proceed_to_next_step` drives stage-by-stage execution. Fee deduction via `apply_fee` at path completion. | +| `state.rs` | Storage: `CONFIG`, `FEE_MAP`, `ACTIVE_ROUTES`, `SUBMSG_REPLY_STATES`, `REPLY_ID_COUNTER`, `TAX_TOKEN_REGISTRY`. Defines `ExecutionState`, `SubmsgReplyState`, `Awaiting` enum. | +| `query.rs` | `simulate_route`, `query_config`, `query_fee_for_pool`, `query_all_fees`. Contains unit tests. | +| `error.rs` | `ContractError` enum with `thiserror`. | + +### Execution Flow + +1. User calls `ExecuteRoute` (native funds) or sends CW20 via `Receive` hook +2. `execute_aggregate_swaps_internal` validates input, creates `ExecutionState`, calls `proceed_to_next_step` +3. Each stage: calculates per-split amounts, dispatches CW20/native conversions if needed (`Awaiting::Conversions`) +4. Executes parallel swap submessages, each tracked by unique reply IDs in `SUBMSG_REPLY_STATES` +5. `handle_swap_reply` processes each reply; for multi-hop paths, chains to next operation +6. Mid-path conversions handled via `Awaiting::PathConversion` +7. After final stage: normalizes output assets (`Awaiting::FinalConversions`), checks `minimum_receive`, sends to user + +### Supporting Contracts + +- `mock_swap` (`contracts/mock_swap/src/lib.rs`) — Mock DEX with configurable rates, supports AMM/Orderbook/CLMM protocol types, used in integration tests +- `cw20_adapter` and `cw20_base` — Pre-compiled WASMs in project root, not built from this workspace + +## Code Conventions + +### Naming +- `snake_case` for functions, variables, module names +- `PascalCase` for types, enums, structs, enum variants +- `UPPER_SNAKE_CASE` for constants + +### Patterns +- Entry points use Injective custom types: `DepsMut`, `Response` +- Messages use `#[cw_serde]` macro; query enum uses `#[derive(QueryResponses)]` with `#[returns(...)]` +- Error handling: `ContractError` enum via `thiserror`, propagated with `?` +- State: `cw-storage-plus` types — `Item` for singletons, `Map` for key-value stores +- Execute handlers return `Result, ContractError>` +- Query handlers return `StdResult` +- Admin checks: `info.sender != config.admin` → `ContractError::Unauthorized {}` +- Response attributes for tracking: `.add_attribute("action", "...")` + +### Asset Handling +- `amm::AssetInfo` enum: `Token { contract_addr }` (CW20) or `NativeToken { denom }` (bank) +- Tax tokens in `TAX_TOKEN_REGISTRY` use `reflection::ExecuteMsg::TaxExemptTransfer` / `TaxExemptSend` +- CW20 tokens sent to pools via `Cw20ExecuteMsg::Send`; native tokens as `funds` in `WasmMsg::Execute` + +### Submessage Reply Pattern +- Each swap gets a unique `submsg_id` from `REPLY_ID_COUNTER` (monotonically incrementing) +- `SubmsgReplyState` maps `submsg_id` → `master_reply_id`, `split_index`, `op_index` +- `ExecutionState` stored in `ACTIVE_ROUTES` keyed by `master_reply_id` +- All submessages use `SubMsg::reply_on_success` +- Reply amounts parsed from wasm event attributes: `return_amount` (AMM), `swap_final_amount` (orderbook), `amount_out` (CLMM), `post_tax_amount` (tax tokens) + +## Testing + +- **Integration tests** (`tests/integration.rs`): Uses `injective-test-tube` for local chain simulation. `setup()` deploys all contracts, returns `TestEnv` with admin/user accounts and contract addresses. +- **Unit tests** (`contracts/dex_aggregator/src/query.rs`): Simulation and fee query tests using `mock_dependencies()`. +- Mock swap contracts configured with `SwapConfig { rate, protocol_type, input_decimals, output_decimals, ... }`. +- WASM artifacts loaded via `include_bytes!` — stale artifacts mean stale tests. + +## Important Notes + +- `reply.rs` is the most complex module — state machine changes require careful review of all `Awaiting` state transitions +- Orderbook swaps only support native token inputs/outputs; amounts rounded to `min_quantity_tick_size` +- CLMM swaps support both native and CW20 tokens; no rounding needed. Pre-execution `Quote` query computes `minimum_amount_out` with 0.5% slippage +- `FPDecimal` (from `injective-math`) for orderbook quantities; `Uint128`/`Decimal` (from `cosmwasm-std`) for everything else (including CLMM) +- Fees deducted at path completion (end of a split's operation chain), not per-operation +- CI (`.github/workflows/test.yml`) runs `cargo build --verbose && cargo test --verbose` on push/PR to main diff --git a/artifacts/checksums.txt b/artifacts/checksums.txt index 67f1093..7f51a39 100644 --- a/artifacts/checksums.txt +++ b/artifacts/checksums.txt @@ -1,2 +1,2 @@ -a0881d16ef7b87479688715c7e9796ee67aa117c3692090813ede98bfe8109c7 dex_aggregator.wasm -fca25ee84ed0903921574c9efef0144880a81f7533e952f6ce8cb3ea4f8e57b4 mock_swap.wasm +f012a5cc59e924f89a4716ed44eb8d94b57b914c045e0d8be0607844f4a61703 dex_aggregator.wasm +a3cb85b097d4cb7640cb441a5dcb8721167961aa5f5eee94eeebfb2818b5e2df mock_swap.wasm diff --git a/artifacts/dex_aggregator.wasm b/artifacts/dex_aggregator.wasm index f15b7d1..3fe94d2 100644 Binary files a/artifacts/dex_aggregator.wasm and b/artifacts/dex_aggregator.wasm differ diff --git a/artifacts/mock_swap.wasm b/artifacts/mock_swap.wasm index e03698e..4b1f973 100644 Binary files a/artifacts/mock_swap.wasm and b/artifacts/mock_swap.wasm differ diff --git a/contracts/dex_aggregator/schema/execute_msg.json b/contracts/dex_aggregator/schema/execute_msg.json index b4036c7..8a67b4e 100644 --- a/contracts/dex_aggregator/schema/execute_msg.json +++ b/contracts/dex_aggregator/schema/execute_msg.json @@ -273,6 +273,26 @@ "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", "type": "string" }, + "ClmmSwapOp": { + "type": "object", + "required": [ + "ask_asset_info", + "offer_asset_info", + "pool_address" + ], + "properties": { + "ask_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "offer_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "pool_address": { + "type": "string" + } + }, + "additionalProperties": false + }, "Cw20ReceiveMsg": { "description": "Cw20ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", "type": "object", @@ -323,6 +343,18 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "clmm_swap" + ], + "properties": { + "clmm_swap": { + "$ref": "#/definitions/ClmmSwapOp" + } + }, + "additionalProperties": false } ] }, diff --git a/contracts/dex_aggregator/schema/query_msg.json b/contracts/dex_aggregator/schema/query_msg.json index ccb56ec..9ed0615 100644 --- a/contracts/dex_aggregator/schema/query_msg.json +++ b/contracts/dex_aggregator/schema/query_msg.json @@ -161,6 +161,26 @@ } ] }, + "ClmmSwapOp": { + "type": "object", + "required": [ + "ask_asset_info", + "offer_asset_info", + "pool_address" + ], + "properties": { + "ask_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "offer_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "pool_address": { + "type": "string" + } + }, + "additionalProperties": false + }, "Coin": { "type": "object", "required": [ @@ -202,6 +222,18 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "clmm_swap" + ], + "properties": { + "clmm_swap": { + "$ref": "#/definitions/ClmmSwapOp" + } + }, + "additionalProperties": false } ] }, diff --git a/contracts/dex_aggregator/src/execute.rs b/contracts/dex_aggregator/src/execute.rs index d24bd5e..b634082 100644 --- a/contracts/dex_aggregator/src/execute.rs +++ b/contracts/dex_aggregator/src/execute.rs @@ -8,7 +8,7 @@ use injective_math::FPDecimal; use std::str::FromStr; use crate::error::ContractError; -use crate::msg::{self, amm, orderbook, Operation, Stage}; +use crate::msg::{self, amm, clmm, orderbook, Operation, Stage}; use crate::reply::proceed_to_next_step; use crate::state::{ Awaiting, ExecutionState, RoutePlan, CONFIG, FEE_MAP, REPLY_ID_COUNTER, TAX_TOKEN_REGISTRY, @@ -213,6 +213,76 @@ pub fn create_swap_cosmos_msg( funds, }) } + Operation::ClmmSwap(clmm_op) => { + // Query the pool for expected output + let quote_query = clmm::ClmmPoolQueryMsg::Quote { + token_in: offer_asset_info.clone(), + amount_in: amount, + }; + let quote_response: clmm::QuoteResponse = deps + .querier + .query_wasm_smart(&clmm_op.pool_address, "e_query)?; + + if quote_response.amount_out.is_zero() { + return Ok(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_json_binary(&{})?, + funds: vec![], + })); + } + + // Apply 0.5% slippage + let minimum_amount_out = quote_response.amount_out.multiply_ratio(995u128, 1000u128); + + let clmm_swap_msg = clmm::ClmmPoolExecuteMsg::SwapExactInput { + minimum_amount_out, + recipient: Some(recipient), + deadline: None, + }; + + match offer_asset_info { + amm::AssetInfo::NativeToken { denom } => CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: clmm_op.pool_address.clone(), + msg: to_json_binary(&clmm_swap_msg)?, + funds: vec![Coin { + denom: denom.clone(), + amount, + }], + }), + amm::AssetInfo::Token { contract_addr } => { + let token_addr = deps.api.addr_validate(contract_addr)?; + let hook_msg = clmm::Cw20HookMsg::SwapExactInput { + minimum_amount_out, + recipient: Some(env.contract.address.to_string()), + deadline: None, + }; + if TAX_TOKEN_REGISTRY.has(deps.storage, &token_addr) { + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: contract_addr.clone(), + msg: to_json_binary( + &crate::msg::reflection::ExecuteMsg::TaxExemptSend { + contract: clmm_op.pool_address.clone(), + amount, + msg: to_json_binary(&hook_msg)?, + }, + )?, + funds: vec![], + }) + } else { + let cw20_send_msg = Cw20ExecuteMsg::Send { + contract: clmm_op.pool_address.clone(), + amount, + msg: to_json_binary(&hook_msg)?, + }; + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: contract_addr.clone(), + msg: to_json_binary(&cw20_send_msg)?, + funds: vec![], + }) + } + } + } + } }; Ok(cosmos_msg) diff --git a/contracts/dex_aggregator/src/msg.rs b/contracts/dex_aggregator/src/msg.rs index fdd6976..43c522e 100644 --- a/contracts/dex_aggregator/src/msg.rs +++ b/contracts/dex_aggregator/src/msg.rs @@ -136,6 +136,43 @@ pub mod reflection { } } +pub mod clmm { + use super::*; + + #[cw_serde] + pub enum ClmmPoolExecuteMsg { + SwapExactInput { + minimum_amount_out: Uint128, + recipient: Option, + deadline: Option, + }, + } + + #[cw_serde] + pub enum Cw20HookMsg { + SwapExactInput { + minimum_amount_out: Uint128, + recipient: Option, + deadline: Option, + }, + } + + #[cw_serde] + pub enum ClmmPoolQueryMsg { + Quote { + token_in: amm::AssetInfo, + amount_in: Uint128, + }, + } + + #[cw_serde] + pub struct QuoteResponse { + pub amount_out: Uint128, + pub amount_in_consumed: Uint128, + pub fee_amount: Uint128, + } +} + #[cw_serde] pub struct AmmSwapOp { pub pool_address: String, @@ -151,10 +188,18 @@ pub struct OrderbookSwapOp { pub min_quantity_tick_size: Uint128, } +#[cw_serde] +pub struct ClmmSwapOp { + pub pool_address: String, + pub offer_asset_info: amm::AssetInfo, + pub ask_asset_info: amm::AssetInfo, +} + #[cw_serde] pub enum Operation { AmmSwap(AmmSwapOp), OrderbookSwap(OrderbookSwapOp), + ClmmSwap(ClmmSwapOp), } #[cw_serde] diff --git a/contracts/dex_aggregator/src/query.rs b/contracts/dex_aggregator/src/query.rs index 46a9190..caff3cf 100644 --- a/contracts/dex_aggregator/src/query.rs +++ b/contracts/dex_aggregator/src/query.rs @@ -1,5 +1,6 @@ use crate::msg::{ - amm, orderbook, AllFeesResponse, FeeInfo, FeeResponse, Operation, SimulateRouteResponse, Stage, + amm, clmm, orderbook, AllFeesResponse, FeeInfo, FeeResponse, Operation, SimulateRouteResponse, + Stage, }; use crate::state::{Config, FEE_MAP}; use cosmwasm_std::{ @@ -170,6 +171,26 @@ fn simulate_single_operation( amount: sim_response.result_quantity.into(), }) } + Operation::ClmmSwap(op) => { + let quote_query = clmm::ClmmPoolQueryMsg::Quote { + token_in: offer_asset.info.clone(), + amount_in: offer_asset.amount, + }; + let contract_addr = op.pool_address.to_string(); + + let quote_response: clmm::QuoteResponse = querier.query( + &WasmQuery::Smart { + contract_addr, + msg: to_json_binary("e_query)?, + } + .into(), + )?; + + Ok(amm::Asset { + info: op.ask_asset_info.clone(), + amount: quote_response.amount_out, + }) + } } } @@ -180,6 +201,7 @@ fn get_path_start_info(path: &[Operation]) -> StdResult { Ok(match first_op { Operation::AmmSwap(op) => op.offer_asset_info.clone(), Operation::OrderbookSwap(op) => op.offer_asset_info.clone(), + Operation::ClmmSwap(op) => op.offer_asset_info.clone(), }) } diff --git a/contracts/dex_aggregator/src/reply.rs b/contracts/dex_aggregator/src/reply.rs index 4f0c5dd..a4e28d0 100644 --- a/contracts/dex_aggregator/src/reply.rs +++ b/contracts/dex_aggregator/src/reply.rs @@ -126,7 +126,8 @@ fn handle_swap_reply( let swap_event_opt = events.iter().rev().find(|e| { e.ty.starts_with("wasm") && (e.attributes.iter().any(|a| a.key == "return_amount") - || e.attributes.iter().any(|a| a.key == "swap_final_amount")) + || e.attributes.iter().any(|a| a.key == "swap_final_amount") + || e.attributes.iter().any(|a| a.key == "amount_out")) }); if swap_event_opt.is_none() { @@ -507,6 +508,7 @@ fn get_operation_output(op: &Operation) -> Result Ok(match op { Operation::AmmSwap(o) => o.ask_asset_info.clone(), Operation::OrderbookSwap(o) => o.ask_asset_info.clone(), + Operation::ClmmSwap(o) => o.ask_asset_info.clone(), }) } @@ -544,16 +546,21 @@ fn parse_amount_from_swap_reply( if !event.ty.starts_with("wasm") { return None; } - let key = if event.ty == "wasm-atomic_swap_execution" { - "swap_final_amount" + if event.ty == "wasm-atomic_swap_execution" { + // Orderbook + event + .attributes + .iter() + .find(|attr| attr.key == "swap_final_amount") + .map(|attr| attr.value.clone()) } else { - "return_amount" - }; - event - .attributes - .iter() - .find(|attr| attr.key == key) - .map(|attr| attr.value.clone()) + // AMM ("return_amount") or CLMM ("amount_out") + event + .attributes + .iter() + .find(|attr| attr.key == "return_amount" || attr.key == "amount_out") + .map(|attr| attr.value.clone()) + } }); match amount_str_opt { @@ -753,6 +760,7 @@ fn get_operation_input(op: &Operation) -> Result Ok(match op { Operation::AmmSwap(o) => o.offer_asset_info.clone(), Operation::OrderbookSwap(o) => o.offer_asset_info.clone(), + Operation::ClmmSwap(o) => o.offer_asset_info.clone(), }) } @@ -809,6 +817,7 @@ fn get_operation_address(op: &Operation) -> &String { match op { Operation::AmmSwap(o) => &o.pool_address, Operation::OrderbookSwap(o) => &o.swap_contract, + Operation::ClmmSwap(o) => &o.pool_address, } } diff --git a/contracts/mock_swap/src/lib.rs b/contracts/mock_swap/src/lib.rs index f49da16..58d83bc 100644 --- a/contracts/mock_swap/src/lib.rs +++ b/contracts/mock_swap/src/lib.rs @@ -51,6 +51,11 @@ pub enum ExecuteMsg { target_denom: String, min_output_quantity: String, }, + SwapExactInput { + minimum_amount_out: Uint128, + recipient: Option, + deadline: Option, + }, Receive(Cw20ReceiveMsg), } @@ -58,6 +63,7 @@ pub enum ExecuteMsg { pub enum ProtocolType { Amm, Orderbook, + Clmm, } #[cw_serde] @@ -89,6 +95,20 @@ pub struct MockSwapHookSwapField { pub deadline: Option, } +#[cw_serde] +pub struct ClmmCw20HookMsg { + pub minimum_amount_out: Uint128, + pub recipient: Option, + pub deadline: Option, +} + +#[cw_serde] +pub struct QuoteResponse { + pub amount_out: Uint128, + pub amount_in_consumed: Uint128, + pub fee_amount: Uint128, +} + #[cw_serde] pub enum QueryMsg { GetOutputQuantity { @@ -96,6 +116,10 @@ pub enum QueryMsg { source_denom: String, target_denom: String, }, + Quote { + token_in: AssetInfo, + amount_in: Uint128, + }, } pub const CONFIG: Item = Item::new("config"); @@ -137,6 +161,19 @@ pub fn execute( denom: info.funds[0].denom.clone(), }, ), + ExecuteMsg::SwapExactInput { + recipient: recip, .. + } => { + if let Some(recip_addr) = recip { + recipient = recip_addr; + } + ( + info.funds[0].amount, + AssetInfo::NativeToken { + denom: info.funds[0].denom.clone(), + }, + ) + } ExecuteMsg::Receive(Cw20ReceiveMsg { sender, amount, @@ -144,6 +181,8 @@ pub fn execute( }) => { if let Ok(hook) = from_json::(&msg) { recipient = hook.swap.to.unwrap_or(sender); + } else if let Ok(clmm_hook) = from_json::(&msg) { + recipient = clmm_hook.recipient.unwrap_or(sender); } else { recipient = sender; } @@ -209,6 +248,10 @@ pub fn execute( .add_attribute("refund_amount", "0") .add_attribute("swap_final_amount", final_return_amount) .add_attribute("swap_final_denom", output_denom_str), + ProtocolType::Clmm => Event::new("wasm") + .add_attribute("action", "swap") + .add_attribute("amount_in", offer_amount.to_string()) + .add_attribute("amount_out", final_return_amount.to_string()), }; Ok(Response::new().add_message(send_msg).add_event(event)) @@ -236,13 +279,13 @@ pub fn query( let config = CONFIG.load(deps.storage)?; // 1. Validation: Ensure the query matches the contract's configured trading pair. - let config_source_denom = match config.input_asset_info { - AssetInfo::NativeToken { denom } => denom, - AssetInfo::Token { contract_addr } => contract_addr, + let config_source_denom = match &config.input_asset_info { + AssetInfo::NativeToken { denom } => denom.clone(), + AssetInfo::Token { contract_addr } => contract_addr.clone(), }; - let config_target_denom = match config.output_asset_info { - AssetInfo::NativeToken { denom } => denom, - AssetInfo::Token { contract_addr } => contract_addr, + let config_target_denom = match &config.output_asset_info { + AssetInfo::NativeToken { denom } => denom.clone(), + AssetInfo::Token { contract_addr } => contract_addr.clone(), }; if source_denom != config_source_denom || target_denom != config_target_denom { @@ -268,5 +311,34 @@ pub fn query( to_json_binary(&response) } + QueryMsg::Quote { + token_in, + amount_in, + } => { + let config = CONFIG.load(deps.storage)?; + + if token_in != config.input_asset_info { + return Err(StdError::generic_err( + "Invalid token_in for this mock contract", + )); + } + + let offer_decimal = Decimal::from_atomics(amount_in, config.input_decimals as u32) + .map_err(|_| StdError::generic_err("Failed to create decimal from amount_in"))?; + let rate_decimal = Decimal::from_str(&config.rate)?; + let return_decimal = offer_decimal * rate_decimal; + let decimal_diff = DECIMAL_PRECISION.saturating_sub(config.output_decimals as u32); + let scaling_factor = Uint128::from(10u128.pow(decimal_diff)); + let amount_out = return_decimal + .atomics() + .checked_div(scaling_factor) + .unwrap_or_default(); + + to_json_binary(&QuoteResponse { + amount_out, + amount_in_consumed: amount_in, + fee_amount: Uint128::zero(), + }) + } } } diff --git a/docs/adding_a_pool_type.md b/docs/adding_a_pool_type.md new file mode 100644 index 0000000..42762a8 --- /dev/null +++ b/docs/adding_a_pool_type.md @@ -0,0 +1,173 @@ +# Adding a New Pool Type (e.g. CLMM) — Information Checklist + +This document explains exactly what information is needed before a new pool type can be integrated into the `dex_aggregator` contract. If you are an agent tasked with adding CLMM (or any new pool type) support, **gather all of the answers below first** before writing any code. + +--- + +## 1. Swap Execute Message + +The aggregator calls each pool via `WasmMsg::Execute`. You must provide the **exact Rust struct/enum** that the target pool contract expects. + +**Questions to answer:** + +- What is the full execute message type for performing a swap? (Provide the Rust enum variant with all fields.) +- Are there required fields beyond the basics (e.g. `sqrt_price_limit`, `tick_range`, `deadline`)? +- Are any fields optional? What are sensible defaults? + +**For reference, here's what we have today:** + +| Pool type | Execute message | Key fields | +|-----------|----------------|------------| +| AMM | `AmmPairExecuteMsg::Swap` | `offer_asset`, `belief_price` (optional), `max_spread` (optional), `to` (optional) | +| Orderbook | `OrderbookExecuteMsg::SwapMinOutput` | `target_denom`, `min_output_quantity` | + +--- + +## 2. Token Input Handling + +The aggregator must know how to attach tokens to the swap message. + +**Questions to answer:** + +- Does the pool accept **native tokens** (sent as `funds` on `WasmMsg::Execute`)? +- Does the pool accept **CW20 tokens** (sent via `Cw20ExecuteMsg::Send` with the swap msg as inner payload)? +- Does it accept **both**? Or only one? +- If CW20: what is the expected inner hook message format when the pool receives tokens via `Cw20::Send`? + +**For reference:** + +| Pool type | Native input | CW20 input | +|-----------|-------------|------------| +| AMM | Yes — coin in `funds` | Yes — `Cw20ExecuteMsg::Send { contract: pool, amount, msg: }` | +| Orderbook | Yes — coin in `funds` | No — errors on CW20 | + +--- + +## 3. Simulation / Quote Query + +The aggregator queries each pool to estimate output (used for both `SimulateRoute` queries and, in the orderbook case, to compute `min_output_quantity` before execution). + +**Questions to answer:** + +- What is the **query message** to get an output estimate for a given input amount? +- What is the **response type** (exact struct with field names and types)? +- Which field in the response contains the expected output amount? +- Does the query use `Uint128`, `FPDecimal`, or another numeric type for amounts? + +**For reference:** + +| Pool type | Query message | Response | Output field | +|-----------|--------------|----------|-------------| +| AMM | `Simulation { offer_asset: Asset }` | `SimulationResponse` | `return_amount: Uint128` | +| Orderbook | `GetOutputQuantity { from_quantity: FPDecimal, source_denom, target_denom }` | `SwapEstimationResult` | `result_quantity: FPDecimal` | + +--- + +## 4. Reply Event Format + +After a swap submessage succeeds, the aggregator parses the **output amount** from the reply's events. This is the most critical piece — if the event format is wrong, the aggregator won't know how much it received. + +**Questions to answer:** + +- What **event type** does the pool emit? (e.g. `"wasm"`, `"wasm-swap"`, a custom event name?) +- What **attribute key** contains the output amount? (e.g. `"return_amount"`, `"swap_final_amount"`) +- Is the amount an integer string, or can it contain decimals? (The aggregator currently truncates decimal amounts.) +- Are there any other attributes needed for disambiguation (e.g. `"action"`, `"sender"`)? + +**For reference:** + +| Pool type | Event type | Amount attribute | Format | +|-----------|-----------|-----------------|--------| +| AMM | `wasm` | `return_amount` | Integer string | +| Orderbook | `wasm-atomic_swap_execution` | `swap_final_amount` | May contain decimals (truncated) | + +--- + +## 5. Operation-Specific Parameters + +Each pool type has its own struct carrying pool-specific config per operation. + +**Questions to answer:** + +- Besides `pool_address`/`contract_address`, `offer_asset_info`, and `ask_asset_info` (which are standard), what **additional fields** does this pool type need per-operation? +- For the orderbook, this is `min_quantity_tick_size`. For CLMM, it might be `sqrt_price_limit`, `tick_spacing`, `fee_tier`, etc. +- Which fields would be provided by the route planner (off-chain) vs. derived on-chain? + +**Proposed struct template:** + +```rust +pub struct ClmmSwapOp { + pub pool_address: String, + pub offer_asset_info: amm::AssetInfo, + pub ask_asset_info: amm::AssetInfo, + // What else goes here? List every field with its type. +} +``` + +--- + +## 6. Pre-Execution Queries or Rounding + +The orderbook integration performs extra work before executing: it rounds the input amount to `min_quantity_tick_size` and queries the simulation to compute a minimum output with 0.5% slippage. + +**Questions to answer:** + +- Does the new pool type require **rounding** the input amount to any tick size or step? +- Does the new pool type require a **pre-execution simulation query** to compute a minimum output, price limit, or other parameter? +- If yes, what slippage tolerance should be applied? +- What should happen if the rounded amount is zero? (Orderbook currently emits a no-op message.) + +--- + +## 7. Output Delivery + +**Questions to answer:** + +- After a swap, does the pool **automatically send** the output tokens to the `to`/recipient address? +- Or does the caller need to **claim/withdraw** the output in a separate step? +- If the pool sends output automatically, does it use `BankMsg::Send` (native) or `Cw20ExecuteMsg::Transfer` (CW20)? + +The aggregator assumes output is automatically delivered to `env.contract.address` (itself) after each swap. If the new pool type requires a separate claim step, that would need additional handling. + +--- + +## 8. Contract Address / Pool Identifier + +**Questions to answer:** + +- Is the pool identified by a single **contract address** (like AMM and orderbook)? +- Or does it use a different identifier (pool ID, factory + pair key, etc.)? +- If a contract address, is it the same address for both execution and simulation queries? + +--- + +## Summary: What to Provide + +Before any code changes, provide a document or message containing: + +| # | Item | What to provide | +|---|------|----------------| +| 1 | Execute message | Full Rust enum/struct definition | +| 2 | Token input | Native, CW20, or both — plus CW20 hook msg format if applicable | +| 3 | Simulation query | Query msg struct, response struct, which field = output amount | +| 4 | Reply events | Event type name, attribute key for output amount, attribute value format | +| 5 | Op struct fields | All per-operation fields beyond the standard three | +| 6 | Pre-execution logic | Any rounding, simulation queries, or slippage computation needed | +| 7 | Output delivery | Auto-sent to recipient, or requires claim? | +| 8 | Pool identifier | Contract address or other identifier | + +--- + +## Files That Will Be Modified + +Once the above information is gathered, here are the exact files and locations that need changes: + +| File | What changes | +|------|-------------| +| `contracts/dex_aggregator/src/msg.rs` | Add `ClmmSwapOp` struct, add `Clmm(ClmmSwapOp)` variant to `Operation` enum, add `clmm` submodule with external message types | +| `contracts/dex_aggregator/src/execute.rs` | Add `Operation::Clmm` arm in `create_swap_cosmos_msg` (~line 93) | +| `contracts/dex_aggregator/src/reply.rs` | Add arm in `get_operation_output` (~line 508), `get_operation_input` (~line 754), `get_operation_address` (~line 810). Update `parse_amount_from_swap_reply` (~line 513) if the event format differs from existing patterns. | +| `contracts/dex_aggregator/src/query.rs` | Add `Operation::Clmm` arm in `simulate_single_operation` (~line 116) and `get_path_start_info` (~line 181) | +| `contracts/mock_swap/src/lib.rs` | Add `ProtocolType::Clmm` variant, add matching event emission in `execute` (~line 201), add simulation query handling in `query` if the query format differs | +| `tests/integration.rs` | Add integration tests deploying mock CLMM pools and routing through them | +| `contracts/dex_aggregator/schema/` | Regenerate schemas after msg.rs changes (`cd contracts/dex_aggregator && cargo run --example schema`) | diff --git a/docs/dex_aggregator.md b/docs/dex_aggregator.md new file mode 100644 index 0000000..6c151c0 --- /dev/null +++ b/docs/dex_aggregator.md @@ -0,0 +1,313 @@ +# dex_aggregator Contract — Deep Reference + +## Purpose + +The `dex_aggregator` contract orchestrates multi-hop, multi-path token swaps across AMM pools and orderbook contracts on Injective. A user submits a **route** — a sequence of **stages**, each containing parallel **splits** — and the contract executes every swap, handles CW20/native asset conversions mid-route, deducts per-pool fees, and pays out the final result with slippage protection. + +## Source Files + +All source lives in `contracts/dex_aggregator/src/`. + +| File | Lines | Role | +|------|-------|------| +| `lib.rs` | 9 | Module declarations. Re-exports `ContractError`. | +| `contract.rs` | 158 | Entry points (`instantiate`, `execute`, `query`, `reply`). Routes each `ExecuteMsg` variant to the appropriate handler. | +| `msg.rs` | 268 | Every message type and data structure. Contains submodules `amm`, `orderbook`, `cw20_adapter`, and `reflection`. | +| `state.rs` | 64 | All storage keys and the core state-machine types (`ExecutionState`, `Awaiting`, `SubmsgReplyState`). | +| `error.rs` | 64 | `ContractError` enum (thiserror). | +| `execute.rs` | 380 | Swap construction, admin functions, and the main `execute_aggregate_swaps_internal` entry. | +| `query.rs` | 576 | Route simulation, config/fee queries, and unit tests. | +| `reply.rs` | 887 | Submessage reply state machine — the most complex file. Drives stage-by-stage execution. | + +--- + +## Message Types (msg.rs) + +### Route Structure + +A route is expressed as `Vec`. Stages execute sequentially; splits within a stage execute in parallel. + +``` +Route + └── Stage[] (sequential) + └── Split[] (parallel within a stage) + ├── percent: u8 (must sum to 100 across splits in a stage) + └── path: Vec (sequential hops within one split) + └── Operation + ├── AmmSwap(AmmSwapOp) + └── OrderbookSwap(OrderbookSwapOp) +``` + +### AmmSwapOp + +```rust +pub struct AmmSwapOp { + pub pool_address: String, + pub offer_asset_info: amm::AssetInfo, + pub ask_asset_info: amm::AssetInfo, +} +``` + +Supports both native and CW20 inputs/outputs. + +### OrderbookSwapOp + +```rust +pub struct OrderbookSwapOp { + pub swap_contract: String, + pub offer_asset_info: amm::AssetInfo, + pub ask_asset_info: amm::AssetInfo, + pub min_quantity_tick_size: Uint128, +} +``` + +**Native tokens only** for both input and output. Amounts are rounded down to the nearest `min_quantity_tick_size` before execution. The contract queries `GetOutputQuantity` on the orderbook contract, applies 0.5% slippage, and submits `SwapMinOutput`. + +### Asset Abstraction (amm submodule) + +```rust +pub enum AssetInfo { + Token { contract_addr: String }, // CW20 + NativeToken { denom: String }, // bank +} + +pub struct Asset { + pub info: AssetInfo, + pub amount: Uint128, +} +``` + +Used throughout to represent any token. The contract checks asset type mismatches between stages and inserts automatic conversions via `cw20_adapter`. + +### External Protocol Interfaces (msg.rs submodules) + +| Submodule | What it talks to | Key messages | +|-----------|-----------------|--------------| +| `amm` | AMM DEX pools | `AmmPairExecuteMsg::Swap`, `QueryMsg::Simulation` | +| `orderbook` | Orderbook DEX contracts | `OrderbookExecuteMsg::SwapMinOutput`, `QueryMsg::GetOutputQuantity` | +| `cw20_adapter` | Injective CW20 adapter | `ExecuteMsg::RedeemAndTransfer` (native→CW20), `Cw20::Send` to adapter (CW20→native) | +| `reflection` | Tax token contracts | `ExecuteMsg::TaxExemptTransfer`, `ExecuteMsg::TaxExemptSend` | + +### ExecuteMsg Variants + +| Variant | Auth | Purpose | +|---------|------|---------| +| `ExecuteRoute { stages, minimum_receive }` | Any | Start swap with native token (exactly 1 coin in `funds`) | +| `Receive(Cw20ReceiveMsg)` | Any | CW20 hook entry. Inner msg is `Cw20HookMsg::ExecuteRoute`. Also handles internal conversion receipts (non-hook CW20 receives emit a normalization event). | +| `UpdateAdmin { new_admin }` | Admin | Transfer admin | +| `SetFee { pool_address, fee_percent }` | Admin | Set/update per-pool fee (must be < 100%) | +| `RemoveFee { pool_address }` | Admin | Remove fee for a pool | +| `UpdateFeeCollector { new_fee_collector }` | Admin | Change fee recipient address | +| `EmergencyWithdraw { asset_info }` | Admin | Withdraw all of a specific asset from the contract | +| `RegisterTaxToken { contract_addr }` | Admin | Register a CW20 as a tax token | +| `DeregisterTaxToken { contract_addr }` | Admin | Deregister a tax token | + +### QueryMsg Variants + +| Variant | Response type | Purpose | +|---------|--------------|---------| +| `SimulateRoute { stages, amount_in }` | `SimulateRouteResponse { output_amount }` | Simulate route output without executing | +| `Config {}` | `Config { admin, cw20_adapter_address, fee_collector }` | Get contract config | +| `FeeForPool { pool_address }` | `FeeResponse { fee: Option }` | Get fee for specific pool | +| `AllFees { start_after, limit }` | `AllFeesResponse { fees: Vec }` | Paginated fee list (default 10, max 30) | + +--- + +## State (state.rs) + +### Storage Keys + +| Key | Type | Purpose | +|-----|------|---------| +| `CONFIG` | `Item` | Admin address, CW20 adapter address, fee collector address | +| `FEE_MAP` | `Map<&Addr, Decimal>` | Pool address → fee percentage | +| `ACTIVE_ROUTES` | `Map` | Master reply ID → in-progress route state | +| `SUBMSG_REPLY_STATES` | `Map` | Submessage reply ID → routing info back to parent state | +| `REPLY_ID_COUNTER` | `Item` | Monotonically incrementing counter for unique reply IDs | +| `TAX_TOKEN_REGISTRY` | `Map<&Addr, bool>` | Token address → registered (always `true`). Presence = tax token. | + +### ExecutionState + +```rust +pub struct ExecutionState { + pub plan: RoutePlan, // Immutable route + sender + minimum_receive + pub awaiting: Awaiting, // Current state machine phase + pub current_stage_index: u64, // Which stage we're on + pub replies_expected: u64, // Countdown of pending submessage replies + pub accumulated_assets: Vec, // Outputs collected so far for this stage + pub pending_swaps: Vec, // Swaps deferred while conversions complete + pub pending_path_op: Option, // Deferred next-op for mid-path conversion +} +``` + +### Awaiting Enum (State Machine Phases) + +| State | Meaning | Transitions to | +|-------|---------|---------------| +| `Swaps` | Waiting for swap submessage replies | `Swaps` (next stage), `Conversions` (next stage needs conversions), `FinalConversions` (last stage output normalization), or route complete | +| `Conversions` | Waiting for CW20↔native conversions before swaps in a stage | `Swaps` (all conversions done, execute deferred swaps) | +| `FinalConversions` | Waiting for output asset normalization after last stage | Route complete (payout) | +| `PathConversion` | Waiting for a mid-path asset type conversion within a multi-hop path | `Swaps` (conversion done, resume path) | + +--- + +## Execution Flow (execute.rs + reply.rs) + +### Entry + +1. `ExecuteRoute` or `Receive` hook → `execute_aggregate_swaps_internal` +2. Validates: non-zero amount, non-empty stages, first stage split percentages sum to 100 +3. Allocates a `master_reply_id` from `REPLY_ID_COUNTER` +4. Creates initial `ExecutionState` with the offer asset in `accumulated_assets` +5. Calls `proceed_to_next_step` to begin stage execution + +### Stage Execution Loop (reply.rs: `proceed_to_next_step`) + +For each stage: + +1. **`plan_next_stage`** — Examines what the next stage's splits need (native vs CW20 inputs), compares against what's accumulated, and produces: + - `swaps_to_execute: Vec` — the first operation of each split with its calculated amount + - `conversions_needed: Vec<(Asset, AssetInfo)>` — any CW20↔native conversions required before swaps can proceed + +2. **If conversions needed:** + - Creates conversion submessages via `create_conversion_msg` (CW20→native: `Cw20::Send` to adapter; native→CW20: `RedeemAndTransfer`) + - Sets `awaiting = Conversions`, stashes `pending_swaps` + - Waits for all conversion replies → `handle_conversion_reply` → `execute_planned_swaps` + +3. **If no conversions needed:** + - Calls `execute_planned_swaps` directly + +4. **`execute_planned_swaps`** — For each planned swap: + - Allocates a unique `submsg_id` + - Saves `SubmsgReplyState { master_reply_id, split_index, op_index }` so replies can be routed + - Calls `create_swap_cosmos_msg` to build the correct `CosmosMsg` + - Dispatches all as `SubMsg::reply_on_success` + +### Swap Message Construction (execute.rs: `create_swap_cosmos_msg`) + +**AMM swaps:** +- Native input → `WasmMsg::Execute` with `funds` containing the coin +- CW20 input (standard) → `Cw20ExecuteMsg::Send` to pool with inner `AmmPairExecuteMsg::Swap` +- CW20 input (tax token) → `reflection::ExecuteMsg::TaxExemptSend` to pool with inner swap msg + +**Orderbook swaps:** +- Native input only (errors on CW20) +- Rounds amount down to nearest `min_quantity_tick_size` +- Queries `GetOutputQuantity` for expected output +- Applies 0.5% slippage buffer +- Sends `SwapMinOutput` with rounded funds + +### Reply Handling (reply.rs: `handle_reply`) + +Dispatch logic: +- If `SUBMSG_REPLY_STATES` contains `msg.id` → it's a swap reply → `handle_swap_reply` +- Otherwise, use `msg.id` as `master_reply_id`, dispatch based on `exec_state.awaiting`: + - `Conversions` → `handle_conversion_reply` + - `FinalConversions` → `handle_final_conversion_reply` + - `PathConversion` → `handle_path_conversion_reply` + +### Swap Reply Processing (reply.rs: `handle_swap_reply`) + +1. Parses output amount from events via `parse_amount_from_swap_reply`: + - First checks for `post_tax_amount` in wasm events (tax tokens, matched by `to == contract_address`) + - Then falls back to `return_amount` (AMM) or `swap_final_amount` (orderbook from `wasm-atomic_swap_execution` event) + - Truncates decimal portions to integer + +2. **If more operations remain in this split's path** (multi-hop): + - Checks if next operation's expected input type matches the received output type + - If mismatch: sets `Awaiting::PathConversion`, stashes `PendingPathOp`, dispatches conversion + - If match: creates next swap message, allocates new `submsg_id`, dispatches + +3. **If path is complete** (last operation in split): + - Applies fee via `apply_fee` (looks up `FEE_MAP` for the pool, deducts percentage) + - Sends fee to `fee_collector` if non-zero + - Adds output to `accumulated_assets` + - Decrements `replies_expected` + - If all splits done → increments `current_stage_index` → `proceed_to_next_step` + +### Final Stage (reply.rs: `handle_final_stage`) + +After all stages complete: + +1. Checks if all accumulated outputs are the same asset type +2. **If uniform:** checks `minimum_receive`, sends total to `plan.sender` +3. **If mixed types:** dispatches conversion submessages for non-matching assets → `Awaiting::FinalConversions` +4. `handle_final_conversion_reply` accumulates converted amounts → checks `minimum_receive` → sends payout + +### Amount Parsing from Replies + +**Swap replies** (`parse_amount_from_swap_reply`): +- Priority 1: `post_tax_amount` from wasm event where `to == contract_address` (tax tokens) +- Priority 2: `return_amount` from wasm events (AMM) +- Priority 3: `swap_final_amount` from `wasm-atomic_swap_execution` events (orderbook) + +**Conversion replies** (`parse_amount_from_conversion_reply`): +- Priority 1: `amount` from `transfer` event where `recipient == contract_address` (native→CW20) +- Priority 2: `amount` from wasm event where `action == "transfer"` (CW20→native) + +--- + +## Fee System + +- Fees are configured per pool address in `FEE_MAP` as `Decimal` percentages (must be < 1.0 / 100%) +- Deducted at **path completion** — when the last operation in a split's path returns a result +- Formula: `fee = amount * fee_percent`, `amount_after_fee = amount - fee` +- Fee is sent to `config.fee_collector` as a separate message appended to the response +- Pools with no entry in `FEE_MAP` have zero fee + +## Tax Token Handling + +- Tax tokens are CW20 tokens with transfer taxes (registered in `TAX_TOKEN_REGISTRY`) +- When sending tax tokens to pools: uses `reflection::ExecuteMsg::TaxExemptSend` instead of `Cw20ExecuteMsg::Send` +- When transferring tax tokens to users: uses `reflection::ExecuteMsg::TaxExemptTransfer` instead of `Cw20ExecuteMsg::Transfer` +- Reply parsing checks for `post_tax_amount` attribute first (the actual amount received after tax) + +## Simulation (query.rs) + +`simulate_route` walks through stages and splits exactly as execution would, but queries each pool's simulation endpoint instead of executing swaps: +- AMM: queries `Simulation { offer_asset }` → uses `return_amount` +- Orderbook: queries `GetOutputQuantity { from_quantity, source_denom, target_denom }` → uses `result_quantity` + +Does **not** account for fees, conversions, or tax token deductions. + +Split amount calculation: each split gets `total * percent / 100`, except the last split which gets the remainder to avoid rounding dust. + +## Error Types (error.rs) + +| Variant | When | +|---------|------| +| `Std(StdError)` | Propagated from cosmwasm-std | +| `Unauthorized` | Non-admin calls admin function | +| `ZeroAmount` | Offer amount is zero | +| `NoStages` | Empty stages vec | +| `EmptyRoute` | A stage or path has no entries | +| `InvalidPercentageSum` | Split percentages don't sum to 100 | +| `InvalidFunds { sent }` | ExecuteRoute called with != 1 coin | +| `MinimumReceiveNotMet { minimum_receive, actual_receive }` | Final output below threshold | +| `SubmessageFailed { split_index, op_index, contract_addr, error }` | A swap submessage failed | +| `ConversionFailed { awaiting_state, error }` | A CW20↔native conversion failed | +| `NoAmountInReply` | Reply events missing amount attribute | +| `MalformedAmountInReply { value }` | Amount attribute can't be parsed | +| `NoConversionEventInReply` | Conversion reply has no recognizable event | + +## Instantiation + +```rust +InstantiateMsg { + admin: String, // Admin address + cw20_adapter_address: String, // CW20 adapter contract for CW20↔native conversions + fee_collector_address: String, // Address that receives collected fees +} +``` + +Validates all addresses, saves `Config`, initializes `REPLY_ID_COUNTER` to 0. + +## Key Implementation Details + +- All entry points are parameterized with Injective types: `DepsMut`, `Response` +- `REPLY_ID_COUNTER` is a global monotonic counter shared across all concurrent routes +- The `master_reply_id` is the ID used for the overall route; individual swap submessages get their own IDs from the counter +- Conversion submessages reuse the `master_reply_id` since they don't need per-swap routing +- `pending_swaps` in `ExecutionState` temporarily holds the planned swaps while conversions complete +- `pending_path_op` holds the next operation in a multi-hop path while a mid-path conversion completes +- The last split in a stage receives the remainder amount (total - already allocated) to prevent rounding dust loss diff --git a/docs/mock_swap.md b/docs/mock_swap.md new file mode 100644 index 0000000..3cae16e --- /dev/null +++ b/docs/mock_swap.md @@ -0,0 +1,262 @@ +# mock_swap Contract — Deep Reference + +## Purpose + +The `mock_swap` contract is a test-only helper that simulates both AMM and orderbook DEX pools with a configurable exchange rate. It implements the same execute and query interfaces that the `dex_aggregator` contract calls, so integration tests can deploy mock pools with known rates and verify the aggregator's routing logic end-to-end. + +**This contract is never deployed to mainnet/testnet.** It exists solely for `injective-test-tube` integration tests. + +## Source + +Single file: `contracts/mock_swap/src/lib.rs` (~273 lines) + +Compiled artifact: `artifacts/mock_swap.wasm` (produced by `./build_release.sh`) + +--- + +## Configuration + +Each mock_swap instance is configured at instantiation with a single trading pair and rate: + +```rust +pub struct SwapConfig { + pub input_asset_info: AssetInfo, // What this pool accepts + pub output_asset_info: AssetInfo, // What this pool returns + pub rate: String, // Exchange rate as decimal string (e.g. "2.0") + pub protocol_type: ProtocolType, // Amm or Orderbook + pub input_decimals: u8, // Decimal places of input token + pub output_decimals: u8, // Decimal places of output token +} + +pub enum ProtocolType { + Amm, + Orderbook, +} + +pub struct InstantiateMsg { + pub config: SwapConfig, +} +``` + +The config is stored in a single `Item` keyed as `"config"`. + +### Rate Calculation + +The output amount is calculated as: + +``` +offer_decimal = offer_amount / 10^input_decimals +return_decimal = offer_decimal * rate +final_return_amount = return_decimal.atomics() / 10^(18 - output_decimals) +``` + +Where 18 is the internal `DECIMAL_PRECISION` used by cosmwasm `Decimal`. If the input asset doesn't match `config.input_asset_info`, the output is `Uint128::zero()` and the swap is silently skipped. + +--- + +## Execute Interface + +The contract accepts three execute message variants: + +### `Swap` (AMM protocol) + +```rust +Swap { + offer_asset: Asset, + belief_price: Option, // ignored + max_spread: Option, // ignored + to: Option, // recipient override + deadline: Option, // ignored +} +``` + +This matches the interface the aggregator uses for `amm::AmmPairExecuteMsg::Swap`. The mock: +1. Extracts `offer_asset.amount` and `offer_asset.info` +2. If `to` is provided, sends output there; otherwise sends to `info.sender` +3. Calculates output using the configured rate +4. Emits a `wasm` event with `return_amount` attribute (parsed by the aggregator's reply handler) + +### `SwapMinOutput` (Orderbook protocol) + +```rust +SwapMinOutput { + target_denom: String, + min_output_quantity: String, // ignored by mock +} +``` + +This matches `orderbook::OrderbookExecuteMsg::SwapMinOutput`. The mock: +1. Takes the input from `info.funds[0]` +2. Calculates output using the configured rate +3. Emits an `atomic_swap_execution` event with `swap_final_amount` attribute (parsed by the aggregator's reply handler) + +### `Receive` (CW20 hook) + +```rust +Receive(Cw20ReceiveMsg { sender, amount, msg }) +``` + +Handles CW20 token inputs. The inner `msg` is decoded as `MockSwapHookMsg`: + +```rust +pub struct MockSwapHookMsg { + pub swap: MockSwapHookSwapField, +} + +pub struct MockSwapHookSwapField { + pub offer_asset: Option, + pub belief_price: Option, + pub max_spread: Option, + pub to: Option, + pub deadline: Option, +} +``` + +If decoding succeeds and `to` is set, output goes to that address. Otherwise output goes to `sender`. The input asset is identified as `AssetInfo::Token { contract_addr: info.sender }` (the CW20 contract that called Receive). + +--- + +## Output Messages + +After calculating the return amount, the mock sends the output via: + +- **CW20 output:** `WasmMsg::Execute` → `Cw20ExecuteMsg::Transfer { recipient, amount }` +- **Native output:** `BankMsg::Send { to_address, amount: [Coin] }` + +If `final_return_amount` is zero, no message is sent and the response contains only an attribute `action: swap_skipped_or_zero_amount`. + +--- + +## Events Emitted + +The event type depends on `config.protocol_type`: + +### AMM (`ProtocolType::Amm`) + +``` +Event("wasm") + action = "swap" + return_amount = "" +``` + +The aggregator's `parse_amount_from_swap_reply` looks for `return_amount` in wasm events. + +### Orderbook (`ProtocolType::Orderbook`) + +``` +Event("atomic_swap_execution") + sender = "" + swap_input_amount = "" + swap_input_denom = "" + refund_amount = "0" + swap_final_amount = "" + swap_final_denom = "" +``` + +The aggregator looks for `swap_final_amount` in `wasm-atomic_swap_execution` events. + +--- + +## Query Interface + +### `GetOutputQuantity` (Orderbook simulation) + +```rust +GetOutputQuantity { + from_quantity: FPDecimal, + source_denom: String, + target_denom: String, +} +``` + +Returns `SwapEstimationResult`: + +```rust +pub struct SwapEstimationResult { + pub result_quantity: FPDecimal, + pub expected_fees: Vec, +} +``` + +**Validation:** The `source_denom` and `target_denom` must match the configured input/output asset denoms. If not, returns a `StdError::generic_err` explaining the mismatch. + +**Calculation:** `result_quantity = from_quantity * rate` using `FPDecimal` arithmetic. + +**Fees:** Always returns a single `FPCoin` with `amount: FPDecimal::ZERO` and `denom` set to the target denom. + +The aggregator calls this query in `create_swap_cosmos_msg` (for orderbook ops) to determine `min_output_quantity` with slippage. + +Note: The mock does **not** implement `amm::QueryMsg::Simulation`. The aggregator's simulation queries for AMM pools go to the real pool contracts in production; in integration tests, AMM simulation queries are not used since `SimulateRoute` is tested via unit tests with `MockQuerier` in `query.rs`. + +--- + +## Asset Types + +The mock defines its own copies of the asset types (not imported from `dex_aggregator`): + +```rust +pub enum AssetInfo { + Token { contract_addr: String }, + NativeToken { denom: String }, +} + +pub struct Asset { + pub info: AssetInfo, + pub amount: Uint128, +} +``` + +These serialize identically to `dex_aggregator::msg::amm::AssetInfo` and `amm::Asset`. + +--- + +## Usage in Integration Tests + +In `tests/integration.rs`, mock_swap contracts are deployed with specific configurations to create test pools: + +```rust +// Example: deploy a mock AMM pool that converts INJ → USDT at rate 20.0 +let config = SwapConfig { + input_asset_info: AssetInfo::NativeToken { denom: "inj".into() }, + output_asset_info: AssetInfo::NativeToken { denom: "usdt".into() }, + rate: "20.0".into(), + protocol_type: ProtocolType::Amm, + input_decimals: 18, + output_decimals: 6, +}; +``` + +The test `setup()` function typically: +1. Stores the `mock_swap.wasm` code +2. Instantiates multiple instances with different configs (different pairs, rates, protocol types) +3. Funds each mock contract with output tokens so it can fulfill swaps +4. Returns pool addresses in `TestEnv` for use in route construction + +### Important: Funding Mock Pools + +Mock contracts must be pre-funded with their output asset. For native outputs, tests use `bank_send` to fund the contract. For CW20 outputs, tests mint/transfer tokens to the mock contract address. Without funding, swap messages will fail with insufficient balance errors. + +### Key Testing Patterns + +- **Rate verification:** Deploy pool with rate X, swap amount Y, verify output is Y * X (adjusted for decimals) +- **Multi-hop chains:** Deploy pool A→B and pool B→C, build a 2-operation path, verify end-to-end output +- **Protocol mixing:** Deploy AMM and orderbook mocks in the same route to test mixed protocol handling +- **CW20 swaps:** Deploy a mock that accepts CW20 input and outputs native (or vice versa), test with `Cw20ExecuteMsg::Send` + +--- + +## Dependencies + +```toml +[dependencies] +cosmwasm-std = "2.2.2" +cosmwasm-schema = "2.2.2" +cw-storage-plus = "2.0.0" +cw20 = "2.0.0" +injective-cosmwasm = "0.3.4-1" +injective-math = "0.3.4-1" +schemars = "0.8.22" +serde = "1.0.219" +``` + +Note: `injective-cosmwasm` is needed because the query entry point uses `Deps` for compatibility with the Injective test-tube environment. diff --git a/readme.md b/readme.md index cc578b6..db09b04 100644 --- a/readme.md +++ b/readme.md @@ -89,7 +89,7 @@ AGGREGATION_CONTRACT/ │ │ └── state.rs # State definitions and storage management. │ │ │ ├── mock_swap/ # A mock DEX contract used for integration testing. It simulates -│ │ # both AMM and Orderbook behavior with predictable rates. +│ │ # AMM, Orderbook, and CLMM behavior with predictable rates. │ │ │ ├── cw20_adapter/ # A utility contract to handle conversions between native │ │ # Injective tokenfactory denoms and their CW20 equivalents. @@ -172,6 +172,8 @@ pub enum Operation { AmmSwap(AmmSwapOp), /// A swap on an orderbook-style DEX. OrderbookSwap(OrderbookSwapOp), + /// A swap on a concentrated liquidity (CLMM) DEX. + ClmmSwap(ClmmSwapOp), } // These structs define the specific details for each operation type. @@ -187,13 +189,19 @@ pub struct OrderbookSwapOp { pub ask_asset_info: external::AssetInfo, pub min_quantity_tick_size: Uint128, } + +pub struct ClmmSwapOp { + pub pool_address: String, + pub offer_asset_info: external::AssetInfo, + pub ask_asset_info: external::AssetInfo, +} ``` ### Example Usage Here is an example of a complex route that showcases the multi-hop `Path` functionality. -**Route:** Start with `INJ`. Split the funds 50/50 into two parallel, multi-hop paths that use different intermediate assets (`USDT` and `AUSD`) but both end up with `SHROOM`. +**Route:** Start with `INJ`. Split the funds into three parallel paths using AMM, Orderbook, and CLMM pools, all ending up with `USDT`. ```json { @@ -202,7 +210,7 @@ Here is an example of a complex route that showcases the multi-hop `Path` functi { "splits": [ { - "percent": 50, + "percent": 33, "path": [ { "amm_swap": { @@ -210,32 +218,30 @@ Here is an example of a complex route that showcases the multi-hop `Path` functi "offer_asset_info": { "native_token": { "denom": "inj" } }, "ask_asset_info": { "native_token": { "denom": "peggy0x...usdt" } } } - }, + } + ] + }, + { + "percent": 34, + "path": [ { "orderbook_swap": { "swap_contract": "inj1...", - "offer_asset_info": { "native_token": { "denom": "peggy0x...usdt" } }, - "ask_asset_info": { "token": { "contract_addr": "inj1...shroom" } }, - "min_quantity_tick_size": 100000000 + "offer_asset_info": { "native_token": { "denom": "inj" } }, + "ask_asset_info": { "native_token": { "denom": "peggy0x...usdt" } }, + "min_quantity_tick_size": "1000000000000000" } } ] }, { - "percent": 50, + "percent": 33, "path": [ { - "amm_swap": { + "clmm_swap": { "pool_address": "inj1...", "offer_asset_info": { "native_token": { "denom": "inj" } }, - "ask_asset_info": { "native_token": { "denom": "peggy0x...ausd" } } - } - }, - { - "amm_swap": { - "pool_address": "inj1...", - "offer_asset_info": { "native_token": { "denom": "peggy0x...ausd" } }, - "ask_asset_info": { "token": { "contract_addr": "inj1...shroom" } } + "ask_asset_info": { "native_token": { "denom": "peggy0x...usdt" } } } } ] diff --git a/tests/integration.rs b/tests/integration.rs index 558585f..a83cf9d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -7,7 +7,7 @@ use cosmwasm_std::{to_json_binary, Addr, Coin, Decimal, Uint128}; use cw20::{BalanceResponse, Cw20QueryMsg}; use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; use dex_aggregator::msg::{ - amm, cw20_adapter, AmmSwapOp, Cw20HookMsg, ExecuteMsg, InstantiateMsg, Operation, + amm, cw20_adapter, AmmSwapOp, ClmmSwapOp, Cw20HookMsg, ExecuteMsg, InstantiateMsg, Operation, OrderbookSwapOp, QueryMsg, Split, Stage, }; use dex_aggregator::state::Config as AggregatorConfig; @@ -40,6 +40,7 @@ pub struct TestEnv { pub mock_amm_2_addr: String, pub mock_ob_inj_usdt_addr: String, pub mock_ob_usdt_inj_addr: String, + pub mock_clmm_inj_usdt_addr: String, } /// Sets up the test environment, deploying the aggregator and three mock swap contracts. @@ -229,6 +230,32 @@ fn setup() -> TestEnv { .data .address; + let mock_clmm_inj_usdt_addr = wasm + .instantiate( + mock_swap_code_id, + &MockInstantiateMsg { + config: SwapConfig { + input_asset_info: AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + output_asset_info: AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + rate: "15.0".to_string(), + protocol_type: ProtocolType::Clmm, + input_decimals: 18, + output_decimals: 6, + }, + }, + Some(&admin.address()), + Some("mock-clmm-inj-usdt"), + &[], + &admin, + ) + .unwrap() + .data + .address; + let bank = Bank::new(&app); let funds_to_send = vec![ ProtoCoin { @@ -241,12 +268,13 @@ fn setup() -> TestEnv { }, ]; - // Fund all three mock contracts from the admin account. + // Fund all mock contracts from the admin account. for addr in [ &mock_amm_1_addr, &mock_amm_2_addr, &mock_ob_inj_usdt_addr, &mock_ob_usdt_inj_addr, + &mock_clmm_inj_usdt_addr, ] { bank.send( MsgSend { @@ -269,6 +297,7 @@ fn setup() -> TestEnv { mock_amm_2_addr, mock_ob_inj_usdt_addr, mock_ob_usdt_inj_addr, + mock_clmm_inj_usdt_addr, } } @@ -948,6 +977,7 @@ fn setup_for_conversion_test() -> ConversionTestSetup { mock_amm_2_addr: "".to_string(), mock_ob_inj_usdt_addr: "".to_string(), mock_ob_usdt_inj_addr: "".to_string(), + mock_clmm_inj_usdt_addr: "".to_string(), }, shroom_cw20_addr, sai_cw20_addr, @@ -3556,3 +3586,230 @@ fn test_multi_hop_consecutive_orderbook_swaps() { "Final amount should be greater than the initial amount for this profitable swap" ); } + +#[test] +fn test_clmm_single_hop_swap() { + let env = setup(); + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + // Input: 10 INJ (10 * 10^18) + // CLMM pool rate: 15.0 -> 10 INJ = 150 USDT (150 * 10^6) + // The aggregator queries Quote first, gets amount_out=150_000_000, + // then applies 0.5% slippage for minimum_amount_out. + + let msg = ExecuteMsg::ExecuteRoute { + stages: vec![Stage { + splits: vec![Split { + percent: 100, + path: vec![Operation::ClmmSwap(ClmmSwapOp { + pool_address: env.mock_clmm_inj_usdt_addr.clone(), + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + })], + }], + }], + minimum_receive: Some(Uint128::new(149_000_000)), // 149 USDT + }; + + let res = wasm.execute( + &env.aggregator_addr, + &msg, + &[Coin::new(10_000_000_000_000_000_000u128, "inj")], + &env.user, + ); + + assert!(res.is_ok(), "CLMM swap failed: {:?}", res.unwrap_err()); + + let response = res.unwrap(); + let success_event = response + .events + .iter() + .find(|e| { + e.ty == "wasm" + && e.attributes + .iter() + .any(|a| a.key == "action" && a.value == "aggregate_swap_complete") + }) + .expect("Did not find success event"); + + let total_received = success_event + .attributes + .iter() + .find(|a| a.key == "final_received") + .unwrap(); + + // 10 INJ * 15.0 = 150 USDT = 150_000_000 (6 decimals) + assert_eq!(total_received.value, "150000000"); + + // Verify user's USDT balance increased + let balance_response = bank + .query_balance(&QueryBalanceRequest { + address: env.user.address(), + denom: "usdt".to_string(), + }) + .unwrap(); + let final_balance = Uint128::from_str(&balance_response.balance.unwrap().amount).unwrap(); + // Initial: 1_000_000_000_000 + swap output: 150_000_000 + assert_eq!(final_balance, Uint128::new(1_000_150_000_000)); +} + +#[test] +fn test_clmm_mixed_with_amm_split() { + let env = setup(); + let wasm = Wasm::new(&env.app); + + // Input: 100 INJ + // Split 1 (50%): 50 INJ -> AMM1 @ 10.0 = 500 USDT + // Split 2 (50%): 50 INJ -> CLMM @ 15.0 = 750 USDT + // Total: 1250 USDT + + let msg = ExecuteMsg::ExecuteRoute { + stages: vec![Stage { + splits: vec![ + Split { + percent: 50, + path: vec![Operation::AmmSwap(AmmSwapOp { + pool_address: env.mock_amm_1_addr.clone(), + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + })], + }, + Split { + percent: 50, + path: vec![Operation::ClmmSwap(ClmmSwapOp { + pool_address: env.mock_clmm_inj_usdt_addr.clone(), + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + })], + }, + ], + }], + minimum_receive: Some(Uint128::new(1_200_000_000)), // 1200 USDT + }; + + let res = wasm.execute( + &env.aggregator_addr, + &msg, + &[Coin::new(100_000_000_000_000_000_000u128, "inj")], + &env.user, + ); + + assert!( + res.is_ok(), + "Mixed AMM+CLMM swap failed: {:?}", + res.unwrap_err() + ); + + let response = res.unwrap(); + let success_event = response + .events + .iter() + .find(|e| { + e.ty == "wasm" + && e.attributes + .iter() + .any(|a| a.key == "action" && a.value == "aggregate_swap_complete") + }) + .expect("Did not find success event"); + + let total_received = success_event + .attributes + .iter() + .find(|a| a.key == "final_received") + .unwrap(); + + // 50 INJ * 10 = 500 USDT + 50 INJ * 15 = 750 USDT = 1250 USDT + assert_eq!(total_received.value, "1250000000"); +} + +#[test] +fn test_clmm_multi_hop() { + let env = setup(); + let wasm = Wasm::new(&env.app); + + // Multi-hop: USDT -> OB (rate 0.1) -> INJ -> CLMM (rate 15.0) -> USDT + // Stage 1: 1000 USDT -> OB @ 0.1 = 100 INJ + // Stage 2: 100 INJ -> CLMM @ 15.0 = 1500 USDT + + let msg = ExecuteMsg::ExecuteRoute { + stages: vec![ + Stage { + splits: vec![Split { + percent: 100, + path: vec![Operation::OrderbookSwap(OrderbookSwapOp { + swap_contract: env.mock_ob_usdt_inj_addr.clone(), + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + min_quantity_tick_size: Uint128::new(1_000_000), + })], + }], + }, + Stage { + splits: vec![Split { + percent: 100, + path: vec![Operation::ClmmSwap(ClmmSwapOp { + pool_address: env.mock_clmm_inj_usdt_addr.clone(), + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + })], + }], + }, + ], + minimum_receive: Some(Uint128::new(1_400_000_000)), // 1400 USDT + }; + + let res = wasm.execute( + &env.aggregator_addr, + &msg, + &[Coin::new(1_000_000_000u128, "usdt")], // 1000 USDT + &env.user, + ); + + assert!( + res.is_ok(), + "Multi-hop CLMM swap failed: {:?}", + res.unwrap_err() + ); + + let response = res.unwrap(); + let success_event = response + .events + .iter() + .find(|e| { + e.ty == "wasm" + && e.attributes + .iter() + .any(|a| a.key == "action" && a.value == "aggregate_swap_complete") + }) + .expect("Did not find success event"); + + let total_received = success_event + .attributes + .iter() + .find(|a| a.key == "final_received") + .unwrap(); + + // 1000 USDT * 0.1 = 100 INJ, 100 INJ * 15 = 1500 USDT + assert_eq!(total_received.value, "1500000000"); +}