diff --git a/Cargo.lock b/Cargo.lock index 24dc2199..50d5a180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3346,6 +3346,7 @@ dependencies = [ "num-traits", "orca_whirlpools_core", "primitives", + "rand", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index ad3bee9c..9a511532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ lazy_static = "1.4.0" futures-util = "0.3.30" uuid = { version = "1.8.0", features = ["v4"] } rand = { version = "0.8.5" } +rand_core = { version = "0.6.4" } # db diesel = { version = "2.2.3", features = ["postgres", "chrono", "serde_json"] } diff --git a/crates/gem_evm/src/address.rs b/crates/gem_evm/src/address.rs index e14f2376..4ed85850 100644 --- a/crates/gem_evm/src/address.rs +++ b/crates/gem_evm/src/address.rs @@ -60,6 +60,10 @@ impl FromStr for EthereumAddress { impl EthereumAddress { pub const LEN: usize = 20; + pub fn zero() -> Self { + Self { bytes: vec![0u8; Self::LEN] } + } + pub fn parse(str: &str) -> Option { Self::from_str(str).ok() } diff --git a/crates/gem_evm/src/erc20.rs b/crates/gem_evm/src/erc20.rs index 1d34d9de..17cbe86f 100644 --- a/crates/gem_evm/src/erc20.rs +++ b/crates/gem_evm/src/erc20.rs @@ -3,5 +3,6 @@ use alloy_core::sol; sol! { interface IERC20 { function allowance(address owner, address spender) external view returns (uint256); + function decimals() external view returns (uint8); } } diff --git a/crates/gem_evm/src/jsonrpc.rs b/crates/gem_evm/src/jsonrpc.rs index bbcaab27..d481ab69 100644 --- a/crates/gem_evm/src/jsonrpc.rs +++ b/crates/gem_evm/src/jsonrpc.rs @@ -1,3 +1,4 @@ +use alloy_primitives::U256; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -35,6 +36,17 @@ impl TransactionObject { data: format!("0x{}", hex::encode(data)), } } + + pub fn new_call_with_value(from: &str, to: &str, data: Vec, value: &str) -> Self { + Self { + from: Some(from.to_string()), + to: to.to_string(), + gas: None, + gas_price: None, + value: Some(value.to_string()), + data: format!("0x{}", hex::encode(data)), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -73,6 +85,8 @@ pub enum EthereumRpc { GasPrice, GetBalance(&'static str), Call(TransactionObject, BlockParameter), + GetTransactionReceipt(String), + EstimateGas(TransactionObject), } impl EthereumRpc { @@ -81,6 +95,8 @@ impl EthereumRpc { EthereumRpc::GasPrice => "eth_gasPrice", EthereumRpc::GetBalance(_) => "eth_getBalance", EthereumRpc::Call(_, _) => "eth_call", + EthereumRpc::GetTransactionReceipt(_) => "eth_getTransactionReceipt", + EthereumRpc::EstimateGas(_) => "eth_estimateGas", } } } diff --git a/crates/gem_evm/src/lib.rs b/crates/gem_evm/src/lib.rs index 8907f054..9b880afb 100644 --- a/crates/gem_evm/src/lib.rs +++ b/crates/gem_evm/src/lib.rs @@ -3,5 +3,6 @@ pub mod erc20; pub mod erc2612; pub mod jsonrpc; pub mod lido; +pub mod mayan; pub mod permit2; pub mod uniswap; diff --git a/crates/gem_evm/src/mayan/forwarder.rs b/crates/gem_evm/src/mayan/forwarder.rs new file mode 100644 index 00000000..0f73f1bd --- /dev/null +++ b/crates/gem_evm/src/mayan/forwarder.rs @@ -0,0 +1,53 @@ +use alloy_core::sol; + +sol! { + /// @title MayanForwarder Interface + #[derive(Debug, PartialEq)] + interface IMayanForwarder { + + /// @notice Forward ETH to Mayan protocol + function forwardEth(address mayanProtocol, bytes calldata protocolData) external payable; + + /// @notice Forward ERC20 tokens to Mayan protocol + function forwardERC20( + address tokenIn, + uint256 amountIn, + PermitParams calldata permitParams, + address mayanProtocol, + bytes calldata protocolData + ) external payable; + + /// @notice Swap ETH to token and forward to Mayan protocol + function swapAndForwardEth( + uint256 amountIn, + address swapProtocol, + bytes calldata swapData, + address middleToken, + uint256 minMiddleAmount, + address mayanProtocol, + bytes calldata mayanData + ) external payable; + + /// @notice Swap ERC20 token and forward to Mayan protocol + function swapAndForwardERC20( + address tokenIn, + uint256 amountIn, + PermitParams calldata permitParams, + address swapProtocol, + bytes calldata swapData, + address middleToken, + uint256 minMiddleAmount, + address mayanProtocol, + bytes calldata mayanData + ) external payable; + + /// Structs + struct PermitParams { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + } +} diff --git a/crates/gem_evm/src/mayan/mod.rs b/crates/gem_evm/src/mayan/mod.rs new file mode 100644 index 00000000..29ee1422 --- /dev/null +++ b/crates/gem_evm/src/mayan/mod.rs @@ -0,0 +1,2 @@ +pub mod forwarder; +pub mod swift; diff --git a/crates/gem_evm/src/mayan/swift/deployment.rs b/crates/gem_evm/src/mayan/swift/deployment.rs new file mode 100644 index 00000000..2e97cde2 --- /dev/null +++ b/crates/gem_evm/src/mayan/swift/deployment.rs @@ -0,0 +1,144 @@ +use std::collections::HashMap; + +use primitives::Chain; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum MayanSwiftDeploymentWormholeId { + Solana = 1, + Ethereum = 2, + SmartChain = 4, + Polygon = 5, + Arbitrum = 23, + Optimism = 24, + Base = 30, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MayanSwiftDeployment { + pub address: String, + pub wormhole_id: MayanSwiftDeploymentWormholeId, +} + +pub fn get_swift_providers() -> HashMap { + HashMap::from([ + ( + Chain::Solana, + MayanSwiftDeployment { + address: "BLZRi6frs4X4DNLw56V4EXai1b6QVESN1BhHBTYM9VcY".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Solana, + }, + ), + ( + Chain::Ethereum, + MayanSwiftDeployment { + address: "0xC38e4e6A15593f908255214653d3D947CA1c2338".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Ethereum, + }, + ), + ( + Chain::SmartChain, + MayanSwiftDeployment { + address: "0xC38e4e6A15593f908255214653d3D947CA1c2338".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::SmartChain, + }, + ), + ( + Chain::Polygon, + MayanSwiftDeployment { + address: "0xC38e4e6A15593f908255214653d3D947CA1c2338".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Polygon, + }, + ), + ( + Chain::Arbitrum, + MayanSwiftDeployment { + address: "0xC38e4e6A15593f908255214653d3D947CA1c2338".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Arbitrum, + }, + ), + ( + Chain::Optimism, + MayanSwiftDeployment { + address: "0xC38e4e6A15593f908255214653d3D947CA1c2338".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Optimism, + }, + ), + ( + Chain::Base, + MayanSwiftDeployment { + address: "0xC38e4e6A15593f908255214653d3D947CA1c2338".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Base, + }, + ), + ]) +} + +pub fn get_swift_deployment_chains() -> Vec { + get_swift_providers().keys().cloned().collect() +} + +pub fn get_swift_deployment_by_chain(chain: Chain) -> Option { + get_swift_providers().get(&chain).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_swift_provider_address() { + // Test all supported chains + assert_eq!( + get_swift_deployment_by_chain(Chain::Solana), + Some(MayanSwiftDeployment { + address: "BLZRi6frs4X4DNLw56V4EXai1b6QVESN1BhHBTYM9VcY".to_string(), + wormhole_id: MayanSwiftDeploymentWormholeId::Solana, + }) + ); + + let evm_address = "0xC38e4e6A15593f908255214653d3D947CA1c2338"; + + assert_eq!(get_swift_deployment_by_chain(Chain::Ethereum).map(|x| x.address), Some(evm_address.to_string())); + assert_eq!( + get_swift_deployment_by_chain(Chain::SmartChain).map(|x| x.address), + Some(evm_address.to_string()) + ); + assert_eq!(get_swift_deployment_by_chain(Chain::Polygon).map(|x| x.address), Some(evm_address.to_string())); + // assert_eq!(get_swift_deployment_by_chain(Chain::Avalanche).map(|x| x.address), Some(evm_address.to_string())); + assert_eq!(get_swift_deployment_by_chain(Chain::Arbitrum).map(|x| x.address), Some(evm_address.to_string())); + assert_eq!(get_swift_deployment_by_chain(Chain::Optimism).map(|x| x.address), Some(evm_address.to_string())); + + // Test unsupported chain + assert_eq!(get_swift_deployment_by_chain(Chain::Sui), None); + } + + #[test] + fn test_chain_ids() { + // Verify chain IDs match the provided table, and that they are in order + assert_eq!( + get_swift_deployment_by_chain(Chain::Solana).map(|x| x.wormhole_id), + Some(MayanSwiftDeploymentWormholeId::Solana) + ); + assert_eq!( + get_swift_deployment_by_chain(Chain::Ethereum).map(|x| x.wormhole_id), + Some(MayanSwiftDeploymentWormholeId::Ethereum) + ); + assert_eq!( + get_swift_deployment_by_chain(Chain::SmartChain).map(|x| x.wormhole_id), + Some(MayanSwiftDeploymentWormholeId::SmartChain) + ); + assert_eq!( + get_swift_deployment_by_chain(Chain::Polygon).map(|x| x.wormhole_id), + Some(MayanSwiftDeploymentWormholeId::Polygon) + ); + // assert_eq!(get_swift_deployment_by_chain(Chain::Avalanche).map(|x| x.wormhole_id), Some(6)); + assert_eq!( + get_swift_deployment_by_chain(Chain::Arbitrum).map(|x| x.wormhole_id), + Some(MayanSwiftDeploymentWormholeId::Arbitrum) + ); + assert_eq!( + get_swift_deployment_by_chain(Chain::Optimism).map(|x| x.wormhole_id), + Some(MayanSwiftDeploymentWormholeId::Optimism) + ); + } +} diff --git a/crates/gem_evm/src/mayan/swift/mod.rs b/crates/gem_evm/src/mayan/swift/mod.rs new file mode 100644 index 00000000..1adfd3dd --- /dev/null +++ b/crates/gem_evm/src/mayan/swift/mod.rs @@ -0,0 +1,2 @@ +pub mod deployment; +pub mod swift; diff --git a/crates/gem_evm/src/mayan/swift/swift.rs b/crates/gem_evm/src/mayan/swift/swift.rs new file mode 100644 index 00000000..06bc0b21 --- /dev/null +++ b/crates/gem_evm/src/mayan/swift/swift.rs @@ -0,0 +1,34 @@ +use alloy_core::sol; + +sol! { + /// @title MayanSwift Cross-Chain Swap Contract + #[derive(Debug)] + contract IMayanSwift{ + struct OrderParams { + bytes32 trader; + bytes32 tokenOut; + uint64 minAmountOut; + uint64 gasDrop; + uint64 cancelFee; + uint64 refundFee; + uint64 deadline; + bytes32 destAddr; + uint16 destChainId; + bytes32 referrerAddr; + uint8 referrerBps; + uint8 auctionMode; + bytes32 random; + } + + struct PermitParams { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + + function createOrderWithEth(OrderParams memory params) external payable returns (bytes32 orderHash); + function createOrderWithToken(address tokenIn, uint256 amountIn, OrderParams memory params) external returns (bytes32 orderHash); + } +} diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 5081616a..343a5582 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -40,6 +40,7 @@ num-bigint.workspace = true num-traits.workspace = true futures.workspace = true borsh.workspace = true +rand.workspace = true orca_whirlpools_core = "1.0.0" [build-dependencies] diff --git a/gemstone/src/network/mock.rs b/gemstone/src/network/mock.rs index 20ee8590..0447ce27 100644 --- a/gemstone/src/network/mock.rs +++ b/gemstone/src/network/mock.rs @@ -1,6 +1,7 @@ -use super::{AlienError, AlienProvider, AlienTarget, Data}; use async_trait::async_trait; use primitives::Chain; + +use super::{AlienError, AlienProvider, AlienTarget, Data}; use std::{fmt::Debug, sync::Arc, time::Duration}; #[derive(Debug, uniffi::Object)] diff --git a/gemstone/src/swapper/mayan/constants.rs b/gemstone/src/swapper/mayan/constants.rs new file mode 100644 index 00000000..67a11361 --- /dev/null +++ b/gemstone/src/swapper/mayan/constants.rs @@ -0,0 +1,3 @@ +pub const MAYAN_PROGRAM_ID: &str = "FC4eXxkyrMPTjiYUpp4EAnkmwMbQyZ6NDCh1kfLn6vsf"; +pub const MAYAN_FORWARDER_CONTRACT: &str = "0x0654874eb7F59C6f5b39931FC45dC45337c967c3"; +pub const MAYAN_ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; diff --git a/gemstone/src/swapper/mayan/forwarder.rs b/gemstone/src/swapper/mayan/forwarder.rs new file mode 100644 index 00000000..3ef5b266 --- /dev/null +++ b/gemstone/src/swapper/mayan/forwarder.rs @@ -0,0 +1,126 @@ +use std::str::FromStr; + +use alloy_core::{ + primitives::{Address, U256}, + sol_types::SolCall, +}; +use gem_evm::mayan::forwarder::IMayanForwarder; +use thiserror::Error; + +use super::swift::MayanSwiftPermit; + +#[derive(Debug, Error)] +pub enum MayanForwarderError { + #[error("ABI Error: {msg}")] + ABIError { msg: String }, +} + +pub struct MayanForwarder {} + +impl MayanForwarder { + pub fn new() -> Self { + Self {} + } + + pub async fn encode_forward_eth_call(&self, mayan_protocol: &str, protocol_data: Vec) -> Result, MayanForwarderError> { + let call_data = IMayanForwarder::forwardEthCall { + mayanProtocol: Address::from_str(mayan_protocol).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid protocol address: {}", e), + })?, + protocolData: protocol_data.into(), + } + .abi_encode(); + + Ok(call_data) + } + + pub fn encode_forward_erc20_call( + &self, + token_in: &str, + amount_in: U256, + permit: Option, + mayan_protocol: &str, + protocol_data: Vec, + ) -> Result, MayanForwarderError> { + let call_data = IMayanForwarder::forwardERC20Call { + tokenIn: Address::from_str(token_in).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid token address: {}", e), + })?, + amountIn: amount_in, + permitParams: permit.map_or(MayanSwiftPermit::zero().to_contract_params(), |p| p.to_contract_params()), + mayanProtocol: Address::from_str(mayan_protocol).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid protocol address: {}", e), + })?, + protocolData: protocol_data.into(), + } + .abi_encode(); + + Ok(call_data) + } + + pub async fn encode_swap_and_forward_eth_call( + &self, + amount_in: U256, + swap_protocol: &str, + swap_data: Vec, + middle_token: &str, + min_middle_amount: U256, + mayan_protocol: &str, + mayan_data: Vec, + ) -> Result, MayanForwarderError> { + let call_data = IMayanForwarder::swapAndForwardEthCall { + amountIn: amount_in, + swapProtocol: Address::from_str(swap_protocol).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid swap protocol address: {}", e), + })?, + swapData: swap_data.into(), + middleToken: Address::from_str(middle_token).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid middle token address: {}", e), + })?, + minMiddleAmount: min_middle_amount, + mayanProtocol: Address::from_str(mayan_protocol).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid mayan protocol address: {}", e), + })?, + mayanData: mayan_data.into(), + } + .abi_encode(); + + Ok(call_data) + } + + pub fn encode_swap_and_forward_erc20_call( + &self, + token_in: &str, + amount_in: U256, + permit: Option, + swap_protocol: &str, + swap_data: Vec, + middle_token: &str, + min_middle_amount: U256, + mayan_protocol: &str, + mayan_data: Vec, + ) -> Result, MayanForwarderError> { + let call_data = IMayanForwarder::swapAndForwardERC20Call { + tokenIn: Address::from_str(token_in).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid token address: {}", e), + })?, + amountIn: amount_in, + permitParams: permit.map_or(MayanSwiftPermit::zero().to_contract_params(), |p| p.to_contract_params()), + swapProtocol: Address::from_str(swap_protocol).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid swap protocol address: {}", e), + })?, + swapData: swap_data.into(), + middleToken: Address::from_str(middle_token).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid middle token address: {}", e), + })?, + minMiddleAmount: min_middle_amount, + mayanProtocol: Address::from_str(mayan_protocol).map_err(|e| MayanForwarderError::ABIError { + msg: format!("Invalid mayan protocol address: {}", e), + })?, + mayanData: mayan_data.into(), + } + .abi_encode(); + + Ok(call_data) + } +} diff --git a/gemstone/src/swapper/mayan/mod.rs b/gemstone/src/swapper/mayan/mod.rs new file mode 100644 index 00000000..2bf23ae0 --- /dev/null +++ b/gemstone/src/swapper/mayan/mod.rs @@ -0,0 +1,5 @@ +mod constants; +mod forwarder; +mod relayer; +mod swift; +pub mod swift_provider; diff --git a/gemstone/src/swapper/mayan/relayer.rs b/gemstone/src/swapper/mayan/relayer.rs new file mode 100644 index 00000000..b68498fd --- /dev/null +++ b/gemstone/src/swapper/mayan/relayer.rs @@ -0,0 +1,309 @@ +use std::{fmt::Display, sync::Arc}; + +use primitives::Chain; +use serde::{Deserialize, Serialize}; + +use crate::network::{AlienHttpMethod, AlienProvider, AlienTarget}; + +use super::constants::{MAYAN_FORWARDER_CONTRACT, MAYAN_PROGRAM_ID}; + +const SDK_VERSION: &str = "9_7_0"; + +#[derive(Debug, Deserialize)] +struct ApiError { + code: String, + msg: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct QuoteParams { + pub amount: f64, + pub from_token: String, + pub from_chain: Chain, + pub to_token: String, + pub to_chain: Chain, + #[serde(skip_serializing_if = "Option::is_none")] + pub slippage_bps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gas_drop: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer_bps: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct QuoteOptions { + pub swift: bool, + pub mctp: bool, + pub gasless: bool, + pub only_direct: bool, +} + +impl Default for QuoteOptions { + fn default() -> Self { + Self { + swift: true, + mctp: true, + gasless: false, + only_direct: false, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct Token { + pub name: String, + pub standard: String, + pub symbol: String, + pub mint: String, + pub verified: bool, + pub contract: String, + pub wrapped_address: Option, + pub chain_id: Option, + pub w_chain_id: Option, + pub decimals: u8, + + #[serde(rename = "logoURI")] + pub logo_uri: String, + pub coingecko_id: String, + pub real_origin_chain_id: Option, + pub real_origin_contract_address: Option, + pub supports_permit: bool, + pub has_auction: bool, +} + +#[derive(Debug, PartialEq)] +#[allow(dead_code)] +pub enum QuoteType { + Swift, + Mctp, // TODO: do we want to support all types? + Swap, + WH, +} + +impl Display for QuoteType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + QuoteType::Swift => write!(f, "SWIFT"), + QuoteType::Mctp => write!(f, "MCTP"), + QuoteType::Swap => write!(f, "SWAP"), + QuoteType::WH => write!(f, "WH"), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct Quote { + #[serde(rename = "type")] + pub r#type: String, + pub effective_amount_in: f64, + pub expected_amount_out: f64, + pub price_impact: Option, + pub min_amount_out: f64, + pub min_middle_amount: Option, + pub evm_swap_router_address: Option, + pub evm_swap_router_calldata: Option, + pub min_received: f64, + pub gas_drop: f64, + pub price: f64, + pub swap_relayer_feed: Option, + pub redeem_relayer_fee: Option, + pub refund_relayer_fee: Option, + pub solana_relayer_fee: Option, + pub refund_relayer_fee64: String, + pub cancel_relayer_fee64: String, + pub from_token: Token, + pub to_token: Token, + pub from_chain: String, + pub to_chain: String, + pub slippage_bps: u32, + pub bridge_fee: f64, + pub suggested_priority_fee: f64, + pub only_bridging: bool, + pub deadline64: String, + pub referrer_bps: Option, + pub protocol_bps: Option, + pub swift_mayan_contract: Option, + pub swift_auction_mode: Option, + pub swift_input_contract: String, + pub swift_input_decimals: u8, + pub gasless: bool, + pub relayer: String, + pub send_transaction_cost: f64, + pub max_user_gas_drop: f64, +} + +#[derive(Debug, Deserialize)] +struct QuoteResponse { + quotes: Vec, + + #[serde(rename = "minimumSdkVersion")] + pub minimum_sdk_version: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum MayanRelayerError { + #[error("Network error: {0}")] + NetworkError(String), + #[error("Invalid response: {0}")] + InvalidResponse(String), + #[error("SDK version not supported")] + SdkVersionNotSupported, + #[error("Invalid parameters: {0}")] + InvalidParameters(String), +} + +#[derive(Debug)] +pub struct MayanRelayer { + url: String, + provider: Arc, +} + +impl MayanRelayer { + pub fn new(url: String, provider: Arc) -> Self { + Self { url, provider } + } + + pub fn default_relayer(provider: Arc) -> Self { + Self::new("https://price-api.mayan.finance/v3".to_string(), provider) + } + + pub async fn get_quote(&self, params: QuoteParams, options: Option) -> Result, MayanRelayerError> { + let options = options.unwrap_or_default(); + let from_chain = if params.from_chain == Chain::SmartChain { + "bsc".to_string() + } else { + params.from_chain.to_string() + }; + + let mut query_params = vec![ + ("swift", options.swift.to_string()), + ("mctp", options.mctp.to_string()), + ("gasless", options.gasless.to_string()), + ("onlyDirect", options.only_direct.to_string()), + ("solanaProgram", MAYAN_PROGRAM_ID.to_string()), + ("forwarderAddress", MAYAN_FORWARDER_CONTRACT.to_string()), + ("amountIn", params.amount.to_string()), + ("fromToken", params.from_token), + ("fromChain", from_chain), + ("toToken", params.to_token), + ("toChain", params.to_chain.to_string()), + // ("slippageBps", params.slippage_bps.map_or("auto".to_string(), |v| v.to_string())), + // ("gasDrop", params.gas_drop.unwrap_or(0).to_string()), + ("sdkVersion", "9_7_0".to_string()), + ]; + + if let Some(slippage) = params.slippage_bps { + query_params.push(("slippageBps", slippage.to_string())); + } + if let Some(gas_drop) = params.gas_drop { + query_params.push(("gasDrop", gas_drop.to_string())); + } + if let Some(referrer) = params.referrer { + query_params.push(("referrer", referrer)); + } + if let Some(referrer_bps) = params.referrer_bps { + query_params.push(("referrerBps", referrer_bps.to_string())); + } + + let query = serde_urlencoded::to_string(&query_params).map_err(|e| MayanRelayerError::InvalidParameters(e.to_string()))?; + + let url = format!("{}/quote?{}", self.url, query); + + let target = AlienTarget { + url, + method: AlienHttpMethod::Get, + headers: None, + body: None, + }; + + let data = self + .provider + .request(target) + .await + .map_err(|err| MayanRelayerError::NetworkError(err.to_string()))?; + + let quote_response = serde_json::from_slice::(&data); + match quote_response { + Ok(response) => { + if !self.check_sdk_version(response.minimum_sdk_version) { + return Err(MayanRelayerError::SdkVersionNotSupported); + } + + Ok(response.quotes) + } + Err(err) => { + if let Ok(api_error) = serde_json::from_slice::(&data) { + return Err(MayanRelayerError::InvalidResponse(api_error.msg)); + } + Err(MayanRelayerError::NetworkError(err.to_string())) + } + } + } + + fn check_sdk_version(&self, minimum_version: Vec) -> bool { + let sdk_version = SDK_VERSION.split('_').filter_map(|x| x.parse::().ok()).collect::>(); + + // Major version check + if sdk_version[0] < minimum_version[0] { + return false; + } + if sdk_version[0] > minimum_version[0] { + return true; + } + + // Minor version check + if sdk_version[1] < minimum_version[1] { + return false; + } + if sdk_version[1] > minimum_version[1] { + return true; + } + + if sdk_version[2] >= minimum_version[2] { + return true; + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quote_deserialization() { + let data = include_str!("tests/quote_response.json"); + let quote: Quote = serde_json::from_str(data).expect("Failed to deserialize Quote"); + assert_eq!(quote.r#type, "SWIFT"); + assert_eq!(quote.swift_input_decimals, 18); + } + + #[test] + fn test_token_deserialization() { + let data = include_str!("tests/quote_token_response.json"); + + let token: Token = serde_json::from_str(data).expect("Failed to deserialize Token"); + assert_eq!(token.name, "ETH"); + assert!(token.verified); + assert_eq!(token.chain_id, Some(8453)); + assert_eq!(token.wrapped_address.unwrap(), "0x4200000000000000000000000000000000000006"); + } + + #[test] + fn test_quote_response_deserialization() { + let json_data = r#"{ + "quotes": [], + "minimumSdkVersion": [7, 0, 0] + }"#; + + let response: QuoteResponse = serde_json::from_str(json_data).expect("Failed to deserialize QuoteResponse"); + assert_eq!(response.minimum_sdk_version, vec![7, 0, 0]); + } +} diff --git a/gemstone/src/swapper/mayan/swift.rs b/gemstone/src/swapper/mayan/swift.rs new file mode 100644 index 00000000..c4982543 --- /dev/null +++ b/gemstone/src/swapper/mayan/swift.rs @@ -0,0 +1,122 @@ +use alloy_core::{ + primitives::{Address, U256}, + sol_types::SolCall, +}; + +use gem_evm::mayan::{forwarder::IMayanForwarder::PermitParams, swift::swift::IMayanSwift}; +use std::str::FromStr; +use thiserror::Error; + +pub struct MayanSwift {} + +#[derive(Error, Debug)] +pub enum MayanSwiftError { + #[error("Call failed: {msg}")] + CallFailed { msg: String }, + + #[error("Invalid response: {msg}")] + InvalidResponse { msg: String }, + + #[error("ABI error: {msg}")] + ABIError { msg: String }, + + #[error("Invalid amount")] + InvalidAmount, +} + +// Parameter structs with native types +#[derive(Debug, Clone)] +pub struct OrderParams { + pub trader: [u8; 32], + pub token_out: [u8; 32], + pub min_amount_out: u64, + pub gas_drop: u64, + pub cancel_fee: u64, + pub refund_fee: u64, + pub deadline: u64, + pub dest_addr: [u8; 32], + pub dest_chain_id: u16, + pub referrer_addr: [u8; 32], + pub referrer_bps: u8, + pub auction_mode: u8, + pub random: [u8; 32], +} + +impl OrderParams { + pub fn to_contract_params(&self) -> IMayanSwift::OrderParams { + IMayanSwift::OrderParams { + trader: self.trader.into(), + tokenOut: self.token_out.into(), + minAmountOut: self.min_amount_out, + gasDrop: self.gas_drop, + cancelFee: self.cancel_fee, + refundFee: self.refund_fee, + deadline: self.deadline, + destAddr: self.dest_addr.into(), + destChainId: self.dest_chain_id, + referrerAddr: self.referrer_addr.into(), + referrerBps: self.referrer_bps, + auctionMode: self.auction_mode, + random: self.random.into(), + } + } +} + +#[derive(Debug)] +pub struct MayanSwiftPermit { + pub value: String, + pub deadline: u64, + pub v: u8, + pub r: [u8; 32], + pub s: [u8; 32], +} + +impl MayanSwiftPermit { + pub fn zero() -> Self { + Self { + value: "0".to_string(), + deadline: 0, + v: 0, + r: [0u8; 32], + s: [0u8; 32], + } + } + + pub fn to_contract_params(&self) -> PermitParams { + PermitParams { + value: U256::from_str(&self.value).unwrap(), + deadline: U256::from(self.deadline), + v: self.v, + r: self.r.into(), + s: self.s.into(), + } + } +} + +impl MayanSwift { + pub fn new() -> Self { + Self {} + } + + pub async fn encode_create_order_with_eth(&self, params: OrderParams) -> Result, MayanSwiftError> { + let call_data = IMayanSwift::createOrderWithEthCall { + params: params.to_contract_params(), + } + .abi_encode(); + + Ok(call_data) + } + + pub async fn encode_create_order_with_token(&self, token_in: &str, amount: U256, params: OrderParams) -> Result, MayanSwiftError> { + let call_data = IMayanSwift::createOrderWithTokenCall { + tokenIn: Address::from_str(token_in).map_err(|e| MayanSwiftError::ABIError { + msg: format!("Invalid token address: {}", e), + })?, + amountIn: amount, + params: params.to_contract_params(), + } + .abi_encode(); + + Ok(call_data) + } +} diff --git a/gemstone/src/swapper/mayan/swift_provider.rs b/gemstone/src/swapper/mayan/swift_provider.rs new file mode 100644 index 00000000..33d8489c --- /dev/null +++ b/gemstone/src/swapper/mayan/swift_provider.rs @@ -0,0 +1,559 @@ +use rand::Rng; +use std::{str::FromStr, sync::Arc}; + +use alloy_core::{ + hex::{decode as HexDecode, ToHexExt}, + primitives::{ + utils::{parse_units, Unit}, + U256, + }, + sol_types::SolCall, +}; + +use async_trait::async_trait; +use gem_evm::{ + address::EthereumAddress, + erc20::IERC20, + jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, + mayan::swift::deployment::{get_swift_deployment_chains, get_swift_providers}, +}; +use primitives::{Asset, AssetId, Chain}; + +use crate::{ + config::swap_config::SwapReferralFee, + network::{jsonrpc_call, AlienProvider}, + swapper::{ + approval::{check_approval, CheckApprovalType}, + ApprovalType, FetchQuoteData, GemSwapProvider, SwapProvider, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteRequest, SwapRoute, SwapperError, + }, +}; + +use super::{ + constants::{MAYAN_FORWARDER_CONTRACT, MAYAN_ZERO_ADDRESS}, + forwarder::MayanForwarder, + relayer::{MayanRelayer, Quote, QuoteOptions, QuoteParams, QuoteType}, + swift::{MayanSwift, MayanSwiftError, OrderParams}, +}; + +#[derive(Debug, Default)] +pub struct MayanSwiftProvider {} + +impl From for SwapperError { + fn from(err: MayanSwiftError) -> Self { + SwapperError::NetworkError { msg: err.to_string() } + } +} + +impl MayanSwiftProvider { + fn get_chain_by_wormhole_id(&self, wormhole_id: u64) -> Option { + get_swift_providers() + .into_iter() + .find(|(_, deployment)| deployment.wormhole_id.clone() as u64 == wormhole_id) + .map(|(chain, _)| chain) + } + + async fn check_approval(&self, request: &SwapQuoteRequest, provider: Arc) -> Result { + if request.from_asset.is_native() { + return Ok(ApprovalType::None); + } + + check_approval( + CheckApprovalType::ERC20( + request.wallet_address.clone(), + request.from_asset.token_id.clone().unwrap_or_default(), + MAYAN_FORWARDER_CONTRACT.to_string(), + U256::from_str(request.value.as_str()).map_err(|_| SwapperError::InvalidAmount)?, + ), + provider, + &request.from_asset.chain, + ) + .await + } + + fn to_native_wormhole_address(&self, address: &str, w_chain_id: u64) -> Result<[u8; 32], SwapperError> { + let chain = self.get_chain_by_wormhole_id(w_chain_id).ok_or(SwapperError::InvalidRoute)?; + + if chain == Chain::Solana { + todo!() + } else { + Ok(self.address_to_bytes32(address)?) + } + } + + fn build_swift_order_params(&self, request: &SwapQuoteRequest, quote: &Quote) -> Result { + let dest_chain_id = quote.to_token.w_chain_id.unwrap(); + let from_chain_id = quote.from_token.w_chain_id.unwrap(); + + let deadline = quote.deadline64.parse::().map_err(|_| SwapperError::ComputeQuoteError { + msg: "Failed to parse deadline".to_string(), + })?; + + let trader_address = self.address_to_bytes32(&request.wallet_address)?; + let destination_address = self.address_to_bytes32(&request.destination_address)?; + let token_out = self.address_to_bytes32("e.to_token.contract)?; + + let min_amount_out = self.get_amount_of_fractional_amount(quote.min_amount_out, quote.to_token.decimals)?; + // TODO: check if we need to use to token or from token decimals + let gas_drop = self.convert_amount_to_wei(quote.gas_drop, quote.to_token.decimals.into()).map_err(|e| { + eprintln!("Failed to convert gas_drop: {}", quote.gas_drop); + e + })?; + + let random_bytes = Self::generate_random_bytes32(); + + let referrer = self.get_referrer(request)?; + + let params = OrderParams { + trader: trader_address, + token_out, + min_amount_out: min_amount_out.parse().map_err(|_| SwapperError::InvalidAmount)?, + gas_drop: gas_drop.parse().map_err(|_| SwapperError::InvalidAmount)?, + cancel_fee: quote.cancel_relayer_fee64.parse::().map_err(|_| SwapperError::InvalidAmount)?, + refund_fee: quote.refund_relayer_fee64.parse::().map_err(|_| SwapperError::InvalidAmount)?, + deadline, + dest_addr: destination_address, + dest_chain_id: dest_chain_id.to_string().parse().map_err(|_| SwapperError::InvalidAmount)?, + referrer_addr: referrer.clone().map_or([0u8; 32], |x| { + self.to_native_wormhole_address(x.address.as_str(), from_chain_id) + .map_err(|_| SwapperError::ComputeQuoteError { + msg: "Unable to get referrer wormhole address".to_string(), + }) + .unwrap() + }), + referrer_bps: referrer.map_or(0u8, |x| x.bps.try_into().map_err(|_| SwapperError::InvalidAmount).unwrap()), + auction_mode: quote.swift_auction_mode.unwrap_or(0), + random: random_bytes, + }; + + Ok(params) + } + + fn generate_random_bytes32() -> [u8; 32] { + let mut rng = rand::thread_rng(); + let mut random_bytes = [0u8; 32]; + rng.fill(&mut random_bytes); + random_bytes + } + + fn address_to_bytes32(&self, address: &str) -> Result<[u8; 32], SwapperError> { + let addr = EthereumAddress::from_str(address).map_err(|_| SwapperError::InvalidAddress { address: address.to_string() })?; + let mut bytes32 = [0u8; 32]; + bytes32[12..].copy_from_slice(&addr.bytes); + Ok(bytes32) + } + + fn get_referrer(&self, request: &SwapQuoteRequest) -> Result, SwapperError> { + if let Some(referrer) = &request.options.fee { + let evm_fee = &referrer.evm; + let solana_fee = &referrer.solana; + + if request.from_asset.chain == Chain::Solana { + return Ok(Some(solana_fee.clone())); + } + + return Ok(Some(evm_fee.clone())); + } + + Ok(None) + } + + async fn fetch_quote_from_request(&self, request: &SwapQuoteRequest, provider: Arc) -> Result { + let asset_decimals = self.get_asset_decimals(request.from_asset.clone(), provider.clone()).await?; + let mayan_relayer = MayanRelayer::default_relayer(provider.clone()); + let referrer = self.get_referrer(request)?; + let quote_params = QuoteParams { + amount: self.convert_to_decimals( + request.value.parse().map_err(|_| SwapperError::ComputeQuoteError { + msg: "Failed to convert request value to number".to_string(), + })?, + asset_decimals, + ), + from_token: request.from_asset.token_id.clone().unwrap_or(EthereumAddress::zero().to_checksum()), + from_chain: request.from_asset.chain, + to_token: request.to_asset.token_id.clone().unwrap_or(EthereumAddress::zero().to_checksum()), + to_chain: request.to_asset.chain, + slippage_bps: Some(100), + gas_drop: None, + referrer: referrer.clone().map(|x| x.address), + referrer_bps: referrer.map(|x| x.bps), + }; + + let quote_options = QuoteOptions { + swift: true, + mctp: false, + gasless: false, + only_direct: false, + }; + + let quote = mayan_relayer + .get_quote(quote_params, Some(quote_options)) + .await + .map_err(|e| SwapperError::ComputeQuoteError { + msg: format!("Mayan relayer quote error: {:?}", e), + })?; + + // TODO: adjust to find most effective quote + let most_effective_qoute = quote.into_iter().filter(|x| x.r#type == QuoteType::Swift.to_string()).last(); + + most_effective_qoute.ok_or(SwapperError::ComputeQuoteError { + msg: "Quote is not available".to_string(), + }) + } + + fn convert_amount_to_wei(&self, amount: f64, decimals: u8) -> Result { + if amount < 0.0 { + return Err(SwapperError::ComputeQuoteError { + msg: "Cannot convert negative amount".to_string(), + }); + } + + let parsed = parse_units(amount.to_string().as_str(), decimals).map_err(|_| SwapperError::ComputeQuoteError { + msg: "Invalid conversion amount to decimals".to_string(), + })?; + + Ok(parsed.to_string()) + } + + fn get_amount_of_fractional_amount(&self, amount: f64, decimals: u8) -> Result { + if amount < 0.0 || !amount.is_finite() { + return Err(SwapperError::InvalidAmount); + } + + // Determine the cut factor (maximum of 8 or the provided decimals) + let cut_factor = std::cmp::min(8, decimals as i32); + + // Format the amount to cut_factor + 1 decimal places + let formatted_amount = format!("{:.precision$}", amount, precision = (cut_factor + 1) as usize); + + // Extract and truncate to cut_factor decimal places + let truncated_amount = if let Some((int_part, decimal_part)) = formatted_amount.split_once('.') { + let truncated_decimals = &decimal_part[..std::cmp::min(decimal_part.len(), cut_factor as usize)]; + format!("{}.{}", int_part, truncated_decimals) + } else { + formatted_amount + }; + + // Calculate the result scaled by 10^cut_factor + let scaled_amount = truncated_amount.parse::().map_err(|_| SwapperError::InvalidAmount)? * 10f64.powi(cut_factor); + + // Validate range + if scaled_amount < 0.0 || scaled_amount > (u64::MAX as f64) { + return Err(SwapperError::InvalidAmount); + } + + // Return the scaled amount as a string + Ok(format!("{:.0}", scaled_amount)) + } + + async fn get_asset_decimals(&self, asset_id: AssetId, provider: Arc) -> Result { + let asset = Asset::from_chain(asset_id.chain); + + if asset_id.is_native() { + return Ok(asset.decimals as u32); + } + let address = asset_id.token_id.clone().unwrap(); + let decimals_data = IERC20::decimalsCall {}.abi_encode(); + let decimals_call = EthereumRpc::Call(TransactionObject::new_call(&address, decimals_data), BlockParameter::Latest); + + let response = jsonrpc_call(&decimals_call, provider.clone(), &asset_id.chain) + .await + .map_err(|err| SwapperError::ComputeQuoteError { + msg: format!("Failed to get ERC20 decimals: {}", err), + })?; + let result: String = response.take().map_err(|_| SwapperError::ComputeQuoteError { + msg: "Failed to get ERC20 decimals".to_string(), + })?; + let decoded = HexDecode(result).map_err(|_| SwapperError::ComputeQuoteError { + msg: "Failed to decode decimals return".to_string(), + })?; + let decimals_return = IERC20::decimalsCall::abi_decode_returns(&decoded, false).map_err(|_| SwapperError::ComputeQuoteError { + msg: "Failed to decode decimals return".to_string(), + })?; + + Ok(decimals_return._0.into()) + } + + fn convert_to_decimals(&self, wei_amount: u128, decimals: u32) -> f64 { + let divisor = 10_u64.pow(decimals); + wei_amount as f64 / divisor as f64 + } +} + +#[async_trait] +impl GemSwapProvider for MayanSwiftProvider { + fn provider(&self) -> SwapProvider { + SwapProvider::MayanSwift + } + + fn supported_chains(&self) -> Vec { + get_swift_deployment_chains() + } + + async fn fetch_quote(&self, request: &SwapQuoteRequest, provider: Arc) -> Result { + // Validate chain support + if !self.supported_chains().contains(&request.from_asset.chain) { + return Err(SwapperError::NotSupportedChain); + } + + let quote = self.fetch_quote_from_request(request, provider.clone()).await?; + + if quote.r#type != QuoteType::Swift.to_string() { + return Err(SwapperError::ComputeQuoteError { + msg: "Quote type is not SWIFT".to_string(), + }); + } + + // Create route information + let route = SwapRoute { + route_data: "swift-order".to_string(), + input: request.from_asset.clone(), + output: request.to_asset.clone(), + gas_estimate: None, + }; + + let approval = self.check_approval(request, provider.clone()).await?; + + Ok(SwapQuote { + from_value: request.value.clone(), + to_value: self + .convert_amount_to_wei(quote.min_amount_out, quote.to_token.decimals) + .map_err(|e| SwapperError::ComputeQuoteError { msg: e.to_string() })?, + data: SwapProviderData { + provider: self.provider(), + suggested_slippage_bps: Some(quote.slippage_bps), + routes: vec![route], + }, + approval, + request: request.clone(), + }) + } + + async fn fetch_quote_data(&self, quote: &SwapQuote, provider: Arc, _data: FetchQuoteData) -> Result { + let request = "e.request; + let mayan_quote = self.fetch_quote_from_request(request, provider.clone()).await?; + let swift_address = mayan_quote.swift_mayan_contract.clone().ok_or(SwapperError::ComputeQuoteError { + msg: "No swift_mayan_contract in quote".to_string(), + })?; + let swift_contract = MayanSwift::new(); + let swift_order_params = self.build_swift_order_params("e.request, &mayan_quote)?; + let forwarder = MayanForwarder::new(); + + let swift_call_data = if mayan_quote.swift_input_contract == MAYAN_ZERO_ADDRESS { + swift_contract + .encode_create_order_with_eth(swift_order_params) + .await + .map_err(|e| SwapperError::ABIError { msg: e.to_string() })? + } else { + swift_contract + .encode_create_order_with_token( + mayan_quote.swift_input_contract.as_str(), + quote.from_value.parse().map_err(|_| SwapperError::InvalidAmount)?, + swift_order_params, + ) + .await + .map_err(|e| SwapperError::ABIError { msg: e.to_string() })? + }; + + let mut value = quote.from_value.clone(); + let effective_amount_in = self + .get_amount_of_fractional_amount(mayan_quote.effective_amount_in, mayan_quote.from_token.decimals) + .map_err(|e| SwapperError::ComputeQuoteError { msg: e.to_string() })?; + + let forwarder_call_data = if mayan_quote.from_token.contract == mayan_quote.swift_input_contract { + if mayan_quote.from_token.contract == MAYAN_ZERO_ADDRESS { + forwarder + .encode_forward_eth_call(swift_address.as_str(), swift_call_data.clone()) + .await + .map_err(|e| SwapperError::ABIError { msg: e.to_string() })? + } else { + value = "0".to_string(); + forwarder + .encode_forward_erc20_call( + mayan_quote.swift_input_contract.as_str(), + U256::from_str(effective_amount_in.as_str()).unwrap(), + None, + swift_address.as_str(), + swift_call_data.clone(), + ) + .map_err(|e| SwapperError::ABIError { msg: e.to_string() })? + } + } else { + let evm_swap_router_address = mayan_quote.evm_swap_router_address.clone().ok_or_else(|| SwapperError::ComputeQuoteError { + msg: "Missing evmSwapRouterAddress".to_string(), + })?; + let evm_swap_router_calldata = mayan_quote.evm_swap_router_calldata.clone().ok_or_else(|| SwapperError::ComputeQuoteError { + msg: "Missing evmSwapRouterCalldata".to_string(), + })?; + let min_middle_amount = mayan_quote.min_middle_amount.ok_or_else(|| SwapperError::ComputeQuoteError { + msg: "Missing minMiddleAmount".to_string(), + })?; + + let token_in = mayan_quote.from_token.contract.clone(); + let formatted_min_middle_amount = self + .get_amount_of_fractional_amount(min_middle_amount, mayan_quote.swift_input_decimals) + .map_err(|e| SwapperError::ComputeQuoteError { msg: e.to_string() })?; + + let amount_in = U256::from_str(quote.from_value.as_str()).map_err(|_| SwapperError::InvalidAmount)?; + let swap_data = hex::decode(evm_swap_router_calldata.trim_start_matches("0x")).map_err(|_| SwapperError::ABIError { + msg: "Failed to decode evm_swap_router_calldata hex string without prefix 0x ".to_string(), + })?; + let min_middle_amount = U256::from_str(&formatted_min_middle_amount).map_err(|_| SwapperError::InvalidAmount)?; + + if mayan_quote.from_token.contract == MAYAN_ZERO_ADDRESS { + forwarder + .encode_swap_and_forward_eth_call( + amount_in, + evm_swap_router_address.as_str(), + swap_data, + mayan_quote.swift_input_contract.as_str(), + min_middle_amount, + swift_address.as_str(), + swift_call_data, + ) + .await + .map_err(|e| SwapperError::ABIError { msg: e.to_string() })? + } else { + value = "0".to_string(); + forwarder + .encode_swap_and_forward_erc20_call( + token_in.as_str(), + amount_in, + None, + evm_swap_router_address.as_str(), + swap_data, + mayan_quote.swift_input_contract.as_str(), + min_middle_amount, + swift_address.as_str(), + swift_call_data, + ) + .map_err(|e| SwapperError::ABIError { msg: e.to_string() })? + } + }; + + Ok(SwapQuoteData { + to: MAYAN_FORWARDER_CONTRACT.to_string(), + value: value.clone(), + data: forwarder_call_data.encode_hex(), + }) + } + + async fn get_transaction_status(&self, chain: Chain, transaction_hash: &str, provider: Arc) -> Result { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use primitives::AssetId; + + use crate::swapper::{GemSwapMode, GemSwapOptions}; + + use super::*; + + pub fn generate_mock_quote() -> Quote { + let data = include_str!("tests/quote_response.json"); + + let quote: Quote = serde_json::from_str(data).expect("Failed to deserialize Quote"); + quote + } + + /// Generates a `SwapQuoteRequest` object using values directly. + pub fn generate_mock_request() -> SwapQuoteRequest { + SwapQuoteRequest { + wallet_address: "0x0655c6AbdA5e2a5241aa08486bd50Cf7d475CF24".to_string(), + destination_address: "0x0655c6AbdA5e2a5241aa08486bd50Cf7d475CF24".to_string(), + from_asset: AssetId { + chain: Chain::Base, + token_id: None, + }, + to_asset: AssetId { + chain: Chain::Optimism, + token_id: None, + }, + value: "1230000000000000000".to_string(), + mode: GemSwapMode::ExactIn, + options: GemSwapOptions { + slippage_bps: 12, + fee: None, + preferred_providers: vec![], + }, + } + } + + #[test] + fn test_supported_chains() { + let provider = MayanSwiftProvider::default(); + let chains = provider.supported_chains(); + + assert!(chains.contains(&Chain::Solana)); + assert!(chains.contains(&Chain::Ethereum)); + assert!(chains.contains(&Chain::SmartChain)); + assert!(chains.contains(&Chain::Polygon)); + assert!(chains.contains(&Chain::Arbitrum)); + assert!(chains.contains(&Chain::Optimism)); + assert!(chains.contains(&Chain::Base)); + } + + #[test] + fn test_address_to_bytes32_valid() { + let provider = MayanSwiftProvider::default(); + let address = "0x0655c6AbdA5e2a5241aa08486bd50Cf7d475CF24"; + let bytes32 = provider.address_to_bytes32(address).unwrap(); + let expected_bytes32 = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 85, 198, 171, 218, 94, 42, 82, 65, 170, 8, 72, 107, 213, 12, 247, 212, 117, 207, 36, + ]; + assert_eq!(bytes32, expected_bytes32); + } + + #[test] + fn test_address_to_bytes32_invalid() { + let provider = MayanSwiftProvider::default(); + let invalid_address = "invalid_address"; + let result = provider.address_to_bytes32(invalid_address); + assert!(result.is_err()); + } + + #[test] + fn test_convert_amount_to_wei_valid() { + let provider = MayanSwiftProvider::default(); + let amount = 1.23; + let result = provider.convert_amount_to_wei(amount, 18).unwrap(); + assert_eq!(result, "1230000000000000000"); // 1.23 ETH in Wei + } + + #[test] + fn test_convert_amount_to_wei_invalid() { + let provider = MayanSwiftProvider::default(); + let amount = -1.0; + let result = provider.convert_amount_to_wei(amount, 18); + assert!(result.is_err()); + } + + #[test] + fn test_build_swift_order_params_valid() { + let provider = MayanSwiftProvider::default(); + let request = generate_mock_request(); + let quote = generate_mock_quote(); + + let result = provider.build_swift_order_params(&request, "e); + assert!(result.is_ok()); + } + + #[test] + fn test_get_amount_of_fractional_amount_valid() { + // Test with valid inputs and expected results + let test_cases = vec![ + (0.000075203, 18, 7520), // Regular case with precision truncation + (1.23456789, 8, 123456789), // Decimals less than 8 + (0.1, 6, 100000), // Simple rounding + (0.12345678, 8, 12345678), // Exact decimals + ]; + + for (amount, decimals, expected) in test_cases { + let provider = MayanSwiftProvider::default(); + let result = provider.get_amount_of_fractional_amount(amount, decimals); + assert!(result.is_ok(), "Failed for amount: {}", amount); + assert_eq!(result.unwrap(), expected.to_string()); + } + } +} diff --git a/gemstone/src/swapper/mayan/tests/quote_response.json b/gemstone/src/swapper/mayan/tests/quote_response.json new file mode 100644 index 00000000..700f2b35 --- /dev/null +++ b/gemstone/src/swapper/mayan/tests/quote_response.json @@ -0,0 +1,97 @@ +{ + "maxUserGasDrop": 0.01, + "sendTransactionCost": 0, + "rentCost": "40000000", + "gasless": false, + "swiftAuctionMode": 2, + "swiftMayanContract": "0xC38e4e6A15593f908255214653d3D947CA1c2338", + "minMiddleAmount": null, + "swiftInputContract": "0x0000000000000000000000000000000000000000", + "swiftInputDecimals": 18, + "slippageBps": 100, + "effectiveAmountIn": 0.1, + "expectedAmountOut": 0.09972757016582551, + "price": 0.9996900030000002, + "priceImpact": null, + "minAmountOut": 0.09873029446416727, + "minReceived": 0.09873029446416727, + "route": null, + "swapRelayerFee": null, + "swapRelayerFee64": null, + "redeemRelayerFee": null, + "redeemRelayerFee64": null, + "solanaRelayerFee": null, + "solanaRelayerFee64": null, + "refundRelayerFee": null, + "refundRelayerFee64": "1056", + "cancelRelayerFee64": "25", + "submitRelayerFee64": "0", + "deadline64": "1732777812", + "clientRelayerFeeSuccess": null, + "clientRelayerFeeRefund": 0.038947098479114796, + "fromToken": { + "name": "ETH", + "standard": "native", + "symbol": "ETH", + "mint": "", + "verified": true, + "contract": "0x0000000000000000000000000000000000000000", + "wrappedAddress": "0x4200000000000000000000000000000000000006", + "chainId": 8453, + "wChainId": 30, + "decimals": 18, + "logoURI": "https://statics.mayan.finance/eth.png", + "coingeckoId": "weth", + "pythUsdPriceId": "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + "realOriginContractAddress": "0x4200000000000000000000000000000000000006", + "realOriginChainId": 30, + "supportsPermit": false, + "hasAuction": true + }, + "fromChain": "base", + "toToken": { + "name": "ETH", + "standard": "native", + "symbol": "ETH", + "mint": "8M6d63oL7dvMZ1gNbgGe3h8afMSWJEKEhtPTFM2u8h3c", + "verified": true, + "contract": "0x0000000000000000000000000000000000000000", + "wrappedAddress": "0x4200000000000000000000000000000000000006", + "chainId": 10, + "wChainId": 24, + "decimals": 18, + "logoURI": "https://statics.mayan.finance/eth.png", + "coingeckoId": "weth", + "pythUsdPriceId": "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + "realOriginContractAddress": "0x4200000000000000000000000000000000000006", + "realOriginChainId": 24, + "supportsPermit": false, + "hasAuction": true + }, + "toTokenPrice": 3602.87682508, + "toChain": "optimism", + "mintDecimals": null, + "gasDrop": 0, + "eta": 1, + "etaSeconds": 12, + "clientEta": "12s", + "bridgeFee": 0, + "suggestedPriorityFee": 0, + "type": "SWIFT", + "priceStat": { + "ratio": 0.9996907516582549, + "status": "GOOD" + }, + "referrerBps": 0, + "protocolBps": 3, + "onlyBridging": false, + "sourceSwapExpense": 0, + "relayer": "7dm9am6Qx7cH64RB99Mzf7ZsLbEfmXM7ihXXCvMiT2X1", + "meta": { + "advertisedDescription": "Cheapest and Fastest", + "advertisedTitle": "Best", + "icon": "https://cdn.mayan.finance/fast_icon.png", + "switchText": "Switch to the best route", + "title": "Best" + } +} diff --git a/gemstone/src/swapper/mayan/tests/quote_token_response.json b/gemstone/src/swapper/mayan/tests/quote_token_response.json new file mode 100644 index 00000000..601cdfef --- /dev/null +++ b/gemstone/src/swapper/mayan/tests/quote_token_response.json @@ -0,0 +1,18 @@ +{ + "name": "ETH", + "standard": "native", + "symbol": "ETH", + "mint": "", + "verified": true, + "contract": "0x0000000000000000000000000000000000000000", + "wrappedAddress": "0x4200000000000000000000000000000000000006", + "chainId": 8453, + "wChainId": 30, + "decimals": 18, + "logoURI": "https://statics.mayan.finance/eth.png", + "coingeckoId": "weth", + "realOriginChainId": 30, + "realOriginContractAddress": "0x4200000000000000000000000000000000000006", + "supportsPermit": false, + "hasAuction": true +} diff --git a/gemstone/src/swapper/mod.rs b/gemstone/src/swapper/mod.rs index 67d76307..b7efad1e 100644 --- a/gemstone/src/swapper/mod.rs +++ b/gemstone/src/swapper/mod.rs @@ -8,6 +8,7 @@ mod custom_types; mod permit2_data; pub mod jupiter; +pub mod mayan; pub mod models; pub mod orca; pub mod pancakeswap_aptos; @@ -60,6 +61,7 @@ impl GemSwapper { Box::new(universal_router::UniswapV3::new_pancakeswap()), Box::new(thorchain::ThorChain::default()), Box::new(jupiter::Jupiter::default()), + Box::new(mayan::swift_provider::MayanSwiftProvider::default()), Box::new(pancakeswap_aptos::PancakeSwapAptos::default()), ], } diff --git a/gemstone/src/swapper/models.rs b/gemstone/src/swapper/models.rs index b01b0a63..05614822 100644 --- a/gemstone/src/swapper/models.rs +++ b/gemstone/src/swapper/models.rs @@ -58,6 +58,7 @@ pub enum SwapProvider { PancakeSwapAptosV2, Thorchain, Orca, + MayanSwift, Jupiter, } @@ -76,6 +77,7 @@ impl SwapProvider { Self::PancakeSwapAptosV2 => "PancakeSwap v2", Self::Thorchain => "THORChain", Self::Orca => "Orca Whirlpool", + Self::MayanSwift => "Mayan Swift", Self::Jupiter => "Jupiter", } } @@ -88,6 +90,7 @@ impl SwapProvider { Self::Thorchain => SwapProviderType::CrossChain, Self::Orca => SwapProviderType::OnChain, Self::Jupiter => SwapProviderType::OnChain, + Self::MayanSwift => SwapProviderType::CrossChain, } } } diff --git a/gemstone/src/swapper/universal_router/mod.rs b/gemstone/src/swapper/universal_router/mod.rs index e8419641..6a62fe66 100644 --- a/gemstone/src/swapper/universal_router/mod.rs +++ b/gemstone/src/swapper/universal_router/mod.rs @@ -53,6 +53,14 @@ impl JsonRpcRequestConvert for EthereumRpc { let value = serde_json::to_value(tx).unwrap(); vec![value, block.into()] } + EthereumRpc::GetTransactionReceipt(hash) => { + let value = serde_json::to_value(hash).unwrap(); + vec![value] + } + EthereumRpc::EstimateGas(tx) => { + let value = serde_json::to_value(tx).unwrap(); + vec![value] + } }; JsonRpcRequest::new(id, method, params) diff --git a/gemstone/tests/integration_test.rs b/gemstone/tests/integration_test.rs index 99b4f624..b105837e 100644 --- a/gemstone/tests/integration_test.rs +++ b/gemstone/tests/integration_test.rs @@ -6,18 +6,19 @@ mod tests { network::{provider::AlienProvider, target::*, *}, swapper::{orca::Orca, *}, }; - use primitives::{AssetId, Chain}; + use mayan::swift_provider::MayanSwiftProvider; + use primitives::{Asset, AssetId, Chain}; use reqwest::Client; use std::{collections::HashMap, sync::Arc}; #[derive(Debug)] pub struct NativeProvider { - pub node_config: HashMap, + pub node_config: HashMap, pub client: Client, } impl NativeProvider { - pub fn new(node_config: HashMap) -> Self { + pub fn new(node_config: HashMap) -> Self { Self { node_config, client: Client::new(), @@ -27,7 +28,7 @@ mod tests { #[async_trait] impl AlienProvider for NativeProvider { - fn get_endpoint(&self, chain: String) -> Result { + fn get_endpoint(&self, chain: Chain) -> Result { Ok(self .node_config .get(&chain) @@ -94,7 +95,7 @@ mod tests { #[tokio::test] async fn test_orca_get_quote_by_input() -> Result<(), SwapperError> { - let node_config = HashMap::from([(Chain::Solana.to_string(), "https://solana-rpc.publicnode.com".into())]); + let node_config = HashMap::from([(Chain::Solana, "https://solana-rpc.publicnode.com".into())]); let swap_provider: Box = Box::new(Orca::default()); let network_provider = Arc::new(NativeProvider::new(node_config)); @@ -105,12 +106,57 @@ mod tests { destination_address: "G7B17AigRCGvwnxFc5U8zY5T3NBGduLzT7KYApNU2VdR".into(), value: "1000000".into(), mode: GemSwapMode::ExactIn, - options: None, + options: GemSwapOptions { + slippage_bps: 10, + fee: None, + preferred_providers: vec![], + }, }; let quote = swap_provider.fetch_quote(&request, network_provider.clone()).await?; assert_eq!(quote.from_value, "1000000"); - assert!(quote.to_value.parse::().unwrap() > 0); + assert!(quote.to_value.parse::().unwrap() > 0); + + Ok(()) + } + + #[tokio::test] + async fn test_mayan_swift_quote() -> Result<(), SwapperError> { + const TEST_WALLET_ADDRESS: &str = "0x0655c6AbdA5e2a5241aa08486bd50Cf7d475CF24"; + + let node_config = HashMap::from([(Chain::Base, "https://mainnet.base.org".to_string())]); + let network_provider = Arc::new(NativeProvider::new(node_config)); + + let mayan_swift_provider = MayanSwiftProvider::default(); + + // Create a swap quote request + let request = SwapQuoteRequest { + from_asset: AssetId::from(Chain::Base, Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913".to_string())), + to_asset: AssetId::from_chain(Chain::Optimism), + wallet_address: TEST_WALLET_ADDRESS.to_string(), + destination_address: TEST_WALLET_ADDRESS.to_string(), + value: "9000000".to_string(), + mode: GemSwapMode::ExactIn, // Swap mode + options: GemSwapOptions { + slippage_bps: 10, + fee: None, + preferred_providers: vec![], + }, + }; + + let quote = mayan_swift_provider.fetch_quote(&request, network_provider.clone()).await?; + + assert_eq!(quote.from_value, "9000000"); + // Expect the to_value to be + assert!(quote.to_value.parse::().unwrap() > 0); + + // Verify + assert_eq!(quote.data.routes.len(), 1); + assert_eq!( + quote.data.routes[0].input, + AssetId::from(Chain::Base, Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913".to_string())), + ); + assert_eq!(quote.data.routes[0].output, AssetId::from_chain(Chain::Optimism)); Ok(()) }