feat/alt RFQ MM implementation (#139)

* baseline adapter code [WIP]

* fixed adapter logic, quote_requester instantiation

* modified quote-requestor test to include alt implementation

* type changes, fixes to quote requestor test

* small fixes

* working tests, made alt utils more readable

* lint errors

* added alt indicative quote tests, minor fixes

* export alt MM market offering types

* altered alt market offering to have id instead of symbols

* addressed minor comments

* updated changelog

* got rid of unnecessary, large if-block, fixed the buy-sell assignment to be from the MM's perspective

* extra logging for debugging

* fixed existingOrder size

* get rid of only flag on test, get rid of extra logging

* prettier
This commit is contained in:
Alex Kroeger 2021-02-22 16:07:30 -08:00 committed by GitHub
parent fa78d1092a
commit 514f9d2621
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 796 additions and 60 deletions

View File

@ -33,6 +33,10 @@
{
"note": "Create `FakeTaker` contract to get result data and gas used",
"pr": 151
},
{
"note": "Add an alternative RFQ market making implementation",
"pr": 139
}
]
},

View File

@ -24,6 +24,7 @@ const ONE_MINUTE_SECS = 60;
const ONE_MINUTE_MS = ONE_SECOND_MS * ONE_MINUTE_SECS;
const DEFAULT_PER_PAGE = 1000;
const ZERO_AMOUNT = new BigNumber(0);
const ALT_MM_IMPUTED_INDICATIVE_EXPIRY_SECONDS = 180;
const DEFAULT_ORDER_PRUNER_OPTS: OrderPrunerOpts = {
expiryBufferMs: 120000, // 2 minutes
@ -111,4 +112,5 @@ export const constants = {
DEFAULT_INFO_LOGGER,
DEFAULT_WARNING_LOGGER,
EMPTY_BYTES32,
ALT_MM_IMPUTED_INDICATIVE_EXPIRY_SECONDS,
};

View File

@ -75,6 +75,8 @@ export { SwapQuoteConsumer } from './quote_consumers/swap_quote_consumer';
export { SwapQuoter, Orderbook } from './swap_quoter';
export {
AffiliateFee,
AltOffering,
AltRfqtMakerAssetOfferings,
AssetSwapperContractAddresses,
CalldataInfo,
ExchangeProxyContractOpts,

View File

@ -348,6 +348,7 @@ export class SwapQuoter {
if (calcOpts.rfqt !== undefined) {
calcOpts.rfqt.quoteRequestor = new QuoteRequestor(
rfqtOptions ? rfqtOptions.makerAssetOfferings || {} : {},
rfqtOptions ? rfqtOptions.altRfqCreds : undefined,
rfqtOptions ? rfqtOptions.warningLogger : undefined,
rfqtOptions ? rfqtOptions.infoLogger : undefined,
this.expiryBufferMs,

View File

@ -7,7 +7,7 @@ import {
RfqOrderFields,
Signature,
} from '@0x/protocol-utils';
import { TakerRequestQueryParams } from '@0x/quote-server';
import { TakerRequestQueryParams, V4SignedRfqOrder } from '@0x/quote-server';
import { BigNumber } from '@0x/utils';
import {
@ -228,6 +228,7 @@ export interface RfqtRequestOpts {
isIndicative?: boolean;
makerEndpointMaxResponseTimeMs?: number;
nativeExclusivelyRFQT?: boolean;
altRfqtAssetOfferings?: AltRfqtMakerAssetOfferings;
}
/**
@ -246,6 +247,25 @@ export interface RfqtMakerAssetOfferings {
[endpoint: string]: Array<[string, string]>;
}
export interface AltOffering {
id: string;
baseAsset: string;
quoteAsset: string;
baseAssetDecimals: number;
quoteAssetDecimals: number;
}
export interface AltRfqtMakerAssetOfferings {
[endpoint: string]: AltOffering[];
}
export enum RfqPairType {
Standard = 'standard',
Alt = 'alt',
}
export interface TypedMakerUrl {
url: string;
pairType: RfqPairType;
}
export type LogFunction = (obj: object, msg?: string, ...args: any[]) => void;
export interface RfqtFirmQuoteValidator {
@ -255,6 +275,10 @@ export interface RfqtFirmQuoteValidator {
export interface SwapQuoterRfqtOpts {
takerApiKeyWhitelist: string[];
makerAssetOfferings: RfqtMakerAssetOfferings;
altRfqCreds?: {
altRfqApiKey: string;
altRfqProfile: string;
};
warningLogger?: LogFunction;
infoLogger?: LogFunction;
}
@ -333,6 +357,17 @@ export interface MockedRfqtQuoteResponse {
responseCode: number;
}
/**
* Represents a mocked RFQT maker responses.
*/
export interface AltMockedRfqtQuoteResponse {
endpoint: string;
mmApiKey: string;
requestData: AltQuoteRequestData;
responseData: any;
responseCode: number;
}
export interface SamplerOverrides {
overrides: GethCallOverrides;
block: BlockParam;
@ -344,3 +379,50 @@ export interface SamplerCallResult {
}
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export enum AltQuoteModel {
Firm = 'firm',
Indicative = 'indicative',
}
export enum AltQuoteSide {
Buy = 'buy',
Sell = 'sell',
}
export interface AltQuoteRequestData {
market: string;
model: AltQuoteModel;
profile: string;
side: AltQuoteSide;
value?: string;
amount?: string;
meta: {
txOrigin: string;
taker: string;
client: string;
existingOrder?: {
price: string;
value?: string;
amount?: string;
};
};
}
export interface AltBaseRfqResponse extends AltQuoteRequestData {
id: string;
price?: string;
}
export interface AltIndicativeQuoteResponse extends AltBaseRfqResponse {
model: AltQuoteModel.Indicative;
status: 'live' | 'rejected';
}
export interface AltFirmQuoteResponse extends AltBaseRfqResponse {
model: AltQuoteModel.Firm;
data: {
'0xv4order': V4SignedRfqOrder;
};
status: 'active' | 'rejected';
}

View File

@ -0,0 +1,233 @@
import { Web3Wrapper } from '@0x/dev-utils';
import { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote } from '@0x/quote-server';
import { BigNumber } from '@0x/utils';
import { AxiosInstance } from 'axios';
import { constants } from '../constants';
import {
AltFirmQuoteResponse,
AltIndicativeQuoteResponse,
AltOffering,
AltQuoteModel,
AltQuoteRequestData,
AltQuoteSide,
AltRfqtMakerAssetOfferings,
} from '../types';
function getAltMarketInfo(
offerings: AltOffering[],
buyTokenAddress: string,
sellTokenAddress: string,
): AltOffering | undefined {
for (const offering of offerings) {
if (
(buyTokenAddress.toLowerCase() === offering.baseAsset.toLowerCase() &&
sellTokenAddress.toLowerCase() === offering.quoteAsset.toLowerCase()) ||
(sellTokenAddress.toLowerCase() === offering.baseAsset.toLowerCase() &&
buyTokenAddress.toLowerCase() === offering.quoteAsset.toLowerCase())
) {
return offering;
}
}
return undefined;
}
function parseFirmQuoteResponseFromAltMM(altFirmQuoteReponse: AltFirmQuoteResponse): V4RFQFirmQuote {
return {
signedOrder: altFirmQuoteReponse.data['0xv4order'],
};
}
function parseIndicativeQuoteResponseFromAltMM(
altIndicativeQuoteResponse: AltIndicativeQuoteResponse,
altPair: AltOffering,
makerToken: string,
takerToken: string,
): V4RFQIndicativeQuote {
let makerAmount: BigNumber;
let takerAmount: BigNumber;
let quoteAmount: BigNumber;
let baseAmount: BigNumber;
if (!altIndicativeQuoteResponse.price) {
throw new Error('Price not returned by alt MM');
}
if (altIndicativeQuoteResponse.amount) {
// if amount is specified, amount is the base token amount
baseAmount = Web3Wrapper.toBaseUnitAmount(
new BigNumber(altIndicativeQuoteResponse.amount),
altPair.baseAssetDecimals,
);
// if amount is specified, use the price (quote/base) to get the quote amount
quoteAmount = Web3Wrapper.toBaseUnitAmount(
new BigNumber(altIndicativeQuoteResponse.amount)
.times(new BigNumber(altIndicativeQuoteResponse.price))
.decimalPlaces(altPair.quoteAssetDecimals, BigNumber.ROUND_DOWN),
altPair.quoteAssetDecimals,
);
} else if (altIndicativeQuoteResponse.value) {
// if value is specified, value is the quote token amount
quoteAmount = Web3Wrapper.toBaseUnitAmount(
new BigNumber(altIndicativeQuoteResponse.value),
altPair.quoteAssetDecimals,
);
// if value is specified, use the price (quote/base) to get the base amount
baseAmount = Web3Wrapper.toBaseUnitAmount(
new BigNumber(altIndicativeQuoteResponse.value)
.dividedBy(new BigNumber(altIndicativeQuoteResponse.price))
.decimalPlaces(altPair.baseAssetDecimals, BigNumber.ROUND_DOWN),
altPair.baseAssetDecimals,
);
} else {
throw new Error('neither amount or value were specified');
}
if (makerToken.toLowerCase() === altPair.baseAsset.toLowerCase()) {
makerAmount = baseAmount;
takerAmount = quoteAmount;
} else if (makerToken.toLowerCase() === altPair.quoteAsset.toLowerCase()) {
makerAmount = quoteAmount;
takerAmount = baseAmount;
} else {
throw new Error(`Base, quote tokens don't align with maker, taker tokens`);
}
return {
makerToken,
makerAmount,
takerToken,
takerAmount,
// HACK: alt implementation does not return an expiration with indicative quotes
// return now + { IMPUTED EXPIRY SECONDS } to have it included after order checks
expiry:
// tslint:disable-next-line:custom-no-magic-numbers
new BigNumber(Date.now() / 1000)
.integerValue(BigNumber.ROUND_DOWN)
.plus(constants.ALT_MM_IMPUTED_INDICATIVE_EXPIRY_SECONDS),
};
}
/**
* Turn a standard quote request into an alt quote request
* and return the appropriate standard quote response
*/
export async function returnQuoteFromAltMMAsync<ResponseT>(
url: string,
apiKey: string,
profile: string,
integratorKey: string,
quoteModel: AltQuoteModel,
makerToken: string,
takerToken: string,
maxResponseTimeMs: number,
altRfqtAssetOfferings: AltRfqtMakerAssetOfferings,
takerRequestQueryParams: TakerRequestQueryParams,
axiosInstance: AxiosInstance,
): Promise<{ data: ResponseT; status: number }> {
const altPair = getAltMarketInfo(
altRfqtAssetOfferings[url],
takerRequestQueryParams.buyTokenAddress,
takerRequestQueryParams.sellTokenAddress,
);
if (!altPair) {
throw new Error(`Alt pair not found`);
}
const side = altPair.baseAsset === takerRequestQueryParams.buyTokenAddress ? AltQuoteSide.Sell : AltQuoteSide.Buy;
// comparison price needs to be quote/base
// in the standard implementation, it's maker/taker
let altComparisonPrice: string | undefined;
if (altPair.quoteAsset === makerToken) {
altComparisonPrice = takerRequestQueryParams.comparisonPrice
? takerRequestQueryParams.comparisonPrice
: undefined;
} else {
altComparisonPrice = takerRequestQueryParams.comparisonPrice
? new BigNumber(takerRequestQueryParams.comparisonPrice).pow(-1).toString()
: undefined;
}
let data: AltQuoteRequestData;
data = {
market: `${altPair.id}`,
model: quoteModel,
profile,
side,
meta: {
txOrigin: takerRequestQueryParams.txOrigin!,
taker: takerRequestQueryParams.takerAddress,
client: integratorKey,
},
};
// specify a comparison price if it exists
if (altComparisonPrice) {
data.meta.existingOrder = {
price: altComparisonPrice,
};
}
// need to specify amount or value
// amount is units of the base asset
// value is units of the quote asset
let requestSize: string;
if (takerRequestQueryParams.buyAmountBaseUnits) {
requestSize = Web3Wrapper.toUnitAmount(
new BigNumber(takerRequestQueryParams.buyAmountBaseUnits),
takerRequestQueryParams.buyTokenAddress === altPair.baseAsset
? altPair.baseAssetDecimals
: altPair.quoteAssetDecimals,
).toString();
if (takerRequestQueryParams.buyTokenAddress === altPair.baseAsset) {
data.amount = requestSize;
// add to 'existing order' if there is a comparison price
if (data.meta.existingOrder) {
data.meta.existingOrder.amount = requestSize;
}
} else {
data.value = requestSize;
// add to 'existing order' if there is a comparison price
if (data.meta.existingOrder) {
data.meta.existingOrder.value = requestSize;
}
}
} else if (takerRequestQueryParams.sellAmountBaseUnits) {
requestSize = Web3Wrapper.toUnitAmount(
new BigNumber(takerRequestQueryParams.sellAmountBaseUnits),
takerRequestQueryParams.sellTokenAddress === altPair.baseAsset
? altPair.baseAssetDecimals
: altPair.quoteAssetDecimals,
).toString();
if (takerRequestQueryParams.sellTokenAddress === altPair.baseAsset) {
data.amount = requestSize;
if (data.meta.existingOrder) {
data.meta.existingOrder.amount = requestSize;
}
} else {
data.value = requestSize;
if (data.meta.existingOrder) {
data.meta.existingOrder.value = requestSize;
}
}
}
const response = await axiosInstance.post(`${url}/quotes`, data, {
headers: { Authorization: `Bearer ${apiKey}` },
timeout: maxResponseTimeMs,
});
if (response.data.status === 'rejected') {
throw new Error('alt MM rejected quote');
}
const parsedResponse =
quoteModel === 'firm'
? parseFirmQuoteResponseFromAltMM(response.data)
: parseIndicativeQuoteResponseFromAltMM(response.data, altPair, makerToken, takerToken);
return {
// hack to appease type checking
data: (parsedResponse as unknown) as ResponseT,
status: response.status,
};
}

View File

@ -7,8 +7,19 @@ import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import { constants } from '../constants';
import { LogFunction, MarketOperation, RfqtMakerAssetOfferings, RfqtRequestOpts, SignedNativeOrder } from '../types';
import {
AltQuoteModel,
AltRfqtMakerAssetOfferings,
LogFunction,
MarketOperation,
RfqPairType,
RfqtMakerAssetOfferings,
RfqtRequestOpts,
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';
@ -126,6 +137,7 @@ export class QuoteRequestor {
constructor(
private readonly _rfqtAssetOfferings: RfqtMakerAssetOfferings,
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,
@ -311,13 +323,29 @@ export class QuoteRequestor {
return true;
}
private _makerSupportsPair(makerUrl: string, makerToken: string, takerToken: string): boolean {
for (const assetPair of this._rfqtAssetOfferings[makerUrl]) {
if (
(assetPair[0] === makerToken && assetPair[1] === takerToken) ||
(assetPair[0] === takerToken && assetPair[1] === makerToken)
) {
return true;
private _makerSupportsPair(
typedMakerUrl: TypedMakerUrl,
makerToken: string,
takerToken: string,
altMakerAssetOfferings: AltRfqtMakerAssetOfferings | 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;
@ -347,6 +375,7 @@ export class QuoteRequestor {
assetFillAmount,
comparisonPrice,
);
const quotePath = (() => {
switch (quoteType) {
case 'firm':
@ -358,45 +387,95 @@ export class QuoteRequestor {
}
})();
const makerUrls = Object.keys(this._rfqtAssetOfferings);
const quotePromises = makerUrls.map(async url => {
const standardUrls = Object.keys(this._rfqtAssetOfferings).map(
(mm: string): TypedMakerUrl => {
return { pairType: RfqPairType.Standard, url: mm };
},
);
const altUrls = options.altRfqtAssetOfferings
? Object.keys(options.altRfqtAssetOfferings).map(
(mm: string): TypedMakerUrl => {
return { pairType: RfqPairType.Alt, url: mm };
},
)
: [];
const typedMakerUrls = standardUrls.concat(altUrls);
const quotePromises = typedMakerUrls.map(async typedMakerUrl => {
// filter out requests to skip
const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(url);
const partialLogEntry = { url, quoteType, requestParams, isBlacklisted };
const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(typedMakerUrl.url);
const partialLogEntry = { url: typedMakerUrl.url, quoteType, requestParams, isBlacklisted };
if (isBlacklisted) {
this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry } });
return;
} else if (!this._makerSupportsPair(url, makerToken, takerToken)) {
} else if (!this._makerSupportsPair(typedMakerUrl, makerToken, takerToken, options.altRfqtAssetOfferings)) {
return;
} else {
// make request to MMs
// make request to MM
const timeBeforeAwait = Date.now();
const maxResponseTimeMs =
options.makerEndpointMaxResponseTimeMs === undefined
? constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs!
: options.makerEndpointMaxResponseTimeMs;
try {
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,
txOrigin: requestParams.txOrigin,
statusCode: response.status,
latencyMs,
if (typedMakerUrl.pairType === RfqPairType.Standard) {
const response = await quoteRequestorHttpClient.get(`${typedMakerUrl.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,
txOrigin: requestParams.txOrigin,
statusCode: response.status,
latencyMs,
},
},
},
});
rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs >= maxResponseTimeMs);
return { response: response.data, makerUri: url };
});
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= maxResponseTimeMs);
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.apiKey,
quoteType === 'firm' ? AltQuoteModel.Firm : AltQuoteModel.Indicative,
makerToken,
takerToken,
maxResponseTimeMs,
options.altRfqtAssetOfferings || {},
requestParams,
quoteRequestorHttpClient,
);
const latencyMs = Date.now() - timeBeforeAwait;
this._infoLogger({
rfqtMakerInteraction: {
...partialLogEntry,
response: {
included: true,
apiKey: options.apiKey,
takerAddress: requestParams.takerAddress,
txOrigin: requestParams.txOrigin,
statusCode: quote.status,
latencyMs,
},
},
});
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= maxResponseTimeMs);
return { response: quote.data, makerUri: typedMakerUrl.url };
}
} catch (err) {
// log error if any
const latencyMs = Date.now() - timeBeforeAwait;
@ -413,12 +492,14 @@ export class QuoteRequestor {
},
},
});
rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs >= maxResponseTimeMs);
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.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} and tx origin ${options.txOrigin}`,
`Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${
typedMakerUrl.url
} for API key ${options.apiKey} for taker address ${options.takerAddress} and tx origin ${
options.txOrigin
}`,
);
return;
}

View File

@ -1,14 +1,22 @@
import { tokenUtils } from '@0x/dev-utils';
import { FillQuoteTransformerOrderType, SignatureType } from '@0x/protocol-utils';
import { TakerRequestQueryParams } from '@0x/quote-server';
import { TakerRequestQueryParams, V4RFQIndicativeQuote } from '@0x/quote-server';
import { StatusCodes } from '@0x/types';
import { BigNumber } from '@0x/utils';
import { BigNumber, logUtils } from '@0x/utils';
import * as chai from 'chai';
import _ = require('lodash');
import 'mocha';
import { constants } from '../src/constants';
import { MarketOperation, MockedRfqtQuoteResponse } from '../src/types';
import {
AltMockedRfqtQuoteResponse,
AltQuoteModel,
AltQuoteRequestData,
AltQuoteSide,
AltRfqtMakerAssetOfferings,
MarketOperation,
MockedRfqtQuoteResponse,
} from '../src/types';
import { NULL_ADDRESS } from '../src/utils/market_operation_utils/constants';
import { QuoteRequestor, quoteRequestorHttpClient } from '../src/utils/quote_requestor';
@ -17,6 +25,12 @@ import { RfqtQuoteEndpoint, testHelpers } from './utils/test_helpers';
chaiSetup.configure();
const expect = chai.expect;
const ALT_MM_API_KEY = 'averysecurekey';
const ALT_PROFILE = 'acoolprofile';
const ALT_RFQ_CREDS = {
altRfqApiKey: ALT_MM_API_KEY,
altRfqProfile: ALT_PROFILE,
};
function makeThreeMinuteExpiry(): BigNumber {
const expiry = new Date(Date.now());
@ -28,6 +42,18 @@ describe('QuoteRequestor', async () => {
const [makerToken, takerToken, otherToken1] = tokenUtils.getDummyERC20TokenAddresses();
const validSignature = { v: 28, r: '0x', s: '0x', signatureType: SignatureType.EthSign };
const altRfqtAssetOfferings: AltRfqtMakerAssetOfferings = {
'https://132.0.0.1': [
{
id: 'XYZ-123',
baseAsset: makerToken,
quoteAsset: takerToken,
baseAssetDecimals: 2,
quoteAssetDecimals: 3,
},
],
};
describe('requestRfqtFirmQuotesAsync for firm quotes', async () => {
it('should return successful RFQT requests', async () => {
const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a';
@ -37,6 +63,8 @@ describe('QuoteRequestor', async () => {
// Set up RFQT responses
// tslint:disable-next-line:array-type
const mockedRequests: MockedRfqtQuoteResponse[] = [];
const altMockedRequests: AltMockedRfqtQuoteResponse[] = [];
const expectedParams: TakerRequestQueryParams = {
sellTokenAddress: takerToken,
buyTokenAddress: makerToken,
@ -66,6 +94,30 @@ describe('QuoteRequestor', async () => {
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,
},
};
// Successful response
mockedRequests.push({
@ -124,6 +176,14 @@ describe('QuoteRequestor', async () => {
endpoint: 'https://425.0.0.1',
responseData: { signedOrder: { ...validSignedOrder, txOrigin: NULL_ADDRESS } },
});
// 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: StatusCodes.Success,
requestData: altFirmRequestData,
responseData: altFirmResponse,
});
const normalizedSuccessfulOrder = {
order: {
@ -139,20 +199,24 @@ describe('QuoteRequestor', async () => {
return testHelpers.withMockedRfqtQuotes(
mockedRequests,
altMockedRequests,
RfqtQuoteEndpoint.Firm,
async () => {
const qr = new QuoteRequestor({
'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-T provider when they don't support the requested asset pair. */,
'https://37.0.0.1': [[makerToken, takerToken]],
});
const qr = new QuoteRequestor(
{
'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-T provider when they don't support the requested asset pair. */,
'https://37.0.0.1': [[makerToken, takerToken]],
},
ALT_RFQ_CREDS,
);
const resp = await qr.requestRfqtFirmQuotesAsync(
makerToken,
takerToken,
@ -164,9 +228,14 @@ describe('QuoteRequestor', async () => {
takerAddress,
txOrigin: takerAddress,
intentOnFilling: true,
altRfqtAssetOfferings,
},
);
expect(resp).to.deep.eq([normalizedSuccessfulOrder, normalizedSuccessfulOrder]);
expect(resp).to.deep.eq([
normalizedSuccessfulOrder,
normalizedSuccessfulOrder,
normalizedSuccessfulOrder,
]);
},
quoteRequestorHttpClient,
);
@ -255,6 +324,7 @@ describe('QuoteRequestor', async () => {
return testHelpers.withMockedRfqtQuotes(
mockedRequests,
[],
RfqtQuoteEndpoint.Indicative,
async () => {
const qr = new QuoteRequestor({
@ -318,6 +388,7 @@ describe('QuoteRequestor', async () => {
return testHelpers.withMockedRfqtQuotes(
mockedRequests,
[],
RfqtQuoteEndpoint.Indicative,
async () => {
const qr = new QuoteRequestor({ 'https://1337.0.0.1': [[makerToken, takerToken]] });
@ -339,5 +410,241 @@ describe('QuoteRequestor', async () => {
quoteRequestorHttpClient,
);
});
it('should return successful alt indicative quotes', async () => {
const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a';
const txOrigin = '0xf209925defc99488e3afff1174e48b4fa628302a';
const apiKey = 'my-ko0l-api-key';
// base token has 2 decimals
// quote token has 3 decimals
const baseToken = makerToken;
const quoteToken = takerToken;
// Set up RFQT responses
const altMockedRequests: AltMockedRfqtQuoteResponse[] = [];
const altScenarios: Array<{
successfulQuote: V4RFQIndicativeQuote;
requestedMakerToken: string;
requestedTakerToken: string;
requestedAmount: BigNumber;
requestedOperation: MarketOperation;
}> = [];
// SCENARIO 1
// buy, base asset specified
// requesting to buy 100 units (10000 base units) of the base token
// returning a price of 0.01, which should mean 10000 maker, 1000 taker amount
const buyAmountAltRequest: AltQuoteRequestData = {
market: 'XYZ-123',
model: AltQuoteModel.Indicative,
profile: ALT_PROFILE,
side: AltQuoteSide.Sell,
meta: {
txOrigin,
taker: takerAddress,
client: apiKey,
},
amount: '100',
};
// Successful response
const buyAmountAltResponse = {
...buyAmountAltRequest,
id: 'random_id',
// tslint:disable-next-line:custom-no-magic-numbers
price: new BigNumber(0.01).toString(),
status: 'live',
};
const successfulBuyAmountQuote: V4RFQIndicativeQuote = {
makerToken: baseToken,
takerToken: quoteToken,
makerAmount: new BigNumber(10000),
takerAmount: new BigNumber(1000),
expiry: new BigNumber(0),
};
altMockedRequests.push({
endpoint: 'https://132.0.0.1',
mmApiKey: ALT_MM_API_KEY,
responseCode: StatusCodes.Success,
requestData: buyAmountAltRequest,
responseData: buyAmountAltResponse,
});
altScenarios.push({
successfulQuote: successfulBuyAmountQuote,
requestedMakerToken: baseToken,
requestedTakerToken: quoteToken,
requestedAmount: new BigNumber(10000),
requestedOperation: MarketOperation.Buy,
});
// SCENARIO 2
// alt buy, quote asset specified
// user is requesting to sell 1 unit of the quote token, or 1000 base units
// returning a price of 0.01, which should mean 10000 maker amount, 1000 taker amount
const buyValueAltRequest: AltQuoteRequestData = {
market: 'XYZ-123',
model: AltQuoteModel.Indicative,
profile: ALT_PROFILE,
side: AltQuoteSide.Sell,
meta: {
txOrigin,
taker: takerAddress,
client: apiKey,
},
value: '1',
};
// Successful response
const buyValueAltResponse = {
...buyValueAltRequest,
id: 'random_id',
// tslint:disable-next-line:custom-no-magic-numbers
price: new BigNumber(0.01).toString(),
status: 'live',
};
const successfulBuyValueQuote: V4RFQIndicativeQuote = {
makerToken: baseToken,
takerToken: quoteToken,
makerAmount: new BigNumber(10000),
takerAmount: new BigNumber(1000),
expiry: new BigNumber(0),
};
altMockedRequests.push({
endpoint: 'https://132.0.0.1',
mmApiKey: ALT_MM_API_KEY,
responseCode: StatusCodes.Success,
requestData: buyValueAltRequest,
responseData: buyValueAltResponse,
});
altScenarios.push({
successfulQuote: successfulBuyValueQuote,
requestedMakerToken: baseToken,
requestedTakerToken: quoteToken,
requestedAmount: new BigNumber(1000),
requestedOperation: MarketOperation.Sell,
});
// SCENARIO 3
// alt sell, base asset specified
// user is requesting to sell 100 units (10000 base units) of the base token
// returning a price of 0.01, which should mean 10000 taker amount, 1000 maker amount
const sellAmountAltRequest: AltQuoteRequestData = {
market: 'XYZ-123',
model: AltQuoteModel.Indicative,
profile: ALT_PROFILE,
side: AltQuoteSide.Buy,
meta: {
txOrigin,
taker: takerAddress,
client: apiKey,
},
amount: '100',
};
// Successful response
const sellAmountAltResponse = {
...sellAmountAltRequest,
id: 'random_id',
// tslint:disable-next-line:custom-no-magic-numbers
price: new BigNumber(0.01).toString(),
status: 'live',
};
const successfulSellAmountQuote: V4RFQIndicativeQuote = {
makerToken: quoteToken,
takerToken: baseToken,
makerAmount: new BigNumber(1000),
takerAmount: new BigNumber(10000),
expiry: new BigNumber(0),
};
altMockedRequests.push({
endpoint: 'https://132.0.0.1',
mmApiKey: ALT_MM_API_KEY,
responseCode: StatusCodes.Success,
requestData: sellAmountAltRequest,
responseData: sellAmountAltResponse,
});
altScenarios.push({
successfulQuote: successfulSellAmountQuote,
requestedMakerToken: quoteToken,
requestedTakerToken: baseToken,
requestedAmount: new BigNumber(10000),
requestedOperation: MarketOperation.Sell,
});
// SCENARIO 4
// alt sell, quote asset specified
// user is requesting to buy 1 unit (1000 base units) of the quote token
// returning a price of 0.01, which should mean 10000 taker amount, 1000 maker amount
const sellValueAltRequest: AltQuoteRequestData = {
market: 'XYZ-123',
model: AltQuoteModel.Indicative,
profile: ALT_PROFILE,
side: AltQuoteSide.Buy,
meta: {
txOrigin,
taker: takerAddress,
client: apiKey,
},
value: '1',
};
// Successful response
const sellValueAltResponse = {
...sellValueAltRequest,
id: 'random_id',
// tslint:disable-next-line:custom-no-magic-numbers
price: new BigNumber(0.01).toString(),
status: 'live',
};
const successfulSellValueQuote: V4RFQIndicativeQuote = {
makerToken: quoteToken,
takerToken: baseToken,
makerAmount: new BigNumber(1000),
takerAmount: new BigNumber(10000),
expiry: new BigNumber(0),
};
altMockedRequests.push({
endpoint: 'https://132.0.0.1',
mmApiKey: ALT_MM_API_KEY,
responseCode: StatusCodes.Success,
requestData: sellValueAltRequest,
responseData: sellValueAltResponse,
});
altScenarios.push({
successfulQuote: successfulSellValueQuote,
requestedMakerToken: quoteToken,
requestedTakerToken: baseToken,
requestedAmount: new BigNumber(1000),
requestedOperation: MarketOperation.Buy,
});
let scenarioCounter = 1;
for (const altScenario of altScenarios) {
logUtils.log(`Alt MM indicative scenario ${scenarioCounter}`);
scenarioCounter += 1;
await testHelpers.withMockedRfqtQuotes(
[],
altMockedRequests,
RfqtQuoteEndpoint.Indicative,
async () => {
const qr = new QuoteRequestor({}, ALT_RFQ_CREDS);
const resp = await qr.requestRfqtIndicativeQuotesAsync(
altScenario.requestedMakerToken,
altScenario.requestedTakerToken,
altScenario.requestedAmount,
altScenario.requestedOperation,
undefined,
{
apiKey,
takerAddress,
txOrigin,
intentOnFilling: true,
altRfqtAssetOfferings,
},
);
// hack to get the expiry right, since it's dependent on the current timestamp
const expected = { ...altScenario.successfulQuote, expiry: resp[0].expiry };
expect(resp.sort()).to.eql([expected].sort());
},
quoteRequestorHttpClient,
);
}
});
});
});

View File

@ -1,9 +1,10 @@
import { BigNumber } from '@0x/utils';
import axios, { AxiosInstance } from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import * as _ from 'lodash';
import { InsufficientAssetLiquidityError } from '../../src/errors';
import { MockedRfqtQuoteResponse } from '../../src/types';
import { AltMockedRfqtQuoteResponse, MockedRfqtQuoteResponse } from '../../src/types';
export enum RfqtQuoteEndpoint {
Indicative = 'price',
@ -36,21 +37,44 @@ export const testHelpers = {
* requests to RFQ-t providers
*/
withMockedRfqtQuotes: async (
mockedResponses: MockedRfqtQuoteResponse[],
standardMockedResponses: MockedRfqtQuoteResponse[],
altMockedResponses: AltMockedRfqtQuoteResponse[],
quoteType: RfqtQuoteEndpoint,
afterResponseCallback: () => Promise<void>,
axiosClient: AxiosInstance = axios,
): Promise<void> => {
const mockedAxios = new AxiosMockAdapter(axiosClient);
const mockedAxios = new AxiosMockAdapter(axiosClient, { onNoMatch: 'throwException' });
try {
// Mock out RFQT responses
for (const mockedResponse of mockedResponses) {
// Mock out Standard RFQT responses
for (const mockedResponse of standardMockedResponses) {
const { endpoint, requestApiKey, requestParams, responseData, responseCode } = mockedResponse;
const requestHeaders = { Accept: 'application/json, text/plain, */*', '0x-api-key': requestApiKey };
mockedAxios
.onGet(`${endpoint}/${quoteType}`, { params: requestParams }, requestHeaders)
.replyOnce(responseCode, responseData);
}
// Mock out Alt RFQT responses
for (const mockedResponse of altMockedResponses) {
const { endpoint, mmApiKey, requestData, responseData, responseCode } = mockedResponse;
const requestHeaders = {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json;charset=utf-8',
Authorization: `Bearer ${mmApiKey}`,
};
mockedAxios
.onPost(
`${endpoint}/quotes`,
// hack to get AxiosMockAdapter to recognize the match
// b/t the mock data and the request data
{
asymmetricMatch: (x: any) => {
return _.isEqual(requestData, x);
},
},
requestHeaders,
)
.replyOnce(responseCode, responseData);
}
// Perform the callback function, e.g. a test validation
await afterResponseCallback();
} finally {