From be33b2dfc49abaafaea80bbe2420eb9a61dc64ae Mon Sep 17 00:00:00 2001 From: Mark Toda Date: Wed, 21 Feb 2024 19:30:49 -0500 Subject: [PATCH 1/2] feat: add hard quote initial this commit adds the initial data required for hard quotes - hard quote entity and schema - hard quote basic handler --- lib/entities/HardQuoteRequest.ts | 110 +++++ lib/entities/index.ts | 1 + lib/handlers/hard-quote/handler.ts | 63 +++ lib/handlers/hard-quote/index.ts | 3 + lib/handlers/hard-quote/injector.ts | 98 ++++ lib/handlers/hard-quote/schema.ts | 22 + lib/util/validator.ts | 9 + package.json | 1 + test/entities/HardQuoteRequest.test.ts | 110 +++++ test/handlers/hard-quote/handler.test.ts | 592 +++++++++++++++++++++++ test/handlers/hard-quote/schema.test.ts | 135 ++++++ yarn.lock | 27 +- 12 files changed, 1169 insertions(+), 2 deletions(-) create mode 100644 lib/entities/HardQuoteRequest.ts create mode 100644 lib/handlers/hard-quote/handler.ts create mode 100644 lib/handlers/hard-quote/index.ts create mode 100644 lib/handlers/hard-quote/injector.ts create mode 100644 lib/handlers/hard-quote/schema.ts create mode 100644 test/entities/HardQuoteRequest.test.ts create mode 100644 test/handlers/hard-quote/handler.test.ts create mode 100644 test/handlers/hard-quote/schema.test.ts diff --git a/lib/entities/HardQuoteRequest.ts b/lib/entities/HardQuoteRequest.ts new file mode 100644 index 00000000..69c71ba9 --- /dev/null +++ b/lib/entities/HardQuoteRequest.ts @@ -0,0 +1,110 @@ +import { TradeType } from '@uniswap/sdk-core'; +import { V2DutchOrder } from '@uniswap/uniswapx-sdk'; +import { BigNumber, utils } from 'ethers'; +import { QuoteRequestDataJSON } from '.'; +import { HardQuoteRequestBody } from '../handlers/hard-quote'; + +export class HardQuoteRequest { + public order: V2DutchOrder; + + public static fromHardRequestBody(_body: HardQuoteRequestBody): HardQuoteRequest { + // TODO: parse hard request into the same V2 request object format + throw new Error('Method not implemented.'); + } + + constructor(private data: HardQuoteRequestBody) { + this.order = V2DutchOrder.parse(data.encodedInnerOrder, data.tokenInChainId); + } + + public toCleanJSON(): QuoteRequestDataJSON { + return { + tokenInChainId: this.tokenInChainId, + tokenOutChainId: this.tokenOutChainId, + swapper: utils.getAddress(this.swapper), + requestId: this.requestId, + tokenIn: this.tokenIn, + tokenOut: this.tokenOut, + amount: this.amount.toString(), + type: TradeType[this.type], + numOutputs: this.numOutputs, + ...(this.quoteId && { quoteId: this.quoteId }), + }; + } + + // return an opposing quote request, + // i.e. quoting the other side of the trade + public toOpposingCleanJSON(): QuoteRequestDataJSON { + const type = this.type === TradeType.EXACT_INPUT ? TradeType.EXACT_OUTPUT : TradeType.EXACT_INPUT; + return { + tokenInChainId: this.tokenInChainId, + tokenOutChainId: this.tokenOutChainId, + requestId: this.requestId, + swapper: utils.getAddress(this.swapper), + // switch tokenIn/tokenOut + tokenIn: utils.getAddress(this.tokenOut), + tokenOut: utils.getAddress(this.tokenIn), + amount: this.amount.toString(), + // switch tradeType + type: TradeType[type], + numOutputs: this.numOutputs, + ...(this.quoteId && { quoteId: this.quoteId }), + }; + } + + public get requestId(): string { + return this.data.requestId; + } + + public get tokenInChainId(): number { + return this.data.tokenInChainId; + } + + public get tokenOutChainId(): number { + return this.data.tokenInChainId; + } + + public get swapper(): string { + return this.order.info.swapper; + } + + public get tokenIn(): string { + return utils.getAddress(this.order.info.input.token) + } + + public get tokenOut(): string { + return utils.getAddress(this.order.info.outputs[0].token); + } + + public get amount(): BigNumber { + if (this.type === TradeType.EXACT_INPUT) { + return this.order.info.input.startAmount; + } else { + const amount = BigNumber.from(0); + for (const output of this.order.info.outputs) { + amount.add(output.startAmount); + } + + return amount; + } + } + + public get type(): TradeType { + return this.order.info.input.startAmount === this.order.info.input.endAmount ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT; + } + + public get numOutputs(): number { + return this.order.info.outputs.length; + } + + public get cosigner(): string { + return this.order.info.cosigner; + } + + public get quoteId(): string | undefined { + return this.data.quoteId; + } + + public set quoteId(quoteId: string | undefined) { + this.data.quoteId = quoteId; + } +} diff --git a/lib/entities/index.ts b/lib/entities/index.ts index 2e97229b..bab845f5 100644 --- a/lib/entities/index.ts +++ b/lib/entities/index.ts @@ -2,3 +2,4 @@ export * from './analytics-events'; export * from './aws-metrics-logger'; export * from './QuoteRequest'; export * from './QuoteResponse'; +export * from './HardQuoteRequest'; diff --git a/lib/handlers/hard-quote/handler.ts b/lib/handlers/hard-quote/handler.ts new file mode 100644 index 00000000..ae0d5db2 --- /dev/null +++ b/lib/handlers/hard-quote/handler.ts @@ -0,0 +1,63 @@ +import { MetricLoggerUnit } from '@uniswap/smart-order-router'; +import Joi from 'joi'; + +import { Metric } from '../../entities'; +import { HardQuoteRequest } from '../../entities'; +import { timestampInMstoSeconds } from '../../util/time'; +import { APIGLambdaHandler } from '../base'; +import { APIHandleRequestParams, ErrorResponse, Response } from '../base/api-handler'; +import { HardQuoteRequestBody, HardQuoteRequestBodyJoi } from './schema'; +import { ContainerInjected, RequestInjected } from './injector'; + +export class QuoteHandler extends APIGLambdaHandler< + ContainerInjected, + RequestInjected, + HardQuoteRequestBody, + void, + null +> { + public async handleRequest( + params: APIHandleRequestParams + ): Promise> { + const { + requestInjected: { log, metric }, + requestBody, + } = params; + const start = Date.now(); + + metric.putMetric(Metric.QUOTE_REQUESTED, 1, MetricLoggerUnit.Count); + + const request = HardQuoteRequest.fromHardRequestBody(requestBody); + + // TODO: finalize on v2 metrics logging + log.info({ + eventType: 'HardQuoteRequest', + body: { + requestId: request.requestId, + tokenInChainId: request.tokenInChainId, + tokenOutChainId: request.tokenInChainId, + encoded: requestBody.encodedInnerOrder, + sig: requestBody.innerSig, + createdAt: timestampInMstoSeconds(start), + createdAtMs: start.toString(), + }, + }); + + return { + statusCode: 200, + body: null, + }; + } + + protected requestBodySchema(): Joi.ObjectSchema | null { + return HardQuoteRequestBodyJoi; + } + + protected requestQueryParamsSchema(): Joi.ObjectSchema | null { + return null; + } + + protected responseBodySchema(): Joi.ObjectSchema | null { + return null; + } +} diff --git a/lib/handlers/hard-quote/index.ts b/lib/handlers/hard-quote/index.ts new file mode 100644 index 00000000..db81cd24 --- /dev/null +++ b/lib/handlers/hard-quote/index.ts @@ -0,0 +1,3 @@ +export { QuoteHandler as HardQuoteHandler } from './handler'; +export { QuoteInjector as HardQuoteInjector } from './injector'; +export * from './schema'; diff --git a/lib/handlers/hard-quote/injector.ts b/lib/handlers/hard-quote/injector.ts new file mode 100644 index 00000000..b966ef0b --- /dev/null +++ b/lib/handlers/hard-quote/injector.ts @@ -0,0 +1,98 @@ +import { IMetric, setGlobalLogger, setGlobalMetric } from '@uniswap/smart-order-router'; +import { MetricsLogger } from 'aws-embedded-metrics'; +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { default as bunyan, default as Logger } from 'bunyan'; + +import { + FADE_RATE_BUCKET, + FADE_RATE_S3_KEY, + BETA_S3_KEY, PRODUCTION_S3_KEY, WEBHOOK_CONFIG_BUCKET } from '../../constants'; +import { AWSMetricsLogger, UniswapXParamServiceMetricDimension } from '../../entities/aws-metrics-logger'; +import { S3CircuitBreakerConfigurationProvider } from '../../providers/circuit-breaker/s3'; +import { S3WebhookConfigurationProvider } from '../../providers'; +import { FirehoseLogger } from '../../providers/analytics'; +import { MockFillerComplianceConfigurationProvider } from '../../providers/compliance'; +import { Quoter, WebhookQuoter } from '../../quoters'; +import { STAGE } from '../../util/stage'; +import { ApiInjector, ApiRInj } from '../base/api-handler'; +import { HardQuoteRequestBody } from './schema'; + +export interface ContainerInjected { + quoters: Quoter[]; + firehose: FirehoseLogger; +} + +export interface RequestInjected extends ApiRInj { + metric: IMetric; +} + +export class QuoteInjector extends ApiInjector { + public async buildContainerInjected(): Promise { + const log: Logger = bunyan.createLogger({ + name: this.injectorName, + serializers: bunyan.stdSerializers, + level: bunyan.INFO, + }); + + const stage = process.env['stage']; + const s3Key = stage === STAGE.BETA ? BETA_S3_KEY : PRODUCTION_S3_KEY; + + const circuitBreakerProvider = new S3CircuitBreakerConfigurationProvider( + log, + `${FADE_RATE_BUCKET}-${stage}-1`, + FADE_RATE_S3_KEY + ); + + const webhookProvider = new S3WebhookConfigurationProvider(log, `${WEBHOOK_CONFIG_BUCKET}-${stage}-1`, s3Key); + await webhookProvider.fetchEndpoints(); + + // TODO: decide if we should handle filler compliance differently + //const complianceKey = stage === STAGE.BETA ? BETA_COMPLIANCE_S3_KEY : PROD_COMPLIANCE_S3_KEY; + //const fillerComplianceProvider = new S3FillerComplianceConfigurationProvider( + // log, + // `${COMPLIANCE_CONFIG_BUCKET}-${stage}-1`, + // complianceKey + //); + const fillerComplianceProvider = new MockFillerComplianceConfigurationProvider([]); + + const firehose = new FirehoseLogger(log, process.env.ANALYTICS_STREAM_ARN!); + + const quoters: Quoter[] = [ + new WebhookQuoter(log, firehose, webhookProvider, circuitBreakerProvider, fillerComplianceProvider), + ]; + return { + quoters: quoters, + firehose: firehose, + }; + } + + public async getRequestInjected( + _containerInjected: ContainerInjected, + requestBody: HardQuoteRequestBody, + _requestQueryParams: void, + _event: APIGatewayProxyEvent, + context: Context, + log: Logger, + metricsLogger: MetricsLogger + ): Promise { + const requestId = context.awsRequestId; + + log = log.child({ + serializers: bunyan.stdSerializers, + requestBody, + requestId, + }); + setGlobalLogger(log); + + metricsLogger.setNamespace('Uniswap'); + metricsLogger.setDimensions(UniswapXParamServiceMetricDimension); + const metric = new AWSMetricsLogger(metricsLogger); + setGlobalMetric(metric); + + return { + log, + metric, + requestId, + }; + } +} diff --git a/lib/handlers/hard-quote/schema.ts b/lib/handlers/hard-quote/schema.ts new file mode 100644 index 00000000..f31c5a41 --- /dev/null +++ b/lib/handlers/hard-quote/schema.ts @@ -0,0 +1,22 @@ +import Joi from 'joi'; + +import { FieldValidator } from '../../util/validator'; + +/* Hard quote request from user */ +export const HardQuoteRequestBodyJoi = Joi.object({ + requestId: FieldValidator.requestId.required(), + quoteId: FieldValidator.uuid.optional(), + encodedInnerOrder: Joi.string().required(), + innerSig: FieldValidator.rawSignature.required(), + tokenInChainId: FieldValidator.chainId.required(), + tokenOutChainId: Joi.number().integer().valid(Joi.ref('tokenInChainId')).required(), +}); + +export type HardQuoteRequestBody = { + requestId: string; + quoteId?: string; + encodedInnerOrder: string; + innerSig: string; + tokenInChainId: number; + tokenOutChainId: number; +}; diff --git a/lib/util/validator.ts b/lib/util/validator.ts index 17620d71..234fe1e6 100644 --- a/lib/util/validator.ts +++ b/lib/util/validator.ts @@ -33,4 +33,13 @@ export class FieldValidator { public static readonly tradeType = Joi.string().valid('EXACT_INPUT', 'EXACT_OUTPUT'); public static readonly uuid = Joi.string().guid({ version: 'uuidv4' }); + + // A Raw Signature is a common Signature format where the r, s and v + // are concatenated into a 65 byte(130 nibble) DataHexString + public static readonly rawSignature = Joi.string().custom((value: string, helpers: CustomHelpers) => { + if (!ethers.utils.isHexString(value, 65) && !ethers.utils.isHexString(value, 64)) { + return helpers.message({ custom: 'Signature in wrong format' }); + } + return value; + }); } diff --git a/package.json b/package.json index 3b135aed..86afc35d 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@uniswap/sdk-core": "^3.1.0", "@uniswap/smart-order-router": "^3.3.0", "@uniswap/token-lists": "^1.0.0-beta.31", + "@uniswap/uniswapx-sdk": "1.5.0-alpha.5", "@uniswap/v3-sdk": "^3.9.0", "aws-cdk-lib": "2.85.0", "aws-embedded-metrics": "^4.1.0", diff --git a/test/entities/HardQuoteRequest.test.ts b/test/entities/HardQuoteRequest.test.ts new file mode 100644 index 00000000..afe1036a --- /dev/null +++ b/test/entities/HardQuoteRequest.test.ts @@ -0,0 +1,110 @@ +import { TradeType } from '@uniswap/sdk-core'; +import { V2DutchOrder, V2DutchOrderInfo } from '@uniswap/uniswapx-sdk'; +import { BigNumber, ethers } from 'ethers'; + +import { HardQuoteRequest } from '../../lib/entities'; +import { HardQuoteRequestBody } from '../../lib/handlers/hard-quote'; + +const NOW = Math.floor(new Date().getTime() / 1000); +const RAW_AMOUNT = BigNumber.from("1000000"); +const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; +const SWAPPER = '0x0000000000000000000000000000000000000000'; +const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; +const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; +const CHAIN_ID = 1; + +export const getOrderInfo = (data: Partial): V2DutchOrderInfo => { + return Object.assign( + { + deadline: NOW + 1000, + reactor: ethers.constants.AddressZero, + swapper: ethers.constants.AddressZero, + nonce: BigNumber.from(10), + additionalValidationContract: ethers.constants.AddressZero, + additionalValidationData: "0x", + cosigner: ethers.constants.AddressZero, + cosignerData: undefined, + input: { + token: TOKEN_IN, + startAmount: RAW_AMOUNT, + endAmount: RAW_AMOUNT, + }, + outputs: [ + { + token: TOKEN_OUT, + startAmount: RAW_AMOUNT.mul(2), + endAmount: RAW_AMOUNT.mul(90).div(100), + recipient: ethers.constants.AddressZero, + }, + ], + cosignature: undefined, + }, + data + ); +}; + +const makeRequest = (data: Partial): HardQuoteRequest => { + return new HardQuoteRequest(Object.assign({ + requestId: '1', + quoteId: '1', + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + encodedInnerOrder: '0x', + innerSig: '0x', + }, data)); +} + +describe('QuoteRequest', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('parses order properly', () => { + const order = new V2DutchOrder(getOrderInfo({ + swapper: SWAPPER, + }), CHAIN_ID); + const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); + expect(request.swapper).toEqual(SWAPPER); + expect(request.tokenIn).toEqual(TOKEN_IN); + expect(request.tokenOut).toEqual(TOKEN_OUT); + expect(request.numOutputs).toEqual(1); + expect(request.amount).toEqual(RAW_AMOUNT.toString()); + expect(request.type).toEqual(TradeType.EXACT_INPUT); + }); + + it('toCleanJSON', async () => { + const order = new V2DutchOrder(getOrderInfo({ + swapper: SWAPPER, + }), CHAIN_ID); + const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); + expect(request.toCleanJSON()).toEqual({ + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + requestId: REQUEST_ID, + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amount: ethers.utils.parseEther('1').toString(), + swapper: ethers.constants.AddressZero, + type: 'EXACT_INPUT', + numOutputs: 1, + }); + }); + + it('toOpposingCleanJSON', async () => { + const order = new V2DutchOrder(getOrderInfo({ + swapper: SWAPPER, + }), CHAIN_ID); + const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); + expect(request.toOpposingCleanJSON()).toEqual({ + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + requestId: REQUEST_ID, + tokenIn: TOKEN_OUT, + tokenOut: TOKEN_IN, + amount: ethers.utils.parseEther('1').toString(), + swapper: ethers.constants.AddressZero, + type: 'EXACT_OUTPUT', + numOutputs: 1, + }); + }); +}); diff --git a/test/handlers/hard-quote/handler.test.ts b/test/handlers/hard-quote/handler.test.ts new file mode 100644 index 00000000..384bc78c --- /dev/null +++ b/test/handlers/hard-quote/handler.test.ts @@ -0,0 +1,592 @@ +import { createMetricsLogger } from 'aws-embedded-metrics'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import axios from 'axios'; +import { default as Logger } from 'bunyan'; +import { ethers } from 'ethers'; + +import { AWSMetricsLogger } from '../../../lib/entities/aws-metrics-logger'; +import { ApiInjector } from '../../../lib/handlers/base/api-handler'; +import { + ContainerInjected, + PostQuoteRequestBody, + PostQuoteResponse, + RequestInjected, +} from '../../../lib/handlers/quote'; +import { QuoteHandler } from '../../../lib/handlers/quote/handler'; +import { MockWebhookConfigurationProvider } from '../../../lib/providers'; +import { FirehoseLogger } from '../../../lib/providers/analytics'; +import { MockCircuitBreakerConfigurationProvider } from '../../../lib/providers/circuit-breaker/mock'; +import { MockFillerComplianceConfigurationProvider } from '../../../lib/providers/compliance'; +import { MOCK_FILLER_ADDRESS, MockQuoter, Quoter, WebhookQuoter } from '../../../lib/quoters'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; +const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; +const SWAPPER = '0x0000000000000000000000000000000000000000'; +const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; +const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; +const CHAIN_ID = 1; + +// silent logger in tests +const logger = Logger.createLogger({ name: 'test' }); +logger.level(Logger.FATAL); + +const emptyMockComplianceProvider = new MockFillerComplianceConfigurationProvider([]); +const mockComplianceProvider = new MockFillerComplianceConfigurationProvider([ + { + endpoints: ['https://uniswap.org', 'google.com'], + addresses: [SWAPPER], + }, +]); +const mockFirehoseLogger = new FirehoseLogger(logger, 'arn:aws:deliverystream/dummy'); + +describe('Quote handler', () => { + // Creating mocks for all the handler dependencies. + const requestInjectedMock: Promise = new Promise( + (resolve) => + resolve({ + log: logger, + requestId: 'test', + metric: new AWSMetricsLogger(createMetricsLogger()), + }) as unknown as RequestInjected + ); + + const injectorPromiseMock = ( + quoters: Quoter[] + ): Promise> => + new Promise((resolve) => + resolve({ + getContainerInjected: () => { + return { + quoters, + }; + }, + getRequestInjected: () => requestInjectedMock, + } as unknown as ApiInjector) + ); + + const getQuoteHandler = (quoters: Quoter[]) => new QuoteHandler('quote', injectorPromiseMock(quoters)); + + const getEvent = (request: PostQuoteRequestBody): APIGatewayProxyEvent => + ({ + body: JSON.stringify(request), + } as APIGatewayProxyEvent); + + const getRequest = (amount: string, type = 'EXACT_INPUT'): PostQuoteRequestBody => ({ + requestId: REQUEST_ID, + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + swapper: SWAPPER, + tokenIn: TOKEN_IN, + amount, + tokenOut: TOKEN_OUT, + type, + numOutputs: 1, + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const responseFromRequest = ( + request: PostQuoteRequestBody, + overrides: Partial + ): PostQuoteResponse => { + return Object.assign( + {}, + { + amountOut: request.amount, + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + amountIn: request.amount, + swapper: request.swapper, + requestId: request.requestId, + chainId: request.tokenInChainId, + filler: MOCK_FILLER_ADDRESS, + quoteId: QUOTE_ID, + }, + overrides + ); + }; + + it('Simple request and response', async () => { + const quoters = [new MockQuoter(logger, 1, 1)]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + expect(response.statusCode).toEqual(200); + expect(responseFromRequest(request, {})).toMatchObject({ ...quoteResponse, quoteId: expect.any(String) }); + }); + + it('Handles hex amount', async () => { + const quoters = [new MockQuoter(logger, 1, 1)]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toHexString()); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + expect(response.statusCode).toEqual(200); + expect( + responseFromRequest(request, { amountIn: amountIn.toString(), amountOut: amountIn.toString() }) + ).toMatchObject({ ...quoteResponse, quoteId: expect.any(String) }); + }); + + it('Pick the greater of two quotes - EXACT_IN', async () => { + const quoters = [new MockQuoter(logger, 1, 1), new MockQuoter(logger, 2, 1)]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + expect(response.statusCode).toEqual(200); + expect( + responseFromRequest(request, { amountOut: amountIn.mul(2).toString(), amountIn: amountIn.mul(1).toString() }) + ).toMatchObject({ + ...quoteResponse, + quoteId: expect.any(String), + }); + }); + + it('Pick the lesser of two quotes - EXACT_OUT', async () => { + const quoters = [new MockQuoter(logger, 1, 1), new MockQuoter(logger, 2, 1)]; + const amountOut = ethers.utils.parseEther('1'); + const request = getRequest(amountOut.toString(), 'EXACT_OUTPUT'); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + expect(response.statusCode).toEqual(200); + expect( + responseFromRequest(request, { amountOut: amountOut.mul(1).toString(), amountIn: amountOut.mul(1).toString() }) + ).toMatchObject({ + ...quoteResponse, + quoteId: expect.any(String), + }); + }); + + it('Two quoters returning the same result', async () => { + const quoters = [new MockQuoter(logger, 1, 1), new MockQuoter(logger, 1, 1)]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + expect(response.statusCode).toEqual(200); + expect(responseFromRequest(request, {})).toMatchObject({ ...quoteResponse, quoteId: expect.any(String) }); + }); + + it('Invalid amountIn', async () => { + const invalidAmounts = ['-100', 'aszzz', 'zz']; + + const quoters = [new MockQuoter(logger, 1, 1)]; + + for (const amount of invalidAmounts) { + const request = getRequest(amount); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const error = JSON.parse(response.body); + expect(response.statusCode).toEqual(400); + expect(error).toMatchObject({ + detail: 'Invalid amount', + errorCode: 'VALIDATION_ERROR', + }); + } + }); + + describe('Webhook Quoter', () => { + it('Simple request and response', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { endpoint: 'https://uniswap.org', headers: {}, name: 'uniswap', hash: '0xuni' }, + ]); + + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { fadeRate: 0.02, enabled: true, hash: '0xuni' }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + amountOut: amountIn.mul(2).toString(), + requestId: request.requestId, + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + amountIn: request.amount, + swapper: request.swapper, + chainId: request.tokenInChainId, + quoteId: QUOTE_ID, + }, + }); + }) + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + amountOut: amountIn.mul(3).toString(), + requestId: request.requestId, + tokenIn: request.tokenOut, + tokenOut: request.tokenIn, + amountIn: request.amount, + swapper: request.swapper, + chainId: request.tokenInChainId, + quoteId: QUOTE_ID, + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); + expect(response.statusCode).toEqual(200); + expect(quoteResponse).toMatchObject({ + amountOut: amountIn.mul(2).toString(), + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + amountIn: request.amount, + swapper: request.swapper, + chainId: request.tokenInChainId, + requestId: request.requestId, + quoteId: expect.any(String), + }); + }); + + it('Passes headers', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { + name: 'uniswap', + endpoint: 'https://uniswap.org', + headers: { + 'X-Authentication': '1234', + }, + hash: '0xuni', + }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post + .mockImplementationOnce((_endpoint, _req, options: any) => { + expect(options.headers['X-Authentication']).toEqual('1234'); + return Promise.resolve({ + data: { + ...responseFromRequest(request, { amountOut: amountIn.mul(2).toString() }), + }, + }); + }) + .mockImplementationOnce((_endpoint, _req, options: any) => { + expect(options.headers['X-Authentication']).toEqual('1234'); + const res = responseFromRequest(request, { amountOut: amountIn.mul(3).toString() }); + return Promise.resolve({ + data: { + ...res, + tokenIn: res.tokenOut, + tokenOut: res.tokenIn, + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); + expect(response.statusCode).toEqual(200); + expect(quoteResponse).toMatchObject({ + amountOut: amountIn.mul(2).toString(), + amountIn: request.amount, + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + chainId: request.tokenInChainId, + swapper: request.swapper, + }); + }); + + it('handles invalid responses', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: 'https://uniswap.org', headers: {}, hash: '0xuni' }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post.mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + ...request, + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(404); + }); + + it('returns error if requestId is invalid', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: 'https://uniswap.org', headers: {}, hash: '0xuni' }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post.mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + requestId: '1234', + amountOut: amountIn.toString(), + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(404); + }); + + it('uses backup on failure', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: 'https://uniswap.org', headers: {}, hash: '0xuni' }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + new MockQuoter(logger, 1, 1), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post.mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + ...request, + quoteId: QUOTE_ID, + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(200); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // MockQuoter wins so returns a random quoteId + expect(responseFromRequest(request, {})).toMatchObject({ ...quoteResponse, quoteId: expect.any(String) }); + }); + + it('uses if better than backup', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: 'https://uniswap.org', headers: {}, hash: '0xuni' }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + new MockQuoter(logger, 1, 1), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + amountOut: amountIn.mul(2).toString(), + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + amountIn: request.amount, + swapper: request.swapper, + chainId: request.tokenInChainId, + requestId: request.requestId, + quoteId: QUOTE_ID, + }, + }); + }) + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + amountOut: amountIn.div(2).toString(), + tokenIn: request.tokenOut, + tokenOut: request.tokenIn, + amountIn: request.amount, + swapper: request.swapper, + chainId: request.tokenInChainId, + requestId: request.requestId, + quoteId: QUOTE_ID, + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(200); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); + expect(responseFromRequest(request, { amountOut: amountIn.mul(2).toString() })).toMatchObject({ + ...quoteResponse, + quoteId: QUOTE_ID, + }); + }); + + it('uses backup if better', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: 'https://uniswap.org', headers: {}, hash: '0xuni' }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ), + new MockQuoter(logger, 1, 1), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + mockedAxios.post.mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + amountOut: amountIn.div(2).toString(), + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + amountIn: request.amount, + swapper: request.swapper, + chainId: request.tokenInChainId, + requestId: request.requestId, + quoteId: QUOTE_ID, + }, + }); + }); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(200); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // MockQuoter wins so returns a random quoteId + expect(responseFromRequest(request, { amountOut: amountIn.toString() })).toMatchObject({ + ...quoteResponse, + quoteId: expect.any(String), + }); + }); + + it('respects filler compliance requirements', async () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: 'https://uniswap.org', headers: {}, hash: '0xuni' }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.02, enabled: true }, + ]); + const quoters = [ + new WebhookQuoter(logger, mockFirehoseLogger, webhookProvider, circuitBreakerProvider, mockComplianceProvider), + ]; + const amountIn = ethers.utils.parseEther('1'); + const request = getRequest(amountIn.toString()); + + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(404); + const quoteResponse: PostQuoteResponse = JSON.parse(response.body); + expect(quoteResponse).toMatchObject( + expect.objectContaining({ + errorCode: 'QUOTE_ERROR', + detail: 'No quotes available', + }) + ); + }); + }); +}); diff --git a/test/handlers/hard-quote/schema.test.ts b/test/handlers/hard-quote/schema.test.ts new file mode 100644 index 00000000..4d8ea91d --- /dev/null +++ b/test/handlers/hard-quote/schema.test.ts @@ -0,0 +1,135 @@ +import { BigNumber, utils } from 'ethers'; +import { V2DutchOrder } from '@uniswap/uniswapx-sdk'; +import { v4 as uuidv4 } from 'uuid'; +import { + HardQuoteRequestBodyJoi, +} from '../../../lib/handlers/hard-quote'; +import { getOrderInfo } from '../../entities/HardQuoteRequest.test'; + +const SWAPPER = '0x0000000000000000000000000000000000000000'; +const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; +const REQUEST_ID = uuidv4(); +const QUOTE_ID = uuidv4(); + +const validTokenIn = [USDC, WETH].reduce(lowerUpper, []); +const validTokenOut = [USDC, WETH].reduce(lowerUpper, []); +const validAmountIn = ['1', '1000', '1234234', utils.parseEther('1').toString(), utils.parseEther('100000').toString()]; +const validHardRequestBodyCombos = validTokenIn.flatMap((tokenIn) => + validTokenOut.flatMap((tokenOut) => + validAmountIn.flatMap((amount) => { + const order = new V2DutchOrder(getOrderInfo({ + input: { + token: tokenIn, + startAmount: BigNumber.from(amount), + endAmount: BigNumber.from(amount), + }, + outputs: [ + { + token: tokenOut, + startAmount: BigNumber.from(amount), + endAmount: BigNumber.from(amount), + recipient: SWAPPER, + }, + ], + }), 1); + return { + requestId: REQUEST_ID, + quoteId: QUOTE_ID, + tokenInChainId: 1, + tokenOutChainId: 1, + encodedInnerOrder: order.serialize(), + innerSig: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + }; + }) + ) +); + +describe('hard-quote schemas', () => { + describe('HardQuoteRequestBody', () => { + it('validates valid hard requests', () => { + for (const body of validHardRequestBodyCombos) { + const validated = HardQuoteRequestBodyJoi.validate(body); + expect(validated.error).toBeUndefined(); + expect(validated.value).toStrictEqual({ + tokenInChainId: 1, + tokenOutChainId: 1, + requestId: REQUEST_ID, + quoteId: QUOTE_ID, + encodedInnerOrder: body.encodedInnerOrder, + innerSig: body.innerSig, + }); + } + }); + + it('requires correct signature length', () => { + let validated = HardQuoteRequestBodyJoi.validate( + Object.assign({}, validHardRequestBodyCombos[0], { + innerSig: '0x1234', + }) + ); + expect(validated.error?.message).toMatch('Signature in wrong format'); + + validated = HardQuoteRequestBodyJoi.validate( + Object.assign({}, validHardRequestBodyCombos[0], { + innerSig: '0x123412341234123412341324132412341324132412341324134', + }) + ); + expect(validated.error?.message).toMatch('Signature in wrong format'); + + validated = HardQuoteRequestBodyJoi.validate( + Object.assign({}, validHardRequestBodyCombos[0], { + innerSig: '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + }) + ); + expect(validated.error).toBeUndefined(); + }); + + + it('requires tokenInChainId to be defined', () => { + const { tokenOutChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; + const validated = HardQuoteRequestBodyJoi.validate({ + tokenOutChainId, + requestId, + quoteId, encodedInnerOrder, innerSig + }); + expect(validated.error?.message).toEqual('"tokenInChainId" is required'); + }); + + it('requires tokenOutChainId to be defined', () => { + const { tokenInChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; + const validated = HardQuoteRequestBodyJoi.validate({ + tokenInChainId, + requestId, + quoteId, encodedInnerOrder, innerSig + }); + expect(validated.error?.message).toEqual('"tokenOutChainId" is required'); + }); + + + it('requires tokenOutChainId and tokenInChainId to be the same value', () => { + const { tokenInChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; + const validated = HardQuoteRequestBodyJoi.validate({ + tokenInChainId, + tokenOutChainId: 5, + requestId, + quoteId, encodedInnerOrder, innerSig + }); + expect(validated.error?.message).toContain('"tokenOutChainId" must be [ref:tokenInChainId]'); + }); + + it('requires tokenInChainId to be supported', () => { + const validated = HardQuoteRequestBodyJoi.validate( + Object.assign({}, validHardRequestBodyCombos[0], { tokenInChainId: 999999 }) + ); + expect(validated.error?.message).toContain('"tokenInChainId" must be one of'); + }); + }); +}); + +function lowerUpper(list: string[], str: string): string[] { + list.push(str.toLowerCase()); + list.push('0x' + str.toUpperCase().slice(2)); + list.push(str); + return list; +} diff --git a/yarn.lock b/yarn.lock index f3b063f6..a7e04b23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2714,7 +2714,7 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/providers@5.7.2": +"@ethersproject/providers@5.7.2", "@ethersproject/providers@^5.7.0": version "5.7.2" resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.7.2.tgz#f8b1a4f275d7ce58cf0a2eec222269a08beb18cb" integrity sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg== @@ -4435,6 +4435,18 @@ tiny-invariant "^1.1.0" toformat "^2.0.0" +"@uniswap/sdk-core@^4.0.3": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@uniswap/sdk-core/-/sdk-core-4.1.2.tgz#8bf64ef6881574f1c3a3c5800916aa2b2965d00a" + integrity sha512-bk/oHiNCofy48ix84Q8aK/HHDmMVmk+SaoTq6U/93MmJ/nE7hbsckmY1c3MG2Vz1+jnGMCX9AmdBbnSF7VR5Ow== + dependencies: + "@ethersproject/address" "^5.0.2" + big.js "^5.2.2" + decimal.js-light "^2.5.0" + jsbi "^3.1.4" + tiny-invariant "^1.1.0" + toformat "^2.0.0" + "@uniswap/smart-order-router@^3.3.0": version "3.3.0" resolved "https://registry.yarnpkg.com/@uniswap/smart-order-router/-/smart-order-router-3.3.0.tgz#b24e1006c97e3a58cab90b6b47e07f88b9055411" @@ -4490,6 +4502,17 @@ resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.31.tgz#ff3852bd505ec7b4c276625c762ea79a93a919ec" integrity sha512-BQVoelKCRf64IToPEs1wxiXOnhr/ukwPOF78XG11PrTAOL4F8umjYKFb8ZPv1/dIJsPaC7GhLSriEqyp94SasQ== +"@uniswap/uniswapx-sdk@1.5.0-alpha.5": + version "1.5.0-alpha.5" + resolved "https://registry.yarnpkg.com/@uniswap/uniswapx-sdk/-/uniswapx-sdk-1.5.0-alpha.5.tgz#5dfb4dcfa35610845587e90014685098ffd3cb02" + integrity sha512-IBmRhDowOc3bKgPKkgRB3qrAyTR3Y4nfll4XkETcnc90DgZ7c4ZjSkyiFXHkwUjaT57FzURXq6C5rKWuVmO7Ag== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/providers" "^5.7.0" + "@uniswap/permit2-sdk" "^1.2.0" + "@uniswap/sdk-core" "^4.0.3" + ethers "^5.7.0" + "@uniswap/universal-router-sdk@^1.3.0": version "1.3.1" resolved "https://registry.yarnpkg.com/@uniswap/universal-router-sdk/-/universal-router-sdk-1.3.1.tgz#2bfc4f5f3195813e2cc0511675da8ebaa199da13" @@ -5865,7 +5888,7 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -ethers@^5.3.1, ethers@^5.7.2: +ethers@^5.3.1, ethers@^5.7.0, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== From 3b1797cf9852c8e13a53ee53f711be3461727c45 Mon Sep 17 00:00:00 2001 From: Mark Toda Date: Wed, 21 Feb 2024 19:38:03 -0500 Subject: [PATCH 2/2] fix: tests --- lib/entities/HardQuoteRequest.ts | 23 ++++---- lib/entities/index.ts | 2 +- lib/handlers/hard-quote/handler.ts | 5 +- lib/handlers/hard-quote/injector.ts | 7 ++- package.json | 2 +- test/entities/HardQuoteRequest.test.ts | 73 +++++++++++++++---------- test/handlers/hard-quote/schema.test.ts | 54 ++++++++++-------- yarn.lock | 8 +-- 8 files changed, 101 insertions(+), 73 deletions(-) diff --git a/lib/entities/HardQuoteRequest.ts b/lib/entities/HardQuoteRequest.ts index 69c71ba9..c7ea5d56 100644 --- a/lib/entities/HardQuoteRequest.ts +++ b/lib/entities/HardQuoteRequest.ts @@ -1,11 +1,12 @@ import { TradeType } from '@uniswap/sdk-core'; -import { V2DutchOrder } from '@uniswap/uniswapx-sdk'; +import { UnsignedV2DutchOrder } from '@uniswap/uniswapx-sdk'; import { BigNumber, utils } from 'ethers'; -import { QuoteRequestDataJSON } from '.'; + import { HardQuoteRequestBody } from '../handlers/hard-quote'; +import { QuoteRequestDataJSON } from '.'; export class HardQuoteRequest { - public order: V2DutchOrder; + public order: UnsignedV2DutchOrder; public static fromHardRequestBody(_body: HardQuoteRequestBody): HardQuoteRequest { // TODO: parse hard request into the same V2 request object format @@ -13,7 +14,7 @@ export class HardQuoteRequest { } constructor(private data: HardQuoteRequestBody) { - this.order = V2DutchOrder.parse(data.encodedInnerOrder, data.tokenInChainId); + this.order = UnsignedV2DutchOrder.parse(data.encodedInnerOrder, data.tokenInChainId); } public toCleanJSON(): QuoteRequestDataJSON { @@ -68,19 +69,19 @@ export class HardQuoteRequest { } public get tokenIn(): string { - return utils.getAddress(this.order.info.input.token) + return utils.getAddress(this.order.info.baseInput.token); } public get tokenOut(): string { - return utils.getAddress(this.order.info.outputs[0].token); + return utils.getAddress(this.order.info.baseOutputs[0].token); } public get amount(): BigNumber { if (this.type === TradeType.EXACT_INPUT) { - return this.order.info.input.startAmount; + return this.order.info.baseInput.startAmount; } else { const amount = BigNumber.from(0); - for (const output of this.order.info.outputs) { + for (const output of this.order.info.baseOutputs) { amount.add(output.startAmount); } @@ -89,11 +90,13 @@ export class HardQuoteRequest { } public get type(): TradeType { - return this.order.info.input.startAmount === this.order.info.input.endAmount ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT; + return this.order.info.baseInput.startAmount.eq(this.order.info.baseInput.endAmount) + ? TradeType.EXACT_INPUT + : TradeType.EXACT_OUTPUT; } public get numOutputs(): number { - return this.order.info.outputs.length; + return this.order.info.baseOutputs.length; } public get cosigner(): string { diff --git a/lib/entities/index.ts b/lib/entities/index.ts index bab845f5..e2da9525 100644 --- a/lib/entities/index.ts +++ b/lib/entities/index.ts @@ -1,5 +1,5 @@ export * from './analytics-events'; export * from './aws-metrics-logger'; +export * from './HardQuoteRequest'; export * from './QuoteRequest'; export * from './QuoteResponse'; -export * from './HardQuoteRequest'; diff --git a/lib/handlers/hard-quote/handler.ts b/lib/handlers/hard-quote/handler.ts index ae0d5db2..8ed84a95 100644 --- a/lib/handlers/hard-quote/handler.ts +++ b/lib/handlers/hard-quote/handler.ts @@ -1,13 +1,12 @@ import { MetricLoggerUnit } from '@uniswap/smart-order-router'; import Joi from 'joi'; -import { Metric } from '../../entities'; -import { HardQuoteRequest } from '../../entities'; +import { HardQuoteRequest, Metric } from '../../entities'; import { timestampInMstoSeconds } from '../../util/time'; import { APIGLambdaHandler } from '../base'; import { APIHandleRequestParams, ErrorResponse, Response } from '../base/api-handler'; -import { HardQuoteRequestBody, HardQuoteRequestBodyJoi } from './schema'; import { ContainerInjected, RequestInjected } from './injector'; +import { HardQuoteRequestBody, HardQuoteRequestBodyJoi } from './schema'; export class QuoteHandler extends APIGLambdaHandler< ContainerInjected, diff --git a/lib/handlers/hard-quote/injector.ts b/lib/handlers/hard-quote/injector.ts index b966ef0b..6cc55267 100644 --- a/lib/handlers/hard-quote/injector.ts +++ b/lib/handlers/hard-quote/injector.ts @@ -4,13 +4,16 @@ import { APIGatewayProxyEvent, Context } from 'aws-lambda'; import { default as bunyan, default as Logger } from 'bunyan'; import { + BETA_S3_KEY, FADE_RATE_BUCKET, FADE_RATE_S3_KEY, - BETA_S3_KEY, PRODUCTION_S3_KEY, WEBHOOK_CONFIG_BUCKET } from '../../constants'; + PRODUCTION_S3_KEY, + WEBHOOK_CONFIG_BUCKET, +} from '../../constants'; import { AWSMetricsLogger, UniswapXParamServiceMetricDimension } from '../../entities/aws-metrics-logger'; -import { S3CircuitBreakerConfigurationProvider } from '../../providers/circuit-breaker/s3'; import { S3WebhookConfigurationProvider } from '../../providers'; import { FirehoseLogger } from '../../providers/analytics'; +import { S3CircuitBreakerConfigurationProvider } from '../../providers/circuit-breaker/s3'; import { MockFillerComplianceConfigurationProvider } from '../../providers/compliance'; import { Quoter, WebhookQuoter } from '../../quoters'; import { STAGE } from '../../util/stage'; diff --git a/package.json b/package.json index 86afc35d..7fcec388 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@uniswap/sdk-core": "^3.1.0", "@uniswap/smart-order-router": "^3.3.0", "@uniswap/token-lists": "^1.0.0-beta.31", - "@uniswap/uniswapx-sdk": "1.5.0-alpha.5", + "@uniswap/uniswapx-sdk": "1.5.0-alpha.7", "@uniswap/v3-sdk": "^3.9.0", "aws-cdk-lib": "2.85.0", "aws-embedded-metrics": "^4.1.0", diff --git a/test/entities/HardQuoteRequest.test.ts b/test/entities/HardQuoteRequest.test.ts index afe1036a..49b3b5f5 100644 --- a/test/entities/HardQuoteRequest.test.ts +++ b/test/entities/HardQuoteRequest.test.ts @@ -1,19 +1,20 @@ import { TradeType } from '@uniswap/sdk-core'; -import { V2DutchOrder, V2DutchOrderInfo } from '@uniswap/uniswapx-sdk'; +import { UnsignedV2DutchOrder, UnsignedV2DutchOrderInfo } from '@uniswap/uniswapx-sdk'; import { BigNumber, ethers } from 'ethers'; import { HardQuoteRequest } from '../../lib/entities'; import { HardQuoteRequestBody } from '../../lib/handlers/hard-quote'; const NOW = Math.floor(new Date().getTime() / 1000); -const RAW_AMOUNT = BigNumber.from("1000000"); +const RAW_AMOUNT = BigNumber.from('1000000'); const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; +const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; const SWAPPER = '0x0000000000000000000000000000000000000000'; const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; const CHAIN_ID = 1; -export const getOrderInfo = (data: Partial): V2DutchOrderInfo => { +export const getOrderInfo = (data: Partial): UnsignedV2DutchOrderInfo => { return Object.assign( { deadline: NOW + 1000, @@ -21,15 +22,14 @@ export const getOrderInfo = (data: Partial): V2DutchOrderInfo swapper: ethers.constants.AddressZero, nonce: BigNumber.from(10), additionalValidationContract: ethers.constants.AddressZero, - additionalValidationData: "0x", + additionalValidationData: '0x', cosigner: ethers.constants.AddressZero, - cosignerData: undefined, - input: { + baseInput: { token: TOKEN_IN, startAmount: RAW_AMOUNT, endAmount: RAW_AMOUNT, }, - outputs: [ + baseOutputs: [ { token: TOKEN_OUT, startAmount: RAW_AMOUNT.mul(2), @@ -37,22 +37,26 @@ export const getOrderInfo = (data: Partial): V2DutchOrderInfo recipient: ethers.constants.AddressZero, }, ], - cosignature: undefined, }, data ); }; const makeRequest = (data: Partial): HardQuoteRequest => { - return new HardQuoteRequest(Object.assign({ - requestId: '1', - quoteId: '1', - tokenInChainId: CHAIN_ID, - tokenOutChainId: CHAIN_ID, - encodedInnerOrder: '0x', - innerSig: '0x', - }, data)); -} + return new HardQuoteRequest( + Object.assign( + { + requestId: REQUEST_ID, + quoteId: QUOTE_ID, + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + encodedInnerOrder: '0x', + innerSig: '0x', + }, + data + ) + ); +}; describe('QuoteRequest', () => { afterEach(() => { @@ -60,30 +64,37 @@ describe('QuoteRequest', () => { }); it('parses order properly', () => { - const order = new V2DutchOrder(getOrderInfo({ - swapper: SWAPPER, - }), CHAIN_ID); + const order = new UnsignedV2DutchOrder( + getOrderInfo({ + swapper: SWAPPER, + }), + CHAIN_ID + ); const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); expect(request.swapper).toEqual(SWAPPER); expect(request.tokenIn).toEqual(TOKEN_IN); expect(request.tokenOut).toEqual(TOKEN_OUT); expect(request.numOutputs).toEqual(1); - expect(request.amount).toEqual(RAW_AMOUNT.toString()); + expect(request.amount).toEqual(RAW_AMOUNT); expect(request.type).toEqual(TradeType.EXACT_INPUT); }); it('toCleanJSON', async () => { - const order = new V2DutchOrder(getOrderInfo({ - swapper: SWAPPER, - }), CHAIN_ID); + const order = new UnsignedV2DutchOrder( + getOrderInfo({ + swapper: SWAPPER, + }), + CHAIN_ID + ); const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); expect(request.toCleanJSON()).toEqual({ tokenInChainId: CHAIN_ID, tokenOutChainId: CHAIN_ID, requestId: REQUEST_ID, + quoteId: QUOTE_ID, tokenIn: TOKEN_IN, tokenOut: TOKEN_OUT, - amount: ethers.utils.parseEther('1').toString(), + amount: RAW_AMOUNT.toString(), swapper: ethers.constants.AddressZero, type: 'EXACT_INPUT', numOutputs: 1, @@ -91,17 +102,21 @@ describe('QuoteRequest', () => { }); it('toOpposingCleanJSON', async () => { - const order = new V2DutchOrder(getOrderInfo({ - swapper: SWAPPER, - }), CHAIN_ID); + const order = new UnsignedV2DutchOrder( + getOrderInfo({ + swapper: SWAPPER, + }), + CHAIN_ID + ); const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); expect(request.toOpposingCleanJSON()).toEqual({ tokenInChainId: CHAIN_ID, tokenOutChainId: CHAIN_ID, requestId: REQUEST_ID, + quoteId: QUOTE_ID, tokenIn: TOKEN_OUT, tokenOut: TOKEN_IN, - amount: ethers.utils.parseEther('1').toString(), + amount: RAW_AMOUNT.toString(), swapper: ethers.constants.AddressZero, type: 'EXACT_OUTPUT', numOutputs: 1, diff --git a/test/handlers/hard-quote/schema.test.ts b/test/handlers/hard-quote/schema.test.ts index 4d8ea91d..b635237c 100644 --- a/test/handlers/hard-quote/schema.test.ts +++ b/test/handlers/hard-quote/schema.test.ts @@ -1,9 +1,8 @@ +import { UnsignedV2DutchOrder } from '@uniswap/uniswapx-sdk'; import { BigNumber, utils } from 'ethers'; -import { V2DutchOrder } from '@uniswap/uniswapx-sdk'; import { v4 as uuidv4 } from 'uuid'; -import { - HardQuoteRequestBodyJoi, -} from '../../../lib/handlers/hard-quote'; + +import { HardQuoteRequestBodyJoi } from '../../../lib/handlers/hard-quote'; import { getOrderInfo } from '../../entities/HardQuoteRequest.test'; const SWAPPER = '0x0000000000000000000000000000000000000000'; @@ -18,28 +17,32 @@ const validAmountIn = ['1', '1000', '1234234', utils.parseEther('1').toString(), const validHardRequestBodyCombos = validTokenIn.flatMap((tokenIn) => validTokenOut.flatMap((tokenOut) => validAmountIn.flatMap((amount) => { - const order = new V2DutchOrder(getOrderInfo({ - input: { - token: tokenIn, - startAmount: BigNumber.from(amount), - endAmount: BigNumber.from(amount), - }, - outputs: [ - { - token: tokenOut, + const order = new UnsignedV2DutchOrder( + getOrderInfo({ + baseInput: { + token: tokenIn, startAmount: BigNumber.from(amount), endAmount: BigNumber.from(amount), - recipient: SWAPPER, }, - ], - }), 1); + baseOutputs: [ + { + token: tokenOut, + startAmount: BigNumber.from(amount), + endAmount: BigNumber.from(amount), + recipient: SWAPPER, + }, + ], + }), + 1 + ); return { requestId: REQUEST_ID, quoteId: QUOTE_ID, tokenInChainId: 1, tokenOutChainId: 1, encodedInnerOrder: order.serialize(), - innerSig: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + innerSig: + '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }; }) ) @@ -79,19 +82,21 @@ describe('hard-quote schemas', () => { validated = HardQuoteRequestBodyJoi.validate( Object.assign({}, validHardRequestBodyCombos[0], { - innerSig: '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + innerSig: + '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }) ); expect(validated.error).toBeUndefined(); }); - it('requires tokenInChainId to be defined', () => { const { tokenOutChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; const validated = HardQuoteRequestBodyJoi.validate({ tokenOutChainId, requestId, - quoteId, encodedInnerOrder, innerSig + quoteId, + encodedInnerOrder, + innerSig, }); expect(validated.error?.message).toEqual('"tokenInChainId" is required'); }); @@ -101,19 +106,22 @@ describe('hard-quote schemas', () => { const validated = HardQuoteRequestBodyJoi.validate({ tokenInChainId, requestId, - quoteId, encodedInnerOrder, innerSig + quoteId, + encodedInnerOrder, + innerSig, }); expect(validated.error?.message).toEqual('"tokenOutChainId" is required'); }); - it('requires tokenOutChainId and tokenInChainId to be the same value', () => { const { tokenInChainId, requestId, quoteId, encodedInnerOrder, innerSig } = validHardRequestBodyCombos[0]; const validated = HardQuoteRequestBodyJoi.validate({ tokenInChainId, tokenOutChainId: 5, requestId, - quoteId, encodedInnerOrder, innerSig + quoteId, + encodedInnerOrder, + innerSig, }); expect(validated.error?.message).toContain('"tokenOutChainId" must be [ref:tokenInChainId]'); }); diff --git a/yarn.lock b/yarn.lock index a7e04b23..39519a75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4502,10 +4502,10 @@ resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.31.tgz#ff3852bd505ec7b4c276625c762ea79a93a919ec" integrity sha512-BQVoelKCRf64IToPEs1wxiXOnhr/ukwPOF78XG11PrTAOL4F8umjYKFb8ZPv1/dIJsPaC7GhLSriEqyp94SasQ== -"@uniswap/uniswapx-sdk@1.5.0-alpha.5": - version "1.5.0-alpha.5" - resolved "https://registry.yarnpkg.com/@uniswap/uniswapx-sdk/-/uniswapx-sdk-1.5.0-alpha.5.tgz#5dfb4dcfa35610845587e90014685098ffd3cb02" - integrity sha512-IBmRhDowOc3bKgPKkgRB3qrAyTR3Y4nfll4XkETcnc90DgZ7c4ZjSkyiFXHkwUjaT57FzURXq6C5rKWuVmO7Ag== +"@uniswap/uniswapx-sdk@1.5.0-alpha.7": + version "1.5.0-alpha.7" + resolved "https://registry.yarnpkg.com/@uniswap/uniswapx-sdk/-/uniswapx-sdk-1.5.0-alpha.7.tgz#6c16e589b2a5492d574a008d5bbfaf3d2fd94f5f" + integrity sha512-G9+KFi++NorgZlbRclQQArynjFr0uWDAW9l/qJoiQJfvQYhqIfNxRkdgp4rWsQldVJf0C1hjyiJbPPfB/sfZoQ== dependencies: "@ethersproject/bytes" "^5.7.0" "@ethersproject/providers" "^5.7.0"