From 1af375a20a7911c9b6eaa43d7a39414d8cb501a2 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:23:00 +0900 Subject: [PATCH 1/4] Refactor error handling and update typeshare usage Standardizes error handling across swappers by removing unused error variants, consolidating error types, and replacing legacy errors with ComputeQuoteError and TransactionError. Updates dependencies to use typeshare from workspace and applies typeshare annotations to SwapperError. Refactors provider and testkit code to support new error handling and adds prioritization for InputAmountTooSmall errors in quote aggregation. Also updates proxy client API versioning and error mapping, and replaces NotSupportedPair with NotSupportedAsset throughout the codebase. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/primitives/Cargo.toml | 2 +- crates/swapper/Cargo.toml | 1 + crates/swapper/src/across/api.rs | 6 +- crates/swapper/src/across/config_store.rs | 4 +- crates/swapper/src/across/hubpool.rs | 17 ++-- crates/swapper/src/across/provider.rs | 16 ++-- crates/swapper/src/approval/evm.rs | 38 ++++++-- crates/swapper/src/approval/tron.rs | 4 +- crates/swapper/src/cetus/provider.rs | 13 ++- crates/swapper/src/chainlink.rs | 4 +- crates/swapper/src/error.rs | 67 +++++++------ crates/swapper/src/eth_address.rs | 5 +- .../src/hyperliquid/provider/hyperliquid.rs | 4 +- .../swapper/src/hyperliquid/provider/math.rs | 6 +- .../src/hyperliquid/provider/spot/math.rs | 12 +-- .../src/hyperliquid/provider/spot/provider.rs | 19 ++-- crates/swapper/src/jupiter/provider.rs | 4 +- crates/swapper/src/near_intents/provider.rs | 10 +- crates/swapper/src/permit2_data.rs | 2 +- crates/swapper/src/proxy/client.rs | 45 +++++++-- crates/swapper/src/proxy/provider.rs | 2 +- crates/swapper/src/swapper.rs | 94 ++++++++++++++++--- crates/swapper/src/swapper_trait.rs | 2 +- crates/swapper/src/testkit.rs | 47 +++++++++- crates/swapper/src/uniswap/v3/path.rs | 2 +- crates/swapper/src/uniswap/v3/quoter_v2.rs | 2 +- crates/swapper/src/uniswap/v4/path.rs | 8 +- crates/swapper/src/uniswap/v4/quoter.rs | 2 +- gemstone/src/gem_swapper/error.rs | 6 -- 31 files changed, 306 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a0d04f47..248529c02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6806,6 +6806,7 @@ dependencies = [ "sui-transaction-builder", "tokio", "tracing", + "typeshare", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 539e56df0..786c5012a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,3 +113,4 @@ alloy-primitives = "1.4.1" alloy-sol-types = { version = "1.4.1", features = ["eip712-serde"] } alloy-dyn-abi = { version = "1.4.1", features = ["eip712"] } alloy-json-abi = { version = "1.4.1" } +typeshare = "1.0.4" diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index dae6d97b8..3ea6cff3a 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -8,7 +8,7 @@ default = [] testkit = [] [dependencies] -typeshare = { version = "1.0.4" } +typeshare = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } diff --git a/crates/swapper/Cargo.toml b/crates/swapper/Cargo.toml index 59a8d9e50..b4a04da52 100644 --- a/crates/swapper/Cargo.toml +++ b/crates/swapper/Cargo.toml @@ -32,6 +32,7 @@ serde_serializers = { path = "../serde_serializers" } number_formatter = { path = "../number_formatter" } reqwest = { workspace = true, optional = true } +typeshare = { workspace = true } bcs.workspace = true sui-types = { workspace = true } diff --git a/crates/swapper/src/across/api.rs b/crates/swapper/src/across/api.rs index 359ceeae8..927919b05 100644 --- a/crates/swapper/src/across/api.rs +++ b/crates/swapper/src/across/api.rs @@ -61,10 +61,10 @@ impl AcrossApi { .map(|topic| topic.eq_ignore_ascii_case(FUNDS_DEPOSITED_TOPIC)) .unwrap_or(false) }) - .ok_or_else(|| SwapperError::NetworkError("FundsDeposited event not found".into()))?; + .ok_or_else(|| SwapperError::TransactionError("FundsDeposited event not found".into()))?; if deposit_log.topics.len() < 3 { - return Err(SwapperError::NetworkError("invalid FundsDeposited topics".into())); + return Err(SwapperError::TransactionError("invalid FundsDeposited topics".into())); } // The deposit ID is in topics[2] (topics[0] is event signature, topics[1] is destination chain ID) let deposit_id_hex = deposit_log.topics[2].clone(); @@ -72,7 +72,7 @@ impl AcrossApi { // Convert hex deposit ID to decimal string let deposit_id = if let Some(stripped) = deposit_id_hex.strip_prefix("0x") { u64::from_str_radix(stripped, 16) - .map_err(|e| SwapperError::NetworkError(format!("Failed to parse deposit ID: {}", e)))? + .map_err(|e| SwapperError::TransactionError(format!("Failed to parse deposit ID: {}", e)))? .to_string() } else { deposit_id_hex diff --git a/crates/swapper/src/across/config_store.rs b/crates/swapper/src/across/config_store.rs index 8cf64d82d..9afbb5ab2 100644 --- a/crates/swapper/src/across/config_store.rs +++ b/crates/swapper/src/across/config_store.rs @@ -74,7 +74,7 @@ impl ConfigStoreClient { let result: TokenConfig = serde_json::from_str(&decoded).map_err(SwapperError::from)?; Ok(result) } else { - Err(SwapperError::ABIError("config call failed".into())) + Err(SwapperError::ComputeQuoteError("config call failed".into())) } } @@ -83,7 +83,7 @@ impl ConfigStoreClient { let call = EthereumRpc::Call(TransactionObject::new_call(&self.contract, data), BlockParameter::Latest); let response: JsonRpcResult = self.client.call_with_cache(&call, Some(CONFIG_CACHE_TTL)).await?; let result = response.take()?; - let hex_data = HexDecode(result).map_err(|e| SwapperError::NetworkError(e.to_string()))?; + let hex_data = HexDecode(result).map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; let decoded = AcrossConfigStore::l1TokenConfigCall::abi_decode_returns(&hex_data).map_err(SwapperError::from)?; let result: TokenConfig = serde_json::from_str(&decoded).map_err(SwapperError::from)?; diff --git a/crates/swapper/src/across/hubpool.rs b/crates/swapper/src/across/hubpool.rs index b0f77b3fd..9a3e96e3c 100644 --- a/crates/swapper/src/across/hubpool.rs +++ b/crates/swapper/src/across/hubpool.rs @@ -37,7 +37,7 @@ impl HubPoolClient { } pub fn decoded_paused_call3(&self, result: &IMulticall3::Result) -> Result { - let value = decode_call3_return::(result).map_err(|e| SwapperError::ABIError(e.to_string()))?; + let value = decode_call3_return::(result).map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; Ok(value) } @@ -59,10 +59,11 @@ impl HubPoolClient { pub fn decoded_pooled_token_call3(&self, result: &IMulticall3::Result) -> Result { if result.success { - let decoded = HubPoolInterface::pooledTokensCall::abi_decode_returns(&result.returnData).map_err(|e| SwapperError::ABIError(e.to_string()))?; + let decoded = + HubPoolInterface::pooledTokensCall::abi_decode_returns(&result.returnData).map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; Ok(decoded) } else { - Err(SwapperError::ABIError("pooled token call failed".into())) + Err(SwapperError::ComputeQuoteError("pooled token call failed".into())) } } @@ -89,7 +90,7 @@ impl HubPoolClient { Ok(BigInt::from_bytes_le(Sign::Plus, &value.to_le_bytes::<32>())) } else { - Err(SwapperError::ABIError("utilization call failed".into())) + Err(SwapperError::ComputeQuoteError("utilization call failed".into())) } } @@ -98,15 +99,17 @@ impl HubPoolClient { } pub fn decoded_current_time(&self, result: &IMulticall3::Result) -> Result { - let value = decode_call3_return::(result).map_err(|e| SwapperError::ABIError(e.to_string()))?; - value.try_into().map_err(|_| SwapperError::ABIError("decode current time failed".into())) + let value = decode_call3_return::(result).map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; + value + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError("decode current time failed".into())) } pub async fn fetch_utilization(&self, pool_token: &Address, amount: U256) -> Result { let call3 = self.utilization_call3(pool_token, amount); let call = EthereumRpc::Call(TransactionObject::new_call(&self.contract, call3.callData.to_vec()), BlockParameter::Latest); let result: String = self.client.request(call).await?; - let hex_data = HexDecode(result).map_err(|e| SwapperError::NetworkError(e.to_string()))?; + let hex_data = HexDecode(result).map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; let value = HubPoolInterface::liquidityUtilizationCurrentCall::abi_decode_returns(&hex_data).map_err(SwapperError::from)?; let result = BigInt::from_bytes_le(num_bigint::Sign::Plus, &value.to_le_bytes::<32>()); Ok(result) diff --git a/crates/swapper/src/across/provider.rs b/crates/swapper/src/across/provider.rs index 91fed5b29..6f109cf5e 100644 --- a/crates/swapper/src/across/provider.rs +++ b/crates/swapper/src/across/provider.rs @@ -96,17 +96,17 @@ impl Across { create_eth_client(self.rpc_provider.clone(), chain)? .multicall3(calls) .await - .map_err(|e| SwapperError::NetworkError(e.to_string())) + .map_err(|e| SwapperError::ComputeQuoteError(e.to_string())) } async fn estimate_gas_transaction(&self, chain: Chain, tx: TransactionObject) -> Result { - let client = create_eth_client(self.rpc_provider.clone(), chain)?; + let client = create_eth_client(self.rpc_provider.clone(), chain).map_err(SwapperError::into_transaction_error)?; let gas_hex = client .estimate_gas(tx.from.as_deref(), &tx.to, tx.value.as_deref(), Some(tx.data.as_str())) .await - .map_err(SwapperError::from)?; + .map_err(|err| SwapperError::TransactionError(err.to_string()))?; - let gas_biguint = biguint_from_hex_str(&gas_hex).map_err(|e| SwapperError::NetworkError(format!("Failed to parse gas estimate: {e}")))?; + let gas_biguint = biguint_from_hex_str(&gas_hex).map_err(|e| SwapperError::TransactionError(format!("Failed to parse gas estimate: {e}")))?; let gas_bigint = BigInt::from_biguint(Sign::Plus, gas_biguint); Self::bigint_to_u256(&gas_bigint) } @@ -319,7 +319,7 @@ impl Swapper for Across { async fn fetch_quote(&self, request: &QuoteRequest) -> Result { // does not support same chain swap if request.from_asset.chain() == request.to_asset.chain() { - return Err(SwapperError::NotSupportedPair); + return Err(SwapperError::NotSupportedAsset); } let input_is_native = request.from_asset.is_native(); @@ -330,11 +330,11 @@ impl Swapper for Across { let _ = AcrossDeployment::deployment_by_chain(&request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; let destination_deployment = AcrossDeployment::deployment_by_chain(&request.to_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; if !Self::is_supported_pair(&request.from_asset.asset_id(), &request.to_asset.asset_id()) { - return Err(SwapperError::NotSupportedPair); + return Err(SwapperError::NotSupportedAsset); } - let input_asset = eth_address::convert_native_to_weth(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedPair)?; - let output_asset = eth_address::convert_native_to_weth(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedPair)?; + let input_asset = eth_address::convert_native_to_weth(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)?; + let output_asset = eth_address::convert_native_to_weth(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)?; let original_output_asset = request.to_asset.asset_id(); let output_token = eth_address::parse_asset_id(&output_asset)?; diff --git a/crates/swapper/src/approval/evm.rs b/crates/swapper/src/approval/evm.rs index f5839608a..3ff4843cc 100644 --- a/crates/swapper/src/approval/evm.rs +++ b/crates/swapper/src/approval/evm.rs @@ -50,15 +50,27 @@ pub async fn check_approval_erc20_with_client( where C: Client + Clone + std::fmt::Debug + Send + Sync + 'static, { - let owner: Address = owner.as_str().parse().map_err(|_| SwapperError::InvalidAddress(owner))?; - let spender: Address = spender.as_str().parse().map_err(|_| SwapperError::InvalidAddress(spender))?; + let owner: Address = owner + .as_str() + .parse() + .map_err(|_| SwapperError::TransactionError(format!("Invalid address {owner}")))?; + let spender: Address = spender + .as_str() + .parse() + .map_err(|_| SwapperError::TransactionError(format!("Invalid address {spender}")))?; let allowance_data = IERC20::allowanceCall { owner, spender }.abi_encode(); let allowance_call = EthereumRpc::Call(TransactionObject::new_call(&token, allowance_data), BlockParameter::Latest); - let result: String = client.request(allowance_call).await.map_err(SwapperError::from)?; - let decoded = HexDecode(result).map_err(|_| SwapperError::ABIError("failed to decode allowance_call result".into()))?; + let result: String = client + .request(allowance_call) + .await + .map_err(SwapperError::from) + .map_err(SwapperError::into_transaction_error)?; + let decoded = HexDecode(result).map_err(|_| SwapperError::TransactionError("failed to decode allowance_call result".into()))?; - let allowance = IERC20::allowanceCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; + let allowance = IERC20::allowanceCall::abi_decode_returns(&decoded) + .map_err(SwapperError::from) + .map_err(SwapperError::into_transaction_error)?; if allowance < amount { return Ok(ApprovalType::Approve(ApprovalData { token: token.to_string(), @@ -101,15 +113,21 @@ where .abi_encode(); let permit2_call = EthereumRpc::Call(TransactionObject::new_call(permit2_contract, permit2_data), BlockParameter::Latest); - let result: String = client.request(permit2_call).await.map_err(SwapperError::from)?; - let decoded = HexDecode(result).map_err(|_| SwapperError::ABIError("failed to decode permit2 allowance result".into()))?; - let allowance_return = IAllowanceTransfer::allowanceCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; + let result: String = client + .request(permit2_call) + .await + .map_err(SwapperError::from) + .map_err(SwapperError::into_transaction_error)?; + let decoded = HexDecode(result).map_err(|_| SwapperError::TransactionError("failed to decode permit2 allowance result".into()))?; + let allowance_return = IAllowanceTransfer::allowanceCall::abi_decode_returns(&decoded) + .map_err(SwapperError::from) + .map_err(SwapperError::into_transaction_error)?; let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs(); let expiration: u64 = allowance_return ._1 .try_into() - .map_err(|_| SwapperError::ABIError("failed to convert expiration to u64".into()))?; + .map_err(|_| SwapperError::TransactionError("failed to convert expiration to u64".into()))?; if U256::from(allowance_return._0) < amount || expiration < timestamp { return Ok(ApprovalType::Permit2(Permit2ApprovalData { @@ -120,7 +138,7 @@ where permit2_nonce: allowance_return ._2 .try_into() - .map_err(|_| SwapperError::ABIError("failed to convert nonce to u64".into()))?, + .map_err(|_| SwapperError::TransactionError("failed to convert nonce to u64".into()))?, })); } diff --git a/crates/swapper/src/approval/tron.rs b/crates/swapper/src/approval/tron.rs index 46f858349..d1783e296 100644 --- a/crates/swapper/src/approval/tron.rs +++ b/crates/swapper/src/approval/tron.rs @@ -12,11 +12,11 @@ pub async fn check_approval_tron( amount: U256, provider: Arc, ) -> Result { - let client = create_tron_client(provider.clone()).map_err(|e| SwapperError::NetworkError(e.to_string()))?; + let client = create_tron_client(provider.clone()).map_err(|e| SwapperError::TransactionError(e.to_string()))?; let allowance = client .get_token_allowance(owner_address, token_address, spender_address) .await - .map_err(|e| SwapperError::NetworkError(e.to_string()))?; + .map_err(|e| SwapperError::TransactionError(e.to_string()))?; let amount_big = BigUint::from_bytes_be(&amount.to_be_bytes::<32>()); if allowance < amount_big { return Ok(ApprovalType::Approve(ApprovalData { diff --git a/crates/swapper/src/cetus/provider.rs b/crates/swapper/src/cetus/provider.rs index f9908241d..b156d7557 100644 --- a/crates/swapper/src/cetus/provider.rs +++ b/crates/swapper/src/cetus/provider.rs @@ -186,7 +186,10 @@ where Some(ObjectDataOptions::default()), ); - let pool_datas: Vec = sui_client.rpc_call(rpc_call).await.map_err(|e| SwapperError::NetworkError(e.to_string()))?; + let pool_datas: Vec = sui_client + .rpc_call(rpc_call) + .await + .map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; let pool_quotes = top_pools .into_iter() @@ -287,11 +290,11 @@ where // Execute gas_price and coin_assets fetching in parallel let (gas_price_result, all_coin_assets_result) = join!(sui_client.get_gas_price(), sui_client.get_coin_assets(sender_address)); - let gas_price_bigint = gas_price_result.map_err(|e| SwapperError::NetworkError(e.to_string()))?; + let gas_price_bigint = gas_price_result.map_err(|e| SwapperError::TransactionError(e.to_string()))?; let gas_price = gas_price_bigint .to_u64() - .ok_or_else(|| SwapperError::NetworkError("Failed to convert gas price to u64".into()))?; - let all_coin_assets = all_coin_assets_result.map_err(|e| SwapperError::NetworkError(e.to_string()))?; + .ok_or_else(|| SwapperError::TransactionError("Failed to convert gas price to u64".into()))?; + let all_coin_assets = all_coin_assets_result.map_err(|e| SwapperError::TransactionError(e.to_string()))?; // Prepare swap params for tx building let a2b = from_coin == route_data.coin_a; @@ -323,7 +326,7 @@ where let inspect_result = sui_client .inspect_transaction_block("e.request.wallet_address, &tx_bytes) .await - .map_err(|e| SwapperError::NetworkError(e.to_string()))?; + .map_err(|e| SwapperError::TransactionError(e.to_string()))?; let gas_budget = GasBudgetCalculator::gas_budget(&inspect_result.effects.gas_used); let coin_refs = all_coin_assets diff --git a/crates/swapper/src/chainlink.rs b/crates/swapper/src/chainlink.rs index d9df74cef..2a82a5595 100644 --- a/crates/swapper/src/chainlink.rs +++ b/crates/swapper/src/chainlink.rs @@ -24,8 +24,8 @@ impl ChainlinkPriceFeed { // Price is in 8 decimals pub fn decoded_answer(result: &IMulticall3::Result) -> Result { - let decoded = - decode_call3_return::(result).map_err(|_| SwapperError::ABIError("failed to decode answer".into()))?; + let decoded = decode_call3_return::(result) + .map_err(|_| SwapperError::ComputeQuoteError("failed to decode answer".into()))?; let price = BigInt::from_le_bytes(&decoded.answer.to_le_bytes::<32>()); Ok(price) } diff --git a/crates/swapper/src/error.rs b/crates/swapper/src/error.rs index 9a13c73fd..8165b9d8b 100644 --- a/crates/swapper/src/error.rs +++ b/crates/swapper/src/error.rs @@ -1,126 +1,131 @@ use crate::alien::AlienError; use gem_client::ClientError; use gem_jsonrpc::types::JsonRpcError; +use serde::{Deserialize, Serialize}; use std::fmt::Debug; +use typeshare::typeshare; -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case", tag = "type", content = "message")] +#[typeshare(swift = "Equatable, Hashable, Sendable")] pub enum SwapperError { NotSupportedChain, NotSupportedAsset, - NotSupportedPair, NoAvailableProvider, - InvalidAddress(String), - InvalidAmount(String), InputAmountTooSmall, InvalidRoute, - NetworkError(String), - ABIError(String), ComputeQuoteError(String), TransactionError(String), NoQuoteAvailable, - NotImplemented, } impl std::fmt::Display for SwapperError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::NotSupportedChain => write!(f, "Not supported chain"), Self::NotSupportedAsset => write!(f, "Not supported asset"), - Self::NotSupportedPair => write!(f, "Not supported pair"), + Self::NotSupportedChain => write!(f, "Not supported chain"), Self::NoAvailableProvider => write!(f, "No available provider"), - Self::InvalidAddress(addr) => write!(f, "Invalid address {}", addr), - Self::InvalidAmount(amount) => write!(f, "Invalid amount {}", amount), Self::InputAmountTooSmall => write!(f, "Input amount is too small"), Self::InvalidRoute => write!(f, "Invalid route or route data"), - Self::NetworkError(msg) => write!(f, "Network related error: {}", msg), - Self::ABIError(msg) => write!(f, "ABI error: {}", msg), Self::ComputeQuoteError(msg) => write!(f, "Compute quote error: {}", msg), Self::TransactionError(msg) => write!(f, "Transaction error: {}", msg), Self::NoQuoteAvailable => write!(f, "No quote available"), - Self::NotImplemented => write!(f, "Not implemented"), } } } impl std::error::Error for SwapperError {} +impl SwapperError { + /// Standard messages for legacy invalid input errors that now map to compute/transaction failures. + pub const INVALID_AMOUNT_MESSAGE: &'static str = "Invalid amount"; + pub const INVALID_ADDRESS_MESSAGE: &'static str = "Invalid address"; + + pub fn into_transaction_error(self) -> Self { + match self { + SwapperError::ComputeQuoteError(msg) => SwapperError::TransactionError(msg), + other => other, + } + } +} + impl From for SwapperError { fn from(err: AlienError) -> Self { match err { - AlienError::RequestError { msg } => Self::NetworkError(format!("Alien request error: {msg}")), - AlienError::ResponseError { msg } => Self::NetworkError(format!("Alien response error: {msg}")), - AlienError::Http { status, len } => Self::NetworkError(format!("Alien HTTP error: status {}, body size: {}", status, len)), + AlienError::RequestError { msg } => Self::ComputeQuoteError(format!("Client request error: {msg}")), + AlienError::ResponseError { msg } => Self::ComputeQuoteError(format!("Client response error: {msg}")), + AlienError::Http { status, len } => Self::ComputeQuoteError(format!("Client HTTP error: status {}, body size: {}", status, len)), } } } impl From for SwapperError { fn from(err: JsonRpcError) -> Self { - Self::NetworkError(format!("JSON RPC error: {err}")) + Self::ComputeQuoteError(format!("JSON RPC error: {err}")) } } impl From for SwapperError { fn from(err: ClientError) -> Self { match err { - ClientError::Network(msg) => Self::NetworkError(msg), - ClientError::Timeout => Self::NetworkError("Request timed out".into()), - ClientError::Http { status, len } => Self::NetworkError(format!("HTTP error: status {}, body size: {}", status, len)), - ClientError::Serialization(msg) => Self::NetworkError(msg), + ClientError::Network(msg) => Self::ComputeQuoteError(msg), + ClientError::Timeout => Self::ComputeQuoteError("Request timed out".into()), + ClientError::Http { status, len } => Self::ComputeQuoteError(format!("HTTP error: status {}, body size: {}", status, len)), + ClientError::Serialization(msg) => Self::ComputeQuoteError(msg), } } } impl From for SwapperError { fn from(err: alloy_primitives::AddressError) -> Self { - Self::InvalidAddress(err.to_string()) + Self::ComputeQuoteError(format!("Invalid address: {err}")) } } impl From for SwapperError { fn from(err: sui_types::AddressParseError) -> Self { - Self::InvalidAddress(err.to_string()) + Self::ComputeQuoteError(format!("Invalid address: {err}")) } } impl From for SwapperError { fn from(err: serde_json::Error) -> Self { - Self::NetworkError(format!("serde_json::Error: {err}")) + Self::ComputeQuoteError(format!("serde_json::Error: {err}")) } } impl From for SwapperError { fn from(err: serde_urlencoded::ser::Error) -> Self { - Self::NetworkError(format!("Request query error: {err}")) + Self::ComputeQuoteError(format!("Request query error: {err}")) } } impl From for SwapperError { fn from(err: alloy_sol_types::Error) -> Self { - Self::ABIError(format!("AlloyError: {err}")) + Self::ComputeQuoteError(format!("AlloyError: {err}")) } } impl From for SwapperError { fn from(err: alloy_primitives::ruint::ParseError) -> Self { - Self::InvalidAmount(err.to_string()) + Self::ComputeQuoteError(format!("Invalid amount: {err}")) } } impl From for SwapperError { fn from(err: std::num::ParseIntError) -> Self { - Self::InvalidAmount(err.to_string()) + Self::ComputeQuoteError(format!("Invalid amount: {err}")) } } impl From for SwapperError { fn from(err: num_bigint::ParseBigIntError) -> Self { - Self::InvalidAmount(err.to_string()) + Self::ComputeQuoteError(format!("Invalid amount: {err}")) } } impl From for SwapperError { fn from(err: number_formatter::NumberFormatterError) -> Self { - Self::InvalidAmount(err.to_string()) + Self::ComputeQuoteError(format!("Invalid amount: {err}")) } } diff --git a/crates/swapper/src/eth_address.rs b/crates/swapper/src/eth_address.rs index c21144e98..0676ecdfe 100644 --- a/crates/swapper/src/eth_address.rs +++ b/crates/swapper/src/eth_address.rs @@ -25,10 +25,11 @@ pub(crate) fn parse_asset_id(asset: &AssetId) -> Result { if let Some(token_id) = &asset.token_id { parse_str(token_id) } else { - Err(SwapperError::InvalidAddress(asset.to_string())) + Err(SwapperError::ComputeQuoteError(format!("Invalid address {}", asset))) } } pub(crate) fn parse_str(str: &str) -> Result { - str.parse::
().map_err(|_| SwapperError::InvalidAddress(str.to_string())) + str.parse::
() + .map_err(|_| SwapperError::ComputeQuoteError(format!("Invalid address {}", str))) } diff --git a/crates/swapper/src/hyperliquid/provider/hyperliquid.rs b/crates/swapper/src/hyperliquid/provider/hyperliquid.rs index 12c951942..04c5fe5d1 100644 --- a/crates/swapper/src/hyperliquid/provider/hyperliquid.rs +++ b/crates/swapper/src/hyperliquid/provider/hyperliquid.rs @@ -66,7 +66,7 @@ impl Swapper for Hyperliquid { return self.bridge.fetch_quote(request).await; } - Err(SwapperError::NotSupportedPair) + Err(SwapperError::NotSupportedAsset) } async fn fetch_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { @@ -78,7 +78,7 @@ impl Swapper for Hyperliquid { return self.bridge.fetch_quote_data(quote, data).await; } - Err(SwapperError::NotSupportedPair) + Err(SwapperError::NotSupportedAsset) } async fn get_swap_result(&self, chain: primitives::Chain, transaction_hash: &str) -> Result { diff --git a/crates/swapper/src/hyperliquid/provider/math.rs b/crates/swapper/src/hyperliquid/provider/math.rs index b7e4127da..fce3c7900 100644 --- a/crates/swapper/src/hyperliquid/provider/math.rs +++ b/crates/swapper/src/hyperliquid/provider/math.rs @@ -19,7 +19,7 @@ pub fn scale_units(value: BigUint, from_decimals: u32, to_decimals: u32) -> Resu let factor = BigUint::from(10u32).pow(diff); let (quotient, remainder) = value.div_rem(&factor); if !remainder.is_zero() { - return Err(SwapperError::InvalidAmount("amount precision loss".to_string())); + return Err(SwapperError::ComputeQuoteError("amount precision loss".into())); } Ok(quotient) } @@ -55,7 +55,7 @@ mod tests { #[test] fn test_scale_units_precision_loss_rejected() { let err = scale_units(BigUint::from(5u32), 3, 1).unwrap_err(); - assert!(matches!(err, SwapperError::InvalidAmount(_))); + assert!(matches!(err, SwapperError::ComputeQuoteError(_))); } #[test] @@ -73,6 +73,6 @@ mod tests { #[test] fn test_scale_quote_value_invalid_number() { let err = scale_quote_value("abc", 6, 8).unwrap_err(); - assert!(matches!(err, SwapperError::InvalidAmount(_))); + assert!(matches!(err, SwapperError::ComputeQuoteError(_))); } } diff --git a/crates/swapper/src/hyperliquid/provider/spot/math.rs b/crates/swapper/src/hyperliquid/provider/spot/math.rs index bd7736c32..bb6dcd336 100644 --- a/crates/swapper/src/hyperliquid/provider/spot/math.rs +++ b/crates/swapper/src/hyperliquid/provider/spot/math.rs @@ -32,14 +32,14 @@ pub(super) fn format_decimal_with_scale(value: &BigDecimal, scale: u32) -> Strin pub(super) fn format_order_size(amount: &BigDecimal, decimals: u32) -> Result { let value = amount .to_f64() - .ok_or_else(|| SwapperError::InvalidAmount("failed to convert amount".to_string()))?; + .ok_or_else(|| SwapperError::ComputeQuoteError("failed to convert amount".into()))?; let rounded = round_to_decimals(value, decimals); let formatted = if decimals == 0 { format!("{rounded:.0}") } else { format!("{rounded:.decimals$}", decimals = decimals as usize) }; - let big_decimal = BigDecimal::from_str(&formatted).map_err(|_| SwapperError::InvalidAmount("failed to format size".to_string()))?; + let big_decimal = BigDecimal::from_str(&formatted).map_err(|_| SwapperError::ComputeQuoteError("failed to format size".into()))?; Ok(BigNumberFormatter::decimal_to_string(&big_decimal, decimals)) } @@ -49,18 +49,18 @@ pub(super) fn spot_asset_index(market_index: u32) -> u32 { pub(super) fn apply_slippage(limit_price: &BigDecimal, side: SpotSide, slippage_bps: u32, price_decimals: u32) -> Result { if limit_price <= &BigDecimal::zero() { - return Err(SwapperError::InvalidAmount("invalid limit price".to_string())); + return Err(SwapperError::ComputeQuoteError("invalid limit price".into())); } let limit_price_f64 = limit_price .to_f64() - .ok_or_else(|| SwapperError::InvalidAmount("failed to convert price".to_string()))?; + .ok_or_else(|| SwapperError::ComputeQuoteError("failed to convert price".into()))?; let slippage_fraction = slippage_bps as f64 / 10_000.0; let multiplier = if side.is_buy() { 1.0 + slippage_fraction } else { 1.0 - slippage_fraction }; if multiplier <= 0.0 { - return Err(SwapperError::InvalidAmount("slippage multiplier not positive".to_string())); + return Err(SwapperError::ComputeQuoteError("slippage multiplier not positive".into())); } let adjusted = limit_price_f64 * multiplier; @@ -72,7 +72,7 @@ pub(super) fn apply_slippage(limit_price: &BigDecimal, side: SpotSide, slippage_ format!("{rounded:.price_decimals$}", price_decimals = price_decimals as usize) }; - BigDecimal::from_str(&formatted).map_err(|_| SwapperError::InvalidAmount("failed to format limit price".to_string())) + BigDecimal::from_str(&formatted).map_err(|_| SwapperError::ComputeQuoteError("failed to format limit price".into())) } fn round_to_decimals(value: f64, decimals: u32) -> f64 { diff --git a/crates/swapper/src/hyperliquid/provider/spot/provider.rs b/crates/swapper/src/hyperliquid/provider/spot/provider.rs index 8549127f2..96c593c9c 100644 --- a/crates/swapper/src/hyperliquid/provider/spot/provider.rs +++ b/crates/swapper/src/hyperliquid/provider/spot/provider.rs @@ -57,12 +57,15 @@ impl HyperCoreSpot { async fn load_spot_meta(&self) -> Result { let client = self.client()?; - client.get_spot_meta().await.map_err(|err| SwapperError::NetworkError(err.to_string())) + client.get_spot_meta().await.map_err(|err| SwapperError::ComputeQuoteError(err.to_string())) } async fn load_orderbook(&self, coin: &str) -> Result { let client = self.client()?; - client.get_spot_orderbook(coin).await.map_err(|err| SwapperError::NetworkError(err.to_string())) + client + .get_spot_orderbook(coin) + .await + .map_err(|err| SwapperError::ComputeQuoteError(err.to_string())) } fn resolve_token<'a>(&self, meta: &'a SpotMeta, asset: &'a SwapperQuoteAsset) -> Result<&'a SpotToken, SwapperError> { @@ -100,7 +103,7 @@ impl HyperCoreSpot { meta.universe() .iter() .find(|market| market.tokens.len() == 2 && market.tokens[0] == base.index && market.tokens[1] == quote.index) - .ok_or(SwapperError::NotSupportedPair) + .ok_or(SwapperError::NotSupportedAsset) } } @@ -124,13 +127,13 @@ impl Swapper for HyperCoreSpot { let amount_in = BigNumberFormatter::big_decimal_value(&request.value, request.from_asset.decimals)?; if amount_in <= BigDecimal::zero() { - return Err(SwapperError::InvalidAmount("amount must be greater than zero".to_string())); + return Err(SwapperError::ComputeQuoteError("amount must be greater than zero".into())); } let (side, base_token, quote_token) = match (from_token.name.as_str(), to_token.name.as_str()) { (PAIR_BASE_SYMBOL, PAIR_QUOTE_SYMBOL) => (SpotSide::Sell, from_token, to_token), (PAIR_QUOTE_SYMBOL, PAIR_BASE_SYMBOL) => (SpotSide::Buy, to_token, from_token), - _ => return Err(SwapperError::NotSupportedPair), + _ => return Err(SwapperError::NotSupportedAsset), }; let market = self.resolve_market(&meta, base_token, quote_token)?; @@ -150,11 +153,11 @@ impl Swapper for HyperCoreSpot { let token_decimals: u32 = to_token .wei_decimals .try_into() - .map_err(|_| SwapperError::InvalidAmount("invalid amount precision".to_string()))?; + .map_err(|_| SwapperError::ComputeQuoteError("invalid amount precision".into()))?; let output_amount_str = format_decimal(&output_amount); let token_units = BigNumberFormatter::value_from_amount_biguint(&output_amount_str, token_decimals) - .map_err(|err| SwapperError::InvalidAmount(format!("invalid amount: {err}")))?; + .map_err(|err| SwapperError::ComputeQuoteError(format!("invalid amount: {err}")))?; let scaled_units = scale_units(token_units, token_decimals, request.to_asset.decimals)?; let to_value = scaled_units.to_string(); @@ -194,7 +197,7 @@ impl Swapper for HyperCoreSpot { async fn fetch_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; let order: PlaceOrder = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; - let order_json = serde_json::to_string(&order).map_err(|err| SwapperError::ComputeQuoteError(err.to_string()))?; + let order_json = serde_json::to_string(&order).map_err(|err| SwapperError::TransactionError(err.to_string()))?; Ok(SwapperQuoteData::new_contract( "".to_string(), diff --git a/crates/swapper/src/jupiter/provider.rs b/crates/swapper/src/jupiter/provider.rs index fa8ad18e3..f4440a434 100644 --- a/crates/swapper/src/jupiter/provider.rs +++ b/crates/swapper/src/jupiter/provider.rs @@ -48,7 +48,7 @@ where pub fn get_asset_address(&self, asset_id: &str) -> Result { get_pubkey_by_str(asset_id) .map(|x| x.to_string()) - .ok_or(SwapperError::InvalidAddress(asset_id.to_string())) + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("Invalid address {asset_id}"))) } fn get_fee_mint(&self, mode: &SwapperMode, input: &str, output: &str) -> String { @@ -80,7 +80,7 @@ where value .value .map(|x| x.owner) - .ok_or(SwapperError::NetworkError("fetch_token_program error".to_string())) + .ok_or_else(|| SwapperError::ComputeQuoteError("fetch_token_program error".to_string())) } async fn fetch_fee_account(&self, mode: &SwapperMode, options: &Options, input_mint: &str, output_mint: &str) -> Result { diff --git a/crates/swapper/src/near_intents/provider.rs b/crates/swapper/src/near_intents/provider.rs index 0e03f0deb..c960a21ef 100644 --- a/crates/swapper/src/near_intents/provider.rs +++ b/crates/swapper/src/near_intents/provider.rs @@ -229,7 +229,7 @@ where from_asset.asset_id().token_id.as_deref(), ) .await - .map_err(|err| SwapperError::NetworkError(format!("Failed to build Sui deposit data: {err}")))?; + .map_err(|err| SwapperError::TransactionError(format!("Failed to build Sui deposit data: {err}")))?; Ok(DepositData { to: deposit_address.to_string(), @@ -252,7 +252,7 @@ fn map_quote_error(error: &QuoteResponseError) -> SwapperError { if lower.contains("too low") { SwapperError::InputAmountTooSmall } else { - SwapperError::NetworkError(format!("Near Intents quote error: {}", error.message)) + SwapperError::ComputeQuoteError(format!("Near Intents quote error: {}", error.message)) } } @@ -272,7 +272,7 @@ where async fn fetch_quote(&self, request: &QuoteRequest) -> Result { let mode = match request.mode { SwapperMode::ExactIn => SwapType::FlexInput, - SwapperMode::ExactOut => return Err(SwapperError::NotImplemented), + SwapperMode::ExactOut => todo!("ExactOut mode is not supported for Near Intents"), }; let amount = Self::resolve_quote_amount(request, &mode)?; @@ -521,12 +521,12 @@ mod swap_integration_tests { let quote = match provider.fetch_quote(&request).await { Ok(quote) => quote, - Err(SwapperError::NetworkError(_)) => return Ok(()), + Err(SwapperError::ComputeQuoteError(_)) => return Ok(()), Err(error) => return Err(error), }; let quote_data = match provider.fetch_quote_data("e, FetchQuoteData::None).await { Ok(data) => data, - Err(SwapperError::NetworkError(_)) => return Ok(()), + Err(SwapperError::TransactionError(_)) => return Ok(()), Err(error) => return Err(error), }; diff --git a/crates/swapper/src/permit2_data.rs b/crates/swapper/src/permit2_data.rs index 580bb4f9d..cc5cfcc5f 100644 --- a/crates/swapper/src/permit2_data.rs +++ b/crates/swapper/src/permit2_data.rs @@ -96,7 +96,7 @@ pub fn permit2_data_to_eip712_json(chain: Chain, data: PermitSingle, contract: & primary_type: "PermitSingle".into(), message: data, }; - let json = serde_json::to_string(&message).map_err(|_| SwapperError::ABIError("failed to serialize EIP712 message to JSON".into()))?; + let json = serde_json::to_string(&message).map_err(|_| SwapperError::TransactionError("failed to serialize EIP712 message to JSON".into()))?; Ok(json) } diff --git a/crates/swapper/src/proxy/client.rs b/crates/swapper/src/proxy/client.rs index 7f44e2af0..c8fc6c428 100644 --- a/crates/swapper/src/proxy/client.rs +++ b/crates/swapper/src/proxy/client.rs @@ -1,14 +1,21 @@ use crate::SwapperError; -use gem_client::Client; +use gem_client::{Client, build_path_with_query}; use primitives::swap::{ProxyQuote, ProxyQuoteRequest, SwapQuoteData}; use serde::Deserialize; +use serde::Serialize; use std::fmt::Debug; +const API_VERSION: u8 = 1; + #[derive(Debug, Deserialize)] -#[serde(untagged)] enum ProxyResult { - Ok(T), - Err { error: String }, + Ok { ok: T }, + Err { err: ProxyError }, +} + +#[derive(Debug, Deserialize)] +enum ProxyError { + Object { code: SwapperError }, } #[derive(Clone, Debug)] @@ -28,18 +35,36 @@ where } pub async fn get_quote(&self, request: ProxyQuoteRequest) -> Result { - let response: ProxyResult = self.client.post("/quote", &request, None).await.map_err(SwapperError::from)?; + let path = build_path_with_query("/quote", &VersionQuery { v: API_VERSION }).map_err(SwapperError::from)?; + let response: ProxyResult = self.client.post(&path, &request, None).await.map_err(SwapperError::from)?; match response { - ProxyResult::Ok(q) => Ok(q), - ProxyResult::Err { error } => Err(SwapperError::ComputeQuoteError(error)), + ProxyResult::Ok { ok } => Ok(ok), + ProxyResult::Err { err } => Err(map_proxy_error(err)), } } pub async fn get_quote_data(&self, quote: ProxyQuote) -> Result { - let response: ProxyResult = self.client.post("/quote_data", "e, None).await.map_err(SwapperError::from)?; + let path = build_path_with_query("/quote_data", &VersionQuery { v: API_VERSION }).map_err(SwapperError::from)?; + let response: ProxyResult = self.client.post(&path, "e, None).await.map_err(SwapperError::from)?; match response { - ProxyResult::Ok(qd) => Ok(qd), - ProxyResult::Err { error } => Err(SwapperError::TransactionError(error)), + ProxyResult::Ok { ok } => Ok(ok), + ProxyResult::Err { err } => Err(map_proxy_error(err)), } } } + +#[derive(Debug, Serialize)] +struct VersionQuery { + v: u8, +} + +/// Try to cast a proxy error back into a `SwapperError` variant. +fn map_proxy_error(error: ProxyError) -> SwapperError { + match error { + ProxyError::Object { code } => match code { + SwapperError::ComputeQuoteError(message) => SwapperError::ComputeQuoteError(message), + SwapperError::TransactionError(message) => SwapperError::TransactionError(message), + other => other, + }, + } +} diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index 40478e974..4ec881d96 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -258,7 +258,7 @@ where if self.provider.mode == SwapperProviderMode::OnChain { Ok(self.get_onchain_swap_status(chain, transaction_hash)) } else { - Err(SwapperError::NotImplemented) + todo!("Swap result not implemented for provider {:?}", self.provider.id) } } } diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index dfddffe01..20678720c 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -75,6 +75,16 @@ impl GemSwapper { from_symbol.contains("USD") && to_symbol.contains("USD") } + + fn prioritized_error(errors: &[SwapperError]) -> Option { + for err in errors { + if let SwapperError::InputAmountTooSmall = err { + return Some(SwapperError::InputAmountTooSmall); + } + } + + None + } } impl GemSwapper { @@ -137,7 +147,7 @@ impl GemSwapper { pub async fn fetch_quote(&self, request: &QuoteRequest) -> Result, SwapperError> { if request.from_asset.id == request.to_asset.id { - return Err(SwapperError::NotSupportedPair); + return Err(SwapperError::NotSupportedAsset); } let from_chain = request.from_asset.chain(); let to_chain = request.to_asset.chain(); @@ -157,21 +167,25 @@ impl GemSwapper { let request_for_quote = Self::transform_request(request); let quotes_futures = providers.into_iter().map(|x| x.fetch_quote(request_for_quote.as_ref())); - let quotes = futures::future::join_all(quotes_futures.into_iter().map(|fut| async { - match &fut.await { - Ok(quote) => Some(quote.clone()), - Err(_err) => { - tracing::debug!("fetch_quote error: {:?}", _err); - None + let quote_results = futures::future::join_all(quotes_futures).await; + + let mut quotes = Vec::new(); + let mut errors = Vec::new(); + + for result in quote_results { + match result { + Ok(quote) => quotes.push(quote), + Err(err) => { + tracing::debug!("fetch_quote error: {:?}", err); + errors.push(err); } } - })) - .await - .into_iter() - .flatten() - .collect::>(); + } if quotes.is_empty() { + if let Some(error) = Self::prioritized_error(&errors) { + return Err(error); + } return Err(SwapperError::NoQuoteAvailable); } @@ -207,15 +221,18 @@ impl GemSwapper { #[cfg(all(test, feature = "reqwest_provider"))] mod tests { + use std::{borrow::Cow, collections::BTreeSet, sync::Arc, vec}; + + use primitives::{AssetId, Chain, asset_constants::USDT_ETH_ASSET_ID}; + use super::*; use crate::{ - Options, SwapperMode, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, + Options, SwapperChainAsset, SwapperMode, SwapperProvider, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, alien::reqwest_provider::NativeProvider, config::{DEFAULT_STABLE_SWAP_REFERRAL_BPS, DEFAULT_SWAP_FEE_BPS, ReferralFees}, + testkit::{MockSwapper, mock_quote}, uniswap::default::{new_pancakeswap, new_uniswap_v3}, }; - use primitives::asset_constants::USDT_ETH_ASSET_ID; - use std::{borrow::Cow, collections::BTreeSet, sync::Arc, vec}; fn build_request(from_symbol: &str, to_symbol: &str, fee: Option) -> QuoteRequest { QuoteRequest { @@ -385,4 +402,51 @@ mod tests { let stable_without_fees = build_request("USDC", "USDT", None); assert!(matches!(GemSwapper::transform_request(&stable_without_fees), Cow::Borrowed(_))); } + + #[tokio::test] + async fn test_fetch_quote_amount_error() { + let request = mock_quote( + SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")), + ); + + let gem_swapper = GemSwapper { + rpc_provider: Arc::new(NativeProvider::default()), + swappers: vec![ + Box::new(MockSwapper::new(SwapperProvider::UniswapV3, || Err(SwapperError::InputAmountTooSmall))), + Box::new(MockSwapper::new(SwapperProvider::PancakeswapV3, || Err(SwapperError::InputAmountTooSmall))), + Box::new(MockSwapper::new(SwapperProvider::Jupiter, || Err(SwapperError::NoQuoteAvailable))), + ], + }; + + let result = gem_swapper.fetch_quote(&request).await; + + match result { + Err(SwapperError::InputAmountTooSmall) => {} + _ => panic!("expected InputAmountTooSmall when every provider rejects the amount"), + } + } + + #[tokio::test] + async fn test_fetch_quote_no_quote() { + let request = mock_quote( + SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, "0xdAC17F958D2ee523a2206206994597C13D831ec7")), + ); + + let gem_swapper = GemSwapper { + rpc_provider: Arc::new(NativeProvider::default()), + swappers: vec![ + Box::new(MockSwapper::new(SwapperProvider::UniswapV3, || Err(SwapperError::NoQuoteAvailable))), + Box::new(MockSwapper::new(SwapperProvider::PancakeswapV3, || Err(SwapperError::NoQuoteAvailable))), + ], + }; + + let result = gem_swapper.fetch_quote(&request).await; + + match result { + Err(SwapperError::NoQuoteAvailable) => {} + _ => panic!("expected NoQuoteAvailable when providers failed without amount errors"), + } + } } diff --git a/crates/swapper/src/swapper_trait.rs b/crates/swapper/src/swapper_trait.rs index d67bc0fa4..2aa9eedb4 100644 --- a/crates/swapper/src/swapper_trait.rs +++ b/crates/swapper/src/swapper_trait.rs @@ -21,7 +21,7 @@ pub trait Swapper: Send + Sync + Debug { if self.provider().mode == SwapperProviderMode::OnChain { Ok(self.get_onchain_swap_status(chain, transaction_hash)) } else { - Err(SwapperError::NotImplemented) + todo!("get_swap_result not implemented for provider {:?}", self.provider().id) } } diff --git a/crates/swapper/src/testkit.rs b/crates/swapper/src/testkit.rs index 5ea9adc7e..282b1480e 100644 --- a/crates/swapper/src/testkit.rs +++ b/crates/swapper/src/testkit.rs @@ -1,6 +1,11 @@ -use crate::{SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, config::get_swap_config}; +use crate::{ + FetchQuoteData, ProviderType, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, SwapperSlippage, + SwapperSlippageMode, config::get_swap_config, +}; +use async_trait::async_trait; +use primitives::Chain; -use super::{Options, QuoteRequest, SwapperMode}; +use super::{Options, Quote, QuoteRequest, SwapperMode}; pub fn mock_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) -> QuoteRequest { let config = get_swap_config(); @@ -23,3 +28,41 @@ pub fn mock_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) -> }, } } + +type MockResponse = fn() -> Result; + +#[derive(Debug)] +pub struct MockSwapper { + provider: ProviderType, + supported_assets: Vec, + response: MockResponse, +} + +impl MockSwapper { + pub fn new(provider: SwapperProvider, response: MockResponse) -> Self { + Self { + provider: ProviderType::new(provider), + supported_assets: vec![SwapperChainAsset::All(Chain::Ethereum)], + response, + } + } +} + +#[async_trait] +impl Swapper for MockSwapper { + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + self.supported_assets.clone() + } + + async fn fetch_quote(&self, _request: &QuoteRequest) -> Result { + (self.response)() + } + + async fn fetch_quote_data(&self, _quote: &Quote, _data: FetchQuoteData) -> Result { + todo!("MockSwapper fetch_quote_data not implemented") + } +} diff --git a/crates/swapper/src/uniswap/v3/path.rs b/crates/swapper/src/uniswap/v3/path.rs index a5ea08ef1..3b03e3de1 100644 --- a/crates/swapper/src/uniswap/v3/path.rs +++ b/crates/swapper/src/uniswap/v3/path.rs @@ -43,7 +43,7 @@ pub fn build_paths_with_routes(routes: &[Route]) -> Result return Err(SwapperError::InvalidRoute); } let route_data: RouteData = serde_json::from_str(&routes[0].route_data).map_err(|_| SwapperError::InvalidRoute)?; - let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::InvalidAmount("invalid fee tier".into()))?; + let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::ComputeQuoteError("invalid fee tier".into()))?; let token_pairs: Vec = routes .iter() .map(|route| TokenPair { diff --git a/crates/swapper/src/uniswap/v3/quoter_v2.rs b/crates/swapper/src/uniswap/v3/quoter_v2.rs index 5d1677396..2db00d6c0 100644 --- a/crates/swapper/src/uniswap/v3/quoter_v2.rs +++ b/crates/swapper/src/uniswap/v3/quoter_v2.rs @@ -30,7 +30,7 @@ pub fn build_quoter_request(mode: &SwapperMode, wallet_address: &str, quoter_v2: // Returns (amountOut, gasEstimate) pub fn decode_quoter_response(response: &JsonRpcResponse) -> Result<(U256, U256), SwapperError> { - let decoded = HexDecode(&response.result).map_err(|_| SwapperError::NetworkError("Failed to decode quoter response".into()))?; + let decoded = HexDecode(&response.result).map_err(|_| SwapperError::ComputeQuoteError("Failed to decode quoter response".into()))?; let quoter_return = IQuoterV2::quoteExactInputCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; Ok((quoter_return.amountOut, quoter_return.gasEstimate)) diff --git a/crates/swapper/src/uniswap/v4/path.rs b/crates/swapper/src/uniswap/v4/path.rs index 53931ae79..0707bcc6f 100644 --- a/crates/swapper/src/uniswap/v4/path.rs +++ b/crates/swapper/src/uniswap/v4/path.rs @@ -91,11 +91,15 @@ impl TryFrom<&Route> for PathKey { type Error = SwapperError; fn try_from(value: &Route) -> Result { - let token_id = value.output.token_id.as_ref().ok_or(SwapperError::InvalidAddress(value.output.to_string()))?; + let token_id = value + .output + .token_id + .as_ref() + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("Invalid address {}", value.output)))?; let currency = eth_address::parse_str(token_id)?; let route_data: RouteData = serde_json::from_str(&value.route_data).map_err(|_| SwapperError::InvalidRoute)?; - let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::InvalidAmount("invalid fee tier".into()))?; + let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::ComputeQuoteError("invalid fee tier".into()))?; Ok(PathKey { intermediateCurrency: currency, fee: fee_tier.as_u24(), diff --git a/crates/swapper/src/uniswap/v4/quoter.rs b/crates/swapper/src/uniswap/v4/quoter.rs index 53505f5e4..dca1e59d7 100644 --- a/crates/swapper/src/uniswap/v4/quoter.rs +++ b/crates/swapper/src/uniswap/v4/quoter.rs @@ -43,7 +43,7 @@ pub fn build_quote_exact_request(v4_quoter: &str, params: &IV4Quoter::QuoteExact // Returns (amountOut, gasEstimate) pub fn decode_quoter_response(response: &JsonRpcResponse) -> Result<(U256, U256), SwapperError> { - let decoded = HexDecode(&response.result).map_err(|e| SwapperError::NetworkError(e.to_string()))?; + let decoded = HexDecode(&response.result).map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; let quoter_return = IV4Quoter::quoteExactInputSingleCall::abi_decode_returns(&decoded).map_err(SwapperError::from)?; Ok((quoter_return.amountOut, quoter_return.gasEstimate)) diff --git a/gemstone/src/gem_swapper/error.rs b/gemstone/src/gem_swapper/error.rs index 43a03e773..eb6ddeefd 100644 --- a/gemstone/src/gem_swapper/error.rs +++ b/gemstone/src/gem_swapper/error.rs @@ -4,16 +4,10 @@ pub type SwapperError = swapper::SwapperError; pub enum SwapperError { NotSupportedChain, NotSupportedAsset, - NotSupportedPair, NoAvailableProvider, - InvalidAddress(String), - InvalidAmount(String), InputAmountTooSmall, InvalidRoute, - NetworkError(String), - ABIError(String), ComputeQuoteError(String), TransactionError(String), NoQuoteAvailable, - NotImplemented, } From c801445a7aa868af519a9bb355ef55f26e89c853 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:38:19 +0900 Subject: [PATCH 2/4] Update client.rs --- crates/swapper/src/proxy/client.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/swapper/src/proxy/client.rs b/crates/swapper/src/proxy/client.rs index c8fc6c428..517beeaa0 100644 --- a/crates/swapper/src/proxy/client.rs +++ b/crates/swapper/src/proxy/client.rs @@ -61,10 +61,6 @@ struct VersionQuery { /// Try to cast a proxy error back into a `SwapperError` variant. fn map_proxy_error(error: ProxyError) -> SwapperError { match error { - ProxyError::Object { code } => match code { - SwapperError::ComputeQuoteError(message) => SwapperError::ComputeQuoteError(message), - SwapperError::TransactionError(message) => SwapperError::TransactionError(message), - other => other, - }, + ProxyError::Object { code } => code, } } From 2a9151d999d6e1a1f19d0179e6b6fc743e5e4222 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:50:37 +0900 Subject: [PATCH 3/4] Refactor SwapperError for input amount details Replaced SwapperError::InputAmountTooSmall with InputAmountError containing an optional min_amount field across swappers and tests. This allows more informative error reporting for minimum input amounts required by providers. Updated dependencies to use a fixed typeshare version instead of workspace. --- Cargo.toml | 1 - crates/primitives/Cargo.toml | 2 +- crates/swapper/Cargo.toml | 2 +- crates/swapper/src/across/provider.rs | 2 +- crates/swapper/src/error.rs | 10 ++++++++-- crates/swapper/src/near_intents/provider.rs | 10 ++++++---- crates/swapper/src/swapper.rs | 18 ++++++++++++------ crates/swapper/src/thorchain/provider.rs | 4 +++- gemstone/src/gem_swapper/error.rs | 2 +- 9 files changed, 33 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 786c5012a..539e56df0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,4 +113,3 @@ alloy-primitives = "1.4.1" alloy-sol-types = { version = "1.4.1", features = ["eip712-serde"] } alloy-dyn-abi = { version = "1.4.1", features = ["eip712"] } alloy-json-abi = { version = "1.4.1" } -typeshare = "1.0.4" diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 3ea6cff3a..dae6d97b8 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -8,7 +8,7 @@ default = [] testkit = [] [dependencies] -typeshare = { workspace = true } +typeshare = { version = "1.0.4" } serde = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } diff --git a/crates/swapper/Cargo.toml b/crates/swapper/Cargo.toml index b4a04da52..e06d79017 100644 --- a/crates/swapper/Cargo.toml +++ b/crates/swapper/Cargo.toml @@ -32,7 +32,7 @@ serde_serializers = { path = "../serde_serializers" } number_formatter = { path = "../number_formatter" } reqwest = { workspace = true, optional = true } -typeshare = { workspace = true } +typeshare = { version = "1.0.4" } bcs.workspace = true sui-types = { workspace = true } diff --git a/crates/swapper/src/across/provider.rs b/crates/swapper/src/across/provider.rs index 6f109cf5e..945151d19 100644 --- a/crates/swapper/src/across/provider.rs +++ b/crates/swapper/src/across/provider.rs @@ -426,7 +426,7 @@ impl Swapper for Across { // Check if bridge amount is too small if remain_amount < gas_fee { - return Err(SwapperError::InputAmountTooSmall); + return Err(SwapperError::InputAmountError { min_amount: None }); } let output_amount = remain_amount - gas_fee; diff --git a/crates/swapper/src/error.rs b/crates/swapper/src/error.rs index 8165b9d8b..74d208ef0 100644 --- a/crates/swapper/src/error.rs +++ b/crates/swapper/src/error.rs @@ -12,7 +12,7 @@ pub enum SwapperError { NotSupportedChain, NotSupportedAsset, NoAvailableProvider, - InputAmountTooSmall, + InputAmountError { min_amount: Option }, InvalidRoute, ComputeQuoteError(String), TransactionError(String), @@ -25,7 +25,13 @@ impl std::fmt::Display for SwapperError { Self::NotSupportedAsset => write!(f, "Not supported asset"), Self::NotSupportedChain => write!(f, "Not supported chain"), Self::NoAvailableProvider => write!(f, "No available provider"), - Self::InputAmountTooSmall => write!(f, "Input amount is too small"), + Self::InputAmountError { min_amount } => { + if let Some(min) = min_amount { + write!(f, "Input amount is too small (minimum {min})") + } else { + write!(f, "Input amount is too small") + } + } Self::InvalidRoute => write!(f, "Invalid route or route data"), Self::ComputeQuoteError(msg) => write!(f, "Compute quote error: {}", msg), Self::TransactionError(msg) => write!(f, "Transaction error: {}", msg), diff --git a/crates/swapper/src/near_intents/provider.rs b/crates/swapper/src/near_intents/provider.rs index c960a21ef..99638e7e2 100644 --- a/crates/swapper/src/near_intents/provider.rs +++ b/crates/swapper/src/near_intents/provider.rs @@ -91,7 +91,9 @@ where let amount_u256 = Self::parse_u256(&base_amount, "amount")?; if amount_u256 <= reserved_fee { - return Err(SwapperError::InputAmountTooSmall); + return Err(SwapperError::InputAmountError { + min_amount: Some(reserved_fee.to_string()), + }); } Ok((amount_u256 - reserved_fee).to_string()) @@ -250,7 +252,7 @@ where fn map_quote_error(error: &QuoteResponseError) -> SwapperError { let lower = error.message.to_ascii_lowercase(); if lower.contains("too low") { - SwapperError::InputAmountTooSmall + SwapperError::InputAmountError { min_amount: None } } else { SwapperError::ComputeQuoteError(format!("Near Intents quote error: {}", error.message)) } @@ -432,7 +434,7 @@ mod tests { let err = NearIntents::::resolve_quote_amount(&request, &SwapType::FlexInput).expect_err("expected error"); - assert!(matches!(err, SwapperError::InputAmountTooSmall)); + assert!(matches!(err, SwapperError::InputAmountError { .. })); } #[test] @@ -446,7 +448,7 @@ mod tests { match decoded { QuoteResponseResult::Err(err) => { assert_eq!(err.message, "Amount is too low for bridge, try at least 8516130"); - assert!(matches!(map_quote_error(&err), SwapperError::InputAmountTooSmall)); + assert!(matches!(map_quote_error(&err), SwapperError::InputAmountError { .. })); } QuoteResponseResult::Ok(_) => panic!("expected error variant"), } diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 20678720c..93c43ea64 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -78,8 +78,10 @@ impl GemSwapper { fn prioritized_error(errors: &[SwapperError]) -> Option { for err in errors { - if let SwapperError::InputAmountTooSmall = err { - return Some(SwapperError::InputAmountTooSmall); + if let SwapperError::InputAmountError { min_amount } = err { + return Some(SwapperError::InputAmountError { + min_amount: min_amount.clone(), + }); } } @@ -413,8 +415,12 @@ mod tests { let gem_swapper = GemSwapper { rpc_provider: Arc::new(NativeProvider::default()), swappers: vec![ - Box::new(MockSwapper::new(SwapperProvider::UniswapV3, || Err(SwapperError::InputAmountTooSmall))), - Box::new(MockSwapper::new(SwapperProvider::PancakeswapV3, || Err(SwapperError::InputAmountTooSmall))), + Box::new(MockSwapper::new(SwapperProvider::UniswapV3, || { + Err(SwapperError::InputAmountError { min_amount: None }) + })), + Box::new(MockSwapper::new(SwapperProvider::PancakeswapV3, || { + Err(SwapperError::InputAmountError { min_amount: None }) + })), Box::new(MockSwapper::new(SwapperProvider::Jupiter, || Err(SwapperError::NoQuoteAvailable))), ], }; @@ -422,8 +428,8 @@ mod tests { let result = gem_swapper.fetch_quote(&request).await; match result { - Err(SwapperError::InputAmountTooSmall) => {} - _ => panic!("expected InputAmountTooSmall when every provider rejects the amount"), + Err(SwapperError::InputAmountError(_)) => {} + _ => panic!("expected InputAmountError when every provider rejects the amount"), } } diff --git a/crates/swapper/src/thorchain/provider.rs b/crates/swapper/src/thorchain/provider.rs index d0931bca6..1aa8bc601 100644 --- a/crates/swapper/src/thorchain/provider.rs +++ b/crates/swapper/src/thorchain/provider.rs @@ -73,7 +73,9 @@ where .ok_or(SwapperError::InvalidRoute)?; if from_inbound_address.dust_threshold > value { - return Err(SwapperError::InputAmountTooSmall); + return Err(SwapperError::InputAmountError { + min_amount: Some(from_inbound_address.dust_threshold.to_string()), + }); } } diff --git a/gemstone/src/gem_swapper/error.rs b/gemstone/src/gem_swapper/error.rs index eb6ddeefd..f022fad22 100644 --- a/gemstone/src/gem_swapper/error.rs +++ b/gemstone/src/gem_swapper/error.rs @@ -5,7 +5,7 @@ pub enum SwapperError { NotSupportedChain, NotSupportedAsset, NoAvailableProvider, - InputAmountTooSmall, + InputAmountError { min_amount: Option }, InvalidRoute, ComputeQuoteError(String), TransactionError(String), From ff6eecee4c977ee745d970b9dea08a87164d1ded Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sat, 13 Dec 2025 19:34:13 +0900 Subject: [PATCH 4/4] fix build error --- crates/swapper/src/swapper.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 93c43ea64..e59c10f52 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -428,7 +428,7 @@ mod tests { let result = gem_swapper.fetch_quote(&request).await; match result { - Err(SwapperError::InputAmountError(_)) => {} + Err(SwapperError::InputAmountError { min_amount: _ }) => {} _ => panic!("expected InputAmountError when every provider rejects the amount"), } }