diff --git a/Cargo.lock b/Cargo.lock index cc54743..4ee9b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -794,7 +794,7 @@ dependencies = [ [[package]] name = "dex_aggregator" -version = "0.1.0" +version = "1.0.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1746,7 +1746,7 @@ dependencies = [ [[package]] name = "mock_swap" -version = "0.1.0" +version = "1.0.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", diff --git a/artifacts/checksums.txt b/artifacts/checksums.txt index e94e95e..8b8a7af 100644 --- a/artifacts/checksums.txt +++ b/artifacts/checksums.txt @@ -1,2 +1,2 @@ -d565fcb50e8379e218e8896f90a9f1e02aa737bd0eb7c1b77756f918c0d38ec0 dex_aggregator.wasm -f33d353816a46e3219f95c046682efeef68408252c9a31ce15599b2de9ba7aaa mock_swap.wasm +c3afe3612d96ba2a7f45296b5cd03e7c785593eb899ad7fc6af21ca61c077698 dex_aggregator.wasm +fca25ee84ed0903921574c9efef0144880a81f7533e952f6ce8cb3ea4f8e57b4 mock_swap.wasm diff --git a/artifacts/dex_aggregator.wasm b/artifacts/dex_aggregator.wasm index d6189d2..f2ddbfd 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 e340b53..e03698e 100644 Binary files a/artifacts/mock_swap.wasm and b/artifacts/mock_swap.wasm differ diff --git a/contracts/dex_aggregator/.cargo/config.toml b/contracts/dex_aggregator/.cargo/config.toml new file mode 100644 index 0000000..e097e0d --- /dev/null +++ b/contracts/dex_aggregator/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/dex_aggregator/Cargo.toml b/contracts/dex_aggregator/Cargo.toml index fb6e7c5..007f713 100644 --- a/contracts/dex_aggregator/Cargo.toml +++ b/contracts/dex_aggregator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dex_aggregator" -version = "0.1.0" +version = "1.0.0" edition = "2021" [lib] diff --git a/contracts/dex_aggregator/examples/schema.rs b/contracts/dex_aggregator/examples/schema.rs new file mode 100644 index 0000000..17397ad --- /dev/null +++ b/contracts/dex_aggregator/examples/schema.rs @@ -0,0 +1,26 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; + +use dex_aggregator::msg::{ + AllFeesResponse, ExecuteMsg, FeeResponse, InstantiateMsg, QueryMsg, SimulateRouteResponse, +}; +use dex_aggregator::state::Config; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema_with_title(&schema_for!(ExecuteMsg), &out_dir, "ExecuteMsg"); + export_schema_with_title(&schema_for!(QueryMsg), &out_dir, "QueryMsg"); + export_schema(&schema_for!(SimulateRouteResponse), &out_dir); + export_schema(&schema_for!(Config), &out_dir); + export_schema(&schema_for!(FeeResponse), &out_dir); + export_schema(&schema_for!(AllFeesResponse), &out_dir); + + println!("JSON schemas generated to the an aggregated directory: ./schema"); +} \ No newline at end of file diff --git a/contracts/dex_aggregator/schema/all_fees_response.json b/contracts/dex_aggregator/schema/all_fees_response.json new file mode 100644 index 0000000..09b0806 --- /dev/null +++ b/contracts/dex_aggregator/schema/all_fees_response.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllFeesResponse", + "type": "object", + "required": [ + "fees" + ], + "properties": { + "fees": { + "type": "array", + "items": { + "$ref": "#/definitions/FeeInfo" + } + } + }, + "additionalProperties": false, + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "FeeInfo": { + "type": "object", + "required": [ + "fee_percent", + "pool_address" + ], + "properties": { + "fee_percent": { + "$ref": "#/definitions/Decimal" + }, + "pool_address": { + "type": "string" + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/dex_aggregator/schema/config.json b/contracts/dex_aggregator/schema/config.json new file mode 100644 index 0000000..e41984b --- /dev/null +++ b/contracts/dex_aggregator/schema/config.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "admin", + "cw20_adapter_address", + "fee_collector" + ], + "properties": { + "admin": { + "$ref": "#/definitions/Addr" + }, + "cw20_adapter_address": { + "$ref": "#/definitions/Addr" + }, + "fee_collector": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } +} diff --git a/contracts/dex_aggregator/schema/execute_msg.json b/contracts/dex_aggregator/schema/execute_msg.json new file mode 100644 index 0000000..6d8401e --- /dev/null +++ b/contracts/dex_aggregator/schema/execute_msg.json @@ -0,0 +1,350 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "execute_route" + ], + "properties": { + "execute_route": { + "type": "object", + "required": [ + "stages" + ], + "properties": { + "minimum_receive": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/definitions/Stage" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "new_admin" + ], + "properties": { + "new_admin": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_fee" + ], + "properties": { + "set_fee": { + "type": "object", + "required": [ + "fee_percent", + "pool_address" + ], + "properties": { + "fee_percent": { + "$ref": "#/definitions/Decimal" + }, + "pool_address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_fee" + ], + "properties": { + "remove_fee": { + "type": "object", + "required": [ + "pool_address" + ], + "properties": { + "pool_address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_fee_collector" + ], + "properties": { + "update_fee_collector": { + "type": "object", + "required": [ + "new_fee_collector" + ], + "properties": { + "new_fee_collector": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "emergency_withdraw" + ], + "properties": { + "emergency_withdraw": { + "type": "object", + "required": [ + "asset_info" + ], + "properties": { + "asset_info": { + "$ref": "#/definitions/AssetInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "AmmSwapOp": { + "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 + }, + "AssetInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "native_token" + ], + "properties": { + "native_token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "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" + }, + "Cw20ReceiveMsg": { + "description": "Cw20ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", + "type": "object", + "required": [ + "amount", + "msg", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "Operation": { + "oneOf": [ + { + "type": "object", + "required": [ + "amm_swap" + ], + "properties": { + "amm_swap": { + "$ref": "#/definitions/AmmSwapOp" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "orderbook_swap" + ], + "properties": { + "orderbook_swap": { + "$ref": "#/definitions/OrderbookSwapOp" + } + }, + "additionalProperties": false + } + ] + }, + "OrderbookSwapOp": { + "type": "object", + "required": [ + "ask_asset_info", + "min_quantity_tick_size", + "offer_asset_info", + "swap_contract" + ], + "properties": { + "ask_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "min_quantity_tick_size": { + "$ref": "#/definitions/Uint128" + }, + "offer_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "swap_contract": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Split": { + "type": "object", + "required": [ + "path", + "percent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "$ref": "#/definitions/Operation" + } + }, + "percent": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Stage": { + "type": "object", + "required": [ + "splits" + ], + "properties": { + "splits": { + "type": "array", + "items": { + "$ref": "#/definitions/Split" + } + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/dex_aggregator/schema/fee_response.json b/contracts/dex_aggregator/schema/fee_response.json new file mode 100644 index 0000000..3aa3fa3 --- /dev/null +++ b/contracts/dex_aggregator/schema/fee_response.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FeeResponse", + "type": "object", + "properties": { + "fee": { + "anyOf": [ + { + "$ref": "#/definitions/Decimal" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + } + } +} diff --git a/contracts/dex_aggregator/schema/instantiate_msg.json b/contracts/dex_aggregator/schema/instantiate_msg.json new file mode 100644 index 0000000..5db5984 --- /dev/null +++ b/contracts/dex_aggregator/schema/instantiate_msg.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "admin", + "cw20_adapter_address", + "fee_collector_address" + ], + "properties": { + "admin": { + "type": "string" + }, + "cw20_adapter_address": { + "type": "string" + }, + "fee_collector_address": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/contracts/dex_aggregator/schema/query_msg.json b/contracts/dex_aggregator/schema/query_msg.json new file mode 100644 index 0000000..ccb56ec --- /dev/null +++ b/contracts/dex_aggregator/schema/query_msg.json @@ -0,0 +1,273 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "simulate_route" + ], + "properties": { + "simulate_route": { + "type": "object", + "required": [ + "amount_in", + "stages" + ], + "properties": { + "amount_in": { + "$ref": "#/definitions/Coin" + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/definitions/Stage" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "fee_for_pool" + ], + "properties": { + "fee_for_pool": { + "type": "object", + "required": [ + "pool_address" + ], + "properties": { + "pool_address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "all_fees" + ], + "properties": { + "all_fees": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "AmmSwapOp": { + "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 + }, + "AssetInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "native_token" + ], + "properties": { + "native_token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Operation": { + "oneOf": [ + { + "type": "object", + "required": [ + "amm_swap" + ], + "properties": { + "amm_swap": { + "$ref": "#/definitions/AmmSwapOp" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "orderbook_swap" + ], + "properties": { + "orderbook_swap": { + "$ref": "#/definitions/OrderbookSwapOp" + } + }, + "additionalProperties": false + } + ] + }, + "OrderbookSwapOp": { + "type": "object", + "required": [ + "ask_asset_info", + "min_quantity_tick_size", + "offer_asset_info", + "swap_contract" + ], + "properties": { + "ask_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "min_quantity_tick_size": { + "$ref": "#/definitions/Uint128" + }, + "offer_asset_info": { + "$ref": "#/definitions/AssetInfo" + }, + "swap_contract": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Split": { + "type": "object", + "required": [ + "path", + "percent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "$ref": "#/definitions/Operation" + } + }, + "percent": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Stage": { + "type": "object", + "required": [ + "splits" + ], + "properties": { + "splits": { + "type": "array", + "items": { + "$ref": "#/definitions/Split" + } + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/dex_aggregator/schema/simulate_route_response.json b/contracts/dex_aggregator/schema/simulate_route_response.json new file mode 100644 index 0000000..68fd422 --- /dev/null +++ b/contracts/dex_aggregator/schema/simulate_route_response.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SimulateRouteResponse", + "type": "object", + "required": [ + "output_amount" + ], + "properties": { + "output_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/dex_aggregator/src/contract.rs b/contracts/dex_aggregator/src/contract.rs index 65cf9e1..af695fe 100644 --- a/contracts/dex_aggregator/src/contract.rs +++ b/contracts/dex_aggregator/src/contract.rs @@ -1,13 +1,13 @@ use cosmwasm_std::{ entry_point, Binary, Deps, DepsMut, Env, Event, MessageInfo, Reply, Response, StdResult, }; +use cw20::Cw20ReceiveMsg; use injective_cosmwasm::{InjectiveMsgWrapper, InjectiveQueryWrapper}; use crate::error::ContractError; use crate::execute::{self, remove_fee, set_fee, update_fee_collector}; use crate::msg::{amm, Cw20HookMsg, ExecuteMsg, InstantiateMsg, QueryMsg}; -use crate::state::{Config, CONFIG}; -use cw20::Cw20ReceiveMsg; +use crate::state::{Config, CONFIG, REPLY_ID_COUNTER}; pub const CONTRACT_NAME: &str = "crates.io:dex-aggregator"; pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -25,13 +25,13 @@ pub fn instantiate( let adapter_addr = deps.api.addr_validate(&msg.cw20_adapter_address)?; let fee_collector_addr = deps.api.addr_validate(&msg.fee_collector_address)?; - // Save the full config let config = Config { admin: admin_addr, cw20_adapter_address: adapter_addr, fee_collector: fee_collector_addr, }; CONFIG.save(deps.storage, &config)?; + REPLY_ID_COUNTER.save(deps.storage, &0u64)?; Ok(Response::new().add_attribute("method", "instantiate")) } @@ -50,7 +50,9 @@ pub fn execute( } => { // This is the entry point for NATIVE token swaps if info.funds.len() != 1 { - return Err(ContractError::InvalidFunds {}); + return Err(ContractError::InvalidFunds { + sent: info.funds.len(), + }); } let offer_asset = amm::Asset { info: amm::AssetInfo::NativeToken { @@ -61,7 +63,6 @@ pub fn execute( execute::execute_aggregate_swaps_internal( deps, env, - info.clone(), stages, minimum_receive, offer_asset, @@ -90,7 +91,6 @@ pub fn execute( execute::execute_aggregate_swaps_internal( deps, env, - info, stages, minimum_receive, offer_asset, diff --git a/contracts/dex_aggregator/src/error.rs b/contracts/dex_aggregator/src/error.rs index ae799fd..003fe20 100644 --- a/contracts/dex_aggregator/src/error.rs +++ b/contracts/dex_aggregator/src/error.rs @@ -1,32 +1,58 @@ -use cosmwasm_std::StdError; +use cosmwasm_std::{StdError, Uint128}; use thiserror::Error; #[derive(Error, Debug, PartialEq)] pub enum ContractError { + // --- Standard & Authorization Errors --- #[error("{0}")] Std(#[from] StdError), #[error("Unauthorized")] Unauthorized {}, - #[error("Minimum receive amount not met")] - MinimumReceiveNotMet {}, - - #[error("Route cannot be empty")] - EmptyRoute {}, - + // --- Input & Route Validation Errors --- #[error("Input amount must be greater than zero")] ZeroAmount {}, + #[error("No stages provided for the swap")] + NoStages {}, + + #[error("A stage or path within the route cannot be empty")] + EmptyRoute {}, + #[error("Percentages in a stage must sum to 100")] InvalidPercentageSum {}, - #[error("No stages provided for the swap")] - NoStages {}, + #[error("Invalid funds for native token swap. Expected 1 coin, sent {sent}")] + InvalidFunds { sent: usize }, - #[error("Failed to parse submessage reply result: {error}")] - SubmessageResultError { error: String }, + // --- Execution & Economic Outcome Errors --- + #[error( + "Minimum receive amount not met. Minimum: {minimum_receive}, Received: {actual_receive}" + )] + MinimumReceiveNotMet { + minimum_receive: Uint128, + actual_receive: Uint128, + }, + // --- Submessage & Reply Handling Errors --- + #[error( + "Submessage from contract {contract_addr} for operation at [split:{split_index}, op:{op_index}] failed with: {error}" + )] + SubmessageFailed { + split_index: usize, + op_index: usize, + contract_addr: String, + error: String, + }, + + #[error("Asset conversion failed during the '{awaiting_state}' step: {error}")] + ConversionFailed { + awaiting_state: String, + error: String, + }, + + // --- Reply Parsing Errors --- #[error("Failed to parse reply: wasm event did not contain a return amount attribute")] NoAmountInReply {}, @@ -35,7 +61,4 @@ pub enum ContractError { #[error("Failed to parse conversion reply: could not find a valid 'transfer' or 'wasm' event")] NoConversionEventInReply {}, - - #[error("AggregateSwaps requires exactly one type of coin to be sent")] - InvalidFunds {}, } diff --git a/contracts/dex_aggregator/src/execute.rs b/contracts/dex_aggregator/src/execute.rs index 86097ca..f49d2c5 100644 --- a/contracts/dex_aggregator/src/execute.rs +++ b/contracts/dex_aggregator/src/execute.rs @@ -1,6 +1,6 @@ use cosmwasm_std::{ to_json_binary, Addr, BankMsg, Coin, CosmosMsg, Decimal, DepsMut, Env, MessageInfo, Response, - StdError, Uint128, WasmMsg, + StdError, StdResult, Uint128, WasmMsg, }; use cw20::{BalanceResponse, Cw20ExecuteMsg, Cw20QueryMsg}; use injective_cosmwasm::{InjectiveMsgWrapper, InjectiveQueryWrapper}; @@ -10,9 +10,7 @@ use std::str::FromStr; use crate::error::ContractError; use crate::msg::{self, amm, orderbook, Operation, Stage}; use crate::reply::proceed_to_next_step; -use crate::state::{ - Awaiting, ExecutionState, RoutePlan, CONFIG, FEE_MAP, REPLY_ID_COUNTER, ROUTE_PLANS, -}; +use crate::state::{Awaiting, ExecutionState, RoutePlan, CONFIG, FEE_MAP, REPLY_ID_COUNTER}; pub fn update_admin( deps: DepsMut, @@ -39,9 +37,8 @@ pub fn update_admin( pub fn execute_aggregate_swaps_internal( mut deps: DepsMut, env: Env, - _info: MessageInfo, stages: Vec, - minimum_receive_str: Option, + minimum_receive: Option, offer_asset: amm::Asset, initiator: Addr, ) -> Result, ContractError> { @@ -58,22 +55,18 @@ pub fn execute_aggregate_swaps_internal( return Err(ContractError::InvalidPercentageSum {}); } - let reply_id = REPLY_ID_COUNTER.may_load(deps.storage)?.unwrap_or(0) + 1; - REPLY_ID_COUNTER.save(deps.storage, &reply_id)?; + let reply_id = REPLY_ID_COUNTER.update(deps.storage, |id| -> StdResult<_> { Ok(id + 1) })?; - let minimum_receive = match minimum_receive_str { - Some(s) => Uint128::from_str(&s)?, - None => Uint128::zero(), - }; + let minimum_receive = minimum_receive.unwrap_or_default(); let plan = RoutePlan { sender: initiator.clone(), minimum_receive, stages, }; - ROUTE_PLANS.save(deps.storage, reply_id, &plan)?; let mut initial_exec_state = ExecutionState { + plan, awaiting: Awaiting::Swaps, current_stage_index: 0, replies_expected: 0, @@ -82,7 +75,7 @@ pub fn execute_aggregate_swaps_internal( pending_path_op: None, }; - proceed_to_next_step(&mut deps, env, &mut initial_exec_state, &plan, reply_id) + proceed_to_next_step(&mut deps, env, &mut initial_exec_state, reply_id) } pub fn create_swap_cosmos_msg( @@ -287,59 +280,52 @@ pub fn emergency_withdraw( return Err(ContractError::Unauthorized {}); } - let (amount_to_withdraw, send_msg) = match asset_info.clone() { + let mut response = Response::new() + .add_attribute("action", "emergency_withdraw") + .add_attribute("recipient", info.sender.to_string()) + .add_attribute("asset", format!("{:?}", asset_info)); + + let (amount_to_withdraw, send_msg) = match asset_info { amm::AssetInfo::NativeToken { denom } => { - // 2a. Query the contract's native token balance let balance = deps.querier.query_balance(&env.contract.address, denom)?; - - if balance.amount.is_zero() { - // Return success but do nothing if balance is zero - (balance.amount, None) - } else { - // 3a. Create a BankMsg to send the full balance to the admin - let msg = CosmosMsg::Bank(BankMsg::Send { + let msg = if !balance.amount.is_zero() { + Some(CosmosMsg::Bank(BankMsg::Send { to_address: info.sender.to_string(), amount: vec![balance.clone()], - }); - (balance.amount, Some(msg)) - } + })) + } else { + None + }; + (balance.amount, msg) } amm::AssetInfo::Token { contract_addr } => { - // 2b. Query the contract's CW20 token balance - let balance_response: BalanceResponse = deps.querier.query_wasm_smart( + let balance: BalanceResponse = deps.querier.query_wasm_smart( contract_addr.clone(), &Cw20QueryMsg::Balance { address: env.contract.address.to_string(), }, )?; - - if balance_response.balance.is_zero() { - // Return success but do nothing if balance is zero - (balance_response.balance, None) - } else { - // 3b. Create a WasmMsg to transfer the full balance to the admin - let msg = CosmosMsg::Wasm(WasmMsg::Execute { + let msg = if !balance.balance.is_zero() { + Some(CosmosMsg::Wasm(WasmMsg::Execute { contract_addr, msg: to_json_binary(&Cw20ExecuteMsg::Transfer { recipient: info.sender.to_string(), - amount: balance_response.balance, + amount: balance.balance, })?, funds: vec![], - }); - (balance_response.balance, Some(msg)) - } + })) + } else { + None + }; + (balance.balance, msg) } }; - let mut response = Response::new() - .add_attribute("action", "emergency_withdraw") - .add_attribute("recipient", info.sender.to_string()) - .add_attribute("asset", format!("{:?}", asset_info)) - .add_attribute("withdrawn_amount", amount_to_withdraw.to_string()); - if let Some(msg) = send_msg { response = response.add_message(msg); } + response = response.add_attribute("withdrawn_amount", amount_to_withdraw.to_string()); + Ok(response) } diff --git a/contracts/dex_aggregator/src/msg.rs b/contracts/dex_aggregator/src/msg.rs index ac2a8bb..4064280 100644 --- a/contracts/dex_aggregator/src/msg.rs +++ b/contracts/dex_aggregator/src/msg.rs @@ -168,7 +168,7 @@ pub struct StagePlan { pub enum Cw20HookMsg { ExecuteRoute { stages: Vec, - minimum_receive: Option, + minimum_receive: Option, }, } @@ -183,7 +183,7 @@ pub struct InstantiateMsg { pub enum ExecuteMsg { ExecuteRoute { stages: Vec, - minimum_receive: Option, + minimum_receive: Option, }, Receive(Cw20ReceiveMsg), // Admin-only diff --git a/contracts/dex_aggregator/src/reply.rs b/contracts/dex_aggregator/src/reply.rs index 6ff76dc..ba1d161 100644 --- a/contracts/dex_aggregator/src/reply.rs +++ b/contracts/dex_aggregator/src/reply.rs @@ -1,10 +1,3 @@ -use crate::error::ContractError; -use crate::execute::create_swap_cosmos_msg; -use crate::msg::{amm, cw20_adapter, Operation, PlannedSwap, Stage, StagePlan}; -use crate::state::{ - Awaiting, Config, ExecutionState, PendingPathOp, RoutePlan, SubmsgReplyState, CONFIG, - EXECUTION_STATES, FEE_MAP, REPLY_ID_COUNTER, ROUTE_PLANS, SUBMSG_REPLY_STATES, -}; use cosmwasm_std::{ to_json_binary, Addr, Coin, CosmosMsg, DepsMut, Env, Reply, Response, StdError, SubMsg, Uint128, WasmMsg, @@ -12,6 +5,16 @@ use cosmwasm_std::{ use cw20::Cw20ExecuteMsg; use injective_cosmwasm::{InjectiveMsgWrapper, InjectiveQueryWrapper}; +use crate::error::ContractError; +use crate::execute::create_swap_cosmos_msg; +use crate::msg::{amm, cw20_adapter, Operation, PlannedSwap, Stage, StagePlan}; +use crate::state::{ + Awaiting, Config, ExecutionState, PendingPathOp, SubmsgReplyState, ACTIVE_ROUTES, CONFIG, + FEE_MAP, REPLY_ID_COUNTER, SUBMSG_REPLY_STATES, +}; + +const DECIMAL_FRACTIONAL: u128 = 1_000_000_000_000_000_000; + pub fn handle_reply( deps: DepsMut, env: Env, @@ -19,25 +22,21 @@ pub fn handle_reply( ) -> Result, ContractError> { if let Ok(submsg_state) = SUBMSG_REPLY_STATES.load(deps.storage, msg.id) { let master_reply_id = submsg_state.master_reply_id; - let mut exec_state = EXECUTION_STATES.load(deps.storage, master_reply_id)?; - let plan = ROUTE_PLANS.load(deps.storage, master_reply_id)?; + let mut exec_state = ACTIVE_ROUTES.load(deps.storage, master_reply_id)?; SUBMSG_REPLY_STATES.remove(deps.storage, msg.id); - handle_swap_reply(deps, env, msg, &mut exec_state, &plan, submsg_state) + handle_swap_reply(deps, env, msg, &mut exec_state, submsg_state) } else { let master_reply_id = msg.id; - let mut exec_state = EXECUTION_STATES.load(deps.storage, master_reply_id)?; - let plan = ROUTE_PLANS.load(deps.storage, master_reply_id)?; + let mut exec_state = ACTIVE_ROUTES.load(deps.storage, master_reply_id)?; match exec_state.awaiting { - Awaiting::Conversions => { - handle_conversion_reply(deps, env, msg, &mut exec_state, &plan) - } + Awaiting::Conversions => handle_conversion_reply(deps, env, msg, &mut exec_state), Awaiting::FinalConversions => { - handle_final_conversion_reply(deps, env, msg, &mut exec_state, &plan) + handle_final_conversion_reply(deps, env, msg, &mut exec_state) } Awaiting::PathConversion => { - handle_path_conversion_reply(deps, env, msg, &mut exec_state, &plan) + handle_path_conversion_reply(deps, env, msg, &mut exec_state) } Awaiting::Swaps => Err(ContractError::Std(StdError::generic_err(format!( "Unregistered swap reply ID received: {}", @@ -51,14 +50,13 @@ pub(crate) fn proceed_to_next_step( deps: &mut DepsMut, env: Env, exec_state: &mut ExecutionState, - plan: &RoutePlan, master_reply_id: u64, ) -> Result, ContractError> { - if exec_state.current_stage_index as usize >= plan.stages.len() { - return handle_final_stage(deps, env, master_reply_id, exec_state, plan); + if exec_state.current_stage_index as usize >= exec_state.plan.stages.len() { + return handle_final_stage(deps, env, master_reply_id, exec_state); } - - let next_stage_to_execute = plan + let next_stage_to_execute = exec_state + .plan .stages .get(exec_state.current_stage_index as usize) .unwrap(); @@ -71,13 +69,12 @@ pub(crate) fn proceed_to_next_step( deps, env, exec_state, - plan, master_reply_id, - stage_plan.swaps_to_execute, + &stage_plan.swaps_to_execute, ) } else { let config = CONFIG.load(deps.storage)?; - let mut conversion_submsgs = vec![]; + let mut conversion_submsgs = Vec::with_capacity(stage_plan.conversions_needed.len()); for (asset_to_convert, _target_info) in &stage_plan.conversions_needed { let msg = create_conversion_msg(asset_to_convert, &config, &env)?; conversion_submsgs.push(SubMsg::reply_on_success(msg, master_reply_id)); @@ -87,7 +84,7 @@ pub(crate) fn proceed_to_next_step( exec_state.replies_expected = conversion_submsgs.len() as u64; exec_state.pending_swaps = stage_plan.swaps_to_execute; - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; Ok(Response::new() .add_submessages(conversion_submsgs) @@ -100,17 +97,31 @@ fn handle_swap_reply( env: Env, msg: Reply, exec_state: &mut ExecutionState, - plan: &RoutePlan, submsg_state: SubmsgReplyState, ) -> Result, ContractError> { let master_reply_id = submsg_state.master_reply_id; + let split_index = submsg_state.split_index; + let op_index = submsg_state.op_index; + + let current_stage = exec_state + .plan + .stages + .get(exec_state.current_stage_index as usize) + .ok_or(ContractError::EmptyRoute {})?; + + let replied_op = ¤t_stage.splits[split_index].path[op_index]; - let events = &msg - .result - .clone() - .into_result() - .map_err(|e| ContractError::SubmessageResultError { error: e })? - .events; + let events = &match msg.result.into_result() { + Ok(response) => response.events, + Err(e) => { + return Err(ContractError::SubmessageFailed { + split_index, + op_index, + contract_addr: get_operation_address(replied_op).to_string(), + error: e, + }); + } + }; let swap_event_opt = events.iter().rev().find(|e| { e.ty.starts_with("wasm") @@ -122,26 +133,16 @@ fn handle_swap_reply( exec_state.replies_expected -= 1; if exec_state.replies_expected == 0 { exec_state.current_stage_index += 1; - return proceed_to_next_step(&mut deps, env, exec_state, plan, master_reply_id); + return proceed_to_next_step(&mut deps, env, exec_state, master_reply_id); } else { - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; return Ok(Response::new() .add_attribute("action", "accumulating_path_outputs") .add_attribute("info", "zero_value_path_completed")); } } - let split_index = submsg_state.split_index; - let op_index = submsg_state.op_index; - - let current_stage = plan - .stages - .get(exec_state.current_stage_index as usize) - .ok_or(ContractError::EmptyRoute {})?; - - let replied_op = ¤t_stage.splits[split_index].path[op_index]; - - let received_amount = parse_amount_from_swap_reply(&msg)?; + let received_amount = parse_amount_from_swap_reply(events)?; let received_asset_info = get_operation_output(replied_op)?; let replied_path = ¤t_stage.splits[split_index].path; @@ -166,9 +167,8 @@ fn handle_swap_reply( let config = CONFIG.load(deps.storage)?; let conversion_msg = create_conversion_msg(&offer_asset_for_next_op, &config, &env)?; - // The reply for this conversion will use the master_reply_id let sub_msg = SubMsg::reply_on_success(conversion_msg, master_reply_id); - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; return Ok(Response::new() .add_submessage(sub_msg) @@ -201,7 +201,7 @@ fn handle_swap_reply( let sub_msg = SubMsg::reply_on_success(next_msg, next_submsg_id); - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; Ok(Response::new() .add_submessage(sub_msg) @@ -211,14 +211,8 @@ fn handle_swap_reply( } else { let replying_pool_addr = deps.api.addr_validate(get_operation_address(replied_op))?; - let fee = match FEE_MAP.may_load(deps.storage, &replying_pool_addr)? { - Some(fee_percent) => { - received_amount.multiply_ratio(fee_percent.atomics(), 1_000_000_000_000_000_000u128) - } - None => Uint128::zero(), - }; + let (amount_after_fee, fee) = apply_fee(&deps, &replying_pool_addr, received_amount)?; - let amount_after_fee = received_amount.checked_sub(fee).map_err(StdError::from)?; exec_state.accumulated_assets.push(amm::Asset { info: received_asset_info.clone(), amount: amount_after_fee, @@ -227,11 +221,11 @@ fn handle_swap_reply( let mut response; if exec_state.replies_expected > 0 { - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; response = Response::new().add_attribute("action", "accumulating_path_outputs"); } else { exec_state.current_stage_index += 1; - response = proceed_to_next_step(&mut deps, env, exec_state, plan, master_reply_id)?; + response = proceed_to_next_step(&mut deps, env, exec_state, master_reply_id)?; } if !fee.is_zero() { @@ -246,6 +240,20 @@ fn handle_swap_reply( } } +fn apply_fee( + deps: &DepsMut, + pool_addr: &Addr, + amount: Uint128, +) -> Result<(Uint128, Uint128), StdError> { + let fee = match FEE_MAP.may_load(deps.storage, pool_addr)? { + Some(fee_percent) => amount.multiply_ratio(fee_percent.atomics(), DECIMAL_FRACTIONAL), + None => Uint128::zero(), + }; + + let amount_after_fee = amount.checked_sub(fee)?; + Ok((amount_after_fee, fee)) +} + // A helper to create the final transfer message. fn create_send_msg( recipient: &Addr, @@ -276,22 +284,22 @@ fn handle_final_stage( env: Env, reply_id: u64, exec_state: &mut ExecutionState, - plan: &RoutePlan, ) -> Result, ContractError> { if exec_state.accumulated_assets.is_empty() { - if !plan.minimum_receive.is_zero() { - return Err(ContractError::MinimumReceiveNotMet {}); + if !exec_state.plan.minimum_receive.is_zero() { + return Err(ContractError::MinimumReceiveNotMet { + minimum_receive: exec_state.plan.minimum_receive, + actual_receive: Uint128::zero(), + }); } - // CLEANUP HERE - EXECUTION_STATES.remove(deps.storage, reply_id); - ROUTE_PLANS.remove(deps.storage, reply_id); + ACTIVE_ROUTES.remove(deps.storage, reply_id); return Ok(Response::new().add_attribute("action", "aggregate_swap_complete_empty")); } // The target asset for normalization is the type of the first asset in the final list. let target_asset_info = exec_state.accumulated_assets[0].info.clone(); - let mut conversion_submsgs = vec![]; + let mut conversion_submsgs = Vec::with_capacity(exec_state.accumulated_assets.len()); let mut ready_amount = Uint128::zero(); let config = CONFIG.load(deps.storage)?; @@ -308,21 +316,26 @@ fn handle_final_stage( // SCENARIO A: All assets were already the same type. We are done. let total_final_amount = ready_amount; // Check against minimum_receive from the immutable plan - if total_final_amount < plan.minimum_receive { - return Err(ContractError::MinimumReceiveNotMet {}); + if total_final_amount < exec_state.plan.minimum_receive { + return Err(ContractError::MinimumReceiveNotMet { + minimum_receive: exec_state.plan.minimum_receive, + actual_receive: total_final_amount, + }); } let mut response = Response::new(); if !total_final_amount.is_zero() { // Use the sender address from the immutable plan - let send_msg = create_send_msg(&plan.sender, &target_asset_info, total_final_amount)?; + let send_msg = create_send_msg( + &exec_state.plan.sender, + &target_asset_info, + total_final_amount, + )?; response = response.add_message(send_msg); } - EXECUTION_STATES.remove(deps.storage, reply_id); - ROUTE_PLANS.remove(deps.storage, reply_id); + ACTIVE_ROUTES.remove(deps.storage, reply_id); - // State cleanup is now handled in the main `handle_reply` function Ok(response .add_attribute("action", "aggregate_swap_complete") .add_attribute("final_received", total_final_amount.to_string())) @@ -335,8 +348,7 @@ fn handle_final_stage( amount: ready_amount, }]; - // Save the small, mutated exec_state - EXECUTION_STATES.save(deps.storage, reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, reply_id, exec_state)?; Ok(Response::new() .add_submessages(conversion_submsgs) @@ -349,10 +361,17 @@ fn handle_final_conversion_reply( env: Env, msg: Reply, exec_state: &mut ExecutionState, - plan: &RoutePlan, ) -> Result, ContractError> { + if msg.result.is_err() { + return Err(ContractError::ConversionFailed { + awaiting_state: "FinalConversions".to_string(), + error: msg.result.unwrap_err(), + }); + } + let reply_id = msg.id; - let converted_amount = parse_amount_from_conversion_reply(&msg, &env)?; + let events = &msg.result.into_result().unwrap().events; + let converted_amount = parse_amount_from_conversion_reply(events, &env)?; let running_total_asset = exec_state.accumulated_assets.get_mut(0).ok_or_else(|| { StdError::generic_err("Final conversion state is invalid: no accumulated asset found") @@ -362,8 +381,8 @@ fn handle_final_conversion_reply( exec_state.replies_expected -= 1; if exec_state.replies_expected > 0 { - // Still waiting for more conversions to finish. Save the updated exec_state. - EXECUTION_STATES.save(deps.storage, reply_id, exec_state)?; + // Still waiting for more conversions to finish. + ACTIVE_ROUTES.save(deps.storage, reply_id, exec_state)?; return Ok(Response::new().add_attribute("action", "accumulating_final_conversions")); } @@ -371,21 +390,25 @@ fn handle_final_conversion_reply( let total_final_amount = running_total_asset.amount; let final_asset_info = running_total_asset.info.clone(); - if total_final_amount < plan.minimum_receive { - return Err(ContractError::MinimumReceiveNotMet {}); + if total_final_amount < exec_state.plan.minimum_receive { + return Err(ContractError::MinimumReceiveNotMet { + minimum_receive: exec_state.plan.minimum_receive, + actual_receive: total_final_amount, + }); } let mut response = Response::new(); if !total_final_amount.is_zero() { - // Get the sender address from the immutable plan - let send_msg = create_send_msg(&plan.sender, &final_asset_info, total_final_amount)?; + let send_msg = create_send_msg( + &exec_state.plan.sender, + &final_asset_info, + total_final_amount, + )?; response = response.add_message(send_msg); } - EXECUTION_STATES.remove(deps.storage, reply_id); - ROUTE_PLANS.remove(deps.storage, reply_id); + ACTIVE_ROUTES.remove(deps.storage, reply_id); - // State cleanup is now handled in the main `handle_reply` function Ok(response .add_attribute("action", "aggregate_swap_complete") .add_attribute("final_received", total_final_amount.to_string())) @@ -396,28 +419,30 @@ fn handle_conversion_reply( env: Env, msg: Reply, exec_state: &mut ExecutionState, - plan: &RoutePlan, ) -> Result, ContractError> { + if msg.result.is_err() { + return Err(ContractError::ConversionFailed { + awaiting_state: "Conversions".to_string(), + error: msg.result.unwrap_err(), + }); + } + let master_reply_id = msg.id; - exec_state.replies_expected -= 1; // Mutate exec_state + exec_state.replies_expected -= 1; if exec_state.replies_expected > 0 { - // Save the small, mutated exec_state - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; return Ok(Response::new().add_attribute("action", "accumulating_conversion_outputs")); } - // Take pending_swaps from the mutated exec_state let swaps_to_execute = std::mem::take(&mut exec_state.pending_swaps); - // Call the updated execute_planned_swaps with both state objects execute_planned_swaps( &mut deps, env, exec_state, - plan, master_reply_id, - swaps_to_execute, + &swaps_to_execute, ) } @@ -464,14 +489,7 @@ fn get_operation_output(op: &Operation) -> Result }) } -fn parse_amount_from_swap_reply(msg: &Reply) -> Result { - let events = msg - .result - .clone() - .into_result() - .map_err(|e| ContractError::SubmessageResultError { error: e })? - .events; - +fn parse_amount_from_swap_reply(events: &[cosmwasm_std::Event]) -> Result { let amount_str_opt = events.iter().find_map(|event| { if !event.ty.starts_with("wasm") { return None; @@ -504,14 +522,10 @@ fn parse_amount_from_swap_reply(msg: &Reply) -> Result { } } -fn parse_amount_from_conversion_reply(msg: &Reply, env: &Env) -> Result { - let events = &msg - .result - .clone() - .into_result() - .map_err(|e| ContractError::SubmessageResultError { error: e })? - .events; - +fn parse_amount_from_conversion_reply( + events: &[cosmwasm_std::Event], + env: &Env, +) -> Result { if let Some(transfer_event) = events.iter().find(|e| { e.ty == "transfer" && e.attributes @@ -697,17 +711,13 @@ fn execute_planned_swaps( deps: &mut DepsMut, env: Env, exec_state: &mut ExecutionState, - _plan: &RoutePlan, master_reply_id: u64, - swaps: Vec, + swaps: &[PlannedSwap], ) -> Result, ContractError> { - let mut submessages = vec![]; - let filtered_swaps: Vec = - swaps.into_iter().filter(|s| !s.amount.is_zero()).collect(); - + let mut submessages = Vec::with_capacity(swaps.len()); let mut reply_id_counter = REPLY_ID_COUNTER.load(deps.storage)?; - for swap in &filtered_swaps { + for swap in swaps.iter().filter(|s| !s.amount.is_zero()) { reply_id_counter += 1; let submsg_id = reply_id_counter; @@ -732,13 +742,13 @@ fn execute_planned_swaps( if submessages.is_empty() { exec_state.current_stage_index += 1; - return proceed_to_next_step(deps, env, exec_state, _plan, master_reply_id); + return proceed_to_next_step(deps, env, exec_state, master_reply_id); } exec_state.awaiting = Awaiting::Swaps; exec_state.replies_expected = submessages.len() as u64; - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; Ok(Response::new() .add_submessages(submessages) @@ -758,16 +768,24 @@ fn handle_path_conversion_reply( env: Env, msg: Reply, exec_state: &mut ExecutionState, - plan: &RoutePlan, ) -> Result, ContractError> { + if msg.result.is_err() { + return Err(ContractError::ConversionFailed { + awaiting_state: "PathConversion".to_string(), + error: msg.result.unwrap_err(), + }); + } + let master_reply_id = msg.id; - let converted_amount = parse_amount_from_conversion_reply(&msg, &env)?; + let events = &msg.result.into_result().unwrap().events; + let converted_amount = parse_amount_from_conversion_reply(events, &env)?; let pending_op_details = exec_state.pending_path_op.take().ok_or_else(|| { StdError::generic_err("Path conversion state is invalid: no pending operation found") })?; - let current_stage = plan + let current_stage = exec_state + .plan .stages .get(exec_state.current_stage_index as usize) .unwrap(); @@ -812,7 +830,7 @@ fn handle_path_conversion_reply( let sub_msg = SubMsg::reply_on_success(swap_msg, submsg_id); exec_state.awaiting = Awaiting::Swaps; - EXECUTION_STATES.save(deps.storage, master_reply_id, exec_state)?; + ACTIVE_ROUTES.save(deps.storage, master_reply_id, exec_state)?; Ok(Response::new() .add_submessage(sub_msg) diff --git a/contracts/dex_aggregator/src/state.rs b/contracts/dex_aggregator/src/state.rs index b5cb91a..16abd8e 100644 --- a/contracts/dex_aggregator/src/state.rs +++ b/contracts/dex_aggregator/src/state.rs @@ -38,6 +38,7 @@ pub struct RoutePlan { #[cw_serde] pub struct ExecutionState { + pub plan: RoutePlan, pub awaiting: Awaiting, pub current_stage_index: u64, pub replies_expected: u64, @@ -53,7 +54,6 @@ pub struct SubmsgReplyState { pub op_index: usize, } -pub const ROUTE_PLANS: Map = Map::new("route_plans"); -pub const EXECUTION_STATES: Map = Map::new("execution_states"); +pub const ACTIVE_ROUTES: Map = Map::new("execution_states"); pub const SUBMSG_REPLY_STATES: Map = Map::new("submsg_reply_states"); pub const REPLY_ID_COUNTER: Item = Item::new("reply_id_counter"); diff --git a/contracts/mock_swap/Cargo.toml b/contracts/mock_swap/Cargo.toml index 9734253..f283f65 100644 --- a/contracts/mock_swap/Cargo.toml +++ b/contracts/mock_swap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mock_swap" -version = "0.1.0" +version = "1.0.0" edition = "2021" [lib] diff --git a/readme.md b/readme.md index fcb3cd1..e46e44f 100644 --- a/readme.md +++ b/readme.md @@ -2,9 +2,9 @@ ## Mainnet Deployment -Code Id: 1860 +Code Id: 1865 -Address: `inj1g5nuy2c6up2f2z0dqf4qayl9seeg7enq3xxaks` +Address: `inj186xc3ge5mvn8995063v30f8we5ncncpujcv28w` ## Getting Started @@ -150,7 +150,7 @@ pub struct ExecuteMsg::AggregateSwaps { /// The minimum amount of the *final* output token the user is willing to receive. /// If the final balance held by the contract is less than this, the transaction reverts. - pub minimum_receive: Option, + pub minimum_receive: Option, } pub struct Stage { diff --git a/tests/integration.rs b/tests/integration.rs index 5a0c240..3255aad 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -326,7 +326,7 @@ fn test_aggregate_swap_success() { }, ], }], - minimum_receive: Some("1910000000".to_string()), // Min 1910 USDT + minimum_receive: Some(Uint128::new(1910000000)), // Min 1910 USDT }; let res = wasm.execute( @@ -443,7 +443,7 @@ fn test_multi_stage_aggregate_swap_success() { }, ], // The minimum we expect from summing the Stage 2 outputs. - minimum_receive: Some("1500000000000".to_string()), // 1,500,000 USDT + minimum_receive: Some(Uint128::new(1500000000000)), // 1,500,000 USDT }; // The initial funds for this route are 1,000,000 USDT @@ -1020,7 +1020,7 @@ fn test_full_normalization_route() { }], }, ], - minimum_receive: Some("97000000".to_string()), // 97 SAI + minimum_receive: Some(Uint128::new(97000000)), // 97 SAI }; let res = wasm.execute( @@ -1109,7 +1109,7 @@ fn test_multi_stage_with_final_normalization() { }, ], // The final expected output is unified CW20 SHROOM - minimum_receive: Some("9900000000".to_string()), // Min 9,900 CW20 SHROOM + minimum_receive: Some(Uint128::new(9900000000)), // Min 9,900 CW20 SHROOM }; // The user initiates the swap with 1,000 USDT @@ -1193,7 +1193,7 @@ fn test_cw20_entry_point_swap_success() { })], }], }], - minimum_receive: Some("99000000".to_string()), // Min 99 SAI + minimum_receive: Some(Uint128::new(99000000)), // Min 99 SAI }; let res = wasm.execute( @@ -1288,7 +1288,7 @@ fn test_reverse_normalization_route() { }], }, ], - minimum_receive: Some("495000000".to_string()), // Min 495 USDT + minimum_receive: Some(Uint128::new(495000000)), // Min 495 USDT }; let initial_balance = bank @@ -1389,7 +1389,7 @@ fn test_failure_if_minimum_receive_not_met() { ], }], - minimum_receive: Some("1920000001".to_string()), + minimum_receive: Some(Uint128::new(1920000001)), }; let funds_to_send = Coin::new(100_000_000_000_000_000_000u128, "inj"); @@ -1576,7 +1576,7 @@ fn test_mixed_input_unified_output_reconciliation() { }; let msg = ExecuteMsg::ExecuteRoute { - minimum_receive: Some("459000000".to_string()), // Min 459 USDT (Target is 460) + minimum_receive: Some(Uint128::new(459000000)), // Min 459 USDT (Target is 460) stages: vec![stage1, stage2], }; @@ -1686,7 +1686,7 @@ fn test_cw20_input_with_initial_reconciliation() { // The hook message sent with the CW20 token let hook_msg = Cw20HookMsg::ExecuteRoute { - minimum_receive: Some("469000000".to_string()), // Min 469 USDT (Target is 470) + minimum_receive: Some(Uint128::new(469000000)), // Min 469 USDT (Target is 470) stages: vec![stage1], }; @@ -1815,7 +1815,7 @@ fn test_complex_reconciliation_mixed_to_mixed() { }; let msg = ExecuteMsg::ExecuteRoute { - minimum_receive: Some("424000000".to_string()), // Min 424 USDT (Target is 425) + minimum_receive: Some(Uint128::new(424000000)), // Min 424 USDT (Target is 425) stages: vec![stage1, stage2], }; @@ -1902,7 +1902,7 @@ fn test_final_output_is_cw20_token() { }; let msg = ExecuteMsg::ExecuteRoute { - minimum_receive: Some("99000000".to_string()), // Min 99 SAI (Target is 100) + minimum_receive: Some(Uint128::new(99000000)), // Min 99 SAI (Target is 100) stages: vec![stage1, stage2], }; @@ -2019,7 +2019,7 @@ fn test_native_input_with_initial_cw20_requirement() { }; let msg = ExecuteMsg::ExecuteRoute { - minimum_receive: Some("99000000".to_string()), // Min 99 SAI (Target is 100) + minimum_receive: Some(Uint128::new(99000000)), // Min 99 SAI (Target is 100) stages: vec![stage1], }; @@ -2179,7 +2179,7 @@ fn test_stage_with_single_hundred_percent_split() { let msg = ExecuteMsg::ExecuteRoute { stages: vec![stage1, stage2], - minimum_receive: Some("99000000000000000000".to_string()), // Min 99 INJ + minimum_receive: Some(Uint128::new(99000000000000000000)), // Min 99 INJ }; let funds_to_send = Coin::new(100_000_000_000_000_000_000u128, "inj"); // 100 INJ @@ -2366,7 +2366,7 @@ fn test_fee_collection_on_single_swap() { })], }], }], - minimum_receive: Some("996000000".to_string()), // Min 996 USDT + minimum_receive: Some(Uint128::new(996000000)), // Min 996 USDT }; let initial_collector_balance_res = bank @@ -2502,7 +2502,7 @@ fn test_fee_collection_on_cw20_output() { let msg = ExecuteMsg::ExecuteRoute { stages: vec![stage1], - minimum_receive: Some("984000000".to_string()), // Min 984 SHROOM + minimum_receive: Some(Uint128::new(984000000)), // Min 984 SHROOM }; // Execute the transaction @@ -2812,7 +2812,7 @@ fn test_multi_split_with_mixed_fees() { let msg = ExecuteMsg::ExecuteRoute { stages: vec![stage1], - minimum_receive: Some("1595000000".to_string()), // Min 1595 USDT + minimum_receive: Some(Uint128::new(1595000000)), // Min 1595 USDT }; // Execute the transaction @@ -3107,7 +3107,7 @@ fn test_multi_hop_path_with_mid_path_conversion() { path, // Use the complex path }], }], - minimum_receive: Some("49000000000000000000".to_string()), // Min 49 INJ + minimum_receive: Some(Uint128::new(49000000000000000000)), // Min 49 INJ }; let funds_to_send = Coin::new(10_000_000_000_000_000_000u128, "inj"); // 10 INJ @@ -3310,3 +3310,240 @@ fn test_emergency_withdraw() { .unwrap(); assert_eq!(contract_shroom_balance.balance, Uint128::zero()); } + +#[test] +fn test_multi_split_to_same_orderbook_contract() { + let env = setup(); + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + // --- SCENARIO --- + // This test ensures the aggregator can correctly handle a route where multiple + // parallel operations (splits) are sent to the exact same contract address. + // + // ROUTE: + // Input: 100 INJ + // Split 1 (40%): 40 INJ -> Mock OB @ 30.0 = 1,200 USDT + // Split 2 (60%): 60 INJ -> Mock OB @ 30.0 = 1,800 USDT + // Total Expected Output: 3,000 USDT + + // Define the single orderbook contract that both splits will use. + let shared_orderbook_contract = env.mock_ob_inj_usdt_addr.clone(); + + // Define the message for the route execution. + let msg = ExecuteMsg::ExecuteRoute { + stages: vec![Stage { + splits: vec![ + Split { + percent: 40, + path: vec![Operation::OrderbookSwap(OrderbookSwapOp { + swap_contract: shared_orderbook_contract.clone(), + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + // Tick size from the generic setup + min_quantity_tick_size: Uint128::new(1_000_000_000_000_000), + })], + }, + Split { + percent: 60, + path: vec![Operation::OrderbookSwap(OrderbookSwapOp { + swap_contract: shared_orderbook_contract.clone(), + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + min_quantity_tick_size: Uint128::new(1_000_000_000_000_000), + })], + }, + ], + }], + minimum_receive: Some(Uint128::new(2990_000_000)), // Min 2990 USDT + }; + + // Get user's initial USDT balance for final assertion. + let initial_usdt_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.user.address(), + denom: "usdt".to_string(), + }) + .unwrap() + .balance + .unwrap(); + let initial_usdt_amount = Uint128::from_str(&initial_usdt_balance.amount).unwrap(); + + // Execute the transaction with 100 INJ. + let funds_to_send = Coin::new(100_000_000_000_000_000_000u128, "inj"); + let res = wasm.execute( + &env.aggregator_addr, + &msg, + &[funds_to_send], + &env.user, + ); + + assert!(res.is_ok(), "Execution failed: {:?}", res.unwrap_err()); + let response = res.unwrap(); + + // --- ASSERTIONS --- + + // 1. Assert the total received amount from the event log. + 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 in reply"); + + let total_received_attr = success_event + .attributes + .iter() + .find(|a| a.key == "final_received") + .unwrap(); + + // Assert the total expected output is 3000 USDT (3000 * 10^6). + let expected_total_output = "3000000000"; + assert_eq!(total_received_attr.value, expected_total_output); + + // 2. Assert the user's final bank balance is correct. + let final_balance_response = bank + .query_balance(&QueryBalanceRequest { + address: env.user.address(), + denom: "usdt".to_string(), + }) + .unwrap(); + let final_balance = final_balance_response.balance.unwrap(); + + let expected_final_balance = initial_usdt_amount + Uint128::from_str(expected_total_output).unwrap(); + let final_amount = Uint128::from_str(&final_balance.amount).unwrap(); + + assert_eq!(final_amount, expected_final_balance); + assert_eq!(final_balance.denom, "usdt"); +} + +#[test] +fn test_multi_hop_consecutive_orderbook_swaps() { + let env = setup(); + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + // --- SCENARIO --- + // Hop 1: 100 INJ -> Mock OB 1 (rate 30.0) = 3,000 USDT + // Hop 2: 3,000 USDT -> Mock OB 2 (rate 0.1) = 300 INJ + // Final Expected Output: 300 INJ. + + let path = vec![ + Operation::OrderbookSwap(OrderbookSwapOp { + swap_contract: env.mock_ob_inj_usdt_addr.clone(), + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + min_quantity_tick_size: Uint128::new(1_000_000_000_000_000), + }), + Operation::OrderbookSwap(OrderbookSwapOp { + swap_contract: env.mock_ob_usdt_inj_addr.clone(), + ask_asset_info: amm::AssetInfo::NativeToken { + denom: "inj".to_string(), + }, + offer_asset_info: amm::AssetInfo::NativeToken { + denom: "usdt".to_string(), + }, + min_quantity_tick_size: Uint128::new(10000), + }), + ]; + + let msg = ExecuteMsg::ExecuteRoute { + stages: vec![Stage { + splits: vec![Split { + percent: 100, + path, + }], + }], + minimum_receive: Some(Uint128::new(299_000_000_000_000_000_000u128)), + }; + + let initial_inj_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.user.address(), + denom: "inj".to_string(), + }) + .unwrap() + .balance + .unwrap(); + let initial_inj_amount = Uint128::from_str(&initial_inj_balance.amount).unwrap(); + + let funds_to_send = Coin::new(100_000_000_000_000_000_000u128, "inj"); + let res = wasm.execute( + &env.aggregator_addr, + &msg, + &[funds_to_send.clone()], + &env.user, + ); + assert!(res.is_ok(), "Execution failed: {:?}", res.unwrap_err()); + let response = res.unwrap(); // Keep the response to check events + + // --- ASSERTIONS --- + + // 1. Assert the event log for the correct, deterministic output amount. + // This confirms the contract's logic is correct, regardless of gas fees. + 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 final aggregate_swap_complete event"); + + let final_received_attr = success_event + .attributes + .iter() + .find(|a| a.key == "final_received") + .unwrap(); + + let expected_swap_output = Uint128::new(300_000_000_000_000_000_000u128); // 300 INJ + assert_eq!(final_received_attr.value, expected_swap_output.to_string()); + + // 2. Assert the user's final bank balance, accounting for gas fees. + let final_inj_balance_response = bank + .query_balance(&QueryBalanceRequest { + address: env.user.address(), + denom: "inj".to_string(), + }) + .unwrap(); + let final_inj_balance = final_inj_balance_response.balance.unwrap(); + let final_amount = Uint128::from_str(&final_inj_balance.amount).unwrap(); + + // Calculate the "perfect world" final balance (without gas costs). + let expected_final_amount_sans_gas = initial_inj_amount + .checked_sub(funds_to_send.amount) + .unwrap() + .checked_add(expected_swap_output) + .unwrap(); + + // The actual final amount must be less than the perfect amount because of gas. + assert!( + final_amount < expected_final_amount_sans_gas, + "Final amount should be less than the ideal amount due to gas fees" + ); + + // As a sanity check, ensure the balance still increased overall as this was a profitable swap. + // The net gain was 200 INJ, so the final balance should be well above the initial. + assert!( + final_amount > initial_inj_amount, + "Final amount should be greater than the initial amount for this profitable swap" + ); +} \ No newline at end of file