protocol/packages/asset-swapper/src/utils/quote_requestor.ts

457 lines
19 KiB
TypeScript

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<TakerRequest, 'buyTokenAddress' | 'sellTokenAddress' | 'buyAmountBaseUnits' | 'sellAmountBaseUnits'> {
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<TakerRequestQueryParams, 'buyTokenAddress' | 'sellTokenAddress' | 'takerAddress' | 'comparisonPrice'> = {
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<RFQTFirmQuote[]> {
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<RFQTFirmQuote>( // 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<RFQTIndicativeQuote[]> {
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<RFQTIndicativeQuote>( // 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<ResponseT>(
makerAssetData: string,
takerAssetData: string,
assetFillAmount: BigNumber,
marketOperation: MarketOperation,
comparisonPrice: BigNumber | undefined,
options: RfqtRequestOpts,
quoteType: 'firm' | 'indicative',
): Promise<Array<{ response: ResponseT; makerUri: string }>> {
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<ResponseT>(`${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;
}
}