diff --git a/mm2src/coins/eth/eth_tests.rs b/mm2src/coins/eth/eth_tests.rs index b4cea9f129..b841687598 100644 --- a/mm2src/coins/eth/eth_tests.rs +++ b/mm2src/coins/eth/eth_tests.rs @@ -121,7 +121,7 @@ fn display_u256_with_point() { } #[test] -fn test_wei_from_big_decimal() { +fn test_u256_from_big_decimal() { let amount = "0.000001".parse().unwrap(); let wei = u256_from_big_decimal(&amount, 18).unwrap(); let expected_wei: U256 = 1000000000000u64.into(); diff --git a/mm2src/coins/eth/eth_utils.rs b/mm2src/coins/eth/eth_utils.rs index c2c67644e0..c70cf1f82b 100644 --- a/mm2src/coins/eth/eth_utils.rs +++ b/mm2src/coins/eth/eth_utils.rs @@ -166,7 +166,6 @@ pub fn wei_from_coins_mm_number(mm_number: &MmNumber, decimals: u8) -> NumConver } #[inline] -#[allow(unused)] pub fn wei_to_coins_mm_number(u256: U256, decimals: u8) -> NumConversResult { Ok(MmNumber::from(u256_to_big_decimal(u256, decimals)?)) } diff --git a/mm2src/coins/lp_coins.rs b/mm2src/coins/lp_coins.rs index fbdc914204..eca595360a 100644 --- a/mm2src/coins/lp_coins.rs +++ b/mm2src/coins/lp_coins.rs @@ -333,6 +333,8 @@ pub type ValidateTakerFundingSpendPreimageResult = MmResult<(), ValidateTakerFun pub type ValidateTakerPaymentSpendPreimageResult = MmResult<(), ValidateTakerPaymentSpendPreimageError>; pub type IguanaPrivKey = Secp256k1Secret; +/// Coin ticker symbol as defined in KDF's coins configuration. +/// This is the unique identifier for a coin/token within the KDF ecosystem. pub type Ticker = String; // Constants for logs used in tests diff --git a/mm2src/common/common.rs b/mm2src/common/common.rs index a8a3456bc5..7831f2e744 100644 --- a/mm2src/common/common.rs +++ b/mm2src/common/common.rs @@ -1313,7 +1313,7 @@ macro_rules! push_if_some { macro_rules! def_with_opt_param { ($var: ident, $var_type: ty) => { $crate::paste! { - pub fn [](&mut self, $var: Option<$var_type>) -> &mut Self { + pub fn [](mut self, $var: Option<$var_type>) -> Self { self.$var = $var; self } diff --git a/mm2src/mm2_main/AGENTS.md b/mm2src/mm2_main/AGENTS.md index 9fda438898..e921aee2d7 100644 --- a/mm2src/mm2_main/AGENTS.md +++ b/mm2src/mm2_main/AGENTS.md @@ -18,6 +18,11 @@ Core application crate: RPC dispatcher, atomic swap engines, order matching, str src/ ├── mm2.rs # Library entry point ├── lp_native_dex.rs # Application lifecycle (lp_main, lp_run) +├── lr_swap/ # Liquidity routing core logic +│ ├── lr_swap.rs # Types: LrSwapParams, AtomicSwapParams +│ ├── lr_errors.rs # LrSwapError definitions +│ ├── lr_helpers.rs # Utilities for 1inch coin lookup +│ └── lr_quote.rs # Best swap path algorithm ├── rpc/ │ ├── dispatcher/ # RPC routing │ │ ├── dispatcher.rs # Main v2 dispatcher @@ -25,7 +30,7 @@ src/ │ ├── lp_commands/ # Handler implementations │ │ ├── pubkey.rs, tokens.rs, trezor.rs, db_id.rs, legacy.rs │ │ ├── one_inch.rs, one_inch/ # 1inch integration -│ │ └── lr_swap.rs, lr_swap/ # Liquidity routing +│ │ └── lr_swap_api.rs, lr_swap_api/ # Liquidity routing RPCs │ ├── streaming_activations/ # SSE handlers │ │ ├── balance.rs, orderbook.rs, swaps.rs, orders.rs │ │ ├── heartbeat.rs, network.rs, shutdown_signal.rs @@ -122,6 +127,16 @@ Use `RpcTaskManager` for task lifecycle management. - **Secret flow**: Maker generates secret → Taker reveals via spend - **State persistence**: Swaps survive restarts via DB checkpointing +## Liquidity Routing (LR) + +Enables taker swaps between tokens that don't have direct orderbook liquidity by routing through intermediate tokens via DEX aggregators. + +- **LR_0**: DEX swap before atomic swap (convert user's token → token to send to maker) +- **LR_1**: DEX swap after atomic swap (convert received token from maker → user's desired token) +- Currently implemented for 1inch (EVM chains) +- Maker-side LR support planned for future +- `lr_quote.rs`: Finds best swap path by comparing total prices across candidates + ## Swap Watcher Third-party nodes assisting swap completion when participants offline. diff --git a/mm2src/mm2_main/src/lp_swap.rs b/mm2src/mm2_main/src/lp_swap.rs index 9e8dcee41c..cf1e0aa239 100644 --- a/mm2src/mm2_main/src/lp_swap.rs +++ b/mm2src/mm2_main/src/lp_swap.rs @@ -124,7 +124,9 @@ mod trade_preimage; #[cfg(target_arch = "wasm32")] mod swap_wasm_db; -pub use check_balance::{check_other_coin_balance_for_swap, CheckBalanceError, CheckBalanceResult}; +pub use check_balance::{ + check_my_coin_balance_for_swap, check_other_coin_balance_for_swap, CheckBalanceError, CheckBalanceResult, +}; use crypto::secret_hash_algo::SecretHashAlgo; use crypto::CryptoCtx; use keys::{KeyPair, SECP_SIGN, SECP_VERIFY}; diff --git a/mm2src/mm2_main/src/lp_swap/taker_swap.rs b/mm2src/mm2_main/src/lp_swap/taker_swap.rs index 62ebc2ceb0..3433efcf67 100644 --- a/mm2src/mm2_main/src/lp_swap/taker_swap.rs +++ b/mm2src/mm2_main/src/lp_swap/taker_swap.rs @@ -2607,6 +2607,7 @@ impl AtomicSwap for TakerSwap { } } +#[derive(Clone)] pub struct TakerSwapPreparedParams { pub(super) dex_fee: MmNumber, pub(super) fee_to_send_dex_fee: TradeFee, diff --git a/mm2src/mm2_main/src/lr_swap.rs b/mm2src/mm2_main/src/lr_swap.rs new file mode 100644 index 0000000000..ca57f275b6 --- /dev/null +++ b/mm2src/mm2_main/src/lr_swap.rs @@ -0,0 +1,106 @@ +//! Code for swaps with liquidity routing (LR) + +use coins::Ticker; +use ethereum_types::{Address as EthAddress, U256}; +use lr_errors::LrSwapError; +use mm2_number::MmNumber; +use mm2_rpc::data::legacy::{MatchBy, OrderType, TakerAction}; +use trading_api::one_inch_api::classic_swap_types::ClassicSwapData; + +pub(crate) mod lr_errors; +pub(crate) mod lr_helpers; +pub(crate) mod lr_quote; + +/// Liquidity routing data for the aggregated taker swap state machine. +/// Used for a DEX swap step (via 1inch) before or after an atomic swap. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LrSwapParams { + /// Source token amount in human-readable coin units (e.g., "1.5" ETH, not wei) + pub src_amount: MmNumber, + /// Source token KDF ticker + pub src: Ticker, + /// Source token ERC20 contract address (or special 1inch ETH address for native coins) + pub src_contract: EthAddress, + /// Source token decimals + pub src_decimals: u8, + /// Destination token KDF ticker + pub dst: Ticker, + /// Destination token ERC20 contract address + pub dst_contract: EthAddress, + /// Destination token decimals + pub dst_decimals: u8, + /// User's wallet address that will execute the swap + pub from: EthAddress, + /// Maximum acceptable slippage percentage for the DEX swap (0.0 to 50.0) + pub slippage: f32, +} + +/// Atomic swap data for the aggregated taker swap state machine. +/// Represents the P2P atomic swap step in a liquidity-routed swap. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AtomicSwapParams { + /// Base coin volume in human-readable units. None if not yet calculated. + pub base_volume: Option, + /// Base coin KDF ticker (the coin being bought when action is Buy) + pub base: Ticker, + /// Rel coin KDF ticker (the coin being sold when action is Buy) + pub rel: Ticker, + /// Price in rel/base units (how much rel per one base) + pub price: MmNumber, + /// Whether taker is buying or selling base coin + pub action: TakerAction, + /// Order matching strategy: Any, specific Orders, or specific Pubkeys + #[serde(default)] + pub match_by: MatchBy, + /// FillOrKill or GoodTillCancelled + #[serde(default)] + pub order_type: OrderType, +} + +impl AtomicSwapParams { + #[allow(unused)] + pub(crate) fn maker_coin(&self) -> Ticker { + match self.action { + TakerAction::Buy => self.base.clone(), + TakerAction::Sell => self.rel.clone(), + } + } + + #[allow(unused)] + pub(crate) fn taker_coin(&self) -> Ticker { + match self.action { + TakerAction::Buy => self.rel.clone(), + TakerAction::Sell => self.base.clone(), + } + } + + #[allow(clippy::result_large_err, unused)] + pub(crate) fn taker_volume(&self) -> Result { + let Some(ref volume) = self.base_volume else { + return Err(LrSwapError::InternalError("no atomic swap volume".to_owned())); + }; + match self.action { + TakerAction::Buy => Ok(volume * &self.price), + TakerAction::Sell => Ok(volume.clone()), + } + } + + #[allow(clippy::result_large_err, unused)] + pub(crate) fn maker_volume(&self) -> Result { + let Some(ref volume) = self.base_volume else { + return Err(LrSwapError::InternalError("no atomic swap volume".to_owned())); + }; + match self.action { + TakerAction::Buy => Ok(volume.clone()), + TakerAction::Sell => Ok(volume * &self.price), + } + } +} + +/// Struct to return extra data (src_amount) in addition to 1inch swap details +pub(crate) struct ClassicSwapDataExt { + pub api_details: ClassicSwapData, + /// Estimated source amount (in wei) for a liquidity routing swap step, includes needed amount to fill the order, plus dex and trade fees (if needed) + pub src_amount: U256, + pub chain_id: u64, +} diff --git a/mm2src/mm2_main/src/lr_swap/lr_errors.rs b/mm2src/mm2_main/src/lr_swap/lr_errors.rs new file mode 100644 index 0000000000..30b7f1a9db --- /dev/null +++ b/mm2src/mm2_main/src/lr_swap/lr_errors.rs @@ -0,0 +1,129 @@ +use crate::lp_swap::CheckBalanceError; +use coins::CoinFindError; +use derive_more::Display; +use enum_derives::EnumFromStringify; +use ethereum_types::U256; +use mm2_number::BigDecimal; +use trading_api::one_inch_api::errors::OneInchError; + +#[derive(Debug, Display, EnumFromStringify)] +pub enum LrSwapError { + NoSuchCoin { + coin: String, + }, + CoinTypeError, + NftProtocolNotSupported, + ChainNotSupported, + DifferentChains, + #[from_stringify("coins::UnexpectedDerivationMethod")] + MyAddressError(String), + #[from_stringify("ethereum_types::FromDecStrErr", "coins::NumConversError", "hex::FromHexError")] + ConversionError(String), + InvalidParam(String), + #[display(fmt = "Parameter {param} out of bounds, value: {value}, min: {min} max: {max}")] + OutOfBounds { + param: String, + value: String, + min: String, + max: String, + }, + #[display(fmt = "allowance not enough for 1inch contract, available: {allowance}, needed: {amount}")] + OneInchAllowanceNotEnough { + allowance: U256, + amount: U256, + }, + OneInchError(OneInchError), // TODO: do not attach the whole error but extract only message + #[allow(dead_code)] + StateError(String), + BestLrSwapNotFound { + candidates: u32, + }, + #[allow(dead_code)] + AtomicSwapError(String), + #[from_stringify("serde_json::Error")] + ResponseParseError(String), + #[from_stringify("coins::TransactionErr")] + TransactionError(String), + #[from_stringify("coins::RawTransactionError")] + SignTransactionError(String), + InternalError(String), + #[display( + fmt = "Not enough {coin} for swap: available {available}, required at least {required}, locked by swaps {locked_by_swaps:?}" + )] + NotSufficientBalance { + coin: String, + available: BigDecimal, + required: BigDecimal, + locked_by_swaps: Option, + }, + #[display(fmt = "The volume {volume} of the {coin} coin less than minimum transaction amount {threshold}")] + VolumeTooLow { + coin: String, + volume: BigDecimal, + threshold: BigDecimal, + }, + TransportError(String), +} + +impl From for LrSwapError { + fn from(err: CoinFindError) -> Self { + match err { + CoinFindError::NoSuchCoin { coin } => LrSwapError::NoSuchCoin { coin }, + } + } +} + +impl From for LrSwapError { + fn from(error: OneInchError) -> Self { + match error { + OneInchError::InvalidParam(error) => LrSwapError::InvalidParam(error), + OneInchError::OutOfBounds { param, value, min, max } => LrSwapError::OutOfBounds { param, value, min, max }, + OneInchError::TransportError(_) + | OneInchError::ParseBodyError { .. } + | OneInchError::GeneralApiError { .. } => LrSwapError::OneInchError(error), + OneInchError::AllowanceNotEnough { allowance, amount, .. } => { + LrSwapError::OneInchAllowanceNotEnough { allowance, amount } + }, + } + } +} + +impl From for LrSwapError { + fn from(err: CheckBalanceError) -> Self { + match err { + CheckBalanceError::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + } => Self::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + }, + CheckBalanceError::NotSufficientBaseCoinBalance { + coin, + available, + required, + locked_by_swaps, + } => Self::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + }, + CheckBalanceError::VolumeTooLow { + coin, + volume, + threshold, + } => Self::VolumeTooLow { + coin, + volume, + threshold, + }, + CheckBalanceError::Transport(nested_err) => Self::TransportError(nested_err), + CheckBalanceError::InternalError(nested_err) => Self::InternalError(nested_err), + } + } +} diff --git a/mm2src/mm2_main/src/lr_swap/lr_helpers.rs b/mm2src/mm2_main/src/lr_swap/lr_helpers.rs new file mode 100644 index 0000000000..d23e6e8c61 --- /dev/null +++ b/mm2src/mm2_main/src/lr_swap/lr_helpers.rs @@ -0,0 +1,48 @@ +use super::lr_errors::LrSwapError; +use coins::eth::{EthCoin, EthCoinType}; +use coins::{lp_coinfind_or_err, MmCoinEnum, Ticker}; +use ethereum_types::Address as EthAddress; +use mm2_core::mm_ctx::MmArc; +use mm2_err_handle::prelude::*; +use mm2_rpc::data::legacy::TakerAction; +use std::str::FromStr; +use trading_api::one_inch_api::client::ApiClient; + +pub(crate) async fn get_coin_for_one_inch( + ctx: &MmArc, + ticker: &Ticker, +) -> MmResult<(EthCoin, EthAddress), LrSwapError> { + let coin = match lp_coinfind_or_err(ctx, ticker).await.map_mm_err()? { + MmCoinEnum::EthCoinVariant(coin) => coin, + _ => return Err(MmError::new(LrSwapError::CoinTypeError)), + }; + let contract = match coin.coin_type { + EthCoinType::Eth => EthAddress::from_str(ApiClient::eth_special_contract()) + .map_to_mm(|_| LrSwapError::InternalError("invalid address".to_owned()))?, + EthCoinType::Erc20 { token_addr, .. } => token_addr, + EthCoinType::Nft { .. } => return Err(MmError::new(LrSwapError::NftProtocolNotSupported)), + }; + Ok((coin, contract)) +} + +#[allow(clippy::result_large_err)] +pub(crate) fn check_if_one_inch_supports_pair(base_chain_id: u64, rel_chain_id: u64) -> MmResult<(), LrSwapError> { + if !ApiClient::is_chain_supported(base_chain_id) { + return MmError::err(LrSwapError::ChainNotSupported); + } + if base_chain_id != rel_chain_id { + return MmError::err(LrSwapError::DifferentChains); + } + Ok(()) +} + +#[allow(clippy::result_large_err)] +pub(crate) fn sell_buy_method(method: &str) -> MmResult { + match method { + "buy" => Ok(TakerAction::Buy), + "sell" => Ok(TakerAction::Sell), + _ => MmError::err(LrSwapError::InvalidParam( + "invalid method in sell/buy request".to_owned(), + )), + } +} diff --git a/mm2src/mm2_main/src/lr_swap/lr_quote.rs b/mm2src/mm2_main/src/lr_swap/lr_quote.rs new file mode 100644 index 0000000000..a16f32678e --- /dev/null +++ b/mm2src/mm2_main/src/lr_swap/lr_quote.rs @@ -0,0 +1,1087 @@ +//! Implementation for finding the best-priced quote for a Taker swap with liquidity routing (LR). +//! A swap with LR may have interim conversion (LR swap) of a source token into a token needed to send in the atomic swap, +//! or, conversion of a received atomic swap token into a destination token. +//! LR currently is supported for EVM chains. + +use super::lr_errors::LrSwapError; +use super::lr_helpers::get_coin_for_one_inch; +use crate::lp_swap::taker_swap::TakerSwapPreparedParams; +use crate::lr_swap::ClassicSwapDataExt; +use crate::rpc::lp_commands::lr_swap_api::lr_api_types::{AskOrBidOrder, AsksForCoin, BidsForCoin}; +use coins::eth::eth_utils::wei_to_coins_mm_number; +use coins::eth::{mm_number_from_u256, wei_from_coins_mm_number, EthCoin}; +use coins::hd_wallet::AddrToString; +use coins::{lp_coinfind_or_err, MarketCoinOps}; +use coins::{DexFee, MmCoin, Ticker}; +use common::log; +use ethereum_types::{Address as EthAddress, U256}; +use futures::future::{join_all, BoxFuture}; +use mm2_core::mm_ctx::MmArc; +use mm2_err_handle::prelude::*; +use mm2_number::MmNumber; +use mm2_rpc::data::legacy::TakerAction; +use num_traits::CheckedDiv; +use std::collections::HashMap; +use std::ops::Deref; +use trading_api::one_inch_api::classic_swap_types::{ClassicSwapData, ClassicSwapQuoteCallBuilder}; +use trading_api::one_inch_api::client::{ + ApiClient, PortfolioApiMethods, PortfolioUrlBuilder, SwapApiMethods, SwapUrlBuilder, +}; +use trading_api::one_inch_api::errors::OneInchError; +use trading_api::one_inch_api::portfolio_types::{CrossPriceParams, CrossPricesSeries, DataGranularity}; + +/// To estimate src/dst price query price history for every 5 min +const CROSS_PRICES_GRANULARITY: DataGranularity = DataGranularity::FiveMin; +/// Use no more than this number of price history samples to estimate src/dst price +/// NOTE: we need the most actual price for estimation, however for limit = 1 the provider often returns an empty result +#[allow(unused)] +const CROSS_PRICES_LIMIT: u32 = 10; + +type ClassicSwapDataResult = MmResult; + +/// Internal struct to collect data for LR step +#[allow(dead_code)] // 'Clone' is detected as dead code in one combinator +#[derive(Clone)] +struct LrStepData { + /// Source coin or token ticker (to swap from) + _src_token: Ticker, + /// Source token contract address + src_contract: Option, + /// Source token amount (estimated) in smallest units + src_amount: Option, + /// Source token decimals + src_decimals: Option, + /// Destination coin or token ticker (to swap into) + _dst_token: Ticker, + /// Destination token contract address + dst_contract: Option, + /// Estimated destination token amount from LR step, in smallest units + dst_amount: Option, + /// Destination token decimals + dst_decimals: Option, + /// Chain id where LR swap occurs (obtained from the destination token) + chain_id: Option, + /// Estimated src token / dst token price. NOTE: the price is calculated in smallest units + lr_price: Option, + /// Estimated dex fee and taker fee amounts to include in LR step quote + /// TODO: return in the rpc result + taker_swap_params: Option, + /// Dex fee added to the source amount, needed to pay in the atomic swap + dex_fee: Option, + /// A quote from LR provider with destination amount for the LR step + lr_swap_data: Option, +} + +impl LrStepData { + fn new(src_ticker: Ticker, dst_ticker: Ticker) -> Self { + LrStepData { + _src_token: src_ticker, + src_contract: None, + src_decimals: None, + src_amount: None, + _dst_token: dst_ticker, + dst_contract: None, + dst_amount: None, + dst_decimals: None, + chain_id: None, + lr_price: None, + taker_swap_params: None, + dex_fee: None, + lr_swap_data: None, + } + } + + #[allow(clippy::result_large_err)] + fn get_chain_contract_info(&self) -> MmResult<(String, String, u64), LrSwapError> { + let src_contract = self + .src_contract + .as_ref() + .ok_or(LrSwapError::InternalError("Source LR contract not set".to_owned()))? + .addr_to_string(); + let dst_contract = self + .dst_contract + .as_ref() + .ok_or(LrSwapError::InternalError("Destination LR contract not set".to_owned()))? + .addr_to_string(); + let chain_id = self + .chain_id + .ok_or(LrSwapError::InternalError("LR chain id not set".to_owned()))?; + Ok((src_contract, dst_contract, chain_id)) + } + + #[allow(clippy::result_large_err)] + fn src_amount(&self) -> Result { + self.src_amount + .ok_or(LrSwapError::InternalError("no src_amount".to_owned())) + } + + #[allow(clippy::result_large_err, unused)] + fn dst_amount(&self) -> Result { + self.dst_amount + .ok_or(LrSwapError::InternalError("no dst_amount".to_owned())) + } + + #[allow(clippy::result_large_err)] + fn src_decimals(&self) -> Result { + self.src_decimals + .ok_or(LrSwapError::InternalError("no src_decimals".to_owned())) + } + + #[allow(clippy::result_large_err)] + fn dst_decimals(&self) -> Result { + self.dst_decimals + .ok_or(LrSwapError::InternalError("no dst_decimals".to_owned())) + } +} + +struct LrSwapCandidateInfo { + /// Data for liquidity routing before atomic swap + lr_data_0: Option, + /// Atomic swap order to fill + maker_order: AskOrBidOrder, + /// Estimated atomic swap initial sell amount, if LR_0 not present + atomic_swap_taker_amount: Option, + /// Estimated atomic swap final buy amount, if LR_1 not present + atomic_swap_maker_amount: Option, + /// Data for liquidity routing after atomic swap + lr_data_1: Option, +} + +/// Array to store data (possible swap route candidated, with prices for each step) needed for estimation +/// of the aggregated swap with liquidity routing, with the best total price +struct LrSwapCandidates { + // The array of swaps with LR candidates + inner: Vec, +} + +/// Mutable iterator over lr_data_0 field +struct LrStepDataMut0<'a> { + inner: std::slice::IterMut<'a, LrSwapCandidateInfo>, +} + +impl<'a> Iterator for LrStepDataMut0<'a> { + type Item = &'a mut LrStepData; + + fn next(&mut self) -> Option { + self.inner.next().and_then(|item| item.lr_data_0.as_mut()) + } +} + +/// Mutable iterator over lr_data_1 field +struct LrStepDataMut1<'a> { + inner: std::slice::IterMut<'a, LrSwapCandidateInfo>, +} + +impl<'a> Iterator for LrStepDataMut1<'a> { + type Item = &'a mut LrStepData; + + fn next(&mut self) -> Option { + self.inner.next().and_then(|item| item.lr_data_1.as_mut()) + } +} + +impl LrSwapCandidates { + fn iter_mut_lr_data_0(&mut self) -> LrStepDataMut0<'_> { + LrStepDataMut0 { + inner: self.inner.iter_mut(), + } + } + + fn iter_mut_lr_data_1(&mut self) -> LrStepDataMut1<'_> { + LrStepDataMut1 { + inner: self.inner.iter_mut(), + } + } + + /// Init LR data map from the source token (mytoken) and tokens from orders + async fn new_with_orders( + ctx: &MmArc, + user_base_ticker: Ticker, + user_rel_ticker: Ticker, + action: &TakerAction, + asks_coins: Vec, + bids_coins: Vec, + ) -> Self { + async fn tokens_in_same_chain(ctx: &MmArc, coin: &EthCoin, other_ticker: &Ticker) -> bool { + if let Some(chain_id) = coin.chain_id() { + if let Ok(other_coin) = get_coin_for_one_inch(ctx, other_ticker).await { + if let Some(other_chain_id) = other_coin.0.chain_id() { + if chain_id == other_chain_id { + return true; + } + } + } + } + false + } + + async fn create_candidate( + ctx: &MmArc, + maker_order: AskOrBidOrder, + user_src_ticker: &Ticker, + user_src_eth_coin: Option<&EthCoin>, + user_dst_ticker: &Ticker, + user_dst_eth_coin: Option<&EthCoin>, + ) -> Option { + let mut lr_data_0 = None; + let mut lr_data_1 = None; + // Add as a LR_0 candidate if the user source token and maker ask rel are in the same EVM chain, but different tokens + if let Some(user_src_eth_coin) = user_src_eth_coin { + log::debug!( + "checking asks for same chain for LR_0 user_src_eth_coin={} taker_ticker={}", + user_src_eth_coin.ticker(), + maker_order.taker_ticker() + ); + if tokens_in_same_chain(ctx, user_src_eth_coin, &maker_order.taker_ticker()).await + && user_src_eth_coin.ticker() != maker_order.taker_ticker().as_str() + { + // Route from source token to maker ask rel + lr_data_0 = Some(LrStepData::new( + user_src_eth_coin.ticker().to_owned(), + maker_order.taker_ticker(), + )); + } else { + log::debug!("checking same chain - NOT"); + } + } + + // Add as a LR_1 candidate if the ask base and the user destination token are in the same EVM chain, but different tokens + if let Some(user_dst_eth_coin) = user_dst_eth_coin { + log::debug!( + "checking asks for same chain for LR_1 user_dst_eth_coin={} maker_ticker={}", + user_dst_eth_coin.ticker(), + maker_order.maker_ticker() + ); + if tokens_in_same_chain(ctx, user_dst_eth_coin, &maker_order.maker_ticker()).await + && maker_order.maker_ticker() != user_dst_eth_coin.ticker() + { + // Route from source token to maker ask rel + lr_data_1 = Some(LrStepData::new( + maker_order.maker_ticker(), + user_dst_eth_coin.ticker().to_owned(), + )); + } else { + log::debug!("checking same chain - NOT"); + } + } + // do not add orders w/o any LR or w/o our coin + if (lr_data_0.is_some() || &maker_order.taker_ticker() == user_src_ticker) + && (lr_data_1.is_some() || &maker_order.maker_ticker() == user_dst_ticker) + { + log::debug!( + "ask candidate added: LR_0: {:?}/{:?} taker/maker: {}/{} LR_1 {:?}/{:?}", + lr_data_0.as_ref().map(|d| &d._src_token), + lr_data_0.as_ref().map(|d| &d._dst_token), + maker_order.taker_ticker(), + maker_order.maker_ticker(), + lr_data_1.as_ref().map(|d| &d._src_token), + lr_data_1.as_ref().map(|d| &d._dst_token) + ); + + let candidate = LrSwapCandidateInfo { + lr_data_0, + maker_order, + atomic_swap_taker_amount: None, + atomic_swap_maker_amount: None, + lr_data_1, + }; + Some(candidate) + } else { + log::debug!("ask candidate not added: lr_data_0.is_some: {} taker_ticker == user_src_ticker: {} lr_data_1.is_some: {} maker_ticker == user_dst_ticker: {}", + lr_data_0.is_some(), &maker_order.taker_ticker() == user_src_ticker, lr_data_1.is_some(), &maker_order.maker_ticker() == user_dst_ticker); + None + } + } + + let user_base = get_coin_for_one_inch(ctx, &user_base_ticker).await.ok(); + let user_rel = get_coin_for_one_inch(ctx, &user_rel_ticker).await.ok(); + let (user_src_ticker, user_src_eth_coin, user_dst_ticker, user_dst_eth_coin) = match action { + TakerAction::Buy => ( + user_rel_ticker, + user_rel.map(|tupl| tupl.0), + user_base_ticker, + user_base.map(|tupl| tupl.0), + ), + TakerAction::Sell => ( + user_base_ticker, + user_base.map(|tupl| tupl.0), + user_rel_ticker, + user_rel.map(|tupl| tupl.0), + ), + }; + + let mut inner = vec![]; + for asks_for_coin in asks_coins { + for order in asks_for_coin.orders { + let maker_order = AskOrBidOrder::Ask { + base: asks_for_coin.base.clone(), + order, + }; + if let Some(candidate) = create_candidate( + ctx, + maker_order, + &user_src_ticker, + user_src_eth_coin.as_ref(), + &user_dst_ticker, + user_dst_eth_coin.as_ref(), + ) + .await + { + inner.push(candidate); + } + } + } + for bids_for_coin in bids_coins { + for order in bids_for_coin.orders { + let maker_order = AskOrBidOrder::Bid { + rel: bids_for_coin.rel.clone(), + order, + }; + if let Some(candidate) = create_candidate( + ctx, + maker_order, + &user_src_ticker, + user_src_eth_coin.as_ref(), + &user_dst_ticker, + user_dst_eth_coin.as_ref(), + ) + .await + { + inner.push(candidate); + } + } + } + Self { inner } + } + + /// Estimate LR_0 destination tokens amounts required to fill maker orders for the required maker_amount. + /// The maker_amount is the source_amount of the LR_1 (if present) or the one provided in the params. + /// The function multiplies the maker_amount by the order price. + /// The maker_amount must be in coin units (with decimals) + async fn calc_lr_0_destination_amounts( + &mut self, + ctx: &MmArc, + user_buy_amount: &MmNumber, + ) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + let taker_ticker = &candidate.maker_order.taker_ticker(); + let taker_coin = lp_coinfind_or_err(ctx, taker_ticker).await.map_mm_err()?; + let maker_sell_price = candidate.maker_order.sell_price(); + let maker_amount = if let Some(ref lr_data_1) = candidate.lr_data_1 { + let Some(src_amount) = lr_data_1.src_amount else { + continue; // No src_amount means LR provider did not send us cross prices, skipping this candidate + }; + wei_to_coins_mm_number(src_amount, lr_data_1.src_decimals()?).map_mm_err()? + } else { + user_buy_amount.clone() + }; + let dst_amount = &maker_amount * &maker_sell_price; + let dst_amount = wei_from_coins_mm_number(&dst_amount, taker_coin.decimals()).map_mm_err()?; + if let Some(ref mut lr_data_0) = candidate.lr_data_0 { + lr_data_0.dst_amount = Some(dst_amount); + } else { + candidate.atomic_swap_taker_amount = Some(dst_amount); + } + + log::debug!( + "calc_lr_0_destination_amounts taker_ticker={} taker_coin.decimals()={} lr_data_0.dst_amount={:?}", + taker_ticker, + taker_coin.decimals(), + dst_amount + ); + } + Ok(()) + } + + fn update_lr_prices(lr_data_refs: Vec<&mut LrStepData>, lr_prices: HashMap<(Ticker, Ticker), Option>) { + for item in lr_data_refs { + if let Some(prices) = lr_prices.get(&(item._src_token.clone(), item._dst_token.clone())) { + item.lr_price = prices.clone(); + } + } + } + + /// Set LR_0 src_amount to user sell amount, or if LR_0 not present, set atomic_swap_src_amount + #[allow(clippy::result_large_err)] + async fn set_lr_0_src_amount(&mut self, ctx: &MmArc, mm_amount: &MmNumber) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + if let Some(ref mut lr_data_0) = candidate.lr_data_0 { + let amount = wei_from_coins_mm_number(mm_amount, lr_data_0.src_decimals()?).map_mm_err()?; + lr_data_0.src_amount = Some(amount); + } else { + let taker_coin = lp_coinfind_or_err(ctx, &candidate.maker_order.taker_ticker()) + .await + .map_mm_err()?; + let taker_amount = wei_from_coins_mm_number(mm_amount, taker_coin.decimals()).map_mm_err()?; + candidate.atomic_swap_taker_amount = Some(taker_amount); + } + } + Ok(()) + } + + fn update_lr_0_swap_data(&mut self, lr_swap_data: Vec<(usize, Option)>) { + for item in lr_swap_data { + if let Some(candidate) = self.inner.get_mut(item.0) { + if let Some(lr_data_0) = candidate.lr_data_0.as_mut() { + lr_data_0.lr_swap_data = item.1; + } + } + } + } + + fn update_lr_1_swap_data(&mut self, lr_swap_data: Vec<(usize, Option)>) { + for item in lr_swap_data { + if let Some(candidate) = self.inner.get_mut(item.0) { + if let Some(lr_data_1) = candidate.lr_data_1.as_mut() { + lr_data_1.lr_swap_data = item.1; + } + } + } + } + + async fn set_contracts(&mut self, ctx: &MmArc) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + if let Some(ref mut lr_data_0) = candidate.lr_data_0 { + let (src_coin, src_contract) = get_coin_for_one_inch(ctx, &lr_data_0._src_token).await?; + let (dst_coin, dst_contract) = get_coin_for_one_inch(ctx, &lr_data_0._dst_token).await?; + let src_decimals = src_coin.decimals(); + let dst_decimals = dst_coin.decimals(); + log::debug!( + "src_coin={} src_decimals={} dst_coin={} dst_decimals={}", + src_coin.ticker(), + src_decimals, + dst_coin.ticker(), + dst_decimals + ); + + #[cfg(feature = "for-tests")] + { + assert_ne!(src_decimals, 0); + assert_ne!(dst_decimals, 0); + } + + lr_data_0.src_contract = Some(src_contract); + lr_data_0.dst_contract = Some(dst_contract); + lr_data_0.src_decimals = Some(src_decimals); + lr_data_0.dst_decimals = Some(dst_decimals); + lr_data_0.chain_id = dst_coin.chain_id(); + } + if let Some(ref mut lr_data_1) = candidate.lr_data_1 { + let (src_coin, src_contract) = get_coin_for_one_inch(ctx, &lr_data_1._src_token).await?; + let (dst_coin, dst_contract) = get_coin_for_one_inch(ctx, &lr_data_1._dst_token).await?; + let src_decimals = src_coin.decimals(); + let dst_decimals = dst_coin.decimals(); + + #[cfg(feature = "for-tests")] + { + assert_ne!(src_decimals, 0); + assert_ne!(dst_decimals, 0); + } + + lr_data_1.src_contract = Some(src_contract); + lr_data_1.dst_contract = Some(dst_contract); + lr_data_1.src_decimals = Some(src_decimals); + lr_data_1.dst_decimals = Some(dst_decimals); + lr_data_1.chain_id = dst_coin.chain_id(); + } + } + Ok(()) + } + + /// Query 1inch token_0/token_1 prices in series and estimate token_0/token_1 average price + /// Assuming the calling code ensures that relation src_tokens : dst_tokens will never be M:N (but only 1:M or M:1) + async fn query_lr_prices(ctx: &MmArc, lr_data_refs: Vec<&mut LrStepData>) -> MmResult<(), LrSwapError> { + let mut prices_futs = vec![]; + let mut src_dst = vec![]; // TODO: use hash map + for lr_data in lr_data_refs.iter() { + let (src_contract, dst_contract, chain_id) = lr_data.get_chain_contract_info()?; + // Run src / dst token price query: + let query_params = CrossPriceParams::new(chain_id, src_contract, dst_contract) + .with_granularity(Some(CROSS_PRICES_GRANULARITY)) + // We do not use the limit parameter to get multiple samples, because some periods may be missing (if there were no trades) + .build_query_params() + .map_mm_err()?; + let url = PortfolioUrlBuilder::create_api_url_builder(ctx, PortfolioApiMethods::CrossPrices) + .map_mm_err()? + .with_query_params(query_params) + .build() + .map_mm_err()?; + let fut = ApiClient::call_api::(url); + prices_futs.push(fut); + src_dst.push((lr_data._src_token.clone(), lr_data._dst_token.clone())); + } + let prices_in_series = join_all(prices_futs).await.into_iter().map(|res| res.ok()); // set bad results to None to preserve prices_in_series length + + let quotes = src_dst + .into_iter() + .zip(prices_in_series) + .map(|((src, dst), series)| { + // Get src/dst price. NOTE: cross_prices return prices in ETH coins or token units (not in smallest units) + let dst_price = cross_prices_close(series); + ((src, dst), dst_price) + }) + .collect::>(); + + log_cross_prices("es); + LrSwapCandidates::update_lr_prices(lr_data_refs, quotes); + Ok(()) + } + + /// Estimate the needed source amount for LR_0 step, by dividing the dst amount by the src/dst price. + /// The dst_amount should be set at this point + #[allow(clippy::result_large_err)] + fn estimate_lr_0_source_amounts(&mut self) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + let Some(ref mut lr_data_0) = candidate.lr_data_0 else { + log::debug!("estimate_lr_0_source_amounts no lr_data_0 skipping candidate"); + continue; + }; + let Some(dst_amount) = lr_data_0.dst_amount else { + log::debug!( + "estimate_lr_0_source_amounts no lr_data_0.dst_amount for {}/{} skipping candidate", + lr_data_0._src_token, + lr_data_0._dst_token + ); + continue; // We may not calculate dst_amount if no cross prices were received at LR_1 + }; + let Some(ref lr_price) = lr_data_0.lr_price else { + log::debug!( + "estimate_lr_0_source_amounts no lr_data_0.lr_price for {}/{} skipping candidate", + lr_data_0._src_token, + lr_data_0._dst_token + ); + continue; + }; + // Get in coin units + // Note: cross_prices API price is returned in src_coin / dst_coin units + let dst_amount = wei_to_coins_mm_number(dst_amount, lr_data_0.dst_decimals()?).map_mm_err()?; + if let Some(src_amount) = &dst_amount.checked_div(lr_price) { + lr_data_0.src_amount = + Some(wei_from_coins_mm_number(src_amount, lr_data_0.src_decimals()?).map_mm_err()?); + log::debug!( + "estimate_lr_0_source_amounts maker_order.taker_ticker={} lr_price={} lr_data_0.src_amount={:?}", + candidate.maker_order.taker_ticker(), + lr_price.to_decimal(), + lr_data_0.src_amount.unwrap_or_default() + ); + } else { + log::debug!( + "estimate_lr_0_source_amounts bad division dst_amount {} on lr_price {}, skipping candidate", + dst_amount.to_decimal(), + lr_price.to_decimal() + ); + } + } + Ok(()) + } + + /// Estimate the needed source amount for LR_1 step, by dividing the known dst amount by the src/dst price + /// The dst amount is set by the User in the 'buy' coin units + #[allow(clippy::result_large_err)] + async fn estimate_lr_1_source_amounts_from_dest( + &mut self, + ctx: &MmArc, + user_dst_amount: &MmNumber, + ) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + if let Some(ref mut lr_data_1) = candidate.lr_data_1 { + let Some(ref lr_price) = lr_data_1.lr_price else { + continue; // No LR provider price - skipping + }; + // Get in coin units + // Note: cross prices API price is returned in coin / coin units (not in smallest units)) + let dst_amount = wei_from_coins_mm_number(user_dst_amount, lr_data_1.dst_decimals()?).map_mm_err()?; + let dst_amount = mm_number_from_u256(dst_amount); + if let Some(src_amount) = &dst_amount.checked_div(lr_price) { + lr_data_1.src_amount = + Some(wei_from_coins_mm_number(src_amount, lr_data_1.src_decimals()?).map_mm_err()?); + log::debug!( + "estimate_lr_1_source_amounts_from_dest lr_data_1._src_token={} lr_price={} lr_data_1.src_amount={:?}", + lr_data_1._src_token, + lr_price.to_decimal(), + lr_data_1.src_amount + ); + } + } else { + let maker_coin = lp_coinfind_or_err(ctx, &candidate.maker_order.maker_ticker()) + .await + .map_mm_err()?; + let dst_amount = wei_from_coins_mm_number(user_dst_amount, maker_coin.decimals()).map_mm_err()?; + candidate.atomic_swap_maker_amount = Some(dst_amount); + } + } + Ok(()) + } + + /// Estimate the LR_1 source amount either from the destination amount from the LR_0 quote (if the LR_0 exists) + /// or from the User's provided sell amount (in coins) + /// For LR_0 we need to deduct dex fee + async fn estimate_lr_1_source_amounts_from_lr_0( + &mut self, + ctx: &MmArc, + user_sell_amount: &MmNumber, + ) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + let taker_amount = if let Some(ref lr_data_0) = candidate.lr_data_0 { + let Some(ref lr_swap_data) = lr_data_0.lr_swap_data else { + log::debug!( + "estimate_lr_1_source_amounts_from_lr_0 lr_data_0 {}/{} no swap_data skipping", + lr_data_0._src_token, + lr_data_0._dst_token + ); + continue; // No LR provider quote - skip this candidate + }; + let quote_dst_amount = U256::from_dec_str(&lr_swap_data.dst_amount)?; // Get the 'real' destination amount from the LR quote (not estimated) + let est_dst_amount = candidate.lr_data_0.as_ref().and_then(|lr_data_0| lr_data_0.dst_amount); + log::debug!("estimate_lr_1_source_amounts_from_lr_0 lr_data_0 {}/{} quote_dst_amount={quote_dst_amount} est_dst_amount={est_dst_amount:?}", + lr_data_0._src_token, lr_data_0._dst_token); + let volume_with_fees = + wei_to_coins_mm_number(quote_dst_amount, lr_data_0.dst_decimals()?).map_mm_err()?; + let maker_ticker = candidate.maker_order.maker_ticker(); + let taker_ticker = candidate.maker_order.taker_ticker(); + let dex_fee_rate = DexFee::dex_fee_rate(&taker_ticker, &maker_ticker); + volume_with_fees / (MmNumber::from("1") + dex_fee_rate) // Deduct dex fee to get the atomic swap taker amount + } else { + user_sell_amount.clone() // TODO: use atomic_swap_taker_amount + }; + // Get maker amount from taker amount + let maker_ticker = &candidate.maker_order.maker_ticker(); + let maker_coin = lp_coinfind_or_err(ctx, maker_ticker).await.map_mm_err()?; + let maker_sell_price: MmNumber = candidate.maker_order.sell_price(); // In maker/taker units + let Some(maker_amount) = &taker_amount.checked_div(&maker_sell_price) else { + continue; + }; + let maker_amount = wei_from_coins_mm_number(maker_amount, maker_coin.decimals()).map_mm_err()?; + if let Some(ref mut lr_data_1) = candidate.lr_data_1 { + lr_data_1.src_amount = Some(maker_amount); + } else { + candidate.atomic_swap_maker_amount = Some(maker_amount); // if no LR_1, store as atomic_swap maker_amount for total price calc + } + log::debug!( + "estimate_lr_1_source_amounts_from_lr_0 maker_ticker={} lr_data_1.src_token={:?} taker_amount={} maker_sell_price={} maker_coin.decimals()={} maker_amount={:?}", + maker_ticker, + candidate.lr_data_1.as_ref().map(|lr_data| &lr_data._src_token), + taker_amount, + maker_sell_price, + maker_coin.decimals(), + maker_amount + ); + } + Ok(()) + } + + /// Estimate dex and trade fees to do the atomic swap and add them to the source amount. + /// The dex fee will be deducted from the destination amount (in proportion) when the atomic swap is running. + #[allow(clippy::result_large_err)] + async fn estimate_lr_0_fee_amounts(&mut self, ctx: &MmArc) -> MmResult<(), LrSwapError> { + for candidate in self.inner.iter_mut() { + let Some(ref lr_data_0) = candidate.lr_data_0 else { + continue; + }; + let Some(src_amount) = lr_data_0.src_amount else { + continue; // No src_amount means LR provider did not send us cross prices, skipping this candidate + }; + let atomic_swap_maker_token = candidate.maker_order.maker_ticker(); + let src_amount_mm_num = wei_to_coins_mm_number(src_amount, lr_data_0.src_decimals()?).map_mm_err()?; + let src_coin = lp_coinfind_or_err(ctx, &lr_data_0._src_token).await.map_mm_err()?; // TODO: when I used get_coin_for_one_inch(), throwing a error if the order coin not EVM, 'lr_quote.rs' is lost in the error path. Why? dedup()? + let dex_fee = DexFee::new_from_taker_coin(src_coin.deref(), &atomic_swap_maker_token, &src_amount_mm_num) + .fee_amount(); // TODO: use simply DexFee::rate? + let Some(ref mut lr_data_0) = candidate.lr_data_0 else { + continue; + }; + // Add dex fee to the amount + //let src_amount_with_fees = LrStepData::add_fees_to_amount(src_amount_mm_num, src_token, &taker_swap_params)?; + let src_amount_with_fees = &src_amount_mm_num + &dex_fee; + log::debug!("estimate_lr_0_fee_amounts src_token={} src_amount_mm_num={src_amount_mm_num} dex_fee={dex_fee} src_amount_with_fees={src_amount_with_fees}", lr_data_0._src_token); + lr_data_0.src_amount = + Some(wei_from_coins_mm_number(&src_amount_with_fees, lr_data_0.src_decimals()?).map_mm_err()?); + //lr_data_0.taker_swap_params = Some(taker_swap_params); + lr_data_0.dex_fee = Some(dex_fee); + } + Ok(()) + } + + /// Run 1inch requests to get LR quotes to convert source tokens to tokens in orders + async fn run_lr_0_quotes(&mut self, ctx: &MmArc) -> MmResult<(), LrSwapError> { + let mut idx = vec![]; + let mut quote_futs = vec![]; + for candidate in self.inner.iter().enumerate() { + let Some(ref lr_data_0) = candidate.1.lr_data_0 else { + log::debug!("run_lr_0_quotes skipping candidate={} no lr_data_0", candidate.0); + continue; + }; + let Some(quote_fut) = create_quote_call(ctx, lr_data_0)? else { + log::debug!( + "run_lr_0_quotes skipping candidate={} could not create quote", + candidate.0 + ); + continue; + }; + // TODO: do not repeat 1inch calls for same pair: + // let indexed_fut = async { + // (candidate.0, quote_fut.await.ok()) + // }; + quote_futs.push(quote_fut); + idx.push(candidate.0); + } + let lr_quotes = join_all(quote_futs).await.into_iter().map(|res| res.ok()); // if a bad result received (for e.g. low liquidity) set to None to preserve swap_data length + let lr_quotes_indexed = idx.into_iter().zip(lr_quotes).collect(); + self.update_lr_0_swap_data(lr_quotes_indexed); + Ok(()) + } + + /// Run 1inch requests to get LR quotes to convert source tokens to tokens in orders + async fn run_lr_1_quotes(&mut self, ctx: &MmArc) -> MmResult<(), LrSwapError> { + let mut idx = vec![]; + let mut quote_futs = vec![]; + for candidate in self.inner.iter().enumerate() { + let Some(ref lr_data_1) = candidate.1.lr_data_1 else { + continue; + }; + let Some(fut) = create_quote_call(ctx, lr_data_1)? else { + continue; + }; + // TODO: combine index in a future + quote_futs.push(fut); + idx.push(candidate.0); + } + let lr_quotes = join_all(quote_futs).await.into_iter().map(|res| res.ok()); // if a bad result received (for e.g. low liquidity) set to None to preserve swap_data length + let lr_quotes_indexed = idx.into_iter().zip(lr_quotes).collect(); + self.update_lr_1_swap_data(lr_quotes_indexed); + Ok(()) + } + + async fn check_order_limits(ctx: &MmArc, candidate: &LrSwapCandidateInfo) -> MmResult { + let atomic_swap_taker_amount = if let Some(ref lr_data_0) = candidate.lr_data_0 { + let Some(ref lr_swap_data) = lr_data_0.lr_swap_data else { + log::debug!( + "check_order_limits: {}/{} no LR_0 quote, skipping candidate", + lr_data_0._src_token, + lr_data_0._dst_token + ); + return Ok(false); // No LR provider quote - skip this candidate + }; + let quote_dst_amount = U256::from_dec_str(&lr_swap_data.dst_amount)?; // Get the 'real' destination amount from the LR quote (not estimated) + wei_to_coins_mm_number(quote_dst_amount, lr_data_0.dst_decimals()?).map_mm_err()? + } else { + let atomic_swap_taker_amount = candidate + .atomic_swap_taker_amount + .ok_or(LrSwapError::InternalError("no atomic swap taker amount".to_owned()))?; + let taker_coin = lp_coinfind_or_err(ctx, &candidate.maker_order.taker_ticker()) + .await + .map_mm_err()?; + wei_to_coins_mm_number(atomic_swap_taker_amount, taker_coin.decimals()).map_mm_err()? + }; + log::debug!( + "check_order_limits: {}/{} atomic_swap_taker_amount={} max_taker_vol={} min_taker_vol={}", + candidate.maker_order.maker_ticker(), + candidate.maker_order.taker_ticker(), + atomic_swap_taker_amount.to_decimal(), + candidate.maker_order.max_taker_vol().decimal, + candidate.maker_order.min_taker_vol().decimal, + ); + if atomic_swap_taker_amount.to_ratio() > candidate.maker_order.max_taker_vol().rational + || atomic_swap_taker_amount.to_ratio() < candidate.maker_order.min_taker_vol().rational + { + log::debug!( + "check_order_limits: {}/{} out of order min/max, skipping candidate", + candidate.maker_order.maker_ticker(), + candidate.maker_order.taker_ticker() + ); + return Ok(false); + } + + let atomic_swap_maker_amount = if let Some(ref lr_data_1) = candidate.lr_data_1 { + wei_to_coins_mm_number(lr_data_1.src_amount()?, lr_data_1.src_decimals()?).map_mm_err()? + } else { + let atomic_swap_maker_amount = candidate + .atomic_swap_maker_amount + .ok_or(LrSwapError::InternalError("no atomic swap maker amount".to_owned()))?; + let maker_coin = lp_coinfind_or_err(ctx, &candidate.maker_order.maker_ticker()) + .await + .map_mm_err()?; + wei_to_coins_mm_number(atomic_swap_maker_amount, maker_coin.decimals()).map_mm_err()? + }; + log::debug!( + "check_order_limits: {}/{} atomic_swap_maker_amount={} max_maker_vol={} min_maker_vol={}", + candidate.maker_order.maker_ticker(), + candidate.maker_order.taker_ticker(), + atomic_swap_maker_amount.to_decimal(), + candidate.maker_order.max_maker_vol().decimal, + candidate.maker_order.min_maker_vol().decimal, + ); + if atomic_swap_maker_amount.to_ratio() > candidate.maker_order.max_maker_vol().rational + || atomic_swap_maker_amount.to_ratio() < candidate.maker_order.min_maker_vol().rational + { + log::debug!( + "check_order_limits: {}/{} out of order min/max, skipping candidate", + candidate.maker_order.maker_ticker(), + candidate.maker_order.taker_ticker() + ); + return Ok(false); + } + Ok(true) + } + + /// Select the best swap path, by minimum of total swap price, including LR steps and atomic swap) + #[allow(clippy::result_large_err)] + async fn select_best_swap(self, ctx: &MmArc) -> MmResult<(LrSwapCandidateInfo, MmNumber), LrSwapError> { + let mut best_price = None; + let mut best_candidate = None; + let candidate_len = self.inner.len(); + for candidate in self.inner { + // For debug printing; + let mut lr_0_src_token_print = None; + let mut lr_0_dst_token_print = None; + let taker_ticker_print = candidate.maker_order.taker_ticker(); + let maker_ticker_print = candidate.maker_order.maker_ticker(); + let mut lr_1_src_token_print = None; + let mut lr_1_dst_token_print = None; + + if !LrSwapCandidates::check_order_limits(ctx, &candidate).await? { + continue; + } + + let sell_amount = if let Some(ref lr_data_0) = candidate.lr_data_0 { + lr_0_src_token_print = Some(lr_data_0._src_token.clone()); + lr_0_dst_token_print = Some(lr_data_0._dst_token.clone()); + if lr_data_0.lr_swap_data.is_none() { + log::debug!("select_best_swap: no LR_0 quote for {lr_0_src_token_print:?}/{lr_0_dst_token_print:?}, skipping candidate"); + continue; // No LR provider quote received - skip this candidate + } + // Exclude dex fee from the LR_0 src_amount for total_price calculation + let volume_with_fees = + wei_to_coins_mm_number(lr_data_0.src_amount()?, lr_data_0.src_decimals()?).map_mm_err()?; + let maker_ticker = candidate.maker_order.maker_ticker(); + let taker_ticker = candidate.maker_order.taker_ticker(); + let dex_fee_rate = DexFee::dex_fee_rate(&taker_ticker, &maker_ticker); + volume_with_fees / (MmNumber::from("1") + dex_fee_rate) + } else { + let atomic_swap_taker_amount = candidate + .atomic_swap_taker_amount + .ok_or(LrSwapError::InternalError("no atomic swap taker amount".to_owned()))?; + let taker_coin = lp_coinfind_or_err(ctx, &candidate.maker_order.taker_ticker()) + .await + .map_mm_err()?; + wei_to_coins_mm_number(atomic_swap_taker_amount, taker_coin.decimals()).map_mm_err()? + }; + + let buy_amount = if let Some(ref lr_data_1) = candidate.lr_data_1 { + lr_1_src_token_print = Some(lr_data_1._src_token.clone()); + lr_1_dst_token_print = Some(lr_data_1._dst_token.clone()); + let Some(ref lr_swap_data) = lr_data_1.lr_swap_data else { + log::debug!("select_best_swap: no LR_1 quote for {lr_1_src_token_print:?}/{lr_1_dst_token_print:?}, skipping candidate"); + continue; // No LR provider quote - skip this candidate + }; + let quote_dst_amount = U256::from_dec_str(&lr_swap_data.dst_amount)?; + wei_to_coins_mm_number(quote_dst_amount, lr_data_1.dst_decimals()?).map_mm_err()? + } else { + let atomic_swap_maker_amount = candidate + .atomic_swap_maker_amount + .ok_or(LrSwapError::InternalError("no atomic swap maker amount".to_owned()))?; + let maker_coin = lp_coinfind_or_err(ctx, &candidate.maker_order.maker_ticker()) + .await + .map_mm_err()?; + wei_to_coins_mm_number(atomic_swap_maker_amount, maker_coin.decimals()).map_mm_err()? + }; + + log::debug!("select_best_swap: candidate: LR_0: {lr_0_src_token_print:?}/{lr_0_dst_token_print:?} Atomic swap: {taker_ticker_print}/{maker_ticker_print} LR_1: {lr_1_src_token_print:?}/{lr_1_dst_token_print:?} sell_amount={} buy_amount={} total_price={:?}", + sell_amount.to_decimal(), buy_amount.to_decimal(), sell_amount.checked_div(&buy_amount).map(|amount| amount.to_decimal())); + let Some(total_price) = sell_amount.checked_div(&buy_amount) else { + log::debug!("select_best_swap: total_price is None, skipping candidate"); + continue; + }; + if let Some(best_price_unwrapped) = best_price.as_ref() { + if &total_price < best_price_unwrapped { + best_price = Some(total_price); + best_candidate = Some(candidate); + } + } else { + best_price = Some(total_price); + best_candidate = Some(candidate); + } + } + + if let Some(best_price) = best_price { + let best_candidate = best_candidate.ok_or(LrSwapError::InternalError("no best_candidate".to_owned()))?; + Ok((best_candidate, best_price)) + } else { + MmError::err(LrSwapError::BestLrSwapNotFound { + candidates: candidate_len as u32, + }) + } + } +} + +/// Implementation code to find the optimal swap path (with the lowest total price) from the `user_base` coin to the `user_rel` coin +/// (`Aggregated taker swap` path). +/// This path includes: +/// - An atomic swap step: used to fill a specific ask or bid order provided in the parameters. +/// - A liquidity routing (LR) step before and/or after the atomic swap: converts `user_base` or `user_sell` into the coin in the order. +/// +/// TODO: Note that in this function we request 1inch quotas (not swap details) so no slippage is applied for this. +/// When the actual aggregated swap is running we would create a new 1inch request for swap detail for these tokens +/// but the new price for them may be different and the estimated amount after the liquidity routing may deviate much +/// from the value needed to fill the atomic maker order (like User wanted this). +/// Maybe we should request for swap details here and this will allow to ensure slippage for the LR amount which we return here. +/// +/// TODO: it's not only the slippage problem though. We try to estimate the needed source amount by querying the OHLC price and +/// this may also add error to the error from the slippage. We should take this error into account too. +/// +/// TODO: if we do LR_0 for a platform coin we should reserve some platform coin amount for fees on other swap steps. +pub async fn find_best_swap_path_with_lr( + ctx: &MmArc, + user_base: Ticker, + user_rel: Ticker, + action: &TakerAction, + asks: Vec, + bids: Vec, + amount: &MmNumber, +) -> MmResult< + ( + Option, + AskOrBidOrder, + Option, + Option, + MmNumber, + ), + LrSwapError, +> { + let mut candidates = LrSwapCandidates::new_with_orders(ctx, user_base, user_rel, action, asks, bids).await; + candidates.set_contracts(ctx).await?; + match action { + TakerAction::Buy => { + // Calculate amounts from the destination coin 'buy' amount (backwards) + // Query src/dst price for LR_1 step (to estimate the source amount). + LrSwapCandidates::query_lr_prices(ctx, candidates.iter_mut_lr_data_1().collect::>()).await?; + candidates.estimate_lr_1_source_amounts_from_dest(ctx, amount).await?; + // Query src/dst price for LR_0 step (to estimate the source amount). + // TODO: good to query prices for LR_0 and LR_1 in one join + LrSwapCandidates::query_lr_prices(ctx, candidates.iter_mut_lr_data_0().collect::>()).await?; + candidates.calc_lr_0_destination_amounts(ctx, amount).await?; + candidates.estimate_lr_0_source_amounts()?; + candidates.estimate_lr_0_fee_amounts(ctx).await?; + candidates.run_lr_0_quotes(ctx).await?; + candidates + .estimate_lr_1_source_amounts_from_lr_0(ctx, &MmNumber::from("0")) + .await?; // Recalculate LR_1 src amount based on LR provider's quote + candidates.run_lr_1_quotes(ctx).await?; + }, + TakerAction::Sell => { + // Calculate amounts starting from the source coin 'sell' amount (forwards) + candidates.set_lr_0_src_amount(ctx, amount).await?; + candidates.estimate_lr_0_fee_amounts(ctx).await?; + candidates.run_lr_0_quotes(ctx).await?; + candidates.estimate_lr_1_source_amounts_from_lr_0(ctx, amount).await?; + candidates.run_lr_1_quotes(ctx).await?; + }, + } + let (best_candidate, best_price) = candidates.select_best_swap(ctx).await?; + let atomic_swap_sell_volume = { + if best_candidate.lr_data_0.is_none() && best_candidate.atomic_swap_taker_amount.is_none() { + return MmError::err(LrSwapError::InternalError("no taker amount".to_owned())); + } + let taker_coin = lp_coinfind_or_err(ctx, &best_candidate.maker_order.taker_ticker()) + .await + .map_mm_err()?; + best_candidate + .atomic_swap_taker_amount + .map(|amount| wei_to_coins_mm_number(amount, taker_coin.decimals())) + .transpose() + .map_mm_err()? + }; + let lr_data_ext_0 = best_candidate + .lr_data_0 + .map(|lr_data| -> Result<_, LrSwapError> { + Ok(ClassicSwapDataExt { + src_amount: lr_data + .src_amount + .ok_or(LrSwapError::InternalError("no src_amount".to_owned()))?, + api_details: lr_data + .lr_swap_data + .ok_or(LrSwapError::InternalError("no lr_swap_data".to_owned()))?, + chain_id: lr_data + .chain_id + .ok_or(LrSwapError::InternalError("no chain_id".to_owned()))?, + }) + }) + .transpose()?; + let lr_data_ext_1 = best_candidate + .lr_data_1 + .map(|lr_data| -> Result<_, LrSwapError> { + Ok(ClassicSwapDataExt { + src_amount: lr_data + .src_amount + .ok_or(LrSwapError::InternalError("no src_amount".to_owned()))?, + api_details: lr_data + .lr_swap_data + .ok_or(LrSwapError::InternalError("no lr_swap_data".to_owned()))?, + chain_id: lr_data + .chain_id + .ok_or(LrSwapError::InternalError("no chain_id".to_owned()))?, + }) + }) + .transpose()?; + Ok(( + lr_data_ext_0, + best_candidate.maker_order, + atomic_swap_sell_volume, + lr_data_ext_1, + best_price, + )) +} + +/// Process 1inch token cross_prices history and return average price for all history +#[allow(unused)] +fn cross_prices_average(series: Option) -> Option { + let series = series?; + if series.is_empty() { + return None; + } + let total: MmNumber = series.iter().fold(MmNumber::from(0), |acc, price_data| { + acc + MmNumber::from(&price_data.avg) + }); + Some(total / MmNumber::from(series.len() as u64)) +} + +/// Get the latest close price from cross_prices history +fn cross_prices_close(series: Option) -> Option { + let series = series?; + if series.is_empty() { + return None; + } + series.first().map(|p| p.close.clone().into()) +} + +fn log_cross_prices(prices: &HashMap<(Ticker, Ticker), Option>) { + for p in prices { + log::debug!( + "1inch cross_prices result(close)={:?} {:?}", + p, + p.1.clone().map(|v| v.to_decimal()) + ); + } +} + +#[allow(clippy::result_large_err)] +fn create_quote_call<'a>( + ctx: &MmArc, + lr_data: &'a LrStepData, +) -> MmResult>, LrSwapError> { + let Some(src_amount) = lr_data.src_amount else { + return Ok(None); + }; + let (src_contract, dst_contract, chain_id) = lr_data.get_chain_contract_info()?; + let query_params = ClassicSwapQuoteCallBuilder::new(src_contract, dst_contract, src_amount.to_string()) + .with_include_tokens_info(Some(true)) + .with_include_gas(Some(true)) + .build_query_params() + .map_mm_err()?; + let url = SwapUrlBuilder::create_api_url_builder(ctx, chain_id, SwapApiMethods::ClassicSwapQuote) + .map_mm_err()? + .with_query_params(query_params) + .build() + .map_mm_err()?; + let fut = ApiClient::call_api::(url); + Ok(Some(Box::pin(fut))) +} diff --git a/mm2src/mm2_main/src/mm2.rs b/mm2src/mm2_main/src/mm2.rs index 9c36e9698a..8fd0912683 100644 --- a/mm2src/mm2_main/src/mm2.rs +++ b/mm2src/mm2_main/src/mm2.rs @@ -84,6 +84,7 @@ pub mod lp_ordermatch; pub mod lp_stats; pub mod lp_swap; pub mod lp_wallet; +pub mod lr_swap; pub mod rpc; #[cfg(not(any(target_arch = "wasm32", target_os = "windows")))] pub mod shutdown_signal_event; diff --git a/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs b/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs index d2f46f6d88..4c443bcf10 100644 --- a/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs +++ b/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs @@ -16,14 +16,14 @@ use crate::lp_swap::swap_v2_rpcs::{active_swaps_rpc, my_recent_swaps_rpc, my_swa use crate::lp_swap::{get_locked_amount_rpc, max_maker_vol, recreate_swap_data, trade_preimage_rpc}; use crate::lp_wallet::{change_mnemonic_password, delete_wallet_rpc, get_mnemonic_rpc, get_wallet_names_rpc}; use crate::rpc::lp_commands::db_id::get_shared_db_id; -use crate::rpc::lp_commands::lr_swap::{ - lr_execute_routed_trade_rpc, lr_find_best_quote_rpc, lr_get_quotes_for_tokens_rpc, -}; -use crate::rpc::lp_commands::one_inch::rpcs::{ +use crate::rpc::lp_commands::ext_api::{ one_inch_v6_0_classic_swap_contract_rpc, one_inch_v6_0_classic_swap_create_rpc, one_inch_v6_0_classic_swap_liquidity_sources_rpc, one_inch_v6_0_classic_swap_quote_rpc, one_inch_v6_0_classic_swap_tokens_rpc, }; +use crate::rpc::lp_commands::lr_swap_api::{ + lr_execute_routed_trade_rpc, lr_find_best_quote_rpc, lr_get_quotes_for_tokens_rpc, +}; use crate::rpc::lp_commands::pubkey::*; use crate::rpc::lp_commands::tokens::get_token_info; use crate::rpc::lp_commands::tokens::{approve_token_rpc, get_token_allowance_rpc}; diff --git a/mm2src/mm2_main/src/rpc/lp_commands/ext_api.rs b/mm2src/mm2_main/src/rpc/lp_commands/ext_api.rs new file mode 100644 index 0000000000..fedf61f25c --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/ext_api.rs @@ -0,0 +1,127 @@ +//! RPC implementation for use API of external trading service providers (1inch etc). + +use crate::lr_swap::lr_helpers::{check_if_one_inch_supports_pair, get_coin_for_one_inch}; +use coins::eth::u256_from_big_decimal; +use coins::{CoinWithDerivationMethod, MmCoin}; +use ext_api_errors::ExtApiRpcError; +use ext_api_helpers::{make_classic_swap_create_params, make_classic_swap_quote_params}; +use ext_api_types::{ + AggregationContractRequest, ClassicSwapCreateRequest, ClassicSwapDetails, ClassicSwapLiquiditySourcesRequest, + ClassicSwapLiquiditySourcesResponse, ClassicSwapQuoteRequest, ClassicSwapResponse, ClassicSwapTokensRequest, + ClassicSwapTokensResponse, +}; +use mm2_core::mm_ctx::MmArc; +use mm2_err_handle::prelude::*; +use trading_api::one_inch_api::classic_swap_types::{ProtocolsResponse, TokensResponse}; +use trading_api::one_inch_api::client::{ApiClient, SwapApiMethods, SwapUrlBuilder}; + +pub(crate) mod ext_api_errors; +pub(crate) mod ext_api_helpers; +#[cfg(test)] +mod ext_api_tests; +pub(crate) mod ext_api_types; + +/// "1inch_v6_0_classic_swap_contract" rpc impl +/// used to get contract address (for e.g. to approve funds) +pub async fn one_inch_v6_0_classic_swap_contract_rpc( + _ctx: MmArc, + _req: AggregationContractRequest, +) -> MmResult { + Ok(ApiClient::classic_swap_contract().to_owned()) +} + +/// "1inch_classic_swap_quote" rpc impl +pub async fn one_inch_v6_0_classic_swap_quote_rpc( + ctx: MmArc, + req: ClassicSwapQuoteRequest, +) -> MmResult { + let (base, base_contract) = get_coin_for_one_inch(&ctx, &req.base).await.map_mm_err()?; + let (rel, rel_contract) = get_coin_for_one_inch(&ctx, &req.rel).await.map_mm_err()?; + let base_chain_id = base.chain_id().ok_or(ExtApiRpcError::ChainNotSupported)?; + let rel_chain_id = rel.chain_id().ok_or(ExtApiRpcError::ChainNotSupported)?; + check_if_one_inch_supports_pair(base_chain_id, rel_chain_id).map_mm_err()?; + let sell_amount = u256_from_big_decimal(&req.amount.to_decimal(), base.decimals()) + .mm_err(|err| ExtApiRpcError::InvalidParam(err.to_string()))?; + let query_params = make_classic_swap_quote_params(base_contract, rel_contract, sell_amount, req.opt_params) + .build_query_params() + .map_mm_err()?; + let url = SwapUrlBuilder::create_api_url_builder(&ctx, base_chain_id, SwapApiMethods::ClassicSwapQuote) + .map_mm_err()? + .with_query_params(query_params) + .build() + .map_mm_err()?; + let quote = ApiClient::call_api(url).await.map_mm_err()?; + ClassicSwapResponse::from_api_classic_swap_data(&ctx, base_chain_id, sell_amount, quote) // use 'base' as amount in errors is in the src coin + .mm_err(|err| ExtApiRpcError::OneInchDataError(err.to_string())) +} + +/// "1inch_classic_swap_create" rpc implementation +/// This rpc actually returns a transaction to call the 1inch swap aggregation contract. GUI should sign it and send to the chain. +/// We don't verify the transaction in any way and trust the 1inch api. +pub async fn one_inch_v6_0_classic_swap_create_rpc( + ctx: MmArc, + req: ClassicSwapCreateRequest, +) -> MmResult { + let (base, base_contract) = get_coin_for_one_inch(&ctx, &req.base).await.map_mm_err()?; + let (rel, rel_contract) = get_coin_for_one_inch(&ctx, &req.rel).await.map_mm_err()?; + let base_chain_id = base.chain_id().ok_or(ExtApiRpcError::ChainNotSupported)?; + let rel_chain_id = rel.chain_id().ok_or(ExtApiRpcError::ChainNotSupported)?; + check_if_one_inch_supports_pair(base_chain_id, rel_chain_id).map_mm_err()?; + let sell_amount = u256_from_big_decimal(&req.amount.to_decimal(), base.decimals()) + .mm_err(|err| ExtApiRpcError::InvalidParam(err.to_string()))?; + let single_address = base.derivation_method().single_addr_or_err().await.map_mm_err()?; + + let query_params = make_classic_swap_create_params( + base_contract, + rel_contract, + sell_amount, + single_address, + req.slippage, + req.opt_params, + ) + .build_query_params() + .map_mm_err()?; + let url = SwapUrlBuilder::create_api_url_builder(&ctx, base_chain_id, SwapApiMethods::ClassicSwapCreate) + .map_mm_err()? + .with_query_params(query_params) + .build() + .map_mm_err()?; + let swap_with_tx = ApiClient::call_api(url).await.map_mm_err()?; + ClassicSwapResponse::from_api_classic_swap_data(&ctx, base_chain_id, sell_amount, swap_with_tx) + .mm_err(|err| ExtApiRpcError::OneInchDataError(err.to_string())) +} + +/// "1inch_v6_0_classic_swap_liquidity_sources" rpc implementation. +/// Returns list of DEX available for routing with the 1inch Aggregation contract +pub async fn one_inch_v6_0_classic_swap_liquidity_sources_rpc( + ctx: MmArc, + req: ClassicSwapLiquiditySourcesRequest, +) -> MmResult { + let url = SwapUrlBuilder::create_api_url_builder(&ctx, req.chain_id, SwapApiMethods::LiquiditySources) + .map_mm_err()? + .build() + .map_mm_err()?; + let response: ProtocolsResponse = ApiClient::call_api(url).await.map_mm_err()?; + Ok(ClassicSwapLiquiditySourcesResponse { + protocols: response.protocols, + }) +} + +/// "1inch_classic_swap_tokens" rpc implementation. +/// Returns list of tokens available for 1inch classic swaps +pub async fn one_inch_v6_0_classic_swap_tokens_rpc( + ctx: MmArc, + req: ClassicSwapTokensRequest, +) -> MmResult { + let url = SwapUrlBuilder::create_api_url_builder(&ctx, req.chain_id, SwapApiMethods::Tokens) + .map_mm_err()? + .build() + .map_mm_err()?; + let mut response: TokensResponse = ApiClient::call_api(url).await.map_mm_err()?; + for (_, token_info) in response.tokens.iter_mut() { + token_info.symbol_kdf = ClassicSwapDetails::token_name_kdf(&ctx, req.chain_id, token_info); + } + Ok(ClassicSwapTokensResponse { + tokens: response.tokens, + }) +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_errors.rs b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_errors.rs new file mode 100644 index 0000000000..e7e7541cc2 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_errors.rs @@ -0,0 +1,291 @@ +//! Errors when accessing external trading providers + +use crate::lp_swap::CheckBalanceError; +use crate::lr_swap::lr_errors::LrSwapError; +use coins::{CoinFindError, NumConversError, TradePreimageError, UnexpectedDerivationMethod}; +use common::{HttpStatusCode, StatusCode}; +use derive_more::Display; +use ethereum_types::U256; +use mm2_number::BigDecimal; +use ser_error_derive::SerializeErrorType; +use serde::Serialize; +use trading_api::one_inch_api::errors::OneInchError; + +#[derive(Debug, Display, Serialize, SerializeErrorType)] +#[serde(tag = "error_type", content = "error_data")] +pub enum ExtApiRpcError { + NoSuchCoin { + coin: String, + }, + #[display(fmt = "EVM token needed")] + CoinTypeError, + #[display(fmt = "NFT not supported")] + NftProtocolNotSupported, + #[display(fmt = "Chain not supported")] + ChainNotSupported, + #[display(fmt = "Must be same chain")] + DifferentChains, + MyAddressError(String), + ConversionError(String), + #[display(fmt = "Internal error: no token info in params for liquidity routing")] + NoLrTokenInfo, + InvalidParam(String), + #[display(fmt = "Parameter {param} out of bounds, value: {value}, min: {min} max: {max}")] + OutOfBounds { + param: String, + value: String, + min: String, + max: String, + }, + #[display(fmt = "allowance not enough for 1inch contract, available: {allowance}, needed: {amount}")] + OneInchAllowanceNotEnough { + allowance: U256, + amount: U256, + }, + #[display(fmt = "1inch API error: {_0}")] + OneInchError(OneInchError), + #[display(fmt = "1inch API data parse error: {_0}")] + OneInchDataError(String), + InternalError(String), + ResponseParseError(String), + #[display(fmt = "Transaction error {_0}")] + TransactionError(String), + #[display(fmt = "Sign transaction error {_0}")] + SignTransactionError(String), + #[display(fmt = "best liquidity routing swap not found, candidates: {candidates}")] + BestLrSwapNotFound { + candidates: u32, + }, + #[display( + fmt = "Not enough {coin} for swap: available {available}, required at least {required}, locked by swaps {locked_by_swaps:?}" + )] + NotSufficientBalance { + coin: String, + available: BigDecimal, + required: BigDecimal, + locked_by_swaps: Option, + }, + #[display(fmt = "The volume {volume} of the {coin} coin less than minimum transaction amount {threshold}")] + VolumeTooLow { + coin: String, + volume: BigDecimal, + threshold: BigDecimal, + }, + #[display(fmt = "Transport error {_0}")] + TransportError(String), +} + +impl HttpStatusCode for ExtApiRpcError { + fn status_code(&self) -> StatusCode { + match self { + ExtApiRpcError::NoSuchCoin { .. } => StatusCode::NOT_FOUND, + ExtApiRpcError::CoinTypeError + | ExtApiRpcError::NftProtocolNotSupported + | ExtApiRpcError::ChainNotSupported + | ExtApiRpcError::DifferentChains + | ExtApiRpcError::MyAddressError(_) + | ExtApiRpcError::NoLrTokenInfo + | ExtApiRpcError::InvalidParam(_) + | ExtApiRpcError::OutOfBounds { .. } + | ExtApiRpcError::OneInchAllowanceNotEnough { .. } + | ExtApiRpcError::ConversionError(_) + | ExtApiRpcError::NotSufficientBalance { .. } + | ExtApiRpcError::VolumeTooLow { .. } => StatusCode::BAD_REQUEST, + ExtApiRpcError::OneInchError(_) + | ExtApiRpcError::BestLrSwapNotFound { .. } + | ExtApiRpcError::OneInchDataError(_) + | ExtApiRpcError::TransactionError(_) + | ExtApiRpcError::TransportError(_) => StatusCode::BAD_GATEWAY, + ExtApiRpcError::ResponseParseError(_) + | ExtApiRpcError::SignTransactionError(_) + | ExtApiRpcError::InternalError { .. } => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ExtApiRpcError { + fn from(error: OneInchError) -> Self { + match error { + OneInchError::InvalidParam(error) => ExtApiRpcError::InvalidParam(error), + OneInchError::OutOfBounds { param, value, min, max } => { + ExtApiRpcError::OutOfBounds { param, value, min, max } + }, + OneInchError::TransportError(_) + | OneInchError::ParseBodyError { .. } + | OneInchError::GeneralApiError { .. } => ExtApiRpcError::OneInchError(error), + OneInchError::AllowanceNotEnough { allowance, amount, .. } => { + ExtApiRpcError::OneInchAllowanceNotEnough { allowance, amount } + }, + } + } +} + +impl From for ExtApiRpcError { + fn from(err: CoinFindError) -> Self { + match err { + CoinFindError::NoSuchCoin { coin } => ExtApiRpcError::NoSuchCoin { coin }, + } + } +} + +impl From for ExtApiRpcError { + fn from(err: CheckBalanceError) -> Self { + match err { + CheckBalanceError::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + } => Self::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + }, + CheckBalanceError::NotSufficientBaseCoinBalance { + coin, + available, + required, + locked_by_swaps, + } => Self::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + }, + CheckBalanceError::VolumeTooLow { + coin, + volume, + threshold, + } => Self::VolumeTooLow { + coin, + volume, + threshold, + }, + CheckBalanceError::Transport(nested_err) => Self::TransportError(nested_err), + CheckBalanceError::InternalError(nested_err) => Self::InternalError(nested_err), + } + } +} + +impl From for ExtApiRpcError { + fn from(err: UnexpectedDerivationMethod) -> Self { + Self::MyAddressError(err.to_string()) + } +} + +impl From for ExtApiRpcError { + fn from(err: NumConversError) -> Self { + Self::ConversionError(err.to_string()) + } +} + +impl From for ExtApiRpcError { + fn from(err: TradePreimageError) -> Self { + match err { + TradePreimageError::AmountIsTooSmall { amount, threshold } => Self::VolumeTooLow { + coin: "".to_owned(), + volume: amount, + threshold, + }, + TradePreimageError::NotSufficientBalance { + coin, + available, + required, + } => Self::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps: Default::default(), + }, + TradePreimageError::Transport(nested_err) => Self::TransportError(nested_err), + TradePreimageError::InternalError(nested_err) => Self::InternalError(nested_err), + TradePreimageError::NftProtocolNotSupported => Self::NftProtocolNotSupported, + TradePreimageError::NoSuchCoin { coin } => Self::NoSuchCoin { coin }, + } + } +} + +/// Error aggregator for errors of conversion of api returned values +#[derive(Debug, Display, Serialize)] +pub(crate) struct FromApiValueError(String); + +impl FromApiValueError { + pub(crate) fn new(msg: String) -> Self { + Self(msg) + } +} + +impl From for FromApiValueError { + fn from(err: NumConversError) -> Self { + Self(err.to_string()) + } +} + +impl From for FromApiValueError { + fn from(err: primitive_types::Error) -> Self { + Self(format!("{err:?}")) + } +} + +impl From for FromApiValueError { + fn from(err: hex::FromHexError) -> Self { + Self(err.to_string()) + } +} + +impl From for FromApiValueError { + fn from(err: ethereum_types::FromDecStrErr) -> Self { + Self(err.to_string()) + } +} + +impl From for ExtApiRpcError { + fn from(err: LrSwapError) -> Self { + match err { + LrSwapError::NoSuchCoin { coin } => ExtApiRpcError::NoSuchCoin { coin }, + LrSwapError::StateError(msg) | LrSwapError::AtomicSwapError(msg) | LrSwapError::InternalError(msg) => { + ExtApiRpcError::InternalError(msg) + }, + LrSwapError::CoinTypeError => ExtApiRpcError::CoinTypeError, + LrSwapError::NftProtocolNotSupported => ExtApiRpcError::NftProtocolNotSupported, + LrSwapError::ChainNotSupported => ExtApiRpcError::ChainNotSupported, + LrSwapError::DifferentChains => ExtApiRpcError::DifferentChains, + LrSwapError::InvalidParam(msg) => ExtApiRpcError::InvalidParam(msg), + LrSwapError::MyAddressError(msg) => ExtApiRpcError::MyAddressError(msg), + LrSwapError::BestLrSwapNotFound { candidates } => ExtApiRpcError::BestLrSwapNotFound { candidates }, + LrSwapError::OutOfBounds { param, value, min, max } => { + ExtApiRpcError::OutOfBounds { param, value, min, max } + }, + LrSwapError::OneInchAllowanceNotEnough { allowance, amount } => { + ExtApiRpcError::OneInchAllowanceNotEnough { allowance, amount } + }, + LrSwapError::OneInchError(msg) => ExtApiRpcError::OneInchError(msg), + LrSwapError::ConversionError(msg) => ExtApiRpcError::ConversionError(msg), + LrSwapError::ResponseParseError(msg) => ExtApiRpcError::ResponseParseError(msg), + LrSwapError::TransactionError(msg) => ExtApiRpcError::TransactionError(msg), + LrSwapError::SignTransactionError(msg) => ExtApiRpcError::SignTransactionError(msg), + LrSwapError::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + } => ExtApiRpcError::NotSufficientBalance { + coin, + available, + required, + locked_by_swaps, + }, + LrSwapError::VolumeTooLow { + coin, + volume, + threshold, + } => ExtApiRpcError::VolumeTooLow { + coin, + volume, + threshold, + }, + LrSwapError::TransportError(nested_err) => ExtApiRpcError::TransportError(nested_err), + } + } +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_helpers.rs b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_helpers.rs new file mode 100644 index 0000000000..af40c464f9 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_helpers.rs @@ -0,0 +1,101 @@ +use super::ext_api_types::{ClassicSwapCreateOptParams, ClassicSwapQuoteOptParams}; +use coins::hd_wallet::DisplayAddress; +use coins::Ticker; +use ethereum_types::{Address as EthAddress, U256}; +use mm2_number::MmNumber; +use mm2_rpc::data::legacy::{MatchBy, OrderType, SellBuyRequest, TakerAction}; +use trading_api::one_inch_api::classic_swap_types::{ClassicSwapCreateCallBuilder, ClassicSwapQuoteCallBuilder}; + +pub(crate) fn make_classic_swap_quote_params( + base_contract: EthAddress, + rel_contract: EthAddress, + sell_amount: U256, + opt_params: ClassicSwapQuoteOptParams, +) -> ClassicSwapQuoteCallBuilder { + ClassicSwapQuoteCallBuilder::new( + base_contract.display_address(), + rel_contract.display_address(), + sell_amount.to_string(), + ) + .with_fee(opt_params.fee) + .with_protocols(opt_params.protocols) + .with_gas_price(opt_params.gas_price) + .with_complexity_level(opt_params.complexity_level) + .with_parts(opt_params.parts) + .with_main_route_parts(opt_params.main_route_parts) + .with_gas_limit(opt_params.gas_limit) + .with_include_tokens_info(Some(opt_params.include_tokens_info)) + .with_include_protocols(Some(opt_params.include_protocols)) + .with_include_gas(Some(opt_params.include_gas)) + .with_connector_tokens(opt_params.connector_tokens) +} + +pub(crate) fn make_classic_swap_create_params( + base_contract: EthAddress, + rel_contract: EthAddress, + sell_amount: U256, + my_address: EthAddress, + _slippage: f32, + opt_params: ClassicSwapCreateOptParams, +) -> ClassicSwapCreateCallBuilder { + ClassicSwapCreateCallBuilder::new( + base_contract.display_address(), + rel_contract.display_address(), + sell_amount.to_string(), + my_address.display_address(), + 0.0, // TODO: enable slippage + ) + .with_fee(opt_params.fee) + .with_protocols(opt_params.protocols) + .with_gas_price(opt_params.gas_price) + .with_complexity_level(opt_params.complexity_level) + .with_parts(opt_params.parts) + .with_main_route_parts(opt_params.main_route_parts) + .with_gas_limit(opt_params.gas_limit) + .with_include_tokens_info(Some(opt_params.include_tokens_info)) + .with_include_protocols(Some(opt_params.include_protocols)) + .with_include_gas(Some(opt_params.include_gas)) + .with_connector_tokens(opt_params.connector_tokens) + .with_excluded_protocols(opt_params.excluded_protocols) + .with_permit(opt_params.permit) + .with_compatibility(opt_params.compatibility) + .with_receiver(opt_params.receiver) + .with_referrer(opt_params.referrer) + .with_disable_estimate(opt_params.disable_estimate) + .with_allow_partial_fill(opt_params.allow_partial_fill) + .with_use_permit2(opt_params.use_permit2) +} + +#[allow(unused)] +pub(crate) fn make_atomic_swap_request( + base: Ticker, + rel: Ticker, + price: MmNumber, + volume: MmNumber, + action: TakerAction, + match_by: MatchBy, + order_type: OrderType, +) -> SellBuyRequest { + SellBuyRequest { + base, + rel, + price, + volume, + timeout: None, + duration: None, + method: match action { + TakerAction::Buy => "buy".to_string(), + TakerAction::Sell => "sell".to_string(), + }, + gui: None, + dest_pub_key: Default::default(), + match_by, + order_type, + base_confs: None, + base_nota: None, + rel_confs: None, + rel_nota: None, + min_volume: None, + save_in_history: true, + } +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_tests.rs b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_tests.rs new file mode 100644 index 0000000000..ad80cd23a9 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_tests.rs @@ -0,0 +1,285 @@ +use crate::rpc::lp_commands::ext_api::{ + ext_api_types::{ + ClassicSwapCreateOptParams, ClassicSwapCreateRequest, ClassicSwapQuoteOptParams, ClassicSwapQuoteRequest, + }, + one_inch_v6_0_classic_swap_create_rpc, one_inch_v6_0_classic_swap_quote_rpc, +}; +use coins::eth::EthCoin; +use coins_activation::platform_for_tests::init_platform_coin_with_tokens_loop; +use common::block_on; +use crypto::CryptoCtx; +use mm2_core::mm_ctx::MmCtxBuilder; +use mm2_number::{BigDecimal, MmNumber}; +use mocktopus::mocking::{MockResult, Mockable}; +use std::str::FromStr; +use trading_api::one_inch_api::{classic_swap_types::ClassicSwapData, client::ApiClient}; + +#[test] +fn test_classic_swap_response_conversion() { + let ticker_coin = "ETH".to_owned(); + let ticker_token = "JST".to_owned(); + let eth_conf = json!({ + "coin": ticker_coin, + "name": "ethereum", + "derivation_path": "m/44'/1'", + "chain_id": 1, + "decimals": 18, + "protocol": { + "type": "ETH", + "protocol_data": { + "chain_id": 1, + } + }, + "trezor_coin": "Ethereum" + }); + let jst_conf = json!({ + "coin": ticker_token, + "name": "jst", + "chain_id": 1, + "decimals": 6, + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": "0x09d0d71FBC00D7CCF9CFf132f5E6825C88293F19" + } + }, + }); + + let conf = json!({ + "coins": [eth_conf, jst_conf], + "1inch_api": "https://api.1inch.dev" + }); + let ctx = MmCtxBuilder::new().with_conf(conf).into_mm_arc(); + CryptoCtx::init_with_iguana_passphrase(ctx.clone(), "123").unwrap(); + + block_on(init_platform_coin_with_tokens_loop::( + ctx.clone(), + serde_json::from_value(json!({ + "ticker": ticker_coin, + "rpc_mode": "Default", + "nodes": [ + {"url": "https://sepolia.drpc.org"}, + {"url": "https://ethereum-sepolia-rpc.publicnode.com"}, + {"url": "https://rpc2.sepolia.org"}, + {"url": "https://rpc.sepolia.org/"} + ], + "swap_contract_address": "0xeA6D65434A15377081495a9E7C5893543E7c32cB", + "erc20_tokens_requests": [{"ticker": ticker_token}], + "priv_key_policy": { "type": "ContextPrivKey" } + })) + .unwrap(), + )) + .unwrap(); + + let response_quote_raw = json!({ + "dstAmount": "13", + "srcToken": { + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": ticker_coin, + "name": "Ether", + "decimals": 18, + "eip2612": false, + "isFoT": false, + "logoURI": "https://tokens.1inch.io/0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.png", + "tags": [ + "crosschain", + "GROUP:ETH", + "native", + "PEG:ETH" + ] + }, + "dstToken": { + "address": "0x1234567890123456789012345678901234567890", + "symbol": ticker_token, + "name": "Test just token", + "decimals": 6, + "eip2612": false, + "isFoT": false, + "logoURI": "https://example.org/0x1234567890123456789012345678901234567890.png", + "tags": [ + "crosschain", + "GROUP:JSTT", + "PEG:JST", + "tokens" + ] + }, + "protocols": [ + [ + [ + { + "name": "SUSHI", + "part": 100, + "fromTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "toTokenAddress": "0xf16e81dce15b08f326220742020379b855b87df9" + } + ], + [ + { + "name": "ONE_INCH_LIMIT_ORDER_V3", + "part": 100, + "fromTokenAddress": "0xf16e81dce15b08f326220742020379b855b87df9", + "toTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7" + } + ] + ] + ], + "gas": 452704 + }); + + let response_create_raw = json!({ + "dstAmount": "13", + "tx": { + "from": "0x590559f6fb7720f24ff3e2fccf6015b466e9c92c", + "to": "0x111111125421ca6dc452d289314280a0f8842a65", + "data": "0x07ed23790000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000590559f6fb7720f24ff3e2fccf6015b466e9c92c0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000648e8755f7ac30b5e4fa3f9c00e2cb6667501797b8bc01a7a367a4b2889ca6a05d9c31a31a781c12a4c3bdfc2ef1e02942e388b6565989ebe860bd67925bda74fbe0000000000000000000000000000000000000000000000000005ea0005bc00a007e5c0d200000000000000000000000000000000059800057e00018500009500001a4041c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2d0e30db00c20c02aaa39b223fe8d0a0e5c4f27ead9083c756cc27b73644935b8e68019ac6356c40661e1bc3158606ae4071118002dc6c07b73644935b8e68019ac6356c40661e1bc3158600000000000000000000000000000000000000000000000000294932ccadc9c58c02aaa39b223fe8d0a0e5c4f27ead9083c756cc251204dff5675ecff96b565ba3804dd4a63799ccba406761d38e5ddf6ccf6cf7c55759d5210750b5d60f30044e331d039000000000000000000000000761d38e5ddf6ccf6cf7c55759d5210750b5d60f3000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002f8a744a79be00000000000000000000000042f527f50f16a103b6ccab48bccca214500c10210000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec00a0860a32ec00000000000000000000000000000000000000000000000000003005635d54300003d05120ead050515e10fdb3540ccd6f8236c46790508a76111111111117dc0aa78b770fa6a738034120c30200c4e525b10b000000000000000000000000000000000000000000000000000000000000002000000000000000000000000022b1a53ac4be63cdc1f47c99572290eff1edd8020000000000000000000000006a32cc044dd6359c27bb66e7b02dce6dd0fda2470000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003005635d5430000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000067138e8c00000000000000000000000000000000000000000000000000030fb9b1525d8185f8d63fbcbe42e5999263c349cb5d81000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000026000000000000000000000000067297ee4eb097e072b4ab6f1620268061ae8046400000000000000000000000060cba82ddbf4b5ddcd4398cdd05354c6a790c309000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041d26038ef66344af785ff342b86db3da06c4cc6a62f0ca80ffd78affc0a95ccad44e814acebb1deda729bbfe3050bec14a47af487cc1cadc75f43db2d073016c31c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041a66cd52a747c5f60b9db637ffe30d0e413ec87858101832b4c5c1ae154bf247f3717c8ed4133e276ddf68d43a827f280863c91d6c42bc6ad1ec7083b2315b6fd1c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020d6bdbf78dac17f958d2ee523a2206206994597c13d831ec780a06c4eca27dac17f958d2ee523a2206206994597c13d831ec7111111125421ca6dc452d289314280a0f8842a65000000000000000000000000000000000000000000000000c095c0a2", + "value": "10000001", + "gas": 721429, + "gasPrice": "9525172167" + }, + "srcToken": { + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "symbol": ticker_coin, + "name": "Ether", + "decimals": 18, + "eip2612": false, + "isFoT": false, + "logoURI": "https://tokens.1inch.io/0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.png", + "tags": [ + "crosschain", + "GROUP:ETH", + "native", + "PEG:ETH" + ] + }, + "dstToken": { + "address": "0x1234567890123456789012345678901234567890", + "symbol": ticker_token, + "name": "Just Token", + "decimals": 6, + "eip2612": false, + "isFoT": false, + "logoURI": "https://tokens.1inch.io/0x1234567890123456789012345678901234567890.png", + "tags": [ + "crosschain", + "GROUP:USDT", + "PEG:USD", + "tokens" + ] + }, + "protocols": [ + [ + [ + { + "name": "UNISWAP_V2", + "part": 100, + "fromTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "toTokenAddress": "0x761d38e5ddf6ccf6cf7c55759d5210750b5d60f3" + } + ], + [ + { + "name": "ONE_INCH_LP_1_1", + "part": 100, + "fromTokenAddress": "0x761d38e5ddf6ccf6cf7c55759d5210750b5d60f3", + "toTokenAddress": "0x111111111117dc0aa78b770fa6a738034120c302" + } + ], + [ + { + "name": "PMM11", + "part": 100, + "fromTokenAddress": "0x111111111117dc0aa78b770fa6a738034120c302", + "toTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7" + } + ] + ] + ] + }); + + let quote_req = ClassicSwapQuoteRequest { + base: ticker_coin.clone(), + rel: ticker_token.clone(), + amount: MmNumber::from("1.0"), + opt_params: ClassicSwapQuoteOptParams { + fee: None, + protocols: None, + gas_price: None, + complexity_level: None, + parts: None, + main_route_parts: None, + gas_limit: None, + include_tokens_info: true, + include_protocols: true, + include_gas: true, + connector_tokens: None, + }, + }; + + let create_req = ClassicSwapCreateRequest { + base: ticker_coin.clone(), + rel: ticker_token.clone(), + amount: MmNumber::from("1.0"), + slippage: 0.0, + opt_params: ClassicSwapCreateOptParams { + fee: None, + protocols: None, + gas_price: None, + complexity_level: None, + parts: None, + main_route_parts: None, + gas_limit: None, + include_tokens_info: true, + include_protocols: true, + include_gas: true, + connector_tokens: None, + excluded_protocols: None, + permit: None, + compatibility: None, + receiver: None, + referrer: None, + disable_estimate: None, + allow_partial_fill: None, + use_permit2: None, + }, + }; + + ApiClient::call_api::.mock_safe(move |_| { + let response_quote_raw = response_quote_raw.clone(); + MockResult::Return(Box::pin(async move { + Ok(serde_json::from_value::(response_quote_raw).unwrap()) + })) + }); + + let quote_response = block_on(one_inch_v6_0_classic_swap_quote_rpc(ctx.clone(), quote_req)).unwrap(); + assert_eq!( + quote_response.dst_amount.amount, + BigDecimal::from_str("0.000013").unwrap() + ); + assert_eq!(quote_response.src_token.as_ref().unwrap().symbol, ticker_coin); + assert_eq!(quote_response.src_token.as_ref().unwrap().decimals, 18); + assert_eq!(quote_response.dst_token.as_ref().unwrap().symbol, ticker_token); + assert_eq!(quote_response.dst_token.as_ref().unwrap().decimals, 6); + assert_eq!(quote_response.gas.unwrap(), 452704_u64); + + ApiClient::call_api::.mock_safe(move |_| { + let response_create_raw = response_create_raw.clone(); + MockResult::Return(Box::pin(async move { + Ok(serde_json::from_value::(response_create_raw).unwrap()) + })) + }); + let create_response = block_on(one_inch_v6_0_classic_swap_create_rpc(ctx, create_req)).unwrap(); + assert_eq!( + create_response.dst_amount.amount, + BigDecimal::from_str("0.000013").unwrap() + ); + assert_eq!(create_response.src_token.as_ref().unwrap().symbol, ticker_coin); + assert_eq!(create_response.src_token.as_ref().unwrap().decimals, 18); + assert_eq!(create_response.dst_token.as_ref().unwrap().symbol, ticker_token); + assert_eq!(create_response.dst_token.as_ref().unwrap().decimals, 6); + assert_eq!(create_response.tx.as_ref().unwrap().data.len(), 1960); + assert_eq!( + create_response.tx.as_ref().unwrap().value, + BigDecimal::from_str("0.000000000010000001").unwrap() + ); +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/types.rs b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_types.rs similarity index 70% rename from mm2src/mm2_main/src/rpc/lp_commands/one_inch/types.rs rename to mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_types.rs index 04ace5e2c9..2b4ff70135 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/types.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/ext_api/ext_api_types.rs @@ -1,6 +1,9 @@ -use super::errors::FromApiValueError; +//! Structs to access external trading providers + +use super::ext_api_errors::FromApiValueError; use coins::eth::erc20::{get_erc20_ticker_by_contract_address, get_platform_ticker}; -use coins::eth::{u256_to_big_decimal, wei_to_eth_decimal, wei_to_gwei_decimal}; +use coins::eth::eth_utils::wei_to_coins_mm_number; +use coins::eth::{wei_to_eth_decimal, wei_to_gwei_decimal}; use coins::Ticker; use common::true_f; use ethereum_types::{Address, U256}; @@ -34,6 +37,13 @@ pub struct ClassicSwapQuoteRequest { pub rel: Ticker, /// Swap amount in coins (with fraction) pub amount: MmNumber, + #[serde(flatten)] + pub opt_params: ClassicSwapQuoteOptParams, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClassicSwapQuoteOptParams { /// Partner fee, percentage of src token amount will be sent to referrer address, min: 0; max: 3. /// Should be the same for quote and swap rpc. Default is 0 pub fee: Option, @@ -54,7 +64,7 @@ pub struct ClassicSwapQuoteRequest { pub main_route_parts: Option, /// Maximum amount of gas for a swap. /// Should be the same for a quote and swap. Default: 11500000; max: 11500000 - pub gas_limit: Option, + pub gas_limit: Option, /// Return fromToken and toToken info in response (default is true) #[serde(default = "true_f")] pub include_tokens_info: bool, @@ -70,7 +80,7 @@ pub struct ClassicSwapQuoteRequest { /// Request to create transaction for 1inch classic swap. /// See 1inch docs for more details: https://portal.1inch.dev/documentation/apis/swap/classic-swap/Parameter%20Descriptions/swap_params -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ClassicSwapCreateRequest { /// Base coin ticker @@ -81,6 +91,15 @@ pub struct ClassicSwapCreateRequest { pub amount: MmNumber, /// Allowed slippage, min: 0; max: 50 pub slippage: f32, + #[serde(flatten)] + pub opt_params: ClassicSwapCreateOptParams, +} + +/// Request to create transaction for 1inch classic swap. +/// See 1inch docs for more details: https://portal.1inch.dev/documentation/apis/swap/classic-swap/Parameter%20Descriptions/swap_params +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClassicSwapCreateOptParams { /// Partner fee, percentage of src token amount will be sent to referrer address, min: 0; max: 3. /// Should be the same for quote and swap rpc. Default is 0 pub fee: Option, @@ -89,19 +108,19 @@ pub struct ClassicSwapCreateRequest { /// (by default - all used) pub protocols: Option, /// Network price per gas, in Gwei for this rpc. - /// 1inch takes in account gas expenses to determine exchange route. Should be the same for a quote and swap. - /// If not set the 'fast' network gas price will be used + /// 1inch takes in account gas expenses to determine exchange route. Should be set to the same value both for quote and swap calls. + /// If not set, the 'fast' network gas price will be used by the provider pub gas_price: Option, /// Maximum number of token-connectors to be used in a transaction, min: 0; max: 3; default: 2 pub complexity_level: Option, /// Limit maximum number of parts each main route parts can be split into. /// Should be the same for a quote and swap. Default: 20; max: 100 pub parts: Option, - /// Limit maximum number of main route parts. Should be the same for a quote and swap. Default: 20; max: 50; + /// Limit maximum number of main route parts. Should be set to the same value both for quote and swap calls. Default: 20; max: 50; pub main_route_parts: Option, /// Maximum amount of gas for a swap. /// Should be the same for a quote and swap. Default: 11500000; max: 11500000 - pub gas_limit: Option, + pub gas_limit: Option, /// Return fromToken and toToken info in response (default is true) #[serde(default = "true_f")] pub include_tokens_info: bool, @@ -133,24 +152,45 @@ pub struct ClassicSwapCreateRequest { pub use_permit2: Option, } +impl Default for ClassicSwapCreateOptParams { + fn default() -> Self { + Self { + fee: None, + protocols: None, + gas_price: None, + complexity_level: None, + parts: None, + main_route_parts: None, + gas_limit: None, + include_tokens_info: true, // Need token info + include_protocols: true, + include_gas: true, + connector_tokens: None, + excluded_protocols: None, + permit: None, + compatibility: None, + receiver: None, + referrer: None, + disable_estimate: None, + allow_partial_fill: None, + use_permit2: None, + } + } +} + /// Details to create classic swap calls -#[derive(Serialize, Deserialize, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct ClassicSwapDetails { - /// Destination token amount, in coins (with fraction) + /// Original source token amount, in eth units. We add it for use ClassicSwapDetails in other rpcs + pub src_amount: MmNumber, // TODO: DetailedAmount? + /// Destination token amount, in eth units pub dst_amount: DetailedAmount, /// Source (base) token info #[serde(skip_serializing_if = "Option::is_none")] pub src_token: Option, - /// Source (base) token name as it is defined in the coins file - pub src_token_kdf: Option, /// Destination (rel) token info #[serde(skip_serializing_if = "Option::is_none")] pub dst_token: Option, - /// Destination (rel) token name as it is defined in the coins file. - /// This is used to show route tokens in the GUI, like they are in the coin file. - /// However, route tokens can be missed in the coins file and therefore cannot be filled. - /// In this case GUI may use LrTokenInfo::Address or LrTokenInfo::Symbol - pub dst_token_kdf: Option, /// Used liquidity sources #[serde(skip_serializing_if = "Option::is_none")] pub protocols: Option>>>, @@ -158,15 +198,15 @@ pub struct ClassicSwapDetails { #[serde(skip_serializing_if = "Option::is_none")] pub tx: Option, /// Estimated (returned only for quote rpc) - pub gas: Option, + pub gas: Option, } /// Response for both classic swap quote or create swap calls pub type ClassicSwapResponse = ClassicSwapDetails; impl ClassicSwapDetails { - /// Get token name as it is defined in the coins file by contract address - async fn token_name_kdf(ctx: &MmArc, chain_id: u64, token_info: &LrTokenInfo) -> Option { + /// Get token name as it is defined in the coins file by contract address + pub(crate) fn token_name_kdf(ctx: &MmArc, chain_id: u64, token_info: &LrTokenInfo) -> Option { let special_contract = Address::from_str(ApiClient::eth_special_contract()).expect("1inch special address must be valid"); // TODO: must call 1inch to get it, instead of burned consts @@ -178,38 +218,49 @@ impl ClassicSwapDetails { } } - pub(crate) async fn from_api_classic_swap_data( + pub(crate) fn from_api_classic_swap_data( ctx: &MmArc, chain_id: u64, - data: one_inch_api::classic_swap_types::ClassicSwapData, + src_amount: U256, + swap_data: one_inch_api::classic_swap_types::ClassicSwapData, ) -> MmResult { - let src_token_info = data + let src_token_info = swap_data .src_token .ok_or(FromApiValueError::new("Missing source TokenInfo".to_owned()))?; - let dst_token_info = data + let dst_token_info = swap_data .dst_token .ok_or(FromApiValueError::new("Missing destination TokenInfo".to_owned()))?; + let src_decimals: u8 = src_token_info + .decimals + .try_into() + .map_to_mm(|_| FromApiValueError::new("invalid decimals in source TokenInfo".to_owned()))?; let dst_decimals: u8 = dst_token_info .decimals .try_into() .map_to_mm(|_| FromApiValueError::new("invalid decimals in destination TokenInfo".to_owned()))?; + let src_token_kdf = Self::token_name_kdf(ctx, chain_id, &src_token_info); + let dst_token_kdf = Self::token_name_kdf(ctx, chain_id, &dst_token_info); Ok(Self { - dst_amount: MmNumber::from( - u256_to_big_decimal(U256::from_dec_str(&data.dst_amount)?, dst_decimals).map_mm_err()?, - ) - .into(), - src_token_kdf: Self::token_name_kdf(ctx, chain_id, &src_token_info).await, - src_token: Some(src_token_info), - dst_token_kdf: Self::token_name_kdf(ctx, chain_id, &dst_token_info).await, - dst_token: Some(dst_token_info), - protocols: data.protocols, - tx: data.tx.map(TxFields::from_api_tx_fields).transpose()?, - gas: data.gas, + src_amount: wei_to_coins_mm_number(src_amount, src_decimals).map_mm_err()?, + dst_amount: wei_to_coins_mm_number(U256::from_dec_str(&swap_data.dst_amount)?, dst_decimals) + .map_mm_err()? + .into(), + src_token: Some(LrTokenInfo { + symbol_kdf: src_token_kdf, + ..src_token_info + }), + dst_token: Some(LrTokenInfo { + symbol_kdf: dst_token_kdf, + ..dst_token_info + }), + protocols: swap_data.protocols, + tx: swap_data.tx.map(TxFields::from_api_tx_fields).transpose()?, + gas: swap_data.gas, }) } } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Clone, Deserialize, Serialize, Debug)] pub struct TxFields { pub from: Address, pub to: Address, @@ -217,7 +268,10 @@ pub struct TxFields { pub value: BigDecimal, /// Estimated gas price in gwei pub gas_price: BigDecimal, - pub gas: u128, // TODO: in eth EthTxFeeDetails rpc we use u64. Better have identical u128 everywhere + /// Estimated gas. + /// NOTE: Originally in 1inch this field is u128 (changed because u128 is not supported by serde) + /// TODO: 1inch advice is to increase this value by 25% + pub gas: u64, } impl TxFields { diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap.rs deleted file mode 100644 index 8f1583e1d5..0000000000 --- a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! RPC implementations for swaps with liquidity routing (LR) of EVM tokens - -use super::one_inch::types::ClassicSwapDetails; -use crate::rpc::lp_commands::one_inch::errors::ApiIntegrationRpcError; -use crate::rpc::lp_commands::one_inch::rpcs::get_coin_for_one_inch; -use lr_impl::find_best_swap_path_with_lr; -use mm2_core::mm_ctx::MmArc; -use mm2_err_handle::{map_mm_error::MapMmError, mm_error::MmResult}; -use types::{ - LrExecuteRoutedTradeRequest, LrExecuteRoutedTradeResponse, LrFindBestQuoteRequest, LrFindBestQuoteResponse, - LrGetQuotesForTokensRequest, -}; - -mod lr_impl; -mod types; - -/// Finds the most cost-effective swap path using liquidity routing (LR) for EVM-compatible tokens (`Aggregated taker swap` path), -/// by selecting the best option from a list of orderbook entries (ask/bid orders). -/// This RPC returns the data needed for actual execution of the swap with LR . -/// -/// # Overview -/// This RPC helps users execute token swaps even if they do not directly hold the tokens required -/// by the maker orders. It uses external liquidity routing (e.g., via 1inch provider) to perform necessary conversions, currently for EVM networks -/// -/// A swap path may consist of: -/// - A liquidity routing (LR) step before or after the atomic swap. -/// - An atomic swap step to fill the selected maker order (ask or bid). -/// -/// Use Case -/// The user wants to buy a specific amount of a token `user_base`, but only holds a different token `user_rel`. -/// This RPC evaluates possible swap paths by combining: -/// - Converting `user_rel` (`user_base`) to the token required by a maker order via LR. -/// - Filling the order through an atomic swap. -/// - Converting the token required by a maker order to `user_base` (`user_rel`) via LR. -/// It then selects and returns the most price-effective path, taking into account: -/// - prices of orders (provided in the params) -/// - 1inch LR quotes -/// - (TODO) Total swap and routing fees -/// Sell requests are processed in a similar way. -/// -/// Example -/// A user wants to buy 1 BTC with their USDT, but the best available order sells 1 BTC for DAI. -/// This RPC calculates the total cost of liquidity routing the user's USDT into DAI and then using the -/// acquired DAI to take the BTC order. It compares this path against other potential candidates -/// (e.g., a order selling BTC for USDC and routing the user's USDT into USDC via LR) to find the cheapest option. -/// -/// Inputs -/// - A list of maker ask or bid orders (orderbook entries) -/// - Trade method (`buy` or `sell`) -/// - Target or source amount to buy/sell -/// - User’s tokens `user_rel` and `user_base` to be used for the swap -/// -/// Outputs -/// - The best swap path including any required LR steps -/// -/// Current Limitations -/// - Only supports filling ask orders with: -/// - `user_rel` (sell request) -/// - Liquidity routing before the atomic swap: `user_rel` -> maker `rel` -/// - Does not yet support: -/// - User's buy request -/// - Filling bid orders -/// - Liquidity routing after the atomic swap -/// -/// TODO: -/// - Return full trade fee breakdown (e.g., DEX fees, LR fees) -/// - Support the following additional aggregated swap configurations: -/// - Filling ask orders with LR after the atomic swap -/// - Filling bid orders with LR before and after the atomic swap -/// - Support user's buy request -/// -/// Notes: -/// - This function relies on external quote APIs (currently 1inch) and may incur latency. -/// - Use this RPC when a direct atomic swap is not available or optimal, and pre/post-routing is needed. -pub async fn lr_find_best_quote_rpc( - ctx: MmArc, - req: LrFindBestQuoteRequest, -) -> MmResult { - // TODO: add validation: - // order.base_min_volume << req.amount <= order.base_max_volume - // order.coin is supported in 1inch - // order.price not zero - // when best order is selected validate against req.rel_max_volume and req.rel_min_volume - // coins in orders should be unique - - let (user_rel_coin, _) = get_coin_for_one_inch(&ctx, &req.user_rel).await?; - let user_rel_chain = user_rel_coin - .chain_id() - .ok_or(ApiIntegrationRpcError::ChainNotSupported)?; - let (swap_data, best_order, total_price) = - find_best_swap_path_with_lr(&ctx, req.user_base, req.user_rel, req.asks, req.bids, &req.volume).await?; - let lr_swap_details = ClassicSwapDetails::from_api_classic_swap_data(&ctx, user_rel_chain, swap_data) - .await - .mm_err(|err| ApiIntegrationRpcError::ApiDataError(err.to_string()))?; - Ok(LrFindBestQuoteResponse { - lr_swap_details, - best_order, - total_price, - // TODO: implement later - // trade_fee: ... - }) -} - -/// Find possible swaps with liquidity routing of several user tokens to fill one order. -/// For the provided single order the RPC searches for the most price-effective swap path with LR for user tokens. -/// -/// More info: -/// User is interested in buying some coin. There is an order available the User would like to fill but the User does not have tokens from the order. -/// User calls this RPC with the order, desired coin name, amount to buy or sell and list of User tokens to convert to/from with LR. -/// The RPC calls several 1inch classic swap quotes (to find most efficient token conversions) -/// and return possible LR paths to fill the order, with total swap prices. -/// TODO: should also returns total fees. -/// -/// NOTE: this RPC does not select the best quote between User tokens because it finds routes for different tokens (with own value), -/// so returns all of them. -/// That is, it's up to the User to select the most cost effective swap, for e.g. comparing token fiat value. -/// In fact, this could be done even in this RPC as 1inch also can get value in fiat but maybe User evaludation is more prefferable. -/// Again, it's a TODO. -pub async fn lr_get_quotes_for_tokens_rpc( - _ctx: MmArc, - _req: LrGetQuotesForTokensRequest, -) -> MmResult { - // TODO: impl later - todo!() -} - -/// Run a swap with LR to fill a maker order -pub async fn lr_execute_routed_trade_rpc( - _ctx: MmArc, - _req: LrExecuteRoutedTradeRequest, -) -> MmResult { - todo!() -} - -#[cfg(all(test, feature = "test-ext-api", not(target_arch = "wasm32")))] -mod tests { - use super::types::{AsksForCoin, LrFindBestQuoteRequest}; - use crate::lp_ordermatch::{OrderbookAddress, RpcOrderbookEntryV2}; - use crate::rpc::lp_commands::legacy::electrum; - use coins::eth::EthCoin; - use coins_activation::platform_for_tests::init_platform_coin_with_tokens_loop; - use crypto::CryptoCtx; - use mm2_number::{MmNumber, MmNumberMultiRepr}; - use mm2_test_helpers::for_tests::{btc_with_spv_conf, mm_ctx_with_custom_db_with_conf}; - use std::str::FromStr; - use uuid::Uuid; - - /// Test to find best swap with LR. - /// checks how to find an order from an utxo/token ask order list, which is the most price efficient if route from my token into the token in the order. - /// With this test use --features test-ext-api and set ONE_INCH_API_TEST_AUTH env to the 1inch dev auth key - /// TODO: make it mockable to run within CI - #[tokio::test] - async fn test_find_best_lr_swap_for_order_list() { - // let _ = env_logger::try_init(); // enable to print log messages in the impl - let main_net_url: String = std::env::var("ETH_MAIN_NET_URL_FOR_TEST").unwrap_or_default(); - let platform_coin = "ETH".to_owned(); - let base_conf = btc_with_spv_conf(); - let platform_coin_conf = json!({ - "coin": platform_coin.clone(), - "name": "ethereum", - "derivation_path": "m/44'/1'", - "protocol": { - "type": "ETH", - "protocol_data": { - "chain_id": 1 - } - } - }); - - // WETH = 2696.90 USD - let weth_conf = json!({ - "coin": "WETH-ERC20", - "name": "WETH-ERC20", - "derivation_path": "m/44'/1'", - "decimals": 18, - "protocol": { - "type": "ERC20", - "protocol_data": { - "platform": "ETH", - "contract_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - } - } - }); - - // BNB = 612.36 USD - let bnb_conf = json!({ - "coin": "BNB-ERC20", - "name": "BNB token", - "derivation_path": "m/44'/1'", - "decimals": 18, - "protocol": { - "type": "ERC20", - "protocol_data": { - "platform": "ETH", - "contract_address": "0xB8c77482e45F1F44dE1745F52C74426C631bDD52" - } - } - }); - // AAVE 258.75 USD - let aave_conf = json!({ - "coin": "AAVE-ERC20", - "name": "AAVE token", - "derivation_path": "m/44'/1'", - "decimals": 18, - "protocol": { - "type": "ERC20", - "protocol_data": { - "platform": "ETH", - "contract_address": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9" - } - } - }); - // CNC 0.136968 USD USD - let cnc_conf = json!({ - "coin": "CNC-ERC20", - "name": "CNC token", - "derivation_path": "m/44'/1'", - "decimals": 18, - "protocol": { - "type": "ERC20", - "protocol_data": { - "platform": "ETH", - "contract_address": "0x9aE380F0272E2162340a5bB646c354271c0F5cFC" - } - } - }); - - let base_ticker = base_conf["coin"].as_str().unwrap().to_owned(); - let weth_ticker = weth_conf["coin"].as_str().unwrap().to_owned(); - let bnb_ticker = bnb_conf["coin"].as_str().unwrap().to_owned(); - let aave_ticker = aave_conf["coin"].as_str().unwrap().to_owned(); - let cnc_ticker = cnc_conf["coin"].as_str().unwrap().to_owned(); - - let conf = json!({ - "coins": [base_conf, platform_coin_conf, weth_conf, bnb_conf, aave_conf, cnc_conf], - "1inch_api": "https://api.1inch.dev" - }); - let ctx = mm_ctx_with_custom_db_with_conf(Some(conf)); - CryptoCtx::init_with_iguana_passphrase(ctx.clone(), "123").unwrap(); - - electrum( - ctx.clone(), - json!({ - "coin": base_ticker, - "mm2": 1, - "method": "electrum", - "servers": [ - {"url": "electrum1.cipig.net:10001"}, - {"url": "electrum2.cipig.net:10001"}, - {"url": "electrum3.cipig.net:10001"} - ], - "tx_history": false - }), - ) - .await - .unwrap(); - init_platform_coin_with_tokens_loop::( - ctx.clone(), - serde_json::from_value(json!({ - "ticker": platform_coin.clone(), - "rpc_mode": "Default", - "nodes": [ - {"url": main_net_url} - ], - "swap_contract_address": "0xeA6D65434A15377081495a9E7C5893543E7c32cB", - "erc20_tokens_requests": [ - {"ticker": weth_ticker.clone()}, - {"ticker": bnb_ticker.clone()}, - {"ticker": aave_ticker.clone()}, - {"ticker": cnc_ticker.clone()} - ], - "priv_key_policy": { "type": "ContextPrivKey" } - })) - .unwrap(), - ) - .await - .unwrap(); - - let asks = vec![AsksForCoin { - base: base_ticker.clone(), - orders: vec![ - RpcOrderbookEntryV2 { - coin: bnb_ticker, - address: OrderbookAddress::Transparent("RLL6n4ayAv1haokcEd1QUEYniyeoiYkn7W".into()), - price: MmNumberMultiRepr::from(MmNumber::from("145.69")), - pubkey: "02f3578fbc0fc76056eae34180a71e9190ee08ad05d40947aab7a286666e2ce798".to_owned(), - uuid: Uuid::from_str("7f26dc6a-39ab-4685-b5f1-55f12268ea50").unwrap(), - is_mine: false, - base_max_volume: MmNumberMultiRepr::from(MmNumber::from("1")), - base_min_volume: MmNumberMultiRepr::from(MmNumber::from("0.1")), - rel_max_volume: MmNumberMultiRepr::from(MmNumber::from("145.69")), - rel_min_volume: MmNumberMultiRepr::from(MmNumber::from("14.569")), - conf_settings: Default::default(), - }, - RpcOrderbookEntryV2 { - coin: aave_ticker, - address: OrderbookAddress::Transparent("RK1JDwZ1LvH47Tvqm6pQM7aSqC2Zo6JwRF".into()), - price: MmNumberMultiRepr::from(MmNumber::from("370.334")), - pubkey: "02470bfb8e7710be4a7c2b8e9ba4bcfc5362a71643e64fc2e33b0d64c844ee9123".to_owned(), - uuid: Uuid::from_str("2aadf450-6a8e-4e4e-8b89-19ca10f23cc3").unwrap(), - is_mine: false, - base_max_volume: MmNumberMultiRepr::from(MmNumber::from("1")), - base_min_volume: MmNumberMultiRepr::from(MmNumber::from("0.1")), - rel_max_volume: MmNumberMultiRepr::from(MmNumber::from("370.334")), - rel_min_volume: MmNumberMultiRepr::from(MmNumber::from("37.0334")), - conf_settings: Default::default(), - }, - RpcOrderbookEntryV2 { - coin: cnc_ticker, - address: OrderbookAddress::Transparent("RK1JDwZ1LvH47Tvqm6pQM7aSqC2Zo6JwRF".into()), - price: MmNumberMultiRepr::from(MmNumber::from("699300.69")), - pubkey: "03de96cb66dcfaceaa8b3d4993ce8914cd5fe84e3fd53cefdae45add8032792a12".to_owned(), - uuid: Uuid::from_str("89ab019f-b2fe-4d89-9764-96ac4a3fbf8e").unwrap(), - is_mine: false, - base_max_volume: MmNumberMultiRepr::from(MmNumber::from("1")), - base_min_volume: MmNumberMultiRepr::from(MmNumber::from("0.1")), - rel_max_volume: MmNumberMultiRepr::from(MmNumber::from("699300.69")), - rel_min_volume: MmNumberMultiRepr::from(MmNumber::from("69930.069")), - conf_settings: Default::default(), - }, - ], - }]; - let bids = vec![]; - - let req = LrFindBestQuoteRequest { - user_base: base_ticker, - volume: "0.123".into(), - asks, - method: "buy".to_string(), - bids, - user_rel: weth_ticker, - }; - - let response = super::lr_find_best_quote_rpc(ctx, req).await; - // log!("response={:?}", response); // enable to investigate the response - assert!(response.is_ok()); - - // BTC / WETH price around 35.0 - log!("response total_price={}", response.unwrap().total_price.to_decimal()); - } -} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs deleted file mode 100644 index 70f19072b9..0000000000 --- a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs +++ /dev/null @@ -1,414 +0,0 @@ -//! Finding best quote to do swaps with liquidity routing (LR) support -//! Swaps with LR run additional interim swaps in EVM chains to convert one token into another token suitable to do a normal atomic swap. - -use crate::lp_ordermatch::RpcOrderbookEntryV2; -use crate::rpc::lp_commands::lr_swap::types::{AskOrBidOrder, AsksForCoin, BidsForCoin}; -use crate::rpc::lp_commands::one_inch::errors::ApiIntegrationRpcError; -use crate::rpc::lp_commands::one_inch::rpcs::get_coin_for_one_inch; -use coins::eth::{mm_number_from_u256, mm_number_to_u256, wei_from_coins_mm_number}; -use coins::hd_wallet::DisplayAddress; -use coins::lp_coinfind_or_err; -use coins::MmCoin; -use coins::Ticker; -use common::log; -use ethereum_types::Address as EthAddress; -use ethereum_types::U256; -use futures::future::join_all; -use mm2_core::mm_ctx::MmArc; -use mm2_err_handle::prelude::*; -use mm2_number::MmNumber; -use num_traits::CheckedDiv; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; -use trading_api::one_inch_api::classic_swap_types::{ClassicSwapData, ClassicSwapQuoteParams}; -use trading_api::one_inch_api::client::{ - ApiClient, PortfolioApiMethods, PortfolioUrlBuilder, SwapApiMethods, SwapUrlBuilder, -}; -use trading_api::one_inch_api::portfolio_types::{CrossPriceParams, CrossPricesSeries, DataGranularity}; - -/// To estimate src/dst price query price history for last 5 min -const CROSS_PRICES_GRANULARITY: DataGranularity = DataGranularity::FiveMin; -/// Use no more than 1 price history samples to estimate src/dst price -const CROSS_PRICES_LIMIT: u32 = 1; - -/// Internal struct to collect data for LR swap step -#[allow(dead_code)] // 'Clone' is detected as dead code in one combinator -#[derive(Clone)] -struct LrStepData { - /// Source coin or token ticker (to swap from) - _src_token: Ticker, - /// Source token contract address - src_contract: Option, - /// Source token amount in wei - src_amount: Option, - /// Source token decimals - src_decimals: Option, - /// Destination coin or token ticker (to swap into) - _dst_token: Ticker, - /// Destination token contract address - dst_contract: Option, - /// Destination token amount in wei - dst_amount: Option, - /// Destination token decimals - dst_decimals: Option, - /// Chain id where LR swap occurs (obtained from the destination token) - chain_id: Option, - /// Estimated src token / dst token price - lr_price: Option, - /// A quote from LR provider with destination amount for LR swap step - lr_swap_data: Option, -} - -impl LrStepData { - #[allow(clippy::result_large_err)] - fn get_chain_contract_info(&self) -> MmResult<(String, String, u64), ApiIntegrationRpcError> { - let src_contract = self - .src_contract - .as_ref() - .ok_or(ApiIntegrationRpcError::InternalError( - "Source LR contract not set".to_owned(), - ))? - .display_address(); - let dst_contract = self - .dst_contract - .as_ref() - .ok_or(ApiIntegrationRpcError::InternalError( - "Destination LR contract not set".to_owned(), - ))? - .display_address(); - let chain_id = self - .chain_id - .ok_or(ApiIntegrationRpcError::InternalError("LR chain id not set".to_owned()))?; - Ok((src_contract, dst_contract, chain_id)) - } -} - -struct LrSwapCandidateInfo { - /// Data for liquidity routing before atomic swap - lr_data_0: Option, - /// Atomic swap order to fill - atomic_swap_order: AskOrBidOrder, - /// Data for liquidity routing after atomic swap - _lr_data_1: Option, -} - -/// Array to store data (possible swap route candidated, with prices for each step) needed for estimation -/// of the aggregated swap with liquidity routing, with the best total price -struct LrSwapCandidates { - // The array of swaps with LR candidated is indexed by HashMaps with LR_0 and LR_1 base/rel pairs (to easily access and updated) - // TODO: maybe this is overcomplicated and just a vector of candidates would be sufficicent - inner0: HashMap<(Ticker, Ticker), Arc>>, - _inner1: HashMap<(Ticker, Ticker), Arc>>, -} - -impl LrSwapCandidates { - /// Init LR data map from the source token (mytoken) and tokens from orders - fn new_with_orders(src_token: Ticker, asks_coins: Vec, _bids_coins: Vec) -> Self { - let mut inner0 = HashMap::new(); - let inner1 = HashMap::new(); - for asks_for_coin in asks_coins { - for order in asks_for_coin.orders { - let candidate = LrSwapCandidateInfo { - lr_data_0: Some(LrStepData { - _src_token: src_token.clone(), - src_contract: None, - src_decimals: None, - src_amount: None, - _dst_token: order.coin.clone(), - dst_contract: None, - dst_amount: None, - dst_decimals: None, - chain_id: None, - lr_price: None, - lr_swap_data: None, - }), - atomic_swap_order: AskOrBidOrder::Ask { - base: asks_for_coin.base.clone(), - order: order.clone(), - }, - _lr_data_1: None, // TODO: add support for LR 1 - }; - let candidate = Arc::new(RwLock::new(candidate)); - inner0.insert((src_token.clone(), order.coin.clone()), candidate); - // TODO: add support for inner1 - } - } - Self { - inner0, - _inner1: inner1, - } - } - - /// Calculate amounts of destination tokens required to fill ask orders for the requested base_amount: - /// multiplies base_amount by the order price. Base_amount must be in coin units (with decimals) - async fn calc_destination_token_amounts( - &mut self, - ctx: &MmArc, - base_amount: &MmNumber, - ) -> MmResult<(), ApiIntegrationRpcError> { - for candidate in self.inner0.values_mut() { - let order_ticker = candidate.read().unwrap().atomic_swap_order.order().coin.clone(); - let coin = lp_coinfind_or_err(ctx, &order_ticker).await.map_mm_err()?; - let mut candidate_write = candidate.write().unwrap(); - let price: MmNumber = candidate_write.atomic_swap_order.order().price.rational.clone().into(); - let dst_amount = base_amount * &price; - let Some(ref mut lr_data_0) = candidate_write.lr_data_0 else { - continue; - }; - let dst_amount = wei_from_coins_mm_number(&dst_amount, coin.decimals()).map_mm_err()?; - lr_data_0.dst_amount = Some(dst_amount); - log::debug!( - "calc_destination_token_amounts atomic_swap_order.order.coin={} coin.decimals()={} lr_data_0.dst_amount={:?}", - order_ticker, - coin.decimals(), - dst_amount - ); - } - Ok(()) - } - - fn update_with_lr_prices(&mut self, mut lr_prices: HashMap<(Ticker, Ticker), Option>) { - for (key, val) in self.inner0.iter_mut() { - if let Some(ref mut lr_data_0) = val.write().unwrap().lr_data_0 { - lr_data_0.lr_price = lr_prices.remove(key).flatten(); - } - } - } - - fn update_with_lr_swap_data(&mut self, mut lr_swap_data: HashMap<(Ticker, Ticker), Option>) { - for (key, val) in self.inner0.iter_mut() { - if let Some(ref mut lr_data_0) = val.write().unwrap().lr_data_0 { - lr_data_0.lr_swap_data = lr_swap_data.remove(key).flatten(); - } - } - } - - async fn update_with_contracts(&mut self, ctx: &MmArc) -> MmResult<(), ApiIntegrationRpcError> { - for ((src_token, dst_token), candidate) in self.inner0.iter_mut() { - let (src_coin, src_contract) = get_coin_for_one_inch(ctx, src_token).await?; - let (dst_coin, dst_contract) = get_coin_for_one_inch(ctx, dst_token).await?; - let mut candidate_write = candidate.write().unwrap(); - let Some(ref mut lr_data_0) = candidate_write.lr_data_0 else { - continue; - }; - let src_decimals = src_coin.decimals(); - let dst_decimals = dst_coin.decimals(); - - #[cfg(feature = "for-tests")] - { - assert_ne!(src_decimals, 0); - assert_ne!(dst_decimals, 0); - } - - lr_data_0.src_contract = Some(src_contract); - lr_data_0.dst_contract = Some(dst_contract); - lr_data_0.src_decimals = Some(src_decimals); - lr_data_0.dst_decimals = Some(dst_decimals); - lr_data_0.chain_id = dst_coin.chain_id(); - } - Ok(()) - } - - /// Query 1inch token_0/token_1 price in series and calc average price - /// Assuming the outer RPC-level code ensures that relation src_tokens : dst_tokens will never be M:N (but only 1:M or M:1) - async fn query_destination_token_prices(&mut self, ctx: &MmArc) -> MmResult<(), ApiIntegrationRpcError> { - let mut prices_futs = vec![]; - let mut src_dst = vec![]; - for ((src_token, dst_token), candidate) in self.inner0.iter() { - let candidate_read = candidate.read().unwrap(); - let Some(ref lr_data_0) = candidate_read.lr_data_0 else { - continue; - }; - let (src_contract, dst_contract, chain_id) = lr_data_0.get_chain_contract_info()?; - // Run src / dst token price query: - let query_params = CrossPriceParams::new(chain_id, src_contract, dst_contract) - .with_granularity(Some(CROSS_PRICES_GRANULARITY)) - .with_limit(Some(CROSS_PRICES_LIMIT)) - .build_query_params() - .map_mm_err()?; - let url = PortfolioUrlBuilder::create_api_url_builder(ctx, PortfolioApiMethods::CrossPrices) - .map_mm_err()? - .with_query_params(query_params) - .build() - .map_mm_err()?; - let fut = ApiClient::call_api::(url); - prices_futs.push(fut); - src_dst.push((src_token.clone(), dst_token.clone())); - } - let prices_in_series = join_all(prices_futs).await.into_iter().map(|res| res.ok()); // set bad results to None to preserve prices_in_series length - - let quotes = src_dst - .into_iter() - .zip(prices_in_series) - .map(|((src, dst), series)| { - let dst_price = cross_prices_average(series); - ((src, dst), dst_price) - }) - .collect::>(); - - log_cross_prices("es); - self.update_with_lr_prices(quotes); - Ok(()) - } - - /// Estimate the needed source amount for LR swap, by dividing the known dst amount by the src/dst price - #[allow(clippy::result_large_err)] - fn estimate_source_token_amounts(&mut self) -> MmResult<(), ApiIntegrationRpcError> { - for candidate in self.inner0.values_mut() { - let order_ticker = candidate.read().unwrap().atomic_swap_order.order().coin.clone(); - let mut candidate_write = candidate.write().unwrap(); - let Some(ref mut lr_data_0) = candidate_write.lr_data_0 else { - continue; - }; - let Some(ref dst_price) = lr_data_0.lr_price else { - continue; - }; - let dst_amount = lr_data_0 - .dst_amount - .ok_or(ApiIntegrationRpcError::InternalError("no dst_amount".to_owned()))?; - let dst_amount = mm_number_from_u256(dst_amount); - if let Some(src_amount) = &dst_amount.checked_div(dst_price) { - lr_data_0.src_amount = Some(mm_number_to_u256(src_amount)?); - log::debug!( - "estimate_source_token_amounts lr_data.order.coin={} dst_price={} lr_data.src_amount={:?}", - order_ticker, - dst_price.to_decimal(), - src_amount - ); - } - } - Ok(()) - } - - /// Run 1inch requests to get LR quotes to convert source tokens to tokens in orders - async fn run_lr_quotes(&mut self, ctx: &MmArc) -> MmResult<(), ApiIntegrationRpcError> { - let mut src_dst = vec![]; - let mut quote_futs = vec![]; - for ((src_token, dst_token), candidate) in self.inner0.iter() { - let candidate_read = candidate.read().unwrap(); - let Some(ref lr_data_0) = candidate_read.lr_data_0 else { - continue; - }; - let Some(src_amount) = lr_data_0.src_amount else { - continue; - }; - let (src_contract, dst_contract, chain_id) = lr_data_0.get_chain_contract_info()?; - let query_params = ClassicSwapQuoteParams::new(src_contract, dst_contract, src_amount.to_string()) - .with_include_tokens_info(Some(true)) - .with_include_gas(Some(true)) - .build_query_params() - .map_mm_err()?; - let url = SwapUrlBuilder::create_api_url_builder(ctx, chain_id, SwapApiMethods::ClassicSwapQuote) - .map_mm_err()? - .with_query_params(query_params) - .build() - .map_mm_err()?; - let fut = ApiClient::call_api::(url); - quote_futs.push(fut); - src_dst.push((src_token.clone(), dst_token.clone())); - } - let swap_data = join_all(quote_futs).await.into_iter().map(|res| res.ok()); // if a bad result received (for e.g. low liguidity) set to None to preserve swap_data length - let swap_data_map = src_dst.into_iter().zip(swap_data).collect(); - self.update_with_lr_swap_data(swap_data_map); - Ok(()) - } - - /// Select the best swap path, by minimum of total swap price (including order and LR swap) - #[allow(clippy::result_large_err)] - fn select_best_swap(&self) -> MmResult<(ClassicSwapData, AskOrBidOrder, MmNumber), ApiIntegrationRpcError> { - // Calculate swap's total_price (filling the order plus LR swap) as src_amount / order_amount - // where src_amount is user tokens to pay for the swap with LR, 'order_amount' is amount which will fill the order - // Tx fee is not accounted here because it is in the platform coin, not token, so we can't compare LR swap tx fee directly here. - // Instead, GUI may calculate and show to the user the total spendings for LR swap, including fees, in USD or other fiat currency - let calc_total_price = |src_amount: U256, lr_swap: &ClassicSwapData, order: &RpcOrderbookEntryV2| { - let src_amount = mm_number_from_u256(src_amount); - let order_price = MmNumber::from(order.price.rational.clone()); - let dst_amount = MmNumber::from(lr_swap.dst_amount.as_str()); - let order_amount = dst_amount.checked_div(&order_price)?; - let total_price = src_amount.checked_div(&order_amount); - log::debug!("select_best_swap order.coin={} lr_swap.dst_amount(wei)={} order_amount(to fill order, wei)={} total_price(with LR)={}", - order.coin, lr_swap.dst_amount, order_amount.to_decimal(), total_price.clone().unwrap_or(MmNumber::from(0)).to_decimal()); - total_price - }; - - self.inner0 - .values() - .filter_map(|candidate| { - let candidate_read = candidate.read().unwrap(); - let atomic_swap_order = candidate_read.atomic_swap_order.clone(); - candidate_read - .lr_data_0 - .as_ref() - .map(|lr_data_0| (atomic_swap_order, lr_data_0.clone())) - }) - // filter out orders for which we did not get LR swap quotes and were not able to estimate needed source amount - .filter_map( - |(atomic_swap_order, lr_data_0)| match (lr_data_0.src_amount, lr_data_0.lr_swap_data) { - (Some(src_amount), Some(lr_swap_data)) => Some((src_amount, lr_swap_data, atomic_swap_order)), - (_, _) => None, - }, - ) - // calculate total price and filter out orders for which we could not calculate the total price - .filter_map(|(src_amount, lr_swap_data, order)| { - calc_total_price(src_amount, &lr_swap_data, order.order()) - .map(|total_price| (lr_swap_data, order, total_price)) - }) - .min_by(|(_, _, price_0), (_, _, price_1)| price_0.cmp(price_1)) - .ok_or(MmError::new(ApiIntegrationRpcError::BestLrSwapNotFound)) - } -} - -/// Implementation code to find the optimal swap path (with the lowest total price) from the `user_base` coin to the `user_rel` coin -/// (`Aggregated taker swap` path). -/// This path includes: -/// - An atomic swap step: used to fill a specific ask (or, in future, bid) order provided in the parameters. -/// - A liquidity routing (LR) step before and/or after (todo) the atomic swap: converts `user_base` or `user_sell` into the coin in the order. -/// -/// This function currently supports only: -/// - Ask orders and User 'sell' requests. -/// - Liquidity routing before the atomic swap. -/// -/// TODO: -/// - Support bid orders and User 'buy' requests. -/// - Support liquidity routing after the atomic swap (e.g., to convert the output coin into `user_rel`). -pub async fn find_best_swap_path_with_lr( - ctx: &MmArc, - _user_base: Ticker, - user_rel: Ticker, - asks: Vec, - bids: Vec, - base_amount: &MmNumber, -) -> MmResult<(ClassicSwapData, AskOrBidOrder, MmNumber), ApiIntegrationRpcError> { - let mut candidates = LrSwapCandidates::new_with_orders(user_rel, asks, bids); - candidates.update_with_contracts(ctx).await?; - candidates.calc_destination_token_amounts(ctx, base_amount).await?; - candidates.query_destination_token_prices(ctx).await?; - candidates.estimate_source_token_amounts()?; - candidates.run_lr_quotes(ctx).await?; - - candidates.select_best_swap() -} - -/// Helper to process 1inch token cross prices data and return average price -fn cross_prices_average(series: Option) -> Option { - let series = series?; - - if series.is_empty() { - return None; - } - - let total: MmNumber = series.iter().fold(MmNumber::from(0), |acc, price_data| { - acc + MmNumber::from(price_data.avg.clone()) - }); - Some(total / MmNumber::from(series.len() as u64)) -} - -fn log_cross_prices(prices: &HashMap<(Ticker, Ticker), Option>) { - for p in prices { - log::debug!( - "cross prices api src/dst price={:?} {:?}", - p, - p.1.clone().map(|v| v.to_decimal()) - ); - } -} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/types.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/types.rs deleted file mode 100644 index d407a6b63b..0000000000 --- a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/types.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Types for LR swaps rpc - -// Most of the code in this module fails on clippy. -#![allow(dead_code)] - -use crate::lp_ordermatch::RpcOrderbookEntryV2; -use crate::rpc::lp_commands::one_inch::types::ClassicSwapDetails; -use coins::Ticker; -use mm2_number::MmNumber; -use mm2_rpc::data::legacy::{SellBuyRequest, SellBuyResponse}; - -#[derive(Debug, Deserialize)] -pub struct AsksForCoin { - /// Base coin for ask orders - pub base: Ticker, - /// Best maker ask orders that could be filled with liquidity routing from the User source_token into ask's rel token - pub orders: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct BidsForCoin { - /// Rel coin for bid orders - pub rel: Ticker, - /// Best maker ask orders that could be filled with liquidity routing from the User source_token into ask's rel token - pub orders: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type")] -pub enum AskOrBidOrder { - Ask { base: Ticker, order: RpcOrderbookEntryV2 }, - Bid { rel: Ticker, order: RpcOrderbookEntryV2 }, -} - -impl AskOrBidOrder { - pub fn order(&self) -> &RpcOrderbookEntryV2 { - match self { - AskOrBidOrder::Ask { base: _, order } => order, - AskOrBidOrder::Bid { rel: _, order } => order, - } - } -} - -/// Request to find best swap path with LR to fill an order from list. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LrFindBestQuoteRequest { - /// Base coin to fill an atomic swap maker order with possible liquidity routing from this coin over a coin/token in an ask/bid - pub user_base: Ticker, - /// List of maker atomic swap ask orders, to find best swap path with liquidity routing from user_base or user_rel coin - pub asks: Vec, - /// List of maker atomic swap bid orders, to find best swap path with liquidity routing from user_base or user_rel coin - pub bids: Vec, - /// Buy or sell volume (in coin units, i.e. with fraction) - pub volume: MmNumber, - /// Method buy or sell - /// TODO: use this field, now we support 'buy' only - pub method: String, - /// Rel coin to fill an atomic swap maker order with possible liquidity routing from this coin over a coin/token in an ask/bid - pub user_rel: Ticker, -} - -/// Response for find best swap path with LR -#[derive(Debug, Serialize)] -pub struct LrFindBestQuoteResponse { - /// Swap tx data (from 1inch quote) - pub lr_swap_details: ClassicSwapDetails, - /// found best order which can be filled with LR swap - pub best_order: AskOrBidOrder, - /// base/rel price including the price of the LR swap part - pub total_price: MmNumber, - // /// Fees to pay, including LR swap fee - // pub trade_fee: TradePreimageResponse, // TODO: implement when trade_preimage implemented for TPU -} - -/// Request to get quotes with possible swap paths to fill order with multiple tokens with LR -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LrGetQuotesForTokensRequest { - /// Order base coin ticker (from the orderbook). - pub base: Ticker, - /// Swap amount in base coins to sell (with fraction) - pub amount: MmNumber, - /// Maker order to find possible swap path with LR - pub orderbook_entry: RpcOrderbookEntryV2, - /// List of user tokens to trade with LR - pub my_tokens: Vec, -} - -/// Details with swap with LR -#[derive(Debug, Serialize)] -pub struct QuotesDetails { - /// interim token to route to/from - pub dest_token: Ticker, - /// Swap tx data (from 1inch quote) - pub lr_swap_details: ClassicSwapDetails, - /// total swap price with LR - pub total_price: MmNumber, - // /// Fees to pay, including LR swap fee - // pub trade_fee: TradePreimageResponse, // TODO: implement when trade_preimage implemented for TPU -} - -/// Response for quotes to fill order with LR -#[derive(Debug, Serialize)] -pub struct LrGetQuotesForTokensResponse { - pub quotes: Vec, -} - -/// Request to sell or buy order with LR -/// TODO: this struct will be changed in the next PR -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LrExecuteRoutedTradeRequest { - /// Original sell or buy request (but only MatchBy::Orders could be used to fill the maker swap found in ) - #[serde(flatten)] - pub fill_req: SellBuyRequest, - - /// Tx data to create one inch swap (from 1inch quote) - /// TODO: make this an enum to allow other LR providers - pub lr_swap_details: ClassicSwapDetails, -} - -/// Response to sell or buy order with LR -#[derive(Debug, Serialize)] -#[serde(deny_unknown_fields)] -pub struct LrExecuteRoutedTradeResponse { - /// Original sell or buy response - #[serde(flatten)] - pub fill_response: SellBuyResponse, -} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api.rs new file mode 100644 index 0000000000..13c1e97a39 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api.rs @@ -0,0 +1,152 @@ +//! RPC implementations for swaps with liquidity routing (LR) of EVM tokens + +use super::ext_api::ext_api_errors::ExtApiRpcError; +use super::ext_api::ext_api_types::ClassicSwapDetails; +use crate::lr_swap::lr_helpers::sell_buy_method; +use crate::lr_swap::lr_quote::find_best_swap_path_with_lr; +use lr_api_types::{ + AtomicSwapRpcParams, LrExecuteRoutedTradeRequest, LrExecuteRoutedTradeResponse, LrFindBestQuoteRequest, + LrFindBestQuoteResponse, LrGetQuotesForTokensRequest, LrGetQuotesForTokensResponse, +}; +use mm2_core::mm_ctx::MmArc; +use mm2_err_handle::prelude::MmResultExt; +use mm2_err_handle::{map_mm_error::MapMmError, mm_error::MmResult}; + +#[cfg(all(test, not(target_arch = "wasm32"), feature = "test-ext-api"))] +mod lr_api_tests; +pub(crate) mod lr_api_types; + +/// The "find_best_quote" RPC implementation to find the best swap with liquidity routing (LR), also known as 'aggregated taker swap with LR'. +/// For the provided list of orderbook entries this RPC will find out the most price-effective swap which may include LR steps. +/// There are LR_0 and LR_1 steps supported meaning liquidity routing before or after the atomic swap. +/// Liquidity routing is supported in the EVM chains. This is actually a token swap performed by an LR provider (currently 1inch). +/// Both tokens must be in the same EVM network. +/// +/// Use cases: +/// User is interested in buying some coin. There are orders available with the desired coin but User does not have tokens to fill those orders. +/// User has some amount of a user_rel token and would like to use it for buying the desired coin. +/// User calls this RPC to find the best swap with LR: with user_rel token converted into the atomic swap taker coin with LR_0 step. +/// User may also request to convert received maker coin into user_base token via LR_1 swap. +/// +/// Similar logic is for the case when User would like to sell a token and use LR step to convert it into an atomic swap taker coin +/// and/or convert a maker coin into the User desired token. +/// +/// User calls this RPC with the following params: +/// * user_base and user_rel tickers, +/// * amount to buy or sell, +/// * list of ask and bid orders list. +/// +/// If user_base and/or user_rel differs from the coins in the orders the RPC creates LR_0 and/or LR_1 steps and gets LR provider quotes +/// to obtain prices for LR_0 and LR_1 steps. The RPC alsouses order prices to calculate the most price-effective swap path. +/// As the result the RPC returns params for LR_0 LR_1 and atomic swap corresponding the most price effective aggregated swap. +/// If best swap cannot be found a error is returned. +/// TODO: we should also return total fees. +/// +/// More info: +/// The GUI should provide this RPC with a list of ask or bid orders to select best swap path from. +/// Currently for that the "best_orders" RPC can be used: +/// The GUI should pick tokens which resides in the same chains with user_base and/or user_rel and query "best_orders" RPC (maybe multiple times). +/// The "best_orders" results are asks or bids which can be passed into this RPC. +/// +/// TODO: develop a more convenient RPC to find ask and bid orders for finding the best swap with LR. +/// It should support getting info about most liquid white-listed tokens from the LR provider, to do search for best swap more efficiently. +/// +pub async fn lr_find_best_quote_rpc( + ctx: MmArc, + req: LrFindBestQuoteRequest, +) -> MmResult { + // TODO: add validation: + // order.base_min_volume << req.amount <= order.base_max_volume - DONE + // when best order is selected validate against req.rel_max_volume and req.rel_min_volume (?) + // order coins are supported in 1inch + // order.price not zero + // coins in orders should be unique (?) + // Check user available balance for user_base/user_rel when selecting best swap (?) + + let action = sell_buy_method(&req.method).map_mm_err()?; + + let (lr_data_0, best_order, atomic_swap_volume, lr_data_1, total_price) = find_best_swap_path_with_lr( + &ctx, + req.user_base, + req.user_rel, + &action, + req.asks, + req.bids, + &req.volume, + ) + .await + .map_mm_err()?; + + let lr_data_0 = lr_data_0 + .map(|lr_data| { + ClassicSwapDetails::from_api_classic_swap_data( + &ctx, + lr_data.chain_id, + lr_data.src_amount, + lr_data.api_details, + ) + .mm_err(|err| ExtApiRpcError::OneInchDataError(err.to_string())) + }) + .transpose()?; + let lr_data_1 = lr_data_1 + .map(|lr_data| { + ClassicSwapDetails::from_api_classic_swap_data( + &ctx, + lr_data.chain_id, + lr_data.src_amount, + lr_data.api_details, + ) + .mm_err(|err| ExtApiRpcError::OneInchDataError(err.to_string())) + }) + .transpose()?; + Ok(LrFindBestQuoteResponse { + lr_data_0, + lr_data_1, + atomic_swap: AtomicSwapRpcParams { + volume: atomic_swap_volume, + base: best_order.taker_ticker(), + rel: best_order.maker_ticker(), + price: best_order.buy_price(), + method: "sell".to_owned(), // Always convert to the 'sell' action to simplify LR_0 estimations + order_uuid: best_order.order().uuid, + match_by: None, + order_type: None, + }, + total_price, + // TODO: implement later + // trade_fee: ... + }) +} + +/// TODO: Find possible swaps with liquidity routing of several user tokens to fill one order. +/// For the provided single order the RPC searches for the most price-effective swap path with LR for user tokens. +/// +/// More info: +/// User is interested in buying some coin. There is an order available the User would like to fill but the User does not have tokens from the order. +/// User calls this RPC with the order, desired coin name, amount to buy or sell and list of User tokens to convert to/from with LR. +/// The RPC calls several 1inch classic swap quotes (to find most efficient token conversions) +/// and return possible LR paths to fill the order, with total swap prices. +/// TODO: should also returns total fees. +/// +/// NOTE: this RPC does not select the best quote between User tokens because it finds routes for different tokens (with own value), +/// so returns all of them. +/// That is, it's up to the User to select the most cost effective swap, for e.g. comparing token fiat value. +/// In fact, this could be done even in this RPC as 1inch also can get value in fiat but maybe User evaludation is more prefferable. +pub async fn lr_get_quotes_for_tokens_rpc( + _ctx: MmArc, + _req: LrGetQuotesForTokensRequest, +) -> MmResult { + // TODO: impl later + todo!() +} + +/// Run an aggregated swap with LR to fill a maker order. +/// The `req` parameter is constructed from the result of a "find_best_quote" RPC call +/// It contains parameters for the atomic swap and LR_0 and LR_1 steps +/// LR_0 and LR_1 are liquidity routing steps before and after the atomic swap (see the description for lr_find_best_quote_rpc fn) +pub async fn lr_execute_routed_trade_rpc( + _ctx: MmArc, + _req: LrExecuteRoutedTradeRequest, +) -> MmResult { + todo!() +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api/lr_api_tests.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api/lr_api_tests.rs new file mode 100644 index 0000000000..e1908e7ee8 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api/lr_api_tests.rs @@ -0,0 +1,202 @@ +use super::lr_api_types::{AsksForCoin, LrFindBestQuoteRequest}; +use crate::lp_ordermatch::{OrderbookAddress, RpcOrderbookEntryV2}; +use crate::rpc::lp_commands::legacy::electrum; +use coins::eth::EthCoin; +use coins_activation::platform_for_tests::init_platform_coin_with_tokens_loop; +use crypto::CryptoCtx; +use mm2_number::{MmNumber, MmNumberMultiRepr}; +use mm2_test_helpers::for_tests::{btc_with_spv_conf, mm_ctx_with_custom_db_with_conf, ETH_MAINNET_NODES}; +use std::str::FromStr; +use uuid::Uuid; + +/// Test to find best swap with LR. +/// checks how to find an order from an utxo/token ask order list, which is the most price efficient if route from my token into the token in the order. +/// With this test use --features test-ext-api and set ONE_INCH_API_TEST_AUTH env to the 1inch dev auth key +/// TODO: make it mockable to run within CI +#[tokio::test] +async fn test_find_best_lr_swap_for_order_list() { + let _ = env_logger::try_init(); // to print log::info! log::debug! etc messages from the impl code (also use RUST_LOG) + + let platform_coin = "ETH".to_owned(); + let base_conf = btc_with_spv_conf(); + let platform_coin_conf = json!({ + "coin": platform_coin.clone(), + "name": "ethereum", + "derivation_path": "m/44'/1'", + "protocol": { + "type": "ETH", + "protocol_data": { + "chain_id": 1 + } + } + }); + + // WETH = 2696.90 USD + let weth_conf = json!({ + "coin": "WETH-ERC20", + "name": "WETH-ERC20", + "derivation_path": "m/44'/1'", + "decimals": 18, + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + } + } + }); + + // BNB = 612.36 USD + let bnb_conf = json!({ + "coin": "BNB-ERC20", + "name": "BNB token", + "derivation_path": "m/44'/1'", + "decimals": 18, + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": "0xB8c77482e45F1F44dE1745F52C74426C631bDD52" + } + } + }); + // AAVE 258.75 USD + let aave_conf = json!({ + "coin": "AAVE-ERC20", + "name": "AAVE token", + "derivation_path": "m/44'/1'", + "decimals": 18, + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9" + } + } + }); + // CNC 0.136968 USD USD + let cnc_conf = json!({ + "coin": "CNC-ERC20", + "name": "CNC token", + "derivation_path": "m/44'/1'", + "decimals": 18, + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": "0x9aE380F0272E2162340a5bB646c354271c0F5cFC" + } + } + }); + + let base_ticker = base_conf["coin"].as_str().unwrap().to_owned(); + let weth_ticker = weth_conf["coin"].as_str().unwrap().to_owned(); + let bnb_ticker = bnb_conf["coin"].as_str().unwrap().to_owned(); + let aave_ticker = aave_conf["coin"].as_str().unwrap().to_owned(); + let cnc_ticker = cnc_conf["coin"].as_str().unwrap().to_owned(); + + let conf = json!({ + "coins": [base_conf, platform_coin_conf, weth_conf, bnb_conf, aave_conf, cnc_conf], + "1inch_api": "https://api.1inch.dev" + }); + let ctx = mm_ctx_with_custom_db_with_conf(Some(conf)); + CryptoCtx::init_with_iguana_passphrase(ctx.clone(), "123").unwrap(); + + electrum( + ctx.clone(), + json!({ + "coin": base_ticker, + "mm2": 1, + "method": "electrum", + "servers": [ + {"url": "electrum1.cipig.net:10001"}, + {"url": "electrum2.cipig.net:10001"}, + {"url": "electrum3.cipig.net:10001"} + ], + "tx_history": false + }), + ) + .await + .unwrap(); + init_platform_coin_with_tokens_loop::( + ctx.clone(), + serde_json::from_value(json!({ + "ticker": platform_coin.clone(), + "rpc_mode": "Default", + "nodes": ETH_MAINNET_NODES.iter().map(|u| json!({"url": u})).collect::>(), + "swap_contract_address": "0xeA6D65434A15377081495a9E7C5893543E7c32cB", + "erc20_tokens_requests": [ + {"ticker": weth_ticker.clone()}, + {"ticker": bnb_ticker.clone()}, + {"ticker": aave_ticker.clone()}, + {"ticker": cnc_ticker.clone()} + ], + "priv_key_policy": { "type": "ContextPrivKey" } + })) + .unwrap(), + ) + .await + .unwrap(); + + let asks = vec![AsksForCoin { + base: base_ticker.clone(), + orders: vec![ + RpcOrderbookEntryV2 { + coin: bnb_ticker, + address: OrderbookAddress::Transparent("RLL6n4ayAv1haokcEd1QUEYniyeoiYkn7W".into()), + price: MmNumberMultiRepr::from(MmNumber::from("145.69")), + pubkey: "02f3578fbc0fc76056eae34180a71e9190ee08ad05d40947aab7a286666e2ce798".to_owned(), + uuid: Uuid::from_str("7f26dc6a-39ab-4685-b5f1-55f12268ea50").unwrap(), + is_mine: false, + base_max_volume: MmNumberMultiRepr::from(MmNumber::from("1")), + base_min_volume: MmNumberMultiRepr::from(MmNumber::from("0.1")), + rel_max_volume: MmNumberMultiRepr::from(MmNumber::from("145.69")), + rel_min_volume: MmNumberMultiRepr::from(MmNumber::from("14.569")), + conf_settings: Default::default(), + }, + RpcOrderbookEntryV2 { + coin: aave_ticker, + address: OrderbookAddress::Transparent("RK1JDwZ1LvH47Tvqm6pQM7aSqC2Zo6JwRF".into()), + price: MmNumberMultiRepr::from(MmNumber::from("370.334")), + pubkey: "02470bfb8e7710be4a7c2b8e9ba4bcfc5362a71643e64fc2e33b0d64c844ee9123".to_owned(), + uuid: Uuid::from_str("2aadf450-6a8e-4e4e-8b89-19ca10f23cc3").unwrap(), + is_mine: false, + base_max_volume: MmNumberMultiRepr::from(MmNumber::from("1")), + base_min_volume: MmNumberMultiRepr::from(MmNumber::from("0.1")), + rel_max_volume: MmNumberMultiRepr::from(MmNumber::from("370.334")), + rel_min_volume: MmNumberMultiRepr::from(MmNumber::from("37.0334")), + conf_settings: Default::default(), + }, + RpcOrderbookEntryV2 { + coin: cnc_ticker, + address: OrderbookAddress::Transparent("RK1JDwZ1LvH47Tvqm6pQM7aSqC2Zo6JwRF".into()), + price: MmNumberMultiRepr::from(MmNumber::from("699300.69")), + pubkey: "03de96cb66dcfaceaa8b3d4993ce8914cd5fe84e3fd53cefdae45add8032792a12".to_owned(), + uuid: Uuid::from_str("89ab019f-b2fe-4d89-9764-96ac4a3fbf8e").unwrap(), + is_mine: false, + base_max_volume: MmNumberMultiRepr::from(MmNumber::from("1")), + base_min_volume: MmNumberMultiRepr::from(MmNumber::from("0.1")), + rel_max_volume: MmNumberMultiRepr::from(MmNumber::from("699300.69")), + rel_min_volume: MmNumberMultiRepr::from(MmNumber::from("69930.069")), + conf_settings: Default::default(), + }, + ], + }]; + let bids = vec![]; + + let req = LrFindBestQuoteRequest { + user_base: base_ticker, + asks, + bids, + volume: "0.123".into(), + method: "buy".to_owned(), + user_rel: weth_ticker, + }; + + let response = super::lr_find_best_quote_rpc(ctx, req).await; + log!("response={:?}", response); + assert!(response.is_ok(), "response={:?}", response); + + // BTC / WETH price around 35.0 + log!("response total_price={}", response.unwrap().total_price.to_decimal()); +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api/lr_api_types.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api/lr_api_types.rs new file mode 100644 index 0000000000..9a8b0f0217 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap_api/lr_api_types.rs @@ -0,0 +1,244 @@ +//! Types for LR swaps rpc + +use crate::lp_ordermatch::RpcOrderbookEntryV2; +use crate::rpc::lp_commands::ext_api::ext_api_errors::ExtApiRpcError; +use crate::rpc::lp_commands::ext_api::ext_api_types::{ClassicSwapCreateOptParams, ClassicSwapDetails}; +use coins::Ticker; +use mm2_number::{MmNumber, MmNumberMultiRepr}; +use mm2_rpc::data::legacy::{MatchBy, OrderType}; +use uuid::Uuid; + +/// Struct to pass maker ask orders into liquidity routing RPCs +#[derive(Debug, Deserialize)] +pub struct AsksForCoin { + /// Base coin for ask orders + pub base: Ticker, + /// Best maker ask orders that could be filled with liquidity routing from the User source_token into ask's rel token + pub orders: Vec, +} + +/// Struct to pass maker bid orders into liquidity routing RPCs +#[derive(Debug, Deserialize)] +pub struct BidsForCoin { + /// Rel coin for bid orders + pub rel: Ticker, + /// Best maker ask orders that could be filled with liquidity routing from the User source_token into ask's rel token + pub orders: Vec, +} + +/// Struct to return the best order from liquidity routing RPCs +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum AskOrBidOrder { + Ask { base: Ticker, order: RpcOrderbookEntryV2 }, + Bid { rel: Ticker, order: RpcOrderbookEntryV2 }, +} + +impl AskOrBidOrder { + pub fn order(&self) -> &RpcOrderbookEntryV2 { + match self { + AskOrBidOrder::Ask { order, .. } => order, + AskOrBidOrder::Bid { order, .. } => order, + } + } + pub fn maker_ticker(&self) -> Ticker { + match self { + AskOrBidOrder::Ask { base, .. } => base.clone(), + AskOrBidOrder::Bid { order, .. } => order.coin.clone(), + } + } + pub fn taker_ticker(&self) -> Ticker { + match self { + AskOrBidOrder::Ask { order, .. } => order.coin.clone(), + AskOrBidOrder::Bid { rel, .. } => rel.clone(), + } + } + + /// Convert to maker sell price + pub fn sell_price(&self) -> MmNumber { + match self { + AskOrBidOrder::Ask { order, .. } => order.price.rational.clone().into(), + AskOrBidOrder::Bid { order, .. } => &MmNumber::from(1) / &order.price.rational.clone().into(), + } + } + /// Convert to maker buy price + #[allow(unused)] + pub fn buy_price(&self) -> MmNumber { + match self { + AskOrBidOrder::Ask { order, .. } => &MmNumber::from(1) / &order.price.rational.clone().into(), + AskOrBidOrder::Bid { order, .. } => order.price.rational.clone().into(), + } + } + + pub fn max_maker_vol(&self) -> MmNumberMultiRepr { + match self { + AskOrBidOrder::Ask { order, .. } => order.base_max_volume.clone(), + AskOrBidOrder::Bid { order, .. } => order.rel_max_volume.clone(), + } + } + + pub fn min_maker_vol(&self) -> MmNumberMultiRepr { + match self { + AskOrBidOrder::Ask { order, .. } => order.base_min_volume.clone(), + AskOrBidOrder::Bid { order, .. } => order.rel_min_volume.clone(), + } + } + + pub fn max_taker_vol(&self) -> MmNumberMultiRepr { + match self { + AskOrBidOrder::Ask { order, .. } => order.rel_max_volume.clone(), + AskOrBidOrder::Bid { order, .. } => order.base_max_volume.clone(), + } + } + + pub fn min_taker_vol(&self) -> MmNumberMultiRepr { + match self { + AskOrBidOrder::Ask { order, .. } => order.rel_min_volume.clone(), + AskOrBidOrder::Bid { order, .. } => order.base_min_volume.clone(), + } + } +} + +/// Request to find best swap path with LR to fill an order from list. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LrFindBestQuoteRequest { + /// Base coin to fill an atomic swap maker order with possible liquidity routing from this coin over a coin/token in an ask/bid + pub user_base: Ticker, + /// List of maker atomic swap ask orders, to find best swap path with liquidity routing from user_base or user_rel coin + pub asks: Vec, + /// List of maker atomic swap bid orders, to find best swap path with liquidity routing from user_base or user_rel coin + pub bids: Vec, + /// Buy or sell volume (in coin units, i.e. with fraction) + pub volume: MmNumber, + /// Method buy or sell + /// TODO: use this field, now we support 'buy' only + pub method: String, + /// Rel coin to fill an atomic swap maker order with possible liquidity routing from this coin over a coin/token in an ask/bid + pub user_rel: Ticker, +} + +/// Response for find best swap path with LR +#[derive(Debug, Serialize)] +pub struct LrFindBestQuoteResponse { + /// LR_0 tx data (from 1inch quote) + pub lr_data_0: Option, + /// LR_1 tx data (from 1inch quote) + pub lr_data_1: Option, + /// found best order which can be filled with LR swap + pub atomic_swap: AtomicSwapRpcParams, + /// base/rel price including the price of the LR swap part + pub total_price: MmNumber, + // /// Fees to pay, including LR swap fee + // pub trade_fee: TradePreimageResponse, // TODO: implement when trade_preimage implemented for TPU +} + +/// Request to get quotes with possible swap paths to fill order with multiple tokens with LR +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LrGetQuotesForTokensRequest { + /// Order base coin ticker (from the orderbook). + pub base: Ticker, + /// Swap amount in base coins to sell (with fraction) + pub amount: MmNumber, + /// Maker order to find possible swap path with LR + pub orderbook_entry: RpcOrderbookEntryV2, + /// List of user tokens to trade with LR + pub my_tokens: Vec, +} + +/// Details for best swap with LR +#[derive(Debug, Serialize)] +pub struct QuoteDetails { + /// interim token to route to/from + pub dest_token: Ticker, + /// Swap tx data (from 1inch quote) + pub lr_swap_details: ClassicSwapDetails, + /// total swap price with LR + pub total_price: MmNumber, + // /// Fees to pay, including LR swap fee + // pub trade_fee: TradePreimageResponse, // TODO: implement when trade_preimage implemented for TPU +} + +/// Response for quotes to fill order with LR +#[derive(Debug, Serialize)] +pub struct LrGetQuotesForTokensResponse { + pub quotes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LrSwapRpcParams { + pub slippage: f32, + pub swap_details: ClassicSwapDetails, + pub opt_params: Option, +} + +impl LrSwapRpcParams { + #[allow(clippy::result_large_err, unused)] + pub fn get_source_token(&self) -> Result { + Ok(self + .swap_details + .src_token + .as_ref() + .ok_or(ExtApiRpcError::NoLrTokenInfo)? + .symbol_kdf + .as_ref() + .ok_or(ExtApiRpcError::NoLrTokenInfo)? + .clone()) + } + + #[allow(clippy::result_large_err, unused)] + pub fn get_destination_token(&self) -> Result { + Ok(self + .swap_details + .dst_token + .as_ref() + .ok_or(ExtApiRpcError::NoLrTokenInfo)? + .symbol_kdf + .as_ref() + .ok_or(ExtApiRpcError::NoLrTokenInfo)? + .clone()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AtomicSwapRpcParams { + pub volume: Option, + pub base: Ticker, + pub rel: Ticker, + pub price: MmNumber, + pub method: String, + pub order_uuid: Uuid, + #[serde(default)] + pub match_by: Option, + #[serde(default)] + pub order_type: Option, + // TODO: add opt params +} + +/// Request to fill a maker order with atomic swap with LR steps +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LrExecuteRoutedTradeRequest { + /// Sell or buy params for the atomic swap step + #[allow(unused)] + pub atomic_swap: AtomicSwapRpcParams, + + /// Params to create 1inch LR swap (from 1inch quote) + /// TODO: make this an enum to allow other LR providers + #[allow(unused)] + pub lr_swap_0: Option, + + /// Params to create 1inch LR swap (from 1inch quote) + #[allow(unused)] + pub lr_swap_1: Option, +} + +/// Response to sell or buy order with LR +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LrExecuteRoutedTradeResponse { + /// Created aggregated swap uuid for tracking the swap + pub uuid: Uuid, +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/mod.rs b/mm2src/mm2_main/src/rpc/lp_commands/mod.rs index 0826bbb648..51511268ff 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/mod.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/mod.rs @@ -1,7 +1,7 @@ pub(crate) mod db_id; +pub(crate) mod ext_api; pub mod legacy; -pub(crate) mod lr_swap; -pub(crate) mod one_inch; +pub(crate) mod lr_swap_api; pub(crate) mod pubkey; pub(crate) mod tokens; pub(crate) mod trezor; diff --git a/mm2src/mm2_main/src/rpc/lp_commands/one_inch.rs b/mm2src/mm2_main/src/rpc/lp_commands/one_inch.rs deleted file mode 100644 index 3d47853294..0000000000 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! RPC implementation for integration with 1inch swap API provider. - -pub mod errors; -pub mod rpcs; -pub mod types; diff --git a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs deleted file mode 100644 index 55dd8eef7b..0000000000 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs +++ /dev/null @@ -1,128 +0,0 @@ -use coins::{CoinFindError, NumConversError}; -use common::{HttpStatusCode, StatusCode}; -use derive_more::Display; -use enum_derives::EnumFromStringify; -use ethereum_types::U256; -use ser_error_derive::SerializeErrorType; -use serde::Serialize; -use trading_api::one_inch_api::errors::ApiClientError; - -#[derive(Debug, Display, Serialize, SerializeErrorType, EnumFromStringify)] -#[serde(tag = "error_type", content = "error_data")] -pub enum ApiIntegrationRpcError { - NoSuchCoin { - coin: String, - }, - #[display(fmt = "EVM token needed")] - CoinTypeError, - #[display(fmt = "NFT not supported")] - NftProtocolNotSupported, - #[display(fmt = "Chain not supported")] - ChainNotSupported, - #[display(fmt = "Must be same chain")] - DifferentChains, - #[from_stringify("coins::UnexpectedDerivationMethod")] - MyAddressError(String), - #[from_stringify("ethereum_types::FromDecStrErr", "coins::NumConversError")] - NumberError(String), - InvalidParam(String), - #[display(fmt = "Parameter {param} out of bounds, value: {value}, min: {min} max: {max}")] - OutOfBounds { - param: String, - value: String, - min: String, - max: String, - }, - #[display(fmt = "allowance not enough for 1inch contract, available: {allowance}, needed: {amount}")] - OneInchAllowanceNotEnough { - allowance: U256, - amount: U256, - }, - #[display(fmt = "1inch API error: {_0}")] - OneInchError(ApiClientError), - ApiDataError(String), - InternalError(String), - #[display(fmt = "liquidity routing swap not found")] - BestLrSwapNotFound, -} - -impl HttpStatusCode for ApiIntegrationRpcError { - fn status_code(&self) -> StatusCode { - match self { - ApiIntegrationRpcError::NoSuchCoin { .. } => StatusCode::NOT_FOUND, - ApiIntegrationRpcError::CoinTypeError - | ApiIntegrationRpcError::NftProtocolNotSupported - | ApiIntegrationRpcError::ChainNotSupported - | ApiIntegrationRpcError::DifferentChains - | ApiIntegrationRpcError::MyAddressError(_) - | ApiIntegrationRpcError::InvalidParam(_) - | ApiIntegrationRpcError::OutOfBounds { .. } - | ApiIntegrationRpcError::OneInchAllowanceNotEnough { .. } - | ApiIntegrationRpcError::NumberError(_) - | ApiIntegrationRpcError::BestLrSwapNotFound => StatusCode::BAD_REQUEST, - ApiIntegrationRpcError::OneInchError(_) | ApiIntegrationRpcError::ApiDataError(_) => { - StatusCode::BAD_GATEWAY - }, - ApiIntegrationRpcError::InternalError { .. } => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - -impl From for ApiIntegrationRpcError { - fn from(error: ApiClientError) -> Self { - match error { - ApiClientError::InvalidParam(error) => ApiIntegrationRpcError::InvalidParam(error), - ApiClientError::OutOfBounds { param, value, min, max } => { - ApiIntegrationRpcError::OutOfBounds { param, value, min, max } - }, - ApiClientError::TransportError(_) - | ApiClientError::ParseBodyError { .. } - | ApiClientError::GeneralApiError { .. } => ApiIntegrationRpcError::OneInchError(error), - ApiClientError::AllowanceNotEnough { allowance, amount, .. } => { - ApiIntegrationRpcError::OneInchAllowanceNotEnough { allowance, amount } - }, - } - } -} - -impl From for ApiIntegrationRpcError { - fn from(err: CoinFindError) -> Self { - match err { - CoinFindError::NoSuchCoin { coin } => ApiIntegrationRpcError::NoSuchCoin { coin }, - } - } -} - -/// Error aggregator for errors of conversion of api returned values -#[derive(Debug, Display, Serialize)] -pub(crate) struct FromApiValueError(String); - -impl FromApiValueError { - pub(crate) fn new(msg: String) -> Self { - Self(msg) - } -} - -impl From for FromApiValueError { - fn from(err: NumConversError) -> Self { - Self(err.to_string()) - } -} - -impl From for FromApiValueError { - fn from(err: primitive_types::Error) -> Self { - Self(format!("{err:?}")) - } -} - -impl From for FromApiValueError { - fn from(err: hex::FromHexError) -> Self { - Self(err.to_string()) - } -} - -impl From for FromApiValueError { - fn from(err: ethereum_types::FromDecStrErr) -> Self { - Self(err.to_string()) - } -} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs deleted file mode 100644 index 820ce2c580..0000000000 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs +++ /dev/null @@ -1,465 +0,0 @@ -use super::errors::ApiIntegrationRpcError; -use super::types::{ - AggregationContractRequest, ClassicSwapCreateRequest, ClassicSwapLiquiditySourcesRequest, - ClassicSwapLiquiditySourcesResponse, ClassicSwapQuoteRequest, ClassicSwapResponse, ClassicSwapTokensRequest, - ClassicSwapTokensResponse, -}; -use coins::eth::{u256_from_big_decimal, EthCoin, EthCoinType}; -use coins::hd_wallet::DisplayAddress; -use coins::{lp_coinfind_or_err, CoinWithDerivationMethod, MmCoin, MmCoinEnum, Ticker}; -use ethereum_types::Address; -use mm2_core::mm_ctx::MmArc; -use mm2_err_handle::prelude::*; -use std::str::FromStr; -use trading_api::one_inch_api::classic_swap_types::{ - ClassicSwapCreateParams, ClassicSwapQuoteParams, ProtocolsResponse, TokensResponse, -}; -use trading_api::one_inch_api::client::{ApiClient, SwapApiMethods, SwapUrlBuilder}; - -/// "1inch_v6_0_classic_swap_contract" rpc impl -/// used to get contract address (for e.g. to approve funds) -pub async fn one_inch_v6_0_classic_swap_contract_rpc( - _ctx: MmArc, - _req: AggregationContractRequest, -) -> MmResult { - Ok(ApiClient::classic_swap_contract().to_owned()) -} - -/// "1inch_classic_swap_quote" rpc impl -pub async fn one_inch_v6_0_classic_swap_quote_rpc( - ctx: MmArc, - req: ClassicSwapQuoteRequest, -) -> MmResult { - let (base, base_contract) = get_coin_for_one_inch(&ctx, &req.base).await?; - let (rel, rel_contract) = get_coin_for_one_inch(&ctx, &req.rel).await?; - let base_chain_id = base.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?; - let rel_chain_id = rel.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?; - api_supports_pair(base_chain_id, rel_chain_id)?; - let sell_amount = u256_from_big_decimal(&req.amount.to_decimal(), base.decimals()) - .mm_err(|err| ApiIntegrationRpcError::InvalidParam(err.to_string()))?; - let query_params = ClassicSwapQuoteParams::new( - base_contract.display_address(), - rel_contract.display_address(), - sell_amount.to_string(), - ) - .with_fee(req.fee) - .with_protocols(req.protocols) - .with_gas_price(req.gas_price) - .with_complexity_level(req.complexity_level) - .with_parts(req.parts) - .with_main_route_parts(req.main_route_parts) - .with_gas_limit(req.gas_limit) - .with_include_tokens_info(Some(req.include_tokens_info)) - .with_include_protocols(Some(req.include_protocols)) - .with_include_gas(Some(req.include_gas)) - .with_connector_tokens(req.connector_tokens) - .build_query_params() - .map_mm_err()?; - let url = SwapUrlBuilder::create_api_url_builder(&ctx, base_chain_id, SwapApiMethods::ClassicSwapQuote) - .map_mm_err()? - .with_query_params(query_params) - .build() - .map_mm_err()?; - let quote = ApiClient::call_api(url).await.map_mm_err()?; - ClassicSwapResponse::from_api_classic_swap_data(&ctx, base_chain_id, quote) // use 'base' as amount in errors is in the src coin - .await - .mm_err(|err| ApiIntegrationRpcError::ApiDataError(err.to_string())) -} - -/// "1inch_classic_swap_create" rpc implementation -/// This rpc actually returns a transaction to call the 1inch swap aggregation contract. GUI should sign it and send to the chain. -/// We don't verify the transaction in any way and trust the 1inch api. -pub async fn one_inch_v6_0_classic_swap_create_rpc( - ctx: MmArc, - req: ClassicSwapCreateRequest, -) -> MmResult { - let (base, base_contract) = get_coin_for_one_inch(&ctx, &req.base).await?; - let (rel, rel_contract) = get_coin_for_one_inch(&ctx, &req.rel).await?; - let base_chain_id = base.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?; - let rel_chain_id = rel.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?; - api_supports_pair(base_chain_id, rel_chain_id)?; - let sell_amount = u256_from_big_decimal(&req.amount.to_decimal(), base.decimals()) - .mm_err(|err| ApiIntegrationRpcError::InvalidParam(err.to_string()))?; - let single_address = base.derivation_method().single_addr_or_err().await.map_mm_err()?; - - let query_params = ClassicSwapCreateParams::new( - base_contract.display_address(), - rel_contract.display_address(), - sell_amount.to_string(), - single_address.display_address(), - req.slippage, - ) - .with_fee(req.fee) - .with_protocols(req.protocols) - .with_gas_price(req.gas_price) - .with_complexity_level(req.complexity_level) - .with_parts(req.parts) - .with_main_route_parts(req.main_route_parts) - .with_gas_limit(req.gas_limit) - .with_include_tokens_info(Some(req.include_tokens_info)) - .with_include_protocols(Some(req.include_protocols)) - .with_include_gas(Some(req.include_gas)) - .with_connector_tokens(req.connector_tokens) - .with_excluded_protocols(req.excluded_protocols) - .with_permit(req.permit) - .with_compatibility(req.compatibility) - .with_receiver(req.receiver) - .with_referrer(req.referrer) - .with_disable_estimate(req.disable_estimate) - .with_allow_partial_fill(req.allow_partial_fill) - .with_use_permit2(req.use_permit2) - .build_query_params() - .map_mm_err()?; - let url = SwapUrlBuilder::create_api_url_builder(&ctx, base_chain_id, SwapApiMethods::ClassicSwapCreate) - .map_mm_err()? - .with_query_params(query_params) - .build() - .map_mm_err()?; - let swap_with_tx = ApiClient::call_api(url).await.map_mm_err()?; - ClassicSwapResponse::from_api_classic_swap_data(&ctx, base_chain_id, swap_with_tx) - .await - .mm_err(|err| ApiIntegrationRpcError::ApiDataError(err.to_string())) -} - -/// "1inch_v6_0_classic_swap_liquidity_sources" rpc implementation. -/// Returns list of DEX available for routing with the 1inch Aggregation contract -pub async fn one_inch_v6_0_classic_swap_liquidity_sources_rpc( - ctx: MmArc, - req: ClassicSwapLiquiditySourcesRequest, -) -> MmResult { - let url = SwapUrlBuilder::create_api_url_builder(&ctx, req.chain_id, SwapApiMethods::LiquiditySources) - .map_mm_err()? - .build() - .map_mm_err()?; - let response: ProtocolsResponse = ApiClient::call_api(url).await.map_mm_err()?; - Ok(ClassicSwapLiquiditySourcesResponse { - protocols: response.protocols, - }) -} - -/// "1inch_classic_swap_tokens" rpc implementation. -/// Returns list of tokens available for 1inch classic swaps -pub async fn one_inch_v6_0_classic_swap_tokens_rpc( - ctx: MmArc, - req: ClassicSwapTokensRequest, -) -> MmResult { - let url = SwapUrlBuilder::create_api_url_builder(&ctx, req.chain_id, SwapApiMethods::Tokens) - .map_mm_err()? - .build() - .map_mm_err()?; - let response: TokensResponse = ApiClient::call_api(url).await.map_mm_err()?; - Ok(ClassicSwapTokensResponse { - tokens: response.tokens, - }) -} - -pub(crate) async fn get_coin_for_one_inch( - ctx: &MmArc, - ticker: &Ticker, -) -> MmResult<(EthCoin, Address), ApiIntegrationRpcError> { - let coin = match lp_coinfind_or_err(ctx, ticker).await.map_mm_err()? { - MmCoinEnum::EthCoinVariant(coin) => coin, - _ => return Err(MmError::new(ApiIntegrationRpcError::CoinTypeError)), - }; - let contract = match coin.coin_type { - EthCoinType::Eth => Address::from_str(ApiClient::eth_special_contract()) - .map_to_mm(|_| ApiIntegrationRpcError::InternalError("invalid address".to_owned()))?, - EthCoinType::Erc20 { token_addr, .. } => token_addr, - EthCoinType::Nft { .. } => return Err(MmError::new(ApiIntegrationRpcError::NftProtocolNotSupported)), - }; - Ok((coin, contract)) -} - -#[allow(clippy::result_large_err)] -fn api_supports_pair(base_chain_id: u64, rel_chain_id: u64) -> MmResult<(), ApiIntegrationRpcError> { - if !ApiClient::is_chain_supported(base_chain_id) { - return MmError::err(ApiIntegrationRpcError::ChainNotSupported); - } - if base_chain_id != rel_chain_id { - return MmError::err(ApiIntegrationRpcError::DifferentChains); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use crate::rpc::lp_commands::one_inch::{ - rpcs::{one_inch_v6_0_classic_swap_create_rpc, one_inch_v6_0_classic_swap_quote_rpc}, - types::{ClassicSwapCreateRequest, ClassicSwapQuoteRequest}, - }; - use coins::eth::EthCoin; - use coins_activation::platform_for_tests::init_platform_coin_with_tokens_loop; - use common::block_on; - use crypto::CryptoCtx; - use mm2_core::mm_ctx::MmCtxBuilder; - use mm2_number::{BigDecimal, MmNumber}; - use mocktopus::mocking::{MockResult, Mockable}; - use std::str::FromStr; - use trading_api::one_inch_api::{classic_swap_types::ClassicSwapData, client::ApiClient}; - - #[test] - fn test_classic_swap_response_conversion() { - let ticker_coin = "ETH".to_owned(); - let ticker_token = "JST".to_owned(); - let eth_conf = json!({ - "coin": ticker_coin, - "name": "ethereum", - "derivation_path": "m/44'/1'", - "chain_id": 1, - "decimals": 18, - "protocol": { - "type": "ETH", - "protocol_data": { - "chain_id": 1, - } - }, - "trezor_coin": "Ethereum" - }); - let jst_conf = json!({ - "coin": ticker_token, - "name": "jst", - "chain_id": 1, - "decimals": 6, - "protocol": { - "type": "ERC20", - "protocol_data": { - "platform": "ETH", - "contract_address": "0x09d0d71FBC00D7CCF9CFf132f5E6825C88293F19" - } - }, - }); - - let conf = json!({ - "coins": [eth_conf, jst_conf], - "1inch_api": "https://api.1inch.dev" - }); - let ctx = MmCtxBuilder::new().with_conf(conf).into_mm_arc(); - CryptoCtx::init_with_iguana_passphrase(ctx.clone(), "123").unwrap(); - - block_on(init_platform_coin_with_tokens_loop::( - ctx.clone(), - serde_json::from_value(json!({ - "ticker": ticker_coin, - "rpc_mode": "Default", - "nodes": [ - {"url": "https://sepolia.drpc.org"}, - {"url": "https://ethereum-sepolia-rpc.publicnode.com"}, - {"url": "https://rpc2.sepolia.org"}, - {"url": "https://rpc.sepolia.org/"} - ], - "swap_contract_address": "0xeA6D65434A15377081495a9E7C5893543E7c32cB", - "erc20_tokens_requests": [{"ticker": ticker_token}], - "priv_key_policy": { "type": "ContextPrivKey" } - })) - .unwrap(), - )) - .unwrap(); - - let response_quote_raw = json!({ - "dstAmount": "13", - "srcToken": { - "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "symbol": ticker_coin, - "name": "Ether", - "decimals": 18, - "eip2612": false, - "isFoT": false, - "logoURI": "https://tokens.1inch.io/0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.png", - "tags": [ - "crosschain", - "GROUP:ETH", - "native", - "PEG:ETH" - ] - }, - "dstToken": { - "address": "0x1234567890123456789012345678901234567890", - "symbol": ticker_token, - "name": "Test just token", - "decimals": 6, - "eip2612": false, - "isFoT": false, - "logoURI": "https://example.org/0x1234567890123456789012345678901234567890.png", - "tags": [ - "crosschain", - "GROUP:JSTT", - "PEG:JST", - "tokens" - ] - }, - "protocols": [ - [ - [ - { - "name": "SUSHI", - "part": 100, - "fromTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "toTokenAddress": "0xf16e81dce15b08f326220742020379b855b87df9" - } - ], - [ - { - "name": "ONE_INCH_LIMIT_ORDER_V3", - "part": 100, - "fromTokenAddress": "0xf16e81dce15b08f326220742020379b855b87df9", - "toTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7" - } - ] - ] - ], - "gas": 452704 - }); - - let response_create_raw = json!({ - "dstAmount": "13", - "tx": { - "from": "0x590559f6fb7720f24ff3e2fccf6015b466e9c92c", - "to": "0x111111125421ca6dc452d289314280a0f8842a65", - "data": "0x07ed23790000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000590559f6fb7720f24ff3e2fccf6015b466e9c92c0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000648e8755f7ac30b5e4fa3f9c00e2cb6667501797b8bc01a7a367a4b2889ca6a05d9c31a31a781c12a4c3bdfc2ef1e02942e388b6565989ebe860bd67925bda74fbe0000000000000000000000000000000000000000000000000005ea0005bc00a007e5c0d200000000000000000000000000000000059800057e00018500009500001a4041c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2d0e30db00c20c02aaa39b223fe8d0a0e5c4f27ead9083c756cc27b73644935b8e68019ac6356c40661e1bc3158606ae4071118002dc6c07b73644935b8e68019ac6356c40661e1bc3158600000000000000000000000000000000000000000000000000294932ccadc9c58c02aaa39b223fe8d0a0e5c4f27ead9083c756cc251204dff5675ecff96b565ba3804dd4a63799ccba406761d38e5ddf6ccf6cf7c55759d5210750b5d60f30044e331d039000000000000000000000000761d38e5ddf6ccf6cf7c55759d5210750b5d60f3000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002f8a744a79be00000000000000000000000042f527f50f16a103b6ccab48bccca214500c10210000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec00a0860a32ec00000000000000000000000000000000000000000000000000003005635d54300003d05120ead050515e10fdb3540ccd6f8236c46790508a76111111111117dc0aa78b770fa6a738034120c30200c4e525b10b000000000000000000000000000000000000000000000000000000000000002000000000000000000000000022b1a53ac4be63cdc1f47c99572290eff1edd8020000000000000000000000006a32cc044dd6359c27bb66e7b02dce6dd0fda2470000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003005635d5430000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000067138e8c00000000000000000000000000000000000000000000000000030fb9b1525d8185f8d63fbcbe42e5999263c349cb5d81000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000026000000000000000000000000067297ee4eb097e072b4ab6f1620268061ae8046400000000000000000000000060cba82ddbf4b5ddcd4398cdd05354c6a790c309000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041d26038ef66344af785ff342b86db3da06c4cc6a62f0ca80ffd78affc0a95ccad44e814acebb1deda729bbfe3050bec14a47af487cc1cadc75f43db2d073016c31c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041a66cd52a747c5f60b9db637ffe30d0e413ec87858101832b4c5c1ae154bf247f3717c8ed4133e276ddf68d43a827f280863c91d6c42bc6ad1ec7083b2315b6fd1c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020d6bdbf78dac17f958d2ee523a2206206994597c13d831ec780a06c4eca27dac17f958d2ee523a2206206994597c13d831ec7111111125421ca6dc452d289314280a0f8842a65000000000000000000000000000000000000000000000000c095c0a2", - "value": "10000001", - "gas": 721429, - "gasPrice": "9525172167" - }, - "srcToken": { - "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "symbol": ticker_coin, - "name": "Ether", - "decimals": 18, - "eip2612": false, - "isFoT": false, - "logoURI": "https://tokens.1inch.io/0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.png", - "tags": [ - "crosschain", - "GROUP:ETH", - "native", - "PEG:ETH" - ] - }, - "dstToken": { - "address": "0x1234567890123456789012345678901234567890", - "symbol": ticker_token, - "name": "Just Token", - "decimals": 6, - "eip2612": false, - "isFoT": false, - "logoURI": "https://tokens.1inch.io/0x1234567890123456789012345678901234567890.png", - "tags": [ - "crosschain", - "GROUP:USDT", - "PEG:USD", - "tokens" - ] - }, - "protocols": [ - [ - [ - { - "name": "UNISWAP_V2", - "part": 100, - "fromTokenAddress": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "toTokenAddress": "0x761d38e5ddf6ccf6cf7c55759d5210750b5d60f3" - } - ], - [ - { - "name": "ONE_INCH_LP_1_1", - "part": 100, - "fromTokenAddress": "0x761d38e5ddf6ccf6cf7c55759d5210750b5d60f3", - "toTokenAddress": "0x111111111117dc0aa78b770fa6a738034120c302" - } - ], - [ - { - "name": "PMM11", - "part": 100, - "fromTokenAddress": "0x111111111117dc0aa78b770fa6a738034120c302", - "toTokenAddress": "0xdac17f958d2ee523a2206206994597c13d831ec7" - } - ] - ] - ] - }); - - let quote_req = ClassicSwapQuoteRequest { - base: ticker_coin.clone(), - rel: ticker_token.clone(), - amount: MmNumber::from("1.0"), - fee: None, - protocols: None, - gas_price: None, - complexity_level: None, - parts: None, - main_route_parts: None, - gas_limit: None, - include_tokens_info: true, - include_protocols: true, - include_gas: true, - connector_tokens: None, - }; - - let create_req = ClassicSwapCreateRequest { - base: ticker_coin.clone(), - rel: ticker_token.clone(), - amount: MmNumber::from("1.0"), - fee: None, - protocols: None, - gas_price: None, - complexity_level: None, - parts: None, - main_route_parts: None, - gas_limit: None, - include_tokens_info: true, - include_protocols: true, - include_gas: true, - connector_tokens: None, - slippage: 0.0, - excluded_protocols: None, - permit: None, - compatibility: None, - receiver: None, - referrer: None, - disable_estimate: None, - allow_partial_fill: None, - use_permit2: None, - }; - - ApiClient::call_api::.mock_safe(move |_| { - let response_quote_raw = response_quote_raw.clone(); - MockResult::Return(Box::pin(async move { - Ok(serde_json::from_value::(response_quote_raw).unwrap()) - })) - }); - - let quote_response = block_on(one_inch_v6_0_classic_swap_quote_rpc(ctx.clone(), quote_req)).unwrap(); - assert_eq!( - quote_response.dst_amount.amount, - BigDecimal::from_str("0.000013").unwrap() - ); - assert_eq!(quote_response.src_token.as_ref().unwrap().symbol, ticker_coin); - assert_eq!(quote_response.src_token.as_ref().unwrap().decimals, 18); - assert_eq!(quote_response.dst_token.as_ref().unwrap().symbol, ticker_token); - assert_eq!(quote_response.dst_token.as_ref().unwrap().decimals, 6); - assert_eq!(quote_response.gas.unwrap(), 452704_u128); - - ApiClient::call_api::.mock_safe(move |_| { - let response_create_raw = response_create_raw.clone(); - MockResult::Return(Box::pin(async move { - Ok(serde_json::from_value::(response_create_raw).unwrap()) - })) - }); - let create_response = block_on(one_inch_v6_0_classic_swap_create_rpc(ctx, create_req)).unwrap(); - assert_eq!( - create_response.dst_amount.amount, - BigDecimal::from_str("0.000013").unwrap() - ); - assert_eq!(create_response.src_token.as_ref().unwrap().symbol, ticker_coin); - assert_eq!(create_response.src_token.as_ref().unwrap().decimals, 18); - assert_eq!(create_response.dst_token.as_ref().unwrap().symbol, ticker_token); - assert_eq!(create_response.dst_token.as_ref().unwrap().decimals, 6); - assert_eq!(create_response.tx.as_ref().unwrap().data.len(), 1960); - assert_eq!( - create_response.tx.as_ref().unwrap().value, - BigDecimal::from_str("0.000000000010000001").unwrap() - ); - } -} diff --git a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs index 4f7e40532e..d839154df3 100644 --- a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs +++ b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs @@ -5722,7 +5722,8 @@ fn test_peer_time_sync_validation() { let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 10.into()); let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 10.into()); let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let bob_conf = Mm2TestConf::seednode(&hex::encode(bob_priv_key), &coins); + let mut bob_conf = Mm2TestConf::seednode(&hex::encode(bob_priv_key), &coins); + bob_conf.conf["skip_seednodes_check"] = true.into(); let mut mm_bob = block_on(MarketMakerIt::start_with_envs( bob_conf.conf, bob_conf.rpc_password, @@ -5732,8 +5733,9 @@ fn test_peer_time_sync_validation() { .unwrap(); let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - let alice_conf = + let mut alice_conf = Mm2TestConf::light_node(&hex::encode(alice_priv_key), &coins, &[mm_bob.ip.to_string().as_str()]); + alice_conf.conf["skip_seednodes_check"] = true.into(); let mut mm_alice = block_on(MarketMakerIt::start_with_envs( alice_conf.conf, alice_conf.rpc_password, diff --git a/mm2src/trading_api/src/one_inch_api/classic_swap_types.rs b/mm2src/trading_api/src/one_inch_api/classic_swap_types.rs index b7bb7902e8..ad1c5f9c8a 100644 --- a/mm2src/trading_api/src/one_inch_api/classic_swap_types.rs +++ b/mm2src/trading_api/src/one_inch_api/classic_swap_types.rs @@ -1,17 +1,17 @@ //! Structs to call 1inch classic swap api use super::client::QueryParams; -use super::errors::ApiClientError; +use super::errors::OneInchError; use common::{def_with_opt_param, push_if_some}; use ethereum_types::Address; -use mm2_err_handle::mm_error::{MmError, MmResult}; +use mm2_err_handle::mm_error::MmResult; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use url::Url; const ONE_INCH_MAX_SLIPPAGE: f32 = 50.0; const ONE_INCH_MAX_FEE_SHARE: f32 = 3.0; -const ONE_INCH_MAX_GAS: u128 = 11500000; +const ONE_INCH_MAX_GAS: u64 = 11500000; const ONE_INCH_MAX_PARTS: u32 = 100; const ONE_INCH_MAX_MAIN_ROUTE_PARTS: u32 = 50; const ONE_INCH_MAX_COMPLEXITY_LEVEL: u32 = 3; @@ -21,11 +21,12 @@ const ONE_INCH_DOMAIN: &str = "1inch.io"; /// API params builder for swap quote #[derive(Default)] -pub struct ClassicSwapQuoteParams { +pub struct ClassicSwapQuoteCallBuilder { /// Source token address src: String, /// Destination token address dst: String, + /// Source amount, decimal in coin units amount: String, // Optional fields fee: Option, @@ -34,14 +35,14 @@ pub struct ClassicSwapQuoteParams { complexity_level: Option, parts: Option, main_route_parts: Option, - gas_limit: Option, + gas_limit: Option, // originally in 1inch was u128 but we made it u64 as serde does not support u128 include_tokens_info: Option, include_protocols: Option, include_gas: Option, connector_tokens: Option, } -impl ClassicSwapQuoteParams { +impl ClassicSwapQuoteCallBuilder { pub fn new(src: String, dst: String, amount: String) -> Self { Self { src, @@ -57,14 +58,14 @@ impl ClassicSwapQuoteParams { def_with_opt_param!(complexity_level, u32); def_with_opt_param!(parts, u32); def_with_opt_param!(main_route_parts, u32); - def_with_opt_param!(gas_limit, u128); + def_with_opt_param!(gas_limit, u64); def_with_opt_param!(include_tokens_info, bool); def_with_opt_param!(include_protocols, bool); def_with_opt_param!(include_gas, bool); def_with_opt_param!(connector_tokens, String); #[allow(clippy::result_large_err)] - pub fn build_query_params(&self) -> MmResult { + pub fn build_query_params(&self) -> MmResult { self.validate_params()?; let mut params = vec![ @@ -89,7 +90,7 @@ impl ClassicSwapQuoteParams { /// Validate params by 1inch rules (to avoid extra requests) #[allow(clippy::result_large_err)] - fn validate_params(&self) -> MmResult<(), ApiClientError> { + fn validate_params(&self) -> MmResult<(), OneInchError> { validate_fee(&self.fee)?; validate_complexity_level(&self.complexity_level)?; validate_gas_limit(&self.gas_limit)?; @@ -100,21 +101,21 @@ impl ClassicSwapQuoteParams { } /// API params builder to create a tx for swap -#[derive(Default)] -pub struct ClassicSwapCreateParams { - src: String, - dst: String, - amount: String, +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ClassicSwapCreateCallBuilder { + pub src: String, + pub dst: String, + /// Amount in token smallest units + pub amount: String, from: String, slippage: f32, - // Optional fields fee: Option, protocols: Option, gas_price: Option, complexity_level: Option, parts: Option, main_route_parts: Option, - gas_limit: Option, + gas_limit: Option, // originally in 1inch was u128 but we made it u64 as serde does not support u128 include_tokens_info: Option, include_protocols: Option, include_gas: Option, @@ -129,7 +130,7 @@ pub struct ClassicSwapCreateParams { use_permit2: Option, } -impl ClassicSwapCreateParams { +impl ClassicSwapCreateCallBuilder { pub fn new(src: String, dst: String, amount: String, from: String, slippage: f32) -> Self { Self { src, @@ -147,7 +148,7 @@ impl ClassicSwapCreateParams { def_with_opt_param!(complexity_level, u32); def_with_opt_param!(parts, u32); def_with_opt_param!(main_route_parts, u32); - def_with_opt_param!(gas_limit, u128); + def_with_opt_param!(gas_limit, u64); def_with_opt_param!(include_tokens_info, bool); def_with_opt_param!(include_protocols, bool); def_with_opt_param!(include_gas, bool); @@ -162,7 +163,7 @@ impl ClassicSwapCreateParams { def_with_opt_param!(use_permit2, bool); #[allow(clippy::result_large_err)] - pub fn build_query_params(&self) -> MmResult { + pub fn build_query_params(&self) -> MmResult { self.validate_params()?; let mut params = vec![ @@ -198,7 +199,7 @@ impl ClassicSwapCreateParams { /// Validate params by 1inch rules (to avoid extra requests) #[allow(clippy::result_large_err)] - fn validate_params(&self) -> MmResult<(), ApiClientError> { + fn validate_params(&self) -> MmResult<(), OneInchError> { validate_slippage(self.slippage)?; validate_fee(&self.fee)?; validate_complexity_level(&self.complexity_level)?; @@ -218,9 +219,18 @@ pub struct TokenInfo { pub eip2612: bool, #[serde(rename = "isFoT", default)] pub is_fot: bool, - #[serde(rename = "logoURI", with = "serde_one_inch_link")] - pub logo_uri: String, + #[serde( + rename = "logoURI", + default, + deserialize_with = "serde_one_inch_link::deserialize_opt_string" + )] // Note: needed to use 'default' with 'deserialize_with' to allow optional 'logoURI' + pub logo_uri: Option, pub tags: Vec, + /// Token name as it is defined in the coins file. + /// This is used to show route tokens in the GUI, like they are in the coin file. + /// However, route tokens can be missed in the coins file and therefore cannot be filled. + /// In this case GUI may use LrTokenInfo::Address or LrTokenInfo::Symbol + pub symbol_kdf: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -236,7 +246,7 @@ pub struct ProtocolInfo { /// Returned data from an API call to get quote or create swap #[derive(Clone, Deserialize, Debug)] pub struct ClassicSwapData { - /// dst token amount to receive, in api is a decimal number as string + /// dst token amount to receive, integer number as string (1inch API format) #[serde(rename = "dstAmount")] pub dst_amount: String, #[serde(rename = "srcToken")] @@ -247,7 +257,8 @@ pub struct ClassicSwapData { /// Returned from create swap call pub tx: Option, /// Returned from quote call - pub gas: Option, + /// NOTE: in the 1inch API this field is u128 but this type is not supported by serde. u64 should be enough + pub gas: Option, } #[derive(Clone, Deserialize, Debug)] @@ -255,23 +266,23 @@ pub struct TxFields { pub from: Address, pub to: Address, pub data: String, - /// tx value, in api is a decimal number as string + /// tx value, integer number as string, in smallest units (1inch API format) pub value: String, - /// gas price, in api is a decimal number as string + /// gas price, integer number as string, in wei (1inch API format) #[serde(rename = "gasPrice")] pub gas_price: String, - /// gas limit, in api is a decimal number - pub gas: u128, + /// gas limit + pub gas: u64, } #[derive(Deserialize, Serialize)] pub struct ProtocolImage { pub id: String, pub title: String, - #[serde(with = "serde_one_inch_link")] - pub img: String, - #[serde(with = "serde_one_inch_link")] - pub img_color: String, + #[serde(deserialize_with = "serde_one_inch_link::deserialize_opt_string")] + pub img: Option, + #[serde(deserialize_with = "serde_one_inch_link::deserialize_opt_string")] + pub img_color: Option, } #[derive(Deserialize)] @@ -286,30 +297,22 @@ pub struct TokensResponse { mod serde_one_inch_link { use super::validate_one_inch_link; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use serde::{Deserialize, Deserializer}; - /// Just forward to the normal serializer - pub(super) fn serialize(s: &String, serializer: S) -> Result - where - S: Serializer, - { - s.serialize(serializer) - } - - /// Deserialise String with checking links - pub(super) fn deserialize<'a, D>(deserializer: D) -> Result + /// Deserialise Option with checking links + pub(super) fn deserialize_opt_string<'a, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'a>, { - ::deserialize(deserializer) - .map(|value| validate_one_inch_link(&value).unwrap_or_default()) + as Deserialize>::deserialize(deserializer) + .map(|opt_value| opt_value.map(|value| validate_one_inch_link(&value).unwrap_or_default())) } } #[allow(clippy::result_large_err)] -fn validate_slippage(slippage: f32) -> MmResult<(), ApiClientError> { +fn validate_slippage(slippage: f32) -> MmResult<(), OneInchError> { if !(0.0..=ONE_INCH_MAX_SLIPPAGE).contains(&slippage) { - return Err(ApiClientError::OutOfBounds { + return Err(OneInchError::OutOfBounds { param: "slippage".to_owned(), value: slippage.to_string(), min: 0.0.to_string(), @@ -321,10 +324,10 @@ fn validate_slippage(slippage: f32) -> MmResult<(), ApiClientError> { } #[allow(clippy::result_large_err)] -fn validate_fee(fee: &Option) -> MmResult<(), ApiClientError> { +fn validate_fee(fee: &Option) -> MmResult<(), OneInchError> { if let Some(fee) = fee { if !(0.0..=ONE_INCH_MAX_FEE_SHARE).contains(fee) { - return Err(ApiClientError::OutOfBounds { + return Err(OneInchError::OutOfBounds { param: "fee".to_owned(), value: fee.to_string(), min: 0.0.to_string(), @@ -337,10 +340,10 @@ fn validate_fee(fee: &Option) -> MmResult<(), ApiClientError> { } #[allow(clippy::result_large_err)] -fn validate_gas_limit(gas_limit: &Option) -> MmResult<(), ApiClientError> { +fn validate_gas_limit(gas_limit: &Option) -> MmResult<(), OneInchError> { if let Some(gas_limit) = gas_limit { if gas_limit > &ONE_INCH_MAX_GAS { - return Err(ApiClientError::OutOfBounds { + return Err(OneInchError::OutOfBounds { param: "gas_limit".to_owned(), value: gas_limit.to_string(), min: 0.to_string(), @@ -353,10 +356,10 @@ fn validate_gas_limit(gas_limit: &Option) -> MmResult<(), ApiClientError> } #[allow(clippy::result_large_err)] -fn validate_parts(parts: &Option) -> MmResult<(), ApiClientError> { +fn validate_parts(parts: &Option) -> MmResult<(), OneInchError> { if let Some(parts) = parts { if parts > &ONE_INCH_MAX_PARTS { - return Err(ApiClientError::OutOfBounds { + return Err(OneInchError::OutOfBounds { param: "parts".to_owned(), value: parts.to_string(), min: 0.to_string(), @@ -369,10 +372,10 @@ fn validate_parts(parts: &Option) -> MmResult<(), ApiClientError> { } #[allow(clippy::result_large_err)] -fn validate_main_route_parts(main_route_parts: &Option) -> MmResult<(), ApiClientError> { +fn validate_main_route_parts(main_route_parts: &Option) -> MmResult<(), OneInchError> { if let Some(main_route_parts) = main_route_parts { if main_route_parts > &ONE_INCH_MAX_MAIN_ROUTE_PARTS { - return Err(ApiClientError::OutOfBounds { + return Err(OneInchError::OutOfBounds { param: "main route parts".to_owned(), value: main_route_parts.to_string(), min: 0.to_string(), @@ -385,10 +388,10 @@ fn validate_main_route_parts(main_route_parts: &Option) -> MmResult<(), Api } #[allow(clippy::result_large_err)] -fn validate_complexity_level(complexity_level: &Option) -> MmResult<(), ApiClientError> { +fn validate_complexity_level(complexity_level: &Option) -> MmResult<(), OneInchError> { if let Some(complexity_level) = complexity_level { if complexity_level > &ONE_INCH_MAX_COMPLEXITY_LEVEL { - return Err(ApiClientError::OutOfBounds { + return Err(OneInchError::OutOfBounds { param: "complexity level".to_owned(), value: complexity_level.to_string(), min: 0.to_string(), @@ -401,9 +404,8 @@ fn validate_complexity_level(complexity_level: &Option) -> MmResult<(), Api } /// Check if url is valid and is a subdomain of 1inch domain (simple anti-phishing check) -#[allow(clippy::result_large_err)] -fn validate_one_inch_link(s: &str) -> MmResult { - let url = Url::parse(s).map_err(|_err| ApiClientError::ParseBodyError { +fn validate_one_inch_link(s: &str) -> Result { + let url = Url::parse(s).map_err(|_err| OneInchError::ParseBodyError { error_msg: BAD_URL_IN_RESPONSE_ERROR.to_owned(), })?; if let Some(host) = url.host() { @@ -411,7 +413,7 @@ fn validate_one_inch_link(s: &str) -> MmResult { return Ok(s.to_owned()); } } - MmError::err(ApiClientError::ParseBodyError { + Err(OneInchError::ParseBodyError { error_msg: BAD_URL_IN_RESPONSE_ERROR.to_owned(), }) } @@ -419,6 +421,7 @@ fn validate_one_inch_link(s: &str) -> MmResult { #[test] fn test_validate_one_inch_link() { assert!(validate_one_inch_link("https://cdn.1inch.io/liquidity-sources-logo/wmatic_color.png").is_ok()); + assert!(validate_one_inch_link("https://CDN.1INCH.IO/liquidity-sources-logo/wmatic_color.png").is_ok()); assert!(validate_one_inch_link("https://example.org/somepath/somefile.png").is_err()); assert!(validate_one_inch_link("https://inch.io/somepath/somefile.png").is_err()); assert!(validate_one_inch_link("127.0.0.1").is_err()); diff --git a/mm2src/trading_api/src/one_inch_api/client.rs b/mm2src/trading_api/src/one_inch_api/client.rs index 3ed9df3b4d..c25e64fb03 100644 --- a/mm2src/trading_api/src/one_inch_api/client.rs +++ b/mm2src/trading_api/src/one_inch_api/client.rs @@ -1,4 +1,4 @@ -use super::errors::ApiClientError; +use super::errors::OneInchError; use crate::one_inch_api::errors::NativeError; use common::{log, StatusCode}; #[cfg(feature = "test-ext-api")] @@ -23,6 +23,8 @@ use futures::lock::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard}; use mocktopus::macros::*; const ONE_INCH_AGGREGATION_ROUTER_CONTRACT_V6_0: &str = "0x111111125421ca6dc452d289314280a0f8842a65"; +/// Special contract address used by 1inch to represent native ETH in their API. This is a widely adopted +/// convention in the Ethereum ecosystem, to reference the native blockchain currency (ETH) in smart contracts and APIs using this address. const ONE_INCH_ETH_SPECIAL_CONTRACT: &str = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; #[cfg(test)] @@ -80,7 +82,7 @@ impl UrlBuilder { } #[allow(clippy::result_large_err)] - pub fn build(&self) -> MmResult { + pub fn build(&self) -> MmResult { let url = self.base_url.join(self.endpoint)?; let url = if let Some(chain_id) = self.chain_id { url.join(&format!("{chain_id}/"))? @@ -132,7 +134,7 @@ impl SwapUrlBuilder { ctx: &MmArc, chain_id: u64, method: SwapApiMethods, - ) -> MmResult { + ) -> MmResult { Ok(UrlBuilder::new( ApiClient::base_url(ctx)?, Some(chain_id), @@ -161,7 +163,7 @@ impl PortfolioUrlBuilder { const PORTFOLIO_PRICES_ENDPOINT_V1_0: &str = "portfolio/integrations/prices/v1/"; #[allow(clippy::result_large_err)] - pub fn create_api_url_builder(ctx: &MmArc, method: PortfolioApiMethods) -> MmResult { + pub fn create_api_url_builder(ctx: &MmArc, method: PortfolioApiMethods) -> MmResult { Ok(UrlBuilder::new( ApiClient::base_url(ctx)?, None, @@ -179,11 +181,11 @@ pub struct ApiClient; impl ApiClient { #[allow(unused_variables)] #[allow(clippy::result_large_err)] - fn base_url(ctx: &MmArc) -> MmResult { + fn base_url(ctx: &MmArc) -> MmResult { #[cfg(not(test))] let url_cfg = ctx.conf["1inch_api"] .as_str() - .ok_or(ApiClientError::InvalidParam("No API config param".to_owned()))?; + .ok_or(OneInchError::InvalidParam("No API config param".to_owned()))?; #[cfg(test)] let url_cfg = ONE_INCH_API_TEST_URL; @@ -193,7 +195,7 @@ impl ApiClient { pub const fn eth_special_contract() -> &'static str { ONE_INCH_ETH_SPECIAL_CONTRACT - } + } // TODO: must use the 1inch call, not a const (on zk chain it's not const) pub const fn classic_swap_contract() -> &'static str { ONE_INCH_AGGREGATION_ROUTER_CONTRACT_V6_0 @@ -212,7 +214,7 @@ impl ApiClient { ] } - pub async fn call_api(api_url: Url) -> MmResult + pub async fn call_api(api_url: Url) -> MmResult where T: DeserializeOwned, { @@ -222,18 +224,21 @@ impl ApiClient { log::debug!("1inch call url={api_url}"); let (status_code, _, body) = slurp_url_with_headers(api_url.as_str(), ApiClient::get_headers()) .await - .mm_err(ApiClientError::TransportError)?; - log::debug!("1inch response body={}", String::from_utf8_lossy(&body)); + .mm_err(OneInchError::TransportError)?; + log::debug!( + "1inch response body={}", + String::from_utf8_lossy(&body[..std::cmp::min(128, body.len())]) + ); // TODO: handle text body errors like 'The limit of requests per second has been exceeded' - let body = serde_json::from_slice(&body).map_to_mm(|err| ApiClientError::ParseBodyError { + let body = serde_json::from_slice(&body).map_to_mm(|err| OneInchError::ParseBodyError { error_msg: err.to_string(), })?; if status_code != StatusCode::OK { let error = NativeError::new(status_code, body); - return Err(MmError::new(ApiClientError::from_native_error(error))); + return Err(MmError::new(OneInchError::from_native_error(error))); } serde_json::from_value(body).map_err(|err| { - ApiClientError::ParseBodyError { + OneInchError::ParseBodyError { error_msg: err.to_string(), } .into() diff --git a/mm2src/trading_api/src/one_inch_api/errors.rs b/mm2src/trading_api/src/one_inch_api/errors.rs index 614d406b23..b1a5aeba56 100644 --- a/mm2src/trading_api/src/one_inch_api/errors.rs +++ b/mm2src/trading_api/src/one_inch_api/errors.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Display, Serialize, EnumFromStringify)] -pub enum ApiClientError { +pub enum OneInchError { #[from_stringify("url::ParseError")] InvalidParam(String), #[display(fmt = "Parameter {param} out of bounds, value: {value}, min: {min} max: {max}")] @@ -88,10 +88,10 @@ impl NativeError { } } -impl ApiClientError { +impl OneInchError { /// Convert from native API errors to lib errors /// Look for known API errors. If none found return as general API error - pub(crate) fn from_native_error(api_error: NativeError) -> ApiClientError { + pub(crate) fn from_native_error(api_error: NativeError) -> OneInchError { match api_error { NativeError::HttpError400(error_400) => { if let Some(meta) = error_400.meta { @@ -104,7 +104,7 @@ impl ApiClientError { Default::default() }; let allowance = U256::from_dec_str(&meta_allowance.meta_value).unwrap_or_default(); - return ApiClientError::AllowanceNotEnough { + return OneInchError::AllowanceNotEnough { error_msg: error_400.error, status_code: error_400.status_code, description: error_400.description.unwrap_or_default(), @@ -113,18 +113,18 @@ impl ApiClientError { }; } } - ApiClientError::GeneralApiError { + OneInchError::GeneralApiError { error_msg: error_400.error, status_code: error_400.status_code, description: error_400.description.unwrap_or_default(), } }, - NativeError::HttpError { error_msg, status_code } => ApiClientError::GeneralApiError { + NativeError::HttpError { error_msg, status_code } => OneInchError::GeneralApiError { error_msg, status_code, description: Default::default(), }, - NativeError::ParseError { error_msg } => ApiClientError::ParseBodyError { error_msg }, + NativeError::ParseError { error_msg } => OneInchError::ParseBodyError { error_msg }, } } } diff --git a/mm2src/trading_api/src/one_inch_api/portfolio_types.rs b/mm2src/trading_api/src/one_inch_api/portfolio_types.rs index a77a3d647c..4e0f127e1a 100644 --- a/mm2src/trading_api/src/one_inch_api/portfolio_types.rs +++ b/mm2src/trading_api/src/one_inch_api/portfolio_types.rs @@ -1,7 +1,7 @@ //! Structs to call 1inch portfolio api use super::client::QueryParams; -use super::errors::ApiClientError; +use super::errors::OneInchError; use common::{def_with_opt_param, push_if_some}; use mm2_err_handle::mm_error::MmResult; use mm2_number::BigDecimal; @@ -63,7 +63,7 @@ impl CrossPriceParams { def_with_opt_param!(limit, u32); #[allow(clippy::result_large_err)] - pub fn build_query_params(&self) -> MmResult { + pub fn build_query_params(&self) -> MmResult { let mut params = vec![ ("chain_id", self.chain_id.to_string()), ("token0_address", self.token0_address.clone()),