diff --git a/packages/checkout/sdk/src/sdk.test.ts b/packages/checkout/sdk/src/sdk.test.ts index eab2c3a066..f3d2d9140a 100644 --- a/packages/checkout/sdk/src/sdk.test.ts +++ b/packages/checkout/sdk/src/sdk.test.ts @@ -1079,6 +1079,7 @@ describe('Connect', () => { }, value: BigInt('1050000000000000000'), // Example value with 5% max slippage }, + priceImpact: 0, }, swapReceipt: { to: '0xRecipientAddress', diff --git a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts index b8f31107b3..5a7a63334e 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/bridgeAndSwap/constructBridgeRequirements.test.ts @@ -47,6 +47,7 @@ describe('constructBridgeRequirements', () => { basisPoints: 0, }, ], + priceImpact: 0, }, }; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.test.ts index 27490fd7ed..f4fe0f1970 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/swap/quoteFetcher.test.ts @@ -46,6 +46,7 @@ describe('quoteFetcher', () => { basisPoints: 0, }, ], + priceImpact: 0, }, approval: null, }; @@ -98,6 +99,7 @@ describe('quoteFetcher', () => { basisPoints: 0, }, ], + priceImpact: 0, }, }; diff --git a/packages/checkout/sdk/src/smartCheckout/routing/swap/swapRoute.test.ts b/packages/checkout/sdk/src/smartCheckout/routing/swap/swapRoute.test.ts index 8c690365cc..89aa9dc1f4 100644 --- a/packages/checkout/sdk/src/smartCheckout/routing/swap/swapRoute.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/routing/swap/swapRoute.test.ts @@ -71,6 +71,7 @@ describe('swapRoute', () => { basisPoints: 0, }, ], + priceImpact: 0, }, approval: { value: BigInt(1), @@ -125,6 +126,7 @@ describe('swapRoute', () => { basisPoints: 0, }, ], + priceImpact: 0, }, approval: { value: BigInt(1), diff --git a/packages/checkout/widgets-lib/src/widgets/swap/functions/swapConversionRate.test.ts b/packages/checkout/widgets-lib/src/widgets/swap/functions/swapConversionRate.test.ts index 9895383283..33eaba080f 100644 --- a/packages/checkout/widgets-lib/src/widgets/swap/functions/swapConversionRate.test.ts +++ b/packages/checkout/widgets-lib/src/widgets/swap/functions/swapConversionRate.test.ts @@ -224,6 +224,7 @@ describe('formatQuoteConversionRate', () => { amountWithMaxSlippage: {} as Amount, slippage: 0, fees: [], + priceImpact: 0, } as Quote, swap: { gasFeeEstimate: { diff --git a/packages/checkout/widgets-lib/src/widgets/swap/functions/swapFees.test.ts b/packages/checkout/widgets-lib/src/widgets/swap/functions/swapFees.test.ts index ce4f94e9e2..f43047ee62 100644 --- a/packages/checkout/widgets-lib/src/widgets/swap/functions/swapFees.test.ts +++ b/packages/checkout/widgets-lib/src/widgets/swap/functions/swapFees.test.ts @@ -24,6 +24,7 @@ describe('formatSwapFees', () => { amountWithMaxSlippage: {} as Amount, slippage: 0, fees: [], + priceImpact: 0, } as Quote, swap: { gasFeeEstimate: { diff --git a/packages/internal/dex/sdk/jest.config.ts b/packages/internal/dex/sdk/jest.config.ts index f93ab65b9c..1ca80b303f 100644 --- a/packages/internal/dex/sdk/jest.config.ts +++ b/packages/internal/dex/sdk/jest.config.ts @@ -21,6 +21,7 @@ const config: Config = { }, coveragePathIgnorePatterns:['node_modules', 'src/contracts/', 'src/test/'], transformIgnorePatterns: [], + setupFiles: ['/jest.setup.js'], }; export default config; diff --git a/packages/internal/dex/sdk/jest.setup.js b/packages/internal/dex/sdk/jest.setup.js new file mode 100644 index 0000000000..2e10cd9913 --- /dev/null +++ b/packages/internal/dex/sdk/jest.setup.js @@ -0,0 +1,16 @@ +import { TextEncoder } from 'util'; + +global.TextEncoder = TextEncoder; + +/** + * Required for ethers v6 + * @see https://github.com/ethers-io/ethers.js/issues/4365 + */ +Object.defineProperty(Uint8Array, Symbol.hasInstance, { + value(potentialInstance) { + return this === Uint8Array + ? Object.prototype.toString.call(potentialInstance) === + '[object Uint8Array]' + : Uint8Array[Symbol.hasInstance].call(this, potentialInstance); + }, + }); diff --git a/packages/internal/dex/sdk/src/exchange.ts b/packages/internal/dex/sdk/src/exchange.ts index 96fa48e33d..9f5b861d6a 100644 --- a/packages/internal/dex/sdk/src/exchange.ts +++ b/packages/internal/dex/sdk/src/exchange.ts @@ -36,11 +36,13 @@ const toPublicQuote = ( amount: CoinAmount, amountWithMaxSlippage: CoinAmount, slippage: number, + priceImpact: number, fees: Fees, ): Quote => ({ amount: toPublicAmount(amount), amountWithMaxSlippage: toPublicAmount(amountWithMaxSlippage), slippage, + priceImpact, fees: fees.withAmounts().map((fee) => ({ ...fee, amount: toPublicAmount(fee.amount), @@ -223,6 +225,7 @@ export class Exchange { amount: toPublicAmount(otherTokenCoinAmount), amountWithMaxSlippage: toPublicAmount(otherTokenCoinAmount), slippage: 0, + priceImpact: 0, fees: [], }; @@ -340,7 +343,13 @@ export class Exchange { ? await getApproval(this.provider, fromAddress, preparedApproval, gasPrice) : null; - const quote = toPublicQuote(quotedAmount, quotedAmountWithMaxSlippage, slippagePercent, fees); + const quote = toPublicQuote( + quotedAmount, + quotedAmountWithMaxSlippage, + slippagePercent, + Number(adjustedQuote.priceImpact.toSignificant(10)), + fees, + ); return { quote, approval, swap }; } diff --git a/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.test.ts b/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.test.ts index d6630b2160..1989176e3f 100644 --- a/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.test.ts +++ b/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.test.ts @@ -1,33 +1,29 @@ +/* eslint-disable max-len */ import { FeeAmount, Pool, Route, TickMath, } from '@uniswap/v3-sdk'; import { TradeType } from '@uniswap/sdk-core'; import { formatEther, parseEther, AbiCoder } from 'ethers'; import { ProviderCallError } from '../errors'; -import { getQuotesForRoutes, Provider } from './getQuotesForRoutes'; +import { getQuotesForRoutes, parseQuoteResult, Provider } from './getQuotesForRoutes'; import { + FUN_TEST_TOKEN, IMX_TEST_TOKEN, TEST_QUOTER_ADDRESS, + USDC_TEST_TOKEN, + USDT_TEST_TOKEN, WETH_TEST_TOKEN, formatAmount, newAmountFromString, } from '../test/utils'; -import { erc20ToUniswapToken, newAmount } from './utils'; +import { erc20ToUniswapToken, newAmount, quoteReturnMapping } from './utils'; const UNISWAP_IMX = erc20ToUniswapToken(IMX_TEST_TOKEN); +const UNISWAP_FUN = erc20ToUniswapToken(FUN_TEST_TOKEN); +const UNISWAP_USDT = erc20ToUniswapToken(USDT_TEST_TOKEN); +const UNISWAP_USDC = erc20ToUniswapToken(USDC_TEST_TOKEN); const UNISWAP_WETH = erc20ToUniswapToken(WETH_TEST_TOKEN); -const pool = new Pool( - UNISWAP_WETH, - UNISWAP_IMX, - FeeAmount.HIGH, - TickMath.getSqrtRatioAtTick(100), - 1000, - 100, -); - -const route = new Route([pool], UNISWAP_WETH, UNISWAP_IMX); - const types = [ 'uint256', // amountOut/amountIn 'uint160', // sqrtPrice after @@ -37,57 +33,63 @@ const types = [ const buildProvider = (send: jest.Mock): Provider => ({ send }); -describe('getQuotesForRoutes', () => { - it('makes an eth_call against the provider', async () => { - const expectedAmountOut = parseEther('1000'); - const expectedGasEstimate = '100000'; - - const returnData = AbiCoder.defaultAbiCoder().encode(types, [ - expectedAmountOut, - '100', - '1', - expectedGasEstimate, - ]); - - const provider = buildProvider(jest.fn().mockResolvedValueOnce(returnData)); - - const quoteResults = await getQuotesForRoutes( - provider, - TEST_QUOTER_ADDRESS, - [route], - newAmountFromString('1', WETH_TEST_TOKEN), - TradeType.EXACT_INPUT, - ); - - expect(quoteResults).toHaveLength(1); - expect(provider.send).toHaveBeenCalledWith('eth_call', [{ - // eslint-disable-next-line max-len - data: '0xc6a5026a0000000000000000000000004f062a3eaec3730560ab89b5ce5ac0ab2c5517ae00000000000000000000000072958b06abdf2701ace6ceb3ce0b8b1ce11e08510000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000027100000000000000000000000000000000000000000000000000000000000000000', - to: '0x9B323E56215aAdcD4f45a6Be660f287DE154AFC5', - }, 'latest']); - }); - - describe('when all calls in the batch fail', () => { - it('returns no quote results', async () => { - const provider = buildProvider( - jest.fn().mockRejectedValue(new ProviderCallError('an rpc error message')), - ); - - const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); +const returnTypes = quoteReturnMapping['0xcdca1753']; + +const buildResult = ({ + amountOut, + sqrtPriceX96AfterList, + initializedTicksCrossedList, + gasEstimate, +}: { + amountOut: bigint, + sqrtPriceX96AfterList: bigint[], + initializedTicksCrossedList: bigint[], + gasEstimate: bigint +}) => AbiCoder.defaultAbiCoder().decode( + returnTypes, + AbiCoder.defaultAbiCoder().encode(returnTypes, [ + amountOut, + sqrtPriceX96AfterList, + initializedTicksCrossedList, + gasEstimate, + ]), +); - const quoteResults = await getQuotesForRoutes( - provider, - TEST_QUOTER_ADDRESS, - [route], - amount, - TradeType.EXACT_INPUT, - ); - expect(quoteResults).toHaveLength(0); +describe('getQuotesForRoutes', () => { + describe('parseQuoteResult', () => { + it('detects a high price impact for a multi-pool route', () => { + const funUsdtPool = new Pool(UNISWAP_FUN, UNISWAP_USDT, FeeAmount.LOWEST, '28632999097454925582774', '205850654607274164', -296681); + const usdtUsdcPool = new Pool(UNISWAP_USDT, UNISWAP_USDC, FeeAmount.LOWEST, '79218947437379829182319841397', '615083188613638', -3); + const usdcWethPool = new Pool(UNISWAP_USDC, UNISWAP_WETH, FeeAmount.LOWEST, '4136724696665609466361418', '8867304981303', -197214); + const route = new Route([funUsdtPool, usdtUsdcPool, usdcWethPool], UNISWAP_FUN, UNISWAP_WETH); + + const amount = newAmountFromString('5654.4', FUN_TEST_TOKEN); + const decodedQuoteResult = buildResult({ + amountOut: 40732407119585286n, + sqrtPriceX96AfterList: [28351578844822416415875n, 79218853285550387257966700430n, 1799577836807645269467520725n], + initializedTicksCrossedList: [0n, 0n, 53n], + gasEstimate: 5910178n, + }); + + const quoteResult = parseQuoteResult(decodedQuoteResult, route, amount, TradeType.EXACT_INPUT); + expect(quoteResult.amountOut.value).toEqual(40732407119585286n); + expect(quoteResult.amountIn.value).toEqual(amount.value); + expect(quoteResult.gasEstimate).toEqual(5910178n); + expect(quoteResult.priceImpact.toSignificant(10)).toEqual('-84.96747014'); }); }); + describe('getQuotesForRoutes', () => { + const pool = new Pool( + UNISWAP_WETH, + UNISWAP_IMX, + FeeAmount.HIGH, + TickMath.getSqrtRatioAtTick(100), + 1000, + 100, + ); - describe('when one call of two in the batch fail', () => { - it('returns one quote results', async () => { + const route = new Route([pool], UNISWAP_WETH, UNISWAP_IMX); + it('makes an eth_call against the provider', async () => { const expectedAmountOut = parseEther('1000'); const expectedGasEstimate = '100000'; @@ -98,97 +100,152 @@ describe('getQuotesForRoutes', () => { expectedGasEstimate, ]); - const provider = buildProvider( - jest.fn() - .mockRejectedValueOnce(new ProviderCallError('an rpc error message')) - .mockResolvedValueOnce(returnData), - ); - - const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); + const provider = buildProvider(jest.fn().mockResolvedValueOnce(returnData)); const quoteResults = await getQuotesForRoutes( provider, TEST_QUOTER_ADDRESS, - [route, route], - amount, + [route], + newAmountFromString('1', WETH_TEST_TOKEN), TradeType.EXACT_INPUT, + 'latest', ); + expect(quoteResults).toHaveLength(1); + expect(provider.send).toHaveBeenCalledWith('eth_call', [{ + // eslint-disable-next-line max-len + data: '0xc6a5026a0000000000000000000000004f062a3eaec3730560ab89b5ce5ac0ab2c5517ae00000000000000000000000072958b06abdf2701ace6ceb3ce0b8b1ce11e08510000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000027100000000000000000000000000000000000000000000000000000000000000000', + to: '0x9B323E56215aAdcD4f45a6Be660f287DE154AFC5', + }, 'latest']); }); - }); - - describe('with one quote', () => { - it('returns the only quote', async () => { - const expectedAmountOut = parseEther('1000'); - const expectedGasEstimate = '100000'; - - const returnData = AbiCoder.defaultAbiCoder().encode(types, [ - expectedAmountOut, - '100', - '1', - expectedGasEstimate, - ]); - - const provider = buildProvider( - jest.fn().mockResolvedValue(returnData), - ); - const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); - const amountOutReceived = await getQuotesForRoutes( - provider, - TEST_QUOTER_ADDRESS, - [route], - amount, - TradeType.EXACT_INPUT, - ); - expect(amountOutReceived.length).toEqual(1); - expect(formatAmount(amountOutReceived[0].amountOut)).toEqual(formatEther(expectedAmountOut)); - expect(formatAmount(amountOutReceived[0].amountIn)).toEqual('0.000000000000123123'); - expect(amountOutReceived[0].gasEstimate.toString()).toEqual(expectedGasEstimate); + describe('when all calls in the batch fail', () => { + it('returns no quote results', async () => { + const provider = buildProvider( + jest.fn().mockRejectedValue(new ProviderCallError('an rpc error message')), + ); + + const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); + + const quoteResults = await getQuotesForRoutes( + provider, + TEST_QUOTER_ADDRESS, + [route], + amount, + TradeType.EXACT_INPUT, + 'latest', + ); + expect(quoteResults).toHaveLength(0); + }); }); - }); - - describe('with multiple quotes', () => { - it('returns all quotes', async () => { - const expectedAmountOut1 = parseEther('1000'); - const expectedAmountOut2 = parseEther('2000'); - const expectedGasEstimate1 = '100000'; - const expectedGasEstimate2 = '200000'; - const returnData1 = AbiCoder.defaultAbiCoder().encode(types, [ - expectedAmountOut1, - '100', - '1', - expectedGasEstimate1, - ]); - const returnData2 = AbiCoder.defaultAbiCoder().encode(types, [ - expectedAmountOut2, - '100', - '1', - expectedGasEstimate2, - ]); + describe('when one call of two in the batch fail', () => { + it('returns one quote results', async () => { + const expectedAmountOut = parseEther('1000'); + const expectedGasEstimate = '100000'; + + const returnData = AbiCoder.defaultAbiCoder().encode(types, [ + expectedAmountOut, + '100', + '1', + expectedGasEstimate, + ]); + + const provider = buildProvider( + jest.fn() + .mockRejectedValueOnce(new ProviderCallError('an rpc error message')) + .mockResolvedValueOnce(returnData), + ); + + const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); + + const quoteResults = await getQuotesForRoutes( + provider, + TEST_QUOTER_ADDRESS, + [route, route], + amount, + TradeType.EXACT_INPUT, + 'latest', + ); + expect(quoteResults).toHaveLength(1); + }); + }); - const provider = buildProvider( - jest.fn().mockResolvedValueOnce(returnData1).mockResolvedValueOnce(returnData2), - ); + describe('with one quote', () => { + it('returns the only quote', async () => { + const expectedAmountOut = parseEther('1000'); + const expectedGasEstimate = '100000'; + + const returnData = AbiCoder.defaultAbiCoder().encode(types, [ + expectedAmountOut, + '100', + '1', + expectedGasEstimate, + ]); + + const provider = buildProvider( + jest.fn().mockResolvedValue(returnData), + ); + + const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); + const amountOutReceived = await getQuotesForRoutes( + provider, + TEST_QUOTER_ADDRESS, + [route], + amount, + TradeType.EXACT_INPUT, + 'latest', + ); + expect(amountOutReceived.length).toEqual(1); + expect(formatAmount(amountOutReceived[0].amountOut)).toEqual(formatEther(expectedAmountOut)); + expect(formatAmount(amountOutReceived[0].amountIn)).toEqual('0.000000000000123123'); + expect(amountOutReceived[0].gasEstimate.toString()).toEqual(expectedGasEstimate); + }); + }); - const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); - const amountOutReceived = await getQuotesForRoutes( - provider, - TEST_QUOTER_ADDRESS, - [route, route], - amount, - TradeType.EXACT_INPUT, - ); - expect(amountOutReceived.length).toBe(2); - expect(formatAmount(amountOutReceived[0].amountOut)).toBe( - formatEther(expectedAmountOut1), - ); - expect(amountOutReceived[0].gasEstimate.toString()).toEqual(expectedGasEstimate1); - expect(formatAmount(amountOutReceived[1].amountOut)).toBe( - formatEther(expectedAmountOut2), - ); - expect(amountOutReceived[1].gasEstimate.toString()).toEqual(expectedGasEstimate2); + describe('with multiple quotes', () => { + it('returns all quotes', async () => { + const expectedAmountOut1 = parseEther('1000'); + const expectedAmountOut2 = parseEther('2000'); + const expectedGasEstimate1 = '100000'; + const expectedGasEstimate2 = '200000'; + + const returnData1 = AbiCoder.defaultAbiCoder().encode(types, [ + expectedAmountOut1, + '100', + '1', + expectedGasEstimate1, + ]); + const returnData2 = AbiCoder.defaultAbiCoder().encode(types, [ + expectedAmountOut2, + '100', + '1', + expectedGasEstimate2, + ]); + + const provider = buildProvider( + jest.fn().mockResolvedValueOnce(returnData1).mockResolvedValueOnce(returnData2), + ); + + const amount = newAmount(BigInt('123123'), WETH_TEST_TOKEN); + const amountOutReceived = await getQuotesForRoutes( + provider, + TEST_QUOTER_ADDRESS, + [route, route], + amount, + TradeType.EXACT_INPUT, + 'latest', + ); + expect(amountOutReceived.length).toBe(2); + expect(formatAmount(amountOutReceived[0].amountOut)).toBe( + formatEther(expectedAmountOut1), + ); + expect(amountOutReceived[0].gasEstimate.toString()).toEqual(expectedGasEstimate1); + expect(formatAmount(amountOutReceived[1].amountOut)).toBe( + formatEther(expectedAmountOut2), + ); + expect(amountOutReceived[1].gasEstimate.toString()).toEqual(expectedGasEstimate2); + }); }); }); }); diff --git a/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.ts b/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.ts index 1a6ec6dff2..24aff807c5 100644 --- a/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.ts +++ b/packages/internal/dex/sdk/src/lib/getQuotesForRoutes.ts @@ -1,10 +1,11 @@ import { Route, SwapQuoter } from '@uniswap/v3-sdk'; -import { TradeType, Token } from '@uniswap/sdk-core'; -import { AbiCoder } from 'ethers'; +import { TradeType, Token, Price, Fraction } from '@uniswap/sdk-core'; +import { AbiCoder, Result } from 'ethers'; import { CoinAmount, ERC20 } from '../types'; import { newAmount, quoteReturnMapping, toCurrencyAmount, uniswapTokenToERC20, } from './utils'; +import { BlockTag } from './multicall'; const amountIndex = 0; const gasEstimateIndex = 3; @@ -19,14 +20,51 @@ export type QuoteResult = { amountIn: CoinAmount; amountOut: CoinAmount; tradeType: TradeType; + priceImpact: Fraction; }; +export function parseQuoteResult( + quoteResult: Result, + route: Route, + amountSpecified: CoinAmount, + tradeType: TradeType, +) { + const quoteAmount = quoteResult[amountIndex]; + if (typeof quoteAmount !== 'bigint') throw new Error('Expected BigNumber'); + + const { midPrice, input, output } = route; + const inputToken = uniswapTokenToERC20(input); + const outputToken = uniswapTokenToERC20(output); + + const amountIn = tradeType === TradeType.EXACT_INPUT ? amountSpecified : newAmount(quoteAmount, inputToken); + const amountOut = tradeType === TradeType.EXACT_INPUT ? newAmount(quoteAmount, outputToken) : amountSpecified; + + const executionPrice = new Price( + input, + output, + amountIn.value.toString(), + amountOut.value.toString(), + ); + + const priceImpact = executionPrice.subtract(midPrice.asFraction).divide(midPrice.asFraction).multiply(100); + + return { + route, + amountIn, + amountOut, + gasEstimate: BigInt(quoteResult[gasEstimateIndex]), + tradeType, + priceImpact, + }; +} + export async function getQuotesForRoutes( provider: Provider, quoterContractAddress: string, routes: Route[], amountSpecified: CoinAmount, tradeType: TradeType, + blockTag: BlockTag, ): Promise { const callDatas = routes.map( (route) => SwapQuoter.quoteCallParameters(route, toCurrencyAmount(amountSpecified), tradeType, { @@ -35,9 +73,7 @@ export async function getQuotesForRoutes( ); const promises = await Promise.allSettled(callDatas.map((data) => - provider.send('eth_call', [ - { to: quoterContractAddress, data }, 'latest', - ]))); + provider.send('eth_call', [{ to: quoterContractAddress, data }, blockTag]))); const decodedQuoteResults = promises.reduce((quoteResults, promiseResult, i) => { if (promiseResult.status === 'rejected') return quoteResults; @@ -58,23 +94,8 @@ export async function getQuotesForRoutes( returnTypes, promiseResult.value, ); - - if (decodedQuoteResult) { - // The 0th element in each decoded data is going to be the amountOut or amountIn. - const quoteAmount = decodedQuoteResult[amountIndex]; - if (typeof quoteAmount !== 'bigint') throw new Error('Expected BigNumber'); - - const input = uniswapTokenToERC20(routes[i].input); - const output = uniswapTokenToERC20(routes[i].output); - - quoteResults.push({ - route: routes[i], - amountIn: tradeType === TradeType.EXACT_INPUT ? amountSpecified : newAmount(quoteAmount, input), - amountOut: tradeType === TradeType.EXACT_INPUT ? newAmount(quoteAmount, output) : amountSpecified, - gasEstimate: BigInt(decodedQuoteResult[gasEstimateIndex]), - tradeType, - }); - } + const quoteResult = parseQuoteResult(decodedQuoteResult, routes[i], amountSpecified, tradeType); + quoteResults.push(quoteResult); } catch { // Failed to get the quote for this particular route // Other quotes for routes may still succeed, so do nothing diff --git a/packages/internal/dex/sdk/src/lib/multicall.ts b/packages/internal/dex/sdk/src/lib/multicall.ts index c011d8317c..5553b5645b 100644 --- a/packages/internal/dex/sdk/src/lib/multicall.ts +++ b/packages/internal/dex/sdk/src/lib/multicall.ts @@ -9,8 +9,15 @@ const DEFAULT_GAS_QUOTE = 2_000_000; type Address = string; +export type BlockTag = 'latest' | `0x${string}`; + export type SingleContractCallOptions = { gasRequired: number; + blockTag: BlockTag; +}; + +export type MulticallOptions = { + blockTag: BlockTag; }; export type MulticallResponse = { @@ -23,6 +30,7 @@ export async function multicallSingleCallDataMultipleContracts( multicallContract: Multicall, functionName: string, addresses: Address[], + options?: MulticallOptions, ): Promise { // Encode args - generate calldata for contract const contractIFace = UniswapV3Pool__factory.createInterface(); @@ -44,6 +52,10 @@ export async function multicallSingleCallDataMultipleContracts( }); } + // Use block number if provided, otherwise use latest block + if (options?.blockTag) { + return multicallContract.multicall.staticCall(calls, { blockTag: options.blockTag }); + } return multicallContract.multicall.staticCall(calls); } @@ -67,6 +79,10 @@ export async function multicallMultipleCallDataSingContract( }; } + // Use block number if provided, otherwise use latest block + if (options?.blockTag) { + return multicallContract.multicall.staticCall(calls, { blockTag: options.blockTag }); + } return multicallContract.multicall.staticCall(calls); } diff --git a/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.test.ts b/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.test.ts index bea4952e15..5733c01d1c 100644 --- a/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.test.ts +++ b/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.test.ts @@ -46,6 +46,7 @@ describe('fetchPools', () => { [WETH_TEST_TOKEN, IMX_TEST_TOKEN], [], TEST_V3_CORE_FACTORY_ADDRESS, + 'latest', )).rejects.toThrow(new ProviderCallError('failed multicall: an rpc error message')); }); }); @@ -112,6 +113,7 @@ describe('fetchPools', () => { [WETH_TEST_TOKEN, IMX_TEST_TOKEN], [], TEST_V3_CORE_FACTORY_ADDRESS, + 'latest', ); expect(pools).toHaveLength(0); @@ -236,6 +238,7 @@ describe('fetchPools', () => { [WETH_TEST_TOKEN, IMX_TEST_TOKEN], [], TEST_V3_CORE_FACTORY_ADDRESS, + 'latest', ); expect(pools).toHaveLength(0); @@ -363,6 +366,7 @@ describe('fetchPools', () => { [WETH_TEST_TOKEN, IMX_TEST_TOKEN], [], TEST_V3_CORE_FACTORY_ADDRESS, + 'latest', ); expect(pools).toHaveLength(4); diff --git a/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.ts b/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.ts index 4b49318291..24d51d25a1 100644 --- a/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.ts +++ b/packages/internal/dex/sdk/src/lib/poolUtils/fetchValidPools.ts @@ -6,18 +6,18 @@ import { generatePossiblePoolsFromERC20Pair } from './generatePossiblePoolsFromE import { ERC20Pair } from './generateERC20Pairs'; import { Multicall, UniswapV3Pool__factory } from '../../contracts/types'; import { UniswapV3PoolInterface } from '../../contracts/types/UniswapV3Pool'; -import { MulticallResponse, multicallSingleCallDataMultipleContracts } from '../multicall'; +import { BlockTag, MulticallResponse, multicallSingleCallDataMultipleContracts } from '../multicall'; export type Slot0 = { sqrtPriceX96: bigint; tick: number; - observationIndex: number; observationCardinality: number; observationCardinalityNext: number; feeProtocol: number; unlocked: boolean; }; + const liquidityFuncString = 'liquidity'; const slot0FuncString = 'slot0'; const noDataResult = '0x'; @@ -29,6 +29,7 @@ export const fetchValidPools = async ( erc20Pair: ERC20Pair, commonRoutingERC20s: ERC20[], factoryAddress: string, + blockTag: BlockTag, ): Promise => { const poolIDs = generatePossiblePoolsFromERC20Pair( erc20Pair, @@ -47,11 +48,13 @@ export const fetchValidPools = async ( multicallContract, slot0FuncString, poolAddresses, + { blockTag }, ), multicallSingleCallDataMultipleContracts( multicallContract, liquidityFuncString, poolAddresses, + { blockTag }, ), ]); } catch (e) { diff --git a/packages/internal/dex/sdk/src/lib/router.spec.ts b/packages/internal/dex/sdk/src/lib/router.spec.ts new file mode 100644 index 0000000000..a37198cae6 --- /dev/null +++ b/packages/internal/dex/sdk/src/lib/router.spec.ts @@ -0,0 +1,44 @@ +import { JsonRpcProvider } from 'ethers/providers'; +import { TradeType } from '@uniswap/sdk-core'; +import { Router } from './router'; +import { + IMMUTABLE_MAINNET_CHAIN_ID, + IMMUTABLE_MAINNET_COMMON_ROUTING_TOKENS, + IMMUTABLE_MAINNET_RPC_URL, +} from '../constants'; +import { Multicall__factory } from '../contracts/types'; +import { CONTRACTS_FOR_CHAIN_ID } from '../config'; +import { newAmountFromString } from '../test/utils'; +import { ERC20 } from '../types'; + +describe('Router', () => { + // Unskip to run the test against the real RPC + it.skip('calculates the price impact for a route', async () => { + const contracts = CONTRACTS_FOR_CHAIN_ID[IMMUTABLE_MAINNET_CHAIN_ID]; + const provider = new JsonRpcProvider(IMMUTABLE_MAINNET_RPC_URL, IMMUTABLE_MAINNET_CHAIN_ID); + const multicall = Multicall__factory.connect(contracts.multicall, provider); + const router = new Router( + provider, + multicall, + IMMUTABLE_MAINNET_COMMON_ROUTING_TOKENS, + { + multicall: contracts.multicall, + coreFactory: contracts.coreFactory, + quoter: contracts.quoter, + }, + ); + + const inputToken: ERC20 = { + type: 'erc20', + chainId: IMMUTABLE_MAINNET_CHAIN_ID, + address: '0x0FA1d8Ffa9B414ABF0F47183e088bddC32e084F3', + decimals: 18, + }; + const outputToken = IMMUTABLE_MAINNET_COMMON_ROUTING_TOKENS[1]; + const amount = newAmountFromString('1825.09', inputToken); + // eslint-disable-next-line max-len + const route = await router.findOptimalRoute(amount, outputToken, TradeType.EXACT_INPUT, undefined, `0x${Number(22780317 - 1).toString(16)}`); + expect(route).toBeDefined(); + expect(route.priceImpact.toSignificant(10)).toEqual('-15.33623394'); + }); +}); diff --git a/packages/internal/dex/sdk/src/lib/router.ts b/packages/internal/dex/sdk/src/lib/router.ts index ed5a74148b..d0c5e13112 100644 --- a/packages/internal/dex/sdk/src/lib/router.ts +++ b/packages/internal/dex/sdk/src/lib/router.ts @@ -9,6 +9,7 @@ import { fetchValidPools } from './poolUtils/fetchValidPools'; import { ERC20Pair } from './poolUtils/generateERC20Pairs'; import { DEFAULT_MAX_HOPS } from '../constants/router'; import type { Multicall } from '../contracts/types'; +import { BlockTag } from './multicall'; export type RoutingContracts = { multicall: string; @@ -33,7 +34,9 @@ export class Router { amountSpecified: CoinAmount, otherToken: ERC20, tradeType: TradeType, - maxHops: number = DEFAULT_MAX_HOPS, + maxHops = DEFAULT_MAX_HOPS, + blockTag: BlockTag = 'latest', + ): Promise { const [tokenIn, tokenOut] = this.determineERC20InAndERC20Out(tradeType, amountSpecified, otherToken); @@ -45,6 +48,7 @@ export class Router { erc20Pair, this.routingTokens, this.routingContracts.coreFactory, + blockTag, ); const noValidPools = pools.length === 0; @@ -63,13 +67,14 @@ export class Router { } // Get the best quote from all of the given routes - return await this.getBestQuoteFromRoutes(routes, amountSpecified, tradeType); + return await this.getBestQuoteFromRoutes(routes, amountSpecified, tradeType, blockTag); } private async getBestQuoteFromRoutes( routes: Route[], amountSpecified: CoinAmount, tradeType: TradeType, + blockTag: BlockTag, ): Promise { const quotes = await getQuotesForRoutes( this.provider, @@ -77,7 +82,9 @@ export class Router { routes, amountSpecified, tradeType, + blockTag, ); + if (quotes.length === 0) { throw new NoRoutesAvailableError(); } diff --git a/packages/internal/dex/sdk/src/lib/transactionUtils/getQuote.test.ts b/packages/internal/dex/sdk/src/lib/transactionUtils/getQuote.test.ts index e4a978bab1..8090fa64b1 100644 --- a/packages/internal/dex/sdk/src/lib/transactionUtils/getQuote.test.ts +++ b/packages/internal/dex/sdk/src/lib/transactionUtils/getQuote.test.ts @@ -1,4 +1,4 @@ -import { TradeType } from '@uniswap/sdk-core'; +import { Percent, TradeType } from '@uniswap/sdk-core'; import { Pool, Route } from '@uniswap/v3-sdk'; import { formatEther, parseEther } from 'ethers'; import { Fees } from '../fees'; @@ -138,6 +138,7 @@ describe('prepareUserQuote', () => { gasEstimate, route, tradeType: TradeType.EXACT_INPUT, + priceImpact: new Percent(0, 100), }; const userQuote = prepareUserQuote(nativeTokenService, quoteResult, DEFAULT_SLIPPAGE, tokenOfQuotedAmount); @@ -156,6 +157,7 @@ describe('prepareUserQuote', () => { gasEstimate, route, tradeType: TradeType.EXACT_OUTPUT, + priceImpact: new Percent(0, 100), }; const userQuote = prepareUserQuote(nativeTokenService, quoteResult, DEFAULT_SLIPPAGE, tokenOfQuotedAmount); @@ -175,6 +177,7 @@ describe('prepareUserQuote', () => { gasEstimate, route, tradeType: TradeType.EXACT_INPUT, + priceImpact: new Percent(0, 100), }; const userQuote = prepareUserQuote(nativeTokenService, quoteResult, DEFAULT_SLIPPAGE, tokenOfQuotedAmount); expectERC20(userQuote.quotedAmount.token); @@ -193,6 +196,7 @@ describe('prepareUserQuote', () => { gasEstimate, route, tradeType: TradeType.EXACT_INPUT, + priceImpact: new Percent(0, 100), }; const userQuote = prepareUserQuote(nativeTokenService, quoteResult, DEFAULT_SLIPPAGE, tokenOfQuotedAmount); diff --git a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts index dcc98f6026..42d587b7e2 100644 --- a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts +++ b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts @@ -1,4 +1,4 @@ -import { TradeType } from '@uniswap/sdk-core'; +import { Percent, TradeType } from '@uniswap/sdk-core'; import { Pool, Route } from '@uniswap/v3-sdk'; import { parseEther } from 'ethers'; import { @@ -76,6 +76,7 @@ const buildExactInputQuote = (tokenIn = IMX_TEST_TOKEN, tokenOut = FUN_TEST_TOKE amountIn: newAmountFromString('99', tokenIn), amountOut: newAmountFromString('990', tokenOut), tradeType: TradeType.EXACT_INPUT, + priceImpact: new Percent(0, 100), }); const buildExactOutputQuote = (tokenIn = IMX_TEST_TOKEN, tokenOut = FUN_TEST_TOKEN): QuoteResult => ({ @@ -84,6 +85,7 @@ const buildExactOutputQuote = (tokenIn = IMX_TEST_TOKEN, tokenOut = FUN_TEST_TOK amountIn: newAmountFromString('100', tokenIn), amountOut: newAmountFromString('1000', tokenOut), tradeType: TradeType.EXACT_OUTPUT, + priceImpact: new Percent(0, 100), }); const buildMultiExactInputQuote = ( @@ -96,6 +98,7 @@ const buildMultiExactInputQuote = ( amountIn: newAmountFromString('99', tokenIn), amountOut: newAmountFromString('990', tokenOut), tradeType: TradeType.EXACT_INPUT, + priceImpact: new Percent(0, 100), }); const buildMultiExactOutputQuote = ( @@ -108,6 +111,7 @@ const buildMultiExactOutputQuote = ( amountIn: newAmountFromString('100', tokenIn), amountOut: newAmountFromString('1000', tokenOut), tradeType: TradeType.EXACT_OUTPUT, + priceImpact: new Percent(0, 100), }); const tenPercentFees = (tokenIn: Coin): Fees => @@ -533,6 +537,7 @@ describe('adjustQuoteWithFees', () => { amountIn: newAmountFromString('9', nativeTokenService.wrappedToken), // has been wrapped amountOut: newAmountFromString('1', FUN_TEST_TOKEN), tradeType: TradeType.EXACT_INPUT, + priceImpact: new Percent(0, 100), }; const userSpecifiedAmountIn = newAmountFromString('10', nativeTokenService.nativeToken); @@ -607,6 +612,7 @@ describe('adjustQuoteWithFees', () => { amountIn: newAmountFromString('10', nativeTokenService.wrappedToken), // has been wrapped amountOut: newAmountFromString('1', FUN_TEST_TOKEN), tradeType: TradeType.EXACT_OUTPUT, + priceImpact: new Percent(0, 100), }; const userSpecifiedAmountOut = quote.amountOut; diff --git a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts index 6b5016b47b..0a06374fe8 100644 --- a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts +++ b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts @@ -535,5 +535,6 @@ export function adjustQuoteWithFees( amountIn: adjustedAmountIn, amountOut: ourQuote.amountOut, tradeType: ourQuote.tradeType, + priceImpact: ourQuote.priceImpact, }; } diff --git a/packages/internal/dex/sdk/src/lib/utils.ts b/packages/internal/dex/sdk/src/lib/utils.ts index 1c7e6c432a..88817eb732 100644 --- a/packages/internal/dex/sdk/src/lib/utils.ts +++ b/packages/internal/dex/sdk/src/lib/utils.ts @@ -6,6 +6,7 @@ import { Amount, Coin, CoinAmount, ERC20, Native, Token } from '../types'; import { DEFAULT_DEADLINE_SECONDS } from '../constants/router'; export const quoteReturnMapping: { [signature: string]: string[] } = { + // function quoteExactInput(bytes memory path, uint256 amountIn) external returns (uint256 amountOut, uint160[] memory sqrtPriceX96AfterList, uint32[] memory initializedTicksCrossedList, uint256 gasEstimate); // eslint-disable-next-line @typescript-eslint/naming-convention '0xcdca1753': ['uint256', 'uint160[]', 'uint32[]', 'uint256'], // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/packages/internal/dex/sdk/src/test/utils.ts b/packages/internal/dex/sdk/src/test/utils.ts index f7ac99f5f1..3a760c0af6 100644 --- a/packages/internal/dex/sdk/src/test/utils.ts +++ b/packages/internal/dex/sdk/src/test/utils.ts @@ -1,4 +1,4 @@ -import { TradeType } from '@uniswap/sdk-core'; +import { Fraction, TradeType } from '@uniswap/sdk-core'; import { Pool, Route, TickMath } from '@uniswap/v3-sdk'; import { Environment, ImmutableConfiguration } from '@imtbl/config'; import { @@ -79,6 +79,15 @@ export const USDC_TEST_TOKEN: ERC20 = { type: 'erc20', }; +export const USDT_TEST_TOKEN: ERC20 = { + chainId: TEST_CHAIN_ID, + address: '0xF72fFE2f7F1Abc7f7C83623C4a690Cf9133325fc', + decimals: 6, + symbol: 'USDT', + name: 'Tether USD', + type: 'erc20', +}; + export const FUN_TEST_TOKEN: ERC20 = { chainId: TEST_CHAIN_ID, address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195', @@ -458,6 +467,7 @@ export function mockRouterImplementation(params: MockParams) { amountOut, tradeType, gasEstimate: TEST_TRANSACTION_GAS_USAGE, + priceImpact: new Fraction(1), }; return trade; diff --git a/packages/internal/dex/sdk/src/types/index.ts b/packages/internal/dex/sdk/src/types/index.ts index aa285cf90f..3421774314 100644 --- a/packages/internal/dex/sdk/src/types/index.ts +++ b/packages/internal/dex/sdk/src/types/index.ts @@ -72,6 +72,7 @@ export type Quote = { amount: Amount; amountWithMaxSlippage: Amount; slippage: number; + priceImpact: number; fees: Fee[]; };