From 62c22f8cf57c92e326849402bbea769bf1b4ac3f Mon Sep 17 00:00:00 2001 From: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> Date: Fri, 15 May 2026 16:37:18 +0000 Subject: [PATCH] Improve Uniswap quote routing --- crates/gem_evm/src/uniswap/actions.rs | 75 +++--- crates/gem_evm/src/uniswap/mod.rs | 12 + crates/gem_evm/src/uniswap/path.rs | 87 +++---- crates/swapper/src/uniswap/quote_result.rs | 13 +- crates/swapper/src/uniswap/swap_route.rs | 45 ++-- crates/swapper/src/uniswap/v3/commands.rs | 218 +++++++++++++----- crates/swapper/src/uniswap/v3/path.rs | 112 ++++++--- crates/swapper/src/uniswap/v3/provider.rs | 100 +++++--- crates/swapper/src/uniswap/v4/commands.rs | 99 +++++--- crates/swapper/src/uniswap/v4/path.rs | 85 +++++-- crates/swapper/src/uniswap/v4/provider.rs | 254 +++++++++++++++------ crates/swapper/src/uniswap/v4/quoter.rs | 6 +- 12 files changed, 742 insertions(+), 364 deletions(-) diff --git a/crates/gem_evm/src/uniswap/actions.rs b/crates/gem_evm/src/uniswap/actions.rs index 2390e261e2..95b43c5334 100644 --- a/crates/gem_evm/src/uniswap/actions.rs +++ b/crates/gem_evm/src/uniswap/actions.rs @@ -52,49 +52,44 @@ pub fn decode_action_data(data: &[u8]) -> Result, alloy_sol_types: // The ABI encoding for a sequence of actions is (bytes opcodes, bytes[] action_data) let (action_opcodes_bytes, action_data_bytes) = <(Bytes, Vec) as SolValue>::abi_decode_sequence(data)?; - let action_opcodes: Vec = action_opcodes_bytes.to_vec(); - let action_data_list: Vec> = action_data_bytes.into_iter().map(|b| b.to_vec()).collect(); - - if action_opcodes.len() != action_data_list.len() { + if action_opcodes_bytes.len() != action_data_bytes.len() { return Err(alloy_sol_types::Error::Other("Mismatched opcodes and data lengths".into())); } - let mut decoded_actions = Vec::with_capacity(action_opcodes.len()); - - for (i, opcode) in action_opcodes.iter().enumerate() { - let action_data = &action_data_list[i]; - let action_data_slice = action_data.as_slice(); - let action = match *opcode { - SWAP_EXACT_IN_SINGLE_ACTION => V4Action::SWAP_EXACT_IN_SINGLE(::abi_decode(action_data_slice)?), - SWAP_EXACT_IN_ACTION => V4Action::SWAP_EXACT_IN(::abi_decode(action_data_slice)?), - SWAP_EXACT_OUT_SINGLE_ACTION => V4Action::SWAP_EXACT_OUT_SINGLE(::abi_decode(action_data_slice)?), - SWAP_EXACT_OUT_ACTION => V4Action::SWAP_EXACT_OUT(::abi_decode(action_data_slice)?), - SETTLE_ACTION => { - let (currency, amount, payer_is_user) = <(Address, U256, bool) as SolValue>::abi_decode(action_data_slice)?; - V4Action::SETTLE { currency, amount, payer_is_user } - } - SETTLE_ALL_ACTION => { - let (currency, max_amount) = <(Address, U256) as SolValue>::abi_decode(action_data_slice)?; - V4Action::SETTLE_ALL { currency, max_amount } - } - TAKE_ACTION => { - let (currency, recipient, amount) = <(Address, Address, U256) as SolValue>::abi_decode(action_data_slice)?; - V4Action::TAKE { currency, recipient, amount } - } - TAKE_ALL_ACTION => { - let (currency, min_amount) = <(Address, U256) as SolValue>::abi_decode(action_data_slice)?; - V4Action::TAKE_ALL { currency, min_amount } - } - TAKE_PORTION_ACTION => { - let (currency, recipient, bips) = <(Address, Address, U256) as SolValue>::abi_decode(action_data_slice)?; - V4Action::TAKE_PORTION { currency, recipient, bips } - } - _ => return Err(alloy_sol_types::Error::Other(format!("Unknown action opcode: {opcode}").into())), - }; - decoded_actions.push(action); - } - - Ok(decoded_actions) + action_opcodes_bytes + .iter() + .zip(action_data_bytes.iter()) + .map(|(opcode, action_data)| { + let action_data_slice = action_data.as_ref(); + Ok(match *opcode { + SWAP_EXACT_IN_SINGLE_ACTION => V4Action::SWAP_EXACT_IN_SINGLE(::abi_decode(action_data_slice)?), + SWAP_EXACT_IN_ACTION => V4Action::SWAP_EXACT_IN(::abi_decode(action_data_slice)?), + SWAP_EXACT_OUT_SINGLE_ACTION => V4Action::SWAP_EXACT_OUT_SINGLE(::abi_decode(action_data_slice)?), + SWAP_EXACT_OUT_ACTION => V4Action::SWAP_EXACT_OUT(::abi_decode(action_data_slice)?), + SETTLE_ACTION => { + let (currency, amount, payer_is_user) = <(Address, U256, bool) as SolValue>::abi_decode(action_data_slice)?; + V4Action::SETTLE { currency, amount, payer_is_user } + } + SETTLE_ALL_ACTION => { + let (currency, max_amount) = <(Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::SETTLE_ALL { currency, max_amount } + } + TAKE_ACTION => { + let (currency, recipient, amount) = <(Address, Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::TAKE { currency, recipient, amount } + } + TAKE_ALL_ACTION => { + let (currency, min_amount) = <(Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::TAKE_ALL { currency, min_amount } + } + TAKE_PORTION_ACTION => { + let (currency, recipient, bips) = <(Address, Address, U256) as SolValue>::abi_decode(action_data_slice)?; + V4Action::TAKE_PORTION { currency, recipient, bips } + } + _ => return Err(alloy_sol_types::Error::Other(format!("Unknown action opcode: {opcode}").into())), + }) + }) + .collect() } #[rustfmt::skip] diff --git a/crates/gem_evm/src/uniswap/mod.rs b/crates/gem_evm/src/uniswap/mod.rs index 498f03e7b7..b146b30c1f 100644 --- a/crates/gem_evm/src/uniswap/mod.rs +++ b/crates/gem_evm/src/uniswap/mod.rs @@ -53,6 +53,7 @@ impl TryFrom for FeeTier { fn try_from(value: u32) -> Result { match value { 100 => Ok(FeeTier::Hundred), + 400 => Ok(FeeTier::FourHundred), 500 => Ok(FeeTier::FiveHundred), 1500 => Ok(FeeTier::ThousandFiveHundred), 2500 => Ok(FeeTier::TwoThousandFiveHundred), @@ -62,3 +63,14 @@ impl TryFrom for FeeTier { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fee_tier_try_from_four_hundred() { + assert_eq!(FeeTier::try_from(400).unwrap(), FeeTier::FourHundred); + assert_eq!(FeeTier::try_from("400").unwrap(), FeeTier::FourHundred); + } +} diff --git a/crates/gem_evm/src/uniswap/path.rs b/crates/gem_evm/src/uniswap/path.rs index 1d221d20f7..c1ec5e710a 100644 --- a/crates/gem_evm/src/uniswap/path.rs +++ b/crates/gem_evm/src/uniswap/path.rs @@ -23,11 +23,10 @@ pub struct TokenPairs(pub Vec); impl Display for TokenPairs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "[")?; - let mut iter = self.0.iter(); - if let Some(first) = iter.next() { - write!(f, "{first}")?; // Write first element without a leading comma - for item in iter { - write!(f, ", {item}")?; // Write subsequent elements with a leading comma + if let Some((first, rest)) = self.0.split_first() { + write!(f, "{first}")?; + for item in rest { + write!(f, ", {item}")?; } } write!(f, "]") @@ -36,16 +35,20 @@ impl Display for TokenPairs { impl TokenPair { pub fn new_two_hop(token_in: &Address, intermediary: &Address, token_out: &Address, fee_tier: FeeTier) -> Vec { + Self::new_two_hop_with_fees(token_in, intermediary, token_out, fee_tier, fee_tier) + } + + pub fn new_two_hop_with_fees(token_in: &Address, intermediary: &Address, token_out: &Address, first_fee_tier: FeeTier, second_fee_tier: FeeTier) -> Vec { vec![ TokenPair { token_in: *token_in, token_out: *intermediary, - fee_tier, + fee_tier: first_fee_tier, }, TokenPair { token_in: *intermediary, token_out: *token_out, - fee_tier, + fee_tier: second_fee_tier, }, ] } @@ -60,17 +63,15 @@ pub struct BasePair { impl BasePair { pub fn path_building_array(&self) -> Vec
{ - let mut array = vec![self.native]; - array.extend(self.stables.iter().cloned()); // alternatives is not used for path building to reduce requests - array + std::iter::once(self.native).chain(self.stables.iter().copied()).collect() } pub fn fee_token_array(&self) -> Vec
{ - let mut array = vec![self.native]; - array.extend(self.stables.iter().cloned()); - array.extend(self.alternatives.iter().cloned()); - array + std::iter::once(self.native) + .chain(self.stables.iter().copied()) + .chain(self.alternatives.iter().copied()) + .collect() } } @@ -150,57 +151,43 @@ pub fn get_base_pair(chain: &EVMChain, weth_as_native: bool) -> Option _ => panic!("USDT is not configured for this chain"), }; - let mut stables = vec![]; - if !usdc.is_empty() { - stables.push(usdc.parse().ok()?); - } - if !usdt.is_empty() { - stables.push(usdt.parse().ok()?); - } - let alternatives = { if btc.is_empty() { vec![] } else { vec![btc.parse().ok()?] } }; + let stables = [usdc, usdt] + .into_iter() + .filter(|token| !token.is_empty()) + .map(|token| token.parse().ok()) + .collect::>>()?; + let alternatives = if btc.is_empty() { vec![] } else { vec![btc.parse().ok()?] }; Some(BasePair { native, stables, alternatives }) } pub fn build_direct_pair(token_in: &Address, token_out: &Address, fee_tier: FeeTier) -> Bytes { - let mut bytes: Vec = vec![]; - let fee = U24::from(fee_tier.as_u24()); - bytes.extend(token_in.as_slice()); - bytes.extend(&fee.to_be_bytes_vec()); - bytes.extend(token_out.as_slice()); - Bytes::from(bytes) + let fee = U24::from(fee_tier.as_u24()).to_be_bytes_vec(); + Bytes::from([token_in.as_slice(), fee.as_slice(), token_out.as_slice()].concat()) } pub fn validate_pairs(token_pairs: &[TokenPair]) -> bool { // verify token in and out are chained - let mut iter = token_pairs.iter().peekable(); - let mut valid = true; - while let Some(current_pair) = iter.next() { - if let Some(next_pair) = iter.peek() - && current_pair.token_out != next_pair.token_in - { - valid = false; - break; - } - } - valid + token_pairs.windows(2).all(|pairs| pairs[0].token_out == pairs[1].token_in) } pub fn build_pairs(token_pairs: &[TokenPair]) -> Bytes { - let valid = validate_pairs(token_pairs); - if !valid { + if !validate_pairs(token_pairs) { panic!("invalid token pairs"); } - let mut bytes: Vec = vec![]; - for (idx, token_pair) in token_pairs.iter().enumerate() { - let fee = U24::from(token_pair.fee_tier.as_u24()); - if idx == 0 { - bytes.extend(token_pair.token_in.as_slice()); - } - bytes.extend(&fee.to_be_bytes_vec()); - bytes.extend(token_pair.token_out.as_slice()); - } + let bytes = token_pairs + .iter() + .enumerate() + .flat_map(|(idx, token_pair)| { + let fee = U24::from(token_pair.fee_tier.as_u24()).to_be_bytes_vec(); + if idx == 0 { + [token_pair.token_in.as_slice(), fee.as_slice(), token_pair.token_out.as_slice()].concat() + } else { + [fee.as_slice(), token_pair.token_out.as_slice()].concat() + } + }) + .collect::>(); Bytes::from(bytes) } diff --git a/crates/swapper/src/uniswap/quote_result.rs b/crates/swapper/src/uniswap/quote_result.rs index 44edb492ca..3c25f34861 100644 --- a/crates/swapper/src/uniswap/quote_result.rs +++ b/crates/swapper/src/uniswap/quote_result.rs @@ -5,7 +5,7 @@ use gem_jsonrpc::types::{JsonRpcError, JsonRpcResponse, JsonRpcResult, JsonRpcRe #[derive(Debug)] pub struct QuoteResult { pub amount_out: U256, - pub fee_tier_idx: usize, + pub route_idx: usize, pub batch_idx: usize, } @@ -22,10 +22,10 @@ where .0 .iter() .enumerate() - .filter_map(|(fee_idx, result)| match result { + .filter_map(|(route_idx, result)| match result { JsonRpcResult::Value(value) => decoder(value).ok().map(|quoter_tuple| QuoteResult { amount_out: quoter_tuple.0, - fee_tier_idx: fee_idx, + route_idx, batch_idx, }), _ => None, @@ -37,3 +37,10 @@ where .max_by_key(|quote| quote.amount_out) .ok_or(SwapperError::NoQuoteAvailable) } + +pub fn get_selected_candidate<'a, T>(candidates: &'a [Vec], quote: &QuoteResult) -> Result<&'a T, SwapperError> { + candidates + .get(quote.batch_idx) + .and_then(|batch| batch.get(quote.route_idx)) + .ok_or(SwapperError::InvalidRoute) +} diff --git a/crates/swapper/src/uniswap/swap_route.rs b/crates/swapper/src/uniswap/swap_route.rs index ed11809cef..c95c47910b 100644 --- a/crates/swapper/src/uniswap/swap_route.rs +++ b/crates/swapper/src/uniswap/swap_route.rs @@ -1,7 +1,7 @@ -use crate::Route; +use crate::{Route, SwapperError}; use alloy_primitives::Address; -use gem_evm::uniswap::path::BasePair; -use primitives::AssetId; +use gem_evm::uniswap::path::{BasePair, TokenPair}; +use primitives::{AssetId, Chain}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -23,26 +23,23 @@ pub fn get_intermediaries_by_array(token_in: &Address, token_out: &Address, arra .collect() } -pub fn build_swap_route(token_in: &AssetId, intermediary: Option<&AssetId>, token_out: &AssetId, route_data: &RouteData) -> Vec { - let data = serde_json::to_string(route_data).unwrap(); - if let Some(intermediary) = intermediary { - vec![ - Route { - input: token_in.clone(), - output: intermediary.clone(), - route_data: data.clone(), - }, - Route { - input: intermediary.clone(), - output: token_out.clone(), - route_data: data, - }, - ] - } else { - vec![Route { - input: token_in.clone(), - output: token_out.clone(), - route_data: data, - }] +pub fn build_swap_route(chain: Chain, token_pairs: &[TokenPair], min_amount_out: &str) -> Result, SwapperError> { + if token_pairs.is_empty() { + return Err(SwapperError::InvalidRoute); } + + token_pairs + .iter() + .map(|pair| { + let route_data = RouteData { + fee_tier: (pair.fee_tier as u32).to_string(), + min_amount_out: min_amount_out.to_string(), + }; + Ok(Route { + input: AssetId::from(chain, Some(pair.token_in.to_checksum(None))), + output: AssetId::from(chain, Some(pair.token_out.to_checksum(None))), + route_data: serde_json::to_string(&route_data).map_err(|_| SwapperError::InvalidRoute)?, + }) + }) + .collect() } diff --git a/crates/swapper/src/uniswap/v3/commands.rs b/crates/swapper/src/uniswap/v3/commands.rs index dcc674e81b..b9402d2d20 100644 --- a/crates/swapper/src/uniswap/v3/commands.rs +++ b/crates/swapper/src/uniswap/v3/commands.rs @@ -1,4 +1,4 @@ -use crate::{SwapperError, eth_address, fees::apply_slippage_in_bp, models::*, uniswap::requires_native_wrapping}; +use crate::{SwapperError, eth_address, models::*, uniswap::requires_native_wrapping}; use gem_evm::uniswap::command::{ADDRESS_THIS, PayPortion, Permit2Permit, Sweep, Transfer, UniversalRouterCommand, UnwrapWeth, V3SwapExactIn, WrapEth}; use alloy_primitives::{Address, Bytes, U256}; @@ -9,108 +9,117 @@ pub fn build_commands( token_in: &Address, token_out: &Address, amount_in: U256, - quote_amount: U256, + amount_out_min: U256, path: &Bytes, permit: Option, fee_token_is_input: bool, ) -> Result, SwapperError> { - let options = request.options.clone(); - let fee_options = options.fee.unwrap_or_default().evm; + let fee_options = request.options.fee.as_ref().map(|fees| &fees.evm).filter(|fee| fee.bps > 0); let recipient = eth_address::parse_str(&request.wallet_address)?; + let address_this = Address::from_str(ADDRESS_THIS).unwrap(); let wrap_input_eth = requires_native_wrapping(&request.from_asset.asset_id()); let unwrap_output_weth = requires_native_wrapping(&request.to_asset.asset_id()); - let pay_fees = fee_options.bps > 0; - let mut commands: Vec = vec![]; - - let amount_out = apply_slippage_in_bp("e_amount, options.slippage.bps + fee_options.bps); - if wrap_input_eth { + let setup_command = if wrap_input_eth { // Wrap ETH, recipient is this_address - commands.push(UniversalRouterCommand::WRAP_ETH(WrapEth { - recipient: Address::from_str(ADDRESS_THIS).unwrap(), + Some(UniversalRouterCommand::WRAP_ETH(WrapEth { + recipient: address_this, amount_min: amount_in, - })); - } else if let Some(permit) = permit { - commands.push(UniversalRouterCommand::PERMIT2_PERMIT(permit)); - } + })) + } else { + permit.map(UniversalRouterCommand::PERMIT2_PERMIT) + }; // payer_is_user: is true when swapping tokens let payer_is_user = !wrap_input_eth; - if pay_fees { + let swap_recipient = if unwrap_output_weth { address_this } else { recipient }; + let swap_commands = if let Some(fee_options) = fee_options { + let fee_recipient = Address::from_str(fee_options.address.as_str()).unwrap(); if fee_token_is_input { // insert TRANSFER fee first let fee = amount_in * U256::from(fee_options.bps) / U256::from(10000); - let fee_recipient = Address::from_str(fee_options.address.as_str()).unwrap(); - if wrap_input_eth { + let fee_command = if wrap_input_eth { // if input is native ETH, we can transfer directly because of WRAP_ETH command - commands.push(UniversalRouterCommand::TRANSFER(Transfer { + UniversalRouterCommand::TRANSFER(Transfer { token: *token_in, recipient: fee_recipient, value: fee, - })); + }) } else { // call permit2 transfer instead - commands.push(UniversalRouterCommand::PERMIT2_TRANSFER_FROM(Transfer { + UniversalRouterCommand::PERMIT2_TRANSFER_FROM(Transfer { token: *token_in, recipient: fee_recipient, value: fee, - })); + }) }; - // insert V3_SWAP_EXACT_IN with amount - fee, recipient is user address - commands.push(UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { - recipient, - amount_in: amount_in - fee, - amount_out_min: amount_out, - path: path.clone(), - payer_is_user, - })); + vec![ + fee_command, + UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: swap_recipient, + amount_in: amount_in - fee, + amount_out_min, + path: path.clone(), + payer_is_user, + }), + ] } else { // insert V3_SWAP_EXACT_IN // amount_out_min: if needs to pay fees, amount_out_min set to 0 and we will sweep the rest - commands.push(UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { - recipient: Address::from_str(ADDRESS_THIS).unwrap(), + let swap_command = UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: address_this, amount_in, - amount_out_min: if pay_fees { U256::from(0) } else { amount_out }, + amount_out_min: U256::from(0), path: path.clone(), payer_is_user, - })); + }); // insert PAY_PORTION to fee_address - commands.push(UniversalRouterCommand::PAY_PORTION(PayPortion { + let fee_command = UniversalRouterCommand::PAY_PORTION(PayPortion { token: *token_out, - recipient: Address::from_str(fee_options.address.as_str()).unwrap(), + recipient: fee_recipient, bips: U256::from(fee_options.bps), - })); + }); - if !unwrap_output_weth { + if unwrap_output_weth { + vec![swap_command, fee_command] + } else { // MSG_SENDER should be the address of the caller - commands.push(UniversalRouterCommand::SWEEP(Sweep { - token: *token_out, - recipient, - amount_min: U256::from(amount_out), - })); + vec![ + swap_command, + fee_command, + UniversalRouterCommand::SWEEP(Sweep { + token: *token_out, + recipient, + amount_min: amount_out_min, + }), + ] } } } else { // insert V3_SWAP_EXACT_IN - commands.push(UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { - recipient, + vec![UniversalRouterCommand::V3_SWAP_EXACT_IN(V3SwapExactIn { + recipient: swap_recipient, amount_in, - amount_out_min: amount_out, + amount_out_min, path: path.clone(), payer_is_user, - })); - } + })] + }; - if unwrap_output_weth { + let unwrap_command = if unwrap_output_weth { // insert UNWRAP_WETH - commands.push(UniversalRouterCommand::UNWRAP_WETH(UnwrapWeth { + Some(UniversalRouterCommand::UNWRAP_WETH(UnwrapWeth { recipient, - amount_min: U256::from(amount_out), - })); - } + amount_min: amount_out_min, + })) + } else { + None + }; + + let commands = setup_command.into_iter().chain(swap_commands).chain(unwrap_command).collect(); Ok(commands) } @@ -132,7 +141,7 @@ mod tests { #[test] fn test_build_commands_eth_to_token() { - let mut request = QuoteRequest { + let request = QuoteRequest { // ETH -> USDC from_asset: AssetId::from(Chain::Ethereum, None).into(), to_asset: AssetId::from(Chain::Ethereum, Some(ETHEREUM_USDC_TOKEN_ID.into())).into(), @@ -155,15 +164,17 @@ mod tests { assert!(matches!(commands[0], UniversalRouterCommand::WRAP_ETH(_))); assert!(matches!(commands[1], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); - let options = Options { - slippage: 100.into(), - fee: Some(ReferralFees::evm(ReferralFee { - bps: 25, - address: "0x3d83ec320541ae96c4c91e9202643870458fb290".into(), - })), - use_max_amount: false, + let request = QuoteRequest { + options: Options { + slippage: 100.into(), + fee: Some(ReferralFees::evm(ReferralFee { + bps: 25, + address: "0x3d83ec320541ae96c4c91e9202643870458fb290".into(), + })), + use_max_amount: false, + }, + ..request }; - request.options = options; let commands = super::build_commands(&request, &token_in, &token_out, amount_in, U256::from(0), &path, None, false).unwrap(); @@ -214,6 +225,30 @@ mod tests { assert!(matches!(commands[1], UniversalRouterCommand::V3_SWAP_EXACT_IN(_))); } + #[test] + fn test_build_commands_uses_min_amount_without_reapplying_slippage() { + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDC_TOKEN_ID.into())).into(), + to_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDT_TOKEN_ID.into())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "6500000".into(), + options: Options::new_with_slippage(100.into()), + }; + let token_in = eth_address::parse_str(request.from_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let token_out = eth_address::parse_str(request.to_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let amount_in = U256::from_str(&request.value).unwrap(); + let amount_out_min = U256::from(6_500_000_u64); + let path = build_direct_pair(&token_in, &token_out, FeeTier::FiveHundred); + + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, amount_out_min, &path, None, false).unwrap(); + + match &commands[0] { + UniversalRouterCommand::V3_SWAP_EXACT_IN(swap) => assert_eq!(swap.amount_out_min, amount_out_min), + _ => panic!("expected V3_SWAP_EXACT_IN"), + } + } + #[test] fn test_build_commands_usdc_to_aave() { let request = QuoteRequest { @@ -261,7 +296,7 @@ mod tests { let request = QuoteRequest { // USDCE -> ETH from_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDC_E_TOKEN_ID.into())).into(), - to_asset: AssetId::from(Chain::Ethereum, None).into(), + to_asset: AssetId::from(Chain::Optimism, None).into(), wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), value: "10000000".into(), @@ -314,6 +349,63 @@ mod tests { assert!(matches!(commands[3], UniversalRouterCommand::UNWRAP_WETH(_))); } + #[test] + fn test_build_commands_routes_weth_output_to_router_before_unwrap() { + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Optimism, Some(OPTIMISM_USDC_E_TOKEN_ID.into())).into(), + to_asset: AssetId::from(Chain::Optimism, None).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "10000000".into(), + options: Options::default(), + }; + let token_in = eth_address::parse_str(request.from_asset.asset_id().token_id.as_ref().unwrap()).unwrap(); + let token_out = eth_address::parse_str(OPTIMISM_WETH_TOKEN_ID).unwrap(); + let amount_in = U256::from_str(&request.value).unwrap(); + let amount_out_min = U256::from(3997001989341576u64); + let address_this = Address::from_str(ADDRESS_THIS).unwrap(); + let path = build_direct_pair(&token_in, &token_out, FeeTier::FiveHundred); + + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, amount_out_min, &path, None, false).unwrap(); + + assert_eq!(commands.len(), 2); + match &commands[0] { + UniversalRouterCommand::V3_SWAP_EXACT_IN(swap) => assert_eq!(swap.recipient, address_this), + _ => panic!("expected V3_SWAP_EXACT_IN"), + } + match &commands[1] { + UniversalRouterCommand::UNWRAP_WETH(_) => {} + _ => panic!("expected UNWRAP_WETH"), + } + + let request = QuoteRequest { + options: Options { + slippage: 100.into(), + fee: Some(ReferralFees::evm(ReferralFee { + bps: 25, + address: "0x3d83ec320541ae96c4c91e9202643870458fb290".into(), + })), + use_max_amount: false, + }, + ..request + }; + let commands = super::build_commands(&request, &token_in, &token_out, amount_in, amount_out_min, &path, None, true).unwrap(); + + assert_eq!(commands.len(), 3); + match &commands[0] { + UniversalRouterCommand::PERMIT2_TRANSFER_FROM(_) => {} + _ => panic!("expected PERMIT2_TRANSFER_FROM"), + } + match &commands[1] { + UniversalRouterCommand::V3_SWAP_EXACT_IN(swap) => assert_eq!(swap.recipient, address_this), + _ => panic!("expected V3_SWAP_EXACT_IN"), + } + match &commands[2] { + UniversalRouterCommand::UNWRAP_WETH(_) => {} + _ => panic!("expected UNWRAP_WETH"), + } + } + #[test] fn test_build_commands_eth_to_uni_with_input_fee() { // Replicate https://optimistic.etherscan.io/tx/0x18277deea3e273a7fb9abc985269dcdabe3d34c2b604fbd82dcd0a5a5204f72c diff --git a/crates/swapper/src/uniswap/v3/path.rs b/crates/swapper/src/uniswap/v3/path.rs index 3b03e3de1e..277210aef5 100644 --- a/crates/swapper/src/uniswap/v3/path.rs +++ b/crates/swapper/src/uniswap/v3/path.rs @@ -1,7 +1,7 @@ use alloy_primitives::{Address, Bytes}; use gem_evm::uniswap::{ FeeTier, - path::{BasePair, TokenPair, build_direct_pair, build_pairs}, + path::{BasePair, TokenPair, build_pairs, validate_pairs}, }; use crate::{ @@ -9,49 +9,107 @@ use crate::{ uniswap::swap_route::{RouteData, get_intermediaries}, }; +fn path_for_pairs(token_pairs: Vec) -> (Vec, Bytes) { + let path = build_pairs(&token_pairs); + (token_pairs, path) +} + pub fn build_paths(token_in: &Address, token_out: &Address, fee_tiers: &[FeeTier], base_pair: &BasePair) -> Vec, Bytes)>> { - let mut paths: Vec, Bytes)>> = vec![]; let direct_paths: Vec<_> = fee_tiers .iter() .map(|fee_tier| { - ( - vec![TokenPair { - token_in: *token_in, - token_out: *token_out, - fee_tier: *fee_tier, - }], - build_direct_pair(token_in, token_out, *fee_tier), - ) + path_for_pairs(vec![TokenPair { + token_in: *token_in, + token_out: *token_out, + fee_tier: *fee_tier, + }]) }) .collect(); - paths.push(direct_paths); let intermediaries = get_intermediaries(token_in, token_out, base_pair); - intermediaries.iter().for_each(|intermediary| { - let token_pairs: Vec> = fee_tiers - .iter() - .map(|fee_tier| TokenPair::new_two_hop(token_in, intermediary, token_out, *fee_tier)) - .collect(); - let pair_paths: Vec<_> = token_pairs.iter().map(|token_pairs| (token_pairs.to_vec(), build_pairs(token_pairs))).collect(); - paths.push(pair_paths); - }); - paths + std::iter::once(direct_paths) + .chain(intermediaries.iter().map(|intermediary| { + fee_tiers + .iter() + .flat_map(|first_fee_tier| { + fee_tiers + .iter() + .map(move |second_fee_tier| path_for_pairs(TokenPair::new_two_hop_with_fees(token_in, intermediary, token_out, *first_fee_tier, *second_fee_tier))) + }) + .collect() + })) + .collect() } pub fn build_paths_with_routes(routes: &[Route]) -> Result { if routes.is_empty() { return Err(SwapperError::InvalidRoute); } - let route_data: RouteData = serde_json::from_str(&routes[0].route_data).map_err(|_| SwapperError::InvalidRoute)?; - let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::ComputeQuoteError("invalid fee tier".into()))?; let token_pairs: Vec = routes .iter() - .map(|route| TokenPair { - token_in: eth_address::parse_asset_id(&route.input).unwrap(), - token_out: eth_address::parse_asset_id(&route.output).unwrap(), - fee_tier, + .map(|route| { + let route_data: RouteData = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; + let fee_tier = FeeTier::try_from(route_data.fee_tier.as_str()).map_err(|_| SwapperError::ComputeQuoteError("invalid fee tier".into()))?; + Ok(TokenPair { + token_in: eth_address::parse_asset_id(&route.input)?, + token_out: eth_address::parse_asset_id(&route.output)?, + fee_tier, + }) }) - .collect(); + .collect::, SwapperError>>()?; + if !validate_pairs(&token_pairs) { + return Err(SwapperError::InvalidRoute); + } let paths = build_pairs(&token_pairs); Ok(paths) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::uniswap::swap_route::build_swap_route; + use alloy_primitives::{address, hex::encode_prefixed as HexEncode}; + use primitives::Chain; + + #[test] + fn test_build_paths_uses_mixed_fee_two_hop() { + let token_in = address!("0x1111111111111111111111111111111111111111"); + let intermediary = address!("0x2222222222222222222222222222222222222222"); + let token_out = address!("0x3333333333333333333333333333333333333333"); + let fee_tiers = vec![FeeTier::Hundred, FeeTier::ThreeThousand]; + let base_pair = BasePair { + native: intermediary, + stables: vec![], + alternatives: vec![], + }; + + let paths = build_paths(&token_in, &token_out, &fee_tiers, &base_pair); + + assert_eq!(paths.len(), 2); + assert_eq!(paths[0].len(), 2); + assert_eq!(paths[1].len(), 4); + let fees: Vec<(FeeTier, FeeTier)> = paths[1].iter().map(|path| (path.0[0].fee_tier, path.0[1].fee_tier)).collect(); + assert_eq!( + fees, + vec![ + (FeeTier::Hundred, FeeTier::Hundred), + (FeeTier::Hundred, FeeTier::ThreeThousand), + (FeeTier::ThreeThousand, FeeTier::Hundred), + (FeeTier::ThreeThousand, FeeTier::ThreeThousand), + ] + ); + } + + #[test] + fn test_build_paths_with_routes_uses_per_hop_fee_tiers() { + let token_in = address!("0x1111111111111111111111111111111111111111"); + let intermediary = address!("0x2222222222222222222222222222222222222222"); + let token_out = address!("0x3333333333333333333333333333333333333333"); + let token_pairs = TokenPair::new_two_hop_with_fees(&token_in, &intermediary, &token_out, FeeTier::Hundred, FeeTier::ThreeThousand); + let routes = build_swap_route(Chain::Optimism, &token_pairs, "100").unwrap(); + + let path = build_paths_with_routes(&routes).unwrap(); + + assert_eq!(HexEncode(path), HexEncode(build_pairs(&token_pairs))); + } +} diff --git a/crates/swapper/src/uniswap/v3/provider.rs b/crates/swapper/src/uniswap/v3/provider.rs index aaa01d5c31..547d27d2a7 100644 --- a/crates/swapper/src/uniswap/v3/provider.rs +++ b/crates/swapper/src/uniswap/v3/provider.rs @@ -3,12 +3,12 @@ use crate::{ alien::{RpcClient, RpcProvider}, approval::{check_approval_erc20_with_client, check_approval_permit2_with_client, get_swap_gas_limit_with_approval}, eth_address, - fees::apply_slippage_in_bp, + fees::{apply_slippage_in_bp, quote_value_after_reserve_by_chain}, models::*, uniswap::{ deadline::get_sig_deadline, fee_token::{FeeToken, get_fee_token}, - quote_result::get_best_quote, + quote_result::{get_best_quote, get_selected_candidate}, requires_native_wrapping, swap_route::{RouteData, build_swap_route}, }, @@ -50,11 +50,11 @@ impl UniswapV3 { eth_address::parse_or_weth_address(&asset_id, evm_chain) } - fn parse_request(request: &QuoteRequest) -> Result<(EVMChain, Address, Address, U256), SwapperError> { + fn parse_request_value(request: &QuoteRequest, value: &str) -> Result<(EVMChain, Address, Address, U256), SwapperError> { let evm_chain = EVMChain::from_chain(request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; let token_in = Self::get_asset_address(&request.from_asset.id, evm_chain)?; let token_out = Self::get_asset_address(&request.to_asset.id, evm_chain)?; - let amount_in = U256::from_str(&request.value).map_err(SwapperError::from)?; + let amount_in = U256::from_str(value).map_err(SwapperError::from)?; Ok((evm_chain, token_in, token_out, amount_in)) } @@ -113,9 +113,9 @@ impl Swapper for UniswapV3 { async fn get_quote(&self, request: &QuoteRequest) -> Result { let from_chain = request.from_asset.chain(); - let to_chain = request.to_asset.chain(); let deployment = self.provider.get_deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; - let (evm_chain, token_in, token_out, from_value) = Self::parse_request(request)?; + let from_value = quote_value_after_reserve_by_chain(request)?; + let (evm_chain, token_in, token_out, from_value) = Self::parse_request_value(request, &from_value)?; if requires_native_wrapping(&request.from_asset.asset_id()) || requires_native_wrapping(&request.to_asset.asset_id()) { _ = evm_chain.weth_contract().ok_or(SwapperError::NotSupportedChain)?; } @@ -129,7 +129,7 @@ impl Swapper for UniswapV3 { let fee_token_in = FeeToken::new(token_in, request.from_asset.symbol.as_str()); let fee_token_out = FeeToken::new(token_out, request.to_asset.symbol.as_str()); let fee_preference = get_fee_token(Some(&base_pair), &fee_token_in, &fee_token_out); - let fee_bps = request.options.clone().fee.unwrap_or_default().evm.bps; + let fee_bps = request.options.fee.as_ref().map_or(0, |fees| fees.evm.bps); let quote_amount_in = if fee_preference.is_input_token && fee_bps > 0 { apply_slippage_in_bp(&from_value, fee_bps) @@ -141,7 +141,7 @@ impl Swapper for UniswapV3 { let requests: Vec<_> = paths_array .iter() .map(|paths| { - let client = client.clone(); + let client = Arc::clone(&client); let calls: Vec = paths .iter() .map(|path| super::quoter_v2::build_quoter_request(&request.wallet_address, deployment.quoter_v2, quote_amount_in, &path.1)) @@ -153,6 +153,7 @@ impl Swapper for UniswapV3 { let batch_results = futures::future::join_all(requests).await; let quote_result = get_best_quote(&batch_results, super::quoter_v2::decode_quoter_response)?; + let selected_path = get_selected_candidate(&paths_array, "e_result)?; let to_value = if fee_preference.is_input_token { quote_result.amount_out @@ -161,31 +162,14 @@ impl Swapper for UniswapV3 { }; let to_min_value = apply_slippage_in_bp(&to_value, request.options.slippage.bps); - let fee_tier_idx = quote_result.fee_tier_idx; - let batch_idx = quote_result.batch_idx; - - let fee_tier: u32 = fee_tiers[fee_tier_idx % fee_tiers.len()] as u32; - let asset_id_in = AssetId::from(from_chain, Some(token_in.to_checksum(None))); - let asset_id_out = AssetId::from(to_chain, Some(token_out.to_checksum(None))); - let asset_id_intermediary: Option = match batch_idx { - 0 => None, - _ => { - let first_token_out = &paths_array[batch_idx][0].0[0].token_out; - Some(AssetId::from(to_chain, Some(first_token_out.to_checksum(None)))) - } - }; - let route_data = RouteData { - fee_tier: fee_tier.to_string(), - min_amount_out: to_min_value.to_string(), - }; - let routes = build_swap_route(&asset_id_in, asset_id_intermediary.as_ref(), &asset_id_out, &route_data); + let routes = build_swap_route(from_chain, &selected_path.0, &to_min_value.to_string())?; Ok(Quote { - from_value: request.value.clone(), + from_value: from_value.to_string(), to_value: to_value.to_string(), data: ProviderData { provider: self.provider().clone(), - routes: routes.clone(), + routes, slippage_bps: request.options.slippage.bps, }, request: request.clone(), @@ -200,7 +184,7 @@ impl Swapper for UniswapV3 { } let client = self.client_for(from_asset.chain)?; let wallet_address = eth_address::parse_str("e.request.wallet_address)?; - let (_, token_in, _, amount_in) = Self::parse_request("e.request)?; + let (_, token_in, _, amount_in) = Self::parse_request_value("e.request, "e.from_value)?; self.check_permit2_approval(&client, wallet_address, &token_in.to_checksum(None), amount_in, &from_asset.chain) .await } @@ -208,12 +192,13 @@ impl Swapper for UniswapV3 { async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { let request = "e.request; let from_chain = request.from_asset.chain(); - let (_, token_in, token_out, amount_in) = Self::parse_request(request)?; + let (_, token_in, token_out, amount_in) = Self::parse_request_value(request, "e.from_value)?; let deployment = self.provider.get_deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; let client = self.client_for(from_chain)?; - let route_data: RouteData = serde_json::from_str("e.data.routes.first().unwrap().route_data).map_err(|_| SwapperError::InvalidRoute)?; + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let route_data: RouteData = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; let to_amount = U256::from_str(&route_data.min_amount_out).map_err(SwapperError::from)?; let wallet_address = eth_address::parse_str(&request.wallet_address)?; @@ -242,7 +227,7 @@ impl Swapper for UniswapV3 { let commands = build_commands(request, &token_in, &token_out, amount_in, to_amount, &path, permit, fee_preference.is_input_token)?; let encoded = encode_commands(&commands, U256::from(sig_deadline)); - let value = if wrap_input_eth { request.value.clone() } else { String::from("0") }; + let value = if wrap_input_eth { quote.from_value.clone() } else { String::from("0") }; Ok(SwapperQuoteData::new_contract( deployment.universal_router.into(), @@ -253,3 +238,54 @@ impl Swapper for UniswapV3 { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Options, Swapper, + alien::mock::ProviderMock, + uniswap::{default::new_uniswap_v3, swap_route::build_swap_route}, + }; + use gem_evm::uniswap::{FeeTier, path::TokenPair}; + use primitives::asset_constants::{ETHEREUM_USDC_TOKEN_ID, ETHEREUM_WETH_TOKEN_ID}; + use std::sync::Arc; + + #[tokio::test] + async fn test_quote_data_uses_quote_from_value_for_native_value() { + let provider = Arc::new(ProviderMock::new("{}".to_string())); + let swapper = new_uniswap_v3(provider); + let request = QuoteRequest { + from_asset: AssetId::from_chain(Chain::Ethereum).into(), + to_asset: AssetId::from(Chain::Ethereum, Some(ETHEREUM_USDC_TOKEN_ID.into())).into(), + wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), + value: "2000000000000000".into(), + options: Options { + use_max_amount: true, + ..Options::default() + }, + }; + let token_pairs = vec![TokenPair { + token_in: eth_address::parse_str(ETHEREUM_WETH_TOKEN_ID).unwrap(), + token_out: eth_address::parse_str(ETHEREUM_USDC_TOKEN_ID).unwrap(), + fee_tier: FeeTier::FiveHundred, + }]; + let routes = build_swap_route(Chain::Ethereum, &token_pairs, "100").unwrap(); + let quote = Quote { + from_value: "1000000000000000".into(), + to_value: "100".into(), + data: ProviderData { + provider: swapper.provider().clone(), + routes, + slippage_bps: request.options.slippage.bps, + }, + request, + eta_in_seconds: None, + }; + + let quote_data = swapper.get_quote_data("e, FetchQuoteData::None).await.unwrap(); + + assert_eq!(quote_data.value, quote.from_value); + } +} diff --git a/crates/swapper/src/uniswap/v4/commands.rs b/crates/swapper/src/uniswap/v4/commands.rs index 06862f38e3..4b6f741a6b 100644 --- a/crates/swapper/src/uniswap/v4/commands.rs +++ b/crates/swapper/src/uniswap/v4/commands.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use crate::{QuoteRequest, Route, SwapperError, eth_address, fees::apply_slippage_in_bp, uniswap::requires_native_wrapping}; +use crate::{QuoteRequest, Route, SwapperError, eth_address, uniswap::requires_native_wrapping}; use alloy_primitives::{Address, U256}; use gem_evm::uniswap::{ actions::V4Action::{SETTLE, SWAP_EXACT_IN, TAKE}, @@ -13,75 +13,70 @@ pub fn build_commands( token_in: &Address, token_out: &Address, amount_in: u128, - quote_amount: u128, + amount_out_min: u128, swap_routes: &[Route], permit: Option, fee_token_is_input: bool, ) -> Result, SwapperError> { - let options = request.options.clone(); - let fee_options = options.fee.unwrap_or_default().evm; + let fee_options = request.options.fee.as_ref().map(|fees| &fees.evm).filter(|fee| fee.bps > 0); let recipient = eth_address::parse_str(&request.wallet_address)?; let input_is_native = requires_native_wrapping(&request.from_asset.asset_id()); - let pay_fees = fee_options.bps > 0; - - let mut commands: Vec = vec![]; - let amount_out = apply_slippage_in_bp("e_amount, options.slippage.bps + fee_options.bps); // Insert permit2 if needed - if let Some(permit) = permit { - commands.push(UniversalRouterCommand::PERMIT2_PERMIT(permit)); - } + let permit_command = permit.map(UniversalRouterCommand::PERMIT2_PERMIT); - if pay_fees { + let swap_commands = if let Some(fee_options) = fee_options { + let fee_recipient = Address::from_str(fee_options.address.as_str()).unwrap(); if fee_token_is_input { // insert TRANSFER fee first let fee = amount_in * (fee_options.bps as u128) / 10000_u128; - let fee_recipient = Address::from_str(fee_options.address.as_str()).unwrap(); - if input_is_native { + let fee_command = if input_is_native { // if input is native ETH, we can transfer directly - commands.push(UniversalRouterCommand::TRANSFER(Transfer { + UniversalRouterCommand::TRANSFER(Transfer { token: *token_in, recipient: fee_recipient, value: U256::from(fee), - })); + }) } else { // call permit2 transfer instead - commands.push(UniversalRouterCommand::PERMIT2_TRANSFER_FROM(Transfer { + UniversalRouterCommand::PERMIT2_TRANSFER_FROM(Transfer { token: *token_in, recipient: fee_recipient, value: U256::from(fee), - })); + }) }; // insert V4_SWAP with amount - fee // fee charged in token_in, so we need to use recipient as recipient - let command = build_v4_swap_command(token_in, token_out, amount_in - fee, amount_out, swap_routes, &recipient)?; - commands.push(command); + let command = build_v4_swap_command(token_in, token_out, amount_in - fee, amount_out_min, swap_routes, &recipient)?; + vec![fee_command, command] } else { // insert V4 SWAP // if needs to pay fees, amount_out_min set to 0 and we will sweep the rest let address_this = ADDRESS_THIS.parse().unwrap(); - let amount_out_min = if pay_fees { 0 } else { amount_out }; - let command = build_v4_swap_command(token_in, token_out, amount_in, amount_out_min, swap_routes, &address_this)?; - commands.push(command); + let command = build_v4_swap_command(token_in, token_out, amount_in, 0, swap_routes, &address_this)?; // insert PAY_PORTION to fee_address - commands.push(UniversalRouterCommand::PAY_PORTION(PayPortion { - token: *token_out, - recipient: Address::from_str(fee_options.address.as_str()).unwrap(), - bips: U256::from(fee_options.bps), - })); - - commands.push(UniversalRouterCommand::SWEEP(Sweep { - token: *token_out, - recipient, - amount_min: U256::from(amount_out), - })); + vec![ + command, + UniversalRouterCommand::PAY_PORTION(PayPortion { + token: *token_out, + recipient: fee_recipient, + bips: U256::from(fee_options.bps), + }), + UniversalRouterCommand::SWEEP(Sweep { + token: *token_out, + recipient, + amount_min: U256::from(amount_out_min), + }), + ] } } else { - let command = build_v4_swap_command(token_in, token_out, amount_in, amount_out, swap_routes, &recipient)?; - commands.push(command); - } + let command = build_v4_swap_command(token_in, token_out, amount_in, amount_out_min, swap_routes, &recipient)?; + vec![command] + }; + + let commands = permit_command.into_iter().chain(swap_commands).collect(); Ok(commands) } @@ -186,4 +181,34 @@ mod tests { assert!(matches!(commands[1], UniversalRouterCommand::PAY_PORTION(_))); assert!(matches!(commands[2], UniversalRouterCommand::SWEEP(_))); } + + #[test] + fn test_build_commands_uses_min_amount_without_reapplying_slippage() { + let token_celo = Address::from_str(CELO_WETH_TOKEN_ID).unwrap(); + let token_usdt = Address::from_str(CELO_USDT_TOKEN_ID).unwrap(); + let wallet = "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7"; + let routes = vec![Route::mock( + AssetId::from(Chain::Celo, Some(CELO_WETH_TOKEN_ID.into())), + AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())), + )]; + let request = QuoteRequest { + from_asset: AssetId::from(Chain::Celo, None).into(), + to_asset: AssetId::from(Chain::Celo, Some(CELO_USDT_TOKEN_ID.into())).into(), + wallet_address: wallet.into(), + destination_address: wallet.into(), + value: "22000000000000000000".into(), + options: Options::new_with_slippage(100.into()), + }; + let amount_out_min = 14_804_757_u128; + + let commands = build_commands(&request, &token_celo, &token_usdt, 22_000_000_000_000_000_000, amount_out_min, &routes, None, false).unwrap(); + + match &commands[0] { + UniversalRouterCommand::V4_SWAP { actions } => match &actions[0] { + SWAP_EXACT_IN(params) => assert_eq!(params.amountOutMinimum, amount_out_min), + _ => panic!("expected SWAP_EXACT_IN"), + }, + _ => panic!("expected V4_SWAP"), + } + } } diff --git a/crates/swapper/src/uniswap/v4/path.rs b/crates/swapper/src/uniswap/v4/path.rs index 1ff66b2c2e..5d2cc332ee 100644 --- a/crates/swapper/src/uniswap/v4/path.rs +++ b/crates/swapper/src/uniswap/v4/path.rs @@ -58,25 +58,26 @@ pub fn build_quote_exact_params( .map(|intermediary| { fee_tiers .iter() - .map(|fee_tier| TokenPair::new_two_hop(token_in, intermediary, token_out, *fee_tier)) - .filter(|token_pairs| token_pairs.len() >= 2) - .map(|token_pairs| { - let quote_exact_params = QuoteExactParams { - exactCurrency: token_pairs[0].token_in, - path: token_pairs - .iter() - .map(|token_pair| PathKey { - intermediateCurrency: token_pair.token_out, - fee: token_pair.fee_tier.as_u24(), - tickSpacing: token_pair.fee_tier.default_tick_spacing(), - hooks: Address::ZERO, - hookData: Bytes::new(), - }) - .collect(), - exactAmount: amount_in, - }; - - (token_pairs, quote_exact_params) + .flat_map(|first_fee_tier| { + fee_tiers.iter().map(move |second_fee_tier| { + let token_pairs = TokenPair::new_two_hop_with_fees(token_in, intermediary, token_out, *first_fee_tier, *second_fee_tier); + let quote_exact_params = QuoteExactParams { + exactCurrency: token_pairs[0].token_in, + path: token_pairs + .iter() + .map(|token_pair| PathKey { + intermediateCurrency: token_pair.token_out, + fee: token_pair.fee_tier.as_u24(), + tickSpacing: token_pair.fee_tier.default_tick_spacing(), + hooks: Address::ZERO, + hookData: Bytes::new(), + }) + .collect(), + exactAmount: amount_in, + }; + + (token_pairs, quote_exact_params) + }) }) .collect() }) @@ -105,3 +106,49 @@ impl TryFrom<&Route> for PathKey { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::uniswap::swap_route::build_swap_route; + use alloy_primitives::address; + use primitives::Chain; + + #[test] + fn test_build_quote_exact_params_uses_mixed_fee_two_hop() { + let token_in = address!("0x1111111111111111111111111111111111111111"); + let intermediary = address!("0x2222222222222222222222222222222222222222"); + let token_out = address!("0x3333333333333333333333333333333333333333"); + let fee_tiers = vec![FeeTier::Hundred, FeeTier::ThreeThousand]; + + let params = build_quote_exact_params(100, &token_in, &token_out, &fee_tiers, &[intermediary]); + + assert_eq!(params.len(), 1); + assert_eq!(params[0].len(), 4); + let fees: Vec<(FeeTier, FeeTier)> = params[0].iter().map(|param| (param.0[0].fee_tier, param.0[1].fee_tier)).collect(); + assert_eq!( + fees, + vec![ + (FeeTier::Hundred, FeeTier::Hundred), + (FeeTier::Hundred, FeeTier::ThreeThousand), + (FeeTier::ThreeThousand, FeeTier::Hundred), + (FeeTier::ThreeThousand, FeeTier::ThreeThousand), + ] + ); + } + + #[test] + fn test_path_key_from_route_uses_per_hop_fee_tier() { + let token_in = address!("0x1111111111111111111111111111111111111111"); + let intermediary = address!("0x2222222222222222222222222222222222222222"); + let token_out = address!("0x3333333333333333333333333333333333333333"); + let token_pairs = TokenPair::new_two_hop_with_fees(&token_in, &intermediary, &token_out, FeeTier::Hundred, FeeTier::ThreeThousand); + let routes = build_swap_route(Chain::Optimism, &token_pairs, "100").unwrap(); + + let first = PathKey::try_from(&routes[0]).unwrap(); + let second = PathKey::try_from(&routes[1]).unwrap(); + + assert_eq!(first.fee, FeeTier::Hundred.as_u24()); + assert_eq!(second.fee, FeeTier::ThreeThousand.as_u24()); + } +} diff --git a/crates/swapper/src/uniswap/v4/provider.rs b/crates/swapper/src/uniswap/v4/provider.rs index fa8e578beb..55bb34a68e 100644 --- a/crates/swapper/src/uniswap/v4/provider.rs +++ b/crates/swapper/src/uniswap/v4/provider.rs @@ -1,6 +1,6 @@ use alloy_primitives::{Address, U256, hex::encode_prefixed as HexEncode}; use async_trait::async_trait; -use std::{collections::HashSet, fmt, str::FromStr, sync::Arc, vec}; +use std::{fmt, str::FromStr, sync::Arc}; use crate::{ FetchQuoteData, Permit2ApprovalData, ProviderData, ProviderType, Quote, QuoteRequest, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperQuoteData, @@ -8,12 +8,12 @@ use crate::{ approval::evm::{check_approval_erc20_with_client, check_approval_permit2_with_client}, approval::get_swap_gas_limit_with_approval, eth_address, - fees::apply_slippage_in_bp, + fees::{apply_slippage_in_bp, quote_value_after_reserve_by_chain}, uniswap::{ deadline::get_sig_deadline, fee_token::{FeeToken, get_fee_token}, is_native_erc20, - quote_result::get_best_quote, + quote_result::{get_best_quote, get_selected_candidate}, requires_native_wrapping, swap_route::{RouteData, build_swap_route, get_intermediaries}, }, @@ -24,12 +24,14 @@ use gem_evm::{ uniswap::{ FeeTier, command::encode_commands, - contracts::v4::IV4Quoter::QuoteExactParams, deployment::v4::get_uniswap_deployment_by_chain, path::{TokenPair, get_base_pair}, }, }; -use gem_jsonrpc::client::JsonRpcClient; +use gem_jsonrpc::{ + client::JsonRpcClient, + types::{JsonRpcError, JsonRpcResults}, +}; use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; use super::{ @@ -39,6 +41,8 @@ use super::{ quoter::{build_quote_exact_requests, build_quote_exact_single_request}, }; +type QuoteBatchFuture = BoxFuture<'static, Result, JsonRpcError>>; + pub struct UniswapV4 { pub provider: ProviderType, rpc_provider: Arc, @@ -66,11 +70,6 @@ impl UniswapV4 { Ok(JsonRpcClient::new(client)) } - fn is_base_pair(token_in: &Address, token_out: &Address, evm_chain: &EVMChain) -> bool { - let base_set: HashSet
= HashSet::from_iter(get_base_pair(evm_chain, is_native_erc20(evm_chain.to_chain())).unwrap().path_building_array()); - base_set.contains(token_in) || base_set.contains(token_out) - } - fn parse_asset_address(asset_id: &str, evm_chain: EVMChain) -> Result { let asset_id = AssetId::new(asset_id).ok_or(SwapperError::NotSupportedAsset)?; if requires_native_wrapping(&asset_id) { @@ -80,11 +79,11 @@ impl UniswapV4 { } } - fn parse_request(request: &QuoteRequest) -> Result<(EVMChain, Address, Address, u128), SwapperError> { + fn parse_request_value(request: &QuoteRequest, value: &str) -> Result<(EVMChain, Address, Address, u128), SwapperError> { let evm_chain = EVMChain::from_chain(request.from_asset.chain()).ok_or(SwapperError::NotSupportedChain)?; let token_in = Self::parse_asset_address(&request.from_asset.id, evm_chain)?; let token_out = Self::parse_asset_address(&request.to_asset.id, evm_chain)?; - let amount_in = u128::from_str(&request.value).map_err(SwapperError::from)?; + let amount_in = u128::from_str(value).map_err(SwapperError::from)?; Ok((evm_chain, token_in, token_out, amount_in)) } @@ -108,15 +107,15 @@ impl Swapper for UniswapV4 { async fn get_quote(&self, request: &QuoteRequest) -> Result { let from_chain = request.from_asset.chain(); - let to_chain = request.to_asset.chain(); let deployment = get_uniswap_deployment_by_chain(&from_chain).ok_or(SwapperError::NotSupportedChain)?; - let (evm_chain, token_in, token_out, from_value) = Self::parse_request(request)?; + let from_value = quote_value_after_reserve_by_chain(request)?; + let (evm_chain, token_in, token_out, from_value) = Self::parse_request_value(request, &from_value)?; let fee_tiers = self.get_tiers(); let base_pair = get_base_pair(&evm_chain, is_native_erc20(from_chain)).ok_or(SwapperError::ComputeQuoteError("base pair not found".into()))?; let fee_token_in = FeeToken::new(token_in, request.from_asset.symbol.as_str()); let fee_token_out = FeeToken::new(token_out, request.to_asset.symbol.as_str()); let fee_preference = get_fee_token(Some(&base_pair), &fee_token_in, &fee_token_out); - let fee_bps = request.options.clone().fee.unwrap_or_default().evm.bps; + let fee_bps = request.options.fee.as_ref().map_or(0, |fees| fees.evm.bps); let quote_amount_in = if fee_preference.is_input_token && fee_bps > 0 { apply_slippage_in_bp(&from_value, fee_bps) } else { @@ -126,33 +125,31 @@ impl Swapper for UniswapV4 { let pool_keys = build_pool_keys(&token_in, &token_out, &fee_tiers); let client = Arc::new(self.client_for(from_chain)?); - let mut requests: Vec> = Vec::new(); let initial_client = Arc::clone(&client); let direct_calls: Vec = pool_keys .iter() .map(|pool_key| build_quote_exact_single_request(&token_in, deployment.quoter, quote_amount_in, &pool_key.1)) .collect(); - requests.push(Box::pin(async move { initial_client.batch_call_requests(direct_calls).await })); - - let quote_exact_params: Vec, QuoteExactParams)>>; - if !Self::is_base_pair(&token_in, &token_out, &evm_chain) { - let intermediaries = get_intermediaries(&token_in, &token_out, &base_pair); - quote_exact_params = build_quote_exact_params(quote_amount_in, &token_in, &token_out, &fee_tiers, &intermediaries); - build_quote_exact_requests(deployment.quoter, "e_exact_params).iter().for_each(|call_array| { + let direct_request: QuoteBatchFuture = Box::pin(async move { initial_client.batch_call_requests(direct_calls).await }); + let direct_batch = (pool_keys.into_iter().map(|pool_key| pool_key.0).collect(), direct_request); + + let intermediaries = get_intermediaries(&token_in, &token_out, &base_pair); + let quote_exact_params = build_quote_exact_params(quote_amount_in, &token_in, &token_out, &fee_tiers, &intermediaries); + let quote_calls = build_quote_exact_requests(deployment.quoter, "e_exact_params); + let quote_batches: Vec<(Vec>, QuoteBatchFuture)> = std::iter::once(direct_batch) + .chain(quote_calls.into_iter().zip(quote_exact_params.iter()).map(|(calls, quote_array)| { + let candidates = quote_array.iter().map(|param| param.0.clone()).collect(); let client = Arc::clone(&client); - let calls = call_array.clone(); - requests.push(Box::pin(async move { client.batch_call_requests(calls).await })); - }); - } else { - quote_exact_params = vec![]; - } + let request: QuoteBatchFuture = Box::pin(async move { client.batch_call_requests(calls).await }); + (candidates, request) + })) + .collect(); + let (quote_candidates, requests): (Vec>>, Vec) = quote_batches.into_iter().unzip(); let batch_results = join_all(requests).await; let quote_result = get_best_quote(&batch_results, super::quoter::decode_quoter_response)?; - - let fee_tier_idx = quote_result.fee_tier_idx; - let batch_idx = quote_result.batch_idx; + let selected_path = get_selected_candidate("e_candidates, "e_result)?; let to_value = if fee_preference.is_input_token { quote_result.amount_out @@ -161,28 +158,14 @@ impl Swapper for UniswapV4 { }; let to_min_value = apply_slippage_in_bp(&to_value, request.options.slippage.bps); - let fee_tier: u32 = fee_tiers[fee_tier_idx % fee_tiers.len()] as u32; - let asset_id_in = AssetId::from(from_chain, Some(token_in.to_checksum(None))); - let asset_id_out = AssetId::from(to_chain, Some(token_out.to_checksum(None))); - let asset_id_intermediary: Option = match batch_idx { - 0 => None, - _ => { - let first_token_out = "e_exact_params[batch_idx][0].0[0].token_out; - Some(AssetId::from(to_chain, Some(first_token_out.to_checksum(None)))) - } - }; - let route_data = RouteData { - fee_tier: fee_tier.to_string(), - min_amount_out: to_min_value.to_string(), - }; - let routes = build_swap_route(&asset_id_in, asset_id_intermediary.as_ref(), &asset_id_out, &route_data); + let routes = build_swap_route(from_chain, selected_path, &to_min_value.to_string())?; Ok(Quote { - from_value: request.value.clone(), + from_value: from_value.to_string(), to_value: to_value.to_string(), data: ProviderData { provider: self.provider().clone(), - routes: routes.clone(), + routes, slippage_bps: request.options.slippage.bps, }, request: request.clone(), @@ -195,7 +178,7 @@ impl Swapper for UniswapV4 { if requires_native_wrapping(&from_asset) { return Ok(None); } - let (_, token_in, _, amount_in) = Self::parse_request("e.request)?; + let (_, token_in, _, amount_in) = Self::parse_request_value("e.request, "e.from_value)?; let deployment = get_uniswap_deployment_by_chain(&from_asset.chain).ok_or(SwapperError::NotSupportedChain)?; let client = self.client_for(from_asset.chain)?; @@ -216,9 +199,10 @@ impl Swapper for UniswapV4 { async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { let request = "e.request; let from_asset = request.from_asset.asset_id(); - let (_, token_in, token_out, amount_in) = Self::parse_request(request)?; + let (_, token_in, token_out, amount_in) = Self::parse_request_value(request, "e.from_value)?; let deployment = get_uniswap_deployment_by_chain(&from_asset.chain).ok_or(SwapperError::NotSupportedChain)?; - let route_data: RouteData = serde_json::from_str("e.data.routes.first().unwrap().route_data).map_err(|_| SwapperError::InvalidRoute)?; + let route = quote.data.routes.first().ok_or(SwapperError::InvalidRoute)?; + let route_data: RouteData = serde_json::from_str(&route.route_data).map_err(|_| SwapperError::InvalidRoute)?; let to_amount = u128::from_str(&route_data.min_amount_out).map_err(SwapperError::from)?; let client = self.client_for(from_asset.chain)?; @@ -259,7 +243,7 @@ impl Swapper for UniswapV4 { )?; let encoded = encode_commands(&commands, U256::from(sig_deadline)); - let value = if wrap_input_eth { request.value.clone() } else { String::from("0") }; + let value = if wrap_input_eth { quote.from_value.clone() } else { String::from("0") }; Ok(SwapperQuoteData::new_contract( deployment.universal_router.into(), @@ -274,27 +258,165 @@ impl Swapper for UniswapV4 { #[cfg(test)] mod tests { use super::*; - use crate::{Options, alien::mock::ProviderMock}; + use crate::uniswap::quote_result::QuoteResult; + use crate::{ + Options, Swapper, + alien::{AlienError, Target}, + uniswap::default::new_uniswap_v4, + }; + use alloy_primitives::address; + use async_trait::async_trait; + use gem_jsonrpc::{RpcResponse, rpc::RpcProvider as GenericRpcProvider}; + use primitives::asset_constants::ETHEREUM_USDC_TOKEN_ID; + use serde_json::Value; use std::sync::Arc; - #[test] - fn test_is_base_pair() { - let provider = Arc::new(ProviderMock::new("{}".to_string())); - let swapper = UniswapV4::new(provider); + fn quote_exact_single_result(amount_out: u128, gas_estimate: u128) -> String { + format!("0x{amount_out:064x}{gas_estimate:064x}") + } + + #[derive(Debug)] + struct QuoterProviderMock { + result: String, + expected_amount: String, + } + + impl QuoterProviderMock { + fn new(result: String, expected_amount: u128) -> Arc { + Arc::new(Self { + result, + expected_amount: format!("{expected_amount:064x}"), + }) + } + + fn batch_response(&self, target: Target) -> RpcResponse { + let body = target.body.unwrap(); + let requests: Vec = serde_json::from_slice(&body).unwrap(); + let matching_amount_count = requests + .iter() + .filter(|request| request["params"][0]["data"].as_str().unwrap().contains(&self.expected_amount)) + .count(); + + assert!(!requests.is_empty()); + assert_eq!(matching_amount_count, requests.len()); + + let responses: Vec = requests + .iter() + .map(|request| { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"].as_u64().unwrap(), + "result": self.result, + }) + }) + .collect(); + RpcResponse { + status: Some(200), + data: serde_json::to_vec(&responses).unwrap(), + } + } + } + + #[async_trait] + impl GenericRpcProvider for QuoterProviderMock { + type Error = AlienError; + + async fn request(&self, target: Target) -> Result { + Ok(self.batch_response(target)) + } + + fn get_endpoint(&self, _chain: Chain) -> Result { + Ok(String::from("http://localhost:8080")) + } + } + + #[tokio::test] + async fn test_use_max_amount_reserves_native_value_for_quote_and_quote_data() { + let provider = QuoterProviderMock::new(quote_exact_single_result(25_710_318, 84_766), 1_000_000_000_000_000); + let swapper = new_uniswap_v4(provider); let request = QuoteRequest { - from_asset: AssetId::from(Chain::SmartChain, Some("0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82".to_string())).into(), - to_asset: AssetId::from_chain(Chain::SmartChain).into(), + from_asset: AssetId::from_chain(Chain::Ethereum).into(), + to_asset: AssetId::from(Chain::Ethereum, Some(ETHEREUM_USDC_TOKEN_ID.into())).into(), wallet_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".into(), - value: "40000000000000000".into(), // 0.04 Cake - options: Options::default(), + value: "2000000000000000".into(), + options: Options { + use_max_amount: true, + ..Options::default() + }, }; - let (evm_chain, token_in, token_out, _) = UniswapV4::parse_request(&request).unwrap(); + let quote = swapper.get_quote(&request).await.unwrap(); + let quote_data = swapper.get_quote_data("e, FetchQuoteData::None).await.unwrap(); + + assert_eq!(quote.from_value, "1000000000000000"); + assert_eq!(quote_data.value, quote.from_value); + } - assert!(UniswapV4::is_base_pair(&token_in, &token_out, &evm_chain)); - // Ensure provider field is used to avoid warnings - assert_eq!(swapper.provider.id, SwapperProvider::UniswapV4); + #[test] + fn test_selected_candidate_batches_include_direct_routes() { + let token_in = address!("0x1111111111111111111111111111111111111111"); + let first_intermediary = address!("0x2222222222222222222222222222222222222222"); + let last_intermediary = address!("0x3333333333333333333333333333333333333333"); + let token_out = address!("0x4444444444444444444444444444444444444444"); + let candidates = vec![ + vec![vec![TokenPair { + token_in, + token_out, + fee_tier: FeeTier::Hundred, + }]], + vec![TokenPair::new_two_hop_with_fees( + &token_in, + &first_intermediary, + &token_out, + FeeTier::Hundred, + FeeTier::ThreeThousand, + )], + vec![TokenPair::new_two_hop_with_fees( + &token_in, + &last_intermediary, + &token_out, + FeeTier::FiveHundred, + FeeTier::TenThousand, + )], + ]; + + assert_eq!( + get_selected_candidate( + &candidates, + &QuoteResult { + amount_out: U256::from(1), + route_idx: 0, + batch_idx: 0, + } + ) + .unwrap(), + &candidates[0][0] + ); + assert_eq!( + get_selected_candidate( + &candidates, + &QuoteResult { + amount_out: U256::from(2), + route_idx: 0, + batch_idx: 1, + } + ) + .unwrap(), + &candidates[1][0] + ); + assert_eq!( + get_selected_candidate( + &candidates, + &QuoteResult { + amount_out: U256::from(3), + route_idx: 0, + batch_idx: 2, + } + ) + .unwrap(), + &candidates[2][0] + ); } #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] diff --git a/crates/swapper/src/uniswap/v4/quoter.rs b/crates/swapper/src/uniswap/v4/quoter.rs index 9b5766df91..5cac5dc43d 100644 --- a/crates/swapper/src/uniswap/v4/quoter.rs +++ b/crates/swapper/src/uniswap/v4/quoter.rs @@ -26,7 +26,7 @@ pub fn build_quote_exact_single_request(token_in: &Address, v4_quoter: &str, amo pub fn build_quote_exact_requests(v4_quoter: &str, quote_params: &[Vec<(Vec, IV4Quoter::QuoteExactParams)>]) -> Vec> { quote_params .iter() - .map(|quote_array| quote_array.iter().map(|x| build_quote_exact_request(v4_quoter, &x.1).clone()).collect::>()) + .map(|quote_array| quote_array.iter().map(|x| build_quote_exact_request(v4_quoter, &x.1)).collect::>()) .collect() } @@ -98,7 +98,7 @@ mod tests { assert_eq!(rpc_calls.len(), 3); // 3 intermediaries (ETH, USDC, USDT) - // 3 fee tiers - rpc_calls.iter().for_each(|call_array| assert_eq!(call_array.len(), 3)); + // 3 fee tiers per hop: 3 x 3 mixed-fee combinations + rpc_calls.iter().for_each(|call_array| assert_eq!(call_array.len(), 9)); } }