diff --git a/Cargo.lock b/Cargo.lock index 47cc741..e0068de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + [[package]] name = "anyhow" version = "1.0.86" @@ -798,6 +804,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "drop-factory" version = "1.0.0" @@ -977,6 +989,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + [[package]] name = "generic-array" version = "0.14.7" @@ -1115,6 +1133,12 @@ dependencies = [ "signature", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.155" @@ -1140,6 +1164,33 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "neutron-proto" version = "0.1.1" @@ -1295,6 +1346,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -1398,6 +1475,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "pryzm-std" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f30262f6c45bc128cb866a7c26f5ff9137e090eda1a29780eec48d32a2f51d" +dependencies = [ + "chrono", + "cosmwasm-std", + "prost 0.12.6", + "prost-types 0.12.6", + "pryzm-std-derive", + "schemars", + "serde", + "serde-cw-value", +] + +[[package]] +name = "pryzm-std-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27015d082aa4edc995247d0f2fa81b53d6b4e116ed06e3532c162cd34d3bc010" +dependencies = [ + "itertools 0.10.5", + "proc-macro2", + "prost-types 0.11.9", + "quote", + "syn 1.0.109", +] + [[package]] name = "quote" version = "1.0.36" @@ -1791,6 +1897,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "skip-go-swap-adapter-pryzm" +version = "0.3.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "mockall", + "pryzm-std", + "serde", + "skip", + "test-case", + "thiserror", +] + [[package]] name = "skip-go-swap-adapter-white-whale" version = "0.3.0" @@ -1950,6 +2073,12 @@ dependencies = [ "time", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "test-case" version = "3.3.1" diff --git a/Cargo.toml b/Cargo.toml index 6a93dfe..ee9b8d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,10 +39,13 @@ ibc-proto = { version = "0.32.1", default-features = false } lido-satellite = { git = "https://github.com/hadronlabs-org/lido-satellite", branch = "main", features = ["library"] } drop-factory = { git = "https://github.com/hadronlabs-org/drop-contracts.git", branch = "feat/audit-fixes", features = ["library"] } drop-staking-base = { git = "https://github.com/hadronlabs-org/drop-contracts.git", branch = "feat/audit-fixes", features = ["library"] } +mockall = "0.12.1" neutron-proto = { version = "0.1.1", default-features = false, features = ["cosmwasm"] } neutron-sdk = "0.10.0" osmosis-std = "0.15.3" prost = "0.11" +pryzm-std = "0.1.7" +serde = { version = "1.0.194", default-features = false, features = ["derive"] } serde-cw-value = "0.7.0" serde-json-wasm = "1.0.1" skip = { version = "0.3.0", path = "./packages/skip" } diff --git a/contracts/adapters/swap/pryzm/Cargo.toml b/contracts/adapters/swap/pryzm/Cargo.toml new file mode 100644 index 0000000..13aa581 --- /dev/null +++ b/contracts/adapters/swap/pryzm/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "skip-go-swap-adapter-pryzm" +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +keywords = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +pryzm-std = { workspace = true } +skip = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +test-case = { workspace = true } +mockall = { workspace = true } +serde = { workspace = true } \ No newline at end of file diff --git a/contracts/adapters/swap/pryzm/README.md b/contracts/adapters/swap/pryzm/README.md new file mode 100644 index 0000000..88a6e4e --- /dev/null +++ b/contracts/adapters/swap/pryzm/README.md @@ -0,0 +1,333 @@ +# PRYZM Swap Adapter Contract + +The Pryzm swap adapter contract is responsible for: + +1. Taking the standardized entry point swap operations message format and converting it to the respective messages on + Pryzm. +2. Swapping on Pryzm's [AMM](https://docs.pryzm.zone/core/amm) or liquid staking on + Pryzm's [ICStaking](https://docs.pryzm.zone/core/icstaking) module. +3. Providing query methods that can be called by the entry point contract (generally, to any external actor) to simulate + multi-hop swaps that either specify an exact amount in (estimating how much would be received from the swap) or an + exact amount out (estimating how much is required to get the specified amount out). + +Note: Swap adapter contracts expect to be called by an entry point contract that provides basic validation and minimum +amount out safety guarantees for the caller. There are no slippage guarantees provided by swap adapter contracts. + +WARNING: Do not send funds directly to the contract without calling one of its functions. Funds sent directly to the +contract do not trigger any contract logic that performs validation / safety checks (as the Cosmos SDK handles direct +fund transfers in the `Bank` module and not the `Wasm` module). There are no explicit recovery mechanisms for +accidentally sent funds. + +## InstantiateMsg + +Instantiates a new Pryzm swap adapter contract. + +``` json +{ + "entry_point_contract_address": "" +} +``` + +## ExecuteMsg + +### `swap` + +Swaps the coin sent using the operations provided. + +Note: The `pool` string field provided in the operations must have the following format: + +* For AMM swap, it must be "amm:" appended with a valid `u64` pool id, i.e: `amm:1` +* For liquid staking, it must be "icstaking:" appended with a valid registered host chain id and the transfer channel, + i.e: `icstaking:uatom:channel-0` + +``` json +{ + "swap": { + "operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "denom_out": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" + }, + { + "pool": "icstaking:uatom:channel-0", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "c:uatom" + } + ] + } +} +``` + +### `transfer_funds_back` + +Transfers all contract funds to the address provided, called by the swap adapter contract to send back the entry point +contract the assets received from swapping. + +Note: This function can be called by anyone as the contract is assumed to have no balance before/after it's called by +the entry point contract. Do not send funds directly to this contract without calling a function. + +``` json +{ + "transfer_funds_back": { + "swapper": "pryzm...", + "return_denom": "c:uatom" + } +} +``` + +## QueryMsg + +### `simulate_swap_exact_asset_in` + +Returns the asset_out that would be received from swapping the `asset_in` specified in the call (swapped through +the `swap_operatons` provided) + +Query: + +``` json +{ + "simulate_swap_exact_asset_in": { + "asset_in": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "100" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + } +} +``` + +Response: + +``` json +{ + "denom": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "amount": "100000" +} +``` + +### `simulate_swap_exact_asset_out` + +Returns the asset_in required to receive the `asset_out` specified in the call (swapped through the `swap_operatons` +provided) + +Query: + +``` json +{ + "simulate_swap_exact_asset_out": { + "asset_out": { + "denom": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "amount": "100000" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + } +} +``` + +Response: + +``` json +{ + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "100" +} +``` + +### `simulate_swap_exact_asset_in_with_metadata` + +Similar to `simulate_swap_exact_asset_in`, but also includes swap spot price if requested. + +Query: + +``` json +{ + "simulate_swap_exact_asset_in_with_metadata": { + "asset_in": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "100" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + } +} +``` + +Response: + +``` json +{ + "denom": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "amount": "100000" +} +``` + +### `simulate_swap_exact_asset_out_with_metadata` + +Similar to `simulate_swap_exact_asset_out`, but also includes swap spot price if requested. + +Query: + +``` json +{ + "simulate_swap_exact_asset_out_with_metadata": { + "asset_out": { + "denom": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "amount": "100000" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + } +} +``` + +Response: + +``` json +{ + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "100" +} +``` + +### `simulate_smart_swap_exact_asset_in` + +Returns the asset_out that would be received from swapping an asset through multiple routes (the asset_in amount is +divided into multiple parts and each part is swapped using a different route) + +Query: + +``` json +{ + "simulate_swap_exact_asset_in": { + "asset_in": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "100" + }, + "routes": [ + { + "offer_asset": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "70" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + }, + { + "offer_asset": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "30" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "c:uatom" + }, + { + "pool": "amm:5", + "denom_in": "c:uatom", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + } + ] + } +} +``` + +Response: + +``` json +{ + "denom": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "amount": "100000" +} +``` + +### `simulate_smart_swap_exact_asset_in_with_metadata` + +Similar to `simulate_smart_swap_exact_asset_in`, but also return the swap weighted spot price if requested. + +Query: + +``` json +{ + "simulate_smart_swap_exact_asset_in_with_metadata": { + "asset_in": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "100" + }, + "routes": [ + { + "offer_asset": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "70" + }, + "swap_operations": [ + { + "pool": "amm:1", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + }, + { + "offer_asset": { + "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "amount": "30" + }, + "swap_operations": [ + { + "pool": "amm:3", + "denom_in": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + "denom_out": "c:uatom" + }, + { + "pool": "amm:5", + "denom_in": "c:uatom", + "denom_out": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4" + } + ] + } + ] + } +} +``` + +Response: + +``` json +{ + "denom": "ibc/987C17B11ABC2B20019178ACE62929FE9840202CE79498E29FE8E5CB02B7C0A4", + "amount": "100000", + "spot_price": "1000" +} +``` \ No newline at end of file diff --git a/contracts/adapters/swap/pryzm/src/bin/pryzm-schema.rs b/contracts/adapters/swap/pryzm/src/bin/pryzm-schema.rs new file mode 100644 index 0000000..4f4733f --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/bin/pryzm-schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use skip::swap::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg + } +} diff --git a/contracts/adapters/swap/pryzm/src/consts.rs b/contracts/adapters/swap/pryzm/src/consts.rs new file mode 100644 index 0000000..d27149d --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/consts.rs @@ -0,0 +1,8 @@ +// The pool prefix that identifies the swap operation as an AMM pool swap +pub const AMM_POOL_PREFIX: &str = "amm:"; + +// The pool prefix that identifies the swap operation as a liquid staking operation on Pryzm's icstaking module +pub const ICSTAKING_POOL_PREFIX: &str = "icstaking:"; + +// The prefix of the icstaking module's cAsset denominations +pub const C_ASSET_PREFIX: &str = "c:"; diff --git a/contracts/adapters/swap/pryzm/src/contract.rs b/contracts/adapters/swap/pryzm/src/contract.rs new file mode 100644 index 0000000..27c04b6 --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/contract.rs @@ -0,0 +1,308 @@ +use std::collections::VecDeque; + +use cosmwasm_std::{ + entry_point, to_json_binary, Addr, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Reply, + Response, SubMsg, SubMsgResponse, SubMsgResult, WasmMsg, +}; +use cw2::set_contract_version; +use cw_utils::one_coin; +use pryzm_std::types::pryzm::{amm::v1::MsgBatchSwapResponse, icstaking::v1::MsgStakeResponse}; + +use skip::swap::{ + execute_transfer_funds_back, get_ask_denom_for_routes, ExecuteMsg, InstantiateMsg, MigrateMsg, + QueryMsg, SwapOperation, +}; + +use crate::{ + error::{ContractError, ContractResult}, + execution::{extract_execution_steps, parse_coin, SwapExecutionStep}, + reply_id, + simulate::{ + simulate_smart_swap_exact_asset_in, simulate_smart_swap_exact_asset_in_with_metadata, + simulate_swap_exact_asset_in, simulate_swap_exact_asset_in_with_metadata, + simulate_swap_exact_asset_out, simulate_swap_exact_asset_out_with_metadata, + }, + state::{ENTRY_POINT_CONTRACT_ADDRESS, IN_PROGRESS_SWAP_OPERATIONS, IN_PROGRESS_SWAP_SENDER}, +}; + +/////////////// +/// MIGRATE /// +/////////////// + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> ContractResult { + unimplemented!() +} + +/////////////////// +/// INSTANTIATE /// +/////////////////// + +// Contract name and version used for migration. +const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> ContractResult { + // Set contract version + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // Validate entry point contract address + let checked_entry_point_contract_address = + deps.api.addr_validate(&msg.entry_point_contract_address)?; + + // Store the entry point contract address + ENTRY_POINT_CONTRACT_ADDRESS.save(deps.storage, &checked_entry_point_contract_address)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute( + "entry_point_contract_address", + checked_entry_point_contract_address.to_string(), + )) +} + +/////////////// +/// EXECUTE /// +/////////////// + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> ContractResult { + match msg { + ExecuteMsg::Swap { operations } => execute_swap(deps, env, info, operations), + ExecuteMsg::TransferFundsBack { + swapper, + return_denom, + } => Ok(execute_transfer_funds_back( + deps, + env, + info, + swapper, + return_denom, + )?), + _ => { + unimplemented!() + } + } +} + +// Executes a swap with the given swap operations and then transfers the funds back to the caller +fn execute_swap( + deps: DepsMut, + env: Env, + info: MessageInfo, + operations: Vec, +) -> ContractResult { + // Get entry point contract address from storage + let entry_point_contract_address = ENTRY_POINT_CONTRACT_ADDRESS.load(deps.storage)?; + + // Enforce the caller is the entry point contract + if info.sender != entry_point_contract_address { + return Err(ContractError::Unauthorized); + } + + // Get coin in from the message info, error if there is not exactly one coin sent + let coin_in = one_coin(&info)?; + + // Extract the execution steps from the provided swap operations + let execution_steps = extract_execution_steps(operations)?; + + // Execute the swap + execute_steps(deps, env, info.sender, coin_in, execution_steps) +} + +// Executes the swap of the provided coin using the provided execution steps for the swapper +fn execute_steps( + deps: DepsMut, + env: Env, + swapper: Addr, + coin_in: Coin, + execution_steps: VecDeque, +) -> ContractResult { + // return error if execution_steps is empty + if execution_steps.is_empty() { + return Err(ContractError::SwapOperationsEmpty); + } + + // convert the first execution step to the appropriate cosmos message + let first_step = execution_steps.front().unwrap(); + let msg = first_step + .clone() + .to_cosmos_msg(env.contract.address.to_string(), coin_in)?; + + // If there is only one execution step, create the transfer funds back message since the swap is done is a single step + if execution_steps.len() == 1 { + // Create the transfer funds back message + let return_denom = first_step.clone().get_return_denom()?; + let transfer_funds_back_msg = WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_json_binary(&ExecuteMsg::TransferFundsBack { + swapper, + return_denom, + })?, + funds: vec![], + }; + + return Ok(Response::new() + .add_message(msg.clone()) + .add_message(transfer_funds_back_msg) + .add_attribute("action", "dispatch_swap_and_transfer_back")); + } + + // if there are more than one step, create sub message for the first step + let sub_msg = match first_step { + SwapExecutionStep::Swap { .. } => { + SubMsg::reply_on_success(msg.clone(), reply_id::BATCH_SWAP_REPLY_ID) + } + SwapExecutionStep::Stake { .. } => { + SubMsg::reply_on_success(msg.clone(), reply_id::STAKE_REPLY_ID) + } + }; + + // store the steps and the swapper to continue after the current step is executed in the reply entrypoint + IN_PROGRESS_SWAP_OPERATIONS.save(deps.storage, &execution_steps)?; + IN_PROGRESS_SWAP_SENDER.save(deps.storage, &swapper)?; + + Ok(Response::new() + .add_submessage(sub_msg) + .add_attribute("action", "dispatch_swap_step")) +} + +///////////// +/// REPLY /// +///////////// + +// Handles the reply from the swap step execution messages +#[entry_point] +pub fn reply(deps: DepsMut, env: Env, reply: Reply) -> ContractResult { + // Get the sub message result from the reply + let SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) = reply.result else { + return Err(ContractError::InvalidState { + msg: "could not get sub message response from reply result".to_string(), + }); + }; + + // handle the reply and use the output of the swap as the coin_in for the next swap steps + let coin_in: Coin; + match reply.id { + reply_id::BATCH_SWAP_REPLY_ID => { + // Parse the batch swap response from the sub message + let resp: MsgBatchSwapResponse = b.try_into().map_err(ContractError::Std).unwrap(); + if resp.amounts_out.len() != 1 { + return Err(ContractError::InvalidMsgResponse { + msg: "unexpected amounts out length is batch swap response".to_string(), + }); + } + coin_in = parse_coin(resp.amounts_out.first().unwrap()) + } + reply_id::STAKE_REPLY_ID => { + // Parse the stake response from the sub message + let resp: MsgStakeResponse = b.try_into().map_err(ContractError::Std).unwrap(); + if let Some(c_amount) = resp.c_amount { + coin_in = parse_coin(&c_amount) + } else { + return Err(ContractError::InvalidMsgResponse { + msg: "expected valid c_amount in stake response, received None".to_string(), + }); + } + } + _ => { + return Err(ContractError::InvalidState { + msg: format!("unexpected reply id {}", reply.id), + }); + } + } + + // load the swap execution steps from the store + let mut in_progress_exec_steps = IN_PROGRESS_SWAP_OPERATIONS.load(deps.storage)?; + IN_PROGRESS_SWAP_OPERATIONS.remove(deps.storage); + + // load the swapper address from the store + let swapper = IN_PROGRESS_SWAP_SENDER.load(deps.storage)?; + IN_PROGRESS_SWAP_SENDER.remove(deps.storage); + + // remove the first step (which is already executed) + in_progress_exec_steps.pop_front(); + + // continue the swap execution with the next steps + execute_steps(deps, env, swapper, coin_in, in_progress_exec_steps) +} + +///////////// +/// QUERY /// +///////////// + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> ContractResult { + match msg { + QueryMsg::SimulateSwapExactAssetIn { + asset_in, + swap_operations, + } => to_json_binary(&simulate_swap_exact_asset_in( + deps, + asset_in, + swap_operations, + )?), + QueryMsg::SimulateSwapExactAssetOut { + asset_out, + swap_operations, + } => to_json_binary(&simulate_swap_exact_asset_out( + deps, + asset_out, + swap_operations, + )?), + QueryMsg::SimulateSwapExactAssetInWithMetadata { + asset_in, + swap_operations, + include_spot_price, + } => to_json_binary(&simulate_swap_exact_asset_in_with_metadata( + deps, + asset_in, + swap_operations, + include_spot_price, + )?), + QueryMsg::SimulateSwapExactAssetOutWithMetadata { + asset_out, + swap_operations, + include_spot_price, + } => to_json_binary(&simulate_swap_exact_asset_out_with_metadata( + deps, + asset_out, + swap_operations, + include_spot_price, + )?), + QueryMsg::SimulateSmartSwapExactAssetIn { routes, .. } => { + let ask_denom = get_ask_denom_for_routes(&routes)?; + + to_json_binary(&simulate_smart_swap_exact_asset_in( + deps, ask_denom, routes, + )?) + } + QueryMsg::SimulateSmartSwapExactAssetInWithMetadata { + routes, + asset_in, + include_spot_price, + } => { + let ask_denom = get_ask_denom_for_routes(&routes)?; + + to_json_binary(&simulate_smart_swap_exact_asset_in_with_metadata( + deps, + asset_in, + ask_denom, + routes, + include_spot_price, + )?) + } + } + .map_err(From::from) +} diff --git a/contracts/adapters/swap/pryzm/src/error.rs b/contracts/adapters/swap/pryzm/src/error.rs new file mode 100644 index 0000000..70d6899 --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/error.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::StdError; +use skip::error::SkipError; +use thiserror::Error; + +pub type ContractResult = Result; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Skip(#[from] SkipError), + + #[error(transparent)] + Payment(#[from] cw_utils::PaymentError), + + #[error(transparent)] + Overflow(#[from] cosmwasm_std::OverflowError), + + #[error(transparent)] + CheckedFromRatioError(#[from] cosmwasm_std::CheckedFromRatioError), + + #[error("Unauthorized")] + Unauthorized, + + #[error("provided pool string is not a valid swap route: {msg:?}")] + InvalidPool { msg: String }, + + #[error("swap_operations cannot be empty")] + SwapOperationsEmpty, + + #[error("coin_in denom must match the first swap operation's denom in")] + CoinInDenomMismatch, + + #[error("coin_out denom must match the last swap operation's denom out")] + CoinOutDenomMismatch, + + #[error("Asset Must Be Native, Pryzm Does Not Support CW20 Tokens")] + AssetNotNative, + + #[error("Unexpected message response received: {msg:?}")] + InvalidMsgResponse { msg: String }, + + #[error("Unexpected query response received: {msg:?}")] + InvalidQueryResponse { msg: String }, + + #[error("InvalidState: {msg:?}")] + InvalidState { msg: String }, +} diff --git a/contracts/adapters/swap/pryzm/src/execution.rs b/contracts/adapters/swap/pryzm/src/execution.rs new file mode 100644 index 0000000..26f84af --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/execution.rs @@ -0,0 +1,224 @@ +use crate::consts; +use crate::error::ContractError; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{coin, Coin, CosmosMsg, Uint128}; +use pryzm_std::types::cosmos::base::v1beta1::Coin as CosmosCoin; +use pryzm_std::types::pryzm::{ + amm::v1::{MsgBatchSwap, SwapStep, SwapType}, + icstaking::v1::MsgStake, +}; +use skip::swap::SwapOperation; +use std::collections::VecDeque; + +// SwapExecutionStep is an enum that represents the different types of swap operations that can be executed +#[cw_serde] +pub enum SwapExecutionStep { + // Swap represents a batch swap operation on the AMM module + Swap { + swap_steps: Vec, // the batch swap steps + }, + // Stake represents a liquid staking operation on Pryzm's icstaking module + Stake { + host_chain_id: String, // the host chain id for staking + transfer_channel: String, // the transfer channel of the tokens + }, +} + +impl SwapExecutionStep { + // Converts the step to the appropriate Pryzm message + pub fn to_cosmos_msg( + &self, + address: String, + coin_in: Coin, + ) -> Result { + match self { + SwapExecutionStep::Swap { swap_steps } => { + create_amm_swap_msg(swap_steps, address, coin_in) + } + SwapExecutionStep::Stake { + host_chain_id, + transfer_channel, + } => create_icstaking_stake_msg( + host_chain_id.clone(), + transfer_channel.clone(), + address, + coin_in, + ), + } + } + + // Returns the output denom of the step + pub fn get_return_denom(&self) -> Result { + return match self { + SwapExecutionStep::Swap { swap_steps } => { + // take the last step token_out as the return denom + let token_out = match swap_steps.last() { + Some(last_op) => last_op.token_out.clone(), + None => return Err(ContractError::SwapOperationsEmpty), + }; + Ok(token_out) + } + SwapExecutionStep::Stake { host_chain_id, .. } => { + // calculate the cAsset denom by prefixing "c:" to the host chain id + Ok(format!("{}{}", consts::C_ASSET_PREFIX, host_chain_id)) + } + }; + } +} + +// Iterates over the swap operations and aggregates the operations into execution steps +pub fn extract_execution_steps( + operations: Vec, +) -> Result, ContractError> { + // Return error if swap operations is empty + if operations.is_empty() { + return Err(ContractError::SwapOperationsEmpty); + } + + // Create a vector to push the steps into + let mut execution_steps: VecDeque = VecDeque::new(); + + // Create a vector to keep consecutive AMM operations in order to batch them into a single step + let mut amm_swap_steps: Vec = Vec::new(); + + // Iterate over the swap operations + let swap_operations_iter = operations.iter(); + for swap_op in swap_operations_iter { + if swap_op.pool.starts_with(consts::ICSTAKING_POOL_PREFIX) { + // Validate that the icstaking operation is converting an asset to a cAsset, + // not a cAsset to an asset which is not supported + if swap_op.denom_in.starts_with(consts::C_ASSET_PREFIX) + || !swap_op.denom_out.starts_with(consts::C_ASSET_PREFIX) + { + return Err(ContractError::InvalidPool { + msg: format!( + "icstaking swap operation can only convert an asset to cAsset: cannot convert {} to {}", + swap_op.denom_in, swap_op.denom_out + ) + }); + } + + // If there are AMM swap steps from before, aggregate and push them into the execution steps + if !amm_swap_steps.is_empty() { + execution_steps.push_back(SwapExecutionStep::Swap { + swap_steps: amm_swap_steps, + }); + amm_swap_steps = Vec::new(); + } + + // split and validate the pool string + let split: Vec<&str> = swap_op.pool.split(':').collect(); + if split.len() != 3 { + return Err(ContractError::InvalidPool { + msg: format!( + "icstaking pool string must be in the format \"icstaking::\": {}", + swap_op.pool + ) + }); + } + + // Push the staking operation into the execution steps + execution_steps.push_back(SwapExecutionStep::Stake { + host_chain_id: split.get(1).unwrap().to_string(), + transfer_channel: split.get(2).unwrap().to_string(), + }); + } else if swap_op.pool.starts_with(consts::AMM_POOL_PREFIX) { + // replace the pool prefix and parse the pool id + let pool_id = swap_op.pool.replace(consts::AMM_POOL_PREFIX, ""); + if let Ok(pool) = pool_id.parse() { + // Add the operation to the amm swap steps + amm_swap_steps.push(SwapStep { + pool_id: pool, + token_in: swap_op.denom_in.clone(), + token_out: swap_op.denom_out.clone(), + amount: None, + }); + } else { + return Err(ContractError::InvalidPool { + msg: format!("invalid amm pool id {}", pool_id), + }); + } + } else { + return Err(ContractError::InvalidPool { + msg: format!( + "pool must be started with \"amm\" or \"icstaking\": {}", + swap_op.pool + ), + }); + } + } + + // If there is any AMM swap steps left, push them into the execution steps + if !amm_swap_steps.is_empty() { + execution_steps.push_back(SwapExecutionStep::Swap { + swap_steps: amm_swap_steps, + }); + } + + Ok(execution_steps) +} + +// create Pryzm MsgBatchSwap using the provided swap steps +pub fn create_amm_swap_msg( + swap_steps: &[SwapStep], + address: String, + coin_in: Coin, +) -> Result { + // take the last step token_out as the return denom + let token_out = match swap_steps.last() { + Some(last_op) => last_op.token_out.clone(), + None => return Err(ContractError::SwapOperationsEmpty), + }; + + // set the amount_in on the first swap step + let mut steps = swap_steps.to_vec(); + if let Some(first_step) = steps.get_mut(0) { + first_step.amount = coin_in.amount.to_string().into(); + } + + // construct the message + let swap_msg: CosmosMsg = MsgBatchSwap { + creator: address, + swap_type: SwapType::GivenIn.into(), + max_amounts_in: vec![format_coin(coin_in)], + min_amounts_out: vec![CosmosCoin { + amount: "1".to_string(), + denom: token_out.to_string(), + }], + steps, + } + .into(); + + Ok(swap_msg) +} + +// create Pryzm MsgStake using the provided host chain and transfer channel +fn create_icstaking_stake_msg( + host_chain_id: String, + transfer_channel: String, + address: String, + coin_in: Coin, +) -> Result { + // Construct the message + let msg: CosmosMsg = MsgStake { + creator: address, + host_chain: host_chain_id, + transfer_channel, + amount: coin_in.amount.into(), + } + .into(); + + Ok(msg) +} + +pub fn parse_coin(c: &CosmosCoin) -> Coin { + let p: Uint128 = c.amount.parse().unwrap(); + coin(p.u128(), &c.denom) +} + +pub fn format_coin(c: Coin) -> CosmosCoin { + CosmosCoin { + amount: c.amount.to_string(), + denom: c.denom, + } +} diff --git a/contracts/adapters/swap/pryzm/src/lib.rs b/contracts/adapters/swap/pryzm/src/lib.rs new file mode 100644 index 0000000..4c71d8e --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/lib.rs @@ -0,0 +1,7 @@ +pub mod consts; +pub mod contract; +pub mod error; +pub mod execution; +pub mod reply_id; +pub mod simulate; +pub mod state; diff --git a/contracts/adapters/swap/pryzm/src/reply_id.rs b/contracts/adapters/swap/pryzm/src/reply_id.rs new file mode 100644 index 0000000..f3877f1 --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/reply_id.rs @@ -0,0 +1,5 @@ +// The reply id for handling amm module's batch swap message +pub const BATCH_SWAP_REPLY_ID: u64 = 1; + +// The reply id for handling icstaking module's staking message +pub const STAKE_REPLY_ID: u64 = 2; diff --git a/contracts/adapters/swap/pryzm/src/simulate.rs b/contracts/adapters/swap/pryzm/src/simulate.rs new file mode 100644 index 0000000..c896a34 --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/simulate.rs @@ -0,0 +1,363 @@ +use std::str::FromStr; + +use cosmwasm_std::{Decimal, Deps, Uint128}; +use pryzm_std::types::pryzm::amm::v1::{ + AmmQuerier, QuerySimulateBatchSwapResponse, QuerySpotPriceResponse, SwapType, +}; +use pryzm_std::types::pryzm::icstaking::v1::{IcstakingQuerier, QuerySimulateStakeResponse}; + +use skip::asset::Asset; +use skip::swap::{ + Route, SimulateSmartSwapExactAssetInResponse, SimulateSwapExactAssetInResponse, + SimulateSwapExactAssetOutResponse, SwapOperation, +}; + +use crate::error::{ContractError, ContractResult}; +use crate::execution::{extract_execution_steps, parse_coin, SwapExecutionStep}; + +// Simulates a swap given the exact amount in +pub fn simulate_swap_exact_asset_in( + deps: Deps, + asset_in: Asset, + swap_operations: Vec, +) -> ContractResult { + // Error if swap operations is empty + let Some(first_op) = swap_operations.first() else { + return Err(ContractError::SwapOperationsEmpty); + }; + + // Get coin in from asset in, error if asset in is not a + // native coin because Pryzm does not support CW20 tokens. + let coin_in = match asset_in { + Asset::Native(coin) => coin, + _ => return Err(ContractError::AssetNotNative), + }; + + // Ensure coin_in's denom is the same as the first swap operation's denom in + if coin_in.denom != first_op.denom_in { + return Err(ContractError::CoinInDenomMismatch); + } + + // instantiate module queriers + let amm_querier = &AmmQuerier::new(&deps.querier); + let icstaking_querier = &IcstakingQuerier::new(&deps.querier); + + // Extract the execution steps from the provided swap operations + let execution_steps = extract_execution_steps(swap_operations)?; + + // Iterate over steps and simulate the step given the output of the last step + // The first step uses the coin_in as input + let mut step_amount = coin_in; + for step in execution_steps { + match step { + SwapExecutionStep::Swap { swap_steps } => { + // Set the amount on the first step of the batch swap + let mut vec = swap_steps.clone(); + if let Some(first_step) = vec.first_mut() { + first_step.amount = step_amount.amount.to_string().into(); + } + // execute the simulation query on the amm module + let res: QuerySimulateBatchSwapResponse = + amm_querier.simulate_batch_swap(SwapType::GivenIn.into(), vec)?; + if res.amounts_out.len() != 1 { + return Err(ContractError::InvalidQueryResponse { + msg: "unexpected amounts out length in batch swap simulation".to_string(), + }); + } + // set the output of the simulation as the input for the next step + step_amount = parse_coin(res.amounts_out.first().unwrap()); + } + SwapExecutionStep::Stake { + host_chain_id, + transfer_channel, + } => { + // execute the simulation query on the icstaking module + let res: QuerySimulateStakeResponse = icstaking_querier.simulate_stake( + host_chain_id, + transfer_channel, + step_amount.amount.to_string().into(), + None, + )?; + if let Some(amount_out) = res.amount_out { + // set the output of the simulation as the input for the next step + step_amount = parse_coin(&amount_out); + } else { + return Err(ContractError::InvalidQueryResponse { + msg: "unexpected amount_out in liquid staking simulation".to_string(), + }); + } + } + } + } + + // return the last step output as the result of the simulation + Ok(Asset::from(step_amount)) +} + +// Simulates a swap given the exact amount in, include spot price if requested +pub fn simulate_swap_exact_asset_in_with_metadata( + deps: Deps, + asset_in: Asset, + swap_operations: Vec, + include_spot_price: bool, +) -> ContractResult { + // simulate the swap + let mut response = SimulateSwapExactAssetInResponse { + asset_out: simulate_swap_exact_asset_in(deps, asset_in, swap_operations.clone())?, + spot_price: None, + }; + + // calculate and include spot price if requested + if include_spot_price { + response.spot_price = Some(calculate_spot_price(deps, swap_operations)?) + } + + Ok(response) +} + +// Simulates a swap given exact amount out +pub fn simulate_swap_exact_asset_out( + deps: Deps, + asset_out: Asset, + swap_operations: Vec, +) -> ContractResult { + // Error if swap operations is empty + let Some(last_op) = swap_operations.last() else { + return Err(ContractError::SwapOperationsEmpty); + }; + + // Get coin out from asset out, error if asset out is not a + // native coin because Pryzm does not support CW20 tokens. + let coin_out = match asset_out { + Asset::Native(coin) => coin, + _ => return Err(ContractError::AssetNotNative), + }; + + // Ensure coin_out's denom is the same as the last swap operation's denom out + if coin_out.denom != last_op.denom_out { + return Err(ContractError::CoinOutDenomMismatch); + } + + // instantiate module queriers + let amm_querier = &AmmQuerier::new(&deps.querier); + let icstaking_querier = &IcstakingQuerier::new(&deps.querier); + + // Iterate over steps starting from the last step and simulate the step given the result of the + // last step. The first step uses the coin_out as input + let mut step_amount = coin_out; + let execution_steps = extract_execution_steps(swap_operations)?; + let reverse_iter = execution_steps.into_iter().rev(); + for step in reverse_iter { + match step { + SwapExecutionStep::Swap { swap_steps } => { + // make the swap steps reversed and set the amount on the first step + let mut vec = swap_steps.clone(); + vec.reverse(); + if let Some(first_step) = vec.first_mut() { + first_step.amount = step_amount.amount.to_string().into(); + } + // execute the simulation query on the amm module + let res: QuerySimulateBatchSwapResponse = + amm_querier.simulate_batch_swap(SwapType::GivenOut.into(), vec)?; + // set the output of the simulation as the input for the next step + if res.amounts_in.len() != 1 { + return Err(ContractError::InvalidQueryResponse { + msg: "unexpected amounts in length in batch swap simulation".to_string(), + }); + } + step_amount = parse_coin(res.amounts_in.first().unwrap()); + } + SwapExecutionStep::Stake { + host_chain_id, + transfer_channel, + } => { + // execute the simulation query on the icstaking module + let res: QuerySimulateStakeResponse = icstaking_querier.simulate_stake( + host_chain_id, + transfer_channel, + None, + step_amount.amount.to_string().into(), + )?; + if let Some(amount_in) = res.amount_in { + // set the output of the simulation as the input for the next step + step_amount = parse_coin(&amount_in); + } else { + return Err(ContractError::InvalidQueryResponse { + msg: "unexpected amount_in in liquid staking simulation".to_string(), + }); + } + } + } + } + + // return the last step output as the result of the simulation + Ok(Asset::from(step_amount)) +} + +// Simulates a swap given exact amount out, include spot price if requested +pub fn simulate_swap_exact_asset_out_with_metadata( + deps: Deps, + asset_out: Asset, + swap_operations: Vec, + include_spot_price: bool, +) -> ContractResult { + // simulate the swap + let mut response = SimulateSwapExactAssetOutResponse { + asset_in: simulate_swap_exact_asset_out(deps, asset_out, swap_operations.clone())?, + spot_price: None, + }; + + // calculate and include spot price if requested + if include_spot_price { + response.spot_price = Some(calculate_spot_price(deps, swap_operations)?) + } + + Ok(response) +} + +// Simulates a smart swap given the exact amount in +pub fn simulate_smart_swap_exact_asset_in( + deps: Deps, + ask_denom: String, + routes: Vec, +) -> ContractResult { + // initialize the total output with zero value + let mut asset_out = Asset::new(deps.api, &ask_denom, Uint128::zero()); + + // Iterate over routes and simulate the swap for each route + for route in &routes { + let route_asset_out = simulate_swap_exact_asset_in( + deps, + route.offer_asset.clone(), + route.operations.clone(), + )?; + + // add the output of swap using this route to the total output + asset_out.add(route_asset_out.amount())?; + } + + Ok(asset_out) +} + +// Simulates a smart swap given the exact amount in, and includes spot price if requested +pub fn simulate_smart_swap_exact_asset_in_with_metadata( + deps: Deps, + asset_in: Asset, + ask_denom: String, + routes: Vec, + include_spot_price: bool, +) -> ContractResult { + // simulate the swap + let asset_out = simulate_smart_swap_exact_asset_in(deps, ask_denom, routes.clone())?; + + // instantiate the response + let mut response = SimulateSmartSwapExactAssetInResponse { + asset_out, + spot_price: None, + }; + + // calculate and include weighted spot price if requested + if include_spot_price { + response.spot_price = Some(calculate_weighted_spot_price(deps, asset_in, routes)?) + } + + Ok(response) +} + +// Calculate the spot price for the swap +fn calculate_spot_price( + deps: Deps, + swap_operations: Vec, +) -> ContractResult { + // Extract the execution steps from the provided swap operations + let execution_steps = extract_execution_steps(swap_operations)?; + + // instantiate module queriers + let amm_querier = &AmmQuerier::new(&deps.querier); + let icstaking_querier = &IcstakingQuerier::new(&deps.querier); + + // iterate over execution steps, calculate spot price for each step and multiply all spot prices + let spot_price = execution_steps.into_iter().try_fold( + Decimal::one(), + |curr_spot_price, step| -> ContractResult { + let step_spot_price = match step { + SwapExecutionStep::Swap { swap_steps } => swap_steps.into_iter().try_fold( + Decimal::one(), + |curr_spot_price, step| -> ContractResult { + // spot price for a Swap step can be queried from amm module + let spot_price_res: QuerySpotPriceResponse = amm_querier.spot_price( + step.pool_id, + step.token_in, + step.token_out, + false, + )?; + // parse the result and multiply the spot price with the current value + if let Ok(spot_price) = Decimal::from_str(&spot_price_res.spot_price) { + Ok(curr_spot_price.checked_mul(spot_price)?) + } else { + Err(ContractError::InvalidQueryResponse { + msg: "invalid spot price in amm spot price query".to_string(), + }) + } + }, + ), + SwapExecutionStep::Stake { + host_chain_id, + transfer_channel, + } => { + // calculate spot price for liquid staking, by simulating stake for an amount + let amount = Decimal::from_str("1000000000000000000")?; // 1e18 + let res: QuerySimulateStakeResponse = icstaking_querier.simulate_stake( + host_chain_id, + transfer_channel, + amount.to_string().into(), + None, + )?; + // calculate the spot price by dividing the output of staking by the input amount + if let Some(amount_out) = res.amount_out { + if let Ok(output) = Decimal::from_str(&amount_out.amount) { + Ok(output.checked_div(amount)?) + } else { + Err(ContractError::InvalidQueryResponse { + msg: "invalid amount for amount_out coin in staking simulation response".to_string(), + }) + } + } else { + return Err(ContractError::InvalidQueryResponse { + msg: "unexpected amount_out in liquid staking simulation".to_string(), + }); + } + } + }; + + Ok(curr_spot_price.checked_mul(step_spot_price?)?) + }, + )?; + + Ok(spot_price) +} + +// Calculate weighted spot price for a set of routes +fn calculate_weighted_spot_price( + deps: Deps, + asset_in: Asset, + routes: Vec, +) -> ContractResult { + // iterate over the routes, calculate each route spot price, and multiply them based on the + // weight of that route + let spot_price = routes.into_iter().try_fold( + Decimal::zero(), + |curr_spot_price, route| -> ContractResult { + // calculate route's spot price + let route_spot_price = calculate_spot_price(deps, route.operations)?; + + // calculate the weight of the route, which is equal to the ratio of amount swapped on + // the route to the total amount being swapped + let weight = Decimal::from_ratio(route.offer_asset.amount(), asset_in.amount()); + + Ok(curr_spot_price + (route_spot_price * weight)) + }, + )?; + + Ok(spot_price) +} diff --git a/contracts/adapters/swap/pryzm/src/state.rs b/contracts/adapters/swap/pryzm/src/state.rs new file mode 100644 index 0000000..cffa0ee --- /dev/null +++ b/contracts/adapters/swap/pryzm/src/state.rs @@ -0,0 +1,13 @@ +use crate::execution::SwapExecutionStep; +use cosmwasm_std::Addr; +use cw_storage_plus::Item; +use std::collections::VecDeque; + +pub const ENTRY_POINT_CONTRACT_ADDRESS: Item = Item::new("entry_point_contract_address"); + +// stores the list of operations of the in progress swap, used by the reply entrypoint +pub const IN_PROGRESS_SWAP_OPERATIONS: Item> = + Item::new("in_progress_swap_operations"); + +// stores the address of the swapper for the in progress swap, used by the reply entrypoint +pub const IN_PROGRESS_SWAP_SENDER: Item = Item::new("in_progress_swap_sender"); diff --git a/contracts/adapters/swap/pryzm/tests/mock/mod.rs b/contracts/adapters/swap/pryzm/tests/mock/mod.rs new file mode 100644 index 0000000..d11d0c4 --- /dev/null +++ b/contracts/adapters/swap/pryzm/tests/mock/mod.rs @@ -0,0 +1,45 @@ +use cosmwasm_std::{ + to_json_binary, to_json_vec, ContractResult, Querier as CWStdQuerier, QuerierResult, + SystemResult, +}; +use cosmwasm_std::{Empty, QueryRequest}; +use mockall::predicate::*; +use mockall::*; +use serde::Serialize; + +mock! { + StdQuerier {} + impl CWStdQuerier for StdQuerier { + fn raw_query(&self, bin_request: &[u8]) -> QuerierResult; + } +} + +pub struct MockQuerier { + inner_mock: MockStdQuerier, +} + +impl CWStdQuerier for MockQuerier { + fn raw_query(&self, bin_request: &[u8]) -> QuerierResult { + self.inner_mock.raw_query(bin_request) + } +} + +impl MockQuerier { + pub fn new() -> Self { + Self { + inner_mock: MockStdQuerier::new(), + } + } + + pub fn mock_query(&mut self, request: QueryRequest, response: &T) + where + T: Serialize + ?Sized, + { + self.inner_mock + .expect_raw_query() + .with(eq(to_json_vec(&request).unwrap())) + .return_const(SystemResult::Ok(ContractResult::Ok( + to_json_binary(response).unwrap(), + ))); + } +} diff --git a/contracts/adapters/swap/pryzm/tests/test_execute_reply.rs b/contracts/adapters/swap/pryzm/tests/test_execute_reply.rs new file mode 100644 index 0000000..e9299cc --- /dev/null +++ b/contracts/adapters/swap/pryzm/tests/test_execute_reply.rs @@ -0,0 +1,274 @@ +use std::collections::VecDeque; + +#[allow(unused_imports)] +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + to_json_binary, Addr, Binary, Coin, + ReplyOn::Never, + ReplyOn::Success, + SubMsg, WasmMsg, +}; +use cosmwasm_std::{Reply, SubMsgResponse, SubMsgResult}; +#[allow(unused_imports)] +use pryzm_std::types::{ + cosmos::base::v1beta1::Coin as CosmosCoin, + pryzm::amm::v1::{MsgBatchSwap, MsgBatchSwapResponse, SwapStep, SwapType}, + pryzm::icstaking::v1::{MsgStake, MsgStakeResponse}, +}; +use test_case::test_case; + +#[allow(unused_imports)] +use skip::swap::{ExecuteMsg, SwapOperation}; +use skip_go_swap_adapter_pryzm::execution::SwapExecutionStep; +use skip_go_swap_adapter_pryzm::state::{IN_PROGRESS_SWAP_OPERATIONS, IN_PROGRESS_SWAP_SENDER}; +#[allow(unused_imports)] +use skip_go_swap_adapter_pryzm::{ + contract, error::ContractResult, reply_id::BATCH_SWAP_REPLY_ID, reply_id::STAKE_REPLY_ID, + state::ENTRY_POINT_CONTRACT_ADDRESS, +}; + +/* +Test Cases: + +Expect Success + - One-Step Left + - Many Steps Left + + */ + +// Define test parameters +struct Params { + swapper: String, + reply_id: u64, + swap_steps: Vec, + response: Binary, + expected_messages: Vec, + expected_error_string: String, + expected_stored_swapper: String, + expected_stored_steps: Vec, +} + +// Test execute_swap +#[test_case( + Params { + swapper: "entry_point".to_string(), + reply_id: STAKE_REPLY_ID, + swap_steps: vec![ + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string() + }, + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 4, + token_in: "c:uatom".to_string(), + token_out: "p:uatom:30Sep2024".to_string(), + amount: None, + }, + ], + }, + ], + response: MsgStakeResponse { + c_amount: Some(CosmosCoin {amount: "1800".to_string(), denom: "c:uatom".to_string()}), + fee: None, + }.into(), + expected_messages: vec![ + SubMsg { + id: 0, + msg: MsgBatchSwap { + creator: "swap_contract_address".to_string(), + swap_type: SwapType::GivenIn.into(), + max_amounts_in: vec![CosmosCoin{amount: "1800".to_string(), denom: "c:uatom".to_string()}], + min_amounts_out: vec![CosmosCoin{amount: "1".to_string(), denom: "p:uatom:30Sep2024".to_string()}], + steps: vec![ + SwapStep { + pool_id: 4, + token_in: "c:uatom".to_string(), + token_out: "p:uatom:30Sep2024".to_string(), + amount: Some("1800".to_string()), + } + ], + }.into(), + gas_limit: None, + reply_on: Never, + }, + SubMsg { + id: 0, + msg: WasmMsg::Execute { + contract_addr: "swap_contract_address".to_string(), + msg: to_json_binary(&ExecuteMsg::TransferFundsBack { + return_denom: "p:uatom:30Sep2024".to_string(), + swapper: Addr::unchecked("entry_point"), + })?, + funds: vec![], + } + .into(), + gas_limit: None, + reply_on: Never, + }, + ], + expected_error_string: "".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; +"One Step Left")] +#[test_case( + Params { + swapper: "entry_point".to_string(), + reply_id: BATCH_SWAP_REPLY_ID, + swap_steps: vec![ + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "ibc/uosmo".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: Some("2000".to_string()), + }, + SwapStep { + pool_id: 2, + token_in: "ibc/uusdc".to_string(), + token_out: "ibc/uatom".to_string(), + amount: None, + }, + ], + }, + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string() + }, + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "c:uatom".to_string(), + token_out: "lp:0:uatomlpt".to_string(), + amount: None, + } + ], + }, + ], + response: MsgBatchSwapResponse { + amounts_out: vec![CosmosCoin {amount: "100".to_string(), denom: "ibc/uatom".to_string()}], + amounts_in: vec![], + join_exit_protocol_fee: vec![], + swap_fee: vec![], + swap_protocol_fee: vec![], + }.into(), + expected_messages: vec![ + SubMsg { + id: STAKE_REPLY_ID, + msg: MsgStake { + creator: "swap_contract_address".to_string(), + host_chain: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + amount: "100".to_string(), + } + .into(), + gas_limit: None, + reply_on: Success, + }, + ], + expected_error_string: "".to_string(), + expected_stored_swapper: "entry_point".to_string(), + expected_stored_steps: vec![ + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string() + }, + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "c:uatom".to_string(), + token_out: "lp:0:uatomlpt".to_string(), + amount: None, + } + ], + }, + ], + }; +"Multi Step Left")] +fn test_execute_reply(params: Params) -> ContractResult<()> { + // Create mock dependencies + let mut deps = mock_dependencies(); + + // Create mock env + let mut env = mock_env(); + env.contract.address = Addr::unchecked("swap_contract_address"); + + // Fill the storage + ENTRY_POINT_CONTRACT_ADDRESS.save(deps.as_mut().storage, &Addr::unchecked("entry_point"))?; + IN_PROGRESS_SWAP_OPERATIONS.save(deps.as_mut().storage, &VecDeque::from(params.swap_steps))?; + IN_PROGRESS_SWAP_SENDER.save( + deps.as_mut().storage, + &Addr::unchecked(params.swapper.as_str()), + )?; + + // Call execute_swap with the given test parameters + let res = contract::reply( + deps.as_mut(), + env, + Reply { + id: params.reply_id, + result: SubMsgResult::Ok(SubMsgResponse { + data: Some(params.response), + events: vec![], + }), + }, + ); + + // Assert the behavior is correct + match res { + Ok(res) => { + // Assert the test did not expect an error + assert!( + params.expected_error_string.is_empty(), + "expected test to error with {:?}, but it succeeded", + params.expected_error_string + ); + + // Assert the messages are correct + assert_eq!(res.messages, params.expected_messages); + + if !params.expected_stored_steps.is_empty() { + // Assert the stored steps are correct + let stored_steps = IN_PROGRESS_SWAP_OPERATIONS.load(deps.as_ref().storage)?; + assert_eq!(stored_steps, VecDeque::from(params.expected_stored_steps)); + } else { + // Assert no steps are stored + assert!(IN_PROGRESS_SWAP_OPERATIONS + .load(deps.as_ref().storage) + .is_err()); + } + + if !params.expected_stored_swapper.is_empty() { + // Assert the stored swapper is correct + let stored_swapper = IN_PROGRESS_SWAP_SENDER.load(deps.as_ref().storage)?; + assert_eq!( + stored_swapper, + Addr::unchecked(params.expected_stored_swapper.as_str()) + ); + } else { + // Assert no address is stored + assert!(IN_PROGRESS_SWAP_SENDER.load(deps.as_ref().storage).is_err()); + } + } + Err(err) => { + // Assert the test expected an error + assert!( + !params.expected_error_string.is_empty(), + "expected test to succeed, but it errored with {:?}", + err + ); + + // Assert the error is correct + assert!(err + .to_string() + .contains(params.expected_error_string.as_str())); + } + } + + Ok(()) +} diff --git a/contracts/adapters/swap/pryzm/tests/test_execute_swap.rs b/contracts/adapters/swap/pryzm/tests/test_execute_swap.rs new file mode 100644 index 0000000..1f44d99 --- /dev/null +++ b/contracts/adapters/swap/pryzm/tests/test_execute_swap.rs @@ -0,0 +1,419 @@ +#[allow(unused_imports)] +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + to_json_binary, Addr, Coin, + ReplyOn::Never, + ReplyOn::Success, + SubMsg, WasmMsg, +}; +#[allow(unused_imports)] +use pryzm_std::types::{ + cosmos::base::v1beta1::Coin as CosmosCoin, + pryzm::amm::v1::{MsgBatchSwap, SwapStep, SwapType}, + pryzm::icstaking::v1::MsgStake, +}; +use std::collections::VecDeque; +use test_case::test_case; + +#[allow(unused_imports)] +use skip::swap::{ExecuteMsg, SwapOperation}; + +use skip_go_swap_adapter_pryzm::execution::SwapExecutionStep; +use skip_go_swap_adapter_pryzm::state::{IN_PROGRESS_SWAP_OPERATIONS, IN_PROGRESS_SWAP_SENDER}; +#[allow(unused_imports)] +use skip_go_swap_adapter_pryzm::{ + contract, error::ContractResult, reply_id::BATCH_SWAP_REPLY_ID, reply_id::STAKE_REPLY_ID, + state::ENTRY_POINT_CONTRACT_ADDRESS, +}; +/* +Test Cases: + +Expect Success + - One Swap Operation + - Multiple Swap Operations + - No Swap Operations (This is prevented in the entry point contract) + +Expect Error + - Unauthorized Caller (Only the stored entry point contract can call this function) + - No Coin Sent + - More Than One Coin Sent + - Invalid Pool ID Conversion For Swap Operations + + */ + +// Define test parameters +struct Params { + caller: String, + info_funds: Vec, + swap_operations: Vec, + expected_messages: Vec, + expected_error_string: String, + expected_stored_swapper: String, + expected_stored_steps: Vec, +} + +// Test execute_swap +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![Coin::new(100, "pr")], + swap_operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "pr".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + } + ], + expected_messages: vec![ + SubMsg { + id: 0, + msg: MsgBatchSwap { + creator: "swap_contract_address".to_string(), + swap_type: SwapType::GivenIn.into(), + max_amounts_in: vec![CosmosCoin {amount: "100".to_string(), denom: "pr".to_string()}], + min_amounts_out: vec![CosmosCoin {amount: "1".to_string(), denom: "ibc/uusdc".to_string()}], + steps: vec![ + SwapStep { + pool_id: 1, + token_in: "pr".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: Some("100".to_string()), + } + ] + } + .into(), + gas_limit: None, + reply_on: Never, + }, + SubMsg { + id: 0, + msg: WasmMsg::Execute { + contract_addr: "swap_contract_address".to_string(), + msg: to_json_binary(&ExecuteMsg::TransferFundsBack { + return_denom: "ibc/uusdc".to_string(), + swapper: Addr::unchecked("entry_point"), + })?, + funds: vec![], + } + .into(), + gas_limit: None, + reply_on: Never, + }, + ], + expected_error_string: "".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; +"One Swap Operation")] +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![Coin::new(2000, "pr")], + swap_operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "pr".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "p:uatom:30Sep2024".to_string(), + interface: None, + } + ], + expected_messages: vec![ + SubMsg { + id: BATCH_SWAP_REPLY_ID, + msg: MsgBatchSwap { + creator: "swap_contract_address".to_string(), + swap_type: SwapType::GivenIn.into(), + max_amounts_in: vec![CosmosCoin { + amount: "2000".to_string(), + denom: "pr".to_string(), + }], + min_amounts_out: vec![CosmosCoin { + amount: "1".to_string(), + denom: "ibc/uatom".to_string(), + }], + steps: vec![ + SwapStep { + pool_id: 1, + token_in: "pr".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: Some("2000".to_string()), + }, + SwapStep { + pool_id: 2, + token_in: "ibc/uusdc".to_string(), + token_out: "ibc/uatom".to_string(), + amount: None, + }, + ], + } + .into(), + gas_limit: None, + reply_on: Success, + }, + ], + expected_error_string: "".to_string(), + expected_stored_swapper: "entry_point".to_string(), + expected_stored_steps: vec![ + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "pr".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: None, + }, + SwapStep { + pool_id: 2, + token_in: "ibc/uusdc".to_string(), + token_out: "ibc/uatom".to_string(), + amount: None, + }, + ], + }, + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string() + }, + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 3, + token_in: "c:uatom".to_string(), + token_out: "p:uatom:30Sep2024".to_string(), + amount: None, + }, + ], + }, + ], + }; +"Multiple Swap Operations")] +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![Coin::new(2000, "pr")], + swap_operations: vec![ + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "pr".to_string(), + denom_out: "c:pr".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:4".to_string(), + denom_in: "c:pr".to_string(), + denom_out: "p:pr:30Sep2024".to_string(), + interface: None, + } + ], + expected_messages: vec![ + SubMsg { + id: STAKE_REPLY_ID, + msg: MsgStake { + creator: "swap_contract_address".to_string(), + host_chain: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + amount: "2000".to_string(), + } + .into(), + gas_limit: None, + reply_on: Success, + }, + ], + expected_error_string: "".to_string(), + expected_stored_swapper: "entry_point".to_string(), + expected_stored_steps: vec![ + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string() + }, + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 4, + token_in: "c:pr".to_string(), + token_out: "p:pr:30Sep2024".to_string(), + amount: None, + }, + ], + }, + ], + }; +"First Step Liquid Staking")] +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![Coin::new(100, "pr")], + swap_operations: vec![], + expected_messages: vec![], + expected_error_string: "swap_operations cannot be empty".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; +"No Swap Operations")] +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![], + swap_operations: vec![ + SwapOperation { + pool: "pool_1".to_string(), + denom_in: "pr".to_string(), + denom_out: "uatom".to_string(), + interface: None, + } + ], + expected_messages: vec![], + expected_error_string: "No funds sent".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; + "No Coin Sent - Expect Error")] +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![ + Coin::new(100, "pr"), + Coin::new(100, "uatom"), + ], + swap_operations: vec![ + SwapOperation { + pool: "pool_1".to_string(), + denom_in: "pr".to_string(), + denom_out: "uatom".to_string(), + interface: None, + } + ], + expected_messages: vec![], + expected_error_string: "Sent more than one denomination".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; + "More Than One Coin Sent - Expect Error")] +#[test_case( + Params { + caller: "entry_point".to_string(), + info_funds: vec![Coin::new(100, "pr")], + swap_operations: vec![ + SwapOperation { + pool: "1".to_string(), + denom_in: "pr".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + } + ], + expected_messages: vec![], + expected_error_string: "provided pool string is not a valid swap route".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; + "Invalid Pool For Swap Operations - Expect Error")] +#[test_case( + Params { + caller: "random".to_string(), + info_funds: vec![Coin::new(100, "pr")], + swap_operations: vec![], + expected_messages: vec![], + expected_error_string: "Unauthorized".to_string(), + expected_stored_swapper: "".to_string(), + expected_stored_steps: vec![], + }; + "Unauthorized Caller - Expect Error")] +fn test_execute_swap(params: Params) -> ContractResult<()> { + // Create mock dependencies + let mut deps = mock_dependencies(); + + // Create mock env + let mut env = mock_env(); + env.contract.address = Addr::unchecked("swap_contract_address"); + + // Convert info funds vector into a slice of Coin objects + let info_funds: &[Coin] = ¶ms.info_funds; + + // Create mock info with entry point contract address + let info = mock_info(¶ms.caller, info_funds); + + // Store the entry point contract address + ENTRY_POINT_CONTRACT_ADDRESS.save(deps.as_mut().storage, &Addr::unchecked("entry_point"))?; + + // Call execute_swap with the given test parameters + let res = contract::execute( + deps.as_mut(), + env, + info, + ExecuteMsg::Swap { + operations: params.swap_operations.clone(), + }, + ); + + // Assert the behavior is correct + match res { + Ok(res) => { + // Assert the test did not expect an error + assert!( + params.expected_error_string.is_empty(), + "expected test to error with {:?}, but it succeeded", + params.expected_error_string + ); + + // Assert the messages are correct + assert_eq!(res.messages, params.expected_messages); + + if !params.expected_stored_steps.is_empty() { + // Assert the stored steps are correct + let stored_steps = IN_PROGRESS_SWAP_OPERATIONS.load(deps.as_ref().storage)?; + assert_eq!(stored_steps, VecDeque::from(params.expected_stored_steps)); + } else { + // Assert no steps are stored + assert!(IN_PROGRESS_SWAP_OPERATIONS + .load(deps.as_ref().storage) + .is_err()); + } + + if !params.expected_stored_swapper.is_empty() { + // Assert the stored swapper is correct + let stored_swapper = IN_PROGRESS_SWAP_SENDER.load(deps.as_ref().storage)?; + assert_eq!( + stored_swapper, + Addr::unchecked(params.expected_stored_swapper.as_str()) + ); + } else { + // Assert no address is stored + assert!(IN_PROGRESS_SWAP_SENDER.load(deps.as_ref().storage).is_err()); + } + } + Err(err) => { + // Assert the test expected an error + assert!( + !params.expected_error_string.is_empty(), + "expected test to succeed, but it errored with {:?}", + err + ); + + // Assert the error is correct + assert!(err + .to_string() + .contains(params.expected_error_string.as_str())); + } + } + + Ok(()) +} diff --git a/contracts/adapters/swap/pryzm/tests/test_execute_transfer_funds_back.rs b/contracts/adapters/swap/pryzm/tests/test_execute_transfer_funds_back.rs new file mode 100644 index 0000000..1835052 --- /dev/null +++ b/contracts/adapters/swap/pryzm/tests/test_execute_transfer_funds_back.rs @@ -0,0 +1,169 @@ +use cosmwasm_std::{ + testing::{mock_dependencies_with_balances, mock_env, mock_info}, + Addr, BankMsg, Coin, + ReplyOn::Never, + SubMsg, +}; +use test_case::test_case; + +use skip::{error::SkipError, swap::ExecuteMsg}; +use skip_go_swap_adapter_pryzm::contract; +use skip_go_swap_adapter_pryzm::error::{ContractError, ContractResult}; + +/* +Test Cases: + +Expect Success + - One Coin Balance + - Multiple Coin Balance + - No Coin Balance (This will fail at the bank module if attempted) + +Expect Error + - Unauthorized Caller (Only contract itself can call this function) + */ + +// Define test parameters +struct Params { + caller: String, + contract_balance: Vec, + return_denom: String, + expected_messages: Vec, + expected_error: Option, +} + +// Test execute_transfer_funds_back +#[test_case( + Params { + caller: "swap_contract_address".to_string(), + contract_balance: vec![Coin::new(100, "pr")], + return_denom: "pr".to_string(), + expected_messages: vec![ + SubMsg { + id: 0, + msg: BankMsg::Send { + to_address: "swapper".to_string(), + amount: vec![Coin::new(100, "pr")], + }.into(), + gas_limit: None, + reply_on: Never, + }, + ], + expected_error: None, + }; + "Transfers One Coin Balance")] +#[test_case( + Params { + caller: "swap_contract_address".to_string(), + contract_balance: vec![ + Coin::new(100, "pr"), + Coin::new(100, "uatom"), + ], + return_denom: "pr".to_string(), + expected_messages: vec![ + SubMsg { + id: 0, + msg: BankMsg::Send { + to_address: "swapper".to_string(), + amount: vec![ + Coin::new(100, "pr"), + Coin::new(100, "uatom") + ], + }.into(), + gas_limit: None, + reply_on: Never, + }, + ], + expected_error: None, + }; + "Transfers Multiple Coin Balance")] +#[test_case( + Params { + caller: "swap_contract_address".to_string(), + contract_balance: vec![], + return_denom: "pr".to_string(), + expected_messages: vec![ + SubMsg { + id: 0, + msg: BankMsg::Send { + to_address: "swapper".to_string(), + amount: vec![], + }.into(), + gas_limit: None, + reply_on: Never, + }, + ], + expected_error: None, + }; + "Transfers No Coin Balance")] +#[test_case( + Params { + caller: "random".to_string(), + contract_balance: vec![], + return_denom: "pr".to_string(), + expected_messages: vec![ + SubMsg { + id: 0, + msg: BankMsg::Send { + to_address: "swapper".to_string(), + amount: vec![], + }.into(), + gas_limit: None, + reply_on: Never, + }, + ], + expected_error: Some(ContractError::Skip(SkipError::Unauthorized)), + }; + "Unauthorized Caller")] +fn test_execute_transfer_funds_back(params: Params) -> ContractResult<()> { + // Convert params contract balance to a slice + let contract_balance: &[Coin] = ¶ms.contract_balance; + + // Create mock dependencies + let mut deps = mock_dependencies_with_balances(&[("swap_contract_address", contract_balance)]); + + // Create mock env + let mut env = mock_env(); + env.contract.address = Addr::unchecked("swap_contract_address"); + + // Create mock info + let info = mock_info(¶ms.caller, &[]); + + // Call execute_swap with the given test parameters + let res = contract::execute( + deps.as_mut(), + env, + info, + ExecuteMsg::TransferFundsBack { + return_denom: params.return_denom, + swapper: Addr::unchecked("swapper"), + }, + ); + + // Assert the behavior is correct + match res { + Ok(res) => { + // Assert the test did not expect an error + assert!( + params.expected_error.is_none(), + "expected test to error with {:?}, but it succeeded", + params.expected_error + ); + + // Assert the messages are correct + assert_eq!(res.messages, params.expected_messages); + } + Err(err) => { + // Assert the test expected an error + assert!( + params.expected_error.is_some(), + "expected test to succeed, but it errored with {:?}", + err + ); + + // Assert the error is correct + assert_eq!(err, params.expected_error.unwrap()); + } + } + + Ok(()) +} diff --git a/contracts/adapters/swap/pryzm/tests/test_execution.rs b/contracts/adapters/swap/pryzm/tests/test_execution.rs new file mode 100644 index 0000000..c51cdb1 --- /dev/null +++ b/contracts/adapters/swap/pryzm/tests/test_execution.rs @@ -0,0 +1,435 @@ +use cosmwasm_std::{coin, CosmosMsg}; +use pryzm_std::types::cosmos::base::v1beta1::Coin as CosmosCoin; +use pryzm_std::types::pryzm::{ + amm::v1::{MsgBatchSwap, SwapStep, SwapType}, + icstaking::v1::MsgStake, +}; + +use skip::swap::SwapOperation; +use skip_go_swap_adapter_pryzm::error::ContractError; +use skip_go_swap_adapter_pryzm::execution::{extract_execution_steps, SwapExecutionStep}; + +#[test] +fn test_execution_step_return_denom() { + // empty swap steps + let step = SwapExecutionStep::Swap { swap_steps: vec![] }; + assert!(step.get_return_denom().is_err()); + + // single step Swap type step + let step = SwapExecutionStep::Swap { + swap_steps: vec![SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: Some("1000".to_string()), + }], + }; + assert!(step.get_return_denom().is_ok()); + assert_eq!("b", step.get_return_denom().unwrap()); + + // multistep Swap type step + let step = SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: Some("1000".to_string()), + }, + SwapStep { + pool_id: 3, + token_in: "b".to_string(), + token_out: "c".to_string(), + amount: None, + }, + SwapStep { + pool_id: 2, + token_in: "c".to_string(), + token_out: "d".to_string(), + amount: None, + }, + ], + }; + assert!(step.get_return_denom().is_ok()); + assert_eq!("d", step.get_return_denom().unwrap()); + + // stake step + let step = SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + }; + assert!(step.get_return_denom().is_ok()); + assert_eq!("c:uatom", step.get_return_denom().unwrap()); +} + +#[test] +fn test_execution_step_cosmos_msg() { + let address = "address"; + + // empty swap steps + let step = SwapExecutionStep::Swap { swap_steps: vec![] }; + assert!(step + .to_cosmos_msg(address.to_string(), coin(1000, "a")) + .is_err()); + + // single step Swap type step + let step = SwapExecutionStep::Swap { + swap_steps: vec![SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: None, + }], + }; + let result = step.to_cosmos_msg(address.to_string(), coin(1000, "a")); + assert!(result.is_ok()); + assert_eq!( + >::into(MsgBatchSwap { + creator: address.to_string(), + swap_type: SwapType::GivenIn.into(), + max_amounts_in: vec![CosmosCoin { + amount: "1000".to_string(), + denom: "a".to_string() + }], + min_amounts_out: vec![CosmosCoin { + amount: "1".to_string(), + denom: "b".to_string() + }], + steps: vec![SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: "1000".to_string().into() + }], + }), + result.unwrap() + ); + + // multistep Swap type step + let step = SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: None, + }, + SwapStep { + pool_id: 3, + token_in: "b".to_string(), + token_out: "c".to_string(), + amount: None, + }, + SwapStep { + pool_id: 2, + token_in: "c".to_string(), + token_out: "d".to_string(), + amount: None, + }, + ], + }; + let result = step.to_cosmos_msg(address.to_string(), coin(1000, "a")); + assert!(result.is_ok()); + assert_eq!( + >::into(MsgBatchSwap { + creator: address.to_string(), + swap_type: SwapType::GivenIn.into(), + max_amounts_in: vec![CosmosCoin { + amount: "1000".to_string(), + denom: "a".to_string() + }], + min_amounts_out: vec![CosmosCoin { + amount: "1".to_string(), + denom: "d".to_string() + }], + steps: vec![ + SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: "1000".to_string().into() + }, + SwapStep { + pool_id: 3, + token_in: "b".to_string(), + token_out: "c".to_string(), + amount: None + }, + SwapStep { + pool_id: 2, + token_in: "c".to_string(), + token_out: "d".to_string(), + amount: None + } + ], + }), + result.unwrap() + ); + + // stake step + let step = SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + }; + let result = step.to_cosmos_msg(address.to_string(), coin(1000, "uatom")); + assert!(result.is_ok()); + assert_eq!( + >::into(MsgStake { + creator: address.to_string(), + host_chain: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + amount: "1000".into(), + }), + result.unwrap() + ); +} + +#[test] +fn test_extract_execution_step() { + // empty swap operations + let result = extract_execution_steps(vec![]); + assert!(result.is_err()); + + // single amm swap step + let result = extract_execution_steps(vec![SwapOperation { + pool: "amm:1".to_string(), + denom_in: "a".to_string(), + denom_out: "b".to_string(), + interface: None, + }]); + assert!(result.is_ok()); + let vec = result.unwrap(); + assert_eq!(1, vec.len()); + assert_eq!( + SwapExecutionStep::Swap { + swap_steps: vec![SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: None + }] + }, + vec.front().unwrap().clone() + ); + + // multi step amm swap + let result = extract_execution_steps(vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "a".to_string(), + denom_out: "b".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "b".to_string(), + denom_out: "c".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "c".to_string(), + denom_out: "d".to_string(), + interface: None, + }, + ]); + assert!(result.is_ok()); + let vec = result.unwrap(); + assert_eq!(1, vec.len()); + assert_eq!( + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 1, + token_in: "a".to_string(), + token_out: "b".to_string(), + amount: None + }, + SwapStep { + pool_id: 3, + token_in: "b".to_string(), + token_out: "c".to_string(), + amount: None + }, + SwapStep { + pool_id: 2, + token_in: "c".to_string(), + token_out: "d".to_string(), + amount: None + } + ] + }, + vec.front().unwrap().clone() + ); + + // single staking step + let result = extract_execution_steps(vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }]); + assert!(result.is_ok()); + let vec = result.unwrap(); + assert_eq!(1, vec.len()); + assert_eq!( + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + }, + vec.front().unwrap().clone() + ); + + // multiple steps including amm and icstaking + let result = extract_execution_steps(vec![ + SwapOperation { + pool: "amm:7".to_string(), + denom_in: "uusdc".to_string(), + denom_out: "uauuu".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:12".to_string(), + denom_in: "uauuu".to_string(), + denom_out: "uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ]); + assert!(result.is_ok()); + let mut vec = result.unwrap(); + assert_eq!(3, vec.len()); + assert_eq!( + SwapExecutionStep::Swap { + swap_steps: vec![ + SwapStep { + pool_id: 7, + token_in: "uusdc".to_string(), + token_out: "uauuu".to_string(), + amount: None + }, + SwapStep { + pool_id: 12, + token_in: "uauuu".to_string(), + token_out: "uatom".to_string(), + amount: None + }, + ] + }, + vec.pop_front().unwrap().clone() + ); + assert_eq!( + SwapExecutionStep::Stake { + host_chain_id: "uatom".to_string(), + transfer_channel: "channel-0".to_string(), + }, + vec.pop_front().unwrap().clone() + ); + assert_eq!( + SwapExecutionStep::Swap { + swap_steps: vec![SwapStep { + pool_id: 1, + token_in: "c:uatom".to_string(), + token_out: "y:uatom:30Sep2024".to_string(), + amount: None + },] + }, + vec.pop_front().unwrap().clone() + ); + + // invalid pools + let result = extract_execution_steps(vec![SwapOperation { + pool: "amm:invalid".to_string(), + denom_in: "a".to_string(), + denom_out: "b".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); + + let result = extract_execution_steps(vec![SwapOperation { + pool: "invalid:1".to_string(), + denom_in: "a".to_string(), + denom_out: "b".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); + + let result = extract_execution_steps(vec![SwapOperation { + pool: "icstaking:1".to_string(), + denom_in: "uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); + + let result = extract_execution_steps(vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "uatom".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); + + let result = extract_execution_steps(vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "c:uosmo".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); + + let result = extract_execution_steps(vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "uatom".to_string(), + denom_out: "uosmo".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); + + let result = extract_execution_steps(vec![SwapOperation { + pool: "icstaking:uatom:channel-0:some".to_string(), + denom_in: "uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }]); + assert!(result.is_err()); + assert!(matches!( + result.err().unwrap(), + ContractError::InvalidPool { .. } + )); +} diff --git a/contracts/adapters/swap/pryzm/tests/test_simulate.rs b/contracts/adapters/swap/pryzm/tests/test_simulate.rs new file mode 100644 index 0000000..824ee91 --- /dev/null +++ b/contracts/adapters/swap/pryzm/tests/test_simulate.rs @@ -0,0 +1,769 @@ +use std::marker::PhantomData; +use std::str::FromStr; + +use cosmwasm_std::testing::{mock_env, MockApi, MockStorage}; +use cosmwasm_std::{coin, from_json, Decimal, OwnedDeps, StdResult}; +use pryzm_std::types::cosmos::base::v1beta1::Coin as CosmosCoin; +use pryzm_std::types::pryzm::amm::v1::{ + QuerySimulateBatchSwapRequest, QuerySimulateBatchSwapResponse, QuerySpotPriceRequest, + QuerySpotPriceResponse, SwapStep, SwapType, +}; +use pryzm_std::types::pryzm::icstaking::v1::{ + QuerySimulateStakeRequest, QuerySimulateStakeResponse, +}; + +use skip::asset::Asset; +use skip::error::SkipError; +use skip::swap::{ + QueryMsg, Route, SimulateSwapExactAssetInResponse, SimulateSwapExactAssetOutResponse, + SwapOperation, +}; +use skip_go_swap_adapter_pryzm::contract; +use skip_go_swap_adapter_pryzm::error::ContractError; + +use crate::mock::MockQuerier; + +mod mock; + +#[test] +fn test_simulate_exact_asset_in() { + let mut deps = OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: setup_mocks(), + custom_query_type: PhantomData, + }; + + // empty swap operations + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetIn { + asset_in: Asset::Native(coin(1000, "ibc/uatom")), + swap_operations: vec![], + }, + ); + assert!(res.is_err()); + assert!(matches!( + res.err().unwrap(), + ContractError::SwapOperationsEmpty + )); + + // invalid asset provided + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetIn { + asset_in: Asset::Native(coin(1000, "ibc/uosmo")), + swap_operations: vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }], + }, + ); + assert!(res.is_err()); + assert!(matches!( + res.err().unwrap(), + ContractError::CoinInDenomMismatch + )); + + // valid stake operation + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetIn { + asset_in: Asset::Native(coin(1000, "ibc/uatom")), + swap_operations: vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }], + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let token_out = output.unwrap(); + assert_eq!("c:uatom", token_out.denom()); + assert_eq!(950, token_out.amount().u128()); + + // valid multi step swap + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetIn { + asset_in: Asset::Native(coin(2000, "ibc/uosmo")), + swap_operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let token_out = output.unwrap(); + assert_eq!("y:uatom:30Sep2024", token_out.denom()); + assert_eq!(1200, token_out.amount().u128()); + + // get with spot price + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetInWithMetadata { + asset_in: Asset::Native(coin(2000, "ibc/uosmo")), + swap_operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + include_spot_price: true, + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let response = output.unwrap(); + let token_out = response.asset_out; + assert_eq!("y:uatom:30Sep2024", token_out.denom()); + assert_eq!(1200, token_out.amount().u128()); + assert_eq!( + Decimal::from_str("0.600000000125").unwrap(), + response.spot_price.unwrap() + ); +} + +#[test] +fn test_simulate_exact_asset_out() { + let mut deps = OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: setup_mocks(), + custom_query_type: PhantomData, + }; + + // empty swap operations + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetOut { + asset_out: Asset::Native(coin(1200, "y:uatom:30Sep2024")), + swap_operations: vec![], + }, + ); + assert!(res.is_err()); + assert!(matches!( + res.err().unwrap(), + ContractError::SwapOperationsEmpty + )); + + // invalid asset provided + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetOut { + asset_out: Asset::Native(coin(1000, "c:uosmo")), + swap_operations: vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }], + }, + ); + assert!(res.is_err()); + assert!(matches!( + res.err().unwrap(), + ContractError::CoinOutDenomMismatch + )); + + // valid stake operation + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetOut { + asset_out: Asset::Native(coin(950, "c:uatom")), + swap_operations: vec![SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }], + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let token_in = output.unwrap(); + assert_eq!("ibc/uatom", token_in.denom()); + assert_eq!(1000, token_in.amount().u128()); + + // valid multi step swap + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetOut { + asset_out: Asset::Native(coin(1200, "y:uatom:30Sep2024")), + swap_operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let token_in = output.unwrap(); + assert_eq!("ibc/uosmo", token_in.denom()); + assert_eq!(2000, token_in.amount().u128()); + + // get with spot price + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSwapExactAssetOutWithMetadata { + asset_out: Asset::Native(coin(1200, "y:uatom:30Sep2024")), + swap_operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + include_spot_price: true, + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let response = output.unwrap(); + let token_in = response.asset_in; + assert_eq!("ibc/uosmo", token_in.denom()); + assert_eq!(2000, token_in.amount().u128()); + assert_eq!( + Decimal::from_str("0.600000000125").unwrap(), + response.spot_price.unwrap() + ); +} + +#[test] +fn test_simulate_smart_swap() { + let mut deps = OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: setup_mocks(), + custom_query_type: PhantomData, + }; + + // empty routes + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSmartSwapExactAssetIn { + asset_in: Asset::Native(coin(1000, "ibc/uatom")), + routes: vec![], + }, + ); + assert!(res.is_err()); + assert!(matches!( + res.err().unwrap(), + ContractError::Skip(SkipError::RoutesEmpty) + )); + + // valid multi step swap + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSmartSwapExactAssetIn { + asset_in: Asset::Native(coin(5000, "ibc/uosmo")), + routes: vec![ + Route { + offer_asset: Asset::Native(coin(2000, "ibc/uosmo")), + operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + }, + Route { + offer_asset: Asset::Native(coin(3000, "ibc/uosmo")), + operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:4".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + }, + ], + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let token_out = output.unwrap(); + assert_eq!("y:uatom:30Sep2024", token_out.denom()); + assert_eq!(2905, token_out.amount().u128()); + + // get with spot price + let res = contract::query( + deps.as_mut().as_ref(), + mock_env(), + QueryMsg::SimulateSmartSwapExactAssetInWithMetadata { + asset_in: Asset::Native(coin(5000, "ibc/uosmo")), + routes: vec![ + Route { + offer_asset: Asset::Native(coin(2000, "ibc/uosmo")), + operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "icstaking:uatom:channel-0".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + }, + Route { + offer_asset: Asset::Native(coin(3000, "ibc/uosmo")), + operations: vec![ + SwapOperation { + pool: "amm:1".to_string(), + denom_in: "ibc/uosmo".to_string(), + denom_out: "ibc/uusdc".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:2".to_string(), + denom_in: "ibc/uusdc".to_string(), + denom_out: "ibc/uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:4".to_string(), + denom_in: "ibc/uatom".to_string(), + denom_out: "c:uatom".to_string(), + interface: None, + }, + SwapOperation { + pool: "amm:3".to_string(), + denom_in: "c:uatom".to_string(), + denom_out: "y:uatom:30Sep2024".to_string(), + interface: None, + }, + ], + }, + ], + include_spot_price: true, + }, + ); + assert!(res.is_ok()); + let output: StdResult = from_json(res.unwrap()); + assert!(output.is_ok()); + let response = output.unwrap(); + let token_out = response.asset_out; + assert_eq!("y:uatom:30Sep2024", token_out.denom()); + assert_eq!(2905, token_out.amount().u128()); + + // weighted spot price: + // 2000 with 0.600000000125 + // 3000 with 0.56842105275 + assert_eq!( + Decimal::from_str("0.5810526317").unwrap(), + response.spot_price.unwrap() + ); +} + +fn setup_mocks() -> MockQuerier { + let mut querier = MockQuerier::new(); + + mock_stake_given_in(&mut querier, "uatom", "channel-0", "1000", "950"); + mock_stake_given_out(&mut querier, "uatom", "channel-0", "950", "1000"); + mock_stake_given_in( + &mut querier, + "uatom", + "channel-0", + "1000000000000000000", + "950000000000000000", + ); + + mock_batch_swap_given_in( + &mut querier, + vec![ + SwapStep { + pool_id: 1, + token_in: "ibc/uosmo".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: Some("2000".to_string()), + }, + SwapStep { + pool_id: 2, + token_in: "ibc/uusdc".to_string(), + token_out: "ibc/uatom".to_string(), + amount: None, + }, + ], + "1000", + "ibc/uatom", + ); + mock_batch_swap_given_out( + &mut querier, + vec![ + SwapStep { + pool_id: 2, + token_in: "ibc/uusdc".to_string(), + token_out: "ibc/uatom".to_string(), + amount: Some("1000".to_string()), + }, + SwapStep { + pool_id: 1, + token_in: "ibc/uosmo".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: None, + }, + ], + "2000", + "ibc/uosmo", + ); + + mock_batch_swap_given_in( + &mut querier, + vec![SwapStep { + pool_id: 3, + token_in: "c:uatom".to_string(), + token_out: "y:uatom:30Sep2024".to_string(), + amount: Some("950".to_string()), + }], + "1200", + "y:uatom:30Sep2024", + ); + mock_batch_swap_given_out( + &mut querier, + vec![SwapStep { + pool_id: 3, + token_in: "c:uatom".to_string(), + token_out: "y:uatom:30Sep2024".to_string(), + amount: Some("1200".to_string()), + }], + "950", + "c:uatom", + ); + + mock_batch_swap_given_in( + &mut querier, + vec![ + SwapStep { + pool_id: 1, + token_in: "ibc/uosmo".to_string(), + token_out: "ibc/uusdc".to_string(), + amount: Some("3000".to_string()), + }, + SwapStep { + pool_id: 2, + token_in: "ibc/uusdc".to_string(), + token_out: "ibc/uatom".to_string(), + amount: None, + }, + SwapStep { + pool_id: 4, + token_in: "ibc/uatom".to_string(), + token_out: "c:uatom".to_string(), + amount: None, + }, + SwapStep { + pool_id: 3, + token_in: "c:uatom".to_string(), + token_out: "y:uatom:30Sep2024".to_string(), + amount: None, + }, + ], + "1705", + "ibc/y:uatom:30Sep2024", + ); + + mock_spot_price(&mut querier, 1, "ibc/uosmo", "ibc/uusdc", "0.25"); + mock_spot_price(&mut querier, 2, "ibc/uusdc", "ibc/uatom", "2"); + mock_spot_price( + &mut querier, + 3, + "c:uatom", + "y:uatom:30Sep2024", + "1.263157895", + ); + mock_spot_price(&mut querier, 4, "ibc/uatom", "c:uatom", "0.9"); + + querier +} + +fn mock_stake_given_in( + querier: &mut MockQuerier, + host_chain: &str, + channel: &str, + amount_in: &str, + amount_out: &str, +) { + querier.mock_query( + QuerySimulateStakeRequest { + host_chain: host_chain.to_string(), + transfer_channel: channel.to_string(), + amount_in: Some(amount_in.to_string()), + amount_out: None, + } + .into(), + &QuerySimulateStakeResponse { + amount_in: None, + amount_out: Some(CosmosCoin { + amount: amount_out.to_string(), + denom: format!("c:{}", host_chain), + }), + fee_amount: Some(CosmosCoin { + amount: "0".to_string(), + denom: format!("ibc/{}", host_chain), + }), + }, + ); +} + +fn mock_stake_given_out( + querier: &mut MockQuerier, + host_chain: &str, + channel: &str, + amount_out: &str, + amount_in: &str, +) { + querier.mock_query( + QuerySimulateStakeRequest { + host_chain: host_chain.to_string(), + transfer_channel: channel.to_string(), + amount_in: None, + amount_out: Some(amount_out.to_string()), + } + .into(), + &QuerySimulateStakeResponse { + amount_in: Some(CosmosCoin { + amount: amount_in.to_string(), + denom: format!("ibc/{}", host_chain), + }), + amount_out: None, + fee_amount: Some(CosmosCoin { + amount: "0".to_string(), + denom: format!("ibc/{}", host_chain), + }), + }, + ); +} + +fn mock_batch_swap_given_in( + querier: &mut MockQuerier, + swap_steps: Vec, + out_amount: &str, + out_denom: &str, +) { + querier.mock_query( + QuerySimulateBatchSwapRequest { + swap_type: SwapType::GivenIn.into(), + steps: swap_steps, + } + .into(), + &QuerySimulateBatchSwapResponse { + amounts_out: vec![CosmosCoin { + amount: out_amount.to_string(), + denom: out_denom.to_string(), + }], + amounts_in: vec![], + swap_fee: vec![], + join_exit_protocol_fee: vec![], + swap_protocol_fee: vec![], + }, + ); +} + +fn mock_batch_swap_given_out( + querier: &mut MockQuerier, + swap_steps: Vec, + in_amount: &str, + in_denom: &str, +) { + querier.mock_query( + QuerySimulateBatchSwapRequest { + swap_type: SwapType::GivenOut.into(), + steps: swap_steps, + } + .into(), + &QuerySimulateBatchSwapResponse { + amounts_in: vec![CosmosCoin { + amount: in_amount.to_string(), + denom: in_denom.to_string(), + }], + amounts_out: vec![], + swap_fee: vec![], + join_exit_protocol_fee: vec![], + swap_protocol_fee: vec![], + }, + ); +} + +fn mock_spot_price( + querier: &mut MockQuerier, + pool_id: u64, + token_in: &str, + token_out: &str, + spot_price: &str, +) { + querier.mock_query( + QuerySpotPriceRequest { + pool_id, + token_in: token_in.to_string(), + token_out: token_out.to_string(), + apply_fee: false, + } + .into(), + &QuerySpotPriceResponse { + spot_price: spot_price.to_string(), + }, + ); +}