diff --git a/package.json b/package.json index 1cd442d3..3f3718a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@uniswap/universal-router-sdk", - "version": "1.8.2", + "version": "2.0.0", "description": "sdk for integrating with the Universal Router contracts", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -53,7 +53,7 @@ "@uniswap/router-sdk": "^1.9.0", "@uniswap/sdk-core": "^4.2.0", "@uniswap/universal-router": "1.6.0", - "@uniswap/v2-sdk": "^4.2.0", + "@uniswap/v2-sdk": "^4.3.0", "@uniswap/v3-sdk": "^3.11.0", "bignumber.js": "^9.0.2", "ethers": "^5.3.1" diff --git a/src/entities/protocols/uniswap.ts b/src/entities/protocols/uniswap.ts index 9c058420..41638895 100644 --- a/src/entities/protocols/uniswap.ts +++ b/src/entities/protocols/uniswap.ts @@ -30,7 +30,9 @@ export type FlatFeeOptions = { // the existing router permit object doesn't include enough data for permit2 // so we extend swap options with the permit2 permit // when safe mode is enabled, the SDK will add an extra ETH sweep for security +// when useRouterBalance is enabled the SDK will use the balance in the router for the swap export type SwapOptions = Omit & { + useRouterBalance?: boolean inputTokenPermit?: Permit2Permit flatFee?: FlatFeeOptions safeMode?: boolean @@ -48,22 +50,28 @@ interface Swap { // also translates trade objects from previous (v2, v3) SDKs export class UniswapTrade implements Command { readonly tradeType: RouterTradeType = RouterTradeType.UniswapTrade + readonly payerIsUser: boolean + constructor(public trade: RouterTrade, public options: SwapOptions) { if (!!options.fee && !!options.flatFee) throw new Error('Only one fee option permitted') + + if (this.inputRequiresWrap) this.payerIsUser = false + else if (this.options.useRouterBalance) this.payerIsUser = false + else this.payerIsUser = true } - encode(planner: RoutePlanner, _config: TradeConfig): void { - let payerIsUser = true + get inputRequiresWrap(): boolean { + return this.trade.inputAmount.currency.isNative + } + encode(planner: RoutePlanner, _config: TradeConfig): void { // If the input currency is the native currency, we need to wrap it with the router as the recipient - if (this.trade.inputAmount.currency.isNative) { + if (this.inputRequiresWrap) { // TODO: optimize if only one v2 pool we can directly send this to the pool planner.addCommand(CommandType.WRAP_ETH, [ ROUTER_AS_RECIPIENT, this.trade.maximumAmountIn(this.options.slippageTolerance).quotient.toString(), ]) - // since WETH is now owned by the router, the router pays for inputs - payerIsUser = false } // The overall recipient at the end of the trade, SENDER_AS_RECIPIENT uses the msg.sender this.options.recipient = this.options.recipient ?? SENDER_AS_RECIPIENT @@ -75,19 +83,18 @@ export class UniswapTrade implements Command { const performAggregatedSlippageCheck = this.trade.tradeType === TradeType.EXACT_INPUT && this.trade.routes.length > 2 const outputIsNative = this.trade.outputAmount.currency.isNative - const inputIsNative = this.trade.inputAmount.currency.isNative const routerMustCustody = performAggregatedSlippageCheck || outputIsNative || hasFeeOption(this.options) for (const swap of this.trade.swaps) { switch (swap.route.protocol) { case Protocol.V2: - addV2Swap(planner, swap, this.trade.tradeType, this.options, payerIsUser, routerMustCustody) + addV2Swap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody) break case Protocol.V3: - addV3Swap(planner, swap, this.trade.tradeType, this.options, payerIsUser, routerMustCustody) + addV3Swap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody) break case Protocol.MIXED: - addMixedSwap(planner, swap, this.trade.tradeType, this.options, payerIsUser, routerMustCustody) + addMixedSwap(planner, swap, this.trade.tradeType, this.options, this.payerIsUser, routerMustCustody) break default: throw new Error('UNSUPPORTED_TRADE_PROTOCOL') @@ -149,7 +156,7 @@ export class UniswapTrade implements Command { } } - if (inputIsNative && (this.trade.tradeType === TradeType.EXACT_OUTPUT || riskOfPartialFill(this.trade))) { + if (this.inputRequiresWrap && (this.trade.tradeType === TradeType.EXACT_OUTPUT || riskOfPartialFill(this.trade))) { // for exactOutput swaps that take native currency as input // we need to send back the change to the user planner.addCommand(CommandType.UNWRAP_WETH, [this.options.recipient, 0]) diff --git a/src/index.ts b/src/index.ts index b966818b..4c576827 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { SwapRouter } from './swapRouter' export * from './entities' +export * from './utils/routerTradeAdapter' export { RoutePlanner, CommandType } from './utils/routerCommands' export { UNIVERSAL_ROUTER_ADDRESS, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 0261f243..c1262557 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -128,6 +128,7 @@ export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3' export const CONTRACT_BALANCE = BigNumber.from(2).pow(255) export const ETH_ADDRESS = '0x0000000000000000000000000000000000000000' +export const E_ETH_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' export const MAX_UINT256 = BigNumber.from(2).pow(256).sub(1) export const MAX_UINT160 = BigNumber.from(2).pow(160).sub(1) diff --git a/src/utils/routerTradeAdapter.ts b/src/utils/routerTradeAdapter.ts new file mode 100644 index 00000000..a6aacfff --- /dev/null +++ b/src/utils/routerTradeAdapter.ts @@ -0,0 +1,206 @@ +import { MixedRouteSDK, Trade as RouterTrade } from '@uniswap/router-sdk' +import { Currency, CurrencyAmount, Ether, Token, TradeType } from '@uniswap/sdk-core' +import { Pair, Route as V2Route } from '@uniswap/v2-sdk' +import { Pool, Route as V3Route, FeeAmount } from '@uniswap/v3-sdk' +import { BigNumber } from 'ethers' +import { ETH_ADDRESS, E_ETH_ADDRESS } from './constants' + +export type TokenInRoute = { + address: string + chainId: number + symbol: string + decimals: string + name?: string + buyFeeBps?: string + sellFeeBps?: string +} + +export enum PoolType { + V2Pool = 'v2-pool', + V3Pool = 'v3-pool', +} + +export type V2Reserve = { + token: TokenInRoute + quotient: string +} + +export type V2PoolInRoute = { + type: PoolType.V2Pool + address?: string + tokenIn: TokenInRoute + tokenOut: TokenInRoute + reserve0: V2Reserve + reserve1: V2Reserve + amountIn?: string + amountOut?: string +} + +export type V3PoolInRoute = { + type: PoolType.V3Pool + address?: string + tokenIn: TokenInRoute + tokenOut: TokenInRoute + sqrtRatioX96: string + liquidity: string + tickCurrent: string + fee: string + amountIn?: string + amountOut?: string +} + +export type PartialClassicQuote = { + // We need tokenIn/Out to support native currency + tokenIn: string + tokenOut: string + tradeType: TradeType + route: Array<(V3PoolInRoute | V2PoolInRoute)[]> +} + +interface RouteResult { + routev3: V3Route | null + routev2: V2Route | null + mixedRoute: MixedRouteSDK | null + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount +} + +export const isNativeCurrency = (address: string) => + address.toLowerCase() === ETH_ADDRESS.toLowerCase() || address.toLowerCase() === E_ETH_ADDRESS.toLowerCase() + +// Helper class to convert routing-specific quote entities to RouterTrade entities +// the returned RouterTrade can then be used to build the UniswapTrade entity in this package +export class RouterTradeAdapter { + // Generate a RouterTrade using fields from a classic quote response + static fromClassicQuote(quote: PartialClassicQuote) { + const { route, tokenIn, tokenOut } = quote + + if (!route) throw new Error('Expected route to be present') + if (!route.length) throw new Error('Expected there to be at least one route') + if (route.some((r) => !r.length)) throw new Error('Expected all routes to have at least one pool') + const firstRoute = route[0] + + const tokenInData = firstRoute[0].tokenIn + const tokenOutData = firstRoute[firstRoute.length - 1].tokenOut + + if (!tokenInData || !tokenOutData) throw new Error('Expected both tokenIn and tokenOut to be present') + if (tokenInData.chainId !== tokenOutData.chainId) + throw new Error('Expected tokenIn and tokenOut to be have same chainId') + + const parsedCurrencyIn = RouterTradeAdapter.toCurrency(isNativeCurrency(tokenIn), tokenInData) + const parsedCurrencyOut = RouterTradeAdapter.toCurrency(isNativeCurrency(tokenOut), tokenOutData) + + const typedRoutes: RouteResult[] = route.map((subRoute) => { + const rawAmountIn = subRoute[0].amountIn + const rawAmountOut = subRoute[subRoute.length - 1].amountOut + + if (!rawAmountIn || !rawAmountOut) { + throw new Error('Expected both raw amountIn and raw amountOut to be present') + } + + const inputAmount = CurrencyAmount.fromRawAmount(parsedCurrencyIn, rawAmountIn) + const outputAmount = CurrencyAmount.fromRawAmount(parsedCurrencyOut, rawAmountOut) + + const isOnlyV2 = RouterTradeAdapter.isVersionedRoute(PoolType.V2Pool, subRoute) + const isOnlyV3 = RouterTradeAdapter.isVersionedRoute(PoolType.V3Pool, subRoute) + + return { + routev3: isOnlyV3 + ? new V3Route( + (subRoute as V3PoolInRoute[]).map(RouterTradeAdapter.toPool), + parsedCurrencyIn, + parsedCurrencyOut + ) + : null, + routev2: isOnlyV2 + ? new V2Route( + (subRoute as V2PoolInRoute[]).map(RouterTradeAdapter.toPair), + parsedCurrencyIn, + parsedCurrencyOut + ) + : null, + mixedRoute: + !isOnlyV3 && !isOnlyV2 + ? new MixedRouteSDK(subRoute.map(RouterTradeAdapter.toPoolOrPair), parsedCurrencyIn, parsedCurrencyOut) + : null, + inputAmount, + outputAmount, + } + }) + + return new RouterTrade({ + v2Routes: typedRoutes + .filter((route) => route.routev2) + .map((route) => ({ + routev2: route.routev2 as V2Route, + inputAmount: route.inputAmount, + outputAmount: route.outputAmount, + })), + v3Routes: typedRoutes + .filter((route) => route.routev3) + .map((route) => ({ + routev3: route.routev3 as V3Route, + inputAmount: route.inputAmount, + outputAmount: route.outputAmount, + })), + mixedRoutes: typedRoutes + .filter((route) => route.mixedRoute) + .map((route) => ({ + mixedRoute: route.mixedRoute as MixedRouteSDK, + inputAmount: route.inputAmount, + outputAmount: route.outputAmount, + })), + tradeType: quote.tradeType, + }) + } + + private static toCurrency(isNative: boolean, token: TokenInRoute): Currency { + if (isNative) { + return Ether.onChain(token.chainId) + } + return this.toToken(token) + } + + private static toPoolOrPair = (pool: V3PoolInRoute | V2PoolInRoute): Pool | Pair => { + return pool.type === PoolType.V3Pool ? RouterTradeAdapter.toPool(pool) : RouterTradeAdapter.toPair(pool) + } + + private static toToken(token: TokenInRoute): Token { + const { chainId, address, decimals, symbol, buyFeeBps, sellFeeBps } = token + return new Token( + chainId, + address, + parseInt(decimals.toString()), + symbol, + /* name */ undefined, + false, + buyFeeBps ? BigNumber.from(buyFeeBps) : undefined, + sellFeeBps ? BigNumber.from(sellFeeBps) : undefined + ) + } + + private static toPool({ fee, sqrtRatioX96, liquidity, tickCurrent, tokenIn, tokenOut }: V3PoolInRoute): Pool { + return new Pool( + RouterTradeAdapter.toToken(tokenIn), + RouterTradeAdapter.toToken(tokenOut), + parseInt(fee) as FeeAmount, + sqrtRatioX96, + liquidity, + parseInt(tickCurrent) + ) + } + + private static toPair = ({ reserve0, reserve1 }: V2PoolInRoute): Pair => { + return new Pair( + CurrencyAmount.fromRawAmount(RouterTradeAdapter.toToken(reserve0.token), reserve0.quotient), + CurrencyAmount.fromRawAmount(RouterTradeAdapter.toToken(reserve1.token), reserve1.quotient) + ) + } + + private static isVersionedRoute( + type: PoolType, + route: (V3PoolInRoute | V2PoolInRoute)[] + ): route is T[] { + return route.every((pool) => pool.type === type) + } +} diff --git a/test/uniswapTrades.test.ts b/test/uniswapTrades.test.ts index 2ac15bd6..9b52b2b1 100644 --- a/test/uniswapTrades.test.ts +++ b/test/uniswapTrades.test.ts @@ -1,17 +1,25 @@ import { expect } from 'chai' import JSBI from 'jsbi' -import { BigNumber, utils, Wallet } from 'ethers' +import { BigNumber, ethers, utils, Wallet } from 'ethers' import { expandTo18Decimals } from '../src/utils/numbers' import { SwapRouter, UniswapTrade, FlatFeeOptions } from '../src' import { MixedRouteTrade, MixedRouteSDK } from '@uniswap/router-sdk' import { Trade as V2Trade, Pair, Route as RouteV2 } from '@uniswap/v2-sdk' import { Trade as V3Trade, Route as RouteV3, Pool, FeeOptions } from '@uniswap/v3-sdk' import { generatePermitSignature, toInputPermit, makePermit, generateEip2098PermitSignature } from './utils/permit2' -import { CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' +import { CurrencyAmount, Ether, Percent, Token, TradeType } from '@uniswap/sdk-core' import { registerFixture } from './forge/writeInterop' -import { buildTrade, getUniswapPools, swapOptions, ETHER, DAI, USDC } from './utils/uniswapData' +import { buildTrade, getUniswapPools, swapOptions, ETHER, DAI, USDC, WETH } from './utils/uniswapData' import { hexToDecimalString } from './utils/hexToDecimalString' import { FORGE_PERMIT2_ADDRESS, FORGE_ROUTER_ADDRESS, TEST_FEE_RECIPIENT_ADDRESS } from './utils/addresses' +import { + PartialClassicQuote, + PoolType, + RouterTradeAdapter, + V2PoolInRoute, + V3PoolInRoute, +} from '../src/utils/routerTradeAdapter' +import { E_ETH_ADDRESS, ETH_ADDRESS } from '../src/utils/constants' const FORK_BLOCK = 16075500 @@ -220,6 +228,7 @@ describe('Uniswap', () => { CurrencyAmount.fromRawAmount(USDC, outputUSDC), TradeType.EXACT_OUTPUT ) + const routerTrade = buildTrade([trade]) const opts = swapOptions({}) const methodParameters = SwapRouter.swapERC20CallParameters(buildTrade([trade]), opts) const methodParametersV2 = SwapRouter.swapCallParameters(new UniswapTrade(buildTrade([trade]), opts)) @@ -693,4 +702,621 @@ describe('Uniswap', () => { }).to.throw('Flat fee amount greater than minimumAmountOut') }) }) + + const mockV2PoolInRoute = ( + pair: Pair, + tokenIn: Token, + tokenOut: Token, + amountIn: string, + amountOut: string + ): V2PoolInRoute => { + // get token0 and token1 + const token0 = tokenIn.sortsBefore(tokenOut) ? tokenIn : tokenOut + const token1 = tokenIn.sortsBefore(tokenOut) ? tokenOut : tokenIn + + return { + type: PoolType.V2Pool, + tokenIn: { + address: tokenIn.address, + chainId: 1, + symbol: tokenIn.symbol!, + decimals: String(tokenIn.decimals), + }, + tokenOut: { + address: tokenOut.address, + chainId: 1, + symbol: tokenOut.symbol!, + decimals: String(tokenOut.decimals), + }, + reserve0: { + token: { + address: token0.address, + chainId: 1, + symbol: token0.symbol!, + decimals: String(token0.decimals), + }, + quotient: pair.reserve0.quotient.toString(), + }, + reserve1: { + token: { + address: token1.address, + chainId: 1, + symbol: token1.symbol!, + decimals: String(token1.decimals), + }, + quotient: pair.reserve1.quotient.toString(), + }, + amountIn, + amountOut, + } + } + + const mockV3PoolInRoute = ( + pool: Pool, + tokenIn: Token, + tokenOut: Token, + amountIn: string, + amountOut: string + ): V3PoolInRoute => { + return { + type: PoolType.V3Pool, + tokenIn: { + address: tokenIn.address, + chainId: 1, + symbol: tokenIn.symbol!, + decimals: String(tokenIn.decimals), + }, + tokenOut: { + address: tokenOut.address, + chainId: 1, + symbol: tokenOut.symbol!, + decimals: String(tokenOut.decimals), + }, + sqrtRatioX96: pool.sqrtRatioX96.toString(), + liquidity: pool.liquidity.toString(), + tickCurrent: pool.tickCurrent.toString(), + fee: pool.fee.toString(), + amountIn, + amountOut, + } + } + + for (let tradeType of [TradeType.EXACT_INPUT, TradeType.EXACT_OUTPUT]) { + describe('RouterTradeAdapter ' + tradeType, () => { + const getAmountToken = (tokenIn: Token | Ether, tokenOut: Token | Ether, tradeType: TradeType): Token | Ether => { + return tradeType === TradeType.EXACT_INPUT ? tokenIn : tokenOut + } + const getAmount = ( + tokenIn: Token | Ether, + tokenOut: Token | Ether, + amount: string, + tradeType: TradeType + ): CurrencyAmount => { + return tradeType === TradeType.EXACT_INPUT + ? CurrencyAmount.fromRawAmount(tokenIn, amount) + : CurrencyAmount.fromRawAmount(tokenOut, amount) + } + + function compareUniswapTrades(left: UniswapTrade, right: UniswapTrade): void { + expect(SwapRouter.swapCallParameters(left).calldata).to.eq(SwapRouter.swapCallParameters(right).calldata) + expect(SwapRouter.swapCallParameters(left).value).to.eq(SwapRouter.swapCallParameters(right).value) + } + + it('v2 - erc20 <> erc20', async () => { + const [tokenIn, tokenOut] = [DAI, USDC] + const inputAmount = ethers.utils + .parseUnits('1000', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + // amount should always be interms of output token + const trade = new V2Trade(new RouteV2([USDC_DAI_V2], DAI, USDC), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: DAI.address, + tokenOut: USDC.address, + tradeType, + route: [ + [ + mockV2PoolInRoute( + USDC_DAI_V2, + tokenIn, + tokenOut, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v3 - erc20 <> erc20', async () => { + const [tokenIn, tokenOut] = [DAI, USDC] + const inputAmount = ethers.utils + .parseUnits('1000', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = await V3Trade.fromRoute(new RouteV3([USDC_DAI_V3], tokenIn, tokenOut), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: DAI.address, + tokenOut: USDC.address, + tradeType, + route: [ + [ + mockV3PoolInRoute( + USDC_DAI_V3, + tokenIn, + tokenOut, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v2 - handles weth input properly', async () => { + const [tokenIn, tokenOut] = [WETH, USDC] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = new V2Trade(new RouteV2([WETH_USDC_V2], tokenIn, tokenOut), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType, + route: [ + [ + mockV2PoolInRoute( + WETH_USDC_V2, + WETH, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v3 - handles weth input properly', async () => { + const [tokenIn, tokenOut] = [WETH, USDC] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = await V3Trade.fromRoute(new RouteV3([WETH_USDC_V3], WETH, USDC), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType, + route: [ + [ + mockV3PoolInRoute( + WETH_USDC_V3, + WETH, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v2 - handles eth input properly', async () => { + const [tokenIn, tokenOut] = [Ether.onChain(1), USDC] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = new V2Trade(new RouteV2([WETH_USDC_V2], Ether.onChain(1), USDC), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: ETH_ADDRESS, + tokenOut: USDC.address, + tradeType, + route: [ + [ + // WETH here since all pairs use WETH + mockV2PoolInRoute( + WETH_USDC_V2, + WETH, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v2 - handles eth input properly - 0xeeee...eeee address', async () => { + const [tokenIn, tokenOut] = [Ether.onChain(1), USDC] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = new V2Trade(new RouteV2([WETH_USDC_V2], Ether.onChain(1), USDC), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: E_ETH_ADDRESS, + tokenOut: USDC.address, + tradeType, + route: [ + [ + // WETH here since all pairs use WETH + mockV2PoolInRoute( + WETH_USDC_V2, + WETH, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v3 - handles eth input properly', async () => { + const [tokenIn, tokenOut] = [Ether.onChain(1), USDC] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = await V3Trade.fromRoute( + new RouteV3([WETH_USDC_V3], Ether.onChain(1), USDC), + rawInputAmount, + tradeType + ) + + const classicQuote: PartialClassicQuote = { + tokenIn: ETH_ADDRESS, + tokenOut: USDC.address, + tradeType, + route: [ + [ + // WETH here since all pools use WETH + mockV3PoolInRoute( + WETH_USDC_V3, + WETH, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v2 - handles eth output properly', async () => { + const [tokenIn, tokenOut] = [USDC, Ether.onChain(1)] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = new V2Trade(new RouteV2([WETH_USDC_V2], tokenIn, tokenOut), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: USDC.address, + tokenOut: ETH_ADDRESS, + tradeType, + route: [ + [ + // WETH here since all pairs use WETH + mockV2PoolInRoute( + WETH_USDC_V2, + USDC, + WETH, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v3 - handles eth output properly', async () => { + const [tokenIn, tokenOut] = [USDC, Ether.onChain(1)] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = await V3Trade.fromRoute(new RouteV3([WETH_USDC_V3], tokenIn, tokenOut), rawInputAmount, tradeType) + + const classicQuote: PartialClassicQuote = { + tokenIn: USDC.address, + tokenOut: ETH_ADDRESS, + tradeType, + route: [ + [ + // WETH here since all pairs use WETH + mockV3PoolInRoute( + WETH_USDC_V3, + USDC, + WETH, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + it('v3 - multi pool erc20 <> erc20', async () => { + const [tokenIn, tokenOut] = [DAI, WETH] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = await V3Trade.fromRoute( + new RouteV3([USDC_DAI_V3, WETH_USDC_V3], tokenIn, tokenOut), + rawInputAmount, + tradeType + ) + + const classicQuote: PartialClassicQuote = { + tokenIn: DAI.address, + tokenOut: USDC.address, + tradeType, + route: [ + [ + mockV3PoolInRoute( + USDC_DAI_V3, + DAI, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + mockV3PoolInRoute( + WETH_USDC_V3, + USDC, + WETH, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + + // Mixed routes are only supported for exact input + if (tradeType === TradeType.EXACT_INPUT) { + it('v2/v3 - mixed route erc20 <> erc20', async () => { + const [tokenIn, tokenOut] = [DAI, WETH] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade = await MixedRouteTrade.fromRoute( + new MixedRouteSDK([USDC_DAI_V3, WETH_USDC_V2], tokenIn, tokenOut), + rawInputAmount, + tradeType + ) + + const classicQuote: PartialClassicQuote = { + tokenIn: DAI.address, + tokenOut: USDC.address, + tradeType, + route: [ + [ + mockV3PoolInRoute( + USDC_DAI_V3, + DAI, + USDC, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + mockV2PoolInRoute( + WETH_USDC_V2, + USDC, + WETH, + trade.inputAmount.quotient.toString(), + trade.outputAmount.quotient.toString() + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade]), opts), new UniswapTrade(routerTrade, opts)) + }) + } + + it('v3 - handles split routes properly', async () => { + const [tokenIn, tokenOut] = [WETH, USDC] + const inputAmount = ethers.utils + .parseUnits('1', getAmountToken(tokenIn, tokenOut, tradeType).decimals) + .toString() + const rawInputAmount = getAmount(tokenIn, tokenOut, inputAmount, tradeType) + + const opts = swapOptions({}) + const trade1 = await V3Trade.fromRoute( + new RouteV3([WETH_USDC_V3], tokenIn, tokenOut), + rawInputAmount.divide(2), + tradeType + ) + const trade2 = await V3Trade.fromRoute( + new RouteV3([WETH_USDC_V3_LOW_FEE], tokenIn, tokenOut), + rawInputAmount.divide(2), + tradeType + ) + + const splitRouteInputAmounts = [trade1.inputAmount.quotient.toString(), trade2.inputAmount.quotient.toString()] + const splitRouteOutputAmounts = [ + trade1.outputAmount.quotient.toString(), + trade2.outputAmount.quotient.toString(), + ] + + const classicQuote: PartialClassicQuote = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType, + route: [ + [mockV3PoolInRoute(WETH_USDC_V3, WETH, USDC, splitRouteInputAmounts[0], splitRouteOutputAmounts[0])], + [ + mockV3PoolInRoute( + WETH_USDC_V3_LOW_FEE, + WETH, + USDC, + splitRouteInputAmounts[1], + splitRouteOutputAmounts[1] + ), + ], + ], + } + const routerTrade = RouterTradeAdapter.fromClassicQuote(classicQuote) + + compareUniswapTrades(new UniswapTrade(buildTrade([trade1, trade2]), opts), new UniswapTrade(routerTrade, opts)) + }) + }) + } + + describe('RouterTradeAdapter handles malformed classic quote', () => { + it('throws on missing route', async () => { + const classicQuote: any = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType: TradeType.EXACT_INPUT, + } + expect(() => RouterTradeAdapter.fromClassicQuote(classicQuote)).to.throw('Expected route to be present') + }) + it('throws on no route', async () => { + const classicQuote: any = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType: TradeType.EXACT_INPUT, + route: [], + } + expect(() => RouterTradeAdapter.fromClassicQuote(classicQuote)).to.throw( + 'Expected there to be at least one route' + ) + }) + it('throws on route with no pools', async () => { + const classicQuote: any = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType: TradeType.EXACT_INPUT, + route: [[]], + } + expect(() => RouterTradeAdapter.fromClassicQuote(classicQuote)).to.throw( + 'Expected all routes to have at least one pool' + ) + }) + it('throws on quote missing tokenIn/Out', async () => { + const classicQuote: any = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType: TradeType.EXACT_INPUT, + route: [ + [ + { + ...mockV2PoolInRoute(USDC_DAI_V2, DAI, USDC, '1000', '1000'), + tokenIn: undefined, + }, + ], + ], + } + expect(() => RouterTradeAdapter.fromClassicQuote(classicQuote)).to.throw( + 'Expected both tokenIn and tokenOut to be present' + ) + }) + it('throws on route with mismatched token chainIds', async () => { + const classicQuote: PartialClassicQuote = { + tokenIn: DAI.address, + tokenOut: USDC.address, + tradeType: TradeType.EXACT_INPUT, + route: [ + [ + { + ...mockV2PoolInRoute(USDC_DAI_V2, DAI, USDC, '1000', '1000'), + tokenIn: { + address: DAI.address, + // Different chainId + chainId: 2, + symbol: DAI.symbol!, + decimals: String(DAI.decimals), + }, + }, + ], + ], + } + expect(() => RouterTradeAdapter.fromClassicQuote(classicQuote)).to.throw( + 'Expected tokenIn and tokenOut to be have same chainId' + ) + }) + it('throws on route with missing amountIn/Out', async () => { + const classicQuote: any = { + tokenIn: WETH.address, + tokenOut: USDC.address, + tradeType: TradeType.EXACT_INPUT, + route: [ + [ + { + ...mockV2PoolInRoute(USDC_DAI_V2, DAI, USDC, '1000', '1000'), + amountIn: undefined, + }, + ], + ], + } + expect(() => RouterTradeAdapter.fromClassicQuote(classicQuote)).to.throw( + 'Expected both raw amountIn and raw amountOut to be present' + ) + }) + }) }) diff --git a/yarn.lock b/yarn.lock index 76a60478..6f534344 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2253,7 +2253,7 @@ resolved "https://registry.npmjs.org/@uniswap/v2-core/-/v2-core-1.0.1.tgz" integrity sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q== -"@uniswap/v2-sdk@^4.2.0", "@uniswap/v2-sdk@^4.3.0": +"@uniswap/v2-sdk@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@uniswap/v2-sdk/-/v2-sdk-4.3.0.tgz#621dcf4ea236448a0a49cd901932b3a466c8ae02" integrity sha512-FUKkgo/1TQc/HuWWgsoy1FIcsLkKwm3Nnor88yfn2NH8ER5RK/wDF9UzDDilYh3yyf2mAnaY89CKFhcIl+lbBQ== @@ -5728,9 +5728,9 @@ js-yaml@^3.13.1: esprima "^4.0.0" jsbi@^3.1.4: - version "3.2.5" - resolved "https://registry.npmjs.org/jsbi/-/jsbi-3.2.5.tgz" - integrity sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ== + version "3.1.4" + resolved "https://registry.yarnpkg.com/jsbi/-/jsbi-3.1.4.tgz#9654dd02207a66a4911b4e4bb74265bc2cbc9dd0" + integrity sha512-52QRRFSsi9impURE8ZUbzAMCLjPm4THO7H2fcuIvaaeFTbSysvkodbQQXIVsNgq/ypDbq6dJiuGKL0vZ/i9hUg== jsbn@~0.1.0: version "0.1.1"