diff --git a/Cargo.lock b/Cargo.lock index 19646821e..db8fdfca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7542,6 +7542,7 @@ dependencies = [ "async-trait", "base64", "bigdecimal", + "borsh", "bs58", "chrono", "futures", @@ -7567,6 +7568,7 @@ dependencies = [ "serde_json", "serde_serializers", "serde_urlencoded", + "sha2", "solana-primitives", "strum", "tokio", diff --git a/crates/gem_evm/src/across/contracts/spoke_pool.rs b/crates/gem_evm/src/across/contracts/spoke_pool.rs index 15f0966e5..4d3457177 100644 --- a/crates/gem_evm/src/across/contracts/spoke_pool.rs +++ b/crates/gem_evm/src/across/contracts/spoke_pool.rs @@ -1,7 +1,7 @@ use alloy_sol_types::sol; // https://docs.across.to/reference/selected-contract-functions -// https://github.com/across-protocol/contracts/blob/master/contracts/interfaces/SpokePoolInterface.sol +// https://github.com/across-protocol/contracts/blob/master/contracts/interfaces/V3SpokePoolInterface.sol sol! { // Contains structs and functions used by SpokePool contracts to facilitate universal settlement. interface V3SpokePoolInterface { @@ -10,16 +10,16 @@ sol! { // replay attacks on other chains. If any portion of this data differs, the relay is considered to be // completely distinct. struct V3RelayData { - // The address that made the deposit on the origin chain. - address depositor; - // The recipient address on the destination chain. - address recipient; + // The bytes32 that made the deposit on the origin chain. + bytes32 depositor; + // The recipient bytes32 on the destination chain. + bytes32 recipient; // This is the exclusive relayer who can fill the deposit before the exclusivity deadline. - address exclusiveRelayer; + bytes32 exclusiveRelayer; // Token that is deposited on origin chain by depositor. - address inputToken; + bytes32 inputToken; // Token that is received on destination chain by recipient. - address outputToken; + bytes32 outputToken; // The amount of input token deposited by depositor. uint256 inputAmount; // The amount of output token to be received by recipient. @@ -27,7 +27,7 @@ sol! { // Origin chain id. uint256 originChainId; // The id uniquely identifying this deposit on the origin chain. - uint32 depositId; + uint256 depositId; // The timestamp on the destination chain after which this deposit can no longer be filled. uint32 fillDeadline; // The timestamp on the destination chain after which any relayer can fill the deposit. @@ -38,21 +38,25 @@ sol! { function getCurrentTime() public view virtual returns (uint256); - function depositV3( - address depositor, - address recipient, - address inputToken, - address outputToken, + function deposit( + bytes32 depositor, + bytes32 recipient, + bytes32 inputToken, + bytes32 outputToken, uint256 inputAmount, uint256 outputAmount, uint256 destinationChainId, - address exclusiveRelayer, + bytes32 exclusiveRelayer, uint32 quoteTimestamp, uint32 fillDeadline, uint32 exclusivityDeadline, bytes calldata message ) external payable; - function fillV3Relay(V3RelayData calldata relayData, uint256 repaymentChainId) external; + function fillRelay( + V3RelayData calldata relayData, + uint256 repaymentChainId, + bytes32 repaymentAddress + ) external; } } diff --git a/crates/gem_evm/src/across/deployment.rs b/crates/gem_evm/src/across/deployment.rs index d0a411b77..823c00417 100644 --- a/crates/gem_evm/src/across/deployment.rs +++ b/crates/gem_evm/src/across/deployment.rs @@ -2,16 +2,18 @@ use super::fees::CapitalCostConfig; use crate::ether_conv::EtherConv; use alloy_primitives::map::HashSet; use num_bigint::BigInt; -use primitives::{AssetId, Chain, asset_constants::*}; +use primitives::{AssetId, Chain, ChainType, asset_constants::*}; use std::{collections::HashMap, vec}; pub const ACROSS_CONFIG_STORE: &str = "0x3B03509645713718B78951126E0A6de6f10043f5"; pub const ACROSS_HUBPOOL: &str = "0xc186fA914353c44b2E33eBE05f21846F1048bEda"; pub const MULTICALL_HANDLER: &str = "0x924a9f036260DdD5808007E1AA95f08eD08aA569"; +static SOLANA_CHAIN_ID: u64 = 34268394551451_u64; /// https://docs.across.to/developer-docs/developers/contract-addresses pub struct AcrossDeployment { - pub chain_id: u32, + pub chain_id: u64, + pub chain_type: ChainType, pub spoke_pool: &'static str, } @@ -23,68 +25,92 @@ pub struct AssetMapping { impl AcrossDeployment { pub fn deployment_by_chain(chain: &Chain) -> Option { - let chain_id: u32 = chain.network_id().parse().unwrap(); + let chain_id: u64 = if chain.chain_type() == ChainType::Solana { + SOLANA_CHAIN_ID + } else { + chain.network_id().parse().unwrap() + }; match chain { Chain::Ethereum => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", }), Chain::Arbitrum => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0xe35e9842fceaca96570b734083f4a58e8f7c5f2a", }), Chain::Base => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64", }), Chain::Blast => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", }), Chain::Linea => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x7E63A5f1a8F0B4d0934B2f2327DAED3F6bb2ee75", }), Chain::Optimism => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x6f26Bf09B1C792e3228e5467807a900A503c0281", }), Chain::Polygon => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x9295ee1d8C5b022Be115A2AD3c30C72E34e7F096", }), Chain::World => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64", }), Chain::ZkSync => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0xE0B015E54d54fc84a6cB9B666099c46adE9335FF", }), Chain::Ink => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0xeF684C38F94F48775959ECf2012D7E864ffb9dd4", }), Chain::Unichain => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64", }), Chain::Monad => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0xd2ecb3afe598b746F8123CaE365a598DA831A449", }), Chain::SmartChain => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x4e8E101924eDE233C13e2D8622DC8aED2872d505", }), Chain::Hyperliquid => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x35E63eA3eb0fb7A3bc543C71FB66412e1F6B0E04", }), Chain::Plasma => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x50039fAEfebef707cFD94D6d462fE6D10B39207a", }), + Chain::Solana => Some(Self { + chain_id: SOLANA_CHAIN_ID, + chain_type: ChainType::Solana, + spoke_pool: "DLv3NggMiSaef97YCkew5xKUHDh13tVGZ7tydt3ZeAru", + }), _ => None, } } @@ -92,15 +118,15 @@ impl AcrossDeployment { pub fn multicall_handler(&self) -> String { match self.chain_id { // Linea - 59144 => "0x1015c58894961F4F7Dd7D68ba033e28Ed3ee1cDB".into(), + 59144_u64 => "0x1015c58894961F4F7Dd7D68ba033e28Ed3ee1cDB".into(), // zkSync - 324 => "0x863859ef502F0Ee9676626ED5B418037252eFeb2".into(), + 324_u64 => "0x863859ef502F0Ee9676626ED5B418037252eFeb2".into(), // SmartChain - 56 => "0xAC537C12fE8f544D712d71ED4376a502EEa944d7".into(), + 56_u64 => "0xAC537C12fE8f544D712d71ED4376a502EEa944d7".into(), // Monad - 143 => "0xeC41F75c686e376Ab2a4F18bde263ab5822c4511".into(), + 143_u64 => "0xeC41F75c686e376Ab2a4F18bde263ab5822c4511".into(), // HyperEvm | Plasma - 999 | 9745 => "0x5E7840E06fAcCb6d1c3b5F5E0d1d3d07F2829bba".into(), + 999_u64 | 9745_u64 => "0x5E7840E06fAcCb6d1c3b5F5E0d1d3d07F2829bba".into(), _ => MULTICALL_HANDLER.into(), } } @@ -141,6 +167,7 @@ impl AcrossDeployment { (Chain::Monad, vec![USDC_MONAD_ASSET_ID.into(), USDT_MONAD_ASSET_ID.into()]), (Chain::SmartChain, vec![ETH_SMARTCHAIN_ASSET_ID.into()]), (Chain::Plasma, vec![USDT_PLASMA_ASSET_ID.into()]), + (Chain::Solana, vec![USDC_SOLANA_ASSET_ID.into(), USDT_SOLANA_ASSET_ID.into()]), ]) } @@ -184,6 +211,7 @@ impl AcrossDeployment { USDC_UNICHAIN_ASSET_ID.into(), USDC_HYPEREVM_ASSET_ID.into(), USDC_MONAD_ASSET_ID.into(), + USDC_SOLANA_ASSET_ID.into(), ]), }, // USDC on BSC decimals are 18 @@ -214,6 +242,7 @@ impl AcrossDeployment { USDT_HYPEREVM_ASSET_ID.into(), USDT_PLASMA_ASSET_ID.into(), USDT_MONAD_ASSET_ID.into(), + USDT_SOLANA_ASSET_ID.into(), ]), }, // USDT on BSC decimals are 18 diff --git a/crates/gem_evm/src/chainlink/contract.rs b/crates/gem_evm/src/chainlink/contract.rs index c7b4f1e4b..ef553f184 100644 --- a/crates/gem_evm/src/chainlink/contract.rs +++ b/crates/gem_evm/src/chainlink/contract.rs @@ -9,3 +9,4 @@ sol! { pub const CHAINLINK_ETH_USD_FEED: &str = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"; pub const CHAINLINK_MON_USD_FEED: &str = "0xBcD78f76005B7515837af6b50c7C52BCf73822fb"; +pub const CHAINLINK_SOL_USD_FEED: &str = "0x4ffC43a60e009B551865A93d232E33Fce9f01507"; diff --git a/crates/gem_solana/src/jsonrpc.rs b/crates/gem_solana/src/jsonrpc.rs index 1b1e2a651..66ece6417 100644 --- a/crates/gem_solana/src/jsonrpc.rs +++ b/crates/gem_solana/src/jsonrpc.rs @@ -11,8 +11,10 @@ pub enum SolanaRpc { GetProgramAccounts(String, Vec), GetAccountInfo(String), GetMultipleAccounts(Vec), + GetTransaction(String), GetEpochInfo, GetLatestBlockhash, + GetRecentPrioritizationFees, } impl Display for SolanaRpc { @@ -21,8 +23,10 @@ impl Display for SolanaRpc { SolanaRpc::GetProgramAccounts(_, _) => write!(f, "getProgramAccounts"), SolanaRpc::GetAccountInfo(_) => write!(f, "getAccountInfo"), SolanaRpc::GetMultipleAccounts(_) => write!(f, "getMultipleAccounts"), + SolanaRpc::GetTransaction(_) => write!(f, "getTransaction"), SolanaRpc::GetEpochInfo => write!(f, "getEpochInfo"), SolanaRpc::GetLatestBlockhash => write!(f, "getLatestBlockhash"), + SolanaRpc::GetRecentPrioritizationFees => write!(f, "getRecentPrioritizationFees"), } } } @@ -40,7 +44,8 @@ impl JsonRpcRequestConvert for SolanaRpc { Value::Array(accounts.iter().map(|x| serde_json::to_value(x).unwrap()).collect()), serde_json::to_value(default_config).unwrap(), ], - SolanaRpc::GetEpochInfo | SolanaRpc::GetLatestBlockhash => vec![], + SolanaRpc::GetTransaction(signature) => vec![Value::String(signature.into()), serde_json::json!({ "maxSupportedTransactionVersion": 0 })], + SolanaRpc::GetEpochInfo | SolanaRpc::GetLatestBlockhash | SolanaRpc::GetRecentPrioritizationFees => vec![], }; JsonRpcRequest::new(id, &method, params.into()) diff --git a/crates/gem_solana/src/models/transaction.rs b/crates/gem_solana/src/models/transaction.rs index 90188433e..93bf3d34d 100644 --- a/crates/gem_solana/src/models/transaction.rs +++ b/crates/gem_solana/src/models/transaction.rs @@ -176,3 +176,17 @@ pub struct SingleTransaction { pub meta: Meta, pub transaction: Transaction, } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionMetaWithLogs { + pub err: Option, + pub log_messages: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionWithLogs { + pub slot: u64, + pub meta: Option, +} diff --git a/crates/primitives/src/asset_constants.rs b/crates/primitives/src/asset_constants.rs index 38545b81e..5d191afd1 100644 --- a/crates/primitives/src/asset_constants.rs +++ b/crates/primitives/src/asset_constants.rs @@ -71,7 +71,7 @@ pub const USDT_POLYGON_ASSET_ID: &str = "polygon_0xc2132D05D31c914a87C6611C10748 pub const USDT_ZKSYNC_ASSET_ID: &str = "zksync_0x493257fD37EDB34451f62EDf8D2a0C418852bA4C"; pub const USDT_SMARTCHAIN_ASSET_ID: &str = "smartchain_0x55d398326f99059fF775485246999027B3197955"; pub const USDT_AVAX_ASSET_ID: &str = "avalanchec_0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7"; -pub const USDT_SOLANA_ASSET_ID: &str = "solana_Es9vMFrzaCERmJFRf4H2Fyd4KCoNkY11McCe8BEnwNYB"; +pub const USDT_SOLANA_ASSET_ID: &str = "solana_Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; pub const USDT_TRON_ASSET_ID: &str = "tron_TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; pub const USDT_TON_ASSET_ID: &str = "ton_EQcxE6MuTQJkfNGfAARoTKOVt1LZBADiix1KCixRv7NW2id_sDs"; pub const USDT_NEAR_ASSET_ID: &str = "near_usdt.tether-token.near"; diff --git a/crates/swapper/Cargo.toml b/crates/swapper/Cargo.toml index 85575e62c..c8b09c483 100644 --- a/crates/swapper/Cargo.toml +++ b/crates/swapper/Cargo.toml @@ -25,6 +25,7 @@ serde_serializers = { path = "../serde_serializers" } number_formatter = { path = "../number_formatter" } reqwest = { workspace = true, optional = true } +borsh.workspace = true typeshare = { version = "1.0.4" } strum = { workspace = true } @@ -37,6 +38,7 @@ async-trait.workspace = true chrono.workspace = true alloy-primitives.workspace = true alloy-sol-types.workspace = true +sha2.workspace = true hex.workspace = true num-bigint.workspace = true num-integer.workspace = true diff --git a/crates/swapper/src/across/api.rs b/crates/swapper/src/across/api.rs index f06cf51da..b8913f28c 100644 --- a/crates/swapper/src/across/api.rs +++ b/crates/swapper/src/across/api.rs @@ -3,6 +3,7 @@ use crate::{ alien::{RpcProvider, Target}, client_factory::create_eth_client, }; +use gem_evm::across::deployment::AcrossDeployment; use primitives::{Chain, swap::SwapStatus}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -47,6 +48,13 @@ impl DepositStatus { impl AcrossApi { pub async fn deposit_status(&self, chain: Chain, tx_hash: &str) -> Result { + if chain == Chain::Solana { + return self.deposit_status_solana(tx_hash).await; + } + self.deposit_status_evm(chain, tx_hash).await + } + + async fn deposit_status_evm(&self, chain: Chain, tx_hash: &str) -> Result { let receipt = create_eth_client(self.provider.clone(), chain)? .get_transaction_receipt(tx_hash) .await @@ -73,11 +81,20 @@ impl AcrossApi { deposit_id_hex }; - let url = format!("{}/api/deposit/status?originChainId={}&depositId={}", self.url, chain.network_id(), &deposit_id); + let query = format!("originChainId={}&depositId={}", chain.network_id(), deposit_id); + self.fetch_deposit_status(&query).await + } + + async fn deposit_status_solana(&self, tx_hash: &str) -> Result { + let deployment = AcrossDeployment::deployment_by_chain(&Chain::Solana).ok_or(SwapperError::NotSupportedChain)?; + let query = format!("originChainId={}&depositTxHash={}", deployment.chain_id, tx_hash); + self.fetch_deposit_status(&query).await + } + + async fn fetch_deposit_status(&self, query: &str) -> Result { + let url = format!("{}/api/deposit/status?{}", self.url, query); let target = Target::get(&url); let response = self.provider.request(target).await?; - let status: DepositStatus = serde_json::from_slice(&response.data).map_err(SwapperError::from)?; - - Ok(status) + serde_json::from_slice(&response.data).map_err(SwapperError::from) } } diff --git a/crates/swapper/src/across/mod.rs b/crates/swapper/src/across/mod.rs index 54a247b0d..daf68bafb 100644 --- a/crates/swapper/src/across/mod.rs +++ b/crates/swapper/src/across/mod.rs @@ -3,7 +3,11 @@ pub use provider::Across; pub mod api; pub mod config_store; pub mod hubpool; +pub mod models; +pub mod solana; +mod solana_tx; const DEFAULT_FILL_TIMEOUT: u32 = 60 * 60 * 6; // 6 hours const DEFAULT_DEPOSIT_GAS_LIMIT: u64 = 180_000; // gwei const DEFAULT_FILL_GAS_LIMIT: u64 = 120_000; // gwei +const MESSAGE_GAS_MULTIPLIER: u64 = 10; diff --git a/crates/swapper/src/across/models.rs b/crates/swapper/src/across/models.rs new file mode 100644 index 000000000..b41866a83 --- /dev/null +++ b/crates/swapper/src/across/models.rs @@ -0,0 +1,37 @@ +use alloy_primitives::{Address, U256}; +use gem_evm::across::{deployment::AcrossDeployment, fees}; +use primitives::{AssetId, Chain}; +use solana_primitives::types::Pubkey as SolanaPubkey; + +use crate::config::ReferralFee; + +pub struct QuoteContext<'a> { + pub from_amount: U256, + pub depositor: RelayRecipient, + pub evm_address: Address, + pub from_chain: Chain, + pub to_chain: Chain, + pub input_is_native: bool, + pub input_asset: AssetId, + pub output_asset: AssetId, + pub original_output_asset: AssetId, + pub mainnet_token: Address, + pub capital_cost: fees::CapitalCostConfig, + pub referral_fee: ReferralFee, + pub destination_deployment: AcrossDeployment, + pub solana_destination_address: Option<&'a str>, + pub output_token_decimals: u8, +} + +#[derive(Clone, Debug)] +pub struct DestinationMessage { + pub bytes: Vec, + pub referral_fee: U256, + pub recipient: RelayRecipient, +} + +#[derive(Clone, Debug)] +pub enum RelayRecipient { + Evm(Address), + Solana(SolanaPubkey), +} diff --git a/crates/swapper/src/across/provider.rs b/crates/swapper/src/across/provider.rs index 14d6068d5..66277509f 100644 --- a/crates/swapper/src/across/provider.rs +++ b/crates/swapper/src/across/provider.rs @@ -3,25 +3,29 @@ use super::{ api::AcrossApi, config_store::{ConfigStoreClient, TokenConfig}, hubpool::HubPoolClient, + models::{DestinationMessage, QuoteContext, RelayRecipient}, + solana::{AcrossPlusMessage, CompiledIx, DEFAULT_SOLANA_COMPUTE_LIMIT, MULTICALL_HANDLER, SOL_NATIVE_DECIMALS}, + solana_tx, }; use crate::{ SwapResult, Swapper, SwapperError, SwapperProvider, SwapperQuoteData, - across::{DEFAULT_DEPOSIT_GAS_LIMIT, DEFAULT_FILL_GAS_LIMIT}, + across::{DEFAULT_DEPOSIT_GAS_LIMIT, DEFAULT_FILL_GAS_LIMIT, MESSAGE_GAS_MULTIPLIER}, alien::RpcProvider, approval::check_approval_erc20, asset::*, chainlink::ChainlinkPriceFeed, - client_factory::create_eth_client, - config::ReferralFee, + client_factory::{create_client_with_chain, create_eth_client}, + error::{INVALID_ADDRESS, INVALID_AMOUNT}, eth_address, models::*, }; use alloy_primitives::{ - Address, Bytes, U256, + Address, Bytes, FixedBytes, U256, hex::{decode as HexDecode, encode_prefixed as HexEncode}, }; use alloy_sol_types::{SolCall, SolValue}; use async_trait::async_trait; +use bs58; use gem_evm::{ across::{ contracts::{ @@ -36,10 +40,24 @@ use gem_evm::{ multicall3::IMulticall3, weth::WETH9, }; +use gem_solana::{jsonrpc::SolanaRpc, models::prioritization_fee::SolanaPrioritizationFee}; use num_bigint::{BigInt, Sign}; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData, swap::SwapStatus}; +use primitives::{AssetId, Chain, ChainType, EVMChain, swap::ApprovalData, swap::SwapStatus}; use serde_serializers::biguint_from_hex_str; -use std::{fmt::Debug, str::FromStr, sync::Arc}; +use solana_primitives::{ + instructions::{associated_token::get_associated_token_address, program_ids, token::transfer as spl_transfer}, + types::{Instruction as SolInstruction, Pubkey as SolanaPubkey, find_program_address}, +}; +use std::{collections::HashMap, fmt::Debug, str::FromStr, sync::Arc}; + +struct PoolState { + token_config: TokenConfig, + utilization_before: BigInt, + utilization_after: BigInt, + timestamp: u32, + eth_price: Option, + sol_price: Option, +} #[derive(Debug)] pub struct Across { @@ -69,6 +87,10 @@ impl Across { } pub fn is_supported_pair(from_asset: &AssetId, to_asset: &AssetId) -> bool { + if from_asset.chain == Chain::Solana || to_asset.chain == Chain::Solana { + return false; + } + let Some(from) = eth_address::convert_native_to_weth(from_asset) else { return false; }; @@ -79,72 +101,415 @@ impl Across { AcrossDeployment::asset_mappings().into_iter().any(|x| x.set.contains(&from) && x.set.contains(&to)) } - pub fn get_rate_model(from_asset: &AssetId, to_asset: &AssetId, token_config: &TokenConfig) -> RateModel { - let key = format!("{}-{}", from_asset.chain.network_id(), to_asset.chain.network_id()); - let rate_model = token_config.route_rate_model.get(&key).unwrap_or(&token_config.rate_model); - rate_model.clone().into() + fn decode_address_bytes32(addr: &Address) -> FixedBytes<32> { + let mut bytes = [0u8; 32]; + bytes[12..32].copy_from_slice(addr.as_slice()); + FixedBytes::from(bytes) } - async fn gas_price(&self, chain: Chain) -> Result { - let gas_price = create_eth_client(self.rpc_provider.clone(), chain)?.gas_price().await?; - Self::bigint_to_u256(&gas_price) + fn decode_bs58_bytes32(addr: &str) -> Result, SwapperError> { + let invalid_address = || SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {addr}")); + let decoded = bs58::decode(addr).into_vec().map_err(|_| invalid_address())?; + if decoded.len() != 32 { + return Err(invalid_address()); + } + let bytes: [u8; 32] = decoded.try_into().map_err(|_| invalid_address())?; + Ok(FixedBytes::from(bytes)) } - async fn multicall3(&self, chain: Chain, calls: Vec) -> Result, SwapperError> { - create_eth_client(self.rpc_provider.clone(), chain)? - .multicall3(calls) - .await - .map_err(|e| SwapperError::ComputeQuoteError(e.to_string())) + fn recipient_to_fixed_bytes(recipient: &RelayRecipient) -> Result, SwapperError> { + match recipient { + RelayRecipient::Evm(address) => Ok(Self::decode_address_bytes32(address)), + RelayRecipient::Solana(pubkey) => Ok(FixedBytes::from(*pubkey.as_bytes())), + } } - async fn estimate_gas_transaction(&self, chain: Chain, tx: TransactionObject) -> Result { - let client = create_eth_client(self.rpc_provider.clone(), chain)?; - let gas_hex = client.estimate_gas(tx.from.as_deref(), &tx.to, tx.value.as_deref(), Some(tx.data.as_str())).await?; + fn recipient_evm_address(recipient: &RelayRecipient) -> Option<&Address> { + match recipient { + RelayRecipient::Evm(address) => Some(address), + RelayRecipient::Solana(_) => None, + } + } - let gas_biguint = biguint_from_hex_str(&gas_hex).map_err(|e| SwapperError::ComputeQuoteError(format!("Failed to parse gas estimate: {e}")))?; - let gas_bigint = BigInt::from_biguint(Sign::Plus, gas_biguint); - Self::bigint_to_u256(&gas_bigint) + fn token_bytes32_for_asset(asset: &AssetId) -> Result, SwapperError> { + match asset.chain.chain_type() { + ChainType::Solana => { + let id = asset + .token_id + .as_deref() + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: missing token_id for Solana")))?; + Self::decode_bs58_bytes32(id) + } + ChainType::Ethereum => { + let evm_chain = EVMChain::from_chain(asset.chain).ok_or(SwapperError::NotSupportedChain)?; + let default_weth = evm_chain.weth_contract().ok_or(SwapperError::NotSupportedChain)?; + let id = if asset.is_native() { default_weth } else { asset.token_id.as_deref().unwrap() }; + Ok(Self::decode_address_bytes32(ð_address::parse_str(id)?)) + } + _ => Err(SwapperError::NotSupportedChain), + } } - /// Return (message, referral_fee) - pub fn message_for_multicall_handler( - &self, - amount: &U256, - original_output_asset: &AssetId, - output_token: &Address, - user_address: &Address, - referral_fee: &ReferralFee, - ) -> (Vec, U256) { - if referral_fee.bps == 0 { - return (vec![], U256::from(0)); + fn is_solana_destination(request: &QuoteRequest) -> bool { + request.to_asset.chain() == Chain::Solana + } + + fn is_solana_origin(request: &QuoteRequest) -> bool { + request.from_asset.chain() == Chain::Solana + } + + fn get_output_asset(request: &QuoteRequest) -> Result { + if Self::is_solana_destination(request) { + Ok(request.to_asset.asset_id()) + } else { + eth_address::convert_native_to_weth(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset) } - let fee_address = Address::from_str(&referral_fee.address).unwrap(); + } + + fn get_destination_chain_id(chain: &Chain) -> Result { + let deployment = AcrossDeployment::deployment_by_chain(chain).ok_or(SwapperError::NotSupportedChain)?; + Ok(deployment.chain_id) + } + + fn build_context<'a>(&self, request: &'a QuoteRequest) -> Result, SwapperError> { + if request.from_asset.chain() == request.to_asset.chain() { + return Err(SwapperError::NoQuoteAvailable); + } + if request.from_asset.chain() == Chain::Solana || request.to_asset.chain() == Chain::Solana { + return Err(SwapperError::NoQuoteAvailable); + } + + let from_amount: U256 = request.value.parse().map_err(SwapperError::from)?; + let from_chain = request.from_asset.chain(); + let to_chain = request.to_asset.chain(); + let is_solana_origin = Self::is_solana_origin(request); + let depositor = if is_solana_origin { + let depositor_address = + SolanaPubkey::from_str(&request.wallet_address).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {}", request.wallet_address)))?; + RelayRecipient::Solana(depositor_address) + } else { + RelayRecipient::Evm(eth_address::parse_str(&request.wallet_address)?) + }; + let evm_address = if is_solana_origin { + eth_address::parse_str(&request.destination_address)? + } else { + eth_address::parse_str(&request.wallet_address)? + }; + + let _origin_deployment = AcrossDeployment::deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; + let destination_deployment = AcrossDeployment::deployment_by_chain(&to_chain).ok_or(SwapperError::NotSupportedChain)?; + + if !Self::is_supported_pair(&request.from_asset.asset_id(), &request.to_asset.asset_id()) { + return Err(SwapperError::NoQuoteAvailable); + } + + let input_asset = if is_solana_origin { + request.from_asset.asset_id() + } else { + eth_address::convert_native_to_weth(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)? + }; + let output_asset = Self::get_output_asset(request)?; + let original_output_asset = request.to_asset.asset_id(); + + let asset_mapping = AcrossDeployment::asset_mappings() + .into_iter() + .find(|mapping| mapping.set.contains(&input_asset)) + .ok_or(SwapperError::NoQuoteAvailable)?; + let mainnet_asset = asset_mapping + .set + .iter() + .find(|asset| asset.chain == Chain::Ethereum) + .cloned() + .ok_or(SwapperError::NoQuoteAvailable)?; + let mainnet_chain = EVMChain::from_chain(mainnet_asset.chain).ok_or(SwapperError::NotSupportedChain)?; + let mainnet_token = eth_address::parse_or_weth_address(&mainnet_asset, mainnet_chain)?; + + let referral_fees = request.options.fee.clone().unwrap_or_default(); + let referral_fee = referral_fees.evm_bridge; + + let output_token_decimals = u8::try_from(asset_mapping.capital_cost.decimals).map_err(|_| SwapperError::ComputeQuoteError("Unsupported token decimals".into()))?; + + Ok(QuoteContext { + from_amount, + depositor, + evm_address, + from_chain, + to_chain, + input_is_native: request.from_asset.is_native(), + input_asset, + output_asset, + original_output_asset, + mainnet_token, + capital_cost: asset_mapping.capital_cost, + referral_fee, + destination_deployment, + solana_destination_address: if to_chain == Chain::Solana { Some(request.destination_address.as_str()) } else { None }, + output_token_decimals, + }) + } + + async fn fetch_pool_state(&self, ctx: &QuoteContext<'_>) -> Result { + let hubpool_client = HubPoolClient::new(self.rpc_provider.clone(), Chain::Ethereum); + let config_client = ConfigStoreClient::new(self.rpc_provider.clone(), Chain::Ethereum); + + let preflight_calls = vec![ + hubpool_client.paused_call3(), + hubpool_client.sync_call3(&ctx.mainnet_token), + hubpool_client.pooled_token_call3(&ctx.mainnet_token), + ]; + let preflight_results = self.multicall3(hubpool_client.chain, preflight_calls).await?; + + if hubpool_client.decoded_paused_call3(&preflight_results[0])? { + return Err(SwapperError::ComputeQuoteError("Across protocol is paused".into())); + } + + let reserves = hubpool_client.decoded_pooled_token_call3(&preflight_results[2])?.liquidReserves; + if ctx.from_amount > reserves { + return Err(SwapperError::ComputeQuoteError("Bridge amount is too large".into())); + } + + let token_config_future = config_client.fetch_config(&ctx.mainnet_token); + + let mut call_requests = vec![ + hubpool_client.utilization_call3(&ctx.mainnet_token, U256::from(0)), + hubpool_client.utilization_call3(&ctx.mainnet_token, ctx.from_amount), + hubpool_client.get_current_time(), + ]; + + let mut index_tracker: HashMap<&'static str, usize> = HashMap::new(); + let mut next_index = 3usize; + + if !ctx.input_is_native && ctx.to_chain != Chain::Monad { + let feed = ChainlinkPriceFeed::new_usd_feed_for_chain(ctx.to_chain).unwrap_or_else(ChainlinkPriceFeed::new_eth_usd_feed); + call_requests.push(feed.latest_round_call3()); + index_tracker.insert("eth_price", next_index); + next_index += 1; + } + + if ctx.to_chain == Chain::Solana { + call_requests.push(ChainlinkPriceFeed::new_sol_usd_feed().latest_round_call3()); + index_tracker.insert("sol_price", next_index); + } + + let multicall_future = self.multicall3(hubpool_client.chain, call_requests); + let (token_config, multicall_results) = futures::join!(token_config_future, multicall_future); + + let token_config = token_config?; + let multicall_results = multicall_results?; + + let utilization_before = hubpool_client.decoded_utilization_call3(&multicall_results[0])?; + let utilization_after = hubpool_client.decoded_utilization_call3(&multicall_results[1])?; + let timestamp = hubpool_client.decoded_current_time(&multicall_results[2])?; + + let mut eth_price = None; + if !ctx.input_is_native { + if ctx.to_chain == Chain::Monad { + let feed = ChainlinkPriceFeed::new_usd_feed_for_chain(ctx.to_chain).unwrap_or_else(ChainlinkPriceFeed::new_eth_usd_feed); + let results = create_eth_client(self.rpc_provider.clone(), Chain::Monad)? + .multicall3(vec![feed.latest_round_call3()]) + .await + .map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; + eth_price = Some(ChainlinkPriceFeed::decoded_answer(&results[0])?); + } else if let Some(index) = index_tracker.get("eth_price") { + eth_price = Some(ChainlinkPriceFeed::decoded_answer(&multicall_results[*index])?); + } + } + + let sol_price = index_tracker + .get("sol_price") + .map(|index| ChainlinkPriceFeed::decoded_answer(&multicall_results[*index])) + .transpose()?; + + Ok(PoolState { + token_config, + utilization_before, + utilization_after, + timestamp, + eth_price, + sol_price, + }) + } + + fn build_v3_relay_data(&self, ctx: &QuoteContext<'_>, recipient: FixedBytes<32>, output_token: FixedBytes<32>, message: &[u8]) -> Result { + let origin_chain_id = Self::get_destination_chain_id(&ctx.from_chain)?; + let depositor = Self::recipient_to_fixed_bytes(&ctx.depositor)?; + + Ok(V3RelayData { + depositor, + recipient, + exclusiveRelayer: FixedBytes::from([0u8; 32]), + inputToken: Self::token_bytes32_for_asset(&ctx.input_asset)?, + outputToken: output_token, + inputAmount: ctx.from_amount, + outputAmount: U256::from(100), + originChainId: U256::from(origin_chain_id), + depositId: U256::from(u32::MAX), + fillDeadline: u32::MAX, + exclusivityDeadline: 0, + message: Bytes::from(message.to_vec()), + }) + } + + fn calculate_relayer_capital_fee(from_amount: U256, cost_config: &fees::CapitalCostConfig) -> U256 { + let relayer_calc = RelayerFeeCalculator::default(); + let from_amount_bigint = BigInt::from_bytes_le(Sign::Plus, &from_amount.to_le_bytes::<32>()); + let relayer_fee_percent = relayer_calc.capital_fee_percent(&from_amount_bigint, cost_config); + fees::multiply(from_amount, relayer_fee_percent, cost_config.decimals) + } + + pub fn get_rate_model(from_asset: &AssetId, to_asset: &AssetId, token_config: &TokenConfig) -> RateModel { + let key = format!("{}-{}", from_asset.chain.network_id(), to_asset.chain.network_id()); + let rate_model = token_config.route_rate_model.get(&key).unwrap_or(&token_config.rate_model); + rate_model.clone().into() + } + + fn build_destination_message(&self, ctx: &QuoteContext<'_>, amount: &U256, output_token_evm: Option<&Address>) -> Result { + match ctx.to_chain.chain_type() { + ChainType::Ethereum => self.build_evm_destination_message(ctx, amount, output_token_evm), + ChainType::Solana => self.build_solana_destination_message(ctx, amount), + _ => Err(SwapperError::NotSupportedChain), + } + } + + fn build_evm_destination_message(&self, ctx: &QuoteContext<'_>, amount: &U256, output_token_evm: Option<&Address>) -> Result { + let referral_fee = &ctx.referral_fee; + if referral_fee.bps == 0 || referral_fee.address.is_empty() { + return Ok(DestinationMessage { + bytes: vec![], + referral_fee: U256::from(0), + recipient: RelayRecipient::Evm(ctx.evm_address), + }); + } + + let token = output_token_evm.ok_or(SwapperError::NotSupportedAsset)?; + let fee_address = Address::from_str(&referral_fee.address).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {}", referral_fee.address)))?; let fee_amount = amount * U256::from(referral_fee.bps) / U256::from(10000); - let user_amount = amount - fee_amount; - let calls = if original_output_asset.is_native() { - // output_token is WETH and we need to unwrap it - Self::unwrap_weth_calls(output_token, amount, user_address, &user_amount, &fee_address, &fee_amount) + let calls = if ctx.original_output_asset.is_native() { + Self::unwrap_weth_calls(token, amount, &fee_address, &fee_amount) } else { - Self::erc20_transfer_calls(output_token, user_address, &user_amount, &fee_address, &fee_amount) + Self::erc20_transfer_calls(token, &fee_address, &fee_amount) }; + let instructions = multicall_handler::Instructions { calls, - fallbackRecipient: *user_address, + fallbackRecipient: ctx.evm_address, }; let message = instructions.abi_encode(); - (message, fee_amount) - } - - fn unwrap_weth_calls( - weth_contract: &Address, - output_amount: &U256, - user_address: &Address, - user_amount: &U256, - fee_address: &Address, - fee_amount: &U256, - ) -> Vec { - assert!(fee_amount + user_amount == *output_amount); + let multicall_address = eth_address::parse_str(ctx.destination_deployment.multicall_handler().as_str())?; + + Ok(DestinationMessage { + bytes: message, + referral_fee: fee_amount, + recipient: RelayRecipient::Evm(multicall_address), + }) + } + + fn build_solana_destination_message(&self, ctx: &QuoteContext<'_>, amount: &U256) -> Result { + let destination_address = ctx + .solana_destination_address + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: Missing Solana destination address")))?; + let user_account = SolanaPubkey::from_str(destination_address).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {destination_address}")))?; + + let referral_fee = &ctx.referral_fee; + if referral_fee.bps == 0 || referral_fee.address.is_empty() { + return Ok(DestinationMessage { + bytes: vec![], + referral_fee: U256::from(0), + recipient: RelayRecipient::Solana(user_account), + }); + } + + let referral_account = + SolanaPubkey::from_str(&referral_fee.address).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {}", referral_fee.address)))?; + let handler_program = SolanaPubkey::from_str(MULTICALL_HANDLER).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {MULTICALL_HANDLER}")))?; + let (handler_signer, _) = + find_program_address(&handler_program, &[b"handler_signer"]).map_err(|_| SwapperError::ComputeQuoteError("Failed to derive handler signer".into()))?; + + let mint_id = ctx + .original_output_asset + .token_id + .as_deref() + .ok_or_else(|| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: Missing Solana mint")))?; + let mint = SolanaPubkey::from_str(mint_id).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {mint_id}")))?; + + let token_program = + SolanaPubkey::from_str(program_ids::TOKEN_PROGRAM_ID).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {}", program_ids::TOKEN_PROGRAM_ID)))?; + + let handler_token_account = get_associated_token_address(&handler_signer, &mint); + let referral_token_account = get_associated_token_address(&referral_account, &mint); + let user_token_account = get_associated_token_address(&user_account, &mint); + + let fee_amount = amount * U256::from(referral_fee.bps) / U256::from(10000); + let user_amount = amount - fee_amount; + + let fee_amount_u64: u64 = fee_amount + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_AMOUNT}: Referral fee overflow")))?; + let user_amount_u64: u64 = user_amount + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_AMOUNT}: User amount overflow")))?; + + let transfer_fee_ix = spl_transfer(&handler_token_account, &referral_token_account, &handler_signer, fee_amount_u64); + let transfer_user_ix = spl_transfer(&handler_token_account, &user_token_account, &handler_signer, user_amount_u64); + + let accounts = vec![handler_token_account, referral_token_account, user_token_account, handler_signer, token_program]; + + let compiled_ixs = self.compile_solana_instructions(&[transfer_fee_ix, transfer_user_ix], &accounts)?; + let handler_message = borsh::to_vec(&compiled_ixs).map_err(|_| SwapperError::ComputeQuoteError("Failed to encode handler message".into()))?; + + let across_message = AcrossPlusMessage { + handler: handler_program, + read_only_len: 2, // handler_signer and token_program are read-only + value_amount: 0, + accounts, + handler_message, + }; + let message_bytes = borsh::to_vec(&across_message).map_err(|_| SwapperError::ComputeQuoteError("Failed to encode Across message".into()))?; + + Ok(DestinationMessage { + bytes: message_bytes, + referral_fee: fee_amount, + recipient: RelayRecipient::Solana(handler_signer), + }) + } + + fn compile_solana_instructions(&self, instructions: &[SolInstruction], accounts: &[SolanaPubkey]) -> Result, SwapperError> { + let mut account_index_map: HashMap = HashMap::new(); + for (idx, account) in accounts.iter().enumerate() { + account_index_map.insert(account.to_base58(), idx as u8); + } + + let mut compiled = Vec::with_capacity(instructions.len()); + for instruction in instructions { + let program_key = instruction.program_id.to_base58(); + let program_index = account_index_map + .get(&program_key) + .copied() + .ok_or_else(|| SwapperError::ComputeQuoteError("Program account missing from message".into()))?; + + let mut account_key_indexes = Vec::with_capacity(instruction.accounts.len()); + for account in &instruction.accounts { + let key = account.pubkey.to_base58(); + let index = account_index_map + .get(&key) + .copied() + .ok_or_else(|| SwapperError::ComputeQuoteError("Account missing from message".into()))?; + account_key_indexes.push(index); + } + + compiled.push(CompiledIx { + program_id_index: program_index, + account_key_indexes, + data: instruction.data.clone(), + }); + } + + Ok(compiled) + } + + fn unwrap_weth_calls(weth_contract: &Address, output_amount: &U256, fee_address: &Address, fee_amount: &U256) -> Vec { + assert!(*fee_amount <= *output_amount); let withdraw_call = WETH9::withdrawCall { wad: *output_amount }; vec![ multicall_handler::Call { @@ -152,11 +517,6 @@ impl Across { callData: withdraw_call.abi_encode().into(), value: U256::from(0), }, - multicall_handler::Call { - target: *user_address, - callData: Bytes::new(), - value: *user_amount, - }, multicall_handler::Call { target: *fee_address, callData: Bytes::new(), @@ -165,71 +525,69 @@ impl Across { ] } - fn erc20_transfer_calls(token: &Address, user_address: &Address, user_amount: &U256, fee_address: &Address, fee_amount: &U256) -> Vec { + fn erc20_transfer_calls(token: &Address, fee_address: &Address, fee_amount: &U256) -> Vec { let target = *token; - let user_transfer = IERC20::transferCall { - to: *user_address, - value: *user_amount, - }; let fee_transfer = IERC20::transferCall { to: *fee_address, value: *fee_amount, }; - vec![ - multicall_handler::Call { - target, - callData: user_transfer.abi_encode().into(), - value: U256::from(0), - }, - multicall_handler::Call { - target, - callData: fee_transfer.abi_encode().into(), - value: U256::from(0), - }, - ] + vec![multicall_handler::Call { + target, + callData: fee_transfer.abi_encode().into(), + value: U256::from(0), + }] + } + + async fn gas_price(&self, chain: Chain) -> Result { + let gas_price = create_eth_client(self.rpc_provider.clone(), chain)?.gas_price().await?; + Self::bigint_to_u256(&gas_price) + } + + async fn multicall3(&self, chain: Chain, calls: Vec) -> Result, SwapperError> { + create_eth_client(self.rpc_provider.clone(), chain)? + .multicall3(calls) + .await + .map_err(|e| SwapperError::ComputeQuoteError(e.to_string())) + } + + async fn estimate_gas_transaction(&self, chain: Chain, tx: TransactionObject) -> Result { + let client = create_eth_client(self.rpc_provider.clone(), chain)?; + let gas_hex = client.estimate_gas(tx.from.as_deref(), &tx.to, tx.value.as_deref(), Some(tx.data.as_str())).await?; + + let gas_biguint = biguint_from_hex_str(&gas_hex).map_err(|e| SwapperError::ComputeQuoteError(format!("Failed to parse gas estimate: {e}")))?; + let gas_bigint = BigInt::from_biguint(Sign::Plus, gas_biguint); + Self::bigint_to_u256(&gas_bigint) } - pub async fn estimate_gas_limit( + async fn estimate_gas_limit( &self, - amount: &U256, - is_native: bool, - input_asset: &AssetId, - output_token: &Address, - wallet_address: &Address, - message: &[u8], - deployment: &AcrossDeployment, - chain: Chain, + ctx: &QuoteContext<'_>, + destination_message: &DestinationMessage, + output_token: FixedBytes<32>, ) -> Result<(U256, V3RelayData), SwapperError> { - let chain_id: u32 = chain.network_id().parse().unwrap(); + let chain = ctx.to_chain; + if chain.chain_type() != ChainType::Ethereum { + return Err(SwapperError::NotSupportedChain); + } - let recipient = if message.is_empty() { - *wallet_address - } else { - Address::from_str(deployment.multicall_handler().as_str()).unwrap() - }; + let recipient_address = Self::recipient_evm_address(&destination_message.recipient).ok_or(SwapperError::NotSupportedChain)?; + let recipient = Self::decode_address_bytes32(recipient_address); + let v3_relay_data = self.build_v3_relay_data(ctx, recipient, output_token, &destination_message.bytes)?; - let v3_relay_data = V3RelayData { - depositor: *wallet_address, - recipient, - exclusiveRelayer: Address::ZERO, - inputToken: Address::from_str(input_asset.token_id.clone().unwrap().as_ref()).unwrap(), - outputToken: *output_token, - inputAmount: *amount, - outputAmount: U256::from(100), // safe amount - originChainId: U256::from(chain_id), - depositId: u32::MAX, - fillDeadline: u32::MAX, - exclusivityDeadline: 0, - message: Bytes::from(message.to_vec()), - }; - let value = if is_native { format!("{amount:#x}") } else { String::from("0x0") }; - let data = V3SpokePoolInterface::fillV3RelayCall { + let value = if ctx.input_is_native { format!("{:#x}", ctx.from_amount) } else { String::from("0x0") }; + let chain_id = Self::get_destination_chain_id(&chain)?; + let data = V3SpokePoolInterface::fillRelayCall { relayData: v3_relay_data.clone(), repaymentChainId: U256::from(chain_id), + repaymentAddress: Self::decode_address_bytes32(&ctx.evm_address), } .abi_encode(); - let tx = TransactionObject::new_call_to_value(deployment.spoke_pool, &value, data); - let gas_limit = self.estimate_gas_transaction(chain, tx).await.unwrap_or(U256::from(Self::get_default_fill_limit(chain))); + + let tx = TransactionObject::new_call_to_value(ctx.destination_deployment.spoke_pool, &value, data); + let gas_limit = self + .estimate_gas_transaction(chain, tx) + .await + .unwrap_or_else(|_| U256::from(Self::get_default_fill_limit(chain))); Ok((gas_limit, v3_relay_data)) } @@ -240,44 +598,90 @@ impl Across { } } - async fn usd_price_for_chain(&self, chain: Chain, existing_results: &[IMulticall3::Result]) -> Result { - let feed = ChainlinkPriceFeed::new_usd_feed_for_chain(chain).ok_or(SwapperError::NotSupportedChain)?; - if chain == Chain::Monad { - let results = create_eth_client(self.rpc_provider.clone(), Chain::Monad)? - .multicall3(vec![feed.latest_round_call3()]) - .await - .map_err(|e| SwapperError::ComputeQuoteError(e.to_string()))?; - ChainlinkPriceFeed::decoded_answer(&results[0]) - } else { - ChainlinkPriceFeed::decoded_answer(&existing_results[3]) - } - } - - pub fn update_v3_relay_data( - &self, - v3_relay_data: &mut V3RelayData, - user_address: &Address, - output_amount: &U256, - original_output_asset: &AssetId, - output_token: &Address, - timestamp: u32, - referral_fee: &ReferralFee, - ) -> Result<(), SwapperError> { - let (message, _) = self.message_for_multicall_handler(output_amount, original_output_asset, output_token, user_address, referral_fee); - + fn update_v3_relay_data(&self, v3_relay_data: &mut V3RelayData, output_amount: &U256, timestamp: u32, destination_message: DestinationMessage) -> U256 { v3_relay_data.outputAmount = *output_amount; v3_relay_data.fillDeadline = timestamp + DEFAULT_FILL_TIMEOUT; - v3_relay_data.message = message.into(); + v3_relay_data.message = destination_message.bytes.into(); - Ok(()) + destination_message.referral_fee } pub fn calculate_fee_in_token(fee_in_wei: &U256, token_price: &BigInt, token_decimals: u32) -> U256 { - let fee = BigInt::from_bytes_le(Sign::Plus, &fee_in_wei.to_le_bytes::<32>()); - let fee_in_token = fee * token_price * BigInt::from(10_u64.pow(token_decimals)) / BigInt::from(10_u64.pow(8)) / BigInt::from(10_u64.pow(18)); + Self::calculate_fee_in_token_with_native_decimals(fee_in_wei, token_price, token_decimals, 18) + } + + fn calculate_fee_in_token_with_native_decimals(fee_in_native: &U256, token_price: &BigInt, token_decimals: u32, native_decimals: u32) -> U256 { + let fee = BigInt::from_bytes_le(Sign::Plus, &fee_in_native.to_le_bytes::<32>()); + let fee_in_token = fee * token_price * BigInt::from(10_u64.pow(token_decimals)) / BigInt::from(10_u64.pow(8)) / BigInt::from(10_u64.pow(native_decimals)); U256::from_le_slice(&fee_in_token.to_bytes_le().1) } + async fn fetch_solana_unit_price(provider: Arc) -> Result { + let client = create_client_with_chain(provider, Chain::Solana); + let rpc_call = SolanaRpc::GetRecentPrioritizationFees; + let fees: Vec = client.request(rpc_call).await?; + + if fees.is_empty() { + return Err(SwapperError::ComputeQuoteError("Failed to fetch recent prioritization fees".to_string())); + } + + let total_fee: u64 = fees.iter().map(|f| f.prioritization_fee as u64).sum(); + let average_fee = total_fee / fees.len() as u64; + + Ok(std::cmp::max(1, average_fee)) + } + + async fn calculate_gas_price_and_fee( + &self, + ctx: &QuoteContext<'_>, + destination_message: &DestinationMessage, + output_token: FixedBytes<32>, + eth_price: Option<&BigInt>, + sol_price: Option<&BigInt>, + ) -> Result<(U256, V3RelayData), SwapperError> { + let has_message = !destination_message.bytes.is_empty(); + + if ctx.to_chain == Chain::Solana { + let unit_price = Self::fetch_solana_unit_price(self.rpc_provider.clone()).await?; + let gas_fee_micro_lamports = DEFAULT_SOLANA_COMPUTE_LIMIT * unit_price; + let gas_fee_lamports = gas_fee_micro_lamports / 1_000_000; + let total_gas_lamports = gas_fee_lamports + 5000; + + let mut gas_fee = if let Some(price) = sol_price { + Self::calculate_fee_in_token_with_native_decimals(&U256::from(total_gas_lamports), price, ctx.output_token_decimals as u32, SOL_NATIVE_DECIMALS) + } else { + U256::ZERO + }; + + if has_message { + gas_fee *= U256::from(MESSAGE_GAS_MULTIPLIER); + } + + let recipient = Self::recipient_to_fixed_bytes(&destination_message.recipient)?; + let v3_relay_data = self.build_v3_relay_data(ctx, recipient, output_token, &destination_message.bytes)?; + + Ok((gas_fee, v3_relay_data)) + } else { + let gas_chain = ctx.to_chain; + let gas_price_req = self.gas_price(gas_chain); + let gas_limit_req = self.estimate_gas_limit(ctx, destination_message, output_token); + + let (tuple, gas_price) = futures::join!(gas_limit_req, gas_price_req); + let (gas_limit, v3_relay_data) = tuple?; + let mut gas_fee = gas_limit * gas_price?; + + if let Some(price) = eth_price { + gas_fee = Self::calculate_fee_in_token(&gas_fee, price, 6); + } + + if has_message { + gas_fee *= U256::from(MESSAGE_GAS_MULTIPLIER); + } + + Ok((gas_fee, v3_relay_data)) + } + } + pub fn get_eta_in_seconds(&self, from_chain: &Chain, to_chain: &Chain) -> Option { let from_chain = EVMChain::from_chain(*from_chain)?; let to_chain = EVMChain::from_chain(*to_chain)?; @@ -318,130 +722,56 @@ impl Swapper for Across { } async fn fetch_quote(&self, request: &QuoteRequest) -> Result { - if request.from_asset.chain() == request.to_asset.chain() { - return Err(SwapperError::NoQuoteAvailable); - } - - let input_is_native = request.from_asset.is_native(); - let from_chain = EVMChain::from_chain(request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; - let from_amount: U256 = request.value.parse().map_err(SwapperError::from)?; - let wallet_address = eth_address::parse_str(&request.wallet_address)?; - - let _ = AcrossDeployment::deployment_by_chain(&request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; - let destination_deployment = AcrossDeployment::deployment_by_chain(&request.to_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; - if !Self::is_supported_pair(&request.from_asset.asset_id(), &request.to_asset.asset_id()) { - return Err(SwapperError::NoQuoteAvailable); - } - - let input_asset = eth_address::convert_native_to_weth(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)?; - let output_asset = eth_address::convert_native_to_weth(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedAsset)?; - let original_output_asset = request.to_asset.asset_id(); - let output_token = eth_address::parse_asset_id(&output_asset)?; + let ctx = self.build_context(request)?; + let pool_state = self.fetch_pool_state(&ctx).await?; - // Get L1 token address - let mappings = AcrossDeployment::asset_mappings(); - let asset_mapping = mappings.iter().find(|x| x.set.contains(&input_asset)).unwrap(); - let asset_mainnet = asset_mapping.set.iter().find(|x| x.chain == Chain::Ethereum).unwrap(); - let mainnet_token = eth_address::parse_or_weth_address(asset_mainnet, from_chain)?; - - let hubpool_client = HubPoolClient::new(self.rpc_provider.clone(), Chain::Ethereum); - let config_client = ConfigStoreClient::new(self.rpc_provider.clone(), Chain::Ethereum); - - let calls = vec![ - hubpool_client.paused_call3(), - hubpool_client.sync_call3(&mainnet_token), - hubpool_client.pooled_token_call3(&mainnet_token), - ]; - let results = self.multicall3(hubpool_client.chain, calls).await?; - - // Check if protocol is paused - let is_paused = hubpool_client.decoded_paused_call3(&results[0])?; - if is_paused { - return Err(SwapperError::ComputeQuoteError("Across protocol is paused".into())); - } - - // Check bridge amount is too large (Across API has some limit in USD amount but we don't have that info) - if from_amount > hubpool_client.decoded_pooled_token_call3(&results[2])?.liquidReserves { - return Err(SwapperError::ComputeQuoteError("Bridge amount is too large".into())); - } - - // Prepare data for lp fee calculation (token config, utilization, current time) - let token_config_req = config_client.fetch_config(&mainnet_token); // cache is used inside config_client - let mut calls = vec![ - hubpool_client.utilization_call3(&mainnet_token, U256::from(0)), - hubpool_client.utilization_call3(&mainnet_token, from_amount), - hubpool_client.get_current_time(), - ]; + let rate_model = Self::get_rate_model(&ctx.input_asset, &ctx.output_asset, &pool_state.token_config); + let lpfee_calc = LpFeeCalculator::new(rate_model); + let lpfee_percent = lpfee_calc.realized_lp_fee_pct(&pool_state.utilization_before, &pool_state.utilization_after, false); + let lpfee = fees::multiply(ctx.from_amount, lpfee_percent, ctx.capital_cost.decimals); + let relayer_fee = Self::calculate_relayer_capital_fee(ctx.from_amount, &ctx.capital_cost); - let gas_price_feed = ChainlinkPriceFeed::new_usd_feed_for_chain(request.to_asset.chain()).unwrap_or_else(ChainlinkPriceFeed::new_eth_usd_feed); - if !input_is_native { - calls.push(gas_price_feed.latest_round_call3()); + if lpfee + relayer_fee >= ctx.from_amount { + return Err(SwapperError::InputAmountError { min_amount: None }); } + let remain_amount = ctx.from_amount - lpfee - relayer_fee; - let multicall_results = self.multicall3(hubpool_client.chain, calls).await?; - let token_config = token_config_req.await?; - - let util_before = hubpool_client.decoded_utilization_call3(&multicall_results[0])?; - let util_after = hubpool_client.decoded_utilization_call3(&multicall_results[1])?; - let timestamp = hubpool_client.decoded_current_time(&multicall_results[2])?; - - let rate_model = Self::get_rate_model(&input_asset, &output_asset, &token_config); - let cost_config = &asset_mapping.capital_cost; - - // Calculate lp fee - let lpfee_calc = LpFeeCalculator::new(rate_model); - let lpfee_percent = lpfee_calc.realized_lp_fee_pct(&util_before, &util_after, false); - let lpfee = fees::multiply(from_amount, lpfee_percent, cost_config.decimals); + let output_token_evm = if ctx.to_chain.chain_type() == ChainType::Ethereum { + Some(eth_address::parse_asset_id(&ctx.output_asset)?) + } else { + None + }; - // Calculate relayer fee - let relayer_calc = RelayerFeeCalculator::default(); - let relayer_fee_percent = relayer_calc.capital_fee_percent(&BigInt::from_str(&request.value).unwrap(), cost_config); - let relayer_fee = fees::multiply(from_amount, relayer_fee_percent, cost_config.decimals); - - let referral_config = request.options.fee.clone().unwrap_or_default().evm_bridge; - - // Calculate gas limit / price for relayer - let remain_amount = from_amount - lpfee - relayer_fee; - let (message, referral_fee) = self.message_for_multicall_handler(&remain_amount, &original_output_asset, &wallet_address, &output_token, &referral_config); - - let gas_price = self.gas_price(request.to_asset.chain()).await?; - let (gas_limit, mut v3_relay_data) = self - .estimate_gas_limit( - &from_amount, - input_is_native, - &input_asset, - &output_token, - &wallet_address, - &message, - &destination_deployment, - request.to_asset.chain(), + let initial_destination_message = self.build_destination_message(&ctx, &remain_amount, output_token_evm.as_ref())?; + let output_token_bytes = Self::token_bytes32_for_asset(&ctx.output_asset)?; + let (gas_fee, mut v3_relay_data) = self + .calculate_gas_price_and_fee( + &ctx, + &initial_destination_message, + output_token_bytes, + pool_state.eth_price.as_ref(), + pool_state.sol_price.as_ref(), ) .await?; - let mut gas_fee = gas_limit * gas_price; - if !input_is_native { - let price = self.usd_price_for_chain(request.to_asset.chain(), &multicall_results).await?; - gas_fee = Self::calculate_fee_in_token(&gas_fee, &price, 6); + + if remain_amount <= gas_fee { + return Err(SwapperError::InputAmountError { min_amount: None }); } + let output_amount = remain_amount - gas_fee; - // Check if bridge amount is too small - if remain_amount < gas_fee { + let final_destination_message = self.build_destination_message(&ctx, &output_amount, output_token_evm.as_ref())?; + let recipient_bytes = Self::recipient_to_fixed_bytes(&final_destination_message.recipient)?; + if v3_relay_data.recipient != recipient_bytes { + v3_relay_data.recipient = recipient_bytes; + } + let final_referral_fee = self.update_v3_relay_data(&mut v3_relay_data, &output_amount, pool_state.timestamp, final_destination_message); + if final_referral_fee > output_amount { return Err(SwapperError::InputAmountError { min_amount: None }); } + let to_value = output_amount - final_referral_fee; - let output_amount = remain_amount - gas_fee; - let to_value = output_amount - referral_fee; - - // Update v3 relay data (was used to estimate gas limit) with final output amount, quote timestamp and referral fee. - self.update_v3_relay_data( - &mut v3_relay_data, - &wallet_address, - &output_amount, - &original_output_asset, - &output_token, - timestamp, - &referral_config, - )?; - let route_data = HexEncode(v3_relay_data.abi_encode()); + let encoded_data = v3_relay_data.abi_encode(); + let route_data = HexEncode(encoded_data); Ok(Quote { from_value: request.value.clone(), @@ -450,34 +780,54 @@ impl Swapper for Across { provider: self.provider().clone(), slippage_bps: request.options.slippage.bps, routes: vec![Route { - input: input_asset.clone(), - output: output_asset.clone(), + input: ctx.input_asset.clone(), + output: ctx.output_asset.clone(), route_data, gas_limit: Some(DEFAULT_DEPOSIT_GAS_LIMIT.to_string()), }], }, request: request.clone(), - eta_in_seconds: self.get_eta_in_seconds(&request.from_asset.chain(), &request.to_asset.chain()), + eta_in_seconds: self.get_eta_in_seconds(&ctx.from_chain, &ctx.to_chain), }) } async fn fetch_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { let from_chain = quote.request.from_asset.chain(); + if from_chain == Chain::Solana { + if quote.data.routes.is_empty() { + return Err(SwapperError::InvalidRoute); + } + let route = "e.data.routes[0]; + let route_data = HexDecode(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let v3_relay_data = V3RelayData::abi_decode(&route_data).map_err(|_| SwapperError::InvalidRoute)?; + + return solana_tx::build_deposit_tx(self.rpc_provider.clone(), quote, &v3_relay_data).await; + } + let deployment = AcrossDeployment::deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; - let dst_chain_id: u32 = quote.request.to_asset.chain().network_id().parse().unwrap(); + let dst_chain_id = Self::get_destination_chain_id("e.request.to_asset.chain())?; let route = "e.data.routes[0]; let route_data = HexDecode(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; let v3_relay_data = V3RelayData::abi_decode(&route_data).map_err(|_| SwapperError::InvalidRoute)?; - let deposit_v3_call = V3SpokePoolInterface::depositV3Call { - depositor: v3_relay_data.depositor, - recipient: v3_relay_data.recipient, - inputToken: v3_relay_data.inputToken, - outputToken: v3_relay_data.outputToken, + let depositor = Self::decode_address_bytes32(ð_address::parse_str("e.request.wallet_address)?); + let recipient = v3_relay_data.recipient; + + let input_asset_id = quote.request.from_asset.asset_id(); + let input_token = Self::token_bytes32_for_asset(&input_asset_id)?; + + let to_asset_id = quote.request.to_asset.asset_id(); + let output_token = Self::token_bytes32_for_asset(&to_asset_id)?; + + let deposit_call = V3SpokePoolInterface::depositCall { + depositor, + recipient, + inputToken: input_token, + outputToken: output_token, inputAmount: v3_relay_data.inputAmount, outputAmount: v3_relay_data.outputAmount, destinationChainId: U256::from(dst_chain_id), - exclusiveRelayer: Address::ZERO, + exclusiveRelayer: FixedBytes::from([0u8; 32]), quoteTimestamp: v3_relay_data.fillDeadline - DEFAULT_FILL_TIMEOUT, fillDeadline: v3_relay_data.fillDeadline, exclusivityDeadline: 0, @@ -494,7 +844,7 @@ impl Swapper for Across { } else { check_approval_erc20( quote.request.wallet_address.clone(), - v3_relay_data.inputToken.to_string(), + eth_address::parse_asset_id("e.request.from_asset.asset_id())?.to_string(), deployment.spoke_pool.into(), v3_relay_data.inputAmount, self.rpc_provider.clone(), @@ -510,7 +860,7 @@ impl Swapper for Across { if matches!(data, FetchQuoteData::EstimateGas) { let hex_value = format!("{:#x}", U256::from_str(value).unwrap()); - let tx = TransactionObject::new_call_to_value(&to, &hex_value, deposit_v3_call.clone()); + let tx = TransactionObject::new_call_to_value(&to, &hex_value, deposit_call.clone()); let _gas_limit = self.estimate_gas_transaction(from_chain, tx).await?; gas_limit = Some(_gas_limit.to_string()); } @@ -518,11 +868,12 @@ impl Swapper for Across { Ok(SwapperQuoteData::new_contract( deployment.spoke_pool.into(), value.to_string(), - HexEncode(deposit_v3_call.clone()), + HexEncode(deposit_call.clone()), approval, gas_limit, )) } + async fn get_swap_result(&self, chain: Chain, transaction_hash: &str) -> Result { let api = AcrossApi::new(self.rpc_provider.clone()); let status = api.deposit_status(chain, transaction_hash).await?; @@ -530,7 +881,6 @@ impl Swapper for Across { let swap_status = status.swap_status(); let destination_chain = Chain::from_chain_id(status.destination_chain_id); - // Determine the transaction hash to show based on status let (to_chain, to_tx_hash) = match swap_status { SwapStatus::Completed => (destination_chain, status.fill_tx.clone()), SwapStatus::Failed | SwapStatus::Refunded => (Some(chain), None), @@ -550,14 +900,91 @@ impl Swapper for Across { #[cfg(test)] mod tests { use super::*; - use gem_evm::multicall3::IMulticall3; + use crate::alien::mock::{MockFn, ProviderMock}; + use crate::config::ReferralFee; + use crate::{SwapperMode, SwapperQuoteAsset}; + use gem_evm::{ + across::contracts::{multicall_handler, spoke_pool::V3SpokePoolInterface::depositCall}, + multicall3::IMulticall3, + weth::WETH9, + }; use primitives::asset_constants::*; + use std::time::Duration; + + fn make_quote_asset(asset_id: &AssetId, decimals: u32) -> SwapperQuoteAsset { + SwapperQuoteAsset { + id: asset_id.to_string(), + symbol: String::new(), + decimals, + } + } + + fn make_request(from_asset: AssetId, to_asset: AssetId, wallet: &str, destination: &str, value: &str) -> QuoteRequest { + QuoteRequest { + from_asset: make_quote_asset(&from_asset, 18), + to_asset: make_quote_asset(&to_asset, 18), + wallet_address: wallet.into(), + destination_address: destination.into(), + value: value.into(), + mode: SwapperMode::ExactIn, + options: Options::default(), + } + } + + #[allow(clippy::too_many_arguments)] + fn make_quote_context<'a>( + _request: &'a QuoteRequest, + from_amount: U256, + wallet_address: &str, + from_chain: Chain, + to_chain: Chain, + input_asset: AssetId, + output_asset: AssetId, + original_output_asset: AssetId, + referral_fee: ReferralFee, + solana_destination_address: Option<&'a str>, + input_is_native: bool, + output_token_decimals: u8, + ) -> QuoteContext<'a> { + let depositor = RelayRecipient::Evm(Address::from_str(wallet_address).unwrap()); + + QuoteContext { + from_amount, + depositor, + evm_address: Address::from_str(wallet_address).unwrap(), + from_chain, + to_chain, + input_is_native, + input_asset, + output_asset, + original_output_asset, + mainnet_token: Address::from_str("0x0000000000000000000000000000000000000001").unwrap(), + capital_cost: fees::CapitalCostConfig { + lower_bound: BigInt::from(0), + upper_bound: BigInt::from(0), + cutoff: BigInt::from(1), + decimals: output_token_decimals as u32, + }, + referral_fee, + destination_deployment: AcrossDeployment::deployment_by_chain(&to_chain).unwrap(), + solana_destination_address, + output_token_decimals, + } + } + + fn mock_provider(response: &str) -> Arc { + let response = response.to_string(); + Arc::new(ProviderMock { + response: MockFn(Box::new(move |_| response.clone())), + timeout: Duration::from_millis(50), + }) + } #[test] fn test_is_supported_pair() { - let weth_eth = AssetId::from_token(Chain::Ethereum, WETH_ETH_CONTRACT); - let weth_op = AssetId::from_token(Chain::Optimism, WETH_OP_CONTRACT); - let weth_arb = AssetId::from_token(Chain::Arbitrum, WETH_ARB_CONTRACT); + let weth_eth: AssetId = AssetId::from_token(Chain::Ethereum, WETH_ETH_CONTRACT); + let weth_op: AssetId = AssetId::from_token(Chain::Optimism, WETH_OP_CONTRACT); + let weth_arb: AssetId = AssetId::from_token(Chain::Arbitrum, WETH_ARB_CONTRACT); let weth_bsc: AssetId = ETH_SMARTCHAIN_ASSET_ID.into(); let usdc_eth: AssetId = USDC_ETH_ASSET_ID.into(); @@ -575,7 +1002,6 @@ mod tests { assert!(!Across::is_supported_pair(&weth_eth, &usdc_eth)); - // native asset let eth = AssetId::from(Chain::Ethereum, None); let op = AssetId::from(Chain::Optimism, None); let arb = AssetId::from(Chain::Arbitrum, None); @@ -585,6 +1011,64 @@ mod tests { assert!(Across::is_supported_pair(&op, ð)); assert!(Across::is_supported_pair(&arb, ð)); assert!(Across::is_supported_pair(&op, &arb)); + + let solana_usdc = SOLANA_USDC.id.clone(); + + assert!(!Across::is_supported_pair(&usdc_eth, &solana_usdc)); + assert!(!Across::is_supported_pair(&usdc_arb, &solana_usdc)); + + let solana_usdt = SOLANA_USDT.id.clone(); + + assert!(!Across::is_supported_pair(&usdt_eth, &solana_usdt)); + assert!(!Across::is_supported_pair(&solana_usdt, &usdt_eth)); + assert!(!Across::is_supported_pair(&usdc_eth, &solana_usdt)); + assert!(!Across::is_supported_pair(&solana_usdt, &usdc_eth)); + + assert!(!Across::is_supported_pair(&solana_usdc, &usdc_eth)); + assert!(!Across::is_supported_pair(&solana_usdc, &usdc_arb)); + assert!(!Across::is_supported_pair(&weth_eth, &solana_usdc)); + assert!(!Across::is_supported_pair(&weth_eth, &solana_usdt)); + } + + #[test] + fn test_solana_address_to_bytes32() { + let bytes = Across::decode_bs58_bytes32("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(); + let expected = "0xc6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d61"; + + assert_eq!(HexEncode(bytes), expected); + + let bytes = Across::decode_bs58_bytes32("G7B17AigRCGvwnxFc5U8zY5T3NBGduLzT7KYApNU2VdR").unwrap(); + let expected = "0xe074190d46821cf0b318d4503f63178e25d76cc7d9d2498d54781fb95bb68868"; + + assert_eq!(HexEncode(bytes), expected); + } + + #[test] + fn test_v3_relay_data_solana_encoding() { + let depositor_addr = Address::from_str("0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7").unwrap(); + let input_token_addr = Address::from_str("0xaf88d065e77c8cc2239327c5edb3a432268e5831").unwrap(); + let depositor = Across::decode_address_bytes32(&depositor_addr); + let recipient = Across::decode_bs58_bytes32("G7B17AigRCGvwnxFc5U8zY5T3NBGduLzT7KYApNU2VdR").unwrap(); + let input_token = Across::decode_address_bytes32(&input_token_addr); + let output_token = Across::decode_bs58_bytes32("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(); + let call = depositCall { + depositor, + recipient, + inputToken: input_token, + outputToken: output_token, + inputAmount: U256::from(7000000_u64), + outputAmount: U256::from(6997408_u64), + destinationChainId: U256::from(34268394551451_u64), + exclusiveRelayer: FixedBytes::from([0u8; 32]), + quoteTimestamp: 1756299179, + fillDeadline: 1756311051, + exclusivityDeadline: 0, + message: Bytes::new(), + }; + let encoded_call = call.abi_encode(); + let call_data = "0xad5425c6000000000000000000000000514bcb1f9aabb904e6106bd1052b66d2706dbbb7e074190d46821cf0b318d4503f63178e25d76cc7d9d2498d54781fb95bb68868000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831c6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d6100000000000000000000000000000000000000000000000000000000006acfc000000000000000000000000000000000000000000000000000000000006ac5a000000000000000000000000000000000000000000000000000001f2abb7bf89b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068aeffab0000000000000000000000000000000000000000000000000000000068af2e0b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000"; + + assert_eq!(HexEncode(encoded_call), call_data); } #[test] @@ -604,6 +1088,250 @@ mod tests { assert_eq!(fee_in_token.to_string(), "6243790"); } + #[test] + fn test_build_destination_message_eth_to_base() { + let across = Across::new(mock_provider("{}")); + let amount = U256::from_str("1000000000000000000").unwrap(); + let request = make_request( + AssetId::from_chain(Chain::Ethereum), + AssetId::from_chain(Chain::Base), + "0x1111111111111111111111111111111111111111", + "11111111111111111111111111111111", + amount.to_string().as_str(), + ); + let referral_fee = ReferralFee { + address: "0x2222222222222222222222222222222222222222".into(), + bps: 100, + }; + let fee_address = Address::from_str(&referral_fee.address).unwrap(); + let ctx = make_quote_context( + &request, + amount, + &request.wallet_address, + Chain::Ethereum, + Chain::Base, + AssetId::from_chain(Chain::Ethereum), + AssetId::from_token(Chain::Base, "0x4200000000000000000000000000000000000006"), + AssetId::from_chain(Chain::Base), + referral_fee, + None, + true, + 18, + ); + + let output_token = Address::from_str("0x4200000000000000000000000000000000000006").unwrap(); + let destination_message = across.build_destination_message(&ctx, &amount, Some(&output_token)).unwrap(); + + let expected_fee = amount * U256::from(100u64) / U256::from(10000u64); + assert_eq!(destination_message.referral_fee, expected_fee); + + let instructions = multicall_handler::Instructions::abi_decode(&destination_message.bytes).unwrap(); + assert_eq!(instructions.fallbackRecipient, ctx.evm_address); + assert_eq!(instructions.calls.len(), 2); + + let expected_withdraw = WETH9::withdrawCall { wad: amount }.abi_encode(); + assert_eq!(instructions.calls[0].target, output_token); + assert_eq!(instructions.calls[0].callData, Bytes::from(expected_withdraw)); + assert_eq!(instructions.calls[1].target, fee_address); + assert_eq!(instructions.calls[1].value, expected_fee); + } + + #[test] + fn test_build_destination_message_usdc_to_optimism() { + let across = Across::new(mock_provider("{}")); + let amount = U256::from(1_000_000u64); + let request = make_request( + AssetId::from_token(Chain::Arbitrum, "0xaf88d065e77c8cc2239327c5edb3a432268e5831"), + USDC_OP_ASSET_ID.into(), + "0x1111111111111111111111111111111111111111", + "11111111111111111111111111111111", + amount.to_string().as_str(), + ); + let referral_fee = ReferralFee { + address: "0x2222222222222222222222222222222222222222".into(), + bps: 100, + }; + let fee_address = Address::from_str(&referral_fee.address).unwrap(); + let ctx = make_quote_context( + &request, + amount, + &request.wallet_address, + Chain::Arbitrum, + Chain::Optimism, + AssetId::from_token(Chain::Arbitrum, "0xaf88d065e77c8cc2239327c5edb3a432268e5831"), + USDC_OP_ASSET_ID.into(), + USDC_OP_ASSET_ID.into(), + referral_fee, + None, + false, + 6, + ); + + let token_address = Address::from_str("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85").unwrap(); + let destination_message = across.build_destination_message(&ctx, &amount, Some(&token_address)).unwrap(); + + let expected_fee = amount * U256::from(100u64) / U256::from(10000u64); + assert_eq!(destination_message.referral_fee, expected_fee); + + let instructions = multicall_handler::Instructions::abi_decode(&destination_message.bytes).unwrap(); + assert_eq!(instructions.calls.len(), 1); + assert_eq!(instructions.calls[0].target, token_address); + assert_eq!(instructions.calls[0].value, U256::from(0)); + let fee_call = IERC20::transferCall::abi_decode(&instructions.calls[0].callData).unwrap(); + assert_eq!(fee_call.to, fee_address); + assert_eq!(fee_call.value, expected_fee); + } + + #[test] + fn test_build_destination_message_solana_with_referral() { + let across = Across::new(mock_provider("{}")); + let amount = U256::from(2_000_000u64); + let destination = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy"; + let referral_address = "5fmLrs2GuhfDP1B51ziV5Kd1xtAr9rw1jf3aQ4ihZ2gy"; + let request = make_request( + AssetId::from_token(Chain::Ethereum, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + SOLANA_USDC.id.clone(), + "0x1111111111111111111111111111111111111111", + destination, + amount.to_string().as_str(), + ); + let referral_fee = ReferralFee { + address: referral_address.into(), + bps: 100, + }; + let ctx = make_quote_context( + &request, + amount, + &request.wallet_address, + Chain::Ethereum, + Chain::Solana, + AssetId::from_token(Chain::Ethereum, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + SOLANA_USDC.id.clone(), + SOLANA_USDC.id.clone(), + referral_fee, + Some(destination), + false, + 6, + ); + + let destination_message = across.build_destination_message(&ctx, &amount, None).unwrap(); + let expected_fee = amount * U256::from(100u64) / U256::from(10000u64); + assert_eq!(destination_message.referral_fee, expected_fee); + + let across_message: AcrossPlusMessage = borsh::from_slice(&destination_message.bytes).unwrap(); + assert_eq!(across_message.read_only_len, 2); + + let mint = SolanaPubkey::from_str(SOLANA_USDC.id.token_id.as_ref().unwrap()).unwrap(); + let user_pubkey = SolanaPubkey::from_str(destination).unwrap(); + let referral_pubkey = SolanaPubkey::from_str(referral_address).unwrap(); + let user_token_account = get_associated_token_address(&user_pubkey, &mint); + let referral_token_account = get_associated_token_address(&referral_pubkey, &mint); + + assert!(across_message.accounts.iter().any(|acc| *acc == user_token_account)); + assert!(across_message.accounts.iter().any(|acc| *acc == referral_token_account)); + + let compiled: Vec = borsh::from_slice(&across_message.handler_message).unwrap(); + assert_eq!(compiled.len(), 2); + assert_eq!(compiled[0].account_key_indexes.len(), 3); + assert_eq!(compiled[0].account_key_indexes, vec![0, 1, 3]); + assert_eq!(compiled[1].account_key_indexes, vec![0, 2, 3]); + } + + #[tokio::test] + async fn test_relay_data_recipient_destination() { + let across = Across::new(mock_provider("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x5208\"}")); + let amount = U256::from(12345u64); + let wallet = "0x1111111111111111111111111111111111111111"; + let request = make_request( + AssetId::from_token(Chain::Ethereum, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), + USDC_OP_ASSET_ID.into(), + wallet, + wallet, + amount.to_string().as_str(), + ); + let input_asset = AssetId::from_token(Chain::Ethereum, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + let output_token = Across::decode_address_bytes32(&Address::from_str("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85").unwrap()); + let ctx = make_quote_context( + &request, + amount, + wallet, + Chain::Ethereum, + Chain::Optimism, + input_asset.clone(), + USDC_OP_ASSET_ID.into(), + USDC_OP_ASSET_ID.into(), + ReferralFee::default(), + None, + true, + 6, + ); + + let empty_message = DestinationMessage { + bytes: vec![], + referral_fee: U256::ZERO, + recipient: RelayRecipient::Evm(Address::from_str(wallet).unwrap()), + }; + let (gas_limit, v3_relay_data) = across.estimate_gas_limit(&ctx, &empty_message, output_token).await.unwrap(); + + assert_eq!(gas_limit, U256::from(21000u64)); + + let expected_recipient_user = Across::decode_address_bytes32(&Address::from_str(wallet).unwrap()); + + assert_eq!(v3_relay_data.recipient, expected_recipient_user); + + let expected_input_token = Across::decode_address_bytes32(&Address::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap()); + + assert_eq!(v3_relay_data.inputToken, expected_input_token); + assert_eq!(v3_relay_data.outputToken, output_token); + + let multicall_addr = Address::from_str(ctx.destination_deployment.multicall_handler().as_str()).unwrap(); + let message = DestinationMessage { + bytes: vec![0x01], + referral_fee: U256::ZERO, + recipient: RelayRecipient::Evm(multicall_addr), + }; + let (gas_limit2, v3_relay_data2) = across.estimate_gas_limit(&ctx, &message, output_token).await.unwrap(); + + assert_eq!(gas_limit2, U256::from(21000u64)); + + let expected_recipient_mc = Across::decode_address_bytes32(&multicall_addr); + + assert_eq!(v3_relay_data2.recipient, expected_recipient_mc); + assert_eq!(v3_relay_data2.inputToken, expected_input_token); + assert_eq!(v3_relay_data2.outputToken, output_token); + + let base_weth = "0x4200000000000000000000000000000000000006"; + let output_token_base = Across::decode_address_bytes32(&Address::from_str(base_weth).unwrap()); + let base_ctx = make_quote_context( + &request, + amount, + wallet, + Chain::Ethereum, + Chain::Base, + input_asset.clone(), + AssetId::from_token(Chain::Base, base_weth), + AssetId::from_chain(Chain::Base), + ReferralFee::default(), + None, + true, + 18, + ); + let base_message = DestinationMessage { + bytes: vec![], + referral_fee: U256::ZERO, + recipient: RelayRecipient::Evm(Address::from_str(wallet).unwrap()), + }; + let (gas_limit3, v3_relay_data3) = across.estimate_gas_limit(&base_ctx, &base_message, output_token_base).await.unwrap(); + + assert_eq!(gas_limit3, U256::from(21000u64)); + + let expected_input_token_eth_weth = Across::decode_address_bytes32(&Address::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap()); + let expected_output_token_base_weth = Across::decode_address_bytes32(&Address::from_str(base_weth).unwrap()); + + assert_eq!(v3_relay_data3.inputToken, expected_input_token_eth_weth); + assert_eq!(v3_relay_data3.outputToken, expected_output_token_base_weth); + } + #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] mod swap_integration_tests { use super::*; @@ -637,7 +1365,7 @@ mod tests { to_asset: AssetId::from_chain(Chain::Arbitrum).into(), wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), - value: "20000000000000000".into(), // 0.02 ETH + value: "20000000000000000".into(), mode: SwapperMode::ExactIn, options, }; @@ -675,7 +1403,7 @@ mod tests { to_asset: to_asset.into(), wallet_address: wallet.into(), destination_address: wallet.into(), - value: "50000000".into(), // 50 USDC + value: "50000000".into(), mode: SwapperMode::ExactIn, options, }; @@ -694,16 +1422,79 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_across_quote_eth_usdc_to_solana_usdc() -> Result<(), SwapperError> { + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = Across::boxed(network_provider.clone()); + let options = Options { + slippage: 100.into(), + fee: None, + preferred_providers: vec![], + use_max_amount: false, + }; + + let wallet = "0x9b1fe00135e0ff09389bfaeff0c8f299ec818d4a"; + let destination = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy"; + let from_asset: AssetId = USDC_ETH_ASSET_ID.into(); + let to_asset: AssetId = USDC_SOLANA_ASSET_ID.into(); + let request = QuoteRequest { + from_asset: from_asset.into(), + to_asset: to_asset.into(), + wallet_address: wallet.into(), + destination_address: destination.into(), + value: "1000000".into(), + mode: SwapperMode::ExactIn, + options, + }; + + let result = swap_provider.fetch_quote(&request).await; + match result { + Err(err) => assert_eq!(err, SwapperError::NoQuoteAvailable), + Ok(_) => panic!("expected NoQuoteAvailable"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_across_quote_solana_usdc_to_eth_usdc() -> Result<(), SwapperError> { + let network_provider = Arc::new(NativeProvider::default()); + let swap_provider = Across::boxed(network_provider.clone()); + let options = Options { + slippage: 100.into(), + fee: None, + preferred_providers: vec![], + use_max_amount: false, + }; + + let wallet = "7g2rVN8fAAQdPh1mkajpvELqYa3gWvFXJsBLnKfEQfqy"; + let destination = "0x9b1fe00135e0ff09389bfaeff0c8f299ec818d4a"; + let from_asset: AssetId = USDC_SOLANA_ASSET_ID.into(); + let to_asset: AssetId = USDC_ETH_ASSET_ID.into(); + let request = QuoteRequest { + from_asset: from_asset.into(), + to_asset: to_asset.into(), + wallet_address: wallet.into(), + destination_address: destination.into(), + value: "1000000".into(), + mode: SwapperMode::ExactIn, + options, + }; + + let result = swap_provider.fetch_quote(&request).await; + match result { + Err(err) => assert_eq!(err, SwapperError::NoQuoteAvailable), + Ok(_) => panic!("expected NoQuoteAvailable"), + } + + Ok(()) + } + #[tokio::test] async fn test_get_swap_result() -> Result<(), Box> { let network_provider = Arc::new(NativeProvider::default()); let swap_provider = Across::new(network_provider.clone()); - // https://uniscan.xyz/tx/0x9827ca4bdd5dea3a310cff3485f87463987cdc52118077dba34f86ee79456952 - // IMPORTANT: This transaction may not be available on the default Unichain RPC endpoint - // (https://mainnet.unichain.org). It works on https://unichain-rpc.publicnode.com - // The transaction receipt contains: - // - Log 1, Topic 2: deposit ID (0x86f4 = 34548) let tx_hash = "0x9827ca4bdd5dea3a310cff3485f87463987cdc52118077dba34f86ee79456952"; let chain = Chain::Unichain; diff --git a/crates/swapper/src/across/solana.rs b/crates/swapper/src/across/solana.rs new file mode 100644 index 000000000..4b9b13870 --- /dev/null +++ b/crates/swapper/src/across/solana.rs @@ -0,0 +1,39 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_primitives::types::Pubkey; + +pub const MULTICALL_HANDLER: &str = "HaQe51FWtnmaEcuYEfPA7MRCXKrtqptat4oJdJ8zV5Be"; +pub const DEFAULT_SOLANA_COMPUTE_LIMIT: u64 = 200_000; +pub const SOL_NATIVE_DECIMALS: u32 = 9; +pub const SOL_RELAYER_FEE_LAMPORTS: u64 = 5_000; + +#[derive(BorshDeserialize, BorshSerialize)] +pub struct CompiledIx { + pub program_id_index: u8, + pub account_key_indexes: Vec, + pub data: Vec, +} + +#[derive(BorshDeserialize, BorshSerialize, Clone)] +pub struct RelayData { + pub depositor: Pubkey, + pub recipient: Pubkey, + pub exclusive_relayer: Pubkey, + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: [u8; 32], + pub output_amount: u64, + pub origin_chain_id: u64, + pub deposit_id: [u8; 32], + pub fill_deadline: u32, + pub exclusivity_deadline: u32, + pub message: Vec, +} + +#[derive(BorshSerialize, BorshDeserialize, Clone)] +pub struct AcrossPlusMessage { + pub handler: Pubkey, + pub read_only_len: u8, + pub value_amount: u64, + pub accounts: Vec, + pub handler_message: Vec, +} diff --git a/crates/swapper/src/across/solana_tx.rs b/crates/swapper/src/across/solana_tx.rs new file mode 100644 index 000000000..e9824da8f --- /dev/null +++ b/crates/swapper/src/across/solana_tx.rs @@ -0,0 +1,229 @@ +use super::DEFAULT_FILL_TIMEOUT; +use crate::{ + SwapperError, SwapperQuoteData, + alien::RpcProvider, + error::{INVALID_ADDRESS, INVALID_AMOUNT}, + models::Quote, + solana::tx_builder, +}; +use alloy_primitives::FixedBytes; +use borsh::BorshSerialize; +use gem_evm::across::{contracts::V3SpokePoolInterface::V3RelayData, deployment::AcrossDeployment}; +use gem_hash::keccak; +use sha2::{Digest, Sha256}; +use solana_primitives::instructions::token::TokenInstruction; +use solana_primitives::{ + instructions::{associated_token::get_associated_token_address, program_ids}, + types::{AccountMeta, Instruction as SolInstruction, Pubkey as SolanaPubkey, find_program_address}, +}; +use std::{str::FromStr, sync::Arc}; + +const SVM_SPOKE_STATE_SEED: u64 = 0; +const SVM_DELEGATE_SEED: &[u8] = b"delegate"; +const SVM_EVENT_AUTHORITY_SEED: &[u8] = b"__event_authority"; + +pub async fn build_deposit_tx(rpc_provider: Arc, quote: &Quote, v3_relay_data: &V3RelayData) -> Result { + let depositor = + SolanaPubkey::from_str("e.request.wallet_address).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {}", quote.request.wallet_address)))?; + + let origin_chain = quote.request.from_asset.chain(); + let deployment = AcrossDeployment::deployment_by_chain(&origin_chain).ok_or(SwapperError::NotSupportedChain)?; + let spoke_pool_program = SolanaPubkey::from_str(deployment.spoke_pool).map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_ADDRESS}: {}", deployment.spoke_pool)))?; + + let recipient = solana_pubkey_from_fixed_bytes(&v3_relay_data.recipient); + let input_token = solana_pubkey_from_fixed_bytes(&v3_relay_data.inputToken); + let output_token = solana_pubkey_from_fixed_bytes(&v3_relay_data.outputToken); + + let input_amount: u64 = v3_relay_data + .inputAmount + .try_into() + .map_err(|_| SwapperError::ComputeQuoteError(format!("{INVALID_AMOUNT}: Input amount overflow")))?; + let output_amount = v3_relay_data.outputAmount.to_be_bytes::<32>(); + + let destination_chain_id = AcrossDeployment::deployment_by_chain("e.request.to_asset.chain()) + .ok_or(SwapperError::NotSupportedChain)? + .chain_id; + let quote_timestamp = v3_relay_data.fillDeadline.checked_sub(DEFAULT_FILL_TIMEOUT).ok_or(SwapperError::InvalidRoute)?; + let fill_deadline = v3_relay_data.fillDeadline; + let exclusivity_parameter = v3_relay_data.exclusivityDeadline; + let exclusive_relayer = SolanaPubkey::new([0u8; 32]); + let message = v3_relay_data.message.as_ref().to_vec(); + + let deposit_seed_data = DepositSeedData { + depositor, + recipient, + input_token, + output_token, + input_amount, + output_amount, + destination_chain_id, + exclusive_relayer, + quote_timestamp, + fill_deadline, + exclusivity_parameter, + message: &message, + }; + let seed_hash = deposit_seed_hash(&deposit_seed_data)?; + let (delegate, _) = + find_program_address(&spoke_pool_program, &[SVM_DELEGATE_SEED, &seed_hash]).map_err(|_| SwapperError::TransactionError("Failed to derive delegate PDA".into()))?; + + let (state, _) = find_program_address(&spoke_pool_program, &[b"state", SVM_SPOKE_STATE_SEED.to_le_bytes().as_ref()]) + .map_err(|_| SwapperError::TransactionError("Failed to derive state PDA".into()))?; + let (event_authority, _) = + find_program_address(&spoke_pool_program, &[SVM_EVENT_AUTHORITY_SEED]).map_err(|_| SwapperError::TransactionError("Failed to derive event authority PDA".into()))?; + + let depositor_token_account = get_associated_token_address(&depositor, &input_token); + let vault = get_associated_token_address(&state, &input_token); + + let token_decimals = AcrossDeployment::asset_mappings() + .into_iter() + .find(|mapping| mapping.set.contains("e.request.from_asset.asset_id())) + .and_then(|mapping| u8::try_from(mapping.capital_cost.decimals).ok()) + .ok_or_else(|| SwapperError::ComputeQuoteError("Unsupported token decimals".into()))?; + + let approve_ix = approve_checked_instruction(&depositor_token_account, &input_token, &delegate, &depositor, input_amount, token_decimals); + + let deposit_args = borsh_encode(&deposit_seed_data)?; + let mut deposit_data = Vec::with_capacity(8 + deposit_args.len()); + deposit_data.extend_from_slice(&anchor_discriminator("deposit")); + deposit_data.extend_from_slice(&deposit_args); + + let deposit_ix = SolInstruction { + program_id: spoke_pool_program, + accounts: vec![ + AccountMeta { + pubkey: depositor, + is_signer: true, + is_writable: true, + }, + AccountMeta { + pubkey: state, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: delegate, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: depositor_token_account, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: vault, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: input_token, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: SolanaPubkey::from_base58(program_ids::TOKEN_PROGRAM_ID).unwrap(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: SolanaPubkey::from_base58(program_ids::ASSOCIATED_TOKEN_PROGRAM_ID).unwrap(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: SolanaPubkey::from_base58(program_ids::SYSTEM_PROGRAM_ID).unwrap(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: event_authority, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: spoke_pool_program, + is_signer: false, + is_writable: false, + }, + ], + data: deposit_data, + }; + + let tx_b64 = tx_builder::build_base64_transaction(depositor, vec![approve_ix, deposit_ix], rpc_provider) + .await + .map_err(SwapperError::TransactionError)?; + + Ok(SwapperQuoteData::new_contract(deployment.spoke_pool.to_string(), "".to_string(), tx_b64, None, None)) +} + +fn anchor_discriminator(name: &str) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(format!("global:{name}")); + let hash = hasher.finalize(); + let mut discriminator = [0u8; 8]; + discriminator.copy_from_slice(&hash[..8]); + discriminator +} + +#[derive(BorshSerialize)] +struct DepositSeedData<'a> { + depositor: SolanaPubkey, + recipient: SolanaPubkey, + input_token: SolanaPubkey, + output_token: SolanaPubkey, + input_amount: u64, + output_amount: [u8; 32], + destination_chain_id: u64, + exclusive_relayer: SolanaPubkey, + quote_timestamp: u32, + fill_deadline: u32, + exclusivity_parameter: u32, + message: &'a [u8], +} + +fn borsh_encode(value: &T) -> Result, SwapperError> { + let mut data = Vec::new(); + value.serialize(&mut data).map_err(|e| SwapperError::TransactionError(e.to_string()))?; + Ok(data) +} + +fn deposit_seed_hash(seed_data: &DepositSeedData<'_>) -> Result<[u8; 32], SwapperError> { + let data = borsh_encode(seed_data)?; + Ok(keccak::keccak256(&data)) +} + +fn solana_pubkey_from_fixed_bytes(bytes: &FixedBytes<32>) -> SolanaPubkey { + SolanaPubkey::new(bytes.0) +} + +fn approve_checked_instruction(source: &SolanaPubkey, mint: &SolanaPubkey, delegate: &SolanaPubkey, owner: &SolanaPubkey, amount: u64, decimals: u8) -> SolInstruction { + let accounts = vec![ + AccountMeta { + pubkey: *source, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: *mint, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: *delegate, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: *owner, + is_signer: true, + is_writable: false, + }, + ]; + let data = TokenInstruction::ApproveChecked { amount, decimals }.serialize(); + SolInstruction { + program_id: SolanaPubkey::from_base58(program_ids::TOKEN_PROGRAM_ID).unwrap(), + accounts, + data, + } +} diff --git a/crates/swapper/src/chainflip/tx_builder.rs b/crates/swapper/src/chainflip/tx_builder.rs index d657a7a2a..6f245a77e 100644 --- a/crates/swapper/src/chainflip/tx_builder.rs +++ b/crates/swapper/src/chainflip/tx_builder.rs @@ -1,12 +1,9 @@ use super::broker::SolanaVaultSwapResponse; -use crate::{alien::RpcProvider, client_factory::create_client_with_chain}; +use crate::{alien::RpcProvider, solana::tx_builder}; use alloy_primitives::hex; -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use gem_solana::{jsonrpc::SolanaRpc, models::LatestBlockhash}; -use primitives::Chain; -use solana_primitives::{AccountMeta, InstructionBuilder, Pubkey, TransactionBuilder}; +use solana_primitives::InstructionBuilder; +use solana_primitives::{AccountMeta, Pubkey}; use std::{str::FromStr, sync::Arc}; pub async fn build_solana_tx(fee_payer: &str, response: &SolanaVaultSwapResponse, provider: Arc) -> Result { @@ -14,13 +11,6 @@ pub async fn build_solana_tx(fee_payer: &str, response: &SolanaVaultSwapResponse let program_id = Pubkey::from_str(response.program_id.as_str()).map_err(|_| "Invalid program ID".to_string())?; let data = hex::decode(response.data.as_str()).map_err(|_| "Invalid data".to_string())?; - let rpc_client = create_client_with_chain(provider, Chain::Solana); - let blockhash_response: LatestBlockhash = rpc_client.request(SolanaRpc::GetLatestBlockhash).await.map_err(|e| e.to_string())?; - let recent_blockhash = blockhash_response.value.blockhash; - let blockhash = bs58::decode(recent_blockhash).into_vec().map_err(|_| "Failed to decode blockhash".to_string())?; - - let blockhash_array: [u8; 32] = blockhash.try_into().map_err(|_| "Failed to convert blockhash to array".to_string())?; - let mut instruction = InstructionBuilder::new(program_id).data(data).build(); response.accounts.iter().for_each(|account| { instruction.accounts.push(AccountMeta { @@ -29,14 +19,7 @@ pub async fn build_solana_tx(fee_payer: &str, response: &SolanaVaultSwapResponse pubkey: Pubkey::from_str(account.pubkey.as_str()).unwrap(), }); }); - - let mut transaction_builder = TransactionBuilder::new(fee_payer, blockhash_array); - transaction_builder.add_instruction(instruction); - - let transaction = transaction_builder.build().map_err(|e| e.to_string())?; - let bytes = transaction.serialize_legacy().map_err(|e| e.to_string())?; - - Ok(STANDARD.encode(&bytes)) + tx_builder::build_base64_transaction(fee_payer, vec![instruction], provider).await } #[cfg(test)] diff --git a/crates/swapper/src/chainlink.rs b/crates/swapper/src/chainlink.rs index 660cc1f86..2e85a6287 100644 --- a/crates/swapper/src/chainlink.rs +++ b/crates/swapper/src/chainlink.rs @@ -3,7 +3,7 @@ use num_traits::FromBytes; use crate::SwapperError; use gem_evm::{ - chainlink::contract::{AggregatorInterface, CHAINLINK_ETH_USD_FEED, CHAINLINK_MON_USD_FEED}, + chainlink::contract::{AggregatorInterface, CHAINLINK_ETH_USD_FEED, CHAINLINK_MON_USD_FEED, CHAINLINK_SOL_USD_FEED}, multicall3::{IMulticall3, create_call3, decode_call3_return}, }; @@ -31,6 +31,12 @@ impl ChainlinkPriceFeed { } } + pub fn new_sol_usd_feed() -> ChainlinkPriceFeed { + ChainlinkPriceFeed { + contract: CHAINLINK_SOL_USD_FEED.into(), + } + } + pub fn latest_round_call3(&self) -> IMulticall3::Call3 { create_call3(&self.contract, AggregatorInterface::latestRoundDataCall {}) } diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index d20e161f4..093ddb487 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -20,6 +20,7 @@ pub mod near_intents; pub mod permit2_data; pub mod proxy; pub mod slippage; +pub mod solana; pub mod swapper; pub mod thorchain; pub mod uniswap; diff --git a/crates/swapper/src/solana/mod.rs b/crates/swapper/src/solana/mod.rs new file mode 100644 index 000000000..1845916ca --- /dev/null +++ b/crates/swapper/src/solana/mod.rs @@ -0,0 +1 @@ +pub mod tx_builder; diff --git a/crates/swapper/src/solana/tx_builder.rs b/crates/swapper/src/solana/tx_builder.rs new file mode 100644 index 000000000..afe4835b7 --- /dev/null +++ b/crates/swapper/src/solana/tx_builder.rs @@ -0,0 +1,34 @@ +use crate::{alien::RpcProvider, client_factory::create_client_with_chain}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use gem_solana::{jsonrpc::SolanaRpc, models::LatestBlockhash}; +use primitives::Chain; +use solana_primitives::{ + TransactionBuilder, + types::{Instruction, Pubkey}, +}; +use std::sync::Arc; + +const SOLANA_TX_SIZE_LIMIT: usize = 1232; + +pub async fn build_base64_transaction(fee_payer: Pubkey, instructions: Vec, provider: Arc) -> Result { + let rpc_client = create_client_with_chain(provider, Chain::Solana); + let blockhash_response: LatestBlockhash = rpc_client.request(SolanaRpc::GetLatestBlockhash).await.map_err(|e| e.to_string())?; + let recent_blockhash = blockhash_response.value.blockhash; + let blockhash = bs58::decode(recent_blockhash).into_vec().map_err(|_| "Failed to decode blockhash".to_string())?; + let blockhash_array: [u8; 32] = blockhash.try_into().map_err(|_| "Failed to convert blockhash to array".to_string())?; + + let mut transaction_builder = TransactionBuilder::new(fee_payer, blockhash_array); + for instruction in instructions { + transaction_builder.add_instruction(instruction); + } + + let transaction = transaction_builder.build().map_err(|e| e.to_string())?; + let bytes = transaction.serialize_legacy().map_err(|e| e.to_string())?; + + if bytes.len() > SOLANA_TX_SIZE_LIMIT { + return Err(format!("Transaction too large: {} bytes (limit: {} bytes)", bytes.len(), SOLANA_TX_SIZE_LIMIT)); + } + + Ok(STANDARD.encode(&bytes)) +}