* Refactor integrator ID and add Prometheus metrics * Update packages/asset-swapper/src/swap_quoter.ts Co-authored-by: David Walsh <5778036+rhinodavid@users.noreply.github.com> * Update packages/asset-swapper/src/swap_quoter.ts Co-authored-by: David Walsh <5778036+rhinodavid@users.noreply.github.com> * Update packages/asset-swapper/src/swap_quoter.ts Co-authored-by: David Walsh <5778036+rhinodavid@users.noreply.github.com> * Added documentation and fixed some minor requests * Added more metrics * more docs * lint fix * added new Integrator ID addition * refactor tests * Refactor new types Co-authored-by: David Walsh <5778036+rhinodavid@users.noreply.github.com>
795 lines
32 KiB
TypeScript
795 lines
32 KiB
TypeScript
import { schemas, SchemaValidator } from '@0x/json-schemas';
|
|
import { FillQuoteTransformerOrderType, Signature } from '@0x/protocol-utils';
|
|
import {
|
|
TakerRequestQueryParamsUnnested,
|
|
V4RFQFirmQuote,
|
|
V4RFQIndicativeQuote,
|
|
V4SignedRfqOrder,
|
|
} from '@0x/quote-server';
|
|
import { Fee } from '@0x/quote-server/lib/src/types';
|
|
import { BigNumber, NULL_ADDRESS } from '@0x/utils';
|
|
import axios, { AxiosInstance } from 'axios';
|
|
|
|
import { constants } from '../constants';
|
|
import {
|
|
AltQuoteModel,
|
|
AltRfqMakerAssetOfferings,
|
|
Integrator,
|
|
LogFunction,
|
|
MarketOperation,
|
|
RfqMakerAssetOfferings,
|
|
RfqmRequestOptions,
|
|
RfqPairType,
|
|
RfqRequestOpts,
|
|
SignedNativeOrder,
|
|
TypedMakerUrl,
|
|
} from '../types';
|
|
|
|
import { returnQuoteFromAltMMAsync } from './alt_mm_implementation_utils';
|
|
import { ONE_SECOND_MS } from './market_operation_utils/constants';
|
|
import { RfqMakerBlacklist } from './rfq_maker_blacklist';
|
|
|
|
const MAKER_TIMEOUT_STREAK_LENGTH = 10;
|
|
const MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES = 10;
|
|
const FILL_RATIO_WARNING_LEVEL = 0.99;
|
|
const rfqMakerBlacklist = new RfqMakerBlacklist(MAKER_TIMEOUT_STREAK_LENGTH, MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES);
|
|
|
|
interface RfqQuote<T> {
|
|
response: T;
|
|
makerUri: string;
|
|
}
|
|
|
|
export interface MetricsProxy {
|
|
/**
|
|
* Increments a counter that is tracking valid Firm Quotes that are dropped due to low expiration.
|
|
* @param isLastLook mark if call is coming from RFQM
|
|
* @param maker the maker address
|
|
*/
|
|
incrementExpirationToSoonCounter(isLastLook: boolean, maker: string): void;
|
|
|
|
/**
|
|
* Keeps track of summary statistics for expiration on Firm Quotes.
|
|
* @param isLastLook mark if call is coming from RFQM
|
|
* @param maker the maker address
|
|
* @param expirationTimeSeconds the expiration time in seconds
|
|
*/
|
|
measureExpirationForValidOrder(isLastLook: boolean, maker: string, expirationTimeSeconds: BigNumber): void;
|
|
|
|
/**
|
|
* Increments a counter that tracks when an order is not fully fillable.
|
|
* @param isLastLook mark if call is coming from RFQM
|
|
* @param maker the maker address
|
|
* @param expirationTimeSeconds the expiration time in seconds
|
|
*/
|
|
incrementFillRatioWarningCounter(isLastLook: boolean, maker: string): void;
|
|
|
|
/**
|
|
* Logs the outcome of a network (HTTP) interaction with a market maker.
|
|
*
|
|
* @param interaction.isLastLook true if the request is RFQM
|
|
* @param interaction.integrator the integrator that is requesting the RFQ quote
|
|
* @param interaction.url the URL of the market maker
|
|
* @param interaction.quoteType indicative or firm quote
|
|
* @param interaction.statusCode the statusCode returned by a market maker
|
|
* @param interaction.latencyMs the latency of the HTTP request (in ms)
|
|
* @param interaction.included if a firm quote that was returned got included in the next step of processing.
|
|
* NOTE: this does not mean that the request returned a valid fillable order. It just
|
|
* means that the network response was successful.
|
|
*/
|
|
logRfqMakerNetworkInteraction(interaction: {
|
|
isLastLook: boolean;
|
|
integrator: Integrator;
|
|
url: string;
|
|
quoteType: 'firm' | 'indicative';
|
|
statusCode: number | undefined;
|
|
latencyMs: number;
|
|
included: boolean;
|
|
sellTokenAddress: string;
|
|
buyTokenAddress: string;
|
|
}): void;
|
|
}
|
|
|
|
/**
|
|
* Request quotes from RFQ-T providers
|
|
*/
|
|
|
|
function hasExpectedAddresses(comparisons: Array<[string, string]>): boolean {
|
|
return comparisons.every(c => c[0].toLowerCase() === c[1].toLowerCase());
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function nativeDataToId(data: { signature: Signature }): string {
|
|
const { v, r, s } = data.signature;
|
|
return `${v}${r}${s}`;
|
|
}
|
|
|
|
export class QuoteRequestor {
|
|
private readonly _schemaValidator: SchemaValidator = new SchemaValidator();
|
|
private readonly _orderSignatureToMakerUri: { [signature: string]: string } = {};
|
|
|
|
public static makeQueryParameters(
|
|
txOrigin: string,
|
|
takerAddress: string,
|
|
marketOperation: MarketOperation,
|
|
buyTokenAddress: string, // maker token
|
|
sellTokenAddress: string, // taker token
|
|
assetFillAmount: BigNumber,
|
|
comparisonPrice?: BigNumber,
|
|
isLastLook?: boolean | undefined,
|
|
fee?: Fee | undefined,
|
|
): TakerRequestQueryParamsUnnested {
|
|
const { buyAmountBaseUnits, sellAmountBaseUnits } =
|
|
marketOperation === MarketOperation.Buy
|
|
? {
|
|
buyAmountBaseUnits: assetFillAmount,
|
|
sellAmountBaseUnits: undefined,
|
|
}
|
|
: {
|
|
sellAmountBaseUnits: assetFillAmount,
|
|
buyAmountBaseUnits: undefined,
|
|
};
|
|
|
|
const requestParamsWithBigNumbers: Pick<
|
|
TakerRequestQueryParamsUnnested,
|
|
| 'txOrigin'
|
|
| 'takerAddress'
|
|
| 'buyTokenAddress'
|
|
| 'sellTokenAddress'
|
|
| 'comparisonPrice'
|
|
| 'isLastLook'
|
|
| 'protocolVersion'
|
|
| 'feeAmount'
|
|
| 'feeToken'
|
|
| 'feeType'
|
|
> = {
|
|
txOrigin,
|
|
takerAddress,
|
|
buyTokenAddress,
|
|
sellTokenAddress,
|
|
comparisonPrice: comparisonPrice === undefined ? undefined : comparisonPrice.toString(),
|
|
protocolVersion: '4',
|
|
};
|
|
if (isLastLook) {
|
|
if (fee === undefined) {
|
|
throw new Error(`isLastLook cannot be passed without a fee parameter`);
|
|
}
|
|
requestParamsWithBigNumbers.isLastLook = isLastLook.toString();
|
|
requestParamsWithBigNumbers.feeAmount = fee.amount.toString();
|
|
requestParamsWithBigNumbers.feeToken = fee.token;
|
|
requestParamsWithBigNumbers.feeType = fee.type;
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets both standard RFQ makers and "alternative" RFQ makers and combines them together
|
|
* in a single configuration map. If an integration key whitelist is present, it will be used
|
|
* to filter a specific makers.
|
|
*
|
|
* @param options the RfqmRequestOptions passed in
|
|
* @param assetOfferings the RFQM or RFQT maker offerings
|
|
* @returns a list of TypedMakerUrl instances
|
|
*/
|
|
public static getTypedMakerUrlsAndWhitelist(
|
|
options: Pick<RfqmRequestOptions, 'integrator' | 'altRfqAssetOfferings'>,
|
|
assetOfferings: RfqMakerAssetOfferings,
|
|
): TypedMakerUrl[] {
|
|
const standardUrls = Object.keys(assetOfferings).map(
|
|
(mm: string): TypedMakerUrl => {
|
|
return { pairType: RfqPairType.Standard, url: mm };
|
|
},
|
|
);
|
|
const altUrls = options.altRfqAssetOfferings
|
|
? Object.keys(options.altRfqAssetOfferings).map(
|
|
(mm: string): TypedMakerUrl => {
|
|
return { pairType: RfqPairType.Alt, url: mm };
|
|
},
|
|
)
|
|
: [];
|
|
|
|
let typedMakerUrls = standardUrls.concat(altUrls);
|
|
|
|
// If there is a whitelist, only allow approved maker URLs
|
|
if (options.integrator.whitelistIntegratorUrls !== undefined) {
|
|
const whitelist = new Set(options.integrator.whitelistIntegratorUrls.map(key => key.toLowerCase()));
|
|
typedMakerUrls = typedMakerUrls.filter(makerUrl => whitelist.has(makerUrl.url.toLowerCase()));
|
|
}
|
|
return typedMakerUrls;
|
|
}
|
|
|
|
public static getDurationUntilExpirationMs(expirationTimeSeconds: BigNumber): BigNumber {
|
|
const expirationTimeMs = expirationTimeSeconds.times(constants.ONE_SECOND_MS);
|
|
const currentTimeMs = new BigNumber(Date.now());
|
|
return BigNumber.max(expirationTimeMs.minus(currentTimeMs), 0);
|
|
}
|
|
|
|
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,
|
|
private readonly _infoLogger: LogFunction = constants.DEFAULT_INFO_LOGGER,
|
|
private readonly _expiryBufferMs: number = constants.DEFAULT_SWAP_QUOTER_OPTS.expiryBufferMs,
|
|
private readonly _metrics?: MetricsProxy,
|
|
) {
|
|
rfqMakerBlacklist.infoLogger = this._infoLogger;
|
|
}
|
|
|
|
public async requestRfqmFirmQuotesAsync(
|
|
makerToken: string, // maker token
|
|
takerToken: string, // taker token
|
|
assetFillAmount: BigNumber,
|
|
marketOperation: MarketOperation,
|
|
comparisonPrice: BigNumber | undefined,
|
|
options: RfqmRequestOptions,
|
|
): Promise<SignedNativeOrder[]> {
|
|
const _opts: RfqRequestOpts = {
|
|
...constants.DEFAULT_RFQT_REQUEST_OPTS,
|
|
...options,
|
|
};
|
|
|
|
return this._fetchAndValidateFirmQuotesAsync(
|
|
makerToken,
|
|
takerToken,
|
|
assetFillAmount,
|
|
marketOperation,
|
|
comparisonPrice,
|
|
_opts,
|
|
this._rfqmAssetOfferings,
|
|
);
|
|
}
|
|
|
|
public async requestRfqtFirmQuotesAsync(
|
|
makerToken: string, // maker token
|
|
takerToken: string, // taker token
|
|
assetFillAmount: BigNumber,
|
|
marketOperation: MarketOperation,
|
|
comparisonPrice: BigNumber | undefined,
|
|
options: RfqRequestOpts,
|
|
): Promise<SignedNativeOrder[]> {
|
|
const _opts: RfqRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options };
|
|
if (!_opts.txOrigin || [undefined, '', '0x', NULL_ADDRESS].includes(_opts.txOrigin)) {
|
|
throw new Error('RFQ-T firm quotes require the presence of a tx origin');
|
|
}
|
|
|
|
return this._fetchAndValidateFirmQuotesAsync(
|
|
makerToken,
|
|
takerToken,
|
|
assetFillAmount,
|
|
marketOperation,
|
|
comparisonPrice,
|
|
_opts,
|
|
this._rfqtAssetOfferings,
|
|
);
|
|
}
|
|
|
|
public async requestRfqmIndicativeQuotesAsync(
|
|
makerToken: string,
|
|
takerToken: string,
|
|
assetFillAmount: BigNumber,
|
|
marketOperation: MarketOperation,
|
|
comparisonPrice: BigNumber | undefined,
|
|
options: RfqmRequestOptions,
|
|
): Promise<V4RFQIndicativeQuote[]> {
|
|
const _opts: RfqRequestOpts = {
|
|
...constants.DEFAULT_RFQT_REQUEST_OPTS,
|
|
...options,
|
|
};
|
|
|
|
return this._fetchAndValidateIndicativeQuotesAsync(
|
|
makerToken,
|
|
takerToken,
|
|
assetFillAmount,
|
|
marketOperation,
|
|
comparisonPrice,
|
|
_opts,
|
|
this._rfqmAssetOfferings,
|
|
);
|
|
}
|
|
|
|
public async requestRfqtIndicativeQuotesAsync(
|
|
makerToken: string,
|
|
takerToken: string,
|
|
assetFillAmount: BigNumber,
|
|
marketOperation: MarketOperation,
|
|
comparisonPrice: BigNumber | undefined,
|
|
options: RfqRequestOpts,
|
|
): Promise<V4RFQIndicativeQuote[]> {
|
|
const _opts: RfqRequestOpts = { ...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;
|
|
}
|
|
if (!_opts.txOrigin) {
|
|
_opts.txOrigin = constants.NULL_ADDRESS;
|
|
}
|
|
return this._fetchAndValidateIndicativeQuotesAsync(
|
|
makerToken,
|
|
takerToken,
|
|
assetFillAmount,
|
|
marketOperation,
|
|
comparisonPrice,
|
|
_opts,
|
|
this._rfqtAssetOfferings,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Given an order signature, returns the makerUri that the order originated from
|
|
*/
|
|
public getMakerUriForSignature(signature: Signature): string | undefined {
|
|
return this._orderSignatureToMakerUri[nativeDataToId({ signature })];
|
|
}
|
|
|
|
private _isValidRfqtIndicativeQuoteResponse(response: V4RFQIndicativeQuote): boolean {
|
|
const requiredKeys: Array<keyof V4RFQIndicativeQuote> = [
|
|
'makerAmount',
|
|
'takerAmount',
|
|
'makerToken',
|
|
'takerToken',
|
|
'expiry',
|
|
];
|
|
|
|
for (const k of requiredKeys) {
|
|
if (response[k] === undefined) {
|
|
return false;
|
|
}
|
|
}
|
|
// TODO (jacob): I have a feeling checking 5 schemas is slower then checking one
|
|
const hasValidMakerAssetAmount = this._schemaValidator.isValid(response.makerAmount, schemas.wholeNumberSchema);
|
|
const hasValidTakerAssetAmount = this._schemaValidator.isValid(response.takerAmount, schemas.wholeNumberSchema);
|
|
const hasValidMakerToken = this._schemaValidator.isValid(response.makerToken, schemas.hexSchema);
|
|
const hasValidTakerToken = this._schemaValidator.isValid(response.takerToken, schemas.hexSchema);
|
|
const hasValidExpirationTimeSeconds = this._schemaValidator.isValid(response.expiry, schemas.wholeNumberSchema);
|
|
if (
|
|
!hasValidMakerAssetAmount ||
|
|
!hasValidTakerAssetAmount ||
|
|
!hasValidMakerToken ||
|
|
!hasValidTakerToken ||
|
|
!hasValidExpirationTimeSeconds
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async _getQuotesAsync<ResponseT>(
|
|
makerToken: string,
|
|
takerToken: string,
|
|
assetFillAmount: BigNumber,
|
|
marketOperation: MarketOperation,
|
|
comparisonPrice: BigNumber | undefined,
|
|
options: RfqRequestOpts,
|
|
quoteType: 'firm' | 'indicative',
|
|
assetOfferings: RfqMakerAssetOfferings,
|
|
): Promise<Array<RfqQuote<ResponseT>>> {
|
|
const requestParams = QuoteRequestor.makeQueryParameters(
|
|
options.txOrigin,
|
|
options.takerAddress,
|
|
marketOperation,
|
|
makerToken,
|
|
takerToken,
|
|
assetFillAmount,
|
|
comparisonPrice,
|
|
options.isLastLook,
|
|
options.fee,
|
|
);
|
|
|
|
const quotePath = (() => {
|
|
switch (quoteType) {
|
|
case 'firm':
|
|
return 'quote';
|
|
case 'indicative':
|
|
return 'price';
|
|
default:
|
|
throw new Error(`Unexpected quote type ${quoteType}`);
|
|
}
|
|
})();
|
|
|
|
const timeoutMs =
|
|
options.makerEndpointMaxResponseTimeMs ||
|
|
constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs!;
|
|
const bufferMs = 20;
|
|
|
|
// Set Timeout on CancelToken
|
|
const cancelTokenSource = axios.CancelToken.source();
|
|
setTimeout(() => {
|
|
cancelTokenSource.cancel('timeout via cancel token');
|
|
}, timeoutMs + bufferMs);
|
|
|
|
const typedMakerUrls = QuoteRequestor.getTypedMakerUrlsAndWhitelist(options, assetOfferings);
|
|
const quotePromises = typedMakerUrls.map(async typedMakerUrl => {
|
|
// filter out requests to skip
|
|
const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(typedMakerUrl.url);
|
|
const partialLogEntry = { url: typedMakerUrl.url, quoteType, requestParams, isBlacklisted };
|
|
const { isLastLook, integrator } = options;
|
|
const { sellTokenAddress, buyTokenAddress } = requestParams;
|
|
if (isBlacklisted) {
|
|
this._metrics?.logRfqMakerNetworkInteraction({
|
|
isLastLook: false,
|
|
url: typedMakerUrl.url,
|
|
quoteType,
|
|
statusCode: undefined,
|
|
sellTokenAddress,
|
|
buyTokenAddress,
|
|
latencyMs: 0,
|
|
included: false,
|
|
integrator,
|
|
});
|
|
this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry } });
|
|
return;
|
|
} else if (
|
|
!QuoteRequestor._makerSupportsPair(
|
|
typedMakerUrl,
|
|
makerToken,
|
|
takerToken,
|
|
options.altRfqAssetOfferings,
|
|
assetOfferings,
|
|
)
|
|
) {
|
|
return;
|
|
} else {
|
|
// make request to MM
|
|
const timeBeforeAwait = Date.now();
|
|
try {
|
|
if (typedMakerUrl.pairType === RfqPairType.Standard) {
|
|
const response = await this._quoteRequestorHttpClient.get(`${typedMakerUrl.url}/${quotePath}`, {
|
|
headers: {
|
|
'0x-api-key': options.integrator.integratorId,
|
|
'0x-integrator-id': options.integrator.integratorId,
|
|
},
|
|
params: requestParams,
|
|
timeout: timeoutMs,
|
|
cancelToken: cancelTokenSource.token,
|
|
});
|
|
const latencyMs = Date.now() - timeBeforeAwait;
|
|
this._metrics?.logRfqMakerNetworkInteraction({
|
|
isLastLook: isLastLook || false,
|
|
url: typedMakerUrl.url,
|
|
quoteType,
|
|
statusCode: response.status,
|
|
sellTokenAddress,
|
|
buyTokenAddress,
|
|
latencyMs,
|
|
included: true,
|
|
integrator,
|
|
});
|
|
this._infoLogger({
|
|
rfqtMakerInteraction: {
|
|
...partialLogEntry,
|
|
response: {
|
|
included: true,
|
|
apiKey: options.integrator.integratorId,
|
|
takerAddress: requestParams.takerAddress,
|
|
txOrigin: requestParams.txOrigin,
|
|
statusCode: response.status,
|
|
latencyMs,
|
|
},
|
|
},
|
|
});
|
|
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
|
|
return { response: response.data, makerUri: typedMakerUrl.url };
|
|
} else {
|
|
if (this._altRfqCreds === undefined) {
|
|
throw new Error(`don't have credentials for alt MM`);
|
|
}
|
|
const quote = await returnQuoteFromAltMMAsync<ResponseT>(
|
|
typedMakerUrl.url,
|
|
this._altRfqCreds.altRfqApiKey,
|
|
this._altRfqCreds.altRfqProfile,
|
|
options.integrator.integratorId,
|
|
quoteType === 'firm' ? AltQuoteModel.Firm : AltQuoteModel.Indicative,
|
|
makerToken,
|
|
takerToken,
|
|
timeoutMs,
|
|
options.altRfqAssetOfferings || {},
|
|
requestParams,
|
|
this._quoteRequestorHttpClient,
|
|
this._warningLogger,
|
|
cancelTokenSource.token,
|
|
);
|
|
|
|
const latencyMs = Date.now() - timeBeforeAwait;
|
|
this._metrics?.logRfqMakerNetworkInteraction({
|
|
isLastLook: isLastLook || false,
|
|
url: typedMakerUrl.url,
|
|
quoteType,
|
|
statusCode: quote.status,
|
|
sellTokenAddress,
|
|
buyTokenAddress,
|
|
latencyMs,
|
|
included: true,
|
|
integrator,
|
|
});
|
|
this._infoLogger({
|
|
rfqtMakerInteraction: {
|
|
...partialLogEntry,
|
|
response: {
|
|
included: true,
|
|
apiKey: options.integrator.integratorId,
|
|
takerAddress: requestParams.takerAddress,
|
|
txOrigin: requestParams.txOrigin,
|
|
statusCode: quote.status,
|
|
latencyMs,
|
|
},
|
|
},
|
|
});
|
|
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
|
|
return { response: quote.data, makerUri: typedMakerUrl.url };
|
|
}
|
|
} catch (err) {
|
|
// log error if any
|
|
const latencyMs = Date.now() - timeBeforeAwait;
|
|
this._metrics?.logRfqMakerNetworkInteraction({
|
|
isLastLook: isLastLook || false,
|
|
url: typedMakerUrl.url,
|
|
quoteType,
|
|
statusCode: err.response?.status,
|
|
sellTokenAddress,
|
|
buyTokenAddress,
|
|
latencyMs,
|
|
included: false,
|
|
integrator,
|
|
});
|
|
this._infoLogger({
|
|
rfqtMakerInteraction: {
|
|
...partialLogEntry,
|
|
response: {
|
|
included: false,
|
|
apiKey: options.integrator.integratorId,
|
|
takerAddress: requestParams.takerAddress,
|
|
txOrigin: requestParams.txOrigin,
|
|
statusCode: err.response ? err.response.status : undefined,
|
|
latencyMs,
|
|
},
|
|
},
|
|
});
|
|
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
|
|
this._warningLogger(
|
|
convertIfAxiosError(err),
|
|
`Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${typedMakerUrl.url} for integrator ${options.integrator.integratorId} (${options.integrator.label}) for taker address ${options.takerAddress} and tx origin ${options.txOrigin}`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
const results = (await Promise.all(quotePromises)).filter(x => x !== undefined);
|
|
return results as Array<RfqQuote<ResponseT>>;
|
|
}
|
|
private async _fetchAndValidateFirmQuotesAsync(
|
|
makerToken: string,
|
|
takerToken: string,
|
|
assetFillAmount: BigNumber,
|
|
marketOperation: MarketOperation,
|
|
comparisonPrice: BigNumber | undefined,
|
|
options: RfqRequestOpts,
|
|
assetOfferings: RfqMakerAssetOfferings,
|
|
): Promise<SignedNativeOrder[]> {
|
|
const quotesRaw = await this._getQuotesAsync<V4RFQFirmQuote>(
|
|
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;
|
|
}
|
|
const isLastLook = Boolean(options.isLastLook);
|
|
const msRemainingUntilExpiration = QuoteRequestor.getDurationUntilExpirationMs(new BigNumber(order.expiry));
|
|
const isExpirationTooSoon = msRemainingUntilExpiration.lt(this._expiryBufferMs);
|
|
if (isExpirationTooSoon) {
|
|
this._warningLogger(order, 'Expiry too soon in RFQ-T firm quote, filtering out');
|
|
this._metrics?.incrementExpirationToSoonCounter(isLastLook, order.maker);
|
|
return false;
|
|
} else {
|
|
const secondsRemaining = msRemainingUntilExpiration.div(ONE_SECOND_MS);
|
|
this._metrics?.measureExpirationForValidOrder(isLastLook, order.maker, secondsRemaining);
|
|
|
|
const takerAmount = new BigNumber(order.takerAmount);
|
|
const fillRatio = takerAmount.div(assetFillAmount);
|
|
if (fillRatio.lt(1) && fillRatio.gte(FILL_RATIO_WARNING_LEVEL)) {
|
|
this._warningLogger(
|
|
{
|
|
makerUri: result.makerUri,
|
|
fillRatio,
|
|
assetFillAmount,
|
|
takerToken,
|
|
makerToken,
|
|
takerAmount: order.takerAmount,
|
|
makerAmount: order.makerAmount,
|
|
},
|
|
'Fill ratio in warning range',
|
|
);
|
|
this._metrics?.incrementFillRatioWarningCounter(isLastLook, order.maker);
|
|
}
|
|
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<V4RFQIndicativeQuote[]> {
|
|
// fetch quotes
|
|
const rawQuotes = await this._getQuotesAsync<V4RFQIndicativeQuote>(
|
|
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;
|
|
}
|
|
const msRemainingUntilExpiration = QuoteRequestor.getDurationUntilExpirationMs(new BigNumber(order.expiry));
|
|
const isExpirationTooSoon = msRemainingUntilExpiration.lt(this._expiryBufferMs);
|
|
if (isExpirationTooSoon) {
|
|
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;
|
|
}
|
|
}
|