diff --git a/crates/gem_client/Cargo.toml b/crates/gem_client/Cargo.toml index f75e75668..7649fcc99 100644 --- a/crates/gem_client/Cargo.toml +++ b/crates/gem_client/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [features] default = [] reqwest = ["dep:reqwest", "dep:tokio"] +testkit = [] [dependencies] async-trait = { workspace = true } diff --git a/crates/gem_client/src/lib.rs b/crates/gem_client/src/lib.rs index 7456f24d4..4fa8125d4 100644 --- a/crates/gem_client/src/lib.rs +++ b/crates/gem_client/src/lib.rs @@ -1,6 +1,9 @@ mod content_type; mod types; +#[cfg(feature = "testkit")] +pub mod testkit; + #[cfg(feature = "reqwest")] mod reqwest_client; @@ -12,9 +15,9 @@ pub mod client_config; pub mod query; -pub use content_type::{ContentType, CONTENT_TYPE}; +pub use content_type::{CONTENT_TYPE, ContentType}; pub use query::build_path_with_query; -pub use types::{decode_json_byte_array, deserialize_response, ClientError, Response}; +pub use types::{ClientError, Response, decode_json_byte_array, deserialize_response}; #[cfg(feature = "reqwest")] pub use reqwest_client::ReqwestClient; @@ -26,7 +29,7 @@ pub use retry::{default_should_retry, retry, retry_policy}; pub use client_config::builder; use async_trait::async_trait; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{Serialize, de::DeserializeOwned}; use std::{collections::HashMap, fmt::Debug}; pub type Data = Vec; diff --git a/crates/gem_client/src/reqwest_client.rs b/crates/gem_client/src/reqwest_client.rs index 74e5e13a3..339c93e95 100644 --- a/crates/gem_client/src/reqwest_client.rs +++ b/crates/gem_client/src/reqwest_client.rs @@ -1,8 +1,8 @@ -use crate::{deserialize_response, retry_policy, Client, ClientError, ContentType, Response, CONTENT_TYPE}; +use crate::{CONTENT_TYPE, Client, ClientError, ContentType, Response, deserialize_response, retry_policy}; use async_trait::async_trait; -use reqwest::header::USER_AGENT; use reqwest::RequestBuilder; -use serde::{de::DeserializeOwned, Serialize}; +use reqwest::header::USER_AGENT; +use serde::{Serialize, de::DeserializeOwned}; use std::{collections::HashMap, str::FromStr, time::Duration}; #[derive(Debug, Clone)] diff --git a/crates/gem_client/src/retry.rs b/crates/gem_client/src/retry.rs index 20c384551..d7ebe7ca7 100644 --- a/crates/gem_client/src/retry.rs +++ b/crates/gem_client/src/retry.rs @@ -1,4 +1,4 @@ -use reqwest::{retry, StatusCode}; +use reqwest::{StatusCode, retry}; use std::future::Future; use std::time::Duration; diff --git a/crates/gem_client/src/testkit.rs b/crates/gem_client/src/testkit.rs new file mode 100644 index 000000000..e81805aae --- /dev/null +++ b/crates/gem_client/src/testkit.rs @@ -0,0 +1,68 @@ +use crate::{Client, ClientError}; +use async_trait::async_trait; +use serde::{de::DeserializeOwned, Serialize}; +use std::{ + collections::HashMap, + fmt::{Debug, Formatter}, + sync::Arc, +}; + +type GetHandler = Arc Result, ClientError> + Send + Sync>; +type PostHandler = Arc Result, ClientError> + Send + Sync>; + +#[derive(Clone, Default)] +pub struct MockClient { + get_handler: Option, + post_handler: Option, +} + +impl MockClient { + pub fn new() -> Self { + Self::default() + } + + pub fn with_get(mut self, handler: F) -> Self + where + F: Fn(&str) -> Result, ClientError> + Send + Sync + 'static, + { + self.get_handler = Some(Arc::new(handler)); + self + } + + pub fn with_post(mut self, handler: F) -> Self + where + F: Fn(&str, &[u8]) -> Result, ClientError> + Send + Sync + 'static, + { + self.post_handler = Some(Arc::new(handler)); + self + } +} + +impl Debug for MockClient { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MockClient").finish() + } +} + +#[async_trait] +impl Client for MockClient { + async fn get(&self, path: &str) -> Result + where + R: DeserializeOwned, + { + let handler = self.get_handler.as_ref().ok_or(ClientError::Http { status: 404, body: vec![] })?; + let bytes = handler(path)?; + serde_json::from_slice(&bytes).map_err(|e| ClientError::Serialization(e.to_string())) + } + + async fn post(&self, path: &str, body: &T, _headers: Option>) -> Result + where + T: Serialize + Send + Sync, + R: DeserializeOwned, + { + let handler = self.post_handler.as_ref().ok_or(ClientError::Http { status: 404, body: vec![] })?; + let body_bytes = serde_json::to_vec(body).map_err(|e| ClientError::Serialization(e.to_string()))?; + let bytes = handler(path, &body_bytes)?; + serde_json::from_slice(&bytes).map_err(|e| ClientError::Serialization(e.to_string())) + } +} diff --git a/crates/gem_solana/src/provider/preload_mapper.rs b/crates/gem_solana/src/provider/preload_mapper.rs index e2e1c0080..9cff291fa 100644 --- a/crates/gem_solana/src/provider/preload_mapper.rs +++ b/crates/gem_solana/src/provider/preload_mapper.rs @@ -41,7 +41,13 @@ fn get_gas_limit(input_type: &TransactionInputType) -> BigInt { | TransactionInputType::TokenApprove(_, _) | TransactionInputType::Generic(_, _, _) | TransactionInputType::Perpetual(_, _) => BigInt::from(100_000), - TransactionInputType::Swap(_, _, _) => BigInt::from(420_000), + TransactionInputType::Swap(_, _, swap_data) => swap_data + .data + .gas_limit + .as_ref() + .and_then(|x| x.parse::().ok()) + .map(BigInt::from) + .unwrap_or(BigInt::from(420_000)), TransactionInputType::Stake(_, _) => BigInt::from(100_000), } } @@ -112,9 +118,14 @@ mod tests { use primitives::swap::SwapData; use primitives::{Asset, AssetId, AssetType, Chain, SwapProvider}; + fn mock_swap_data_with_gas_limit(provider: SwapProvider, gas_limit: Option<&str>) -> SwapData { + let mut data = SwapData::mock_with_provider(provider); + data.data.gas_limit = gas_limit.map(|s| s.to_string()); + data + } + #[test] fn test_calculate_transaction_fee() { - // Test with EIP-1559 gas pricing let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(15000u64)); let input_type = TransactionInputType::Transfer(Asset { id: AssetId::from_chain(Chain::Solana), @@ -137,36 +148,25 @@ mod tests { #[test] fn test_calculate_transaction_fee_swap() { - // Test swap transaction with higher gas limit - let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(30000u64)); - let input_type = TransactionInputType::Swap( - Asset { - id: AssetId::from_chain(Chain::Solana), - chain: Chain::Solana, - token_id: None, - name: "SOL".to_string(), - symbol: "SOL".to_string(), - decimals: 9, - asset_type: AssetType::NATIVE, - }, - Asset { - id: AssetId::from_chain(Chain::Solana), - chain: Chain::Solana, - token_id: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), - name: "USDC".to_string(), - symbol: "USDC".to_string(), - decimals: 6, - asset_type: AssetType::SPL, - }, - SwapData::mock_with_provider(SwapProvider::Jupiter), - ); + let gas_price_type = GasPriceType::solana(5000u64, 30000u64, 100u64); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), mock_swap_data_with_gas_limit(SwapProvider::Jupiter, None)); - let fee = calculate_transaction_fee(&input_type, &gas_price_type, None); + let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("recipient_token_address".to_string())); assert_eq!(fee.fee, BigInt::from(35_000u64)); assert_eq!(fee.gas_limit, BigInt::from(420_000u64)); } + #[test] + fn test_calculate_transaction_fee_swap_with_provider_gas_limit() { + let gas_price_type = GasPriceType::solana(5000u64, 30000u64, 100u64); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), mock_swap_data_with_gas_limit(SwapProvider::Okx, Some("550000"))); + + let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("recipient_token_address".to_string())); + + assert_eq!(fee.gas_limit, BigInt::from(550_000u64)); + } + #[test] fn test_calculate_transaction_fee_cross_chain_swap_without_token_creation() { let gas_price_type = GasPriceType::eip1559(BigInt::from(5000u64), BigInt::from(15000u64)); @@ -273,27 +273,7 @@ mod tests { #[test] fn test_calculate_fee_rates_swap() { let fees = vec![SolanaPrioritizationFee { prioritization_fee: 150_000 }]; - let input_type = TransactionInputType::Swap( - Asset { - id: AssetId::from_chain(Chain::Solana), - chain: Chain::Solana, - token_id: None, - name: "SOL".to_string(), - symbol: "SOL".to_string(), - decimals: 9, - asset_type: AssetType::NATIVE, - }, - Asset { - id: AssetId::from_chain(Chain::Solana), - chain: Chain::Solana, - token_id: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), - name: "USDC".to_string(), - symbol: "USDC".to_string(), - decimals: 6, - asset_type: AssetType::SPL, - }, - SwapData::mock_with_provider(SwapProvider::Jupiter), - ); + let input_type = TransactionInputType::Swap(Asset::mock_sol(), Asset::mock_spl_token(), mock_swap_data_with_gas_limit(SwapProvider::Jupiter, None)); let rates = calculate_fee_rates(&input_type, &fees); assert_eq!(rates.len(), 3); @@ -365,7 +345,7 @@ mod tests { }; let input_type = TransactionInputType::Transfer(asset); - let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("existing_account".to_string())); + let fee = calculate_transaction_fee(&input_type, &gas_price_type, Some("recipient_token_address".to_string())); assert_eq!(fee.fee, BigInt::from(20_000u64)); assert!(fee.options.is_empty()); diff --git a/crates/primitives/src/testkit/swap_mock.rs b/crates/primitives/src/testkit/swap_mock.rs index fb5660204..539a596b5 100644 --- a/crates/primitives/src/testkit/swap_mock.rs +++ b/crates/primitives/src/testkit/swap_mock.rs @@ -59,6 +59,13 @@ impl SwapQuoteData { gas_limit: Some("21000".to_string()), } } + + pub fn mock_with_gas_limit(gas_limit: Option) -> Self { + SwapQuoteData { + gas_limit, + ..Self::mock() + } + } } impl SwapProviderData { diff --git a/crates/swapper/Cargo.toml b/crates/swapper/Cargo.toml index 8124b5fc0..3315296d3 100644 --- a/crates/swapper/Cargo.toml +++ b/crates/swapper/Cargo.toml @@ -50,3 +50,5 @@ tracing = "0.1.44" [dev-dependencies] tokio.workspace = true +primitives = { path = "../primitives", features = ["testkit"], default-features = false } +gem_client = { path = "../gem_client", features = ["testkit"] } diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index c5c07f330..01135463c 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -46,7 +46,7 @@ where } } - pub async fn check_approval(&self, quote: &Quote, quote_data: &SwapQuoteData) -> Result<(Option, Option), SwapperError> { + pub async fn check_approval_and_limit(&self, quote: &Quote, quote_data: &SwapQuoteData) -> Result<(Option, Option), SwapperError> { let request = "e.request; let from_asset = request.from_asset.asset_id(); @@ -66,8 +66,7 @@ where .await } } - ChainType::Tron => Ok((None, None)), - _ => Ok((None, None)), + _ => Ok((None, quote_data.gas_limit.clone())), } } @@ -222,7 +221,7 @@ where let route_data: ProxyQuote = serde_json::from_str(&routes.first().unwrap().route_data).map_err(|_| SwapperError::InvalidRoute)?; let data = self.client.get_quote_data(route_data).await?; - let (approval, gas_limit) = self.check_approval(quote, &data).await?; + let (approval, gas_limit) = self.check_approval_and_limit(quote, &data).await?; Ok(SwapperQuoteData::new_contract(data.to, data.value, data.data, approval, gas_limit)) } @@ -261,6 +260,51 @@ where } } +#[cfg(test)] +mod tests { + use super::super::client::ProxyClient; + use super::*; + use crate::alien::mock::ProviderMock; + use gem_client::testkit::MockClient; + use primitives::swap::SwapQuoteData; + + fn mock_provider(provider: SwapperProvider) -> ProxyProvider { + let rpc_provider = Arc::new(ProviderMock::new("{}".to_string())); + ProxyProvider::new_with_client(provider, ProxyClient::new(MockClient::new()), vec![], rpc_provider) + } + + #[tokio::test] + async fn test_solana_preserves_provider_gas_limit() { + let provider = mock_provider(SwapperProvider::Okx); + let quote = Quote::mock(Chain::Solana, None); + let data = SwapQuoteData::mock_with_gas_limit(Some("550000".to_string())); + + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + + assert!(approval.is_none()); + assert_eq!(gas_limit, Some("550000".to_string())); + + let data = SwapQuoteData::mock_with_gas_limit(None); + + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + + assert!(approval.is_none()); + assert!(gas_limit.is_none()); + } + + #[tokio::test] + async fn test_evm_native_ignores_provider_gas_limit() { + let provider = mock_provider(SwapperProvider::Mayan); + let quote = Quote::mock(Chain::Ethereum, None); + let data = SwapQuoteData::mock_with_gas_limit(Some("550000".to_string())); + + let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); + + assert!(approval.is_none()); + assert!(gas_limit.is_none()); + } +} + #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; diff --git a/crates/swapper/src/testkit.rs b/crates/swapper/src/testkit.rs index 1763b2290..83b0cca1f 100644 --- a/crates/swapper/src/testkit.rs +++ b/crates/swapper/src/testkit.rs @@ -1,12 +1,48 @@ use crate::{ - FetchQuoteData, ProviderType, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, SwapperSlippage, SwapperSlippageMode, - config::get_swap_config, + FetchQuoteData, ProviderData, ProviderType, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, SwapperSlippage, + SwapperSlippageMode, config::get_swap_config, }; use async_trait::async_trait; -use primitives::Chain; +use primitives::{AssetId, Chain}; use super::{Options, Quote, QuoteRequest, SwapperMode}; +impl ProviderData { + pub fn mock() -> Self { + ProviderData { + provider: ProviderType::new(SwapperProvider::Okx), + routes: vec![], + slippage_bps: 50, + } + } +} + +impl QuoteRequest { + pub fn mock(chain: Chain, token_id: Option<&str>) -> Self { + QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from(chain, token_id.map(|s| s.to_string()))), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(chain)), + wallet_address: "address".to_string(), + destination_address: "address".to_string(), + value: "1000000".to_string(), + mode: SwapperMode::ExactIn, + options: Options::default(), + } + } +} + +impl Quote { + pub fn mock(chain: Chain, token_id: Option<&str>) -> Self { + Quote { + from_value: "1000000".to_string(), + to_value: "1000000".to_string(), + data: ProviderData::mock(), + request: QuoteRequest::mock(chain, token_id), + eta_in_seconds: None, + } + } +} + pub fn mock_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) -> QuoteRequest { let config = get_swap_config(); diff --git a/gemstone/src/models/transaction.rs b/gemstone/src/models/transaction.rs index 40fb4f9c9..450b59e9d 100644 --- a/gemstone/src/models/transaction.rs +++ b/gemstone/src/models/transaction.rs @@ -730,8 +730,8 @@ impl From for GemTransferDataExtra { fn from(value: TransferDataExtra) -> Self { GemTransferDataExtra { to: value.to, - gas_limit: value.gas_limit.map(|gl| gl.to_string()), - gas_price: value.gas_price.map(|gp| gp.into()), + gas_limit: value.gas_limit.map(|x| x.to_string()), + gas_price: value.gas_price.map(|x| x.into()), data: value.data, output_type: value.output_type, output_action: value.output_action,