├── .nvmrc ├── lib ├── providers │ ├── index.ts │ ├── analytics │ │ ├── index.ts │ │ └── firehose.ts │ ├── order │ │ ├── mock.ts │ │ ├── index.ts │ │ └── uniswapxService.ts │ ├── compliance │ │ ├── index.ts │ │ ├── mock.ts │ │ └── s3.ts │ ├── webhook │ │ ├── mock.ts │ │ ├── index.ts │ │ └── s3.ts │ └── circuit-breaker │ │ ├── index.ts │ │ ├── mock.ts │ │ └── dynamo.ts ├── handlers │ ├── base │ │ ├── index.ts │ │ └── base.ts │ ├── quote │ │ ├── index.ts │ │ ├── exports.ts │ │ ├── schema.ts │ │ ├── handler.ts │ │ └── injector.ts │ ├── synth-switch │ │ ├── index.ts │ │ ├── exports.ts │ │ ├── schema.ts │ │ ├── handler.ts │ │ └── injector.ts │ ├── hard-quote │ │ ├── index.ts │ │ ├── exports.ts │ │ ├── schema.ts │ │ └── injector.ts │ ├── index.ts │ └── blueprints │ │ └── transformations.js ├── util │ ├── stage.ts │ ├── time.ts │ ├── chains.ts │ ├── errors.ts │ ├── fieldValidator.ts │ └── rfqValidator.ts ├── repositories │ ├── index.ts │ ├── analytics-repository.ts │ ├── base.ts │ ├── switch-repository.ts │ ├── timestamp-repository.ts │ └── filler-address-repository.ts ├── config │ ├── chains.ts │ └── routing.ts ├── entities │ ├── index.ts │ ├── analytics-events.ts │ ├── V2HardQuoteResponse.ts │ ├── V3HardQuoteResponse.ts │ ├── HardQuoteResponse.ts │ ├── aws-metrics-logger.ts │ ├── QuoteRequest.ts │ ├── HardQuoteRequest.ts │ └── QuoteResponse.ts ├── preconditions │ └── preconditions.ts ├── quoters │ ├── index.ts │ └── MockQuoter.ts ├── cron │ └── redshift-reaper.ts └── constants.ts ├── cdk.json ├── conf └── webhookConfiguration.json ├── bin ├── constants.ts ├── config.ts └── stacks │ ├── kms-stack.ts │ ├── firehose-stack.ts │ └── cron-dashboard-stack.ts ├── test ├── repositories │ ├── shared.ts │ ├── switch-repository.test.ts │ ├── timestamp-repository.test.ts │ └── filler-address-repository.test.ts ├── fixtures.ts ├── preconditions │ └── preconditions.test.ts ├── handlers │ ├── base.test.ts │ └── hard-quote │ │ ├── schema.test.ts │ │ └── handlerDutchV3.test.ts ├── providers │ ├── analytics │ │ └── firehose.test.ts │ ├── circuit-breaker │ │ └── cb-provider.test.ts │ ├── webhook │ │ └── s3.test.ts │ └── compliance │ │ └── s3.test.ts ├── util │ ├── axios.ts │ └── token-configs.test.ts ├── entities │ ├── QuoteRequest.test.ts │ ├── HardQuoteRequest.test.ts │ └── HardQuoteResponse.test.ts ├── integ │ └── quote.test.ts └── crons │ └── fade-rate-v2.test.ts ├── tsconfig.cdk.json ├── jest.config.js ├── tsconfig.json ├── .eslintrc ├── jest-dynamodb-config.js ├── .gitignore ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ └── trufflehog.yml ├── cdk.context.json ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.17.1 -------------------------------------------------------------------------------- /lib/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './order'; 2 | export * from './webhook'; 3 | -------------------------------------------------------------------------------- /lib/handlers/base/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-handler'; 2 | export * from './base'; 3 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --project=tsconfig.cdk.json bin/app.ts", 3 | "context": {} 4 | } 5 | -------------------------------------------------------------------------------- /lib/util/stage.ts: -------------------------------------------------------------------------------- 1 | export enum STAGE { 2 | BETA = 'beta', 3 | PROD = 'prod', 4 | LOCAL = 'local', 5 | } 6 | -------------------------------------------------------------------------------- /lib/handlers/quote/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handler'; 2 | export * from './injector'; 3 | export * from './schema'; 4 | -------------------------------------------------------------------------------- /conf/webhookConfiguration.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "endpoint": "https://test.com/rfq", 4 | "headers": {} 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /lib/handlers/synth-switch/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handler'; 2 | export * from './injector'; 3 | export * from './schema'; 4 | -------------------------------------------------------------------------------- /lib/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analytics-repository'; 2 | export * from './base'; 3 | export * from './fades-repository'; 4 | export * from './timestamp-repository'; 5 | -------------------------------------------------------------------------------- /lib/handlers/hard-quote/index.ts: -------------------------------------------------------------------------------- 1 | export { QuoteHandler as HardQuoteHandler } from './handler'; 2 | export { ContainerInjected, QuoteInjector as HardQuoteInjector, RequestInjected } from './injector'; 3 | export * from './schema'; 4 | -------------------------------------------------------------------------------- /lib/providers/analytics/index.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsEvent } from '../../entities'; 2 | 3 | export interface IAnalyticsLogger { 4 | sendAnalyticsEvent(analyticsEvent: AnalyticsEvent): Promise; 5 | } 6 | 7 | export * from './firehose'; 8 | -------------------------------------------------------------------------------- /lib/config/chains.ts: -------------------------------------------------------------------------------- 1 | import { ChainId } from '../util/chains'; 2 | 3 | export const SUPPORTED_CHAINS: ChainId[] = [ 4 | ChainId.MAINNET, 5 | ChainId.GÖRLI, 6 | ChainId.POLYGON, 7 | ChainId.SEPOLIA, 8 | ChainId.ARBITRUM_ONE, 9 | ]; 10 | -------------------------------------------------------------------------------- /lib/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analytics-events'; 2 | export * from './aws-metrics-logger'; 3 | export * from './HardQuoteRequest'; 4 | export * from './HardQuoteResponse'; 5 | export * from './QuoteRequest'; 6 | export * from './QuoteResponse'; 7 | -------------------------------------------------------------------------------- /lib/preconditions/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 | -------------------------------------------------------------------------------- /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 = 'GoudaParameterization'; 5 | -------------------------------------------------------------------------------- /test/repositories/shared.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'; 2 | 3 | export const DYNAMO_CONFIG: DynamoDBClientConfig = { 4 | endpoint: 'http://localhost:8000', 5 | region: 'local', 6 | credentials: { 7 | accessKeyId: 'fakeMyKeyId', 8 | secretAccessKey: 'fakeSecretAccessKey', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /lib/handlers/quote/exports.ts: -------------------------------------------------------------------------------- 1 | import { QuoteHandler } from './handler'; 2 | import { QuoteInjector } from './injector'; 3 | 4 | const quoteInjectorPromise = new QuoteInjector('quoteInjector').build(); 5 | 6 | const quoteHandler = new QuoteHandler('quoteHandler', quoteInjectorPromise); 7 | 8 | module.exports = { 9 | quoteHandler: quoteHandler.handler, 10 | }; 11 | -------------------------------------------------------------------------------- /lib/handlers/synth-switch/exports.ts: -------------------------------------------------------------------------------- 1 | import { SwitchHandler } from './handler'; 2 | import { SwitchInjector } from './injector'; 3 | 4 | const switchInjectorPromise = new SwitchInjector('switchInjector').build(); 5 | const switchHandler = new SwitchHandler('SwitchHandler', switchInjectorPromise); 6 | 7 | module.exports = { 8 | switchHandler: switchHandler.handler, 9 | }; 10 | -------------------------------------------------------------------------------- /lib/handlers/hard-quote/exports.ts: -------------------------------------------------------------------------------- 1 | import { QuoteHandler } from './handler'; 2 | import { QuoteInjector } from './injector'; 3 | 4 | const hardQuoteInjectorPromise = new QuoteInjector('hardQuoteInjector').build(); 5 | const hardQuoteHandler = new QuoteHandler('hardQuoteHandler', hardQuoteInjectorPromise); 6 | 7 | module.exports = { 8 | hardQuoteHandler: hardQuoteHandler.handler, 9 | }; 10 | -------------------------------------------------------------------------------- /lib/quoters/index.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | import { QuoteRequest, QuoteResponse } from '../entities'; 3 | 4 | export enum QuoterType { 5 | TEST = 'TEST', 6 | ROUTER = 'ROUTER', 7 | RFQ = 'RFQ', 8 | } 9 | 10 | export interface Quoter { 11 | quote(request: QuoteRequest, provider?: ethers.providers.StaticJsonRpcProvider): Promise; 12 | type(): QuoterType; 13 | } 14 | 15 | export * from './MockQuoter'; 16 | export * from './WebhookQuoter'; 17 | -------------------------------------------------------------------------------- /lib/util/time.ts: -------------------------------------------------------------------------------- 1 | export const currentTimestampInSeconds = () => Math.floor(Date.now() / 1000).toString(); 2 | export const currentTimestampInMs = () => Date.now().toString(); 3 | export const timestampInMstoISOString = (timestamp: number) => new Date(timestamp).toISOString(); 4 | export const timestampInMstoSeconds = (timestamp: number) => Math.floor(timestamp / 1000).toString(); 5 | 6 | export function sleep(ms: number) { 7 | return new Promise((resolve) => { 8 | setTimeout(resolve, ms); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /lib/providers/order/mock.ts: -------------------------------------------------------------------------------- 1 | import { OrderServiceProvider, PostOrderArgs, UniswapXServiceResponse } from '.'; 2 | import { ErrorResponse } from '../../handlers/base'; 3 | 4 | export class MockOrderServiceProvider implements OrderServiceProvider { 5 | public orders: string[] = []; 6 | 7 | async postOrder(args: PostOrderArgs): Promise { 8 | const { order } = args; 9 | this.orders.push(order.serialize()); 10 | return { 11 | statusCode: 200, 12 | data: 'Order posted', 13 | }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /bin/config.ts: -------------------------------------------------------------------------------- 1 | import { BillingMode } from 'aws-cdk-lib/aws-dynamodb'; 2 | 3 | import { TableCapacityConfig } from './stacks/cron-stack'; 4 | 5 | export const PROD_TABLE_CAPACITY: TableCapacityConfig = { 6 | fillerAddress: { billingMode: BillingMode.PROVISIONED, readCapacity: 70, writeCapacity: 250 }, 7 | fadeRate: { billingMode: BillingMode.PROVISIONED, readCapacity: 50, writeCapacity: 5 }, 8 | synthSwitch: { billingMode: BillingMode.PROVISIONED, readCapacity: 2000, writeCapacity: 5 }, 9 | timestamps: { billingMode: BillingMode.PROVISIONED, readCapacity: 100, writeCapacity: 10 }, 10 | }; 11 | -------------------------------------------------------------------------------- /lib/providers/compliance/index.ts: -------------------------------------------------------------------------------- 1 | export interface FillerComplianceConfiguration { 2 | endpoints: string[]; 3 | addresses: string[]; 4 | complianceListUrl?: string; 5 | } 6 | export interface FillerComplianceList { 7 | addresses: string[]; 8 | } 9 | 10 | export interface FillerComplianceConfigurationProvider { 11 | getConfigs(): Promise; 12 | // getExcludedAddrToEndpointsMap(): Promise>>; 13 | getEndpointToExcludedAddrsMap(): Promise>>; 14 | } 15 | 16 | export * from './mock'; 17 | export * from './s3'; 18 | -------------------------------------------------------------------------------- /lib/providers/order/index.ts: -------------------------------------------------------------------------------- 1 | import { Order } from '@uniswap/uniswapx-sdk'; 2 | import { ErrorResponse } from '../../handlers/base'; 3 | 4 | export interface UniswapXServiceResponse { 5 | statusCode: number; 6 | data: string; 7 | } 8 | 9 | export interface PostOrderArgs { 10 | order: Order; 11 | signature: string; 12 | quoteId?: string; 13 | requestId?: string; 14 | } 15 | 16 | export interface OrderServiceProvider { 17 | postOrder(args: PostOrderArgs): Promise; 18 | } 19 | 20 | export * from './mock'; 21 | export * from './uniswapxService'; 22 | -------------------------------------------------------------------------------- /lib/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | botOrderEventsProcessor, 3 | fillEventProcessor, 4 | postOrderProcessor, 5 | quoteProcessor, 6 | unimindResponseProcessor, 7 | unimindParameterUpdateProcessor, 8 | } from './blueprints/cw-log-firehose-processor'; 9 | 10 | module.exports = { 11 | fillEventProcessor: fillEventProcessor, 12 | postOrderProcessor: postOrderProcessor, 13 | quoteProcessor: quoteProcessor, 14 | botOrderEventsProcessor: botOrderEventsProcessor, 15 | unimindResponseProcessor: unimindResponseProcessor, 16 | unimindParameterUpdateProcessor: unimindParameterUpdateProcessor, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/providers/webhook/mock.ts: -------------------------------------------------------------------------------- 1 | import { ProtocolVersion, WebhookConfiguration, WebhookConfigurationProvider } from '.'; 2 | 3 | export class MockWebhookConfigurationProvider implements WebhookConfigurationProvider { 4 | constructor(private endpoints: WebhookConfiguration[]) {} 5 | 6 | async getEndpoints(): Promise { 7 | return this.endpoints; 8 | } 9 | 10 | async getFillerSupportedProtocols(endpoint: string): Promise { 11 | const config = this.endpoints.find((e) => e.endpoint === endpoint); 12 | return config?.supportedVersions ?? [ProtocolVersion.V1]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/providers/circuit-breaker/index.ts: -------------------------------------------------------------------------------- 1 | import { FillerTimestampMap } from '../../repositories'; 2 | import { WebhookConfiguration } from '../webhook'; 3 | 4 | export interface CircuitBreakerConfiguration { 5 | hash: string; 6 | fadeRate: number; 7 | enabled: boolean; 8 | } 9 | 10 | export interface EndpointStatuses { 11 | enabled: WebhookConfiguration[]; 12 | disabled: { 13 | webhook: WebhookConfiguration; 14 | blockUntil: number; 15 | }[]; 16 | } 17 | 18 | export interface CircuitBreakerConfigurationProvider { 19 | allow_list?: Set; 20 | getConfigurations(): Promise; 21 | getEndpointStatuses(endpoints: WebhookConfiguration[]): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /lib/providers/webhook/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mock'; 2 | export * from './s3'; 3 | 4 | type WebhookOverrides = { 5 | timeout: number; 6 | }; 7 | 8 | export enum ProtocolVersion { 9 | V1 = 'v1', 10 | V2 = 'v2', 11 | } 12 | 13 | export interface WebhookConfiguration { 14 | name: string; 15 | hash: string; 16 | endpoint: string; 17 | headers?: { [key: string]: string }; 18 | overrides?: WebhookOverrides; 19 | // the chainids the endpoint should receive webhooks for 20 | // if null, send for all chains 21 | chainIds?: number[]; 22 | addresses?: string[]; 23 | supportedVersions?: ProtocolVersion[]; 24 | } 25 | 26 | export interface WebhookConfigurationProvider { 27 | getEndpoints(): Promise; 28 | } 29 | -------------------------------------------------------------------------------- /lib/handlers/blueprints/transformations.js: -------------------------------------------------------------------------------- 1 | export function transformLogEvent(logEvent) { 2 | const logData = JSON.parse(logEvent.message); 3 | if (!logData.body) { 4 | throw new Error('Missing body field in log event: ' + logEvent.message); 5 | } 6 | return JSON.stringify(logData.body); 7 | } 8 | 9 | export function transformFillLogEvent(logEvent) { 10 | const logData = JSON.parse(logEvent.message); 11 | if (!logData.orderInfo) { 12 | throw new Error('Missing orderInfo field in log event: ' + logEvent.message); 13 | } 14 | return JSON.stringify(logData.orderInfo); 15 | } 16 | 17 | export const transformPostOrderLogEvent = transformLogEvent; 18 | export const transformUnimindResponseLogEvent = transformLogEvent; 19 | export const transformUnimindParameterUpdateLogEvent = transformLogEvent; 20 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { MockV2CircuitBreakerConfigurationProvider } from '../lib/providers/circuit-breaker/mock'; 2 | 3 | const now = Math.floor(Date.now() / 1000); 4 | 5 | export const WEBHOOK_URL = 'https://uniswap.org'; 6 | export const WEBHOOK_URL_ONEINCH = 'https://1inch.io'; 7 | export const WEBHOOK_URL_SEARCHER = 'https://searcher.com'; 8 | export const WEBHOOK_URL_FOO = 'https://foo.com'; 9 | 10 | export const MOCK_V2_CB_PROVIDER = new MockV2CircuitBreakerConfigurationProvider( 11 | [WEBHOOK_URL, WEBHOOK_URL_ONEINCH, WEBHOOK_URL_SEARCHER], 12 | new Map([ 13 | [WEBHOOK_URL_ONEINCH, { blockUntilTimestamp: now + 100000, lastPostTimestamp: now - 10, consecutiveBlocks: 0 }], 14 | [WEBHOOK_URL_SEARCHER, { blockUntilTimestamp: now - 10, lastPostTimestamp: now - 100, consecutiveBlocks: NaN }], 15 | ]) 16 | ); 17 | -------------------------------------------------------------------------------- /test/preconditions/preconditions.test.ts: -------------------------------------------------------------------------------- 1 | import { checkDefined } from '../../lib/preconditions/preconditions'; 2 | 3 | describe('checkDefined', () => { 4 | it('throws on null value', async () => { 5 | expect(() => checkDefined(null)).toThrow(); 6 | }); 7 | 8 | it('throws on undefined value', async () => { 9 | expect(() => checkDefined(undefined)).toThrow(); 10 | }); 11 | 12 | it('throws on null value with message', async () => { 13 | expect(() => checkDefined(null, 'foo')).toThrow(new Error('foo')); 14 | }); 15 | 16 | it('throws on undefined value with message', async () => { 17 | expect(() => checkDefined(undefined, 'foo')).toThrow(new Error('foo')); 18 | }); 19 | 20 | it('returns defined value', async () => { 21 | expect(checkDefined('foo', 'bar')).toEqual('foo'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /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 | }, 25 | "files": ["./.env.js"], 26 | "exclude": ["cdk.out", "./dist/**/*"] 27 | } 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const ts_preset = require('ts-jest/jest-preset'); 2 | const dynamo_preset = require('@shelf/jest-dynamodb/jest-preset'); 3 | 4 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 5 | module.exports = { 6 | ...ts_preset, 7 | ...dynamo_preset, 8 | testTimeout: 10000, 9 | testEnvironment: 'node', 10 | testPathIgnorePatterns: ['bin', 'dist'], 11 | collectCoverageFrom: ['**/*.ts', '!**/build/**', '!**/node_modules/**', '!**/dist/**', '!**/bin/**'], 12 | transform: { 13 | // Use swc to speed up ts-jest's sluggish compilation times. 14 | // Using this cuts the initial time to compile from 6-12 seconds to 15 | // ~1 second consistently. 16 | // Inspiration from: https://github.com/kulshekhar/ts-jest/issues/259#issuecomment-1332269911 17 | // 18 | // https://swc.rs/docs/usage/jest#usage 19 | '^.+\\.(t|j)s?$': '@swc/jest', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/providers/compliance/mock.ts: -------------------------------------------------------------------------------- 1 | import { FillerComplianceConfiguration, FillerComplianceConfigurationProvider } from '.'; 2 | 3 | export class MockFillerComplianceConfigurationProvider implements FillerComplianceConfigurationProvider { 4 | constructor(private configs: FillerComplianceConfiguration[]) {} 5 | 6 | async getConfigs(): Promise { 7 | return this.configs; 8 | } 9 | 10 | async getEndpointToExcludedAddrsMap(): Promise>> { 11 | const map = new Map>(); 12 | this.configs.forEach((config) => { 13 | config.endpoints.forEach((endpoint) => { 14 | if (!map.has(endpoint)) { 15 | map.set(endpoint, new Set()); 16 | } 17 | config.addresses.forEach((address) => { 18 | map.get(endpoint)?.add(address); 19 | }); 20 | }); 21 | }); 22 | return map; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/util/chains.ts: -------------------------------------------------------------------------------- 1 | export enum ChainId { 2 | MAINNET = 1, 3 | GÖRLI = 5, 4 | POLYGON = 137, 5 | SEPOLIA = 11155111, 6 | ARBITRUM_ONE = 42161, 7 | } 8 | 9 | export const supportedChains = [ 10 | ChainId.MAINNET, 11 | ChainId.ARBITRUM_ONE, 12 | ] 13 | 14 | export enum ChainName { 15 | // ChainNames match infura network strings 16 | MAINNET = 'mainnet', 17 | GÖRLI = 'goerli', 18 | POLYGON = 'polygon', 19 | SEPOLIA = 'sepolia', 20 | ARBITRUM_ONE = 'arbitrum-mainnet', 21 | } 22 | 23 | export const ID_TO_NETWORK_NAME = (id: number): ChainName => { 24 | switch (id) { 25 | case 1: 26 | return ChainName.MAINNET; 27 | case 5: 28 | return ChainName.GÖRLI; 29 | case 137: 30 | return ChainName.POLYGON; 31 | case 11155111: 32 | return ChainName.SEPOLIA; 33 | case 42161: 34 | return ChainName.ARBITRUM_ONE; 35 | default: 36 | throw new Error(`Unknown chain id: ${id}`); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /test/handlers/base.test.ts: -------------------------------------------------------------------------------- 1 | import { BaseInjector } from '../../lib/handlers/base'; 2 | 3 | interface MockContainerInjected { 4 | foo: string; 5 | } 6 | 7 | class MockInjector extends BaseInjector { 8 | protected buildContainerInjected(): Promise { 9 | throw new Error('Method not implemented.'); 10 | } 11 | } 12 | 13 | describe('BaseInjector tests', () => { 14 | it('should throw if handlerName is not defined', () => { 15 | expect(() => { 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | new MockInjector(undefined as any); 18 | }).toThrow(); 19 | }); 20 | 21 | it('should throw if build() method is not called before getRequestInjected()', () => { 22 | const inj = new MockInjector('foo'); 23 | expect(() => { 24 | inj.getContainerInjected(); 25 | }).toThrow('Container injected undefined. Must call build() before using.'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /bin/stacks/kms-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { CfnOutput, RemovalPolicy } from 'aws-cdk-lib'; 3 | import { KeySpec, KeyUsage } from 'aws-cdk-lib/aws-kms'; 4 | import { Construct } from 'constructs'; 5 | 6 | export class KmsStack extends cdk.NestedStack { 7 | public readonly key: cdk.aws_kms.Key; 8 | 9 | constructor(parent: Construct, name: string) { 10 | super(parent, name); 11 | 12 | /** 13 | * Unless absolutely necessary, DO NOT change this construct. 14 | * This uses the 'Retain' DeletionPolicy, which will cause the resource to be retained 15 | * in the account, but orphaned from the stack if the Key construct is ever changed. 16 | */ 17 | this.key = new cdk.aws_kms.Key(this, name, { 18 | removalPolicy: RemovalPolicy.RETAIN, 19 | keySpec: KeySpec.ECC_SECG_P256K1, 20 | keyUsage: KeyUsage.SIGN_VERIFY, 21 | alias: name, 22 | }); 23 | 24 | new CfnOutput(this, `${name}KeyId`, { 25 | value: this.key.keyId, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/entities/analytics-events.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | import { timestampInMstoISOString } from '../util/time'; 4 | 5 | export enum AnalyticsEventType { 6 | WEBHOOK_RESPONSE = 'WebhookQuoterResponse', 7 | } 8 | 9 | export enum WebhookResponseType { 10 | OK = 'OK', 11 | NON_QUOTE = 'NON_QUOTE', 12 | VALIDATION_ERROR = 'VALIDATION_ERROR', 13 | REQUEST_ID_MISMATCH = 'REQUEST_ID_MISMATCH', 14 | TIMEOUT = 'TIMEOUT', 15 | HTTP_ERROR = 'HTTP_ERROR', 16 | OTHER_ERROR = 'OTHER_ERROR', 17 | } 18 | 19 | export class AnalyticsEvent { 20 | eventId: string; // gets set in constructor 21 | eventType: AnalyticsEventType; 22 | eventTime: string; // gets set in constructor 23 | eventProperties: { [key: string]: any }; 24 | 25 | constructor(eventType: AnalyticsEventType, eventProperties: { [key: string]: any }) { 26 | this.eventId = uuidv4(); 27 | this.eventType = eventType; 28 | this.eventTime = timestampInMstoISOString(Date.now()); 29 | this.eventProperties = eventProperties; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/handlers/synth-switch/schema.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | import { FieldValidator } from '../../util/fieldValidator'; 4 | 5 | export const SynthSwitchQueryParamsJoi = Joi.object({ 6 | tokenIn: FieldValidator.address.required(), 7 | tokenInChainId: FieldValidator.chainId.required(), 8 | tokenOut: FieldValidator.address.required(), 9 | tokenOutChainId: FieldValidator.chainId.required(), 10 | type: FieldValidator.tradeType.required(), 11 | amount: FieldValidator.amount.required(), // tokenInAmount if EXACT_INPUT, tokenOutAmount if EXACT_OUTPUT 12 | }); 13 | 14 | export type SynthSwitchQueryParams = SynthSwitchTrade & { 15 | amount: string; 16 | }; 17 | 18 | export type SynthSwitchTrade = { 19 | tokenInChainId: number; 20 | tokenOutChainId: number; 21 | tokenIn: string; 22 | tokenOut: string; 23 | type: string; 24 | }; 25 | 26 | export const SynthSwitchResponseJoi = Joi.object({ 27 | enabled: Joi.boolean().required(), 28 | }); 29 | 30 | export type SynthSwitchResponse = { 31 | enabled: boolean; 32 | }; 33 | -------------------------------------------------------------------------------- /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": true, 22 | "outDir": "dist", 23 | "allowJs": true, 24 | "typeRoots": ["./node_modules/@types"], 25 | "skipLibCheck": true 26 | }, 27 | "exclude": ["cdk.out", "./dist/**/*"] 28 | } 29 | -------------------------------------------------------------------------------- /.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"], 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/ban-types": "warn", 32 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 33 | "@typescript-eslint/ban-ts-comment": "off" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/cron/redshift-reaper.ts: -------------------------------------------------------------------------------- 1 | import { EventBridgeEvent, ScheduledHandler } from 'aws-lambda'; 2 | 3 | import { checkDefined } from '../preconditions/preconditions'; 4 | import { AnalyticsRepository, SharedConfigs, TimestampThreshold } from '../repositories'; 5 | 6 | const CREATEDAT = 'createdat'; 7 | const TABLES_TO_CLEAN = [ 8 | 'unifiedroutingrequests', 9 | 'unifiedroutingresponses', 10 | 'rfqrequests', 11 | 'rfqresponses', 12 | 'postedorders', 13 | ]; 14 | 15 | export const handler: ScheduledHandler = async (_event: EventBridgeEvent) => { 16 | const sharedConfig: SharedConfigs = { 17 | Database: checkDefined(process.env.REDSHIFT_DATABASE), 18 | ClusterIdentifier: checkDefined(process.env.REDSHIFT_CLUSTER_IDENTIFIER), 19 | SecretArn: checkDefined(process.env.REDSHIFT_SECRET_ARN), 20 | }; 21 | const analyticsRepository = AnalyticsRepository.create(sharedConfig); 22 | 23 | // needs to be sequential be cause of the vacuum command 24 | for (const table of TABLES_TO_CLEAN) { 25 | await analyticsRepository.cleanUpTable(table, CREATEDAT, TimestampThreshold.TWO_WEEKS); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /jest-dynamodb-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tables: [ 3 | { 4 | TableName: `SyntheticSwitchTable`, 5 | KeySchema: [ 6 | { AttributeName: 'tokenIn#tokenInChainId#tokenOut#tokenOutChainId#type', KeyType: 'HASH' }, 7 | ], 8 | AttributeDefinitions: [ 9 | { AttributeName: 'tokenIn#tokenInChainId#tokenOut#tokenOutChainId#type', AttributeType: 'S' }, 10 | ], 11 | ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 }, 12 | }, 13 | { 14 | TableName: `FillerAddress`, 15 | KeySchema: [ 16 | { AttributeName: 'pk', KeyType: 'HASH' }, 17 | ], 18 | AttributeDefinitions: [ 19 | { AttributeName: 'pk', AttributeType: 'S' }, 20 | ], 21 | ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 }, 22 | }, 23 | { 24 | TableName: 'FillerCBTimestamps', 25 | KeySchema: [ 26 | { AttributeName: 'hash', KeyType: 'HASH' }, 27 | ], 28 | AttributeDefinitions: [ 29 | { AttributeName: 'hash', AttributeType: 'S' }, 30 | ], 31 | ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 }, 32 | } 33 | ], 34 | port: 8000, 35 | }; 36 | -------------------------------------------------------------------------------- /lib/quoters/MockQuoter.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan'; 2 | import { BigNumber } from 'ethers'; 3 | 4 | import { Quoter, QuoterType } from '.'; 5 | import { QuoteRequest, QuoteResponse } from '../entities'; 6 | 7 | export const MOCK_FILLER_ADDRESS = '0x0000000000000000000000000000000000000001'; 8 | const METADATA = { 9 | endpoint: 'https://uniswap.org', 10 | fillerName: 'uniswap', 11 | }; 12 | 13 | // mock quoter which simply returns a quote at a preconfigured exchange rate 14 | export class MockQuoter implements Quoter { 15 | private log: Logger; 16 | 17 | constructor(_log: Logger, private numerator?: number, private denominator?: number) { 18 | this.log = _log.child({ quoter: 'MockQuoter' }); 19 | } 20 | 21 | public async quote(request: QuoteRequest): Promise { 22 | const amountQuoted = 23 | this.denominator && this.numerator ? request.amount.mul(this.numerator).div(this.denominator) : BigNumber.from(1); 24 | 25 | this.log.info( 26 | `MockQuoter: request ${request.requestId}: ${request.amount.toString()} -> ${amountQuoted.toString()}` 27 | ); 28 | return [QuoteResponse.fromRequest({ request, amountQuoted, metadata: METADATA, filler: MOCK_FILLER_ADDRESS })]; 29 | } 30 | 31 | public type(): QuoterType { 32 | return QuoterType.TEST; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/handlers/base/base.ts: -------------------------------------------------------------------------------- 1 | import { default as Logger } from 'bunyan'; 2 | 3 | import { checkDefined } from '../../preconditions/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /lib/config/routing.ts: -------------------------------------------------------------------------------- 1 | import { Protocol } from '@uniswap/router-sdk'; 2 | import { AlphaRouterConfig } from '@uniswap/smart-order-router'; 3 | 4 | import { ChainId } from '../util/chains'; 5 | 6 | export const DEFAULT_ROUTING_CONFIG_BY_CHAIN = (chainId: ChainId): AlphaRouterConfig => { 7 | switch (chainId) { 8 | case ChainId.MAINNET: 9 | case ChainId.GÖRLI: 10 | default: 11 | return { 12 | v2PoolSelection: { 13 | topN: 3, 14 | topNDirectSwaps: 1, 15 | topNTokenInOut: 5, 16 | topNSecondHop: 2, 17 | topNWithEachBaseToken: 2, 18 | topNWithBaseToken: 6, 19 | }, 20 | v3PoolSelection: { 21 | topN: 2, 22 | topNDirectSwaps: 2, 23 | topNTokenInOut: 3, 24 | topNSecondHop: 1, 25 | topNWithEachBaseToken: 3, 26 | topNWithBaseToken: 5, 27 | }, 28 | v4PoolSelection: { 29 | topN: 2, 30 | topNDirectSwaps: 2, 31 | topNTokenInOut: 3, 32 | topNSecondHop: 1, 33 | topNWithEachBaseToken: 3, 34 | topNWithBaseToken: 5, 35 | }, 36 | maxSwapsPerPath: 3, 37 | minSplits: 1, 38 | maxSplits: 7, 39 | distributionPercent: 5, 40 | forceCrossProtocol: false, 41 | protocols: [Protocol.V3], 42 | }; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /lib/providers/analytics/firehose.ts: -------------------------------------------------------------------------------- 1 | import { FirehoseClient, PutRecordCommand } from '@aws-sdk/client-firehose'; 2 | import { default as Logger } from 'bunyan'; 3 | 4 | import { IAnalyticsLogger } from '.'; 5 | import { AnalyticsEvent } from '../../entities/analytics-events'; 6 | 7 | export class FirehoseLogger implements IAnalyticsLogger { 8 | private log: Logger; 9 | private readonly streamName: string; 10 | private readonly firehose: FirehoseClient; 11 | 12 | constructor(_log: Logger, streamArn: string) { 13 | this.log = _log; 14 | // Split the streamArn to extract the streamName 15 | const streamArnParts = streamArn.split('/'); 16 | 17 | if (streamArnParts.length !== 2) { 18 | this.log.error({ streamArn: streamArn }, `Firehose client error parsing stream from ${streamArn}.`); 19 | } 20 | 21 | this.streamName = streamArnParts[1]; 22 | this.firehose = new FirehoseClient(); 23 | } 24 | 25 | async sendAnalyticsEvent(analyticsEvent: AnalyticsEvent): Promise { 26 | const jsonString = JSON.stringify(analyticsEvent) + '\n'; 27 | const params = { 28 | DeliveryStreamName: this.streamName, 29 | Record: { 30 | Data: Buffer.from(jsonString), 31 | }, 32 | }; 33 | 34 | try { 35 | await this.firehose.send(new PutRecordCommand(params)); 36 | } catch (error) { 37 | this.log.error({ streamName: this.streamName }, `Firehose client error putting record. ${error}`); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/repositories/analytics-repository.ts: -------------------------------------------------------------------------------- 1 | import { RedshiftDataClient } from '@aws-sdk/client-redshift-data'; 2 | import Logger from 'bunyan'; 3 | 4 | import { BaseRedshiftRepository, SharedConfigs, TimestampThreshold } from './base'; 5 | 6 | export class AnalyticsRepository extends BaseRedshiftRepository { 7 | static log: Logger; 8 | 9 | static create(configs: SharedConfigs): AnalyticsRepository { 10 | this.log = Logger.createLogger({ 11 | name: 'RedshiftRepository', 12 | serializers: Logger.stdSerializers, 13 | }); 14 | 15 | return new AnalyticsRepository(new RedshiftDataClient({}), configs); 16 | } 17 | 18 | constructor(readonly client: RedshiftDataClient, configs: SharedConfigs) { 19 | super(client, configs); 20 | } 21 | 22 | public async cleanUpTable( 23 | tableName: string, 24 | timestampField: string, 25 | timestampThreshold = TimestampThreshold.TWO_MONTHS 26 | ): Promise { 27 | const deleteSql = ` 28 | DELETE FROM ${tableName} 29 | WHERE ${timestampField} < EXTRACT(EPOCH from (GETDATE() - INTERVAL ${timestampThreshold})) 30 | `; 31 | // immediately reclaim storage space, deleting at least 99% of the rows marked for deletion 32 | const vacuumSql = `VACUUM DELETE ONLY ${tableName} TO 99 PERCENT`; 33 | 34 | await this.executeStatement(deleteSql, AnalyticsRepository.log, { waitTimeMs: 10_000 }); 35 | await this.executeStatement(vacuumSql, AnalyticsRepository.log, { waitTimeMs: 2_000 }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/handlers/hard-quote/schema.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | import { FieldValidator } from '../../util/fieldValidator'; 4 | 5 | /* Hard quote request from user */ 6 | export const HardQuoteRequestBodyJoi = Joi.object({ 7 | requestId: FieldValidator.requestId.required(), 8 | quoteId: FieldValidator.uuid.optional(), 9 | encodedInnerOrder: Joi.string().required(), 10 | innerSig: FieldValidator.rawSignature.required(), 11 | tokenInChainId: FieldValidator.chainId.required(), 12 | tokenOutChainId: Joi.number().integer().valid(Joi.ref('tokenInChainId')).required(), 13 | allowNoQuote: Joi.boolean().optional(), 14 | forceOpenOrder: Joi.boolean().optional(), 15 | }); 16 | 17 | export type HardQuoteRequestBody = { 18 | requestId: string; 19 | quoteId?: string; 20 | encodedInnerOrder: string; 21 | innerSig: string; 22 | tokenInChainId: number; 23 | tokenOutChainId: number; 24 | allowNoQuote?: boolean; 25 | forceOpenOrder?: boolean; 26 | }; 27 | 28 | export const HardQuoteResponseDataJoi = Joi.object({ 29 | requestId: FieldValidator.uuid.required(), 30 | quoteId: FieldValidator.uuid, 31 | chainId: FieldValidator.chainId.required(), 32 | encodedOrder: Joi.string().required(), 33 | orderHash: FieldValidator.orderHash.required(), 34 | filler: FieldValidator.address, 35 | }); 36 | 37 | export type HardQuoteResponseData = { 38 | requestId: string; 39 | quoteId?: string; 40 | chainId: number; 41 | encodedOrder: string; 42 | orderHash: string; 43 | filler?: string; 44 | }; 45 | -------------------------------------------------------------------------------- /lib/entities/V2HardQuoteResponse.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers"; 2 | import { HardQuoteResponse } from "./HardQuoteResponse"; 3 | import { CosignedV2DutchOrder } from "@uniswap/uniswapx-sdk"; 4 | 5 | export class V2HardQuoteResponse extends HardQuoteResponse { 6 | public toLog() { 7 | return { 8 | quoteId: this.quoteId, 9 | requestId: this.requestId, 10 | tokenInChainId: this.chainId, 11 | tokenOutChainId: this.chainId, 12 | tokenIn: this.tokenIn, 13 | amountIn: this.amountIn.toString(), 14 | tokenOut: this.tokenOut, 15 | amountOut: this.amountOut.toString(), 16 | swapper: this.swapper, 17 | filler: this.filler, 18 | orderHash: this.order.hash(), 19 | createdAt: this.createdAt, 20 | createdAtMs: this.createdAtMs, 21 | }; 22 | } 23 | 24 | public get amountOut(): BigNumber { 25 | const resolved = this.order.resolve({ 26 | timestamp: this.order.info.cosignerData.decayStartTime, 27 | }); 28 | let amount = BigNumber.from(0); 29 | for (const output of resolved.outputs) { 30 | amount = amount.add(output.amount); 31 | } 32 | 33 | return amount; 34 | } 35 | 36 | public get amountIn(): BigNumber { 37 | const resolved = this.order.resolve({ 38 | timestamp: this.order.info.cosignerData.decayStartTime, 39 | }); 40 | return resolved.input.amount; 41 | } 42 | } -------------------------------------------------------------------------------- /lib/providers/circuit-breaker/mock.ts: -------------------------------------------------------------------------------- 1 | import { CircuitBreakerConfigurationProvider, EndpointStatuses } from '.'; 2 | import { FillerTimestampMap } from '../../repositories'; 3 | import { WebhookConfiguration } from '../webhook'; 4 | 5 | export class MockV2CircuitBreakerConfigurationProvider implements CircuitBreakerConfigurationProvider { 6 | constructor(public fillers: string[], private timestamps: FillerTimestampMap) {} 7 | 8 | async getConfigurations(): Promise { 9 | return this.timestamps; 10 | } 11 | 12 | async getEndpointStatuses(endpoints: WebhookConfiguration[]): Promise { 13 | const now = Math.floor(Date.now() / 1000); 14 | const fillerTimestamps = await this.getConfigurations(); 15 | if (fillerTimestamps.size) { 16 | const enabledEndpoints = endpoints.filter((e) => { 17 | return !(fillerTimestamps.has(e.endpoint) && fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp > now); 18 | }); 19 | const disabledEndpoints = endpoints 20 | .filter((e) => { 21 | return fillerTimestamps.has(e.endpoint) && fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp > now; 22 | }) 23 | .map((e) => { 24 | return { 25 | webhook: e, 26 | blockUntil: fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp, 27 | }; 28 | }); 29 | 30 | return { 31 | enabled: enabledEndpoints, 32 | disabled: disabledEndpoints, 33 | }; 34 | } 35 | return { 36 | enabled: endpoints, 37 | disabled: [], 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const COMPLIANCE_CONFIG_BUCKET = 'compliance-config'; 2 | export const WEBHOOK_CONFIG_BUCKET = 'rfq-config'; 3 | export const SYNTH_SWITCH_BUCKET = 'synth-config'; 4 | export const FADE_RATE_BUCKET = 'fade-rate-config'; 5 | export const INTEGRATION_S3_KEY = 'integration.json'; 6 | export const PRODUCTION_S3_KEY = 'production.json'; 7 | export const BETA_S3_KEY = 'beta.json'; 8 | export const FADE_RATE_S3_KEY = 'fade-rate.json'; 9 | export const PROD_COMPLIANCE_S3_KEY = 'production.json'; 10 | export const BETA_COMPLIANCE_S3_KEY = 'beta.json'; 11 | 12 | export const DYNAMO_TABLE_NAME = { 13 | FADES: 'Fades', 14 | SYNTHETIC_SWITCH_TABLE: 'SyntheticSwitchTable', 15 | FILLER_ADDRESS: 'FillerAddress', 16 | FILLER_CB_TIMESTAMPS: 'FillerCBTimestamps', 17 | }; 18 | 19 | export const DYNAMO_TABLE_KEY = { 20 | FILLER: 'filler', 21 | TOKEN_IN: 'tokenIn', 22 | TOKEN_IN_CHAIN_ID: 'tokenInChainId', 23 | TOKEN_OUT: 'tokenOut', 24 | TOKEN_OUT_CHAIN_ID: 'tokenOutChainId', 25 | TRADE_TYPE: 'type', 26 | LOWER: 'lower', 27 | ENABLED: 'enabled', 28 | BLOCK_UNTIL_TIMESTAMP: 'blockUntilTimestamp', 29 | LAST_POST_TIMESTAMP: 'lastPostTimestamp', 30 | FADED: 'faded', 31 | CONSECUTIVE_BLOCKS: 'consecutiveBlocks', 32 | }; 33 | 34 | export const POST_ORDER_ERROR_REASON = { 35 | INSUFFICIENT_FUNDS: 'Onchain validation failed: InsufficientFunds', 36 | }; 37 | 38 | export const WEBHOOK_TIMEOUT_MS = 500; 39 | export const NOTIFICATION_TIMEOUT_MS = 10; 40 | export const V3_BLOCK_BUFFER = 4; 41 | 42 | export const RPC_HEADERS = { 43 | 'x-uni-service-id': 'x_parameterization_api', 44 | } as const -------------------------------------------------------------------------------- /lib/entities/V3HardQuoteResponse.ts: -------------------------------------------------------------------------------- 1 | import { CosignedV3DutchOrder } from "@uniswap/uniswapx-sdk"; 2 | import { HardQuoteResponse } from "./HardQuoteResponse"; 3 | 4 | export class V3HardQuoteResponse extends HardQuoteResponse { 5 | public toLog() { 6 | return { 7 | quoteId: this.quoteId, 8 | requestId: this.requestId, 9 | tokenInChainId: this.chainId, 10 | tokenOutChainId: this.chainId, 11 | tokenIn: this.tokenIn, 12 | input: this.input, 13 | tokenOut: this.tokenOut, 14 | outputs: this.outputs, 15 | swapper: this.swapper, 16 | filler: this.filler, 17 | orderHash: this.order.hash(), 18 | createdAt: this.createdAt, 19 | createdAtMs: this.createdAtMs, 20 | }; 21 | } 22 | 23 | get input() { 24 | const input = this.order.info.input; 25 | const relativeAmounts = input.curve.relativeAmounts.map((amount) => amount.toString()); 26 | 27 | return { 28 | ...input, 29 | curve: { 30 | ...input.curve, 31 | relativeAmounts, 32 | }, 33 | } 34 | } 35 | 36 | get outputs() { 37 | const processedOutputs = this.order.info.outputs.map((output) => { 38 | return { 39 | ...output, 40 | curve: { 41 | ...output.curve, 42 | relativeAmounts: output.curve.relativeAmounts.map((amount) => amount.toString()), 43 | } 44 | } 45 | }); 46 | return processedOutputs; 47 | } 48 | } -------------------------------------------------------------------------------- /.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 | - uses: bullfrogsec/bullfrog@dcde5841b19b7ef693224207a7fdec67fce604db # v0.8.3 16 | with: 17 | # List of IPs to allow outbound connections to. 18 | # By default, only localhost and IPs required for the essential operations of Github Actions are allowed. 19 | # allowed-ips: | 20 | 21 | # List of domains to allow outbound connections to. 22 | # Wildcards are accepted. For example, if allowing `*.google.com`, this will allow `www.google.com`, `console.cloud.google.com` but not `google.com`. 23 | # By default, only domains required for essential operations of Github Actions and uploading job summaries are allowed. 24 | # Refer to https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#communication-requirements-for-github-hosted-runners-and-github for additional domains that should be allowed for additional Github Actions features. 25 | #allowed-domains: 26 | 27 | # The egress policy to enforce. Valid values are `audit` and `block`. 28 | # Default: audit 29 | egress-policy: audit 30 | - name: Check out Git repository 31 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 32 | 33 | - name: Set up node 34 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 35 | with: 36 | node-version: 20.x 37 | registry-url: https://registry.npmjs.org 38 | 39 | - name: Install dependencies 40 | run: yarn install --frozen-lockfile 41 | 42 | - name: Run linters 43 | run: yarn lint 44 | -------------------------------------------------------------------------------- /lib/entities/HardQuoteResponse.ts: -------------------------------------------------------------------------------- 1 | import { CosignedV2DutchOrder, CosignedV3DutchOrder } from '@uniswap/uniswapx-sdk'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | import { HardQuoteRequest } from '.'; 5 | import { HardQuoteResponseData } from '../handlers/hard-quote/schema'; 6 | import { currentTimestampInMs, timestampInMstoSeconds } from '../util/time'; 7 | 8 | // data class for hard quote response helpers and conversions 9 | export abstract class HardQuoteResponse { 10 | public createdAt: string; 11 | 12 | constructor( 13 | public request: HardQuoteRequest, 14 | public order: T, 15 | public createdAtMs = currentTimestampInMs() 16 | ) { 17 | this.createdAt = timestampInMstoSeconds(parseInt(this.createdAtMs)); 18 | } 19 | 20 | public toResponseJSON(): HardQuoteResponseData { 21 | return { 22 | requestId: this.request.requestId, 23 | quoteId: this.request.quoteId, 24 | chainId: this.request.tokenInChainId, 25 | filler: this.order.info.cosignerData.exclusiveFiller, 26 | encodedOrder: this.order.serialize(), 27 | orderHash: this.order.hash(), 28 | }; 29 | } 30 | 31 | public abstract toLog(): any; 32 | 33 | public get quoteId(): string { 34 | return this.request.quoteId ?? uuidv4(); 35 | } 36 | 37 | public get requestId(): string { 38 | return this.request.requestId; 39 | } 40 | 41 | public get chainId(): number { 42 | return this.order.chainId; 43 | } 44 | 45 | public get swapper(): string { 46 | return this.request.swapper; 47 | } 48 | 49 | public get tokenIn(): string { 50 | return this.request.tokenIn; 51 | } 52 | 53 | public get tokenOut(): string { 54 | return this.request.tokenOut; 55 | } 56 | 57 | public get filler(): string | undefined { 58 | return this.order.info.cosignerData.exclusiveFiller; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "vpc-provider:account=797568089812:filter.isDefault=true:region=us-east-2:returnAsymmetricSubnets=true": { 3 | "vpcId": "vpc-0f01e0476ed6ac08f", 4 | "vpcCidrBlock": "172.31.0.0/16", 5 | "availabilityZones": [], 6 | "subnetGroups": [ 7 | { 8 | "name": "Public", 9 | "type": "Public", 10 | "subnets": [ 11 | { 12 | "subnetId": "subnet-0aa43a27eff586263", 13 | "cidr": "172.31.0.0/20", 14 | "availabilityZone": "us-east-2a", 15 | "routeTableId": "rtb-0dd17bd7583924b5e" 16 | }, 17 | { 18 | "subnetId": "subnet-00bacbf7af79c0ee6", 19 | "cidr": "172.31.16.0/20", 20 | "availabilityZone": "us-east-2b", 21 | "routeTableId": "rtb-0dd17bd7583924b5e" 22 | }, 23 | { 24 | "subnetId": "subnet-0a2f376fe1b43adba", 25 | "cidr": "172.31.32.0/20", 26 | "availabilityZone": "us-east-2c", 27 | "routeTableId": "rtb-0dd17bd7583924b5e" 28 | } 29 | ] 30 | } 31 | ] 32 | }, 33 | "availability-zones:account=797568089812:region=us-east-2": [ 34 | "us-east-2a", 35 | "us-east-2b", 36 | "us-east-2c" 37 | ], 38 | "availability-zones:account=644039819003:region=us-east-2": [ 39 | "us-east-2a", 40 | "us-east-2b", 41 | "us-east-2c" 42 | ], 43 | "availability-zones:account=801328487475:region=us-east-2": [ 44 | "us-east-2a", 45 | "us-east-2b", 46 | "us-east-2c" 47 | ], 48 | "availability-zones:account=830217277613:region=us-east-2": [ 49 | "us-east-2a", 50 | "us-east-2b", 51 | "us-east-2c" 52 | ], 53 | "availability-zones:account=032111050613:region=us-east-2": [ 54 | "us-east-2a", 55 | "us-east-2b", 56 | "us-east-2c" 57 | ], 58 | "acknowledged-issue-numbers": [ 59 | 32775, 60 | 32775, 61 | 34892 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.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: Lint and test 12 | 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | node: ['20.x'] 17 | os: [ubuntu-latest] 18 | 19 | steps: 20 | - uses: bullfrogsec/bullfrog@dcde5841b19b7ef693224207a7fdec67fce604db # v0.8.3 21 | with: 22 | # List of IPs to allow outbound connections to. 23 | # By default, only localhost and IPs required for the essential operations of Github Actions are allowed. 24 | # allowed-ips: | 25 | 26 | # List of domains to allow outbound connections to. 27 | # Wildcards are accepted. For example, if allowing `*.google.com`, this will allow `www.google.com`, `console.cloud.google.com` but not `google.com`. 28 | # By default, only domains required for essential operations of Github Actions and uploading job summaries are allowed. 29 | # Refer to https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#communication-requirements-for-github-hosted-runners-and-github for additional domains that should be allowed for additional Github Actions features. 30 | #allowed-domains: 31 | 32 | # The egress policy to enforce. Valid values are `audit` and `block`. 33 | # Default: audit 34 | egress-policy: audit 35 | - name: Checkout repo 36 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 37 | 38 | - name: Use Node ${{ matrix.node }} 39 | uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 40 | with: 41 | node-version: ${{ matrix.node }} 42 | 43 | - name: Install 44 | run: yarn install 45 | 46 | - name: Build 47 | run: yarn build 48 | 49 | - name: Lint 50 | run: yarn lint 51 | 52 | - name: Test 53 | run: yarn test:unit 54 | -------------------------------------------------------------------------------- /bin/stacks/firehose-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as aws_iam from 'aws-cdk-lib/aws-iam'; 3 | import * as aws_firehose from 'aws-cdk-lib/aws-kinesisfirehose'; 4 | import * as aws_s3 from 'aws-cdk-lib/aws-s3'; 5 | import { Construct } from 'constructs'; 6 | 7 | /** 8 | * FirehoseStack 9 | * Sets up a single Firehose delivery stream that can be reused by all handlers to 10 | * log analytics events to the same destination S3 bucket as GZIP compressed newline JSON. 11 | * This format is optimized for loading into BigQuery. 12 | */ 13 | 14 | export class FirehoseStack extends cdk.NestedStack { 15 | public readonly analyticsStreamArn: string; 16 | 17 | constructor(scope: Construct, id: string) { 18 | super(scope, id); 19 | 20 | /* S3 Initialization */ 21 | const analyticsEventsBucket = new aws_s3.Bucket(this, 'AnalyticsEventsBucket'); 22 | const bqLoadRole = aws_iam.Role.fromRoleArn(this, 'BqLoadRole', 'arn:aws:iam::867401673276:user/bq-load-sa'); 23 | analyticsEventsBucket.grantRead(bqLoadRole); 24 | 25 | /* Kinesis Firehose Initialization */ 26 | const firehoseRole = new aws_iam.Role(this, 'FirehoseRole', { 27 | assumedBy: new aws_iam.ServicePrincipal('firehose.amazonaws.com'), 28 | managedPolicies: [aws_iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], 29 | }); 30 | analyticsEventsBucket.grantReadWrite(firehoseRole); 31 | 32 | // CDK doesn't have this implemented yet, so have to use the CloudFormation resource (lower level of abstraction) 33 | // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kinesisfirehose-deliverystream.html 34 | 35 | const analyticsEventsStream = new aws_firehose.CfnDeliveryStream(this, 'AnalyticsEventsStream', { 36 | s3DestinationConfiguration: { 37 | bucketArn: analyticsEventsBucket.bucketArn, 38 | roleArn: firehoseRole.roleArn, 39 | compressionFormat: 'GZIP', 40 | prefix: 'events/', 41 | } 42 | }); 43 | this.analyticsStreamArn = analyticsEventsStream.attrArn; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /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 NoQuotesAvailable extends CustomError { 14 | private static MESSAGE = 'No quotes available'; 15 | 16 | constructor() { 17 | super(NoQuotesAvailable.MESSAGE); 18 | // Set the prototype explicitly. 19 | Object.setPrototypeOf(this, NoQuotesAvailable.prototype); 20 | } 21 | 22 | toJSON(id?: string): APIGatewayProxyResult { 23 | return { 24 | statusCode: 404, 25 | body: JSON.stringify({ 26 | errorCode: ErrorCode.QuoteError, 27 | detail: this.message, 28 | id, 29 | }), 30 | }; 31 | } 32 | } 33 | 34 | export class OrderPostError extends CustomError { 35 | private static MESSAGE = 'Error posting order'; 36 | 37 | constructor(message?: string) { 38 | super(message ?? OrderPostError.MESSAGE); 39 | // Set the prototype explicitly. 40 | Object.setPrototypeOf(this, OrderPostError.prototype); 41 | } 42 | 43 | toJSON(id?: string): APIGatewayProxyResult { 44 | return { 45 | statusCode: 400, 46 | body: JSON.stringify({ 47 | errorCode: ErrorCode.QuoteError, 48 | detail: this.message, 49 | id, 50 | }), 51 | }; 52 | } 53 | } 54 | 55 | export class UnknownOrderCosignerError extends CustomError { 56 | private static MESSAGE = 'Unknown cosigner'; 57 | 58 | constructor() { 59 | super(UnknownOrderCosignerError.MESSAGE); 60 | // Set the prototype explicitly. 61 | Object.setPrototypeOf(this, UnknownOrderCosignerError.prototype); 62 | } 63 | 64 | toJSON(id?: string): APIGatewayProxyResult { 65 | return { 66 | statusCode: 400, 67 | body: JSON.stringify({ 68 | errorCode: ErrorCode.QuoteError, 69 | detail: this.message, 70 | id, 71 | }), 72 | }; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/handlers/synth-switch/handler.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | import { ErrorCode } from '../../util/errors'; 4 | import { APIGLambdaHandler } from '../base'; 5 | import { APIHandleRequestParams, ErrorResponse, Response } from '../base/api-handler'; 6 | import { ContainerInjected, RequestInjected } from './injector'; 7 | import { 8 | SynthSwitchQueryParams, 9 | SynthSwitchQueryParamsJoi, 10 | SynthSwitchResponse, 11 | SynthSwitchResponseJoi, 12 | } from './schema'; 13 | 14 | const SYNTHETIC_SWITCH_DISABLED = true; 15 | 16 | export class SwitchHandler extends APIGLambdaHandler< 17 | ContainerInjected, 18 | RequestInjected, 19 | void, 20 | SynthSwitchQueryParams, 21 | SynthSwitchResponse 22 | > { 23 | public async handleRequest( 24 | params: APIHandleRequestParams 25 | ): Promise> { 26 | const { 27 | requestInjected: { log, tokenIn, tokenOut, tokenInChainId, tokenOutChainId, amount, type }, 28 | containerInjected: { dbInterface }, 29 | } = params; 30 | 31 | if (SYNTHETIC_SWITCH_DISABLED) 32 | return { 33 | statusCode: 200, 34 | body: { enabled: false }, 35 | }; 36 | 37 | let enabled: boolean; 38 | try { 39 | enabled = await dbInterface.syntheticQuoteForTradeEnabled({ 40 | tokenIn, 41 | tokenInChainId, 42 | tokenOut, 43 | tokenOutChainId, 44 | type, 45 | amount, 46 | }); 47 | return { 48 | statusCode: 200, 49 | body: { enabled }, 50 | }; 51 | } catch (e) { 52 | log.error({ err: e }, 'error querying synthSwitch dynamo table'); 53 | return { 54 | statusCode: 500, 55 | errorCode: ErrorCode.InternalError, 56 | detail: 'DynamoDB Error', 57 | }; 58 | } 59 | } 60 | 61 | protected requestBodySchema(): Joi.ObjectSchema | null { 62 | return null; 63 | } 64 | 65 | protected requestQueryParamsSchema(): Joi.ObjectSchema | null { 66 | return SynthSwitchQueryParamsJoi; 67 | } 68 | 69 | protected responseBodySchema(): Joi.ObjectSchema | null { 70 | return SynthSwitchResponseJoi; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/providers/analytics/firehose.test.ts: -------------------------------------------------------------------------------- 1 | import { FirehoseClient } from '@aws-sdk/client-firehose'; 2 | 3 | import { AnalyticsEvent, AnalyticsEventType } from '../../../lib/entities/analytics-events'; 4 | import { FirehoseLogger } from '../../../lib/providers/analytics'; 5 | 6 | const mockedFirehose = FirehoseClient as jest.Mocked; 7 | 8 | const logger = { error: jest.fn() } as any; 9 | 10 | describe('FirehoseLogger', () => { 11 | const invalidStreamArn = 'dummy-stream'; 12 | const validStreamArn = 'arn:aws:firehose:region:account-id:deliverystream/stream-name'; 13 | 14 | afterEach(() => { 15 | jest.clearAllMocks(); 16 | }); 17 | 18 | it('logs an error with an invalid streamArn constructor arg', async () => { 19 | new FirehoseLogger(logger, invalidStreamArn); 20 | expect(logger.error).toHaveBeenCalledWith( 21 | { streamArn: invalidStreamArn }, 22 | expect.stringContaining(`Firehose client error parsing stream from ${invalidStreamArn}.`) 23 | ); 24 | }); 25 | 26 | it('initializes Firehose client with the correct stream name', async () => { 27 | const firehose = new FirehoseLogger(logger, validStreamArn); 28 | expect(logger.error).not.toHaveBeenCalledWith( 29 | { streamArn: validStreamArn }, 30 | expect.stringContaining(`Firehose client error parsing stream from ${validStreamArn}.`) 31 | ); 32 | expect(firehose).toBeInstanceOf(FirehoseLogger); 33 | expect(firehose['streamName']).toBe('stream-name'); 34 | }); 35 | 36 | it('should send analytics event to Firehose', async () => { 37 | const firehose = new FirehoseLogger(logger, validStreamArn); 38 | const analyticsEvent = new AnalyticsEvent(AnalyticsEventType.WEBHOOK_RESPONSE, { status: 200 }); 39 | 40 | const putRecordMock = jest.fn(); 41 | mockedFirehose.prototype.send = putRecordMock; 42 | 43 | await firehose.sendAnalyticsEvent(analyticsEvent); 44 | 45 | const input = { 46 | DeliveryStreamName: 'stream-name', 47 | Record: { 48 | Data: Buffer.from(JSON.stringify(analyticsEvent) + '\n'), 49 | }, 50 | }; 51 | 52 | expect(putRecordMock).toHaveBeenCalledWith( 53 | expect.objectContaining({ 54 | input: input, 55 | }) 56 | ); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/util/axios.ts: -------------------------------------------------------------------------------- 1 | import axiosStatic from 'axios'; 2 | import axiosRetry from 'axios-retry'; 3 | import { expect } from 'chai'; 4 | 5 | export default class AxiosUtils { 6 | static buildAxiosOption(method: string, url: string, body: any, headers?: Record) { 7 | const option: any = { 8 | method: method, 9 | url: url, 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | ...headers, 13 | }, 14 | }; 15 | if (body) { 16 | option['data'] = body; 17 | } 18 | return option; 19 | } 20 | 21 | static async call(method: string, url: string, body: any, headers?: Record) { 22 | const axios = axiosStatic.create(); 23 | 24 | axiosRetry(axios, { 25 | retries: 2, 26 | retryCondition: (err) => err.response?.status == 429, 27 | retryDelay: axiosRetry.exponentialDelay, 28 | }); 29 | 30 | const option = AxiosUtils.buildAxiosOption(method, url, body, headers); 31 | const { data, status } = await axios(option); 32 | 33 | return { 34 | data, 35 | status, 36 | }; 37 | } 38 | 39 | static async callPassThroughFail(method: string, url: string, body: any, headers?: Record) { 40 | const axios = axiosStatic.create(); 41 | 42 | axiosRetry(axios, { 43 | retries: 2, 44 | retryCondition: (err) => err.response?.status == 429, 45 | retryDelay: axiosRetry.exponentialDelay, 46 | }); 47 | 48 | const option = AxiosUtils.buildAxiosOption(method, url, body, headers); 49 | try { 50 | const { data, status } = await axios(option); 51 | return { 52 | data, 53 | status, 54 | }; 55 | } catch (err: any) { 56 | if (err.response) { 57 | return { 58 | data: err.response.data, 59 | status: err.response.status, 60 | }; 61 | } 62 | throw err; 63 | } 64 | } 65 | 66 | static async callAndExpectFail( 67 | method: string, 68 | url: string, 69 | body: any, 70 | resp: { status: number; data: any }, 71 | headers?: Record 72 | ) { 73 | try { 74 | await AxiosUtils.call(method, url, body, headers); 75 | fail(); 76 | } catch (err: any) { 77 | expect(err.response).to.containSubset(resp); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/trufflehog.yml: -------------------------------------------------------------------------------- 1 | name: Trufflehog 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | - reopened 12 | 13 | permissions: 14 | contents: read 15 | id-token: write 16 | issues: write 17 | pull-requests: write 18 | 19 | jobs: 20 | TruffleHog: 21 | runs-on: ubuntu-latest 22 | defaults: 23 | run: 24 | shell: bash 25 | steps: 26 | - uses: bullfrogsec/bullfrog@dcde5841b19b7ef693224207a7fdec67fce604db # v0.8.3 27 | with: 28 | # List of IPs to allow outbound connections to. 29 | # By default, only localhost and IPs required for the essential operations of Github Actions are allowed. 30 | # allowed-ips: | 31 | 32 | # List of domains to allow outbound connections to. 33 | # Wildcards are accepted. For example, if allowing `*.google.com`, this will allow `www.google.com`, `console.cloud.google.com` but not `google.com`. 34 | # By default, only domains required for essential operations of Github Actions and uploading job summaries are allowed. 35 | # Refer to https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#communication-requirements-for-github-hosted-runners-and-github for additional domains that should be allowed for additional Github Actions features. 36 | #allowed-domains: 37 | 38 | # The egress policy to enforce. Valid values are `audit` and `block`. 39 | # Default: audit 40 | egress-policy: audit 41 | - name: Checkout code 42 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # f43a0e5ff2bd294095638e18286ca9a3d1956744 43 | with: 44 | fetch-depth: 0 45 | 46 | - name: TruffleHog OSS 47 | id: trufflehog 48 | uses: trufflesecurity/trufflehog@b0fd951652a50ffb1911073f0bfb6a8ade7afc37 # b0fd951652a50ffb1911073f0bfb6a8ade7afc37 49 | continue-on-error: true 50 | with: 51 | path: ./ 52 | base: "${{ github.event.repository.default_branch }}" 53 | head: HEAD 54 | extra_args: --debug --only-verified 55 | 56 | - name: Scan Results Status 57 | if: steps.trufflehog.outcome == 'failure' 58 | run: exit 1 59 | -------------------------------------------------------------------------------- /lib/util/fieldValidator.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, ethers } from 'ethers'; 2 | import Joi, { CustomHelpers } from 'joi'; 3 | 4 | import { SUPPORTED_CHAINS } from '../config/chains'; 5 | import { ProtocolVersion } from '../providers'; 6 | 7 | export class FieldValidator { 8 | public static readonly address = Joi.string().custom((value: string, helpers: CustomHelpers) => { 9 | if (!ethers.utils.isAddress(value)) { 10 | return helpers.message({ custom: 'Invalid address' }); 11 | } 12 | return ethers.utils.getAddress(value); 13 | }); 14 | 15 | public static readonly orderHash = Joi.string().regex(this.getHexadecimalRegex(64)); 16 | 17 | public static readonly amount = Joi.string().custom((value: string, helpers: CustomHelpers) => { 18 | try { 19 | const result = BigNumber.from(value); 20 | if (result.lt(0)) { 21 | return helpers.message({ custom: 'Invalid amount' }); 22 | } 23 | } catch { 24 | // bignumber error is a little ugly for API response so rethrow our own 25 | return helpers.message({ custom: 'Invalid amount' }); 26 | } 27 | return value; 28 | }); 29 | 30 | public static readonly chainId = Joi.number() 31 | .integer() 32 | .valid(...SUPPORTED_CHAINS); 33 | 34 | public static readonly requestId = Joi.string().guid({ version: 'uuidv4' }); 35 | 36 | public static readonly tradeType = Joi.string().valid('EXACT_INPUT', 'EXACT_OUTPUT'); 37 | 38 | public static readonly uuid = Joi.string().guid({ version: 'uuidv4' }); 39 | 40 | public static readonly protocol = Joi.string().valid(...Object.values(ProtocolVersion)); 41 | 42 | // A Raw Signature is a common Signature format where the r, s and v 43 | // are concatenated into a 65 byte(130 nibble) DataHexString 44 | public static readonly rawSignature = Joi.string().custom((value: string, helpers: CustomHelpers) => { 45 | if (!ethers.utils.isHexString(value, 65) && !ethers.utils.isHexString(value, 64)) { 46 | return helpers.message({ custom: 'Signature in wrong format' }); 47 | } 48 | return value; 49 | }); 50 | 51 | private static getHexadecimalRegex(length?: number, maxLength = false): RegExp { 52 | let lengthModifier = '*'; 53 | if (length) { 54 | lengthModifier = maxLength ? `{0,${length}}` : `{${length}}`; 55 | } 56 | return new RegExp(`^0x[0-9,a-z,A-Z]${lengthModifier}$`); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/entities/QuoteRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { ethers } from 'ethers'; 3 | 4 | import { QuoteRequest } from '../../lib/entities'; 5 | import { ProtocolVersion } from '../../lib/providers'; 6 | 7 | const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; 8 | const SWAPPER = '0x0000000000000000000000000000000000000000'; 9 | const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; 10 | const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 11 | const CHAIN_ID = 1; 12 | 13 | describe('QuoteRequest', () => { 14 | afterEach(() => { 15 | jest.clearAllMocks(); 16 | }); 17 | 18 | const request = new QuoteRequest({ 19 | tokenInChainId: CHAIN_ID, 20 | tokenOutChainId: CHAIN_ID, 21 | requestId: REQUEST_ID, 22 | swapper: SWAPPER, 23 | tokenIn: TOKEN_IN, 24 | tokenOut: TOKEN_OUT, 25 | amount: ethers.utils.parseEther('1'), 26 | type: TradeType.EXACT_INPUT, 27 | numOutputs: 1, 28 | protocol: ProtocolVersion.V1, 29 | }); 30 | 31 | it('toCleanJSON', async () => { 32 | expect(request.toCleanJSON()).toEqual({ 33 | tokenInChainId: CHAIN_ID, 34 | tokenOutChainId: CHAIN_ID, 35 | requestId: REQUEST_ID, 36 | tokenIn: TOKEN_IN, 37 | tokenOut: TOKEN_OUT, 38 | amount: ethers.utils.parseEther('1').toString(), 39 | swapper: ethers.constants.AddressZero, 40 | type: 'EXACT_INPUT', 41 | numOutputs: 1, 42 | protocol: ProtocolVersion.V1, 43 | }); 44 | }); 45 | 46 | it('toOpposingCleanJSON', async () => { 47 | expect(request.toOpposingCleanJSON()).toEqual({ 48 | tokenInChainId: CHAIN_ID, 49 | tokenOutChainId: CHAIN_ID, 50 | requestId: REQUEST_ID, 51 | tokenIn: TOKEN_OUT, 52 | tokenOut: TOKEN_IN, 53 | amount: ethers.utils.parseEther('1').toString(), 54 | swapper: ethers.constants.AddressZero, 55 | type: 'EXACT_OUTPUT', 56 | numOutputs: 1, 57 | protocol: ProtocolVersion.V1, 58 | }); 59 | }); 60 | 61 | it('toOpposingRequest', async () => { 62 | const opposingRequest = request.toOpposingRequest(); 63 | expect(opposingRequest.toCleanJSON()).toEqual({ 64 | tokenInChainId: CHAIN_ID, 65 | tokenOutChainId: CHAIN_ID, 66 | requestId: REQUEST_ID, 67 | tokenIn: TOKEN_OUT, 68 | tokenOut: TOKEN_IN, 69 | amount: ethers.utils.parseEther('1').toString(), 70 | swapper: SWAPPER, 71 | type: 'EXACT_OUTPUT', 72 | numOutputs: 1, 73 | protocol: ProtocolVersion.V1, 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/providers/circuit-breaker/cb-provider.test.ts: -------------------------------------------------------------------------------- 1 | import { FillerTimestamps } from '../../../lib/cron/fade-rate-v2'; 2 | import { MockV2CircuitBreakerConfigurationProvider } from '../../../lib/providers/circuit-breaker/mock'; 3 | 4 | const FILLERS = ['filler1', 'filler2', 'filler3', 'filler4', 'filler5']; 5 | const now = Math.floor(Date.now() / 1000); 6 | const FILLER_TIMESTAMPS: FillerTimestamps = new Map([ 7 | ['filler1', { lastPostTimestamp: now - 150, blockUntilTimestamp: NaN, consecutiveBlocks: NaN }], 8 | ['filler2', { lastPostTimestamp: now - 75, blockUntilTimestamp: now - 50, consecutiveBlocks: 0 }], 9 | ['filler3', { lastPostTimestamp: now - 101, blockUntilTimestamp: now + 1000, consecutiveBlocks: 0 }], 10 | ['filler4', { lastPostTimestamp: now - 150, blockUntilTimestamp: NaN, consecutiveBlocks: 0 }], 11 | ['filler5', { lastPostTimestamp: now - 150, blockUntilTimestamp: now + 100, consecutiveBlocks: 1 }], 12 | ]); 13 | 14 | const WEBHOOK_CONFIGS = [ 15 | { 16 | name: 'f1', 17 | endpoint: 'filler1', 18 | hash: '0xfiller1', 19 | }, 20 | { 21 | name: 'f2', 22 | endpoint: 'filler2', 23 | hash: '0xfiller2', 24 | }, 25 | { 26 | name: 'f3', 27 | endpoint: 'filler3', 28 | hash: '0xfiller3', 29 | }, 30 | { 31 | name: 'f4', 32 | endpoint: 'filler4', 33 | hash: '0xfiller4', 34 | }, 35 | { 36 | name: 'f5', 37 | endpoint: 'filler5', 38 | hash: '0xfiller5', 39 | }, 40 | ]; 41 | 42 | describe('V2CircuitBreakerProvider', () => { 43 | const provider = new MockV2CircuitBreakerConfigurationProvider(FILLERS, FILLER_TIMESTAMPS); 44 | 45 | it('returns eligible endpoints', async () => { 46 | expect(await provider.getEndpointStatuses(WEBHOOK_CONFIGS)).toEqual({ 47 | enabled: [ 48 | { 49 | name: 'f1', 50 | endpoint: 'filler1', 51 | hash: '0xfiller1', 52 | }, 53 | { 54 | name: 'f2', 55 | endpoint: 'filler2', 56 | hash: '0xfiller2', 57 | }, 58 | { 59 | name: 'f4', 60 | endpoint: 'filler4', 61 | hash: '0xfiller4', 62 | }, 63 | ], 64 | disabled: [ 65 | { 66 | webhook: { 67 | name: 'f3', 68 | endpoint: 'filler3', 69 | hash: '0xfiller3', 70 | }, 71 | blockUntil: now + 1000, 72 | }, 73 | { 74 | webhook: { 75 | name: 'f5', 76 | endpoint: 'filler5', 77 | hash: '0xfiller5', 78 | }, 79 | blockUntil: now + 100, 80 | }, 81 | ], 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /lib/providers/order/uniswapxService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import Logger from 'bunyan'; 3 | 4 | import { OrderServiceProvider, PostOrderArgs, UniswapXServiceResponse } from '.'; 5 | import { ErrorResponse } from '../../handlers/base'; 6 | import { ErrorCode } from '../../util/errors'; 7 | import { CosignedV2DutchOrder, CosignedV3DutchOrder, OrderType } from '@uniswap/uniswapx-sdk'; 8 | 9 | const ORDER_SERVICE_TIMEOUT_MS = 2000; 10 | const ORDER_TYPE_MAP = new Map([ 11 | [CosignedV2DutchOrder, OrderType.Dutch_V2], 12 | [CosignedV3DutchOrder, OrderType.Dutch_V3] 13 | ]); 14 | 15 | export class UniswapXServiceProvider implements OrderServiceProvider { 16 | private log: Logger; 17 | 18 | constructor(_log: Logger, private uniswapxServiceUrl: string) { 19 | this.log = _log.child({ quoter: 'UniswapXOrderService' }); 20 | } 21 | 22 | async postOrder(args: PostOrderArgs): Promise { 23 | const { order, signature, quoteId, requestId } = args; 24 | this.log.info({ orderHash: order.hash() }, 'Posting order to UniswapX Service'); 25 | 26 | const orderType = ORDER_TYPE_MAP.get(order.constructor); 27 | if (!orderType) { 28 | throw new Error(`Unsupported order type: ${order.constructor.name}`); 29 | } 30 | 31 | const axiosConfig = { 32 | timeout: ORDER_SERVICE_TIMEOUT_MS, 33 | }; 34 | try { 35 | const response = await axios.post( 36 | `${this.uniswapxServiceUrl}dutch-auction/order`, 37 | { 38 | encodedOrder: order.serialize(), 39 | signature: signature, 40 | chainId: order.chainId, 41 | quoteId: quoteId, 42 | requestId: requestId, 43 | orderType: orderType, 44 | }, 45 | axiosConfig 46 | ); 47 | this.log.info({ response: response, orderHash: order.hash() }, 'Order posted to UniswapX Service'); 48 | return { 49 | statusCode: response.status, 50 | data: response.data, 51 | }; 52 | } catch (e) { 53 | if (e instanceof AxiosError) { 54 | this.log.error({ error: e.response?.data }, 'Error posting order to UniswapX Service'); 55 | return { 56 | statusCode: e.response?.data.statusCode, 57 | errorCode: e.response?.data.errorCode, 58 | detail: e.response?.data.detail, 59 | }; 60 | } else { 61 | this.log.error({ error: e }, 'Unknown error posting order to UniswapX Service'); 62 | return { 63 | statusCode: 500, 64 | errorCode: ErrorCode.InternalError, 65 | detail: 'Unknown Error posting to UniswapX Service', 66 | }; 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/repositories/switch-repository.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 4 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 5 | 6 | import { SynthSwitchQueryParams } from '../../lib/handlers/synth-switch'; 7 | import { SwitchRepository } from '../../lib/repositories/switch-repository'; 8 | import { DYNAMO_CONFIG } from './shared'; 9 | 10 | const SWITCH: SynthSwitchQueryParams = { 11 | tokenIn: 'USDC', 12 | tokenOut: 'UNI', 13 | tokenInChainId: 1, 14 | tokenOutChainId: 1, 15 | amount: '10000000000', 16 | type: 'EXACT_INPUT', 17 | }; 18 | 19 | const NONEXISTENT_SWITCH: SynthSwitchQueryParams = { 20 | tokenIn: 'USDC', 21 | tokenOut: 'UNI', 22 | tokenInChainId: 1, 23 | tokenOutChainId: 1, 24 | amount: '1000000000000000000', 25 | type: 'EXACT_OUTPUT', 26 | }; 27 | 28 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient(DYNAMO_CONFIG), { 29 | marshallOptions: { 30 | convertEmptyValues: true, 31 | }, 32 | unmarshallOptions: { 33 | wrapNumbers: true, 34 | }, 35 | }); 36 | 37 | const switchRepository = SwitchRepository.create(documentClient); 38 | 39 | describe('put switch tests', () => { 40 | it('should put synth switch into db and overwrites previous one if exists', async () => { 41 | await expect(switchRepository.putSynthSwitch(SWITCH, '10000', true)).resolves.not.toThrow(); 42 | 43 | let enabled = await switchRepository.syntheticQuoteForTradeEnabled(SWITCH); 44 | expect(enabled).toBe(true); 45 | 46 | await switchRepository.putSynthSwitch(SWITCH, '1000000000', false); 47 | 48 | enabled = await switchRepository.syntheticQuoteForTradeEnabled(SWITCH); 49 | expect(enabled).toBe(false); 50 | }); 51 | 52 | it('should not return true if amount is not greater than lower', async () => { 53 | await switchRepository.putSynthSwitch(SWITCH, '1000000000000000000', true); 54 | 55 | const enabled = await switchRepository.syntheticQuoteForTradeEnabled(SWITCH); 56 | expect(enabled).toBe(false); 57 | }); 58 | 59 | it('should return false for non-existent switch', async () => { 60 | await expect(switchRepository.syntheticQuoteForTradeEnabled(NONEXISTENT_SWITCH)).resolves.toBe(false); 61 | }); 62 | }); 63 | 64 | describe('static helper function tests', () => { 65 | it('correctly serializes key from trade', () => { 66 | expect(SwitchRepository.getKey(SWITCH)).toBe('usdc#1#uni#1#EXACT_INPUT'); 67 | }); 68 | 69 | it('should throw error for invalid key on parse', () => { 70 | expect(() => { 71 | // missing type 72 | SwitchRepository.parseKey('token0#1#token1#1'); 73 | }).toThrowError('Invalid key: token0#1#token1#1'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /lib/handlers/synth-switch/injector.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 3 | import { IMetric, setGlobalLogger, setGlobalMetric } from '@uniswap/smart-order-router'; 4 | import { MetricsLogger } from 'aws-embedded-metrics'; 5 | import { APIGatewayProxyEvent, Context } from 'aws-lambda'; 6 | import { default as bunyan, default as Logger } from 'bunyan'; 7 | 8 | import { AWSMetricsLogger, UniswapXParamServiceMetricDimension } from '../../entities'; 9 | import { BaseSwitchRepository } from '../../repositories/base'; 10 | import { SwitchRepository } from '../../repositories/switch-repository'; 11 | import { ApiInjector, ApiRInj } from '../base/api-handler'; 12 | import { SynthSwitchQueryParams } from './schema'; 13 | 14 | export interface ContainerInjected { 15 | dbInterface: BaseSwitchRepository; 16 | } 17 | 18 | export interface RequestInjected extends ApiRInj { 19 | _metric: IMetric; 20 | tokenIn: string; 21 | tokenOut: string; 22 | tokenInChainId: number; 23 | tokenOutChainId: number; 24 | amount: string; 25 | type: string; 26 | } 27 | 28 | export class SwitchInjector extends ApiInjector { 29 | public async buildContainerInjected(): Promise { 30 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), { 31 | marshallOptions: { 32 | convertEmptyValues: true, 33 | }, 34 | unmarshallOptions: { 35 | wrapNumbers: true, 36 | }, 37 | }); 38 | return { 39 | dbInterface: SwitchRepository.create(documentClient), 40 | }; 41 | } 42 | 43 | public async getRequestInjected( 44 | _containerInjected: ContainerInjected, 45 | requestBody: void, 46 | requestQueryParams: SynthSwitchQueryParams, 47 | _event: APIGatewayProxyEvent, 48 | context: Context, 49 | log: Logger, 50 | metricsLogger: MetricsLogger 51 | ): Promise { 52 | const requestId = context.awsRequestId; 53 | 54 | log = log.child({ 55 | serializers: bunyan.stdSerializers, 56 | requestBody, 57 | requestId, 58 | }); 59 | setGlobalLogger(log); 60 | 61 | metricsLogger.setNamespace('Uniswap'); 62 | metricsLogger.setDimensions(UniswapXParamServiceMetricDimension); 63 | const metric = new AWSMetricsLogger(metricsLogger); 64 | setGlobalMetric(metric); 65 | 66 | return { 67 | log, 68 | _metric: metric, 69 | requestId, 70 | tokenIn: requestQueryParams.tokenIn, 71 | tokenOut: requestQueryParams.tokenOut, 72 | tokenInChainId: requestQueryParams.tokenInChainId, 73 | tokenOutChainId: requestQueryParams.tokenOutChainId, 74 | amount: requestQueryParams.amount, 75 | type: requestQueryParams.type, 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/repositories/timestamp-repository.test.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 3 | 4 | import { ToUpdateTimestampRow } from '../../lib/repositories'; 5 | import { TimestampRepository } from '../../lib/repositories/timestamp-repository'; 6 | import { DYNAMO_CONFIG } from './shared'; 7 | 8 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient(DYNAMO_CONFIG), { 9 | marshallOptions: { 10 | convertEmptyValues: true, 11 | }, 12 | unmarshallOptions: { 13 | wrapNumbers: true, 14 | }, 15 | }); 16 | 17 | const repo = TimestampRepository.create(documentClient); 18 | 19 | describe('Dynamo TimestampRepo tests', () => { 20 | it('should batch put timestamps', async () => { 21 | const toUpdate: ToUpdateTimestampRow[] = [ 22 | { 23 | hash: '0x1', 24 | lastPostTimestamp: 1, 25 | blockUntilTimestamp: undefined, 26 | consecutiveBlocks: 0, 27 | }, 28 | { 29 | hash: '0x2', 30 | lastPostTimestamp: 2, 31 | blockUntilTimestamp: 5, 32 | consecutiveBlocks: 0, 33 | }, 34 | { 35 | hash: '0x3', 36 | lastPostTimestamp: 3, 37 | blockUntilTimestamp: 6, 38 | consecutiveBlocks: 1, 39 | }, 40 | ]; 41 | 42 | await expect(repo.updateTimestampsBatch(toUpdate)).resolves.not.toThrow(); 43 | 44 | let row = await repo.getFillerTimestamps('0x1'); 45 | expect(row).toBeDefined(); 46 | expect(row?.lastPostTimestamp).toBe(1); 47 | expect(row?.blockUntilTimestamp).toBe(NaN); 48 | expect(row?.consecutiveBlocks).toBe(0); 49 | 50 | row = await repo.getFillerTimestamps('0x2'); 51 | expect(row).toBeDefined(); 52 | expect(row?.lastPostTimestamp).toBe(2); 53 | expect(row?.blockUntilTimestamp).toBe(5); 54 | expect(row?.consecutiveBlocks).toBe(0); 55 | 56 | row = await repo.getFillerTimestamps('0x3'); 57 | expect(row).toBeDefined(); 58 | expect(row?.lastPostTimestamp).toBe(3); 59 | expect(row?.blockUntilTimestamp).toBe(6); 60 | expect(row?.consecutiveBlocks).toBe(1); 61 | }); 62 | 63 | it('should batch get timestamps', async () => { 64 | const res = await repo.getTimestampsBatch(['0x1', '0x2', '0x3']); 65 | expect(res.length).toBe(3); 66 | expect(res).toEqual( 67 | expect.arrayContaining([ 68 | { 69 | hash: '0x1', 70 | lastPostTimestamp: 1, 71 | blockUntilTimestamp: NaN, 72 | consecutiveBlocks: 0, 73 | }, 74 | { 75 | hash: '0x2', 76 | lastPostTimestamp: 2, 77 | blockUntilTimestamp: 5, 78 | consecutiveBlocks: 0, 79 | }, 80 | { 81 | hash: '0x3', 82 | lastPostTimestamp: 3, 83 | blockUntilTimestamp: 6, 84 | consecutiveBlocks: 1, 85 | }, 86 | ]) 87 | ); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /lib/handlers/quote/schema.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | import { ProtocolVersion } from '../../providers'; 4 | import { FieldValidator } from '../../util/fieldValidator'; 5 | 6 | export const PostQuoteRequestBodyJoi = Joi.object({ 7 | requestId: FieldValidator.requestId.required(), 8 | tokenInChainId: FieldValidator.chainId.required(), 9 | tokenOutChainId: Joi.number().integer().valid(Joi.ref('tokenInChainId')).required(), 10 | swapper: FieldValidator.address.required(), 11 | tokenIn: FieldValidator.address.required(), 12 | tokenOut: FieldValidator.address.required(), 13 | amount: FieldValidator.amount.required(), 14 | type: FieldValidator.tradeType.required(), 15 | numOutputs: Joi.number().integer().min(1).required(), 16 | protocol: FieldValidator.protocol.default(ProtocolVersion.V1), 17 | }); 18 | 19 | export type PostQuoteRequestBody = { 20 | requestId: string; 21 | tokenInChainId: number; 22 | tokenOutChainId: number; 23 | swapper: string; 24 | tokenIn: string; 25 | tokenOut: string; 26 | amount: string; 27 | type: string; 28 | numOutputs: number; 29 | protocol: ProtocolVersion; 30 | }; 31 | 32 | export const PostQuoteResponseJoi = Joi.object({ 33 | chainId: FieldValidator.chainId.required(), 34 | requestId: FieldValidator.uuid.required(), 35 | tokenIn: Joi.string().required(), 36 | amountIn: FieldValidator.amount.required(), 37 | tokenOut: Joi.string().required(), 38 | amountOut: FieldValidator.amount.required(), 39 | swapper: FieldValidator.address.optional(), 40 | filler: FieldValidator.address, 41 | quoteId: FieldValidator.uuid, 42 | }); 43 | 44 | export type PostQuoteResponse = { 45 | chainId: number; 46 | requestId: string; 47 | tokenIn: string; 48 | amountIn: string; 49 | tokenOut: string; 50 | amountOut: string; 51 | swapper: string; 52 | filler?: string; 53 | quoteId?: string; 54 | }; 55 | 56 | export const URAResponseJoi = Joi.object({ 57 | chainId: FieldValidator.chainId.required(), 58 | requestId: FieldValidator.uuid.required(), 59 | tokenIn: Joi.string().required(), 60 | amountIn: FieldValidator.amount.required(), 61 | tokenOut: Joi.string().required(), 62 | amountOut: FieldValidator.amount.required(), 63 | swapper: FieldValidator.address.required(), 64 | filler: FieldValidator.address, 65 | quoteId: FieldValidator.uuid, 66 | }); 67 | 68 | export const RfqResponseJoi = Joi.object({ 69 | chainId: FieldValidator.chainId.required(), 70 | requestId: FieldValidator.uuid.required(), 71 | tokenIn: Joi.string().required(), 72 | amountIn: FieldValidator.amount.required(), 73 | tokenOut: Joi.string().required(), 74 | amountOut: FieldValidator.amount.required(), 75 | filler: FieldValidator.address.optional(), 76 | quoteId: FieldValidator.uuid, 77 | }); 78 | 79 | export type RfqResponse = { 80 | chainId: number; 81 | requestId: string; 82 | tokenIn: string; 83 | amountIn: string; 84 | tokenOut: string; 85 | amountOut: string; 86 | quoteId: string; 87 | filler?: string; 88 | }; 89 | -------------------------------------------------------------------------------- /test/util/token-configs.test.ts: -------------------------------------------------------------------------------- 1 | import { filterResults, ResultRowType, TokenConfig, validateConfigs } from '../../lib/cron/synth-switch'; 2 | 3 | const EXAMPLE_ROW_RESULT = { 4 | tokenin: '0xa', 5 | tokenout: '0xb', 6 | tokeninchainid: 1, 7 | tokenoutchainid: 1, 8 | dutch_amountin: '0', 9 | dutch_amountout: '0', 10 | classic_amountin: '0', 11 | classic_amountout: '0', 12 | classic_amountingasadjusted: '0', 13 | classic_amountoutgasadjusted: '0', 14 | dutch_amountingasadjusted: '0', 15 | dutch_amountoutgasadjusted: '0', 16 | filler: '0', 17 | filltimestamp: '0', 18 | settledAmountIn: '0', 19 | settledAmountOut: '0', 20 | }; 21 | 22 | describe('synth-switch util tests', () => { 23 | describe('validateConfigs', () => { 24 | it('filters out bad configs', () => { 25 | const badAddresses: TokenConfig[] = [ 26 | { 27 | tokenIn: '0xdead', 28 | tokenOut: '0xbeef', 29 | tokenInChainId: 1, 30 | tokenOutChainId: 1, 31 | tradeTypes: ['EXACT_INPUT'], 32 | lowerBound: ['0'], 33 | }, 34 | ]; 35 | expect(validateConfigs(badAddresses)).toStrictEqual([]); 36 | }); 37 | }); 38 | 39 | describe('filterResults', () => { 40 | it('filters out rows that do not have matching token pairs in the configs', () => { 41 | const configs: TokenConfig[] = [ 42 | { 43 | tokenIn: '0xa', 44 | tokenOut: '0xb', 45 | tokenInChainId: 1, 46 | tokenOutChainId: 1, 47 | tradeTypes: ['EXACT_INPUT'], 48 | lowerBound: ['0'], 49 | }, 50 | { 51 | tokenIn: '0xc', 52 | tokenOut: '0xd', 53 | tokenInChainId: 1, 54 | tokenOutChainId: 1, 55 | tradeTypes: ['EXACT_INPUT'], 56 | lowerBound: ['0'], 57 | }, 58 | ]; 59 | const results: ResultRowType[] = [ 60 | { 61 | ...EXAMPLE_ROW_RESULT, 62 | tokenin: '0xa', 63 | tokenout: '0xb', 64 | }, 65 | { 66 | ...EXAMPLE_ROW_RESULT, 67 | tokenin: '0xa', 68 | tokenout: '0xc', 69 | }, 70 | { 71 | ...EXAMPLE_ROW_RESULT, 72 | tokenin: '0xb', 73 | tokenout: '0xc', 74 | }, 75 | { 76 | ...EXAMPLE_ROW_RESULT, 77 | tokenin: '0xc', 78 | tokenout: '0xd', 79 | }, 80 | ]; 81 | const filteredResults = filterResults(configs, results); 82 | // should only have 2 results, a -> b and c -> d 83 | expect(filteredResults).toHaveLength(2); 84 | expect(filteredResults[0]).toMatchObject(results[0]); 85 | expect(filteredResults[0].tokenin).toBe(configs[0].tokenIn); 86 | expect(filteredResults[0].tokenout).toBe(configs[0].tokenOut); 87 | expect(filteredResults[1]).toMatchObject(results[3]); 88 | expect(filteredResults[1].tokenin).toBe(configs[1].tokenIn); 89 | expect(filteredResults[1].tokenout).toBe(configs[1].tokenOut); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uniswapx-parametrization-api 2 | 3 | [![Unit Tests](https://github.com/Uniswap/uniswapx-parameterization-api/actions/workflows/test.yml/badge.svg)](https://github.com/Uniswap/uniswapx-parameterization-api/actions/workflows/test.yml) 4 | 5 | UniswapX Parameterization API is a service to parameterize UniswapX orders. The service fetches quotes on-demand from external providers to get a sense of the current market price for a given trade. 6 | 7 | ## Getting Started 8 | 9 | follow [api-template README](https://github.com/Uniswap/api-template#first-time-developing-on-aws-or-with-cdk) to get your AWS CDK set up and bootstrapped. 10 | 11 | To run dynamodb-related tests, you need to have Java Runtime installed (https://www.java.com/en/download/manual.jsp). 12 | 13 | ## Delopyment 14 | 15 | ### Dev Environment 16 | 17 | To deploy to your own AWS account, 18 | 19 | ``` 20 | yarn && yarn build 21 | ``` 22 | 23 | then 24 | 25 | ``` 26 | cdk deploy 27 | ``` 28 | 29 | after successful deployment, you should see something like 30 | 31 | ``` 32 | ✅ GoudaParameterizationStack 33 | 34 | ✨ Deployment time: 93.78s 35 | 36 | Outputs: 37 | GoudaParameterizationStack.GoudaParameterizationEndpoint57A27B25 = 38 | GoudaParameterizationStack.Url = 39 | ``` 40 | 41 | The project currently has a `GET hello-world` Api Gateway<>Lambda integration set up: 42 | 43 | ``` 44 | ❯ curl /prod/quote/hello-world 45 | "hello world"% 46 | ``` 47 | 48 | ## Integration Tests 49 | 50 | 1. Deploy your API using the intructions above. 51 | 52 | 1. Add your API url to your `.env` file as `UNISWAP_API` 53 | 54 | ``` 55 | UNISWAP_API='' 56 | ``` 57 | 58 | 1. Run the tests with: 59 | ``` 60 | yarn test:integ 61 | ``` 62 | 63 | ## Webhook Quoting Schema 64 | 65 | Quoters will need to abide by the following schemas in order to successfully quote UniswapX orders. 66 | 67 | ### Request 68 | 69 | This data will be included in the body of the request and will be sent to the given quote endpoint. 70 | 71 | ``` 72 | { 73 | tokenInChainId: number, 74 | tokenOutChainId: number, 75 | requestId: string, 76 | quoteId: string, 77 | tokenIn: string, 78 | tokenOut: string, 79 | amount: string, 80 | swapper: string, 81 | type: string (EXACT_INPUT or EXACT_OUTPUT), 82 | } 83 | ``` 84 | 85 | ### Response 86 | 87 | This data will be expected in the body of the quote response. 88 | 89 | _Note: if a quoter elects to not quote a swap they should still send back a response but with a zero value in the `amountIn`/`amountOut` field, depending on the trade type._ 90 | 91 | ``` 92 | { 93 | chainId: number, 94 | requestId: number, 95 | quoteId: string, 96 | tokenIn: string, 97 | amountIn: string, 98 | tokenOut: string, 99 | amountOut: string, 100 | filler: string, 101 | } 102 | ``` 103 | 104 | The `quoteId`, `requestId`, `tokenIn`, `chainId`, `tokenIn`, and `tokenOut` fields should be mirrored from the request. The `filler` address should be the address of the fill contract. 105 | -------------------------------------------------------------------------------- /test/providers/webhook/s3.test.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from '@aws-sdk/client-s3'; 2 | import { default as Logger } from 'bunyan'; 3 | 4 | import { S3WebhookConfigurationProvider, WebhookConfiguration } from '../../../lib/providers'; 5 | 6 | const mockEndpoints = [ 7 | { 8 | name: 'google', 9 | endpoint: 'https://google.com', 10 | headers: { 11 | 'x-api-key': '1234', 12 | }, 13 | addresses: ['google.com'], 14 | hash: '0xgoogle', 15 | }, 16 | { 17 | name: 'meta', 18 | endpoint: 'https://meta.com', 19 | addresses: ['facebook.com', 'meta.com'], 20 | hash: '0xmeta', 21 | }, 22 | ]; 23 | 24 | function applyMock(endpoints: WebhookConfiguration[]) { 25 | jest.spyOn(S3Client.prototype, 'send').mockImplementationOnce(() => 26 | Promise.resolve({ 27 | Body: { 28 | transformToString: () => Promise.resolve(JSON.stringify(endpoints)), 29 | }, 30 | }) 31 | ); 32 | } 33 | 34 | // silent logger in tests 35 | const logger = Logger.createLogger({ name: 'test' }); 36 | logger.level(Logger.FATAL); 37 | 38 | describe('S3WebhookConfigurationProvider', () => { 39 | const bucket = 'test-bucket'; 40 | const key = 'test-key'; 41 | 42 | afterEach(() => { 43 | jest.clearAllMocks(); 44 | }); 45 | 46 | it('Fetches endpoints', async () => { 47 | applyMock(mockEndpoints); 48 | const provider = new S3WebhookConfigurationProvider(logger, bucket, key); 49 | const endpoints = await provider.getEndpoints(); 50 | expect(endpoints).toEqual(mockEndpoints); 51 | }); 52 | 53 | it('Caches fetched endpoints', async () => { 54 | applyMock(mockEndpoints); 55 | const provider = new S3WebhookConfigurationProvider(logger, bucket, key); 56 | let endpoints = await provider.getEndpoints(); 57 | expect(endpoints).toEqual(mockEndpoints); 58 | endpoints = await provider.getEndpoints(); 59 | expect(endpoints).toEqual(mockEndpoints); 60 | }); 61 | 62 | it('Generates filler endpoint to filler map', async () => { 63 | applyMock(mockEndpoints); 64 | const provider = new S3WebhookConfigurationProvider(logger, bucket, key); 65 | const map = await provider.addressToFillerHash(); 66 | expect(map.get('google.com')).toEqual('0xgoogle'); 67 | expect(map.get('facebook.com')).toEqual('0xmeta'); 68 | expect(map.get('meta.com')).toEqual('0xmeta'); 69 | }); 70 | 71 | it('Refetches after cache expires', async () => { 72 | applyMock(mockEndpoints); 73 | const provider = new S3WebhookConfigurationProvider(logger, bucket, key); 74 | let endpoints = await provider.getEndpoints(); 75 | expect(endpoints).toEqual(mockEndpoints); 76 | 77 | const updatedEndpoints = [ 78 | { 79 | name: 'updated', 80 | endpoint: 'https://updated.com', 81 | headers: { 82 | 'x-api-key': 'updated', 83 | }, 84 | hash: '0xupdated', 85 | }, 86 | ]; 87 | 88 | applyMock(updatedEndpoints); 89 | 90 | // still original 91 | endpoints = await provider.getEndpoints(); 92 | expect(endpoints).toEqual(mockEndpoints); 93 | 94 | // now updates after date changes 95 | jest.useFakeTimers().setSystemTime(Date.now() + 1000000); 96 | endpoints = await provider.getEndpoints(); 97 | expect(endpoints).toEqual(updatedEndpoints); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /lib/providers/webhook/s3.ts: -------------------------------------------------------------------------------- 1 | import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; 2 | import { default as Logger } from 'bunyan'; 3 | 4 | import { ProtocolVersion, WebhookConfiguration, WebhookConfigurationProvider } from '.'; 5 | import { checkDefined } from '../../preconditions/preconditions'; 6 | 7 | export type FillerAddressesMap = Map>; 8 | 9 | // reads endpoint configuration from a static file 10 | export class S3WebhookConfigurationProvider implements WebhookConfigurationProvider { 11 | private log: Logger; 12 | private endpoints: WebhookConfiguration[]; 13 | private lastUpdatedEndpointsTimestamp: number; 14 | 15 | // try to refetch endpoints every 5 mins 16 | private static UPDATE_ENDPOINTS_PERIOD_MS = 5 * 60000; 17 | 18 | constructor(_log: Logger, private bucket: string, private key: string) { 19 | this.endpoints = []; 20 | this.log = _log.child({ quoter: 'S3WebhookConfigurationProvider' }); 21 | this.lastUpdatedEndpointsTimestamp = Date.now(); 22 | } 23 | 24 | fillers(): string[] { 25 | return [...new Set(this.endpoints.map((endpoint) => endpoint.hash))]; 26 | } 27 | 28 | fillerEndpoints(): string[] { 29 | return this.endpoints.map((endpoint) => endpoint.endpoint); 30 | } 31 | 32 | async addressToFillerHash(): Promise> { 33 | const map = new Map(); 34 | if (this.endpoints.length === 0) { 35 | await this.fetchEndpoints(); 36 | } 37 | this.endpoints.forEach((endpoint) => { 38 | endpoint.addresses?.forEach((address) => { 39 | this.log.info({ address, endpoint }, 'address to filler mapping'); 40 | map.set(address, endpoint.hash); 41 | }); 42 | }); 43 | return map; 44 | } 45 | 46 | async getEndpoints(): Promise { 47 | if ( 48 | this.endpoints.length === 0 || 49 | Date.now() - this.lastUpdatedEndpointsTimestamp > S3WebhookConfigurationProvider.UPDATE_ENDPOINTS_PERIOD_MS 50 | ) { 51 | await this.fetchEndpoints(); 52 | this.lastUpdatedEndpointsTimestamp = Date.now(); 53 | } 54 | return this.endpoints; 55 | } 56 | 57 | async fetchEndpoints(): Promise { 58 | const s3Client = new S3Client({}); 59 | const s3Res = await s3Client.send( 60 | new GetObjectCommand({ 61 | Bucket: this.bucket, 62 | Key: this.key, 63 | }) 64 | ); 65 | const s3Body = checkDefined(s3Res.Body, 's3Res.Body is undefined'); 66 | this.endpoints = JSON.parse(await s3Body.transformToString()) as WebhookConfiguration[]; 67 | this.log.info({ endpoints: this.endpoints }, `Fetched ${this.endpoints.length} endpoints from S3`); 68 | } 69 | 70 | /* 71 | * Returns the supported protocol versions for the filler at the given endpoint. 72 | * @param endpoint - The endpoint to check the supported protocol versions for. 73 | * @returns List of endpoint's supported protocols; defaults to v1 only 74 | * 75 | */ 76 | async getFillerSupportedProtocols(endpoint: string): Promise { 77 | if ( 78 | this.endpoints.length === 0 || 79 | Date.now() - this.lastUpdatedEndpointsTimestamp > S3WebhookConfigurationProvider.UPDATE_ENDPOINTS_PERIOD_MS 80 | ) { 81 | await this.fetchEndpoints(); 82 | this.lastUpdatedEndpointsTimestamp = Date.now(); 83 | } 84 | const config = this.endpoints.find((e) => e.endpoint === endpoint); 85 | return config?.supportedVersions ?? [ProtocolVersion.V1, ProtocolVersion.V2]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/repositories/base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DescribeStatementCommand, 3 | ExecuteStatementCommand, 4 | RedshiftDataClient, 5 | StatusString, 6 | } from '@aws-sdk/client-redshift-data'; 7 | import Logger from 'bunyan'; 8 | 9 | import { SynthSwitchQueryParams, SynthSwitchTrade } from '../handlers/synth-switch'; 10 | import { checkDefined } from '../preconditions/preconditions'; 11 | import { sleep } from '../util/time'; 12 | 13 | export * from './analytics-repository'; 14 | 15 | export type SharedConfigs = { 16 | Database: string; 17 | ClusterIdentifier: string; 18 | SecretArn: string; 19 | }; 20 | 21 | export type ExecutionConfigs = { 22 | waitTimeMs: number; 23 | }; 24 | 25 | export enum TimestampThreshold { 26 | TWO_WEEKS = "'2 WEEKS'", 27 | ONE_MONTH = "'1 MONTH'", 28 | TWO_MONTHS = "'2 MONTHS'", 29 | } 30 | 31 | export type TimestampRepoRow = { 32 | hash: string; 33 | lastPostTimestamp: number; 34 | blockUntilTimestamp: number; 35 | consecutiveBlocks: number; 36 | }; 37 | 38 | export type DynamoTimestampRepoRow = Exclude & { 39 | lastPostTimestamp: string; 40 | blockUntilTimestamp: string; 41 | consecutiveBlocks: string; 42 | }; 43 | 44 | export type ToUpdateTimestampRow = Omit & { 45 | blockUntilTimestamp?: number; 46 | }; 47 | 48 | /* 49 | fillerHash -> { lastPostTimestamp, blockUntilTimestamp } 50 | */ 51 | export type FillerTimestampMap = Map>; 52 | 53 | export abstract class BaseRedshiftRepository { 54 | constructor(readonly client: RedshiftDataClient, private readonly configs: SharedConfigs) {} 55 | 56 | async executeStatement(sql: string, log: Logger, executionConfigs?: ExecutionConfigs): Promise { 57 | const response = await this.client.send(new ExecuteStatementCommand({ ...this.configs, Sql: sql })); 58 | const stmtId = checkDefined(response.Id); 59 | 60 | for (;;) { 61 | const status = await this.client.send(new DescribeStatementCommand({ Id: stmtId })); 62 | if (status.Error) { 63 | log.error({ error: status.Error }, 'Failed to execute command'); 64 | throw new Error(status.Error); 65 | } 66 | if (status.Status === StatusString.ABORTED || status.Status === StatusString.FAILED) { 67 | log.error({ error: status.Error }, 'Failed to execute command'); 68 | throw new Error(status.Error); 69 | } else if ( 70 | status.Status === StatusString.PICKED || 71 | status.Status === StatusString.STARTED || 72 | status.Status === StatusString.SUBMITTED 73 | ) { 74 | await sleep(executionConfigs?.waitTimeMs ?? 2000); 75 | } else if (status.Status === StatusString.FINISHED) { 76 | log.info({ sql }, 'Command finished'); 77 | return stmtId; 78 | } else { 79 | log.error({ error: status.Error }, 'Unknown status'); 80 | throw new Error(status.Error); 81 | } 82 | } 83 | } 84 | } 85 | 86 | export interface BaseSwitchRepository { 87 | putSynthSwitch(trade: SynthSwitchTrade, lower: string, enabled: boolean): Promise; 88 | syntheticQuoteForTradeEnabled(trade: SynthSwitchQueryParams): Promise; 89 | } 90 | 91 | export interface BaseTimestampRepository { 92 | updateTimestampsBatch(toUpdate: ToUpdateTimestampRow[]): Promise; 93 | getFillerTimestamps(hash: string): Promise; 94 | getFillerTimestampsMap(hashes: string[]): Promise>>; 95 | getTimestampsBatch(hashes: string[]): Promise; 96 | } 97 | -------------------------------------------------------------------------------- /lib/repositories/switch-repository.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 2 | import Logger from 'bunyan'; 3 | import { Entity, Table } from 'dynamodb-toolbox'; 4 | import { BigNumber } from 'ethers'; 5 | 6 | import { DYNAMO_TABLE_KEY, DYNAMO_TABLE_NAME } from '../constants'; 7 | import { SynthSwitchQueryParams, SynthSwitchTrade } from '../handlers/synth-switch'; 8 | import { BaseSwitchRepository } from './base'; 9 | 10 | export const PARTITION_KEY = `${DYNAMO_TABLE_KEY.TOKEN_IN}#${DYNAMO_TABLE_KEY.TOKEN_IN_CHAIN_ID}#${DYNAMO_TABLE_KEY.TOKEN_OUT}#${DYNAMO_TABLE_KEY.TOKEN_OUT_CHAIN_ID}#${DYNAMO_TABLE_KEY.TRADE_TYPE}`; 11 | 12 | export class SwitchRepository implements BaseSwitchRepository { 13 | static log: Logger; 14 | 15 | static create(documentClient: DynamoDBDocumentClient): BaseSwitchRepository { 16 | this.log = Logger.createLogger({ 17 | name: 'DynamoSwitchRepository', 18 | serializers: Logger.stdSerializers, 19 | }); 20 | 21 | const switchTable = new Table({ 22 | name: DYNAMO_TABLE_NAME.SYNTHETIC_SWITCH_TABLE, 23 | partitionKey: PARTITION_KEY, 24 | DocumentClient: documentClient, 25 | }); 26 | 27 | const switchEntity = new Entity({ 28 | name: 'SynthSwitchEntity', 29 | attributes: { 30 | [PARTITION_KEY]: { partitionKey: true }, 31 | lower: { type: 'string' }, 32 | enabled: { type: 'boolean' }, 33 | }, 34 | table: switchTable, 35 | autoExecute: true, 36 | } as const); 37 | 38 | return new SwitchRepository(switchTable, switchEntity); 39 | } 40 | 41 | private constructor( 42 | // eslint-disable-next-line 43 | // @ts-expect-error 44 | private readonly _switchTable: Table< 45 | 'SyntheticSwitchTable', 46 | 'tokenIn#tokenInChainId#tokenOut#tokenOutChainId#type', 47 | 'lower' 48 | >, 49 | private readonly switchEntity: Entity 50 | ) {} 51 | 52 | public async syntheticQuoteForTradeEnabled(trade: SynthSwitchQueryParams): Promise { 53 | const { amount } = trade; 54 | 55 | // get row for which lower bucket <= amount 56 | const pk = `${SwitchRepository.getKey(trade)}`; 57 | const result = await this.switchEntity.get( 58 | { 59 | [PARTITION_KEY]: pk, 60 | }, 61 | { execute: true, consistent: true } 62 | ); 63 | 64 | if (result.Item && BigNumber.from(result.Item.lower).lte(amount)) { 65 | return result.Item.enabled; 66 | } else { 67 | SwitchRepository.log.info({ pk }, 'No row found'); 68 | } 69 | return false; 70 | } 71 | 72 | public async putSynthSwitch(trade: SynthSwitchTrade, lower: string, enabled: boolean): Promise { 73 | await this.switchEntity.put( 74 | { 75 | [PARTITION_KEY]: `${SwitchRepository.getKey(trade)}`, 76 | [`${DYNAMO_TABLE_KEY.LOWER}`]: lower, 77 | enabled: enabled, 78 | }, 79 | { execute: true } 80 | ); 81 | } 82 | 83 | static getKey(trade: SynthSwitchTrade): string { 84 | const { tokenIn, tokenInChainId, tokenOut, tokenOutChainId, type } = trade; 85 | return `${tokenIn.toLowerCase()}#${tokenInChainId}#${tokenOut.toLowerCase()}#${tokenOutChainId}#${type}`; 86 | } 87 | 88 | static parseKey(key: string): SynthSwitchTrade { 89 | const [tokenIn, tokenInChainId, tokenOut, tokenOutChainId, type] = key.split('#'); 90 | if (!tokenIn || !tokenInChainId || !tokenOut || !tokenOutChainId || !type) throw new Error(`Invalid key: ${key}`); 91 | return { 92 | tokenIn, 93 | tokenInChainId: parseInt(tokenInChainId), 94 | tokenOut: tokenOut, 95 | tokenOutChainId: parseInt(tokenOutChainId), 96 | type, 97 | }; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/providers/compliance/s3.test.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from '@aws-sdk/client-s3'; 2 | import axios from 'axios'; 3 | import { default as Logger } from 'bunyan'; 4 | 5 | import { 6 | FillerComplianceConfiguration, 7 | S3FillerComplianceConfigurationProvider, 8 | } from '../../../lib/providers/compliance'; 9 | 10 | const mockConfigs = [ 11 | { 12 | endpoints: ['https://google.com'], 13 | addresses: ['0x1234'], 14 | }, 15 | { 16 | endpoints: ['https://meta.com'], 17 | addresses: ['0x1234', '0x5678'], 18 | }, 19 | { 20 | endpoints: ['https://x.com'], 21 | addresses: ['0x7890'], 22 | complianceListUrl: 'https://example.com/compliance-list.json', 23 | }, 24 | ]; 25 | 26 | const mockComplianceList = { 27 | addresses: ['0x2345', '0x6789'], 28 | }; 29 | 30 | function applyMock(configs: FillerComplianceConfiguration[]) { 31 | jest.spyOn(S3Client.prototype, 'send').mockImplementationOnce(() => 32 | Promise.resolve({ 33 | Body: { 34 | transformToString: () => Promise.resolve(JSON.stringify(configs)), 35 | }, 36 | }) 37 | ); 38 | } 39 | 40 | // silent logger in tests 41 | const logger = Logger.createLogger({ name: 'test' }); 42 | logger.level(Logger.FATAL); 43 | 44 | jest.mock('axios'); 45 | const mockedAxios = axios as jest.Mocked; 46 | 47 | describe('S3ComplianceConfigurationProvider', () => { 48 | const bucket = 'test-bucket'; 49 | const key = 'test-key'; 50 | 51 | afterEach(() => { 52 | jest.clearAllMocks(); 53 | }); 54 | 55 | it('fetches configs', async () => { 56 | applyMock(mockConfigs); 57 | const provider = new S3FillerComplianceConfigurationProvider(logger, bucket, key); 58 | const endpoints = await provider.getConfigs(); 59 | expect(endpoints).toEqual(mockConfigs); 60 | }); 61 | 62 | it('generates endpoint to addrs map', async () => { 63 | applyMock(mockConfigs); 64 | const provider = new S3FillerComplianceConfigurationProvider(logger, bucket, key); 65 | const map = await provider.getEndpointToExcludedAddrsMap(); 66 | expect(map).toMatchObject( 67 | new Map([ 68 | ['https://google.com', new Set(['0x1234'])], 69 | ['https://meta.com', new Set(['0x1234', '0x5678'])], 70 | ['https://x.com', new Set(['0x7890'])], 71 | ]) 72 | ); 73 | }); 74 | 75 | it('fetches and merges compliance list addresses', async () => { 76 | applyMock(mockConfigs); 77 | mockedAxios.get.mockResolvedValueOnce({ 78 | status: 200, 79 | data: mockComplianceList 80 | }); 81 | 82 | const provider = new S3FillerComplianceConfigurationProvider(logger, bucket, key); 83 | const map = await provider.getEndpointToExcludedAddrsMap(); 84 | 85 | expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/compliance-list.json'); 86 | expect(map).toMatchObject( 87 | new Map([ 88 | ['https://google.com', new Set(['0x1234'])], 89 | ['https://meta.com', new Set(['0x1234', '0x5678'])], 90 | ['https://x.com', new Set(['0x7890', '0x2345', '0x6789'])], 91 | ]) 92 | ); 93 | }); 94 | 95 | it('handles compliance list fetch failure gracefully', async () => { 96 | applyMock(mockConfigs); 97 | mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); 98 | 99 | const provider = new S3FillerComplianceConfigurationProvider(logger, bucket, key); 100 | const map = await provider.getEndpointToExcludedAddrsMap(); 101 | 102 | expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/compliance-list.json'); 103 | expect(map).toMatchObject( 104 | new Map([ 105 | ['https://google.com', new Set(['0x1234'])], 106 | ['https://meta.com', new Set(['0x1234', '0x5678'])], 107 | ['https://x.com', new Set(['0x7890'])], 108 | ]) 109 | ); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uniswap/uniswapx-parametrization-api", 3 | "version": "0.0.1", 4 | "bin": { 5 | "app": "./dist/bin/app.js" 6 | }, 7 | "license": "UNLICENSED", 8 | "repository": "https://github.com/Uniswap/uniswapx-parameterization-api", 9 | "scripts": { 10 | "build": "tsc", 11 | "clean": "rm -rf dist cdk.out", 12 | "watch": "tsc -w", 13 | "test": "run-s build test:*", 14 | "test:unit": "jest --detectOpenHandles --forceExit --testPathIgnorePatterns test/integ dist/", 15 | "test:integ": "ts-mocha -p tsconfig.cdk.json -r dotenv/config test/integ/**/*.test.ts --timeout 50000", 16 | "fix": "run-s fix:*", 17 | "fix:prettier": "prettier \"lib/**/*.ts\" --write", 18 | "fix:prettiertest": "prettier \"test/**/*.ts\" --write", 19 | "fix:lint": "eslint lib test --ext .ts --fix", 20 | "fix:lint:cdk": "eslint bin --ext .ts --fix", 21 | "lint": "run-s lint:*", 22 | "lint:prettier": "prettier \"lib/**/*.ts\"", 23 | "lint:lint:cdk": "eslint bin --ext .ts", 24 | "deploy": "yarn build && cdk deploy" 25 | }, 26 | "devDependencies": { 27 | "@shelf/jest-dynamodb": "^3.4.1", 28 | "@types/aws-lambda": "^8.10.108", 29 | "@types/bunyan": "^1.8.8", 30 | "@types/chai": "^4.3.4", 31 | "@types/chai-as-promised": "^7.1.5", 32 | "@types/chai-subset": "^1.3.3", 33 | "@types/jest": "^29.2.0", 34 | "@types/node": "^20.14.8", 35 | "@types/qs": "^6.9.7", 36 | "@types/uuid": "^9.0.0", 37 | "@typescript-eslint/eslint-plugin": "^5.40.1", 38 | "@typescript-eslint/parser": "^5.40.1", 39 | "aws-sdk-client-mock": "^2.0.0", 40 | "esbuild": "^0.15.12", 41 | "eslint": "^8.26.0", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-plugin-eslint-comments": "^3.2.0", 44 | "eslint-plugin-import": "^2.26.0", 45 | "eslint-plugin-simple-import-sort": "^8.0.0", 46 | "ethers": "^5.7.2", 47 | "jest": "^29.2.2", 48 | "npm-run-all": "^4.1.5", 49 | "prettier": "^2.7.1", 50 | "prettier-plugin-organize-imports": "^3.1.1", 51 | "sinon": "^14.0.1", 52 | "ts-jest": "^29.0.3", 53 | "ts-node": "^10.9.1", 54 | "typescript": "^4.8.4" 55 | }, 56 | "dependencies": { 57 | "@aws-cdk/aws-redshift-alpha": "^2.210.0-alpha.0", 58 | "@aws-sdk/client-dynamodb": "^3.386.0", 59 | "@aws-sdk/client-firehose": "^3.490.0", 60 | "@aws-sdk/client-redshift-data": "^3.382.0", 61 | "@aws-sdk/client-s3": "^3.304.0", 62 | "@aws-sdk/lib-dynamodb": "^3.386.0", 63 | "@smithy/node-http-handler": "^2.1.5", 64 | "@swc/core": "^1.3.101", 65 | "@swc/jest": "^0.2.29", 66 | "@types/async-retry": "^1.4.5", 67 | "@uniswap/default-token-list": "^6.0.0", 68 | "@uniswap/router-sdk": "^1.4.0", 69 | "@uniswap/sdk-core": "^3.1.0", 70 | "@uniswap/signer": "0.0.3-beta.4", 71 | "@uniswap/smart-order-router": "^4.22.20", 72 | "@uniswap/token-lists": "^1.0.0-beta.31", 73 | "@uniswap/uniswapx-sdk": "3.0.0-beta.9", 74 | "@uniswap/v3-sdk": "^3.9.0", 75 | "aws-cdk-lib": "^2.214.0", 76 | "aws-embedded-metrics": "^4.1.0", 77 | "aws-sdk": "^2.1542.0", 78 | "axios": "^1.2.1", 79 | "axios-retry": "^3.4.0", 80 | "bunyan": "^1.8.15", 81 | "chai": "^4.3.7", 82 | "chai-as-promised": "^7.1.1", 83 | "chai-subset": "^1.6.0", 84 | "constructs": "^10.1.137", 85 | "dotenv": "^16.0.3", 86 | "dynamodb-toolbox": "^0.8.5", 87 | "esm": "^3.2.25", 88 | "joi": "^17.7.0", 89 | "mocha": "^10.2.0", 90 | "node-cache": "^5.1.2", 91 | "source-map-support": "^0.5.21", 92 | "ts-mocha": "^10.0.0", 93 | "uuid": "^9.0.0" 94 | }, 95 | "prettier": { 96 | "printWidth": 120, 97 | "semi": true, 98 | "singleQuote": true, 99 | "organizeImportsSkipDestructiveCodeActions": true 100 | }, 101 | "engines": { 102 | "node": ">=20.0.0" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/entities/aws-metrics-logger.ts: -------------------------------------------------------------------------------- 1 | import { IMetric, MetricLoggerUnit } from '@uniswap/smart-order-router'; 2 | import { MetricsLogger as AWSEmbeddedMetricsLogger } from 'aws-embedded-metrics'; 3 | 4 | export const UniswapXParamServiceMetricDimension = { 5 | Service: 'UniswapXParameterizationAPI', 6 | }; 7 | 8 | export const UniswapXParamServiceIntegrationMetricDimension = { 9 | Service: 'UniswapXParameterizationAPI-Integration', 10 | }; 11 | 12 | export const SyntheticSwitchMetricDimension = { 13 | Service: 'SyntheticSwitch', 14 | }; 15 | 16 | export const CircuitBreakerMetricDimension = { 17 | Service: 'CircuitBreaker', 18 | }; 19 | 20 | export const SoftQuoteMetricDimension = { 21 | Service: 'SoftQuote', 22 | }; 23 | 24 | export const HardQuoteMetricDimension = { 25 | Service: 'HardQuote', 26 | }; 27 | 28 | export class AWSMetricsLogger implements IMetric { 29 | constructor(private awsMetricLogger: AWSEmbeddedMetricsLogger) {} 30 | 31 | public setProperty(key: string, value: unknown): void { 32 | this.awsMetricLogger.setProperty(key, value); 33 | } 34 | 35 | public putDimensions(dimensions: Record): void { 36 | this.awsMetricLogger.putDimensions(dimensions); 37 | } 38 | 39 | public putMetric(key: string, value: number, unit?: MetricLoggerUnit): void { 40 | this.awsMetricLogger.putMetric(key, value, unit); 41 | } 42 | } 43 | 44 | export enum MetricDimension { 45 | METHOD = 'method', 46 | } 47 | 48 | export enum Metric { 49 | QUOTE_200 = 'QUOTE_200', 50 | QUOTE_400 = 'QUOTE_400', 51 | QUOTE_404 = 'QUOTE_404', 52 | QUOTE_500 = 'QUOTE_500', 53 | 54 | QUOTE_REQUESTED = 'QUOTE_REQUESTED', 55 | QUOTE_LATENCY = 'QUOTE_LATENCY', 56 | QUOTE_RESPONSE_COUNT = 'QUOTE_RESPONSE_COUNT', 57 | HANDLER_DURATION = 'HANDLER_DURATION', 58 | 59 | QUOTE_POST_ERROR = 'QUOTE_POST_ERROR', 60 | QUOTE_POST_ATTEMPT = 'QUOTE_POST_ATTEMPT', 61 | 62 | RFQ_REQUESTED = 'RFQ_REQUESTED', 63 | RFQ_SUCCESS = 'RFQ_SUCCESS', 64 | RFQ_RESPONSE_TIME = 'RFQ_RESPONSE_TIME', 65 | RFQ_FAIL_REQUEST_MATCH = 'RFQ_FAIL_REQUEST_MATCH', 66 | RFQ_NON_QUOTE = 'RFQ_NON_QUOTE', 67 | RFQ_FAIL_VALIDATION = 'RFQ_FAIL_VALIDATION', 68 | RFQ_FAIL_ERROR = 'RFQ_FAIL_ERROR', 69 | RFQ_COUNT_0 = 'RFQ_COUNT_0', 70 | RFQ_COUNT_1 = 'RFQ_COUNT_1', 71 | RFQ_COUNT_2 = 'RFQ_COUNT_2', 72 | RFQ_COUNT_3 = 'RFQ_COUNT_3', 73 | RFQ_COUNT_4_PLUS = 'RFQ_COUNT_4_PLUS', 74 | 75 | // Metrics for synth switch cron 76 | DYNAMO_REQUEST = 'DYNAMO_REQUEST', 77 | DYNAMO_REQUEST_ERROR = 'DYNAMO_REQUEST_ERROR', 78 | SYTH_PAIR_ENABLED = 'SYTH_PAIR_ENABLED', 79 | SYNTH_PAIR_DISABLED = 'SYNTH_PAIR_DISABLED', 80 | SYNTH_ORDERS = 'SYTH_ORDERS', 81 | SYNTH_ORDERS_PROCESSING_TIME = 'SYNTH_ORDERS_PROCESSING_TIME', 82 | SYNTH_ORDERS_VIEW_CREATION_TIME = 'SYNTH_ORDERS_VIEW_CREATION_TIME', 83 | SYNTH_ORDERS_QUERY_TIME = 'SYNTH_ORDERS_QUERY_TIME', 84 | SYNTH_ORDERS_POSITIVE_OUTCOME = 'SYNTH_ORDERS_POSITIVE_OUTCOME', 85 | SYNTH_ORDERS_NEGATIVE_OUTCOME = 'SYNTH_ORDERS_NEGATIVE_OUTCOME', 86 | 87 | // Metrics for circuit breaker 88 | CIRCUIT_BREAKER_V2_CONSECUTIVE_BLOCKS = 'CIRCUIT_BREAKER_V2_CONSECUTIVE_BLOCKS', 89 | CIRCUIT_BREAKER_V2_BLOCKED = 'CIRCUIT_BREAKER_V2_BLOCKED', 90 | CIRCUIT_BREAKER_TRIGGERED = 'CIRCUIT_BREAKER_TRIGGERED', 91 | } 92 | 93 | type MetricNeedingContext = 94 | | Metric.RFQ_REQUESTED 95 | | Metric.RFQ_SUCCESS 96 | | Metric.RFQ_RESPONSE_TIME 97 | | Metric.RFQ_FAIL_REQUEST_MATCH 98 | | Metric.RFQ_FAIL_VALIDATION 99 | | Metric.RFQ_NON_QUOTE 100 | | Metric.RFQ_FAIL_ERROR 101 | | Metric.DYNAMO_REQUEST 102 | | Metric.DYNAMO_REQUEST_ERROR 103 | | Metric.SYTH_PAIR_ENABLED 104 | | Metric.SYNTH_PAIR_DISABLED 105 | | Metric.SYNTH_ORDERS_POSITIVE_OUTCOME 106 | | Metric.SYNTH_ORDERS_NEGATIVE_OUTCOME 107 | | Metric.CIRCUIT_BREAKER_V2_CONSECUTIVE_BLOCKS; 108 | 109 | export function metricContext(metric: MetricNeedingContext, context: string): string { 110 | return `${metric}_${context}`; 111 | } 112 | -------------------------------------------------------------------------------- /lib/providers/compliance/s3.ts: -------------------------------------------------------------------------------- 1 | import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; 2 | import axios from 'axios'; 3 | import { default as Logger } from 'bunyan'; 4 | 5 | import { FillerComplianceConfiguration, FillerComplianceConfigurationProvider, FillerComplianceList } from '.'; 6 | import { checkDefined } from '../../preconditions/preconditions'; 7 | 8 | export class S3FillerComplianceConfigurationProvider implements FillerComplianceConfigurationProvider { 9 | private log: Logger; 10 | private configs: FillerComplianceConfiguration[]; 11 | private endpointToExcludedAddrsMap: Map>; 12 | private lastFetchTime: number; 13 | private readonly REFRESH_INTERVAL = 5 * 60 * 1000; 14 | 15 | constructor(_log: Logger, private bucket: string, private key: string) { 16 | this.configs = []; 17 | this.log = _log.child({ quoter: 'S3FillerComplianceConfigurationProvider' }); 18 | this.endpointToExcludedAddrsMap = new Map>(); 19 | this.lastFetchTime = 0; 20 | } 21 | 22 | private async fetchComplianceList(url: string): Promise { 23 | try { 24 | const response = await axios.get(url); 25 | if (response.status !== 200) { 26 | this.log.warn( 27 | { url, status: response.status }, 28 | 'Failed to fetch compliance list' 29 | ); 30 | return []; 31 | } 32 | const complianceList = response.data as FillerComplianceList; 33 | return complianceList.addresses; 34 | } catch (e: any) { 35 | this.log.warn( 36 | { url, error: e.message }, 37 | 'Error fetching compliance list' 38 | ); 39 | return []; 40 | } 41 | } 42 | 43 | async getEndpointToExcludedAddrsMap(): Promise>> { 44 | const now = Date.now(); 45 | if (this.configs.length === 0 || now - this.lastFetchTime >= this.REFRESH_INTERVAL) { 46 | await this.fetchConfigs(); 47 | this.endpointToExcludedAddrsMap.clear(); 48 | this.lastFetchTime = now; 49 | } 50 | if (this.endpointToExcludedAddrsMap.size > 0) { 51 | return this.endpointToExcludedAddrsMap; 52 | } 53 | 54 | // Fetch additional addresses from complianceListUrl for each config 55 | for (const config of this.configs) { 56 | if (config.complianceListUrl) { 57 | const additionalAddresses = await this.fetchComplianceList(config.complianceListUrl); 58 | config.addresses = [...config.addresses, ...additionalAddresses]; 59 | } 60 | } 61 | 62 | // Build the endpoint to addresses map 63 | this.configs.forEach((config) => { 64 | config.endpoints.forEach((endpoint) => { 65 | if (!this.endpointToExcludedAddrsMap.has(endpoint)) { 66 | this.endpointToExcludedAddrsMap.set(endpoint, new Set()); 67 | } 68 | config.addresses.forEach((address) => { 69 | this.endpointToExcludedAddrsMap.get(endpoint)?.add(address); 70 | }); 71 | }); 72 | }); 73 | 74 | return this.endpointToExcludedAddrsMap; 75 | } 76 | 77 | async getConfigs(): Promise { 78 | if (this.configs.length === 0) { 79 | await this.fetchConfigs(); 80 | } 81 | return this.configs; 82 | } 83 | 84 | async fetchConfigs(): Promise { 85 | const s3Client = new S3Client({}); 86 | try { 87 | const s3Res = await s3Client.send( 88 | new GetObjectCommand({ 89 | Bucket: this.bucket, 90 | Key: this.key, 91 | }) 92 | ); 93 | const s3Body = checkDefined(s3Res.Body, 's3Res.Body is undefined'); 94 | this.configs = JSON.parse(await s3Body.transformToString()) as FillerComplianceConfiguration[]; 95 | this.log.info({ configsLength: this.configs.map((c) => c.addresses.length) }, `Fetched configs`); 96 | } catch (e: any) { 97 | this.log.info( 98 | { name: e.name, message: e.message }, 99 | 'Error fetching compliance s3 config. Default to allowing all' 100 | ); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/repositories/timestamp-repository.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 2 | import Logger from 'bunyan'; 3 | import { Entity, Table } from 'dynamodb-toolbox'; 4 | 5 | import { DYNAMO_TABLE_KEY, DYNAMO_TABLE_NAME } from '../constants'; 6 | import { BaseTimestampRepository, DynamoTimestampRepoRow, TimestampRepoRow, ToUpdateTimestampRow } from './base'; 7 | 8 | export type BatchGetResponse = { 9 | tableName: string; 10 | }; 11 | 12 | export class TimestampRepository implements BaseTimestampRepository { 13 | static log: Logger; 14 | static PARTITION_KEY = 'hash'; 15 | 16 | static create(documentClient: DynamoDBDocumentClient): BaseTimestampRepository { 17 | this.log = Logger.createLogger({ 18 | name: 'DynamoTimestampRepository', 19 | serializers: Logger.stdSerializers, 20 | }); 21 | delete this.log.fields.pid; 22 | delete this.log.fields.hostname; 23 | 24 | const table = new Table({ 25 | name: DYNAMO_TABLE_NAME.FILLER_CB_TIMESTAMPS, 26 | partitionKey: TimestampRepository.PARTITION_KEY, 27 | DocumentClient: documentClient, 28 | }); 29 | 30 | const entity = new Entity({ 31 | name: 'FillerTimestampEntity', 32 | attributes: { 33 | [TimestampRepository.PARTITION_KEY]: { partitionKey: true, type: 'string' }, 34 | [`${DYNAMO_TABLE_KEY.LAST_POST_TIMESTAMP}`]: { type: 'string' }, 35 | [`${DYNAMO_TABLE_KEY.BLOCK_UNTIL_TIMESTAMP}`]: { type: 'string' }, 36 | [`${DYNAMO_TABLE_KEY.CONSECUTIVE_BLOCKS}`]: { type: 'string' }, 37 | }, 38 | table: table, 39 | autoExecute: true, 40 | } as const); 41 | 42 | return new TimestampRepository(table, entity); 43 | } 44 | 45 | private constructor( 46 | // eslint-disable-next-line 47 | private readonly table: Table<'Timestamp', 'hash', null>, 48 | private readonly entity: Entity 49 | ) {} 50 | 51 | public async updateTimestampsBatch(updatedTimestamps: ToUpdateTimestampRow[]): Promise { 52 | await this.table.batchWrite( 53 | updatedTimestamps.map((row) => { 54 | return this.entity.putBatch({ 55 | [TimestampRepository.PARTITION_KEY]: row.hash, 56 | [`${DYNAMO_TABLE_KEY.LAST_POST_TIMESTAMP}`]: row.lastPostTimestamp, 57 | [`${DYNAMO_TABLE_KEY.BLOCK_UNTIL_TIMESTAMP}`]: row.blockUntilTimestamp, 58 | [`${DYNAMO_TABLE_KEY.CONSECUTIVE_BLOCKS}`]: row.consecutiveBlocks, 59 | }); 60 | }), 61 | { 62 | execute: true, 63 | } 64 | ); 65 | } 66 | 67 | public async getFillerTimestamps(hash: string): Promise { 68 | const { Item } = await this.entity.get( 69 | { hash: hash }, 70 | { 71 | execute: true, 72 | } 73 | ); 74 | return { 75 | hash: Item?.hash, 76 | lastPostTimestamp: parseInt(Item?.lastPostTimestamp), 77 | blockUntilTimestamp: parseInt(Item?.blockUntilTimestamp), 78 | consecutiveBlocks: parseInt(Item?.consecutiveBlocks), 79 | }; 80 | } 81 | 82 | public async getTimestampsBatch(hashes: string[]): Promise { 83 | const { Responses: items } = await this.table.batchGet( 84 | hashes.map((hash) => { 85 | return this.entity.getBatch({ 86 | [TimestampRepository.PARTITION_KEY]: hash, 87 | }); 88 | }), 89 | { 90 | execute: true, 91 | parse: true, 92 | } 93 | ); 94 | return items[DYNAMO_TABLE_NAME.FILLER_CB_TIMESTAMPS].map((row: DynamoTimestampRepoRow) => { 95 | return { 96 | hash: row.hash, 97 | lastPostTimestamp: parseInt(row.lastPostTimestamp), 98 | blockUntilTimestamp: parseInt(row.blockUntilTimestamp), 99 | consecutiveBlocks: parseInt(row.consecutiveBlocks), 100 | }; 101 | }); 102 | } 103 | 104 | public async getFillerTimestampsMap(hashes: string[]): Promise>> { 105 | const rows = await this.getTimestampsBatch(hashes); 106 | const res = new Map>(); 107 | rows.forEach((row) => { 108 | res.set(row.hash, { 109 | lastPostTimestamp: row.lastPostTimestamp, 110 | blockUntilTimestamp: row.blockUntilTimestamp, 111 | consecutiveBlocks: row.consecutiveBlocks, 112 | }); 113 | }); 114 | return res; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/providers/circuit-breaker/dynamo.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 3 | import Logger from 'bunyan'; 4 | 5 | import { CircuitBreakerConfigurationProvider, EndpointStatuses } from '.'; 6 | import { BaseTimestampRepository, FillerTimestampMap, TimestampRepository } from '../../repositories'; 7 | import { S3WebhookConfigurationProvider, WebhookConfiguration } from '../webhook'; 8 | 9 | export class DynamoCircuitBreakerConfigurationProvider implements CircuitBreakerConfigurationProvider { 10 | private log: Logger; 11 | private webhookProvider: S3WebhookConfigurationProvider; 12 | private fillerEndpoints: string[] = []; 13 | private lastUpdatedTimestamp: number; 14 | private timestampDB: BaseTimestampRepository; 15 | private timestamps: FillerTimestampMap = new Map(); 16 | 17 | // try to refetch endpoints every 30 seconds 18 | private static UPDATE_PERIOD_MS = 1 * 30000; 19 | 20 | constructor(_log: Logger, _webhookProvider: S3WebhookConfigurationProvider) { 21 | this.log = _log.child({ quoter: 'CircuitBreakerConfigurationProvider' }); 22 | this.webhookProvider = _webhookProvider; 23 | this.lastUpdatedTimestamp = Date.now(); 24 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), { 25 | marshallOptions: { 26 | convertEmptyValues: true, 27 | }, 28 | unmarshallOptions: { 29 | wrapNumbers: true, 30 | }, 31 | }); 32 | this.timestampDB = TimestampRepository.create(documentClient); 33 | } 34 | 35 | private async getFillerEndpoints(): Promise { 36 | if (this.fillerEndpoints.length === 0) { 37 | this.fillerEndpoints = this.webhookProvider.fillerEndpoints(); 38 | this.lastUpdatedTimestamp = Date.now(); 39 | } 40 | return this.fillerEndpoints; 41 | } 42 | 43 | async getConfigurations(): Promise { 44 | if ( 45 | (await this.getFillerEndpoints()).length === 0 || 46 | Date.now() - this.lastUpdatedTimestamp > DynamoCircuitBreakerConfigurationProvider.UPDATE_PERIOD_MS 47 | ) { 48 | await this.fetchConfigurations(); 49 | this.lastUpdatedTimestamp = Date.now(); 50 | } 51 | this.log.info({ timestamps: Array.from(this.timestamps.entries()) }, 'filler timestamps'); 52 | return this.timestamps; 53 | } 54 | 55 | async fetchConfigurations(): Promise { 56 | this.timestamps = await this.timestampDB.getFillerTimestampsMap(await this.getFillerEndpoints()); 57 | } 58 | 59 | /* add filler to `enabled` array if it's not blocked until a future timestamp; 60 | add disabled fillers and the `blockUntilTimestamp`s to disabled array */ 61 | async getEndpointStatuses(endpoints: WebhookConfiguration[]): Promise { 62 | try { 63 | const now = Math.floor(Date.now() / 1000); 64 | const fillerTimestamps = await this.getConfigurations(); 65 | if (fillerTimestamps.size) { 66 | this.log.info({ fillerTimestamps: [...fillerTimestamps.entries()] }, `Circuit breaker config used`); 67 | const enabledEndpoints = endpoints.filter((e) => { 68 | return !(fillerTimestamps.has(e.endpoint) && fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp > now); 69 | }); 70 | const disabledEndpoints = endpoints 71 | .filter((e) => { 72 | return fillerTimestamps.has(e.endpoint) && fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp > now; 73 | }) 74 | .map((e) => { 75 | return { 76 | webhook: e, 77 | blockUntil: fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp, 78 | }; 79 | }); 80 | 81 | this.log.info({ num: enabledEndpoints.length, endpoints: enabledEndpoints }, `Endpoints enabled`); 82 | this.log.info({ num: disabledEndpoints.length, endpoints: disabledEndpoints }, `Endpoints disabled`); 83 | 84 | return { 85 | enabled: enabledEndpoints, 86 | disabled: disabledEndpoints, 87 | }; 88 | } 89 | 90 | return { 91 | enabled: endpoints, 92 | disabled: [], 93 | }; 94 | } catch (e) { 95 | this.log.error({ error: e }, `Error getting eligible endpoints, default to returning all`); 96 | return { 97 | enabled: endpoints, 98 | disabled: [], 99 | }; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/entities/HardQuoteRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { OrderType, UnsignedV2DutchOrder, UnsignedV2DutchOrderInfo } from '@uniswap/uniswapx-sdk'; 3 | import { BigNumber, ethers } from 'ethers'; 4 | 5 | import { HardQuoteRequest } from '../../lib/entities'; 6 | import { HardQuoteRequestBody } from '../../lib/handlers/hard-quote'; 7 | import { ProtocolVersion } from '../../lib/providers'; 8 | 9 | const NOW = Math.floor(new Date().getTime() / 1000); 10 | const RAW_AMOUNT = BigNumber.from('1000000'); 11 | const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; 12 | const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; 13 | const SWAPPER = '0x0000000000000000000000000000000000000000'; 14 | const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; 15 | const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 16 | const CHAIN_ID = 1; 17 | 18 | export const getOrderInfo = (data: Partial): UnsignedV2DutchOrderInfo => { 19 | return Object.assign( 20 | { 21 | deadline: NOW + 1000, 22 | reactor: ethers.constants.AddressZero, 23 | swapper: ethers.constants.AddressZero, 24 | nonce: BigNumber.from(10), 25 | additionalValidationContract: ethers.constants.AddressZero, 26 | additionalValidationData: '0x', 27 | cosigner: ethers.constants.AddressZero, 28 | input: { 29 | token: TOKEN_IN, 30 | startAmount: RAW_AMOUNT, 31 | endAmount: RAW_AMOUNT, 32 | }, 33 | outputs: [ 34 | { 35 | token: TOKEN_OUT, 36 | startAmount: RAW_AMOUNT.mul(2), 37 | endAmount: RAW_AMOUNT.mul(90).div(100), 38 | recipient: ethers.constants.AddressZero, 39 | }, 40 | ], 41 | }, 42 | data 43 | ); 44 | }; 45 | 46 | const makeRequest = (data: Partial): HardQuoteRequest => { 47 | return new HardQuoteRequest( 48 | Object.assign( 49 | { 50 | requestId: REQUEST_ID, 51 | quoteId: QUOTE_ID, 52 | tokenInChainId: CHAIN_ID, 53 | tokenOutChainId: CHAIN_ID, 54 | encodedInnerOrder: '0x', 55 | innerSig: '0x', 56 | }, 57 | data 58 | ), 59 | OrderType.Dutch_V2 60 | ); 61 | }; 62 | 63 | describe('QuoteRequest', () => { 64 | afterEach(() => { 65 | jest.clearAllMocks(); 66 | }); 67 | 68 | it('parses order properly', () => { 69 | const order = new UnsignedV2DutchOrder( 70 | getOrderInfo({ 71 | swapper: SWAPPER, 72 | }), 73 | CHAIN_ID 74 | ); 75 | const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); 76 | expect(request.swapper).toEqual(SWAPPER); 77 | expect(request.tokenIn).toEqual(TOKEN_IN); 78 | expect(request.tokenOut).toEqual(TOKEN_OUT); 79 | expect(request.numOutputs).toEqual(1); 80 | expect(request.amount).toEqual(RAW_AMOUNT); 81 | expect(request.type).toEqual(TradeType.EXACT_INPUT); 82 | }); 83 | 84 | it('toCleanJSON', async () => { 85 | const order = new UnsignedV2DutchOrder( 86 | getOrderInfo({ 87 | swapper: SWAPPER, 88 | }), 89 | CHAIN_ID 90 | ); 91 | const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); 92 | expect(request.toCleanJSON()).toEqual({ 93 | tokenInChainId: CHAIN_ID, 94 | tokenOutChainId: CHAIN_ID, 95 | requestId: REQUEST_ID, 96 | quoteId: QUOTE_ID, 97 | tokenIn: TOKEN_IN, 98 | tokenOut: TOKEN_OUT, 99 | amount: RAW_AMOUNT.toString(), 100 | swapper: ethers.constants.AddressZero, 101 | type: 'EXACT_INPUT', 102 | numOutputs: 1, 103 | protocol: ProtocolVersion.V2, 104 | }); 105 | }); 106 | 107 | it('toOpposingCleanJSON', async () => { 108 | const order = new UnsignedV2DutchOrder( 109 | getOrderInfo({ 110 | swapper: SWAPPER, 111 | }), 112 | CHAIN_ID 113 | ); 114 | const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); 115 | expect(request.toOpposingCleanJSON()).toEqual({ 116 | tokenInChainId: CHAIN_ID, 117 | tokenOutChainId: CHAIN_ID, 118 | requestId: REQUEST_ID, 119 | quoteId: QUOTE_ID, 120 | tokenIn: TOKEN_OUT, 121 | tokenOut: TOKEN_IN, 122 | amount: RAW_AMOUNT.toString(), 123 | swapper: ethers.constants.AddressZero, 124 | type: 'EXACT_OUTPUT', 125 | numOutputs: 1, 126 | protocol: ProtocolVersion.V2, 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /test/repositories/filler-address-repository.test.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient, DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'; 2 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 3 | 4 | import { DynamoFillerAddressRepository } from '../../lib/repositories/filler-address-repository'; 5 | 6 | const dynamoConfig: DynamoDBClientConfig = { 7 | endpoint: 'http://localhost:8000', 8 | region: 'local', 9 | credentials: { 10 | accessKeyId: 'fakeMyKeyId', 11 | secretAccessKey: 'fakeSecretAccessKey', 12 | }, 13 | }; 14 | 15 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient(dynamoConfig), { 16 | marshallOptions: { 17 | convertEmptyValues: true, 18 | }, 19 | unmarshallOptions: { 20 | wrapNumbers: true, 21 | }, 22 | }); 23 | 24 | const repository = DynamoFillerAddressRepository.create(documentClient); 25 | 26 | const ADDR1 = '0x0000000000000000000000000000000000000001'; 27 | const ADDR2 = '0x0000000000000000000000000000000000000002'; 28 | const ADDR3 = '0x0000000000000000000000000000000000000003'; 29 | const ADDR4 = '0x0000000000000000000000000000000000000004'; 30 | const ADDR5 = '0x0000000000000000000000000000000000000005'; 31 | 32 | const CHECKSUMED_ADDR = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; 33 | const LOWER_CASE_ADDR = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; 34 | 35 | describe('filler address repository test', () => { 36 | /* 37 | * filler1: [addr1, addr2] 38 | * filler2: [addr3] 39 | * filler3: [addr4, addr5] 40 | * 41 | */ 42 | beforeAll(async () => { 43 | await repository.addNewAddressToFiller(ADDR1, 'filler1'); 44 | await repository.addNewAddressToFiller(ADDR2, 'filler1'); 45 | await repository.addNewAddressToFiller(ADDR3, 'filler2'); 46 | await repository.addNewAddressToFiller(ADDR4, 'filler3'); 47 | await repository.addNewAddressToFiller(ADDR5, 'filler3'); 48 | await repository.addNewAddressToFiller(LOWER_CASE_ADDR, 'filler4'); 49 | }); 50 | 51 | it('should get filler addresses', async () => { 52 | const addresses = await repository.getFillerAddresses('filler1'); 53 | expect(addresses).toEqual([ADDR1, ADDR2]); 54 | 55 | const addresses2 = await repository.getFillerAddresses('filler2'); 56 | expect(addresses2).toEqual([ADDR3]); 57 | 58 | const addresses3 = await repository.getFillerAddresses('filler3'); 59 | expect(addresses3).toEqual([ADDR4, ADDR5]); 60 | }); 61 | 62 | it('should get filler by address', async () => { 63 | const filler = await repository.getFillerByAddress(ADDR1); 64 | expect(filler).toEqual('filler1'); 65 | 66 | const filler2 = await repository.getFillerByAddress(ADDR2); 67 | expect(filler2).toEqual('filler1'); 68 | 69 | const filler3 = await repository.getFillerByAddress(ADDR3); 70 | expect(filler3).toEqual('filler2'); 71 | 72 | const filler4 = await repository.getFillerByAddress(ADDR4); 73 | expect(filler4).toEqual('filler3'); 74 | 75 | const filler5 = await repository.getFillerByAddress(ADDR5); 76 | expect(filler5).toEqual('filler3'); 77 | }); 78 | 79 | it('should batch get filler to addresses map', async () => { 80 | const resMap = await repository.getFillerAddressesBatch(['filler1', 'filler2', 'filler3']); 81 | expect(resMap.size).toBe(3); 82 | expect(resMap.get('filler1')).toEqual(new Set([ADDR1, ADDR2])); 83 | expect(resMap.get('filler2')).toEqual(new Set([ADDR3])); 84 | expect(resMap.get('filler3')).toEqual(new Set([ADDR4, ADDR5])); 85 | }); 86 | 87 | it('should get address to filler mapping', async () => { 88 | const res = await repository.getAddressToFillerMap(['filler1', 'filler2', 'filler3']); 89 | expect(res.size).toBe(5); 90 | expect(res.get(ADDR1)).toEqual('filler1'); 91 | expect(res.get(ADDR2)).toEqual('filler1'); 92 | expect(res.get(ADDR3)).toEqual('filler2'); 93 | expect(res.get(ADDR4)).toEqual('filler3'); 94 | expect(res.get(ADDR5)).toEqual('filler3'); 95 | }); 96 | 97 | it("if address already exists, doesn't modify state", async () => { 98 | await repository.addNewAddressToFiller(ADDR1, 'filler1'); 99 | const addresses = await repository.getFillerAddresses('filler1'); 100 | expect(addresses).toEqual([ADDR1, ADDR2]); 101 | const filler = await repository.getFillerByAddress(ADDR1); 102 | expect(filler).toEqual('filler1'); 103 | }); 104 | 105 | it('should checksum address when adding to db', async () => { 106 | expect(await repository.getFillerAddresses('filler4')).toEqual([CHECKSUMED_ADDR]); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /lib/util/rfqValidator.ts: -------------------------------------------------------------------------------- 1 | import Logger from "bunyan"; 2 | import { ethers, BigNumber } from "ethers"; 3 | import { QuoteRequestData } from "../entities"; 4 | import { RfqResponse } from "../handlers/quote"; 5 | import { PermissionedTokenValidator } from "@uniswap/uniswapx-sdk"; 6 | 7 | export class RFQValidator { 8 | static ProviderRequiredError = class extends Error { 9 | constructor(tokenAddress: string, chainId: number) { 10 | super(`provider is required for permissioned token check for token: ${tokenAddress} on chain: ${chainId}`); 11 | this.name = 'ProviderRequiredError'; 12 | } 13 | }; 14 | 15 | static PreTransferCheckError = class extends Error { 16 | constructor(tokenAddress: string, from: string, to: string, amount: string) { 17 | super(`preTransferCheck check failed for token: ${tokenAddress} from ${from} to ${to} with amount ${amount}`); 18 | this.name = 'PreTransferCheckError'; 19 | } 20 | }; 21 | 22 | /** 23 | * Validates if a token requires permission checks and if so, performs the preTransferCheck 24 | * @param tokenAddress - The address of the token to validate 25 | * @param chainId - The chain ID where the token exists 26 | * @param from - The address tokens are being transferred from 27 | * @param to - The address tokens are being transferred to 28 | * @param amount - The amount of tokens being transferred (as a string) 29 | * @param provider - Optional StaticJsonRpcProvider needed for permissioned token checks 30 | * @returns A string containing an error message if validation fails, undefined if successful 31 | */ 32 | private static async validatePermissionedToken( 33 | tokenAddress: string, 34 | chainId: number, 35 | from: string, 36 | to: string, 37 | amount: string, 38 | provider?: ethers.providers.StaticJsonRpcProvider 39 | ): Promise { 40 | if (!PermissionedTokenValidator.isPermissionedToken(tokenAddress, chainId)) { 41 | return undefined; 42 | } 43 | 44 | if (!provider) { 45 | return new RFQValidator.ProviderRequiredError(tokenAddress, chainId).message; 46 | } 47 | 48 | const isValid = await PermissionedTokenValidator.preTransferCheck( 49 | provider, 50 | tokenAddress, 51 | from, 52 | to, 53 | amount 54 | ); 55 | 56 | if (!isValid) { 57 | return new RFQValidator.PreTransferCheckError(tokenAddress, from, to, amount).message; 58 | } 59 | 60 | return undefined; 61 | } 62 | 63 | /** 64 | * Validates both input and output tokens for permission requirements and transfer validity 65 | * @param request - The quote request data containing token addresses and chain IDs 66 | * @param data - The RFQ response data containing filler information 67 | * @param amountIn - The input amount as a BigNumber 68 | * @param amountOut - The output amount as a BigNumber 69 | * @param provider - Optional StaticJsonRpcProvider needed for permissioned token checks 70 | * @param log - Optional logger instance for error reporting 71 | * @returns A string containing the first error message encountered, undefined if all validations pass 72 | * @dev This function fails open (returns undefined) if an error occurs during validation 73 | * @dev Only performs checks if a filler address is provided in the RFQ response 74 | */ 75 | public static async validatePermissionedTokens( 76 | request: QuoteRequestData, 77 | data: RfqResponse, 78 | amountIn: BigNumber, 79 | amountOut: BigNumber, 80 | provider?: ethers.providers.StaticJsonRpcProvider, 81 | log?: Logger 82 | ): Promise { 83 | 84 | if (!data.filler) { 85 | return undefined; 86 | } 87 | 88 | try { 89 | const [tokenInError, tokenOutError] = await Promise.all([ 90 | this.validatePermissionedToken( 91 | request.tokenIn, 92 | request.tokenInChainId, 93 | request.swapper, 94 | data.filler, 95 | amountIn.toString(), 96 | provider 97 | ), 98 | this.validatePermissionedToken( 99 | request.tokenOut, 100 | request.tokenOutChainId, 101 | data.filler, 102 | request.swapper, 103 | amountOut.toString(), 104 | provider 105 | ) 106 | ]); 107 | 108 | if (tokenInError) return tokenInError; 109 | if (tokenOutError) return tokenOutError; 110 | } catch (error) { 111 | // fail open, likely a dev error 112 | log?.error({ error }, 'error checking permissioned tokens'); 113 | } 114 | 115 | return undefined; 116 | } 117 | } -------------------------------------------------------------------------------- /lib/handlers/quote/handler.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { IMetric, MetricLoggerUnit } from '@uniswap/smart-order-router'; 3 | import Logger from 'bunyan'; 4 | import Joi from 'joi'; 5 | import { ethers } from 'ethers'; 6 | 7 | import { Metric, QuoteRequest, QuoteResponse } from '../../entities'; 8 | import { Quoter } from '../../quoters'; 9 | import { NoQuotesAvailable } from '../../util/errors'; 10 | import { timestampInMstoSeconds } from '../../util/time'; 11 | import { APIGLambdaHandler } from '../base'; 12 | import { APIHandleRequestParams, ErrorResponse, Response } from '../base/api-handler'; 13 | import { ContainerInjected, RequestInjected } from './injector'; 14 | import { PostQuoteRequestBody, PostQuoteRequestBodyJoi, PostQuoteResponse, URAResponseJoi } from './schema'; 15 | 16 | export type EventType = 'QuoteResponse' | 'HardResponse'; 17 | 18 | export class QuoteHandler extends APIGLambdaHandler< 19 | ContainerInjected, 20 | RequestInjected, 21 | PostQuoteRequestBody, 22 | void, 23 | PostQuoteResponse 24 | > { 25 | public async handleRequest( 26 | params: APIHandleRequestParams 27 | ): Promise> { 28 | const { 29 | requestInjected: { log, metric }, 30 | requestBody, 31 | containerInjected: { quoters, chainIdRpcMap }, 32 | } = params; 33 | const start = Date.now(); 34 | 35 | metric.putMetric(Metric.QUOTE_REQUESTED, 1, MetricLoggerUnit.Count); 36 | 37 | const provider = chainIdRpcMap.get(requestBody.tokenInChainId); 38 | 39 | const request = QuoteRequest.fromRequestBody(requestBody); 40 | log.info({ 41 | eventType: 'QuoteRequest', 42 | body: { 43 | requestId: request.requestId, 44 | tokenInChainId: request.tokenInChainId, 45 | tokenOutChainId: request.tokenInChainId, 46 | offerer: request.swapper, 47 | tokenIn: request.tokenIn, 48 | tokenOut: request.tokenOut, 49 | amount: request.amount.toString(), 50 | type: TradeType[request.type], 51 | createdAt: timestampInMstoSeconds(start), 52 | createdAtMs: start.toString(), 53 | numOutputs: request.numOutputs, 54 | }, 55 | }); 56 | 57 | const bestQuote = await getBestQuote(quoters, request, log, metric, provider); 58 | if (!bestQuote) { 59 | metric.putMetric(Metric.QUOTE_404, 1, MetricLoggerUnit.Count); 60 | throw new NoQuotesAvailable(); 61 | } 62 | 63 | log.info({ bestQuote: bestQuote }, 'bestQuote'); 64 | 65 | metric.putMetric(Metric.QUOTE_200, 1, MetricLoggerUnit.Count); 66 | metric.putMetric(Metric.QUOTE_LATENCY, Date.now() - start, MetricLoggerUnit.Milliseconds); 67 | return { 68 | statusCode: 200, 69 | body: bestQuote.toResponseJSON(), 70 | }; 71 | } 72 | 73 | protected requestBodySchema(): Joi.ObjectSchema | null { 74 | return PostQuoteRequestBodyJoi; 75 | } 76 | 77 | protected requestQueryParamsSchema(): Joi.ObjectSchema | null { 78 | return null; 79 | } 80 | 81 | protected responseBodySchema(): Joi.ObjectSchema | null { 82 | return URAResponseJoi; 83 | } 84 | } 85 | 86 | // fetch quotes from all quoters and return the best one 87 | export async function getBestQuote( 88 | quoters: Quoter[], 89 | quoteRequest: QuoteRequest, 90 | log: Logger, 91 | metric: IMetric, 92 | provider?: ethers.providers.StaticJsonRpcProvider, 93 | eventType: EventType = 'QuoteResponse' 94 | ): Promise { 95 | const responses: QuoteResponse[] = (await Promise.all(quoters.map((q) => q.quote(quoteRequest, provider)))).flat(); 96 | switch (responses.length) { 97 | case 0: 98 | metric.putMetric(Metric.RFQ_COUNT_0, 1, MetricLoggerUnit.Count); 99 | break; 100 | case 1: 101 | metric.putMetric(Metric.RFQ_COUNT_1, 1, MetricLoggerUnit.Count); 102 | break; 103 | case 2: 104 | metric.putMetric(Metric.RFQ_COUNT_2, 1, MetricLoggerUnit.Count); 105 | break; 106 | case 3: 107 | metric.putMetric(Metric.RFQ_COUNT_3, 1, MetricLoggerUnit.Count); 108 | break; 109 | default: 110 | metric.putMetric(Metric.RFQ_COUNT_4_PLUS, 1, MetricLoggerUnit.Count); 111 | break; 112 | } 113 | 114 | // return the response with the highest amountOut value 115 | return responses.reduce((bestQuote: QuoteResponse | null, quote: QuoteResponse) => { 116 | log.info({ 117 | eventType: eventType, 118 | body: { ...quote.toLog(), offerer: quote.swapper, endpoint: quote.endpoint, fillerName: quote.fillerName }, 119 | }); 120 | 121 | if ( 122 | !bestQuote || 123 | (quoteRequest.type == TradeType.EXACT_INPUT && quote.amountOut.gt(bestQuote.amountOut)) || 124 | (quoteRequest.type == TradeType.EXACT_OUTPUT && quote.amountIn.lt(bestQuote.amountIn)) 125 | ) { 126 | return quote; 127 | } 128 | return bestQuote; 129 | }, null); 130 | } 131 | -------------------------------------------------------------------------------- /lib/entities/QuoteRequest.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { BigNumber, ethers } from 'ethers'; 3 | import { getAddress } from 'ethers/lib/utils'; 4 | 5 | import { PostQuoteRequestBody } from '../handlers/quote/schema'; 6 | import { ProtocolVersion } from '../providers'; 7 | 8 | export interface QuoteRequestData { 9 | tokenInChainId: number; 10 | tokenOutChainId: number; 11 | requestId: string; 12 | swapper: string; 13 | tokenIn: string; 14 | amount: BigNumber; 15 | tokenOut: string; 16 | type: TradeType; 17 | numOutputs: number; 18 | protocol: ProtocolVersion; 19 | quoteId?: string; 20 | } 21 | 22 | export interface QuoteRequestDataJSON extends Omit { 23 | amount: string; 24 | type: string; 25 | } 26 | 27 | // data class for QuoteRequest helpers and conversions 28 | export class QuoteRequest { 29 | public static fromRequestBody(body: PostQuoteRequestBody): QuoteRequest { 30 | return new QuoteRequest({ 31 | tokenInChainId: body.tokenInChainId, 32 | tokenOutChainId: body.tokenOutChainId, 33 | requestId: body.requestId, 34 | swapper: getAddress(body.swapper), 35 | tokenIn: getAddress(body.tokenIn), 36 | tokenOut: getAddress(body.tokenOut), 37 | amount: BigNumber.from(body.amount), 38 | type: TradeType[body.type as keyof typeof TradeType], 39 | numOutputs: body.numOutputs, 40 | protocol: body.protocol, 41 | }); 42 | } 43 | 44 | constructor(private data: QuoteRequestData) {} 45 | 46 | public toJSON(): QuoteRequestDataJSON { 47 | return { 48 | tokenInChainId: this.tokenInChainId, 49 | tokenOutChainId: this.tokenOutChainId, 50 | requestId: this.requestId, 51 | swapper: getAddress(this.swapper), 52 | tokenIn: getAddress(this.tokenIn), 53 | tokenOut: getAddress(this.tokenOut), 54 | amount: this.amount.toString(), 55 | type: TradeType[this.type], 56 | numOutputs: this.numOutputs, 57 | protocol: this.protocol, 58 | ...(this.quoteId && { quoteId: this.quoteId }), 59 | }; 60 | } 61 | 62 | public toCleanJSON(): QuoteRequestDataJSON { 63 | return { 64 | tokenInChainId: this.tokenInChainId, 65 | tokenOutChainId: this.tokenOutChainId, 66 | requestId: this.requestId, 67 | tokenIn: getAddress(this.tokenIn), 68 | tokenOut: getAddress(this.tokenOut), 69 | amount: this.amount.toString(), 70 | swapper: ethers.constants.AddressZero, 71 | type: TradeType[this.type], 72 | numOutputs: this.numOutputs, 73 | protocol: this.protocol, 74 | ...(this.quoteId && { quoteId: this.quoteId }), 75 | }; 76 | } 77 | 78 | // return an opposing quote request, 79 | // i.e. quoting the other side of the trade 80 | public toOpposingCleanJSON(): QuoteRequestDataJSON { 81 | const type = this.type === TradeType.EXACT_INPUT ? TradeType.EXACT_OUTPUT : TradeType.EXACT_INPUT; 82 | return { 83 | tokenInChainId: this.tokenOutChainId, 84 | tokenOutChainId: this.tokenInChainId, 85 | requestId: this.requestId, 86 | // switch tokenIn/tokenOut 87 | tokenIn: getAddress(this.tokenOut), 88 | tokenOut: getAddress(this.tokenIn), 89 | amount: this.amount.toString(), 90 | swapper: ethers.constants.AddressZero, 91 | // switch tradeType 92 | type: TradeType[type], 93 | numOutputs: this.numOutputs, 94 | protocol: this.protocol, 95 | ...(this.quoteId && { quoteId: this.quoteId }), 96 | }; 97 | } 98 | 99 | public toOpposingRequest(): QuoteRequest { 100 | const opposingJSON = this.toOpposingCleanJSON(); 101 | return new QuoteRequest({ 102 | ...opposingJSON, 103 | amount: BigNumber.from(opposingJSON.amount), 104 | type: TradeType[opposingJSON.type as keyof typeof TradeType], 105 | }); 106 | } 107 | 108 | public get requestId(): string { 109 | return this.data.requestId; 110 | } 111 | 112 | public get tokenInChainId(): number { 113 | return this.data.tokenInChainId; 114 | } 115 | 116 | public get tokenOutChainId(): number { 117 | return this.data.tokenInChainId; 118 | } 119 | 120 | public get swapper(): string { 121 | return this.data.swapper; 122 | } 123 | 124 | public get tokenIn(): string { 125 | return this.data.tokenIn; 126 | } 127 | 128 | public get tokenOut(): string { 129 | return this.data.tokenOut; 130 | } 131 | 132 | public get amount(): BigNumber { 133 | return this.data.amount; 134 | } 135 | 136 | public get type(): TradeType { 137 | return this.data.type; 138 | } 139 | 140 | public get numOutputs(): number { 141 | return this.data.numOutputs; 142 | } 143 | 144 | public get protocol(): ProtocolVersion { 145 | return this.data.protocol; 146 | } 147 | 148 | public get quoteId(): string | undefined { 149 | return this.data.quoteId; 150 | } 151 | 152 | public set quoteId(quoteId: string | undefined) { 153 | this.data.quoteId = quoteId; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/handlers/quote/injector.ts: -------------------------------------------------------------------------------- 1 | import { IMetric, setGlobalLogger, setGlobalMetric } from '@uniswap/smart-order-router'; 2 | import { MetricsLogger } from 'aws-embedded-metrics'; 3 | import { APIGatewayProxyEvent, Context } from 'aws-lambda'; 4 | import { default as bunyan, default as Logger } from 'bunyan'; 5 | 6 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 7 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 8 | import { 9 | BETA_COMPLIANCE_S3_KEY, 10 | BETA_S3_KEY, 11 | COMPLIANCE_CONFIG_BUCKET, 12 | PRODUCTION_S3_KEY, 13 | PROD_COMPLIANCE_S3_KEY, 14 | RPC_HEADERS, 15 | WEBHOOK_CONFIG_BUCKET, 16 | } from '../../constants'; 17 | import { AWSMetricsLogger, SoftQuoteMetricDimension } from '../../entities/aws-metrics-logger'; 18 | import { S3WebhookConfigurationProvider } from '../../providers'; 19 | import { FirehoseLogger } from '../../providers/analytics'; 20 | import { DynamoCircuitBreakerConfigurationProvider } from '../../providers/circuit-breaker/dynamo'; 21 | import { S3FillerComplianceConfigurationProvider } from '../../providers/compliance/s3'; 22 | import { Quoter, WebhookQuoter } from '../../quoters'; 23 | import { DynamoFillerAddressRepository } from '../../repositories/filler-address-repository'; 24 | import { STAGE } from '../../util/stage'; 25 | import { ApiInjector, ApiRInj } from '../base/api-handler'; 26 | import { PostQuoteRequestBody } from './schema'; 27 | import { ChainId, supportedChains } from '../../util/chains'; 28 | import { ethers } from 'ethers'; 29 | import { checkDefined } from '../../preconditions/preconditions'; 30 | 31 | export interface ContainerInjected { 32 | quoters: Quoter[]; 33 | firehose: FirehoseLogger; 34 | chainIdRpcMap: Map; 35 | } 36 | 37 | export interface RequestInjected extends ApiRInj { 38 | metric: IMetric; 39 | } 40 | 41 | export class QuoteInjector extends ApiInjector { 42 | public async buildContainerInjected(): Promise { 43 | const log: Logger = bunyan.createLogger({ 44 | name: this.injectorName, 45 | serializers: bunyan.stdSerializers, 46 | level: bunyan.INFO, 47 | }); 48 | 49 | const stage = process.env['stage']; 50 | const s3Key = stage === STAGE.BETA ? BETA_S3_KEY : PRODUCTION_S3_KEY; 51 | 52 | const webhookProvider = new S3WebhookConfigurationProvider(log, `${WEBHOOK_CONFIG_BUCKET}-${stage}-1`, s3Key); 53 | const circuitBreakerProvider = new DynamoCircuitBreakerConfigurationProvider(log, webhookProvider); 54 | 55 | const complianceKey = stage === STAGE.BETA ? BETA_COMPLIANCE_S3_KEY : PROD_COMPLIANCE_S3_KEY; 56 | const fillerComplianceProvider = new S3FillerComplianceConfigurationProvider( 57 | log, 58 | `${COMPLIANCE_CONFIG_BUCKET}-${stage}-1`, 59 | complianceKey 60 | ); 61 | 62 | const firehose = new FirehoseLogger(log, process.env.ANALYTICS_STREAM_ARN!); 63 | 64 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), { 65 | marshallOptions: { 66 | convertEmptyValues: true, 67 | }, 68 | unmarshallOptions: { 69 | wrapNumbers: true, 70 | }, 71 | }); 72 | const repository = DynamoFillerAddressRepository.create(documentClient); 73 | 74 | const quoters: Quoter[] = [ 75 | new WebhookQuoter(log, firehose, webhookProvider, circuitBreakerProvider, fillerComplianceProvider, repository), 76 | ]; 77 | 78 | const chainIdRpcMap = new Map(); 79 | supportedChains.forEach( 80 | chainId => { 81 | const rpcUrl = checkDefined( 82 | process.env[`RPC_${chainId}`], 83 | `RPC_${chainId} is not defined` 84 | ); 85 | const provider = new ethers.providers.StaticJsonRpcProvider({ 86 | url: rpcUrl, 87 | headers: RPC_HEADERS 88 | }, chainId) 89 | chainIdRpcMap.set(chainId, provider); 90 | } 91 | ); 92 | 93 | return { 94 | quoters: quoters, 95 | firehose: firehose, 96 | chainIdRpcMap: chainIdRpcMap, 97 | }; 98 | } 99 | 100 | public async getRequestInjected( 101 | _containerInjected: ContainerInjected, 102 | requestBody: PostQuoteRequestBody, 103 | _requestQueryParams: void, 104 | _event: APIGatewayProxyEvent, 105 | context: Context, 106 | log: Logger, 107 | metricsLogger: MetricsLogger 108 | ): Promise { 109 | const requestId = context.awsRequestId; 110 | 111 | log = log.child({ 112 | serializers: bunyan.stdSerializers, 113 | requestBody, 114 | requestId, 115 | }); 116 | setGlobalLogger(log); 117 | 118 | metricsLogger.setNamespace('Uniswap'); 119 | metricsLogger.setDimensions(SoftQuoteMetricDimension); 120 | const metric = new AWSMetricsLogger(metricsLogger); 121 | setGlobalMetric(metric); 122 | 123 | return { 124 | log, 125 | metric, 126 | requestId, 127 | }; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /lib/entities/HardQuoteRequest.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { OrderType, UnsignedV2DutchOrder, UnsignedV3DutchOrder } from '@uniswap/uniswapx-sdk'; 3 | import { BigNumber, ethers, utils } from 'ethers'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | import { QuoteRequest, QuoteRequestDataJSON } from '.'; 7 | import { HardQuoteRequestBody } from '../handlers/hard-quote'; 8 | import { ProtocolVersion } from '../providers'; 9 | 10 | export class HardQuoteRequest { 11 | public order: UnsignedV2DutchOrder | UnsignedV3DutchOrder; 12 | private data: HardQuoteRequestBody; 13 | 14 | public static fromHardRequestBody(body: HardQuoteRequestBody, orderType: OrderType): HardQuoteRequest { 15 | return new HardQuoteRequest(body, orderType); 16 | } 17 | 18 | constructor(_data: HardQuoteRequestBody, orderType: OrderType) { 19 | this.data = { 20 | ..._data, 21 | requestId: _data.quoteId ?? uuidv4(), 22 | }; 23 | if (orderType === OrderType.Dutch_V2) { 24 | this.order = UnsignedV2DutchOrder.parse(_data.encodedInnerOrder, _data.tokenInChainId); 25 | } else if (orderType === OrderType.Dutch_V3) { 26 | this.order = UnsignedV3DutchOrder.parse(_data.encodedInnerOrder, _data.tokenInChainId); 27 | } else { 28 | throw new Error('Unsupported order type'); 29 | } 30 | } 31 | 32 | public toCleanJSON(): QuoteRequestDataJSON { 33 | return { 34 | tokenInChainId: this.tokenInChainId, 35 | tokenOutChainId: this.tokenOutChainId, 36 | swapper: ethers.constants.AddressZero, 37 | requestId: this.requestId, 38 | tokenIn: this.tokenIn, 39 | tokenOut: this.tokenOut, 40 | amount: this.amount.toString(), 41 | type: TradeType[this.type], 42 | numOutputs: this.numOutputs, 43 | ...(this.quoteId && { quoteId: this.quoteId }), 44 | protocol: ProtocolVersion.V2, 45 | }; 46 | } 47 | 48 | // return an opposing quote request, 49 | // i.e. quoting the other side of the trade 50 | public toOpposingCleanJSON(): QuoteRequestDataJSON { 51 | const type = this.type === TradeType.EXACT_INPUT ? TradeType.EXACT_OUTPUT : TradeType.EXACT_INPUT; 52 | return { 53 | ...this.toCleanJSON(), 54 | // switch tokenIn/tokenOut 55 | tokenIn: utils.getAddress(this.tokenOut), 56 | tokenOut: utils.getAddress(this.tokenIn), 57 | amount: this.amount.toString(), 58 | // switch tradeType 59 | type: TradeType[type], 60 | }; 61 | } 62 | 63 | // transforms into a quote request that can be used to query quoters 64 | public toQuoteRequest(): QuoteRequest { 65 | return new QuoteRequest({ 66 | ...this.toCleanJSON(), 67 | swapper: this.swapper, 68 | amount: this.amount, 69 | type: this.type, 70 | }); 71 | } 72 | 73 | public get requestId(): string { 74 | return this.data.requestId; 75 | } 76 | 77 | public get tokenInChainId(): number { 78 | return this.data.tokenInChainId; 79 | } 80 | 81 | public get tokenOutChainId(): number { 82 | return this.data.tokenInChainId; 83 | } 84 | 85 | public get swapper(): string { 86 | return this.order.info.swapper; 87 | } 88 | 89 | public get tokenIn(): string { 90 | return utils.getAddress(this.order.info.input.token); 91 | } 92 | 93 | public get tokenOut(): string { 94 | return utils.getAddress(this.order.info.outputs[0].token); 95 | } 96 | 97 | public get totalOutputAmountStart(): BigNumber { 98 | let amount = BigNumber.from(0); 99 | for (const output of this.order.info.outputs) { 100 | amount = amount.add(output.startAmount); 101 | } 102 | 103 | return amount; 104 | } 105 | 106 | public get totalInputAmountStart(): BigNumber { 107 | return this.order.info.input.startAmount; 108 | } 109 | 110 | public get amount(): BigNumber { 111 | if (this.type === TradeType.EXACT_INPUT) { 112 | return this.totalInputAmountStart; 113 | } else { 114 | return this.totalOutputAmountStart; 115 | } 116 | } 117 | 118 | public get type(): TradeType { 119 | if (this.order instanceof UnsignedV2DutchOrder) { 120 | return this.order.info.input.startAmount.eq(this.order.info.input.endAmount) 121 | ? TradeType.EXACT_INPUT 122 | : TradeType.EXACT_OUTPUT 123 | } 124 | else if (this.order instanceof UnsignedV3DutchOrder) { 125 | // If curve doesn't exist OR has all relative amounts are zero, then it's EXACT_INPUT 126 | return !this.order.info.input.curve || 127 | !this.order.info.input.curve.relativeAmounts.some(relativeAmount => relativeAmount !== BigInt(0)) 128 | ? TradeType.EXACT_INPUT 129 | : TradeType.EXACT_OUTPUT; 130 | } 131 | else { 132 | throw new Error('Unsupported order type'); 133 | } 134 | } 135 | 136 | public get numOutputs(): number { 137 | return this.order.info.outputs.length; 138 | } 139 | 140 | public get cosigner(): string { 141 | return this.order.info.cosigner; 142 | } 143 | 144 | public get innerSig(): string { 145 | return this.data.innerSig; 146 | } 147 | 148 | public get quoteId(): string | undefined { 149 | return this.data.quoteId; 150 | } 151 | 152 | public set quoteId(quoteId: string | undefined) { 153 | this.data.quoteId = quoteId; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/handlers/hard-quote/injector.ts: -------------------------------------------------------------------------------- 1 | import { IMetric, setGlobalLogger, setGlobalMetric } from '@uniswap/smart-order-router'; 2 | import { MetricsLogger } from 'aws-embedded-metrics'; 3 | import { APIGatewayProxyEvent, Context } from 'aws-lambda'; 4 | import { default as bunyan, default as Logger } from 'bunyan'; 5 | 6 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 7 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 8 | import { BETA_S3_KEY, PRODUCTION_S3_KEY, WEBHOOK_CONFIG_BUCKET, RPC_HEADERS } from '../../constants'; 9 | import { AWSMetricsLogger, HardQuoteMetricDimension } from '../../entities/aws-metrics-logger'; 10 | import { checkDefined } from '../../preconditions/preconditions'; 11 | import { OrderServiceProvider, S3WebhookConfigurationProvider, UniswapXServiceProvider } from '../../providers'; 12 | import { FirehoseLogger } from '../../providers/analytics'; 13 | import { DynamoCircuitBreakerConfigurationProvider } from '../../providers/circuit-breaker/dynamo'; 14 | import { MockFillerComplianceConfigurationProvider } from '../../providers/compliance'; 15 | import { Quoter, WebhookQuoter } from '../../quoters'; 16 | import { DynamoFillerAddressRepository } from '../../repositories/filler-address-repository'; 17 | import { STAGE } from '../../util/stage'; 18 | import { ApiInjector, ApiRInj } from '../base/api-handler'; 19 | import { HardQuoteRequestBody } from './schema'; 20 | import { ethers } from 'ethers'; 21 | import { ChainId, supportedChains } from '../../util/chains'; 22 | 23 | export interface ContainerInjected { 24 | quoters: Quoter[]; 25 | firehose: FirehoseLogger; 26 | orderServiceProvider: OrderServiceProvider; 27 | chainIdRpcMap: Map; 28 | } 29 | 30 | export interface RequestInjected extends ApiRInj { 31 | metric: IMetric; 32 | } 33 | 34 | export class QuoteInjector extends ApiInjector { 35 | public async buildContainerInjected(): Promise { 36 | const log: Logger = bunyan.createLogger({ 37 | name: this.injectorName, 38 | serializers: bunyan.stdSerializers, 39 | level: bunyan.INFO, 40 | }); 41 | 42 | const stage = process.env['stage']; 43 | const s3Key = stage === STAGE.BETA ? BETA_S3_KEY : PRODUCTION_S3_KEY; 44 | 45 | const orderServiceUrl = checkDefined(process.env.ORDER_SERVICE_URL, 'ORDER_SERVICE_URL is not defined'); 46 | 47 | const webhookProvider = new S3WebhookConfigurationProvider(log, `${WEBHOOK_CONFIG_BUCKET}-${stage}-1`, s3Key); 48 | const circuitBreakerProvider = new DynamoCircuitBreakerConfigurationProvider(log, webhookProvider); 49 | 50 | const orderServiceProvider = new UniswapXServiceProvider(log, orderServiceUrl); 51 | 52 | // TODO: decide if we should handle filler compliance differently 53 | //const complianceKey = stage === STAGE.BETA ? BETA_COMPLIANCE_S3_KEY : PROD_COMPLIANCE_S3_KEY; 54 | //const fillerComplianceProvider = new S3FillerComplianceConfigurationProvider( 55 | // log, 56 | // `${COMPLIANCE_CONFIG_BUCKET}-${stage}-1`, 57 | // complianceKey 58 | //); 59 | const fillerComplianceProvider = new MockFillerComplianceConfigurationProvider([]); 60 | 61 | const firehose = new FirehoseLogger(log, process.env.ANALYTICS_STREAM_ARN!); 62 | 63 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), { 64 | marshallOptions: { 65 | convertEmptyValues: true, 66 | }, 67 | unmarshallOptions: { 68 | wrapNumbers: true, 69 | }, 70 | }); 71 | const repository = DynamoFillerAddressRepository.create(documentClient); 72 | 73 | const quoters: Quoter[] = [ 74 | new WebhookQuoter(log, firehose, webhookProvider, circuitBreakerProvider, fillerComplianceProvider, repository), 75 | ]; 76 | 77 | const chainIdRpcMap = new Map(); 78 | supportedChains.forEach( 79 | chainId => { 80 | const rpcUrl = checkDefined( 81 | process.env[`RPC_${chainId}`], 82 | `RPC_${chainId} is not defined` 83 | ); 84 | const provider = new ethers.providers.StaticJsonRpcProvider({ 85 | url: rpcUrl, 86 | headers: RPC_HEADERS 87 | }, chainId) 88 | chainIdRpcMap.set( chainId, provider); 89 | } 90 | ); 91 | 92 | return { 93 | quoters: quoters, 94 | firehose: firehose, 95 | orderServiceProvider, 96 | chainIdRpcMap, 97 | }; 98 | } 99 | 100 | public async getRequestInjected( 101 | _containerInjected: ContainerInjected, 102 | requestBody: HardQuoteRequestBody, 103 | _requestQueryParams: void, 104 | _event: APIGatewayProxyEvent, 105 | context: Context, 106 | log: Logger, 107 | metricsLogger: MetricsLogger 108 | ): Promise { 109 | const requestId = context.awsRequestId; 110 | 111 | log = log.child({ 112 | serializers: bunyan.stdSerializers, 113 | requestBody, 114 | requestId, 115 | }); 116 | setGlobalLogger(log); 117 | 118 | metricsLogger.setNamespace('Uniswap'); 119 | metricsLogger.setDimensions(HardQuoteMetricDimension); 120 | const metric = new AWSMetricsLogger(metricsLogger); 121 | setGlobalMetric(metric); 122 | 123 | return { 124 | log, 125 | metric, 126 | requestId, 127 | }; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /test/handlers/hard-quote/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { UnsignedV2DutchOrder } from '@uniswap/uniswapx-sdk'; 2 | import { BigNumber, utils } from 'ethers'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | import { HardQuoteRequestBodyJoi } from '../../../lib/handlers/hard-quote'; 6 | import { getOrderInfo } from '../../entities/HardQuoteRequest.test'; 7 | 8 | const SWAPPER = '0x0000000000000000000000000000000000000000'; 9 | const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; 10 | const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 11 | const REQUEST_ID = uuidv4(); 12 | const QUOTE_ID = uuidv4(); 13 | 14 | const validTokenIn = [USDC, WETH].reduce(lowerUpper, []); 15 | const validTokenOut = [USDC, WETH].reduce(lowerUpper, []); 16 | const validAmountIn = ['1', '1000', '1234234', utils.parseEther('1').toString(), utils.parseEther('100000').toString()]; 17 | const validHardRequestBodyCombos = validTokenIn.flatMap((tokenIn) => 18 | validTokenOut.flatMap((tokenOut) => 19 | validAmountIn.flatMap((amount) => { 20 | const order = new UnsignedV2DutchOrder( 21 | getOrderInfo({ 22 | input: { 23 | token: tokenIn, 24 | startAmount: BigNumber.from(amount), 25 | endAmount: BigNumber.from(amount), 26 | }, 27 | outputs: [ 28 | { 29 | token: tokenOut, 30 | startAmount: BigNumber.from(amount), 31 | endAmount: BigNumber.from(amount), 32 | recipient: SWAPPER, 33 | }, 34 | ], 35 | }), 36 | 1 37 | ); 38 | return { 39 | requestId: REQUEST_ID, 40 | quoteId: QUOTE_ID, 41 | tokenInChainId: 1, 42 | tokenOutChainId: 1, 43 | encodedInnerOrder: order.serialize(), 44 | innerSig: 45 | '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', 46 | }; 47 | }) 48 | ) 49 | ); 50 | 51 | describe('hard-quote schemas', () => { 52 | describe('HardQuoteRequestBody', () => { 53 | it('validates valid hard requests', () => { 54 | for (const body of validHardRequestBodyCombos) { 55 | const validated = HardQuoteRequestBodyJoi.validate(body); 56 | expect(validated.error).toBeUndefined(); 57 | expect(validated.value).toStrictEqual({ 58 | tokenInChainId: 1, 59 | tokenOutChainId: 1, 60 | requestId: REQUEST_ID, 61 | quoteId: QUOTE_ID, 62 | encodedInnerOrder: body.encodedInnerOrder, 63 | innerSig: body.innerSig, 64 | }); 65 | } 66 | }); 67 | 68 | it('requires correct signature length', () => { 69 | let validated = HardQuoteRequestBodyJoi.validate( 70 | Object.assign({}, validHardRequestBodyCombos[0], { 71 | innerSig: '0x1234', 72 | }) 73 | ); 74 | expect(validated.error?.message).toMatch('Signature in wrong format'); 75 | 76 | validated = HardQuoteRequestBodyJoi.validate( 77 | Object.assign({}, validHardRequestBodyCombos[0], { 78 | innerSig: '0x123412341234123412341324132412341324132412341324134', 79 | }) 80 | ); 81 | expect(validated.error?.message).toMatch('Signature in wrong format'); 82 | 83 | validated = HardQuoteRequestBodyJoi.validate( 84 | Object.assign({}, validHardRequestBodyCombos[0], { 85 | innerSig: 86 | '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', 87 | }) 88 | ); 89 | expect(validated.error).toBeUndefined(); 90 | }); 91 | 92 | it('requires tokenInChainId to be defined', () => { 93 | const { tokenOutChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; 94 | const validated = HardQuoteRequestBodyJoi.validate({ 95 | tokenOutChainId, 96 | requestId, 97 | quoteId, 98 | encodedInnerOrder, 99 | innerSig, 100 | }); 101 | expect(validated.error?.message).toEqual('"tokenInChainId" is required'); 102 | }); 103 | 104 | it('requires tokenOutChainId to be defined', () => { 105 | const { tokenInChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; 106 | const validated = HardQuoteRequestBodyJoi.validate({ 107 | tokenInChainId, 108 | requestId, 109 | quoteId, 110 | encodedInnerOrder, 111 | innerSig, 112 | }); 113 | expect(validated.error?.message).toEqual('"tokenOutChainId" is required'); 114 | }); 115 | 116 | it('requires tokenOutChainId and tokenInChainId to be the same value', () => { 117 | const { tokenInChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; 118 | const validated = HardQuoteRequestBodyJoi.validate({ 119 | tokenInChainId, 120 | tokenOutChainId: 5, 121 | requestId, 122 | quoteId, 123 | encodedInnerOrder, 124 | innerSig, 125 | }); 126 | expect(validated.error?.message).toContain('"tokenOutChainId" must be [ref:tokenInChainId]'); 127 | }); 128 | 129 | it('requires tokenInChainId to be supported', () => { 130 | const validated = HardQuoteRequestBodyJoi.validate( 131 | Object.assign({}, validHardRequestBodyCombos[0], { tokenInChainId: 999999 }) 132 | ); 133 | expect(validated.error?.message).toContain('"tokenInChainId" must be one of'); 134 | }); 135 | }); 136 | }); 137 | 138 | function lowerUpper(list: string[], str: string): string[] { 139 | list.push(str.toLowerCase()); 140 | list.push('0x' + str.toUpperCase().slice(2)); 141 | list.push(str); 142 | return list; 143 | } 144 | -------------------------------------------------------------------------------- /test/entities/HardQuoteResponse.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CosignedV2DutchOrder, 3 | CosignerData, 4 | OrderType, 5 | UnsignedV2DutchOrder, 6 | UnsignedV2DutchOrderInfo, 7 | } from '@uniswap/uniswapx-sdk'; 8 | import { ethers, Wallet } from 'ethers'; 9 | import { parseEther } from 'ethers/lib/utils'; 10 | 11 | import { HardQuoteRequest } from '../../lib/entities'; 12 | import { HardQuoteRequestBody } from '../../lib/handlers/hard-quote'; 13 | import { getOrder } from '../handlers/hard-quote/handler.test'; 14 | import { V2HardQuoteResponse } from '../../lib/entities/V2HardQuoteResponse'; 15 | 16 | const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; 17 | const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f7'; 18 | const SWAPPER = '0x0000000000000000000000000000000000000002'; 19 | const FILLER = '0x0000000000000000000000000000000000000001'; 20 | const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; 21 | const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 22 | const CHAIN_ID = 1; 23 | const fixedTime = 4206969; 24 | jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); 25 | 26 | const DEFAULT_EXCLUSIVITY_OVERRIDE_BPS = ethers.BigNumber.from(100); 27 | 28 | describe('HardQuoteResponse', () => { 29 | const swapperWallet = Wallet.createRandom(); 30 | const cosignerWallet = Wallet.createRandom(); 31 | 32 | afterEach(() => { 33 | jest.clearAllMocks(); 34 | }); 35 | 36 | const getRequest = async (order: UnsignedV2DutchOrder): Promise => { 37 | const { types, domain, values } = order.permitData(); 38 | const sig = await swapperWallet._signTypedData(domain, types, values); 39 | return { 40 | requestId: REQUEST_ID, 41 | tokenInChainId: CHAIN_ID, 42 | tokenOutChainId: CHAIN_ID, 43 | encodedInnerOrder: order.serialize(), 44 | innerSig: sig, 45 | }; 46 | }; 47 | 48 | const getResponse = async (data: Partial, cosignerData: CosignerData) => { 49 | const unsigned = getOrder(data); 50 | const cosignature = cosignerWallet._signingKey().signDigest(unsigned.cosignatureHash(cosignerData)); 51 | const order = CosignedV2DutchOrder.fromUnsignedOrder( 52 | unsigned, 53 | cosignerData, 54 | ethers.utils.joinSignature(cosignature) 55 | ); 56 | return new V2HardQuoteResponse(new HardQuoteRequest(await getRequest(unsigned), OrderType.Dutch_V2), order); 57 | }; 58 | 59 | it('toResponseJSON', async () => { 60 | const now = Math.floor(Date.now() / 1000); 61 | const quoteResponse = await getResponse( 62 | {}, 63 | { 64 | decayStartTime: now + 100, 65 | decayEndTime: now + 200, 66 | exclusiveFiller: FILLER, 67 | exclusivityOverrideBps: DEFAULT_EXCLUSIVITY_OVERRIDE_BPS, 68 | inputOverride: parseEther('1'), 69 | outputOverrides: [parseEther('1')], 70 | } 71 | ); 72 | expect(quoteResponse.toResponseJSON()).toEqual({ 73 | requestId: REQUEST_ID, 74 | quoteId: QUOTE_ID, 75 | chainId: CHAIN_ID, 76 | filler: FILLER, 77 | encodedOrder: quoteResponse.order.serialize(), 78 | orderHash: quoteResponse.order.hash(), 79 | }); 80 | }); 81 | 82 | it('toLog', async () => { 83 | const now = Math.floor(Date.now() / 1000); 84 | const quoteResponse = await getResponse( 85 | {}, 86 | { 87 | decayStartTime: now + 100, 88 | decayEndTime: now + 200, 89 | exclusiveFiller: FILLER, 90 | exclusivityOverrideBps: DEFAULT_EXCLUSIVITY_OVERRIDE_BPS, 91 | inputOverride: ethers.utils.parseEther('1'), 92 | outputOverrides: [ethers.utils.parseEther('1')], 93 | } 94 | ); 95 | expect(quoteResponse.toLog()).toEqual({ 96 | createdAt: expect.any(String), 97 | createdAtMs: expect.any(String), 98 | amountOut: parseEther('1').toString(), 99 | amountIn: parseEther('1').toString(), 100 | quoteId: QUOTE_ID, 101 | requestId: REQUEST_ID, 102 | swapper: SWAPPER, 103 | tokenIn: TOKEN_IN, 104 | tokenOut: TOKEN_OUT, 105 | filler: FILLER, 106 | tokenInChainId: CHAIN_ID, 107 | tokenOutChainId: CHAIN_ID, 108 | }); 109 | 110 | it('amountOut uses post cosigned resolution', async () => { 111 | const now = Math.floor(Date.now() / 1000); 112 | const quoteResponse = await getResponse( 113 | {}, 114 | { 115 | decayStartTime: now + 100, 116 | decayEndTime: now + 200, 117 | exclusiveFiller: FILLER, 118 | exclusivityOverrideBps: DEFAULT_EXCLUSIVITY_OVERRIDE_BPS, 119 | inputOverride: parseEther('1'), 120 | outputOverrides: [parseEther('2')], 121 | } 122 | ); 123 | expect(quoteResponse.amountOut).toEqual(parseEther('2')); 124 | }); 125 | 126 | it('amountIn uses post cosigned resolution', async () => { 127 | const now = Math.floor(Date.now() / 1000); 128 | const quoteResponse = await getResponse( 129 | { 130 | cosigner: cosignerWallet.address, 131 | input: { 132 | token: TOKEN_IN, 133 | startAmount: parseEther('1'), 134 | endAmount: parseEther('1.1'), 135 | }, 136 | outputs: [ 137 | { 138 | token: TOKEN_OUT, 139 | startAmount: parseEther('1'), 140 | endAmount: parseEther('1'), 141 | recipient: ethers.constants.AddressZero, 142 | }, 143 | ], 144 | }, 145 | { 146 | decayStartTime: now + 100, 147 | decayEndTime: now + 200, 148 | exclusiveFiller: FILLER, 149 | exclusivityOverrideBps: DEFAULT_EXCLUSIVITY_OVERRIDE_BPS, 150 | inputOverride: parseEther('0.8'), 151 | outputOverrides: [parseEther('1')], 152 | } 153 | ); 154 | expect(quoteResponse.amountIn).toEqual(parseEther('0.8')); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/integ/quote.test.ts: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import chaiSubset from 'chai-subset'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | import { PostQuoteRequestBody } from '../../lib/handlers/quote'; 7 | import { ProtocolVersion } from '../../lib/providers'; 8 | import AxiosUtils from '../util/axios'; 9 | 10 | chai.use(chaiAsPromised); 11 | chai.use(chaiSubset); 12 | 13 | if (!process.env.UNISWAP_API) { 14 | throw new Error('Must set UNISWAP_API env variable for integ tests. See README'); 15 | } 16 | 17 | const API = `${process.env.UNISWAP_API!}quote`; 18 | const REQUEST_ID = uuidv4(); 19 | const SWAPPER = '0x0000000000000000000000000000000000000000'; 20 | const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; 21 | const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 22 | 23 | describe('Quote endpoint integration test', function () { 24 | // TODO: re-add these test cases once market makers are actively quoting 25 | 26 | it(`succeeds basic quote roundtrip`, async () => { 27 | const quoteReq: PostQuoteRequestBody = { 28 | requestId: REQUEST_ID, 29 | tokenInChainId: 1, 30 | tokenOutChainId: 1, 31 | swapper: SWAPPER, 32 | tokenIn: TOKEN_IN, 33 | tokenOut: TOKEN_OUT, 34 | amount: '1', 35 | type: 'EXACT_INPUT', 36 | numOutputs: 1, 37 | protocol: ProtocolVersion.V1, 38 | }; 39 | 40 | const { data, status } = await AxiosUtils.callPassThroughFail('POST', API, quoteReq); 41 | expect([404, 200]).to.include(status); 42 | if (status == 404) { 43 | expect(data.detail).to.be.equal('No quotes available'); 44 | } else { 45 | expect(data.requestId).to.be.equal(REQUEST_ID); 46 | expect(data.swapper).to.be.equal(SWAPPER); 47 | expect(data.tokenIn).to.be.equal(TOKEN_IN); 48 | expect(data.tokenOut).to.be.equal(TOKEN_OUT); 49 | } 50 | }); 51 | 52 | // it(`succeeds basic quote polygon`, async () => { 53 | // const quoteReq: PostQuoteRequestBody = { 54 | // requestId: REQUEST_ID, 55 | // tokenInChainId: 137, 56 | // tokenOutChainId: 137, 57 | // swapper: SWAPPER, 58 | // tokenIn: TOKEN_IN, 59 | // tokenOut: TOKEN_OUT, 60 | // amount: '1', 61 | // type: 'EXACT_INPUT', 62 | // }; 63 | 64 | // const quoteResponse = await call('POST', API, quoteReq); 65 | // expect(quoteResponse).to.be.not.equal(null); 66 | // expect(quoteResponse.requestId).to.be.equal(REQUEST_ID); 67 | // expect(quoteResponse.swapper).to.be.equal(SWAPPER); 68 | // expect(quoteResponse.tokenIn).to.be.equal(TOKEN_IN); 69 | // expect(quoteResponse.tokenOut).to.be.equal(TOKEN_OUT); 70 | // }); 71 | 72 | it(`fails request validation, bad request id`, async () => { 73 | const quoteReq = { 74 | requestId: 'bad_request_id', 75 | tokenInChainId: 1, 76 | tokenOutChainId: 1, 77 | swapper: SWAPPER, 78 | tokenIn: TOKEN_IN, 79 | tokenOut: TOKEN_OUT, 80 | amount: '1', 81 | type: 'EXACT_INPUT', 82 | numOutputs: 12341234, 83 | }; 84 | 85 | await AxiosUtils.callAndExpectFail('POST', API, quoteReq, { 86 | status: 400, 87 | data: { 88 | detail: '"requestId" must be a valid GUID', 89 | errorCode: 'VALIDATION_ERROR', 90 | }, 91 | }); 92 | }); 93 | 94 | it(`fails request validation, missing amount`, async () => { 95 | const quoteReq = { 96 | requestId: REQUEST_ID, 97 | tokenInChainId: 1, 98 | tokenOutChainId: 1, 99 | swapper: SWAPPER, 100 | tokenIn: TOKEN_IN, 101 | tokenOut: TOKEN_OUT, 102 | type: 'EXACT_INPUT', 103 | numOutputs: 12341234, 104 | }; 105 | 106 | await AxiosUtils.callAndExpectFail('POST', API, quoteReq, { 107 | status: 400, 108 | data: { 109 | detail: '"amount" is required', 110 | errorCode: 'VALIDATION_ERROR', 111 | }, 112 | }); 113 | }); 114 | 115 | it(`fails request validation, incorrect trade type`, async () => { 116 | const quoteReq = { 117 | requestId: REQUEST_ID, 118 | tokenInChainId: 1, 119 | tokenOutChainId: 1, 120 | swapper: SWAPPER, 121 | tokenIn: TOKEN_IN, 122 | tokenOut: TOKEN_OUT, 123 | type: 'EXACT_NOTHING', 124 | amount: '1', 125 | numOutputs: 12341234, 126 | }; 127 | 128 | await AxiosUtils.callAndExpectFail('POST', API, quoteReq, { 129 | status: 400, 130 | data: { 131 | detail: '"type" must be one of [EXACT_INPUT, EXACT_OUTPUT]', 132 | errorCode: 'VALIDATION_ERROR', 133 | }, 134 | }); 135 | }); 136 | 137 | it(`fails request validation, incorrect tokenIn`, async () => { 138 | const quoteReq = { 139 | requestId: REQUEST_ID, 140 | tokenInChainId: 1, 141 | tokenOutChainId: 1, 142 | swapper: SWAPPER, 143 | tokenIn: 'USDC', 144 | tokenOut: TOKEN_OUT, 145 | type: 'EXACT_OUTPUT', 146 | amount: '1', 147 | numOutputs: 12341234, 148 | }; 149 | 150 | await AxiosUtils.callAndExpectFail('POST', API, quoteReq, { 151 | status: 400, 152 | data: { 153 | detail: 'Invalid address', 154 | errorCode: 'VALIDATION_ERROR', 155 | }, 156 | }); 157 | }); 158 | 159 | it(`fails request validation, incorrect tokenOutChainId`, async () => { 160 | const quoteReq = { 161 | requestId: REQUEST_ID, 162 | tokenInChainId: 1, 163 | tokenOutChainId: 5, 164 | swapper: SWAPPER, 165 | tokenIn: TOKEN_IN, 166 | tokenOut: TOKEN_OUT, 167 | type: 'EXACT_OUTPUT', 168 | amount: '1', 169 | numOutputs: 12341234, 170 | }; 171 | 172 | await AxiosUtils.callAndExpectFail('POST', API, quoteReq, { 173 | status: 400, 174 | data: { 175 | detail: '"tokenOutChainId" must be [ref:tokenInChainId]', 176 | errorCode: 'VALIDATION_ERROR', 177 | }, 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /lib/entities/QuoteResponse.ts: -------------------------------------------------------------------------------- 1 | import { TradeType } from '@uniswap/sdk-core'; 2 | import { BigNumber } from 'ethers'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | import { QuoteRequestData } from '.'; 6 | import { PostQuoteResponse, RfqResponse, RfqResponseJoi } from '../handlers/quote/schema'; 7 | import { currentTimestampInMs, timestampInMstoSeconds } from '../util/time'; 8 | 9 | export interface QuoteResponseData 10 | extends Omit { 11 | chainId: number; 12 | amountOut: BigNumber; 13 | amountIn: BigNumber; 14 | filler?: string; 15 | quoteId: string; 16 | } 17 | 18 | export interface QuoteMetadata { 19 | endpoint: string; 20 | fillerName: string; 21 | } 22 | 23 | type ValidationError = { 24 | message: string | undefined; 25 | value: { [key: string]: any }; 26 | }; 27 | 28 | interface ValidatedResponse { 29 | response: QuoteResponse; 30 | validationError?: ValidationError; 31 | } 32 | 33 | interface FromRfqArgs { 34 | request: QuoteRequestData; 35 | data: RfqResponse; 36 | type: TradeType; 37 | metadata: QuoteMetadata; 38 | } 39 | 40 | interface FromRequestArgs { 41 | request: QuoteRequestData; 42 | amountQuoted: BigNumber; 43 | metadata: QuoteMetadata; 44 | filler?: string; 45 | } 46 | 47 | // data class for QuoteRequest helpers and conversions 48 | export class QuoteResponse implements QuoteResponseData { 49 | public createdAt: string; 50 | 51 | public static fromRequest(args: FromRequestArgs): QuoteResponse { 52 | const { request, amountQuoted, metadata, filler } = args; 53 | return new QuoteResponse( 54 | { 55 | chainId: request.tokenInChainId, // TODO: update schema 56 | requestId: request.requestId, 57 | swapper: request.swapper, 58 | tokenIn: request.tokenIn, 59 | tokenOut: request.tokenOut, 60 | amountIn: request.type === TradeType.EXACT_INPUT ? request.amount : amountQuoted, 61 | amountOut: request.type === TradeType.EXACT_OUTPUT ? request.amount : amountQuoted, 62 | filler: filler, 63 | quoteId: request.quoteId ?? uuidv4(), 64 | }, 65 | request.type, 66 | metadata 67 | ); 68 | } 69 | 70 | public static fromRFQ(args: FromRfqArgs): ValidatedResponse { 71 | const { request, data, type, metadata } = args; 72 | let validationError: ValidationError | undefined; 73 | 74 | const responseValidation = RfqResponseJoi.validate(data, { 75 | allowUnknown: true, 76 | stripUnknown: true, 77 | }); 78 | 79 | if (responseValidation?.error) { 80 | validationError = { 81 | message: responseValidation.error?.message, 82 | value: data, 83 | }; 84 | } 85 | 86 | // ensure quoted tokens match 87 | if ( 88 | request?.tokenIn?.toLowerCase() !== data?.tokenIn?.toLowerCase() || 89 | request?.tokenOut?.toLowerCase() !== data?.tokenOut?.toLowerCase() 90 | ) { 91 | validationError = { 92 | message: `RFQ response token mismatch: request tokenIn: ${request.tokenIn} tokenOut: ${request.tokenOut} response tokenIn: ${data.tokenIn} tokenOut: ${data.tokenOut}`, 93 | value: data, 94 | }; 95 | } 96 | 97 | // take quoted amount from RFQ response 98 | // but specified amount from request to avoid any inaccuracies from incorrect echoed response 99 | const [amountIn, amountOut] = 100 | request.type === TradeType.EXACT_INPUT 101 | ? [request.amount, BigNumber.from(data.amountOut ?? 0)] 102 | : [BigNumber.from(data.amountIn ?? 0), request.amount]; 103 | return { 104 | response: new QuoteResponse( 105 | { 106 | ...data, 107 | quoteId: data.quoteId ?? uuidv4(), 108 | swapper: request.swapper, 109 | amountIn, 110 | amountOut, 111 | }, 112 | type, 113 | metadata 114 | ), 115 | ...(validationError && { validationError }), 116 | }; 117 | } 118 | 119 | constructor( 120 | private data: QuoteResponseData, 121 | public type: TradeType, 122 | public metadata: QuoteMetadata, 123 | public createdAtMs = currentTimestampInMs() 124 | ) { 125 | this.createdAt = timestampInMstoSeconds(parseInt(this.createdAtMs)); 126 | } 127 | 128 | public toResponseJSON(): PostQuoteResponse & { quoteId: string } { 129 | return { 130 | quoteId: this.quoteId, 131 | chainId: this.chainId, 132 | requestId: this.requestId, 133 | tokenIn: this.tokenIn, 134 | amountIn: this.amountIn.toString(), 135 | tokenOut: this.tokenOut, 136 | amountOut: this.amountOut.toString(), 137 | swapper: this.swapper, 138 | filler: this.filler, 139 | }; 140 | } 141 | 142 | public toLog() { 143 | return { 144 | quoteId: this.quoteId, 145 | requestId: this.requestId, 146 | tokenInChainId: this.chainId, 147 | tokenOutChainId: this.chainId, 148 | tokenIn: this.tokenIn, 149 | amountIn: this.amountIn.toString(), 150 | tokenOut: this.tokenOut, 151 | amountOut: this.amountOut.toString(), 152 | swapper: this.swapper, 153 | filler: this.filler, 154 | createdAt: this.createdAt, 155 | createdAtMs: this.createdAtMs, 156 | }; 157 | } 158 | 159 | public get quoteId(): string { 160 | return this.data.quoteId; 161 | } 162 | 163 | public get requestId(): string { 164 | return this.data.requestId; 165 | } 166 | 167 | public get chainId(): number { 168 | return this.data.chainId; 169 | } 170 | 171 | public get swapper(): string { 172 | return this.data.swapper; 173 | } 174 | 175 | public get tokenIn(): string { 176 | return this.data.tokenIn; 177 | } 178 | 179 | public get amountIn(): BigNumber { 180 | return this.data.amountIn; 181 | } 182 | 183 | public get tokenOut(): string { 184 | return this.data.tokenOut; 185 | } 186 | 187 | public get amountOut(): BigNumber { 188 | return this.data.amountOut; 189 | } 190 | 191 | public get filler(): string | undefined { 192 | return this.data.filler; 193 | } 194 | 195 | public get endpoint(): string { 196 | return this.metadata.endpoint; 197 | } 198 | 199 | public get fillerName(): string { 200 | return this.metadata.fillerName; 201 | } 202 | } -------------------------------------------------------------------------------- /bin/stacks/cron-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 | 5 | import { Metric, metricContext, SyntheticSwitchMetricDimension } from '../../lib/entities'; 6 | import { LambdaWidget } from './param-dashboard-stack'; 7 | 8 | const PERIOD = 5 * 60; 9 | 10 | const OrdersQueryExecutionTime = (region: string): LambdaWidget => ({ 11 | height: 6, 12 | width: 12, 13 | y: 0, 14 | x: 0, 15 | type: 'metric', 16 | properties: { 17 | metrics: [['Uniswap', Metric.SYNTH_ORDERS_QUERY_TIME, 'Service', SyntheticSwitchMetricDimension.Service]], 18 | view: 'timeSeries', 19 | region, 20 | stat: 'p90', 21 | period: PERIOD, 22 | stacked: false, 23 | title: 'Orders Query Execution Time', 24 | }, 25 | }); 26 | 27 | const OrdersFetchedWidget = (region: string): LambdaWidget => ({ 28 | height: 6, 29 | width: 12, 30 | y: 0, 31 | x: 0, 32 | type: 'metric', 33 | properties: { 34 | metrics: [['Uniswap', Metric.SYNTH_ORDERS, 'Service', SyntheticSwitchMetricDimension.Service]], 35 | view: 'timeSeries', 36 | region, 37 | stat: 'Sum', 38 | period: PERIOD, 39 | stacked: false, 40 | title: 'Orders Fetched', 41 | }, 42 | }); 43 | 44 | const DynamoErrorRateWidget = (region: string): LambdaWidget => ({ 45 | height: 6, 46 | width: 12, 47 | y: 0, 48 | x: 0, 49 | type: 'metric', 50 | properties: { 51 | metrics: [ 52 | [ 53 | { 54 | expression: `100*(m1/m2)`, 55 | id: `e1`, 56 | region, 57 | }, 58 | ], 59 | [ 60 | 'Uniswap', 61 | Metric.DYNAMO_REQUEST_ERROR, 62 | 'Service', 63 | SyntheticSwitchMetricDimension.Service, 64 | { 65 | label: 'DynamoDB Error Rate', 66 | id: 'm1', 67 | visible: false, 68 | }, 69 | ], 70 | [ 71 | 'Uniswap', 72 | Metric.DYNAMO_REQUEST, 73 | 'Service', 74 | SyntheticSwitchMetricDimension.Service, 75 | { 76 | label: 'DynamoDB Request Count', 77 | id: 'm2', 78 | visible: false, 79 | }, 80 | ], 81 | ], 82 | view: 'timeSeries', 83 | region, 84 | stat: 'Sum', 85 | period: PERIOD, 86 | stacked: false, 87 | title: 'DynamoDB Error Rate', 88 | yAxis: { 89 | left: { 90 | label: 'Percent', 91 | showUnits: false, 92 | }, 93 | }, 94 | }, 95 | }); 96 | 97 | const DynamoErrorRateOrdersQueryWidget = (region: string): LambdaWidget => ({ 98 | height: 6, 99 | width: 12, 100 | y: 0, 101 | x: 0, 102 | type: 'metric', 103 | properties: { 104 | metrics: [ 105 | [ 106 | 'Uniswap', 107 | metricContext(Metric.DYNAMO_REQUEST_ERROR, 'orders_network'), 108 | 'Service', 109 | SyntheticSwitchMetricDimension.Service, 110 | ], 111 | [ 112 | 'Uniswap', 113 | metricContext(Metric.DYNAMO_REQUEST_ERROR, 'orders_status'), 114 | 'Service', 115 | SyntheticSwitchMetricDimension.Service, 116 | ], 117 | [ 118 | 'Uniswap', 119 | metricContext(Metric.DYNAMO_REQUEST_ERROR, 'orders_unknown'), 120 | 'Service', 121 | SyntheticSwitchMetricDimension.Service, 122 | ], 123 | ], 124 | view: 'timeSeries', 125 | region, 126 | stat: 'Sum', 127 | period: PERIOD, 128 | stacked: false, 129 | title: 'DynamoDB Orders Query Error Rate Breakdown', 130 | yAxis: { 131 | left: { 132 | label: 'Percent', 133 | showUnits: false, 134 | }, 135 | }, 136 | }, 137 | }); 138 | 139 | const OrdersOutcomeWidget = (region: string, lambdaName: string): LambdaWidget => ({ 140 | height: 6, 141 | width: 24, 142 | y: 6, 143 | x: 0, 144 | type: 'log', 145 | properties: { 146 | view: 'table', 147 | region, 148 | stacked: false, 149 | title: 'Synth Switch Flipped', 150 | query: `SOURCE '/aws/lambda/${lambdaName}' | fields @timestamp, msg\n| filter msg like '[Disabling]' or msg like '[Enabling]'\n| sort @timestamp desc`, 151 | }, 152 | }); 153 | 154 | const CircuitBreakerTriggeredWidget = (region: string, lambdaName: string): LambdaWidget => ({ 155 | height: 6, 156 | width: 24, 157 | y: 6, 158 | x: 0, 159 | type: 'log', 160 | properties: { 161 | view: 'table', 162 | region, 163 | stacked: false, 164 | title: 'Circuit Breaker Triggered', 165 | query: `SOURCE '/aws/lambda/${lambdaName}' | fields @timestamp, msg\n| filter msg like 'circuit breaker triggered'\n| sort @timestamp desc`, 166 | }, 167 | }); 168 | 169 | const SynthSwitchFlippedWidget = (region: string): LambdaWidget => ({ 170 | height: 11, 171 | width: 24, 172 | y: 0, 173 | x: 0, 174 | type: 'metric', 175 | properties: { 176 | metrics: [ 177 | ['Uniswap', Metric.SYNTH_ORDERS_POSITIVE_OUTCOME, 'Service', SyntheticSwitchMetricDimension.Service], 178 | ['Uniswap', Metric.SYNTH_ORDERS_NEGATIVE_OUTCOME, 'Service', SyntheticSwitchMetricDimension.Service], 179 | ], 180 | view: 'timeSeries', 181 | region, 182 | stat: 'Average', 183 | period: PERIOD, 184 | stacked: true, 185 | title: 'Orders Outcome', 186 | }, 187 | }); 188 | 189 | export interface CronDashboardStackProps extends cdk.NestedStackProps { 190 | synthSwitchLambdaName: string; 191 | quoteLambdaName: string; 192 | } 193 | 194 | export class CronDashboardStack extends cdk.NestedStack { 195 | constructor(scope: Construct, name: string, props: CronDashboardStackProps) { 196 | super(scope, name, props); 197 | 198 | const region = cdk.Stack.of(this).region; 199 | 200 | new aws_cloudwatch.CfnDashboard(this, 'UniswapXCronDashboard', { 201 | dashboardName: `UniswapXCronDashboard`, 202 | dashboardBody: JSON.stringify({ 203 | periodOverride: 'inherit', 204 | widgets: [ 205 | OrdersFetchedWidget(region), 206 | OrdersQueryExecutionTime(region), 207 | DynamoErrorRateWidget(region), 208 | DynamoErrorRateOrdersQueryWidget(region), 209 | OrdersOutcomeWidget(region, props.synthSwitchLambdaName), 210 | CircuitBreakerTriggeredWidget(region, props.quoteLambdaName), 211 | SynthSwitchFlippedWidget(region), 212 | ], 213 | }), 214 | }); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /test/handlers/hard-quote/handlerDutchV3.test.ts: -------------------------------------------------------------------------------- 1 | import { KMSClient } from '@aws-sdk/client-kms'; 2 | import { KmsSigner } from '@uniswap/signer'; 3 | import { USDT_ARBITRUM, WBTC_ARBITRUM } from '@uniswap/smart-order-router'; 4 | import { 5 | CosignedV3DutchOrder, 6 | UnsignedV3DutchOrder, 7 | UnsignedV3DutchOrderInfo, 8 | V3DutchOrderBuilder, 9 | } from '@uniswap/uniswapx-sdk'; 10 | import { createMetricsLogger } from 'aws-embedded-metrics'; 11 | import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; 12 | import { default as Logger } from 'bunyan'; 13 | import { BigNumber, ethers, Wallet } from 'ethers'; 14 | import { AWSMetricsLogger } from '../../../lib/entities/aws-metrics-logger'; 15 | import { ApiInjector } from '../../../lib/handlers/base/api-handler'; 16 | import { 17 | ContainerInjected, 18 | HardQuoteHandler, 19 | HardQuoteRequestBody, 20 | HardQuoteResponseData, 21 | RequestInjected, 22 | } from '../../../lib/handlers/hard-quote'; 23 | import { MockOrderServiceProvider } from '../../../lib/providers'; 24 | import { MockQuoter, Quoter } from '../../../lib/quoters'; 25 | 26 | jest.mock('axios'); 27 | jest.mock('@aws-sdk/client-kms'); 28 | jest.mock('@uniswap/signer'); 29 | 30 | //const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; 31 | const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; 32 | const TOKEN_IN = USDT_ARBITRUM; 33 | const TOKEN_OUT = WBTC_ARBITRUM; 34 | const RAW_AMOUNT = BigNumber.from('1000000000000000000'); 35 | const CHAIN_ID = 42161; 36 | 37 | const logger = Logger.createLogger({ name: 'test' }); 38 | logger.level(Logger.FATAL); 39 | 40 | process.env.KMS_KEY_ID = 'test-key-id'; 41 | process.env.REGION = 'us-east-2'; 42 | 43 | export const getPartialOrder = (data: Partial): UnsignedV3DutchOrder => { 44 | const now = Math.floor(new Date().getTime() / 1000); 45 | const validPartialOrder = new V3DutchOrderBuilder(CHAIN_ID) 46 | .cosigner(data.cosigner ?? ethers.constants.AddressZero) 47 | .deadline(now + 1000) 48 | .swapper(ethers.constants.AddressZero) 49 | .nonce(BigNumber.from(100)) 50 | .startingBaseFee(BigNumber.from(0)) 51 | .input({ 52 | token: TOKEN_IN.address, 53 | startAmount: RAW_AMOUNT, 54 | curve: { 55 | relativeBlocks: [], 56 | relativeAmounts: [], 57 | }, 58 | maxAmount: RAW_AMOUNT, 59 | adjustmentPerGweiBaseFee: BigNumber.from(0), 60 | }) 61 | .output({ 62 | token: TOKEN_OUT.address, 63 | startAmount: RAW_AMOUNT, 64 | curve: { 65 | relativeBlocks: [4], 66 | relativeAmounts: [BigInt(4)], 67 | }, 68 | recipient: ethers.constants.AddressZero, 69 | minAmount: RAW_AMOUNT.sub(4), 70 | adjustmentPerGweiBaseFee: BigNumber.from(0), 71 | }) 72 | 73 | .buildPartial(); 74 | 75 | return validPartialOrder; 76 | }; 77 | 78 | describe('Quote handler', () => { 79 | const swapperWallet = Wallet.createRandom(); 80 | const cosignerWallet = Wallet.createRandom(); 81 | 82 | const mockGetAddress = jest.fn().mockResolvedValue(cosignerWallet.address); 83 | const mockSignDigest = jest 84 | .fn() 85 | .mockImplementation((digest) => cosignerWallet.signMessage(ethers.utils.arrayify(digest))); 86 | 87 | (KmsSigner as jest.Mock).mockImplementation(() => ({ 88 | getAddress: mockGetAddress, 89 | signDigest: mockSignDigest, 90 | })); 91 | (KMSClient as jest.Mock).mockImplementation(() => jest.fn()); 92 | 93 | // Creating mocks for all the handler dependencies. 94 | const requestInjectedMock: Promise = new Promise( 95 | (resolve) => 96 | resolve({ 97 | log: logger, 98 | requestId: 'test', 99 | metric: new AWSMetricsLogger(createMetricsLogger()), 100 | }) as unknown as RequestInjected 101 | ); 102 | 103 | const injectorPromiseMock = ( 104 | quoters: Quoter[] 105 | ): Promise> => 106 | new Promise((resolve) => 107 | resolve({ 108 | getContainerInjected: () => { 109 | return { 110 | quoters, 111 | orderServiceProvider: new MockOrderServiceProvider(), 112 | // Mock chainIdRpcMap 113 | chainIdRpcMap: new Map([ 114 | [42161, new ethers.providers.StaticJsonRpcProvider()], 115 | ]), 116 | }; 117 | }, 118 | getRequestInjected: () => requestInjectedMock, 119 | } as unknown as ApiInjector) 120 | ); 121 | 122 | const getQuoteHandler = (quoters: Quoter[]) => new HardQuoteHandler('quote', injectorPromiseMock(quoters)); 123 | 124 | const getEvent = (request: HardQuoteRequestBody): APIGatewayProxyEvent => 125 | ({ 126 | body: JSON.stringify(request), 127 | } as APIGatewayProxyEvent); 128 | 129 | const getRequest = async (order: UnsignedV3DutchOrder): Promise => { 130 | const { types, domain, values } = order.permitData(); 131 | const sig = await swapperWallet._signTypedData(domain, types, values); 132 | return { 133 | requestId: REQUEST_ID, 134 | tokenInChainId: CHAIN_ID, 135 | tokenOutChainId: CHAIN_ID, 136 | encodedInnerOrder: order.serialize(), 137 | innerSig: sig, 138 | }; 139 | }; 140 | 141 | afterEach(() => { 142 | jest.clearAllMocks(); 143 | }); 144 | 145 | it.skip('Simple request and response', async () => { 146 | // Skip until V3 Order Service is ready 147 | const quoters = [new MockQuoter(logger, 1, 1)]; 148 | const request = await getRequest(getPartialOrder({ cosigner: cosignerWallet.address })); 149 | 150 | const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( 151 | getEvent(request), 152 | {} as unknown as Context 153 | ); 154 | const quoteResponse: HardQuoteResponseData = JSON.parse(response.body); // random quoteId 155 | expect(response.statusCode).toEqual(200); 156 | expect(quoteResponse.requestId).toEqual(request.requestId); 157 | expect(quoteResponse.quoteId).toEqual(request.quoteId); 158 | expect(quoteResponse.chainId).toEqual(request.tokenInChainId); 159 | expect(quoteResponse.filler).toEqual(ethers.constants.AddressZero); 160 | const cosignedOrder = CosignedV3DutchOrder.parse(quoteResponse.encodedOrder, CHAIN_ID); 161 | 162 | // no overrides since quote was same as request 163 | expect(cosignedOrder.info.cosignerData.exclusiveFiller).toEqual(ethers.constants.AddressZero); 164 | expect(cosignedOrder.info.cosignerData.inputOverride).toEqual(BigNumber.from(0)); 165 | expect(cosignedOrder.info.cosignerData.outputOverrides.length).toEqual(1); 166 | expect(cosignedOrder.info.cosignerData.outputOverrides[0]).toEqual(BigNumber.from(0)); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /test/crons/fade-rate-v2.test.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan'; 2 | 3 | import { 4 | BASE_BLOCK_SECS, 5 | calculateNewTimestamps, 6 | FillerFades, 7 | FillerTimestamps, 8 | getFillersNewFades, 9 | NUM_FADES_MULTIPLIER, 10 | } from '../../lib/cron/fade-rate-v2'; 11 | import { ToUpdateTimestampRow, V2FadesRowType } from '../../lib/repositories'; 12 | 13 | const now = Math.floor(Date.now() / 1000); 14 | 15 | const FADES_ROWS: V2FadesRowType[] = [ 16 | // filler1 17 | { fillerAddress: '0x0000000000000000000000000000000000000001', faded: 1, postTimestamp: now - 100 }, 18 | { fillerAddress: '0x0000000000000000000000000000000000000001', faded: 0, postTimestamp: now - 90 }, 19 | { fillerAddress: '0x0000000000000000000000000000000000000001', faded: 1, postTimestamp: now - 80 }, 20 | { fillerAddress: '0x0000000000000000000000000000000000000002', faded: 1, postTimestamp: now - 80 }, 21 | // filler2 22 | { fillerAddress: '0x0000000000000000000000000000000000000003', faded: 1, postTimestamp: now - 70 }, 23 | { fillerAddress: '0x0000000000000000000000000000000000000003', faded: 1, postTimestamp: now - 100 }, 24 | // filler3 25 | { fillerAddress: '0x0000000000000000000000000000000000000004', faded: 1, postTimestamp: now - 100 }, 26 | // filler4 27 | { fillerAddress: '0x0000000000000000000000000000000000000005', faded: 0, postTimestamp: now - 100 }, 28 | // filler5 29 | { fillerAddress: '0x0000000000000000000000000000000000000006', faded: 0, postTimestamp: now - 100 }, 30 | // filler6 31 | { fillerAddress: '0x0000000000000000000000000000000000000007', faded: 1, postTimestamp: now - 100 }, 32 | // filler7 33 | { fillerAddress: '0x0000000000000000000000000000000000000008', faded: 1, postTimestamp: now - 100 }, 34 | // filler8 35 | { fillerAddress: '0x0000000000000000000000000000000000000009', faded: 0, postTimestamp: now - 100 }, 36 | ]; 37 | 38 | const ADDRESS_TO_FILLER = new Map([ 39 | ['0x0000000000000000000000000000000000000001', 'filler1'], 40 | ['0x0000000000000000000000000000000000000002', 'filler1'], 41 | ['0x0000000000000000000000000000000000000003', 'filler2'], 42 | ['0x0000000000000000000000000000000000000004', 'filler3'], 43 | ['0x0000000000000000000000000000000000000005', 'filler4'], 44 | ['0x0000000000000000000000000000000000000006', 'filler5'], 45 | ['0x0000000000000000000000000000000000000007', 'filler6'], 46 | ['0x0000000000000000000000000000000000000008', 'filler7'], 47 | ['0x0000000000000000000000000000000000000009', 'filler8'], 48 | ]); 49 | 50 | const FILLER_TIMESTAMPS: FillerTimestamps = new Map([ 51 | ['filler1', { lastPostTimestamp: now - 150, blockUntilTimestamp: NaN, consecutiveBlocks: NaN }], 52 | ['filler2', { lastPostTimestamp: now - 75, blockUntilTimestamp: now - 50, consecutiveBlocks: 0 }], 53 | ['filler3', { lastPostTimestamp: now - 101, blockUntilTimestamp: now + 1000, consecutiveBlocks: 0 }], 54 | ['filler4', { lastPostTimestamp: now - 150, blockUntilTimestamp: NaN, consecutiveBlocks: 0 }], 55 | ['filler5', { lastPostTimestamp: now - 150, blockUntilTimestamp: now + 100, consecutiveBlocks: 0 }], 56 | ['filler7', { lastPostTimestamp: now - 150, blockUntilTimestamp: now - 50, consecutiveBlocks: 2 }], 57 | ['filler8', { lastPostTimestamp: now - 150, blockUntilTimestamp: now - 50, consecutiveBlocks: 2 }], 58 | ]); 59 | 60 | // silent logger in tests 61 | const logger = Logger.createLogger({ name: 'test' }); 62 | logger.level(Logger.FATAL); 63 | 64 | describe('FadeRateCron test', () => { 65 | let newFades: FillerFades; 66 | beforeAll(() => { 67 | newFades = getFillersNewFades(FADES_ROWS, ADDRESS_TO_FILLER, FILLER_TIMESTAMPS, logger); 68 | }); 69 | 70 | describe('getFillersNewFades', () => { 71 | it('takes into account multiple filler addresses of the same filler', () => { 72 | expect(newFades).toEqual({ 73 | filler1: 3, // count all fades in FADES_ROWS 74 | filler2: 1, // only count postTimestamp == now - 70 75 | filler3: 1, 76 | filler4: 0, 77 | filler5: 0, 78 | filler6: 1, 79 | filler7: 1, 80 | filler8: 0, 81 | }); 82 | }); 83 | }); 84 | 85 | describe('calculateNewTimestamps', () => { 86 | let newTimestamps: ToUpdateTimestampRow[]; 87 | 88 | beforeAll(() => { 89 | newTimestamps = calculateNewTimestamps(FILLER_TIMESTAMPS, newFades, now, logger); 90 | }); 91 | 92 | it('calculates blockUntilTimestamp for each filler', () => { 93 | expect(newTimestamps).toEqual( 94 | expect.arrayContaining([ 95 | { 96 | hash: 'filler1', 97 | lastPostTimestamp: now, 98 | blockUntilTimestamp: now + Math.floor(BASE_BLOCK_SECS * Math.pow(NUM_FADES_MULTIPLIER, 2)), 99 | consecutiveBlocks: 1, 100 | }, 101 | { 102 | hash: 'filler2', 103 | lastPostTimestamp: now, 104 | blockUntilTimestamp: now + Math.floor(BASE_BLOCK_SECS * Math.pow(NUM_FADES_MULTIPLIER, 0)), 105 | consecutiveBlocks: 1, 106 | }, 107 | // still update lastPostTimestamp when blockUntilTimestamp > now 108 | { 109 | hash: 'filler3', 110 | lastPostTimestamp: now, 111 | blockUntilTimestamp: now + 1000, 112 | consecutiveBlocks: 0, 113 | }, 114 | { 115 | hash: 'filler5', 116 | lastPostTimestamp: now, 117 | blockUntilTimestamp: now + 100, 118 | consecutiveBlocks: 0, 119 | }, 120 | // test exponential backoff 121 | { 122 | hash: 'filler7', 123 | lastPostTimestamp: now, 124 | blockUntilTimestamp: now + Math.floor(BASE_BLOCK_SECS * Math.pow(NUM_FADES_MULTIPLIER, 0) * Math.pow(2, 2)), 125 | consecutiveBlocks: 3, 126 | }, 127 | // test consecutiveBlocks reset 128 | // does not really block filler, as blockUntilTimestamp is not in the future 129 | { 130 | hash: 'filler8', 131 | lastPostTimestamp: now, 132 | blockUntilTimestamp: now, 133 | consecutiveBlocks: 0, 134 | }, 135 | ]) 136 | ); 137 | }); 138 | 139 | it('notices new fillers not already in fillerTimestamps', () => { 140 | // filler6 one fade, no existing consecutiveBlocks 141 | expect(newTimestamps).toEqual( 142 | expect.arrayContaining([ 143 | { 144 | hash: 'filler6', 145 | lastPostTimestamp: now, 146 | blockUntilTimestamp: now + Math.floor(BASE_BLOCK_SECS * Math.pow(NUM_FADES_MULTIPLIER, 0)), 147 | consecutiveBlocks: 1, 148 | }, 149 | ]) 150 | ); 151 | }); 152 | 153 | it('keep old blockUntilTimestamp if no new fades', () => { 154 | expect(newTimestamps).not.toContain([['filler4', expect.anything(), expect.anything()]]); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /lib/repositories/filler-address-repository.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; 2 | import Logger from 'bunyan'; 3 | import { Entity, Table } from 'dynamodb-toolbox'; 4 | 5 | import { getAddress } from 'ethers/lib/utils'; 6 | import { DYNAMO_TABLE_NAME } from '../constants'; 7 | 8 | export type DynamoFillerToAddressRow = { 9 | pk: string; 10 | addresses: string[]; 11 | }; 12 | 13 | export interface FillerAddressRepository { 14 | getFillerAddresses(filler: string): Promise; 15 | getFillerByAddress(address: string): Promise; 16 | addNewAddressToFiller(address: string, filler?: string): Promise; 17 | getFillerAddressesBatch(fillers: string[]): Promise>>; 18 | getAddressToFillerMap(fillers: string[]): Promise>; 19 | } 20 | /* 21 | * Dynamo repository for managing filler addresses 22 | * Supports two way lookups: filler -> addr, addr -> fillers 23 | */ 24 | export class DynamoFillerAddressRepository implements FillerAddressRepository { 25 | static log: Logger; 26 | 27 | static create(documentClient: DynamoDBDocumentClient): FillerAddressRepository { 28 | this.log = Logger.createLogger({ 29 | name: 'FillerAddressRepository', 30 | serializers: Logger.stdSerializers, 31 | }); 32 | 33 | const addressTable = new Table({ 34 | name: DYNAMO_TABLE_NAME.FILLER_ADDRESS, 35 | partitionKey: 'pk', // generic partition key name to support both filler and address 36 | DocumentClient: documentClient, 37 | }); 38 | 39 | const fillerToAddressEntity = new Entity({ 40 | name: 'fillerToAddressEntity', 41 | attributes: { 42 | pk: { partitionKey: true }, 43 | addresses: { type: 'set', setType: 'string' }, 44 | }, 45 | table: addressTable, 46 | autoExecute: true, 47 | } as const); 48 | 49 | const addressToFillerEntity = new Entity({ 50 | name: 'addressToFillerEntity', 51 | attributes: { 52 | pk: { partitionKey: true }, 53 | filler: { type: 'string' }, 54 | }, 55 | table: addressTable, 56 | autoExecute: true, 57 | } as const); 58 | 59 | return new DynamoFillerAddressRepository(addressTable, fillerToAddressEntity, addressToFillerEntity); 60 | } 61 | private constructor( 62 | private readonly _addressTable: Table<'FillerAddress', 'pk', null>, 63 | private readonly _fillerToAddressEntity: Entity, 64 | private readonly _addressToFillerEntity: Entity 65 | ) {} 66 | 67 | async getFillerAddresses(filler: string): Promise { 68 | const result = await this._fillerToAddressEntity.get({ pk: filler }, { execute: true, parse: true }); 69 | if (result.Item?.addresses) { 70 | return (result.Item.addresses as string[]).map((addr) => getAddress(addr)); 71 | } 72 | return undefined; 73 | } 74 | 75 | async getFillerByAddress(address: string): Promise { 76 | const result = await this._addressToFillerEntity.get({ pk: getAddress(address) }, { execute: true, parse: true }); 77 | return result.Item?.filler; 78 | } 79 | 80 | async addNewAddressToFiller(address: string, filler?: string): Promise { 81 | const addrToAdd = getAddress(address); 82 | await this._addressToFillerEntity.put({ pk: addrToAdd, filler: filler }); 83 | if (filler) { 84 | const fillerAddresses = await this.getFillerAddresses(filler); 85 | if (!fillerAddresses || fillerAddresses.length === 0) { 86 | await this._fillerToAddressEntity.put({ pk: filler, addresses: [addrToAdd] }); 87 | } else { 88 | await this._fillerToAddressEntity.update({ pk: filler, addresses: { $add: [addrToAdd] } }); 89 | } 90 | } else { 91 | const existingFiller = await this.getFillerByAddress(addrToAdd); 92 | if (!existingFiller) { 93 | throw new Error(`Filler not found for address ${addrToAdd}`); 94 | } 95 | await this._fillerToAddressEntity.update({ pk: existingFiller, addresses: { $add: [addrToAdd] } }); 96 | } 97 | } 98 | 99 | /* 100 | @returns a map of filler -> [addresses] 101 | */ 102 | async getFillerAddressesBatch(fillers: string[]): Promise>> { 103 | const { Responses: items } = await this._addressTable.batchGet( 104 | fillers.map((fillerHash) => this._fillerToAddressEntity.getBatch({ pk: fillerHash })), 105 | { execute: true, parse: true } 106 | ); 107 | 108 | DynamoFillerAddressRepository.log.info( 109 | { fillersAddresses: items, fillers: fillers }, 110 | 'filler addresses from dynamo' 111 | ); 112 | const resMap = new Map>(); 113 | items.FillerAddress.forEach((row: DynamoFillerToAddressRow) => { 114 | resMap.set(row.pk, new Set(row.addresses.map((addr) => getAddress(addr)))); 115 | }); 116 | return resMap; 117 | } 118 | 119 | async getAddressToFillerMap(fillers: string[]): Promise> { 120 | const fillerAddresses = await this.getFillerAddressesBatch(fillers); 121 | DynamoFillerAddressRepository.log.info( 122 | { fillerAddressesMap: [...fillerAddresses.entries()] }, 123 | 'filler addresses map' 124 | ); 125 | const addrToFillerMap = new Map(); 126 | fillerAddresses.forEach((addresses, hash) => { 127 | addresses.forEach((addr) => addrToFillerMap.set(addr, hash)); 128 | }); 129 | return addrToFillerMap; 130 | } 131 | } 132 | 133 | export class MockFillerAddressRepository implements FillerAddressRepository { 134 | private readonly _fillerToAddress: Map>; 135 | private readonly _addressToFiller: Map; 136 | 137 | constructor() { 138 | this._fillerToAddress = new Map>(); 139 | this._addressToFiller = new Map(); 140 | } 141 | 142 | async getFillerAddresses(filler: string): Promise { 143 | return Array.from(this._fillerToAddress.get(filler) || []); 144 | } 145 | 146 | async getFillerByAddress(address: string): Promise { 147 | return this._addressToFiller.get(address); 148 | } 149 | 150 | async addNewAddressToFiller(address: string, filler?: string): Promise { 151 | if (filler) { 152 | const fillerAddresses = this._fillerToAddress.get(filler) || new Set(); 153 | fillerAddresses.add(address); 154 | this._fillerToAddress.set(filler, fillerAddresses); 155 | this._addressToFiller.set(address, filler); 156 | } else { 157 | const existingFiller = this._addressToFiller.get(address); 158 | if (!existingFiller) { 159 | throw new Error(`Filler not found for address ${address}`); 160 | } 161 | const fillerAddresses = this._fillerToAddress.get(existingFiller) || new Set(); 162 | fillerAddresses.add(address); 163 | this._fillerToAddress.set(existingFiller, fillerAddresses); 164 | } 165 | } 166 | 167 | async getFillerAddressesBatch(fillers: string[]): Promise>> { 168 | const res = new Map>(); 169 | for (const filler of fillers) { 170 | const addrs = await this.getFillerAddresses(filler); 171 | if (addrs) { 172 | res.set(filler, new Set(addrs)); 173 | } 174 | } 175 | return res; 176 | } 177 | 178 | async getAddressToFillerMap(fillers: string[]): Promise> { 179 | const fillerAddresses = await this.getFillerAddressesBatch(fillers); 180 | const addrToFillerMap = new Map(); 181 | fillerAddresses.forEach((addresses, hash) => { 182 | addresses.forEach((addr) => addrToFillerMap.set(addr, hash)); 183 | }); 184 | return addrToFillerMap; 185 | } 186 | } 187 | --------------------------------------------------------------------------------