diff --git a/bin/app.ts b/bin/app.ts index 04f22c28..8868f1ea 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -99,6 +99,10 @@ export class APIPipeline extends Stack { synth: synthStep, }); + const urlSecrets = sm.Secret.fromSecretAttributes(this, 'urlSecrets', { + secretCompleteArn: 'arn:aws:secretsmanager:us-east-2:644039819003:secret:gouda-service-api-xCINOs', + }); + const rfqWebhookConfig = sm.Secret.fromSecretAttributes(this, 'RfqConfig', { secretCompleteArn: 'arn:aws:secretsmanager:us-east-2:644039819003:secret:rfq-webhook-config-sy04bH', }); @@ -117,6 +121,7 @@ export class APIPipeline extends Stack { stage: STAGE.BETA, envVars: { RFQ_WEBHOOK_CONFIG: rfqWebhookConfig.secretValue.toString(), + ORDER_SERVICE_URL: urlSecrets.secretValueFromJson('GOUDA_SERVICE_BETA').toString(), FILL_LOG_SENDER_ACCOUNT: '321377678687', ORDER_LOG_SENDER_ACCOUNT: '321377678687', URA_ACCOUNT: '665191769009', @@ -136,6 +141,7 @@ export class APIPipeline extends Stack { chatbotSNSArn: 'arn:aws:sns:us-east-2:644039819003:SlackChatbotTopic', envVars: { RFQ_WEBHOOK_CONFIG: rfqWebhookConfig.secretValue.toString(), + ORDER_SERVICE_URL: urlSecrets.secretValueFromJson('GOUDA_SERVICE_PROD').toString(), FILL_LOG_SENDER_ACCOUNT: '316116520258', ORDER_LOG_SENDER_ACCOUNT: '316116520258', URA_ACCOUNT: '652077092967', diff --git a/lib/entities/HardQuoteRequest.ts b/lib/entities/HardQuoteRequest.ts index c7ea5d56..b08cefbb 100644 --- a/lib/entities/HardQuoteRequest.ts +++ b/lib/entities/HardQuoteRequest.ts @@ -1,16 +1,15 @@ import { TradeType } from '@uniswap/sdk-core'; import { UnsignedV2DutchOrder } from '@uniswap/uniswapx-sdk'; -import { BigNumber, utils } from 'ethers'; +import { BigNumber, ethers, utils } from 'ethers'; import { HardQuoteRequestBody } from '../handlers/hard-quote'; -import { QuoteRequestDataJSON } from '.'; +import { QuoteRequest, QuoteRequestDataJSON } from '.'; export class HardQuoteRequest { public order: UnsignedV2DutchOrder; - public static fromHardRequestBody(_body: HardQuoteRequestBody): HardQuoteRequest { - // TODO: parse hard request into the same V2 request object format - throw new Error('Method not implemented.'); + public static fromHardRequestBody(body: HardQuoteRequestBody): HardQuoteRequest { + return new HardQuoteRequest(body); } constructor(private data: HardQuoteRequestBody) { @@ -21,7 +20,7 @@ export class HardQuoteRequest { return { tokenInChainId: this.tokenInChainId, tokenOutChainId: this.tokenOutChainId, - swapper: utils.getAddress(this.swapper), + swapper: ethers.constants.AddressZero, requestId: this.requestId, tokenIn: this.tokenIn, tokenOut: this.tokenOut, @@ -37,21 +36,26 @@ export class HardQuoteRequest { 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), + ...this.toCleanJSON(), // 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 }), }; } + // transforms into a quote request that can be used to query quoters + public toQuoteRequest(): QuoteRequest { + return new QuoteRequest({ + ...this.toCleanJSON(), + swapper: this.swapper, + amount: this.amount, + type: this.type, + }); + } + public get requestId(): string { return this.data.requestId; } @@ -76,16 +80,24 @@ export class HardQuoteRequest { return utils.getAddress(this.order.info.baseOutputs[0].token); } + public get totalOutputAmountStart(): BigNumber { + let amount = BigNumber.from(0); + for (const output of this.order.info.baseOutputs) { + amount = amount.add(output.startAmount); + } + + return amount; + } + + public get totalInputAmountStart(): BigNumber { + return this.order.info.baseInput.startAmount; + } + public get amount(): BigNumber { if (this.type === TradeType.EXACT_INPUT) { - return this.order.info.baseInput.startAmount; + return this.totalInputAmountStart; } else { - const amount = BigNumber.from(0); - for (const output of this.order.info.baseOutputs) { - amount.add(output.startAmount); - } - - return amount; + return this.totalOutputAmountStart; } } @@ -103,6 +115,10 @@ export class HardQuoteRequest { return this.order.info.cosigner; } + public get innerSig(): string { + return this.data.innerSig; + } + public get quoteId(): string | undefined { return this.data.quoteId; } diff --git a/lib/entities/HardQuoteResponse.ts b/lib/entities/HardQuoteResponse.ts new file mode 100644 index 00000000..6262653c --- /dev/null +++ b/lib/entities/HardQuoteResponse.ts @@ -0,0 +1,96 @@ +import { CosignedV2DutchOrder } from '@uniswap/uniswapx-sdk'; +import { BigNumber } from 'ethers'; +import { v4 as uuidv4 } from 'uuid'; + +import { HardQuoteResponseData } from '../handlers/hard-quote/schema'; +import { currentTimestampInMs, timestampInMstoSeconds } from '../util/time'; +import { HardQuoteRequest } from '.'; + +// data class for hard quote response helpers and conversions +export class HardQuoteResponse { + public createdAt: string; + + constructor( + public request: HardQuoteRequest, + public order: CosignedV2DutchOrder, + public createdAtMs = currentTimestampInMs() + ) { + this.createdAt = timestampInMstoSeconds(parseInt(this.createdAtMs)); + } + + public toResponseJSON(): HardQuoteResponseData { + return { + requestId: this.request.requestId, + quoteId: this.request.quoteId, + chainId: this.request.tokenInChainId, + filler: this.order.info.cosignerData.exclusiveFiller, + encodedOrder: this.order.serialize(), + orderHash: this.order.hash(), + }; + } + + public toLog() { + return { + quoteId: this.quoteId, + requestId: this.requestId, + tokenInChainId: this.chainId, + tokenOutChainId: this.chainId, + tokenIn: this.tokenIn, + amountIn: this.amountIn.toString(), + tokenOut: this.tokenOut, + amountOut: this.amountOut.toString(), + swapper: this.swapper, + filler: this.filler, + orderHash: this.order.hash(), + createdAt: this.createdAt, + createdAtMs: this.createdAtMs, + }; + } + + public get quoteId(): string { + return this.request.quoteId ?? uuidv4(); + } + + public get requestId(): string { + return this.request.requestId; + } + + public get chainId(): number { + return this.order.chainId; + } + + public get swapper(): string { + return this.request.swapper; + } + + public get tokenIn(): string { + return this.request.tokenIn; + } + + public get amountOut(): BigNumber { + const resolved = this.order.resolve({ + timestamp: this.order.info.cosignerData.decayStartTime, + }); + let amount = BigNumber.from(0); + for (const output of resolved.outputs) { + amount = amount.add(output.amount); + } + + return amount; + } + + public get amountIn(): BigNumber { + const resolved = this.order.resolve({ + timestamp: this.order.info.cosignerData.decayStartTime, + }); + return resolved.input.amount; + } + + public get tokenOut(): string { + return this.request.tokenOut; + } + + public get filler(): string | undefined { + return this.order.info.cosignerData.exclusiveFiller; + } +} diff --git a/lib/entities/aws-metrics-logger.ts b/lib/entities/aws-metrics-logger.ts index 165f1676..9b41c3e8 100644 --- a/lib/entities/aws-metrics-logger.ts +++ b/lib/entities/aws-metrics-logger.ts @@ -35,10 +35,19 @@ export enum Metric { QUOTE_404 = 'QUOTE_404', QUOTE_500 = 'QUOTE_500', + HARD_QUOTE_200 = 'HARD_QUOTE_200', + HARD_QUOTE_400 = 'HARD_QUOTE_400', + HARD_QUOTE_404 = 'HARD_QUOTE_404', + HARD_QUOTE_500 = 'HARD_QUOTE_500', + QUOTE_REQUESTED = 'QUOTE_REQUESTED', QUOTE_LATENCY = 'QUOTE_LATENCY', QUOTE_RESPONSE_COUNT = 'QUOTE_RESPONSE_COUNT', + HARD_QUOTE_REQUESTED = 'HARD_QUOTE_REQUESTED', + HARD_QUOTE_LATENCY = 'HARD_QUOTE_LATENCY', + HARD_QUOTE_RESPONSE_COUNT = 'HARD_QUOTE_RESPONSE_COUNT', + RFQ_REQUESTED = 'RFQ_REQUESTED', RFQ_SUCCESS = 'RFQ_SUCCESS', RFQ_RESPONSE_TIME = 'RFQ_RESPONSE_TIME', diff --git a/lib/entities/index.ts b/lib/entities/index.ts index e2da9525..3768734c 100644 --- a/lib/entities/index.ts +++ b/lib/entities/index.ts @@ -1,5 +1,6 @@ export * from './analytics-events'; export * from './aws-metrics-logger'; export * from './HardQuoteRequest'; +export * from './HardQuoteResponse'; export * from './QuoteRequest'; export * from './QuoteResponse'; diff --git a/lib/handlers/hard-quote/handler.ts b/lib/handlers/hard-quote/handler.ts index 8ed84a95..ff4d34ed 100644 --- a/lib/handlers/hard-quote/handler.ts +++ b/lib/handlers/hard-quote/handler.ts @@ -1,33 +1,52 @@ +import { TradeType } from '@uniswap/sdk-core'; import { MetricLoggerUnit } from '@uniswap/smart-order-router'; +import { CosignedV2DutchOrder, CosignerData } from '@uniswap/uniswapx-sdk'; +import { BigNumber, ethers } from 'ethers'; import Joi from 'joi'; -import { HardQuoteRequest, Metric } from '../../entities'; +import { HardQuoteRequest, HardQuoteResponse, Metric, QuoteResponse } from '../../entities'; +import { NoQuotesAvailable, OrderPostError, UnknownOrderCosignerError } from '../../util/errors'; import { timestampInMstoSeconds } from '../../util/time'; import { APIGLambdaHandler } from '../base'; import { APIHandleRequestParams, ErrorResponse, Response } from '../base/api-handler'; +import { getBestQuote } from '../quote/handler'; import { ContainerInjected, RequestInjected } from './injector'; -import { HardQuoteRequestBody, HardQuoteRequestBodyJoi } from './schema'; +import { + HardQuoteRequestBody, + HardQuoteRequestBodyJoi, + HardQuoteResponseData, + HardQuoteResponseDataJoi, +} from './schema'; + +const DEFAULT_EXCLUSIVITY_OVERRIDE_BPS = 100; // non-exclusive fillers must override price by this much export class QuoteHandler extends APIGLambdaHandler< ContainerInjected, RequestInjected, HardQuoteRequestBody, void, - null + HardQuoteResponseData > { public async handleRequest( params: APIHandleRequestParams - ): Promise> { + ): Promise> { const { requestInjected: { log, metric }, + containerInjected: { quoters, orderServiceProvider, cosignerWallet }, requestBody, } = params; const start = Date.now(); - metric.putMetric(Metric.QUOTE_REQUESTED, 1, MetricLoggerUnit.Count); + metric.putMetric(Metric.HARD_QUOTE_REQUESTED, 1, MetricLoggerUnit.Count); const request = HardQuoteRequest.fromHardRequestBody(requestBody); + // we dont have access to the cosigner key, throw + if (request.order.info.cosigner !== cosignerWallet.address) { + log.error({ cosigner: request.order.info.cosigner }, 'Unknown cosigner'); + throw new UnknownOrderCosignerError(); + } + // TODO: finalize on v2 metrics logging log.info({ eventType: 'HardQuoteRequest', @@ -42,9 +61,37 @@ export class QuoteHandler extends APIGLambdaHandler< }, }); + const bestQuote = await getBestQuote(quoters, request.toQuoteRequest(), log, metric); + if (!bestQuote) { + metric.putMetric(Metric.HARD_QUOTE_404, 1, MetricLoggerUnit.Count); + throw new NoQuotesAvailable(); + } + + log.info({ bestQuote: bestQuote }, 'bestQuote'); + + // TODO: use server key to cosign instead of local wallet + const cosignerData = getCosignerData(request, bestQuote); + const cosignature = cosignerWallet._signingKey().signDigest(request.order.cosignatureHash(cosignerData)); + const cosignedOrder = CosignedV2DutchOrder.fromUnsignedOrder( + request.order, + cosignerData, + ethers.utils.joinSignature(cosignature) + ); + + try { + await orderServiceProvider.postOrder(cosignedOrder, request.innerSig, request.quoteId); + } catch (e) { + metric.putMetric(Metric.HARD_QUOTE_400, 1, MetricLoggerUnit.Count); + throw new OrderPostError(); + } + + metric.putMetric(Metric.HARD_QUOTE_200, 1, MetricLoggerUnit.Count); + metric.putMetric(Metric.HARD_QUOTE_LATENCY, Date.now() - start, MetricLoggerUnit.Milliseconds); + const response = new HardQuoteResponse(request, cosignedOrder); + return { statusCode: 200, - body: null, + body: response.toResponseJSON(), }; } @@ -57,6 +104,61 @@ export class QuoteHandler extends APIGLambdaHandler< } protected responseBodySchema(): Joi.ObjectSchema | null { - return null; + return HardQuoteResponseDataJoi; + } +} + +export function getCosignerData(request: HardQuoteRequest, quote: QuoteResponse): CosignerData { + const decayStartTime = getDecayStartTime(request.tokenInChainId); + // default to open order with the original prices + let filler = ethers.constants.AddressZero; + let inputAmount = BigNumber.from(0); + const outputAmounts = request.order.info.baseOutputs.map(() => BigNumber.from(0)); + + // if the quote is better, then increase amounts by the difference + if (request.type === TradeType.EXACT_INPUT) { + if (quote.amountOut.gt(request.totalOutputAmountStart)) { + const increase = quote.amountOut.sub(request.totalOutputAmountStart); + // give all the increase to the first (swapper) output + outputAmounts[0] = request.order.info.baseOutputs[0].startAmount.add(increase); + if (quote.filler) { + filler = quote.filler; + } + } + } else { + if (quote.amountIn.lt(request.totalInputAmountStart)) { + inputAmount = quote.amountIn; + if (quote.filler) { + filler = quote.filler; + } + } + } + + return { + decayStartTime: decayStartTime, + decayEndTime: getDecayEndTime(request.tokenInChainId, decayStartTime), + exclusiveFiller: filler, + exclusivityOverrideBps: DEFAULT_EXCLUSIVITY_OVERRIDE_BPS, + inputAmount: inputAmount, + outputAmounts: outputAmounts, + }; +} + +function getDecayStartTime(chainId: number): number { + const nowTimestamp = Math.floor(Date.now() / 1000); + switch (chainId) { + case 1: + return nowTimestamp + 24; // 2 blocks + default: + return nowTimestamp + 10; // 10 seconds + } +} + +function getDecayEndTime(chainId: number, startTime: number): number { + switch (chainId) { + case 1: + return startTime + 60; // 5 blocks + default: + return startTime + 30; // 30 seconds } } diff --git a/lib/handlers/hard-quote/index.ts b/lib/handlers/hard-quote/index.ts index db81cd24..a60e1b13 100644 --- a/lib/handlers/hard-quote/index.ts +++ b/lib/handlers/hard-quote/index.ts @@ -1,3 +1,3 @@ export { QuoteHandler as HardQuoteHandler } from './handler'; -export { QuoteInjector as HardQuoteInjector } from './injector'; +export { ContainerInjected, QuoteInjector as HardQuoteInjector, RequestInjected } from './injector'; export * from './schema'; diff --git a/lib/handlers/hard-quote/injector.ts b/lib/handlers/hard-quote/injector.ts index 6cc55267..78a9922e 100644 --- a/lib/handlers/hard-quote/injector.ts +++ b/lib/handlers/hard-quote/injector.ts @@ -2,6 +2,7 @@ import { IMetric, setGlobalLogger, setGlobalMetric } from '@uniswap/smart-order- import { MetricsLogger } from 'aws-embedded-metrics'; import { APIGatewayProxyEvent, Context } from 'aws-lambda'; import { default as bunyan, default as Logger } from 'bunyan'; +import { Wallet } from 'ethers'; import { BETA_S3_KEY, @@ -11,7 +12,8 @@ import { WEBHOOK_CONFIG_BUCKET, } from '../../constants'; import { AWSMetricsLogger, UniswapXParamServiceMetricDimension } from '../../entities/aws-metrics-logger'; -import { S3WebhookConfigurationProvider } from '../../providers'; +import { checkDefined } from '../../preconditions/preconditions'; +import { OrderServiceProvider, S3WebhookConfigurationProvider, UniswapXServiceProvider } from '../../providers'; import { FirehoseLogger } from '../../providers/analytics'; import { S3CircuitBreakerConfigurationProvider } from '../../providers/circuit-breaker/s3'; import { MockFillerComplianceConfigurationProvider } from '../../providers/compliance'; @@ -23,6 +25,8 @@ import { HardQuoteRequestBody } from './schema'; export interface ContainerInjected { quoters: Quoter[]; firehose: FirehoseLogger; + orderServiceProvider: OrderServiceProvider; + cosignerWallet: Wallet; } export interface RequestInjected extends ApiRInj { @@ -40,6 +44,8 @@ export class QuoteInjector extends ApiInjector; +} + +export * from './mock'; +export * from './uniswapxService'; diff --git a/lib/providers/order/mock.ts b/lib/providers/order/mock.ts new file mode 100644 index 00000000..2ef84f81 --- /dev/null +++ b/lib/providers/order/mock.ts @@ -0,0 +1,13 @@ +import { Order } from '@uniswap/uniswapx-sdk'; + +import { OrderServiceProvider } from '.'; + +export class MockOrderServiceProvider implements OrderServiceProvider { + public orders: string[] = []; + + constructor() {} + + async postOrder(order: Order, _signature: string, _quoteId?: string): Promise { + this.orders.push(order.serialize()); + } +} diff --git a/lib/providers/order/uniswapxService.ts b/lib/providers/order/uniswapxService.ts new file mode 100644 index 00000000..c43a5b68 --- /dev/null +++ b/lib/providers/order/uniswapxService.ts @@ -0,0 +1,39 @@ +import { Order } from '@uniswap/uniswapx-sdk'; +import axios from 'axios'; +import Logger from 'bunyan'; + +import { OrderServiceProvider } from '.'; + +const ORDER_SERVICE_TIMEOUT_MS = 500; + +export class UniswapXServiceProvider implements OrderServiceProvider { + private log: Logger; + + constructor(_log: Logger, private uniswapxServiceUrl: string) { + this.log = _log.child({ quoter: 'WebhookQuoter' }); + } + + async postOrder(order: Order, signature: string, quoteId?: string): Promise { + this.log.info({ orderHash: order.hash() }, 'Posting order to UniswapX Service'); + + const axiosConfig = { + timeout: ORDER_SERVICE_TIMEOUT_MS, + }; + + try { + await axios.post( + this.uniswapxServiceUrl, + { + encodedOrder: order.serialize(), + signature: signature, + chainId: order.chainId, + quoteId: quoteId, + }, + axiosConfig + ); + this.log.info({ orderHash: order.hash() }, 'Order posted to UniswapX Service'); + } catch (e) { + this.log.error({ error: e }, 'Error posting order to UniswapX Service'); + } + } +} diff --git a/lib/util/errors.ts b/lib/util/errors.ts index ed3f455d..f6cb241f 100644 --- a/lib/util/errors.ts +++ b/lib/util/errors.ts @@ -30,3 +30,45 @@ export class NoQuotesAvailable extends CustomError { }; } } + +export class OrderPostError extends CustomError { + private static MESSAGE = 'Error posting order'; + + constructor() { + super(OrderPostError.MESSAGE); + // Set the prototype explicitly. + Object.setPrototypeOf(this, OrderPostError.prototype); + } + + toJSON(id?: string): APIGatewayProxyResult { + return { + statusCode: 400, + body: JSON.stringify({ + errorCode: ErrorCode.QuoteError, + detail: this.message, + id, + }), + }; + } +} + +export class UnknownOrderCosignerError extends CustomError { + private static MESSAGE = 'Unknown cosigner'; + + constructor() { + super(UnknownOrderCosignerError.MESSAGE); + // Set the prototype explicitly. + Object.setPrototypeOf(this, UnknownOrderCosignerError.prototype); + } + + toJSON(id?: string): APIGatewayProxyResult { + return { + statusCode: 400, + body: JSON.stringify({ + errorCode: ErrorCode.QuoteError, + detail: this.message, + id, + }), + }; + } +} diff --git a/lib/util/validator.ts b/lib/util/validator.ts index 234fe1e6..fc71f38e 100644 --- a/lib/util/validator.ts +++ b/lib/util/validator.ts @@ -11,6 +11,8 @@ export class FieldValidator { return ethers.utils.getAddress(value); }); + public static readonly orderHash = Joi.string().regex(this.getHexadecimalRegex(64)); + public static readonly amount = Joi.string().custom((value: string, helpers: CustomHelpers) => { try { const result = BigNumber.from(value); @@ -42,4 +44,12 @@ export class FieldValidator { } return value; }); + + private static getHexadecimalRegex(length?: number, maxLength = false): RegExp { + let lengthModifier = '*'; + if (length) { + lengthModifier = maxLength ? `{0,${length}}` : `{${length}}`; + } + return new RegExp(`^0x[0-9,a-z,A-Z]${lengthModifier}$`); + } } diff --git a/test/entities/HardQuoteResponse.test.ts b/test/entities/HardQuoteResponse.test.ts new file mode 100644 index 00000000..c133961e --- /dev/null +++ b/test/entities/HardQuoteResponse.test.ts @@ -0,0 +1,153 @@ +import { + CosignedV2DutchOrder, + CosignerData, + UnsignedV2DutchOrder, + UnsignedV2DutchOrderInfo, +} from '@uniswap/uniswapx-sdk'; +import { ethers, Wallet } from 'ethers'; +import { parseEther } from 'ethers/lib/utils'; + +import { HardQuoteRequest, HardQuoteResponse } from '../../lib/entities'; +import { HardQuoteRequestBody } from '../../lib/handlers/hard-quote'; +import { getOrder } from '../handlers/hard-quote/handler.test'; + +const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; +const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f7'; +const SWAPPER = '0x0000000000000000000000000000000000000002'; +const FILLER = '0x0000000000000000000000000000000000000001'; +const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; +const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; +const CHAIN_ID = 1; +const fixedTime = 4206969; +jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + +describe('HardQuoteResponse', () => { + const swapperWallet = Wallet.createRandom(); + const cosignerWallet = Wallet.createRandom(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getRequest = async (order: UnsignedV2DutchOrder): Promise => { + const { types, domain, values } = order.permitData(); + const sig = await swapperWallet._signTypedData(domain, types, values); + return { + requestId: REQUEST_ID, + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + encodedInnerOrder: order.serialize(), + innerSig: sig, + }; + }; + + const getResponse = async (data: Partial, cosignerData: CosignerData) => { + const unsigned = getOrder(data); + const cosignature = cosignerWallet._signingKey().signDigest(unsigned.cosignatureHash(cosignerData)); + const order = CosignedV2DutchOrder.fromUnsignedOrder( + unsigned, + cosignerData, + ethers.utils.joinSignature(cosignature) + ); + return new HardQuoteResponse(new HardQuoteRequest(await getRequest(unsigned)), order); + }; + + it('toResponseJSON', async () => { + const now = Math.floor(Date.now() / 1000); + const quoteResponse = await getResponse( + {}, + { + decayStartTime: now + 100, + decayEndTime: now + 200, + exclusiveFiller: FILLER, + exclusivityOverrideBps: 100, + inputAmount: parseEther('1'), + outputAmounts: [parseEther('1')], + } + ); + expect(quoteResponse.toResponseJSON()).toEqual({ + requestId: REQUEST_ID, + quoteId: QUOTE_ID, + chainId: CHAIN_ID, + filler: FILLER, + encodedOrder: quoteResponse.order.serialize(), + orderHash: quoteResponse.order.hash(), + }); + }); + + it('toLog', async () => { + const now = Math.floor(Date.now() / 1000); + const quoteResponse = await getResponse( + {}, + { + decayStartTime: now + 100, + decayEndTime: now + 200, + exclusiveFiller: FILLER, + exclusivityOverrideBps: 100, + inputAmount: ethers.utils.parseEther('1'), + outputAmounts: [ethers.utils.parseEther('1')], + } + ); + expect(quoteResponse.toLog()).toEqual({ + createdAt: expect.any(String), + createdAtMs: expect.any(String), + amountOut: parseEther('1').toString(), + amountIn: parseEther('1').toString(), + quoteId: QUOTE_ID, + requestId: REQUEST_ID, + swapper: SWAPPER, + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + filler: FILLER, + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + }); + + it('amountOut uses post cosigned resolution', async () => { + const now = Math.floor(Date.now() / 1000); + const quoteResponse = await getResponse( + {}, + { + decayStartTime: now + 100, + decayEndTime: now + 200, + exclusiveFiller: FILLER, + exclusivityOverrideBps: 100, + inputAmount: parseEther('1'), + outputAmounts: [parseEther('2')], + } + ); + expect(quoteResponse.amountOut).toEqual(parseEther('2')); + }); + + it('amountIn uses post cosigned resolution', async () => { + const now = Math.floor(Date.now() / 1000); + const quoteResponse = await getResponse( + { + cosigner: cosignerWallet.address, + baseInput: { + token: TOKEN_IN, + startAmount: parseEther('1'), + endAmount: parseEther('1.1'), + }, + baseOutputs: [ + { + token: TOKEN_OUT, + startAmount: parseEther('1'), + endAmount: parseEther('1'), + recipient: ethers.constants.AddressZero, + }, + ], + }, + { + decayStartTime: now + 100, + decayEndTime: now + 200, + exclusiveFiller: FILLER, + exclusivityOverrideBps: 100, + inputAmount: parseEther('0.8'), + outputAmounts: [parseEther('1')], + } + ); + expect(quoteResponse.amountIn).toEqual(parseEther('0.8')); + }); + }); +}); diff --git a/test/handlers/hard-quote/handler.test.ts b/test/handlers/hard-quote/handler.test.ts index 384bc78c..8e15ba9a 100644 --- a/test/handlers/hard-quote/handler.test.ts +++ b/test/handlers/hard-quote/handler.test.ts @@ -1,48 +1,76 @@ +import { TradeType } from '@uniswap/sdk-core'; +import { CosignedV2DutchOrder, UnsignedV2DutchOrder, UnsignedV2DutchOrderInfo } from '@uniswap/uniswapx-sdk'; import { createMetricsLogger } from 'aws-embedded-metrics'; import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; -import axios from 'axios'; +// import axios from 'axios'; import { default as Logger } from 'bunyan'; -import { ethers } from 'ethers'; +import { BigNumber, ethers, Wallet } from 'ethers'; +import { HardQuoteRequest, QuoteResponse, QuoteResponseData } from '../../../lib/entities'; import { AWSMetricsLogger } from '../../../lib/entities/aws-metrics-logger'; import { ApiInjector } from '../../../lib/handlers/base/api-handler'; import { ContainerInjected, - PostQuoteRequestBody, - PostQuoteResponse, + HardQuoteHandler, + HardQuoteRequestBody, + HardQuoteResponseData, 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'; +} from '../../../lib/handlers/hard-quote'; +import { getCosignerData } from '../../../lib/handlers/hard-quote/handler'; +import { MockOrderServiceProvider } from '../../../lib/providers'; +import { MOCK_FILLER_ADDRESS, MockQuoter, Quoter } 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 RAW_AMOUNT = BigNumber.from('1000000000000000000'); 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'); +export const getOrder = (data: Partial): UnsignedV2DutchOrder => { + const now = Math.floor(new Date().getTime() / 1000); + return new UnsignedV2DutchOrder( + 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, + baseInput: { + token: TOKEN_IN, + startAmount: RAW_AMOUNT, + endAmount: RAW_AMOUNT, + }, + baseOutputs: [ + { + token: TOKEN_OUT, + startAmount: RAW_AMOUNT, + endAmount: RAW_AMOUNT.mul(90).div(100), + recipient: ethers.constants.AddressZero, + }, + ], + cosignature: undefined, + }, + data + ), + CHAIN_ID + ); +}; describe('Quote handler', () => { + const swapperWallet = Wallet.createRandom(); + const cosignerWallet = Wallet.createRandom(); + // Creating mocks for all the handler dependencies. const requestInjectedMock: Promise = new Promise( (resolve) => @@ -55,538 +83,264 @@ describe('Quote handler', () => { const injectorPromiseMock = ( quoters: Quoter[] - ): Promise> => + ): Promise> => new Promise((resolve) => resolve({ getContainerInjected: () => { return { quoters, + cosignerWallet, + orderServiceProvider: new MockOrderServiceProvider(), }; }, getRequestInjected: () => requestInjectedMock, - } as unknown as ApiInjector) + } as unknown as ApiInjector) ); - const getQuoteHandler = (quoters: Quoter[]) => new QuoteHandler('quote', injectorPromiseMock(quoters)); + const getQuoteHandler = (quoters: Quoter[]) => new HardQuoteHandler('quote', injectorPromiseMock(quoters)); - const getEvent = (request: PostQuoteRequestBody): APIGatewayProxyEvent => + const getEvent = (request: HardQuoteRequestBody): 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, - }); + const getRequest = async (order: UnsignedV2DutchOrder): Promise => { + const { types, domain, values } = order.permitData(); + const sig = await swapperWallet._signTypedData(domain, types, values); + return { + requestId: REQUEST_ID, + tokenInChainId: CHAIN_ID, + tokenOutChainId: CHAIN_ID, + encodedInnerOrder: order.serialize(), + innerSig: sig, + }; + }; 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 request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( getEvent(request), {} as unknown as Context ); - const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + const quoteResponse: HardQuoteResponseData = 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) }); + expect(quoteResponse.requestId).toEqual(request.requestId); + expect(quoteResponse.quoteId).toEqual(request.quoteId); + expect(quoteResponse.chainId).toEqual(request.tokenInChainId); + expect(quoteResponse.filler).toEqual(ethers.constants.AddressZero); + const cosignedOrder = CosignedV2DutchOrder.parse(quoteResponse.encodedOrder, CHAIN_ID); + + // no overrides since quote was same as request + expect(cosignedOrder.info.cosignerData.exclusiveFiller).toEqual(ethers.constants.AddressZero); + expect(cosignedOrder.info.cosignerData.inputAmount).toEqual(BigNumber.from(0)); + expect(cosignedOrder.info.cosignerData.outputAmounts.length).toEqual(1); + expect(cosignedOrder.info.cosignerData.outputAmounts[0]).toEqual(BigNumber.from(0)); }); 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 request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( getEvent(request), {} as unknown as Context ); - const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + const quoteResponse: HardQuoteResponseData = 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), - }); + expect(quoteResponse.requestId).toEqual(request.requestId); + expect(quoteResponse.quoteId).toEqual(request.quoteId); + expect(quoteResponse.chainId).toEqual(request.tokenInChainId); + expect(quoteResponse.filler).toEqual(MOCK_FILLER_ADDRESS); + const cosignedOrder = CosignedV2DutchOrder.parse(quoteResponse.encodedOrder, CHAIN_ID); + expect(cosignedOrder.info.cosignerData.exclusiveFiller).toEqual(MOCK_FILLER_ADDRESS); + + // overridden output amount to 2x + expect(cosignedOrder.info.cosignerData.inputAmount).toEqual(BigNumber.from(0)); + expect(cosignedOrder.info.cosignerData.outputAmounts.length).toEqual(1); + expect(cosignedOrder.info.cosignerData.outputAmounts[0]).toEqual(RAW_AMOUNT.mul(2)); }); 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 quoters = [new MockQuoter(logger, 9, 10), new MockQuoter(logger, 8, 10)]; + const order = getOrder({ + cosigner: cosignerWallet.address, + baseInput: { + token: TOKEN_IN, + startAmount: RAW_AMOUNT, + endAmount: RAW_AMOUNT.mul(110).div(100), + }, + baseOutputs: [ + { + token: TOKEN_OUT, + startAmount: RAW_AMOUNT, + endAmount: RAW_AMOUNT, + recipient: ethers.constants.AddressZero, + }, + ], + }); + const request = await getRequest(order); const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( getEvent(request), {} as unknown as Context ); - const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + const quoteResponse: HardQuoteResponseData = 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), - }); + expect(quoteResponse.requestId).toEqual(request.requestId); + expect(quoteResponse.quoteId).toEqual(request.quoteId); + expect(quoteResponse.chainId).toEqual(request.tokenInChainId); + expect(quoteResponse.filler).toEqual(MOCK_FILLER_ADDRESS); + const cosignedOrder = CosignedV2DutchOrder.parse(quoteResponse.encodedOrder, CHAIN_ID); + expect(cosignedOrder.info.cosignerData.exclusiveFiller).toEqual(MOCK_FILLER_ADDRESS); + + // overridden output amount to 2x + expect(cosignedOrder.info.cosignerData.inputAmount).toEqual(RAW_AMOUNT.mul(8).div(10)); + expect(cosignedOrder.info.cosignerData.outputAmounts.length).toEqual(1); + expect(cosignedOrder.info.cosignerData.outputAmounts[0]).toEqual(BigNumber.from(0)); }); 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 request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( getEvent(request), {} as unknown as Context ); - const quoteResponse: PostQuoteResponse = JSON.parse(response.body); // random quoteId + const quoteResponse: HardQuoteResponseData = JSON.parse(response.body); // random quoteId expect(response.statusCode).toEqual(200); - expect(responseFromRequest(request, {})).toMatchObject({ ...quoteResponse, quoteId: expect.any(String) }); + expect(quoteResponse.requestId).toEqual(request.requestId); + expect(quoteResponse.quoteId).toEqual(request.quoteId); + expect(quoteResponse.chainId).toEqual(request.tokenInChainId); + expect(quoteResponse.filler).toEqual(ethers.constants.AddressZero); + const cosignedOrder = CosignedV2DutchOrder.parse(quoteResponse.encodedOrder, CHAIN_ID); + expect(cosignedOrder.info.cosignerData.exclusiveFiller).toEqual(ethers.constants.AddressZero); + + // overridden output amount to 2x + expect(cosignedOrder.info.cosignerData.inputAmount).toEqual(BigNumber.from(0)); + expect(cosignedOrder.info.cosignerData.outputAmounts.length).toEqual(1); + expect(cosignedOrder.info.cosignerData.outputAmounts[0]).toEqual(BigNumber.from(0)); }); - it('Invalid amountIn', async () => { - const invalidAmounts = ['-100', 'aszzz', 'zz']; - + it('Unknown cosigner', async () => { const quoters = [new MockQuoter(logger, 1, 1)]; + const request = await getRequest(getOrder({ cosigner: '0x1111111111111111111111111111111111111111' })); - 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', - }); - } + const response: APIGatewayProxyResult = await getQuoteHandler(quoters).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(400); + const error = JSON.parse(response.body); + expect(error).toMatchObject({ + detail: 'Unknown cosigner', + errorCode: 'QUOTE_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.only('No quotes', async () => { + const request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); - 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, - }); + const response: APIGatewayProxyResult = await getQuoteHandler([]).handler( + getEvent(request), + {} as unknown as Context + ); + expect(response.statusCode).toEqual(404); + const error = JSON.parse(response.body); + expect(error).toMatchObject({ + detail: 'No quotes available', + errorCode: 'QUOTE_ERROR', }); + }); - 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, + describe('getCosignerData', () => { + const getQuoteResponse = ( + data: Partial, + type: TradeType = TradeType.EXACT_INPUT + ): QuoteResponse => { + return new QuoteResponse( + Object.assign( + { + chainId: CHAIN_ID, + amountOut: ethers.utils.parseEther('1'), + amountIn: ethers.utils.parseEther('1'), + quoteId: QUOTE_ID, + requestId: REQUEST_ID, + filler: MOCK_FILLER_ADDRESS, + swapper: swapperWallet.address, + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, }, - }); - }); - - 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 + data ), - ]; - 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 + type ); - expect(response.statusCode).toEqual(404); + }; + + it('updates decay times reasonably', async () => { + const request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); + const now = Math.floor(Date.now() / 1000); + const cosignerData = getCosignerData(new HardQuoteRequest(request), getQuoteResponse({})); + expect(cosignerData.decayStartTime).toBeGreaterThan(now); + expect(cosignerData.decayStartTime).toBeLessThan(now + 1000); + expect(cosignerData.decayEndTime).toBeGreaterThan(cosignerData.decayStartTime); + expect(cosignerData.decayEndTime).toBeLessThan(cosignerData.decayStartTime + 1000); }); - 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 + it('exact input quote worse, no exclusivity', async () => { + const request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); + const cosignerData = getCosignerData( + new HardQuoteRequest(request), + getQuoteResponse({ amountOut: ethers.utils.parseEther('0.8') }) ); - 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) }); + expect(cosignerData.exclusiveFiller).toEqual(ethers.constants.AddressZero); + expect(cosignerData.inputAmount).toEqual(BigNumber.from(0)); + expect(cosignerData.outputAmounts.length).toEqual(1); + expect(cosignerData.outputAmounts[0]).toEqual(BigNumber.from(0)); }); - 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 + it('exact input quote better, sets exclusivity and updates amounts', async () => { + const request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); + const outputAmount = ethers.utils.parseEther('2'); + const cosignerData = getCosignerData( + new HardQuoteRequest(request), + getQuoteResponse({ amountOut: outputAmount }) ); - 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, - }); + expect(cosignerData.exclusiveFiller).toEqual(MOCK_FILLER_ADDRESS); + expect(cosignerData.inputAmount).toEqual(BigNumber.from(0)); + expect(cosignerData.outputAmounts.length).toEqual(1); + expect(cosignerData.outputAmounts[0]).toEqual(outputAmount); }); - 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 + it('exact output quote worse, no exclusivity', async () => { + const request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); + const cosignerData = getCosignerData( + new HardQuoteRequest(request), + getQuoteResponse({ amountIn: ethers.utils.parseEther('1.2') }, TradeType.EXACT_OUTPUT) ); - 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), - }); + expect(cosignerData.exclusiveFiller).toEqual(ethers.constants.AddressZero); + expect(cosignerData.inputAmount).toEqual(BigNumber.from(0)); + expect(cosignerData.outputAmounts.length).toEqual(1); + expect(cosignerData.outputAmounts[0]).toEqual(BigNumber.from(0)); }); - 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', - }) + it('exact input quote better, sets exclusivity and updates amounts', async () => { + const request = await getRequest(getOrder({ cosigner: cosignerWallet.address })); + const inputAmount = ethers.utils.parseEther('0.8'); + const cosignerData = getCosignerData( + new HardQuoteRequest(request), + getQuoteResponse({ amountIn: ethers.utils.parseEther('1.2') }, TradeType.EXACT_OUTPUT) ); + expect(cosignerData.exclusiveFiller).toEqual(MOCK_FILLER_ADDRESS); + expect(cosignerData.inputAmount).toEqual(inputAmount); + expect(cosignerData.outputAmounts.length).toEqual(1); + expect(cosignerData.outputAmounts[0]).toEqual(BigNumber.from(0)); }); }); });