diff --git a/Cargo.lock b/Cargo.lock index c6cd5560d8..639515ccdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4078,6 +4078,7 @@ dependencies = [ "either", "enum-primitive-derive", "enum_derives", + "env_logger", "ethabi", "ethcore-transaction", "ethereum-types", @@ -7427,6 +7428,7 @@ dependencies = [ "derive_more", "enum_derives", "ethereum-types", + "futures 0.3.28", "lazy_static", "mm2_core", "mm2_err_handle", diff --git a/mm2src/coins/eth.rs b/mm2src/coins/eth.rs index 8f0fc3d385..9483cf914e 100644 --- a/mm2src/coins/eth.rs +++ b/mm2src/coins/eth.rs @@ -128,6 +128,13 @@ use super::{coin_conf, lp_coinfind_or_err, AsyncMutex, BalanceError, BalanceFut, WithdrawError, WithdrawFee, WithdrawFut, WithdrawRequest, WithdrawResult, EARLY_CONFIRMATION_ERR_LOG, INVALID_CONTRACT_ADDRESS_ERR_LOG, INVALID_PAYMENT_STATE_ERR_LOG, INVALID_RECEIVER_ERR_LOG, INVALID_SENDER_ERR_LOG, INVALID_SWAP_ID_ERR_LOG}; +#[cfg(test)] +pub(crate) use eth_utils::display_u256_with_decimal_point; +pub use eth_utils::{addr_from_pubkey_str, addr_from_raw_pubkey, mm_number_from_u256, mm_number_to_u256, + u256_to_big_decimal, wei_from_big_decimal, wei_from_coins_mm_number, wei_from_gwei_decimal, + wei_to_coins_mm_number, wei_to_eth_decimal, wei_to_gwei_decimal}; +use eth_utils::{get_function_input_data, get_function_name}; + pub use rlp; cfg_native! { use std::path::PathBuf; @@ -164,8 +171,12 @@ use erc20::get_token_decimals; pub(crate) mod eth_swap_v2; use eth_swap_v2::{extract_id_from_tx_data, EthPaymentType, PaymentMethod, SpendTxSearchParams}; +pub mod eth_utils; pub mod tron; +pub const ETH_PROTOCOL_TYPE: &str = "ETH"; +pub const ERC20_PROTOCOL_TYPE: &str = "ERC20"; + /// https://github.com/artemii235/etomic-swap/blob/master/contracts/EtomicSwap.sol /// Dev chain (195.201.137.5:8565) contract address: 0x83965C539899cC0F918552e5A26915de40ee8852 /// Ropsten: https://ropsten.etherscan.io/address/0x7bc1bbdd6a0a722fc9bffc49c921b685ecb84b94 @@ -6248,77 +6259,6 @@ fn validate_fee_impl(coin: EthCoin, validate_fee_args: EthValidateFeeArgs<'_>) - Box::new(fut.boxed().compat()) } -fn get_function_input_data(decoded: &[Token], func: &Function, index: usize) -> Result { - decoded.get(index).cloned().ok_or(format!( - "Missing input in function {}: No input found at index {}", - func.name.clone(), - index - )) -} - -fn get_function_name(name: &str, watcher_reward: bool) -> String { - if watcher_reward { - format!("{}{}", name, "Reward") - } else { - name.to_owned() - } -} - -pub fn addr_from_raw_pubkey(pubkey: &[u8]) -> Result { - let pubkey = try_s!(PublicKey::from_slice(pubkey).map_err(|e| ERRL!("{:?}", e))); - let eth_public = Public::from_slice(&pubkey.serialize_uncompressed()[1..65]); - Ok(public_to_address(ð_public)) -} - -pub fn addr_from_pubkey_str(pubkey: &str) -> Result { - let pubkey_bytes = try_s!(hex::decode(pubkey)); - let addr = try_s!(addr_from_raw_pubkey(&pubkey_bytes)); - Ok(format!("{:#02x}", addr)) -} - -fn display_u256_with_decimal_point(number: U256, decimals: u8) -> String { - let mut string = number.to_string(); - let decimals = decimals as usize; - if string.len() <= decimals { - string.insert_str(0, &"0".repeat(decimals - string.len() + 1)); - } - - string.insert(string.len() - decimals, '.'); - string.trim_end_matches('0').into() -} - -/// Converts 'number' to value with decimal point and shifts it left by 'decimals' places -pub fn u256_to_big_decimal(number: U256, decimals: u8) -> NumConversResult { - let string = display_u256_with_decimal_point(number, decimals); - Ok(string.parse::()?) -} - -/// Shifts 'number' with decimal point right by 'decimals' places and converts it to U256 value -pub fn wei_from_big_decimal(amount: &BigDecimal, decimals: u8) -> NumConversResult { - let mut amount = amount.to_string(); - let dot = amount.find(|c| c == '.'); - let decimals = decimals as usize; - if let Some(index) = dot { - let mut fractional = amount.split_off(index); - // remove the dot from fractional part - fractional.remove(0); - if fractional.len() < decimals { - fractional.insert_str(fractional.len(), &"0".repeat(decimals - fractional.len())); - } - fractional.truncate(decimals); - amount.push_str(&fractional); - } else { - amount.insert_str(amount.len(), &"0".repeat(decimals)); - } - U256::from_dec_str(&amount).map_to_mm(|e| NumConversError::new(format!("{:?}", e))) -} - -pub fn wei_from_gwei_decimal(bigdec: &BigDecimal) -> NumConversResult { - wei_from_big_decimal(bigdec, ETH_GWEI_DECIMALS) -} - -pub fn wei_to_gwei_decimal(wei: U256) -> NumConversResult { u256_to_big_decimal(wei, ETH_GWEI_DECIMALS) } - impl Transaction for SignedEthTx { fn tx_hex(&self) -> Vec { rlp::encode(self).to_vec() } diff --git a/mm2src/coins/eth/erc20.rs b/mm2src/coins/eth/erc20.rs index 3ab3d89a03..53c6b89cdf 100644 --- a/mm2src/coins/eth/erc20.rs +++ b/mm2src/coins/eth/erc20.rs @@ -1,11 +1,13 @@ +use super::{ERC20_PROTOCOL_TYPE, ETH_PROTOCOL_TYPE}; use crate::eth::web3_transport::Web3Transport; use crate::eth::{EthCoin, ERC20_CONTRACT}; -use crate::{CoinsContext, MarketCoinOps, MmCoinEnum}; +use crate::{CoinsContext, MarketCoinOps, MmCoinEnum, Ticker}; use ethabi::Token; use ethereum_types::Address; use futures_util::TryFutureExt; use mm2_core::mm_ctx::MmArc; use mm2_err_handle::mm_error::MmResult; +use std::str::FromStr; use web3::types::{BlockId, BlockNumber, CallRequest}; use web3::{Transport, Web3}; @@ -70,19 +72,40 @@ pub async fn get_erc20_token_info(coin: &EthCoin, token_addr: Address) -> Result Ok(Erc20TokenInfo { symbol, decimals }) } +/// Finds eth platfrom coin in coins config by chain_id and returns its ticker. +pub fn get_platform_ticker(ctx: &MmArc, chain_id: u64) -> Option { + ctx.conf["coins"].as_array()?.iter().find_map(|coin| { + let protocol = coin.get("protocol")?; + let protocol_type = protocol.get("type")?.as_str()?; + if protocol_type != ETH_PROTOCOL_TYPE { + return None; + } + let coin_chain_id = protocol.get("protocol_data")?.get("chain_id")?.as_u64()?; + if coin_chain_id == chain_id { + coin.get("coin")?.as_str().map(|s| s.to_string()) + } else { + None + } + }) +} + /// Finds if an ERC20 token is in coins config by its contract address and returns its ticker. -pub fn get_erc20_ticker_by_contract_address(ctx: &MmArc, platform: &str, contract_address: &str) -> Option { +pub fn get_erc20_ticker_by_contract_address( + ctx: &MmArc, + platform_ticker: &str, + contract_address: &Address, +) -> Option { ctx.conf["coins"].as_array()?.iter().find_map(|coin| { let protocol = coin.get("protocol")?; let protocol_type = protocol.get("type")?.as_str()?; - if protocol_type != "ERC20" { + if protocol_type != ERC20_PROTOCOL_TYPE { return None; } let protocol_data = protocol.get("protocol_data")?; - let coin_platform = protocol_data.get("platform")?.as_str()?; - let coin_contract_address = protocol_data.get("contract_address")?.as_str()?; + let coin_platform_ticker = protocol_data.get("platform")?.as_str()?; + let coin_contract_address = Address::from_str(protocol_data.get("contract_address")?.as_str()?).ok()?; - if coin_platform == platform && coin_contract_address == contract_address { + if coin_platform_ticker == platform_ticker && &coin_contract_address == contract_address { coin.get("coin")?.as_str().map(|s| s.to_string()) } else { None diff --git a/mm2src/coins/eth/eth_utils.rs b/mm2src/coins/eth/eth_utils.rs new file mode 100644 index 0000000000..e6a191d534 --- /dev/null +++ b/mm2src/coins/eth/eth_utils.rs @@ -0,0 +1,107 @@ +use super::{ETH_DECIMALS, ETH_GWEI_DECIMALS}; +use crate::{NumConversError, NumConversResult}; +use ethabi::{Function, Token}; +use ethereum_types::{Address, FromDecStrErr, U256}; +use ethkey::{public_to_address, Public}; +use mm2_err_handle::prelude::MapToMmResult; +use mm2_number::{BigDecimal, MmNumber}; +use secp256k1::PublicKey; + +pub(crate) fn get_function_input_data(decoded: &[Token], func: &Function, index: usize) -> Result { + decoded.get(index).cloned().ok_or(format!( + "Missing input in function {}: No input found at index {}", + func.name.clone(), + index + )) +} + +pub(crate) fn get_function_name(name: &str, watcher_reward: bool) -> String { + if watcher_reward { + format!("{}{}", name, "Reward") + } else { + name.to_owned() + } +} + +pub fn addr_from_raw_pubkey(pubkey: &[u8]) -> Result { + let pubkey = try_s!(PublicKey::from_slice(pubkey).map_err(|e| ERRL!("{:?}", e))); + let eth_public = Public::from_slice(&pubkey.serialize_uncompressed()[1..65]); + Ok(public_to_address(ð_public)) +} + +pub fn addr_from_pubkey_str(pubkey: &str) -> Result { + let pubkey_bytes = try_s!(hex::decode(pubkey)); + let addr = try_s!(addr_from_raw_pubkey(&pubkey_bytes)); + Ok(format!("{:#02x}", addr)) +} + +pub(crate) fn display_u256_with_decimal_point(number: U256, decimals: u8) -> String { + let mut string = number.to_string(); + let decimals = decimals as usize; + if string.len() <= decimals { + string.insert_str(0, &"0".repeat(decimals - string.len() + 1)); + } + + string.insert(string.len() - decimals, '.'); + string.trim_end_matches('0').into() +} + +/// Converts 'number' to value with decimal point and shifts it left by 'decimals' places +pub fn u256_to_big_decimal(number: U256, decimals: u8) -> NumConversResult { + let string = display_u256_with_decimal_point(number, decimals); + Ok(string.parse::()?) +} + +/// Shifts 'number' with decimal point right by 'decimals' places and converts it to U256 value +pub fn wei_from_big_decimal(amount: &BigDecimal, decimals: u8) -> NumConversResult { + let mut amount = amount.to_string(); + let dot = amount.find(|c| c == '.'); + let decimals = decimals as usize; + if let Some(index) = dot { + let mut fractional = amount.split_off(index); + // remove the dot from fractional part + fractional.remove(0); + if fractional.len() < decimals { + fractional.insert_str(fractional.len(), &"0".repeat(decimals - fractional.len())); + } + fractional.truncate(decimals); + amount.push_str(&fractional); + } else { + amount.insert_str(amount.len(), &"0".repeat(decimals)); + } + U256::from_dec_str(&amount).map_to_mm(|e| NumConversError::new(format!("{:?}", e))) +} + +/// Converts BigDecimal gwei value to wei value as U256 +#[inline(always)] +pub fn wei_from_gwei_decimal(bigdec: &BigDecimal) -> NumConversResult { + wei_from_big_decimal(bigdec, ETH_GWEI_DECIMALS) +} + +/// Converts a U256 wei value to an gwei value as a BigDecimal +#[inline(always)] +pub fn wei_to_gwei_decimal(wei: U256) -> NumConversResult { u256_to_big_decimal(wei, ETH_GWEI_DECIMALS) } + +/// Converts a U256 wei value to an ETH value as a BigDecimal +/// TODO: use wei_to_eth_decimal instead of u256_to_big_decimal(gas_cost_wei, ETH_DECIMALS) +#[inline(always)] +pub fn wei_to_eth_decimal(wei: U256) -> NumConversResult { u256_to_big_decimal(wei, ETH_DECIMALS) } + +#[inline] +pub fn mm_number_to_u256(mm_number: &MmNumber) -> Result { + U256::from_dec_str(mm_number.to_ratio().to_integer().to_string().as_str()) +} + +#[inline] +pub fn mm_number_from_u256(u256: U256) -> MmNumber { MmNumber::from(u256.to_string().as_str()) } + +#[inline] +pub fn wei_from_coins_mm_number(mm_number: &MmNumber, decimals: u8) -> NumConversResult { + wei_from_big_decimal(&mm_number.to_decimal(), decimals) +} + +#[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 81bd868324..a3d9c8f856 100644 --- a/mm2src/coins/lp_coins.rs +++ b/mm2src/coins/lp_coins.rs @@ -52,7 +52,7 @@ use crypto::{derive_secp256k1_secret, Bip32Error, Bip44Chain, CryptoCtx, CryptoC Secp256k1ExtendedPublicKey, Secp256k1Secret, WithHwRpcError}; use derive_more::Display; use enum_derives::{EnumFromStringify, EnumFromTrait}; -use ethereum_types::{H256, H264, H520, U256}; +use ethereum_types::{Address as EthAddress, H256, H264, H520, U256}; use futures::compat::Future01CompatExt; use futures::lock::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard}; use futures::{FutureExt, TryFutureExt}; @@ -309,6 +309,7 @@ pub type ValidateTakerFundingSpendPreimageResult = MmResult<(), ValidateTakerFun pub type ValidateTakerPaymentSpendPreimageResult = MmResult<(), ValidateTakerPaymentSpendPreimageError>; pub type IguanaPrivKey = Secp256k1Secret; +pub type Ticker = String; // Constants for logs used in tests pub const INVALID_SENDER_ERR_LOG: &str = "Invalid sender"; @@ -4590,18 +4591,26 @@ pub enum CustomTokenError { fmt = "Token with the same ticker already exists in coins configs, ticker in config: {}", ticker_in_config )] - DuplicateTickerInConfig { ticker_in_config: String }, + DuplicateTickerInConfig { + ticker_in_config: String, + }, #[display( fmt = "Token with the same contract address already exists in coins configs, ticker in config: {}", ticker_in_config )] - DuplicateContractInConfig { ticker_in_config: String }, + DuplicateContractInConfig { + ticker_in_config: String, + }, #[display( fmt = "Token is already activated, ticker: {}, contract address: {}", ticker, contract_address )] - TokenWithSameContractAlreadyActivated { ticker: String, contract_address: String }, + TokenWithSameContractAlreadyActivated { + ticker: String, + contract_address: String, + }, + InvalidTokenAddress, } impl CoinProtocol { @@ -4667,7 +4676,11 @@ impl CoinProtocol { // if it is duplicated in config, we will have two orderbooks one using the ticker and one using the contract address. // Todo: We should use the contract address for orderbook topics instead of the ticker once we make custom tokens non-wallet only. // If a coin is added to the config later, users who added it as a custom token and did not update will not see the orderbook. - if let Some(existing_ticker) = get_erc20_ticker_by_contract_address(ctx, platform, contract_address) { + if let Some(existing_ticker) = get_erc20_ticker_by_contract_address( + ctx, + platform, + &EthAddress::from_str(contract_address).map_err(|_| MmError::new(CustomTokenError::InvalidTokenAddress))?, + ) { return Err(MmError::new(CustomTokenError::DuplicateContractInConfig { ticker_in_config: existing_ticker, })); diff --git a/mm2src/coins_activation/src/erc20_token_activation.rs b/mm2src/coins_activation/src/erc20_token_activation.rs index aa9d66ddeb..dad933ac16 100644 --- a/mm2src/coins_activation/src/erc20_token_activation.rs +++ b/mm2src/coins_activation/src/erc20_token_activation.rs @@ -72,6 +72,7 @@ impl TryFromCoinProtocol for Erc20Protocol { contract_address, } => { let token_addr = valid_addr_from_str(&contract_address).map_err(|_| CoinProtocol::ERC20 { + // TODO: maybe add error description to this err (we're losing 'Invalid address checksum' here) platform: platform.clone(), contract_address, })?; diff --git a/mm2src/mm2_main/Cargo.toml b/mm2src/mm2_main/Cargo.toml index b2fe520cfb..f3b526d6bd 100644 --- a/mm2src/mm2_main/Cargo.toml +++ b/mm2src/mm2_main/Cargo.toml @@ -135,6 +135,7 @@ coins_activation = { path = "../coins_activation", features = ["for-tests"] } common = { path = "../common", features = ["for-tests"] } mm2_test_helpers = { path = "../mm2_test_helpers" } trading_api = { path = "../trading_api", features = ["for-tests"] } +env_logger.workspace = true mocktopus.workspace = true testcontainers.workspace = true web3 = { workspace = true, default-features = false, features = ["http-rustls-tls"] } diff --git a/mm2src/mm2_main/src/lp_ordermatch.rs b/mm2src/mm2_main/src/lp_ordermatch.rs index 0975675158..601a9030fb 100644 --- a/mm2src/mm2_main/src/lp_ordermatch.rs +++ b/mm2src/mm2_main/src/lp_ordermatch.rs @@ -6028,19 +6028,19 @@ async fn subscribe_to_orderbook_topic( Ok(()) } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct RpcOrderbookEntryV2 { - coin: String, - address: OrderbookAddress, - price: MmNumberMultiRepr, - pubkey: String, - uuid: Uuid, - is_mine: bool, - base_max_volume: MmNumberMultiRepr, - base_min_volume: MmNumberMultiRepr, - rel_max_volume: MmNumberMultiRepr, - rel_min_volume: MmNumberMultiRepr, - conf_settings: Option, + pub coin: String, + pub address: OrderbookAddress, + pub price: MmNumberMultiRepr, + pub pubkey: String, + pub uuid: Uuid, + pub is_mine: bool, + pub base_max_volume: MmNumberMultiRepr, + pub base_min_volume: MmNumberMultiRepr, + pub rel_max_volume: MmNumberMultiRepr, + pub rel_min_volume: MmNumberMultiRepr, + pub conf_settings: Option, } fn choose_maker_confs_and_notas( @@ -6153,7 +6153,7 @@ fn choose_taker_confs_and_notas( } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "address_type", content = "address_data")] pub enum OrderbookAddress { Transparent(String), diff --git a/mm2src/mm2_main/src/lp_swap/trade_preimage.rs b/mm2src/mm2_main/src/lp_swap/trade_preimage.rs index ceccd64241..e9a3cef9b6 100644 --- a/mm2src/mm2_main/src/lp_swap/trade_preimage.rs +++ b/mm2src/mm2_main/src/lp_swap/trade_preimage.rs @@ -18,6 +18,7 @@ construct_detailed!(DetailedRequiredBalance, required_balance); pub type TradePreimageRpcResult = Result>; +/// Calculate all fees for the swap and max volume (for maker) pub async fn trade_preimage_rpc( ctx: MmArc, req: TradePreimageRequest, diff --git a/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs b/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs index 4b18118a7d..6e55fc0e65 100644 --- a/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs +++ b/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs @@ -13,6 +13,8 @@ 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::{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, @@ -162,7 +164,12 @@ async fn experimental_rpcs_dispatcher( if let Some(staking_method) = experimental_method.strip_prefix("staking::") { return staking_dispatcher(request, ctx, staking_method).await; } - + if let Some(lr_method) = experimental_method.strip_prefix("liquidity_routing::") { + return liquidity_routing_dispatcher(request, ctx, lr_method).await; + } + if let Some(one_inch_method) = experimental_method.strip_prefix("1inch_v6_0::") { + return one_inch_dispatcher(request, ctx, one_inch_method).await; + } MmError::err(DispatcherError::NoSuchMethod) } @@ -254,13 +261,6 @@ async fn dispatcher_v2(request: MmRpcRequest, ctx: MmArc) -> DispatcherResult handle_mmrpc(ctx, request, set_swap_transaction_fee_policy).await, "send_asked_data" => handle_mmrpc(ctx, request, send_asked_data_rpc).await, "z_coin_tx_history" => handle_mmrpc(ctx, request, coins::my_tx_history_v2::z_coin_tx_history_rpc).await, - "1inch_v6_0_classic_swap_contract" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_contract_rpc).await, - "1inch_v6_0_classic_swap_quote" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_quote_rpc).await, - "1inch_v6_0_classic_swap_create" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_create_rpc).await, - "1inch_v6_0_classic_swap_liquidity_sources" => { - handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_liquidity_sources_rpc).await - }, - "1inch_v6_0_classic_swap_tokens" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_tokens_rpc).await, "wc_new_connection" => handle_mmrpc(ctx, request, new_connection).await, "wc_get_session" => handle_mmrpc(ctx, request, get_session).await, "wc_get_sessions" => handle_mmrpc(ctx, request, get_all_sessions).await, @@ -493,3 +493,33 @@ async fn staking_dispatcher( _ => MmError::err(DispatcherError::NoSuchMethod), } } + +async fn one_inch_dispatcher( + request: MmRpcRequest, + ctx: MmArc, + lr_method: &str, +) -> DispatcherResult>> { + match lr_method { + "classic_swap_contract" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_contract_rpc).await, + "classic_swap_quote" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_quote_rpc).await, + "classic_swap_create" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_create_rpc).await, + "classic_swap_liquidity_sources" => { + handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_liquidity_sources_rpc).await + }, + "classic_swap_tokens" => handle_mmrpc(ctx, request, one_inch_v6_0_classic_swap_tokens_rpc).await, + _ => MmError::err(DispatcherError::NoSuchMethod), + } +} + +async fn liquidity_routing_dispatcher( + request: MmRpcRequest, + ctx: MmArc, + lr_method: &str, +) -> DispatcherResult>> { + match lr_method { + "find_best_quote" => handle_mmrpc(ctx, request, lr_find_best_quote_rpc).await, + "get_quotes_for_tokens" => handle_mmrpc(ctx, request, lr_get_quotes_for_tokens_rpc).await, + "execute_routed_trade" => handle_mmrpc(ctx, request, lr_execute_routed_trade_rpc).await, + _ => MmError::err(DispatcherError::NoSuchMethod), + } +} diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap.rs new file mode 100644 index 0000000000..289cd08f0d --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap.rs @@ -0,0 +1,339 @@ +//! 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 new file mode 100644 index 0000000000..18e43acfcd --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs @@ -0,0 +1,414 @@ +//! 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)) + .map(|(lr_swap_data, order, price)| (lr_swap_data, order, price)) + .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 Some(series) = series else { + return None; + }; + 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 new file mode 100644 index 0000000000..2e16f54310 --- /dev/null +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/types.rs @@ -0,0 +1,127 @@ +//! Types for LR swaps rpc + +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/mod.rs b/mm2src/mm2_main/src/rpc/lp_commands/mod.rs index e61d5aead8..0826bbb648 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/mod.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod db_id; pub mod legacy; +pub(crate) mod lr_swap; pub(crate) mod one_inch; pub(crate) mod pubkey; pub(crate) mod tokens; 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 index 8ee65af984..7a4d995ecf 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs @@ -1,7 +1,7 @@ -use coins::{eth::u256_to_big_decimal, NumConversError}; +use coins::{CoinFindError, NumConversError}; use common::{HttpStatusCode, StatusCode}; use enum_derives::EnumFromStringify; -use mm2_number::BigDecimal; +use ethereum_types::U256; use ser_error_derive::SerializeErrorType; use serde::Serialize; use trading_api::one_inch_api::errors::ApiClientError; @@ -9,18 +9,21 @@ use trading_api::one_inch_api::errors::ApiClientError; #[derive(Debug, Display, Serialize, SerializeErrorType, EnumFromStringify)] #[serde(tag = "error_type", content = "error_data")] pub enum ApiIntegrationRpcError { - #[from_stringify("coins::CoinFindError")] - NoSuchCoin(String), + NoSuchCoin { + coin: String, + }, #[display(fmt = "EVM token needed")] CoinTypeError, #[display(fmt = "NFT not supported")] - NftNotSupported, + 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 { @@ -31,12 +34,15 @@ pub enum ApiIntegrationRpcError { }, #[display(fmt = "allowance not enough for 1inch contract, available: {allowance}, needed: {amount}")] OneInchAllowanceNotEnough { - allowance: BigDecimal, - amount: BigDecimal, + 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 { @@ -44,22 +50,25 @@ impl HttpStatusCode for ApiIntegrationRpcError { match self { ApiIntegrationRpcError::NoSuchCoin { .. } => StatusCode::NOT_FOUND, ApiIntegrationRpcError::CoinTypeError - | ApiIntegrationRpcError::NftNotSupported + | ApiIntegrationRpcError::NftProtocolNotSupported | ApiIntegrationRpcError::ChainNotSupported | ApiIntegrationRpcError::DifferentChains | ApiIntegrationRpcError::MyAddressError(_) | ApiIntegrationRpcError::InvalidParam(_) | ApiIntegrationRpcError::OutOfBounds { .. } - | ApiIntegrationRpcError::OneInchAllowanceNotEnough { .. } => StatusCode::BAD_REQUEST, + | ApiIntegrationRpcError::OneInchAllowanceNotEnough { .. } + | ApiIntegrationRpcError::NumberError(_) + | ApiIntegrationRpcError::BestLrSwapNotFound => StatusCode::BAD_REQUEST, ApiIntegrationRpcError::OneInchError(_) | ApiIntegrationRpcError::ApiDataError(_) => { StatusCode::BAD_GATEWAY }, + ApiIntegrationRpcError::InternalError { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } } -impl ApiIntegrationRpcError { - pub(crate) fn from_api_error(error: ApiClientError, decimals: Option) -> Self { +impl From for ApiIntegrationRpcError { + fn from(error: ApiClientError) -> Self { match error { ApiClientError::InvalidParam(error) => ApiIntegrationRpcError::InvalidParam(error), ApiClientError::OutOfBounds { param, value, min, max } => { @@ -69,19 +78,28 @@ impl ApiIntegrationRpcError { | ApiClientError::ParseBodyError { .. } | ApiClientError::GeneralApiError { .. } => ApiIntegrationRpcError::OneInchError(error), ApiClientError::AllowanceNotEnough { allowance, amount, .. } => { - ApiIntegrationRpcError::OneInchAllowanceNotEnough { - allowance: u256_to_big_decimal(allowance, decimals.unwrap_or_default()).unwrap_or_default(), - amount: u256_to_big_decimal(amount, decimals.unwrap_or_default()).unwrap_or_default(), - } + 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()) } } 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 index 45fec7f680..049c3de7c2 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs @@ -4,12 +4,14 @@ use super::types::{AggregationContractRequest, ClassicSwapCreateRequest, Classic ClassicSwapTokensRequest, ClassicSwapTokensResponse}; use coins::eth::{wei_from_big_decimal, EthCoin, EthCoinType}; use coins::hd_wallet::DisplayAddress; -use coins::{lp_coinfind_or_err, CoinWithDerivationMethod, MmCoin, MmCoinEnum}; +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 trading_api::one_inch_api::client::ApiClient; -use trading_api::one_inch_api::types::{ClassicSwapCreateParams, ClassicSwapQuoteParams, ProtocolsResponse, - TokensResponse}; +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) @@ -27,33 +29,37 @@ pub async fn one_inch_v6_0_classic_swap_quote_rpc( ) -> 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?; - api_supports_pair(&base, &rel)?; + 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 = wei_from_big_decimal(&req.amount.to_decimal(), base.decimals()) .mm_err(|err| ApiIntegrationRpcError::InvalidParam(err.to_string()))?; - let query_params = ClassicSwapQuoteParams::new(base_contract, rel_contract, 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() - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, Some(base.decimals())))?; - let quote = ApiClient::new(ctx) - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, Some(base.decimals())))? - .call_swap_api( - base.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?, - ApiClient::get_quote_method().to_owned(), - Some(query_params), - ) + 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(|api_err| ApiIntegrationRpcError::from_api_error(api_err, Some(base.decimals())))?; // use 'base' as amount in errors is in the src coin - ClassicSwapResponse::from_api_classic_swap_data(quote, rel.decimals()) // use 'rel' as quote value is in the dst coin .mm_err(|err| ApiIntegrationRpcError::ApiDataError(err.to_string())) } @@ -66,14 +72,16 @@ pub async fn one_inch_v6_0_classic_swap_create_rpc( ) -> 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?; - api_supports_pair(&base, &rel)?; + 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 = wei_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, - rel_contract, + base_contract.display_address(), + rel_contract.display_address(), sell_amount.to_string(), single_address.display_address(), req.slippage, @@ -98,17 +106,15 @@ pub async fn one_inch_v6_0_classic_swap_create_rpc( .with_allow_partial_fill(req.allow_partial_fill) .with_use_permit2(req.use_permit2) .build_query_params() - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, Some(base.decimals())))?; - let swap_with_tx = ApiClient::new(ctx) - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, Some(base.decimals())))? - .call_swap_api( - base.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?, - ApiClient::get_swap_method().to_owned(), - Some(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(|api_err| ApiIntegrationRpcError::from_api_error(api_err, Some(base.decimals())))?; // use 'base' as amount in errors is in the src coin - ClassicSwapResponse::from_api_classic_swap_data(swap_with_tx, base.decimals()) // use 'base' as we spend in the src coin .mm_err(|err| ApiIntegrationRpcError::ApiDataError(err.to_string())) } @@ -118,11 +124,11 @@ pub async fn one_inch_v6_0_classic_swap_liquidity_sources_rpc( ctx: MmArc, req: ClassicSwapLiquiditySourcesRequest, ) -> MmResult { - let response: ProtocolsResponse = ApiClient::new(ctx) - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, None))? - .call_swap_api(req.chain_id, ApiClient::get_liquidity_sources_method().to_owned(), None) - .await - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, None))?; + 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, }) @@ -134,35 +140,39 @@ pub async fn one_inch_v6_0_classic_swap_tokens_rpc( ctx: MmArc, req: ClassicSwapTokensRequest, ) -> MmResult { - let response: TokensResponse = ApiClient::new(ctx) - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, None))? - .call_swap_api(req.chain_id, ApiClient::get_tokens_method().to_owned(), None) - .await - .mm_err(|api_err| ApiIntegrationRpcError::from_api_error(api_err, None))?; + 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, }) } -async fn get_coin_for_one_inch(ctx: &MmArc, ticker: &str) -> MmResult<(EthCoin, String), ApiIntegrationRpcError> { +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::EthCoin(coin) => coin, _ => return Err(MmError::new(ApiIntegrationRpcError::CoinTypeError)), }; let contract = match coin.coin_type { - EthCoinType::Eth => ApiClient::eth_special_contract().to_owned(), - EthCoinType::Erc20 { token_addr, .. } => token_addr.display_address(), - EthCoinType::Nft { .. } => return Err(MmError::new(ApiIntegrationRpcError::NftNotSupported)), + 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: &EthCoin, rel: &EthCoin) -> MmResult<(), ApiIntegrationRpcError> { - if !ApiClient::is_chain_supported(base.chain_id().ok_or(ApiIntegrationRpcError::ChainNotSupported)?) { +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() { + if base_chain_id != rel_chain_id { return MmError::err(ApiIntegrationRpcError::DifferentChains); } Ok(()) @@ -181,7 +191,7 @@ mod tests { use mm2_number::{BigDecimal, MmNumber}; use mocktopus::mocking::{MockResult, Mockable}; use std::str::FromStr; - use trading_api::one_inch_api::{client::ApiClient, types::ClassicSwapData}; + use trading_api::one_inch_api::{classic_swap_types::ClassicSwapData, client::ApiClient}; #[test] fn test_classic_swap_response_conversion() { @@ -191,6 +201,8 @@ mod tests { "coin": ticker_coin, "name": "ethereum", "derivation_path": "m/44'/1'", + "chain_id": 1, + "decimals": 18, "protocol": { "type": "ETH", "protocol_data": { @@ -203,6 +215,7 @@ mod tests { "coin": ticker_token, "name": "jst", "chain_id": 1, + "decimals": 6, "protocol": { "type": "ERC20", "protocol_data": { @@ -296,42 +309,42 @@ mod tests { let response_create_raw = json!({ "dstAmount": "13", "tx": { - "from": "0x590559f6fb7720f24ff3e2fccf6015b466e9c92c", - "to": "0x111111125421ca6dc452d289314280a0f8842a65", - "data": "0x07ed23790000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000590559f6fb7720f24ff3e2fccf6015b466e9c92c0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000648e8755f7ac30b5e4fa3f9c00e2cb6667501797b8bc01a7a367a4b2889ca6a05d9c31a31a781c12a4c3bdfc2ef1e02942e388b6565989ebe860bd67925bda74fbe0000000000000000000000000000000000000000000000000005ea0005bc00a007e5c0d200000000000000000000000000000000059800057e00018500009500001a4041c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2d0e30db00c20c02aaa39b223fe8d0a0e5c4f27ead9083c756cc27b73644935b8e68019ac6356c40661e1bc3158606ae4071118002dc6c07b73644935b8e68019ac6356c40661e1bc3158600000000000000000000000000000000000000000000000000294932ccadc9c58c02aaa39b223fe8d0a0e5c4f27ead9083c756cc251204dff5675ecff96b565ba3804dd4a63799ccba406761d38e5ddf6ccf6cf7c55759d5210750b5d60f30044e331d039000000000000000000000000761d38e5ddf6ccf6cf7c55759d5210750b5d60f3000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002f8a744a79be00000000000000000000000042f527f50f16a103b6ccab48bccca214500c10210000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec00a0860a32ec00000000000000000000000000000000000000000000000000003005635d54300003d05120ead050515e10fdb3540ccd6f8236c46790508a76111111111117dc0aa78b770fa6a738034120c30200c4e525b10b000000000000000000000000000000000000000000000000000000000000002000000000000000000000000022b1a53ac4be63cdc1f47c99572290eff1edd8020000000000000000000000006a32cc044dd6359c27bb66e7b02dce6dd0fda2470000000000000000000000005f515f6c524b18ca30f7783fb58dd4be2e9904ec000000000000000000000000111111111117dc0aa78b770fa6a738034120c302000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003005635d5430000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000067138e8c00000000000000000000000000000000000000000000000000030fb9b1525d8185f8d63fbcbe42e5999263c349cb5d81000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000026000000000000000000000000067297ee4eb097e072b4ab6f1620268061ae8046400000000000000000000000060cba82ddbf4b5ddcd4398cdd05354c6a790c309000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041d26038ef66344af785ff342b86db3da06c4cc6a62f0ca80ffd78affc0a95ccad44e814acebb1deda729bbfe3050bec14a47af487cc1cadc75f43db2d073016c31c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041a66cd52a747c5f60b9db637ffe30d0e413ec87858101832b4c5c1ae154bf247f3717c8ed4133e276ddf68d43a827f280863c91d6c42bc6ad1ec7083b2315b6fd1c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020d6bdbf78dac17f958d2ee523a2206206994597c13d831ec780a06c4eca27dac17f958d2ee523a2206206994597c13d831ec7111111125421ca6dc452d289314280a0f8842a65000000000000000000000000000000000000000000000000c095c0a2", - "value": "10000000", - "gas": 721429, - "gasPrice": "9525172167" + "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" - ] + "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" - ] + "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": [ [ @@ -406,7 +419,7 @@ mod tests { use_permit2: None, }; - ApiClient::call_swap_api::.mock_safe(move |_, _, _, _| { + 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()) @@ -416,7 +429,7 @@ mod tests { 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.000000000000000013").unwrap() + 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); @@ -424,7 +437,7 @@ mod tests { assert_eq!(quote_response.dst_token.as_ref().unwrap().decimals, 6); assert_eq!(quote_response.gas.unwrap(), 452704_u128); - ApiClient::call_swap_api::.mock_safe(move |_, _, _, _| { + 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()) @@ -433,12 +446,16 @@ mod tests { 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.000000000000000013").unwrap() + 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/one_inch/types.rs index 8324e801d4..b2f552c1ae 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/types.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/types.rs @@ -1,14 +1,20 @@ -use crate::rpc::lp_commands::one_inch::errors::FromApiValueError; -use coins::eth::{u256_to_big_decimal, wei_to_gwei_decimal}; +use super::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::Ticker; use common::true_f; use ethereum_types::{Address, U256}; +use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; use mm2_number::{construct_detailed, BigDecimal, MmNumber}; use rpc::v1::types::Bytes as BytesJson; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::convert::TryInto; +use std::str::FromStr; use trading_api::one_inch_api::{self, - types::{ProtocolImage, ProtocolInfo, TokenInfo}}; + classic_swap_types::{ProtocolImage, ProtocolInfo, TokenInfo as LrTokenInfo}, + client::ApiClient}; construct_detailed!(DetailedAmount, amount); @@ -21,9 +27,9 @@ pub struct AggregationContractRequest {} #[serde(deny_unknown_fields)] pub struct ClassicSwapQuoteRequest { /// Base coin ticker - pub base: String, + pub base: Ticker, /// Rel coin ticker - pub rel: String, + pub rel: Ticker, /// Swap amount in coins (with fraction) pub amount: MmNumber, /// Partner fee, percentage of src token amount will be sent to referrer address, min: 0; max: 3. @@ -66,9 +72,9 @@ pub struct ClassicSwapQuoteRequest { #[serde(deny_unknown_fields)] pub struct ClassicSwapCreateRequest { /// Base coin ticker - pub base: String, + pub base: Ticker, /// Rel coin ticker - pub rel: String, + pub rel: Ticker, /// Swap amount in coins (with fraction) pub amount: MmNumber, /// Allowed slippage, min: 0; max: 50 @@ -125,17 +131,24 @@ pub struct ClassicSwapCreateRequest { pub use_permit2: Option, } -/// Response for both classic swap quote or create swap calls -#[derive(Serialize, Debug)] -pub struct ClassicSwapResponse { +/// Details to create classic swap calls +#[derive(Serialize, Deserialize, Debug)] +pub struct ClassicSwapDetails { /// Destination token amount, in coins (with fraction) pub dst_amount: DetailedAmount, /// Source (base) token info #[serde(skip_serializing_if = "Option::is_none")] - pub src_token: Option, + 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, + 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>>>, @@ -146,29 +159,55 @@ pub struct ClassicSwapResponse { pub gas: Option, } -impl ClassicSwapResponse { - pub(crate) fn from_api_classic_swap_data( - data: one_inch_api::types::ClassicSwapData, - decimals: u8, +/// 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 { + 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 + + let platform_ticker = get_platform_ticker(ctx, chain_id)?; + if token_info.address == special_contract { + Some(platform_ticker) + } else { + get_erc20_ticker_by_contract_address(ctx, &platform_ticker, &token_info.address) + } + } + + pub(crate) async fn from_api_classic_swap_data( + ctx: &MmArc, + chain_id: u64, + data: one_inch_api::classic_swap_types::ClassicSwapData, ) -> MmResult { + let src_token_info = data + .src_token + .ok_or(FromApiValueError::new("Missing source TokenInfo".to_owned()))?; + let dst_token_info = data + .dst_token + .ok_or(FromApiValueError::new("Missing destination 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()))?; Ok(Self { dst_amount: MmNumber::from( - u256_to_big_decimal(U256::from_dec_str(&data.dst_amount)?, decimals).map_mm_err()?, + u256_to_big_decimal(U256::from_dec_str(&data.dst_amount)?, dst_decimals).map_mm_err()?, ) .into(), - src_token: data.src_token, - dst_token: data.dst_token, + 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(|tx| TxFields::from_api_tx_fields(tx, decimals)) - .transpose()?, + tx: data.tx.map(TxFields::from_api_tx_fields).transpose()?, gas: data.gas, }) } } -#[derive(Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub struct TxFields { pub from: Address, pub to: Address, @@ -181,14 +220,13 @@ pub struct TxFields { impl TxFields { pub(crate) fn from_api_tx_fields( - tx_fields: one_inch_api::types::TxFields, - decimals: u8, + tx_fields: one_inch_api::classic_swap_types::TxFields, ) -> MmResult { Ok(Self { from: tx_fields.from, to: tx_fields.to, data: BytesJson::from(hex::decode(str_strip_0x!(tx_fields.data.as_str()))?), - value: u256_to_big_decimal(U256::from_dec_str(&tx_fields.value)?, decimals).map_mm_err()?, + value: wei_to_eth_decimal(U256::from_dec_str(&tx_fields.value)?).map_mm_err()?, gas_price: wei_to_gwei_decimal(U256::from_dec_str(&tx_fields.gas_price)?).map_mm_err()?, gas: tx_fields.gas, }) @@ -212,5 +250,5 @@ pub struct ClassicSwapTokensRequest { #[derive(Serialize)] pub struct ClassicSwapTokensResponse { - pub tokens: HashMap, + pub tokens: HashMap, } diff --git a/mm2src/mm2_main/src/rpc/lp_commands/tokens.rs b/mm2src/mm2_main/src/rpc/lp_commands/tokens.rs index da54111581..9900b8d933 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/tokens.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/tokens.rs @@ -88,7 +88,7 @@ pub async fn get_token_info(ctx: MmArc, req: TokenInfoRequest) -> MmResult for MmNumber { } /// Useful for tests -impl From<&'static str> for MmNumber { - fn from(str: &'static str) -> MmNumber { +impl From<&str> for MmNumber { + fn from(str: &str) -> MmNumber { let num: BigDecimal = str.parse().expect("Input should be string representing decimal num"); num.into() } diff --git a/mm2src/mm2_number/src/mm_number_multi_repr.rs b/mm2src/mm2_number/src/mm_number_multi_repr.rs index 501a7626ba..973f1ffeb5 100644 --- a/mm2src/mm2_number/src/mm_number_multi_repr.rs +++ b/mm2src/mm2_number/src/mm_number_multi_repr.rs @@ -3,10 +3,10 @@ use crate::from_ratio_to_dec; use crate::mm_number::MmNumber; use bigdecimal::BigDecimal; use num_rational::BigRational; -use serde::Serialize; +use serde::{Deserialize, Serialize}; /// MmNumber representation in all available forms. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct MmNumberMultiRepr { pub decimal: BigDecimal, pub rational: BigRational, diff --git a/mm2src/mm2_rpc/src/data/legacy/orders.rs b/mm2src/mm2_rpc/src/data/legacy/orders.rs index 9890b1d709..d88e73a8cd 100644 --- a/mm2src/mm2_rpc/src/data/legacy/orders.rs +++ b/mm2src/mm2_rpc/src/data/legacy/orders.rs @@ -36,7 +36,7 @@ pub struct SellBuyRequest { pub save_in_history: bool, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Debug, Deserialize)] pub struct SellBuyResponse { #[serde(flatten)] pub request: TakerRequestForRpc, diff --git a/mm2src/trading_api/Cargo.toml b/mm2src/trading_api/Cargo.toml index bc30874dfc..ffabd326ec 100644 --- a/mm2src/trading_api/Cargo.toml +++ b/mm2src/trading_api/Cargo.toml @@ -14,6 +14,7 @@ mm2_number = { path = "../mm2_number" } mocktopus = { workspace = true, optional = true } derive_more.workspace = true ethereum-types.workspace = true +futures = { workspace = true, features = ["async-await"], optional = true } lazy_static.workspace = true serde.workspace = true serde_derive.workspace = true @@ -21,7 +22,10 @@ serde_json = { workspace = true, features = ["preserve_order", "raw_value"] } url.workspace = true [features] -test-ext-api = [] # use test config to connect to an external api +# use this to connect to external trading api in tests +test-ext-api = [ + "futures" +] for-tests = ["dep:mocktopus"] [dev-dependencies] diff --git a/mm2src/trading_api/src/one_inch_api.rs b/mm2src/trading_api/src/one_inch_api.rs index 9b0af1625e..6c4c69ebab 100644 --- a/mm2src/trading_api/src/one_inch_api.rs +++ b/mm2src/trading_api/src/one_inch_api.rs @@ -1,5 +1,6 @@ //! Wrapper for 1inch API. +pub mod classic_swap_types; pub mod client; pub mod errors; -pub mod types; +pub mod portfolio_types; diff --git a/mm2src/trading_api/src/one_inch_api/types.rs b/mm2src/trading_api/src/one_inch_api/classic_swap_types.rs similarity index 93% rename from mm2src/trading_api/src/one_inch_api/types.rs rename to mm2src/trading_api/src/one_inch_api/classic_swap_types.rs index f13e943768..b7bb7902e8 100644 --- a/mm2src/trading_api/src/one_inch_api/types.rs +++ b/mm2src/trading_api/src/one_inch_api/classic_swap_types.rs @@ -1,4 +1,4 @@ -#![allow(clippy::result_large_err)] +//! Structs to call 1inch classic swap api use super::client::QueryParams; use super::errors::ApiClientError; @@ -63,7 +63,8 @@ impl ClassicSwapQuoteParams { def_with_opt_param!(include_gas, bool); def_with_opt_param!(connector_tokens, String); - pub fn build_query_params(&self) -> MmResult, ApiClientError> { + #[allow(clippy::result_large_err)] + pub fn build_query_params(&self) -> MmResult { self.validate_params()?; let mut params = vec![ @@ -87,6 +88,7 @@ impl ClassicSwapQuoteParams { } /// Validate params by 1inch rules (to avoid extra requests) + #[allow(clippy::result_large_err)] fn validate_params(&self) -> MmResult<(), ApiClientError> { validate_fee(&self.fee)?; validate_complexity_level(&self.complexity_level)?; @@ -159,7 +161,8 @@ impl ClassicSwapCreateParams { def_with_opt_param!(allow_partial_fill, bool); def_with_opt_param!(use_permit2, bool); - pub fn build_query_params(&self) -> MmResult, ApiClientError> { + #[allow(clippy::result_large_err)] + pub fn build_query_params(&self) -> MmResult { self.validate_params()?; let mut params = vec![ @@ -194,6 +197,7 @@ impl ClassicSwapCreateParams { } /// Validate params by 1inch rules (to avoid extra requests) + #[allow(clippy::result_large_err)] fn validate_params(&self) -> MmResult<(), ApiClientError> { validate_slippage(self.slippage)?; validate_fee(&self.fee)?; @@ -205,7 +209,7 @@ impl ClassicSwapCreateParams { } } -#[derive(Deserialize, Debug, Serialize)] +#[derive(Clone, Deserialize, Debug, Serialize)] pub struct TokenInfo { pub address: Address, pub symbol: String, @@ -219,7 +223,7 @@ pub struct TokenInfo { pub tags: Vec, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct ProtocolInfo { pub name: String, pub part: f64, @@ -229,7 +233,8 @@ pub struct ProtocolInfo { pub to_token_address: Address, } -#[derive(Deserialize, Debug)] +/// 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 #[serde(rename = "dstAmount")] @@ -239,11 +244,13 @@ pub struct ClassicSwapData { #[serde(rename = "dstToken")] pub dst_token: Option, pub protocols: Option>>>, + /// Returned from create swap call pub tx: Option, + /// Returned from quote call pub gas: Option, } -#[derive(Deserialize, Debug)] +#[derive(Clone, Deserialize, Debug)] pub struct TxFields { pub from: Address, pub to: Address, @@ -299,6 +306,7 @@ mod serde_one_inch_link { } } +#[allow(clippy::result_large_err)] fn validate_slippage(slippage: f32) -> MmResult<(), ApiClientError> { if !(0.0..=ONE_INCH_MAX_SLIPPAGE).contains(&slippage) { return Err(ApiClientError::OutOfBounds { @@ -312,6 +320,7 @@ fn validate_slippage(slippage: f32) -> MmResult<(), ApiClientError> { Ok(()) } +#[allow(clippy::result_large_err)] fn validate_fee(fee: &Option) -> MmResult<(), ApiClientError> { if let Some(fee) = fee { if !(0.0..=ONE_INCH_MAX_FEE_SHARE).contains(fee) { @@ -327,6 +336,7 @@ fn validate_fee(fee: &Option) -> MmResult<(), ApiClientError> { Ok(()) } +#[allow(clippy::result_large_err)] fn validate_gas_limit(gas_limit: &Option) -> MmResult<(), ApiClientError> { if let Some(gas_limit) = gas_limit { if gas_limit > &ONE_INCH_MAX_GAS { @@ -342,6 +352,7 @@ fn validate_gas_limit(gas_limit: &Option) -> MmResult<(), ApiClientError> Ok(()) } +#[allow(clippy::result_large_err)] fn validate_parts(parts: &Option) -> MmResult<(), ApiClientError> { if let Some(parts) = parts { if parts > &ONE_INCH_MAX_PARTS { @@ -357,6 +368,7 @@ fn validate_parts(parts: &Option) -> MmResult<(), ApiClientError> { Ok(()) } +#[allow(clippy::result_large_err)] fn validate_main_route_parts(main_route_parts: &Option) -> MmResult<(), ApiClientError> { if let Some(main_route_parts) = main_route_parts { if main_route_parts > &ONE_INCH_MAX_MAIN_ROUTE_PARTS { @@ -372,6 +384,7 @@ fn validate_main_route_parts(main_route_parts: &Option) -> MmResult<(), Api Ok(()) } +#[allow(clippy::result_large_err)] fn validate_complexity_level(complexity_level: &Option) -> MmResult<(), ApiClientError> { if let Some(complexity_level) = complexity_level { if complexity_level > &ONE_INCH_MAX_COMPLEXITY_LEVEL { @@ -388,6 +401,7 @@ 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 { error_msg: BAD_URL_IN_RESPONSE_ERROR.to_owned(), diff --git a/mm2src/trading_api/src/one_inch_api/client.rs b/mm2src/trading_api/src/one_inch_api/client.rs index 2825d930b5..c4856ae572 100644 --- a/mm2src/trading_api/src/one_inch_api/client.rs +++ b/mm2src/trading_api/src/one_inch_api/client.rs @@ -1,6 +1,6 @@ use super::errors::ApiClientError; use crate::one_inch_api::errors::NativeError; -use common::StatusCode; +use common::{log, StatusCode}; #[cfg(feature = "test-ext-api")] use lazy_static::lazy_static; use mm2_core::mm_ctx::MmArc; use mm2_err_handle::{map_mm_error::MapMmError, @@ -10,15 +10,14 @@ use mm2_net::transport::slurp_url_with_headers; use serde::de::DeserializeOwned; use url::Url; +#[cfg(feature = "test-ext-api")] use common::executor::Timer; + +#[cfg(feature = "test-ext-api")] +use futures::lock::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard}; + #[cfg(any(test, feature = "for-tests"))] use mocktopus::macros::*; -const ONE_INCH_API_ENDPOINT_V6_0: &str = "swap/v6.0/"; -const SWAP_METHOD: &str = "swap"; -const QUOTE_METHOD: &str = "quote"; -const LIQUIDITY_SOURCES_METHOD: &str = "liquidity-sources"; -const TOKENS_METHOD: &str = "tokens"; - const ONE_INCH_AGGREGATION_ROUTER_CONTRACT_V6_0: &str = "0x111111125421ca6dc452d289314280a0f8842a65"; const ONE_INCH_ETH_SPECIAL_CONTRACT: &str = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; @@ -31,7 +30,7 @@ lazy_static! { static ref ONE_INCH_API_TEST_AUTH: String = std::env::var("ONE_INCH_API_TEST_AUTH").unwrap_or_default(); } -pub(crate) type QueryParams<'life> = Vec<(&'life str, String)>; +pub(crate) type QueryParams = Vec<(&'static str, String)>; /// 1inch v6.0 supported eth-based chains const ONE_INCH_V6_0_SUPPORTED_CHAINS: &[(&str, u64)] = &[ @@ -49,37 +48,42 @@ const ONE_INCH_V6_0_SUPPORTED_CHAINS: &[(&str, u64)] = &[ ("Aurora", 1313161554), ]; -pub(crate) struct UrlBuilder<'a> { +/// 1inch API basic url builder +pub struct UrlBuilder { base_url: Url, - endpoint: &'a str, - chain_id: u64, + endpoint: &'static str, + chain_id: Option, method_name: String, - query_params: QueryParams<'a>, + query_params: QueryParams, } -impl<'a> UrlBuilder<'a> { - pub(crate) fn new(api_client: &ApiClient, chain_id: u64, method_name: String) -> Self { +impl UrlBuilder { + /// Create new basic url builder to call 1inch API's. Normally used by specific API url builders. + /// Note: in the classic swap API chain_id is added into url path, in portfolio chain_id is a query param so it would be optional here. + fn new(base_url: Url, chain_id: Option, endpoint: &'static str, method_name: String) -> Self { Self { - base_url: api_client.base_url.clone(), - endpoint: ApiClient::get_swap_endpoint(), + base_url, + endpoint, chain_id, method_name, query_params: vec![], } } - pub(crate) fn with_query_params(&mut self, mut more_params: QueryParams<'a>) -> &mut Self { + pub fn with_query_params(mut self, mut more_params: QueryParams) -> Self { self.query_params.append(&mut more_params); self } #[allow(clippy::result_large_err)] - pub(crate) fn build(&self) -> MmResult { - let url = self - .base_url - .join(self.endpoint)? - .join(&format!("{}/", self.chain_id))? - .join(self.method_name.as_str())?; + 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))? + } else { + url + }; + let url = url.join(self.method_name.as_str())?; Ok(Url::parse_with_params( url.as_str(), self.query_params @@ -90,17 +94,88 @@ impl<'a> UrlBuilder<'a> { } } -/// 1-inch API caller -pub struct ApiClient { - base_url: Url, +/// 1inch swap api methods +pub enum SwapApiMethods { + ClassicSwapQuote, + ClassicSwapCreate, + LiquiditySources, + Tokens, } +impl SwapApiMethods { + const SWAP_METHOD: &str = "swap"; + const QUOTE_METHOD: &str = "quote"; + const LIQUIDITY_SOURCES_METHOD: &str = "liquidity-sources"; + const TOKENS_METHOD: &str = "tokens"; + + fn name(&self) -> &'static str { + match self { + Self::ClassicSwapQuote => Self::QUOTE_METHOD, + Self::ClassicSwapCreate => Self::SWAP_METHOD, + Self::LiquiditySources => Self::LIQUIDITY_SOURCES_METHOD, + Self::Tokens => Self::TOKENS_METHOD, + } + } +} + +/// 1inch swap api url builder +pub struct SwapUrlBuilder; + +impl SwapUrlBuilder { + const CLASSIC_SWAP_ENDPOINT_V6_0: &str = "swap/v6.0/"; + + #[allow(clippy::result_large_err)] + pub fn create_api_url_builder( + ctx: &MmArc, + chain_id: u64, + method: SwapApiMethods, + ) -> MmResult { + Ok(UrlBuilder::new( + ApiClient::base_url(ctx)?, + Some(chain_id), + Self::CLASSIC_SWAP_ENDPOINT_V6_0, + method.name().to_owned(), + )) + } +} + +pub enum PortfolioApiMethods { + CrossPrices, +} +impl PortfolioApiMethods { + const CROSS_PRICES_METHOD: &str = "time_range/cross_prices"; + + fn name(&self) -> &'static str { + match self { + Self::CrossPrices => Self::CROSS_PRICES_METHOD, + } + } +} + +pub struct PortfolioUrlBuilder; + +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 { + Ok(UrlBuilder::new( + ApiClient::base_url(ctx)?, + None, + Self::PORTFOLIO_PRICES_ENDPOINT_V1_0, + method.name().to_owned(), + )) + } +} + +/// 1-inch API caller +pub struct ApiClient; #[allow(clippy::swap_ptr_to_ref)] // need for moctopus #[cfg_attr(any(test, feature = "for-tests"), mockable)] impl ApiClient { #[allow(unused_variables)] #[allow(clippy::result_large_err)] - pub fn new(ctx: MmArc) -> MmResult { + fn base_url(ctx: &MmArc) -> MmResult { #[cfg(not(test))] let url_cfg = ctx.conf["1inch_api"] .as_str() @@ -109,9 +184,7 @@ impl ApiClient { #[cfg(test)] let url_cfg = ONE_INCH_API_TEST_URL; - Ok(Self { - base_url: Url::parse(url_cfg)?, - }) + Ok(Url::parse(url_cfg)?) } pub const fn eth_special_contract() -> &'static str { ONE_INCH_ETH_SPECIAL_CONTRACT } @@ -127,23 +200,20 @@ impl ApiClient { #[cfg(feature = "test-ext-api")] ("Authorization", ONE_INCH_API_TEST_AUTH.as_str()), ("accept", "application/json"), + ("content-type", "application/json"), ] } - fn get_swap_endpoint() -> &'static str { ONE_INCH_API_ENDPOINT_V6_0 } - - pub const fn get_swap_method() -> &'static str { SWAP_METHOD } - - pub const fn get_quote_method() -> &'static str { QUOTE_METHOD } + pub async fn call_api(api_url: Url) -> MmResult { + #[cfg(feature = "test-ext-api")] + let _guard = ApiClient::one_req_per_sec().await; - pub const fn get_liquidity_sources_method() -> &'static str { LIQUIDITY_SOURCES_METHOD } - - pub const fn get_tokens_method() -> &'static str { TOKENS_METHOD } - - pub(crate) async fn call_api(api_url: &Url) -> MmResult { + log::debug!("1inch call url={}", api_url.to_string()); 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)); + // 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 { error_msg: err.to_string(), })?; @@ -159,18 +229,15 @@ impl ApiClient { }) } - pub async fn call_swap_api<'l, T: DeserializeOwned>( - &self, - chain_id: u64, - method: String, - params: Option>, - ) -> MmResult { - let mut builder = UrlBuilder::new(self, chain_id, method); - if let Some(params) = params { - builder.with_query_params(params); + /// Prevent concurrent calls + #[cfg(feature = "test-ext-api")] + async fn one_req_per_sec<'a>() -> AsyncMutexGuard<'a, ()> { + lazy_static! { + /// Lock to ensure requests to the API are not running concurrently in tests + static ref ONE_INCH_REQ_SYNC: AsyncMutex<()> = AsyncMutex::new(()); } - let api_url = builder.build()?; - - ApiClient::call_api(&api_url).await + let guard = ONE_INCH_REQ_SYNC.lock().await; + Timer::sleep(1.).await; // ensure 1 req per sec to prevent 1inch rate limiter error for dev account + guard } } diff --git a/mm2src/trading_api/src/one_inch_api/portfolio_types.rs b/mm2src/trading_api/src/one_inch_api/portfolio_types.rs new file mode 100644 index 0000000000..5100c7434c --- /dev/null +++ b/mm2src/trading_api/src/one_inch_api/portfolio_types.rs @@ -0,0 +1,98 @@ +//! Structs to call 1inch portfolio api + +use super::client::QueryParams; +use super::errors::ApiClientError; +use common::{def_with_opt_param, push_if_some}; +use mm2_err_handle::mm_error::MmResult; +use mm2_number::BigDecimal; +use serde::Deserialize; + +#[derive(Default)] +pub enum DataGranularity { + Month, + Week, + Day, + FourHour, + Hour, + FifteenMin, + #[default] + FiveMin, +} + +impl ToString for DataGranularity { + fn to_string(&self) -> String { + match self { + DataGranularity::Month => "month".to_owned(), + DataGranularity::Week => "week".to_owned(), + DataGranularity::Day => "day".to_owned(), + DataGranularity::FourHour => "4hour".to_owned(), + DataGranularity::Hour => "hour".to_owned(), + DataGranularity::FifteenMin => "15min".to_owned(), + DataGranularity::FiveMin => "5min".to_owned(), + } + } +} + +/// API params builder to get OHLC price history for token pair +/// See 1inch docs: https://portal.1inch.dev/documentation/apis/portfolio/swagger?method=get&path=%2Fintegrations%2Fprices%2Fv1%2Ftime_range%2Fcross_prices +#[derive(Default)] +pub struct CrossPriceParams { + chain_id: u64, + /// Base token address + token0_address: String, + /// Quote token address + token1_address: String, + /// Returned time series intervals + granularity: Option, + /// max number of time series + limit: Option, +} + +impl CrossPriceParams { + pub fn new(chain_id: u64, token0_address: String, token1_address: String) -> Self { + Self { + chain_id, + token0_address, + token1_address, + ..Default::default() + } + } + + def_with_opt_param!(granularity, DataGranularity); + def_with_opt_param!(limit, u32); + + #[allow(clippy::result_large_err)] + pub fn build_query_params(&self) -> MmResult { + let mut params = vec![ + ("chain_id", self.chain_id.to_string()), + ("token0_address", self.token0_address.clone()), + ("token1_address", self.token1_address.clone()), + ]; + + push_if_some!(params, "granularity", &self.granularity); + push_if_some!(params, "limit", &self.limit); + + Ok(params) + } +} + +/// Element of token_0/token_1 price series returned from the 1inch cross_prices call. +/// Contains OHLC (Open, High, Low, Close) prices for the granularity period. +/// TODO: check cross_prices v2 +#[derive(Clone, Deserialize, Debug)] +pub struct CrossPricesData { + /// Time of the granularity period + pub timestamp: u64, + /// Price at the period opening + pub open: BigDecimal, + /// Lowest price within the period + pub low: BigDecimal, + /// Average price within the period + pub avg: BigDecimal, + /// Highest price within the period + pub high: BigDecimal, + /// Price at the period closing + pub close: BigDecimal, +} + +pub type CrossPricesSeries = Vec;