diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index d20e161f4..d046af4c5 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -19,6 +19,8 @@ pub mod models; pub mod near_intents; pub mod permit2_data; pub mod proxy; +pub mod referrer; +pub mod relay; pub mod slippage; pub mod swapper; pub mod thorchain; diff --git a/crates/swapper/src/models.rs b/crates/swapper/src/models.rs index 61467b864..8b2d1caee 100644 --- a/crates/swapper/src/models.rs +++ b/crates/swapper/src/models.rs @@ -46,7 +46,7 @@ impl ProviderType { | SwapperProvider::Orca => SwapProviderMode::OnChain, SwapperProvider::Mayan | SwapperProvider::Chainflip | SwapperProvider::NearIntents => SwapProviderMode::CrossChain, SwapperProvider::Thorchain => SwapProviderMode::OmniChain(vec![Chain::Thorchain, Chain::Tron]), - SwapperProvider::Relay => SwapProviderMode::OmniChain(vec![Chain::Hyperliquid, Chain::Manta, Chain::Berachain]), + SwapperProvider::Relay => SwapProviderMode::OmniChain(vec![Chain::Bitcoin, Chain::Solana, Chain::Hyperliquid, Chain::Berachain]), SwapperProvider::Across => SwapProviderMode::Bridge, SwapperProvider::Hyperliquid => SwapProviderMode::OmniChain(vec![Chain::HyperCore, Chain::Hyperliquid]), } diff --git a/crates/swapper/src/near_intents/model.rs b/crates/swapper/src/near_intents/model.rs index b76723eff..28f419f3c 100644 --- a/crates/swapper/src/near_intents/model.rs +++ b/crates/swapper/src/near_intents/model.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; -pub const DEFAULT_REFERRAL: &str = "gemwallet"; pub const DEPOSIT_TYPE_ORIGIN: &str = "ORIGIN_CHAIN"; pub const RECIPIENT_TYPE_DESTINATION: &str = "DESTINATION_CHAIN"; pub const DEFAULT_WAIT_TIME_MS: u32 = 1_024; @@ -18,29 +17,19 @@ pub struct QuoteRequest { pub origin_asset: String, pub destination_asset: String, pub amount: String, - #[serde(default = "default_referral")] pub referral: String, pub recipient: String, pub swap_type: SwapType, - #[serde(default = "default_slippage_tolerance")] pub slippage_tolerance: u32, #[serde(skip_serializing_if = "Option::is_none")] pub app_fees: Option>, - #[serde(default = "default_deposit_type")] pub deposit_type: String, - #[serde(default)] pub refund_to: String, - #[serde(default = "default_refund_type")] pub refund_type: String, - #[serde(default = "default_recipient_type")] pub recipient_type: String, - #[serde(default)] pub deadline: String, - #[serde(default = "default_quote_waiting_time_ms")] - pub quote_waiting_time_ms: u32, - #[serde(default)] + pub quote_waiting_time_ms: Option, pub dry: bool, - #[serde(default)] pub deposit_mode: DepositMode, } @@ -83,7 +72,6 @@ pub enum QuoteResponseResult { pub struct Quote { pub deposit_address: Option, pub deposit_memo: Option, - #[serde(default)] pub deposit_mode: Option, pub amount_in: String, pub amount_in_formatted: String, @@ -99,27 +87,19 @@ pub struct Quote { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecutionStatus { - #[serde(default)] pub quote_response: Option, pub status: String, - #[serde(default)] pub updated_at: String, - #[serde(default)] pub swap_details: Option, } #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct SwapDetails { - #[serde(default)] pub amount_in: Option, - #[serde(default)] pub amount_out: Option, - #[serde(default)] pub origin_chain_tx_hashes: Vec, - #[serde(default)] pub destination_chain_tx_hashes: Vec, - #[serde(default)] pub refunded_amount: Option, } @@ -127,30 +107,5 @@ pub struct SwapDetails { #[serde(rename_all = "camelCase")] pub struct TransactionDetails { pub hash: String, - #[serde(default)] pub explorer_url: Option, } - -fn default_referral() -> String { - DEFAULT_REFERRAL.to_string() -} - -fn default_deposit_type() -> String { - DEPOSIT_TYPE_ORIGIN.to_string() -} - -fn default_refund_type() -> String { - DEPOSIT_TYPE_ORIGIN.to_string() -} - -fn default_recipient_type() -> String { - RECIPIENT_TYPE_DESTINATION.to_string() -} - -fn default_slippage_tolerance() -> u32 { - 0 -} - -fn default_quote_waiting_time_ms() -> u32 { - DEFAULT_WAIT_TIME_MS -} diff --git a/crates/swapper/src/near_intents/provider.rs b/crates/swapper/src/near_intents/provider.rs index e090fd362..abbdf8dcc 100644 --- a/crates/swapper/src/near_intents/provider.rs +++ b/crates/swapper/src/near_intents/provider.rs @@ -1,12 +1,12 @@ use super::{ AppFee, DepositMode, ExecutionStatus, NearIntentsClient, QuoteRequest as NearQuoteRequest, QuoteResponse, QuoteResponseError, QuoteResponseResult, SwapType, asset_id_from_near_intents, auto_quote_time_chains, deposit_memo_chains, get_near_intents_asset_id, - model::{DEFAULT_REFERRAL, DEFAULT_WAIT_TIME_MS, DEPOSIT_TYPE_ORIGIN, RECIPIENT_TYPE_DESTINATION}, + model::{DEFAULT_WAIT_TIME_MS, DEPOSIT_TYPE_ORIGIN, RECIPIENT_TYPE_DESTINATION}, reserved_tx_fees, supported_assets, }; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperMode, - SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, client_factory::create_client_with_chain, amount_to_value, near_intents::client::base_url, + SwapperProvider, SwapperQuoteAsset, SwapperQuoteData, client_factory::create_client_with_chain, amount_to_value, near_intents::client::base_url, referrer::DEFAULT_REFERRER, }; use alloy_primitives::U256; use async_trait::async_trait; @@ -126,7 +126,7 @@ where let deposit_mode = Self::resolve_deposit_mode(&request.from_asset); let from_chain = request.from_asset.asset_id().chain; let to_chain = request.to_asset.asset_id().chain; - let quote_waiting_time_ms = Self::resolve_quote_waiting_time(from_chain, to_chain); + let quote_waiting_time_ms = Some(Self::resolve_quote_waiting_time(from_chain, to_chain)); let deadline = (Utc::now() + Duration::minutes(DEFAULT_DEADLINE_MINUTES)).to_rfc3339(); @@ -134,7 +134,7 @@ where origin_asset, destination_asset, amount, - referral: DEFAULT_REFERRAL.to_string(), + referral: DEFAULT_REFERRER.to_string(), recipient: request.destination_address.clone(), swap_type: mode, slippage_tolerance: request.options.slippage.bps, @@ -470,7 +470,11 @@ mod tests { mod swap_integration_tests { use super::*; use crate::{FetchQuoteData, SwapperMode, SwapperQuoteAsset, SwapperSlippage, SwapperSlippageMode, alien::reqwest_provider::NativeProvider, models::Options}; - use primitives::{AssetId, Chain, swap::SwapStatus}; + use primitives::{ + AssetId, Chain, + asset_constants::{USDC_ARB_ASSET_ID, USDC_BASE_ASSET_ID}, + swap::SwapStatus, + }; use std::sync::Arc; #[tokio::test] @@ -492,10 +496,10 @@ mod swap_integration_tests { }; let request = QuoteRequest { - from_asset: SwapperQuoteAsset::from(AssetId::new("arbitrum_0xaf88d065e77c8cc2239327c5edb3a432268e5831").unwrap()), - to_asset: SwapperQuoteAsset::from(AssetId::new("solana_epjfwdd5aufqssqem2qn1xzybapc8g4weggkzwytdt1v").unwrap()), - wallet_address: "0x2527D02599Ba641c19FEa793cD0F167589a0f10D".to_string(), - destination_address: "13QkxhNMrTPxoCkRdYdJ65tFuwXPhL5gLS2Z5Nr6gjRK".to_string(), + from_asset: SwapperQuoteAsset::from(AssetId::new(USDC_ARB_ASSET_ID).unwrap()), + to_asset: SwapperQuoteAsset::from(AssetId::new(USDC_BASE_ASSET_ID).unwrap()), + wallet_address: "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7".to_string(), + destination_address: "0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7".to_string(), value: "500000".to_string(), mode: SwapperMode::ExactIn, options, diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index 8da3e5f3f..bf84d4baf 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -153,19 +153,6 @@ impl ProxyProvider { Self::new_with_path(SwapperProvider::Mayan, "mayan", assets, rpc_provider) } - - pub fn new_relay(rpc_provider: Arc) -> Self { - Self::new_with_path( - SwapperProvider::Relay, - "relay", - vec![ - SwapperChainAsset::All(Chain::Hyperliquid), - SwapperChainAsset::All(Chain::Manta), - SwapperChainAsset::All(Chain::Berachain), - ], - rpc_provider, - ) - } } #[async_trait] diff --git a/crates/swapper/src/proxy/provider_factory.rs b/crates/swapper/src/proxy/provider_factory.rs index 73ca3ade0..ba2bd20c8 100644 --- a/crates/swapper/src/proxy/provider_factory.rs +++ b/crates/swapper/src/proxy/provider_factory.rs @@ -22,7 +22,3 @@ pub fn new_panora(rpc_provider: Arc) -> ProxyProvider) -> ProxyProvider { ProxyProvider::new_mayan(rpc_provider) } - -pub fn new_relay(rpc_provider: Arc) -> ProxyProvider { - ProxyProvider::new_relay(rpc_provider) -} diff --git a/crates/swapper/src/referrer.rs b/crates/swapper/src/referrer.rs new file mode 100644 index 000000000..c71f7ab5d --- /dev/null +++ b/crates/swapper/src/referrer.rs @@ -0,0 +1 @@ +pub const DEFAULT_REFERRER: &str = "gemwallet"; diff --git a/crates/swapper/src/relay/asset.rs b/crates/swapper/src/relay/asset.rs new file mode 100644 index 000000000..37b2b05c0 --- /dev/null +++ b/crates/swapper/src/relay/asset.rs @@ -0,0 +1,114 @@ +use std::sync::LazyLock; + +use gem_solana::WSOL_TOKEN_ADDRESS; +use primitives::{ + AssetId, Chain, + asset_constants::{USDC_ARB_ASSET_ID, USDC_HYPEREVM_ASSET_ID, USDT_ARB_ASSET_ID, USDT_HYPEREVM_ASSET_ID}, +}; + +use super::chain::{BITCOIN_CURRENCY, RelayChain}; +use crate::{SwapperChainAsset, SwapperError, asset::*}; + +pub const EVM_ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; + +pub static SUPPORTED_CHAINS: LazyLock> = LazyLock::new(|| { + vec![ + SwapperChainAsset::Assets(Chain::Bitcoin, vec![AssetId::from_chain(Chain::Bitcoin)]), + SwapperChainAsset::Assets( + Chain::Ethereum, + vec![ + AssetId::from_chain(Chain::Ethereum), + AssetId::from_token(Chain::Ethereum, ETHEREUM_USDC_TOKEN_ID), + AssetId::from_token(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID), + ], + ), + SwapperChainAsset::Assets( + Chain::Solana, + vec![ + AssetId::from_chain(Chain::Solana), + AssetId::from_token(Chain::Solana, SOLANA_USDC_TOKEN_ID), + AssetId::from_token(Chain::Solana, SOLANA_USDT_TOKEN_ID), + ], + ), + SwapperChainAsset::Assets( + Chain::SmartChain, + vec![ + AssetId::from_chain(Chain::SmartChain), + AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDC_TOKEN_ID), + AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDT_TOKEN_ID), + ], + ), + SwapperChainAsset::Assets(Chain::Base, vec![AssetId::from_chain(Chain::Base), AssetId::from_token(Chain::Base, BASE_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets( + Chain::Arbitrum, + vec![AssetId::from_chain(Chain::Arbitrum), USDC_ARB_ASSET_ID.into(), USDT_ARB_ASSET_ID.into()], + ), + SwapperChainAsset::Assets( + Chain::Hyperliquid, + vec![AssetId::from_chain(Chain::Hyperliquid), USDC_HYPEREVM_ASSET_ID.into(), USDT_HYPEREVM_ASSET_ID.into()], + ), + SwapperChainAsset::Assets(Chain::Berachain, vec![AssetId::from_chain(Chain::Berachain)]), + SwapperChainAsset::Assets(Chain::Manta, vec![AssetId::from_chain(Chain::Manta)]), + ] +}); + +pub fn map_asset_to_relay_currency(asset_id: &AssetId, relay_chain: &RelayChain) -> Result { + match relay_chain { + RelayChain::Bitcoin => Ok(BITCOIN_CURRENCY.to_string()), + RelayChain::Solana => { + if asset_id.is_native() { + Ok(WSOL_TOKEN_ADDRESS.to_string()) + } else { + asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + } + } + _ if relay_chain.is_evm() => { + if asset_id.is_native() { + Ok(EVM_ZERO_ADDRESS.to_string()) + } else { + asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + } + } + _ => Err(SwapperError::NotSupportedChain), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_evm_native_asset() { + let asset_id = AssetId::from_chain(Chain::Ethereum); + let relay_chain = RelayChain::Ethereum; + let result = map_asset_to_relay_currency(&asset_id, &relay_chain).unwrap(); + assert_eq!(result, EVM_ZERO_ADDRESS); + } + + #[test] + fn test_evm_token_asset() { + let token_address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + let asset_id = AssetId::from_token(Chain::Ethereum, token_address); + let relay_chain = RelayChain::Ethereum; + let result = map_asset_to_relay_currency(&asset_id, &relay_chain).unwrap(); + assert_eq!(result, token_address); + } + + #[test] + fn test_solana_native_asset() { + let asset_id = AssetId::from_chain(Chain::Solana); + let relay_chain = RelayChain::Solana; + let result = map_asset_to_relay_currency(&asset_id, &relay_chain).unwrap(); + assert_eq!(result, WSOL_TOKEN_ADDRESS); + } + + #[test] + fn test_solana_token_asset() { + let mint_address = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + let asset_id = AssetId::from_token(Chain::Solana, mint_address); + let relay_chain = RelayChain::Solana; + let result = map_asset_to_relay_currency(&asset_id, &relay_chain).unwrap(); + assert_eq!(result, mint_address); + } +} diff --git a/crates/swapper/src/relay/chain.rs b/crates/swapper/src/relay/chain.rs new file mode 100644 index 000000000..5615b663d --- /dev/null +++ b/crates/swapper/src/relay/chain.rs @@ -0,0 +1,83 @@ +use primitives::Chain; + +pub const BITCOIN_CHAIN_ID: u64 = 8253038; +pub const BITCOIN_CURRENCY: &str = "bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayChain { + Bitcoin, + Ethereum, + Solana, + SmartChain, + Base, + Arbitrum, + Hyperliquid, + Berachain, + Manta, +} + +impl RelayChain { + pub fn chain_id(&self) -> u64 { + match self { + RelayChain::Bitcoin => BITCOIN_CHAIN_ID, + RelayChain::Ethereum => 1, + RelayChain::Solana => 792703809, + RelayChain::SmartChain => 56, + RelayChain::Base => 8453, + RelayChain::Arbitrum => 42161, + RelayChain::Hyperliquid => 999, + RelayChain::Berachain => 80094, + RelayChain::Manta => 169, + } + } + + pub fn from_chain(chain: &Chain) -> Option { + match chain { + Chain::Bitcoin => Some(RelayChain::Bitcoin), + Chain::Ethereum => Some(RelayChain::Ethereum), + Chain::Solana => Some(RelayChain::Solana), + Chain::SmartChain => Some(RelayChain::SmartChain), + Chain::Base => Some(RelayChain::Base), + Chain::Arbitrum => Some(RelayChain::Arbitrum), + Chain::Hyperliquid => Some(RelayChain::Hyperliquid), + Chain::Berachain => Some(RelayChain::Berachain), + Chain::Manta => Some(RelayChain::Manta), + _ => None, + } + } + + pub fn chain_from_id(chain_id: u64) -> Option { + match chain_id { + BITCOIN_CHAIN_ID => Some(Chain::Bitcoin), + 1 => Some(Chain::Ethereum), + 56 => Some(Chain::SmartChain), + 8453 => Some(Chain::Base), + 42161 => Some(Chain::Arbitrum), + 999 => Some(Chain::Hyperliquid), + 80094 => Some(Chain::Berachain), + 792703809 => Some(Chain::Solana), + 169 => Some(Chain::Manta), + _ => None, + } + } + + pub fn is_evm(&self) -> bool { + match self { + RelayChain::Bitcoin | RelayChain::Solana => false, + RelayChain::Ethereum | RelayChain::SmartChain | RelayChain::Base | RelayChain::Arbitrum | RelayChain::Hyperliquid | RelayChain::Berachain | RelayChain::Manta => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_chain() { + assert_eq!(RelayChain::from_chain(&Chain::Ethereum), Some(RelayChain::Ethereum)); + assert_eq!(RelayChain::from_chain(&Chain::Solana), Some(RelayChain::Solana)); + assert_eq!(RelayChain::from_chain(&Chain::SmartChain), Some(RelayChain::SmartChain)); + assert_eq!(RelayChain::from_chain(&Chain::Cosmos), None); + } +} diff --git a/crates/swapper/src/relay/client.rs b/crates/swapper/src/relay/client.rs new file mode 100644 index 000000000..091718842 --- /dev/null +++ b/crates/swapper/src/relay/client.rs @@ -0,0 +1,33 @@ +use std::{collections::HashMap, fmt::Debug}; + +use gem_client::{CONTENT_TYPE, Client}; + +use super::model::{RelayQuoteRequest, RelayQuoteResponse, RelayStatusResponse}; +use crate::SwapperError; + +#[derive(Clone, Debug)] +pub struct RelayClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: C, +} + +impl RelayClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_quote(&self, request: RelayQuoteRequest) -> Result { + let headers = HashMap::from([(CONTENT_TYPE.to_string(), "application/json".into())]); + self.client.post("/quote/v2", &request, Some(headers)).await.map_err(SwapperError::from) + } + + pub async fn get_swap_status(&self, request_id: &str) -> Result { + let path = format!("/intents/status?requestId={}", request_id); + self.client.get(&path).await.map_err(SwapperError::from) + } +} diff --git a/crates/swapper/src/relay/mod.rs b/crates/swapper/src/relay/mod.rs new file mode 100644 index 000000000..8e9ad17d8 --- /dev/null +++ b/crates/swapper/src/relay/mod.rs @@ -0,0 +1,39 @@ +mod asset; +mod chain; +mod client; +mod model; +mod provider; +mod quote_data_mapper; + +use std::sync::Arc; + +use crate::alien::RpcProvider; +use gem_client::Client; + +use super::{ProviderType, SwapperProvider}; + +const RELAY_API_URL: &str = "https://api.relay.link"; +const DEFAULT_GAS_LIMIT: u64 = 750_000; + +#[derive(Debug)] +pub struct Relay +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + pub provider: ProviderType, + pub rpc_provider: Arc, + pub(crate) client: client::RelayClient, +} + +impl Relay +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + pub fn with_client(client: client::RelayClient, rpc_provider: Arc) -> Self { + Self { + provider: ProviderType::new(SwapperProvider::Relay), + rpc_provider, + client, + } + } +} diff --git a/crates/swapper/src/relay/model.rs b/crates/swapper/src/relay/model.rs new file mode 100644 index 000000000..35f727b31 --- /dev/null +++ b/crates/swapper/src/relay/model.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; + +use primitives::swap::SwapStatus; +use serde_serializers::deserialize_string_from_value; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayQuoteRequest { + pub user: String, + pub origin_chain_id: u64, + pub destination_chain_id: u64, + pub origin_currency: String, + pub destination_currency: String, + pub amount: String, + pub recipient: String, + pub trade_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refund_to: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayQuoteResponse { + pub steps: Vec, + pub details: QuoteDetails, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Step { + pub id: String, + pub kind: String, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StepItem { + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StepData { + #[serde(default)] + pub to: String, + #[serde(default)] + pub data: String, + #[serde(default, deserialize_with = "deserialize_string_from_value")] + pub value: String, + #[serde(default)] + pub instructions: Option, + #[serde(default)] + pub psbt: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteDetails { + pub currency_out: CurrencyAmount, + #[serde(default)] + pub time_estimate: Option, +} + +impl QuoteDetails { + pub fn time_estimate_u32(&self) -> Option { + let value = self.time_estimate?; + if !value.is_finite() || value < 0.0 || value > u32::MAX as f64 { + return None; + } + Some(value.ceil() as u32) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrencyAmount { + pub amount: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RelayStatus { + Pending, + Waiting, + Success, + Completed, + Failed, + Refunded, + #[serde(other)] + Unknown, +} + +impl RelayStatus { + pub fn into_swap_status(self) -> SwapStatus { + match self { + RelayStatus::Pending | RelayStatus::Waiting | RelayStatus::Unknown => SwapStatus::Pending, + RelayStatus::Success | RelayStatus::Completed => SwapStatus::Completed, + RelayStatus::Failed => SwapStatus::Failed, + RelayStatus::Refunded => SwapStatus::Refunded, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayStatusResponse { + pub status: RelayStatus, + pub out_tx_hashes: Option>, + pub destination_chain_id: Option, +} diff --git a/crates/swapper/src/relay/provider.rs b/crates/swapper/src/relay/provider.rs new file mode 100644 index 000000000..96284eb74 --- /dev/null +++ b/crates/swapper/src/relay/provider.rs @@ -0,0 +1,418 @@ +use std::sync::Arc; + +use alloy_primitives::U256; +use async_trait::async_trait; +use gem_client::Client; +use primitives::{Chain, ChainType}; + +use super::{ + RELAY_API_URL, Relay, + asset::{SUPPORTED_CHAINS, map_asset_to_relay_currency}, + chain::RelayChain, + client::RelayClient, + model::{RelayQuoteRequest, RelayQuoteResponse}, + quote_data_mapper, +}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperQuoteData, + approval::check_approval_erc20, config::ReferralFee, referrer::DEFAULT_REFERRER, +}; + +fn resolve_referral_fee(request: &QuoteRequest, to_chain: RelayChain) -> Option<&ReferralFee> { + let fees = request.options.fee.as_ref()?; + let fee = match to_chain { + RelayChain::Bitcoin => return None, + RelayChain::Solana => &fees.solana, + _ if to_chain.is_evm() => &fees.evm, + _ => return None, + }; + + if fee.address.is_empty() || fee.bps == 0 { + return None; + } + + Some(fee) +} + +fn resolve_referrer_data(request: &QuoteRequest, to_chain: RelayChain) -> (Option, Option) { + let fee = resolve_referral_fee(request, to_chain); + let referrer_address = fee.map(|fee| fee.address.clone()); + let referrer = referrer_address.as_ref().map(|_| DEFAULT_REFERRER.to_string()); + + (referrer, referrer_address) +} + +impl Relay { + pub fn new(rpc_provider: Arc) -> Self { + let client = RelayClient::new(RpcClient::new(RELAY_API_URL.to_string(), rpc_provider.clone())); + Self::with_client(client, rpc_provider) + } +} + +#[async_trait] +impl Swapper for Relay +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + SUPPORTED_CHAINS.clone() + } + + async fn fetch_quote(&self, request: &QuoteRequest) -> Result { + let from_chain = RelayChain::from_chain(&request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let to_chain = RelayChain::from_chain(&request.to_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + + let from_asset_id = request.from_asset.asset_id(); + let to_asset_id = request.to_asset.asset_id(); + + let origin_currency = map_asset_to_relay_currency(&from_asset_id, &from_chain)?; + let destination_currency = map_asset_to_relay_currency(&to_asset_id, &to_chain)?; + let (referrer, referrer_address) = resolve_referrer_data(request, to_chain); + + let relay_request = RelayQuoteRequest { + user: request.wallet_address.clone(), + origin_chain_id: from_chain.chain_id(), + destination_chain_id: to_chain.chain_id(), + origin_currency, + destination_currency, + amount: request.value.clone(), + recipient: request.destination_address.clone(), + trade_type: "EXACT_INPUT".to_string(), + referrer, + referrer_address, + refund_to: Some(request.wallet_address.clone()), + }; + + let quote_response = self.client.get_quote(relay_request).await?; + + let to_value = quote_response.details.currency_out.amount.clone(); + let eta_in_seconds = quote_response.details.time_estimate_u32(); + + let quote = Quote { + from_value: request.value.clone(), + to_value, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: from_asset_id, + output: to_asset_id, + route_data: serde_json::to_string("e_response).unwrap_or_default(), + gas_limit: None, + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds, + }; + + Ok(quote) + } + + async fn fetch_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let quote_response: RelayQuoteResponse = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + + let from_chain = RelayChain::from_chain("e.request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; + let from_asset_id = quote.request.from_asset.asset_id(); + + let approval = match from_asset_id.chain.chain_type() { + ChainType::Ethereum if !from_asset_id.is_native() => { + let router_address = quote_response + .steps + .iter() + .find_map(|s| s.items.first().and_then(|item| item.data.as_ref().map(|d| d.to.clone()))) + .ok_or(SwapperError::InvalidRoute)?; + + let token = from_asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset)?; + let amount: U256 = quote.from_value.parse().map_err(SwapperError::from)?; + + check_approval_erc20( + quote.request.wallet_address.clone(), + token, + router_address, + amount, + self.rpc_provider.clone(), + &from_asset_id.chain, + ) + .await? + .approval_data() + } + _ => None, + }; + + quote_data_mapper::map_quote_data(&from_chain, "e_response.steps, "e.from_value, approval) + } + + async fn get_swap_result(&self, chain: Chain, transaction_hash: &str) -> Result { + let status = self.client.get_swap_status(transaction_hash).await?; + let to_chain = status.destination_chain_id.and_then(RelayChain::chain_from_id); + let to_tx_hash = status.out_tx_hashes.and_then(|hashes| hashes.first().cloned()); + + Ok(SwapResult { + status: status.status.into_swap_status(), + from_chain: chain, + from_tx_hash: transaction_hash.to_string(), + to_chain, + to_tx_hash, + }) + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{SwapperMode, SwapperQuoteAsset, alien::reqwest_provider::NativeProvider, asset::SMARTCHAIN_USDT_TOKEN_ID, models::Options}; + use primitives::AssetId; + + #[tokio::test] + async fn test_relay_quote_eth_to_base() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Base)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "10000000000000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(!quote.data.routes.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_base_to_arbitrum() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Base)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Arbitrum)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "10000000000000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_bnb_usdt_to_sol() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::SmartChain, SMARTCHAIN_USDT_TOKEN_ID)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Solana)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + value: "10000000000000000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + + println!("Relay BNB USDT -> SOL quote: from={}, to={}", quote.from_value, quote.to_value); + assert_eq!(quote.from_value, request.value); + assert!(quote.to_value.parse::().unwrap() > 0); + assert!(!quote.data.routes.is_empty()); + assert!(quote.eta_in_seconds.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_data_eth_to_base() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Base)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "10000000000000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + let quote_data = relay.fetch_quote_data("e, FetchQuoteData::None).await?; + + assert!(!quote_data.to.is_empty()); + assert!(!quote_data.data.is_empty()); + assert!(quote_data.approval.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_data_sol_to_eth() -> Result<(), Box> { + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Solana)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + wallet_address: "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "100000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + let quote_data = relay.fetch_quote_data("e, FetchQuoteData::None).await?; + + assert!(!quote_data.data.is_empty()); + assert!(quote_data.approval.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_eth_usdt_to_btc() -> Result<(), Box> { + use crate::asset::ETHEREUM_USDT_TOKEN_ID; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu".to_string(), + value: "10000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + + println!("Relay ETH USDT -> BTC quote: from={}, to={}", quote.from_value, quote.to_value); + assert_eq!(quote.from_value, request.value); + assert!(!quote.to_value.is_empty()); + assert!(!quote.data.routes.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_data_eth_usdt_to_btc() -> Result<(), Box> { + use crate::asset::ETHEREUM_USDT_TOKEN_ID; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, ETHEREUM_USDT_TOKEN_ID)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + destination_address: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu".to_string(), + value: "10000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + let quote_data = relay.fetch_quote_data("e, FetchQuoteData::None).await?; + + println!("Relay ETH USDT -> BTC quote_data: to={}, value={}", quote_data.to, quote_data.value); + assert!(!quote_data.to.is_empty()); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_btc_to_eth_usdc() -> Result<(), Box> { + use crate::asset::ETHEREUM_USDC_TOKEN_ID; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, ETHEREUM_USDC_TOKEN_ID)), + wallet_address: "bc1q4vxn43l44h30nkluqfxd9eckf45vr2awz38lwa".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "2000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + + println!("Relay BTC -> ETH USDC quote: from={}, to={}", quote.from_value, quote.to_value); + assert_eq!(quote.from_value, request.value); + assert!(!quote.to_value.is_empty()); + assert!(!quote.data.routes.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_relay_quote_data_btc_to_eth_usdc() -> Result<(), Box> { + use crate::asset::ETHEREUM_USDC_TOKEN_ID; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let options = Options::new_with_slippage(100.into()); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, ETHEREUM_USDC_TOKEN_ID)), + wallet_address: "bc1q4vxn43l44h30nkluqfxd9eckf45vr2awz38lwa".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "2000000".to_string(), + mode: SwapperMode::ExactIn, + options, + }; + + let quote = relay.fetch_quote(&request).await?; + let quote_data = relay.fetch_quote_data("e, FetchQuoteData::None).await?; + + println!("Relay BTC -> ETH USDC quote_data: value={}, data_len={}", quote_data.value, quote_data.data.len()); + assert!(!quote_data.data.is_empty()); + assert!(quote_data.data.starts_with("70736274")); // PSBT magic bytes in hex + + Ok(()) + } +} diff --git a/crates/swapper/src/relay/quote_data_mapper.rs b/crates/swapper/src/relay/quote_data_mapper.rs new file mode 100644 index 000000000..e2938e281 --- /dev/null +++ b/crates/swapper/src/relay/quote_data_mapper.rs @@ -0,0 +1,137 @@ +use primitives::swap::ApprovalData; + +use super::{ + DEFAULT_GAS_LIMIT, + chain::RelayChain, + model::{Step, StepData}, +}; +use crate::{SwapperError, SwapperQuoteData}; + +fn get_step_data(steps: &[Step]) -> Result<&StepData, SwapperError> { + let tx_step = steps + .iter() + .find(|s| s.id == "swap" || s.id == "deposit" || s.kind == "transaction") + .or_else(|| steps.iter().find(|s| !s.items.is_empty())) + .ok_or(SwapperError::InvalidRoute)?; + tx_step.items.first().and_then(|item| item.data.as_ref()).ok_or(SwapperError::InvalidRoute) +} + +pub fn map_quote_data(chain: &RelayChain, steps: &[Step], value: &str, approval: Option) -> Result { + let step_data = get_step_data(steps)?; + + let (to, tx_value, data, gas_limit) = match chain { + RelayChain::Bitcoin => { + let psbt = step_data.psbt.as_ref().ok_or(SwapperError::InvalidRoute)?; + (String::new(), value.to_string(), psbt.clone(), None) + } + RelayChain::Solana => { + let data = step_data + .instructions + .as_ref() + .map(|i| serde_json::to_string(i).unwrap_or_default()) + .unwrap_or_else(|| step_data.data.clone()); + (step_data.to.clone(), step_data.value.clone(), data, None) + } + _ if chain.is_evm() => { + let gas_limit = approval.as_ref().map(|_| DEFAULT_GAS_LIMIT.to_string()); + (step_data.to.clone(), step_data.value.clone(), step_data.data.clone(), gas_limit) + } + _ => return Err(SwapperError::NotSupportedChain), + }; + + Ok(SwapperQuoteData::new_contract(to, tx_value, data, approval, gas_limit)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay::model::{StepData, StepItem}; + + fn create_transaction_step(to: &str, value: &str, data: &str) -> Step { + Step { + id: "swap".to_string(), + kind: "transaction".to_string(), + items: vec![StepItem { + data: Some(StepData { + to: to.to_string(), + data: data.to_string(), + value: value.to_string(), + instructions: None, + psbt: None, + }), + }], + } + } + + fn create_bitcoin_step(psbt: &str) -> Step { + Step { + id: "deposit".to_string(), + kind: "transaction".to_string(), + items: vec![StepItem { + data: Some(StepData { + to: String::new(), + data: String::new(), + value: String::new(), + instructions: None, + psbt: Some(psbt.to_string()), + }), + }], + } + } + + #[test] + fn test_map_evm_quote_data() { + let steps = vec![create_transaction_step("0xrouter", "1000000000000000000", "0xabcdef")]; + + let result = map_quote_data(&RelayChain::Ethereum, &steps, "1000000000000000000", None).unwrap(); + + assert_eq!(result.to, "0xrouter"); + assert_eq!(result.value, "1000000000000000000"); + assert_eq!(result.data, "0xabcdef"); + assert!(result.approval.is_none()); + assert!(result.gas_limit.is_none()); + } + + #[test] + fn test_map_evm_quote_data_with_approval() { + let steps = vec![create_transaction_step("0xrouter", "0", "0xabcdef")]; + let approval = ApprovalData { + token: "0xtoken".to_string(), + spender: "0xrouter".to_string(), + value: "1000".to_string(), + }; + + let result = map_quote_data(&RelayChain::Ethereum, &steps, "1000000000000000000", Some(approval.clone())).unwrap(); + + assert_eq!(result.to, "0xrouter"); + assert_eq!(result.approval, Some(approval)); + assert_eq!(result.gas_limit, Some(DEFAULT_GAS_LIMIT.to_string())); + } + + #[test] + fn test_map_solana_quote_data() { + let steps = vec![create_transaction_step("SolanaProgramAddress", "0", "base64txdata")]; + + let result = map_quote_data(&RelayChain::Solana, &steps, "1000000000", None).unwrap(); + + assert_eq!(result.to, "SolanaProgramAddress"); + assert_eq!(result.value, "0"); + assert_eq!(result.data, "base64txdata"); + assert!(result.approval.is_none()); + assert!(result.gas_limit.is_none()); + } + + #[test] + fn test_map_bitcoin_quote_data() { + let psbt = "70736274ff0100abcdef"; + let steps = vec![create_bitcoin_step(psbt)]; + + let result = map_quote_data(&RelayChain::Bitcoin, &steps, "2000000", None).unwrap(); + + assert_eq!(result.to, ""); + assert_eq!(result.value, "2000000"); + assert_eq!(result.data, psbt); + assert!(result.approval.is_none()); + assert!(result.gas_limit.is_none()); + } +} diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 42b7477fb..8dfe28e47 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -1,7 +1,7 @@ use crate::{ AssetList, FetchQuoteData, Permit2ApprovalData, ProviderType, Quote, QuoteRequest, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperProviderMode, - SwapperQuoteData, across, alien::RpcProvider, chainflip, config::DEFAULT_STABLE_SWAP_REFERRAL_BPS, hyperliquid, jupiter, near_intents, proxy::provider_factory, thorchain, - uniswap, + SwapperQuoteData, across, alien::RpcProvider, chainflip, config::DEFAULT_STABLE_SWAP_REFERRAL_BPS, hyperliquid, jupiter, near_intents, proxy::provider_factory, relay, + thorchain, uniswap, }; use num_traits::ToPrimitive; use primitives::{AssetId, Chain, EVMChain}; @@ -109,7 +109,7 @@ impl GemSwapper { Box::new(near_intents::NearIntents::new(rpc_provider.clone())), Box::new(chainflip::ChainflipProvider::new(rpc_provider.clone())), Box::new(provider_factory::new_cetus_aggregator(rpc_provider.clone())), - Box::new(provider_factory::new_relay(rpc_provider.clone())), + Box::new(relay::Relay::new(rpc_provider.clone())), Box::new(provider_factory::new_orca(rpc_provider.clone())), uniswap::default::boxed_aerodrome(rpc_provider.clone()), ];