diff --git a/packages/asset-swapper/CHANGELOG.json b/packages/asset-swapper/CHANGELOG.json index 12b3dc00af..57fb966aaa 100644 --- a/packages/asset-swapper/CHANGELOG.json +++ b/packages/asset-swapper/CHANGELOG.json @@ -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 } ] }, diff --git a/packages/asset-swapper/src/constants.ts b/packages/asset-swapper/src/constants.ts index 869daa0f8c..524affb5cd 100644 --- a/packages/asset-swapper/src/constants.ts +++ b/packages/asset-swapper/src/constants.ts @@ -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, }; diff --git a/packages/asset-swapper/src/index.ts b/packages/asset-swapper/src/index.ts index 4a3d8f2295..9797aecd97 100644 --- a/packages/asset-swapper/src/index.ts +++ b/packages/asset-swapper/src/index.ts @@ -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, diff --git a/packages/asset-swapper/src/swap_quoter.ts b/packages/asset-swapper/src/swap_quoter.ts index 357b54b611..a4acc64b84 100644 --- a/packages/asset-swapper/src/swap_quoter.ts +++ b/packages/asset-swapper/src/swap_quoter.ts @@ -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, diff --git a/packages/asset-swapper/src/types.ts b/packages/asset-swapper/src/types.ts index 66909160d0..f4f0d38ec7 100644 --- a/packages/asset-swapper/src/types.ts +++ b/packages/asset-swapper/src/types.ts @@ -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 = Pick>; + +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'; +} diff --git a/packages/asset-swapper/src/utils/alt_mm_implementation_utils.ts b/packages/asset-swapper/src/utils/alt_mm_implementation_utils.ts new file mode 100644 index 0000000000..42006bbe50 --- /dev/null +++ b/packages/asset-swapper/src/utils/alt_mm_implementation_utils.ts @@ -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( + 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, + }; +} diff --git a/packages/asset-swapper/src/utils/quote_requestor.ts b/packages/asset-swapper/src/utils/quote_requestor.ts index 3f158c1ef6..96db143b00 100644 --- a/packages/asset-swapper/src/utils/quote_requestor.ts +++ b/packages/asset-swapper/src/utils/quote_requestor.ts @@ -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(`${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( + 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; } diff --git a/packages/asset-swapper/test/quote_requestor_test.ts b/packages/asset-swapper/test/quote_requestor_test.ts index 12d3d05ffb..96859aaf35 100644 --- a/packages/asset-swapper/test/quote_requestor_test.ts +++ b/packages/asset-swapper/test/quote_requestor_test.ts @@ -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, + ); + } + }); }); }); diff --git a/packages/asset-swapper/test/utils/test_helpers.ts b/packages/asset-swapper/test/utils/test_helpers.ts index 6bd389ca9b..0caf4d3119 100644 --- a/packages/asset-swapper/test/utils/test_helpers.ts +++ b/packages/asset-swapper/test/utils/test_helpers.ts @@ -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, axiosClient: AxiosInstance = axios, ): Promise => { - 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 {