├── .CONTRIBUTING.md ├── .eslintrc ├── .github └── workflows │ ├── docs.yml │ ├── lint.yml │ ├── semgrep.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .vscode └── launch.json ├── CODEOWNERS ├── LICENSE ├── README.md ├── bin ├── app.ts ├── constants.ts └── stacks │ ├── analytics-stack.ts │ ├── api-stack.ts │ ├── dashboard-stack.ts │ └── pair-dashboard-stack.ts ├── cdk.context.json ├── cdk.json ├── hardhat.config.js ├── jest.config.js ├── lib ├── abis │ ├── ExclusiveDutchOrderReactor.json │ ├── Permit2.json │ ├── RelayOrderReactor.json │ ├── Router.json │ ├── V2DutchOrderReactor.json │ └── erc20.json ├── config │ └── ChainConfigManager.ts ├── constants.ts ├── entities │ ├── context │ │ ├── ClassicQuoteContext.ts │ │ ├── DutchQuoteContext.ts │ │ ├── RelayQuoteContext.ts │ │ └── index.ts │ ├── index.ts │ ├── quote │ │ ├── ClassicQuote.ts │ │ ├── DutchQuote.ts │ │ ├── DutchQuoteFactory.ts │ │ ├── DutchV1Quote.ts │ │ ├── DutchV2Quote.ts │ │ ├── RelayQuote.ts │ │ └── index.ts │ └── request │ │ ├── ClassicRequest.ts │ │ ├── DutchV1Request.ts │ │ ├── DutchV2Request.ts │ │ ├── RelayRequest.ts │ │ └── index.ts ├── fetchers │ ├── Permit2Fetcher.ts │ ├── PortionFetcher.ts │ └── TokenFetcher.ts ├── handlers │ ├── base │ │ ├── api-handler.ts │ │ ├── base.ts │ │ └── index.ts │ ├── index.ts │ └── quote │ │ ├── handler.ts │ │ ├── index.ts │ │ ├── injector.ts │ │ └── schema.ts ├── providers │ ├── index.ts │ ├── quoters │ │ ├── RfqQuoter.ts │ │ ├── RoutingApiQuoter.ts │ │ ├── helpers.ts │ │ └── index.ts │ └── syntheticStatusProvider.ts └── util │ ├── errors.ts │ ├── log.ts │ ├── metrics-pair.ts │ ├── metrics.ts │ ├── nonce.ts │ ├── permit2.ts │ ├── portion.ts │ ├── preconditions.ts │ ├── quoteMath.ts │ ├── stage.ts │ ├── time.ts │ └── validator.ts ├── package.json ├── swagger.json ├── test ├── constants.ts ├── integ │ ├── base.test.ts │ ├── quote-classic.test.ts │ ├── quote-gouda.test.ts │ ├── quote-relay.test.ts │ └── quote-xv2.test.ts ├── types.ts ├── unit │ ├── entities │ │ ├── ClassicQuote.test.ts │ │ ├── DutchQuote.test.ts │ │ ├── DutchV2Quote.test.ts │ │ └── RelayQuote.test.ts │ ├── lib │ │ ├── config │ │ │ └── ChainConfigManager.test.ts │ │ ├── constants.test.ts │ │ ├── entities │ │ │ ├── context │ │ │ │ ├── ClassicQuoteContext.test.ts │ │ │ │ ├── DutchQuoteContext.test.ts │ │ │ │ ├── DutchV2QuoteContext.test.ts │ │ │ │ ├── QuoteContextHandler.test.ts │ │ │ │ └── RelayQuoteContext.test.ts │ │ │ ├── quoteRequest.test.ts │ │ │ └── quoteResponse.test.ts │ │ ├── fetchers │ │ │ ├── Permit2Fetcher.test.ts │ │ │ ├── PortionFetcher.test.ts │ │ │ └── TokenFetcher.test.ts │ │ ├── handlers │ │ │ └── quote │ │ │ │ ├── handler.test.ts │ │ │ │ └── schema.test.ts │ │ └── util │ │ │ ├── permit2.test.ts │ │ │ └── quoteMath.test.ts │ └── providers │ │ └── quoters │ │ ├── RfqQuoter.test.ts │ │ └── RoutingApiQuoter.test.ts └── utils │ ├── fixtures.ts │ ├── forkAndFund.ts │ ├── getBalanceAndApprove.ts │ ├── quoteResponse.ts │ └── tokens.ts ├── tsconfig.cdk.json ├── tsconfig.json └── yarn.lock /.CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Adding swagger documentation 2 | 3 | When changing the parameters or endpoints of the API, please update the swagger documentation. 4 | 5 | 1. Visit the open source [editor](https://editor.swagger.io/) 6 | 2. Copy and paste the swagger from [swagger.json](./swagger.json) in the `docs` folder 7 | 3. The editor will ask you to transform the file into yaml, click yes 8 | 4. Make your changes and make sure the swagger is valid. The editor when if it is not 9 | 5. Click on `File`, then `Convert and save as json` and update the [swagger.json](./swagger.json) file -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "sourceType": "module" 6 | }, 7 | "env": { 8 | "node": true, 9 | "es6": true 10 | }, 11 | "plugins": ["@typescript-eslint", "import", "jest"], 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/eslint-recommended", 15 | "plugin:@typescript-eslint/recommended" 16 | ], 17 | "rules": { 18 | "@typescript-eslint/no-this-alias": [ 19 | "error", 20 | { 21 | "allowDestructuring": true, // Allow `const { props, state } = this`; false by default 22 | "allowedNames": [ 23 | "self" // Allow `const self= this`; `[]` by default 24 | ] 25 | } 26 | ], 27 | "import/first": "error", 28 | "import/newline-after-import": "error", 29 | "import/no-duplicates": "error", 30 | "@typescript-eslint/no-empty-interface": "off", 31 | "@typescript-eslint/no-empty-function": "warn", 32 | "@typescript-eslint/ban-types": "warn", 33 | "@typescript-eslint/no-unused-vars": "warn", 34 | "@typescript-eslint/ban-ts-comment": "off", 35 | "jest/no-disabled-tests": "warn", 36 | "jest/no-focused-tests": "error" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Validate docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | validate-docs: 11 | name: validate-docs 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out Git repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Run swagger validation 19 | uses: readmeio/rdme@51a80867c45de15e2b41af0c4bd5bbc61b932804 20 | with: 21 | rdme: openapi:validate swagger.json 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | run-linters: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out Git repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.x 22 | registry-url: https://registry.npmjs.org 23 | 24 | - name: Install dependencies 25 | run: | 26 | npm config set '//registry.npmjs.org/:_authToken' "${{ secrets.NPM_AUTH_TOKEN }}" \ 27 | && yarn install --frozen-lockfile 28 | 29 | - name: Run linters 30 | run: yarn lint 31 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | name: Semgrep 2 | on: 3 | workflow_dispatch: {} 4 | pull_request: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | # random HH:MM to avoid a load spike on GitHub Actions at 00:00 11 | - cron: '47 17 * * *' 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | container: 19 | image: returntocorp/semgrep 20 | if: (github.actor != 'dependabot[bot]') 21 | steps: 22 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 23 | - run: semgrep ci 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: test 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out Git repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.x 22 | registry-url: https://registry.npmjs.org 23 | 24 | - name: Install dependencies 25 | run: | 26 | npm config set '//registry.npmjs.org/:_authToken' "${{ secrets.NPM_AUTH_TOKEN }}" \ 27 | && yarn install --frozen-lockfile && yarn build 28 | 29 | - name: Install dependencies 30 | run: yarn test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | .env.test 65 | 66 | .cache 67 | dist/ 68 | cdk.out/ 69 | idea/ 70 | .idea/ 71 | cache/ 72 | lib/types/ 73 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.17.1 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/.bin/jest", 11 | "--runInBand" 12 | ], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ConjunctiveNormalForm @zhongeric @marktoda @hensha256 @codyborn 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unified Routing API 2 | 3 | [![Lint](https://github.com/Uniswap/unified-routing-api/actions/workflows/lint.yml/badge.svg)](https://github.com/Uniswap/unified-routing-api/actions/workflows/lint.yml) 4 | [![Unit Tests](https://github.com/Uniswap/unified-routing-api/actions/workflows/test.yml/badge.svg)](https://github.com/Uniswap/unified-routing-api/actions/workflows/test.yml) 5 | 6 | Unified Routing API is a service to route and parameterize all Uniswap trade types. 7 | 8 | Currently supported routing types: 9 | - Classic: routes using [Uniswap Routing API](https://github.com/uniswap/routing-api) against the Uniswap v2 and Uniswap v3 AMM protocols 10 | - DutchLimit: parameterizes a UniswapX Dutch Order to be executed by off-chain fillers 11 | 12 | ## Deployment 13 | 14 | ### Dev Environment 15 | 16 | 1. Create a .env file with the necessary dependencies 17 | 18 | ``` 19 | PARAMETERIZATION_API_URL=<> 20 | ROUTING_API_URL=<> 21 | SERVICE_URL=<> 22 | UNISWAP_API='' 23 | ARCHIVE_NODE_RPC=<> 24 | ENABLE_PORTION="true" 25 | ``` 26 | 27 | To deploy to your own AWS account, 28 | 29 | ``` 30 | yarn && yarn build 31 | ``` 32 | 33 | then 34 | 35 | ``` 36 | cdk deploy UnifiedRoutingStack 37 | ``` 38 | 39 | after successful deployment, you should see something like 40 | 41 | ``` 42 | ✅ UnifiedRoutingStack 43 | 44 | ✨ Deployment time: 93.78s 45 | 46 | Outputs: 47 | UnifiedRoutingStack.UnifiedRoutingEndpointEE9D7262 = 48 | UnifiedRoutingStack.Url = 49 | ``` 50 | 51 | The project currently has a `GET hello-world` Api Gateway<>Lambda integration set up: 52 | 53 | ``` 54 | ❯ curl /prod/quote/hello-world 55 | "hello world"% 56 | ``` 57 | 58 | ## Integration Tests 59 | 60 | 1. Deploy your API using the intructions above. 61 | 62 | 2. Add your deployed API url as `UNISWAP_API` and the `ARCHIVE_NODE_RPC` pulled from team secrets to your `.env` file. 63 | 64 | ``` 65 | UNISWAP_API='' 66 | ARCHIVE_NODE_RPC='' 67 | ``` 68 | 69 | 3. Run the tests with: 70 | ``` 71 | yarn test:integ 72 | ``` 73 | -------------------------------------------------------------------------------- /bin/constants.ts: -------------------------------------------------------------------------------- 1 | // IMPORANT: Once this has been changed once from the original value of 'Template', 2 | // do not change again. Changing would cause every piece of infrastructure to change 3 | // name, and thus be redeployed. Should be camel case and contain no non-alphanumeric characters. 4 | export const SERVICE_NAME = 'UnifiedRouting'; 5 | export const SEV3_P99LATENCY_MS = 7000; 6 | export const SEV2_P99LATENCY_MS = 10000; 7 | export const SEV3_P90LATENCY_MS = 5500; 8 | export const SEV2_P90LATENCY_MS = 8500; 9 | export const ROUTING_API_MAX_LATENCY_MS = 4000; 10 | export const LATENCY_ALARM_DEFAULT_PERIOD_MIN = 20; 11 | -------------------------------------------------------------------------------- /bin/stacks/analytics-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { aws_lambda_nodejs, aws_logs } from 'aws-cdk-lib'; 3 | import { Construct } from 'constructs'; 4 | 5 | export interface AnalyticsStackProps extends cdk.NestedStackProps { 6 | quoteLambda: aws_lambda_nodejs.NodejsFunction; 7 | envVars: Record; 8 | } 9 | 10 | /* 11 | * Send quote request and response logs to x-account firehose streams 12 | */ 13 | export class AnalyticsStack extends cdk.NestedStack { 14 | constructor(scope: Construct, id: string, props: AnalyticsStackProps) { 15 | super(scope, id, props); 16 | const { envVars, quoteLambda } = props; 17 | 18 | if (envVars.REQUEST_DESTINATION_ARN) { 19 | new aws_logs.CfnSubscriptionFilter(this, 'RequestSub', { 20 | destinationArn: envVars.REQUEST_DESTINATION_ARN, 21 | filterPattern: '{ $.eventType = "UnifiedRoutingQuoteRequest" }', 22 | logGroupName: quoteLambda.logGroup.logGroupName, 23 | }); 24 | } 25 | 26 | if (envVars.RESPONSE_DESTINATION_ARN) { 27 | new aws_logs.CfnSubscriptionFilter(this, 'ResponseSub', { 28 | destinationArn: envVars.RESPONSE_DESTINATION_ARN, 29 | filterPattern: '{ $.eventType = "UnifiedRoutingQuoteResponse" }', 30 | logGroupName: quoteLambda.logGroup.logGroupName, 31 | }); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /bin/stacks/pair-dashboard-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as aws_cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; 3 | import { Construct } from 'constructs'; 4 | import _ from 'lodash'; 5 | import { MetricPair, trackedPairs } from '../../lib/util/metrics-pair'; 6 | import { METRIC_NAMESPACE, METRIC_SERVICE_NAME } from './dashboard-stack'; 7 | 8 | 9 | export interface DashboardProps extends cdk.NestedStackProps {} 10 | 11 | export class XPairDashboardStack extends cdk.NestedStack { 12 | constructor(scope: Construct, name: string, props: DashboardProps) { 13 | super(scope, name, props); 14 | 15 | const region = cdk.Stack.of(this).region; 16 | 17 | let x = 0; 18 | let y = 0; 19 | new aws_cloudwatch.CfnDashboard(this, 'UnifiedRoutingAPIXPairsDashboard', { 20 | dashboardName: `UnifiedRoutingUniswapXPairsDashboard`, 21 | dashboardBody: JSON.stringify({ 22 | periodOverride: 'inherit', 23 | widgets: _.flatMap(trackedPairs, (trackedPair: MetricPair) => { 24 | const bucketKeys = trackedPair.metricKeys(); 25 | 26 | let widgets: any[] = []; 27 | 28 | for (const bucketKey of bucketKeys) { 29 | const title = bucketKey[0].substring(0, bucketKey[0].lastIndexOf('-')); 30 | const period = 900; 31 | const stat = 'Sum'; 32 | 33 | const line = { 34 | height: 5, 35 | width: 4, 36 | y, 37 | x, 38 | type: 'metric', 39 | properties: { 40 | metrics: _.map(bucketKey, (key) => [ 41 | METRIC_NAMESPACE, 42 | key, 43 | 'Service', 44 | METRIC_SERVICE_NAME, 45 | { region, label: key.substring(key.lastIndexOf('-') + 1) }, 46 | ]), 47 | view: 'timeSeries', 48 | stacked: true, 49 | region, 50 | period, 51 | stat, 52 | title, 53 | }, 54 | }; 55 | 56 | x = x + 4; 57 | y = x > 19 ? y + 1 : y; 58 | x = x > 19 ? 0 : x; 59 | 60 | const pie = { 61 | type: 'metric', 62 | x, 63 | y, 64 | width: 3, 65 | height: 5, 66 | properties: { 67 | metrics: _.map(bucketKey, (key) => [ 68 | METRIC_NAMESPACE, 69 | key, 70 | 'Service', 71 | METRIC_SERVICE_NAME, 72 | { region, label: key.substring(key.lastIndexOf('-') + 1) }, 73 | ]), 74 | view: 'pie', 75 | region, 76 | period, 77 | stat, 78 | title, 79 | }, 80 | }; 81 | x = x + 3; 82 | y = x > 19 ? y + 1 : y; 83 | x = x > 19 ? 0 : x; 84 | 85 | widgets = [...widgets, line, pie]; 86 | } 87 | 88 | return widgets; 89 | }), 90 | }), 91 | }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "acknowledged-issue-numbers": [ 3 | 25356 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --project=tsconfig.cdk.json bin/app.ts", 3 | "context": {} 4 | } 5 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require('@nomiclabs/hardhat-ethers') 2 | require('dotenv').config() 3 | 4 | module.exports = { 5 | defaultNetwork: 'hardhat', 6 | networks: { 7 | hardhat: { 8 | chainId: 1, 9 | forking: { 10 | enabled: true, 11 | url: `${process.env.ARCHIVE_NODE_RPC}`, 12 | }, 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const ts_preset = require('ts-jest/jest-preset'); 2 | 3 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 4 | module.exports = { 5 | ...ts_preset, 6 | testEnvironment: 'node', 7 | testPathIgnorePatterns: ['bin', 'dist'], 8 | collectCoverageFrom: ['**/*.ts', '!**/build/**', '!**/node_modules/**', '!**/dist/**', '!**/bin/**'], 9 | transform: { 10 | // Use swc to speed up ts-jest's sluggish compilation times. 11 | // Using this cuts the initial time to compile from 6-12 seconds to 12 | // ~1 second consistently. 13 | // Inspiration from: https://github.com/kulshekhar/ts-jest/issues/259#issuecomment-1332269911 14 | // 15 | // https://swc.rs/docs/usage/jest#usage 16 | '^.+\\.(t|j)s?$': '@swc/jest', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/abis/V2DutchOrderReactor.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "constructor", 4 | "inputs": [ 5 | { "name": "_permit2", "type": "address", "internalType": "contract IPermit2" }, 6 | { "name": "_protocolFeeOwner", "type": "address", "internalType": "address" } 7 | ], 8 | "stateMutability": "nonpayable" 9 | }, 10 | { "type": "receive", "stateMutability": "payable" }, 11 | { 12 | "type": "function", 13 | "name": "execute", 14 | "inputs": [ 15 | { 16 | "name": "order", 17 | "type": "tuple", 18 | "internalType": "struct SignedOrder", 19 | "components": [ 20 | { "name": "order", "type": "bytes", "internalType": "bytes" }, 21 | { "name": "sig", "type": "bytes", "internalType": "bytes" } 22 | ] 23 | } 24 | ], 25 | "outputs": [], 26 | "stateMutability": "payable" 27 | }, 28 | { 29 | "type": "function", 30 | "name": "executeBatch", 31 | "inputs": [ 32 | { 33 | "name": "orders", 34 | "type": "tuple[]", 35 | "internalType": "struct SignedOrder[]", 36 | "components": [ 37 | { "name": "order", "type": "bytes", "internalType": "bytes" }, 38 | { "name": "sig", "type": "bytes", "internalType": "bytes" } 39 | ] 40 | } 41 | ], 42 | "outputs": [], 43 | "stateMutability": "payable" 44 | }, 45 | { 46 | "type": "function", 47 | "name": "executeBatchWithCallback", 48 | "inputs": [ 49 | { 50 | "name": "orders", 51 | "type": "tuple[]", 52 | "internalType": "struct SignedOrder[]", 53 | "components": [ 54 | { "name": "order", "type": "bytes", "internalType": "bytes" }, 55 | { "name": "sig", "type": "bytes", "internalType": "bytes" } 56 | ] 57 | }, 58 | { "name": "callbackData", "type": "bytes", "internalType": "bytes" } 59 | ], 60 | "outputs": [], 61 | "stateMutability": "payable" 62 | }, 63 | { 64 | "type": "function", 65 | "name": "executeWithCallback", 66 | "inputs": [ 67 | { 68 | "name": "order", 69 | "type": "tuple", 70 | "internalType": "struct SignedOrder", 71 | "components": [ 72 | { "name": "order", "type": "bytes", "internalType": "bytes" }, 73 | { "name": "sig", "type": "bytes", "internalType": "bytes" } 74 | ] 75 | }, 76 | { "name": "callbackData", "type": "bytes", "internalType": "bytes" } 77 | ], 78 | "outputs": [], 79 | "stateMutability": "payable" 80 | }, 81 | { 82 | "type": "function", 83 | "name": "feeController", 84 | "inputs": [], 85 | "outputs": [{ "name": "", "type": "address", "internalType": "contract IProtocolFeeController" }], 86 | "stateMutability": "view" 87 | }, 88 | { 89 | "type": "function", 90 | "name": "owner", 91 | "inputs": [], 92 | "outputs": [{ "name": "", "type": "address", "internalType": "address" }], 93 | "stateMutability": "view" 94 | }, 95 | { 96 | "type": "function", 97 | "name": "permit2", 98 | "inputs": [], 99 | "outputs": [{ "name": "", "type": "address", "internalType": "contract IPermit2" }], 100 | "stateMutability": "view" 101 | }, 102 | { 103 | "type": "function", 104 | "name": "setProtocolFeeController", 105 | "inputs": [{ "name": "_newFeeController", "type": "address", "internalType": "address" }], 106 | "outputs": [], 107 | "stateMutability": "nonpayable" 108 | }, 109 | { 110 | "type": "function", 111 | "name": "transferOwnership", 112 | "inputs": [{ "name": "newOwner", "type": "address", "internalType": "address" }], 113 | "outputs": [], 114 | "stateMutability": "nonpayable" 115 | }, 116 | { 117 | "type": "event", 118 | "name": "Fill", 119 | "inputs": [ 120 | { "name": "orderHash", "type": "bytes32", "indexed": true, "internalType": "bytes32" }, 121 | { "name": "filler", "type": "address", "indexed": true, "internalType": "address" }, 122 | { "name": "swapper", "type": "address", "indexed": true, "internalType": "address" }, 123 | { "name": "nonce", "type": "uint256", "indexed": false, "internalType": "uint256" } 124 | ], 125 | "anonymous": false 126 | }, 127 | { 128 | "type": "event", 129 | "name": "OwnershipTransferred", 130 | "inputs": [ 131 | { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, 132 | { "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" } 133 | ], 134 | "anonymous": false 135 | }, 136 | { 137 | "type": "event", 138 | "name": "ProtocolFeeControllerSet", 139 | "inputs": [ 140 | { "name": "oldFeeController", "type": "address", "indexed": false, "internalType": "address" }, 141 | { "name": "newFeeController", "type": "address", "indexed": false, "internalType": "address" } 142 | ], 143 | "anonymous": false 144 | }, 145 | { "type": "error", "name": "DeadlineBeforeEndTime", "inputs": [] }, 146 | { 147 | "type": "error", 148 | "name": "DuplicateFeeOutput", 149 | "inputs": [{ "name": "duplicateToken", "type": "address", "internalType": "address" }] 150 | }, 151 | { "type": "error", "name": "EndTimeBeforeStartTime", "inputs": [] }, 152 | { 153 | "type": "error", 154 | "name": "FeeTooLarge", 155 | "inputs": [ 156 | { "name": "token", "type": "address", "internalType": "address" }, 157 | { "name": "amount", "type": "uint256", "internalType": "uint256" }, 158 | { "name": "recipient", "type": "address", "internalType": "address" } 159 | ] 160 | }, 161 | { "type": "error", "name": "IncorrectAmounts", "inputs": [] }, 162 | { "type": "error", "name": "InputAndOutputDecay", "inputs": [] }, 163 | { "type": "error", "name": "InsufficientEth", "inputs": [] }, 164 | { "type": "error", "name": "InvalidCosignature", "inputs": [] }, 165 | { "type": "error", "name": "InvalidCosignerInput", "inputs": [] }, 166 | { "type": "error", "name": "InvalidCosignerOutput", "inputs": [] }, 167 | { 168 | "type": "error", 169 | "name": "InvalidFeeToken", 170 | "inputs": [{ "name": "feeToken", "type": "address", "internalType": "address" }] 171 | }, 172 | { "type": "error", "name": "InvalidReactor", "inputs": [] }, 173 | { "type": "error", "name": "NativeTransferFailed", "inputs": [] }, 174 | { "type": "error", "name": "NoExclusiveOverride", "inputs": [] } 175 | ] 176 | -------------------------------------------------------------------------------- /lib/abis/erc20.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "constant": true, 4 | "inputs": [], 5 | "name": "name", 6 | "outputs": [{ "name": "", "type": "string" }], 7 | "payable": false, 8 | "stateMutability": "view", 9 | "type": "function" 10 | }, 11 | { 12 | "constant": false, 13 | "inputs": [ 14 | { "name": "_spender", "type": "address" }, 15 | { "name": "_value", "type": "uint256" } 16 | ], 17 | "name": "approve", 18 | "outputs": [{ "name": "", "type": "bool" }], 19 | "payable": false, 20 | "stateMutability": "nonpayable", 21 | "type": "function" 22 | }, 23 | { 24 | "constant": true, 25 | "inputs": [], 26 | "name": "totalSupply", 27 | "outputs": [{ "name": "", "type": "uint256" }], 28 | "payable": false, 29 | "stateMutability": "view", 30 | "type": "function" 31 | }, 32 | { 33 | "constant": false, 34 | "inputs": [ 35 | { "name": "_from", "type": "address" }, 36 | { "name": "_to", "type": "address" }, 37 | { "name": "_value", "type": "uint256" } 38 | ], 39 | "name": "transferFrom", 40 | "outputs": [{ "name": "", "type": "bool" }], 41 | "payable": false, 42 | "stateMutability": "nonpayable", 43 | "type": "function" 44 | }, 45 | { 46 | "constant": true, 47 | "inputs": [], 48 | "name": "decimals", 49 | "outputs": [{ "name": "", "type": "uint8" }], 50 | "payable": false, 51 | "stateMutability": "view", 52 | "type": "function" 53 | }, 54 | { 55 | "constant": true, 56 | "inputs": [{ "name": "_owner", "type": "address" }], 57 | "name": "balanceOf", 58 | "outputs": [{ "name": "balance", "type": "uint256" }], 59 | "payable": false, 60 | "stateMutability": "view", 61 | "type": "function" 62 | }, 63 | { 64 | "constant": true, 65 | "inputs": [], 66 | "name": "symbol", 67 | "outputs": [{ "name": "", "type": "string" }], 68 | "payable": false, 69 | "stateMutability": "view", 70 | "type": "function" 71 | }, 72 | { 73 | "constant": false, 74 | "inputs": [ 75 | { "name": "_to", "type": "address" }, 76 | { "name": "_value", "type": "uint256" } 77 | ], 78 | "name": "transfer", 79 | "outputs": [{ "name": "", "type": "bool" }], 80 | "payable": false, 81 | "stateMutability": "nonpayable", 82 | "type": "function" 83 | }, 84 | { 85 | "constant": true, 86 | "inputs": [ 87 | { "name": "_owner", "type": "address" }, 88 | { "name": "_spender", "type": "address" } 89 | ], 90 | "name": "allowance", 91 | "outputs": [{ "name": "", "type": "uint256" }], 92 | "payable": false, 93 | "stateMutability": "view", 94 | "type": "function" 95 | }, 96 | { "payable": true, "stateMutability": "payable", "type": "fallback" }, 97 | { 98 | "anonymous": false, 99 | "inputs": [ 100 | { "indexed": true, "name": "owner", "type": "address" }, 101 | { "indexed": true, "name": "spender", "type": "address" }, 102 | { "indexed": false, "name": "value", "type": "uint256" } 103 | ], 104 | "name": "Approval", 105 | "type": "event" 106 | }, 107 | { 108 | "anonymous": false, 109 | "inputs": [ 110 | { "indexed": true, "name": "from", "type": "address" }, 111 | { "indexed": true, "name": "to", "type": "address" }, 112 | { "indexed": false, "name": "value", "type": "uint256" } 113 | ], 114 | "name": "Transfer", 115 | "type": "event" 116 | } 117 | ] 118 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'ethers'; 2 | import { forcePortion } from './util/portion'; 3 | 4 | export const DEFAULT_SLIPPAGE_TOLERANCE = '0.5'; // 0.5% 5 | export const DEFAULT_ROUTING_API_DEADLINE = 600; // 10 minutes 6 | export const BPS = 10000; // 100.00% 7 | export const NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; 8 | export const LARGE_TRADE_USD_THRESHOLD = 10_000; 9 | 10 | // Because we don't natively support ETH input and require users to wrap their ETH before swapping, 11 | // the user experience is significantly worse (requiring 3 user interactions). 12 | // Thus, we add a negative bias towards ETH input trades through adding a gas adjustment in an effort 13 | // to route users towards classic swaps unless UniswapX is significantly better. 14 | export const WETH_WRAP_GAS = 27938 * 3; // 27,938 warm deposit, 45,038 cold deposit 15 | export const WETH_WRAP_GAS_ALREADY_APPROVED = 27938 * 2; 16 | export const WETH_UNWRAP_GAS = 36000; 17 | 18 | export const DEFAULT_EXCLUSIVITY_OVERRIDE_BPS = BigNumber.from(100); // non-exclusive fillers must override price by this much 19 | export const UNISWAPX_BASE_GAS = 275000; // base gas overhead for filling an order through Gouda 20 | export const RELAY_BASE_GAS = 130_000; // base gas overhead for filling a relayed swap 21 | export const DEFAULT_START_TIME_BUFFER_SECS = 45; 22 | export const OPEN_QUOTE_START_TIME_BUFFER_SECS = 60; 23 | export const DEFAULT_AUCTION_PERIOD_SECS = 60; 24 | export const DEFAULT_DEADLINE_BUFFER_SECS = 12; 25 | export const DEFAULT_V2_DEADLINE_BUFFER_SECS = 30; 26 | 27 | export enum RoutingType { 28 | CLASSIC = 'CLASSIC', 29 | DUTCH_LIMIT = 'DUTCH_LIMIT', 30 | RELAY = 'RELAY', 31 | DUTCH_V2 = 'DUTCH_V2', 32 | } 33 | 34 | export enum QuoteType { 35 | CLASSIC, 36 | RFQ, 37 | SYNTHETIC, 38 | } 39 | 40 | export const DEFAULT_POSITIVE_CACHE_ENTRY_TTL = 300; // 5 minutes 41 | export const DEFAULT_NEGATIVE_CACHE_ENTRY_TTL = 300; // 5 minutes 42 | 43 | // we need this functional style of always enquirying the env var, 44 | // otherwise when assigning process.env.ENABLE_PORTION to const variables, 45 | // an update in process.env.ENABLE_PORTION will not be reflected in the lambda invocations until lambda recycles. 46 | export const getEnablePortionEnvVar = () => process.env.ENABLE_PORTION; 47 | 48 | export const uraEnablePortion = () => { 49 | if (forcePortion) { 50 | return true; 51 | } else { 52 | return getEnablePortionEnvVar() === 'true'; 53 | } 54 | }; 55 | 56 | export const frontendEnablePortion = (sendPortionFlag?: boolean) => { 57 | return sendPortionFlag; 58 | }; 59 | 60 | export const frontendAndUraEnablePortion = (sendPortionFlag?: boolean) => { 61 | return frontendEnablePortion(sendPortionFlag) && uraEnablePortion(); 62 | }; 63 | -------------------------------------------------------------------------------- /lib/entities/context/ClassicQuoteContext.ts: -------------------------------------------------------------------------------- 1 | import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'; 2 | import Logger from 'bunyan'; 3 | import { QuoteByKey, QuoteContext } from '.'; 4 | import { RoutingType } from '../../constants'; 5 | import { ClassicQuote, ClassicRequest, Quote, QuoteRequest } from '../../entities'; 6 | import { Permit2Fetcher } from '../../fetchers/Permit2Fetcher'; 7 | 8 | export type ClassicQuoteContextProviders = { 9 | permit2Fetcher: Permit2Fetcher; 10 | }; 11 | 12 | // manages context around a single top level classic quote request 13 | export class ClassicQuoteContext implements QuoteContext { 14 | private log: Logger; 15 | private permit2Fetcher: Permit2Fetcher; 16 | 17 | constructor(_log: Logger, public request: ClassicRequest, providers: ClassicQuoteContextProviders) { 18 | this.log = _log.child({ context: 'ClassicQuoteContext' }); 19 | this.permit2Fetcher = providers.permit2Fetcher; 20 | } 21 | 22 | // classic quotes have no explicit dependencies and can be resolved by themselves 23 | dependencies(): QuoteRequest[] { 24 | return [this.request]; 25 | } 26 | 27 | async resolve(dependencies: QuoteByKey): Promise { 28 | this.log.info({ dependencies }, 'Resolving classic quote'); 29 | const quote = dependencies[this.request.key()]; 30 | 31 | if (!quote) return null; 32 | 33 | if (quote.request.info.swapper && quote.routingType === RoutingType.CLASSIC) { 34 | const allowance = await this.permit2Fetcher.fetchAllowance( 35 | quote.request.info.tokenInChainId, 36 | quote.request.info.swapper, 37 | quote.request.info.tokenIn, 38 | UNIVERSAL_ROUTER_ADDRESS(quote.request.info.tokenInChainId) 39 | ); 40 | 41 | (quote as ClassicQuote).setAllowanceData(allowance); 42 | } 43 | 44 | return quote; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/entities/context/RelayQuoteContext.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan'; 2 | import { ethers } from 'ethers'; 3 | import { QuoteByKey, QuoteContext } from '.'; 4 | import { ClassicQuote, ClassicRequest, Quote, QuoteRequest, RelayQuote } from '..'; 5 | import { RoutingType } from '../../constants'; 6 | import { RelayRequest } from '../request/RelayRequest'; 7 | 8 | export type RelayQuoteContextProviders = { 9 | rpcProvider: ethers.providers.StaticJsonRpcProvider; 10 | }; 11 | 12 | // manages context around a single top level relay quote request 13 | export class RelayQuoteContext implements QuoteContext { 14 | routingType: RoutingType.RELAY; 15 | private log: Logger; 16 | 17 | public requestKey: string; 18 | public classicKey: string; 19 | public routeToNativeKey: string; 20 | public needsRouteToNative: boolean; 21 | 22 | constructor(_log: Logger, public request: RelayRequest, _providers: RelayQuoteContextProviders) { 23 | this.log = _log.child({ context: 'RelayQuoteContext' }); 24 | this.requestKey = this.request.key(); 25 | } 26 | 27 | // Relay quotes have one external dependencies: 28 | // - classic request to be built from 29 | dependencies(): QuoteRequest[] { 30 | // built classic request with all the classic config attributes 31 | const classicRequest = new ClassicRequest(this.request.info, { 32 | ...this.request.config, 33 | // add overrides to prefer top level swapper over nested recipient field in classic config 34 | simulateFromAddress: this.request.info.swapper, 35 | recipient: this.request.info.swapper, 36 | }); 37 | this.classicKey = classicRequest.key(); 38 | this.log.info({ classicRequest: classicRequest.info }, 'Adding base classic request'); 39 | 40 | return [this.request, classicRequest]; 41 | } 42 | 43 | async resolveHandler(dependencies: QuoteByKey): Promise { 44 | const classicQuote = dependencies[this.classicKey] as ClassicQuote; 45 | const relayQuote = dependencies[this.requestKey] as RelayQuote; 46 | 47 | const quote = await this.getRelayQuote(relayQuote, classicQuote); 48 | 49 | if (!quote) { 50 | this.log.warn('No Relay quote'); 51 | return null; 52 | } 53 | 54 | return quote; 55 | } 56 | 57 | // return either the relay quote or a constructed relay quote from classic dependency 58 | async resolve(dependencies: QuoteByKey): Promise { 59 | const quote = await this.resolveHandler(dependencies); 60 | if (!quote || (quote as RelayQuote).amountOut.eq(0)) return null; 61 | return quote; 62 | } 63 | 64 | async getRelayQuote(quote?: RelayQuote, classicQuote?: ClassicQuote): Promise { 65 | // No relay quote or classic quote 66 | if (!quote && !classicQuote) return null; 67 | if (!quote && classicQuote) { 68 | quote = RelayQuote.fromClassicQuote(this.request, classicQuote); 69 | } 70 | 71 | // if there is no quote, or its invalid for some reason, i.e. too much decay then return null 72 | if (!quote || !quote.validate()) return null; 73 | return quote; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/entities/context/index.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | 3 | import { RoutingType } from '../../constants'; 4 | import { ClassicConfig, ClassicRequest, DutchQuoteRequest, Quote, QuoteRequest, RelayRequest } from '../../entities'; 5 | 6 | import { Permit2Fetcher } from '../../fetchers/Permit2Fetcher'; 7 | import { SyntheticStatusProvider } from '../../providers'; 8 | import { log } from '../../util/log'; 9 | 10 | import { ClassicQuoteContext, DutchQuoteContext, RelayQuoteContext } from '.'; 11 | 12 | export * from './ClassicQuoteContext'; 13 | export * from './DutchQuoteContext'; 14 | export * from './RelayQuoteContext'; 15 | 16 | export type RequestByKey = { 17 | [key: string]: QuoteRequest; 18 | }; 19 | 20 | export type QuoteByKey = { 21 | [key: string]: Quote; 22 | }; 23 | 24 | export interface QuoteContext { 25 | // base request of the context 26 | request: QuoteRequest; 27 | 28 | dependencies(): QuoteRequest[]; 29 | 30 | // params should be in the same order as dependencies response 31 | // but resolved with quotes 32 | // returns null if no usable quote is resolved 33 | resolve(dependencies: QuoteByKey): Promise; 34 | } 35 | 36 | // handler for quote contexts and their dependencies 37 | export class QuoteContextManager { 38 | constructor(public contexts: QuoteContext[]) {} 39 | 40 | // deduplicate dependencies 41 | // note this prioritizes user-defined configs first 42 | // then synthetic generated configs 43 | getRequests(): QuoteRequest[] { 44 | const requestMap: RequestByKey = {}; 45 | // first add base user defined requests 46 | for (const context of this.contexts) { 47 | requestMap[context.request.key()] = context.request; 48 | } 49 | 50 | // add any extra dependency requests 51 | for (const context of this.contexts) { 52 | const dependencies = context.dependencies(); 53 | for (const request of dependencies) { 54 | const requestKey = request.key(); 55 | if (!requestMap[requestKey]) { 56 | requestMap[requestKey] = request; 57 | } else { 58 | requestMap[requestKey] = mergeRequests(requestMap[requestKey], request); 59 | } 60 | } 61 | } 62 | 63 | log.info({ requests: requestMap }, `Context requests`); 64 | 65 | return Object.values(requestMap); 66 | } 67 | 68 | // resolve quotes from quote contexts using quoted dependencies 69 | async resolveQuotes(quotes: Quote[]): Promise<(Quote | null)[]> { 70 | log.info({ quotes }, `Context quotes`); 71 | const allQuotes: QuoteByKey = {}; 72 | for (const quote of quotes) { 73 | allQuotes[quote.request.key()] = quote; 74 | } 75 | 76 | const resolved = await Promise.all( 77 | this.contexts.map((context) => { 78 | return context.resolve(allQuotes); 79 | }) 80 | ); 81 | 82 | return resolved; 83 | } 84 | } 85 | 86 | export type QuoteContextProviders = { 87 | permit2Fetcher: Permit2Fetcher; 88 | rpcProvider: ethers.providers.StaticJsonRpcProvider; 89 | syntheticStatusProvider: SyntheticStatusProvider; 90 | }; 91 | 92 | export function parseQuoteContexts(requests: QuoteRequest[], providers: QuoteContextProviders): QuoteContext[] { 93 | return requests.map((request) => { 94 | switch (request.routingType) { 95 | case RoutingType.DUTCH_LIMIT: 96 | case RoutingType.DUTCH_V2: 97 | return new DutchQuoteContext(log, request as DutchQuoteRequest, providers); 98 | case RoutingType.CLASSIC: 99 | return new ClassicQuoteContext(log, request as ClassicRequest, providers); 100 | case RoutingType.RELAY: 101 | return new RelayQuoteContext(log, request as RelayRequest, providers); 102 | default: 103 | throw new Error(`Unsupported routing type: ${request.routingType}`); 104 | } 105 | }); 106 | } 107 | 108 | export function mergeRequests(base: QuoteRequest, layer: QuoteRequest): QuoteRequest { 109 | if (base.routingType === RoutingType.CLASSIC && layer.routingType === RoutingType.CLASSIC) { 110 | const layerConfig: ClassicConfig = layer.config as ClassicConfig; 111 | const baseConfig: ClassicConfig = base.config as ClassicConfig; 112 | const config = Object.assign({}, baseConfig, { 113 | // if base does not specify simulation params but layer does, then we add them 114 | simulateFromAddress: baseConfig.simulateFromAddress ?? layerConfig.simulateFromAddress, 115 | deadline: baseConfig.deadline ?? layerConfig.deadline, 116 | recipient: baseConfig.recipient ?? layerConfig.recipient, 117 | // if base does not specify gasToken but layer does, then we add it 118 | gasToken: baseConfig.gasToken ?? layerConfig.gasToken, 119 | // otherwise defer to base 120 | }); 121 | return ClassicRequest.fromRequest(base.info, config); 122 | } else { 123 | // no special merging logic for dutch, just defer to base 124 | return base; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './context'; 2 | export * from './quote'; 3 | export * from './request'; 4 | -------------------------------------------------------------------------------- /lib/entities/quote/DutchQuoteFactory.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { BigNumber } from 'ethers'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import { ChainConfigManager } from '../../config/ChainConfigManager'; 5 | import { NATIVE_ADDRESS, QuoteType, RoutingType } from '../../constants'; 6 | import { Portion } from '../../fetchers/PortionFetcher'; 7 | import { log } from '../../util/log'; 8 | import { generateRandomNonce } from '../../util/nonce'; 9 | import { currentTimestampInMs } from '../../util/time'; 10 | import { DutchQuoteRequest, DutchV1Request, DutchV2Request } from '../request'; 11 | import { ClassicQuote } from './ClassicQuote'; 12 | import { DutchQuote, DutchQuoteConstructorArgs, DutchQuoteJSON, ParameterizationOptions } from './DutchQuote'; 13 | import { DutchV1Quote } from './DutchV1Quote'; 14 | import { DutchV2Quote } from './DutchV2Quote'; 15 | 16 | export class DutchQuoteFactory { 17 | // build a dutch quote from an RFQ response 18 | public static fromResponseBody( 19 | request: DutchQuoteRequest, 20 | body: DutchQuoteJSON, 21 | nonce?: string, 22 | portion?: Portion 23 | ): DutchQuote { 24 | // if it's exact out, we will explicitly define the amount out start to be the swapper's requested amount 25 | const amountOutStart = 26 | request.info.type === TradeType.EXACT_OUTPUT ? request.info.amount : BigNumber.from(body.amountOut); 27 | const { amountIn: amountInEnd, amountOut: amountOutEnd } = DutchQuote.applySlippage( 28 | { amountIn: BigNumber.from(body.amountIn), amountOut: amountOutStart }, 29 | request 30 | ); 31 | const args: DutchQuoteConstructorArgs = { 32 | createdAtMs: currentTimestampInMs(), 33 | request, 34 | tokenInChainId: body.chainId, 35 | tokenOutChainId: body.chainId, 36 | requestId: body.requestId, 37 | quoteId: body.quoteId, 38 | tokenIn: body.tokenIn, 39 | tokenOut: body.tokenOut, 40 | amountInStart: BigNumber.from(body.amountIn), 41 | amountInEnd, 42 | amountOutStart, 43 | amountOutEnd, 44 | swapper: body.swapper, 45 | quoteType: QuoteType.RFQ, 46 | filler: body.filler, 47 | nonce, 48 | portion, 49 | }; 50 | if (request instanceof DutchV1Request) { 51 | return new DutchV1Quote(args); 52 | } 53 | if (request instanceof DutchV2Request) { 54 | return new DutchV2Quote(args); 55 | } 56 | throw new Error(`Unexpected request type ${typeof request}`); 57 | } 58 | 59 | // build a synthetic dutch quote from a classic quote 60 | public static fromClassicQuote(request: DutchQuoteRequest, quote: ClassicQuote): DutchQuote { 61 | const chainId = request.info.tokenInChainId; 62 | const quoteConfig = ChainConfigManager.getQuoteConfig(chainId, request.routingType); 63 | const priceImprovedStartAmounts = DutchQuote.applyPriceImprovement( 64 | { amountIn: quote.amountInGasAdjusted, amountOut: quote.amountOutGasAdjusted }, 65 | request.info.type, 66 | request.config.priceImprovementBps ?? quoteConfig.priceImprovementBps 67 | ); 68 | const startAmounts = DutchQuote.applyPreSwapGasAdjustment(priceImprovedStartAmounts, quote); 69 | 70 | const gasAdjustedAmounts = DutchQuote.applyGasAdjustment(startAmounts, quote); 71 | const endAmounts = DutchQuote.applySlippage(gasAdjustedAmounts, request); 72 | 73 | log.info('Synthetic quote parameterization', { 74 | priceImprovedAmountIn: priceImprovedStartAmounts.amountIn.toString(), 75 | priceImprovedAmountOut: priceImprovedStartAmounts.amountOut.toString(), 76 | startAmountIn: startAmounts.amountIn.toString(), 77 | startAmountOut: startAmounts.amountOut.toString(), 78 | gasAdjustedAmountIn: gasAdjustedAmounts.amountIn.toString(), 79 | gasAdjustedAmountOut: gasAdjustedAmounts.amountOut.toString(), 80 | slippageAdjustedAmountIn: endAmounts.amountIn.toString(), 81 | slippageAdjustedAmountOut: endAmounts.amountOut.toString(), 82 | }); 83 | 84 | const args: DutchQuoteConstructorArgs = { 85 | createdAtMs: quote.createdAtMs, 86 | request, 87 | tokenInChainId: request.info.tokenInChainId, 88 | tokenOutChainId: request.info.tokenInChainId, 89 | requestId: request.info.requestId, 90 | quoteId: uuidv4(), // synthetic quote doesn't receive a quoteId from RFQ api, so generate one 91 | tokenIn: request.info.tokenIn, 92 | tokenOut: quote.request.info.tokenOut, 93 | amountInStart: startAmounts.amountIn, 94 | amountInEnd: endAmounts.amountIn, 95 | amountOutStart: startAmounts.amountOut, 96 | amountOutEnd: endAmounts.amountOut, 97 | swapper: request.config.swapper, 98 | quoteType: QuoteType.SYNTHETIC, 99 | filler: NATIVE_ADDRESS, // synthetic quote has no filler 100 | nonce: generateRandomNonce(), // synthetic quote has no nonce 101 | portion: quote.portion, 102 | }; 103 | if (request.routingType == RoutingType.DUTCH_LIMIT) { 104 | return new DutchV1Quote(args); 105 | } 106 | if (request.routingType == RoutingType.DUTCH_V2) { 107 | return new DutchV2Quote(args); 108 | } 109 | throw new Error(`Unexpected request type ${typeof request}`); 110 | } 111 | 112 | // reparameterize an RFQ quote with awareness of classic 113 | public static reparameterize( 114 | quote: DutchQuote, 115 | classic?: ClassicQuote, 116 | options?: ParameterizationOptions 117 | ): DutchQuote { 118 | if (!classic) return quote; 119 | 120 | const { amountIn: amountInStart, amountOut: amountOutStart } = DutchQuote.applyPreSwapGasAdjustment( 121 | { amountIn: quote.amountInStart, amountOut: quote.amountOutStart }, 122 | classic, 123 | options 124 | ); 125 | 126 | const classicAmounts = DutchQuote.applyGasAdjustment( 127 | { amountIn: classic.amountInGasAdjusted, amountOut: classic.amountOutGasAdjusted }, 128 | classic, 129 | quote.request.config.gasAdjustmentBps 130 | ); 131 | const { amountIn: amountInEnd, amountOut: amountOutEnd } = DutchQuote.applySlippage(classicAmounts, quote.request); 132 | 133 | log.info('RFQ quote parameterization', { 134 | startAmountIn: amountInStart.toString(), 135 | startAmountOut: amountOutStart.toString(), 136 | gasAdjustedClassicAmountIn: classicAmounts.amountIn.toString(), 137 | gasAdjustedClassicAmountOut: classicAmounts.amountOut.toString(), 138 | slippageAdjustedClassicAmountIn: amountInEnd.toString(), 139 | slippageAdjustedClassicAmountOut: amountOutEnd.toString(), 140 | }); 141 | 142 | const args: DutchQuoteConstructorArgs = { 143 | ...quote, 144 | tokenInChainId: quote.chainId, 145 | tokenOutChainId: quote.chainId, 146 | amountInStart, 147 | amountInEnd, 148 | amountOutStart, 149 | amountOutEnd, 150 | portion: quote.portion ?? classic.portion, 151 | derived: { 152 | largeTrade: options?.largeTrade ?? false, 153 | }, 154 | }; 155 | if (quote.request.routingType == RoutingType.DUTCH_LIMIT) { 156 | return new DutchV1Quote(args); 157 | } 158 | if (quote.request.routingType == RoutingType.DUTCH_V2) { 159 | return new DutchV2Quote(args); 160 | } 161 | throw new Error(`Unexpected request type ${typeof quote.request}`); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /lib/entities/quote/DutchV1Quote.ts: -------------------------------------------------------------------------------- 1 | import { DutchOrder, DutchOrderBuilder } from '@uniswap/uniswapx-sdk'; 2 | import { BigNumber, ethers } from 'ethers'; 3 | 4 | import { DutchQuote, DutchQuoteDataJSON, getPortionAdjustedOutputs, IQuote } from '.'; 5 | import { DutchV1Request } from '..'; 6 | import { ChainConfigManager } from '../../config/ChainConfigManager'; 7 | import { 8 | DEFAULT_AUCTION_PERIOD_SECS, 9 | DEFAULT_DEADLINE_BUFFER_SECS, 10 | DEFAULT_START_TIME_BUFFER_SECS, 11 | frontendAndUraEnablePortion, 12 | OPEN_QUOTE_START_TIME_BUFFER_SECS, 13 | RoutingType, 14 | } from '../../constants'; 15 | import { generateRandomNonce } from '../../util/nonce'; 16 | 17 | export class DutchV1Quote extends DutchQuote implements IQuote { 18 | public routingType: RoutingType.DUTCH_LIMIT = RoutingType.DUTCH_LIMIT; 19 | public readonly defaultDeadlineBufferInSecs: number = DEFAULT_DEADLINE_BUFFER_SECS; 20 | 21 | public toJSON(): DutchQuoteDataJSON { 22 | return { 23 | orderInfo: this.toOrder().toJSON(), 24 | encodedOrder: this.toOrder().serialize(), 25 | quoteId: this.quoteId, 26 | requestId: this.requestId, 27 | orderHash: this.toOrder().hash(), 28 | startTimeBufferSecs: this.startTimeBufferSecs, 29 | auctionPeriodSecs: this.auctionPeriodSecs, 30 | deadlineBufferSecs: this.deadlineBufferSecs, 31 | slippageTolerance: this.request.info.slippageTolerance, 32 | permitData: this.getPermitData(), 33 | // NOTE: important for URA to return 0 bps and amount, in case of no portion. 34 | // this is FE requirement 35 | portionBips: frontendAndUraEnablePortion(this.request.info.sendPortionEnabled) 36 | ? this.portion?.bips ?? 0 37 | : undefined, 38 | portionAmount: frontendAndUraEnablePortion(this.request.info.sendPortionEnabled) 39 | ? this.portionAmountOutStart.toString() ?? '0' 40 | : undefined, 41 | portionRecipient: this.portion?.recipient, 42 | }; 43 | } 44 | 45 | public toOrder(): DutchOrder { 46 | const orderBuilder = new DutchOrderBuilder(this.chainId); 47 | const decayStartTime = Math.floor(Date.now() / 1000); 48 | const nonce = this.nonce ?? generateRandomNonce(); 49 | 50 | const builder = orderBuilder 51 | .decayStartTime(decayStartTime) 52 | .decayEndTime(decayStartTime + this.auctionPeriodSecs) 53 | .deadline(decayStartTime + this.auctionPeriodSecs + this.deadlineBufferSecs) 54 | .swapper(ethers.utils.getAddress(this.request.config.swapper)) 55 | .nonce(BigNumber.from(nonce)) 56 | .input({ 57 | token: this.tokenIn, 58 | startAmount: this.amountInStart, 59 | endAmount: this.amountInEnd, 60 | }); 61 | 62 | const outputs = getPortionAdjustedOutputs( 63 | { 64 | token: this.tokenOut, 65 | startAmount: this.amountOutStart, 66 | endAmount: this.amountOutEnd, 67 | recipient: this.request.config.swapper, 68 | }, 69 | this.request.info.type, 70 | this.request.info.sendPortionEnabled, 71 | this.portion 72 | ); 73 | outputs.forEach((output) => builder.output(output)); 74 | 75 | if (this.isExclusiveQuote() && this.filler) { 76 | builder.exclusiveFiller(this.filler, BigNumber.from(this.request.config.exclusivityOverrideBps)); 77 | } 78 | 79 | return builder.build(); 80 | } 81 | 82 | // The number of seconds from now that order decay should begin 83 | public get startTimeBufferSecs(): number { 84 | if (this.request.config.startTimeBufferSecs !== undefined) { 85 | return this.request.config.startTimeBufferSecs; 86 | } 87 | 88 | if (this.isOpenQuote()) { 89 | return OPEN_QUOTE_START_TIME_BUFFER_SECS; 90 | } 91 | 92 | return DEFAULT_START_TIME_BUFFER_SECS; 93 | } 94 | 95 | // The number of seconds from startTime that decay should end 96 | public get auctionPeriodSecs(): number { 97 | if (this.request.config.auctionPeriodSecs !== undefined) { 98 | return this.request.config.auctionPeriodSecs; 99 | } 100 | 101 | const quoteConfig = ChainConfigManager.getQuoteConfig(this.chainId, this.request.routingType); 102 | if (quoteConfig.largeAuctionPeriodSecs && this.derived.largeTrade) { 103 | return quoteConfig.largeAuctionPeriodSecs; 104 | } 105 | return quoteConfig.stdAuctionPeriodSecs ?? DEFAULT_AUCTION_PERIOD_SECS; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/entities/quote/DutchV2Quote.ts: -------------------------------------------------------------------------------- 1 | import { PermitTransferFromData } from '@uniswap/permit2-sdk'; 2 | import { TradeType } from '@uniswap/sdk-core'; 3 | import { 4 | DutchInput, 5 | DutchOutput, 6 | UnsignedV2DutchOrder, 7 | UnsignedV2DutchOrderInfoJSON, 8 | V2DutchOrderBuilder, 9 | } from '@uniswap/uniswapx-sdk'; 10 | import { BigNumber, ethers } from 'ethers'; 11 | 12 | import { IQuote, LogJSON, SharedOrderQuoteDataJSON } from '.'; 13 | import { DutchV2Request } from '..'; 14 | import { ChainConfigManager } from '../../config/ChainConfigManager'; 15 | import { BPS, DEFAULT_V2_DEADLINE_BUFFER_SECS, frontendAndUraEnablePortion, RoutingType } from '../../constants'; 16 | import { generateRandomNonce } from '../../util/nonce'; 17 | import { timestampInMstoSeconds } from '../../util/time'; 18 | import { DutchQuote, getPortionAdjustedOutputs } from './DutchQuote'; 19 | 20 | export const DEFAULT_LABS_COSIGNER = ethers.constants.AddressZero; 21 | export const DEFAULT_V2_OUTPUT_AMOUNT_BUFFER_BPS = 10; 22 | 23 | // JSON format of a DutchV2Quote, to be returned by the API 24 | export type DutchV2QuoteDataJSON = SharedOrderQuoteDataJSON & { 25 | orderInfo: UnsignedV2DutchOrderInfoJSON; 26 | deadlineBufferSecs: number; 27 | permitData: PermitTransferFromData; 28 | portionBips?: number; 29 | portionAmount?: string; 30 | portionRecipient?: string; 31 | }; 32 | 33 | export class DutchV2Quote extends DutchQuote implements IQuote { 34 | public readonly routingType: RoutingType.DUTCH_V2 = RoutingType.DUTCH_V2; 35 | public readonly defaultDeadlineBufferInSecs: number = DEFAULT_V2_DEADLINE_BUFFER_SECS; 36 | 37 | public toJSON(): DutchV2QuoteDataJSON { 38 | const quoteConfig = ChainConfigManager.getQuoteConfig(this.chainId, this.request.routingType); 39 | return { 40 | orderInfo: this.toOrder().toJSON(), 41 | encodedOrder: this.toOrder().serialize(), 42 | quoteId: this.quoteId, 43 | requestId: this.requestId, 44 | orderHash: this.toOrder().hash(), 45 | deadlineBufferSecs: this.deadlineBufferSecs, 46 | slippageTolerance: this.request.info.slippageTolerance, 47 | permitData: this.getPermitData(), 48 | // NOTE: important for URA to return 0 bps and amount, in case of no portion. 49 | // this is FE requirement 50 | ...(frontendAndUraEnablePortion(this.request.info.sendPortionEnabled) && { 51 | portionBips: this.portion?.bips ?? 0, 52 | portionAmount: 53 | applyBufferToPortion( 54 | this.portionAmountOutStart, 55 | this.request.info.type, 56 | quoteConfig.priceBufferBps ?? DEFAULT_V2_OUTPUT_AMOUNT_BUFFER_BPS 57 | ).toString() ?? '0', 58 | portionRecipient: this.portion?.recipient, 59 | }), 60 | }; 61 | } 62 | 63 | public toOrder(): UnsignedV2DutchOrder { 64 | const orderBuilder = new V2DutchOrderBuilder(this.chainId); 65 | const deadline = Math.floor(Date.now() / 1000 + this.deadlineBufferSecs); 66 | const nonce = this.nonce ?? generateRandomNonce(); 67 | 68 | const builder = orderBuilder 69 | .deadline(deadline) 70 | .swapper(ethers.utils.getAddress(this.request.config.swapper)) 71 | .nonce(BigNumber.from(nonce)) 72 | .cosigner(DutchV2Quote.getLabsCosigner()) 73 | // empty cosignature so we can serialize the order 74 | .cosignature(ethers.constants.HashZero) 75 | .input({ 76 | token: this.tokenIn, 77 | startAmount: this.amountInStart, 78 | endAmount: this.amountInEnd, 79 | }); 80 | 81 | const input = { 82 | token: this.tokenIn, 83 | startAmount: this.amountInStart, 84 | endAmount: this.amountInEnd, 85 | recipient: this.request.config.swapper, 86 | }; 87 | 88 | const output = { 89 | token: this.tokenOut, 90 | startAmount: this.amountOutStart, 91 | endAmount: this.amountOutEnd, 92 | recipient: this.request.config.swapper, 93 | }; 94 | 95 | // Apply negative buffer to allow for improvement during hard quote process 96 | // - the buffer is applied to the output for EXACT_INPUT and to the input for EXACT_OUTPUT 97 | // - any portion is taken out of the the transformed output 98 | const quoteConfig = ChainConfigManager.getQuoteConfig(this.chainId, this.request.routingType); 99 | const { input: bufferedInput, output: bufferedOutput } = DutchV2Quote.applyBufferToInputOutput( 100 | input, 101 | output, 102 | this.request.info.type, 103 | quoteConfig.priceBufferBps 104 | ); 105 | builder.input(bufferedInput); 106 | 107 | const outputs = getPortionAdjustedOutputs( 108 | bufferedOutput, 109 | this.request.info.type, 110 | this.request.info.sendPortionEnabled, 111 | this.portion 112 | ); 113 | outputs.forEach((output) => builder.output(output)); 114 | 115 | return builder.buildPartial(); 116 | } 117 | 118 | public toLog(): LogJSON { 119 | const quoteConfig = ChainConfigManager.getQuoteConfig(this.chainId, this.request.routingType); 120 | return { 121 | tokenInChainId: this.chainId, 122 | tokenOutChainId: this.chainId, 123 | requestId: this.requestId, 124 | quoteId: this.quoteId, 125 | tokenIn: this.tokenIn, 126 | tokenOut: this.tokenOut, 127 | amountIn: this.amountInStart.toString(), 128 | amountOut: this.amountOutStart.toString(), 129 | endAmountIn: this.amountInEnd.toString(), 130 | endAmountOut: this.amountOutEnd.toString(), 131 | amountInGasAdjusted: this.amountInStart.toString(), 132 | filler: this.filler, 133 | amountInGasAndPortionAdjusted: 134 | this.request.info.type === TradeType.EXACT_OUTPUT ? this.amountInGasAndPortionAdjusted.toString() : undefined, 135 | amountOutGasAdjusted: this.amountOutStart.toString(), 136 | amountOutGasAndPortionAdjusted: 137 | this.request.info.type === TradeType.EXACT_INPUT ? this.amountOutGasAndPortionAdjusted.toString() : undefined, 138 | swapper: this.swapper, 139 | routing: RoutingType[this.routingType], 140 | slippage: parseFloat(this.request.info.slippageTolerance), 141 | createdAt: timestampInMstoSeconds(parseInt(this.createdAtMs)), 142 | createdAtMs: this.createdAtMs, 143 | portionBips: this.portion?.bips, 144 | portionRecipient: this.portion?.recipient, 145 | portionAmountOutStart: applyBufferToPortion( 146 | this.portionAmountOutStart, 147 | this.request.info.type, 148 | quoteConfig.priceBufferBps ?? DEFAULT_V2_OUTPUT_AMOUNT_BUFFER_BPS 149 | ).toString(), 150 | portionAmountOutEnd: applyBufferToPortion( 151 | this.portionAmountOutEnd, 152 | this.request.info.type, 153 | quoteConfig.priceBufferBps ?? DEFAULT_V2_OUTPUT_AMOUNT_BUFFER_BPS 154 | ).toString(), 155 | }; 156 | } 157 | 158 | static getLabsCosigner(): string { 159 | return process.env.RFQ_LABS_COSIGNER_ADDRESS || DEFAULT_LABS_COSIGNER; 160 | } 161 | } 162 | 163 | export function addBufferToV2InputOutput( 164 | input: DutchInput, 165 | output: DutchOutput, 166 | type: TradeType, 167 | bps: number 168 | ): { 169 | input: DutchInput; 170 | output: DutchOutput; 171 | } { 172 | if (type === TradeType.EXACT_INPUT) { 173 | return { 174 | input, 175 | output: { 176 | ...output, 177 | // subtract buffer from output 178 | startAmount: output.startAmount.mul(BPS - bps).div(BPS), 179 | endAmount: output.endAmount.mul(BPS - bps).div(BPS), 180 | }, 181 | }; 182 | } else { 183 | return { 184 | input: { 185 | ...input, 186 | // add buffer to input 187 | startAmount: input.startAmount.mul(BPS + bps).div(BPS), 188 | endAmount: input.endAmount.mul(BPS + bps).div(BPS), 189 | }, 190 | output, 191 | }; 192 | } 193 | } 194 | 195 | /* 196 | * if exact_input, apply buffer to both user and portion outputs 197 | * if exact_output, do nothing since the buffer is applied to user input 198 | */ 199 | export function applyBufferToPortion(portionAmount: BigNumber, type: TradeType, bps: number): BigNumber { 200 | if (type === TradeType.EXACT_INPUT) { 201 | return portionAmount.mul(BPS - bps).div(BPS); 202 | } else { 203 | return portionAmount; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /lib/entities/quote/index.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'ethers'; 2 | 3 | import { DutchQuote, DutchQuoteDataJSON, DutchQuoteRequest, DutchV2QuoteDataJSON, QuoteRequest } from '..'; 4 | import { RoutingType } from '../../constants'; 5 | import { ClassicQuote, ClassicQuoteDataJSON } from './ClassicQuote'; 6 | import { RelayQuote, RelayQuoteDataJSON } from './RelayQuote'; 7 | 8 | export * from './ClassicQuote'; 9 | export * from './DutchQuote'; 10 | export * from './DutchV2Quote'; 11 | export * from './RelayQuote'; 12 | 13 | export type QuoteJSON = DutchQuoteDataJSON | DutchV2QuoteDataJSON | RelayQuoteDataJSON | ClassicQuoteDataJSON; 14 | 15 | // Shared fields between all non classic quote types 16 | export type SharedOrderQuoteDataJSON = { 17 | quoteId: string; 18 | requestId: string; 19 | encodedOrder: string; 20 | orderHash: string; 21 | slippageTolerance: string; 22 | }; 23 | 24 | // Superset of all possible log fields from the quote types 25 | export type LogJSON = { 26 | quoteId: string; 27 | requestId: string; 28 | tokenIn: string; 29 | tokenOut: string; 30 | amountIn: string; 31 | amountOut: string; 32 | endAmountIn: string; 33 | endAmountOut: string; 34 | amountInGasAdjusted?: string; 35 | amountInGasAndPortionAdjusted?: string; 36 | amountOutGasAdjusted?: string; 37 | amountOutGasAndPortionAdjusted?: string; 38 | tokenInChainId: number; 39 | tokenOutChainId: number; 40 | swapper: string; 41 | routing: string; 42 | createdAt: string; 43 | createdAtMs: string; 44 | slippage: number; 45 | filler?: string; 46 | gasPriceWei?: string; 47 | portionBips?: number; 48 | portionRecipient?: string; 49 | portionAmount?: string; 50 | portionAmountDecimals?: string; 51 | quoteGasAndPortionAdjusted?: string; 52 | quoteGasAndPortionAdjustedDecimals?: string; 53 | portionAmountOutStart?: string; 54 | portionAmountOutEnd?: string; 55 | gasToken?: string; 56 | feeAmountStart?: string; 57 | feeAmountEnd?: string; 58 | }; 59 | 60 | export interface IQuote { 61 | routingType: RoutingType; 62 | amountOut: BigNumber; 63 | amountIn: BigNumber; 64 | toJSON(): QuoteJSON; 65 | request: QuoteRequest; 66 | toLog(): LogJSON; 67 | } 68 | 69 | export type Quote = DutchQuote | RelayQuote | ClassicQuote; 70 | -------------------------------------------------------------------------------- /lib/entities/request/ClassicRequest.ts: -------------------------------------------------------------------------------- 1 | import { Protocol } from '@uniswap/router-sdk'; 2 | import { BigNumber } from 'ethers'; 3 | 4 | import { defaultRequestKey, QuoteRequest, QuoteRequestHeaders, QuoteRequestInfo } from '.'; 5 | import { RoutingType } from '../../constants'; 6 | import { DutchV1Request } from './DutchV1Request'; 7 | 8 | export interface ClassicConfig { 9 | protocols?: Protocol[]; 10 | gasPriceWei?: string; 11 | simulateFromAddress?: string; 12 | permitSignature?: string; 13 | permitNonce?: string; 14 | permitExpiration?: string; 15 | permitAmount?: BigNumber; 16 | permitSigDeadline?: string; 17 | enableUniversalRouter?: boolean; 18 | recipient?: string; 19 | algorithm?: string; 20 | deadline?: number; 21 | minSplits?: number; 22 | maxSplits?: number; 23 | forceCrossProtocol?: boolean; 24 | forceMixedRoutes?: boolean; 25 | debugRoutingConfig?: string; 26 | unicornSecret?: string; 27 | quoteSpeed?: string; 28 | enableFeeOnTransferFeeFetching?: boolean; 29 | gasToken?: string; 30 | } 31 | 32 | export interface ClassicConfigJSON extends Omit { 33 | routingType: RoutingType.CLASSIC; 34 | protocols?: string[]; 35 | permitAmount?: string; 36 | } 37 | 38 | export class ClassicRequest implements QuoteRequest { 39 | public routingType: RoutingType.CLASSIC = RoutingType.CLASSIC; 40 | 41 | public static fromRequest(info: QuoteRequestInfo, config: ClassicConfig): ClassicRequest { 42 | return new ClassicRequest(info, config); 43 | } 44 | 45 | public static fromRequestBody(info: QuoteRequestInfo, body: ClassicConfigJSON): ClassicRequest { 46 | return new ClassicRequest( 47 | info, 48 | Object.assign({}, body, { 49 | protocols: body.protocols?.flatMap((p: string) => parseProtocol(p)), 50 | gasPriceWei: body.gasPriceWei, 51 | permitAmount: body.permitAmount ? BigNumber.from(body.permitAmount) : undefined, 52 | }) 53 | ); 54 | } 55 | 56 | public static fromDutchRequest(request: DutchV1Request): ClassicRequest { 57 | return new ClassicRequest(request.info, { 58 | protocols: [Protocol.V2, Protocol.V3, Protocol.MIXED], 59 | }); 60 | } 61 | 62 | constructor( 63 | public readonly info: QuoteRequestInfo, 64 | public readonly config: ClassicConfig, 65 | public headers: QuoteRequestHeaders = {} 66 | ) {} 67 | 68 | public toJSON(): ClassicConfigJSON { 69 | return Object.assign({}, this.config, { 70 | routingType: RoutingType.CLASSIC as RoutingType.CLASSIC, 71 | protocols: this.config.protocols?.map((p: Protocol) => p.toString()), 72 | ...(this.config.permitAmount !== undefined && { permitAmount: this.config.permitAmount.toString() }), 73 | ...(this.info.source !== undefined && { source: this.info.source.toString() }), 74 | }); 75 | } 76 | 77 | public key(): string { 78 | return defaultRequestKey(this); 79 | } 80 | } 81 | 82 | export function parseProtocol(protocol: string): Protocol { 83 | switch (protocol.toLowerCase()) { 84 | case 'v2': 85 | return Protocol.V2; 86 | case 'v3': 87 | return Protocol.V3; 88 | case 'mixed': 89 | return Protocol.MIXED; 90 | default: 91 | throw new Error(`Invalid protocol: ${protocol}`); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /lib/entities/request/DutchV1Request.ts: -------------------------------------------------------------------------------- 1 | import { defaultRequestKey, QuoteRequest, QuoteRequestHeaders, QuoteRequestInfo } from '.'; 2 | import { 3 | DEFAULT_EXCLUSIVITY_OVERRIDE_BPS, 4 | DEFAULT_SLIPPAGE_TOLERANCE, 5 | NATIVE_ADDRESS, 6 | RoutingType, 7 | } from '../../constants'; 8 | 9 | export * from './ClassicRequest'; 10 | export * from './DutchV1Request'; 11 | 12 | export interface DutchConfig { 13 | swapper: string; 14 | exclusivityOverrideBps: number; 15 | startTimeBufferSecs?: number; 16 | auctionPeriodSecs?: number; 17 | deadlineBufferSecs?: number; 18 | // Setting true will include an Open Order in the quote comparison 19 | useSyntheticQuotes: boolean; 20 | gasAdjustmentBps?: number; 21 | // Setting true will force an Open Order and skip RFQ 22 | forceOpenOrders?: boolean; 23 | priceImprovementBps?: number; 24 | } 25 | 26 | export interface DutchQuoteRequestInfo extends QuoteRequestInfo { 27 | slippageTolerance: string; 28 | } 29 | 30 | export interface DutchConfigJSON { 31 | routingType: RoutingType.DUTCH_LIMIT | RoutingType.DUTCH_V2; 32 | swapper?: string; 33 | exclusivityOverrideBps?: number; 34 | startTimeBufferSecs?: number; 35 | auctionPeriodSecs?: number; 36 | deadlineBufferSecs?: number; 37 | useSyntheticQuotes?: boolean; 38 | gasAdjustmentBps?: number; 39 | forceOpenOrders?: boolean; 40 | priceImprovementBps?: number; 41 | } 42 | 43 | export class DutchV1Request implements QuoteRequest { 44 | public routingType: RoutingType.DUTCH_LIMIT = RoutingType.DUTCH_LIMIT; 45 | 46 | public static fromRequestBody(info: QuoteRequestInfo, body: DutchConfigJSON): DutchV1Request { 47 | const convertedSlippage = info.slippageTolerance ?? DEFAULT_SLIPPAGE_TOLERANCE; 48 | return new DutchV1Request( 49 | { 50 | ...info, 51 | slippageTolerance: convertedSlippage, 52 | }, 53 | { 54 | swapper: body.swapper ?? NATIVE_ADDRESS, 55 | exclusivityOverrideBps: body.exclusivityOverrideBps ?? DEFAULT_EXCLUSIVITY_OVERRIDE_BPS.toNumber(), 56 | startTimeBufferSecs: body.startTimeBufferSecs, 57 | auctionPeriodSecs: body.auctionPeriodSecs, 58 | deadlineBufferSecs: body.deadlineBufferSecs, 59 | useSyntheticQuotes: body.useSyntheticQuotes ?? false, 60 | gasAdjustmentBps: body.gasAdjustmentBps, 61 | forceOpenOrders: body.forceOpenOrders, 62 | priceImprovementBps: body.priceImprovementBps, 63 | } 64 | ); 65 | } 66 | 67 | constructor( 68 | public readonly info: DutchQuoteRequestInfo, 69 | public readonly config: DutchConfig, 70 | public headers: QuoteRequestHeaders = {} 71 | ) {} 72 | 73 | public toJSON(): DutchConfigJSON { 74 | return Object.assign({}, this.config, { 75 | routingType: this.routingType as RoutingType.DUTCH_LIMIT | RoutingType.DUTCH_V2, 76 | }); 77 | } 78 | 79 | public key(): string { 80 | return defaultRequestKey(this); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/entities/request/DutchV2Request.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | import { defaultRequestKey, QuoteRequest, QuoteRequestHeaders, QuoteRequestInfo } from '.'; 3 | import { DEFAULT_SLIPPAGE_TOLERANCE, RoutingType } from '../../constants'; 4 | import { DutchQuoteRequestInfo } from './DutchV1Request'; 5 | 6 | export interface DutchV2Config { 7 | swapper: string; 8 | deadlineBufferSecs?: number; 9 | // Setting true will include an Open Order in the quote comparison 10 | useSyntheticQuotes: boolean; 11 | gasAdjustmentBps?: number; 12 | // Setting true will force an Open Order and skip RFQ 13 | forceOpenOrders?: boolean; 14 | priceImprovementBps?: number; 15 | } 16 | 17 | export interface DutchV2ConfigJSON extends Omit { 18 | routingType: RoutingType.DUTCH_V2; 19 | useSyntheticQuotes?: boolean; 20 | } 21 | 22 | export class DutchV2Request implements QuoteRequest { 23 | public routingType: RoutingType.DUTCH_V2 = RoutingType.DUTCH_V2; 24 | 25 | public static fromRequestBody(info: QuoteRequestInfo, body: DutchV2ConfigJSON): DutchV2Request { 26 | const convertedSlippage = info.slippageTolerance ?? DEFAULT_SLIPPAGE_TOLERANCE; 27 | return new DutchV2Request( 28 | { 29 | ...info, 30 | slippageTolerance: convertedSlippage, 31 | }, 32 | { 33 | swapper: body.swapper ?? ethers.constants.AddressZero, 34 | deadlineBufferSecs: body.deadlineBufferSecs, 35 | useSyntheticQuotes: body.useSyntheticQuotes ?? false, 36 | gasAdjustmentBps: body.gasAdjustmentBps, 37 | forceOpenOrders: body.forceOpenOrders, 38 | priceImprovementBps: body.priceImprovementBps, 39 | } 40 | ); 41 | } 42 | 43 | constructor( 44 | public readonly info: DutchQuoteRequestInfo, 45 | public readonly config: DutchV2Config, 46 | public headers: QuoteRequestHeaders = {} 47 | ) {} 48 | 49 | public toJSON(): DutchV2ConfigJSON { 50 | return Object.assign({}, this.config, { 51 | routingType: RoutingType.DUTCH_V2 as RoutingType.DUTCH_V2, 52 | }); 53 | } 54 | 55 | public key(): string { 56 | return defaultRequestKey(this); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/entities/request/RelayRequest.ts: -------------------------------------------------------------------------------- 1 | import { Protocol } from '@uniswap/router-sdk'; 2 | 3 | import { BigNumber } from 'ethers'; 4 | import { 5 | ClassicConfig, 6 | ClassicConfigJSON, 7 | defaultRequestKey, 8 | parseProtocol, 9 | QuoteRequest, 10 | QuoteRequestHeaders, 11 | QuoteRequestInfo, 12 | } from '.'; 13 | import { DEFAULT_SLIPPAGE_TOLERANCE, NATIVE_ADDRESS, RoutingType } from '../../constants'; 14 | 15 | export * from './ClassicRequest'; 16 | export * from './RelayRequest'; 17 | 18 | // Relay conrigs are extended classic configs with a required gasToken 19 | // and optional UniswapX-like parameters to customize the parametization of the fee escalation 20 | export interface RelayConfig extends ClassicConfig { 21 | swapper: string; 22 | gasToken: string; 23 | startTimeBufferSecs?: number; 24 | auctionPeriodSecs?: number; 25 | deadlineBufferSecs?: number; 26 | // Passed in by cients 27 | feeAmountStartOverride?: string; 28 | } 29 | 30 | export interface RelayQuoteRequestInfo extends QuoteRequestInfo { 31 | slippageTolerance: string; 32 | } 33 | 34 | export interface RelayConfigJSON extends Omit { 35 | routingType: RoutingType.RELAY; 36 | gasToken: string; 37 | swapper?: string; 38 | startTimeBufferSecs?: number; 39 | auctionPeriodSecs?: number; 40 | deadlineBufferSecs?: number; 41 | feeAmountStartOverride?: string; 42 | } 43 | 44 | export class RelayRequest implements QuoteRequest { 45 | public routingType: RoutingType.RELAY = RoutingType.RELAY; 46 | 47 | public static fromRequestBody(info: QuoteRequestInfo, body: RelayConfigJSON): RelayRequest { 48 | const convertedSlippage = info.slippageTolerance ?? DEFAULT_SLIPPAGE_TOLERANCE; 49 | return new RelayRequest( 50 | { 51 | ...info, 52 | slippageTolerance: convertedSlippage, 53 | }, 54 | Object.assign({}, body, { 55 | // Classic quote specific formatting 56 | protocols: body.protocols?.flatMap((p: string) => parseProtocol(p)), 57 | permitAmount: body.permitAmount ? BigNumber.from(body.permitAmount) : undefined, 58 | // Relay quote specific formatting 59 | swapper: body.swapper ?? NATIVE_ADDRESS, 60 | }) 61 | ); 62 | } 63 | 64 | constructor( 65 | public readonly info: RelayQuoteRequestInfo, 66 | public readonly config: RelayConfig, 67 | public headers: QuoteRequestHeaders = {} 68 | ) {} 69 | 70 | public toJSON(): RelayConfigJSON { 71 | return Object.assign({}, this.config, { 72 | routingType: RoutingType.RELAY as RoutingType.RELAY, 73 | // Classic quote specific formatting 74 | protocols: this.config.protocols?.map((p: Protocol) => p.toString()), 75 | ...(this.config.permitAmount !== undefined && { permitAmount: this.config.permitAmount.toString() }), 76 | ...(this.info.source !== undefined && { source: this.info.source.toString() }), 77 | }); 78 | } 79 | 80 | public key(): string { 81 | return defaultRequestKey(this); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/entities/request/index.ts: -------------------------------------------------------------------------------- 1 | import { Protocol } from '@uniswap/router-sdk'; 2 | import { TradeType } from '@uniswap/sdk-core'; 3 | import { BigNumber } from 'ethers'; 4 | 5 | import { ChainConfigManager } from '../../config/ChainConfigManager'; 6 | import { DEFAULT_SLIPPAGE_TOLERANCE, RoutingType } from '../../constants'; 7 | import { Portion } from '../../fetchers/PortionFetcher'; 8 | import { ValidationError } from '../../util/errors'; 9 | import { ClassicConfig, ClassicConfigJSON, ClassicRequest } from './ClassicRequest'; 10 | import { DutchConfig, DutchConfigJSON, DutchV1Request } from './DutchV1Request'; 11 | import { DutchV2Config, DutchV2ConfigJSON, DutchV2Request } from './DutchV2Request'; 12 | import { RelayConfig, RelayConfigJSON, RelayRequest } from './RelayRequest'; 13 | 14 | export * from './ClassicRequest'; 15 | export * from './DutchV1Request'; 16 | export * from './DutchV2Request'; 17 | export * from './RelayRequest'; 18 | 19 | export type RequestByRoutingType = { [routingType in RoutingType]?: QuoteRequest }; 20 | 21 | // config specific to the given routing type 22 | export type RoutingConfig = DutchConfig | DutchV2Config | RelayConfig | ClassicConfig; 23 | export type DutchRoutingConfig = DutchConfig | DutchV2Config; 24 | export type RoutingConfigJSON = DutchConfigJSON | DutchV2ConfigJSON | RelayConfigJSON | ClassicConfigJSON; 25 | 26 | export interface QuoteRequestHeaders { 27 | [name: string]: string | undefined; 28 | } 29 | 30 | // shared info for all quote requests 31 | export interface QuoteRequestInfo { 32 | requestId: string; 33 | tokenInChainId: number; 34 | tokenOutChainId: number; 35 | tokenIn: string; 36 | tokenOut: string; 37 | amount: BigNumber; 38 | type: TradeType; 39 | slippageTolerance?: string; 40 | swapper?: string; 41 | useUniswapX?: boolean; 42 | sendPortionEnabled?: boolean; 43 | portion?: Portion; 44 | intent?: string; 45 | source?: RequestSource; 46 | } 47 | 48 | export interface DutchQuoteRequestInfo extends QuoteRequestInfo { 49 | slippageTolerance: string; 50 | } 51 | 52 | export interface QuoteRequestBodyJSON extends Omit { 53 | type: string; 54 | amount: string; 55 | configs: RoutingConfigJSON[]; 56 | } 57 | 58 | export enum RequestSource { 59 | UNKNOWN = 'unknown', 60 | UNISWAP_IOS = 'uniswap-ios', 61 | UNISWAP_ANDROID = 'uniswap-android', 62 | UNISWAP_WEB = 'uniswap-web', 63 | EXTERNAL_API = 'external-api', 64 | EXTERNAL_API_MOBILE = 'external-api:mobile', 65 | UNISWAP_EXTENSION = 'uniswap-extension', 66 | } 67 | 68 | export interface QuoteRequest { 69 | routingType: RoutingType; 70 | info: QuoteRequestInfo; 71 | config: RoutingConfig; 72 | headers: QuoteRequestHeaders; 73 | 74 | toJSON(): RoutingConfigJSON; 75 | // return a key that uniquely identifies this request 76 | key(): string; 77 | } 78 | 79 | export interface DutchQuoteRequest { 80 | routingType: RoutingType.DUTCH_LIMIT | RoutingType.DUTCH_V2; 81 | info: DutchQuoteRequestInfo; 82 | config: DutchRoutingConfig; 83 | headers: QuoteRequestHeaders; 84 | 85 | toJSON(): RoutingConfigJSON; 86 | // return a key that uniquely identifies this request 87 | key(): string; 88 | } 89 | 90 | export function parseQuoteRequests(body: QuoteRequestBodyJSON): { 91 | quoteRequests: QuoteRequest[]; 92 | quoteInfo: QuoteRequestInfo; 93 | } { 94 | const info: QuoteRequestInfo = { 95 | requestId: body.requestId, 96 | tokenInChainId: body.tokenInChainId, 97 | tokenOutChainId: body.tokenOutChainId, 98 | tokenIn: body.tokenIn, 99 | tokenOut: body.tokenOut, 100 | amount: BigNumber.from(body.amount), 101 | type: parseTradeType(body.type), 102 | slippageTolerance: body.slippageTolerance ?? DEFAULT_SLIPPAGE_TOLERANCE, 103 | swapper: body.swapper, 104 | sendPortionEnabled: body.sendPortionEnabled, 105 | portion: body.portion, 106 | intent: body.intent, 107 | }; 108 | 109 | const requests = body.configs.flatMap((config) => { 110 | if ( 111 | config.routingType == RoutingType.CLASSIC && 112 | ChainConfigManager.chainSupportsRoutingType(info.tokenInChainId, RoutingType.CLASSIC) 113 | ) { 114 | return ClassicRequest.fromRequestBody(info, config as ClassicConfigJSON); 115 | } else if ( 116 | config.routingType == RoutingType.DUTCH_LIMIT && 117 | ChainConfigManager.chainSupportsRoutingType(info.tokenInChainId, RoutingType.DUTCH_LIMIT) && 118 | info.tokenInChainId === info.tokenOutChainId 119 | ) { 120 | return DutchV1Request.fromRequestBody(info, config as DutchConfigJSON); 121 | } else if ( 122 | config.routingType == RoutingType.RELAY && 123 | ChainConfigManager.chainSupportsRoutingType(info.tokenInChainId, RoutingType.RELAY) && 124 | info.tokenInChainId === info.tokenOutChainId 125 | ) { 126 | return RelayRequest.fromRequestBody(info, config as RelayConfigJSON); 127 | } else if ( 128 | config.routingType == RoutingType.DUTCH_V2 && 129 | ChainConfigManager.chainSupportsRoutingType(info.tokenInChainId, RoutingType.DUTCH_V2) && 130 | info.tokenInChainId === info.tokenOutChainId 131 | ) { 132 | return DutchV2Request.fromRequestBody(info, config as DutchV2ConfigJSON); 133 | } 134 | return []; 135 | }); 136 | 137 | const result: Set = new Set(); 138 | requests.forEach((request) => { 139 | if (result.has(request.routingType)) { 140 | throw new ValidationError(`Duplicate routing type: ${request.routingType}`); 141 | } 142 | result.add(request.routingType); 143 | }); 144 | 145 | return { quoteInfo: info, quoteRequests: requests }; 146 | } 147 | 148 | export function parseTradeType(tradeType: string): TradeType { 149 | if (tradeType === 'exactIn' || tradeType === 'EXACT_INPUT') { 150 | return TradeType.EXACT_INPUT; 151 | } else if (tradeType === 'exactOut' || tradeType === 'EXACT_OUTPUT') { 152 | return TradeType.EXACT_OUTPUT; 153 | } else { 154 | throw new Error(`Invalid trade type: ${tradeType}`); 155 | } 156 | } 157 | 158 | // uniquely identifying key for a request 159 | export function defaultRequestKey(request: QuoteRequest): string { 160 | // specify request key as the shared info and routing type 161 | // so we make have multiple requests with different configs 162 | const info = request.info; 163 | return JSON.stringify({ 164 | routingType: request.routingType, 165 | tokenInChainId: info.tokenInChainId, 166 | tokenOutChainId: info.tokenOutChainId, 167 | tokenIn: info.tokenIn, 168 | tokenOut: info.tokenOut, 169 | amount: info.amount.toString(), 170 | type: info.type, 171 | }); 172 | } 173 | 174 | export function parseProtocol(protocol: string): Protocol { 175 | const protocolUpper = protocol.toUpperCase(); 176 | 177 | if (protocolUpper in Protocol) { 178 | return Protocol[protocolUpper as keyof typeof Protocol]; 179 | } 180 | 181 | throw new Error(`Invalid protocol: ${protocol}`); 182 | } 183 | -------------------------------------------------------------------------------- /lib/fetchers/Permit2Fetcher.ts: -------------------------------------------------------------------------------- 1 | import { permit2Address, PermitDetails } from '@uniswap/permit2-sdk'; 2 | import { ChainId } from '@uniswap/sdk-core'; 3 | import { Unit } from 'aws-embedded-metrics'; 4 | import { Contract, ethers, providers } from 'ethers'; 5 | import PERMIT2_CONTRACT from '../abis/Permit2.json'; 6 | import { log } from '../util/log'; 7 | import { metrics } from '../util/metrics'; 8 | 9 | export class Permit2Fetcher { 10 | public readonly permit2Abi: ethers.ContractInterface; 11 | private readonly chainIdPermit2Map: Map; 12 | private readonly chainIdRpcMap: Map; 13 | 14 | constructor(chainIdRpcMap: Map) { 15 | this.chainIdRpcMap = chainIdRpcMap; 16 | this.permit2Abi = PERMIT2_CONTRACT.abi; 17 | const permit2 = new ethers.Contract(permit2Address(), this.permit2Abi); 18 | const permit2ZkSync = new ethers.Contract(permit2Address(ChainId.ZKSYNC), this.permit2Abi); 19 | this.chainIdPermit2Map = new Map(); 20 | this.chainIdRpcMap.forEach((_, chainId) => { 21 | if (chainId === ChainId.ZKSYNC) { 22 | this.chainIdPermit2Map.set(chainId, permit2ZkSync); 23 | } else { 24 | this.chainIdPermit2Map.set(chainId, permit2); 25 | } 26 | }); 27 | } 28 | 29 | public permit2Address(chainId: ChainId): string { 30 | return permit2Address(chainId); 31 | } 32 | 33 | public async fetchAllowance( 34 | chainId: ChainId, 35 | ownerAddress: string, 36 | tokenAddress: string, 37 | spenderAddress: string 38 | ): Promise { 39 | let allowance = undefined; 40 | metrics.putMetric(`Permit2FetcherRequest`, 1); 41 | try { 42 | const beforePermitCheck = Date.now(); 43 | const rpcProvider = this.chainIdRpcMap.get(chainId); 44 | if (!rpcProvider) throw new Error(`No rpc provider found for chain: ${chainId}`); 45 | const permit2 = this.chainIdPermit2Map.get(chainId); 46 | if (!permit2) throw new Error(`No permit2 contract found for chain: ${chainId}`); 47 | allowance = await permit2.connect(rpcProvider).allowance(ownerAddress, tokenAddress, spenderAddress); 48 | metrics.putMetric(`Permit2FetcherSuccess`, 1); 49 | metrics.putMetric(`Latency-Permit2Fetcher-ChainId${chainId}`, Date.now() - beforePermitCheck, Unit.Milliseconds); 50 | } catch (e) { 51 | log.error(e, 'Permit2FetcherErr'); 52 | metrics.putMetric(`Permit2FetcherErr`, 1); 53 | } 54 | 55 | return allowance; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/fetchers/PortionFetcher.ts: -------------------------------------------------------------------------------- 1 | import { Unit } from 'aws-embedded-metrics'; 2 | import * as http from 'http'; 3 | import * as https from 'https'; 4 | import NodeCache from 'node-cache'; 5 | import { DEFAULT_NEGATIVE_CACHE_ENTRY_TTL, DEFAULT_POSITIVE_CACHE_ENTRY_TTL, uraEnablePortion } from '../constants'; 6 | import { RequestSource } from '../entities'; 7 | import axios from '../providers/quoters/helpers'; 8 | import { log } from '../util/log'; 9 | import { metrics } from '../util/metrics'; 10 | import { forcePortion } from '../util/portion'; 11 | 12 | export enum PortionType { 13 | Flat = 'flat', 14 | Regressive = 'regressive', 15 | } 16 | 17 | export interface Portion { 18 | readonly bips: number; 19 | readonly recipient: string; 20 | readonly type: PortionType; 21 | } 22 | 23 | export interface GetPortionResponse { 24 | readonly hasPortion: boolean; 25 | readonly portion?: Portion; 26 | } 27 | 28 | export const GET_NO_PORTION_RESPONSE: GetPortionResponse = { hasPortion: false, portion: undefined }; 29 | 30 | export class PortionFetcher { 31 | public static PORTION_CACHE_KEY = ( 32 | tokenInChainId: number, 33 | tokenInAddress: string, 34 | tokenOutChainId: number, 35 | tokenOutAddress: string, 36 | requestSource: RequestSource 37 | ) => 38 | `PortionFetcher-${tokenInChainId}-${tokenInAddress.toLowerCase()}-${tokenOutChainId}-${tokenOutAddress.toLowerCase()}-${requestSource}`; 39 | 40 | private getPortionFullPath = `${this.portionApiUrl}/portion`; 41 | private portionServiceInstance = axios.create({ 42 | baseURL: this.portionApiUrl, 43 | // keep connections alive, 44 | // maxSockets default is Infinity, so Infinity is read as 50 sockets 45 | httpAgent: new http.Agent({ keepAlive: true }), 46 | httpsAgent: new https.Agent({ keepAlive: true }), 47 | }); 48 | 49 | constructor( 50 | private portionApiUrl: string, 51 | private portionCache: NodeCache, 52 | private positiveCacheEntryTtl = DEFAULT_POSITIVE_CACHE_ENTRY_TTL, 53 | private negativeCacheEntryTtl = DEFAULT_NEGATIVE_CACHE_ENTRY_TTL 54 | ) {} 55 | 56 | async getPortion( 57 | tokenInChainId: number, 58 | tokenInAddress: string, 59 | tokenOutChainId: number, 60 | tokenOutAddress: string, 61 | requestSource: RequestSource 62 | ): Promise { 63 | metrics.putMetric(`PortionFetcherRequest`, 1); 64 | 65 | // we check ENABLE_PORTION for every request, so that the update to the lambda env var gets reflected 66 | // in real time 67 | if (!uraEnablePortion()) { 68 | metrics.putMetric(`PortionFetcherFlagDisabled`, 1); 69 | return GET_NO_PORTION_RESPONSE; 70 | } 71 | 72 | // We bypass the cache if `forcePortion` is true. 73 | // We do it to avoid cache conflicts since `forcePortion` is only for testing purposes. 74 | const portionFromCache = 75 | !forcePortion && 76 | this.portionCache.get( 77 | PortionFetcher.PORTION_CACHE_KEY( 78 | tokenInChainId, 79 | tokenInAddress, 80 | tokenOutChainId, 81 | tokenOutAddress, 82 | requestSource 83 | ) 84 | ); 85 | 86 | if (portionFromCache) { 87 | metrics.putMetric(`PortionFetcherCacheHit`, 1); 88 | return portionFromCache; 89 | } 90 | 91 | try { 92 | const beforeGetPortion = Date.now(); 93 | const portionResponse = await this.portionServiceInstance.get(this.getPortionFullPath, { 94 | params: { 95 | tokenInChainId: tokenInChainId, 96 | tokenInAddress: tokenInAddress, 97 | tokenOutChainId: tokenOutChainId, 98 | tokenOutAddress: tokenOutAddress, 99 | requestSource: requestSource, 100 | }, 101 | }); 102 | 103 | // TODO: ROUTE-96 - add dashboard for URA <-> portion integration monitoring 104 | metrics.putMetric(`Latency-GetPortion`, Date.now() - beforeGetPortion, Unit.Milliseconds); 105 | metrics.putMetric(`PortionFetcherSuccess`, 1); 106 | metrics.putMetric(`PortionFetcherCacheMiss`, 1); 107 | 108 | // We bypass the cache if `forcePortion` is true. 109 | // We do it to avoid cache conflicts since `forcePortion` is only for testing purposes. 110 | if (!forcePortion) { 111 | this.portionCache.set( 112 | PortionFetcher.PORTION_CACHE_KEY( 113 | tokenInChainId, 114 | tokenInAddress, 115 | tokenOutChainId, 116 | tokenOutAddress, 117 | requestSource 118 | ), 119 | portionResponse.data, 120 | portionResponse.data.portion ? this.positiveCacheEntryTtl : this.negativeCacheEntryTtl 121 | ); 122 | } 123 | 124 | return portionResponse.data; 125 | } catch (e) { 126 | // TODO: ROUTE-96 - add alerting for URA <-> portion integration monitoring 127 | log.error({ e }, 'PortionFetcherErr'); 128 | metrics.putMetric(`PortionFetcherErr`, 1); 129 | metrics.putMetric(`PortionFetcherCacheMiss`, 1); 130 | 131 | return GET_NO_PORTION_RESPONSE; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/fetchers/TokenFetcher.ts: -------------------------------------------------------------------------------- 1 | import DEFAULT_TOKEN_LIST from '@uniswap/default-token-list'; 2 | 3 | import { ChainId, Currency, Ether } from '@uniswap/sdk-core'; 4 | import { 5 | CachingTokenListProvider, 6 | ITokenListProvider, 7 | ITokenProvider, 8 | NATIVE_NAMES_BY_ID, 9 | NodeJSCache, 10 | } from '@uniswap/smart-order-router'; 11 | import { ethers } from 'ethers'; 12 | import NodeCache from 'node-cache'; 13 | import { NATIVE_ADDRESS } from '../constants'; 14 | import { ValidationError } from '../util/errors'; 15 | 16 | type ITokenFetcherProvider = ITokenListProvider & ITokenProvider; 17 | export class TokenFetcher { 18 | private _tokenListProviders: Map = new Map(); 19 | 20 | private createTokenListProvider = (chainId: ChainId): ITokenFetcherProvider => { 21 | return new CachingTokenListProvider(chainId, DEFAULT_TOKEN_LIST, new NodeJSCache(new NodeCache())); 22 | }; 23 | 24 | /** 25 | * Gets the token list provider for the provided chainId. Creates a new one if it doesn't exist. 26 | * Allows us to cache the token list provider for each chainId for the lifetime of the lambda. 27 | */ 28 | private getTokenListProvider(chainId: ChainId): ITokenFetcherProvider { 29 | let tokenListProvider = this._tokenListProviders.get(chainId); 30 | if (tokenListProvider === undefined) { 31 | tokenListProvider = this.createTokenListProvider(chainId); 32 | this._tokenListProviders.set(chainId, tokenListProvider); 33 | } 34 | return tokenListProvider; 35 | } 36 | 37 | /** 38 | * Gets the token address for the provided token symbol or address from the DEFAULT_TOKEN_LIST. 39 | * Throws an error if the token is not found. 40 | */ 41 | public resolveTokenBySymbolOrAddress = async (chainId: ChainId, symbolOrAddress: string): Promise => { 42 | // check for native symbols first 43 | if (NATIVE_NAMES_BY_ID[chainId]!.includes(symbolOrAddress) || symbolOrAddress == NATIVE_ADDRESS) { 44 | return NATIVE_ADDRESS; 45 | } 46 | 47 | try { 48 | // try to parse address normal way 49 | return ethers.utils.getAddress(symbolOrAddress); 50 | } catch { 51 | // if invalid, try to parse as symbol 52 | const tokenListProvider = this.getTokenListProvider(chainId); 53 | const token = await tokenListProvider.getTokenBySymbol(symbolOrAddress); 54 | if (token === undefined) { 55 | throw new ValidationError(`Could not find token with symbol ${symbolOrAddress}`); 56 | } 57 | return token.address; 58 | } 59 | }; 60 | 61 | /** 62 | * Gets the token currency object for the provided token symbol or address if found in the DEFAULT_TOKEN_LIST. 63 | * Returns undefined if the token is not found. 64 | */ 65 | public getTokenBySymbolOrAddress = async ( 66 | chainId: ChainId, 67 | symbolOrAddress: string 68 | ): Promise => { 69 | // check for native symbols first 70 | if (NATIVE_NAMES_BY_ID[chainId]!.includes(symbolOrAddress) || symbolOrAddress == NATIVE_ADDRESS) { 71 | return Ether.onChain(chainId); 72 | } 73 | 74 | const tokenListProvider = this.getTokenListProvider(chainId); 75 | const tokenAddress = await this.resolveTokenBySymbolOrAddress(chainId, symbolOrAddress); 76 | 77 | return await tokenListProvider.getTokenByAddress(tokenAddress); 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /lib/handlers/base/base.ts: -------------------------------------------------------------------------------- 1 | import { default as Logger } from 'bunyan'; 2 | 3 | import { checkDefined } from '../../util/preconditions'; 4 | 5 | export type BaseRInj = { 6 | log: Logger; 7 | }; 8 | 9 | export type BaseHandleRequestParams> = { 10 | event: Event; 11 | containerInjected: CInj; 12 | }; 13 | 14 | export abstract class BaseInjector { 15 | protected containerInjected: CInj | undefined; 16 | 17 | public constructor(protected injectorName: string) { 18 | checkDefined(injectorName, 'Injector name must be defined'); 19 | } 20 | 21 | protected abstract buildContainerInjected(): Promise; 22 | 23 | public async build() { 24 | this.containerInjected = await this.buildContainerInjected(); 25 | return this; 26 | } 27 | 28 | public getContainerInjected(): CInj { 29 | return checkDefined(this.containerInjected, 'Container injected undefined. Must call build() before using.'); 30 | } 31 | } 32 | 33 | export abstract class BaseLambdaHandler { 34 | constructor(protected readonly handlerName: string) {} 35 | 36 | public abstract get handler(): HandlerType; 37 | 38 | protected abstract buildHandler(): HandlerType; 39 | 40 | protected abstract handleRequest(params: InputType): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /lib/handlers/base/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-handler'; 2 | export * from './base'; 3 | -------------------------------------------------------------------------------- /lib/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { QuoteHandler, QuoteInjector } from './quote'; 2 | 3 | const quoteInjectorPromise = new QuoteInjector('quoteInjector').build(); 4 | const quoteHandler = new QuoteHandler('quoteHandler', quoteInjectorPromise); 5 | 6 | module.exports = { 7 | quoteHandler: quoteHandler.handler, 8 | }; 9 | -------------------------------------------------------------------------------- /lib/handlers/quote/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handler'; 2 | export * from './injector'; 3 | export * from './schema'; 4 | -------------------------------------------------------------------------------- /lib/handlers/quote/injector.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, Context } from 'aws-lambda'; 2 | import { default as bunyan, default as Logger } from 'bunyan'; 3 | 4 | import { ChainId } from '@uniswap/sdk-core'; 5 | import { MetricsLogger } from 'aws-embedded-metrics'; 6 | import { providers } from 'ethers'; 7 | import NodeCache from 'node-cache'; 8 | import { ChainConfigManager } from '../../config/ChainConfigManager'; 9 | import { RoutingType } from '../../constants'; 10 | import { QuoteRequestBodyJSON } from '../../entities'; 11 | import { Permit2Fetcher } from '../../fetchers/Permit2Fetcher'; 12 | import { PortionFetcher } from '../../fetchers/PortionFetcher'; 13 | import { TokenFetcher } from '../../fetchers/TokenFetcher'; 14 | import { 15 | DisabledSyntheticStatusProvider, 16 | Quoter, 17 | RfqQuoter, 18 | RoutingApiQuoter, 19 | SyntheticStatusProvider, 20 | } from '../../providers'; 21 | import { setGlobalLogger } from '../../util/log'; 22 | import { setGlobalMetrics } from '../../util/metrics'; 23 | import { setGlobalForcePortion } from '../../util/portion'; 24 | import { checkDefined } from '../../util/preconditions'; 25 | import { ApiInjector, ApiRInj } from '../base/api-handler'; 26 | 27 | export type QuoterByRoutingType = { 28 | [key in RoutingType]?: Quoter; 29 | }; 30 | 31 | export interface ContainerInjected { 32 | quoters: QuoterByRoutingType; 33 | tokenFetcher: TokenFetcher; 34 | portionFetcher: PortionFetcher; 35 | permit2Fetcher: Permit2Fetcher; 36 | syntheticStatusProvider: SyntheticStatusProvider; 37 | chainIdRpcMap: Map; 38 | } 39 | 40 | export class QuoteInjector extends ApiInjector { 41 | public async buildContainerInjected(): Promise { 42 | const log: Logger = bunyan.createLogger({ 43 | name: this.injectorName, 44 | serializers: bunyan.stdSerializers, 45 | level: bunyan.INFO, 46 | }); 47 | setGlobalLogger(log); 48 | 49 | const paramApiUrl = checkDefined(process.env.PARAMETERIZATION_API_URL, 'PARAMETERIZATION_API_URL is not defined'); 50 | const routingApiUrl = checkDefined(process.env.ROUTING_API_URL, 'ROUTING_API_URL is not defined'); 51 | const routingApiKey = checkDefined(process.env.ROUTING_API_KEY, 'ROUTING_API_KEY is not defined'); 52 | const paramApiKey = checkDefined(process.env.PARAMETERIZATION_API_KEY, 'PARAMETERIZATION_API_KEY is not defined'); 53 | const serviceUrl = checkDefined(process.env.SERVICE_URL, 'SERVICE_URL is not defined'); 54 | const portionApiUrl = checkDefined(process.env.PORTION_API_URL, 'PORTION_API_URL is not defined'); 55 | 56 | const chainIdRpcMap = new Map(); 57 | ChainConfigManager.getChainIdsByRoutingType(RoutingType.CLASSIC).forEach((chainId) => { 58 | const rpcUrl = checkDefined(process.env[`RPC_${chainId}`], `RPC_${chainId} is not defined`); 59 | const provider = new providers.StaticJsonRpcProvider(rpcUrl, chainId); // specify chainId to avoid detecctNetwork() call on initialization 60 | chainIdRpcMap.set(chainId, provider); 61 | }); 62 | 63 | // single cache acting as both positive cache and negative cache, 64 | // for load reduction against portion service 65 | const portionCache = new NodeCache({ stdTTL: 600 }); 66 | const tokenFetcher = new TokenFetcher(); 67 | const portionFetcher = new PortionFetcher(portionApiUrl, portionCache); 68 | const rfqQuoter = new RfqQuoter(paramApiUrl, serviceUrl, paramApiKey); 69 | 70 | return { 71 | quoters: { 72 | [RoutingType.DUTCH_V2]: rfqQuoter, 73 | [RoutingType.DUTCH_LIMIT]: rfqQuoter, 74 | [RoutingType.CLASSIC]: new RoutingApiQuoter(routingApiUrl, routingApiKey), 75 | }, 76 | chainIdRpcMap, 77 | tokenFetcher: tokenFetcher, 78 | portionFetcher: portionFetcher, 79 | permit2Fetcher: new Permit2Fetcher(chainIdRpcMap), 80 | syntheticStatusProvider: new DisabledSyntheticStatusProvider(), 81 | }; 82 | } 83 | 84 | public async getRequestInjected( 85 | _containerInjected: ContainerInjected, 86 | requestBody: QuoteRequestBodyJSON, 87 | _requestQueryParams: void, 88 | event: APIGatewayProxyEvent, 89 | context: Context, 90 | log: Logger, 91 | metrics: MetricsLogger 92 | ): Promise { 93 | const requestId = context.awsRequestId; 94 | 95 | log = log.child({ 96 | serializers: bunyan.stdSerializers, 97 | requestBody: requestBody, 98 | requestId, 99 | }); 100 | 101 | setGlobalForcePortion( 102 | process.env.FORCE_PORTION_STRING !== undefined && 103 | event.headers['X-UNISWAP-FORCE-PORTION-SECRET'] === process.env.FORCE_PORTION_STRING 104 | ); 105 | 106 | setGlobalLogger(log); 107 | 108 | metrics.setNamespace('Uniswap'); 109 | metrics.setDimensions({ Service: 'UnifiedRoutingAPI' }); 110 | setGlobalMetrics(metrics); 111 | 112 | return { 113 | log, 114 | requestId, 115 | metrics, 116 | }; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /lib/handlers/quote/schema.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | import { FieldValidator } from '../../util/validator'; 4 | 5 | export const PostQuoteRequestBodyJoi = Joi.object({ 6 | tokenInChainId: FieldValidator.classicChainId.required(), 7 | tokenOutChainId: FieldValidator.classicChainId.required(), 8 | tokenIn: Joi.string().alphanum().max(42).required(), 9 | tokenOut: Joi.string().alphanum().max(42).required(), 10 | amount: FieldValidator.amount.required(), 11 | type: FieldValidator.tradeType.required(), 12 | configs: Joi.array() 13 | .items( 14 | FieldValidator.classicConfig, 15 | FieldValidator.dutchLimitConfig, 16 | FieldValidator.dutchV2Config, 17 | FieldValidator.relayConfig 18 | ) 19 | .unique((a: any, b: any) => { 20 | return a.routingType === b.routingType; 21 | }) 22 | .required() 23 | .min(1) 24 | .messages({ 25 | 'array.unique': 'Duplicate routingType in configs', 26 | }), 27 | swapper: FieldValidator.address.optional(), 28 | useUniswapX: Joi.boolean().default(false).optional(), 29 | sendPortionEnabled: Joi.boolean().default(false).optional(), 30 | intent: Joi.string().optional(), 31 | }); 32 | 33 | export const PostQuoteResponseJoi = Joi.object({ 34 | chainId: FieldValidator.dutchChainId.required(), 35 | quoteId: FieldValidator.uuid.required(), 36 | requestId: FieldValidator.uuid.required(), 37 | tokenIn: FieldValidator.address.required(), 38 | amountIn: FieldValidator.amount.required(), 39 | tokenOut: FieldValidator.address.required(), 40 | amountOut: FieldValidator.amount.required(), 41 | swapper: FieldValidator.address.required(), 42 | filler: FieldValidator.address.required(), 43 | }); 44 | -------------------------------------------------------------------------------- /lib/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './quoters'; 2 | export * from './syntheticStatusProvider'; 3 | -------------------------------------------------------------------------------- /lib/providers/quoters/RfqQuoter.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { ID_TO_CHAIN_ID, WRAPPED_NATIVE_CURRENCY } from '@uniswap/smart-order-router'; 3 | import { BigNumber } from 'ethers'; 4 | import axios from './helpers'; 5 | 6 | import { ChainConfigManager } from '../../config/ChainConfigManager'; 7 | import { BPS, frontendAndUraEnablePortion, NATIVE_ADDRESS, RoutingType } from '../../constants'; 8 | import { DutchQuoteRequest, Quote } from '../../entities'; 9 | import { DutchQuoteFactory } from '../../entities/quote/DutchQuoteFactory'; 10 | import { PostQuoteResponseJoi } from '../../handlers/quote'; 11 | import { log } from '../../util/log'; 12 | import { metrics } from '../../util/metrics'; 13 | import { generateRandomNonce } from '../../util/nonce'; 14 | import { Quoter, QuoterType } from './index'; 15 | 16 | export class RfqQuoter implements Quoter { 17 | static readonly type: QuoterType.UNISWAPX_RFQ; 18 | static readonly supportedRoutingTypes = [RoutingType.DUTCH_LIMIT, RoutingType.DUTCH_V2]; 19 | 20 | constructor(private rfqUrl: string, private serviceUrl: string, private paramApiKey: string) {} 21 | 22 | async quote(request: DutchQuoteRequest): Promise { 23 | // Skip RFQ for forced Open Orders 24 | const quoteConfig = ChainConfigManager.getQuoteConfig(request.info.tokenInChainId, request.routingType); 25 | if (quoteConfig.forceOpenOrders || request.config.forceOpenOrders) { 26 | return null; 27 | } 28 | const swapper = request.config.swapper; 29 | const now = Date.now(); 30 | const portionEnabled = frontendAndUraEnablePortion(request.info.sendPortionEnabled); 31 | // we must adjust up the exact out swap amount to account for portion, before requesting quote from RFQ quoter 32 | const portionAmount = 33 | request.info.portion && request.info.type === TradeType.EXACT_OUTPUT 34 | ? request.info.amount.mul(request.info.portion.bips).div(BPS) 35 | : undefined; 36 | 37 | // we will only add portion to the exact out swap amount if the URA ENABLE_PORTION is true 38 | // as well as the frontend sendPortionEnabled is true 39 | const amount = portionAmount && portionEnabled ? request.info.amount.add(portionAmount) : request.info.amount; 40 | const requests = [ 41 | axios.post( 42 | `${this.rfqUrl}quote`, 43 | { 44 | tokenInChainId: request.info.tokenInChainId, 45 | tokenOutChainId: request.info.tokenOutChainId, 46 | tokenIn: mapNative(request.info.tokenIn, request.info.tokenInChainId), 47 | tokenOut: request.info.tokenOut, 48 | amount: amount.toString(), 49 | swapper: swapper, 50 | requestId: request.info.requestId, 51 | type: TradeType[request.info.type], 52 | numOutputs: portionEnabled ? 2 : 1, 53 | protocol: routingTypeToProtocolVersion(request.routingType), 54 | }, 55 | { headers: { 'x-api-key': this.paramApiKey } } 56 | ), 57 | axios.get(`${this.serviceUrl}dutch-auction/nonce?address=${swapper}&chainId=${request.info.tokenInChainId}`), // should also work for cross-chain? 58 | ]; 59 | 60 | let quote: Quote | null = null; 61 | metrics.putMetric(`RfqQuoterRequest`, 1); 62 | await Promise.allSettled(requests).then((results) => { 63 | if (results[0].status == 'rejected') { 64 | log.error(results[0].reason, 'RfqQuoterErr'); 65 | metrics.putMetric(`RfqQuoterRfqErr`, 1); 66 | } else { 67 | const response = results[0].value.data; 68 | log.info(response, 'RfqQuoter: POST quote request success'); 69 | const validated = PostQuoteResponseJoi.validate(response); 70 | if (validated.error) { 71 | log.error({ validationError: validated.error }, 'RfqQuoterErr: POST quote response invalid'); 72 | metrics.putMetric(`RfqQuoterValidationErr`, 1); 73 | } else { 74 | if (results[1].status == 'rejected') { 75 | log.debug(results[1].reason, 'RfqQuoterErr: GET nonce failed'); 76 | metrics.putMetric(`RfqQuoterLatency`, Date.now() - now); 77 | metrics.putMetric(`RfqQuoterNonceErr`, 1); 78 | quote = DutchQuoteFactory.fromResponseBody(request, response, generateRandomNonce(), request.info.portion); 79 | } else { 80 | log.info(results[1].value.data, 'RfqQuoter: GET nonce success'); 81 | metrics.putMetric(`RfqQuoterLatency`, Date.now() - now); 82 | metrics.putMetric(`RfqQuoterSuccess`, 1); 83 | quote = DutchQuoteFactory.fromResponseBody( 84 | request, 85 | response, 86 | BigNumber.from(results[1].value.data.nonce).add(1).toString(), 87 | request.info.portion 88 | ); 89 | } 90 | } 91 | } 92 | }); 93 | 94 | return quote; 95 | } 96 | } 97 | 98 | function mapNative(token: string, chainId: number): string { 99 | if (token === NATIVE_ADDRESS) { 100 | const wrapped = WRAPPED_NATIVE_CURRENCY[ID_TO_CHAIN_ID(chainId)].address; 101 | return wrapped; 102 | } 103 | return token; 104 | } 105 | 106 | function routingTypeToProtocolVersion(routingType: RoutingType): string { 107 | switch (routingType) { 108 | case RoutingType.DUTCH_LIMIT: 109 | return 'v1'; 110 | case RoutingType.DUTCH_V2: 111 | return 'v2'; 112 | default: 113 | throw new Error(`Invalid routing type: ${routingType}`); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /lib/providers/quoters/RoutingApiQuoter.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { NATIVE_NAMES_BY_ID } from '@uniswap/smart-order-router'; 3 | import { AxiosError, AxiosResponse } from 'axios'; 4 | import querystring from 'querystring'; 5 | 6 | import { frontendAndUraEnablePortion, NATIVE_ADDRESS, RoutingType } from '../../constants'; 7 | import { ClassicQuote, ClassicQuoteDataJSON, ClassicRequest, Quote, QuoteRequestHeaders } from '../../entities'; 8 | import { metrics } from '../../util/metrics'; 9 | import axios from './helpers'; 10 | import { Quoter, QuoterType } from './index'; 11 | 12 | export class RoutingApiQuoter implements Quoter { 13 | static readonly type: QuoterType.ROUTING_API; 14 | 15 | constructor(private routingApiUrl: string, private routingApiKey: string) {} 16 | 17 | async quote(request: ClassicRequest): Promise { 18 | if (request.routingType !== RoutingType.CLASSIC) { 19 | throw new Error(`Invalid routing config type: ${request.routingType}`); 20 | } 21 | 22 | metrics.putMetric(`RoutingApiQuoterRequest`, 1); 23 | try { 24 | const req = this.buildRequest(request); 25 | const now = Date.now(); 26 | const requestHeaders: QuoteRequestHeaders = { 'x-api-key': this.routingApiKey }; 27 | if (request.headers['x-request-source']) requestHeaders['x-request-source'] = request.headers['x-request-source']; 28 | if (request.headers['x-app-version']) requestHeaders['x-app-version'] = request.headers['x-app-version']; 29 | 30 | const response = await axios.get(req, { 31 | headers: requestHeaders, 32 | }); 33 | const portionAdjustedResponse: AxiosResponse = { 34 | ...response, 35 | // NOTE: important to show portion-related fields under flag on only 36 | // this is FE requirement 37 | data: frontendAndUraEnablePortion(request.info.sendPortionEnabled) 38 | ? { 39 | ...response.data, 40 | // NOTE: important for URA to return 0 bps and amount, in case of no portion. 41 | // this is FE requirement 42 | portionBips: response.data.portionBips ?? 0, 43 | portionAmount: response.data.portionAmount ?? '0', 44 | portionAmountDecimals: response.data.portionAmountDecimals ?? '0', 45 | quoteGasAndPortionAdjusted: response.data.quoteGasAndPortionAdjusted ?? response.data.quoteGasAdjusted, 46 | quoteGasAndPortionAdjustedDecimals: 47 | response.data.quoteGasAndPortionAdjustedDecimals ?? response.data.quoteGasAdjustedDecimals, 48 | } 49 | : response.data, 50 | }; 51 | 52 | metrics.putMetric(`RoutingApiQuoterSuccess`, 1); 53 | metrics.putMetric(`RoutingApiQuoterLatency`, Date.now() - now); 54 | return ClassicQuote.fromResponseBody(request, portionAdjustedResponse.data); 55 | } catch (e) { 56 | if (e instanceof AxiosError) { 57 | if (e.response?.status?.toString().startsWith('4')) { 58 | metrics.putMetric(`RoutingApiQuote4xxErr`, 1); 59 | } else { 60 | metrics.putMetric(`RoutingApiQuote5xxErr`, 1); 61 | } 62 | } else { 63 | metrics.putMetric(`RoutingApiQuote5xxErr`, 1); 64 | } 65 | metrics.putMetric(`RoutingApiQuoterErr`, 1); 66 | 67 | // We want to ensure that we throw all non-404 errors 68 | // to ensure that the client will know the request is retryable. 69 | // We include 429's in the retryable errors because a 429 would 70 | // indicate that the Routing API was being rate-limited and a subsequent 71 | // retry may succeed. 72 | 73 | // We also want to retry the request if there is a non-"AxiosError". 74 | // This may be caused by a network interruption or some other infra related issues. 75 | if (!axios.isAxiosError(e)) { 76 | throw e; 77 | } 78 | 79 | const status = e.response?.status; 80 | if (status && (status === 429 || status >= 500)) { 81 | throw e; 82 | } 83 | 84 | return null; 85 | } 86 | } 87 | 88 | buildRequest(request: ClassicRequest): string { 89 | const tradeType = request.info.type === TradeType.EXACT_INPUT ? 'exactIn' : 'exactOut'; 90 | const config = request.config; 91 | const amount = request.info.amount.toString(); 92 | 93 | return ( 94 | this.routingApiUrl + 95 | 'quote?' + 96 | querystring.stringify({ 97 | tokenInAddress: mapNative(request.info.tokenIn, request.info.tokenInChainId), 98 | tokenInChainId: request.info.tokenInChainId, 99 | tokenOutAddress: mapNative(request.info.tokenOut, request.info.tokenInChainId), 100 | tokenOutChainId: request.info.tokenOutChainId, 101 | amount: amount, 102 | type: tradeType, 103 | ...(config.protocols && 104 | config.protocols.length && { protocols: config.protocols.map((p) => p.toLowerCase()).join(',') }), 105 | ...(config.gasPriceWei !== undefined && { gasPriceWei: config.gasPriceWei }), 106 | ...(request.info.slippageTolerance !== undefined && { slippageTolerance: request.info.slippageTolerance }), 107 | ...(config.minSplits !== undefined && { minSplits: config.minSplits }), 108 | ...(config.maxSplits !== undefined && { maxSplits: config.maxSplits }), 109 | ...(config.forceCrossProtocol !== undefined && { forceCrossProtocol: config.forceCrossProtocol }), 110 | ...(config.forceMixedRoutes !== undefined && { forceMixedRoutes: config.forceMixedRoutes }), 111 | ...(config.deadline !== undefined && { deadline: config.deadline }), 112 | ...(config.algorithm !== undefined && { algorithm: config.algorithm }), 113 | ...(config.simulateFromAddress !== undefined && { simulateFromAddress: config.simulateFromAddress }), 114 | ...(config.permitSignature !== undefined && { permitSignature: config.permitSignature }), 115 | ...(config.permitNonce !== undefined && { permitNonce: config.permitNonce }), 116 | ...(config.permitExpiration !== undefined && { permitExpiration: config.permitExpiration }), 117 | ...(config.permitAmount !== undefined && { permitAmount: config.permitAmount.toString() }), 118 | ...(config.permitSigDeadline !== undefined && { permitSigDeadline: config.permitSigDeadline }), 119 | ...(config.enableUniversalRouter !== undefined && { enableUniversalRouter: config.enableUniversalRouter }), 120 | ...(config.recipient !== undefined && { recipient: config.recipient }), 121 | ...(config.gasToken !== undefined && { gasToken: config.gasToken }), 122 | // unicorn secret is only used for debug routing config 123 | // routing-api will only send the debug routing config that overrides the default routing config 124 | // (a.k.a. alpha router config within smart-order-router) if unified-routing-api 125 | // sends the correct unicorn secret 126 | ...(config.debugRoutingConfig !== undefined && { debugRoutingConfig: config.debugRoutingConfig }), 127 | ...(config.unicornSecret !== undefined && { unicornSecret: config.unicornSecret }), 128 | // quote speed can be sent in standalone query string param 129 | // expect web/mobile to send it for the 1st fast quote, 130 | // otherwise default not to send it 131 | ...(config.quoteSpeed !== undefined && { quoteSpeed: config.quoteSpeed }), 132 | ...(config.enableFeeOnTransferFeeFetching !== undefined && { 133 | enableFeeOnTransferFeeFetching: config.enableFeeOnTransferFeeFetching, 134 | }), 135 | ...(request.info.portion && 136 | frontendAndUraEnablePortion(request.info.sendPortionEnabled) && { 137 | portionBips: request.info.portion.bips, 138 | portionRecipient: request.info.portion.recipient, 139 | }), 140 | ...(request.info.intent && { intent: request.info.intent }), 141 | ...(request.info.source && { source: request.info.source }), 142 | }) 143 | ); 144 | } 145 | } 146 | 147 | function mapNative(token: string, chainId: number): string { 148 | if (token === NATIVE_ADDRESS) return NATIVE_NAMES_BY_ID[chainId][0]; 149 | return token; 150 | } 151 | -------------------------------------------------------------------------------- /lib/providers/quoters/helpers.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const DEFAULT_AXIOS_TIMEOUT = 10_000; 4 | 5 | axios.defaults.timeout = DEFAULT_AXIOS_TIMEOUT; 6 | 7 | export default axios; 8 | -------------------------------------------------------------------------------- /lib/providers/quoters/index.ts: -------------------------------------------------------------------------------- 1 | import { Quote, QuoteRequest } from '../../entities'; 2 | 3 | export * from './RfqQuoter'; 4 | export * from './RoutingApiQuoter'; 5 | 6 | export enum QuoterType { 7 | ROUTING_API = 'ROUTING_API', 8 | UNISWAPX_RFQ = 'UNISWAPX_RFQ', 9 | } 10 | 11 | export interface Quoter { 12 | quote(params: QuoteRequest): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /lib/providers/syntheticStatusProvider.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import querystring from 'querystring'; 3 | import { QuoteRequestInfo } from '../entities'; 4 | import { log } from '../util/log'; 5 | import axios from './quoters/helpers'; 6 | 7 | export type SyntheticStatus = { 8 | syntheticEnabled: boolean; 9 | }; 10 | 11 | export interface SyntheticStatusProvider { 12 | getStatus(quoteRequest: QuoteRequestInfo): Promise; 13 | } 14 | 15 | // fetches synthetic status from UniswapX Param API 16 | // TODO: add caching wrapper? Probably dont want to cache too aggressively 17 | // at risk of missing an important switch-off 18 | export class UPASyntheticStatusProvider implements SyntheticStatusProvider { 19 | constructor(private upaUrl: string, private paramApiKey: string) { 20 | // empty constructor 21 | } 22 | 23 | async getStatus(quoteRequest: QuoteRequestInfo): Promise { 24 | const { tokenIn, tokenInChainId, tokenOut, tokenOutChainId, amount, type } = quoteRequest; 25 | 26 | try { 27 | const result = await axios.get( 28 | `${this.upaUrl}synthetic-switch/enabled?` + 29 | querystring.stringify({ 30 | tokenIn, 31 | tokenInChainId, 32 | tokenOut, 33 | tokenOutChainId, 34 | amount: amount.toString(), 35 | type: TradeType[type], 36 | }), 37 | { headers: { 'x-api-key': this.paramApiKey } } 38 | ); 39 | 40 | log.info(`Synthetic status for ${tokenIn} -> ${tokenOut}: ${result.data}`); 41 | return { 42 | syntheticEnabled: result.data.enabled, 43 | }; 44 | } catch (e) { 45 | log.error('Error fetching synthetic status from UPA', e); 46 | return { 47 | syntheticEnabled: false, 48 | }; 49 | } 50 | } 51 | } 52 | 53 | // disabled synthetic status 54 | export class DisabledSyntheticStatusProvider implements SyntheticStatusProvider { 55 | constructor() {} 56 | 57 | async getStatus(_quoteRequest: QuoteRequestInfo): Promise { 58 | return { 59 | syntheticEnabled: false, 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/util/errors.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResult } from 'aws-lambda'; 2 | 3 | export enum ErrorCode { 4 | ValidationError = 'VALIDATION_ERROR', 5 | InternalError = 'INTERNAL_ERROR', 6 | QuoteError = 'QUOTE_ERROR', 7 | } 8 | 9 | export abstract class CustomError extends Error { 10 | abstract toJSON(id?: string): APIGatewayProxyResult; 11 | } 12 | 13 | export class ValidationError extends CustomError { 14 | constructor(message: string) { 15 | super(message); 16 | // Set the prototype explicitly. 17 | Object.setPrototypeOf(this, ValidationError.prototype); 18 | } 19 | 20 | toJSON(id?: string): APIGatewayProxyResult { 21 | return { 22 | statusCode: 400, 23 | body: JSON.stringify({ 24 | errorCode: ErrorCode.ValidationError, 25 | detail: this.message, 26 | id, 27 | }), 28 | }; 29 | } 30 | } 31 | 32 | export class NoQuotesAvailable extends CustomError { 33 | private static MESSAGE = 'No quotes available'; 34 | 35 | constructor() { 36 | super(NoQuotesAvailable.MESSAGE); 37 | // Set the prototype explicitly. 38 | Object.setPrototypeOf(this, NoQuotesAvailable.prototype); 39 | } 40 | 41 | toJSON(id?: string): APIGatewayProxyResult { 42 | return { 43 | statusCode: 404, 44 | body: JSON.stringify({ 45 | errorCode: ErrorCode.QuoteError, 46 | detail: this.message, 47 | id, 48 | }), 49 | }; 50 | } 51 | } 52 | 53 | export class QuoteFetchError extends CustomError { 54 | constructor(message: string) { 55 | super(message); 56 | // Set the prototype explicitly. 57 | Object.setPrototypeOf(this, QuoteFetchError.prototype); 58 | } 59 | 60 | toJSON(id?: string): APIGatewayProxyResult { 61 | return { 62 | statusCode: 500, 63 | body: JSON.stringify({ 64 | errorCode: ErrorCode.QuoteError, 65 | detail: this.message, 66 | id, 67 | }), 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/util/log.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import Logger from 'bunyan'; 4 | 5 | class NullLogger implements Logger { 6 | private ERROR_MESSAGE = 'NullLogger does not support. Instantiate a valid logger using "setGlobalLogger"'; 7 | addStream(_stream: Logger.Stream): void { 8 | throw new Error(this.ERROR_MESSAGE); 9 | } 10 | addSerializers(_serializers: Logger.Serializers): void { 11 | throw new Error(this.ERROR_MESSAGE); 12 | } 13 | child(_options: Object, _simple?: boolean): Logger { 14 | return this; 15 | } 16 | reopenFileStreams(): void { 17 | throw new Error(this.ERROR_MESSAGE); 18 | } 19 | level(): number; 20 | level(value: Logger.LogLevel): void; 21 | level(_value?: any): number | void { 22 | return; 23 | } 24 | levels(): number[]; 25 | levels(name: string | number): number; 26 | levels(name: string | number, value: Logger.LogLevel): void; 27 | levels(_name?: any, _value?: any): number | void | number[] { 28 | return; 29 | } 30 | fields: any; 31 | src = true; 32 | trace(): boolean; 33 | trace(error: Error, ...params: any[]): void; 34 | trace(obj: Object, ...params: any[]): void; 35 | trace(format: any, ...params: any[]): void; 36 | trace(..._rest: any): boolean | void { 37 | return true; 38 | } 39 | debug(): boolean; 40 | debug(error: Error, ...params: any[]): void; 41 | debug(obj: Object, ...params: any[]): void; 42 | debug(format: any, ...params: any[]): void; 43 | debug(..._rest: any): boolean | void { 44 | return true; 45 | } 46 | info(): boolean; 47 | info(error: Error, ...params: any[]): void; 48 | info(obj: Object, ...params: any[]): void; 49 | info(format: any, ...params: any[]): void; 50 | info(..._rest: any): boolean | void { 51 | return true; 52 | } 53 | warn(): boolean; 54 | warn(error: Error, ...params: any[]): void; 55 | warn(obj: Object, ...params: any[]): void; 56 | warn(format: any, ...params: any[]): void; 57 | warn(..._rest: any): boolean | void { 58 | return true; 59 | } 60 | error(): boolean; 61 | error(error: Error, ...params: any[]): void; 62 | error(obj: Object, ...params: any[]): void; 63 | error(format: any, ...params: any[]): void; 64 | error(..._rest: any): boolean | void { 65 | return true; 66 | } 67 | fatal(): boolean; 68 | fatal(error: Error, ...params: any[]): void; 69 | fatal(obj: Object, ...params: any[]): void; 70 | fatal(format: any, ...params: any[]): void; 71 | fatal(..._rest: any): boolean | void { 72 | return true; 73 | } 74 | addListener(_event: string | symbol, _listener: (...args: any[]) => void): this { 75 | throw new Error(this.ERROR_MESSAGE); 76 | } 77 | on(_event: string | symbol, _listener: (...args: any[]) => void): this { 78 | throw new Error(this.ERROR_MESSAGE); 79 | } 80 | once(_event: string | symbol, _listener: (...args: any[]) => void): this { 81 | throw new Error(this.ERROR_MESSAGE); 82 | } 83 | removeListener(_event: string | symbol, _listener: (...args: any[]) => void): this { 84 | throw new Error(this.ERROR_MESSAGE); 85 | } 86 | off(_event: string | symbol, _listener: (...args: any[]) => void): this { 87 | throw new Error(this.ERROR_MESSAGE); 88 | } 89 | removeAllListeners(_event?: string | symbol): this { 90 | throw new Error(this.ERROR_MESSAGE); 91 | } 92 | setMaxListeners(_n: number): this { 93 | throw new Error(this.ERROR_MESSAGE); 94 | } 95 | getMaxListeners(): number { 96 | throw new Error(this.ERROR_MESSAGE); 97 | } 98 | listeners(_event: string | symbol): Function[] { 99 | throw new Error(this.ERROR_MESSAGE); 100 | } 101 | rawListeners(_event: string | symbol): Function[] { 102 | throw new Error(this.ERROR_MESSAGE); 103 | } 104 | emit(_event: string | symbol, ..._args: any[]): boolean { 105 | throw new Error(this.ERROR_MESSAGE); 106 | } 107 | listenerCount(_event: string | symbol): number { 108 | throw new Error(this.ERROR_MESSAGE); 109 | } 110 | prependListener(_event: string | symbol, _listener: (...args: any[]) => void): this { 111 | throw new Error(this.ERROR_MESSAGE); 112 | } 113 | prependOnceListener(_event: string | symbol, _listener: (...args: any[]) => void): this { 114 | throw new Error(this.ERROR_MESSAGE); 115 | } 116 | eventNames(): (string | symbol)[] { 117 | throw new Error(this.ERROR_MESSAGE); 118 | } 119 | } 120 | 121 | export let log: Logger = new NullLogger(); 122 | 123 | export const setGlobalLogger = (_log: Logger) => { 124 | log = _log; 125 | }; 126 | -------------------------------------------------------------------------------- /lib/util/metrics-pair.ts: -------------------------------------------------------------------------------- 1 | import { Currency, CurrencyAmount, Ether, TradeType, WETH9 } from '@uniswap/sdk-core'; 2 | import { DAI_MAINNET, USDC_MAINNET as USDC, USDT_MAINNET, WBTC_MAINNET } from '@uniswap/smart-order-router'; 3 | import { BigNumber } from 'ethers'; 4 | import { parseUnits } from 'ethers/lib/utils'; 5 | import { NATIVE_ADDRESS, QuoteType } from '../constants'; 6 | import { log } from './log'; 7 | import { metrics } from './metrics'; 8 | 9 | export class MetricPair { 10 | private INFINITY = 'X'; 11 | 12 | constructor( 13 | public chainId: number, 14 | public tokenIn: Currency, 15 | public tokenOut: Currency, 16 | public buckets: [string, string][] 17 | ) {} 18 | 19 | public async emitMetricIfValid( 20 | tokenInAddress: string, 21 | tokenOutAddress: string, 22 | amount: string, 23 | bestQuoteType: QuoteType 24 | ): Promise { 25 | try { 26 | const tokenInMetricAddress = this.tokenIn.isNative ? NATIVE_ADDRESS : this.tokenIn.wrapped.address; 27 | const tokenOutMetricAddress = this.tokenOut.isNative ? NATIVE_ADDRESS : this.tokenOut.wrapped.address; 28 | 29 | if ( 30 | tokenInMetricAddress.toLowerCase() != tokenInAddress.toLowerCase() || 31 | tokenOutMetricAddress.toLowerCase() != tokenOutAddress.toLowerCase() 32 | ) { 33 | return null; 34 | } 35 | 36 | const amountIn = CurrencyAmount.fromRawAmount(this.tokenIn, amount); 37 | for (const [low, high] of this.buckets) { 38 | const highAmount = this.parse(high, this.tokenIn); 39 | if (amountIn.lessThan(highAmount)) { 40 | const key = this.metricKey(low, high, bestQuoteType); 41 | metrics.putMetric(key, 1); 42 | 43 | return key; 44 | } 45 | } 46 | 47 | const key = this.metricKey(this.buckets[this.buckets.length - 1][1], this.INFINITY, bestQuoteType); 48 | metrics.putMetric(key, 1); 49 | 50 | return key; 51 | } catch (err) { 52 | log.info( 53 | { 54 | err, 55 | quoteTokenIn: tokenInAddress, 56 | metricTokenIn: this.tokenIn.wrapped.address, 57 | quoteTokenOut: tokenOutAddress, 58 | metricTokenOut: this.tokenOut.wrapped.address, 59 | }, 60 | 'Tried and failed to emit custom pair metric' 61 | ); 62 | return null; 63 | } 64 | } 65 | 66 | private metricKey(low: string, high: string, bestQuoteType: QuoteType): string { 67 | return `${this.tokenIn.symbol!}-${this.tokenOut.symbol!}-${low}-${high}-${bestQuoteType}`; 68 | } 69 | 70 | public metricKeys(): string[][] { 71 | const bucketKeys: string[][] = []; 72 | for (const [low, high] of this.buckets) { 73 | bucketKeys.push([ 74 | this.metricKey(low, high, QuoteType.CLASSIC), 75 | this.metricKey(low, high, QuoteType.RFQ), 76 | this.metricKey(low, high, QuoteType.SYNTHETIC), 77 | ]); 78 | } 79 | 80 | const highest = this.buckets[this.buckets.length - 1][1]; 81 | 82 | bucketKeys.push([ 83 | this.metricKey(highest, this.INFINITY, QuoteType.CLASSIC), 84 | this.metricKey(highest, this.INFINITY, QuoteType.RFQ), 85 | this.metricKey(highest, this.INFINITY, QuoteType.SYNTHETIC), 86 | ]); 87 | 88 | return bucketKeys; 89 | } 90 | 91 | private parse(value: string, currency: Currency): CurrencyAmount { 92 | const typedValueParsed = parseUnits(value, currency.decimals).toString(); 93 | log.info(`parsed value: ${typedValueParsed}`); 94 | return CurrencyAmount.fromRawAmount(currency, typedValueParsed); 95 | } 96 | } 97 | 98 | export const trackedPairs: MetricPair[] = [ 99 | new MetricPair(1, Ether.onChain(1), USDC, [ 100 | ['0', '0.05'], 101 | ['0.05', '0.5'], 102 | ['0.5', '2.5'], 103 | ['2.5', '10'], 104 | ['10', '50'], 105 | ['50', '250'], 106 | ]), 107 | new MetricPair(1, WETH9[1], USDC, [ 108 | ['0', '0.05'], 109 | ['0.05', '0.5'], 110 | ['0.5', '2.5'], 111 | ['2.5', '10'], 112 | ['10', '50'], 113 | ['50', '250'], 114 | ]), 115 | new MetricPair(1, USDC, Ether.onChain(1), [ 116 | ['0', '100'], 117 | ['100', '1000'], 118 | ['1000', '5000'], 119 | ['5000', '20000'], 120 | ['20000', '100000'], 121 | ['100000', '500000'], 122 | ]), 123 | new MetricPair(1, USDC, WETH9[1], [ 124 | ['0', '100'], 125 | ['100', '1000'], 126 | ['1000', '5000'], 127 | ['5000', '20000'], 128 | ['20000', '100000'], 129 | ['100000', '500000'], 130 | ]), 131 | new MetricPair(1, USDT_MAINNET, Ether.onChain(1), [ 132 | ['0', '100'], 133 | ['100', '1000'], 134 | ['1000', '5000'], 135 | ['5000', '20000'], 136 | ['20000', '100000'], 137 | ['100000', '500000'], 138 | ]), 139 | new MetricPair(1, USDT_MAINNET, WETH9[1], [ 140 | ['0', '100'], 141 | ['100', '1000'], 142 | ['1000', '5000'], 143 | ['5000', '20000'], 144 | ['20000', '100000'], 145 | ['100000', '500000'], 146 | ]), 147 | new MetricPair(1, Ether.onChain(1), USDT_MAINNET, [ 148 | ['0', '0.05'], 149 | ['0.05', '0.5'], 150 | ['0.5', '2.5'], 151 | ['2.5', '10'], 152 | ['10', '50'], 153 | ['50', '250'], 154 | ]), 155 | new MetricPair(1, WETH9[1], USDT_MAINNET, [ 156 | ['0', '0.05'], 157 | ['0.05', '0.5'], 158 | ['0.5', '2.5'], 159 | ['2.5', '10'], 160 | ['10', '50'], 161 | ['50', '250'], 162 | ]), 163 | new MetricPair(1, USDC, USDT_MAINNET, [ 164 | ['0', '100'], 165 | ['100', '1000'], 166 | ['1000', '5000'], 167 | ['5000', '20000'], 168 | ['20000', '100000'], 169 | ['100000', '500000'], 170 | ]), 171 | new MetricPair(1, Ether.onChain(1), WBTC_MAINNET, [ 172 | ['0', '0.05'], 173 | ['0.05', '0.5'], 174 | ['0.5', '2.5'], 175 | ['2.5', '10'], 176 | ['10', '50'], 177 | ['50', '250'], 178 | ]), 179 | new MetricPair(1, WETH9[1], WBTC_MAINNET, [ 180 | ['0', '0.05'], 181 | ['0.05', '0.5'], 182 | ['0.5', '2.5'], 183 | ['2.5', '10'], 184 | ['10', '50'], 185 | ['50', '250'], 186 | ]), 187 | new MetricPair(1, WBTC_MAINNET, Ether.onChain(1), [ 188 | ['0', '0.003'], 189 | ['0.003', '0.03'], 190 | ['0.03', '0.15'], 191 | ['0.15', '0.6'], 192 | ['0.6', '3'], 193 | ['3', '15'], 194 | ]), 195 | new MetricPair(1, WBTC_MAINNET, WETH9[1], [ 196 | ['0', '0.003'], 197 | ['0.003', '0.03'], 198 | ['0.03', '0.15'], 199 | ['0.15', '0.6'], 200 | ['0.6', '3'], 201 | ['3', '15'], 202 | ]), 203 | new MetricPair(1, USDC, DAI_MAINNET, [ 204 | ['0', '100'], 205 | ['100', '1000'], 206 | ['1000', '5000'], 207 | ['5000', '20000'], 208 | ['20000', '100000'], 209 | ['100000', '500000'], 210 | ]), 211 | new MetricPair(1, DAI_MAINNET, USDC, [ 212 | ['0', '100'], 213 | ['100', '1000'], 214 | ['1000', '5000'], 215 | ['5000', '20000'], 216 | ['20000', '100000'], 217 | ['100000', '500000'], 218 | ]), 219 | ]; 220 | 221 | export const emitUniswapXPairMetricIfTracking = async ( 222 | tokenInAddress: string, 223 | tokenOutAddress: string, 224 | amountIn: BigNumber, 225 | bestQuoteType: QuoteType, 226 | tradeType: TradeType 227 | ) => { 228 | if (tradeType == TradeType.EXACT_OUTPUT) { 229 | return; 230 | } 231 | 232 | for (const metricPair of trackedPairs) { 233 | const emitted = await metricPair.emitMetricIfValid( 234 | tokenInAddress, 235 | tokenOutAddress, 236 | amountIn.toString(), 237 | bestQuoteType 238 | ); 239 | if (emitted) { 240 | log.info(`custom pair tracking metric emitted for ${emitted}`); 241 | return; 242 | } 243 | } 244 | }; 245 | -------------------------------------------------------------------------------- /lib/util/metrics.ts: -------------------------------------------------------------------------------- 1 | import { StorageResolution, Unit } from 'aws-embedded-metrics'; 2 | 3 | export interface IMetrics { 4 | putMetric(key: string, value: number, unit?: Unit | string, storageResolution?: StorageResolution | number): void; 5 | } 6 | 7 | export class NullMetrics implements IMetrics { 8 | // eslint-disable-next-line @typescript-eslint/no-empty-function 9 | putMetric(_key: string, _value: number, _unit?: Unit | string, _storageResolution?: StorageResolution | number) {} 10 | } 11 | 12 | export let metrics: IMetrics = new NullMetrics(); 13 | 14 | export const setGlobalMetrics = (_metric: IMetrics) => { 15 | metrics = _metric; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/util/nonce.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | 3 | export function generateRandomNonce(): string { 4 | return ethers.BigNumber.from(ethers.utils.randomBytes(31)).shl(8).toString(); 5 | } 6 | -------------------------------------------------------------------------------- /lib/util/permit2.ts: -------------------------------------------------------------------------------- 1 | import { AllowanceTransfer, MaxAllowanceTransferAmount, permit2Address, PermitSingleData } from '@uniswap/permit2-sdk'; 2 | import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'; 3 | import ms from 'ms'; 4 | 5 | const PERMIT_EXPIRATION = ms('30d'); 6 | const PERMIT_SIG_EXPIRATION = ms('30m'); 7 | const PERMIT_AMOUNT = MaxAllowanceTransferAmount.toString(); 8 | 9 | function toDeadline(expiration: number): number { 10 | return Math.floor((Date.now() + expiration) / 1000); 11 | } 12 | 13 | export function createPermitData(tokenAddress: string, chainId: number, nonce: string): PermitSingleData { 14 | const permit = { 15 | details: { 16 | token: tokenAddress, 17 | amount: PERMIT_AMOUNT.toString(), 18 | expiration: toDeadline(PERMIT_EXPIRATION).toString(), 19 | nonce: nonce, 20 | }, 21 | spender: UNIVERSAL_ROUTER_ADDRESS(chainId), 22 | sigDeadline: toDeadline(PERMIT_SIG_EXPIRATION).toString(), 23 | }; 24 | 25 | return AllowanceTransfer.getPermitData(permit, permit2Address(chainId), chainId) as PermitSingleData; 26 | } 27 | -------------------------------------------------------------------------------- /lib/util/portion.ts: -------------------------------------------------------------------------------- 1 | export let forcePortion = false; 2 | 3 | export const setGlobalForcePortion = (_forcePortion: boolean) => (forcePortion = _forcePortion); 4 | -------------------------------------------------------------------------------- /lib/util/preconditions.ts: -------------------------------------------------------------------------------- 1 | export function checkDefined(value: T | null | undefined, message = 'Should be defined'): T { 2 | if (value === null || value === undefined) { 3 | throw new Error(message); 4 | } 5 | return value; 6 | } 7 | -------------------------------------------------------------------------------- /lib/util/quoteMath.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber as BN } from 'bignumber.js'; 2 | import { BigNumber } from 'ethers'; 3 | import { ClassicQuoteDataJSON, Quote } from '../entities'; 4 | 5 | // quoteSizeUSD = (multiple of gas-cost-equivalent quote token) * (gas cost in USD) 6 | export function getQuoteSizeEstimateUSD(classicQuote: Quote) { 7 | const classicQuoteData = classicQuote.toJSON() as ClassicQuoteDataJSON; 8 | return new BN( 9 | BigNumber.from(classicQuoteData.quoteGasAdjusted) 10 | .div(BigNumber.from(classicQuoteData.gasUseEstimateQuote)) 11 | .toString() 12 | ).times(new BN(classicQuoteData.gasUseEstimateUSD)); 13 | } 14 | -------------------------------------------------------------------------------- /lib/util/stage.ts: -------------------------------------------------------------------------------- 1 | export enum STAGE { 2 | BETA = 'beta', 3 | PROD = 'prod', 4 | LOCAL = 'local', 5 | } 6 | -------------------------------------------------------------------------------- /lib/util/time.ts: -------------------------------------------------------------------------------- 1 | export const currentTimestampInSeconds = () => Math.floor(Date.now() / 1000).toString(); 2 | export const currentTimestampInMs = () => Date.now().toString(); 3 | export const timestampInMstoSeconds = (timestamp: number) => Math.floor(timestamp / 1000).toString(); 4 | -------------------------------------------------------------------------------- /lib/util/validator.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, ethers } from 'ethers'; 2 | import Joi, { CustomHelpers } from 'joi'; 3 | 4 | import { ChainConfigManager } from '../config/ChainConfigManager'; 5 | import { BPS, RoutingType } from '../constants'; 6 | import { DutchConfigJSON, DutchV2ConfigJSON } from '../entities'; 7 | 8 | export class FieldValidator { 9 | public static readonly address = Joi.string().custom((value: string, helpers: CustomHelpers) => { 10 | if (!ethers.utils.isAddress(value)) { 11 | return helpers.message({ custom: 'Invalid address' }); 12 | } 13 | return ethers.utils.getAddress(value); 14 | }); 15 | 16 | public static readonly amount = Joi.string().custom((value: string, helpers: CustomHelpers) => { 17 | try { 18 | const result = BigNumber.from(value); 19 | if (result.lt(0)) { 20 | return helpers.message({ custom: 'Invalid amount: negative number' }); 21 | } else if (result.gt(ethers.constants.MaxUint256)) { 22 | return helpers.message({ custom: 'Invalid amount: larger than UINT256_MAX' }); 23 | } 24 | } catch { 25 | // bignumber error is a little ugly for API response so rethrow our own 26 | return helpers.message({ custom: 'Invalid amount' }); 27 | } 28 | return value; 29 | }); 30 | 31 | public static readonly uuid = Joi.string().uuid({ version: 'uuidv4' }); 32 | 33 | public static readonly classicChainId = Joi.number() 34 | .integer() 35 | .valid(...ChainConfigManager.getChainIdsByRoutingType(RoutingType.CLASSIC)); 36 | 37 | public static readonly dutchChainId = Joi.number() 38 | .integer() 39 | .valid(...ChainConfigManager.getChainIdsByRoutingType(RoutingType.DUTCH_LIMIT)); 40 | 41 | public static readonly tradeType = Joi.string().valid('EXACT_INPUT', 'EXACT_OUTPUT'); 42 | 43 | public static readonly routingType = Joi.string().valid('CLASSIC', 'DUTCH_LIMIT', 'DUTCH_V2', 'RELAY').messages({ 44 | 'any.only': 'Invalid routingType', 45 | }); 46 | 47 | public static readonly algorithm = Joi.string().valid('alpha', 'legacy'); 48 | 49 | public static readonly protocol = Joi.string().valid('v2', 'v3', 'mixed', 'V2', 'V3', 'MIXED'); 50 | 51 | public static readonly protocols = Joi.array().items(FieldValidator.protocol); 52 | 53 | public static readonly gasPriceWei = Joi.string() 54 | .pattern(/^[0-9]+$/) 55 | .max(30); 56 | 57 | public static readonly permitSignature = Joi.string(); 58 | 59 | public static readonly permitNonce = Joi.string(); 60 | 61 | public static readonly enableUniversalRouter = Joi.boolean(); 62 | 63 | public static readonly slippageTolerance = Joi.number().min(0).max(20); // 20% 64 | 65 | public static readonly exclusivityOverrideBps = Joi.number().min(0).max(10000); // 0 to 100% 66 | 67 | public static readonly deadline = Joi.number().greater(0).max(10800); // 180 mins, same as interface max; 68 | 69 | public static readonly minSplits = Joi.number().max(7); 70 | 71 | public static readonly maxSplits = Joi.number().max(7); 72 | 73 | public static readonly forceCrossProtocol = Joi.boolean(); 74 | 75 | public static readonly forceMixedRoutes = Joi.boolean(); 76 | 77 | public static readonly positiveNumber = Joi.number().greater(0); 78 | 79 | public static readonly quoteSpeed = Joi.string().valid('fast', 'standard'); 80 | 81 | public static readonly bps = Joi.number().min(0).max(BPS); 82 | 83 | public static readonly classicConfig = Joi.object({ 84 | routingType: Joi.string().valid('CLASSIC'), 85 | protocols: FieldValidator.protocols.required(), 86 | gasPriceWei: FieldValidator.gasPriceWei.optional(), 87 | simulateFromAddress: FieldValidator.address.optional(), 88 | recipient: FieldValidator.address.optional(), 89 | permitSignature: FieldValidator.permitSignature.optional(), 90 | permitNonce: FieldValidator.permitNonce.optional(), 91 | permitExpiration: FieldValidator.positiveNumber.optional(), 92 | permitAmount: FieldValidator.amount.optional(), 93 | permitSigDeadline: FieldValidator.positiveNumber.optional(), 94 | enableUniversalRouter: FieldValidator.enableUniversalRouter.optional(), 95 | deadline: FieldValidator.deadline.optional(), 96 | minSplits: FieldValidator.minSplits.optional(), 97 | maxSplits: FieldValidator.maxSplits.optional(), 98 | forceCrossProtocol: FieldValidator.forceCrossProtocol.optional(), 99 | forceMixedRoutes: FieldValidator.forceMixedRoutes.optional(), 100 | algorithm: FieldValidator.algorithm.optional(), 101 | quoteSpeed: FieldValidator.quoteSpeed.optional(), 102 | enableFeeOnTransferFeeFetching: Joi.boolean().optional(), 103 | }); 104 | 105 | public static readonly dutchLimitConfig = Joi.object({ 106 | routingType: Joi.string().valid('DUTCH_LIMIT'), 107 | swapper: FieldValidator.address.optional(), 108 | exclusivityOverrideBps: FieldValidator.positiveNumber.optional(), 109 | startTimeBufferSecs: FieldValidator.positiveNumber.optional(), 110 | auctionPeriodSecs: FieldValidator.positiveNumber.optional(), 111 | deadlineBufferSecs: FieldValidator.positiveNumber.optional(), 112 | useSyntheticQuotes: Joi.boolean().optional(), 113 | gasAdjustmentBps: FieldValidator.bps.optional(), 114 | forceOpenOrders: Joi.boolean().optional(), 115 | priceImprovementBps: FieldValidator.bps.optional(), 116 | }); 117 | 118 | // extends a classic request config, but requires a gasToken and has optional parameters for the fee auction 119 | public static readonly relayConfig = this.classicConfig.keys({ 120 | routingType: Joi.string().valid('RELAY'), 121 | gasToken: FieldValidator.address.required(), 122 | swapper: FieldValidator.address.optional(), 123 | startTimeBufferSecs: FieldValidator.positiveNumber.optional(), 124 | auctionPeriodSecs: FieldValidator.positiveNumber.optional(), 125 | deadlineBufferSecs: FieldValidator.positiveNumber.optional(), 126 | slippageTolerance: FieldValidator.slippageTolerance.optional(), 127 | amountInGasTokenStartOverride: FieldValidator.amount.optional(), 128 | }); 129 | 130 | public static readonly dutchV2Config = Joi.object({ 131 | routingType: Joi.string().valid('DUTCH_V2'), 132 | swapper: FieldValidator.address.optional(), 133 | deadlineBufferSecs: FieldValidator.positiveNumber.optional(), 134 | useSyntheticQuotes: Joi.boolean().optional(), 135 | gasAdjustmentBps: FieldValidator.bps.optional(), 136 | forceOpenOrders: Joi.boolean().optional(), 137 | priceImprovementBps: FieldValidator.bps.optional(), 138 | }); 139 | } 140 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uniswap/unified-routing-api", 3 | "version": "0.0.1", 4 | "bin": { 5 | "app": "./dist/bin/app.js" 6 | }, 7 | "license": "UNLICENSED", 8 | "repository": "https://github.com/Uniswap/unified-routing-api", 9 | "scripts": { 10 | "compile-external-types": "npx typechain --target ethers-v5 --out-dir lib/types/ext 'lib/abis/**/*.json'", 11 | "compile-v3-contract-types": "npx typechain --target ethers-v5 --out-dir lib/types/v3 './node_modules/@uniswap/?(v3-core|v3-periphery)/artifacts/contracts/**/*.json'", 12 | "build": "run-p compile-*-types && tsc", 13 | "clean": "rm -rf dist cdk.out", 14 | "watch": "tsc -w", 15 | "test": "jest --testPathPattern test/unit --detectOpenHandles --forceExit", 16 | "test:integ": "ts-mocha -p tsconfig.cdk.json -r dotenv/config test/integ/**/*.test.ts", 17 | "fix": "run-s fix:*", 18 | "fix:prettier": "prettier \"lib/**/*.ts\" --write", 19 | "fix:prettiertest": "prettier \"test/**/*.ts\" --write", 20 | "fix:lint": "eslint lib test --ext .ts --fix", 21 | "fix:lint:cdk": "eslint bin --ext .ts --fix", 22 | "lint": "run-s lint:*", 23 | "lint:prettier": "prettier \"lib/**/*.ts\"", 24 | "lint:prettiertest": "prettier \"test/**/*.ts\"", 25 | "lint:eslint": "eslint lib --ext .ts", 26 | "lint:lint:cdk": "eslint bin --ext .ts", 27 | "deploy": "yarn build && cdk deploy" 28 | }, 29 | "devDependencies": { 30 | "@nomiclabs/hardhat-ethers": "^2.2.2", 31 | "@nomiclabs/hardhat-waffle": "^2.0.5", 32 | "@types/aws-lambda": "^8.10.108", 33 | "@types/bunyan": "^1.8.8", 34 | "@types/chai": "^4.3.4", 35 | "@types/chai-as-promised": "^7.1.5", 36 | "@types/chai-subset": "^1.3.3", 37 | "@types/hapi__joi": "^17.1.9", 38 | "@types/jest": "^29.2.0", 39 | "@types/lodash": "^4.14.191", 40 | "@types/mocha": "^10.0.1", 41 | "@types/ms": "^0.7.31", 42 | "@types/node": "^18.15.11", 43 | "@types/qs": "^6.9.7", 44 | "@types/uuid": "^9.0.0", 45 | "@typescript-eslint/eslint-plugin": "^5.40.1", 46 | "@typescript-eslint/parser": "^5.40.1", 47 | "async-retry": "^1.3.3", 48 | "aws-sdk-client-mock": "^2.0.0", 49 | "esbuild": "^0.15.12", 50 | "eslint": "^8.53.0", 51 | "eslint-config-prettier": "^8.5.0", 52 | "eslint-plugin-eslint-comments": "^3.2.0", 53 | "eslint-plugin-import": "^2.26.0", 54 | "eslint-plugin-jest": "^27.6.0", 55 | "eslint-plugin-simple-import-sort": "^8.0.0", 56 | "ethers": "^5.7.2", 57 | "hardhat": "^2.13.0", 58 | "jest": "^29.2.2", 59 | "npm-run-all": "^4.1.5", 60 | "prettier": "^2.7.1", 61 | "prettier-plugin-organize-imports": "^3.1.1", 62 | "ts-jest": "^29.0.3", 63 | "ts-node": "^8.10.2", 64 | "typescript": "^4.2.3" 65 | }, 66 | "dependencies": { 67 | "@swc/core": "^1.3.101", 68 | "@swc/jest": "^0.2.29", 69 | "@typechain/ethers-v5": "^7.0.1", 70 | "@uniswap/default-token-list": "^11.7.0", 71 | "@uniswap/permit2-sdk": "^1.3.0", 72 | "@uniswap/router-sdk": "^1.9.2", 73 | "@uniswap/sdk-core": "^5.3.0", 74 | "@uniswap/smart-order-router": "^3.35.0", 75 | "@uniswap/uniswapx-sdk": "2.1.0-beta.4", 76 | "@uniswap/universal-router-sdk": "^2.2.0", 77 | "aws-cdk-lib": "2.85.0", 78 | "aws-embedded-metrics": "^4.1.0", 79 | "axios": "^1.2.1", 80 | "axios-retry": "^3.4.0", 81 | "bignumber.js": "^9.1.2", 82 | "bunyan": "^1.8.15", 83 | "chai": "^4.3.7", 84 | "chai-as-promised": "^7.1.1", 85 | "chai-subset": "^1.6.0", 86 | "constructs": "^10.1.137", 87 | "dotenv": "^16.0.3", 88 | "dynamodb-toolbox": "^0.6.1", 89 | "esm": "^3.2.25", 90 | "joi": "^17.7.0", 91 | "ms": "^2.1.3", 92 | "node-cache": "^5.1.2", 93 | "sinon": "^15.1.0", 94 | "source-map-support": "^0.5.21", 95 | "tiny-invariant": "^1.3.1", 96 | "ts-mocha": "^10.0.0", 97 | "typechain": "^5.0.0", 98 | "uuid": "^9.0.0" 99 | }, 100 | "prettier": { 101 | "printWidth": 120, 102 | "semi": true, 103 | "singleQuote": true, 104 | "organizeImportsSkipDestructiveCodeActions": true 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /test/integ/base.test.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 2 | import { ZERO } from '@uniswap/router-sdk'; 3 | import { Currency, CurrencyAmount, Fraction, Percent } from '@uniswap/sdk-core'; 4 | import { DAI_MAINNET, parseAmount, USDC_MAINNET, USDT_MAINNET, WBTC_MAINNET, WETH9 } from '@uniswap/smart-order-router'; 5 | import { fail } from 'assert'; 6 | import axiosStatic, { AxiosRequestConfig, AxiosResponse } from 'axios'; 7 | import axiosRetry from 'axios-retry'; 8 | import { expect } from 'chai'; 9 | import { Contract, ContractFactory } from 'ethers'; 10 | import hre from 'hardhat'; 11 | import _ from 'lodash'; 12 | import NodeCache from 'node-cache'; 13 | import { RoutingType } from '../../lib/constants'; 14 | import { ClassicQuoteDataJSON } from '../../lib/entities/quote'; 15 | import { QuoteRequestBodyJSON } from '../../lib/entities/request'; 16 | import { PortionFetcher } from '../../lib/fetchers/PortionFetcher'; 17 | import { QuoteResponseJSON } from '../../lib/handlers/quote/handler'; 18 | import { resetAndFundAtBlock } from '../utils/forkAndFund'; 19 | import { agEUR_MAINNET, BULLET, getAmount, UNI_MAINNET, XSGD_MAINNET } from '../utils/tokens'; 20 | 21 | const { ethers } = hre; 22 | 23 | if (!process.env.UNISWAP_API || !process.env.ARCHIVE_NODE_RPC) { 24 | throw new Error('Must set UNISWAP_API and ARCHIVE_NODE_RPC env variables for integ tests. See README'); 25 | } 26 | 27 | if (!process.env.PORTION_API_URL) { 28 | throw new Error('Must set PORTION_API_URL env variables for integ tests. See README'); 29 | } 30 | 31 | // URA endpoint 32 | const API = `${process.env.UNISWAP_API!}quote`; 33 | 34 | const SLIPPAGE = '5'; 35 | 36 | export interface HardQuoteRequest { 37 | requestId: string; 38 | encodedInnerOrder: string; 39 | innerSig: string; 40 | tokenInChainId: number; 41 | tokenOutChainId: number; 42 | } 43 | 44 | export interface HardQuoteResponseData { 45 | requestId: string; 46 | quoteId?: string; 47 | chainId: number; 48 | encodedOrder: string; 49 | orderHash: string; 50 | filler?: string; 51 | } 52 | 53 | export const axiosHelper = axiosStatic.create({ 54 | timeout: 30000, 55 | }); 56 | const axiosConfig: AxiosRequestConfig = { 57 | headers: { 58 | ...(process.env.URA_INTERNAL_API_KEY && { 'x-api-key': process.env.URA_INTERNAL_API_KEY }), 59 | ...(process.env.FORCE_PORTION_SECRET && { 'X-UNISWAP-FORCE-PORTION-SECRET': process.env.FORCE_PORTION_SECRET }), 60 | }, 61 | }; 62 | 63 | axiosRetry(axiosHelper, { 64 | retries: 10, 65 | retryCondition: (err) => err.response?.status == 429, 66 | retryDelay: axiosRetry.exponentialDelay, 67 | }); 68 | 69 | export const callAndExpectFail = async ( 70 | quoteReq: Partial, 71 | resp: { status: number; data: any } 72 | ) => { 73 | try { 74 | await axiosHelper.post(`${API}`, quoteReq); 75 | fail(); 76 | } catch (err: any) { 77 | expect(_.pick(err.response, ['status', 'data'])).to.containSubset(resp); 78 | } 79 | }; 80 | 81 | export const call = async ( 82 | quoteReq: Partial, 83 | config = axiosConfig 84 | ): Promise> => { 85 | return await axiosHelper.post(`${API}`, quoteReq, config); 86 | }; 87 | 88 | export const callIndicative = async ( 89 | quoteReq: Partial, 90 | config = axiosConfig 91 | ): Promise> => { 92 | return await axiosHelper.post(`${API}`, quoteReq, config); 93 | }; 94 | 95 | export const checkQuoteToken = ( 96 | before: CurrencyAmount, 97 | after: CurrencyAmount, 98 | tokensQuoted: CurrencyAmount 99 | ) => { 100 | // Check which is bigger to support EXACT_INPUT and EXACT_OUTPUT 101 | const tokensSwapped = after.greaterThan(before) ? after.subtract(before) : before.subtract(after); 102 | 103 | const tokensDiff = tokensQuoted.greaterThan(tokensSwapped) 104 | ? tokensQuoted.subtract(tokensSwapped) 105 | : tokensSwapped.subtract(tokensQuoted); 106 | const percentDiff = tokensDiff.asFraction.divide(tokensQuoted.asFraction); 107 | expect( 108 | percentDiff.lessThan(new Fraction(parseInt(SLIPPAGE), 100)), 109 | `expected tokensQuoted ${tokensQuoted.toExact()} actual tokens swapped ${tokensSwapped.toExact()}` 110 | ).to.be.true; 111 | }; 112 | 113 | export const checkPortionRecipientToken = ( 114 | before: CurrencyAmount, 115 | after: CurrencyAmount, 116 | expectedPortionAmountReceived: CurrencyAmount 117 | ) => { 118 | const actualPortionAmountReceived = after.subtract(before); 119 | 120 | const tokensDiff = expectedPortionAmountReceived.greaterThan(actualPortionAmountReceived) 121 | ? expectedPortionAmountReceived.subtract(actualPortionAmountReceived) 122 | : actualPortionAmountReceived.subtract(expectedPortionAmountReceived); 123 | // There will be a slight difference between expected and actual due to slippage during the hardhat fork swap. 124 | const percentDiff = tokensDiff.equalTo(ZERO) 125 | ? new Percent(ZERO) 126 | : tokensDiff.asFraction.divide(expectedPortionAmountReceived.asFraction); 127 | expect(percentDiff.lessThan(new Fraction(parseInt(SLIPPAGE), 100))).to.be.true; 128 | }; 129 | 130 | let warnedTesterPK = false; 131 | export const isTesterPKEnvironmentSet = (): boolean => { 132 | const isSet = !!process.env.TESTER_PK; 133 | if (!isSet && !warnedTesterPK) { 134 | console.log('Skipping tests requiring real PK since env variables for TESTER_PK is not set.'); 135 | warnedTesterPK = true; 136 | } 137 | return isSet; 138 | }; 139 | 140 | export class BaseIntegrationTestSuite { 141 | block: number; 142 | curNonce = 0; 143 | portionFetcher: PortionFetcher; 144 | 145 | nextPermitNonce: () => string = () => { 146 | const nonce = this.curNonce.toString(); 147 | this.curNonce = this.curNonce + 1; 148 | return nonce; 149 | }; 150 | 151 | before = async () => { 152 | let alice: SignerWithAddress; 153 | let filler: SignerWithAddress; 154 | [alice, filler] = await ethers.getSigners(); 155 | 156 | // Make a dummy call to the API to get a block number to fork from. 157 | const quoteReq: QuoteRequestBodyJSON = { 158 | requestId: 'id', 159 | tokenIn: 'USDC', 160 | tokenInChainId: 1, 161 | tokenOut: 'DAI', 162 | tokenOutChainId: 1, 163 | amount: await getAmount(1, 'EXACT_INPUT', 'USDC', 'DAI', '100'), 164 | type: 'EXACT_INPUT', 165 | configs: [ 166 | { 167 | routingType: RoutingType.CLASSIC, 168 | protocols: ['V2'], 169 | }, 170 | ], 171 | }; 172 | 173 | const { 174 | data: { quote }, 175 | } = await call(quoteReq); 176 | const { blockNumber } = quote as ClassicQuoteDataJSON; 177 | 178 | this.block = parseInt(blockNumber) - 10; 179 | 180 | alice = await resetAndFundAtBlock(alice, this.block, [ 181 | parseAmount('80000000', USDC_MAINNET), 182 | parseAmount('50000000', USDT_MAINNET), 183 | parseAmount('100', WBTC_MAINNET), 184 | parseAmount('10000', UNI_MAINNET), 185 | parseAmount('400', WETH9[1]), 186 | parseAmount('5000000', DAI_MAINNET), 187 | parseAmount('50000', agEUR_MAINNET), 188 | parseAmount('475000', XSGD_MAINNET), 189 | parseAmount('700000', BULLET), 190 | ]); 191 | 192 | process.env.ENABLE_PORTION = 'true'; 193 | if (process.env.PORTION_API_URL) { 194 | this.portionFetcher = new PortionFetcher(process.env.PORTION_API_URL, new NodeCache()); 195 | } 196 | 197 | return [alice, filler]; 198 | }; 199 | 200 | deployContract = async (factory: ContractFactory, args: any[]): Promise => { 201 | const contract = await factory.deploy(...args); 202 | await contract.deployed(); 203 | return contract; 204 | }; 205 | } 206 | -------------------------------------------------------------------------------- /test/integ/quote-relay.test.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 2 | import { DAI_MAINNET, ID_TO_NETWORK_NAME, USDC_MAINNET, USDT_MAINNET } from '@uniswap/smart-order-router'; 3 | import { RelayOrder } from '@uniswap/uniswapx-sdk'; 4 | import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'; 5 | import { AxiosResponse } from 'axios'; 6 | import chai, { expect } from 'chai'; 7 | import chaiAsPromised from 'chai-as-promised'; 8 | import chaiSubset from 'chai-subset'; 9 | import _ from 'lodash'; 10 | import { RoutingType } from '../../lib/constants'; 11 | import { QuoteRequestBodyJSON, RelayQuoteDataJSON, RoutingConfigJSON } from '../../lib/entities'; 12 | import { QuoteResponseJSON } from '../../lib/handlers/quote/handler'; 13 | import { RelayOrderReactor__factory } from '../../lib/types/ext'; 14 | import { getAmount } from '../utils/tokens'; 15 | import { BaseIntegrationTestSuite, call, callAndExpectFail } from './base.test'; 16 | 17 | chai.use(chaiAsPromised); 18 | chai.use(chaiSubset); 19 | 20 | const SLIPPAGE = '5'; 21 | 22 | describe.skip('relayQuote', function () { 23 | let baseTest: BaseIntegrationTestSuite; 24 | let reactorAddress: string; 25 | 26 | // Help with test flakiness by retrying. 27 | this.retries(2); 28 | this.timeout(40000); 29 | 30 | let alice: SignerWithAddress; 31 | 32 | beforeEach(async function () { 33 | baseTest = new BaseIntegrationTestSuite(); 34 | [alice] = await baseTest.before(); 35 | // deploy reactor 36 | const factory = new RelayOrderReactor__factory(alice); 37 | const reactorContract = await baseTest.deployContract(factory, [UNIVERSAL_ROUTER_ADDRESS(1)]); 38 | reactorAddress = reactorContract.address; 39 | }); 40 | 41 | for (const type of ['EXACT_INPUT', 'EXACT_OUTPUT']) { 42 | describe(`${ID_TO_NETWORK_NAME(1)} ${type} 2xx`, () => { 43 | describe(`+ Execute Swap`, () => { 44 | it(`stable -> stable, gas token == input token`, async () => { 45 | const quoteReq: QuoteRequestBodyJSON = { 46 | requestId: 'id', 47 | tokenIn: USDC_MAINNET.address, 48 | tokenInChainId: 1, 49 | tokenOut: USDT_MAINNET.address, 50 | tokenOutChainId: 1, 51 | amount: await getAmount(1, type, 'USDC', 'USDT', '100'), 52 | type, 53 | slippageTolerance: SLIPPAGE, 54 | configs: [ 55 | { 56 | routingType: RoutingType.RELAY, 57 | protocols: ['V2', 'V3', 'MIXED'], 58 | swapper: alice.address, 59 | gasToken: USDC_MAINNET.address, 60 | }, 61 | ] as RoutingConfigJSON[], 62 | }; 63 | 64 | const response: AxiosResponse = await call(quoteReq); 65 | const { 66 | data: { quote }, 67 | status, 68 | } = response; 69 | 70 | expect(status).to.equal(200); 71 | const order = RelayOrder.parse((quote as RelayQuoteDataJSON).encodedOrder, 1); 72 | order.info.reactor = reactorAddress; 73 | 74 | expect(order.info.swapper).to.equal(alice.address); 75 | expect(order.info.input).to.not.be.undefined; 76 | expect(order.info.fee).to.not.be.undefined; 77 | expect(order.info.universalRouterCalldata).to.not.be.undefined; 78 | }); 79 | 80 | it(`stable -> stable, gas token != input token`, async () => { 81 | const quoteReq: QuoteRequestBodyJSON = { 82 | requestId: 'id', 83 | tokenIn: USDC_MAINNET.address, 84 | tokenInChainId: 1, 85 | tokenOut: USDT_MAINNET.address, 86 | tokenOutChainId: 1, 87 | amount: await getAmount(1, type, 'USDC', 'USDT', '100'), 88 | type, 89 | slippageTolerance: SLIPPAGE, 90 | configs: [ 91 | { 92 | routingType: RoutingType.RELAY, 93 | protocols: ['V2', 'V3', 'MIXED'], 94 | swapper: alice.address, 95 | gasToken: DAI_MAINNET.address, 96 | }, 97 | ] as RoutingConfigJSON[], 98 | }; 99 | 100 | const response: AxiosResponse = await call(quoteReq); 101 | const { 102 | data: { quote }, 103 | status, 104 | } = response; 105 | 106 | const order = new RelayOrder((quote as any).orderInfo, 1); 107 | expect(status).to.equal(200); 108 | 109 | order.info.reactor = reactorAddress; 110 | 111 | expect(order.info.swapper).to.equal(alice.address); 112 | expect(order.info.input).to.not.be.undefined; 113 | expect(order.info.fee).to.not.be.undefined; 114 | expect(order.info.universalRouterCalldata).to.not.be.undefined; 115 | }); 116 | 117 | it('missing gasToken in request config', async () => { 118 | const quoteReq: QuoteRequestBodyJSON = { 119 | requestId: 'id', 120 | tokenIn: USDC_MAINNET.address, 121 | tokenInChainId: 1, 122 | tokenOut: USDT_MAINNET.address, 123 | tokenOutChainId: 1, 124 | amount: await getAmount(1, type, 'USDC', 'USDT', '100'), 125 | type, 126 | slippageTolerance: SLIPPAGE, 127 | configs: [ 128 | { 129 | routingType: RoutingType.RELAY, 130 | protocols: ['V2', 'V3', 'MIXED'], 131 | swapper: alice.address, 132 | }, 133 | ] as RoutingConfigJSON[], 134 | }; 135 | 136 | await callAndExpectFail(quoteReq, { 137 | status: 400, 138 | data: { 139 | detail: `"configs[0]" does not match any of the allowed types`, 140 | errorCode: 'VALIDATION_ERROR', 141 | }, 142 | }); 143 | }); 144 | }); 145 | }); 146 | } 147 | }); 148 | -------------------------------------------------------------------------------- /test/integ/quote-xv2.test.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 2 | import { ID_TO_NETWORK_NAME, USDC_MAINNET, USDT_MAINNET } from '@uniswap/smart-order-router'; 3 | import { UnsignedV2DutchOrder } from '@uniswap/uniswapx-sdk'; 4 | import { AxiosError, AxiosResponse } from 'axios'; 5 | import chai, { expect } from 'chai'; 6 | import chaiAsPromised from 'chai-as-promised'; 7 | import chaiSubset from 'chai-subset'; 8 | import { RoutingType } from '../../lib/constants'; 9 | import { QuoteRequestBodyJSON, RoutingConfigJSON } from '../../lib/entities'; 10 | import { QuoteResponseJSON } from '../../lib/handlers/quote/handler'; 11 | import { TEST_GAS_ADJUSTMENT_BPS } from '../constants'; 12 | import { getAmount } from '../utils/tokens'; 13 | import { BaseIntegrationTestSuite, callIndicative } from './base.test'; 14 | 15 | chai.use(chaiAsPromised); 16 | chai.use(chaiSubset); 17 | 18 | const SLIPPAGE = '5'; 19 | 20 | describe('quoteUniswapX-v2', function () { 21 | let baseTest: BaseIntegrationTestSuite; 22 | 23 | // Help with test flakiness by retrying. 24 | this.retries(2); 25 | this.timeout(100000); 26 | 27 | let alice: SignerWithAddress; 28 | 29 | before(async function () { 30 | baseTest = new BaseIntegrationTestSuite(); 31 | [alice] = await baseTest.before(); 32 | }); 33 | 34 | for (const type of ['EXACT_INPUT', 'EXACT_OUTPUT']) { 35 | describe(`${ID_TO_NETWORK_NAME(1)} ${type} 2xx`, async () => { 36 | it('valid request should either return quote or 404 no quotes available', async () => { 37 | const quoteReq: QuoteRequestBodyJSON = { 38 | requestId: 'id', 39 | useUniswapX: true, 40 | tokenIn: USDC_MAINNET.address, 41 | tokenInChainId: 1, 42 | tokenOut: USDT_MAINNET.address, 43 | tokenOutChainId: 1, 44 | amount: await getAmount(1, type, 'USDC', 'USDT', '10000'), 45 | type, 46 | slippageTolerance: SLIPPAGE, 47 | configs: [ 48 | { 49 | routingType: RoutingType.DUTCH_V2, 50 | swapper: alice.address, 51 | useSyntheticQuotes: true, 52 | gasAdjustmentBps: TEST_GAS_ADJUSTMENT_BPS, 53 | }, 54 | ] as RoutingConfigJSON[], 55 | }; 56 | 57 | try { 58 | const response: AxiosResponse = await callIndicative(quoteReq); 59 | const { 60 | data: { quote }, 61 | status, 62 | } = response; 63 | 64 | const order = new UnsignedV2DutchOrder((quote as any).orderInfo, 1); 65 | expect(status).to.equal(200); 66 | 67 | expect(order.info.swapper).to.equal(alice.address); 68 | 69 | expect(order.info.outputs.length).to.equal(1); 70 | expect(parseInt(order.info.outputs[0].startAmount.toString())).to.be.greaterThan(9000000000); 71 | expect(parseInt(order.info.outputs[0].startAmount.toString())).to.be.lessThan(11000000000); 72 | expect(parseInt(order.info.input.startAmount.toString())).to.be.greaterThan(9000000000); 73 | expect(parseInt(order.info.input.startAmount.toString())).to.be.lessThan(11000000000); 74 | } catch (e: any) { 75 | if (e instanceof AxiosError && e.response) { 76 | expect(e.response.status).to.equal(404); 77 | expect(e.response.data.detail).to.equal('No quotes available'); 78 | } else { 79 | // throw if not an axios error to debug 80 | throw e; 81 | } 82 | } 83 | }); 84 | }); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | type Input = any; 2 | type Output = any; 3 | type Response = any; 4 | 5 | export type FetcherTest = { 6 | testName: string; 7 | only?: boolean; 8 | input?: Input; 9 | output: Output; 10 | clientResponse?: Response; 11 | reject?: boolean; 12 | errorType?: any; 13 | }; 14 | 15 | export type UtilityTest = { 16 | testName: string; 17 | only?: boolean; 18 | input?: Input; 19 | output: Output; 20 | errorType?: any; 21 | }; 22 | -------------------------------------------------------------------------------- /test/unit/entities/ClassicQuote.test.ts: -------------------------------------------------------------------------------- 1 | import { MaxSigDeadline, MaxUint160 } from '@uniswap/permit2-sdk'; 2 | import { PERMIT2, PERMIT2_USED, PERMIT_DETAILS } from '../../constants'; 3 | import { UtilityTest } from '../../types'; 4 | import { createClassicQuote } from '../../utils/fixtures'; 5 | 6 | const tests: UtilityTest[] = [ 7 | { 8 | testName: 'Succeeds - No Permit', 9 | input: { 10 | quote: createClassicQuote({}, { type: 'EXACT_INPUT' }), 11 | permitDetails: { 12 | ...PERMIT_DETAILS, 13 | amount: MaxUint160, 14 | expiration: MaxSigDeadline, 15 | }, 16 | }, 17 | output: { 18 | permit: undefined, 19 | }, 20 | }, 21 | { 22 | testName: 'Succeeds - No Offerer', 23 | input: { 24 | quote: createClassicQuote({}, { type: 'EXACT_INPUT', swapper: undefined }), 25 | permitDetails: PERMIT_DETAILS, 26 | }, 27 | output: { 28 | permit: undefined, 29 | }, 30 | }, 31 | { 32 | testName: 'Succeeds - No Permit', 33 | input: { 34 | quote: createClassicQuote({}, { type: 'EXACT_INPUT' }), 35 | permitDetails: null, 36 | }, 37 | output: { 38 | permit: PERMIT2, 39 | }, 40 | }, 41 | { 42 | testName: 'Succeeds - Permit Not Enough', 43 | input: { 44 | quote: createClassicQuote({ amount: '15', quote: '5' }, { type: 'EXACT_INPUT' }), 45 | permitDetails: { 46 | ...PERMIT_DETAILS, 47 | amount: '10', 48 | }, 49 | }, 50 | output: { 51 | permit: PERMIT2_USED, 52 | }, 53 | }, 54 | { 55 | testName: 'Succeeds - Permit Expired', 56 | input: { 57 | quote: createClassicQuote({}, { type: 'EXACT_INPUT' }), 58 | permitDetails: { 59 | ...PERMIT_DETAILS, 60 | expiration: '0', 61 | }, 62 | }, 63 | output: { 64 | permit: PERMIT2_USED, 65 | }, 66 | }, 67 | ]; 68 | 69 | describe('ClassicQuote Unit Tests', () => { 70 | for (const test of tests) { 71 | const t = test; 72 | 73 | // eslint-disable-next-line no-restricted-properties 74 | const testFn = t.only ? it.only : it; 75 | 76 | testFn(t.testName, async () => { 77 | const { input, output } = t; 78 | jest.useFakeTimers({ 79 | now: 0, 80 | }); 81 | 82 | input.quote.setAllowanceData(input.permitDetails); 83 | const result = input.quote.getPermitData(); 84 | 85 | if (!output.permit) { 86 | expect(result).toBeUndefined(); 87 | } else { 88 | expect(result).toMatchObject(output.permit); 89 | } 90 | jest.clearAllTimers(); 91 | }); 92 | } 93 | }); 94 | -------------------------------------------------------------------------------- /test/unit/entities/RelayQuote.test.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan'; 2 | import * as _ from 'lodash'; 3 | 4 | import { it } from '@jest/globals'; 5 | import { DEFAULT_START_TIME_BUFFER_SECS } from '../../../lib/constants'; 6 | import { RelayQuote } from '../../../lib/entities'; 7 | import { AMOUNT } from '../../constants'; 8 | import { 9 | CLASSIC_QUOTE_DATA_WITH_ROUTE_AND_GAS_TOKEN, 10 | createClassicQuote, 11 | createRelayQuote, 12 | createRelayQuoteWithRequestOverrides, 13 | makeRelayRequest, 14 | QUOTE_REQUEST_RELAY, 15 | RELAY_QUOTE_DATA, 16 | } from '../../utils/fixtures'; 17 | 18 | describe('RelayQuote', () => { 19 | // silent logger in tests 20 | const logger = Logger.createLogger({ name: 'test' }); 21 | logger.level(Logger.FATAL); 22 | 23 | describe('decay parameters', () => { 24 | it('uses default parameters', () => { 25 | const quote = createRelayQuoteWithRequestOverrides( 26 | {}, 27 | {}, 28 | { 29 | swapper: '0x9999999999999999999999999999999999999999', 30 | startTimeBufferSecs: undefined, 31 | auctionPeriodSecs: undefined, 32 | deadlineBufferSecs: undefined, 33 | } 34 | ); 35 | const result = quote.toJSON(); 36 | expect(result.startTimeBufferSecs).toEqual(DEFAULT_START_TIME_BUFFER_SECS); 37 | expect(result.auctionPeriodSecs).toEqual(60); 38 | expect(result.deadlineBufferSecs).toEqual(12); 39 | }); 40 | 41 | it('overrides parameters in request', () => { 42 | const quote = createRelayQuoteWithRequestOverrides( 43 | {}, 44 | {}, 45 | { 46 | swapper: '0x9999999999999999999999999999999999999999', 47 | startTimeBufferSecs: 111, 48 | auctionPeriodSecs: 222, 49 | deadlineBufferSecs: 333, 50 | } 51 | ); 52 | const result = quote.toJSON(); 53 | expect(result.startTimeBufferSecs).toEqual(111); 54 | expect(result.auctionPeriodSecs).toEqual(222); 55 | expect(result.deadlineBufferSecs).toEqual(333); 56 | }); 57 | }); 58 | 59 | describe('toOrder', () => { 60 | it('generates calldata for a classic swap and adds it', () => { 61 | const quote = createRelayQuote({ amountOut: '10000' }, 'EXACT_INPUT', '1'); 62 | const order = quote.toOrder(); 63 | // expect generated calldata from quote class to be added to order 64 | expect(quote.universalRouterCalldata(order.info.deadline)).toEqual(order.info.universalRouterCalldata); 65 | }); 66 | }); 67 | 68 | describe('toJSON', () => { 69 | it('Succeeds', () => { 70 | const quote = createRelayQuote({ amountOut: '10000' }, 'EXACT_INPUT', '1'); 71 | const quoteJSON = quote.toJSON(); 72 | 73 | expect(quoteJSON).toMatchObject({ 74 | requestId: 'requestId', 75 | quoteId: 'quoteId', 76 | }); 77 | }); 78 | }); 79 | 80 | describe('fromResponseBody', () => { 81 | it('Succeeds', () => { 82 | const relayQuote = RelayQuote.fromResponseBody(QUOTE_REQUEST_RELAY, RELAY_QUOTE_DATA.quote); 83 | expect(relayQuote).toBeDefined(); 84 | // check quote attr 85 | expect(relayQuote.requestId).toEqual(RELAY_QUOTE_DATA.quote.requestId); 86 | expect(relayQuote.quoteId).toEqual(RELAY_QUOTE_DATA.quote.quoteId); 87 | expect(relayQuote.chainId).toEqual(RELAY_QUOTE_DATA.quote.chainId); 88 | expect(relayQuote.amountIn.toString()).toEqual(RELAY_QUOTE_DATA.quote.amountIn); 89 | expect(relayQuote.amountOut.toString()).toEqual(RELAY_QUOTE_DATA.quote.amountOut); 90 | expect(relayQuote.swapper).toEqual(RELAY_QUOTE_DATA.quote.swapper); 91 | expect(relayQuote.toJSON().classicQuoteData).toMatchObject(RELAY_QUOTE_DATA.quote.classicQuoteData); 92 | // check request attr 93 | expect(relayQuote.request.toJSON()).toMatchObject(QUOTE_REQUEST_RELAY.toJSON()); 94 | }); 95 | }); 96 | 97 | describe('fromClassicQuote', () => { 98 | it('Succeeds', () => { 99 | const classicQuote = createClassicQuote(CLASSIC_QUOTE_DATA_WITH_ROUTE_AND_GAS_TOKEN.quote, {}); 100 | const relayRequest = makeRelayRequest({ type: 'EXACT_INPUT' }); 101 | const quote = RelayQuote.fromClassicQuote(relayRequest, classicQuote); 102 | expect(quote).toBeDefined(); 103 | // Expect adjustment to be applied to fee token stat amount 104 | expect(quote.feeAmountStart.gt(AMOUNT)).toBeTruthy(); 105 | // Expect some escalation to be applied to fee token end amount 106 | expect(quote.feeAmountEnd.gt(quote.feeAmountStart)).toBeTruthy(); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/unit/lib/constants.test.ts: -------------------------------------------------------------------------------- 1 | import { frontendAndUraEnablePortion, frontendEnablePortion, uraEnablePortion } from '../../../lib/constants'; 2 | 3 | describe('constants Unit Tests', () => { 4 | const enablePortionFlagOptions = ['true', 'false', undefined, 'garbage']; 5 | 6 | enablePortionFlagOptions.forEach((enablePortionFlagOption) => { 7 | it(`URA enable portion when process.env.ENABLE_PORTION = ${enablePortionFlagOption}`, () => { 8 | process.env.ENABLE_PORTION = enablePortionFlagOption; 9 | 10 | switch (enablePortionFlagOption) { 11 | case 'true': 12 | expect(uraEnablePortion()).toBeTruthy(); 13 | break; 14 | case 'false': 15 | expect(uraEnablePortion()).toBeFalsy(); 16 | break; 17 | default: 18 | expect(uraEnablePortion()).toBeFalsy(); 19 | break; 20 | } 21 | }); 22 | }); 23 | 24 | const sendPortionFlagOptions = [true, false, undefined]; 25 | 26 | sendPortionFlagOptions.forEach((sendPortionFlag) => { 27 | it(`URA enable portion when process.env.ENABLE_PORTION = ${sendPortionFlag}`, () => { 28 | expect(frontendEnablePortion(sendPortionFlag)).toStrictEqual(sendPortionFlag); 29 | }); 30 | }); 31 | 32 | enablePortionFlagOptions.forEach((enablePortionFlagOption) => { 33 | sendPortionFlagOptions.forEach((sendPortionFlag) => { 34 | it(`URA enable portion when process.env.ENABLE_PORTION = ${enablePortionFlagOption} and sendPortionFlag = ${sendPortionFlag}`, () => { 35 | process.env.ENABLE_PORTION = enablePortionFlagOption; 36 | 37 | expect(frontendAndUraEnablePortion(sendPortionFlag)).toStrictEqual( 38 | sendPortionFlag && enablePortionFlagOption === 'true' 39 | ); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/unit/lib/entities/context/ClassicQuoteContext.test.ts: -------------------------------------------------------------------------------- 1 | import { PermitDetails } from '@uniswap/permit2-sdk'; 2 | import Logger from 'bunyan'; 3 | 4 | import { ClassicQuoteContext } from '../../../../../lib/entities'; 5 | import { Permit2Fetcher } from '../../../../../lib/fetchers/Permit2Fetcher'; 6 | import { PERMIT_DETAILS } from '../../../../constants'; 7 | import { 8 | CLASSIC_QUOTE_EXACT_IN_BETTER, 9 | CLASSIC_QUOTE_EXACT_IN_WORSE, 10 | CLASSIC_QUOTE_EXACT_OUT_WORSE, 11 | QUOTE_REQUEST_CLASSIC, 12 | } from '../../../../utils/fixtures'; 13 | 14 | describe('ClassicQuoteContext', () => { 15 | const logger = Logger.createLogger({ name: 'test' }); 16 | logger.level(Logger.FATAL); 17 | 18 | const permit2FetcherMock = (permitDetails: PermitDetails, isError = false): Permit2Fetcher => { 19 | const fetcher = { 20 | fetchAllowance: jest.fn(), 21 | }; 22 | 23 | if (isError) { 24 | fetcher.fetchAllowance.mockRejectedValue(new Error('error')); 25 | return fetcher as unknown as Permit2Fetcher; 26 | } 27 | 28 | fetcher.fetchAllowance.mockResolvedValueOnce(permitDetails); 29 | return fetcher as unknown as Permit2Fetcher; 30 | }; 31 | 32 | describe('dependencies', () => { 33 | it('returns only request dependency', () => { 34 | const permit2Fetcher = permit2FetcherMock(PERMIT_DETAILS); 35 | const context = new ClassicQuoteContext(logger, QUOTE_REQUEST_CLASSIC, { permit2Fetcher }); 36 | expect(context.dependencies()).toEqual([QUOTE_REQUEST_CLASSIC]); 37 | }); 38 | }); 39 | 40 | describe('resolve', () => { 41 | it('returns null if no quotes given', async () => { 42 | const permit2Fetcher = permit2FetcherMock(PERMIT_DETAILS); 43 | const context = new ClassicQuoteContext(logger, QUOTE_REQUEST_CLASSIC, { permit2Fetcher }); 44 | expect(await context.resolve({})).toEqual(null); 45 | }); 46 | 47 | it('still returns quote if too many dependencies given', async () => { 48 | const permit2Fetcher = permit2FetcherMock(PERMIT_DETAILS); 49 | const context = new ClassicQuoteContext(logger, QUOTE_REQUEST_CLASSIC, { permit2Fetcher }); 50 | expect( 51 | await context.resolve({ 52 | [QUOTE_REQUEST_CLASSIC.key()]: CLASSIC_QUOTE_EXACT_IN_BETTER, 53 | [CLASSIC_QUOTE_EXACT_OUT_WORSE.request.key()]: CLASSIC_QUOTE_EXACT_IN_WORSE, 54 | }) 55 | ).toEqual(CLASSIC_QUOTE_EXACT_IN_BETTER); 56 | }); 57 | 58 | it('returns quote', async () => { 59 | const permit2Fetcher = permit2FetcherMock(PERMIT_DETAILS); 60 | const context = new ClassicQuoteContext(logger, QUOTE_REQUEST_CLASSIC, { permit2Fetcher }); 61 | expect( 62 | await context.resolve({ 63 | [QUOTE_REQUEST_CLASSIC.key()]: CLASSIC_QUOTE_EXACT_IN_BETTER, 64 | }) 65 | ).toEqual(CLASSIC_QUOTE_EXACT_IN_BETTER); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/unit/lib/entities/context/RelayQuoteContext.test.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan'; 2 | 3 | import { NATIVE_ADDRESS, RoutingType } from '../../../../../lib/constants'; 4 | import { RelayQuoteContext } from '../../../../../lib/entities'; 5 | import { Erc20__factory } from '../../../../../lib/types/ext/factories/Erc20__factory'; 6 | import { AMOUNT } from '../../../../constants'; 7 | import { 8 | createClassicQuote, 9 | createRelayQuote, 10 | DL_QUOTE_EXACT_IN_BETTER, 11 | makeRelayRequest, 12 | QUOTE_REQUEST_RELAY, 13 | } from '../../../../utils/fixtures'; 14 | 15 | describe('RelayQuoteContext', () => { 16 | const logger = Logger.createLogger({ name: 'test' }); 17 | logger.level(Logger.FATAL); 18 | let provider: any; 19 | 20 | beforeAll(() => { 21 | jest.resetModules(); // Most important - it clears the cache 22 | 23 | jest.mock('../../../../../lib/types/ext/factories/Erc20__factory'); 24 | Erc20__factory.connect = jest.fn().mockImplementation(() => { 25 | return { 26 | allowance: () => ({ gte: () => true }), 27 | }; 28 | }); 29 | provider = jest.fn(); 30 | }); 31 | 32 | function makeProviders() { 33 | return { 34 | rpcProvider: provider, 35 | }; 36 | } 37 | 38 | describe('dependencies', () => { 39 | it('returns expected dependencies when output is weth', () => { 40 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 41 | const deps = context.dependencies(); 42 | expect(deps.length).toEqual(2); 43 | // first is base 44 | expect(deps[0]).toEqual(QUOTE_REQUEST_RELAY); 45 | // second is classic 46 | expect(deps[1].info).toEqual(QUOTE_REQUEST_RELAY.info); 47 | expect(deps[1].routingType).toEqual(RoutingType.CLASSIC); 48 | }); 49 | 50 | it('returns expected dependencies when output is not weth', () => { 51 | const request = makeRelayRequest({ 52 | tokenOut: '0x1111111111111111111111111111111111111111', 53 | }); 54 | const context = new RelayQuoteContext(logger, request, makeProviders()); 55 | const deps = context.dependencies(); 56 | expect(deps.length).toEqual(2); 57 | // first is base 58 | expect(deps[0]).toEqual(request); 59 | // second is classic 60 | expect(deps[1].info).toEqual(request.info); 61 | expect(deps[1].routingType).toEqual(RoutingType.CLASSIC); 62 | }); 63 | }); 64 | 65 | describe('resolve', () => { 66 | it('returns null if no dependencies given', async () => { 67 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 68 | expect(await context.resolve({})).toEqual(null); 69 | }); 70 | 71 | it('returns null if quote key is not set properly', async () => { 72 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 73 | expect( 74 | await context.resolve({ 75 | wrong: DL_QUOTE_EXACT_IN_BETTER, 76 | }) 77 | ).toBeNull(); 78 | }); 79 | 80 | it('returns main quote if others are null', async () => { 81 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 82 | 83 | const relayQuote = createRelayQuote({ amountOut: AMOUNT }, 'EXACT_INPUT'); 84 | const quote = await context.resolve({ 85 | [QUOTE_REQUEST_RELAY.key()]: relayQuote, 86 | }); 87 | expect(quote).toMatchObject(relayQuote); 88 | }); 89 | 90 | it('reconstructs quote from dependencies if main quote is null', async () => { 91 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 92 | 93 | const classicQuote = createClassicQuote( 94 | { quote: '10000000000', quoteGasAdjusted: '9999000000', gasUseEstimateGasToken: '1' }, 95 | { type: 'EXACT_INPUT' } 96 | ); 97 | context.dependencies(); 98 | 99 | const quote = await context.resolve({ 100 | [context.classicKey]: classicQuote, 101 | }); 102 | expect(quote?.routingType).toEqual(RoutingType.RELAY); 103 | }); 104 | 105 | it('returns null if quotes have 0 amountOut', async () => { 106 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 107 | 108 | const relayQuote = createRelayQuote({ amountOut: '0' }, 'EXACT_INPUT'); 109 | expect( 110 | await context.resolve({ 111 | [QUOTE_REQUEST_RELAY.key()]: relayQuote, 112 | }) 113 | ).toBe(null); 114 | }); 115 | 116 | it('returns relay quote if tokenIn is NATIVE_ADDRESS', async () => { 117 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 118 | const relayQuote = createRelayQuote({ tokenIn: NATIVE_ADDRESS, amountOut: '2' }, 'EXACT_INPUT'); 119 | const quote = await context.resolve({ 120 | [QUOTE_REQUEST_RELAY.key()]: relayQuote, 121 | }); 122 | expect(quote?.routingType).toEqual(RoutingType.RELAY); 123 | expect(quote?.amountOut.toString()).toEqual('2'); 124 | }); 125 | 126 | it('returns relay quote if tokenOut is NATIVE_ADDRESS', async () => { 127 | const context = new RelayQuoteContext(logger, QUOTE_REQUEST_RELAY, makeProviders()); 128 | const relayQuote = createRelayQuote({ tokenOut: NATIVE_ADDRESS, amountOut: '2' }, 'EXACT_INPUT'); 129 | const quote = await context.resolve({ 130 | [QUOTE_REQUEST_RELAY.key()]: relayQuote, 131 | }); 132 | expect(quote?.routingType).toEqual(RoutingType.RELAY); 133 | expect(quote?.amountOut.toString()).toEqual('2'); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/unit/lib/entities/quoteRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { RoutingType } from '../../../../lib/constants'; 2 | import { 3 | ClassicConfigJSON, 4 | ClassicRequest, 5 | DutchConfigJSON, 6 | DutchV1Request, 7 | DutchV2ConfigJSON, 8 | DutchV2Request, 9 | parseQuoteRequests, 10 | QuoteRequestBodyJSON, 11 | } from '../../../../lib/entities'; 12 | import { RelayConfigJSON, RelayRequest } from '../../../../lib/entities/request/RelayRequest'; 13 | import { ValidationError } from '../../../../lib/util/errors'; 14 | import { 15 | AMOUNT, 16 | CHAIN_IN_ID, 17 | CHAIN_OUT_ID, 18 | SWAPPER, 19 | TEST_GAS_ADJUSTMENT_BPS, 20 | TOKEN_IN, 21 | TOKEN_OUT, 22 | } from '../../../constants'; 23 | 24 | const MOCK_DL_CONFIG_JSON: DutchConfigJSON = { 25 | routingType: RoutingType.DUTCH_LIMIT, 26 | swapper: SWAPPER, 27 | exclusivityOverrideBps: 24, 28 | auctionPeriodSecs: 60, 29 | deadlineBufferSecs: 12, 30 | useSyntheticQuotes: true, 31 | gasAdjustmentBps: TEST_GAS_ADJUSTMENT_BPS, 32 | }; 33 | 34 | const MOCK_RELAY_CONFIG_JSON: RelayConfigJSON = { 35 | routingType: RoutingType.RELAY, 36 | swapper: SWAPPER, 37 | auctionPeriodSecs: 60, 38 | deadlineBufferSecs: 12, 39 | gasToken: TOKEN_IN, 40 | }; 41 | 42 | const MOCK_DUTCH_V2_CONFIG_JSON: DutchV2ConfigJSON = { 43 | routingType: RoutingType.DUTCH_V2, 44 | swapper: SWAPPER, 45 | deadlineBufferSecs: 12, 46 | gasAdjustmentBps: TEST_GAS_ADJUSTMENT_BPS, 47 | }; 48 | 49 | const CLASSIC_CONFIG_JSON: ClassicConfigJSON = { 50 | routingType: RoutingType.CLASSIC, 51 | protocols: ['V3', 'V2', 'MIXED'], 52 | gasPriceWei: '1000000000', 53 | }; 54 | 55 | const DUPLICATE_REQUEST_JSON = { 56 | requestId: 'requestId', 57 | tokenInChainId: CHAIN_IN_ID, 58 | tokenOutChainId: CHAIN_OUT_ID, 59 | tokenIn: TOKEN_IN, 60 | tokenOut: TOKEN_OUT, 61 | amount: AMOUNT, 62 | type: 'EXACT_INPUT', 63 | configs: [MOCK_DL_CONFIG_JSON, CLASSIC_CONFIG_JSON, MOCK_DL_CONFIG_JSON], 64 | swapper: SWAPPER, 65 | }; 66 | 67 | const EXACT_INPUT_MOCK_REQUEST_JSON: QuoteRequestBodyJSON = { 68 | requestId: 'requestId', 69 | tokenInChainId: CHAIN_IN_ID, 70 | tokenOutChainId: CHAIN_OUT_ID, 71 | tokenIn: TOKEN_IN, 72 | tokenOut: TOKEN_OUT, 73 | amount: AMOUNT, 74 | type: 'EXACT_INPUT', 75 | swapper: SWAPPER, 76 | configs: [MOCK_DL_CONFIG_JSON, MOCK_RELAY_CONFIG_JSON, CLASSIC_CONFIG_JSON], 77 | }; 78 | 79 | const EXACT_OUTPUT_MOCK_REQUEST_JSON: QuoteRequestBodyJSON = { 80 | requestId: 'requestId', 81 | tokenInChainId: CHAIN_IN_ID, 82 | tokenOutChainId: CHAIN_OUT_ID, 83 | tokenIn: TOKEN_IN, 84 | tokenOut: TOKEN_OUT, 85 | amount: AMOUNT, 86 | type: 'EXACT_OUTPUT', 87 | swapper: SWAPPER, 88 | configs: [MOCK_DL_CONFIG_JSON, MOCK_RELAY_CONFIG_JSON, CLASSIC_CONFIG_JSON], 89 | }; 90 | 91 | const EXACT_INPUT_V2_MOCK_REQUEST_JSON: QuoteRequestBodyJSON = { 92 | requestId: 'requestId', 93 | tokenInChainId: CHAIN_IN_ID, 94 | tokenOutChainId: CHAIN_OUT_ID, 95 | tokenIn: TOKEN_IN, 96 | tokenOut: TOKEN_OUT, 97 | amount: AMOUNT, 98 | type: 'EXACT_INPUT', 99 | swapper: SWAPPER, 100 | configs: [MOCK_DUTCH_V2_CONFIG_JSON, CLASSIC_CONFIG_JSON], 101 | }; 102 | 103 | const EXACT_OUTPUT_V2_MOCK_REQUEST_JSON: QuoteRequestBodyJSON = { 104 | requestId: 'requestId', 105 | tokenInChainId: CHAIN_IN_ID, 106 | tokenOutChainId: CHAIN_OUT_ID, 107 | tokenIn: TOKEN_IN, 108 | tokenOut: TOKEN_OUT, 109 | amount: AMOUNT, 110 | type: 'EXACT_OUTPUT', 111 | swapper: SWAPPER, 112 | configs: [MOCK_DUTCH_V2_CONFIG_JSON, CLASSIC_CONFIG_JSON], 113 | }; 114 | 115 | describe('QuoteRequest', () => { 116 | for (const request of [EXACT_INPUT_MOCK_REQUEST_JSON, EXACT_OUTPUT_MOCK_REQUEST_JSON]) { 117 | describe(request.type, () => { 118 | it('parses exactInput dutch limit order config properly', () => { 119 | const { quoteRequests: requests } = parseQuoteRequests(request); 120 | const info = requests[0].info; 121 | 122 | const config = DutchV1Request.fromRequestBody(info, MOCK_DL_CONFIG_JSON); 123 | expect(config.toJSON()).toEqual(MOCK_DL_CONFIG_JSON); 124 | }); 125 | 126 | it('parses exactOutput dutch limit order config properly', () => { 127 | const { quoteRequests: requests } = parseQuoteRequests(request); 128 | const info = requests[0].info; 129 | 130 | const config = DutchV1Request.fromRequestBody(info, MOCK_DL_CONFIG_JSON); 131 | expect(config.toJSON()).toEqual(MOCK_DL_CONFIG_JSON); 132 | }); 133 | 134 | it('parses exactInput relay order config properly', () => { 135 | const { quoteRequests: requests } = parseQuoteRequests(request); 136 | const info = requests[1].info; 137 | 138 | const config = RelayRequest.fromRequestBody(info, MOCK_RELAY_CONFIG_JSON); 139 | expect(config.toJSON()).toEqual(MOCK_RELAY_CONFIG_JSON); 140 | }); 141 | 142 | it('parses exactOutput relay order config properly', () => { 143 | const { quoteRequests: requests } = parseQuoteRequests(request); 144 | const info = requests[1].info; 145 | 146 | const config = RelayRequest.fromRequestBody(info, MOCK_RELAY_CONFIG_JSON); 147 | expect(config.toJSON()).toEqual(MOCK_RELAY_CONFIG_JSON); 148 | }); 149 | 150 | it('parses basic classic quote order config properly', () => { 151 | const { quoteRequests: requests } = parseQuoteRequests(request); 152 | const info = requests[2].info; 153 | const config = ClassicRequest.fromRequestBody(info, CLASSIC_CONFIG_JSON); 154 | 155 | expect(config.toJSON()).toEqual(CLASSIC_CONFIG_JSON); 156 | }); 157 | 158 | it('throws if more than one of the same type', () => { 159 | let threw = false; 160 | try { 161 | parseQuoteRequests(DUPLICATE_REQUEST_JSON); 162 | } catch (e) { 163 | threw = true; 164 | expect(e instanceof ValidationError).toBeTruthy(); 165 | if (e instanceof ValidationError) { 166 | expect(e.message).toEqual('Duplicate routing type: DUTCH_LIMIT'); 167 | } 168 | } 169 | expect(threw).toBeTruthy(); 170 | }); 171 | 172 | it('includes swapper in info for dutch limit', () => { 173 | const { quoteRequests: requests } = parseQuoteRequests(request); 174 | const info = requests[0].info; 175 | const config = DutchV1Request.fromRequestBody(info, MOCK_DL_CONFIG_JSON); 176 | 177 | expect(config.info.swapper).toEqual(SWAPPER); 178 | }); 179 | 180 | it('includes swapper in info for relay', () => { 181 | const { quoteRequests: requests } = parseQuoteRequests(request); 182 | const info = requests[1].info; 183 | const config = RelayRequest.fromRequestBody(info, MOCK_RELAY_CONFIG_JSON); 184 | 185 | expect(config.info.swapper).toEqual(SWAPPER); 186 | }); 187 | 188 | it('includes swapper in info for classic', () => { 189 | const { quoteRequests: requests } = parseQuoteRequests(request); 190 | const info = requests[2].info; 191 | const config = ClassicRequest.fromRequestBody(info, CLASSIC_CONFIG_JSON); 192 | 193 | expect(config.info.swapper).toEqual(SWAPPER); 194 | }); 195 | }); 196 | } 197 | 198 | describe('DutchV2', () => { 199 | for (const request of [EXACT_INPUT_V2_MOCK_REQUEST_JSON, EXACT_OUTPUT_V2_MOCK_REQUEST_JSON]) { 200 | describe(request.type, () => { 201 | it('parses exactInput dutch limit order config properly', () => { 202 | const { quoteRequests: requests } = parseQuoteRequests(request); 203 | const dutchRequest = requests[0] as DutchV2Request; 204 | 205 | expect(dutchRequest.toJSON()).toEqual( 206 | Object.assign({}, MOCK_DUTCH_V2_CONFIG_JSON, { useSyntheticQuotes: false }) 207 | ); 208 | }); 209 | 210 | it('parses basic classic quote order config properly', () => { 211 | const { quoteRequests: requests } = parseQuoteRequests(request); 212 | const classicRequest = requests[1] as ClassicRequest; 213 | 214 | expect(classicRequest.toJSON()).toEqual(CLASSIC_CONFIG_JSON); 215 | }); 216 | }); 217 | } 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /test/unit/lib/entities/quoteResponse.test.ts: -------------------------------------------------------------------------------- 1 | import { DutchOrder, RelayOrder } from '@uniswap/uniswapx-sdk'; 2 | import { BigNumber } from 'ethers'; 3 | 4 | import { 5 | ClassicQuote, 6 | ClassicQuoteDataJSON, 7 | DutchQuoteJSON, 8 | DutchV1Request, 9 | RelayQuote, 10 | RelayQuoteJSON, 11 | } from '../../../../lib/entities'; 12 | import { DutchQuoteFactory } from '../../../../lib/entities/quote/DutchQuoteFactory'; 13 | import { DutchV1Quote } from '../../../../lib/entities/quote/DutchV1Quote'; 14 | import { RelayRequest } from '../../../../lib/entities/request/RelayRequest'; 15 | import { 16 | AMOUNT, 17 | CHAIN_IN_ID, 18 | FILLER, 19 | PERMIT_DETAILS, 20 | PORTION_BIPS, 21 | PORTION_RECIPIENT, 22 | SWAPPER, 23 | TOKEN_IN, 24 | TOKEN_OUT, 25 | } from '../../../constants'; 26 | import { 27 | CLASSIC_QUOTE_DATA_WITH_ROUTE_AND_GAS_TOKEN, 28 | CLASSIC_QUOTE_EXACT_IN_BETTER, 29 | CLASSIC_QUOTE_EXACT_OUT_BETTER, 30 | QUOTE_REQUEST_DL, 31 | QUOTE_REQUEST_RELAY, 32 | } from '../../../utils/fixtures'; 33 | 34 | const DL_QUOTE_JSON: DutchQuoteJSON = { 35 | chainId: CHAIN_IN_ID, 36 | requestId: '0xrequestId', 37 | quoteId: '0xquoteId', 38 | tokenIn: TOKEN_IN, 39 | amountIn: AMOUNT, 40 | tokenOut: TOKEN_OUT, 41 | amountOut: AMOUNT, 42 | swapper: SWAPPER, 43 | filler: FILLER, 44 | }; 45 | 46 | const DL_QUOTE_JSON_RFQ: DutchQuoteJSON = { 47 | chainId: CHAIN_IN_ID, 48 | requestId: '0xrequestId', 49 | quoteId: '0xquoteId', 50 | tokenIn: TOKEN_IN, 51 | amountIn: AMOUNT, 52 | tokenOut: TOKEN_OUT, 53 | amountOut: AMOUNT, 54 | swapper: SWAPPER, 55 | filler: '0x1111111111111111111111111111111111111111', 56 | }; 57 | 58 | const RELAY_QUOTE_JSON: RelayQuoteJSON = { 59 | chainId: 1, 60 | requestId: 'requestId', 61 | quoteId: 'quoteId', 62 | tokenIn: TOKEN_IN, 63 | amountIn: AMOUNT, 64 | tokenOut: TOKEN_OUT, 65 | amountOut: AMOUNT, 66 | gasToken: TOKEN_IN, 67 | feeAmountStart: AMOUNT, 68 | feeAmountEnd: AMOUNT, 69 | swapper: SWAPPER, 70 | classicQuoteData: CLASSIC_QUOTE_DATA_WITH_ROUTE_AND_GAS_TOKEN.quote, 71 | }; 72 | 73 | const CLASSIC_QUOTE_JSON: ClassicQuoteDataJSON = { 74 | requestId: '0xrequestId', 75 | quoteId: '0xquoteId', 76 | amount: AMOUNT, 77 | amountDecimals: '18', 78 | quote: '2000000', 79 | quoteDecimals: '18', 80 | quoteGasAdjusted: AMOUNT, 81 | quoteGasAdjustedDecimals: '18', 82 | gasUseEstimate: '100', 83 | gasUseEstimateQuote: '100', 84 | gasUseEstimateQuoteDecimals: '18', 85 | gasUseEstimateUSD: '100', 86 | simulationStatus: 'asdf', 87 | gasPriceWei: '10000', 88 | blockNumber: '1234', 89 | route: [], 90 | routeString: 'USD-ETH', 91 | tradeType: 'EXACT_INPUT', 92 | slippage: 0.5, 93 | portionBips: PORTION_BIPS, 94 | portionRecipient: PORTION_RECIPIENT, 95 | }; 96 | 97 | const UNIVERSAL_ROUTER_ADDRESS = '0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD'; 98 | 99 | describe('QuoteResponse', () => { 100 | const config: DutchV1Request = QUOTE_REQUEST_DL; 101 | const relayConfig: RelayRequest = QUOTE_REQUEST_RELAY; 102 | 103 | it('parses dutch limit quote from param-api properly', () => { 104 | expect(() => DutchQuoteFactory.fromResponseBody(config, DL_QUOTE_JSON)).not.toThrow(); 105 | }); 106 | 107 | it('parses relay quote properly', () => { 108 | expect(() => RelayQuote.fromResponseBody(relayConfig, RELAY_QUOTE_JSON)).not.toThrow(); 109 | }); 110 | 111 | it('produces dutch limit order info from param-api response and config', () => { 112 | const quote = DutchQuoteFactory.fromResponseBody(config, DL_QUOTE_JSON_RFQ); 113 | expect(quote.toOrder().toJSON()).toMatchObject({ 114 | swapper: SWAPPER, 115 | input: { 116 | token: TOKEN_IN, 117 | startAmount: AMOUNT, 118 | endAmount: AMOUNT, 119 | }, 120 | outputs: [ 121 | { 122 | token: TOKEN_OUT, 123 | startAmount: AMOUNT, 124 | endAmount: BigNumber.from(AMOUNT).mul(995).div(1000).toString(), // default 5% slippage 125 | recipient: SWAPPER, 126 | }, 127 | ], 128 | }); 129 | const order = DutchOrder.fromJSON((quote as DutchV1Quote).toOrder().toJSON(), quote.chainId); 130 | expect(order.info.exclusiveFiller).toEqual('0x1111111111111111111111111111111111111111'); 131 | expect(order.info.exclusivityOverrideBps.toString()).toEqual('12'); 132 | 133 | expect(BigNumber.from(quote.toOrder().toJSON().nonce).gt(0)).toBeTruthy(); 134 | }); 135 | 136 | it('produces relay order info from quote', () => { 137 | const quote = RelayQuote.fromResponseBody(relayConfig, RELAY_QUOTE_JSON); 138 | expect(quote.toOrder().toJSON()).toMatchObject({ 139 | swapper: SWAPPER, 140 | input: { 141 | token: TOKEN_IN, 142 | amount: AMOUNT, 143 | recipient: UNIVERSAL_ROUTER_ADDRESS, 144 | }, 145 | fee: { 146 | token: TOKEN_IN, 147 | startAmount: AMOUNT, 148 | endAmount: AMOUNT, 149 | startTime: expect.any(Number), 150 | endTime: expect.any(Number), 151 | }, 152 | universalRouterCalldata: 153 | '0x24856bc30000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000100000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000dc46ef164c4a49e00000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000001f9840a85d5af5bf1d1762f925bdaddc4201f984000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 154 | }); 155 | const order = RelayOrder.fromJSON(quote.toOrder().toJSON(), quote.chainId); 156 | expect(BigNumber.from(order.toJSON().nonce).gt(0)).toBeTruthy(); 157 | }); 158 | 159 | it('parses classic quote exactInput', () => { 160 | const quote = ClassicQuote.fromResponseBody(CLASSIC_QUOTE_EXACT_IN_BETTER.request, CLASSIC_QUOTE_JSON); 161 | quote.setAllowanceData(PERMIT_DETAILS); 162 | expect(quote.toJSON()).toMatchObject({ 163 | ...CLASSIC_QUOTE_JSON, 164 | quoteId: expect.any(String), 165 | requestId: expect.any(String), 166 | tradeType: 'EXACT_INPUT', 167 | }); 168 | expect(quote.amountIn.toString()).toEqual(CLASSIC_QUOTE_JSON.amount); 169 | expect(quote.amountOut.toString()).toEqual(CLASSIC_QUOTE_JSON.quote); 170 | expect(quote.portion?.bips).toEqual(CLASSIC_QUOTE_JSON.portionBips); 171 | expect(quote.portion?.recipient).toEqual(CLASSIC_QUOTE_JSON.portionRecipient); 172 | }); 173 | 174 | it('parses classic quote exactOutput', () => { 175 | const quote = ClassicQuote.fromResponseBody(CLASSIC_QUOTE_EXACT_OUT_BETTER.request, CLASSIC_QUOTE_JSON); 176 | quote.setAllowanceData(PERMIT_DETAILS); 177 | expect(quote.toJSON()).toMatchObject({ 178 | ...CLASSIC_QUOTE_JSON, 179 | quoteId: expect.any(String), 180 | requestId: expect.any(String), 181 | tradeType: 'EXACT_OUTPUT', 182 | }); 183 | expect(quote.amountIn.toString()).toEqual(CLASSIC_QUOTE_JSON.quote); 184 | expect(quote.amountOut.toString()).toEqual(CLASSIC_QUOTE_JSON.amount); 185 | expect(quote.portion?.bips).toEqual(CLASSIC_QUOTE_JSON.portionBips); 186 | expect(quote.portion?.recipient).toEqual(CLASSIC_QUOTE_JSON.portionRecipient); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /test/unit/lib/fetchers/Permit2Fetcher.test.ts: -------------------------------------------------------------------------------- 1 | import { permit2Address } from '@uniswap/permit2-sdk'; 2 | import { ChainId } from '@uniswap/sdk-core'; 3 | import PERMIT2_CONTRACT from '../../../../lib/abis/Permit2.json'; 4 | import { Permit2Fetcher } from '../../../../lib/fetchers/Permit2Fetcher'; 5 | 6 | describe('Permit2Fetcher Unit Tests', () => { 7 | describe('constructor', () => { 8 | it('gets initialized with correct contract address and ABI', async () => { 9 | const rpcUrlMap = new Map(); 10 | rpcUrlMap.set(ChainId.MAINNET, 'mainnet rpc url'); 11 | const fetcher = new Permit2Fetcher(rpcUrlMap); 12 | expect(fetcher.permit2Address(ChainId.MAINNET)).toBe(permit2Address(ChainId.MAINNET)); 13 | expect(fetcher.permit2Abi).toBe(PERMIT2_CONTRACT.abi); 14 | }); 15 | 16 | it('returns undefined if an error occurs', async () => { 17 | const rpcUrlMap = new Map(); 18 | const fetcher = new Permit2Fetcher(rpcUrlMap); 19 | const result = await fetcher.fetchAllowance(ChainId.MAINNET, 'owner', 'token', 'spender'); 20 | expect(result).toBe(undefined); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/lib/fetchers/PortionFetcher.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | import NodeCache from 'node-cache'; 3 | import { DEFAULT_NEGATIVE_CACHE_ENTRY_TTL, DEFAULT_POSITIVE_CACHE_ENTRY_TTL } from '../../../../lib/constants'; 4 | import { RequestSource } from '../../../../lib/entities'; 5 | import { GetPortionResponse, GET_NO_PORTION_RESPONSE, PortionFetcher } from '../../../../lib/fetchers/PortionFetcher'; 6 | import axios from '../../../../lib/providers/quoters/helpers'; 7 | import { forcePortion, setGlobalForcePortion } from '../../../../lib/util/portion'; 8 | import { FLAT_PORTION } from '../../../constants'; 9 | 10 | function testPortion() { 11 | const tokenInChainId = 1; 12 | const tokenInAddress = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; 13 | const tokenOutChainId = 1; 14 | const tokenOutAddress = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 15 | const requestSource = RequestSource.UNISWAP_WEB; 16 | 17 | it('Portion Service returns portion data', async () => { 18 | const portionResponse: GetPortionResponse = { 19 | hasPortion: true, 20 | portion: FLAT_PORTION, 21 | }; 22 | 23 | const createSpy = jest.spyOn(axios, 'create'); 24 | // @ts-ignore 25 | const axiosInstanceMock: AxiosInstance = { 26 | get: jest.fn().mockResolvedValue({ data: portionResponse }), 27 | // You can optionally mock other methods here, such as post, put, etc. 28 | }; 29 | createSpy.mockReturnValueOnce(axiosInstanceMock); 30 | const currentEpochTimeInSeconds = Math.floor(Date.now() / 1000); 31 | 32 | const portionCache = new NodeCache({ stdTTL: 600 }); 33 | const portionFetcher = new PortionFetcher('https://portion.uniswap.org/', portionCache); 34 | const portionData = await portionFetcher.getPortion( 35 | tokenInChainId, 36 | tokenInAddress, 37 | tokenOutChainId, 38 | tokenOutAddress, 39 | requestSource 40 | ); 41 | expect(portionData.hasPortion).toEqual(true); 42 | expect(portionData.portion).toBeDefined; 43 | 44 | if (portionData.hasPortion && portionData.portion) { 45 | expect(portionData.portion).toStrictEqual(portionResponse.portion); 46 | 47 | const cachedPortionData = portionCache.get( 48 | PortionFetcher.PORTION_CACHE_KEY( 49 | tokenInChainId, 50 | tokenInAddress, 51 | tokenOutChainId, 52 | tokenOutAddress, 53 | requestSource 54 | ) 55 | ); 56 | 57 | if (!forcePortion) { 58 | expect(cachedPortionData).toBeDefined; 59 | expect(cachedPortionData?.portion).toBeDefined; 60 | expect(cachedPortionData?.hasPortion).toEqual(true); 61 | expect(cachedPortionData?.portion).toStrictEqual(portionResponse.portion); 62 | 63 | const ttlUpperBoundBuffer = 1; // in seconds 64 | const ttl = portionCache.getTtl( 65 | PortionFetcher.PORTION_CACHE_KEY( 66 | tokenInChainId, 67 | tokenInAddress, 68 | tokenOutChainId, 69 | tokenOutAddress, 70 | requestSource 71 | ) 72 | ); 73 | expect(Math.floor((ttl ?? 0) / 1000)).toBeGreaterThanOrEqual( 74 | currentEpochTimeInSeconds + DEFAULT_POSITIVE_CACHE_ENTRY_TTL 75 | ); 76 | expect(Math.floor((ttl ?? 0) / 1000)).toBeLessThanOrEqual( 77 | currentEpochTimeInSeconds + DEFAULT_POSITIVE_CACHE_ENTRY_TTL + ttlUpperBoundBuffer 78 | ); 79 | } else { 80 | expect(cachedPortionData).toBeUndefined; 81 | } 82 | } 83 | }); 84 | 85 | it('Portion Service returns no portion data', async () => { 86 | const createSpy = jest.spyOn(axios, 'create'); 87 | // @ts-ignore 88 | const axiosInstanceMock: AxiosInstance = { 89 | get: jest.fn().mockResolvedValue({ data: GET_NO_PORTION_RESPONSE }), 90 | // You can optionally mock other methods here, such as post, put, etc. 91 | }; 92 | createSpy.mockReturnValueOnce(axiosInstanceMock); 93 | const currentEpochTimeInSeconds = Math.floor(Date.now() / 1000); 94 | 95 | const portionCache = new NodeCache({ stdTTL: 600 }); 96 | const portionFetcher = new PortionFetcher('https://portion.uniswap.org/', portionCache); 97 | const portionData = await portionFetcher.getPortion( 98 | tokenInChainId, 99 | tokenInAddress, 100 | tokenOutChainId, 101 | tokenOutAddress, 102 | requestSource 103 | ); 104 | expect(portionData.hasPortion).toEqual(GET_NO_PORTION_RESPONSE.hasPortion); 105 | 106 | const cachedPortionData = portionCache.get( 107 | PortionFetcher.PORTION_CACHE_KEY(tokenInChainId, tokenInAddress, tokenOutChainId, tokenOutAddress, requestSource) 108 | ); 109 | 110 | if (!forcePortion) { 111 | expect(cachedPortionData).toBeDefined; 112 | expect(cachedPortionData?.hasPortion).toEqual(GET_NO_PORTION_RESPONSE.hasPortion); 113 | 114 | const ttlUpperBoundBuffer = 1; // in seconds 115 | const ttl = portionCache.getTtl( 116 | PortionFetcher.PORTION_CACHE_KEY( 117 | tokenInChainId, 118 | tokenInAddress, 119 | tokenOutChainId, 120 | tokenOutAddress, 121 | requestSource 122 | ) 123 | ); 124 | expect(Math.floor((ttl ?? 0) / 1000)).toBeGreaterThanOrEqual( 125 | currentEpochTimeInSeconds + DEFAULT_NEGATIVE_CACHE_ENTRY_TTL 126 | ); 127 | expect(Math.floor((ttl ?? 0) / 1000)).toBeLessThanOrEqual( 128 | currentEpochTimeInSeconds + DEFAULT_NEGATIVE_CACHE_ENTRY_TTL + ttlUpperBoundBuffer 129 | ); 130 | } else { 131 | expect(cachedPortionData).toBeUndefined; 132 | } 133 | }); 134 | 135 | it('Portion Service encounters runtime error', async () => { 136 | const createSpy = jest.spyOn(axios, 'create'); 137 | // @ts-ignore 138 | const axiosInstanceMock: AxiosInstance = { 139 | get: jest.fn().mockRejectedValue(new Error('Portion Service Error')), 140 | // You can optionally mock other methods here, such as post, put, etc. 141 | }; 142 | createSpy.mockReturnValueOnce(axiosInstanceMock); 143 | 144 | const portionCache = new NodeCache({ stdTTL: 600 }); 145 | const portionFetcher = new PortionFetcher('https://portion.uniswap.org/', portionCache); 146 | const portionData = await portionFetcher.getPortion( 147 | tokenInChainId, 148 | tokenInAddress, 149 | tokenOutChainId, 150 | tokenOutAddress, 151 | requestSource 152 | ); 153 | expect(portionData.hasPortion).toEqual(GET_NO_PORTION_RESPONSE.hasPortion); 154 | 155 | const cachedPortionData = portionCache.get( 156 | PortionFetcher.PORTION_CACHE_KEY(tokenInChainId, tokenInAddress, tokenOutChainId, tokenOutAddress, requestSource) 157 | ); 158 | expect(cachedPortionData).toBeUndefined; 159 | }); 160 | } 161 | 162 | describe('PortionFetcher Unit Tests', () => { 163 | describe('with ENABLE_PORTION flag', () => { 164 | beforeEach(() => { 165 | process.env.ENABLE_PORTION = 'true'; 166 | setGlobalForcePortion(false); 167 | }); 168 | 169 | afterEach(() => { 170 | process.env.ENABLE_PORTION = undefined; 171 | }); 172 | testPortion(); 173 | }); 174 | 175 | describe('with forcePortion global', () => { 176 | beforeEach(() => { 177 | process.env.ENABLE_PORTION = undefined; 178 | setGlobalForcePortion(true); 179 | }); 180 | 181 | afterEach(() => { 182 | setGlobalForcePortion(false); 183 | }); 184 | testPortion(); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /test/unit/lib/fetchers/TokenFetcher.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { NATIVE_ADDRESS } from '../../../../lib/constants'; 3 | import { TokenFetcher } from '../../../../lib/fetchers/TokenFetcher'; 4 | import { ValidationError } from '../../../../lib/util/errors'; 5 | import { TOKEN_IN, USDC_ADDRESS, USDC_ADDRESS_POLYGON } from '../../../constants'; 6 | import { FetcherTest } from '../../../types'; 7 | 8 | const tests: FetcherTest[] = [ 9 | { 10 | testName: 'Succeeds - Basic', 11 | input: { 12 | chainId: 1, 13 | address: TOKEN_IN, 14 | }, 15 | output: TOKEN_IN, 16 | }, 17 | { 18 | testName: 'Succeeds - Symbol', 19 | input: { 20 | chainId: 1, 21 | address: 'USDC', 22 | }, 23 | output: USDC_ADDRESS, 24 | }, 25 | { 26 | testName: 'Succeeds - ETH', 27 | input: { 28 | chainId: 1, 29 | address: 'ETH', 30 | }, 31 | output: NATIVE_ADDRESS, 32 | }, 33 | { 34 | testName: 'Succeeds - MATIC', 35 | input: { 36 | chainId: 137, 37 | address: 'MATIC', 38 | }, 39 | output: NATIVE_ADDRESS, 40 | }, 41 | { 42 | testName: 'Succeeds - Symbol Polygon', 43 | input: { 44 | chainId: 137, 45 | address: 'USDC.e', 46 | }, 47 | output: USDC_ADDRESS_POLYGON, 48 | }, 49 | { 50 | testName: 'Fails - Unknown Symbol', 51 | input: { 52 | chainId: 1, 53 | address: 'USDA', 54 | }, 55 | output: { 56 | error: new ValidationError('Could not find token with symbol USDA'), 57 | }, 58 | errorType: ValidationError, 59 | }, 60 | ]; 61 | 62 | describe('TokenFetcher Unit Tests', () => { 63 | for (const test of tests) { 64 | const t = test; 65 | 66 | // eslint-disable-next-line no-restricted-properties 67 | const testFn = t.only ? it.only : it; 68 | 69 | testFn(t.testName, async () => { 70 | const { input, output } = t; 71 | 72 | try { 73 | const result = await new TokenFetcher().resolveTokenBySymbolOrAddress(input.chainId, input.address); 74 | expect(_.isEqual(result, output)).toBe(true); 75 | } catch (e: any) { 76 | expect(e).toBeInstanceOf(t.errorType); 77 | expect(_.isEqual(e.message, t.output.error.message)).toBe(true); 78 | } 79 | }); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /test/unit/lib/util/permit2.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { createPermitData } from '../../../../lib/util/permit2'; 3 | import { PERMIT2, PERMIT2_POLYGON, TOKEN_IN } from '../../../constants'; 4 | import { UtilityTest } from '../../../types'; 5 | 6 | const tests: UtilityTest[] = [ 7 | { 8 | testName: 'Succeeds - Basic', 9 | input: { 10 | token: TOKEN_IN, 11 | chainId: 1, 12 | nonce: '0', 13 | }, 14 | output: { 15 | permit: PERMIT2, 16 | }, 17 | }, 18 | { 19 | testName: 'Succeeds - Basic Polygon', 20 | input: { 21 | token: TOKEN_IN, 22 | chainId: 137, 23 | nonce: '0', 24 | }, 25 | output: { 26 | permit: PERMIT2_POLYGON, 27 | }, 28 | }, 29 | ]; 30 | 31 | describe('permit2 Unit Tests', () => { 32 | for (const test of tests) { 33 | const t = test; 34 | 35 | // eslint-disable-next-line no-restricted-properties 36 | const testFn = t.only ? it.only : it; 37 | 38 | testFn(t.testName, async () => { 39 | const { input, output } = t; 40 | jest.useFakeTimers({ 41 | now: 0, 42 | }); 43 | 44 | try { 45 | const result = createPermitData(input.token, input.chainId, input.nonce); 46 | expect(_.isEqual(result, output.permit)).toBe(true); 47 | } catch (e: any) { 48 | expect(e).toBeInstanceOf(t.errorType); 49 | expect(_.isEqual(e.message, t.output.error.message)).toBe(true); 50 | } 51 | jest.clearAllTimers(); 52 | }); 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /test/unit/lib/util/quoteMath.test.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber as BN } from 'bignumber.js'; 2 | import { getQuoteSizeEstimateUSD } from '../../../../lib/util/quoteMath'; 3 | import { createClassicQuote } from '../../../utils/fixtures'; 4 | 5 | describe('quoteMath', () => { 6 | describe('getQuoteSizeEstimateUSD', () => { 7 | it('should derive quote size from gas parameters of classic quote', () => { 8 | const quote = createClassicQuote({}, { type: 'EXACT_INPUT' }); 9 | /* 10 | * gasPriceWei: 10000 11 | * gasUseEstimate: 100 12 | * gasUseEstimateUSD: 100 13 | * quote: 1000000000000000000 14 | * gasUseEstimateQuote: 100 15 | * 16 | * quoteSizeUSD = (multiple of gas-cost-equivalent quote token) * (gas cost in USD) 17 | * = (quote / gasUseEstimateQuote) * gasUseEstimateUSD 18 | * = (1000000000000000000 / 100) * 100 19 | */ 20 | expect(getQuoteSizeEstimateUSD(quote)).toStrictEqual(new BN('1000000000000000000')); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/utils/forkAndFund.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 2 | import { Currency, CurrencyAmount } from '@uniswap/sdk-core'; 3 | import hre from 'hardhat'; 4 | import { Erc20, Erc20__factory } from '../../lib/types/ext'; 5 | 6 | const WHALES = [ 7 | '0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8', 8 | '0x6555e1cc97d3cba6eaddebbcd7ca51d75771e0b8', 9 | '0x08638ef1a205be6762a8b935f5da9b700cf7322c', 10 | '0xe8e8f41ed29e46f34e206d7d2a7d6f735a3ff2cb', 11 | '0x72a53cdbbcc1b9efa39c834a540550e23463aacb', 12 | '0xbebc44782c7db0a1a60cb6fe97d0b483032ff1c7', 13 | '0x40ec5b33f54e0e8a33a975908c5ba1c14e5bbbdf', 14 | '0x8eb8a3b98659cce290402893d0123abb75e3ab28', 15 | '0x1e3d6eab4bcf24bcd04721caa11c478a2e59852d', 16 | '0x28C6c06298d514Db089934071355E5743bf21d60', 17 | '0xF977814e90dA44bFA03b6295A0616a897441aceC', 18 | '0x5d3a536e4d6dbd6114cc1ead35777bab948e3643', 19 | '0x2775b1c75658be0f640272ccb8c72ac986009e38', 20 | '0x28c6c06298d514db089934071355e5743bf21d60', 21 | '0x47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503', 22 | '0x06601571aa9d3e8f5f7cdd5b993192618964bab5', 23 | '0xa116f421ff82A9704428259fd8CC63347127B777', 24 | '0xd0C6f16CC58f1b23c51d1529B95fec2740218F0a', 25 | '0x171d311eAcd2206d21Cb462d661C33F0eddadC03', 26 | '0xD91cd0Bcc450409a542A4daeFbFc4118a9F9f5f3', 27 | '0xa116f421ff82A9704428259fd8CC63347127B777', // agEUR/EURA 28 | '0x2a048E86Ec84aF4F45f2991Ac0B0D8eda8E798E7', // agEUR/EURA 29 | '0x171d311eAcd2206d21Cb462d661C33F0eddadC03', // bullet 30 | ]; 31 | 32 | const { ethers } = hre; 33 | 34 | export const resetAndFundAtBlock = async ( 35 | alice: SignerWithAddress, 36 | blockNumber: number, 37 | currencyAmounts: CurrencyAmount[] 38 | ): Promise => { 39 | await hre.network.provider.request({ 40 | method: 'hardhat_reset', 41 | params: [ 42 | { 43 | forking: { 44 | jsonRpcUrl: process.env.ARCHIVE_NODE_RPC, 45 | blockNumber, 46 | }, 47 | }, 48 | ], 49 | }); 50 | 51 | await fund(alice, currencyAmounts); 52 | 53 | return alice; 54 | }; 55 | 56 | export const fund = async ( 57 | alice: SignerWithAddress, 58 | currencyAmounts: CurrencyAmount[] 59 | ): Promise => { 60 | for (const whale of WHALES) { 61 | await hre.network.provider.request({ 62 | method: 'hardhat_impersonateAccount', 63 | params: [whale], 64 | }); 65 | } 66 | 67 | for (const currencyAmount of currencyAmounts) { 68 | const currency = currencyAmount.currency; 69 | const amount = currencyAmount.toExact(); 70 | 71 | if (currency.isNative) { 72 | // Requested funding was for ETH. Hardhat prefunds Alice with 1000 Eth. 73 | return alice; 74 | } 75 | 76 | for (let i = 0; i < WHALES.length; i++) { 77 | const whale = WHALES[i]; 78 | const whaleAccount = ethers.provider.getSigner(whale); 79 | try { 80 | // Send native ETH from hardhat alice test address, so that whale accounts have sufficient ETH to pay for gas 81 | await alice.sendTransaction({ 82 | to: whale, 83 | value: ethers.utils.parseEther('0.1'), // Sends exactly 0.1 ether 84 | }); 85 | 86 | const whaleToken: Erc20 = Erc20__factory.connect(currency.wrapped.address, whaleAccount); 87 | 88 | await whaleToken.transfer(alice.address, ethers.utils.parseUnits(amount, currency.decimals)); 89 | 90 | break; 91 | } catch (err) { 92 | if (i == WHALES.length - 1) { 93 | throw new Error( 94 | `Could not fund ${amount} ${currency.symbol} from any whales. Original error ${JSON.stringify(err)}` 95 | ); 96 | } 97 | } 98 | } 99 | } 100 | 101 | return alice; 102 | }; 103 | -------------------------------------------------------------------------------- /test/utils/getBalanceAndApprove.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 2 | import { MaxAllowanceExpiration, MaxAllowanceTransferAmount } from '@uniswap/permit2-sdk'; 3 | import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'; 4 | import { PERMIT2_ADDRESS } from '@uniswap/universal-router-sdk'; 5 | import { constants } from 'ethers'; 6 | import { Erc20, Permit2__factory } from '../../lib/types/ext'; 7 | import { Erc20__factory } from '../../lib/types/ext/factories/Erc20__factory'; 8 | 9 | export const getBalance = async (alice: SignerWithAddress, currency: Currency): Promise> => { 10 | if (!currency.isToken) { 11 | return CurrencyAmount.fromRawAmount(currency, (await alice.getBalance()).toString()); 12 | } 13 | 14 | const aliceTokenIn: Erc20 = Erc20__factory.connect(currency.address, alice); 15 | 16 | return CurrencyAmount.fromRawAmount(currency, (await aliceTokenIn.balanceOf(alice.address)).toString()); 17 | }; 18 | 19 | export const getBalanceOfAddress = async ( 20 | alice: SignerWithAddress, 21 | address: string, 22 | currency: Token 23 | ): Promise> => { 24 | // tokens / WETH only. 25 | const token: Erc20 = Erc20__factory.connect(currency.address, alice); 26 | 27 | return CurrencyAmount.fromRawAmount(currency, (await token.balanceOf(address)).toString()); 28 | }; 29 | 30 | export const getBalanceAndApprove = async ( 31 | alice: SignerWithAddress, 32 | approveTarget: string, 33 | currency: Currency 34 | ): Promise> => { 35 | if (currency.isToken) { 36 | const aliceTokenIn: Erc20 = Erc20__factory.connect(currency.address, alice); 37 | 38 | if (currency.symbol == 'USDT') { 39 | await (await aliceTokenIn.approve(approveTarget, 0)).wait(); 40 | } 41 | await (await aliceTokenIn.approve(approveTarget, constants.MaxUint256)).wait(); 42 | } 43 | 44 | return getBalance(alice, currency); 45 | }; 46 | 47 | export const getBalanceAndApprovePermit2 = async ( 48 | alice: SignerWithAddress, 49 | approveTarget: string, 50 | currency: Currency 51 | ): Promise> => { 52 | const permit2 = Permit2__factory.connect(PERMIT2_ADDRESS, alice); 53 | if (currency.isToken) { 54 | await ( 55 | await permit2.approve(currency.address, approveTarget, MaxAllowanceTransferAmount, MaxAllowanceExpiration) 56 | ).wait(); 57 | } 58 | 59 | return getBalance(alice, currency.wrapped); 60 | }; 61 | -------------------------------------------------------------------------------- /test/utils/quoteResponse.ts: -------------------------------------------------------------------------------- 1 | import { MethodParameters } from '@uniswap/smart-order-router'; 2 | import { V2PoolInRoute, V3PoolInRoute } from '@uniswap/universal-router-sdk'; 3 | import { RoutingType } from '../../lib/constants'; 4 | import { 5 | ClassicQuote, 6 | ClassicQuoteDataJSON, 7 | DutchQuoteJSON, 8 | DutchQuoteRequest, 9 | Quote, 10 | QuoteRequest, 11 | RelayQuote, 12 | RelayQuoteJSON, 13 | RelayRequest, 14 | } from '../../lib/entities'; 15 | import { DutchQuoteFactory } from '../../lib/entities/quote/DutchQuoteFactory'; 16 | import { Portion } from '../../lib/fetchers/PortionFetcher'; 17 | 18 | type ReceivedQuoteData = DutchQuoteJSON | ClassicQuoteDataJSON; 19 | 20 | export type RoutingApiQuoteResponse = { 21 | quoteId: string; 22 | amount: string; 23 | amountDecimals: string; 24 | quote: string; 25 | quoteDecimals: string; 26 | quoteGasAdjusted: string; 27 | quoteGasAdjustedDecimals: string; 28 | gasUseEstimate: string; 29 | gasUseEstimateQuote: string; 30 | gasUseEstimateQuoteDecimals: string; 31 | gasUseEstimateUSD: string; 32 | simulationError?: boolean; 33 | simulationStatus: string; 34 | gasPriceWei: string; 35 | blockNumber: string; 36 | route: Array<(V3PoolInRoute | V2PoolInRoute)[]>; 37 | routeString: string; 38 | methodParameters?: MethodParameters; 39 | }; 40 | 41 | export function buildQuoteResponse( 42 | body: { routing: RoutingType; quote: ReceivedQuoteData }, 43 | request: QuoteRequest, 44 | nonce?: string, 45 | portion?: Portion 46 | ): Quote { 47 | return parseQuote(request, body.routing, body.quote, nonce, portion); 48 | } 49 | 50 | function parseQuote( 51 | request: QuoteRequest, 52 | routing: RoutingType, 53 | quote: ReceivedQuoteData, 54 | nonce?: string, 55 | portion?: Portion 56 | ): Quote { 57 | switch (routing) { 58 | case RoutingType.DUTCH_LIMIT: 59 | case RoutingType.DUTCH_V2: 60 | return DutchQuoteFactory.fromResponseBody(request as DutchQuoteRequest, quote as DutchQuoteJSON, nonce, portion); 61 | case RoutingType.CLASSIC: 62 | // TODO: figure out how to determine tradetype from output JSON 63 | // also: is this parsing quote responses even needed outside of testing? 64 | return ClassicQuote.fromResponseBody(request, quote as ClassicQuoteDataJSON); 65 | case RoutingType.RELAY: 66 | return RelayQuote.fromResponseBody(request as RelayRequest, quote as RelayQuoteJSON, nonce); 67 | default: 68 | throw new Error(`Unknown routing type: ${routing}`); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/utils/tokens.ts: -------------------------------------------------------------------------------- 1 | import DEFAULT_TOKEN_LIST from '@uniswap/default-token-list'; 2 | import { ChainId, Token } from '@uniswap/sdk-core'; 3 | import { 4 | CachingTokenListProvider, 5 | DAI_ARBITRUM, 6 | DAI_AVAX, 7 | DAI_BNB, 8 | DAI_GOERLI, 9 | DAI_MAINNET, 10 | DAI_OPTIMISM, 11 | DAI_OPTIMISM_GOERLI, 12 | DAI_POLYGON, 13 | DAI_POLYGON_MUMBAI, 14 | DAI_SEPOLIA, 15 | NodeJSCache, 16 | USDC_ARBITRUM, 17 | USDC_AVAX, 18 | USDC_BASE, 19 | USDC_BASE_GOERLI, 20 | USDC_BNB, 21 | USDC_GOERLI, 22 | USDC_MAINNET, 23 | USDC_OPTIMISM, 24 | USDC_OPTIMISM_GOERLI, 25 | USDC_POLYGON, 26 | USDC_POLYGON_MUMBAI, 27 | USDC_SEPOLIA, 28 | USDCE_ZKSYNC, 29 | USDT_ARBITRUM, 30 | USDT_BNB, 31 | USDT_GOERLI, 32 | USDT_MAINNET, 33 | USDT_OPTIMISM, 34 | WRAPPED_NATIVE_CURRENCY 35 | } from '@uniswap/smart-order-router'; 36 | import { BigNumber, ethers } from 'ethers'; 37 | import NodeCache from 'node-cache'; 38 | 39 | export const getTokenListProvider = (id: ChainId) => { 40 | return new CachingTokenListProvider(id, DEFAULT_TOKEN_LIST, new NodeJSCache(new NodeCache())); 41 | }; 42 | 43 | export const getAmount = async (id: ChainId, type: string, symbolIn: string, symbolOut: string, amount: string) => { 44 | if (type == 'EXACT_INPUT' ? symbolIn == 'ETH' : symbolOut == 'ETH') { 45 | return ethers.utils.parseUnits(amount, 18).toString(); 46 | } 47 | 48 | const tokenListProvider = getTokenListProvider(id); 49 | const decimals = (await tokenListProvider.getTokenBySymbol(type == 'EXACT_INPUT' ? symbolIn : symbolOut))!.decimals; 50 | 51 | return ethers.utils.parseUnits(amount, decimals).toString(); 52 | }; 53 | 54 | export const getAmountFromToken = async (type: string, tokenIn: Token, tokenOut: Token, amount: string) => { 55 | const decimals = (type == 'EXACT_INPUT' ? tokenIn : tokenOut).decimals; 56 | return ethers.utils.parseUnits(amount, decimals).toString(); 57 | }; 58 | 59 | export const UNI_MAINNET = new Token( 60 | ChainId.MAINNET, 61 | '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', 62 | 18, 63 | 'UNI', 64 | 'Uniswap' 65 | ); 66 | 67 | export const UNI_GORLI = new Token( 68 | ChainId.GOERLI, 69 | '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', 70 | 18, 71 | 'UNI', 72 | 'Uni token' 73 | ); 74 | 75 | export const BUSD_MAINNET = new Token( 76 | ChainId.MAINNET, 77 | '0x4fabb145d64652a948d72533023f6e7a623c7c53', 78 | 18, 79 | 'BUSD', 80 | 'BUSD Token' 81 | ); 82 | 83 | export const agEUR_MAINNET = new Token( 84 | ChainId.MAINNET, 85 | '0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8', 86 | 18, 87 | 'agEUR', 88 | 'agEur' 89 | ); 90 | 91 | export const GUSD_MAINNET = new Token( 92 | ChainId.MAINNET, 93 | '0x056Fd409E1d7A124BD7017459dFEa2F387b6d5Cd', 94 | 2, 95 | 'GUSD', 96 | 'Gemini dollar' 97 | ); 98 | 99 | export const LUSD_MAINNET = new Token( 100 | ChainId.MAINNET, 101 | '0x5f98805A4E8be255a32880FDeC7F6728C6568bA0', 102 | 18, 103 | 'LUSD', 104 | 'LUSD Stablecoin' 105 | ); 106 | 107 | export const EUROC_MAINNET = new Token( 108 | ChainId.MAINNET, 109 | '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c', 110 | 6, 111 | 'EUROC', 112 | 'EUROC' 113 | ); 114 | 115 | export const XSGD_MAINNET = new Token(ChainId.MAINNET, '0x70e8dE73cE538DA2bEEd35d14187F6959a8ecA96', 6, 'XSGD', 'XSGD'); 116 | 117 | export const BULLET = new Token( 118 | ChainId.MAINNET, 119 | '0x8ef32a03784c8Fd63bBf027251b9620865bD54B6', 120 | 8, 121 | 'BULLET', 122 | 'Bullet Game Betting Token' 123 | ); 124 | export const BULLET_WHT_FOT_TAX = new Token( 125 | ChainId.MAINNET, 126 | '0x8ef32a03784c8Fd63bBf027251b9620865bD54B6', 127 | 8, 128 | 'BULLET', 129 | 'Bullet Game Betting Token', 130 | false, 131 | BigNumber.from(500), 132 | BigNumber.from(500) 133 | ); 134 | 135 | export const DAI_ON = (chainId: ChainId): Token => { 136 | switch (chainId) { 137 | case ChainId.MAINNET: 138 | return DAI_MAINNET; 139 | case ChainId.GOERLI: 140 | return DAI_GOERLI; 141 | case ChainId.SEPOLIA: 142 | return DAI_SEPOLIA; 143 | case ChainId.OPTIMISM: 144 | return DAI_OPTIMISM; 145 | case ChainId.OPTIMISM_GOERLI: 146 | return DAI_OPTIMISM_GOERLI; 147 | case ChainId.ARBITRUM_ONE: 148 | return DAI_ARBITRUM; 149 | case ChainId.POLYGON: 150 | return DAI_POLYGON; 151 | case ChainId.POLYGON_MUMBAI: 152 | return DAI_POLYGON_MUMBAI; 153 | case ChainId.BNB: 154 | return DAI_BNB; 155 | case ChainId.AVALANCHE: 156 | return DAI_AVAX; 157 | default: 158 | throw new Error(`Chain id: ${chainId} not supported`); 159 | } 160 | }; 161 | 162 | export const USDT_ON = (chainId: ChainId): Token => { 163 | switch (chainId) { 164 | case ChainId.MAINNET: 165 | return USDT_MAINNET; 166 | case ChainId.GOERLI: 167 | return USDT_GOERLI; 168 | case ChainId.OPTIMISM: 169 | return USDT_OPTIMISM; 170 | case ChainId.ARBITRUM_ONE: 171 | return USDT_ARBITRUM; 172 | case ChainId.BNB: 173 | return USDT_BNB; 174 | default: 175 | throw new Error(`Chain id: ${chainId} not supported`); 176 | } 177 | }; 178 | 179 | export const USDC_ON = (chainId: ChainId): Token => { 180 | switch (chainId) { 181 | case ChainId.MAINNET: 182 | return USDC_MAINNET; 183 | case ChainId.GOERLI: 184 | return USDC_GOERLI; 185 | case ChainId.SEPOLIA: 186 | return USDC_SEPOLIA; 187 | case ChainId.OPTIMISM: 188 | return USDC_OPTIMISM; 189 | case ChainId.OPTIMISM_GOERLI: 190 | return USDC_OPTIMISM_GOERLI; 191 | case ChainId.ARBITRUM_ONE: 192 | return USDC_ARBITRUM; 193 | case ChainId.POLYGON: 194 | return USDC_POLYGON; 195 | case ChainId.POLYGON_MUMBAI: 196 | return USDC_POLYGON_MUMBAI; 197 | case ChainId.BNB: 198 | return USDC_BNB; 199 | case ChainId.AVALANCHE: 200 | return USDC_AVAX; 201 | case ChainId.BASE_GOERLI: 202 | return USDC_BASE_GOERLI; 203 | case ChainId.BASE: 204 | return USDC_BASE; 205 | case ChainId.ZKSYNC: 206 | return USDCE_ZKSYNC; 207 | default: 208 | throw new Error(`Chain id: ${chainId} not supported`); 209 | } 210 | }; 211 | 212 | export const WNATIVE_ON = (chainId: ChainId): Token => { 213 | return WRAPPED_NATIVE_CURRENCY[chainId]; 214 | }; 215 | -------------------------------------------------------------------------------- /tsconfig.cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "resolveJsonModule": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "strictPropertyInitialization": true, 21 | "outDir": "dist", 22 | "allowJs": true, 23 | "typeRoots": ["./node_modules/@types"], 24 | "skipLibCheck": true 25 | }, 26 | "files": ["./.env.js"], 27 | "exclude": ["cdk.out", "./dist/**/*"] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["ES2020"], 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": true /* Report errors on unused locals. */, 16 | "noUnusedParameters": true /* Report errors on unused parameters. */, 17 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 18 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "strictPropertyInitialization": false, 22 | "outDir": "dist", 23 | "allowJs": true, 24 | "typeRoots": ["./node_modules/@types"], 25 | "skipLibCheck": true 26 | }, 27 | "exclude": ["cdk.out", "./dist/**/*"] 28 | } 29 | --------------------------------------------------------------------------------