diff --git a/packages/asset-swapper/compiler.json b/packages/asset-swapper/compiler.json index 20f8637f21..c15d3cd1c6 100644 --- a/packages/asset-swapper/compiler.json +++ b/packages/asset-swapper/compiler.json @@ -8,7 +8,7 @@ "evmVersion": "istanbul", "optimizer": { "enabled": true, - "runs": 1000000, + "runs": 62500, "details": { "yul": true, "deduplicate": true, "cse": true, "constantOptimizer": true } }, "outputSelection": { diff --git a/packages/asset-swapper/contracts/src/NativeOrderSampler.sol b/packages/asset-swapper/contracts/src/NativeOrderSampler.sol index 3ba797906b..c1a36cc0b4 100644 --- a/packages/asset-swapper/contracts/src/NativeOrderSampler.sol +++ b/packages/asset-swapper/contracts/src/NativeOrderSampler.sol @@ -20,6 +20,7 @@ pragma solidity ^0.5.9; pragma experimental ABIEncoderV2; import "@0x/contracts-erc20/contracts/src/interfaces/IERC20Token.sol"; +import "@0x/contracts-erc20/contracts/src/LibERC20Token.sol"; import "@0x/contracts-exchange/contracts/src/interfaces/IExchange.sol"; import "@0x/contracts-exchange-libs/contracts/src/LibOrder.sol"; import "@0x/contracts-exchange-libs/contracts/src/LibMath.sol"; @@ -36,6 +37,19 @@ contract NativeOrderSampler { /// @dev Gas limit for calls to `getOrderFillableTakerAmount()`. uint256 constant internal DEFAULT_CALL_GAS = 200e3; // 200k + function getTokenDecimals( + address makerTokenAddress, + address takerTokenAddress + ) + public + view + returns (uint256, uint256) + { + uint256 fromTokenDecimals = LibERC20Token.decimals(makerTokenAddress); + uint256 toTokenDecimals = LibERC20Token.decimals(takerTokenAddress); + return (fromTokenDecimals, toTokenDecimals); + } + /// @dev Queries the fillable taker asset amounts of native orders. /// Effectively ignores orders that have empty signatures or /// maker/taker asset amounts (returning 0). diff --git a/packages/asset-swapper/package.json b/packages/asset-swapper/package.json index 71da2f0983..604232e324 100644 --- a/packages/asset-swapper/package.json +++ b/packages/asset-swapper/package.json @@ -64,7 +64,7 @@ "@0x/json-schemas": "^5.1.0", "@0x/order-utils": "^10.3.0", "@0x/orderbook": "^2.2.7", - "@0x/quote-server": "^2.0.2", + "@0x/quote-server": "^3.1.0", "@0x/types": "^3.2.0", "@0x/typescript-typings": "^5.1.1", "@0x/utils": "^5.5.1", diff --git a/packages/asset-swapper/src/constants.ts b/packages/asset-swapper/src/constants.ts index e68c605930..d4686e9923 100644 --- a/packages/asset-swapper/src/constants.ts +++ b/packages/asset-swapper/src/constants.ts @@ -122,3 +122,12 @@ export const constants = { DEFAULT_INFO_LOGGER, DEFAULT_WARNING_LOGGER, }; + +// This feature flag allows us to merge the price-aware RFQ pricing +// project while still controlling when to activate the feature. We plan to do some +// data analysis work and address some of the issues with maker fillable amounts +// in later milestones. Once the feature is fully rolled out and is providing value +// and we have assessed that there is no user impact, we will proceed in cleaning up +// the feature flag. When that time comes, follow this PR to "undo" the feature flag: +// https://github.com/0xProject/0x-monorepo/pull/2735 +export const IS_PRICE_AWARE_RFQ_ENABLED: boolean = false; diff --git a/packages/asset-swapper/src/index.ts b/packages/asset-swapper/src/index.ts index 21fcc1b3b9..eaf2d911c0 100644 --- a/packages/asset-swapper/src/index.ts +++ b/packages/asset-swapper/src/index.ts @@ -18,7 +18,7 @@ export { SRAPollingOrderProviderOpts, SRAWebsocketOrderProviderOpts, } from '@0x/orderbook'; -export { RFQTFirmQuote, RFQTIndicativeQuote } from '@0x/quote-server'; +export { RFQTFirmQuote, RFQTIndicativeQuote, TakerRequestQueryParams } from '@0x/quote-server'; export { APIOrder, Asset, diff --git a/packages/asset-swapper/src/swap_quoter.ts b/packages/asset-swapper/src/swap_quoter.ts index 7b99bc55b4..e6c5513100 100644 --- a/packages/asset-swapper/src/swap_quoter.ts +++ b/packages/asset-swapper/src/swap_quoter.ts @@ -8,7 +8,7 @@ import { BlockParamLiteral, SupportedProvider, ZeroExProvider } from 'ethereum-t import * as _ from 'lodash'; import { artifacts } from './artifacts'; -import { constants } from './constants'; +import { constants, IS_PRICE_AWARE_RFQ_ENABLED } from './constants'; import { CalculateSwapQuoteOpts, LiquidityForTakerMakerAssetDataPair, @@ -683,7 +683,21 @@ export class SwapQuoter { this.expiryBufferMs, ); + // If an API key was provided, but the key is not whitelisted, raise a warning and disable RFQ + if (opts.rfqt && opts.rfqt.apiKey && !this._isApiKeyWhitelisted(opts.rfqt.apiKey)) { + if (rfqtOptions && rfqtOptions.warningLogger) { + rfqtOptions.warningLogger( + { + apiKey: opts.rfqt.apiKey, + }, + 'Attempt at using an RFQ API key that is not whitelisted. Disabling RFQ for the request lifetime.', + ); + } + opts.rfqt = undefined; + } + if ( + !IS_PRICE_AWARE_RFQ_ENABLED && // Price-aware RFQ is disabled. opts.rfqt && // This is an RFQT-enabled API request opts.rfqt.intentOnFilling && // The requestor is asking for a firm quote opts.rfqt.apiKey && @@ -700,6 +714,7 @@ export class SwapQuoter { takerAssetData, assetFillAmount, marketOperation, + undefined, opts.rfqt, ) .then(firmQuotes => firmQuotes.map(quote => quote.signedOrder)), @@ -707,9 +722,7 @@ export class SwapQuoter { } const orderBatches: SignedOrder[][] = await Promise.all(orderBatchPromises); - const unsortedOrders: SignedOrder[] = orderBatches.reduce((_orders, batch) => _orders.concat(...batch), []); - const orders = sortingUtils.sortOrders(unsortedOrders); // if no native orders, pass in a dummy order for the sampler to have required metadata for sampling diff --git a/packages/asset-swapper/src/types.ts b/packages/asset-swapper/src/types.ts index 3cb701481f..4eaf8adde2 100644 --- a/packages/asset-swapper/src/types.ts +++ b/packages/asset-swapper/src/types.ts @@ -1,4 +1,5 @@ import { BlockParam, ContractAddresses, GethCallOverrides } from '@0x/contract-wrappers'; +import { TakerRequestQueryParams } from '@0x/quote-server'; import { SignedOrder } from '@0x/types'; import { BigNumber } from '@0x/utils'; @@ -357,9 +358,7 @@ export enum OrderPrunerPermittedFeeTypes { export interface MockedRfqtFirmQuoteResponse { endpoint: string; requestApiKey: string; - requestParams: { - [key: string]: string | undefined; - }; + requestParams: TakerRequestQueryParams; responseData: any; responseCode: number; } @@ -370,9 +369,7 @@ export interface MockedRfqtFirmQuoteResponse { export interface MockedRfqtIndicativeQuoteResponse { endpoint: string; requestApiKey: string; - requestParams: { - [key: string]: string | undefined; - }; + requestParams: TakerRequestQueryParams; responseData: any; responseCode: number; } @@ -381,3 +378,5 @@ export interface SamplerOverrides { overrides: GethCallOverrides; block: BlockParam; } + +export type Omit = Pick>; diff --git a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts index b17aaa5b71..620169ee50 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts @@ -282,3 +282,4 @@ export const ONE_HOUR_IN_SECONDS = 60 * 60; export const ONE_SECOND_MS = 1000; export const NULL_BYTES = '0x'; export const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; +export const COMPARISON_PRICE_DECIMALS = 5; diff --git a/packages/asset-swapper/src/utils/market_operation_utils/index.ts b/packages/asset-swapper/src/utils/market_operation_utils/index.ts index c054959378..ff176d09dc 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/index.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/index.ts @@ -1,15 +1,18 @@ import { ContractAddresses } from '@0x/contract-addresses'; +import { Web3Wrapper } from '@0x/dev-utils'; import { RFQTIndicativeQuote } from '@0x/quote-server'; import { SignedOrder } from '@0x/types'; import { BigNumber, NULL_ADDRESS } from '@0x/utils'; import * as _ from 'lodash'; -import { MarketOperation } from '../../types'; +import { IS_PRICE_AWARE_RFQ_ENABLED } from '../../constants'; +import { MarketOperation, Omit } from '../../types'; import { QuoteRequestor } from '../quote_requestor'; import { generateQuoteReport, QuoteReport } from './../quote_report_generator'; import { BUY_SOURCE_FILTER, + COMPARISON_PRICE_DECIMALS, DEFAULT_GET_MARKET_ORDERS_OPTS, FEE_QUOTE_SOURCES, ONE_ETHER, @@ -33,12 +36,12 @@ import { CollapsedFill, DexSample, ERC20BridgeSource, - ExchangeProxyOverhead, - FeeSchedule, + GenerateOptimizedOrdersOpts, GetMarketOrdersOpts, MarketSideLiquidity, OptimizedMarketOrder, OptimizerResult, + OptimizerResultWithReport, OrderDomain, TokenAdjacencyGraph, } from './types'; @@ -58,6 +61,7 @@ export async function getRfqtIndicativeQuotesAsync( takerAssetData: string, marketOperation: MarketOperation, assetFillAmount: BigNumber, + comparisonPrice: BigNumber | undefined, opts: Partial, ): Promise { if (opts.rfqt && opts.rfqt.isIndicative === true && opts.rfqt.quoteRequestor) { @@ -66,6 +70,7 @@ export async function getRfqtIndicativeQuotesAsync( takerAssetData, assetFillAmount, marketOperation, + comparisonPrice, opts.rfqt, ); } else { @@ -168,6 +173,7 @@ export class MarketOperationUtils { // Call the sampler contract. const samplerPromise = this._sampler.executeAsync( + this._sampler.getTokenDecimals(makerToken, takerToken), // Get native order fillable amounts. this._sampler.getOrderFillableTakerAmounts(nativeOrders, this.contractAddresses.exchange), // Get ETH -> maker token price. @@ -211,15 +217,17 @@ export class MarketOperationUtils { ), ); - const rfqtPromise = quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) - ? getRfqtIndicativeQuotesAsync( - nativeOrders[0].makerAssetData, - nativeOrders[0].takerAssetData, - MarketOperation.Sell, - takerAmount, - _opts, - ) - : Promise.resolve([]); + const rfqtPromise = + !IS_PRICE_AWARE_RFQ_ENABLED && quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) + ? getRfqtIndicativeQuotesAsync( + nativeOrders[0].makerAssetData, + nativeOrders[0].takerAssetData, + MarketOperation.Sell, + takerAmount, + undefined, + _opts, + ) + : Promise.resolve([]); const offChainBalancerPromise = sampleBalancerOffChain ? this._sampler.getBalancerSellQuotesOffChainAsync(makerToken, takerToken, sampleAmounts) @@ -234,7 +242,7 @@ export class MarketOperationUtils { : Promise.resolve([]); const [ - [orderFillableAmounts, ethToMakerAssetRate, ethToTakerAssetRate, dexQuotes, twoHopQuotes], + [tokenDecimals, orderFillableAmounts, ethToMakerAssetRate, ethToTakerAssetRate, dexQuotes, twoHopQuotes], rfqtIndicativeQuotes, offChainBalancerQuotes, offChainCreamQuotes, @@ -247,6 +255,7 @@ export class MarketOperationUtils { offChainBancorPromise, ]); + const [makerTokenDecimals, takerTokenDecimals] = tokenDecimals; return { side: MarketOperation.Sell, inputAmount: takerAmount, @@ -259,6 +268,9 @@ export class MarketOperationUtils { ethToInputRate: ethToTakerAssetRate, rfqtIndicativeQuotes, twoHopQuotes, + quoteSourceFilters, + makerTokenDecimals: makerTokenDecimals.toNumber(), + takerTokenDecimals: takerTokenDecimals.toNumber(), }; } @@ -311,6 +323,7 @@ export class MarketOperationUtils { // Call the sampler contract. const samplerPromise = this._sampler.executeAsync( + this._sampler.getTokenDecimals(makerToken, takerToken), // Get native order fillable amounts. this._sampler.getOrderFillableMakerAmounts(nativeOrders, this.contractAddresses.exchange), // Get ETH -> makerToken token price. @@ -352,17 +365,17 @@ export class MarketOperationUtils { this._liquidityProviderRegistry, ), ); - - const rfqtPromise = quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) - ? getRfqtIndicativeQuotesAsync( - nativeOrders[0].makerAssetData, - nativeOrders[0].takerAssetData, - MarketOperation.Buy, - makerAmount, - _opts, - ) - : Promise.resolve([]); - + const rfqtPromise = + !IS_PRICE_AWARE_RFQ_ENABLED && quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) + ? getRfqtIndicativeQuotesAsync( + nativeOrders[0].makerAssetData, + nativeOrders[0].takerAssetData, + MarketOperation.Buy, + makerAmount, + undefined, + _opts, + ) + : Promise.resolve([]); const offChainBalancerPromise = sampleBalancerOffChain ? this._sampler.getBalancerBuyQuotesOffChainAsync(makerToken, takerToken, sampleAmounts) : Promise.resolve([]); @@ -372,7 +385,7 @@ export class MarketOperationUtils { : Promise.resolve([]); const [ - [orderFillableAmounts, ethToMakerAssetRate, ethToTakerAssetRate, dexQuotes, twoHopQuotes], + [tokenDecimals, orderFillableAmounts, ethToMakerAssetRate, ethToTakerAssetRate, dexQuotes, twoHopQuotes], rfqtIndicativeQuotes, offChainBalancerQuotes, offChainCreamQuotes, @@ -381,6 +394,7 @@ export class MarketOperationUtils { (dexQuotes.find(quotes => quotes[0] && quotes[0].source === ERC20BridgeSource.MultiBridge) || []).forEach( q => (q.fillData = { poolAddress: this._multiBridge }), ); + const [makerTokenDecimals, takerTokenDecimals] = tokenDecimals; return { side: MarketOperation.Buy, inputAmount: makerAmount, @@ -393,6 +407,9 @@ export class MarketOperationUtils { ethToInputRate: ethToMakerAssetRate, rfqtIndicativeQuotes, twoHopQuotes, + quoteSourceFilters, + makerTokenDecimals: makerTokenDecimals.toNumber(), + takerTokenDecimals: takerTokenDecimals.toNumber(), }; } @@ -408,29 +425,8 @@ export class MarketOperationUtils { nativeOrders: SignedOrder[], takerAmount: BigNumber, opts?: Partial, - ): Promise { - const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; - const marketSideLiquidity = await this.getMarketSellLiquidityAsync(nativeOrders, takerAmount, _opts); - const optimizerResult = await this._generateOptimizedOrdersAsync(marketSideLiquidity, { - bridgeSlippage: _opts.bridgeSlippage, - maxFallbackSlippage: _opts.maxFallbackSlippage, - excludedSources: _opts.excludedSources, - feeSchedule: _opts.feeSchedule, - exchangeProxyOverhead: _opts.exchangeProxyOverhead, - allowFallback: _opts.allowFallback, - }); - - // Compute Quote Report and return the results. - let quoteReport: QuoteReport | undefined; - if (_opts.shouldGenerateQuoteReport) { - quoteReport = MarketOperationUtils._computeQuoteReport( - nativeOrders, - _opts.rfqt ? _opts.rfqt.quoteRequestor : undefined, - marketSideLiquidity, - optimizerResult, - ); - } - return { ...optimizerResult, quoteReport }; + ): Promise { + return this._getMarketSideOrdersAsync(nativeOrders, takerAmount, MarketOperation.Sell, opts); } /** @@ -445,27 +441,8 @@ export class MarketOperationUtils { nativeOrders: SignedOrder[], makerAmount: BigNumber, opts?: Partial, - ): Promise { - const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; - const marketSideLiquidity = await this.getMarketBuyLiquidityAsync(nativeOrders, makerAmount, _opts); - const optimizerResult = await this._generateOptimizedOrdersAsync(marketSideLiquidity, { - bridgeSlippage: _opts.bridgeSlippage, - maxFallbackSlippage: _opts.maxFallbackSlippage, - excludedSources: _opts.excludedSources, - feeSchedule: _opts.feeSchedule, - exchangeProxyOverhead: _opts.exchangeProxyOverhead, - allowFallback: _opts.allowFallback, - }); - let quoteReport: QuoteReport | undefined; - if (_opts.shouldGenerateQuoteReport) { - quoteReport = MarketOperationUtils._computeQuoteReport( - nativeOrders, - _opts.rfqt ? _opts.rfqt.quoteRequestor : undefined, - marketSideLiquidity, - optimizerResult, - ); - } - return { ...optimizerResult, quoteReport }; + ): Promise { + return this._getMarketSideOrdersAsync(nativeOrders, makerAmount, MarketOperation.Buy, opts); } /** @@ -548,6 +525,7 @@ export class MarketOperationUtils { inputToken: makerToken, outputToken: takerToken, twoHopQuotes: [], + quoteSourceFilters, }, { bridgeSlippage: _opts.bridgeSlippage, @@ -567,18 +545,9 @@ export class MarketOperationUtils { ); } - private async _generateOptimizedOrdersAsync( - marketSideLiquidity: MarketSideLiquidity, - opts: { - runLimit?: number; - bridgeSlippage?: number; - maxFallbackSlippage?: number; - excludedSources?: ERC20BridgeSource[]; - feeSchedule?: FeeSchedule; - exchangeProxyOverhead?: ExchangeProxyOverhead; - allowFallback?: boolean; - shouldBatchBridgeOrders?: boolean; - }, + public async _generateOptimizedOrdersAsync( + marketSideLiquidity: Omit, + opts: GenerateOptimizedOrdersOpts, ): Promise { const { inputToken, @@ -671,6 +640,151 @@ export class MarketOperationUtils { sourceFlags: collapsedPath.sourceFlags, }; } + + private async _getMarketSideOrdersAsync( + nativeOrders: SignedOrder[], + amount: BigNumber, + side: MarketOperation, + opts?: Partial, + ): Promise { + const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; + const optimizerOpts: GenerateOptimizedOrdersOpts = { + bridgeSlippage: _opts.bridgeSlippage, + maxFallbackSlippage: _opts.maxFallbackSlippage, + excludedSources: _opts.excludedSources, + feeSchedule: _opts.feeSchedule, + allowFallback: _opts.allowFallback, + exchangeProxyOverhead: _opts.exchangeProxyOverhead, + }; + + // Compute an optimized path for on-chain DEX and open-orderbook. This should not include RFQ liquidity. + const marketLiquidityFnAsync = + side === MarketOperation.Sell + ? this.getMarketSellLiquidityAsync.bind(this) + : this.getMarketBuyLiquidityAsync.bind(this); + const marketSideLiquidity = await marketLiquidityFnAsync(nativeOrders, amount, _opts); + let optimizerResult: OptimizerResult | undefined; + try { + optimizerResult = await this._generateOptimizedOrdersAsync(marketSideLiquidity, optimizerOpts); + } catch (e) { + // If no on-chain or off-chain Open Orderbook orders are present, a `NoOptimalPath` will be thrown. + // If this happens at this stage, there is still a chance that an RFQ order is fillable, therefore + // we catch the error and continue. + if (e.message !== AggregationError.NoOptimalPath) { + throw e; + } + } + + // If RFQ liquidity is enabled, make a request to check RFQ liquidity + const { rfqt } = _opts; + if ( + IS_PRICE_AWARE_RFQ_ENABLED && + rfqt && + rfqt.quoteRequestor && + marketSideLiquidity.quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) + ) { + // Calculate a suggested price. For now, this is simply the overall price of the aggregation. + let comparisonPrice: BigNumber | undefined; + if (optimizerResult) { + const totalMakerAmount = BigNumber.sum( + ...optimizerResult.optimizedOrders.map(order => order.makerAssetAmount), + ); + const totalTakerAmount = BigNumber.sum( + ...optimizerResult.optimizedOrders.map(order => order.takerAssetAmount), + ); + if (totalMakerAmount.gt(0)) { + const totalMakerAmountUnitAmount = Web3Wrapper.toUnitAmount( + totalMakerAmount, + marketSideLiquidity.makerTokenDecimals, + ); + const totalTakerAmountUnitAmount = Web3Wrapper.toUnitAmount( + totalTakerAmount, + marketSideLiquidity.takerTokenDecimals, + ); + comparisonPrice = totalMakerAmountUnitAmount + .div(totalTakerAmountUnitAmount) + .decimalPlaces(COMPARISON_PRICE_DECIMALS); + } + } + + // If we are making an indicative quote, make the RFQT request and then re-run the sampler if new orders come back. + if (rfqt.isIndicative) { + const indicativeQuotes = await getRfqtIndicativeQuotesAsync( + nativeOrders[0].makerAssetData, + nativeOrders[0].takerAssetData, + side, + amount, + comparisonPrice, + _opts, + ); + // Re-run optimizer with the new indicative quote + if (indicativeQuotes.length > 0) { + optimizerResult = await this._generateOptimizedOrdersAsync( + { + ...marketSideLiquidity, + rfqtIndicativeQuotes: indicativeQuotes, + }, + optimizerOpts, + ); + } + } else { + // A firm quote is being requested. Ensure that `intentOnFilling` is enabled. + if (rfqt.intentOnFilling) { + // Extra validation happens when requesting a firm quote, such as ensuring that the takerAddress + // is indeed valid. + if (!rfqt.takerAddress || rfqt.takerAddress === NULL_ADDRESS) { + throw new Error('RFQ-T requests must specify a taker address'); + } + const firmQuotes = await rfqt.quoteRequestor.requestRfqtFirmQuotesAsync( + nativeOrders[0].makerAssetData, + nativeOrders[0].takerAssetData, + amount, + side, + comparisonPrice, + rfqt, + ); + if (firmQuotes.length > 0) { + // Re-run optimizer with the new firm quote. This is the second and last time + // we run the optimized in a block of code. In this case, we don't catch a potential `NoOptimalPath` exception + // and we let it bubble up if it happens. + // + // NOTE: as of now, we assume that RFQ orders are 100% fillable because these are trusted market makers, therefore + // we do not perform an extra check to get fillable taker amounts. + optimizerResult = await this._generateOptimizedOrdersAsync( + { + ...marketSideLiquidity, + nativeOrders: marketSideLiquidity.nativeOrders.concat( + firmQuotes.map(quote => quote.signedOrder), + ), + orderFillableAmounts: marketSideLiquidity.orderFillableAmounts.concat( + firmQuotes.map(quote => quote.signedOrder.takerAssetAmount), + ), + }, + optimizerOpts, + ); + } + } + } + } + + // At this point we should have at least one valid optimizer result, therefore we manually raise + // `NoOptimalPath` if no optimizer result was ever set. + if (optimizerResult === undefined) { + throw new Error(AggregationError.NoOptimalPath); + } + + // Compute Quote Report and return the results. + let quoteReport: QuoteReport | undefined; + if (_opts.shouldGenerateQuoteReport) { + quoteReport = MarketOperationUtils._computeQuoteReport( + nativeOrders, + _opts.rfqt ? _opts.rfqt.quoteRequestor : undefined, + marketSideLiquidity, + optimizerResult, + ); + } + return { ...optimizerResult, quoteReport }; + } } // tslint:disable: max-file-line-count diff --git a/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts b/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts index 0a70ab1814..961590b37e 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts @@ -1,6 +1,8 @@ import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; +import { Omit } from '../../types'; + import { ZERO_AMOUNT } from './constants'; import { getTwoHopAdjustedRate } from './rate_utils'; import { @@ -41,7 +43,7 @@ export function getIntermediateTokens( * Returns the best two-hop quote and the fee-adjusted rate of that quote. */ export function getBestTwoHopQuote( - marketSideLiquidity: MarketSideLiquidity, + marketSideLiquidity: Omit, feeSchedule?: FeeSchedule, exchangeProxyOverhead?: ExchangeProxyOverhead, ): { quote: DexSample | undefined; adjustedRate: BigNumber } { diff --git a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts index d78c066e1a..29b1d6ba01 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts @@ -97,6 +97,15 @@ export function createSignedOrdersWithFillableAmounts( orders: SignedOrder[], fillableAmounts: BigNumber[], ): SignedOrderWithFillableAmounts[] { + // Quick safety check: ensures that orders maps perfectly to fillable amounts. + if (orders.length !== fillableAmounts.length) { + throw new Error( + `Number of orders was ${orders.length} but fillable amounts was ${ + fillableAmounts.length + }. This should never happen`, + ); + } + return orders .map((order: SignedOrder, i: number) => { const fillableAmount = fillableAmounts[i]; diff --git a/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts b/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts index 583a3965f4..fe097c31a8 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts @@ -93,6 +93,15 @@ export class SamplerOperations { return this._bancorService; } + public getTokenDecimals(makerTokenAddress: string, takerTokenAddress: string): BatchedOperation { + return new SamplerContractOperation({ + source: ERC20BridgeSource.Native, + contract: this._samplerContract, + function: this._samplerContract.getTokenDecimals, + params: [makerTokenAddress, takerTokenAddress], + }); + } + public getOrderFillableTakerAmounts(orders: SignedOrder[], exchangeAddress: string): BatchedOperation { return new SamplerContractOperation({ source: ERC20BridgeSource.Native, diff --git a/packages/asset-swapper/src/utils/market_operation_utils/types.ts b/packages/asset-swapper/src/utils/market_operation_utils/types.ts index 601eac2936..440dedb06f 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/types.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/types.ts @@ -6,6 +6,8 @@ import { RfqtRequestOpts, SignedOrderWithFillableAmounts } from '../../types'; import { QuoteRequestor } from '../../utils/quote_requestor'; import { QuoteReport } from '../quote_report_generator'; +import { SourceFilters } from './source_filters'; + /** * Order domain keys: chainId and exchange */ @@ -331,6 +333,9 @@ export interface OptimizerResult { optimizedOrders: OptimizedMarketOrder[]; sourceFlags: number; liquidityDelivered: CollapsedFill[] | DexSample; +} + +export interface OptimizerResultWithReport extends OptimizerResult { quoteReport?: QuoteReport; } @@ -353,8 +358,22 @@ export interface MarketSideLiquidity { ethToInputRate: BigNumber; rfqtIndicativeQuotes: RFQTIndicativeQuote[]; twoHopQuotes: Array>; + quoteSourceFilters: SourceFilters; + makerTokenDecimals: number; + takerTokenDecimals: number; } export interface TokenAdjacencyGraph { [token: string]: string[]; } + +export interface GenerateOptimizedOrdersOpts { + runLimit?: number; + bridgeSlippage?: number; + maxFallbackSlippage?: number; + excludedSources?: ERC20BridgeSource[]; + feeSchedule?: FeeSchedule; + exchangeProxyOverhead?: ExchangeProxyOverhead; + allowFallback?: boolean; + shouldBatchBridgeOrders?: boolean; +} diff --git a/packages/asset-swapper/src/utils/quote_requestor.ts b/packages/asset-swapper/src/utils/quote_requestor.ts index 83d48e9bfa..c2e5ea99a8 100644 --- a/packages/asset-swapper/src/utils/quote_requestor.ts +++ b/packages/asset-swapper/src/utils/quote_requestor.ts @@ -1,6 +1,6 @@ import { schemas, SchemaValidator } from '@0x/json-schemas'; import { assetDataUtils, orderCalculationUtils, SignedOrder } from '@0x/order-utils'; -import { RFQTFirmQuote, RFQTIndicativeQuote, TakerRequest } from '@0x/quote-server'; +import { RFQTFirmQuote, RFQTIndicativeQuote, TakerRequestQueryParams } from '@0x/quote-server'; import { ERC20AssetData } from '@0x/types'; import { BigNumber } from '@0x/utils'; import Axios, { AxiosInstance } from 'axios'; @@ -44,29 +44,6 @@ function getTokenAddressOrThrow(assetData: string): string { throw new Error(`Decoded asset data (${JSON.stringify(decodedAssetData)}) does not contain a token address`); } -function inferQueryParams( - marketOperation: MarketOperation, - makerAssetData: string, - takerAssetData: string, - assetFillAmount: BigNumber, -): Pick { - if (marketOperation === MarketOperation.Buy) { - return { - buyTokenAddress: getTokenAddressOrThrow(makerAssetData), - sellTokenAddress: getTokenAddressOrThrow(takerAssetData), - buyAmountBaseUnits: assetFillAmount, - sellAmountBaseUnits: undefined, - }; - } else { - return { - buyTokenAddress: getTokenAddressOrThrow(makerAssetData), - sellTokenAddress: getTokenAddressOrThrow(takerAssetData), - sellAmountBaseUnits: assetFillAmount, - buyAmountBaseUnits: undefined, - }; - } -} - function hasExpectedAssetData( expectedMakerAssetData: string, expectedTakerAssetData: string, @@ -111,6 +88,54 @@ export class QuoteRequestor { private readonly _schemaValidator: SchemaValidator = new SchemaValidator(); private readonly _orderSignatureToMakerUri: { [orderSignature: string]: string } = {}; + public static makeQueryParameters( + takerAddress: string, + marketOperation: MarketOperation, + makerAssetData: string, + takerAssetData: string, + assetFillAmount: BigNumber, + comparisonPrice?: BigNumber, + ): TakerRequestQueryParams { + const buyTokenAddress = getTokenAddressOrThrow(makerAssetData); + const sellTokenAddress = getTokenAddressOrThrow(takerAssetData); + const { buyAmountBaseUnits, sellAmountBaseUnits } = + marketOperation === MarketOperation.Buy + ? { + buyAmountBaseUnits: assetFillAmount, + sellAmountBaseUnits: undefined, + } + : { + sellAmountBaseUnits: assetFillAmount, + buyAmountBaseUnits: undefined, + }; + + const requestParamsWithBigNumbers: Pick< + TakerRequestQueryParams, + 'buyTokenAddress' | 'sellTokenAddress' | 'takerAddress' | 'comparisonPrice' + > = { + takerAddress, + comparisonPrice: comparisonPrice === undefined ? undefined : comparisonPrice.toString(), + buyTokenAddress, + sellTokenAddress, + }; + + // convert BigNumbers to strings + // so they are digestible by axios + if (sellAmountBaseUnits) { + return { + ...requestParamsWithBigNumbers, + sellAmountBaseUnits: sellAmountBaseUnits.toString(), + }; + } else if (buyAmountBaseUnits) { + return { + ...requestParamsWithBigNumbers, + buyAmountBaseUnits: buyAmountBaseUnits.toString(), + }; + } else { + throw new Error('Neither "buyAmountBaseUnits" or "sellAmountBaseUnits" were defined'); + } + } + constructor( private readonly _rfqtAssetOfferings: RfqtMakerAssetOfferings, private readonly _warningLogger: LogFunction = constants.DEFAULT_WARNING_LOGGER, @@ -125,6 +150,7 @@ export class QuoteRequestor { takerAssetData: string, assetFillAmount: BigNumber, marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, options: RfqtRequestOpts, ): Promise { const _opts: RfqtRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options }; @@ -143,6 +169,7 @@ export class QuoteRequestor { takerAssetData, assetFillAmount, marketOperation, + comparisonPrice, _opts, 'firm', ); @@ -213,6 +240,7 @@ export class QuoteRequestor { takerAssetData: string, assetFillAmount: BigNumber, marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, options: RfqtRequestOpts, ): Promise { const _opts: RfqtRequestOpts = { ...constants.DEFAULT_RFQT_REQUEST_OPTS, ...options }; @@ -231,6 +259,7 @@ export class QuoteRequestor { takerAssetData, assetFillAmount, marketOperation, + comparisonPrice, _opts, 'indicative', ); @@ -331,25 +360,18 @@ export class QuoteRequestor { takerAssetData: string, assetFillAmount: BigNumber, marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, options: RfqtRequestOpts, quoteType: 'firm' | 'indicative', ): Promise> { - const requestParamsWithBigNumbers = { - takerAddress: options.takerAddress, - ...inferQueryParams(marketOperation, makerAssetData, takerAssetData, assetFillAmount), - }; - - // convert BigNumbers to strings - // so they are digestible by axios - const requestParams = { - ...requestParamsWithBigNumbers, - sellAmountBaseUnits: requestParamsWithBigNumbers.sellAmountBaseUnits - ? requestParamsWithBigNumbers.sellAmountBaseUnits.toString() - : undefined, - buyAmountBaseUnits: requestParamsWithBigNumbers.buyAmountBaseUnits - ? requestParamsWithBigNumbers.buyAmountBaseUnits.toString() - : undefined, - }; + const requestParams = QuoteRequestor.makeQueryParameters( + options.takerAddress, + marketOperation, + makerAssetData, + takerAssetData, + assetFillAmount, + comparisonPrice, + ); const result: Array<{ response: ResponseT; makerUri: string }> = []; await Promise.all( diff --git a/packages/asset-swapper/test/contracts/native_order_sampler_test.ts b/packages/asset-swapper/test/contracts/native_order_sampler_test.ts index 021ce4e32e..93afd052a3 100644 --- a/packages/asset-swapper/test/contracts/native_order_sampler_test.ts +++ b/packages/asset-swapper/test/contracts/native_order_sampler_test.ts @@ -1,3 +1,4 @@ +import { artifacts as erc20Artifacts, DummyERC20TokenContract } from '@0x/contracts-erc20'; import { assertIntegerRoughlyEquals, blockchainTests, @@ -130,6 +131,36 @@ blockchainTests.resets('NativeOrderSampler contract', env => { .awaitTransactionSuccessAsync(); } + describe('getTokenDecimals()', () => { + it('correctly returns the token balances', async () => { + const newMakerToken = await DummyERC20TokenContract.deployFrom0xArtifactAsync( + erc20Artifacts.DummyERC20Token, + env.provider, + env.txDefaults, + artifacts, + constants.DUMMY_TOKEN_NAME, + constants.DUMMY_TOKEN_SYMBOL, + new BigNumber(18), + constants.DUMMY_TOKEN_TOTAL_SUPPLY, + ); + const newTakerToken = await DummyERC20TokenContract.deployFrom0xArtifactAsync( + erc20Artifacts.DummyERC20Token, + env.provider, + env.txDefaults, + artifacts, + constants.DUMMY_TOKEN_NAME, + constants.DUMMY_TOKEN_SYMBOL, + new BigNumber(6), + constants.DUMMY_TOKEN_TOTAL_SUPPLY, + ); + const [makerDecimals, takerDecimals] = await testContract + .getTokenDecimals(newMakerToken.address, newTakerToken.address) + .callAsync(); + expect(makerDecimals.toString()).to.eql('18'); + expect(takerDecimals.toString()).to.eql('6'); + }); + }); + describe('getOrderFillableTakerAmount()', () => { it('returns the full amount for a fully funded order', async () => { const order = createOrder(); diff --git a/packages/asset-swapper/test/market_operation_utils_test.ts b/packages/asset-swapper/test/market_operation_utils_test.ts index 3628329c81..3ec9351e91 100644 --- a/packages/asset-swapper/test/market_operation_utils_test.ts +++ b/packages/asset-swapper/test/market_operation_utils_test.ts @@ -1,3 +1,4 @@ +// tslint:disable: no-unbound-method import { getContractAddressesForChainOrThrow } from '@0x/contract-addresses'; import { assertRoughlyEquals, @@ -16,6 +17,7 @@ import * as _ from 'lodash'; import * as TypeMoq from 'typemoq'; import { MarketOperation, QuoteRequestor, RfqtRequestOpts, SignedOrderWithFillableAmounts } from '../src'; +import { IS_PRICE_AWARE_RFQ_ENABLED } from '../src/constants'; import { getRfqtIndicativeQuotesAsync, MarketOperationUtils } from '../src/utils/market_operation_utils/'; import { BalancerPoolsCache } from '../src/utils/market_operation_utils/balancer_utils'; import { @@ -29,7 +31,16 @@ import { CreamPoolsCache } from '../src/utils/market_operation_utils/cream_utils import { createFills } from '../src/utils/market_operation_utils/fills'; import { DexOrderSampler } from '../src/utils/market_operation_utils/sampler'; import { BATCH_SOURCE_FILTERS } from '../src/utils/market_operation_utils/sampler_operations'; -import { DexSample, ERC20BridgeSource, FillData, NativeFillData } from '../src/utils/market_operation_utils/types'; +import { SourceFilters } from '../src/utils/market_operation_utils/source_filters'; +import { + AggregationError, + DexSample, + ERC20BridgeSource, + FillData, + GenerateOptimizedOrdersOpts, + MarketSideLiquidity, + NativeFillData, +} from '../src/utils/market_operation_utils/types'; const MAKER_TOKEN = randomAddress(); const TAKER_TOKEN = randomAddress(); @@ -58,6 +69,34 @@ describe('MarketOperationUtils tests', () => { const CHAIN_ID = 1; const contractAddresses = { ...getContractAddressesForChainOrThrow(CHAIN_ID), multiBridge: NULL_ADDRESS }; + function getMockedQuoteRequestor( + type: 'indicative' | 'firm', + results: SignedOrder[], + verifiable: TypeMoq.Times, + ): TypeMoq.IMock { + const args: [any, any, any, any, any, any] = [ + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + ]; + const requestor = TypeMoq.Mock.ofType(QuoteRequestor, TypeMoq.MockBehavior.Loose, true); + if (type === 'firm') { + requestor + .setup(r => r.requestRfqtFirmQuotesAsync(...args)) + .returns(async () => results.map(result => ({ signedOrder: result }))) + .verifiable(verifiable); + } else { + requestor + .setup(r => r.requestRfqtIndicativeQuotesAsync(...args)) + .returns(async () => results) + .verifiable(verifiable); + } + return requestor; + } + function createOrder(overrides?: Partial): SignedOrder { return { chainId: CHAIN_ID, @@ -353,6 +392,10 @@ describe('MarketOperationUtils tests', () => { }; const DEFAULT_OPS = { + getTokenDecimals(_makerAddress: string, _takerAddress: string): BigNumber[] { + const result = new BigNumber(18); + return [result, result]; + }, getOrderFillableTakerAmounts(orders: SignedOrder[]): BigNumber[] { return orders.map(o => o.takerAssetAmount); }, @@ -447,6 +490,7 @@ describe('MarketOperationUtils tests', () => { TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), + TypeMoq.It.isAny(), ), ) .returns(() => Promise.resolve([])) @@ -456,6 +500,7 @@ describe('MarketOperationUtils tests', () => { TAKER_ASSET_DATA, MarketOperation.Sell, new BigNumber('100e18'), + undefined, { rfqt: { quoteRequestor: requestor.object, ...partialRfqt }, }, @@ -693,6 +738,412 @@ describe('MarketOperationUtils tests', () => { } }); + it( + 'getMarketSellOrdersAsync() optimizer will be called once only if RFQ if not defined', + IS_PRICE_AWARE_RFQ_ENABLED + ? async () => { + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + + // Ensure that `_generateOptimizedOrdersAsync` is only called once + mockedMarketOpUtils + .setup(m => m._generateOptimizedOrdersAsync(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(async (a, b) => mockedMarketOpUtils.target._generateOptimizedOrdersAsync(a, b)) + .verifiable(TypeMoq.Times.once()); + + const totalAssetAmount = ORDERS.map(o => o.takerAssetAmount).reduce((a, b) => a.plus(b)); + await mockedMarketOpUtils.object.getMarketSellOrdersAsync( + ORDERS, + totalAssetAmount, + DEFAULT_OPTS, + ); + mockedMarketOpUtils.verifyAll(); + } + : undefined, + ); + + it( + 'optimizer will send in a comparison price to RFQ providers', + IS_PRICE_AWARE_RFQ_ENABLED + ? async () => { + // Set up mocked quote requestor, will return an order that is better + // than the best of the orders. + const mockedQuoteRequestor = TypeMoq.Mock.ofType( + QuoteRequestor, + TypeMoq.MockBehavior.Loose, + false, + {}, + ); + + let requestedComparisonPrice: BigNumber | undefined; + mockedQuoteRequestor + .setup(mqr => + mqr.requestRfqtFirmQuotesAsync( + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + ), + ) + .callback( + ( + _makerAssetData: string, + _takerAssetData: string, + _assetFillAmount: BigNumber, + _marketOperation: MarketOperation, + comparisonPrice: BigNumber | undefined, + _options: RfqtRequestOpts, + ) => { + requestedComparisonPrice = comparisonPrice; + }, + ) + .returns(async () => { + return [ + { + signedOrder: createOrder({ + makerAssetData: MAKER_ASSET_DATA, + takerAssetData: TAKER_ASSET_DATA, + makerAssetAmount: Web3Wrapper.toBaseUnitAmount(321, 6), + takerAssetAmount: Web3Wrapper.toBaseUnitAmount(1, 18), + }), + }, + ]; + }); + + // Set up sampler, will only return 1 on-chain order + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + mockedMarketOpUtils + .setup(mou => + mou.getMarketSellLiquidityAsync( + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + ), + ) + .returns(async () => { + return { + dexQuotes: [], + ethToInputRate: Web3Wrapper.toBaseUnitAmount(1, 18), + ethToOutputRate: Web3Wrapper.toBaseUnitAmount(1, 6), + inputAmount: Web3Wrapper.toBaseUnitAmount(1, 18), + inputToken: MAKER_TOKEN, + outputToken: TAKER_TOKEN, + nativeOrders: [ + createOrder({ + makerAssetData: MAKER_ASSET_DATA, + takerAssetData: TAKER_ASSET_DATA, + makerAssetAmount: Web3Wrapper.toBaseUnitAmount(320, 6), + takerAssetAmount: Web3Wrapper.toBaseUnitAmount(1, 18), + }), + ], + orderFillableAmounts: [Web3Wrapper.toBaseUnitAmount(1, 18)], + rfqtIndicativeQuotes: [], + side: MarketOperation.Sell, + twoHopQuotes: [], + quoteSourceFilters: new SourceFilters(), + makerTokenDecimals: 6, + takerTokenDecimals: 18, + }; + }); + const result = await mockedMarketOpUtils.object.getMarketSellOrdersAsync( + ORDERS, + Web3Wrapper.toBaseUnitAmount(1, 18), + { + ...DEFAULT_OPTS, + rfqt: { + isIndicative: false, + apiKey: 'foo', + takerAddress: randomAddress(), + intentOnFilling: true, + quoteRequestor: { + requestRfqtFirmQuotesAsync: + mockedQuoteRequestor.object.requestRfqtFirmQuotesAsync, + } as any, + }, + }, + ); + expect(result.optimizedOrders.length).to.eql(1); + // tslint:disable-next-line:no-unnecessary-type-assertion + expect(requestedComparisonPrice!.toString()).to.eql('320'); + expect(result.optimizedOrders[0].makerAssetAmount.toString()).to.eql('321000000'); + expect(result.optimizedOrders[0].takerAssetAmount.toString()).to.eql('1000000000000000000'); + } + : undefined, + ); + + it( + 'getMarketSellOrdersAsync() will not rerun the optimizer if no orders are returned', + IS_PRICE_AWARE_RFQ_ENABLED + ? async () => { + // Ensure that `_generateOptimizedOrdersAsync` is only called once + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + mockedMarketOpUtils + .setup(m => m._generateOptimizedOrdersAsync(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(async (a, b) => mockedMarketOpUtils.target._generateOptimizedOrdersAsync(a, b)) + .verifiable(TypeMoq.Times.once()); + + const requestor = getMockedQuoteRequestor('firm', [], TypeMoq.Times.once()); + + const totalAssetAmount = ORDERS.map(o => o.takerAssetAmount).reduce((a, b) => a.plus(b)); + await mockedMarketOpUtils.object.getMarketSellOrdersAsync(ORDERS, totalAssetAmount, { + ...DEFAULT_OPTS, + rfqt: { + isIndicative: false, + apiKey: 'foo', + takerAddress: randomAddress(), + intentOnFilling: true, + quoteRequestor: { + requestRfqtFirmQuotesAsync: requestor.object.requestRfqtFirmQuotesAsync, + } as any, + }, + }); + mockedMarketOpUtils.verifyAll(); + requestor.verifyAll(); + } + : undefined, + ); + + it( + 'getMarketSellOrdersAsync() will rerun the optimizer if one or more indicative are returned', + IS_PRICE_AWARE_RFQ_ENABLED + ? async () => { + const requestor = getMockedQuoteRequestor( + 'indicative', + [ORDERS[0], ORDERS[1]], + TypeMoq.Times.once(), + ); + + const numOrdersInCall: number[] = []; + const numIndicativeQuotesInCall: number[] = []; + + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + mockedMarketOpUtils + .setup(m => m._generateOptimizedOrdersAsync(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback(async (msl: MarketSideLiquidity, _opts: GenerateOptimizedOrdersOpts) => { + numOrdersInCall.push(msl.nativeOrders.length); + numIndicativeQuotesInCall.push(msl.rfqtIndicativeQuotes.length); + }) + .returns(async (a, b) => mockedMarketOpUtils.target._generateOptimizedOrdersAsync(a, b)) + .verifiable(TypeMoq.Times.exactly(2)); + + const totalAssetAmount = ORDERS.map(o => o.takerAssetAmount).reduce((a, b) => a.plus(b)); + await mockedMarketOpUtils.object.getMarketSellOrdersAsync( + ORDERS.slice(2, ORDERS.length), + totalAssetAmount, + { + ...DEFAULT_OPTS, + rfqt: { + isIndicative: true, + apiKey: 'foo', + takerAddress: randomAddress(), + intentOnFilling: true, + quoteRequestor: { + requestRfqtIndicativeQuotesAsync: + requestor.object.requestRfqtIndicativeQuotesAsync, + } as any, + }, + }, + ); + mockedMarketOpUtils.verifyAll(); + requestor.verifyAll(); + + // The first and second optimizer call contains same number of RFQ orders. + expect(numOrdersInCall.length).to.eql(2); + expect(numOrdersInCall[0]).to.eql(1); + expect(numOrdersInCall[1]).to.eql(1); + + // The first call to optimizer will have no RFQ indicative quotes. The second call will have + // two indicative quotes. + expect(numIndicativeQuotesInCall.length).to.eql(2); + expect(numIndicativeQuotesInCall[0]).to.eql(0); + expect(numIndicativeQuotesInCall[1]).to.eql(2); + } + : undefined, + ); + + it( + 'getMarketSellOrdersAsync() will rerun the optimizer if one or more RFQ orders are returned', + IS_PRICE_AWARE_RFQ_ENABLED + ? async () => { + const requestor = getMockedQuoteRequestor('firm', [ORDERS[0]], TypeMoq.Times.once()); + + // Ensure that `_generateOptimizedOrdersAsync` is only called once + + // TODO: Ensure fillable amounts increase too + const numOrdersInCall: number[] = []; + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + mockedMarketOpUtils + .setup(m => m._generateOptimizedOrdersAsync(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback(async (msl: MarketSideLiquidity, _opts: GenerateOptimizedOrdersOpts) => { + numOrdersInCall.push(msl.nativeOrders.length); + }) + .returns(async (a, b) => mockedMarketOpUtils.target._generateOptimizedOrdersAsync(a, b)) + .verifiable(TypeMoq.Times.exactly(2)); + + const totalAssetAmount = ORDERS.map(o => o.takerAssetAmount).reduce((a, b) => a.plus(b)); + await mockedMarketOpUtils.object.getMarketSellOrdersAsync( + ORDERS.slice(1, ORDERS.length), + totalAssetAmount, + { + ...DEFAULT_OPTS, + rfqt: { + isIndicative: false, + apiKey: 'foo', + takerAddress: randomAddress(), + intentOnFilling: true, + quoteRequestor: { + requestRfqtFirmQuotesAsync: requestor.object.requestRfqtFirmQuotesAsync, + } as any, + }, + }, + ); + mockedMarketOpUtils.verifyAll(); + requestor.verifyAll(); + expect(numOrdersInCall.length).to.eql(2); + + // The first call to optimizer was without an RFQ order. + // The first call to optimizer was with an extra RFQ order. + expect(numOrdersInCall[0]).to.eql(2); + expect(numOrdersInCall[1]).to.eql(3); + } + : undefined, + ); + + it( + 'getMarketSellOrdersAsync() will not raise a NoOptimalPath error if no initial path was found during on-chain DEX optimization, but a path was found after RFQ optimization', + IS_PRICE_AWARE_RFQ_ENABLED + ? async () => { + let hasFirstOptimizationRun = false; + let hasSecondOptimizationRun = false; + const requestor = getMockedQuoteRequestor( + 'firm', + [ORDERS[0], ORDERS[1]], + TypeMoq.Times.once(), + ); + + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + mockedMarketOpUtils + .setup(m => m._generateOptimizedOrdersAsync(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(async (msl: MarketSideLiquidity, _opts: GenerateOptimizedOrdersOpts) => { + if (msl.nativeOrders.length === 1) { + hasFirstOptimizationRun = true; + throw new Error(AggregationError.NoOptimalPath); + } else if (msl.nativeOrders.length === 3) { + hasSecondOptimizationRun = true; + return mockedMarketOpUtils.target._generateOptimizedOrdersAsync(msl, _opts); + } else { + throw new Error('Invalid path. this error message should never appear'); + } + }) + .verifiable(TypeMoq.Times.exactly(2)); + + const totalAssetAmount = ORDERS.map(o => o.takerAssetAmount).reduce((a, b) => a.plus(b)); + await mockedMarketOpUtils.object.getMarketSellOrdersAsync( + ORDERS.slice(2, ORDERS.length), + totalAssetAmount, + { + ...DEFAULT_OPTS, + rfqt: { + isIndicative: false, + apiKey: 'foo', + takerAddress: randomAddress(), + intentOnFilling: true, + quoteRequestor: { + requestRfqtFirmQuotesAsync: requestor.object.requestRfqtFirmQuotesAsync, + } as any, + }, + }, + ); + mockedMarketOpUtils.verifyAll(); + requestor.verifyAll(); + + expect(hasFirstOptimizationRun).to.eql(true); + expect(hasSecondOptimizationRun).to.eql(true); + } + : undefined, + ); + + it('getMarketSellOrdersAsync() will raise a NoOptimalPath error if no path was found during on-chain DEX optimization and RFQ optimization', async () => { + const mockedMarketOpUtils = TypeMoq.Mock.ofType( + MarketOperationUtils, + TypeMoq.MockBehavior.Loose, + false, + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + ); + mockedMarketOpUtils.callBase = true; + mockedMarketOpUtils + .setup(m => m._generateOptimizedOrdersAsync(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(async (msl: MarketSideLiquidity, _opts: GenerateOptimizedOrdersOpts) => { + throw new Error(AggregationError.NoOptimalPath); + }) + .verifiable(TypeMoq.Times.exactly(1)); + + try { + await mockedMarketOpUtils.object.getMarketSellOrdersAsync( + ORDERS.slice(2, ORDERS.length), + ORDERS[0].takerAssetAmount, + DEFAULT_OPTS, + ); + expect.fail(`Call should have thrown "${AggregationError.NoOptimalPath}" but instead succeded`); + } catch (e) { + if (e.message !== AggregationError.NoOptimalPath) { + expect.fail(e); + } + } + mockedMarketOpUtils.verifyAll(); + }); + it('generates bridge orders with correct taker amount', async () => { const improvedOrdersResponse = await marketOperationUtils.getMarketSellOrdersAsync( // Pass in empty orders to prevent native orders from being used. diff --git a/packages/asset-swapper/test/quote_requestor_test.ts b/packages/asset-swapper/test/quote_requestor_test.ts index afdd3e0fc6..a30e2bd658 100644 --- a/packages/asset-swapper/test/quote_requestor_test.ts +++ b/packages/asset-swapper/test/quote_requestor_test.ts @@ -1,5 +1,6 @@ import { tokenUtils } from '@0x/dev-utils'; import { assetDataUtils } from '@0x/order-utils'; +import { TakerRequestQueryParams } from '@0x/quote-server'; import { StatusCodes } from '@0x/types'; import { BigNumber } from '@0x/utils'; import * as chai from 'chai'; @@ -35,11 +36,11 @@ describe('QuoteRequestor', async () => { // Set up RFQT responses // tslint:disable-next-line:array-type const mockedRequests: MockedRfqtFirmQuoteResponse[] = []; - const expectedParams = { + const expectedParams: TakerRequestQueryParams = { sellTokenAddress: takerToken, buyTokenAddress: makerToken, sellAmountBaseUnits: '10000', - buyAmountBaseUnits: undefined, + comparisonPrice: undefined, takerAddress, }; // Successful response @@ -174,6 +175,7 @@ describe('QuoteRequestor', async () => { takerAssetData, new BigNumber(10000), MarketOperation.Sell, + undefined, { apiKey, takerAddress, @@ -189,6 +191,17 @@ describe('QuoteRequestor', async () => { }); }); describe('requestRfqtIndicativeQuotesAsync for Indicative quotes', async () => { + it('should optionally accept a "comparisonPrice" parameter', async () => { + const response = QuoteRequestor.makeQueryParameters( + otherToken1, + MarketOperation.Sell, + makerAssetData, + takerAssetData, + new BigNumber(1000), + new BigNumber(300.2), + ); + expect(response.comparisonPrice).to.eql('300.2'); + }); it('should return successful RFQT requests', async () => { const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a'; const apiKey = 'my-ko0l-api-key'; @@ -196,11 +209,11 @@ describe('QuoteRequestor', async () => { // Set up RFQT responses // tslint:disable-next-line:array-type const mockedRequests: MockedRfqtIndicativeQuoteResponse[] = []; - const expectedParams = { + const expectedParams: TakerRequestQueryParams = { sellTokenAddress: takerToken, buyTokenAddress: makerToken, sellAmountBaseUnits: '10000', - buyAmountBaseUnits: undefined, + comparisonPrice: undefined, takerAddress, }; // Successful response @@ -276,6 +289,7 @@ describe('QuoteRequestor', async () => { takerAssetData, new BigNumber(10000), MarketOperation.Sell, + undefined, { apiKey, takerAddress, @@ -294,11 +308,11 @@ describe('QuoteRequestor', async () => { // Set up RFQT responses // tslint:disable-next-line:array-type const mockedRequests: MockedRfqtIndicativeQuoteResponse[] = []; - const expectedParams = { + const expectedParams: TakerRequestQueryParams = { sellTokenAddress: takerToken, buyTokenAddress: makerToken, buyAmountBaseUnits: '10000', - sellAmountBaseUnits: undefined, + comparisonPrice: undefined, takerAddress, }; // Successful response @@ -326,6 +340,7 @@ describe('QuoteRequestor', async () => { takerAssetData, new BigNumber(10000), MarketOperation.Buy, + undefined, { apiKey, takerAddress, diff --git a/yarn.lock b/yarn.lock index c98c05e5d2..52eb853fac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -748,9 +748,10 @@ uuid "^3.3.2" websocket "^1.0.29" -"@0x/quote-server@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@0x/quote-server/-/quote-server-2.0.2.tgz#60d0665c1cad378c9abb89b5491bdc55b4c8412c" +"@0x/quote-server@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@0x/quote-server/-/quote-server-3.1.0.tgz#ba5c0de9f88fedfd522ec1ef608dd8eebb868509" + integrity sha512-o9n7wE9XmV/YMjAcIt3EJMnc0xony8VhqNtO7dGAREi/WQxJBlNAHNZxu4wQ0wV03wroH58eJTOpn4fk+kuXqQ== dependencies: "@0x/json-schemas" "^5.0.7" "@0x/order-utils" "^10.2.4"