From 70ddab0231cc6a3b46f21a6758b658475534dac4 Mon Sep 17 00:00:00 2001 From: phil-ociraptor Date: Wed, 31 Mar 2021 12:11:10 -0500 Subject: [PATCH] [MKR-3] Prepare Asset Swapper for RFQM (#187) * Prepare QuoteRequestor for RFQM * Add unit tests for Quote Requestor changes * Fix lint errors --- packages/asset-swapper/package.json | 2 +- packages/asset-swapper/src/swap_quoter.ts | 1 + packages/asset-swapper/src/types.ts | 2 +- .../src/utils/quote_requestor.ts | 346 ++++++++++++------ .../test/quote_requestor_test.ts | 330 ++++++++++++++++- .../asset-swapper/test/utils/test_helpers.ts | 12 +- yarn.lock | 7 +- 7 files changed, 560 insertions(+), 140 deletions(-) diff --git a/packages/asset-swapper/package.json b/packages/asset-swapper/package.json index 629f128fb4..4956227135 100644 --- a/packages/asset-swapper/package.json +++ b/packages/asset-swapper/package.json @@ -66,7 +66,7 @@ "@0x/dev-utils": "^4.2.1", "@0x/json-schemas": "^5.4.1", "@0x/protocol-utils": "^1.3.1", - "@0x/quote-server": "^4.0.1", + "@0x/quote-server": "^5.0.0", "@0x/types": "^3.3.1", "@0x/typescript-typings": "^5.1.6", "@0x/utils": "^6.2.0", diff --git a/packages/asset-swapper/src/swap_quoter.ts b/packages/asset-swapper/src/swap_quoter.ts index a81522f06f..c6d678996d 100644 --- a/packages/asset-swapper/src/swap_quoter.ts +++ b/packages/asset-swapper/src/swap_quoter.ts @@ -358,6 +358,7 @@ export class SwapQuoter { if (calcOpts.rfqt !== undefined) { calcOpts.rfqt.quoteRequestor = new QuoteRequestor( rfqtOptions ? rfqtOptions.makerAssetOfferings || {} : {}, + {}, this._quoteRequestorHttpClient, rfqtOptions ? rfqtOptions.altRfqCreds : undefined, rfqtOptions ? rfqtOptions.warningLogger : undefined, diff --git a/packages/asset-swapper/src/types.ts b/packages/asset-swapper/src/types.ts index 97fad91e72..ce817d3828 100644 --- a/packages/asset-swapper/src/types.ts +++ b/packages/asset-swapper/src/types.ts @@ -240,6 +240,7 @@ export interface RfqRequestOpts { makerEndpointMaxResponseTimeMs?: number; nativeExclusivelyRFQ?: boolean; altRfqAssetOfferings?: AltRfqMakerAssetOfferings; + isLastLook?: boolean; } /** @@ -257,7 +258,6 @@ export interface SwapQuoteRequestOpts extends GetMarketOrdersOpts { export interface RfqMakerAssetOfferings { [endpoint: string]: Array<[string, string]>; } - export interface AltOffering { id: string; baseAsset: string; diff --git a/packages/asset-swapper/src/utils/quote_requestor.ts b/packages/asset-swapper/src/utils/quote_requestor.ts index db5571178a..3dec05d733 100644 --- a/packages/asset-swapper/src/utils/quote_requestor.ts +++ b/packages/asset-swapper/src/utils/quote_requestor.ts @@ -83,6 +83,7 @@ export class QuoteRequestor { sellTokenAddress: string, // taker token assetFillAmount: BigNumber, comparisonPrice?: BigNumber, + isLastLook: boolean = false, ): TakerRequestQueryParams { const { buyAmountBaseUnits, sellAmountBaseUnits } = marketOperation === MarketOperation.Buy @@ -97,13 +98,20 @@ export class QuoteRequestor { const requestParamsWithBigNumbers: Pick< TakerRequestQueryParams, - 'buyTokenAddress' | 'sellTokenAddress' | 'txOrigin' | 'comparisonPrice' | 'protocolVersion' | 'takerAddress' + | 'txOrigin' + | 'takerAddress' + | 'buyTokenAddress' + | 'sellTokenAddress' + | 'comparisonPrice' + | 'isLastLook' + | 'protocolVersion' > = { txOrigin, takerAddress, - comparisonPrice: comparisonPrice === undefined ? undefined : comparisonPrice.toString(), buyTokenAddress, sellTokenAddress, + comparisonPrice: comparisonPrice === undefined ? undefined : comparisonPrice.toString(), + isLastLook: isLastLook.toString(), protocolVersion: '4', }; @@ -124,8 +132,38 @@ export class QuoteRequestor { } } + private static _makerSupportsPair( + typedMakerUrl: TypedMakerUrl, + makerToken: string, + takerToken: string, + altMakerAssetOfferings: AltRfqMakerAssetOfferings | undefined, + assetOfferings: RfqMakerAssetOfferings | undefined, + ): boolean { + if (typedMakerUrl.pairType === RfqPairType.Standard && assetOfferings) { + for (const assetPair of assetOfferings[typedMakerUrl.url]) { + if ( + (assetPair[0] === makerToken && assetPair[1] === takerToken) || + (assetPair[0] === takerToken && assetPair[1] === makerToken) + ) { + return true; + } + } + } else if (typedMakerUrl.pairType === RfqPairType.Alt && altMakerAssetOfferings) { + for (const altAssetPair of altMakerAssetOfferings[typedMakerUrl.url]) { + if ( + (altAssetPair.baseAsset === makerToken && altAssetPair.quoteAsset === takerToken) || + (altAssetPair.baseAsset === takerToken && altAssetPair.quoteAsset === makerToken) + ) { + return true; + } + } + } + return false; + } + constructor( private readonly _rfqtAssetOfferings: RfqMakerAssetOfferings, + private readonly _rfqmAssetOfferings: RfqMakerAssetOfferings, private readonly _quoteRequestorHttpClient: AxiosInstance, private readonly _altRfqCreds?: { altRfqApiKey: string; altRfqProfile: string }, private readonly _warningLogger: LogFunction = constants.DEFAULT_WARNING_LOGGER, @@ -135,6 +173,31 @@ export class QuoteRequestor { rfqMakerBlacklist.infoLogger = this._infoLogger; } + public async requestRfqmFirmQuotesAsync( + makerToken: string, // maker token + takerToken: string, // taker token + assetFillAmount: BigNumber, + marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, + options: RfqRequestOpts, + ): Promise { + const _opts: RfqRequestOpts = { + ...constants.DEFAULT_RFQT_REQUEST_OPTS, + ...options, + isLastLook: true, + }; + + return this._fetchAndValidateFirmQuotesAsync( + makerToken, + takerToken, + assetFillAmount, + marketOperation, + comparisonPrice, + _opts, + this._rfqmAssetOfferings, + ); + } + public async requestRfqtFirmQuotesAsync( makerToken: string, // maker token takerToken: string, // taker token @@ -148,72 +211,40 @@ export class QuoteRequestor { throw new Error('RFQ-T firm quotes require the presence of a tx origin'); } - const quotesRaw = await this._getQuotesAsync( + return this._fetchAndValidateFirmQuotesAsync( makerToken, takerToken, assetFillAmount, marketOperation, comparisonPrice, _opts, - 'firm', + this._rfqtAssetOfferings, ); - const quotes = quotesRaw.map(result => ({ ...result, response: result.response.signedOrder })); + } - // validate - const validationFunction = (o: V4SignedRfqOrder) => { - try { - // Handle the validate throwing, i.e if it isn't an object or json response - return this._schemaValidator.isValid(o, schemas.v4RfqSignedOrderSchema); - } catch (e) { - return false; - } + public async requestRfqmIndicativeQuotesAsync( + makerToken: string, + takerToken: string, + assetFillAmount: BigNumber, + marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, + options: RfqRequestOpts, + ): Promise { + const _opts: RfqRequestOpts = { + ...constants.DEFAULT_RFQT_REQUEST_OPTS, + ...options, + isLastLook: true, }; - const validQuotes = quotes.filter(result => { - const order = result.response; - if (!validationFunction(order)) { - this._warningLogger(result, 'Invalid RFQ-T firm quote received, filtering out'); - return false; - } - if ( - !hasExpectedAddresses([ - [makerToken, order.makerToken], - [takerToken, order.takerToken], - [_opts.takerAddress, order.taker], - [_opts.txOrigin, order.txOrigin], - ]) - ) { - this._warningLogger( - order, - 'Unexpected token, tx origin or taker address in RFQ-T order, filtering out', - ); - return false; - } - if (this._isExpirationTooSoon(new BigNumber(order.expiry))) { - this._warningLogger(order, 'Expiry too soon in RFQ-T firm quote, filtering out'); - return false; - } else { - return true; - } - }); - // Save the maker URI for later and return just the order - const rfqQuotes = validQuotes.map(result => { - const { signature, ...rest } = result.response; - const order: SignedNativeOrder = { - order: { - ...rest, - makerAmount: new BigNumber(result.response.makerAmount), - takerAmount: new BigNumber(result.response.takerAmount), - expiry: new BigNumber(result.response.expiry), - salt: new BigNumber(result.response.salt), - }, - type: FillQuoteTransformerOrderType.Rfq, - signature, - }; - this._orderSignatureToMakerUri[nativeDataToId(result.response)] = result.makerUri; - return order; - }); - return rfqQuotes; + return this._fetchAndValidateIndicativeQuotesAsync( + makerToken, + takerToken, + assetFillAmount, + marketOperation, + comparisonPrice, + _opts, + this._rfqmAssetOfferings, + ); } public async requestRfqtIndicativeQuotesAsync( @@ -236,42 +267,15 @@ export class QuoteRequestor { if (!_opts.txOrigin) { _opts.txOrigin = constants.NULL_ADDRESS; } - const rawQuotes = await this._getQuotesAsync( + return this._fetchAndValidateIndicativeQuotesAsync( makerToken, takerToken, assetFillAmount, marketOperation, comparisonPrice, _opts, - 'indicative', + this._rfqtAssetOfferings, ); - - // validate - const validationFunction = (o: V4RFQIndicativeQuote) => this._isValidRfqtIndicativeQuoteResponse(o); - const validQuotes = rawQuotes.filter(result => { - const order = result.response; - if (!validationFunction(order)) { - this._warningLogger(result, 'Invalid RFQ-T indicative quote received, filtering out'); - return false; - } - if (!hasExpectedAddresses([[makerToken, order.makerToken], [takerToken, order.takerToken]])) { - this._warningLogger(order, 'Unexpected token or taker address in RFQ-T order, filtering out'); - return false; - } - if (this._isExpirationTooSoon(new BigNumber(order.expiry))) { - this._warningLogger(order, 'Expiry too soon in RFQ-T indicative quote, filtering out'); - return false; - } else { - return true; - } - }); - const quotes = validQuotes.map(r => r.response); - quotes.forEach(q => { - q.makerAmount = new BigNumber(q.makerAmount); - q.takerAmount = new BigNumber(q.takerAmount); - q.expiry = new BigNumber(q.expiry); - }); - return quotes; } /** @@ -313,34 +317,6 @@ export class QuoteRequestor { return true; } - private _makerSupportsPair( - typedMakerUrl: TypedMakerUrl, - makerToken: string, - takerToken: string, - altMakerAssetOfferings: AltRfqMakerAssetOfferings | undefined, - ): boolean { - if (typedMakerUrl.pairType === RfqPairType.Standard) { - for (const assetPair of this._rfqtAssetOfferings[typedMakerUrl.url]) { - if ( - (assetPair[0] === makerToken && assetPair[1] === takerToken) || - (assetPair[0] === takerToken && assetPair[1] === makerToken) - ) { - return true; - } - } - } else if (typedMakerUrl.pairType === RfqPairType.Alt && altMakerAssetOfferings) { - for (const altAssetPair of altMakerAssetOfferings[typedMakerUrl.url]) { - if ( - (altAssetPair.baseAsset === makerToken && altAssetPair.quoteAsset === takerToken) || - (altAssetPair.baseAsset === takerToken && altAssetPair.quoteAsset === makerToken) - ) { - return true; - } - } - } - return false; - } - private _isExpirationTooSoon(expirationTimeSeconds: BigNumber): boolean { const expirationTimeMs = expirationTimeSeconds.times(constants.ONE_SECOND_MS); const currentTimeMs = new BigNumber(Date.now()); @@ -355,6 +331,7 @@ export class QuoteRequestor { comparisonPrice: BigNumber | undefined, options: RfqRequestOpts, quoteType: 'firm' | 'indicative', + assetOfferings: RfqMakerAssetOfferings, ): Promise>> { const requestParams = QuoteRequestor.makeQueryParameters( options.txOrigin, @@ -364,6 +341,7 @@ export class QuoteRequestor { takerToken, assetFillAmount, comparisonPrice, + options.isLastLook, ); const quotePath = (() => { @@ -377,7 +355,7 @@ export class QuoteRequestor { } })(); - const standardUrls = Object.keys(this._rfqtAssetOfferings).map( + const standardUrls = Object.keys(assetOfferings).map( (mm: string): TypedMakerUrl => { return { pairType: RfqPairType.Standard, url: mm }; }, @@ -410,7 +388,15 @@ export class QuoteRequestor { if (isBlacklisted) { this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry } }); return; - } else if (!this._makerSupportsPair(typedMakerUrl, makerToken, takerToken, options.altRfqAssetOfferings)) { + } else if ( + !QuoteRequestor._makerSupportsPair( + typedMakerUrl, + makerToken, + takerToken, + options.altRfqAssetOfferings, + assetOfferings, + ) + ) { return; } else { // make request to MM @@ -509,4 +495,130 @@ export class QuoteRequestor { const results = (await Promise.all(quotePromises)).filter(x => x !== undefined); return results as Array>; } + private async _fetchAndValidateFirmQuotesAsync( + makerToken: string, + takerToken: string, + assetFillAmount: BigNumber, + marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, + options: RfqRequestOpts, + assetOfferings: RfqMakerAssetOfferings, + ): Promise { + const quotesRaw = await this._getQuotesAsync( + makerToken, + takerToken, + assetFillAmount, + marketOperation, + comparisonPrice, + options, + 'firm', + assetOfferings, + ); + const quotes = quotesRaw.map(result => ({ ...result, response: result.response.signedOrder })); + + // validate + const validationFunction = (o: V4SignedRfqOrder) => { + try { + // Handle the validate throwing, i.e if it isn't an object or json response + return this._schemaValidator.isValid(o, schemas.v4RfqSignedOrderSchema); + } catch (e) { + return false; + } + }; + const validQuotes = quotes.filter(result => { + const order = result.response; + if (!validationFunction(order)) { + this._warningLogger(result, 'Invalid RFQ-T firm quote received, filtering out'); + return false; + } + if ( + !hasExpectedAddresses([ + [makerToken, order.makerToken], + [takerToken, order.takerToken], + [options.takerAddress, order.taker], + [options.txOrigin, order.txOrigin], + ]) + ) { + this._warningLogger( + order, + 'Unexpected token, tx origin or taker address in RFQ-T order, filtering out', + ); + return false; + } + if (this._isExpirationTooSoon(new BigNumber(order.expiry))) { + this._warningLogger(order, 'Expiry too soon in RFQ-T firm quote, filtering out'); + return false; + } else { + return true; + } + }); + + // Save the maker URI for later and return just the order + const rfqQuotes = validQuotes.map(result => { + const { signature, ...rest } = result.response; + const order: SignedNativeOrder = { + order: { + ...rest, + makerAmount: new BigNumber(result.response.makerAmount), + takerAmount: new BigNumber(result.response.takerAmount), + expiry: new BigNumber(result.response.expiry), + salt: new BigNumber(result.response.salt), + }, + type: FillQuoteTransformerOrderType.Rfq, + signature, + }; + this._orderSignatureToMakerUri[nativeDataToId(result.response)] = result.makerUri; + return order; + }); + return rfqQuotes; + } + + private async _fetchAndValidateIndicativeQuotesAsync( + makerToken: string, + takerToken: string, + assetFillAmount: BigNumber, + marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, + options: RfqRequestOpts, + assetOfferings: RfqMakerAssetOfferings, + ): Promise { + // fetch quotes + const rawQuotes = await this._getQuotesAsync( + makerToken, + takerToken, + assetFillAmount, + marketOperation, + comparisonPrice, + options, + 'indicative', + assetOfferings, + ); + + // validate + const validationFunction = (o: V4RFQIndicativeQuote) => this._isValidRfqtIndicativeQuoteResponse(o); + const validQuotes = rawQuotes.filter(result => { + const order = result.response; + if (!validationFunction(order)) { + this._warningLogger(result, 'Invalid RFQ indicative quote received, filtering out'); + return false; + } + if (!hasExpectedAddresses([[makerToken, order.makerToken], [takerToken, order.takerToken]])) { + this._warningLogger(order, 'Unexpected token or taker address in RFQ order, filtering out'); + return false; + } + if (this._isExpirationTooSoon(new BigNumber(order.expiry))) { + this._warningLogger(order, 'Expiry too soon in RFQ indicative quote, filtering out'); + return false; + } else { + return true; + } + }); + const quotes = validQuotes.map(r => r.response); + quotes.forEach(q => { + q.makerAmount = new BigNumber(q.makerAmount); + q.takerAmount = new BigNumber(q.takerAmount); + q.expiry = new BigNumber(q.expiry); + }); + return quotes; + } } diff --git a/packages/asset-swapper/test/quote_requestor_test.ts b/packages/asset-swapper/test/quote_requestor_test.ts index e139cbc033..18350bfe4a 100644 --- a/packages/asset-swapper/test/quote_requestor_test.ts +++ b/packages/asset-swapper/test/quote_requestor_test.ts @@ -24,7 +24,7 @@ import { NULL_ADDRESS } from '../src/utils/market_operation_utils/constants'; import { QuoteRequestor } from '../src/utils/quote_requestor'; import { chaiSetup } from './utils/chai_setup'; -import { RfqtQuoteEndpoint, testHelpers } from './utils/test_helpers'; +import { RfqQuoteEndpoint, testHelpers } from './utils/test_helpers'; const quoteRequestorHttpClient = Axios.create({ httpAgent: new HttpAgent({ keepAlive: true, timeout: KEEP_ALIVE_TTL }), @@ -64,6 +64,196 @@ describe('QuoteRequestor', async () => { ], }; + describe('requestRfqmFirmQuotesAsync for firm quotes', async () => { + it('should return successful RFQM requests', async () => { + const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a'; + const txOrigin = takerAddress; + const apiKey = 'my-ko0l-api-key'; + + // Set up RFQM responses + // tslint:disable-next-line:array-type + const mockedRequests: MockedRfqQuoteResponse[] = []; + const altMockedRequests: AltMockedRfqQuoteResponse[] = []; + + const expectedParams: TakerRequestQueryParams = { + sellTokenAddress: takerToken, + buyTokenAddress: makerToken, + sellAmountBaseUnits: '10000', + comparisonPrice: undefined, + takerAddress, + txOrigin, + isLastLook: 'true', // the major difference between RFQ-T and RFQ-M + protocolVersion: '4', + }; + const mockedDefaults = { + requestApiKey: apiKey, + requestParams: expectedParams, + responseCode: StatusCodes.Success, + }; + const validSignedOrder = { + makerToken, + takerToken, + makerAmount: new BigNumber('1000'), + takerAmount: new BigNumber('1000'), + maker: takerAddress, + taker: takerAddress, + pool: '0x', + salt: '0', + chainId: 1, + verifyingContract: takerAddress, + txOrigin, + expiry: makeThreeMinuteExpiry(), + signature: validSignature, + }; + // request is to sell 10000 units of the base token + // 10 units at 3 decimals + const altFirmRequestData = { + market: 'XYZ-123', + model: AltQuoteModel.Firm, + profile: ALT_PROFILE, + side: AltQuoteSide.Sell, + meta: { + txOrigin, + taker: takerAddress, + client: apiKey, + }, + value: '10', + }; + const altFirmResponse = { + ...altFirmRequestData, + id: 'random_id', + // tslint:disable-next-line:custom-no-magic-numbers + price: new BigNumber(10 / 100).toString(), + status: 'active', + data: { + '0xv4order': validSignedOrder, + }, + }; + + // [GOOD] Successful response + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://1337.0.0.1', + responseData: { + signedOrder: validSignedOrder, + }, + }); + // [GOOD] Another Successful response + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://37.0.0.1', + responseData: { signedOrder: validSignedOrder }, + }); + // [BAD] Test out a bad response code, ensure it doesnt cause throw + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://420.0.0.1', + responseData: { error: 'bad request' }, + responseCode: StatusCodes.InternalError, + }); + // [BAD] Test out a successful response code but a partial order + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://421.0.0.1', + responseData: { signedOrder: { makerToken: '123' } }, + }); + // [BAD] A successful response code and invalid response data (encoding) + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://421.1.0.1', + responseData: 'this is not JSON!', + }); + // [BAD] A successful response code and valid order, but for wrong maker asset data + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://422.0.0.1', + responseData: { signedOrder: { ...validSignedOrder, makerToken: '0x1234' } }, + }); + // [BAD] A successful response code and valid order, but for wrong taker asset data + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://423.0.0.1', + responseData: { signedOrder: { ...validSignedOrder, takerToken: '0x1234' } }, + }); + // [BAD] A successful response code and good order but its unsigned + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://424.0.0.1', + responseData: { signedOrder: _.omit(validSignedOrder, ['signature']) }, + }); + // [BAD] A successful response code and good order but for the wrong txOrigin + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://425.0.0.1', + responseData: { signedOrder: { ...validSignedOrder, txOrigin: NULL_ADDRESS } }, + }); + // [GOOD] A successful response code and order from an alt RFQ implementation + altMockedRequests.push({ + endpoint: 'https://132.0.0.1', + mmApiKey: ALT_MM_API_KEY, + responseCode: CREATED_STATUS_CODE, + requestData: altFirmRequestData, + responseData: altFirmResponse, + }); + + const normalizedSuccessfulOrder = { + order: { + ..._.omit(validSignedOrder, ['signature']), + makerAmount: new BigNumber(validSignedOrder.makerAmount), + takerAmount: new BigNumber(validSignedOrder.takerAmount), + expiry: new BigNumber(validSignedOrder.expiry), + salt: new BigNumber(validSignedOrder.salt), + }, + signature: validSignedOrder.signature, + type: FillQuoteTransformerOrderType.Rfq, + }; + + return testHelpers.withMockedRfqQuotes( + mockedRequests, + altMockedRequests, + RfqQuoteEndpoint.Firm, + async () => { + const qr = new QuoteRequestor( + {}, // No RFQ-T asset offerings + { + 'https://1337.0.0.1': [[makerToken, takerToken]], + 'https://420.0.0.1': [[makerToken, takerToken]], + 'https://421.0.0.1': [[makerToken, takerToken]], + 'https://421.1.0.1': [[makerToken, takerToken]], + 'https://422.0.0.1': [[makerToken, takerToken]], + 'https://423.0.0.1': [[makerToken, takerToken]], + 'https://424.0.0.1': [[makerToken, takerToken]], + 'https://425.0.0.1': [[makerToken, takerToken]], + 'https://426.0.0.1': [] /* Shouldn't ping an RFQ provider when they don't support the requested asset pair. */, + 'https://37.0.0.1': [[makerToken, takerToken]], + }, + quoteRequestorHttpClient, + ALT_RFQ_CREDS, + ); + const resp = await qr.requestRfqmFirmQuotesAsync( + makerToken, + takerToken, + new BigNumber(10000), + MarketOperation.Sell, + undefined, + { + apiKey, + takerAddress, + txOrigin: takerAddress, + intentOnFilling: true, + altRfqAssetOfferings, + }, + ); + expect(resp).to.deep.eq([ + normalizedSuccessfulOrder, + normalizedSuccessfulOrder, + normalizedSuccessfulOrder, + ]); + }, + quoteRequestorHttpClient, + ); + }); + }); describe('requestRfqtFirmQuotesAsync for firm quotes', async () => { it('should return successful RFQT requests', async () => { const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a'; @@ -82,6 +272,7 @@ describe('QuoteRequestor', async () => { comparisonPrice: undefined, takerAddress, txOrigin, + isLastLook: 'false', protocolVersion: '4', }; const mockedDefaults = { @@ -207,10 +398,10 @@ describe('QuoteRequestor', async () => { type: FillQuoteTransformerOrderType.Rfq, }; - return testHelpers.withMockedRfqtQuotes( + return testHelpers.withMockedRfqQuotes( mockedRequests, altMockedRequests, - RfqtQuoteEndpoint.Firm, + RfqQuoteEndpoint.Firm, async () => { const qr = new QuoteRequestor( { @@ -225,6 +416,7 @@ describe('QuoteRequestor', async () => { 'https://426.0.0.1': [] /* Shouldn't ping an RFQ-T provider when they don't support the requested asset pair. */, 'https://37.0.0.1': [[makerToken, takerToken]], }, + {}, quoteRequestorHttpClient, ALT_RFQ_CREDS, ); @@ -252,6 +444,114 @@ describe('QuoteRequestor', async () => { ); }); }); + describe('requestRfqmIndicativeQuotesAsync for Indicative quotes', async () => { + it('should return successful RFQM requests', async () => { + const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a'; + const apiKey = 'my-ko0l-api-key'; + + // Set up RFQ responses + // tslint:disable-next-line:array-type + const mockedRequests: MockedRfqQuoteResponse[] = []; + const expectedParams: TakerRequestQueryParams = { + sellTokenAddress: takerToken, + buyTokenAddress: makerToken, + sellAmountBaseUnits: '10000', + comparisonPrice: undefined, + takerAddress, + txOrigin: takerAddress, + isLastLook: 'true', // the major difference between RFQ-T and RFQ-M + protocolVersion: '4', + }; + const mockedDefaults = { + requestApiKey: apiKey, + requestParams: expectedParams, + responseCode: StatusCodes.Success, + }; + + // [GOOD] Successful response + const successfulQuote1 = { + makerToken, + takerToken, + makerAmount: new BigNumber(expectedParams.sellAmountBaseUnits), + takerAmount: new BigNumber(expectedParams.sellAmountBaseUnits), + expiry: makeThreeMinuteExpiry(), + }; + + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://1337.0.0.1', + responseData: successfulQuote1, + }); + // [GOOD] Another Successful response + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://37.0.0.1', + responseData: successfulQuote1, + }); + + // [BAD] Test out a bad response code, ensure it doesnt cause throw + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://420.0.0.1', + responseData: { error: 'bad request' }, + responseCode: StatusCodes.InternalError, + }); + // [BAD] Test out a successful response code but an invalid order + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://421.0.0.1', + responseData: { makerToken: '123' }, + }); + // [BAD] A successful response code and valid response data, but for wrong maker asset data + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://422.0.0.1', + responseData: { ...successfulQuote1, makerToken: otherToken1 }, + }); + // [BAD] A successful response code and valid response data, but for wrong taker asset data + mockedRequests.push({ + ...mockedDefaults, + endpoint: 'https://423.0.0.1', + responseData: { ...successfulQuote1, takerToken: otherToken1 }, + }); + + return testHelpers.withMockedRfqQuotes( + mockedRequests, + [], + RfqQuoteEndpoint.Indicative, + async () => { + const qr = new QuoteRequestor( + {}, // No RFQ-T asset offerings + { + 'https://1337.0.0.1': [[makerToken, takerToken]], + 'https://37.0.0.1': [[makerToken, takerToken]], + 'https://420.0.0.1': [[makerToken, takerToken]], + 'https://421.0.0.1': [[makerToken, takerToken]], + 'https://422.0.0.1': [[makerToken, takerToken]], + 'https://423.0.0.1': [[makerToken, takerToken]], + 'https://424.0.0.1': [[makerToken, takerToken]], + }, + quoteRequestorHttpClient, + ); + const resp = await qr.requestRfqmIndicativeQuotesAsync( + makerToken, + takerToken, + new BigNumber(10000), + MarketOperation.Sell, + undefined, + { + apiKey, + takerAddress, + txOrigin: takerAddress, + intentOnFilling: true, + }, + ); + expect(resp.sort()).to.eql([successfulQuote1, successfulQuote1].sort()); + }, + quoteRequestorHttpClient, + ); + }); + }); describe('requestRfqtIndicativeQuotesAsync for Indicative quotes', async () => { it('should optionally accept a "comparisonPrice" parameter', async () => { const response = QuoteRequestor.makeQueryParameters( @@ -279,6 +579,7 @@ describe('QuoteRequestor', async () => { comparisonPrice: undefined, takerAddress, txOrigin: takerAddress, + isLastLook: 'false', protocolVersion: '4', }; const mockedDefaults = { @@ -333,10 +634,10 @@ describe('QuoteRequestor', async () => { responseData: successfulQuote1, }); - return testHelpers.withMockedRfqtQuotes( + return testHelpers.withMockedRfqQuotes( mockedRequests, [], - RfqtQuoteEndpoint.Indicative, + RfqQuoteEndpoint.Indicative, async () => { const qr = new QuoteRequestor( { @@ -348,6 +649,7 @@ describe('QuoteRequestor', async () => { 'https://424.0.0.1': [[makerToken, takerToken]], 'https://37.0.0.1': [[makerToken, takerToken]], }, + {}, quoteRequestorHttpClient, ); const resp = await qr.requestRfqtIndicativeQuotesAsync( @@ -386,6 +688,7 @@ describe('QuoteRequestor', async () => { takerAddress, txOrigin: takerAddress, protocolVersion: '4', + isLastLook: 'false', }; const mockedDefaults = { requestApiKey: apiKey, @@ -424,16 +727,17 @@ describe('QuoteRequestor', async () => { }, }); - return testHelpers.withMockedRfqtQuotes( + return testHelpers.withMockedRfqQuotes( mockedRequests, [], - RfqtQuoteEndpoint.Indicative, + RfqQuoteEndpoint.Indicative, async () => { const qr = new QuoteRequestor( { 'https://1337.0.0.1': [[makerToken, takerToken]], 'https://420.0.0.1': [[makerToken, takerToken]], }, + {}, quoteRequestorHttpClient, ); const resp = await qr.requestRfqtIndicativeQuotesAsync( @@ -469,6 +773,7 @@ describe('QuoteRequestor', async () => { comparisonPrice: undefined, takerAddress, txOrigin: takerAddress, + isLastLook: 'false', protocolVersion: '4', }; // Successful response @@ -487,13 +792,14 @@ describe('QuoteRequestor', async () => { responseCode: StatusCodes.Success, }); - return testHelpers.withMockedRfqtQuotes( + return testHelpers.withMockedRfqQuotes( mockedRequests, [], - RfqtQuoteEndpoint.Indicative, + RfqQuoteEndpoint.Indicative, async () => { const qr = new QuoteRequestor( { 'https://1337.0.0.1': [[makerToken, takerToken]] }, + {}, quoteRequestorHttpClient, ); const resp = await qr.requestRfqtIndicativeQuotesAsync( @@ -722,12 +1028,12 @@ describe('QuoteRequestor', async () => { for (const altScenario of altScenarios) { logUtils.log(`Alt MM indicative scenario ${scenarioCounter}`); scenarioCounter += 1; - await testHelpers.withMockedRfqtQuotes( + await testHelpers.withMockedRfqQuotes( [], altMockedRequests, - RfqtQuoteEndpoint.Indicative, + RfqQuoteEndpoint.Indicative, async () => { - const qr = new QuoteRequestor({}, quoteRequestorHttpClient, ALT_RFQ_CREDS); + const qr = new QuoteRequestor({}, {}, quoteRequestorHttpClient, ALT_RFQ_CREDS); const resp = await qr.requestRfqtIndicativeQuotesAsync( altScenario.requestedMakerToken, altScenario.requestedTakerToken, diff --git a/packages/asset-swapper/test/utils/test_helpers.ts b/packages/asset-swapper/test/utils/test_helpers.ts index 5fec89bf53..5f4433938d 100644 --- a/packages/asset-swapper/test/utils/test_helpers.ts +++ b/packages/asset-swapper/test/utils/test_helpers.ts @@ -6,7 +6,7 @@ import * as _ from 'lodash'; import { InsufficientAssetLiquidityError } from '../../src/errors'; import { AltMockedRfqQuoteResponse, MockedRfqQuoteResponse } from '../../src/types'; -export enum RfqtQuoteEndpoint { +export enum RfqQuoteEndpoint { Indicative = 'price', Firm = 'quote', } @@ -34,18 +34,18 @@ export const testHelpers = { }, /** * A helper utility for testing which mocks out - * requests to RFQ-t providers + * requests to RFQ-T/M providers */ - withMockedRfqtQuotes: async ( + withMockedRfqQuotes: async ( standardMockedResponses: MockedRfqQuoteResponse[], altMockedResponses: AltMockedRfqQuoteResponse[], - quoteType: RfqtQuoteEndpoint, + quoteType: RfqQuoteEndpoint, afterResponseCallback: () => Promise, axiosClient: AxiosInstance = axios, ): Promise => { const mockedAxios = new AxiosMockAdapter(axiosClient, { onNoMatch: 'throwException' }); try { - // Mock out Standard RFQT responses + // Mock out Standard RFQ-T/M responses for (const mockedResponse of standardMockedResponses) { const { endpoint, requestApiKey, requestParams, responseData, responseCode } = mockedResponse; const requestHeaders = { Accept: 'application/json, text/plain, */*', '0x-api-key': requestApiKey }; @@ -59,7 +59,7 @@ export const testHelpers = { .replyOnce(responseCode, responseData); } } - // Mock out Alt RFQT responses + // Mock out Alt RFQ-T/M responses for (const mockedResponse of altMockedResponses) { const { endpoint, mmApiKey, requestData, responseData, responseCode } = mockedResponse; const requestHeaders = { diff --git a/yarn.lock b/yarn.lock index 5d742e4cf8..7439095bc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -830,9 +830,10 @@ typedoc "~0.16.11" yargs "^10.0.3" -"@0x/quote-server@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@0x/quote-server/-/quote-server-4.0.1.tgz#05947589bfa7905d274ac3c726cb9918b93b0f9e" +"@0x/quote-server@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@0x/quote-server/-/quote-server-5.0.0.tgz#15554099bdfdf71e2910430860257d622f24f703" + integrity sha512-U14C60RVnILL8n5DwuInG98MnhXbBbiEi8M2ymFGnHO+AjucGfm28BM6/GD59ftiqZmFSkOvBRU94QJ3mSsCQw== dependencies: "@0x/json-schemas" "^5.0.7" "@0x/order-utils" "^10.2.4"