diff --git a/packages/asset-swapper/src/index.ts b/packages/asset-swapper/src/index.ts index 184f15066e..db5bef6e7f 100644 --- a/packages/asset-swapper/src/index.ts +++ b/packages/asset-swapper/src/index.ts @@ -84,7 +84,7 @@ export { export { artifacts } from './artifacts'; export { InsufficientAssetLiquidityError } from './errors'; export { SwapQuoteConsumer } from './quote_consumers/swap_quote_consumer'; -export { getSwapMinBuyAmount } from './quote_consumers/utils'; +export { getSwapMinBuyAmount, getQuoteInfoMinBuyAmount } from './quote_consumers/utils'; export { SwapQuoter } from './swap_quoter'; export { AffiliateFee, diff --git a/packages/asset-swapper/src/quote_consumers/utils.ts b/packages/asset-swapper/src/quote_consumers/utils.ts index b085b20dbb..e257c061e2 100644 --- a/packages/asset-swapper/src/quote_consumers/utils.ts +++ b/packages/asset-swapper/src/quote_consumers/utils.ts @@ -1,8 +1,8 @@ import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; -import { MarketOperation, SwapQuote } from '../types'; -import { ERC20BridgeSource } from '../utils/market_operation_utils/types'; +import { MarketOperation, SwapQuote, SwapQuoteInfo } from '../types'; +import { ERC20BridgeSource, OptimizedMarketOrder } from '../utils/market_operation_utils/types'; /** * Compute the minimum buy token amount for market operations by inferring @@ -31,3 +31,34 @@ export function getSwapMinBuyAmount(quote: SwapQuote): BigNumber { } return quote.bestCaseQuoteInfo.makerAssetAmount.times(slipRatio).integerValue(BigNumber.ROUND_DOWN); } + +/** + * Same as `getSwapMinBuyAmount` but operates + * on a single quote info instead of using best and worst case + * Orders must be derived from the same path as the quote info + */ +export function getQuoteInfoMinBuyAmount( + quoteInfo: SwapQuoteInfo, + orders: OptimizedMarketOrder[], + marketOperation: MarketOperation, +): BigNumber { + if (marketOperation === MarketOperation.Buy) { + return quoteInfo.makerAssetAmount; + } + let slipRatio = new BigNumber(1); + // Infer the allowed maker asset slippage from any non-native order. + for (const o of orders) { + if (o.fills.length === 0 || o.fills[0].source === ERC20BridgeSource.Native) { + // No slippage on native orders. + continue; + } + const totalFillMakerAssetAmount = BigNumber.sum(...o.fills.map(f => f.output)); + slipRatio = o.fillableMakerAssetAmount.div(totalFillMakerAssetAmount); + break; + } + if (slipRatio.gte(1)) { + // No slippage allowed across all orders. + return quoteInfo.makerAssetAmount; + } + return quoteInfo.makerAssetAmount.times(slipRatio).integerValue(BigNumber.ROUND_DOWN); +} diff --git a/packages/asset-swapper/src/types.ts b/packages/asset-swapper/src/types.ts index 8b038ee587..5cd4ec1966 100644 --- a/packages/asset-swapper/src/types.ts +++ b/packages/asset-swapper/src/types.ts @@ -187,6 +187,8 @@ export interface SwapQuoteBase { worstCaseQuoteInfo: SwapQuoteInfo; sourceBreakdown: SwapQuoteOrdersBreakdown; quoteReport?: QuoteReport; + unoptimizedQuoteInfo: SwapQuoteInfo; + unoptimizedOrders: OptimizedMarketOrder[]; isTwoHop: boolean; makerTokenDecimals: number; takerTokenDecimals: number; 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 ed9889d420..e587541f5b 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/index.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/index.ts @@ -26,7 +26,7 @@ import { createSignedOrdersWithFillableAmounts, getNativeOrderTokens, } from './orders'; -import { findOptimalPathAsync } from './path_optimizer'; +import { fillsToSortedPaths, findOptimalPathAsync } from './path_optimizer'; import { DexOrderSampler, getSampleAmounts } from './sampler'; import { SourceFilters } from './source_filters'; import { @@ -543,6 +543,10 @@ export class MarketOperationUtils { exchangeProxyOverhead: opts.exchangeProxyOverhead || (() => ZERO_AMOUNT), }; + // Find the unoptimized best rate to calculate savings from optimizer + const unoptimizedPath = fillsToSortedPaths(fills, side, inputAmount, optimizerOpts)[0].collapse(orderOpts); + + // Find the optimal path const optimalPath = await findOptimalPathAsync(side, fills, inputAmount, opts.runLimit, optimizerOpts); const optimalPathRate = optimalPath ? optimalPath.adjustedRate() : ZERO_AMOUNT; @@ -559,6 +563,7 @@ export class MarketOperationUtils { sourceFlags: SOURCE_FLAGS[ERC20BridgeSource.MultiHop], marketSideLiquidity, adjustedRate: bestTwoHopRate, + unoptimizedPath, }; } @@ -591,6 +596,7 @@ export class MarketOperationUtils { sourceFlags: collapsedPath.sourceFlags, marketSideLiquidity, adjustedRate: optimalPathRate, + unoptimizedPath, }; } diff --git a/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts b/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts index 5f0ce4b679..13f94dd2e6 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts @@ -21,14 +21,13 @@ export async function findOptimalPathAsync( runLimit: number = 2 ** 8, opts: PathPenaltyOpts = DEFAULT_PATH_PENALTY_OPTS, ): Promise { - const rates = rateBySourcePathId(side, fills, targetInput); - const paths = fills.map(singleSourceFills => Path.create(side, singleSourceFills, targetInput, opts)); // Sort fill arrays by descending adjusted completed rate. - const sortedPaths = paths.sort((a, b) => b.adjustedCompleteRate().comparedTo(a.adjustedCompleteRate())); + const sortedPaths = fillsToSortedPaths(fills, side, targetInput, opts); if (sortedPaths.length === 0) { return undefined; } let optimalPath = sortedPaths[0]; + const rates = rateBySourcePathId(side, fills, targetInput); for (const [i, path] of sortedPaths.slice(1).entries()) { optimalPath = mixPaths(side, optimalPath, path, targetInput, runLimit * RUN_LIMIT_DECAY_FACTOR ** i, rates); // Yield to event loop. @@ -37,6 +36,18 @@ export async function findOptimalPathAsync( return optimalPath.isComplete() ? optimalPath : undefined; } +// Sort fill arrays by descending adjusted completed rate. +export function fillsToSortedPaths( + fills: Fill[][], + side: MarketOperation, + targetInput: BigNumber, + opts: PathPenaltyOpts, +): Path[] { + const paths = fills.map(singleSourceFills => Path.create(side, singleSourceFills, targetInput, opts)); + const sortedPaths = paths.sort((a, b) => b.adjustedCompleteRate().comparedTo(a.adjustedCompleteRate())); + return sortedPaths; +} + function mixPaths( side: MarketOperation, pathA: Path, 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 536f8988ec..0eeea4a000 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,7 @@ import { RfqtFirmQuoteValidator, RfqtRequestOpts, SignedOrderWithFillableAmounts import { QuoteRequestor } from '../../utils/quote_requestor'; import { QuoteReport } from '../quote_report_generator'; +import { CollapsedPath } from './path'; import { SourceFilters } from './source_filters'; /** @@ -342,6 +343,7 @@ export interface OptimizerResult { liquidityDelivered: CollapsedFill[] | DexSample; marketSideLiquidity: MarketSideLiquidity; adjustedRate: BigNumber; + unoptimizedPath: CollapsedPath; } export interface OptimizerResultWithReport extends OptimizerResult { diff --git a/packages/asset-swapper/src/utils/swap_quote_calculator.ts b/packages/asset-swapper/src/utils/swap_quote_calculator.ts index 4c1edf1b6b..fc0778133c 100644 --- a/packages/asset-swapper/src/utils/swap_quote_calculator.ts +++ b/packages/asset-swapper/src/utils/swap_quote_calculator.ts @@ -22,8 +22,8 @@ import { FillData, GetMarketOrdersOpts, OptimizedMarketOrder, + OptimizerResultWithReport, } from './market_operation_utils/types'; -import { QuoteReport } from './quote_report_generator'; import { QuoteFillResult, simulateBestCaseFill, simulateWorstCaseFill } from './quote_simulation'; import { getTokenFromAssetData, isSupportedAssetDataInOrders } from './utils'; @@ -98,15 +98,13 @@ export class SwapQuoteCalculator { if (result) { const { makerAssetData, takerAssetData } = batchPrunedOrders[i][0]; return createSwapQuote( + result, makerAssetData, takerAssetData, - result.optimizedOrders, operation, assetFillAmounts[i], gasPrice, opts.gasSchedule, - result.marketSideLiquidity.makerTokenDecimals, - result.marketSideLiquidity.takerTokenDecimals, ); } else { return undefined; @@ -128,12 +126,6 @@ export class SwapQuoteCalculator { } // since prunedOrders do not have fillState, we will add a buffer of fillable orders to consider that some native are orders are partially filled - let optimizedOrders: OptimizedMarketOrder[]; - let quoteReport: QuoteReport | undefined; - let sourceFlags: number = 0; - let makerTokenDecimals: number; - let takerTokenDecimals: number; - // Scale fees by gas price. const _opts: GetMarketOrdersOpts = { ...opts, @@ -148,119 +140,122 @@ export class SwapQuoteCalculator { ? await this._marketOperationUtils.getMarketBuyOrdersAsync(prunedOrders, assetFillAmount, _opts) : await this._marketOperationUtils.getMarketSellOrdersAsync(prunedOrders, assetFillAmount, _opts); - optimizedOrders = result.optimizedOrders; - quoteReport = result.quoteReport; - sourceFlags = result.sourceFlags; - makerTokenDecimals = result.marketSideLiquidity.makerTokenDecimals; - takerTokenDecimals = result.marketSideLiquidity.takerTokenDecimals; - - // assetData information for the result const { makerAssetData, takerAssetData } = prunedOrders[0]; - const swapQuote = - sourceFlags === SOURCE_FLAGS[ERC20BridgeSource.MultiHop] - ? createTwoHopSwapQuote( - makerAssetData, - takerAssetData, - optimizedOrders, - operation, - assetFillAmount, - gasPrice, - opts.gasSchedule, - makerTokenDecimals, - takerTokenDecimals, - quoteReport, - ) - : createSwapQuote( - makerAssetData, - takerAssetData, - optimizedOrders, - operation, - assetFillAmount, - gasPrice, - opts.gasSchedule, - makerTokenDecimals, - takerTokenDecimals, - quoteReport, - ); + const swapQuote = createSwapQuote( + result, + makerAssetData, + takerAssetData, + operation, + assetFillAmount, + gasPrice, + opts.gasSchedule, + ); + // Use the raw gas, not scaled by gas price - const exchangeProxyOverhead = opts.exchangeProxyOverhead(sourceFlags).toNumber(); + const exchangeProxyOverhead = opts.exchangeProxyOverhead(result.sourceFlags).toNumber(); swapQuote.bestCaseQuoteInfo.gas += exchangeProxyOverhead; swapQuote.worstCaseQuoteInfo.gas += exchangeProxyOverhead; + swapQuote.unoptimizedQuoteInfo.gas += exchangeProxyOverhead; + return swapQuote; } } function createSwapQuote( + optimizerResult: OptimizerResultWithReport, makerAssetData: string, takerAssetData: string, - optimizedOrders: OptimizedMarketOrder[], operation: MarketOperation, assetFillAmount: BigNumber, gasPrice: BigNumber, gasSchedule: FeeSchedule, - makerTokenDecimals: number, - takerTokenDecimals: number, - quoteReport?: QuoteReport, ): SwapQuote { - const bestCaseFillResult = simulateBestCaseFill({ - gasPrice, - orders: optimizedOrders, - side: operation, - fillAmount: assetFillAmount, - opts: { gasSchedule }, - }); + const { optimizedOrders, quoteReport, sourceFlags, unoptimizedPath } = optimizerResult; + const isTwoHop = sourceFlags === SOURCE_FLAGS[ERC20BridgeSource.MultiHop]; - const worstCaseFillResult = simulateWorstCaseFill({ + // Calculate quote info + const { bestCaseQuoteInfo, worstCaseQuoteInfo, sourceBreakdown } = isTwoHop + ? calculateTwoHopQuoteInfo(optimizedOrders, operation, gasSchedule) + : calculateQuoteInfo(optimizedOrders, operation, assetFillAmount, gasPrice, gasSchedule); + + // Calculate the unoptimised alternative + const unoptimizedFillResult = simulateBestCaseFill({ gasPrice, - orders: optimizedOrders, + orders: unoptimizedPath.orders, side: operation, fillAmount: assetFillAmount, opts: { gasSchedule }, }); + const unoptimizedQuoteInfo = fillResultsToQuoteInfo(unoptimizedFillResult); - const quoteBase = { - takerAssetData, + // Put together the swap quote + const { makerTokenDecimals, takerTokenDecimals } = optimizerResult.marketSideLiquidity; + const swapQuote = { makerAssetData, + takerAssetData, gasPrice, - bestCaseQuoteInfo: fillResultsToQuoteInfo(bestCaseFillResult), - worstCaseQuoteInfo: fillResultsToQuoteInfo(worstCaseFillResult), - sourceBreakdown: getSwapQuoteOrdersBreakdown(bestCaseFillResult.fillAmountBySource), orders: optimizedOrders, + bestCaseQuoteInfo, + worstCaseQuoteInfo, + unoptimizedQuoteInfo, + unoptimizedOrders: unoptimizedPath.orders, + sourceBreakdown, + makerTokenDecimals, + takerTokenDecimals, quoteReport, - isTwoHop: false, + isTwoHop, }; if (operation === MarketOperation.Buy) { return { - ...quoteBase, + ...swapQuote, type: MarketOperation.Buy, makerAssetFillAmount: assetFillAmount, - makerTokenDecimals, - takerTokenDecimals, }; } else { return { - ...quoteBase, + ...swapQuote, type: MarketOperation.Sell, takerAssetFillAmount: assetFillAmount, - makerTokenDecimals, - takerTokenDecimals, }; } } -function createTwoHopSwapQuote( - makerAssetData: string, - takerAssetData: string, +function calculateQuoteInfo( optimizedOrders: OptimizedMarketOrder[], operation: MarketOperation, assetFillAmount: BigNumber, gasPrice: BigNumber, gasSchedule: FeeSchedule, - makerTokenDecimals: number, - takerTokenDecimals: number, - quoteReport?: QuoteReport, -): SwapQuote { +): { bestCaseQuoteInfo: SwapQuoteInfo; worstCaseQuoteInfo: SwapQuoteInfo; sourceBreakdown: SwapQuoteOrdersBreakdown } { + const bestCaseFillResult = simulateBestCaseFill({ + gasPrice, + orders: optimizedOrders, + side: operation, + fillAmount: assetFillAmount, + opts: { gasSchedule }, + }); + + const worstCaseFillResult = simulateWorstCaseFill({ + gasPrice, + orders: optimizedOrders, + side: operation, + fillAmount: assetFillAmount, + opts: { gasSchedule }, + }); + + return { + bestCaseQuoteInfo: fillResultsToQuoteInfo(bestCaseFillResult), + worstCaseQuoteInfo: fillResultsToQuoteInfo(worstCaseFillResult), + sourceBreakdown: getSwapQuoteOrdersBreakdown(bestCaseFillResult.fillAmountBySource), + }; +} + +function calculateTwoHopQuoteInfo( + optimizedOrders: OptimizedMarketOrder[], + operation: MarketOperation, + gasSchedule: FeeSchedule, +): { bestCaseQuoteInfo: SwapQuoteInfo; worstCaseQuoteInfo: SwapQuoteInfo; sourceBreakdown: SwapQuoteOrdersBreakdown } { const [firstHopOrder, secondHopOrder] = optimizedOrders; const [firstHopFill] = firstHopOrder.fills; const [secondHopFill] = secondHopOrder.fills; @@ -271,10 +266,7 @@ function createTwoHopSwapQuote( }), ).toNumber(); - const quoteBase = { - takerAssetData, - makerAssetData, - gasPrice, + return { bestCaseQuoteInfo: { makerAssetAmount: operation === MarketOperation.Sell ? secondHopFill.output : secondHopFill.input, takerAssetAmount: operation === MarketOperation.Sell ? firstHopFill.input : firstHopFill.output, @@ -298,28 +290,7 @@ function createTwoHopSwapQuote( hops: [firstHopFill.source, secondHopFill.source], }, }, - orders: optimizedOrders, - quoteReport, - isTwoHop: true, }; - - if (operation === MarketOperation.Buy) { - return { - ...quoteBase, - type: MarketOperation.Buy, - makerAssetFillAmount: assetFillAmount, - makerTokenDecimals, - takerTokenDecimals, - }; - } else { - return { - ...quoteBase, - type: MarketOperation.Sell, - takerAssetFillAmount: assetFillAmount, - makerTokenDecimals, - takerTokenDecimals, - }; - } } function getSwapQuoteOrdersBreakdown(fillAmountBySource: { [source: string]: BigNumber }): SwapQuoteOrdersBreakdown { diff --git a/packages/asset-swapper/test/utils/swap_quote.ts b/packages/asset-swapper/test/utils/swap_quote.ts index 4512d6ead6..7634afb3e4 100644 --- a/packages/asset-swapper/test/utils/swap_quote.ts +++ b/packages/asset-swapper/test/utils/swap_quote.ts @@ -37,6 +37,8 @@ export async function getFullyFillableSwapQuoteWithNoFeesAsync( gasPrice, bestCaseQuoteInfo: quoteInfo, worstCaseQuoteInfo: quoteInfo, + unoptimizedQuoteInfo: quoteInfo, + unoptimizedOrders: orders.map(order => ({ ...order, fills: [] })), sourceBreakdown: breakdown, isTwoHop: false, };