import { schemas, SchemaValidator } from '@0x/json-schemas'; import { assetDataUtils, orderCalculationUtils, SignedOrder } from '@0x/order-utils'; import { RFQTFirmQuote, RFQTIndicativeQuote, TakerRequest } from '@0x/quote-server'; import { TakerRequestQueryParams } from '@0x/quote-server'; import { ERC20AssetData } from '@0x/types'; import { BigNumber } from '@0x/utils'; import Axios, { AxiosInstance } from 'axios'; import { Agent as HttpAgent } from 'http'; import { Agent as HttpsAgent } from 'https'; import { constants } from '../constants'; import { LogFunction, MarketOperation, RfqtMakerAssetOfferings, RfqtRequestOpts } from '../types'; import { ONE_SECOND_MS } from './market_operation_utils/constants'; import { RfqMakerBlacklist } from './rfq_maker_blacklist'; // tslint:disable-next-line: custom-no-magic-numbers const KEEP_ALIVE_TTL = 5 * 60 * ONE_SECOND_MS; export const quoteRequestorHttpClient: AxiosInstance = Axios.create({ httpAgent: new HttpAgent({ keepAlive: true, timeout: KEEP_ALIVE_TTL }), httpsAgent: new HttpsAgent({ keepAlive: true, timeout: KEEP_ALIVE_TTL }), }); const MAKER_TIMEOUT_STREAK_LENGTH = 10; const MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES = 10; const rfqMakerBlacklist = new RfqMakerBlacklist(MAKER_TIMEOUT_STREAK_LENGTH, MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES); /** * Request quotes from RFQ-T providers */ function getTokenAddressOrThrow(assetData: string): string { const decodedAssetData = assetDataUtils.decodeAssetDataOrThrow(assetData); if (decodedAssetData.hasOwnProperty('tokenAddress')) { // type cast necessary here as decodeAssetDataOrThrow returns // an AssetData object, which doesn't necessarily contain a // token address. (it could possibly be a StaticCallAssetData, // which lacks an address.) so we'll just assume it's a token // here. should be safe, with the enclosing guard condition // and subsequent error. // tslint:disable-next-line:no-unnecessary-type-assertion return (decodedAssetData as ERC20AssetData).tokenAddress; } throw new Error(`Decoded asset data (${JSON.stringify(decodedAssetData)}) does not contain a token address`); } function inferQueryParams( marketOperation: MarketOperation, makerAssetData: string, takerAssetData: string, assetFillAmount: BigNumber, ): Pick { if (marketOperation === MarketOperation.Buy) { return { buyTokenAddress: getTokenAddressOrThrow(makerAssetData), sellTokenAddress: getTokenAddressOrThrow(takerAssetData), buyAmountBaseUnits: assetFillAmount, sellAmountBaseUnits: undefined, }; } else { return { buyTokenAddress: getTokenAddressOrThrow(makerAssetData), sellTokenAddress: getTokenAddressOrThrow(takerAssetData), sellAmountBaseUnits: assetFillAmount, buyAmountBaseUnits: undefined, }; } } function hasExpectedAssetData( expectedMakerAssetData: string, expectedTakerAssetData: string, makerAssetDataInQuestion: string, takerAssetDataInQuestion: string, ): boolean { const hasExpectedMakerAssetData = makerAssetDataInQuestion.toLowerCase() === expectedMakerAssetData.toLowerCase(); const hasExpectedTakerAssetData = takerAssetDataInQuestion.toLowerCase() === expectedTakerAssetData.toLowerCase(); return hasExpectedMakerAssetData && hasExpectedTakerAssetData; } function convertIfAxiosError(error: any): Error | object /* axios' .d.ts has AxiosError.toJSON() returning object */ { if (error.hasOwnProperty('isAxiosError') && error.isAxiosError) { const { message, name, config } = error; const { headers, timeout, httpsAgent } = config; const { keepAlive, keepAliveMsecs, sockets } = httpsAgent; const socketCounts: { [key: string]: number } = {}; for (const socket of Object.keys(sockets)) { socketCounts[socket] = sockets[socket].length; } return { message, name, config: { headers, timeout, httpsAgent: { keepAlive, keepAliveMsecs, socketCounts, }, }, }; } else { return error; } } export class QuoteRequestor { private readonly _schemaValidator: SchemaValidator = new SchemaValidator(); private readonly _orderSignatureToMakerUri: { [orderSignature: string]: string } = {}; public static makeQueryParameters( takerAddress: string, marketOperation: MarketOperation, makerAssetData: string, takerAssetData: string, assetFillAmount: BigNumber, comparisonPrice?: BigNumber | undefined, ): TakerRequestQueryParams { const {buyAmountBaseUnits, sellAmountBaseUnits, ...rest } = inferQueryParams(marketOperation, makerAssetData, takerAssetData, assetFillAmount); const requestParamsWithBigNumbers: Pick = { takerAddress, comparisonPrice: comparisonPrice === undefined ? undefined : comparisonPrice.toString(), ...rest, }; // convert BigNumbers to strings // so they are digestible by axios if (sellAmountBaseUnits) { return { ...requestParamsWithBigNumbers, sellAmountBaseUnits: sellAmountBaseUnits.toString(), }; } else if (buyAmountBaseUnits) { return { ...requestParamsWithBigNumbers, buyAmountBaseUnits: buyAmountBaseUnits.toString(), }; } else { throw new Error('Neither "buyAmountBaseUnits" or "sellAmountBaseUnits" were defined'); } } constructor( private readonly _rfqtAssetOfferings: RfqtMakerAssetOfferings, private readonly _warningLogger: LogFunction = constants.DEFAULT_WARNING_LOGGER, private readonly _infoLogger: LogFunction = constants.DEFAULT_INFO_LOGGER, private readonly _expiryBufferMs: number = constants.DEFAULT_SWAP_QUOTER_OPTS.expiryBufferMs, ) { rfqMakerBlacklist.infoLogger = this._infoLogger; } public async requestRfqtFirmQuotesAsync( makerAssetData: string, takerAssetData: string, assetFillAmount: BigNumber, marketOperation: MarketOperation, comparisonPrice: BigNumber | undefined, options: RfqtRequestOpts, ): Promise { const _opts: RfqtRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options }; if ( _opts.takerAddress === undefined || _opts.takerAddress === '' || _opts.takerAddress === '0x' || !_opts.takerAddress || _opts.takerAddress === constants.NULL_ADDRESS ) { throw new Error('RFQ-T firm quotes require the presence of a taker address'); } const firmQuoteResponses = await this._getQuotesAsync( // not yet BigNumber makerAssetData, takerAssetData, assetFillAmount, marketOperation, comparisonPrice, _opts, 'firm', ); const result: RFQTFirmQuote[] = []; firmQuoteResponses.forEach(firmQuoteResponse => { const orderWithStringInts = firmQuoteResponse.response.signedOrder; try { const hasValidSchema = this._schemaValidator.isValid(orderWithStringInts, schemas.signedOrderSchema); if (!hasValidSchema) { throw new Error('Order not valid'); } } catch (err) { this._warningLogger(orderWithStringInts, `Invalid RFQ-t order received, filtering out. ${err.message}`); return; } if ( !hasExpectedAssetData( makerAssetData, takerAssetData, orderWithStringInts.makerAssetData.toLowerCase(), orderWithStringInts.takerAssetData.toLowerCase(), ) ) { this._warningLogger(orderWithStringInts, 'Unexpected asset data in RFQ-T order, filtering out'); return; } if (orderWithStringInts.takerAddress.toLowerCase() !== _opts.takerAddress.toLowerCase()) { this._warningLogger(orderWithStringInts, 'Unexpected takerAddress in RFQ-T order, filtering out'); return; } const orderWithBigNumberInts: SignedOrder = { ...orderWithStringInts, makerAssetAmount: new BigNumber(orderWithStringInts.makerAssetAmount), takerAssetAmount: new BigNumber(orderWithStringInts.takerAssetAmount), makerFee: new BigNumber(orderWithStringInts.makerFee), takerFee: new BigNumber(orderWithStringInts.takerFee), expirationTimeSeconds: new BigNumber(orderWithStringInts.expirationTimeSeconds), salt: new BigNumber(orderWithStringInts.salt), }; if ( orderCalculationUtils.willOrderExpire( orderWithBigNumberInts, this._expiryBufferMs / constants.ONE_SECOND_MS, ) ) { this._warningLogger(orderWithBigNumberInts, 'Expiry too soon in RFQ-T order, filtering out'); return; } // Store makerUri for looking up later this._orderSignatureToMakerUri[orderWithBigNumberInts.signature] = firmQuoteResponse.makerUri; // Passed all validation, add it to result result.push({ signedOrder: orderWithBigNumberInts }); return; }); return result; } public async requestRfqtIndicativeQuotesAsync( makerAssetData: string, takerAssetData: string, assetFillAmount: BigNumber, marketOperation: MarketOperation, comparisonPrice: BigNumber | undefined, options: RfqtRequestOpts, ): Promise { const _opts: RfqtRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options }; // Originally a takerAddress was required for indicative quotes, but // now we've eliminated that requirement. @0x/quote-server, however, // is still coded to expect a takerAddress. So if the client didn't // send one, just use the null address to satisfy the quote server's // expectations. if (!_opts.takerAddress) { _opts.takerAddress = constants.NULL_ADDRESS; } const responsesWithStringInts = await this._getQuotesAsync( // not yet BigNumber makerAssetData, takerAssetData, assetFillAmount, marketOperation, comparisonPrice, _opts, 'indicative', ); const validResponsesWithStringInts = responsesWithStringInts.filter(result => { const response = result.response; if (!this._isValidRfqtIndicativeQuoteResponse(response)) { this._warningLogger(response, 'Invalid RFQ-T indicative quote received, filtering out'); return false; } if ( !hasExpectedAssetData(makerAssetData, takerAssetData, response.makerAssetData, response.takerAssetData) ) { this._warningLogger(response, 'Unexpected asset data in RFQ-T indicative quote, filtering out'); return false; } return true; }); const validResponses = validResponsesWithStringInts.map(result => { const response = result.response; return { ...response, makerAssetAmount: new BigNumber(response.makerAssetAmount), takerAssetAmount: new BigNumber(response.takerAssetAmount), expirationTimeSeconds: new BigNumber(response.expirationTimeSeconds), }; }); const responses = validResponses.filter(response => { if (this._isExpirationTooSoon(response.expirationTimeSeconds)) { this._warningLogger(response, 'Expiry too soon in RFQ-T indicative quote, filtering out'); return false; } return true; }); return responses; } /** * Given an order signature, returns the makerUri that the order originated from */ public getMakerUriForOrderSignature(orderSignature: string): string | undefined { return this._orderSignatureToMakerUri[orderSignature]; } private _isValidRfqtIndicativeQuoteResponse(response: RFQTIndicativeQuote): boolean { const hasValidMakerAssetAmount = response.makerAssetAmount !== undefined && this._schemaValidator.isValid(response.makerAssetAmount, schemas.wholeNumberSchema); const hasValidTakerAssetAmount = response.takerAssetAmount !== undefined && this._schemaValidator.isValid(response.takerAssetAmount, schemas.wholeNumberSchema); const hasValidMakerAssetData = response.makerAssetData !== undefined && this._schemaValidator.isValid(response.makerAssetData, schemas.hexSchema); const hasValidTakerAssetData = response.takerAssetData !== undefined && this._schemaValidator.isValid(response.takerAssetData, schemas.hexSchema); const hasValidExpirationTimeSeconds = response.expirationTimeSeconds !== undefined && this._schemaValidator.isValid(response.expirationTimeSeconds, schemas.wholeNumberSchema); if ( hasValidMakerAssetAmount && hasValidTakerAssetAmount && hasValidMakerAssetData && hasValidTakerAssetData && hasValidExpirationTimeSeconds ) { return true; } return false; } private _makerSupportsPair(makerUrl: string, makerAssetData: string, takerAssetData: string): boolean { const makerTokenAddress = getTokenAddressOrThrow(makerAssetData); const takerTokenAddress = getTokenAddressOrThrow(takerAssetData); for (const assetPair of this._rfqtAssetOfferings[makerUrl]) { if ( (assetPair[0] === makerTokenAddress && assetPair[1] === takerTokenAddress) || (assetPair[0] === takerTokenAddress && assetPair[1] === makerTokenAddress) ) { return true; } } return false; } private _isExpirationTooSoon(expirationTimeSeconds: BigNumber): boolean { const expirationTimeMs = expirationTimeSeconds.times(constants.ONE_SECOND_MS); const currentTimeMs = new BigNumber(Date.now()); return expirationTimeMs.isLessThan(currentTimeMs.plus(this._expiryBufferMs)); } private async _getQuotesAsync( makerAssetData: string, takerAssetData: string, assetFillAmount: BigNumber, marketOperation: MarketOperation, comparisonPrice: BigNumber | undefined, options: RfqtRequestOpts, quoteType: 'firm' | 'indicative', ): Promise> { const requestParams = QuoteRequestor.makeQueryParameters( options.takerAddress, marketOperation, makerAssetData, takerAssetData, assetFillAmount, comparisonPrice, ); const result: Array<{ response: ResponseT; makerUri: string }> = []; await Promise.all( Object.keys(this._rfqtAssetOfferings).map(async url => { const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(url); const partialLogEntry = { url, quoteType, requestParams, isBlacklisted }; if (isBlacklisted) { this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry } }); } else if (this._makerSupportsPair(url, makerAssetData, takerAssetData)) { const timeBeforeAwait = Date.now(); const maxResponseTimeMs = options.makerEndpointMaxResponseTimeMs === undefined ? constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs! : options.makerEndpointMaxResponseTimeMs; try { const quotePath = (() => { switch (quoteType) { case 'firm': return 'quote'; case 'indicative': return 'price'; default: throw new Error(`Unexpected quote type ${quoteType}`); } })(); const response = await quoteRequestorHttpClient.get(`${url}/${quotePath}`, { headers: { '0x-api-key': options.apiKey }, params: requestParams, timeout: maxResponseTimeMs, }); const latencyMs = Date.now() - timeBeforeAwait; this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry, response: { included: true, apiKey: options.apiKey, takerAddress: requestParams.takerAddress, statusCode: response.status, latencyMs, }, }, }); rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs >= maxResponseTimeMs); result.push({ response: response.data, makerUri: url }); } catch (err) { const latencyMs = Date.now() - timeBeforeAwait; this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry, response: { included: false, apiKey: options.apiKey, takerAddress: requestParams.takerAddress, statusCode: err.response ? err.response.status : undefined, latencyMs, }, }, }); rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs >= maxResponseTimeMs); this._warningLogger( convertIfAxiosError(err), `Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${url} for API key ${ options.apiKey } for taker address ${options.takerAddress}`, ); } } }), ); return result; } }