diff --git a/Cargo.lock b/Cargo.lock index 38233eebb..1f1a2fded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,18 +180,6 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -220,9 +208,9 @@ dependencies = [ [[package]] name = "alloy-ens" -version = "1.0.30" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "becb0c6c71cd2bda64a92d63dde68e650fc3c3515f0de6473cf3b88b10ed7417" +checksum = "d0a7feb13ccb13b784cdd5c1bb8756f74dd5b322c10ccfb737c8979ff26f9808" dependencies = [ "alloy-primitives", ] @@ -1166,39 +1154,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f781dba93de3a5ef6dc5b17c9958b208f6f3f021623b360fb605ea51ce443f10" -[[package]] -name = "borsh" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" -dependencies = [ - "borsh-derive 0.10.4", - "hashbrown 0.13.2", -] - [[package]] name = "borsh" version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" dependencies = [ - "borsh-derive 1.5.7", + "borsh-derive", "cfg_aliases", ] -[[package]] -name = "borsh-derive" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" -dependencies = [ - "borsh-derive-internal", - "borsh-schema-derive-internal", - "proc-macro-crate 0.1.5", - "proc-macro2", - "syn 1.0.109", -] - [[package]] name = "borsh-derive" version = "1.5.7" @@ -1206,34 +1171,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" dependencies = [ "once_cell", - "proc-macro-crate 3.3.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.106", ] -[[package]] -name = "borsh-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-schema-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bs58" version = "0.5.1" @@ -2949,7 +2892,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", - "borsh 1.5.7", + "borsh", "bs58", "chain_traits", "chrono", @@ -3086,7 +3029,7 @@ dependencies = [ [[package]] name = "gemstone" -version = "0.31.1" +version = "0.33.0" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -3095,6 +3038,7 @@ dependencies = [ "base64 0.22.1", "bcs", "bigdecimal", + "borsh", "bs58", "chain_traits", "chrono", @@ -3281,15 +3225,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -4409,7 +4344,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", - "borsh 1.5.7", + "borsh", "gem_client", "gem_evm", "gem_jsonrpc", @@ -4708,7 +4643,7 @@ version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" dependencies = [ - "proc-macro-crate 3.3.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.106", @@ -5120,15 +5055,6 @@ dependencies = [ "url", ] -[[package]] -name = "proc-macro-crate" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" -dependencies = [ - "toml 0.5.11", -] - [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -6637,13 +6563,12 @@ dependencies = [ [[package]] name = "solana-primitives" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a869df7709a24229ccaaafe8008e67d266c8add74611c74847099a6a0a7df4" +checksum = "1e96f296aa3d1a7d8030c2b441d581a491a2c5648f43c3bad7ea5849a21cc034" dependencies = [ "base64 0.22.1", - "borsh 0.10.4", - "borsh-derive 0.10.4", + "borsh", "bs58", "ed25519-dalek", "hex", diff --git a/GEMINI.md b/GEMINI.md index ef495c00b..47dc3e3d8 120000 --- a/GEMINI.md +++ b/GEMINI.md @@ -1 +1 @@ -./CLAUDE.md \ No newline at end of file +AGENTS.md \ No newline at end of file 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 fea0a28c9..9da3dcbe4 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::{asset_constants::*, AssetId, Chain}; +use primitives::{asset_constants::*, AssetId, Chain, ChainType}; use std::collections::HashMap; 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,56 +25,82 @@ 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::Hyperliquid => Some(Self { + chain_id, + chain_type: ChainType::Ethereum, + spoke_pool: "0x35E63eA3eb0fb7A3bc543C71FB66412e1F6B0E04", + }), 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::SmartChain => Some(Self { chain_id, + chain_type: ChainType::Ethereum, spoke_pool: "0x4e8E101924eDE233C13e2D8622DC8aED2872d505", }), + Chain::Solana => Some(Self { + chain_id: 34268394551451_u64, + chain_type: ChainType::Solana, + spoke_pool: "DLv3NggMiSaef97YCkew5xKUHDh13tVGZ7tydt3ZeAru", + }), _ => None, } } @@ -85,6 +113,8 @@ impl AcrossDeployment { 324 => "0x863859ef502F0Ee9676626ED5B418037252eFeb2".into(), // SmartChain 56 => "0xAC537C12fE8f544D712d71ED4376a502EEa944d7".into(), + // HyperEvm + 999 => "0x5E7840E06fAcCb6d1c3b5F5E0d1d3d07F2829bba".into(), _ => MULTICALL_HANDLER.into(), } } @@ -105,6 +135,7 @@ impl AcrossDeployment { vec![USDT_ARB_ASSET_ID.into(), USDC_ARB_ASSET_ID.into(), WETH_ARB_ASSET_ID.into()], ), (Chain::Base, vec![WETH_BASE_ASSET_ID.into(), USDC_BASE_ASSET_ID.into()]), + (Chain::Hyperliquid, vec![USDC_HYPEREVM_ASSET_ID.into(), USDT_HYPEREVM_ASSET_ID.into()]), (Chain::Linea, vec![USDT_LINEA_ASSET_ID.into(), WETH_LINEA_ASSET_ID.into()]), (Chain::ZkSync, vec![WETH_ZKSYNC_ASSET_ID.into(), USDT_ZKSYNC_ASSET_ID.into()]), (Chain::World, vec![WETH_WORLD_ASSET_ID.into()]), @@ -112,6 +143,7 @@ impl AcrossDeployment { (Chain::Ink, vec![WETH_INK_ASSET_ID.into(), USDT_INK_ASSET_ID.into()]), (Chain::Unichain, vec![WETH_UNICHAIN_ASSET_ID.into(), USDC_UNICHAIN_ASSET_ID.into()]), (Chain::SmartChain, vec![ETH_SMARTCHAIN_ASSET_ID.into()]), + (Chain::Solana, vec![USDC_SOLANA_ASSET_ID.into()]), ]) } @@ -152,7 +184,9 @@ impl AcrossDeployment { USDC_ETH_ASSET_ID.into(), USDC_OP_ASSET_ID.into(), USDC_POLYGON_ASSET_ID.into(), + USDC_HYPEREVM_ASSET_ID.into(), USDC_UNICHAIN_ASSET_ID.into(), + USDC_SOLANA_ASSET_ID.into(), ]), }, // USDC on BSC decimals are 18 @@ -180,6 +214,7 @@ impl AcrossDeployment { USDT_POLYGON_ASSET_ID.into(), USDT_ZKSYNC_ASSET_ID.into(), USDT_INK_ASSET_ID.into(), + USDT_HYPEREVM_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 7881443c7..4646893d7 100644 --- a/crates/gem_evm/src/chainlink/contract.rs +++ b/crates/gem_evm/src/chainlink/contract.rs @@ -8,3 +8,4 @@ sol! { } pub const CHAINLINK_ETH_USD_FEED: &str = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"; +pub const CHAINLINK_SOL_USD_FEED: &str = "0x4ffC43a60e009B551865A93d232E33Fce9f01507"; diff --git a/crates/gem_evm/src/uniswap/path.rs b/crates/gem_evm/src/uniswap/path.rs index 6f557631b..b0de970f9 100644 --- a/crates/gem_evm/src/uniswap/path.rs +++ b/crates/gem_evm/src/uniswap/path.rs @@ -125,7 +125,7 @@ pub fn get_base_pair(chain: &EVMChain, weth_as_native: bool) -> Option let usdt: &str = match chain { EVMChain::Ethereum => "0xdAC17F958D2ee523a2206206994597C13D831ec7", - EVMChain::Polygon => "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + EVMChain::Polygon => "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", // USDT0 EVMChain::Arbitrum => "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", EVMChain::Optimism => "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", EVMChain::Base => "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2", diff --git a/crates/gem_solana/src/jsonrpc.rs b/crates/gem_solana/src/jsonrpc.rs index eb3b2e82c..43ae0cee1 100644 --- a/crates/gem_solana/src/jsonrpc.rs +++ b/crates/gem_solana/src/jsonrpc.rs @@ -13,6 +13,7 @@ pub enum SolanaRpc { GetMultipleAccounts(Vec), GetEpochInfo, GetLatestBlockhash, + GetRecentPrioritizationFees, } impl Display for SolanaRpc { @@ -23,6 +24,7 @@ impl Display for SolanaRpc { SolanaRpc::GetMultipleAccounts(_) => write!(f, "getMultipleAccounts"), SolanaRpc::GetEpochInfo => write!(f, "getEpochInfo"), SolanaRpc::GetLatestBlockhash => write!(f, "getLatestBlockhash"), + SolanaRpc::GetRecentPrioritizationFees => write!(f, "getRecentPrioritizationFees"), } } } @@ -43,7 +45,7 @@ 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::GetEpochInfo | SolanaRpc::GetLatestBlockhash | SolanaRpc::GetRecentPrioritizationFees => vec![], }; JsonRpcRequest::new(id, &method, params.into()) diff --git a/crates/primitives/src/asset_constants.rs b/crates/primitives/src/asset_constants.rs index 9af461935..d943560f6 100644 --- a/crates/primitives/src/asset_constants.rs +++ b/crates/primitives/src/asset_constants.rs @@ -33,6 +33,8 @@ pub const WETH_UNICHAIN_ASSET_ID: &str = "unichain_0x420000000000000000000000000 // Binance pegged ETH on SmartChain pub const ETH_SMARTCHAIN_ASSET_ID: &str = "smartchain_0x2170Ed0880ac9A755fd29B2688956BD959F933F8"; +// USDC on Solana +pub const USDC_SOLANA_ASSET_ID: &str = "solana_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC pub const USDC_ARB_ASSET_ID: &str = "arbitrum_0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; @@ -41,6 +43,7 @@ pub const USDC_ETH_ASSET_ID: &str = "ethereum_0xA0b86991c6218b36c1d19D4a2e9Eb0cE pub const USDC_OP_ASSET_ID: &str = "optimism_0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"; pub const USDC_POLYGON_ASSET_ID: &str = "polygon_0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"; pub const USDC_UNICHAIN_ASSET_ID: &str = "unichain_0x078D782b760474a361dDA0AF3839290b0EF57AD6"; +pub const USDC_HYPEREVM_ASSET_ID: &str = "hyperliquid_0xb88339CB7199b77E23DB6E890353E22632Ba630f"; pub const USDC_SMARTCHAIN_ASSET_ID: &str = "smartchain_0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"; pub const USDC_AVAX_ASSET_ID: &str = "avalanchec_0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"; @@ -65,6 +68,7 @@ pub const USDT_ZKSYNC_ASSET_ID: &str = "zksync_0x493257fD37EDB34451f62EDf8D2a0C4 pub const USDT_SMARTCHAIN_ASSET_ID: &str = "smartchain_0x55d398326f99059fF775485246999027B3197955"; pub const USDT_AVAX_ASSET_ID: &str = "avalanchec_0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7"; pub const USDT_INK_ASSET_ID: &str = "ink_0x3baD7AD0728f9917d1Bf08af5782dCbD516cDd96"; // USDT0 +pub const USDT_HYPEREVM_ASSET_ID: &str = "hyperliquid_0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb"; // USDT0 // WBTC pub const WBTC_ARB_ASSET_ID: &str = "arbitrum_0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f"; diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index bff25006e..835be6161 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "gemstone" -version = "0.31.1" +version = "0.33.0" [lib] crate-type = [ @@ -45,6 +45,7 @@ reqwest = { workspace = true, optional = true } bcs.workspace = true sui-types = { workspace = true } sui-transaction-builder = { workspace = true } +borsh.workspace = true # uniffi uniffi.workspace = true thiserror.workspace = true @@ -65,7 +66,7 @@ num-bigint.workspace = true num-traits.workspace = true futures.workspace = true bs58 = { workspace = true } -solana-primitives = "0.2.0" +solana-primitives = "0.2" lazy_static.workspace = true bigdecimal.workspace = true rand.workspace = true diff --git a/gemstone/src/swapper/across/mod.rs b/gemstone/src/swapper/across/mod.rs index 54a247b0d..074af7922 100644 --- a/gemstone/src/swapper/across/mod.rs +++ b/gemstone/src/swapper/across/mod.rs @@ -3,6 +3,8 @@ pub use provider::Across; pub mod api; pub mod config_store; pub mod hubpool; +pub mod models; +pub mod solana; const DEFAULT_FILL_TIMEOUT: u32 = 60 * 60 * 6; // 6 hours const DEFAULT_DEPOSIT_GAS_LIMIT: u64 = 180_000; // gwei diff --git a/gemstone/src/swapper/across/models.rs b/gemstone/src/swapper/across/models.rs new file mode 100644 index 000000000..f72484c9a --- /dev/null +++ b/gemstone/src/swapper/across/models.rs @@ -0,0 +1,36 @@ +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::swap_config::SwapReferralFee; + +pub struct QuoteContext<'a> { + pub from_amount: U256, + pub wallet_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: SwapReferralFee, + pub destination_deployment: AcrossDeployment, + pub 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/gemstone/src/swapper/across/provider.rs b/gemstone/src/swapper/across/provider.rs index 74a50aade..e4c66ef0a 100644 --- a/gemstone/src/swapper/across/provider.rs +++ b/gemstone/src/swapper/across/provider.rs @@ -1,11 +1,12 @@ +use super::models::{DestinationMessage, QuoteContext, RelayRecipient}; use super::{ api::AcrossApi, config_store::{ConfigStoreClient, TokenConfig}, hubpool::HubPoolClient, + solana::{AcrossPlusMessage, CompiledIx, MULTICALL_HANDLER}, DEFAULT_FILL_TIMEOUT, }; use crate::{ - config::swap_config::SwapReferralFee, debug_println, ethereum::jsonrpc as eth_rpc, network::AlienProvider, @@ -21,10 +22,13 @@ use crate::{ }; use alloy_primitives::{ hex::{decode as HexDecode, encode_prefixed as HexEncode}, - Address, Bytes, U256, + Address, Bytes, FixedBytes, U256, }; use alloy_sol_types::{SolCall, SolValue}; + +use crate::network::jsonrpc_client_with_chain; use async_trait::async_trait; +use bs58; use gem_evm::{ across::{ contracts::{ @@ -38,9 +42,25 @@ use gem_evm::{ jsonrpc::TransactionObject, weth::WETH9, }; +use gem_solana::{jsonrpc::SolanaRpc, models::prioritization_fee::SolanaPrioritizationFee}; use num_bigint::{BigInt, Sign}; -use primitives::{swap::SwapStatus, AssetId, Chain, EVMChain}; -use std::{fmt::Debug, str::FromStr, sync::Arc}; +use primitives::{swap::SwapStatus, AssetId, Chain, ChainType, EVMChain}; +use solana_primitives::{ + instructions::{associated_token::get_associated_token_address, program_ids, token::transfer_checked}, + types::{find_program_address, Instruction as SolInstruction, Pubkey as SolanaPubkey}, +}; +use std::{collections::HashMap, fmt::Debug, str::FromStr, sync::Arc}; + +const DEFAULT_SOLANA_COMPUTE_LIMIT: u64 = 200_000; + +struct PoolState { + token_config: TokenConfig, + utilization_before: BigInt, + utilization_after: BigInt, + timestamp: u32, + eth_price: Option, + sol_price: Option, +} #[derive(Debug)] pub struct Across { @@ -61,48 +81,462 @@ impl Across { } pub fn is_supported_pair(from_asset: &AssetId, to_asset: &AssetId) -> bool { - let from = eth_address::normalize_weth_asset(from_asset).unwrap(); - let to = eth_address::normalize_weth_asset(to_asset).unwrap(); + if from_asset.chain == Chain::Solana { + return false; + } + if to_asset.chain == Chain::Solana { + if to_asset != &SOLANA_USDC.id { + return false; + } + // Check if from_asset is a supported USDC token on EVM chains + let from_normalized = match eth_address::normalize_weth_asset(from_asset) { + Some(asset) => asset, + None => return false, + }; + return AcrossDeployment::asset_mappings() + .into_iter() + .any(|mapping| mapping.set.contains(&from_normalized) && mapping.set.contains(&SOLANA_USDC.id)); + } + + let from = match eth_address::normalize_weth_asset(from_asset) { + Some(asset) => asset, + None => return false, + }; + let to = match eth_address::normalize_weth_asset(to_asset) { + Some(asset) => asset, + None => return false, + }; AcrossDeployment::asset_mappings() .into_iter() .any(|x| x.set.contains(&from) && x.set.contains(&to)) } + 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) + } + + fn decode_bs58_bytes32(addr: &str) -> Result, SwapperError> { + let decoded = bs58::decode(addr).into_vec().map_err(|_| SwapperError::InvalidAddress(addr.to_string()))?; + if decoded.len() != 32 { + return Err(SwapperError::InvalidAddress(addr.to_string())); + } + let bytes: [u8; 32] = decoded.try_into().map_err(|_| SwapperError::InvalidAddress(addr.to_string()))?; + Ok(FixedBytes::from(bytes)) + } + + 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())), + } + } + + fn recipient_evm_address(recipient: &RelayRecipient) -> Option<&Address> { + match recipient { + RelayRecipient::Evm(address) => Some(address), + RelayRecipient::Solana(_) => None, + } + } + + 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::InvalidAddress("missing token_id for Solana".into()))?; + 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::from_str(id).unwrap())) + } + _ => Err(SwapperError::NotImplemented), + } + } + + fn is_solana_destination(request: &SwapperQuoteRequest) -> bool { + request.to_asset.chain() == Chain::Solana + } + + fn get_output_asset(request: &SwapperQuoteRequest) -> Result { + if Self::is_solana_destination(request) { + Ok(request.to_asset.asset_id()) + } else { + let norm_output_asset = eth_address::normalize_weth_asset(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedPair)?; + Ok(norm_output_asset) + } + } + + 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 SwapperQuoteRequest) -> Result, SwapperError> { + if request.from_asset.chain() == request.to_asset.chain() { + return Err(SwapperError::NotSupportedPair); + } + + if request.from_asset.chain() == Chain::Solana { + return Err(SwapperError::NotSupportedPair); + } + + let from_amount: U256 = request.value.parse().map_err(SwapperError::from)?; + let wallet_address = eth_address::parse_str(&request.wallet_address)?; + let from_chain = request.from_asset.chain(); + let to_chain = request.to_asset.chain(); + let from_chain_evm = EVMChain::from_chain(from_chain).ok_or(SwapperError::NotSupportedChain)?; + + 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::NotSupportedPair); + } + + let input_asset = eth_address::normalize_weth_asset(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedPair)?; + 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::NotSupportedPair)?; + let mainnet_asset = asset_mapping + .set + .iter() + .find(|asset| asset.chain == Chain::Ethereum) + .cloned() + .ok_or(SwapperError::NotSupportedPair)?; + let mainnet_token = eth_address::normalize_weth_address(&mainnet_asset, from_chain_evm)?; + + let referral_fees = request.options.fee.clone().unwrap_or_default(); + let referral_fee = if to_chain == Chain::Solana { + if referral_fees.solana.address.is_empty() { + referral_fees.evm_bridge.clone() + } else { + referral_fees.solana.clone() + } + } else { + referral_fees.evm_bridge.clone() + }; + + let output_token_decimals = + u8::try_from(asset_mapping.capital_cost.decimals).map_err(|_| SwapperError::ComputeQuoteError("Unsupported token decimals".into()))?; + + Ok(QuoteContext { + from_amount, + wallet_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, + 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<'_>, provider: Arc) -> Result { + let hubpool_client = HubPoolClient::new(provider.clone(), Chain::Ethereum); + let config_client = ConfigStoreClient::new(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 = eth_rpc::multicall3_call(provider.clone(), &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 { + call_requests.push(ChainlinkPriceFeed::new_eth_usd_feed(provider.clone()).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(provider.clone()).latest_round_call3()); + index_tracker.insert("sol_price", next_index); + } + + let multicall_future = eth_rpc::multicall3_call(provider.clone(), &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 eth_price = index_tracker + .get("eth_price") + .map(|index| ChainlinkPriceFeed::decoded_answer(&multicall_results[*index])) + .transpose()?; + 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 chain_id = Self::get_destination_chain_id(&ctx.to_chain)?; + + Ok(V3RelayData { + depositor: Self::decode_address_bytes32(&ctx.wallet_address), + 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(chain_id), + depositId: U256::from(u32::MAX), + fillDeadline: u32::MAX, + exclusivityDeadline: 0, + message: Bytes::from(message.to_vec()), + }) + } + + fn calculate_relayer_fee_for_destination( + request: &SwapperQuoteRequest, + from_amount: U256, + cost_config: &fees::CapitalCostConfig, + sol_price: Option<&BigInt>, + ) -> U256 { + if Self::is_solana_destination(request) { + if let Some(sol_usd_price) = sol_price { + // 0.000005 SOL in lamports (9 decimals) = 5000 lamports + let sol_fee_amount = U256::from(5000_u64); + Self::calculate_fee_in_token(&sol_fee_amount, sol_usd_price, 6) + } else { + // Fallback to hardcoded value if price is not available + U256::from(5000) + } + } else { + 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() } - /// Return (message, referral_fee) - pub fn message_for_multicall_handler( + 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::NotSupportedPair), + } + } + + fn build_evm_destination_message( &self, + ctx: &QuoteContext<'_>, amount: &U256, - original_output_asset: &AssetId, - output_token: &Address, - user_address: &Address, - referral_fee: &SwapReferralFee, - ) -> (Vec, U256) { - if referral_fee.bps == 0 { - return (vec![], U256::from(0)); + 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.wallet_address), + }); } - let fee_address = Address::from_str(&referral_fee.address).unwrap(); + + let token = output_token_evm.ok_or(SwapperError::NotSupportedPair)?; + let fee_address = Address::from_str(&referral_fee.address).map_err(|_| SwapperError::InvalidAddress(referral_fee.address.clone()))?; 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, &ctx.wallet_address, &user_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, &ctx.wallet_address, &user_amount, &fee_address, &fee_amount) }; + let instructions = multicall_handler::Instructions { calls, - fallbackRecipient: *user_address, + fallbackRecipient: ctx.wallet_address, }; let message = instructions.abi_encode(); - (message, fee_amount) + let multicall_address = Address::from_str(ctx.destination_deployment.multicall_handler().as_str()).unwrap(); + + 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 + .destination_address + .ok_or_else(|| SwapperError::InvalidAddress("Missing Solana destination address".into()))?; + let user_account = SolanaPubkey::from_str(destination_address).map_err(|_| SwapperError::InvalidAddress(destination_address.into()))?; + + 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::InvalidAddress(referral_fee.address.clone()))?; + let handler_program = SolanaPubkey::from_str(MULTICALL_HANDLER).map_err(|_| SwapperError::InvalidAddress(MULTICALL_HANDLER.into()))?; + 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::InvalidAddress("Missing Solana mint".into()))?; + let mint = SolanaPubkey::from_str(mint_id).map_err(|_| SwapperError::InvalidAddress(mint_id.into()))?; + + let token_program = + SolanaPubkey::from_str(program_ids::TOKEN_PROGRAM_ID).map_err(|_| SwapperError::InvalidAddress(program_ids::TOKEN_PROGRAM_ID.into()))?; + + let handler_token_account = get_associated_token_address(&handler_signer, &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::InvalidAmount("Referral fee overflow".into()))?; + let user_amount_u64: u64 = user_amount.try_into().map_err(|_| SwapperError::InvalidAmount("User amount overflow".into()))?; + + let transfer_fee_ix = transfer_checked( + &handler_token_account, + &referral_account, + &mint, + &handler_signer, + fee_amount_u64, + ctx.output_token_decimals, + ); + let transfer_user_ix = transfer_checked( + &handler_token_account, + &user_account, + &mint, + &handler_signer, + user_amount_u64, + ctx.output_token_decimals, + ); + + let accounts = vec![handler_token_account, referral_account, user_account, handler_signer, mint, 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: 3, + 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 + 1) 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( @@ -164,68 +598,46 @@ impl Across { ] } - 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, + ctx: &QuoteContext<'_>, + destination_message: &DestinationMessage, + output_token: FixedBytes<32>, provider: Arc, - chain: Chain, ) -> 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::NotImplemented); + } - 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::NotImplemented)?; + 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 ctx.input_is_native { + format!("{:#x}", ctx.from_amount) + } else { + String::from("0x0") }; - let value = if is_native { format!("{amount:#x}") } else { String::from("0x0") }; - let data = V3SpokePoolInterface::fillV3RelayCall { + 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.wallet_address), } .abi_encode(); - let tx = TransactionObject::new_call_to_value(deployment.spoke_pool, &value, data); + + let tx = TransactionObject::new_call_to_value(ctx.destination_deployment.spoke_pool, &value, data); let gas_limit = eth_rpc::estimate_gas(provider, chain, tx).await; Ok((gas_limit.unwrap_or(U256::from(DEFAULT_FILL_GAS_LIMIT)), v3_relay_data)) } - 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: &SwapReferralFee, - ) -> 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 { @@ -234,6 +646,56 @@ impl Across { U256::from_le_slice(&fee_in_token.to_bytes_le().1) } + async fn fetch_solana_unit_price(provider: Arc) -> Result { + let client = jsonrpc_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::NetworkError("Failed to fetch recent prioritization fees".to_string())); + } + + // Calculate average prioritization fee from recent transactions + let total_fee: u64 = fees.iter().map(|f| f.prioritization_fee as u64).sum(); + let average_fee = total_fee / fees.len() as u64; + + // Return at least 1 microlamport per compute unit + Ok(std::cmp::max(1, average_fee)) + } + + async fn calculate_gas_price_and_fee( + &self, + ctx: &QuoteContext<'_>, + destination_message: &DestinationMessage, + output_token: FixedBytes<32>, + provider: Arc, + eth_price: Option<&BigInt>, + ) -> Result<(U256, V3RelayData), SwapperError> { + if ctx.to_chain == Chain::Solana { + let unit_price = Self::fetch_solana_unit_price(provider.clone()).await?; + let gas_fee = DEFAULT_SOLANA_COMPUTE_LIMIT * unit_price; + + 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((U256::from(gas_fee), v3_relay_data)) + } else { + let gas_chain = ctx.to_chain; + let gas_price_req = eth_rpc::fetch_gas_price(provider.clone(), gas_chain); + let gas_limit_req = self.estimate_gas_limit(ctx, destination_message, output_token, provider.clone()); + + 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); + } + + 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)?; @@ -274,182 +736,115 @@ impl Swapper for Across { SwapperChainAsset::Assets(Chain::ZkSync, vec![ZKSYNC_WETH.id.clone(), ZKSYNC_USDT.id.clone()]), SwapperChainAsset::Assets(Chain::World, vec![WORLD_WETH.id.clone()]), SwapperChainAsset::Assets(Chain::Ink, vec![INK_WETH.id.clone(), INK_USDT.id.clone()]), + SwapperChainAsset::Assets(Chain::Hyperliquid, vec![HYPEREVM_USDC.id.clone()]), SwapperChainAsset::Assets(Chain::Unichain, vec![UNICHAIN_WETH.id.clone(), UNICHAIN_USDC.id.clone()]), SwapperChainAsset::Assets(Chain::SmartChain, vec![SMARTCHAIN_ETH.id.clone()]), + SwapperChainAsset::Assets(Chain::Solana, vec![SOLANA_USDC.id.clone()]), ] } async fn fetch_quote(&self, request: &SwapperQuoteRequest, provider: Arc) -> Result { - // does not support same chain swap - if request.from_asset.chain() == request.to_asset.chain() { - return Err(SwapperError::NotSupportedPair); - } - - 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::NotSupportedPair); - } - - let input_asset = eth_address::normalize_weth_asset(&request.from_asset.asset_id()).ok_or(SwapperError::NotSupportedPair)?; - let output_asset = eth_address::normalize_weth_asset(&request.to_asset.asset_id()).ok_or(SwapperError::NotSupportedPair)?; - let original_output_asset = request.to_asset.asset_id(); - let output_token = eth_address::parse_asset_id(&output_asset)?; - - // 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::normalize_weth_address(asset_mainnet, from_chain)?; - - let hubpool_client = HubPoolClient::new(provider.clone(), Chain::Ethereum); - let config_client = ConfigStoreClient::new(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 = eth_rpc::multicall3_call(provider.clone(), &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 eth_price_feed = ChainlinkPriceFeed::new_eth_usd_feed(provider.clone()); - if !input_is_native { - calls.push(eth_price_feed.latest_round_call3()); - } - - let multicall_req = eth_rpc::multicall3_call(provider.clone(), &hubpool_client.chain, calls); + let ctx = self.build_context(request)?; + let pool_state = self.fetch_pool_state(&ctx, provider.clone()).await?; - let batch_results = futures::join!(token_config_req, multicall_req); - let token_config = batch_results.0?; - let multicall_results = batch_results.1?; - - 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 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(&util_before, &util_after, false); - let lpfee = fees::multiply(from_amount, lpfee_percent, cost_config.decimals); + 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); debug_println!("lpfee: {}", lpfee); - // 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 relayer_fee = Self::calculate_relayer_fee_for_destination(request, ctx.from_amount, &ctx.capital_cost, pool_state.sol_price.as_ref()); debug_println!("relayer_fee: {}", relayer_fee); - let referral_config = request.options.fee.clone().unwrap_or_default().evm_bridge; + if lpfee + relayer_fee >= ctx.from_amount { + return Err(SwapperError::InputAmountTooSmall); + } + let remain_amount = ctx.from_amount - lpfee - relayer_fee; - // 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 output_token_evm = if ctx.to_chain.chain_type() == ChainType::Ethereum { + Some(eth_address::parse_asset_id(&ctx.output_asset)?) + } else { + None + }; - let gas_price_req = eth_rpc::fetch_gas_price(provider.clone(), request.to_asset.chain()); - let gas_limit_req = self.estimate_gas_limit( - &from_amount, - input_is_native, - &input_asset, - &output_token, - &wallet_address, - &message, - &destination_deployment, - provider.clone(), - 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, + provider.clone(), + pool_state.eth_price.as_ref(), + ) + .await?; + debug_println!("gas_fee: {}", gas_fee); - let (tuple, gas_price) = futures::join!(gas_limit_req, gas_price_req); - let (gas_limit, mut v3_relay_data) = tuple?; - let mut gas_fee = gas_limit * gas_price?; - if !input_is_native { - let eth_price = ChainlinkPriceFeed::decoded_answer(&multicall_results[3])?; - gas_fee = Self::calculate_fee_in_token(&gas_fee, ð_price, 6); + if remain_amount <= gas_fee { + return Err(SwapperError::InputAmountTooSmall); } - debug_println!("gas_fee: {}", gas_fee); + 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::InputAmountTooSmall); } + 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(SwapperQuote { from_value: request.value.clone(), to_value: to_value.to_string(), data: SwapperProviderData { - provider: self.provider().clone(), + provider: self.provider.clone(), slippage_bps: request.options.slippage.bps, routes: vec![SwapperRoute { - 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: &SwapperQuote, provider: Arc, data: FetchQuoteData) -> Result { let from_chain = quote.request.from_asset.chain(); 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; + + // input token uses bytes32 (EVM padded or Solana raw depending on origin chain) + let input_asset_id = quote.request.from_asset.asset_id(); + let input_token = Self::token_bytes32_for_asset(&input_asset_id)?; + + // output token may be EVM or Solana depending on destination chain + let to_asset_id = quote.request.to_asset.asset_id(); + let output_token = Self::token_bytes32_for_asset(&to_asset_id)?; + + let deposit_v3_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, @@ -466,7 +861,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, provider.clone(), @@ -498,6 +893,7 @@ impl Swapper for Across { Ok(quote_data) } + async fn get_swap_result(&self, chain: Chain, transaction_hash: &str, provider: Arc) -> Result { let api = AcrossApi::new(provider.clone()); let status = api.deposit_status(chain, transaction_hash).await?; @@ -525,8 +921,79 @@ impl Swapper for Across { #[cfg(test)] mod tests { use super::*; - use gem_evm::multicall3::IMulticall3; + use crate::config::swap_config::SwapReferralFee; + use crate::network::mock::{AlienProviderMock, MockFn}; + use crate::swapper::{ + across::solana::AcrossPlusMessage, + models::{SwapperOptions, SwapperQuoteRequest}, + remote_models::{SwapperMode, SwapperQuoteAsset}, + }; + use gem_evm::{ + across::contracts::{multicall_handler, spoke_pool::V3SpokePoolInterface::depositCall}, + multicall3::IMulticall3, + weth::WETH9, + }; + use num_bigint::BigInt; 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) -> SwapperQuoteRequest { + SwapperQuoteRequest { + 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: SwapperOptions::default(), + } + } + + #[allow(clippy::too_many_arguments)] + fn make_quote_context<'a>( + _request: &'a SwapperQuoteRequest, + from_amount: U256, + wallet_address: &str, + from_chain: Chain, + to_chain: Chain, + input_asset: AssetId, + output_asset: AssetId, + original_output_asset: AssetId, + referral_fee: SwapReferralFee, + destination_address: Option<&'a str>, + input_is_native: bool, + output_token_decimals: u8, + ) -> QuoteContext<'a> { + QuoteContext { + from_amount, + wallet_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(), + destination_address, + output_token_decimals, + } + } #[test] fn test_is_supported_pair() { @@ -538,6 +1005,7 @@ mod tests { let usdc_eth: AssetId = USDC_ETH_ASSET_ID.into(); let usdc_arb: AssetId = USDC_ARB_ASSET_ID.into(); + // EVM -> EVM pairs assert!(Across::is_supported_pair(&weth_eth, &weth_op)); assert!(Across::is_supported_pair(&weth_op, &weth_arb)); assert!(Across::is_supported_pair(&usdc_eth, &usdc_arb)); @@ -555,6 +1023,61 @@ mod tests { assert!(Across::is_supported_pair(&op, ð)); assert!(Across::is_supported_pair(&arb, ð)); assert!(Across::is_supported_pair(&op, &arb)); + + // EVM -> Solana pairs + 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 = AssetId::from_token(Chain::Solana, "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"); + assert!(!Across::is_supported_pair(&usdc_eth, &solana_usdt)); // Only USDC supported + + 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)); + } + + #[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() { + // https://etherscan.io/tx/0xd2f84832c9e05ed6b9c1685e253c50c77d52334e354c8af665c7d1159946919b + let depositor_addr = Address::from_str("0x514bcb1f9aabb904e6106bd1052b66d2706dbbb7").unwrap(); + let input_token_addr = Address::from_str("0xaf88d065e77c8cc2239327c5edb3a432268e5831").unwrap(); // USDC on Arbitrum + 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] @@ -573,4 +1096,249 @@ mod tests { assert_eq!(fee_in_token.to_string(), "6243790"); } + + #[test] + fn test_build_destination_message_eth_to_base() { + let across = Across::default(); + let amount = U256::from_str("1000000000000000000").unwrap(); // 1 ETH + let request = make_request( + AssetId::from_chain(Chain::Ethereum), + AssetId::from_chain(Chain::Base), + "0x1111111111111111111111111111111111111111", + "11111111111111111111111111111111", + amount.to_string().as_str(), + ); + let referral_fee = SwapReferralFee { + address: "0x2222222222222222222222222222222222222222".into(), + bps: 100, + }; + 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.wallet_address); + assert_eq!(instructions.calls.len(), 3); + + 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].value + instructions.calls[2].value, amount); + } + + #[test] + fn test_build_destination_message_usdc_to_optimism() { + let across = Across::default(); + 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 = SwapReferralFee { + address: "0x2222222222222222222222222222222222222222".into(), + bps: 100, + }; + 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(), 2); + assert_eq!(instructions.calls[0].target, token_address); + assert_eq!(instructions.calls[1].target, token_address); + assert_eq!(instructions.calls[0].value, U256::from(0)); + assert_eq!(instructions.calls[1].value, U256::from(0)); + } + + #[test] + fn test_build_destination_message_solana_with_referral() { + let across = Across::default(); + 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 = SwapReferralFee { + 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, 3); + assert!(across_message.accounts.iter().any(|acc| acc.to_string() == destination)); + assert!(across_message.accounts.iter().any(|acc| acc.to_string() == referral_address)); + + let compiled: Vec = borsh::from_slice(&across_message.handler_message).unwrap(); + assert_eq!(compiled.len(), 2); + assert_eq!(compiled[0].account_key_indexes.len(), 4); + } + + #[tokio::test] + async fn test_realy_data_recipient_destination() { + // Mock EVM gas estimation response and verify recipient selection + let across = Across::default(); + 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(), + SwapReferralFee::default(), + None, + true, + 6, + ); + + // Build a mock provider that always returns a valid JSON-RPC result for gas estimation + let provider = Arc::new(AlienProviderMock { + response: MockFn(Box::new(|_target| { + // JsonRpcResult matching client.request expectation + "{\"id\":1,\"result\":\"0x5208\"}".to_string() // 21000 + })), + timeout: Duration::from_millis(50), + }); + + // Case 1: empty message -> recipient is user address + 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, provider.clone()).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); + + // Assert tokens for ETH (native) -> OP USDC + 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); + + // Case 2: non-empty message -> recipient is multicall handler address + 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, provider.clone()).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); + + // Case 3: ETH (Ethereum) -> ETH (Base) should use WETH addresses for input/output tokens + 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), + SwapReferralFee::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, provider).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); + } } diff --git a/gemstone/src/swapper/across/solana.rs b/gemstone/src/swapper/across/solana.rs new file mode 100644 index 000000000..29ef5d10e --- /dev/null +++ b/gemstone/src/swapper/across/solana.rs @@ -0,0 +1,36 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_primitives::Pubkey; + +pub const MULTICALL_HANDLER: &str = "HaQe51FWtnmaEcuYEfPA7MRCXKrtqptat4oJdJ8zV5Be"; + +#[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/gemstone/src/swapper/asset.rs b/gemstone/src/swapper/asset.rs index c72a426cc..0e46877c9 100644 --- a/gemstone/src/swapper/asset.rs +++ b/gemstone/src/swapper/asset.rs @@ -38,6 +38,9 @@ pub const BASE_USDC_TOKEN_ID: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 pub const BASE_USDS_TOKEN_ID: &str = "0x820C137fa70C8691f0e44Dc420a5e53c168921Dc"; pub const BASE_CBBTC_TOKEN_ID: &str = "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf"; pub const BASE_WBTC_TOKEN_ID: &str = "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c"; +// Hyperliquid +pub const HYPEREVM_USDC_TOKEN_ID: &str = "0xb88339CB7199b77E23DB6E890353E22632Ba630f"; +pub const HYPEREVM_USDT_TOKEN_ID: &str = "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb"; // Solana pub const SOLANA_USDC_TOKEN_ID: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; pub const SOLANA_USDT_TOKEN_ID: &str = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; @@ -354,6 +357,20 @@ lazy_static! { 18, AssetType::ERC20, ); + pub static ref HYPEREVM_USDC: Asset = Asset::new( + AssetId::from_token(Chain::Hyperliquid, HYPEREVM_USDC_TOKEN_ID), + USDC_NAME.to_owned(), + USDC_SYMBOL.to_owned(), + 6, + AssetType::ERC20, + ); + pub static ref HYPEREVM_USDT: Asset = Asset::new( + AssetId::from_token(Chain::Hyperliquid, HYPEREVM_USDT_TOKEN_ID), + USDT_NAME.to_owned(), + USDT_SYMBOL.to_owned(), + 6, + AssetType::ERC20, + ); // Solana pub static ref SOLANA_USDC: Asset = Asset::new( AssetId::from_token(Chain::Solana, SOLANA_USDC_TOKEN_ID), diff --git a/gemstone/src/swapper/chainlink.rs b/gemstone/src/swapper/chainlink.rs index dc301548a..4e5c60ebc 100644 --- a/gemstone/src/swapper/chainlink.rs +++ b/gemstone/src/swapper/chainlink.rs @@ -10,7 +10,7 @@ use crate::{ swapper::SwapperError, }; use gem_evm::{ - chainlink::contract::{AggregatorInterface, CHAINLINK_ETH_USD_FEED}, + chainlink::contract::{AggregatorInterface, CHAINLINK_ETH_USD_FEED, CHAINLINK_SOL_USD_FEED}, jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, multicall3::{create_call3, decode_call3_return, IMulticall3}, }; @@ -28,6 +28,13 @@ impl ChainlinkPriceFeed { } } + pub fn new_sol_usd_feed(provider: Arc) -> ChainlinkPriceFeed { + ChainlinkPriceFeed { + contract: CHAINLINK_SOL_USD_FEED.into(), + client: jsonrpc_client_with_chain(provider, Chain::Ethereum), + } + } + pub fn latest_round_call3(&self) -> IMulticall3::Call3 { create_call3(&self.contract, AggregatorInterface::latestRoundDataCall {}) }