diff --git a/lib/handlers/hard-quote/handler.ts b/lib/handlers/hard-quote/handler.ts index 371bc6d5..d4576857 100644 --- a/lib/handlers/hard-quote/handler.ts +++ b/lib/handlers/hard-quote/handler.ts @@ -6,6 +6,7 @@ import Joi from 'joi'; import { v4 as uuidv4 } from 'uuid'; import { HardQuoteRequest, HardQuoteResponse, Metric, QuoteResponse } from '../../entities'; +import { ProtocolVersion } from '../../providers'; import { NoQuotesAvailable, OrderPostError, UnknownOrderCosignerError } from '../../util/errors'; import { timestampInMstoSeconds } from '../../util/time'; import { APIGLambdaHandler } from '../base'; @@ -70,7 +71,14 @@ export class QuoteHandler extends APIGLambdaHandler< }, }); - const bestQuote = await getBestQuote(quoters, request.toQuoteRequest(), log, metric, 'HardResponse'); + const bestQuote = await getBestQuote( + quoters, + request.toQuoteRequest(), + log, + metric, + ProtocolVersion.V2, + 'HardResponse' + ); if (!bestQuote && !requestBody.allowNoQuote) { if (!requestBody.allowNoQuote) { throw new NoQuotesAvailable(); diff --git a/lib/handlers/quote/handler.ts b/lib/handlers/quote/handler.ts index d1233d9a..b6462af8 100644 --- a/lib/handlers/quote/handler.ts +++ b/lib/handlers/quote/handler.ts @@ -4,6 +4,7 @@ import Logger from 'bunyan'; import Joi from 'joi'; import { Metric, QuoteRequest, QuoteResponse } from '../../entities'; +import { ProtocolVersion } from '../../providers'; import { Quoter } from '../../quoters'; import { NoQuotesAvailable } from '../../util/errors'; import { timestampInMstoSeconds } from '../../util/time'; @@ -86,9 +87,12 @@ export async function getBestQuote( quoteRequest: QuoteRequest, log: Logger, metric: IMetric, + protocolVersion: ProtocolVersion = ProtocolVersion.V1, eventType: EventType = 'QuoteResponse' ): Promise { - const responses: QuoteResponse[] = (await Promise.all(quoters.map((q) => q.quote(quoteRequest)))).flat(); + const responses: QuoteResponse[] = ( + await Promise.all(quoters.map((q) => q.quote(quoteRequest, protocolVersion))) + ).flat(); switch (responses.length) { case 0: metric.putMetric(Metric.RFQ_COUNT_0, 1, MetricLoggerUnit.Count); diff --git a/lib/providers/webhook/index.ts b/lib/providers/webhook/index.ts index 750bf92d..a54dc8e1 100644 --- a/lib/providers/webhook/index.ts +++ b/lib/providers/webhook/index.ts @@ -5,6 +5,11 @@ type WebhookOverrides = { timeout: number; }; +export enum ProtocolVersion { + V1 = 'v1', + V2 = 'v2', +} + export interface WebhookConfiguration { name: string; hash: string; @@ -15,6 +20,7 @@ export interface WebhookConfiguration { // if null, send for all chains chainIds?: number[]; addresses?: string[]; + supportedVersions?: ProtocolVersion[]; } export interface WebhookConfigurationProvider { diff --git a/lib/providers/webhook/mock.ts b/lib/providers/webhook/mock.ts index 0c587c63..1c748c66 100644 --- a/lib/providers/webhook/mock.ts +++ b/lib/providers/webhook/mock.ts @@ -1,4 +1,4 @@ -import { WebhookConfiguration, WebhookConfigurationProvider } from '.'; +import { ProtocolVersion, WebhookConfiguration, WebhookConfigurationProvider } from '.'; export class MockWebhookConfigurationProvider implements WebhookConfigurationProvider { constructor(private endpoints: WebhookConfiguration[]) {} @@ -6,4 +6,9 @@ export class MockWebhookConfigurationProvider implements WebhookConfigurationPro async getEndpoints(): Promise { return this.endpoints; } + + async getFillerSupportedProtocols(endpoint: string): Promise { + const config = this.endpoints.find((e) => e.endpoint === endpoint); + return config?.supportedVersions ?? [ProtocolVersion.V1]; + } } diff --git a/lib/providers/webhook/s3.ts b/lib/providers/webhook/s3.ts index 7e14e664..053be138 100644 --- a/lib/providers/webhook/s3.ts +++ b/lib/providers/webhook/s3.ts @@ -1,8 +1,8 @@ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { default as Logger } from 'bunyan'; +import { ProtocolVersion, WebhookConfiguration, WebhookConfigurationProvider } from '.'; import { checkDefined } from '../../preconditions/preconditions'; -import { WebhookConfiguration, WebhookConfigurationProvider } from '.'; export type FillerAddressesMap = Map>; @@ -62,4 +62,22 @@ export class S3WebhookConfigurationProvider implements WebhookConfigurationProvi this.endpoints = JSON.parse(await s3Body.transformToString()) as WebhookConfiguration[]; this.log.info({ endpoints: this.endpoints }, `Fetched ${this.endpoints.length} endpoints from S3`); } + + /* + * Returns the supported protocol versions for the filler at the given endpoint. + * @param endpoint - The endpoint to check the supported protocol versions for. + * @returns List of endpoint's supported protocols; defaults to v1 only + * + */ + async getFillerSupportedProtocols(endpoint: string): Promise { + if ( + this.endpoints.length === 0 || + Date.now() - this.lastUpdatedEndpointsTimestamp > S3WebhookConfigurationProvider.UPDATE_ENDPOINTS_PERIOD_MS + ) { + await this.fetchEndpoints(); + this.lastUpdatedEndpointsTimestamp = Date.now(); + } + const config = this.endpoints.find((e) => e.endpoint === endpoint); + return config?.supportedVersions ?? [ProtocolVersion.V1]; + } } diff --git a/lib/quoters/MockQuoter.ts b/lib/quoters/MockQuoter.ts index e5c91752..82d9ec4b 100644 --- a/lib/quoters/MockQuoter.ts +++ b/lib/quoters/MockQuoter.ts @@ -1,8 +1,8 @@ import Logger from 'bunyan'; import { BigNumber } from 'ethers'; -import { QuoteRequest, QuoteResponse } from '../entities'; import { Quoter, QuoterType } from '.'; +import { QuoteRequest, QuoteResponse } from '../entities'; export const MOCK_FILLER_ADDRESS = '0x0000000000000000000000000000000000000001'; diff --git a/lib/quoters/WebhookQuoter.ts b/lib/quoters/WebhookQuoter.ts index 1f415ec3..c33c46fc 100644 --- a/lib/quoters/WebhookQuoter.ts +++ b/lib/quoters/WebhookQuoter.ts @@ -14,7 +14,7 @@ import { QuoteResponse, WebhookResponseType, } from '../entities'; -import { WebhookConfiguration, WebhookConfigurationProvider } from '../providers'; +import { ProtocolVersion, WebhookConfiguration, WebhookConfigurationProvider } from '../providers'; import { FirehoseLogger } from '../providers/analytics'; import { CircuitBreakerConfigurationProvider } from '../providers/circuit-breaker'; import { FillerComplianceConfigurationProvider } from '../providers/compliance'; @@ -41,14 +41,14 @@ export class WebhookQuoter implements Quoter { this.ALLOW_LIST = _allow_list; } - public async quote(request: QuoteRequest): Promise { - const endpoints = await this.getEligibleEndpoints(); + public async quote(request: QuoteRequest, version: ProtocolVersion = ProtocolVersion.V1): Promise { + let endpoints = await this.getEligibleEndpoints(); const endpointToAddrsMap = await this.complianceProvider.getEndpointToExcludedAddrsMap(); - endpoints.filter((e) => { - return ( - endpointToAddrsMap.get(e.endpoint) === undefined || !endpointToAddrsMap.get(e.endpoint)?.has(request.swapper) - ); - }); + endpoints = endpoints.filter( + (e) => + passFillerCompliance(e, endpointToAddrsMap, request.swapper) && + getEndpointSupportedProtocols(e).includes(version) + ); this.log.info({ endpoints }, `Fetching quotes from ${endpoints.length} endpoints`); const quotes = await Promise.all(endpoints.map((e) => this.fetchQuote(e, request))); @@ -312,3 +312,18 @@ function isNonQuote(request: QuoteRequest, hookResponse: AxiosResponse, parsedRe return false; } + +export function getEndpointSupportedProtocols(e: WebhookConfiguration) { + if (!e.supportedVersions || e.supportedVersions.length == 0) { + return [ProtocolVersion.V1]; + } + return e.supportedVersions; +} + +export function passFillerCompliance( + e: WebhookConfiguration, + endpointToAddrsMap: Map>, + swapper: string +) { + return endpointToAddrsMap.get(e.endpoint) === undefined || !endpointToAddrsMap.get(e.endpoint)?.has(swapper); +} diff --git a/lib/quoters/index.ts b/lib/quoters/index.ts index c534828b..0e094a08 100644 --- a/lib/quoters/index.ts +++ b/lib/quoters/index.ts @@ -1,4 +1,5 @@ import { QuoteRequest, QuoteResponse } from '../entities'; +import { ProtocolVersion } from '../providers'; export enum QuoterType { TEST = 'TEST', @@ -7,7 +8,7 @@ export enum QuoterType { } export interface Quoter { - quote(request: QuoteRequest): Promise; + quote(request: QuoteRequest, version: ProtocolVersion): Promise; type(): QuoterType; } diff --git a/test/providers/quoters/WebhookQuoter.test.ts b/test/providers/quoters/WebhookQuoter.test.ts index 1bccc326..ad7bc84f 100644 --- a/test/providers/quoters/WebhookQuoter.test.ts +++ b/test/providers/quoters/WebhookQuoter.test.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import { BigNumber, ethers } from 'ethers'; import { AnalyticsEventType, QuoteRequest, WebhookResponseType } from '../../../lib/entities'; -import { MockWebhookConfigurationProvider } from '../../../lib/providers'; +import { MockWebhookConfigurationProvider, ProtocolVersion } from '../../../lib/providers'; import { FirehoseLogger } from '../../../lib/providers/analytics'; import { MockCircuitBreakerConfigurationProvider } from '../../../lib/providers/circuit-breaker/mock'; import { MockFillerComplianceConfigurationProvider } from '../../../lib/providers/compliance'; @@ -156,9 +156,117 @@ describe('WebhookQuoter tests', () => { { quoteId: expect.any(String), ...request.toCleanJSON() }, { headers: {}, timeout: 500 } ); - expect(mockedAxios.post).not.toBeCalledWith(WEBHOOK_URL_ONEINCH, request.toCleanJSON(), { - headers: {}, - timeout: 500, + expect(mockedAxios.post).not.toBeCalledWith( + WEBHOOK_URL_ONEINCH, + { + quoteId: expect.any(String), + ...request.toCleanJSON(), + }, + { + headers: {}, + timeout: 500, + } + ); + }); + + describe('Supported protocols tests', () => { + const webhookProvider = new MockWebhookConfigurationProvider([ + { name: 'uniswap', endpoint: WEBHOOK_URL, headers: {}, hash: '0xuni', supportedVersions: [ProtocolVersion.V2] }, + { name: '1inch', endpoint: WEBHOOK_URL_ONEINCH, headers: {}, hash: '0x1inch' }, + { + name: 'searcher', + endpoint: WEBHOOK_URL_SEARCHER, + headers: {}, + hash: '0xsearcher', + supportedVersions: [ProtocolVersion.V1, ProtocolVersion.V2], + }, + ]); + const circuitBreakerProvider = new MockCircuitBreakerConfigurationProvider([ + { hash: '0xuni', fadeRate: 0.1, enabled: true }, + { hash: '0x1inch', fadeRate: 0.1, enabled: true }, + { hash: '0xsearcher', fadeRate: 0.1, enabled: true }, + ]); + const webhookQuoter = new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + circuitBreakerProvider, + emptyMockComplianceProvider + ); + it('v1 quote request only sent to fillers supporting v1', async () => { + mockedAxios.post + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: quote, + }); + }) + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + ...quote, + tokenIn: request.tokenOut, + tokenOut: request.tokenIn, + }, + }); + }); + + await webhookQuoter.quote(request, ProtocolVersion.V1); + expect(mockedAxios.post).toBeCalledWith( + WEBHOOK_URL_ONEINCH, + { quoteId: expect.any(String), ...request.toCleanJSON() }, + { headers: {}, timeout: 500 } + ); + expect(mockedAxios.post).toBeCalledWith( + WEBHOOK_URL_SEARCHER, + { quoteId: expect.any(String), ...request.toCleanJSON() }, + { headers: {}, timeout: 500 } + ); + expect(mockedAxios.post).not.toBeCalledWith(WEBHOOK_URL, request.toCleanJSON(), { + headers: {}, + timeout: 500, + }); + }); + + it('v2 quote request only sent to fillers supporting v2', async () => { + mockedAxios.post + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: quote, + }); + }) + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + ...quote, + tokenIn: request.tokenOut, + tokenOut: request.tokenIn, + }, + }); + }); + + await webhookQuoter.quote(request, ProtocolVersion.V2); + expect(mockedAxios.post).toBeCalledWith( + WEBHOOK_URL, + { quoteId: expect.any(String), ...request.toCleanJSON() }, + { headers: {}, timeout: 500 } + ); + expect(mockedAxios.post).toBeCalledWith( + WEBHOOK_URL_SEARCHER, + { quoteId: expect.any(String), ...request.toCleanJSON() }, + { + headers: {}, + timeout: 500, + } + ); + // empty config defaults to v1 only + expect(mockedAxios.post).not.toBeCalledWith( + WEBHOOK_URL_ONEINCH, + { quoteId: expect.any(String), ...request.toCleanJSON() }, + { + headers: {}, + timeout: 500, + } + ); }); });