From 2f6ea289cbbab0bafc0d80eb5a299d221e5d71dc Mon Sep 17 00:00:00 2001 From: Emily Williams Date: Thu, 11 Jul 2024 14:38:30 -0400 Subject: [PATCH] feat(v4-sdk): initial commit (#31) --- sdks/v4-sdk/.gitignore | 67 ++ sdks/v4-sdk/LICENSE | 21 + sdks/v4-sdk/package.json | 95 +++ sdks/v4-sdk/src/entities/index.ts | 3 + sdks/v4-sdk/src/entities/pool.test.ts | 395 +++++++++ sdks/v4-sdk/src/entities/pool.ts | 321 ++++++++ sdks/v4-sdk/src/entities/route.test.ts | 193 +++++ sdks/v4-sdk/src/entities/route.ts | 86 ++ sdks/v4-sdk/src/entities/trade.test.ts | 887 +++++++++++++++++++++ sdks/v4-sdk/src/entities/trade.ts | 637 +++++++++++++++ sdks/v4-sdk/src/index.ts | 1 + sdks/v4-sdk/src/utils/internalConstants.ts | 20 + sdks/v4-sdk/src/utils/sortsBefore.ts | 7 + sdks/v4-sdk/tsconfig.json | 24 + yarn.lock | 43 +- 15 files changed, 2799 insertions(+), 1 deletion(-) create mode 100644 sdks/v4-sdk/.gitignore create mode 100644 sdks/v4-sdk/LICENSE create mode 100644 sdks/v4-sdk/package.json create mode 100644 sdks/v4-sdk/src/entities/index.ts create mode 100644 sdks/v4-sdk/src/entities/pool.test.ts create mode 100644 sdks/v4-sdk/src/entities/pool.ts create mode 100644 sdks/v4-sdk/src/entities/route.test.ts create mode 100644 sdks/v4-sdk/src/entities/route.ts create mode 100644 sdks/v4-sdk/src/entities/trade.test.ts create mode 100644 sdks/v4-sdk/src/entities/trade.ts create mode 100644 sdks/v4-sdk/src/index.ts create mode 100644 sdks/v4-sdk/src/utils/internalConstants.ts create mode 100644 sdks/v4-sdk/src/utils/sortsBefore.ts create mode 100644 sdks/v4-sdk/tsconfig.json diff --git a/sdks/v4-sdk/.gitignore b/sdks/v4-sdk/.gitignore new file mode 100644 index 000000000..b4076ace0 --- /dev/null +++ b/sdks/v4-sdk/.gitignore @@ -0,0 +1,67 @@ +# build output +dist + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +.idea diff --git a/sdks/v4-sdk/LICENSE b/sdks/v4-sdk/LICENSE new file mode 100644 index 000000000..ac9cfc7c3 --- /dev/null +++ b/sdks/v4-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Uniswap Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/v4-sdk/package.json b/sdks/v4-sdk/package.json new file mode 100644 index 000000000..abcdb6a70 --- /dev/null +++ b/sdks/v4-sdk/package.json @@ -0,0 +1,95 @@ +{ + "name": "@uniswap/v4-sdk", + "description": "⚒️ An SDK for building applications on top of Uniswap V4", + "repository": "https://github.com/Uniswap/sdks.git", + "keywords": [ + "uniswap", + "ethereum" + ], + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "module": "dist/v4-sdk.esm.js", + "files": [ + "dist" + ], + "engines": { + "node": ">=14" + }, + "scripts": { + "build": "tsdx build", + "lint": "tsdx lint src", + "release": "semantic-release", + "start": "tsdx watch", + "test": "tsdx test" + }, + "dependencies": { + "@ethersproject/solidity": "^5.0.9", + "@uniswap/sdk-core": "^5.3.1", + "@uniswap/v3-sdk": "3.12.0", + "tiny-invariant": "^1.1.0", + "tiny-warning": "^1.0.3" + }, + "devDependencies": { + "@types/chai": "^4.3.3", + "@types/mocha": "^9.1.1", + "@types/node": "^18.7.16", + "@types/node-fetch": "^2.6.2", + "chai": "^4.3.6", + "dotenv": "^16.0.3", + "eslint-plugin-prettier": "^3.4.1", + "prettier": "^2.4.1", + "ts-node": "^10.9.1", + "tsdx": "^0.14.1", + "tslib": "^2.3.0", + "typedoc": "^0.21.2", + "typescript": "^4.3.3" + }, + "resolutions": { + "regenerator-runtime": "^0.14.1" + }, + "resolutionsComments": { + "regenerator-runtime": "Fixes https://github.com/facebook/regenerator/pull/480. It can be removed when `tsdx` updates their dependencies." + }, + "prettier": { + "printWidth": 120, + "semi": false, + "singleQuote": true + }, + "publishConfig": { + "access": "public" + }, + "release": { + "extends": "semantic-release-monorepo", + "branches": [ + { + "name": "main", + "prerelease": false + }, + { + "name": "beta", + "prerelease": true + } + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": "../../publishing/release-rules.cjs" + } + ], + "@semantic-release/release-notes-generator", + "@semantic-release/npm", + "@semantic-release/github", + [ + "@semantic-release/exec", + { + "successCmd": "git restore yarn.lock && yarn", + "failCmd": "git restore yarn.lock && yarn", + "execCwd": "../.." + } + ] + ] + } +} diff --git a/sdks/v4-sdk/src/entities/index.ts b/sdks/v4-sdk/src/entities/index.ts new file mode 100644 index 000000000..c317a9c69 --- /dev/null +++ b/sdks/v4-sdk/src/entities/index.ts @@ -0,0 +1,3 @@ +export * from './pool' +export * from './route' +export * from './trade' diff --git a/sdks/v4-sdk/src/entities/pool.test.ts b/sdks/v4-sdk/src/entities/pool.test.ts new file mode 100644 index 000000000..850a2a1b6 --- /dev/null +++ b/sdks/v4-sdk/src/entities/pool.test.ts @@ -0,0 +1,395 @@ +import { Token, CurrencyAmount, WETH9 } from '@uniswap/sdk-core' +import { Pool, DYNAMIC_FEE_FLAG } from './pool' +import JSBI from 'jsbi' +import { nearestUsableTick, encodeSqrtRatioX96, TickMath } from '@uniswap/v3-sdk' +import { + ADDRESS_ZERO, + FEE_AMOUNT_LOW, + FEE_AMOUNT_MEDIUM, + FEE_AMOUNT_HIGHEST, + NEGATIVE_ONE, + ONE_ETHER, + TICK_SPACING_TEN, +} from '../utils/internalConstants' + +describe('Pool', () => { + const USDC = new Token(1, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 6, 'USDC', 'USD Coin') + const DAI = new Token(1, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI', 'DAI Stablecoin') + + describe('constructor', () => { + it('cannot be used for currencies on different chains', () => { + expect(() => { + new Pool(USDC, WETH9[3], FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }).toThrow('CHAIN_IDS') + }) + + it('fee must be integer', () => { + expect(() => { + new Pool( + USDC, + WETH9[1], + FEE_AMOUNT_MEDIUM + 0.5, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 1), + 0, + 0, + [] + ) + }).toThrow('FEE') + }) + + it('fee cannot be more than 1e6', () => { + expect(() => { + new Pool(USDC, WETH9[1], 1e6, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }).toThrow('FEE') + }) + + it('fee can be dynamic', () => { + const pool = new Pool( + USDC, + WETH9[1], + DYNAMIC_FEE_FLAG, + TICK_SPACING_TEN, + '0xfff0000000000000000000000000000000000000', + encodeSqrtRatioX96(1, 1), + 0, + 0, + [] + ) + expect(pool.fee).toEqual(DYNAMIC_FEE_FLAG) + }) + + it('dynamic fee pool requires hook', () => { + expect(() => { + new Pool(USDC, WETH9[1], DYNAMIC_FEE_FLAG, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }).toThrow('Dynamic fee pool requires a hook') + }) + + it('cannot give invalid address for hook', () => { + expect(() => { + new Pool(USDC, WETH9[1], FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN, '0x123', encodeSqrtRatioX96(1, 1), 0, 0, []) + }).toThrow('Invalid hook address') + }) + + it('cannot be given two of the same currency', () => { + expect(() => { + new Pool(USDC, USDC, FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }).toThrow('ADDRESSES') + }) + + it('price must be within tick price bounds', () => { + expect(() => { + new Pool(USDC, WETH9[1], FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 1, []) + }).toThrow('PRICE_BOUNDS') + expect(() => { + new Pool( + USDC, + WETH9[1], + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + JSBI.add(encodeSqrtRatioX96(1, 1), JSBI.BigInt(1)), + 0, + -1, + [] + ) + }).toThrow('PRICE_BOUNDS') + }) + + it('works with valid arguments for empty pool medium fee', () => { + new Pool(USDC, WETH9[1], FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }) + + it('works with valid arguments for empty pool lowest fee', () => { + new Pool(USDC, WETH9[1], 1, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }) + + it('works with valid arguments for empty pool highest fee', () => { + new Pool(USDC, WETH9[1], FEE_AMOUNT_HIGHEST, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + }) + }) + + describe('#getPoolId', () => { + it('returns the correct poolId', () => { + const result1 = Pool.getPoolId(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO) + expect(result1).toEqual('0x503fb8d73fd2351c645ae9fea85381bac6b16ea0c2038e14dc1e96d447c8ffbb') + + const result2 = Pool.getPoolId(DAI, USDC, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO) + expect(result2).toEqual(result1) + }) + }) + + describe('#getPoolKey', () => { + it('matches an example', () => { + const result1 = Pool.getPoolKey(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO) + expect(result1).toEqual({ + currency0: DAI.address, + currency1: USDC.address, + fee: FEE_AMOUNT_LOW, + tickSpacing: TICK_SPACING_TEN, + hooks: ADDRESS_ZERO, + }) + + const result2 = Pool.getPoolKey(DAI, USDC, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO) + expect(result2).toEqual(result1) + }) + }) + + describe('#currency0', () => { + it('always is the currency that sorts before', () => { + let pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.currency0).toEqual(DAI) + pool = new Pool(DAI, USDC, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.currency0).toEqual(DAI) + }) + }) + describe('#currency1', () => { + it('always is the currency that sorts after', () => { + let pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.currency1).toEqual(USDC) + pool = new Pool(DAI, USDC, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.currency1).toEqual(USDC) + }) + }) + + describe('#poolId', () => { + let pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.poolId).toEqual('0x503fb8d73fd2351c645ae9fea85381bac6b16ea0c2038e14dc1e96d447c8ffbb') + }) + + describe('#poolKey', () => { + let pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.poolKey).toEqual({ + currency0: DAI.address, + currency1: USDC.address, + fee: FEE_AMOUNT_LOW, + tickSpacing: TICK_SPACING_TEN, + hooks: ADDRESS_ZERO, + }) + }) + + describe('#currency0Price', () => { + it('returns price of currency0 in terms of currency1', () => { + expect( + new Pool( + USDC, + DAI, + FEE_AMOUNT_LOW, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(101e6, 100e18), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(101e6, 100e18)), + [] + ).currency0Price.toSignificant(5) + ).toEqual('1.01') + expect( + new Pool( + DAI, + USDC, + FEE_AMOUNT_LOW, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(101e6, 100e18), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(101e6, 100e18)), + [] + ).currency0Price.toSignificant(5) + ).toEqual('1.01') + }) + }) + + describe('#currency1Price', () => { + it('returns price of currency1 in terms of currency0', () => { + expect( + new Pool( + USDC, + DAI, + FEE_AMOUNT_LOW, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(101e6, 100e18), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(101e6, 100e18)), + [] + ).currency1Price.toSignificant(5) + ).toEqual('0.9901') + expect( + new Pool( + DAI, + USDC, + FEE_AMOUNT_LOW, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(101e6, 100e18), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(101e6, 100e18)), + [] + ).currency1Price.toSignificant(5) + ).toEqual('0.9901') + }) + }) + + describe('#priceOf', () => { + const pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + it('returns price of currency in terms of other currency', () => { + expect(pool.priceOf(DAI)).toEqual(pool.currency0Price) + expect(pool.priceOf(USDC)).toEqual(pool.currency1Price) + }) + + it('throws if invalid currency', () => { + expect(() => pool.priceOf(WETH9[1])).toThrow('CURRENCY') + }) + }) + + describe('#chainId', () => { + it('returns the currency0 chainId', () => { + let pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.chainId).toEqual(1) + pool = new Pool(DAI, USDC, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.chainId).toEqual(1) + }) + }) + + describe('#involvesCurrency', () => { + const pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + expect(pool.involvesCurrency(USDC)).toEqual(true) + expect(pool.involvesCurrency(DAI)).toEqual(true) + expect(pool.involvesCurrency(WETH9[1])).toEqual(false) + }) + + describe('swaps', () => { + let pool: Pool + + beforeEach(() => { + pool = new Pool( + USDC, + DAI, + FEE_AMOUNT_LOW, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 1), + ONE_ETHER, + 0, + [ + { + index: nearestUsableTick(TickMath.MIN_TICK, TICK_SPACING_TEN), + liquidityNet: ONE_ETHER, + liquidityGross: ONE_ETHER, + }, + { + index: nearestUsableTick(TickMath.MAX_TICK, TICK_SPACING_TEN), + liquidityNet: JSBI.multiply(ONE_ETHER, NEGATIVE_ONE), + liquidityGross: ONE_ETHER, + }, + ] + ) + }) + + describe('#getOutputAmount', () => { + it('USDC -> DAI', async () => { + const inputAmount = CurrencyAmount.fromRawAmount(USDC, 100) + const [outputAmount] = await pool.getOutputAmount(inputAmount) + expect(outputAmount.currency.equals(DAI)).toBe(true) + expect(outputAmount.quotient).toEqual(JSBI.BigInt(98)) + }) + + it('DAI -> USDC', async () => { + const inputAmount = CurrencyAmount.fromRawAmount(DAI, 100) + const [outputAmount] = await pool.getOutputAmount(inputAmount) + expect(outputAmount.currency.equals(USDC)).toBe(true) + expect(outputAmount.quotient).toEqual(JSBI.BigInt(98)) + }) + }) + + describe('#getInputAmount', () => { + it('USDC -> DAI', async () => { + const outputAmount = CurrencyAmount.fromRawAmount(DAI, 98) + const [inputAmount] = await pool.getInputAmount(outputAmount) + expect(inputAmount.currency.equals(USDC)).toBe(true) + expect(inputAmount.quotient).toEqual(JSBI.BigInt(100)) + }) + + it('DAI -> USDC', async () => { + const outputAmount = CurrencyAmount.fromRawAmount(USDC, 98) + const [inputAmount] = await pool.getInputAmount(outputAmount) + expect(inputAmount.currency.equals(DAI)).toBe(true) + expect(inputAmount.quotient).toEqual(JSBI.BigInt(100)) + }) + }) + }) + + describe('#bigNums', () => { + let pool: Pool + const bigNum1 = JSBI.add(JSBI.BigInt(Number.MAX_SAFE_INTEGER), JSBI.BigInt(1)) + const bigNum2 = JSBI.add(JSBI.BigInt(Number.MAX_SAFE_INTEGER), JSBI.BigInt(1)) + beforeEach(() => { + pool = new Pool( + USDC, + DAI, + FEE_AMOUNT_LOW, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(bigNum1, bigNum2), + ONE_ETHER, + 0, + [ + { + index: nearestUsableTick(TickMath.MIN_TICK, TICK_SPACING_TEN), + liquidityNet: ONE_ETHER, + liquidityGross: ONE_ETHER, + }, + { + index: nearestUsableTick(TickMath.MAX_TICK, TICK_SPACING_TEN), + liquidityNet: JSBI.multiply(ONE_ETHER, NEGATIVE_ONE), + liquidityGross: ONE_ETHER, + }, + ] + ) + }) + + describe('#priceLimit', () => { + it('correctly compares two BigIntegers', async () => { + expect(bigNum1).toEqual(bigNum2) + }) + it('correctly handles two BigIntegers', async () => { + const inputAmount = CurrencyAmount.fromRawAmount(USDC, 100) + const [outputAmount] = await pool.getOutputAmount(inputAmount) + pool.getInputAmount(outputAmount) + expect(outputAmount.currency.equals(DAI)).toBe(true) + // if output is correct, function has succeeded + }) + }) + }) + + describe('backwards compatibility', () => { + let pool = new Pool(USDC, DAI, FEE_AMOUNT_LOW, TICK_SPACING_TEN, ADDRESS_ZERO, encodeSqrtRatioX96(1, 1), 0, 0, []) + + describe('#token0', () => { + it('equals currency0', () => { + expect(pool.currency0).toEqual(pool.token0) + }) + }) + describe('#token1', () => { + it('equals currency1', () => { + expect(pool.currency1).toEqual(pool.token1) + }) + }) + describe('#token0Price', () => { + it('equals currency0Price', () => { + expect(pool.currency0Price).toEqual(pool.token0Price) + }) + }) + describe('#token1Price', () => { + it('equals currency1Price', () => { + expect(pool.currency1Price).toEqual(pool.token1Price) + }) + }) + describe('#involvesToken', () => { + it('equals involvesCurrency', () => { + expect(pool.involvesCurrency(USDC)).toEqual(pool.involvesToken(USDC)) + }) + }) + }) +}) diff --git a/sdks/v4-sdk/src/entities/pool.ts b/sdks/v4-sdk/src/entities/pool.ts new file mode 100644 index 000000000..95f31f54c --- /dev/null +++ b/sdks/v4-sdk/src/entities/pool.ts @@ -0,0 +1,321 @@ +import invariant from 'tiny-invariant' +import { keccak256 } from '@ethersproject/solidity' +import { BigintIsh, Currency, CurrencyAmount, Price } from '@uniswap/sdk-core' +import { + v3Swap, + NoTickDataProvider, + Tick, + TickConstructorArgs, + TickDataProvider, + TickListDataProvider, + TickMath, +} from '@uniswap/v3-sdk' +import { defaultAbiCoder, isAddress } from 'ethers/lib/utils' +import { sortsBefore } from '../utils/sortsBefore' +import { ADDRESS_ZERO, NEGATIVE_ONE, Q192 } from '../utils/internalConstants' +import JSBI from 'jsbi' + +export const DYNAMIC_FEE_FLAG = 0x800000 +const NO_TICK_DATA_PROVIDER_DEFAULT = new NoTickDataProvider() + +export type PoolKey = { + currency0: string + currency1: string + fee: number + tickSpacing: number + hooks: string +} + +/** + * Represents a V4 pool + */ +export class Pool { + public readonly currency0: Currency + public readonly currency1: Currency + public readonly fee: number + public readonly tickSpacing: number + public readonly sqrtRatioX96: JSBI + public readonly hooks: string // address + public readonly liquidity: JSBI + public readonly tickCurrent: number + public readonly tickDataProvider: TickDataProvider + public readonly poolKey: PoolKey + public readonly poolId: string + + private _currency0Price?: Price + private _currency1Price?: Price + + public static getPoolKey( + currencyA: Currency, + currencyB: Currency, + fee: number, + tickSpacing: number, + hooks: string + ): PoolKey { + invariant(isAddress(hooks), 'Invalid hook address') + + const [currency0, currency1] = sortsBefore(currencyA, currencyB) ? [currencyA, currencyB] : [currencyB, currencyA] + const currency0Addr = currency0.isNative ? ADDRESS_ZERO : currency0.wrapped.address + const currency1Addr = currency1.isNative ? ADDRESS_ZERO : currency1.wrapped.address + + return { + currency0: currency0Addr, + currency1: currency1Addr, + fee, + tickSpacing, + hooks, + } + } + + public static getPoolId( + currencyA: Currency, + currencyB: Currency, + fee: number, + tickSpacing: number, + hooks: string + ): string { + const [currency0, currency1] = sortsBefore(currencyA, currencyB) ? [currencyA, currencyB] : [currencyB, currencyA] + const currency0Addr = currency0.isNative ? ADDRESS_ZERO : currency0.wrapped.address + const currency1Addr = currency1.isNative ? ADDRESS_ZERO : currency1.wrapped.address + return keccak256( + ['bytes'], + [ + defaultAbiCoder.encode( + ['address', 'address', 'uint24', 'int24', 'address'], + [currency0Addr, currency1Addr, fee, tickSpacing, hooks] + ), + ] + ) + } + + /** + * Construct a pool + * @param currencyA One of the currencys in the pool + * @param currencyB The other currency in the pool + * @param fee The fee in hundredths of a bips of the input amount of every swap that is collected by the pool + * @param sqrtRatioX96 The sqrt of the current ratio of amounts of currency1 to currency0 + * @param liquidity The current value of in range liquidity + * @param tickCurrent The current tick of the pool + */ + public constructor( + currencyA: Currency, + currencyB: Currency, + fee: number, + tickSpacing: number, + hooks: string, + sqrtRatioX96: BigintIsh, + liquidity: BigintIsh, + tickCurrent: number, + ticks: TickDataProvider | (Tick | TickConstructorArgs)[] = NO_TICK_DATA_PROVIDER_DEFAULT + ) { + invariant(isAddress(hooks), 'Invalid hook address') + invariant(Number.isInteger(fee) && (fee == DYNAMIC_FEE_FLAG || fee < 1_000_000), 'FEE') + if (fee == DYNAMIC_FEE_FLAG) { + invariant(Number(hooks) > 0, 'Dynamic fee pool requires a hook') + } + const tickCurrentSqrtRatioX96 = TickMath.getSqrtRatioAtTick(tickCurrent) + const nextTickSqrtRatioX96 = TickMath.getSqrtRatioAtTick(tickCurrent + 1) + invariant( + JSBI.greaterThanOrEqual(JSBI.BigInt(sqrtRatioX96), tickCurrentSqrtRatioX96) && + JSBI.lessThanOrEqual(JSBI.BigInt(sqrtRatioX96), nextTickSqrtRatioX96), + 'PRICE_BOUNDS' + ) + + // always create a copy of the list since we want the pool's tick list to be immutable + ;[this.currency0, this.currency1] = sortsBefore(currencyA, currencyB) + ? [currencyA, currencyB] + : [currencyB, currencyA] + this.fee = fee + this.sqrtRatioX96 = JSBI.BigInt(sqrtRatioX96) + this.tickSpacing = tickSpacing + this.hooks = hooks + this.liquidity = JSBI.BigInt(liquidity) + this.tickCurrent = tickCurrent + this.tickDataProvider = Array.isArray(ticks) ? new TickListDataProvider(ticks, tickSpacing) : ticks + this.poolKey = Pool.getPoolKey(this.currency0, this.currency1, this.fee, this.tickSpacing, this.hooks) + this.poolId = Pool.getPoolId(this.currency0, this.currency1, this.fee, this.tickSpacing, this.hooks) + } + + /** backwards compatibility with v2/3 sdks */ + public get token0(): Currency { + return this.currency0 + } + public get token1(): Currency { + return this.currency1 + } + + /** + * Returns true if the currency is either currency0 or currency1 + * @param currency The currency to check + * @returns True if currency is either currency0 or currency1 + */ + public involvesCurrency(currency: Currency): boolean { + return currency.equals(this.currency0) || currency.equals(this.currency1) + } + /** backwards compatibility with v2/3 sdks */ + public involvesToken(currency: Currency): boolean { + return this.involvesCurrency(currency) + } + + /** + * Returns the current mid price of the pool in terms of currency0, i.e. the ratio of currency1 over currency0 + */ + public get currency0Price(): Price { + return ( + this._currency0Price ?? + (this._currency0Price = new Price( + this.currency0, + this.currency1, + Q192, + JSBI.multiply(this.sqrtRatioX96, this.sqrtRatioX96) + )) + ) + } + /** backwards compatibility with v2/3 sdks */ + public get token0Price(): Price { + return this.currency0Price + } + + /** + * Returns the current mid price of the pool in terms of currency1, i.e. the ratio of currency0 over currency1 + */ + public get currency1Price(): Price { + return ( + this._currency1Price ?? + (this._currency1Price = new Price( + this.currency1, + this.currency0, + JSBI.multiply(this.sqrtRatioX96, this.sqrtRatioX96), + Q192 + )) + ) + } + /** backwards compatibility with v2/3 sdks */ + public get token1Price(): Price { + return this.currency1Price + } + + /** + * Return the price of the given currency in terms of the other currency in the pool. + * @param currency The currency to return price of + * @returns The price of the given currency, in terms of the other. + */ + public priceOf(currency: Currency): Price { + invariant(this.involvesCurrency(currency), 'CURRENCY') + return currency.equals(this.currency0) ? this.currency0Price : this.currency1Price + } + + /** + * Returns the chain ID of the currencies in the pool. + */ + public get chainId(): number { + return this.currency0.chainId + } + + /** Works only for vanilla hookless v3 pools, otherwise throws an error */ + public async getOutputAmount( + inputAmount: CurrencyAmount, + sqrtPriceLimitX96?: JSBI + ): Promise<[CurrencyAmount, Pool]> { + invariant(this.involvesCurrency(inputAmount.currency), 'CURRENCY') + + const zeroForOne = inputAmount.currency.equals(this.currency0) + + const { + amountCalculated: outputAmount, + sqrtRatioX96, + liquidity, + tickCurrent, + } = await this.swap(zeroForOne, inputAmount.quotient, sqrtPriceLimitX96) + const outputCurrency = zeroForOne ? this.currency1 : this.currency0 + return [ + CurrencyAmount.fromRawAmount(outputCurrency, JSBI.multiply(outputAmount, NEGATIVE_ONE)), + new Pool( + this.currency0, + this.currency1, + this.fee, + this.tickSpacing, + this.hooks, + sqrtRatioX96, + liquidity, + tickCurrent, + this.tickDataProvider + ), + ] + } + + /** + * Given a desired output amount of a currency, return the computed input amount and a pool with state updated after the trade + * Works only for vanilla hookless v3 pools, otherwise throws an error + * @param outputAmount the output amount for which to quote the input amount + * @param sqrtPriceLimitX96 The Q64.96 sqrt price limit. If zero for one, the price cannot be less than this value after the swap. If one for zero, the price cannot be greater than this value after the swap + * @returns The input amount and the pool with updated state + */ + public async getInputAmount( + outputAmount: CurrencyAmount, + sqrtPriceLimitX96?: JSBI + ): Promise<[CurrencyAmount, Pool]> { + invariant(this.involvesCurrency(outputAmount.currency), 'CURRENCY') + + const zeroForOne = outputAmount.currency.equals(this.currency1) + + const { + amountCalculated: inputAmount, + sqrtRatioX96, + liquidity, + tickCurrent, + } = await this.swap(zeroForOne, JSBI.multiply(outputAmount.quotient, NEGATIVE_ONE), sqrtPriceLimitX96) + const inputCurrency = zeroForOne ? this.currency0 : this.currency1 + return [ + CurrencyAmount.fromRawAmount(inputCurrency, inputAmount), + new Pool( + this.currency0, + this.currency1, + this.fee, + this.tickSpacing, + this.hooks, + sqrtRatioX96, + liquidity, + tickCurrent, + this.tickDataProvider + ), + ] + } + + /** + * Executes a swap + * @param zeroForOne Whether the amount in is token0 or token1 + * @param amountSpecified The amount of the swap, which implicitly configures the swap as exact input (positive), or exact output (negative) + * @param sqrtPriceLimitX96 The Q64.96 sqrt price limit. If zero for one, the price cannot be less than this value after the swap. If one for zero, the price cannot be greater than this value after the swap + * @returns amountCalculated + * @returns sqrtRatioX96 + * @returns liquidity + * @returns tickCurrent + */ + private async swap( + zeroForOne: boolean, + amountSpecified: JSBI, + sqrtPriceLimitX96?: JSBI + ): Promise<{ amountCalculated: JSBI; sqrtRatioX96: JSBI; liquidity: JSBI; tickCurrent: number }> { + if (this.nonImpactfulHook()) { + return v3Swap( + JSBI.BigInt(this.fee), + this.sqrtRatioX96, + this.tickCurrent, + this.liquidity, + this.tickSpacing, + this.tickDataProvider, + zeroForOne, + amountSpecified, + sqrtPriceLimitX96 + ) + } else { + throw 'Error: Unsupported hook' + } + } + + private nonImpactfulHook(): boolean { + // TODO: reference chain specific hook addresses or patterns that do not impact swaps + return this.hooks === ADDRESS_ZERO + } +} diff --git a/sdks/v4-sdk/src/entities/route.test.ts b/sdks/v4-sdk/src/entities/route.test.ts new file mode 100644 index 000000000..d897f8c4d --- /dev/null +++ b/sdks/v4-sdk/src/entities/route.test.ts @@ -0,0 +1,193 @@ +import { Ether, Token } from '@uniswap/sdk-core' +import { encodeSqrtRatioX96, TickMath } from '@uniswap/v3-sdk' +import { Pool } from './pool' +import { Route } from './route' +import { ADDRESS_ZERO, FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN } from '../utils/internalConstants' + +describe('Route', () => { + const eth = Ether.onChain(1) + const currency0 = new Token(1, '0x0000000000000000000000000000000000000001', 18, 't0') + const currency1 = new Token(1, '0x0000000000000000000000000000000000000002', 18, 't1') + const currency2 = new Token(1, '0x0000000000000000000000000000000000000003', 18, 't2') + // const weth = WETH9[1] + + const pool_0_1 = new Pool( + currency0, + currency1, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 1), + 0, + 0, + [] + ) + const pool_0_eth = new Pool( + currency0, + eth, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 1), + 0, + 0, + [] + ) + const pool_1_eth = new Pool( + currency1, + eth, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 1), + 0, + 0, + [] + ) + + describe('path', () => { + it('constructs a path from the currencies', () => { + const route = new Route([pool_0_1], currency0, currency1) + expect(route.pools).toEqual([pool_0_1]) + expect(route.currencyPath).toEqual([currency0, currency1]) + expect(route.input).toEqual(currency0) + expect(route.output).toEqual(currency1) + expect(route.chainId).toEqual(1) + }) + it('should fail if the input is not in the first pool', () => { + expect(() => new Route([pool_0_1], eth, currency1)).toThrow() + }) + it('should fail if output is not in the last pool', () => { + expect(() => new Route([pool_0_1], currency0, eth)).toThrow() + }) + }) + + it('can have a currency as both input and output', () => { + const route = new Route([pool_0_eth, pool_0_1, pool_1_eth], eth, eth) + expect(route.pools).toEqual([pool_0_eth, pool_0_1, pool_1_eth]) + expect(route.input).toEqual(eth) + expect(route.output).toEqual(eth) + }) + + it('supports ether input', () => { + const route = new Route([pool_0_eth], eth, currency0) + expect(route.pools).toEqual([pool_0_eth]) + expect(route.input).toEqual(eth) + expect(route.output).toEqual(currency0) + }) + + it('supports ether output', () => { + const route = new Route([pool_0_eth], currency0, eth) + expect(route.pools).toEqual([pool_0_eth]) + expect(route.input).toEqual(currency0) + expect(route.output).toEqual(eth) + }) + + describe('#midPrice', () => { + const pool_0_1 = new Pool( + currency0, + currency1, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 5), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(1, 5)), + [] + ) + const pool_1_2 = new Pool( + currency1, + currency2, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(15, 30), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(15, 30)), + [] + ) + const pool_0_eth = new Pool( + currency0, + eth, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(3, 1), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(3, 1)), + [] + ) + const pool_1_eth = new Pool( + currency1, + eth, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 7), + 0, + TickMath.getTickAtSqrtRatio(encodeSqrtRatioX96(1, 7)), + [] + ) + + it('correct for 0 -> 1', () => { + const price = new Route([pool_0_1], currency0, currency1).midPrice + expect(price.toFixed(4)).toEqual('0.2000') + expect(price.baseCurrency.equals(currency0)).toEqual(true) + expect(price.quoteCurrency.equals(currency1)).toEqual(true) + }) + + it('is cached', () => { + const route = new Route([pool_0_1], currency0, currency1) + expect(route.midPrice).toStrictEqual(route.midPrice) + }) + + it('correct for 1 -> 0', () => { + const price = new Route([pool_0_1], currency1, currency0).midPrice + expect(price.toFixed(4)).toEqual('5.0000') + expect(price.baseCurrency.equals(currency1)).toEqual(true) + expect(price.quoteCurrency.equals(currency0)).toEqual(true) + }) + + it('correct for 0 -> 1 -> 2', () => { + const price = new Route([pool_0_1, pool_1_2], currency0, currency2).midPrice + expect(price.toFixed(4)).toEqual('0.1000') + expect(price.baseCurrency.equals(currency0)).toEqual(true) + expect(price.quoteCurrency.equals(currency2)).toEqual(true) + }) + + it('correct for 2 -> 1 -> 0', () => { + const price = new Route([pool_1_2, pool_0_1], currency2, currency0).midPrice + expect(price.toFixed(4)).toEqual('10.0000') + expect(price.baseCurrency.equals(currency2)).toEqual(true) + expect(price.quoteCurrency.equals(currency0)).toEqual(true) + }) + + it('correct for ether -> 0', () => { + const price = new Route([pool_0_eth], eth, currency0).midPrice + expect(price.toFixed(4)).toEqual('3.0000') + expect(price.baseCurrency.equals(eth)).toEqual(true) + expect(price.quoteCurrency.equals(currency0)).toEqual(true) + }) + + it('correct for 1 -> eth', () => { + const price = new Route([pool_1_eth], currency1, eth).midPrice + expect(price.toFixed(4)).toEqual('7.0000') + expect(price.baseCurrency.equals(currency1)).toEqual(true) + expect(price.quoteCurrency.equals(eth)).toEqual(true) + }) + + it('correct for ether -> 0 -> 1 -> eth', () => { + const price = new Route([pool_0_eth, pool_0_1, pool_1_eth], eth, eth).midPrice + expect(price.toSignificant(4)).toEqual('4.2') + expect(price.baseCurrency.equals(eth)).toEqual(true) + expect(price.quoteCurrency.equals(eth)).toEqual(true) + }) + + it('correct for eth -> 0 -> 1 -> ether', () => { + const price = new Route([pool_0_eth, pool_0_1, pool_1_eth], eth, eth).midPrice + expect(price.toSignificant(4)).toEqual('4.2') + expect(price.baseCurrency.equals(eth)).toEqual(true) + expect(price.quoteCurrency.equals(eth)).toEqual(true) + }) + }) +}) diff --git a/sdks/v4-sdk/src/entities/route.ts b/sdks/v4-sdk/src/entities/route.ts new file mode 100644 index 000000000..2cc99bac9 --- /dev/null +++ b/sdks/v4-sdk/src/entities/route.ts @@ -0,0 +1,86 @@ +import invariant from 'tiny-invariant' + +import { Currency, Price } from '@uniswap/sdk-core' +import { Pool } from './pool' + +/** + * Represents a list of pools through which a swap can occur + * @template TInput The input currency + * @template TOutput The output currency + */ +export class Route { + public readonly pools: Pool[] + public readonly currencyPath: Currency[] + public readonly input: TInput + public readonly output: TOutput + + private _midPrice: Price | null = null + + /** + * Creates an instance of route. + * @param pools An array of `Pool` objects, ordered by the route the swap will take + * @param input The input currency + * @param output The output currency + */ + public constructor(pools: Pool[], input: TInput, output: TOutput) { + invariant(pools.length > 0, 'POOLS') + + const chainId = pools[0].chainId + const allOnSameChain = pools.every((pool) => pool.chainId === chainId) + invariant(allOnSameChain, 'CHAIN_IDS') + invariant(pools[0].involvesCurrency(input), 'INPUT') + invariant(pools[pools.length - 1].involvesCurrency(output), 'OUTPUT') + + /** + * Normalizes currency0-currency1 order and selects the next currency/fee step to add to the path + * */ + const currencyPath: Currency[] = [input] + for (const [i, pool] of pools.entries()) { + const currentInputCurrency = currencyPath[i] + invariant(currentInputCurrency.equals(pool.currency0) || currentInputCurrency.equals(pool.currency1), 'PATH') + const nextCurrency = currentInputCurrency.equals(pool.currency0) ? pool.currency1 : pool.currency0 + currencyPath.push(nextCurrency) + } + + this.pools = pools + this.currencyPath = currencyPath + this.input = input + this.output = output ?? currencyPath[currencyPath.length - 1] + } + + public get chainId(): number { + return this.pools[0].chainId + } + + /** + * Returns the mid price of the route + */ + public get midPrice(): Price { + if (this._midPrice !== null) return this._midPrice + + const price = this.pools.slice(1).reduce( + ({ nextInput, price }, pool) => { + return nextInput.equals(pool.currency0) + ? { + nextInput: pool.currency1, + price: price.multiply(pool.currency0Price), + } + : { + nextInput: pool.currency0, + price: price.multiply(pool.currency1Price), + } + }, + this.pools[0].currency0.equals(this.input) + ? { + nextInput: this.pools[0].currency1, + price: this.pools[0].currency0Price, + } + : { + nextInput: this.pools[0].currency0, + price: this.pools[0].currency1Price, + } + ).price + + return (this._midPrice = new Price(this.input, this.output, price.denominator, price.numerator)) + } +} diff --git a/sdks/v4-sdk/src/entities/trade.test.ts b/sdks/v4-sdk/src/entities/trade.test.ts new file mode 100644 index 000000000..b2bc04f0c --- /dev/null +++ b/sdks/v4-sdk/src/entities/trade.test.ts @@ -0,0 +1,887 @@ +import { Currency, CurrencyAmount, Ether, Percent, Price, sqrt, Token, TradeType } from '@uniswap/sdk-core' +import { ADDRESS_ZERO, FEE_AMOUNT_MEDIUM, TICK_SPACING_SIXTY } from '../utils/internalConstants' +import JSBI from 'jsbi' +import { nearestUsableTick, encodeSqrtRatioX96, TickMath } from '@uniswap/v3-sdk' +import { Pool } from './pool' +import { Route } from './route' +import { Trade } from './trade' + +describe('Trade', () => { + const ETHER = Ether.onChain(1) + + const token0 = new Token(1, '0x0000000000000000000000000000000000000001', 18, 't0', 'token0') + const token1 = new Token(1, '0x0000000000000000000000000000000000000002', 18, 't1', 'token1') + const token2 = new Token(1, '0x0000000000000000000000000000000000000003', 18, 't2', 'token2') + const token3 = new Token(1, '0x0000000000000000000000000000000000000004', 18, 't3', 'token3') + + function v2StylePool( + reserve0: CurrencyAmount, + reserve1: CurrencyAmount, + feeAmount: number = FEE_AMOUNT_MEDIUM + ) { + const sqrtRatioX96 = encodeSqrtRatioX96(reserve1.quotient, reserve0.quotient) + const liquidity = sqrt(JSBI.multiply(reserve0.quotient, reserve1.quotient)) + return new Pool( + reserve0.currency, + reserve1.currency, + feeAmount, + TICK_SPACING_SIXTY, + ADDRESS_ZERO, + sqrtRatioX96, + liquidity, + TickMath.getTickAtSqrtRatio(sqrtRatioX96), + [ + { + index: nearestUsableTick(TickMath.MIN_TICK, TICK_SPACING_SIXTY), + liquidityNet: liquidity, + liquidityGross: liquidity, + }, + { + index: nearestUsableTick(TickMath.MAX_TICK, TICK_SPACING_SIXTY), + liquidityNet: JSBI.multiply(liquidity, JSBI.BigInt(-1)), + liquidityGross: liquidity, + }, + ] + ) + } + + const pool_0_1 = v2StylePool( + CurrencyAmount.fromRawAmount(token0, 100000), + CurrencyAmount.fromRawAmount(token1, 100000) + ) + const pool_0_2 = v2StylePool( + CurrencyAmount.fromRawAmount(token0, 100000), + CurrencyAmount.fromRawAmount(token2, 110000) + ) + const pool_0_3 = v2StylePool( + CurrencyAmount.fromRawAmount(token0, 100000), + CurrencyAmount.fromRawAmount(token3, 90000) + ) + const pool_1_2 = v2StylePool( + CurrencyAmount.fromRawAmount(token1, 120000), + CurrencyAmount.fromRawAmount(token2, 100000) + ) + const pool_1_3 = v2StylePool( + CurrencyAmount.fromRawAmount(token1, 120000), + CurrencyAmount.fromRawAmount(token3, 130000) + ) + + const pool_eth_0 = v2StylePool( + CurrencyAmount.fromRawAmount(ETHER, JSBI.BigInt(100000)), + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(100000)) + ) + + const pool_eth_1 = v2StylePool( + CurrencyAmount.fromRawAmount(ETHER, JSBI.BigInt(100000)), + CurrencyAmount.fromRawAmount(token1, JSBI.BigInt(100000)) + ) + + const pool_eth_2 = v2StylePool( + CurrencyAmount.fromRawAmount(ETHER, JSBI.BigInt(100000)), + CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100000)) + ) + + describe('#fromRoute', () => { + it('can be constructed with ETHER as input', async () => { + const trade = await Trade.fromRoute( + new Route([pool_eth_0], ETHER, token0), + CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(10000)), + TradeType.EXACT_INPUT + ) + expect(trade.inputAmount.currency).toEqual(ETHER) + expect(trade.outputAmount.currency).toEqual(token0) + }) + it('can be constructed with ETHER as input for exact output', async () => { + const trade = await Trade.fromRoute( + new Route([pool_eth_0], ETHER, token0), + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10000)), + TradeType.EXACT_OUTPUT + ) + expect(trade.inputAmount.currency).toEqual(ETHER) + expect(trade.outputAmount.currency).toEqual(token0) + }) + + it('can be constructed with ETHER as output', async () => { + const trade = await Trade.fromRoute( + new Route([pool_eth_0], token0, ETHER), + CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(10000)), + TradeType.EXACT_OUTPUT + ) + expect(trade.inputAmount.currency).toEqual(token0) + expect(trade.outputAmount.currency).toEqual(ETHER) + }) + it('can be constructed with ETHER as output for exact input', async () => { + const trade = await Trade.fromRoute( + new Route([pool_eth_0], token0, ETHER), + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10000)), + TradeType.EXACT_INPUT + ) + expect(trade.inputAmount.currency).toEqual(token0) + expect(trade.outputAmount.currency).toEqual(ETHER) + }) + }) + + describe('#fromRoutes', () => { + it('can be constructed with ETHER as input with multiple routes', async () => { + const trade = await Trade.fromRoutes( + [ + { + amount: CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(10000)), + route: new Route([pool_eth_0], ETHER, token0), + }, + ], + TradeType.EXACT_INPUT + ) + expect(trade.inputAmount.currency).toEqual(ETHER) + expect(trade.outputAmount.currency).toEqual(token0) + }) + + it('can be constructed with ETHER as input for exact output with multiple routes', async () => { + const trade = await Trade.fromRoutes( + [ + { + amount: CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(3000)), + route: new Route([pool_eth_0], ETHER, token0), + }, + { + amount: CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(7000)), + route: new Route([pool_eth_1, pool_0_1], ETHER, token0), + }, + ], + TradeType.EXACT_OUTPUT + ) + expect(trade.inputAmount.currency).toEqual(ETHER) + expect(trade.outputAmount.currency).toEqual(token0) + }) + + it('can be constructed with ETHER as output with multiple routes', async () => { + const trade = await Trade.fromRoutes( + [ + { + amount: CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(4000)), + route: new Route([pool_eth_0], token0, ETHER), + }, + { + amount: CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(6000)), + route: new Route([pool_0_1, pool_eth_1], token0, ETHER), + }, + ], + TradeType.EXACT_OUTPUT + ) + expect(trade.inputAmount.currency).toEqual(token0) + expect(trade.outputAmount.currency).toEqual(ETHER) + }) + it('can be constructed with ETHER as output for exact input with multiple routes', async () => { + const trade = await Trade.fromRoutes( + [ + { + amount: CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(3000)), + route: new Route([pool_eth_0], token0, ETHER), + }, + { + amount: CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(7000)), + route: new Route([pool_0_1, pool_eth_1], token0, ETHER), + }, + ], + TradeType.EXACT_INPUT + ) + expect(trade.inputAmount.currency).toEqual(token0) + expect(trade.outputAmount.currency).toEqual(ETHER) + }) + + it('throws if pools are re-used between routes', async () => { + await expect( + Trade.fromRoutes( + [ + { + amount: CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(4500)), + route: new Route([pool_0_1, pool_eth_1], token0, ETHER), + }, + { + amount: CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(5500)), + route: new Route([pool_0_1, pool_1_2, pool_eth_2], token0, ETHER), + }, + ], + TradeType.EXACT_INPUT + ) + ).rejects.toThrow('POOLS_DUPLICATED') + }) + }) + + describe('#createUncheckedTrade', () => { + it('throws if input currency does not match route', () => { + expect(() => + Trade.createUncheckedTrade({ + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token2, 10000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 10000), + tradeType: TradeType.EXACT_INPUT, + }) + ).toThrow('INPUT_CURRENCY_MATCH') + }) + it('throws if output currency does not match route', () => { + expect(() => + Trade.createUncheckedTrade({ + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 10000), + outputAmount: CurrencyAmount.fromRawAmount(token2, 10000), + tradeType: TradeType.EXACT_INPUT, + }) + ).toThrow('OUTPUT_CURRENCY_MATCH') + }) + it('can create an exact input trade without simulating', () => { + Trade.createUncheckedTrade({ + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 10000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 100000), + tradeType: TradeType.EXACT_INPUT, + }) + }) + it('can create an exact output trade without simulating', () => { + Trade.createUncheckedTrade({ + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 10000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 100000), + tradeType: TradeType.EXACT_OUTPUT, + }) + }) + }) + describe('#createUncheckedTradeWithMultipleRoutes', () => { + it('throws if input currency does not match route with multiple routes', () => { + expect(() => + Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_1_2], token2, token1), + inputAmount: CurrencyAmount.fromRawAmount(token2, 2000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 2000), + }, + { + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token2, 8000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 8000), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + ).toThrow('INPUT_CURRENCY_MATCH') + }) + it('throws if output currency does not match route with multiple routes', () => { + expect(() => + Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 10000), + outputAmount: CurrencyAmount.fromRawAmount(token2, 10000), + }, + { + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 10000), + outputAmount: CurrencyAmount.fromRawAmount(token2, 10000), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + ).toThrow('OUTPUT_CURRENCY_MATCH') + }) + + it('can create an exact input trade without simulating with multiple routes', () => { + Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 5000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 50000), + }, + { + route: new Route([pool_0_2, pool_1_2], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 5000), + outputAmount: CurrencyAmount.fromRawAmount(token1, 50000), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + }) + + it('can create an exact output trade without simulating with multiple routes', () => { + Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 5001), + outputAmount: CurrencyAmount.fromRawAmount(token1, 50000), + }, + { + route: new Route([pool_0_2, pool_1_2], token0, token1), + inputAmount: CurrencyAmount.fromRawAmount(token0, 4999), + outputAmount: CurrencyAmount.fromRawAmount(token1, 50000), + }, + ], + tradeType: TradeType.EXACT_OUTPUT, + }) + }) + }) + + describe('#route and #swaps', () => { + const singleRoute = Trade.createUncheckedTrade({ + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 100), + outputAmount: CurrencyAmount.fromRawAmount(token2, 69), + tradeType: TradeType.EXACT_INPUT, + }) + const multiRoute = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 50), + outputAmount: CurrencyAmount.fromRawAmount(token2, 35), + }, + { + route: new Route([pool_0_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 50), + outputAmount: CurrencyAmount.fromRawAmount(token2, 34), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + it('can access route for single route trade if less than 0', () => { + expect(singleRoute.route).toBeDefined() + }) + it('can access routes for both single and multi route trades', () => { + expect(singleRoute.swaps).toBeDefined() + expect(singleRoute.swaps).toHaveLength(1) + expect(multiRoute.swaps).toBeDefined() + expect(multiRoute.swaps).toHaveLength(2) + }) + it('throws if access route on multi route trade', () => { + expect(() => multiRoute.route).toThrow('MULTIPLE_ROUTES') + }) + }) + + describe('#worstExecutionPrice', () => { + describe('tradeType = EXACT_INPUT', () => { + const exactIn = Trade.createUncheckedTrade({ + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 100), + outputAmount: CurrencyAmount.fromRawAmount(token2, 69), + tradeType: TradeType.EXACT_INPUT, + }) + const exactInMultiRoute = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 50), + outputAmount: CurrencyAmount.fromRawAmount(token2, 35), + }, + { + route: new Route([pool_0_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 50), + outputAmount: CurrencyAmount.fromRawAmount(token2, 34), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + it('throws if less than 0', () => { + expect(() => exactIn.minimumAmountOut(new Percent(-1, 100))).toThrow('SLIPPAGE_TOLERANCE') + }) + it('returns exact if 0', () => { + expect(exactIn.worstExecutionPrice(new Percent(0, 100))).toEqual(exactIn.executionPrice) + }) + it('returns exact if nonzero', () => { + expect(exactIn.worstExecutionPrice(new Percent(0, 100))).toEqual(new Price(token0, token2, 100, 69)) + expect(exactIn.worstExecutionPrice(new Percent(5, 100))).toEqual(new Price(token0, token2, 100, 65)) + expect(exactIn.worstExecutionPrice(new Percent(200, 100))).toEqual(new Price(token0, token2, 100, 23)) + }) + it('returns exact if nonzero with multiple routes', () => { + expect(exactInMultiRoute.worstExecutionPrice(new Percent(0, 100))).toEqual(new Price(token0, token2, 100, 69)) + expect(exactInMultiRoute.worstExecutionPrice(new Percent(5, 100))).toEqual(new Price(token0, token2, 100, 65)) + expect(exactInMultiRoute.worstExecutionPrice(new Percent(200, 100))).toEqual(new Price(token0, token2, 100, 23)) + }) + }) + describe('tradeType = EXACT_OUTPUT', () => { + const exactOut = Trade.createUncheckedTrade({ + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 156), + outputAmount: CurrencyAmount.fromRawAmount(token2, 100), + tradeType: TradeType.EXACT_OUTPUT, + }) + const exactOutMultiRoute = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 78), + outputAmount: CurrencyAmount.fromRawAmount(token2, 50), + }, + { + route: new Route([pool_0_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 78), + outputAmount: CurrencyAmount.fromRawAmount(token2, 50), + }, + ], + tradeType: TradeType.EXACT_OUTPUT, + }) + + it('throws if less than 0', () => { + expect(() => exactOut.worstExecutionPrice(new Percent(-1, 100))).toThrow('SLIPPAGE_TOLERANCE') + }) + it('returns exact if 0', () => { + expect(exactOut.worstExecutionPrice(new Percent(0, 100))).toEqual(exactOut.executionPrice) + }) + it('returns slippage amount if nonzero', () => { + expect( + exactOut.worstExecutionPrice(new Percent(0, 100)).equalTo(new Price(token0, token2, 156, 100)) + ).toBeTruthy() + expect( + exactOut.worstExecutionPrice(new Percent(5, 100)).equalTo(new Price(token0, token2, 163, 100)) + ).toBeTruthy() + expect( + exactOut.worstExecutionPrice(new Percent(200, 100)).equalTo(new Price(token0, token2, 468, 100)) + ).toBeTruthy() + }) + it('returns exact if nonzero with multiple routes', () => { + expect( + exactOutMultiRoute.worstExecutionPrice(new Percent(0, 100)).equalTo(new Price(token0, token2, 156, 100)) + ).toBeTruthy() + expect( + exactOutMultiRoute.worstExecutionPrice(new Percent(5, 100)).equalTo(new Price(token0, token2, 163, 100)) + ).toBeTruthy() + expect( + exactOutMultiRoute.worstExecutionPrice(new Percent(200, 100)).equalTo(new Price(token0, token2, 468, 100)) + ).toBeTruthy() + }) + }) + }) + + describe('#priceImpact', () => { + describe('tradeType = EXACT_INPUT', () => { + const exactIn = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 100), + outputAmount: CurrencyAmount.fromRawAmount(token2, 69), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + const exactInMultipleRoutes = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 90), + outputAmount: CurrencyAmount.fromRawAmount(token2, 62), + }, + { + route: new Route([pool_0_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 10), + outputAmount: CurrencyAmount.fromRawAmount(token2, 7), + }, + ], + tradeType: TradeType.EXACT_INPUT, + }) + it('is correct', () => { + expect(exactIn.priceImpact.toSignificant(3)).toEqual('17.2') + }) + it('is correct with multiple routes', async () => { + expect(exactInMultipleRoutes.priceImpact.toSignificant(3)).toEqual('19.8') + }) + }) + describe('tradeType = EXACT_OUTPUT', () => { + const exactOut = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 156), + outputAmount: CurrencyAmount.fromRawAmount(token2, 100), + }, + ], + tradeType: TradeType.EXACT_OUTPUT, + }) + const exactOutMultipleRoutes = Trade.createUncheckedTradeWithMultipleRoutes({ + routes: [ + { + route: new Route([pool_0_1, pool_1_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 140), + outputAmount: CurrencyAmount.fromRawAmount(token2, 90), + }, + { + route: new Route([pool_0_2], token0, token2), + inputAmount: CurrencyAmount.fromRawAmount(token0, 16), + outputAmount: CurrencyAmount.fromRawAmount(token2, 10), + }, + ], + tradeType: TradeType.EXACT_OUTPUT, + }) + it('is correct', () => { + expect(exactOut.priceImpact.toSignificant(3)).toEqual('23.1') + }) + it('is correct with multiple routes', () => { + expect(exactOutMultipleRoutes.priceImpact.toSignificant(3)).toEqual('25.5') + }) + }) + }) + + describe('#bestTradeExactIn', () => { + it('throws with empty pools', async () => { + await expect( + Trade.bestTradeExactIn([], CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10000)), token2) + ).rejects.toThrow('POOLS') + }) + it('throws with max hops of 0', async () => { + await expect( + Trade.bestTradeExactIn([pool_0_2], CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10000)), token2, { + maxHops: 0, + }) + ).rejects.toThrow('MAX_HOPS') + }) + + it('provides best route', async () => { + const result = await Trade.bestTradeExactIn( + [pool_0_1, pool_0_2, pool_1_2], + CurrencyAmount.fromRawAmount(token0, 10000), + token2 + ) + expect(result).toHaveLength(2) + expect(result[0].swaps[0].route.pools).toHaveLength(1) // 0 -> 2 at 10:11 + expect(result[0].swaps[0].route.currencyPath).toEqual([token0, token2]) + expect(result[0].inputAmount.equalTo(CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10000)))).toBeTruthy() + expect(result[0].outputAmount.equalTo(CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(9971)))).toBeTruthy() + expect(result[1].swaps[0].route.pools).toHaveLength(2) // 0 -> 1 -> 2 at 12:12:10 + expect(result[1].swaps[0].route.currencyPath).toEqual([token0, token1, token2]) + expect(result[1].inputAmount.equalTo(CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10000)))).toBeTruthy() + expect(result[1].outputAmount.equalTo(CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(7004)))).toBeTruthy() + }) + + it('respects maxHops', async () => { + const result = await Trade.bestTradeExactIn( + [pool_0_1, pool_0_2, pool_1_2], + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10)), + token2, + { maxHops: 1 } + ) + expect(result).toHaveLength(1) + expect(result[0].swaps[0].route.pools).toHaveLength(1) // 0 -> 2 at 10:11 + expect(result[0].swaps[0].route.currencyPath).toEqual([token0, token2]) + }) + + it('insufficient input for one pool', async () => { + const result = await Trade.bestTradeExactIn( + [pool_0_1, pool_0_2, pool_1_2], + CurrencyAmount.fromRawAmount(token0, 1), + token2 + ) + expect(result).toHaveLength(2) + expect(result[0].swaps[0].route.pools).toHaveLength(1) // 0 -> 2 at 10:11 + expect(result[0].swaps[0].route.currencyPath).toEqual([token0, token2]) + expect(result[0].outputAmount).toEqual(CurrencyAmount.fromRawAmount(token2, 0)) + }) + + it('respects n', async () => { + const result = await Trade.bestTradeExactIn( + [pool_0_1, pool_0_2, pool_1_2], + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10)), + token2, + { maxNumResults: 1 } + ) + + expect(result).toHaveLength(1) + }) + + it('no path', async () => { + const result = await Trade.bestTradeExactIn( + [pool_0_1, pool_0_3, pool_1_3], + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(10)), + token2 + ) + expect(result).toHaveLength(0) + }) + + it('works for ETHER currency input', async () => { + const result = await Trade.bestTradeExactIn( + [pool_eth_0, pool_0_1, pool_0_3, pool_1_3], + CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(100)), + token3 + ) + expect(result).toHaveLength(2) + expect(result[0].inputAmount.currency).toEqual(ETHER) + expect(result[0].swaps[0].route.currencyPath).toEqual([ETHER, token0, token1, token3]) + expect(result[0].outputAmount.currency).toEqual(token3) + expect(result[1].inputAmount.currency).toEqual(ETHER) + expect(result[1].swaps[0].route.currencyPath).toEqual([ETHER, token0, token3]) + expect(result[1].outputAmount.currency).toEqual(token3) + }) + + it('works for ETHER currency output', async () => { + const result = await Trade.bestTradeExactIn( + [pool_eth_0, pool_0_1, pool_0_3, pool_1_3], + CurrencyAmount.fromRawAmount(token3, JSBI.BigInt(100)), + ETHER + ) + expect(result).toHaveLength(2) + expect(result[0].inputAmount.currency).toEqual(token3) + expect(result[0].swaps[0].route.currencyPath).toEqual([token3, token0, ETHER]) + expect(result[0].outputAmount.currency).toEqual(ETHER) + expect(result[1].inputAmount.currency).toEqual(token3) + expect(result[1].swaps[0].route.currencyPath).toEqual([token3, token1, token0, ETHER]) + expect(result[1].outputAmount.currency).toEqual(ETHER) + }) + }) + + describe('#maximumAmountIn', () => { + describe('tradeType = EXACT_INPUT', () => { + let exactIn: Trade + beforeEach(async () => { + exactIn = await Trade.fromRoute( + new Route([pool_0_1, pool_1_2], token0, token2), + CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(100)), + TradeType.EXACT_INPUT + ) + }) + it('throws if less than 0', () => { + expect(() => exactIn.maximumAmountIn(new Percent(JSBI.BigInt(-1), JSBI.BigInt(100)))).toThrow( + 'SLIPPAGE_TOLERANCE' + ) + }) + it('returns exact if 0', () => { + expect(exactIn.maximumAmountIn(new Percent(JSBI.BigInt(0), JSBI.BigInt(100)))).toEqual(exactIn.inputAmount) + }) + it('returns exact if nonzero', () => { + expect( + exactIn + .maximumAmountIn(new Percent(JSBI.BigInt(0), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(100))) + ).toBeTruthy() + expect( + exactIn + .maximumAmountIn(new Percent(JSBI.BigInt(5), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(100))) + ).toBeTruthy() + expect( + exactIn + .maximumAmountIn(new Percent(JSBI.BigInt(200), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token0, JSBI.BigInt(100))) + ).toBeTruthy() + }) + }) + + describe('tradeType = EXACT_OUTPUT', () => { + let exactOut: Trade + beforeEach(async () => { + exactOut = await Trade.fromRoute( + new Route([pool_0_1, pool_1_2], token0, token2), + CurrencyAmount.fromRawAmount(token2, 10000), + TradeType.EXACT_OUTPUT + ) + }) + + it('throws if less than 0', () => { + expect(() => exactOut.maximumAmountIn(new Percent(JSBI.BigInt(-1), 10000))).toThrow('SLIPPAGE_TOLERANCE') + }) + + it('returns exact if 0', () => { + expect(exactOut.maximumAmountIn(new Percent(JSBI.BigInt(0), 10000))).toEqual(exactOut.inputAmount) + }) + + it('returns slippage amount if nonzero', () => { + expect( + exactOut + .maximumAmountIn(new Percent(JSBI.BigInt(0), 100)) + .equalTo(CurrencyAmount.fromRawAmount(token0, 15488)) + ).toBeTruthy() + expect( + exactOut + .maximumAmountIn(new Percent(JSBI.BigInt(5), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token0, 16262)) + ).toBeTruthy() + expect( + exactOut + .maximumAmountIn(new Percent(JSBI.BigInt(200), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token0, 46464)) + ).toBeTruthy() + }) + }) + }) + + describe('#minimumAmountOut', () => { + describe('tradeType = EXACT_INPUT', () => { + let exactIn: Trade + beforeEach( + async () => + (exactIn = await Trade.fromRoute( + new Route([pool_0_1, pool_1_2], token0, token2), + CurrencyAmount.fromRawAmount(token0, 10000), + TradeType.EXACT_INPUT + )) + ) + + it('throws if less than 0', () => { + expect(() => exactIn.minimumAmountOut(new Percent(JSBI.BigInt(-1), 100))).toThrow('SLIPPAGE_TOLERANCE') + }) + + it('returns exact if 0', () => { + expect(exactIn.minimumAmountOut(new Percent(JSBI.BigInt(0), 10000))).toEqual(exactIn.outputAmount) + }) + + it('returns exact if nonzero', () => { + expect(exactIn.minimumAmountOut(new Percent(JSBI.BigInt(0), 100))).toEqual( + CurrencyAmount.fromRawAmount(token2, 7004) + ) + expect(exactIn.minimumAmountOut(new Percent(JSBI.BigInt(5), 100))).toEqual( + CurrencyAmount.fromRawAmount(token2, 6670) + ) + expect(exactIn.minimumAmountOut(new Percent(JSBI.BigInt(200), 100))).toEqual( + CurrencyAmount.fromRawAmount(token2, 2334) + ) + }) + }) + describe('tradeType = EXACT_OUTPUT', () => { + let exactOut: Trade + beforeEach(async () => { + exactOut = await Trade.fromRoute( + new Route([pool_0_1, pool_1_2], token0, token2), + CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100)), + TradeType.EXACT_OUTPUT + ) + }) + + it('throws if less than 0', () => { + expect(() => exactOut.minimumAmountOut(new Percent(JSBI.BigInt(-1), JSBI.BigInt(100)))).toThrow( + 'SLIPPAGE_TOLERANCE' + ) + }) + it('returns exact if 0', () => { + expect(exactOut.minimumAmountOut(new Percent(JSBI.BigInt(0), JSBI.BigInt(100)))).toEqual(exactOut.outputAmount) + }) + it('returns slippage amount if nonzero', () => { + expect( + exactOut + .minimumAmountOut(new Percent(JSBI.BigInt(0), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100))) + ).toBeTruthy() + expect( + exactOut + .minimumAmountOut(new Percent(JSBI.BigInt(5), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100))) + ).toBeTruthy() + expect( + exactOut + .minimumAmountOut(new Percent(JSBI.BigInt(200), JSBI.BigInt(100))) + .equalTo(CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100))) + ).toBeTruthy() + }) + }) + }) + + describe('#bestTradeExactOut', () => { + it('throws with empty pools', async () => { + await expect( + Trade.bestTradeExactOut([], token0, CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100))) + ).rejects.toThrow('POOLS') + }) + it('throws with max hops of 0', async () => { + await expect( + Trade.bestTradeExactOut([pool_0_2], token0, CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(100)), { + maxHops: 0, + }) + ).rejects.toThrow('MAX_HOPS') + }) + + it('provides best route', async () => { + const result = await Trade.bestTradeExactOut( + [pool_0_1, pool_0_2, pool_1_2], + token0, + CurrencyAmount.fromRawAmount(token2, 10000) + ) + + expect(result).toHaveLength(2) + expect(result[0].swaps[0].route.pools).toHaveLength(1) // 0 -> 2 at 10:11 + expect(result[0].swaps[0].route.currencyPath).toEqual([token0, token2]) + expect(result[0].inputAmount.equalTo(CurrencyAmount.fromRawAmount(token0, 10032))).toBeTruthy() + expect(result[0].outputAmount.equalTo(CurrencyAmount.fromRawAmount(token2, 10000))).toBeTruthy() + expect(result[1].swaps[0].route.pools).toHaveLength(2) // 0 -> 1 -> 2 at 12:12:10 + expect(result[1].swaps[0].route.currencyPath).toEqual([token0, token1, token2]) + expect(result[1].inputAmount.equalTo(CurrencyAmount.fromRawAmount(token0, 15488))).toBeTruthy() + expect(result[1].outputAmount.equalTo(CurrencyAmount.fromRawAmount(token2, 10000))).toBeTruthy() + }) + + it('respects maxHops', async () => { + const result = await Trade.bestTradeExactOut( + [pool_0_1, pool_0_2, pool_1_2], + token0, + CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(10)), + { maxHops: 1 } + ) + expect(result).toHaveLength(1) + expect(result[0].swaps[0].route.pools).toHaveLength(1) // 0 -> 2 at 10:11 + expect(result[0].swaps[0].route.currencyPath).toEqual([token0, token2]) + }) + + it.skip('insufficient liquidity', () => { + const result = Trade.bestTradeExactOut( + [pool_0_1, pool_0_2, pool_1_2], + token0, + CurrencyAmount.fromRawAmount(token2, 1200) + ) + expect(result).toHaveLength(0) + }) + + it.skip('insufficient liquidity in one pool but not the other', () => { + const result = Trade.bestTradeExactOut( + [pool_0_1, pool_0_2, pool_1_2], + token0, + CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(1050)) + ) + expect(result).toHaveLength(1) + }) + + it('respects n', async () => { + const result = await Trade.bestTradeExactOut( + [pool_0_1, pool_0_2, pool_1_2], + token0, + CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(10)), + { maxNumResults: 1 } + ) + + expect(result).toHaveLength(1) + }) + + it('no path', async () => { + const result = await Trade.bestTradeExactOut( + [pool_0_1, pool_0_3, pool_1_3], + token0, + CurrencyAmount.fromRawAmount(token2, JSBI.BigInt(10)) + ) + expect(result).toHaveLength(0) + }) + + it('works for ETHER currency input', async () => { + const result = await Trade.bestTradeExactOut( + [pool_eth_0, pool_0_1, pool_0_3, pool_1_3], + ETHER, + CurrencyAmount.fromRawAmount(token3, 10000) + ) + expect(result).toHaveLength(2) + expect(result[0].inputAmount.currency).toEqual(ETHER) + expect(result[0].swaps[0].route.currencyPath).toEqual([ETHER, token0, token1, token3]) + expect(result[0].outputAmount.currency).toEqual(token3) + expect(result[1].inputAmount.currency).toEqual(ETHER) + expect(result[1].swaps[0].route.currencyPath).toEqual([ETHER, token0, token3]) + expect(result[1].outputAmount.currency).toEqual(token3) + }) + it('works for ETHER currency output', async () => { + const result = await Trade.bestTradeExactOut( + [pool_eth_0, pool_0_1, pool_0_3, pool_1_3], + token3, + CurrencyAmount.fromRawAmount(Ether.onChain(1), JSBI.BigInt(100)) + ) + expect(result).toHaveLength(2) + expect(result[0].inputAmount.currency).toEqual(token3) + expect(result[0].swaps[0].route.currencyPath).toEqual([token3, token0, ETHER]) + expect(result[0].outputAmount.currency).toEqual(ETHER) + expect(result[1].inputAmount.currency).toEqual(token3) + expect(result[1].swaps[0].route.currencyPath).toEqual([token3, token1, token0, ETHER]) + expect(result[1].outputAmount.currency).toEqual(ETHER) + }) + }) +}) diff --git a/sdks/v4-sdk/src/entities/trade.ts b/sdks/v4-sdk/src/entities/trade.ts new file mode 100644 index 000000000..107fc07a8 --- /dev/null +++ b/sdks/v4-sdk/src/entities/trade.ts @@ -0,0 +1,637 @@ +import { Currency, Fraction, Percent, Price, sortedInsert, CurrencyAmount, TradeType, Token } from '@uniswap/sdk-core' +import invariant from 'tiny-invariant' +import { ONE, ZERO } from '../utils/internalConstants' +import { Pool } from './pool' +import { Route } from './route' + +/** + * Trades comparator, an extension of the input output comparator that also considers other dimensions of the trade in ranking them + * @template TInput The input currency, either Ether or an ERC-20 + * @template TOutput The output currency, either Ether or an ERC-20 + * @template TTradeType The trade type, either exact input or exact output + * @param a The first trade to compare + * @param b The second trade to compare + * @returns A sorted ordering for two neighboring elements in a trade array + */ +export function tradeComparator( + a: Trade, + b: Trade +) { + // must have same input and output currency for comparison + invariant(a.inputAmount.currency.equals(b.inputAmount.currency), 'INPUT_CURRENCY') + invariant(a.outputAmount.currency.equals(b.outputAmount.currency), 'OUTPUT_CURRENCY') + if (a.outputAmount.equalTo(b.outputAmount)) { + if (a.inputAmount.equalTo(b.inputAmount)) { + // consider the number of hops since each hop costs gas + const aHops = a.swaps.reduce((total, cur) => total + cur.route.currencyPath.length, 0) + const bHops = b.swaps.reduce((total, cur) => total + cur.route.currencyPath.length, 0) + return aHops - bHops + } + // trade A requires less input than trade B, so A should come first + if (a.inputAmount.lessThan(b.inputAmount)) { + return -1 + } else { + return 1 + } + } else { + // tradeA has less output than trade B, so should come second + if (a.outputAmount.lessThan(b.outputAmount)) { + return 1 + } else { + return -1 + } + } +} + +export interface BestTradeOptions { + // how many results to return + maxNumResults?: number + // the maximum number of hops a trade should contain + maxHops?: number +} + +/** + * Represents a trade executed against a set of routes where some percentage of the input is + * split across each route. + * + * Each route has its own set of pools. Pools can not be re-used across routes. + * + * Does not account for slippage, i.e., changes in price environment that can occur between + * the time the trade is submitted and when it is executed. + * @template TInput The input currency, either Ether or an ERC-20 + * @template TOutput The output currency, either Ether or an ERC-20 + * @template TTradeType The trade type, either exact input or exact output + */ +export class Trade { + /** + * @deprecated Deprecated in favor of 'swaps' property. If the trade consists of multiple routes + * this will return an error. + * + * When the trade consists of just a single route, this returns the route of the trade, + * i.e. which pools the trade goes through. + */ + public get route(): Route { + invariant(this.swaps.length === 1, 'MULTIPLE_ROUTES') + return this.swaps[0].route + } + + /** + * The swaps of the trade, i.e. which routes and how much is swapped in each that + * make up the trade. + */ + public readonly swaps: { + route: Route + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount + }[] + + /** + * The type of the trade, either exact in or exact out. + */ + public readonly tradeType: TTradeType + + /** + * The cached result of the input amount computation + * @private + */ + private _inputAmount: CurrencyAmount | undefined + + /** + * The input amount for the trade assuming no slippage. + */ + public get inputAmount(): CurrencyAmount { + if (this._inputAmount) { + return this._inputAmount + } + + const inputCurrency = this.swaps[0].inputAmount.currency + const totalInputFromRoutes = this.swaps + .map(({ inputAmount }) => inputAmount) + .reduce((total, cur) => total.add(cur), CurrencyAmount.fromRawAmount(inputCurrency, 0)) + + this._inputAmount = totalInputFromRoutes + return this._inputAmount + } + + /** + * The cached result of the output amount computation + * @private + */ + private _outputAmount: CurrencyAmount | undefined + + /** + * The output amount for the trade assuming no slippage. + */ + public get outputAmount(): CurrencyAmount { + if (this._outputAmount) { + return this._outputAmount + } + + const outputCurrency = this.swaps[0].outputAmount.currency + const totalOutputFromRoutes = this.swaps + .map(({ outputAmount }) => outputAmount) + .reduce((total, cur) => total.add(cur), CurrencyAmount.fromRawAmount(outputCurrency, 0)) + + this._outputAmount = totalOutputFromRoutes + return this._outputAmount + } + + /** + * The cached result of the computed execution price + * @private + */ + private _executionPrice: Price | undefined + + /** + * The price expressed in terms of output amount/input amount. + */ + public get executionPrice(): Price { + return ( + this._executionPrice ?? + (this._executionPrice = new Price( + this.inputAmount.currency, + this.outputAmount.currency, + this.inputAmount.quotient, + this.outputAmount.quotient + )) + ) + } + + /** + * The cached result of the price impact computation + * @private + */ + private _priceImpact: Percent | undefined + + /** + * Returns the percent difference between the route's mid price and the price impact + */ + public get priceImpact(): Percent { + if (this._priceImpact) { + return this._priceImpact + } + + let spotOutputAmount = CurrencyAmount.fromRawAmount(this.outputAmount.currency, 0) + for (const { route, inputAmount } of this.swaps) { + const midPrice = route.midPrice + spotOutputAmount = spotOutputAmount.add(midPrice.quote(inputAmount)) + } + + const priceImpact = spotOutputAmount.subtract(this.outputAmount).divide(spotOutputAmount) + this._priceImpact = new Percent(priceImpact.numerator, priceImpact.denominator) + + return this._priceImpact + } + + /** + * Constructs an exact in trade with the given amount in and route + * @template TInput The input currency, either Ether or an ERC-20 + * @template TOutput The output currency, either Ether or an ERC-20 + * @param route The route of the exact in trade + * @param amountIn The amount being passed in + * @returns The exact in trade + */ + public static async exactIn( + route: Route, + amountIn: CurrencyAmount + ): Promise> { + return Trade.fromRoute(route, amountIn, TradeType.EXACT_INPUT) + } + + /** + * Constructs an exact out trade with the given amount out and route + * @template TInput The input currency, either Ether or an ERC-20 + * @template TOutput The output currency, either Ether or an ERC-20 + * @param route The route of the exact out trade + * @param amountOut The amount returned by the trade + * @returns The exact out trade + */ + public static async exactOut( + route: Route, + amountOut: CurrencyAmount + ): Promise> { + return Trade.fromRoute(route, amountOut, TradeType.EXACT_OUTPUT) + } + + /** + * Constructs a trade by simulating swaps through the given route + * @template TInput The input currency, either Ether or an ERC-20. + * @template TOutput The output currency, either Ether or an ERC-20. + * @template TTradeType The type of the trade, either exact in or exact out. + * @param route route to swap through + * @param amount the amount specified, either input or output, depending on tradeType + * @param tradeType whether the trade is an exact input or exact output swap + * @returns The route + */ + public static async fromRoute( + route: Route, + amount: TTradeType extends TradeType.EXACT_INPUT ? CurrencyAmount : CurrencyAmount, + tradeType: TTradeType + ): Promise> { + const amounts: CurrencyAmount[] = new Array(route.currencyPath.length) + let inputAmount: CurrencyAmount + let outputAmount: CurrencyAmount + if (tradeType === TradeType.EXACT_INPUT) { + invariant(amount.currency.equals(route.input), 'INPUT') + amounts[0] = amount + for (let i = 0; i < route.currencyPath.length - 1; i++) { + const pool = route.pools[i] + const [outputAmount] = await pool.getOutputAmount(amounts[i]) + amounts[i + 1] = outputAmount + } + inputAmount = CurrencyAmount.fromFractionalAmount(route.input, amount.numerator, amount.denominator) + outputAmount = CurrencyAmount.fromFractionalAmount( + route.output, + amounts[amounts.length - 1].numerator, + amounts[amounts.length - 1].denominator + ) + } else { + invariant(amount.currency.equals(route.output), 'OUTPUT') + amounts[amounts.length - 1] = amount + for (let i = route.currencyPath.length - 1; i > 0; i--) { + const pool = route.pools[i - 1] + const [inputAmount] = await pool.getInputAmount(amounts[i]) + amounts[i - 1] = inputAmount + } + inputAmount = CurrencyAmount.fromFractionalAmount(route.input, amounts[0].numerator, amounts[0].denominator) + outputAmount = CurrencyAmount.fromFractionalAmount(route.output, amount.numerator, amount.denominator) + } + + return new Trade({ + routes: [{ inputAmount, outputAmount, route }], + tradeType, + }) + } + + /** + * Constructs a trade from routes by simulating swaps + * + * @template TInput The input currency, either Ether or an ERC-20. + * @template TOutput The output currency, either Ether or an ERC-20. + * @template TTradeType The type of the trade, either exact in or exact out. + * @param routes the routes to swap through and how much of the amount should be routed through each + * @param tradeType whether the trade is an exact input or exact output swap + * @returns The trade + */ + public static async fromRoutes( + routes: { + amount: TTradeType extends TradeType.EXACT_INPUT ? CurrencyAmount : CurrencyAmount + route: Route + }[], + tradeType: TTradeType + ): Promise> { + const populatedRoutes: { + route: Route + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount + }[] = [] + + for (const { route, amount } of routes) { + const amounts: CurrencyAmount[] = new Array(route.currencyPath.length) + let inputAmount: CurrencyAmount + let outputAmount: CurrencyAmount + + if (tradeType === TradeType.EXACT_INPUT) { + invariant(amount.currency.equals(route.input), 'INPUT') + inputAmount = CurrencyAmount.fromFractionalAmount(route.input, amount.numerator, amount.denominator) + amounts[0] = CurrencyAmount.fromFractionalAmount(route.input, amount.numerator, amount.denominator) + + for (let i = 0; i < route.currencyPath.length - 1; i++) { + const pool = route.pools[i] + const [outputAmount] = await pool.getOutputAmount(amounts[i]) + amounts[i + 1] = outputAmount + } + + outputAmount = CurrencyAmount.fromFractionalAmount( + route.output, + amounts[amounts.length - 1].numerator, + amounts[amounts.length - 1].denominator + ) + } else { + invariant(amount.currency.equals(route.output), 'OUTPUT') + outputAmount = CurrencyAmount.fromFractionalAmount(route.output, amount.numerator, amount.denominator) + amounts[amounts.length - 1] = CurrencyAmount.fromFractionalAmount( + route.output, + amount.numerator, + amount.denominator + ) + + for (let i = route.currencyPath.length - 1; i > 0; i--) { + const pool = route.pools[i - 1] + const [inputAmount] = await pool.getInputAmount(amounts[i]) + amounts[i - 1] = inputAmount + } + + inputAmount = CurrencyAmount.fromFractionalAmount(route.input, amounts[0].numerator, amounts[0].denominator) + } + + populatedRoutes.push({ route, inputAmount, outputAmount }) + } + + return new Trade({ + routes: populatedRoutes, + tradeType, + }) + } + + /** + * Creates a trade without computing the result of swapping through the route. Useful when you have simulated the trade + * elsewhere and do not have any tick data + * @template TInput The input currency, either Ether or an ERC-20 + * @template TOutput The output currency, either Ether or an ERC-20 + * @template TTradeType The type of the trade, either exact in or exact out + * @param constructorArguments The arguments passed to the trade constructor + * @returns The unchecked trade + */ + public static createUncheckedTrade< + TInput extends Currency, + TOutput extends Currency, + TTradeType extends TradeType + >(constructorArguments: { + route: Route + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount + tradeType: TTradeType + }): Trade { + return new Trade({ + ...constructorArguments, + routes: [ + { + inputAmount: constructorArguments.inputAmount, + outputAmount: constructorArguments.outputAmount, + route: constructorArguments.route, + }, + ], + }) + } + + /** + * Creates a trade without computing the result of swapping through the routes. Useful when you have simulated the trade + * elsewhere and do not have any tick data + * @template TInput The input currency, either Ether or an ERC-20 + * @template TOutput The output currency, either Ether or an ERC-20 + * @template TTradeType The type of the trade, either exact in or exact out + * @param constructorArguments The arguments passed to the trade constructor + * @returns The unchecked trade + */ + public static createUncheckedTradeWithMultipleRoutes< + TInput extends Currency, + TOutput extends Currency, + TTradeType extends TradeType + >(constructorArguments: { + routes: { + route: Route + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount + }[] + tradeType: TTradeType + }): Trade { + return new Trade(constructorArguments) + } + + /** + * Construct a trade by passing in the pre-computed property values + * @param routes The routes through which the trade occurs + * @param tradeType The type of trade, exact input or exact output + */ + private constructor({ + routes, + tradeType, + }: { + routes: { + route: Route + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount + }[] + tradeType: TTradeType + }) { + const inputCurrency = routes[0].inputAmount.currency + const outputCurrency = routes[0].outputAmount.currency + invariant( + routes.every(({ route }) => inputCurrency.equals(route.input)), + 'INPUT_CURRENCY_MATCH' + ) + invariant( + routes.every(({ route }) => outputCurrency.equals(route.output)), + 'OUTPUT_CURRENCY_MATCH' + ) + + const numPools = routes.map(({ route }) => route.pools.length).reduce((total, cur) => total + cur, 0) + const poolIDSet = new Set() + for (const { route } of routes) { + for (const pool of route.pools) { + poolIDSet.add(Pool.getPoolId(pool.currency0, pool.currency1, pool.fee, pool.tickSpacing, pool.hooks)) + } + } + + invariant(numPools === poolIDSet.size, 'POOLS_DUPLICATED') + + this.swaps = routes + this.tradeType = tradeType + } + + /** + * Get the minimum amount that must be received from this trade for the given slippage tolerance + * @param slippageTolerance The tolerance of unfavorable slippage from the execution price of this trade + * @returns The amount out + */ + public minimumAmountOut(slippageTolerance: Percent, amountOut = this.outputAmount): CurrencyAmount { + invariant(!slippageTolerance.lessThan(ZERO), 'SLIPPAGE_TOLERANCE') + if (this.tradeType === TradeType.EXACT_OUTPUT) { + return amountOut + } else { + const slippageAdjustedAmountOut = new Fraction(ONE) + .add(slippageTolerance) + .invert() + .multiply(amountOut.quotient).quotient + return CurrencyAmount.fromRawAmount(amountOut.currency, slippageAdjustedAmountOut) + } + } + + /** + * Get the maximum amount in that can be spent via this trade for the given slippage tolerance + * @param slippageTolerance The tolerance of unfavorable slippage from the execution price of this trade + * @returns The amount in + */ + public maximumAmountIn(slippageTolerance: Percent, amountIn = this.inputAmount): CurrencyAmount { + invariant(!slippageTolerance.lessThan(ZERO), 'SLIPPAGE_TOLERANCE') + if (this.tradeType === TradeType.EXACT_INPUT) { + return amountIn + } else { + const slippageAdjustedAmountIn = new Fraction(ONE).add(slippageTolerance).multiply(amountIn.quotient).quotient + return CurrencyAmount.fromRawAmount(amountIn.currency, slippageAdjustedAmountIn) + } + } + + /** + * Return the execution price after accounting for slippage tolerance + * @param slippageTolerance the allowed tolerated slippage + * @returns The execution price + */ + public worstExecutionPrice(slippageTolerance: Percent): Price { + return new Price( + this.inputAmount.currency, + this.outputAmount.currency, + this.maximumAmountIn(slippageTolerance).quotient, + this.minimumAmountOut(slippageTolerance).quotient + ) + } + + /** + * Given a list of pools, and a fixed amount in, returns the top `maxNumResults` trades that go from an input currency + * amount to an output currency, making at most `maxHops` hops. + * Note this does not consider aggregation, as routes are linear. It's possible a better route exists by splitting + * the amount in among multiple routes. + * @param pools the pools to consider in finding the best trade + * @param nextAmountIn exact amount of input currency to spend + * @param currencyOut the desired currency out + * @param maxNumResults maximum number of results to return + * @param maxHops maximum number of hops a returned trade can make, e.g. 1 hop goes through a single pool + * @param currentPools used in recursion; the current list of pools + * @param currencyAmountIn used in recursion; the original value of the currencyAmountIn parameter + * @param bestTrades used in recursion; the current list of best trades + * @returns The exact in trade + */ + public static async bestTradeExactIn( + pools: Pool[], + currencyAmountIn: CurrencyAmount, + currencyOut: TOutput, + { maxNumResults = 3, maxHops = 3 }: BestTradeOptions = {}, + // used in recursion. + currentPools: Pool[] = [], + nextAmountIn: CurrencyAmount = currencyAmountIn, + bestTrades: Trade[] = [] + ): Promise[]> { + invariant(pools.length > 0, 'POOLS') + invariant(maxHops > 0, 'MAX_HOPS') + invariant(currencyAmountIn === nextAmountIn || currentPools.length > 0, 'INVALID_RECURSION') + + const amountIn = nextAmountIn + for (let i = 0; i < pools.length; i++) { + const pool = pools[i] + // pool irrelevant + if (!pool.currency0.equals(amountIn.currency) && !pool.currency1.equals(amountIn.currency)) continue + + let amountOut: CurrencyAmount + try { + ;[amountOut] = await pool.getOutputAmount(amountIn) + } catch (error) { + // input too low + if ((error as any).isInsufficientInputAmountError) { + continue + } + throw error + } + // we have arrived at the output currency, so this is the final trade of one of the paths + if (amountOut.currency.equals(currencyOut)) { + sortedInsert( + bestTrades, + await Trade.fromRoute( + new Route([...currentPools, pool], currencyAmountIn.currency, currencyOut), + currencyAmountIn, + TradeType.EXACT_INPUT + ), + maxNumResults, + tradeComparator + ) + } else if (maxHops > 1 && pools.length > 1) { + const poolsExcludingThisPool = pools.slice(0, i).concat(pools.slice(i + 1, pools.length)) + + // otherwise, consider all the other paths that lead from this currency as long as we have not exceeded maxHops + await Trade.bestTradeExactIn( + poolsExcludingThisPool, + currencyAmountIn, + currencyOut, + { + maxNumResults, + maxHops: maxHops - 1, + }, + [...currentPools, pool], + amountOut, + bestTrades + ) + } + } + return bestTrades + } + + /** + * similar to the above method but instead targets a fixed output amount + * given a list of pools, and a fixed amount out, returns the top `maxNumResults` trades that go from an input currency + * to an output currency amount, making at most `maxHops` hops + * note this does not consider aggregation, as routes are linear. it's possible a better route exists by splitting + * the amount in among multiple routes. + * @param pools the pools to consider in finding the best trade + * @param currencyIn the currency to spend + * @param currencyAmountOut the desired currency amount out + * @param nextAmountOut the exact amount of currency out + * @param maxNumResults maximum number of results to return + * @param maxHops maximum number of hops a returned trade can make, e.g. 1 hop goes through a single pool + * @param currentPools used in recursion; the current list of pools + * @param bestTrades used in recursion; the current list of best trades + * @returns The exact out trade + */ + public static async bestTradeExactOut( + pools: Pool[], + currencyIn: TInput, + currencyAmountOut: CurrencyAmount, + { maxNumResults = 3, maxHops = 3 }: BestTradeOptions = {}, + // used in recursion. + currentPools: Pool[] = [], + nextAmountOut: CurrencyAmount = currencyAmountOut, + bestTrades: Trade[] = [] + ): Promise[]> { + invariant(pools.length > 0, 'POOLS') + invariant(maxHops > 0, 'MAX_HOPS') + invariant(currencyAmountOut === nextAmountOut || currentPools.length > 0, 'INVALID_RECURSION') + + const amountOut = nextAmountOut + for (let i = 0; i < pools.length; i++) { + const pool = pools[i] + // pool irrelevant + if (!pool.currency0.equals(amountOut.currency) && !pool.currency1.equals(amountOut.currency)) continue + + let amountIn: CurrencyAmount + try { + ;[amountIn] = await pool.getInputAmount(amountOut) + } catch (error) { + // not enough liquidity in this pool + if ((error as any).isInsufficientReservesError) { + continue + } + throw error + } + // we have arrived at the input currency, so this is the first trade of one of the paths + if (amountIn.currency.equals(currencyIn)) { + sortedInsert( + bestTrades, + await Trade.fromRoute( + new Route([pool, ...currentPools], currencyIn, currencyAmountOut.currency), + currencyAmountOut, + TradeType.EXACT_OUTPUT + ), + maxNumResults, + tradeComparator + ) + } else if (maxHops > 1 && pools.length > 1) { + const poolsExcludingThisPool = pools.slice(0, i).concat(pools.slice(i + 1, pools.length)) + + // otherwise, consider all the other paths that arrive at this currency as long as we have not exceeded maxHops + await Trade.bestTradeExactOut( + poolsExcludingThisPool, + currencyIn, + currencyAmountOut, + { + maxNumResults, + maxHops: maxHops - 1, + }, + [pool, ...currentPools], + amountIn, + bestTrades + ) + } + } + + return bestTrades + } +} diff --git a/sdks/v4-sdk/src/index.ts b/sdks/v4-sdk/src/index.ts new file mode 100644 index 000000000..14efeb52c --- /dev/null +++ b/sdks/v4-sdk/src/index.ts @@ -0,0 +1 @@ +export * from './entities' diff --git a/sdks/v4-sdk/src/utils/internalConstants.ts b/sdks/v4-sdk/src/utils/internalConstants.ts new file mode 100644 index 000000000..71b15b8b6 --- /dev/null +++ b/sdks/v4-sdk/src/utils/internalConstants.ts @@ -0,0 +1,20 @@ +import JSBI from 'jsbi' +import { constants } from 'ethers' + +// constants used internally but not expected to be used externally +export const ADDRESS_ZERO = constants.AddressZero +export const NEGATIVE_ONE = JSBI.BigInt(-1) +export const ZERO = JSBI.BigInt(0) +export const ONE = JSBI.BigInt(1) +export const ONE_ETHER = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(18)) + +// used in liquidity amount math +export const Q96 = JSBI.exponentiate(JSBI.BigInt(2), JSBI.BigInt(96)) +export const Q192 = JSBI.exponentiate(Q96, JSBI.BigInt(2)) + +// pool setup +export const FEE_AMOUNT_LOW = 100 +export const FEE_AMOUNT_MEDIUM = 3000 +export const FEE_AMOUNT_HIGHEST = 10_000 +export const TICK_SPACING_TEN = 10 +export const TICK_SPACING_SIXTY = 60 diff --git a/sdks/v4-sdk/src/utils/sortsBefore.ts b/sdks/v4-sdk/src/utils/sortsBefore.ts new file mode 100644 index 000000000..a68712cf8 --- /dev/null +++ b/sdks/v4-sdk/src/utils/sortsBefore.ts @@ -0,0 +1,7 @@ +import { Currency } from '@uniswap/sdk-core' + +export function sortsBefore(currencyA: Currency, currencyB: Currency): boolean { + if (currencyA.isNative) return true + if (currencyB.isNative) return false + return currencyA.wrapped.sortsBefore(currencyB.wrapped) +} diff --git a/sdks/v4-sdk/tsconfig.json b/sdks/v4-sdk/tsconfig.json new file mode 100644 index 000000000..78ecb1b72 --- /dev/null +++ b/sdks/v4-sdk/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["src"], + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "importHelpers": true, + "declaration": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/yarn.lock b/yarn.lock index 2542e96c5..9ee9ea48e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4630,7 +4630,7 @@ __metadata: languageName: unknown linkType: soft -"@uniswap/sdk-core@npm:^5.0.0, @uniswap/sdk-core@npm:^5.3.1": +"@uniswap/sdk-core@npm:^5.0.0, @uniswap/sdk-core@npm:^5.3.0, @uniswap/sdk-core@npm:^5.3.1": version: 5.3.1 resolution: "@uniswap/sdk-core@npm:5.3.1" dependencies: @@ -4815,6 +4815,22 @@ __metadata: languageName: node linkType: hard +"@uniswap/v3-sdk@npm:3.12.0": + version: 3.12.0 + resolution: "@uniswap/v3-sdk@npm:3.12.0" + dependencies: + "@ethersproject/abi": ^5.5.0 + "@ethersproject/solidity": ^5.0.9 + "@uniswap/sdk-core": ^5.3.0 + "@uniswap/swap-router-contracts": ^1.3.0 + "@uniswap/v3-periphery": ^1.1.1 + "@uniswap/v3-staker": 1.0.0 + tiny-invariant: ^1.1.0 + tiny-warning: ^1.0.3 + checksum: d8d507a8ed302c983217575bcead36700c4ee823db98ea9281cf8f9e5dfb9a5c49da111199f28f65f43ccb4c4dc2996d8a120128076b622b560fe780f8bb8db5 + languageName: node + linkType: hard + "@uniswap/v3-sdk@npm:^3.11.2, @uniswap/v3-sdk@npm:^3.13.1": version: 3.13.1 resolution: "@uniswap/v3-sdk@npm:3.13.1" @@ -4861,6 +4877,31 @@ __metadata: languageName: node linkType: hard +"@uniswap/v4-sdk@workspace:sdks/v4-sdk": + version: 0.0.0-use.local + resolution: "@uniswap/v4-sdk@workspace:sdks/v4-sdk" + dependencies: + "@ethersproject/solidity": ^5.0.9 + "@types/chai": ^4.3.3 + "@types/mocha": ^9.1.1 + "@types/node": ^18.7.16 + "@types/node-fetch": ^2.6.2 + "@uniswap/sdk-core": ^5.3.1 + "@uniswap/v3-sdk": 3.12.0 + chai: ^4.3.6 + dotenv: ^16.0.3 + eslint-plugin-prettier: ^3.4.1 + prettier: ^2.4.1 + tiny-invariant: ^1.1.0 + tiny-warning: ^1.0.3 + ts-node: ^10.9.1 + tsdx: ^0.14.1 + tslib: ^2.3.0 + typedoc: ^0.21.2 + typescript: ^4.3.3 + languageName: unknown + linkType: soft + "JSONStream@npm:^1.0.4": version: 1.3.5 resolution: "JSONStream@npm:1.3.5"