From da1e9c2d976a8138cedf8ccc2173f98b91ef3afc Mon Sep 17 00:00:00 2001 From: Lawrence Forman Date: Wed, 1 Apr 2020 12:52:02 -0400 Subject: [PATCH] `@0x/asset-swapper`: Add DFB support + refactor swap quote calculation utils --- packages/asset-swapper/CHANGELOG.json | 4 + packages/asset-swapper/src/swap_quoter.ts | 2 +- .../utils/market_operation_utils/constants.ts | 1 + .../src/utils/market_operation_utils/fills.ts | 16 +- .../src/utils/market_operation_utils/index.ts | 5 + .../utils/market_operation_utils/orders.ts | 144 +++++-- .../src/utils/market_operation_utils/types.ts | 23 +- .../src/utils/protocol_fee_utils.ts | 18 - .../src/utils/quote_simulation.ts | 344 ++++++++++++++++ .../src/utils/swap_quote_calculator.ts | 378 +++--------------- .../test/exchange_swap_quote_consumer_test.ts | 5 - .../forwarder_swap_quote_consumer_test.ts | 6 - .../test/market_operation_utils_test.ts | 94 ++++- .../test/swap_quote_consumer_utils_test.ts | 7 - packages/asset-swapper/test/utils/mocks.ts | 4 - .../asset-swapper/test/utils/swap_quote.ts | 5 +- 16 files changed, 628 insertions(+), 428 deletions(-) create mode 100644 packages/asset-swapper/src/utils/quote_simulation.ts diff --git a/packages/asset-swapper/CHANGELOG.json b/packages/asset-swapper/CHANGELOG.json index 21f2552386..0ba1b4975d 100644 --- a/packages/asset-swapper/CHANGELOG.json +++ b/packages/asset-swapper/CHANGELOG.json @@ -37,6 +37,10 @@ { "note": "Fix `getBatchMarketBuyOrdersAsync` throwing NO_OPTIMAL_PATH", "pr": 2533 + }, + { + "note": "Add DFB support + refactor swap quote calculator utils", + "pr": 2536 } ] }, diff --git a/packages/asset-swapper/src/swap_quoter.ts b/packages/asset-swapper/src/swap_quoter.ts index 1ddbe95ad2..d87bef6a5b 100644 --- a/packages/asset-swapper/src/swap_quoter.ts +++ b/packages/asset-swapper/src/swap_quoter.ts @@ -179,7 +179,7 @@ export class SwapQuoter { }, liquidityProviderRegistryAddress, ); - this._swapQuoteCalculator = new SwapQuoteCalculator(this._protocolFeeUtils, this._marketOperationUtils); + this._swapQuoteCalculator = new SwapQuoteCalculator(this._marketOperationUtils); } /** 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 6a37a77850..392edbe432 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts @@ -34,6 +34,7 @@ export const DEFAULT_GET_MARKET_ORDERS_OPTS: GetMarketOrdersOpts = { feeSchedule: {}, gasSchedule: {}, allowFallback: true, + shouldBatchBridgeOrders: true, }; /** diff --git a/packages/asset-swapper/src/utils/market_operation_utils/fills.ts b/packages/asset-swapper/src/utils/market_operation_utils/fills.ts index 11a472caef..a5090acb3a 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/fills.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/fills.ts @@ -233,27 +233,25 @@ export function clipPathToInput(path: Fill[], targetInput: BigNumber = POSITIVE_ return clipped; } -export function collapsePath(side: MarketOperation, path: Fill[]): CollapsedFill[] { +export function collapsePath(path: Fill[]): CollapsedFill[] { const collapsed: Array = []; for (const fill of path) { - const makerAssetAmount = side === MarketOperation.Sell ? fill.output : fill.input; - const takerAssetAmount = side === MarketOperation.Sell ? fill.input : fill.output; const source = fill.source; if (collapsed.length !== 0 && source !== ERC20BridgeSource.Native) { const prevFill = collapsed[collapsed.length - 1]; // If the last fill is from the same source, merge them. if (prevFill.source === source) { - prevFill.totalMakerAssetAmount = prevFill.totalMakerAssetAmount.plus(makerAssetAmount); - prevFill.totalTakerAssetAmount = prevFill.totalTakerAssetAmount.plus(takerAssetAmount); - prevFill.subFills.push({ makerAssetAmount, takerAssetAmount }); + prevFill.input = prevFill.input.plus(fill.input); + prevFill.output = prevFill.output.plus(fill.output); + prevFill.subFills.push(fill); continue; } } collapsed.push({ source: fill.source, - totalMakerAssetAmount: makerAssetAmount, - totalTakerAssetAmount: takerAssetAmount, - subFills: [{ makerAssetAmount, takerAssetAmount }], + input: fill.input, + output: fill.output, + subFills: [fill], nativeOrder: fill.source === ERC20BridgeSource.Native ? (fill.fillData as NativeFillData).order : undefined, }); } 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 5f8ed0b3ec..371fcce13f 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/index.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/index.ts @@ -107,6 +107,7 @@ export class MarketOperationUtils { excludedSources: _opts.excludedSources, feeSchedule: _opts.feeSchedule, allowFallback: _opts.allowFallback, + shouldBatchBridgeOrders: _opts.shouldBatchBridgeOrders, }); } @@ -180,6 +181,7 @@ export class MarketOperationUtils { excludedSources: _opts.excludedSources, feeSchedule: _opts.feeSchedule, allowFallback: _opts.allowFallback, + shouldBatchBridgeOrders: _opts.shouldBatchBridgeOrders, }); } @@ -254,6 +256,7 @@ export class MarketOperationUtils { excludedSources: _opts.excludedSources, feeSchedule: _opts.feeSchedule, allowFallback: _opts.allowFallback, + shouldBatchBridgeOrders: _opts.shouldBatchBridgeOrders, }); } catch (e) { // It's possible for one of the pairs to have no path @@ -278,6 +281,7 @@ export class MarketOperationUtils { excludedSources?: ERC20BridgeSource[]; feeSchedule?: { [source: string]: BigNumber }; allowFallback?: boolean; + shouldBatchBridgeOrders?: boolean; liquidityProviderAddress?: string; }): OptimizedMarketOrder[] { const { inputToken, outputToken, side, inputAmount } = opts; @@ -327,6 +331,7 @@ export class MarketOperationUtils { contractAddresses: this.contractAddresses, bridgeSlippage: opts.bridgeSlippage || 0, liquidityProviderAddress: opts.liquidityProviderAddress, + shouldBatchBridgeOrders: !!opts.shouldBatchBridgeOrders, }); } 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 6bef4ba01b..28c1e066df 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts @@ -1,7 +1,8 @@ import { ContractAddresses } from '@0x/contract-addresses'; +import { DexForwaderBridgeData, dexForwarderBridgeDataEncoder } from '@0x/contracts-asset-proxy'; import { assetDataUtils, ERC20AssetData, generatePseudoRandomSalt, orderCalculationUtils } from '@0x/order-utils'; -import { SignedOrder } from '@0x/types'; -import { AbiEncoder, BigNumber } from '@0x/utils'; +import { ERC20BridgeAssetData, SignedOrder } from '@0x/types'; +import { AbiEncoder, BigNumber, hexUtils } from '@0x/utils'; import { MarketOperation, SignedOrderWithFillableAmounts } from '../../types'; @@ -71,12 +72,7 @@ export function convertNativeOrderToFullyFillableOptimizedOrders(order: SignedOr fillableMakerAssetAmount: order.makerAssetAmount, fillableTakerAssetAmount: order.takerAssetAmount, fillableTakerFeeAmount: order.takerFee, - fill: { - source: ERC20BridgeSource.Native, - totalMakerAssetAmount: order.makerAssetAmount, - totalTakerAssetAmount: order.takerAssetAmount, - subFills: [], - }, + fills: [], }; } @@ -119,18 +115,34 @@ export interface CreateOrderFromPathOpts { orderDomain: OrderDomain; contractAddresses: ContractAddresses; bridgeSlippage: number; + shouldBatchBridgeOrders: boolean; liquidityProviderAddress?: string; } // Convert sell fills into orders. export function createOrdersFromPath(path: Fill[], opts: CreateOrderFromPathOpts): OptimizedMarketOrder[] { - const collapsedPath = collapsePath(opts.side, path); + const collapsedPath = collapsePath(path); const orders: OptimizedMarketOrder[] = []; - for (const fill of collapsedPath) { - if (fill.source === ERC20BridgeSource.Native) { - orders.push(createNativeOrder(fill)); + for (let i = 0; i < collapsedPath.length;) { + if (collapsedPath[i].source === ERC20BridgeSource.Native) { + orders.push(createNativeOrder(collapsedPath[i])); + ++i; + continue; + } + // If there are contiguous bridge orders, we can batch them together. + const contiguousBridgeFills = [collapsedPath[i]]; + for (let j = i + 1; j < collapsedPath.length; ++j) { + if (collapsedPath[j].source === ERC20BridgeSource.Native) { + break; + } + contiguousBridgeFills.push(collapsedPath[j]); + } + if (contiguousBridgeFills.length === 1 || !opts.shouldBatchBridgeOrders) { + orders.push(createBridgeOrder(contiguousBridgeFills[0], opts)); + i += 1; } else { - orders.push(createBridgeOrder(fill, opts)); + orders.push(createBatchedBridgeOrder(contiguousBridgeFills, opts)); + i += contiguousBridgeFills.length; } } return orders; @@ -161,8 +173,7 @@ function getBridgeAddressFromSource(source: ERC20BridgeSource, opts: CreateOrder } function createBridgeOrder(fill: CollapsedFill, opts: CreateOrderFromPathOpts): OptimizedMarketOrder { - const takerToken = opts.side === MarketOperation.Sell ? opts.inputToken : opts.outputToken; - const makerToken = opts.side === MarketOperation.Sell ? opts.outputToken : opts.inputToken; + const [makerToken, takerToken] = getMakerTakerTokens(opts); const bridgeAddress = getBridgeAddressFromSource(fill.source, opts); let makerAssetData; @@ -182,14 +193,66 @@ function createBridgeOrder(fill: CollapsedFill, opts: CreateOrderFromPathOpts): createBridgeData(takerToken), ); } + const [slippedMakerAssetAmount, slippedTakerAssetAmount] = getSlippedBridgeAssetAmounts(fill, opts); return { - makerAddress: bridgeAddress, + fills: [fill], makerAssetData, takerAssetData: assetDataUtils.encodeERC20AssetData(takerToken), - ...createCommonBridgeOrderFields(fill, opts), + makerAddress: bridgeAddress, + makerAssetAmount: slippedMakerAssetAmount, + takerAssetAmount: slippedTakerAssetAmount, + fillableMakerAssetAmount: slippedMakerAssetAmount, + fillableTakerAssetAmount: slippedTakerAssetAmount, + ...createCommonBridgeOrderFields(opts), }; } +function createBatchedBridgeOrder(fills: CollapsedFill[], opts: CreateOrderFromPathOpts): OptimizedMarketOrder { + const [makerToken, takerToken] = getMakerTakerTokens(opts); + let totalMakerAssetAmount = ZERO_AMOUNT; + let totalTakerAssetAmount = ZERO_AMOUNT; + const batchedBridgeData: DexForwaderBridgeData = { + inputToken: takerToken, + calls: [], + }; + for (const fill of fills) { + const bridgeOrder = createBridgeOrder(fill, opts); + totalMakerAssetAmount = totalMakerAssetAmount.plus(bridgeOrder.makerAssetAmount); + totalTakerAssetAmount = totalTakerAssetAmount.plus(bridgeOrder.takerAssetAmount); + const { bridgeAddress, bridgeData: orderBridgeData } = + assetDataUtils.decodeAssetDataOrThrow(bridgeOrder.makerAssetData) as ERC20BridgeAssetData; + batchedBridgeData.calls.push({ + target: bridgeAddress, + bridgeData: orderBridgeData, + inputTokenAmount: bridgeOrder.takerAssetAmount, + outputTokenAmount: bridgeOrder.makerAssetAmount, + }); + } + const batchedBridgeAddress = opts.contractAddresses.dexForwarderBridge; + const batchedMakerAssetData = assetDataUtils.encodeERC20BridgeAssetData( + makerToken, + batchedBridgeAddress, + dexForwarderBridgeDataEncoder.encode(batchedBridgeData), + ); + return { + fills, + makerAssetData: batchedMakerAssetData, + takerAssetData: assetDataUtils.encodeERC20AssetData(takerToken), + makerAddress: batchedBridgeAddress, + makerAssetAmount: totalMakerAssetAmount, + takerAssetAmount: totalTakerAssetAmount, + fillableMakerAssetAmount: totalMakerAssetAmount, + fillableTakerAssetAmount: totalTakerAssetAmount, + ...createCommonBridgeOrderFields(opts), + }; +} + +function getMakerTakerTokens(opts: CreateOrderFromPathOpts): [string, string] { + const makerToken = opts.side === MarketOperation.Sell ? opts.outputToken : opts.inputToken; + const takerToken = opts.side === MarketOperation.Sell ? opts.inputToken : opts.outputToken; + return [makerToken, takerToken]; +} + function createBridgeData(tokenAddress: string): string { const encoder = AbiEncoder.create([{ name: 'tokenAddress', type: 'address' }]); return encoder.encode({ tokenAddress }); @@ -210,22 +273,36 @@ function createCurveBridgeData( return curveBridgeDataEncoder.encode([curveAddress, fromTokenIdx, toTokenIdx, version]); } +function getSlippedBridgeAssetAmounts(fill: CollapsedFill, opts: CreateOrderFromPathOpts): [BigNumber, BigNumber] { + return [ + // Maker asset amount. + opts.side === MarketOperation.Sell + ? fill.output.times(1 - opts.bridgeSlippage).integerValue(BigNumber.ROUND_DOWN) + : fill.input, + // Taker asset amount. + opts.side === MarketOperation.Sell + ? fill.input + : fill.output.times(opts.bridgeSlippage + 1).integerValue(BigNumber.ROUND_UP), + ]; +} + type CommonBridgeOrderFields = Pick< OptimizedMarketOrder, - Exclude + Exclude< + keyof OptimizedMarketOrder, + 'fills' + | 'makerAddress' + | 'makerAssetData' + | 'takerAssetData' + | 'makerAssetAmount' + | 'takerAssetAmount' + | 'fillableMakerAssetAmount' + | 'fillableTakerAssetAmount' + > >; -function createCommonBridgeOrderFields(fill: CollapsedFill, opts: CreateOrderFromPathOpts): CommonBridgeOrderFields { - const makerAssetAmountAdjustedWithSlippage = - opts.side === MarketOperation.Sell - ? fill.totalMakerAssetAmount.times(1 - opts.bridgeSlippage).integerValue(BigNumber.ROUND_DOWN) - : fill.totalMakerAssetAmount; - const takerAssetAmountAdjustedWithSlippage = - opts.side === MarketOperation.Sell - ? fill.totalTakerAssetAmount - : fill.totalTakerAssetAmount.times(opts.bridgeSlippage + 1).integerValue(BigNumber.ROUND_UP); +function createCommonBridgeOrderFields(opts: CreateOrderFromPathOpts): CommonBridgeOrderFields { return { - fill, takerAddress: NULL_ADDRESS, senderAddress: NULL_ADDRESS, feeRecipientAddress: NULL_ADDRESS, @@ -235,10 +312,6 @@ function createCommonBridgeOrderFields(fill: CollapsedFill, opts: CreateOrderFro takerFeeAssetData: NULL_BYTES, makerFee: ZERO_AMOUNT, takerFee: ZERO_AMOUNT, - makerAssetAmount: makerAssetAmountAdjustedWithSlippage, - fillableMakerAssetAmount: makerAssetAmountAdjustedWithSlippage, - takerAssetAmount: takerAssetAmountAdjustedWithSlippage, - fillableTakerAssetAmount: takerAssetAmountAdjustedWithSlippage, fillableTakerFeeAmount: ZERO_AMOUNT, signature: WALLET_SIGNATURE, ...opts.orderDomain, @@ -247,12 +320,7 @@ function createCommonBridgeOrderFields(fill: CollapsedFill, opts: CreateOrderFro function createNativeOrder(fill: CollapsedFill): OptimizedMarketOrder { return { - fill: { - source: fill.source, - totalMakerAssetAmount: fill.totalMakerAssetAmount, - totalTakerAssetAmount: fill.totalTakerAssetAmount, - subFills: fill.subFills, - }, + fills: [fill], ...(fill as NativeCollapsedFill).nativeOrder, }; } 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 323297c7ca..b2d372a6e3 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/types.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/types.ts @@ -97,19 +97,19 @@ export interface CollapsedFill { */ source: ERC20BridgeSource; /** - * Total maker asset amount. + * Total input amount (sum of `subFill`s) */ - totalMakerAssetAmount: BigNumber; + input: BigNumber; /** - * Total taker asset amount. + * Total output amount (sum of `subFill`s) */ - totalTakerAssetAmount: BigNumber; + output: BigNumber; /** - * All the fill asset amounts that were collapsed into this node. + * Quantities of all the fills that were collapsed. */ subFills: Array<{ - makerAssetAmount: BigNumber; - takerAssetAmount: BigNumber; + input: BigNumber; + output: BigNumber; }>; } @@ -127,7 +127,7 @@ export interface OptimizedMarketOrder extends SignedOrderWithFillableAmounts { /** * The optimized fills that generated this order. */ - fill: CollapsedFill; + fills: CollapsedFill[]; } /** @@ -180,9 +180,14 @@ export interface GetMarketOrdersOpts { gasSchedule: { [source: string]: number }; /** * Whether to pad the quote with a redundant fallback quote using different - * sources. + * sources. Defaults to `true`. */ allowFallback: boolean; + /** + * Whether to combine contiguous bridge orders into a single DexForwarderBridge + * order. Defaults to `true`. + */ + shouldBatchBridgeOrders: boolean; } /** diff --git a/packages/asset-swapper/src/utils/protocol_fee_utils.ts b/packages/asset-swapper/src/utils/protocol_fee_utils.ts index a505c7e2c2..f0f3e4458d 100644 --- a/packages/asset-swapper/src/utils/protocol_fee_utils.ts +++ b/packages/asset-swapper/src/utils/protocol_fee_utils.ts @@ -15,12 +15,6 @@ export class ProtocolFeeUtils { this._initializeHeartBeat(); } - // TODO(dave4506) at some point, we should add a heart beat to the multiplier, or some RPC call to fetch latest multiplier. - // tslint:disable-next-line:prefer-function-over-method - public async getProtocolFeeMultiplierAsync(): Promise { - return constants.PROTOCOL_FEE_MULTIPLIER; - } - public async getGasPriceEstimationOrThrowAsync(shouldHardRefresh?: boolean): Promise { if (this.gasPriceEstimation.eq(constants.ZERO_AMOUNT)) { return this._getGasPriceFromGasStationOrThrowAsync(); @@ -39,18 +33,6 @@ export class ProtocolFeeUtils { this._gasPriceHeart.kill(); } - /** - * Calculates protocol fee with protofol fee multiplier for each fill. - */ - public async calculateWorstCaseProtocolFeeAsync( - orders: T[], - gasPrice: BigNumber, - ): Promise { - const protocolFeeMultiplier = await this.getProtocolFeeMultiplierAsync(); - const protocolFee = new BigNumber(orders.length).times(protocolFeeMultiplier).times(gasPrice); - return protocolFee; - } - // tslint:disable-next-line: prefer-function-over-method private async _getGasPriceFromGasStationOrThrowAsync(): Promise { try { diff --git a/packages/asset-swapper/src/utils/quote_simulation.ts b/packages/asset-swapper/src/utils/quote_simulation.ts new file mode 100644 index 0000000000..521f24805b --- /dev/null +++ b/packages/asset-swapper/src/utils/quote_simulation.ts @@ -0,0 +1,344 @@ +import { BigNumber } from '@0x/utils'; + +import { constants } from '../constants'; +import { MarketOperation } from '../types'; + +import { CollapsedFill, ERC20BridgeSource, OptimizedMarketOrder } from './market_operation_utils/types'; +import { isOrderTakerFeePayableWithMakerAsset, isOrderTakerFeePayableWithTakerAsset } from './utils'; + +const { PROTOCOL_FEE_MULTIPLIER, ZERO_AMOUNT } = constants; +const { ROUND_DOWN, ROUND_UP } = BigNumber; + +export interface QuoteFillResult { + // Maker asset bought. + makerAssetAmount: BigNumber; + // Taker asset sold. + takerAssetAmount: BigNumber; + // Taker fees that can be paid with the maker asset. + takerFeeMakerAssetAmount: BigNumber; + // Taker fees that can be paid with the taker asset. + takerFeeTakerAssetAmount: BigNumber; + // Total maker asset amount bought (including fees). + totalMakerAssetAmount: BigNumber; + // Total taker asset amount sold (including fees). + totalTakerAssetAmount: BigNumber; + // Protocol fees paid. + protocolFeeAmount: BigNumber; + // (Estimated) gas used. + gas: number; + // Fill amounts by source. + // For sells, this is the taker assets sold. + // For buys, this is the maker assets bought. + fillAmountBySource: { [source: string]: BigNumber }; +} + +interface IntermediateQuoteFillResult { + // Input tokens filled. Taker asset for sells, maker asset for buys. + input: BigNumber; + // Output tokens filled. Maker asset for sells, taker asset for buys. + output: BigNumber; + // Taker fees that can be paid with the output token. + outputFee: BigNumber; + // Taker fees that can be paid with the input token. + inputFee: BigNumber; + // Protocol fees paid. + protocolFee: BigNumber; + // (Estimated) gas used. + gas: number; + // Input amounts filled by sources. + inputBySource: { [source: string]: BigNumber }; +} + +const EMPTY_QUOTE_INTERMEDIATE_FILL_RESULT = { + input: ZERO_AMOUNT, + output: ZERO_AMOUNT, + outputFee: ZERO_AMOUNT, + inputFee: ZERO_AMOUNT, + protocolFee: ZERO_AMOUNT, + gas: 0, +}; + +export interface QuoteFillInfo { + orders: OptimizedMarketOrder[]; + fillAmount: BigNumber; + gasPrice: BigNumber; + side: MarketOperation; + opts: Partial; +} + +export interface QuoteFillInfoOpts { + gasSchedule: { [soruce: string]: number }; + protocolFeeMultiplier: BigNumber; +} + +const DEFAULT_SIMULATED_FILL_QUOTE_INFO_OPTS: QuoteFillInfoOpts = { + gasSchedule: {}, + protocolFeeMultiplier: PROTOCOL_FEE_MULTIPLIER, +}; + +export interface QuoteFillOrderCall { + order: OptimizedMarketOrder; + // Fillable input amount defined in the order. + fillableOrderInput: BigNumber; + // Fillable fees payable with input token. + // Positive for sells, negative for buys. + fillableOrderInputFee: BigNumber; + // Fillable fees payable with output token. + // Negative for sells, positive for buys. + fillableOrderOutputFee: BigNumber; +} + +// Simulates filling a quote in the best case. +export function simulateBestCaseFill(quoteInfo: QuoteFillInfo): QuoteFillResult { + const opts = { + ...DEFAULT_SIMULATED_FILL_QUOTE_INFO_OPTS, + ...quoteInfo.opts, + }; + const result = fillQuoteOrders( + createBestCaseFillOrderCalls(quoteInfo), + quoteInfo.fillAmount, + quoteInfo.gasPrice.times(opts.protocolFeeMultiplier), + opts.gasSchedule, + ); + return fromIntermediateQuoteFillResult(result, quoteInfo); +} + +// Simulates filling a quote in the worst case. +export function simulateWorstCaseFill(quoteInfo: QuoteFillInfo): QuoteFillResult { + const opts = { + ...DEFAULT_SIMULATED_FILL_QUOTE_INFO_OPTS, + ...quoteInfo.opts, + }; + const protocolFeePerFillOrder = quoteInfo.gasPrice.times(opts.protocolFeeMultiplier); + const result = { + ...fillQuoteOrders( + createWorstCaseFillOrderCalls(quoteInfo), + quoteInfo.fillAmount, + protocolFeePerFillOrder, + opts.gasSchedule, + ), + // Worst case gas and protocol fee is hitting all orders. + gas: getTotalGasUsedBySources( + getFlattenedFillsFromOrders(quoteInfo.orders).map(s => s.source), + opts.gasSchedule, + ), + protocolFee: protocolFeePerFillOrder.times(quoteInfo.orders.length), + }; + return fromIntermediateQuoteFillResult(result, quoteInfo); +} + +export function fillQuoteOrders( + fillOrders: QuoteFillOrderCall[], + inputAmount: BigNumber, + protocolFeePerFillOrder: BigNumber, + gasSchedule: { [source: string]: number }, +): IntermediateQuoteFillResult { + const result: IntermediateQuoteFillResult = { + ...EMPTY_QUOTE_INTERMEDIATE_FILL_RESULT, + inputBySource: {}, + }; + let remainingInput = inputAmount; + for (const fo of fillOrders) { + if (remainingInput.lte(0)) { + break; + } + for (const fill of fo.order.fills) { + if (remainingInput.lte(0)) { + break; + } + const { source } = fill; + result.gas += gasSchedule[source] || 0; + result.inputBySource[source] = result.inputBySource[source] || ZERO_AMOUNT; + + // Actual rates are rarely linear, so fill subfills individually to + // get a better approximation of fill size. + for (const subFill of fill.subFills) { + if (remainingInput.lte(0)) { + break; + } + const filledInput = solveForInputFillAmount( + remainingInput, + subFill.input, + fo.fillableOrderInput, + fo.fillableOrderInputFee, + ); + const filledOutput = subFill.output.times(filledInput.div(subFill.input)); + + result.inputBySource[source] = result.inputBySource[source].plus(filledInput); + result.input = result.input + .plus(filledInput); + result.output = result.input + .plus(filledOutput); + const orderFillFrac = filledInput.div(fo.fillableOrderInput); + result.inputFee = result.inputFee + .plus(orderFillFrac.times(fo.fillableOrderInputFee)); + result.outputFee = result.outputFee + .plus(orderFillFrac.times(fo.fillableOrderOutputFee)); + remainingInput = inputAmount + .minus(result.input.plus(result.inputFee)); + } + } + result.protocolFee = result.protocolFee.plus(protocolFeePerFillOrder); + } + return result; +} + +function solveForInputFillAmount( + remainingInput: BigNumber, + fillableInput: BigNumber, + fillableOrderInput: BigNumber, + fillableOrderInputFee: BigNumber, +): BigNumber { + // When accounting for input token taker fees, the effective input amount is + // given by: + // i' = i + f * i / o + // where: + // i' - The effective input amount, including fees + // i - An input amount + // f - fillableOrderInputFee + // o - fillableOrderInput + // Solving for i we get: + // i = (i' * o) / (f + o) + const denom = fillableOrderInput.plus(fillableOrderInputFee); + if (denom.lte(0)) { + // A zero denominator would imply an order whose fees are >= the input + // token amount. + // For sells, takerFeeAmount >= takerAssetAmount (technically OK but really undesirable). + // For buys, takerFeeAmount >= makerAssetAmount (losing all your returns to fees). + throw new Error(`Cannot solve for input amount with order input ${fillableOrderInput} and order fee ${fillableOrderInputFee}.`); + } + // i' = remainingInput + return BigNumber.min(fillableInput, remainingInput.times(fillableOrderInput).div(denom)); +} + +function createBestCaseFillOrderCalls(quoteInfo: QuoteFillInfo): QuoteFillOrderCall[] { + const { orders, side } = quoteInfo; + return orders.map(o => ({ + order: o, + ...(side === MarketOperation.Sell + ? { + fillableOrderInput: o.fillableTakerAssetAmount, + fillableOrderInputFee: isOrderTakerFeePayableWithTakerAsset(o) + ? o.fillableTakerFeeAmount + : ZERO_AMOUNT, + fillableOrderOutputFee: isOrderTakerFeePayableWithMakerAsset(o) + ? o.fillableTakerFeeAmount.negated() + : ZERO_AMOUNT, + } + // Buy + : { + fillableOrderInput: o.fillableMakerAssetAmount, + fillableOrderInputFee: isOrderTakerFeePayableWithMakerAsset(o) + ? o.fillableTakerFeeAmount.negated() + : ZERO_AMOUNT, + fillableOrderOutputFee: isOrderTakerFeePayableWithTakerAsset(o) + ? o.fillableTakerFeeAmount + : ZERO_AMOUNT, + } + ), + })); +} + +function createWorstCaseFillOrderCalls(quoteInfo: QuoteFillInfo): QuoteFillOrderCall[] { + // Reuse best case fill orders. + return createBestCaseFillOrderCalls(quoteInfo).map(fo => ({ + ...fo, + order: { + ...fo.order, + // Apply slippage to order fills and reverse them. + fills: getSlippedOrderFills(fo.order, quoteInfo.side).reverse(), + }, + // Reverse the orders. + })).reverse(); +} + +// Apply order slippage to its fill paths. +function getSlippedOrderFills(order: OptimizedMarketOrder, side: MarketOperation): CollapsedFill[] { + const totalInput = BigNumber.sum(...order.fills.map(f => f.input)); + const totalOutput = BigNumber.sum(...order.fills.map(f => f.output)); + const inputScaling = side === MarketOperation.Sell + ? order.fillableTakerAssetAmount.div(totalInput) // Should be 1 + : order.fillableMakerAssetAmount.div(totalOutput); + const outputScaling = side === MarketOperation.Sell + ? order.fillableMakerAssetAmount.div(totalOutput) + : order.fillableTakerAssetAmount.div(totalInput); // Should be 1 + return order.fills.map(f => ({ + ...f, + input: f.input.times(inputScaling), + output: f.output.times(outputScaling), + subFills: f.subFills.map(sf => ({ + ...sf, + input: sf.input.times(inputScaling), + output: sf.output.times(outputScaling), + })), + })); +} + +function fromIntermediateQuoteFillResult( + ir: IntermediateQuoteFillResult, + quoteInfo: QuoteFillInfo, +): QuoteFillResult { + const { side } = quoteInfo; + // Round to integers. + const inputRounding = side === MarketOperation.Sell + ? ROUND_UP : ROUND_DOWN; + const outputRounding = side === MarketOperation.Sell + ? ROUND_DOWN : ROUND_UP; + const _ir = { + input: ir.input.integerValue(inputRounding), + output: ir.output.integerValue(outputRounding), + inputFee: ir.inputFee.integerValue(inputRounding), + outputFee: ir.outputFee.integerValue(outputRounding), + protocolFee: ir.protocolFee.integerValue(ROUND_UP), + gas: Math.ceil(ir.gas), + inputBySource: Object.assign( + {}, + ...Object.entries(ir.inputBySource) + .map(([k, v]) => ({ [k]: v.integerValue(inputRounding) })), + ), + }; + return { + ...(side === MarketOperation.Sell + // Sell + ? { + makerAssetAmount: _ir.output, + takerAssetAmount: _ir.input, + takerFeeMakerAssetAmount: _ir.outputFee, + takerFeeTakerAssetAmount: _ir.inputFee, + totalMakerAssetAmount: _ir.output.plus(_ir.outputFee), + totalTakerAssetAmount: _ir.input, + } + // Buy + : { + makerAssetAmount: _ir.input, + takerAssetAmount: _ir.output, + takerFeeMakerAssetAmount: _ir.inputFee, + takerFeeTakerAssetAmount: _ir.outputFee, + totalMakerAssetAmount: _ir.input, + totalTakerAssetAmount: _ir.output.plus(_ir.outputFee), + } + ), + protocolFeeAmount: _ir.protocolFee, + gas: _ir.gas, + fillAmountBySource: _ir.inputBySource, + }; +} + +export function getFlattenedFillsFromOrders(orders: OptimizedMarketOrder[]): CollapsedFill[] { + const fills = []; + for (const o of orders) { + fills.push(...o.fills); + } + return fills; +} + +function getTotalGasUsedBySources( + sources: ERC20BridgeSource[], + gasSchedule: { [source: string]: number }, +): number { + let gasUsed = 0; + for (const s of sources) { + gasUsed += gasSchedule[s] || 0; + } + return gasUsed; +} diff --git a/packages/asset-swapper/src/utils/swap_quote_calculator.ts b/packages/asset-swapper/src/utils/swap_quote_calculator.ts index 8f08cc1454..5e164fc5e4 100644 --- a/packages/asset-swapper/src/utils/swap_quote_calculator.ts +++ b/packages/asset-swapper/src/utils/swap_quote_calculator.ts @@ -1,9 +1,8 @@ -import { assetDataUtils, orderCalculationUtils } from '@0x/order-utils'; +import { assetDataUtils } from '@0x/order-utils'; import { AssetProxyId, SignedOrder } from '@0x/types'; import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; -import { constants } from '../constants'; import { CalculateSwapQuoteOpts, MarketBuySwapQuote, @@ -17,24 +16,24 @@ import { SwapQuoterError, } from '../types'; -import { fillableAmountsUtils } from './fillable_amounts_utils'; import { MarketOperationUtils } from './market_operation_utils'; import { convertNativeOrderToFullyFillableOptimizedOrders } from './market_operation_utils/orders'; -import { ERC20BridgeSource, GetMarketOrdersOpts, OptimizedMarketOrder } from './market_operation_utils/types'; -import { ProtocolFeeUtils } from './protocol_fee_utils'; +import { GetMarketOrdersOpts, OptimizedMarketOrder } from './market_operation_utils/types'; import { - isOrderTakerFeePayableWithMakerAsset, - isOrderTakerFeePayableWithTakerAsset, isSupportedAssetDataInOrders, } from './utils'; +import { + QuoteFillResult, + simulateBestCaseFill, + simulateWorstCaseFill, +} from './quote_simulation'; + // TODO(dave4506) How do we want to reintroduce InsufficientAssetLiquidityError? export class SwapQuoteCalculator { - private readonly _protocolFeeUtils: ProtocolFeeUtils; private readonly _marketOperationUtils: MarketOperationUtils; - constructor(protocolFeeUtils: ProtocolFeeUtils, marketOperationUtils: MarketOperationUtils) { - this._protocolFeeUtils = protocolFeeUtils; + constructor(marketOperationUtils: MarketOperationUtils) { this._marketOperationUtils = marketOperationUtils; } @@ -99,7 +98,7 @@ export class SwapQuoteCalculator { batchSignedOrders.map(async (orders, i) => { if (orders) { const { makerAssetData, takerAssetData } = batchPrunedOrders[i][0]; - return this._createSwapQuoteAsync( + return createSwapQuote( makerAssetData, takerAssetData, orders, @@ -163,7 +162,7 @@ export class SwapQuoteCalculator { // assetData information for the result const { makerAssetData, takerAssetData } = prunedOrders[0]; - return this._createSwapQuoteAsync( + return createSwapQuote( makerAssetData, takerAssetData, resultOrders, @@ -173,324 +172,77 @@ export class SwapQuoteCalculator { opts.gasSchedule, ); } - private async _createSwapQuoteAsync( - makerAssetData: string, - takerAssetData: string, - resultOrders: OptimizedMarketOrder[], - operation: MarketOperation, - assetFillAmount: BigNumber, - gasPrice: BigNumber, - gasSchedule: { [source: string]: number }, - ): Promise { - const bestCaseQuoteInfo = await this._calculateQuoteInfoAsync( - resultOrders, - assetFillAmount, - gasPrice, - gasSchedule, - operation, - ); - const worstCaseQuoteInfo = await this._calculateQuoteInfoAsync( - resultOrders, - assetFillAmount, - gasPrice, - gasSchedule, - operation, - true, - ); +} - const breakdown = getSwapQuoteOrdersBreakdown(resultOrders, operation); +function createSwapQuote( + makerAssetData: string, + takerAssetData: string, + resultOrders: OptimizedMarketOrder[], + operation: MarketOperation, + assetFillAmount: BigNumber, + gasPrice: BigNumber, + gasSchedule: { [source: string]: number }, +): SwapQuote { + const bestCaseFillResult = simulateBestCaseFill({ + gasPrice, + orders: resultOrders, + side: operation, + fillAmount: assetFillAmount, + opts: { gasSchedule }, + }); - const quoteBase: SwapQuoteBase = { - takerAssetData, - makerAssetData, - // Remove fill metadata. - orders: resultOrders.map(o => _.omit(o, 'fill')) as SignedOrderWithFillableAmounts[], - bestCaseQuoteInfo, - worstCaseQuoteInfo, - gasPrice, - sourceBreakdown: breakdown, - }; + const worstCaseFillResult = simulateWorstCaseFill({ + gasPrice, + orders: resultOrders, + side: operation, + fillAmount: assetFillAmount, + opts: { gasSchedule }, + }); - if (operation === MarketOperation.Buy) { - return { - ...quoteBase, - type: MarketOperation.Buy, - makerAssetFillAmount: assetFillAmount, - }; - } else { - return { - ...quoteBase, - type: MarketOperation.Sell, - takerAssetFillAmount: assetFillAmount, - }; - } - } + const quoteBase: SwapQuoteBase = { + takerAssetData, + makerAssetData, + // Remove fill metadata. + orders: resultOrders.map(o => _.omit(o, 'fills')) as SignedOrderWithFillableAmounts[], + bestCaseQuoteInfo: fillResultsToQuoteInfo(bestCaseFillResult), + worstCaseQuoteInfo: fillResultsToQuoteInfo(worstCaseFillResult), + gasPrice, + sourceBreakdown: getSwapQuoteOrdersBreakdown(bestCaseFillResult.fillAmountBySource), + }; - // tslint:disable-next-line: prefer-function-over-method - private async _calculateQuoteInfoAsync( - orders: OptimizedMarketOrder[], - assetFillAmount: BigNumber, - gasPrice: BigNumber, - gasSchedule: { [source: string]: number }, - operation: MarketOperation, - worstCase: boolean = false, - ): Promise { + if (operation === MarketOperation.Buy) { return { - ...(operation === MarketOperation.Buy - ? await this._calculateMarketBuyQuoteInfoAsync(orders, assetFillAmount, gasPrice, worstCase) - : await this._calculateMarketSellQuoteInfoAsync(orders, assetFillAmount, gasPrice, worstCase)), - gas: getGasUsedByOrders(orders, gasSchedule), + ...quoteBase, + type: MarketOperation.Buy, + makerAssetFillAmount: assetFillAmount, }; - } - - private async _calculateMarketSellQuoteInfoAsync( - orders: OptimizedMarketOrder[], - takerAssetSellAmount: BigNumber, - gasPrice: BigNumber, - worstCase: boolean = false, - ): Promise { - let totalMakerAssetAmount = constants.ZERO_AMOUNT; - let totalTakerAssetAmount = constants.ZERO_AMOUNT; - let totalFeeTakerAssetAmount = constants.ZERO_AMOUNT; - let remainingTakerAssetFillAmount = takerAssetSellAmount; - const filledOrders = [] as OptimizedMarketOrder[]; - const _orders = !worstCase ? orders : orders.slice().reverse(); - for (const order of _orders) { - let makerAssetAmount = constants.ZERO_AMOUNT; - let takerAssetAmount = constants.ZERO_AMOUNT; - let feeTakerAssetAmount = constants.ZERO_AMOUNT; - if (remainingTakerAssetFillAmount.lte(0)) { - break; - } - if (order.fill.source === ERC20BridgeSource.Native) { - const adjustedFillableMakerAssetAmount = fillableAmountsUtils.getMakerAssetAmountSwappedAfterOrderFees( - order, - ); - const adjustedFillableTakerAssetAmount = fillableAmountsUtils.getTakerAssetAmountSwappedAfterOrderFees( - order, - ); - const takerAssetAmountWithFees = BigNumber.min( - remainingTakerAssetFillAmount, - adjustedFillableTakerAssetAmount, - ); - const takerAssetAmountBreakDown = getTakerAssetAmountBreakDown(order, takerAssetAmountWithFees); - takerAssetAmount = takerAssetAmountBreakDown.takerAssetAmount; - feeTakerAssetAmount = takerAssetAmountBreakDown.feeTakerAssetAmount; - makerAssetAmount = takerAssetAmountWithFees - .div(adjustedFillableTakerAssetAmount) - .times(adjustedFillableMakerAssetAmount) - .integerValue(BigNumber.ROUND_DOWN); - } else { - // This is a collapsed bridge order. - // Because collapsed bridge orders actually fill at different rates, - // we can iterate over the uncollapsed fills to get the actual - // asset amounts transfered. - // We can also assume there are no fees and the order is not - // partially filled. - - // Infer the bridge slippage from the difference between the fill - // size and the actual order asset amounts. - const makerAssetBridgeSlippage = !worstCase - ? constants.ONE_AMOUNT - : order.makerAssetAmount.div(order.fill.totalMakerAssetAmount); - const takerAssetBridgeSlippage = !worstCase - ? constants.ONE_AMOUNT - : order.takerAssetAmount.div(order.fill.totalTakerAssetAmount); - // Consecutively fill the subfills in this order. - const subFills = !worstCase ? order.fill.subFills : order.fill.subFills.slice().reverse(); - for (const subFill of subFills) { - if (remainingTakerAssetFillAmount.minus(takerAssetAmount).lte(0)) { - break; - } - const partialTakerAssetAmount = subFill.takerAssetAmount.times(takerAssetBridgeSlippage); - const partialMakerAssetAmount = subFill.makerAssetAmount.times(makerAssetBridgeSlippage); - const partialTakerAssetFillAmount = BigNumber.min( - partialTakerAssetAmount, - remainingTakerAssetFillAmount.minus(takerAssetAmount), - ); - const partialMakerAssetFillAmount = partialTakerAssetFillAmount - .div(partialTakerAssetAmount) - .times(partialMakerAssetAmount) - .integerValue(BigNumber.ROUND_DOWN); - takerAssetAmount = takerAssetAmount.plus(partialTakerAssetFillAmount); - makerAssetAmount = makerAssetAmount.plus(partialMakerAssetFillAmount); - } - } - totalMakerAssetAmount = totalMakerAssetAmount.plus(makerAssetAmount); - totalTakerAssetAmount = totalTakerAssetAmount.plus(takerAssetAmount); - totalFeeTakerAssetAmount = totalFeeTakerAssetAmount.plus(feeTakerAssetAmount); - remainingTakerAssetFillAmount = remainingTakerAssetFillAmount - .minus(takerAssetAmount) - .minus(feeTakerAssetAmount); - filledOrders.push(order); - } - const protocolFeeInWeiAmount = await this._protocolFeeUtils.calculateWorstCaseProtocolFeeAsync( - !worstCase ? filledOrders : orders, - gasPrice, - ); + } else { return { - feeTakerAssetAmount: totalFeeTakerAssetAmount, - takerAssetAmount: totalTakerAssetAmount, - totalTakerAssetAmount: totalFeeTakerAssetAmount.plus(totalTakerAssetAmount), - makerAssetAmount: totalMakerAssetAmount, - protocolFeeInWeiAmount, - gas: 0, - }; - } - - private async _calculateMarketBuyQuoteInfoAsync( - orders: OptimizedMarketOrder[], - makerAssetBuyAmount: BigNumber, - gasPrice: BigNumber, - worstCase: boolean = false, - ): Promise { - let totalMakerAssetAmount = constants.ZERO_AMOUNT; - let totalTakerAssetAmount = constants.ZERO_AMOUNT; - let totalFeeTakerAssetAmount = constants.ZERO_AMOUNT; - let remainingMakerAssetFillAmount = makerAssetBuyAmount; - const filledOrders = [] as OptimizedMarketOrder[]; - const _orders = !worstCase ? orders : orders.slice().reverse(); - for (const order of _orders) { - let makerAssetAmount = constants.ZERO_AMOUNT; - let takerAssetAmount = constants.ZERO_AMOUNT; - let feeTakerAssetAmount = constants.ZERO_AMOUNT; - if (remainingMakerAssetFillAmount.lte(0)) { - break; - } - if (order.fill.source === ERC20BridgeSource.Native) { - const adjustedFillableMakerAssetAmount = fillableAmountsUtils.getMakerAssetAmountSwappedAfterOrderFees( - order, - ); - const adjustedFillableTakerAssetAmount = fillableAmountsUtils.getTakerAssetAmountSwappedAfterOrderFees( - order, - ); - makerAssetAmount = BigNumber.min(remainingMakerAssetFillAmount, adjustedFillableMakerAssetAmount); - const takerAssetAmountWithFees = makerAssetAmount - .div(adjustedFillableMakerAssetAmount) - .multipliedBy(adjustedFillableTakerAssetAmount) - .integerValue(BigNumber.ROUND_UP); - const takerAssetAmountBreakDown = getTakerAssetAmountBreakDown(order, takerAssetAmountWithFees); - takerAssetAmount = takerAssetAmountBreakDown.takerAssetAmount; - feeTakerAssetAmount = takerAssetAmountBreakDown.feeTakerAssetAmount; - } else { - // This is a collapsed bridge order. - // Because collapsed bridge orders actually fill at different rates, - // we can iterate over the uncollapsed fills to get the actual - // asset amounts transfered. - // We can also assume there are no fees and the order is not - // partially filled. - - // Infer the bridge slippage from the difference between the fill - // size and the actual order asset amounts. - const makerAssetBridgeSlippage = !worstCase - ? constants.ONE_AMOUNT - : order.makerAssetAmount.div(order.fill.totalMakerAssetAmount); - const takerAssetBridgeSlippage = !worstCase - ? constants.ONE_AMOUNT - : order.takerAssetAmount.div(order.fill.totalTakerAssetAmount); - // Consecutively fill the subfills in this order. - const subFills = !worstCase ? order.fill.subFills : order.fill.subFills.slice().reverse(); - for (const subFill of subFills) { - if (remainingMakerAssetFillAmount.minus(makerAssetAmount).lte(0)) { - break; - } - const partialTakerAssetAmount = subFill.takerAssetAmount.times(takerAssetBridgeSlippage); - const partialMakerAssetAmount = subFill.makerAssetAmount.times(makerAssetBridgeSlippage); - const partialMakerAssetFillAmount = BigNumber.min( - partialMakerAssetAmount, - remainingMakerAssetFillAmount.minus(makerAssetAmount), - ); - const partialTakerAssetFillAmount = partialMakerAssetFillAmount - .div(partialMakerAssetAmount) - .times(partialTakerAssetAmount) - .integerValue(BigNumber.ROUND_UP); - takerAssetAmount = takerAssetAmount.plus(partialTakerAssetFillAmount); - makerAssetAmount = makerAssetAmount.plus(partialMakerAssetFillAmount); - } - } - totalMakerAssetAmount = totalMakerAssetAmount.plus(makerAssetAmount); - totalTakerAssetAmount = totalTakerAssetAmount.plus(takerAssetAmount); - totalFeeTakerAssetAmount = totalFeeTakerAssetAmount.plus(feeTakerAssetAmount); - remainingMakerAssetFillAmount = remainingMakerAssetFillAmount.minus(makerAssetAmount); - filledOrders.push(order); - } - const protocolFeeInWeiAmount = await this._protocolFeeUtils.calculateWorstCaseProtocolFeeAsync( - !worstCase ? filledOrders : orders, - gasPrice, - ); - return { - feeTakerAssetAmount: totalFeeTakerAssetAmount, - takerAssetAmount: totalTakerAssetAmount, - totalTakerAssetAmount: totalFeeTakerAssetAmount.plus(totalTakerAssetAmount), - makerAssetAmount: totalMakerAssetAmount, - protocolFeeInWeiAmount, - gas: 0, + ...quoteBase, + type: MarketOperation.Sell, + takerAssetFillAmount: assetFillAmount, }; } } function getSwapQuoteOrdersBreakdown( - orders: OptimizedMarketOrder[], - operation: MarketOperation, + fillAmountBySource: { [source: string]: BigNumber }, ): SwapQuoteOrdersBreakdown { - const orderAmounts = - operation === MarketOperation.Buy - ? orders.map(o => o.fill.totalMakerAssetAmount) - : orders.map(o => o.fill.totalTakerAssetAmount); - const amountsBySource: SwapQuoteOrdersBreakdown = {}; - orders.forEach((o, i) => { - const source = o.fill.source; - amountsBySource[source] = orderAmounts[i].plus(amountsBySource[source] || 0); - }); - const totalAmount = BigNumber.sum(0, ...orderAmounts); + const totalFillAmount = BigNumber.sum(...Object.values(fillAmountBySource)); const breakdown: SwapQuoteOrdersBreakdown = {}; - for (const [source, amount] of Object.entries(amountsBySource)) { - breakdown[source] = amount.div(totalAmount); - } + Object.entries(fillAmountBySource).forEach(([source, fillAmount]) => { + breakdown[source] = fillAmount.div(totalFillAmount); + }); return breakdown; } -function getTakerAssetAmountBreakDown( - order: SignedOrderWithFillableAmounts, - takerAssetAmountWithFees: BigNumber, -): { feeTakerAssetAmount: BigNumber; takerAssetAmount: BigNumber } { - if (isOrderTakerFeePayableWithTakerAsset(order)) { - const adjustedTakerAssetAmount = order.takerAssetAmount.plus(order.takerFee); - const filledRatio = takerAssetAmountWithFees.div(adjustedTakerAssetAmount); - const takerAssetAmount = filledRatio.multipliedBy(order.takerAssetAmount).integerValue(BigNumber.ROUND_CEIL); - return { - takerAssetAmount, - feeTakerAssetAmount: takerAssetAmountWithFees.minus(takerAssetAmount), - }; - } else if (isOrderTakerFeePayableWithMakerAsset(order)) { - if (takerAssetAmountWithFees.isZero()) { - return { - takerAssetAmount: constants.ZERO_AMOUNT, - feeTakerAssetAmount: constants.ZERO_AMOUNT, - }; - } - const takerFeeAmount = orderCalculationUtils.getTakerFeeAmount(order, takerAssetAmountWithFees); - const makerAssetFillAmount = orderCalculationUtils.getMakerFillAmount(order, takerAssetAmountWithFees); - const takerAssetAmount = takerFeeAmount - .div(makerAssetFillAmount) - .multipliedBy(takerAssetAmountWithFees) - .integerValue(BigNumber.ROUND_UP); - return { - takerAssetAmount, - feeTakerAssetAmount: takerAssetAmountWithFees.minus(takerAssetAmount), - }; - } +function fillResultsToQuoteInfo(fr: QuoteFillResult): SwapQuoteInfo { return { - feeTakerAssetAmount: constants.ZERO_AMOUNT, - takerAssetAmount: takerAssetAmountWithFees, + makerAssetAmount: fr.totalMakerAssetAmount, + takerAssetAmount: fr.takerAssetAmount, + totalTakerAssetAmount: fr.totalTakerAssetAmount, + feeTakerAssetAmount: fr.takerFeeTakerAssetAmount, + protocolFeeInWeiAmount: fr.protocolFeeAmount, + gas: fr.gas, }; } - -function getGasUsedByOrders(orders: OptimizedMarketOrder[], gasSchedule: { [source: string]: number }): number { - let totalUsage = 0; - for (const order of orders) { - totalUsage += gasSchedule[order.fill.source] || 0; - } - return totalUsage; -} -// tslint:disable: max-file-line-count diff --git a/packages/asset-swapper/test/exchange_swap_quote_consumer_test.ts b/packages/asset-swapper/test/exchange_swap_quote_consumer_test.ts index 3321aaac5f..06254a924b 100644 --- a/packages/asset-swapper/test/exchange_swap_quote_consumer_test.ts +++ b/packages/asset-swapper/test/exchange_swap_quote_consumer_test.ts @@ -12,7 +12,6 @@ import { SwapQuote } from '../src'; import { constants } from '../src/constants'; import { ExchangeSwapQuoteConsumer } from '../src/quote_consumers/exchange_swap_quote_consumer'; import { MarketOperation, SignedOrderWithFillableAmounts } from '../src/types'; -import { ProtocolFeeUtils } from '../src/utils/protocol_fee_utils'; import { chaiSetup } from './utils/chai_setup'; import { getFullyFillableSwapQuoteWithNoFeesAsync } from './utils/swap_quote'; @@ -60,7 +59,6 @@ const expectMakerAndTakerBalancesAsyncFactory = ( }; describe('ExchangeSwapQuoteConsumer', () => { - let protocolFeeUtils: ProtocolFeeUtils; let userAddresses: string[]; let erc20MakerTokenContract: ERC20TokenContract; let erc20TakerTokenContract: ERC20TokenContract; @@ -123,7 +121,6 @@ describe('ExchangeSwapQuoteConsumer', () => { }; const privateKey = devConstants.TESTRPC_PRIVATE_KEYS[userAddresses.indexOf(makerAddress)]; orderFactory = new OrderFactory(privateKey, defaultOrderParams); - protocolFeeUtils = new ProtocolFeeUtils(constants.PROTOCOL_FEE_UTILS_POLLING_INTERVAL_IN_MS, new BigNumber(1)); expectMakerAndTakerBalancesForTakerAssetAsync = expectMakerAndTakerBalancesAsyncFactory( erc20TakerTokenContract, makerAddress, @@ -156,7 +153,6 @@ describe('ExchangeSwapQuoteConsumer', () => { orders, MarketOperation.Sell, GAS_PRICE, - protocolFeeUtils, ); marketBuySwapQuote = await getFullyFillableSwapQuoteWithNoFeesAsync( @@ -165,7 +161,6 @@ describe('ExchangeSwapQuoteConsumer', () => { orders, MarketOperation.Buy, GAS_PRICE, - protocolFeeUtils, ); swapQuoteConsumer = new ExchangeSwapQuoteConsumer(provider, contractAddresses, { diff --git a/packages/asset-swapper/test/forwarder_swap_quote_consumer_test.ts b/packages/asset-swapper/test/forwarder_swap_quote_consumer_test.ts index 0113fe86ee..3c80e630e3 100644 --- a/packages/asset-swapper/test/forwarder_swap_quote_consumer_test.ts +++ b/packages/asset-swapper/test/forwarder_swap_quote_consumer_test.ts @@ -12,7 +12,6 @@ import { SwapQuote } from '../src'; import { constants } from '../src/constants'; import { ForwarderSwapQuoteConsumer } from '../src/quote_consumers/forwarder_swap_quote_consumer'; import { MarketOperation, SignedOrderWithFillableAmounts } from '../src/types'; -import { ProtocolFeeUtils } from '../src/utils/protocol_fee_utils'; import { chaiSetup } from './utils/chai_setup'; import { getFullyFillableSwapQuoteWithNoFeesAsync } from './utils/swap_quote'; @@ -61,7 +60,6 @@ const expectMakerAndTakerBalancesAsyncFactory = ( }; describe('ForwarderSwapQuoteConsumer', () => { - let protocolFeeUtils: ProtocolFeeUtils; let userAddresses: string[]; let coinbaseAddress: string; let makerAddress: string; @@ -126,7 +124,6 @@ describe('ForwarderSwapQuoteConsumer', () => { }; const privateKey = devConstants.TESTRPC_PRIVATE_KEYS[userAddresses.indexOf(makerAddress)]; orderFactory = new OrderFactory(privateKey, defaultOrderParams); - protocolFeeUtils = new ProtocolFeeUtils(constants.PROTOCOL_FEE_UTILS_POLLING_INTERVAL_IN_MS, new BigNumber(1)); expectMakerAndTakerBalancesAsync = expectMakerAndTakerBalancesAsyncFactory( erc20TokenContract, makerAddress, @@ -179,7 +176,6 @@ describe('ForwarderSwapQuoteConsumer', () => { orders, MarketOperation.Sell, GAS_PRICE, - protocolFeeUtils, ); marketBuySwapQuote = await getFullyFillableSwapQuoteWithNoFeesAsync( @@ -188,7 +184,6 @@ describe('ForwarderSwapQuoteConsumer', () => { orders, MarketOperation.Buy, GAS_PRICE, - protocolFeeUtils, ); invalidMarketBuySwapQuote = await getFullyFillableSwapQuoteWithNoFeesAsync( @@ -197,7 +192,6 @@ describe('ForwarderSwapQuoteConsumer', () => { invalidOrders, MarketOperation.Buy, GAS_PRICE, - protocolFeeUtils, ); swapQuoteConsumer = new ForwarderSwapQuoteConsumer(provider, contractAddresses, { diff --git a/packages/asset-swapper/test/market_operation_utils_test.ts b/packages/asset-swapper/test/market_operation_utils_test.ts index 87a809204c..b731bac836 100644 --- a/packages/asset-swapper/test/market_operation_utils_test.ts +++ b/packages/asset-swapper/test/market_operation_utils_test.ts @@ -299,6 +299,7 @@ describe('MarketOperationUtils tests', () => { maxFallbackSlippage: 100, excludedSources: Object.keys(DEFAULT_CURVE_OPTS) as ERC20BridgeSource[], allowFallback: false, + shouldBatchBridgeOrders: false, }; beforeEach(() => { @@ -422,7 +423,7 @@ describe('MarketOperationUtils tests', () => { ); expect(improvedOrders).to.not.be.length(0); for (const order of improvedOrders) { - const expectedMakerAmount = order.fill.totalMakerAssetAmount; + const expectedMakerAmount = order.fills[0].output; const slippage = 1 - order.makerAssetAmount.div(expectedMakerAmount.plus(1)).toNumber(); assertRoughlyEquals(slippage, bridgeSlippage, 1); } @@ -442,7 +443,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4 }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, @@ -466,7 +467,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4 }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); if (orderSources.includes(ERC20BridgeSource.Kyber)) { expect(orderSources).to.not.include(ERC20BridgeSource.Uniswap); expect(orderSources).to.not.include(ERC20BridgeSource.Eth2Dai); @@ -501,7 +502,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, feeSchedule }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Native, ERC20BridgeSource.Uniswap, @@ -536,7 +537,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, feeSchedule }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Native, ERC20BridgeSource.Eth2Dai, @@ -561,7 +562,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4 }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, @@ -584,7 +585,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, allowFallback: true }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const firstSources = [ ERC20BridgeSource.Native, ERC20BridgeSource.Native, @@ -610,7 +611,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, allowFallback: true, maxFallbackSlippage: 0.5 }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const firstSources = [ERC20BridgeSource.Native, ERC20BridgeSource.Native, ERC20BridgeSource.Uniswap]; const secondSources: ERC20BridgeSource[] = []; expect(orderSources.slice(0, firstSources.length).sort()).to.deep.eq(firstSources.sort()); @@ -672,6 +673,40 @@ describe('MarketOperationUtils tests', () => { expect(getLiquidityProviderParams.makerToken).is.eql(yAsset); expect(getLiquidityProviderParams.takerToken).is.eql(xAsset); }); + + it('batches contiguous bridge sources', async () => { + const rates: RatesBySource = {}; + rates[ERC20BridgeSource.Uniswap] = [1, 0.01, 0.01, 0.01]; + rates[ERC20BridgeSource.Native] = [0.5, 0.01, 0.01, 0.01]; + rates[ERC20BridgeSource.Eth2Dai] = [0.49, 0.01, 0.01, 0.01]; + rates[ERC20BridgeSource.CurveUsdcDai] = [0.48, 0.01, 0.01, 0.01]; + replaceSamplerOps({ + getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), + }); + const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( + createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), + FILL_AMOUNT, + { + ...DEFAULT_OPTS, + numSamples: 4, + excludedSources: [ + ERC20BridgeSource.Kyber, + ..._.without(DEFAULT_OPTS.excludedSources, ERC20BridgeSource.CurveUsdcDai), + ] as ERC20BridgeSource[], + shouldBatchBridgeOrders: true, + }, + ); + expect(improvedOrders).to.be.length(3); + const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source)); + expect(orderFillSources).to.deep.eq([ + [ERC20BridgeSource.Uniswap], + [ERC20BridgeSource.Native], + [ + ERC20BridgeSource.Eth2Dai, + ERC20BridgeSource.CurveUsdcDai, + ], + ]); + }); }); describe('getMarketBuyOrdersAsync()', () => { @@ -687,6 +722,7 @@ describe('MarketOperationUtils tests', () => { maxFallbackSlippage: 100, excludedSources: Object.keys(DEFAULT_CURVE_OPTS) as ERC20BridgeSource[], allowFallback: false, + shouldBatchBridgeOrders: false, }; beforeEach(() => { @@ -789,7 +825,7 @@ describe('MarketOperationUtils tests', () => { } }); - it('generates bridge orders with correct taker amount', async () => { + it('generates bridge orders with correct maker amount', async () => { const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync( // Pass in empty orders to prevent native orders from being used. ORDERS.map(o => ({ ...o, makerAssetAmount: constants.ZERO_AMOUNT })), @@ -810,7 +846,7 @@ describe('MarketOperationUtils tests', () => { ); expect(improvedOrders).to.not.be.length(0); for (const order of improvedOrders) { - const expectedTakerAmount = order.fill.totalTakerAssetAmount; + const expectedTakerAmount = order.fills[0].output; const slippage = order.takerAssetAmount.div(expectedTakerAmount.plus(1)).toNumber() - 1; assertRoughlyEquals(slippage, bridgeSlippage, 1); } @@ -829,7 +865,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4 }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, @@ -865,7 +901,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, feeSchedule }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai, @@ -899,7 +935,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, feeSchedule }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const expectedSources = [ ERC20BridgeSource.Native, ERC20BridgeSource.Eth2Dai, @@ -921,7 +957,7 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, allowFallback: true }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const firstSources = [ ERC20BridgeSource.Native, ERC20BridgeSource.Native, @@ -946,12 +982,40 @@ describe('MarketOperationUtils tests', () => { FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, allowFallback: true, maxFallbackSlippage: 0.5 }, ); - const orderSources = improvedOrders.map(o => o.fill.source); + const orderSources = improvedOrders.map(o => o.fills[0].source); const firstSources = [ERC20BridgeSource.Native, ERC20BridgeSource.Native, ERC20BridgeSource.Uniswap]; const secondSources: ERC20BridgeSource[] = []; expect(orderSources.slice(0, firstSources.length).sort()).to.deep.eq(firstSources.sort()); expect(orderSources.slice(firstSources.length).sort()).to.deep.eq(secondSources.sort()); }); + + it('batches contiguous bridge sources', async () => { + const rates: RatesBySource = {}; + rates[ERC20BridgeSource.Native] = [0.5, 0.01, 0.01, 0.01]; + rates[ERC20BridgeSource.Eth2Dai] = [0.49, 0.01, 0.01, 0.01]; + rates[ERC20BridgeSource.Uniswap] = [0.48, 0.47, 0.01, 0.01]; + replaceSamplerOps({ + getBuyQuotes: createGetMultipleBuyQuotesOperationFromRates(rates), + }); + const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync( + createOrdersFromBuyRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), + FILL_AMOUNT, + { + ...DEFAULT_OPTS, + numSamples: 4, + shouldBatchBridgeOrders: true, + }, + ); + expect(improvedOrders).to.be.length(2); + const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source)); + expect(orderFillSources).to.deep.eq([ + [ERC20BridgeSource.Native], + [ + ERC20BridgeSource.Eth2Dai, + ERC20BridgeSource.Uniswap, + ], + ]); + }); }); }); }); diff --git a/packages/asset-swapper/test/swap_quote_consumer_utils_test.ts b/packages/asset-swapper/test/swap_quote_consumer_utils_test.ts index 5c80acaee0..3fe2fe4919 100644 --- a/packages/asset-swapper/test/swap_quote_consumer_utils_test.ts +++ b/packages/asset-swapper/test/swap_quote_consumer_utils_test.ts @@ -11,7 +11,6 @@ import 'mocha'; import { SwapQuote, SwapQuoteConsumer } from '../src'; import { constants } from '../src/constants'; import { ExtensionContractType, MarketOperation, SignedOrderWithFillableAmounts } from '../src/types'; -import { ProtocolFeeUtils } from '../src/utils/protocol_fee_utils'; import { chaiSetup } from './utils/chai_setup'; import { getFullyFillableSwapQuoteWithNoFeesAsync } from './utils/swap_quote'; @@ -69,7 +68,6 @@ const PARTIAL_LARGE_PRUNED_SIGNED_ORDERS: Array { let wethContract: WETH9Contract; - let protocolFeeUtils: ProtocolFeeUtils; let userAddresses: string[]; let makerAddress: string; let takerAddress: string; @@ -119,7 +117,6 @@ describe('swapQuoteConsumerUtils', () => { }; const privateKey = devConstants.TESTRPC_PRIVATE_KEYS[userAddresses.indexOf(makerAddress)]; orderFactory = new OrderFactory(privateKey, defaultOrderParams); - protocolFeeUtils = new ProtocolFeeUtils(constants.PROTOCOL_FEE_UTILS_POLLING_INTERVAL_IN_MS, new BigNumber(1)); forwarderOrderFactory = new OrderFactory(privateKey, defaultForwarderOrderParams); swapQuoteConsumer = new SwapQuoteConsumer(provider, { @@ -128,7 +125,6 @@ describe('swapQuoteConsumerUtils', () => { }); after(async () => { await blockchainLifecycle.revertAsync(); - await protocolFeeUtils.destroyAsync(); }); beforeEach(async () => { await blockchainLifecycle.startAsync(); @@ -182,7 +178,6 @@ describe('swapQuoteConsumerUtils', () => { forwarderOrders, MarketOperation.Sell, GAS_PRICE, - protocolFeeUtils, ); largeForwarderSwapQuote = await getFullyFillableSwapQuoteWithNoFeesAsync( @@ -191,7 +186,6 @@ describe('swapQuoteConsumerUtils', () => { largeForwarderOrders, MarketOperation.Sell, GAS_PRICE, - protocolFeeUtils, ); exchangeSwapQuote = await getFullyFillableSwapQuoteWithNoFeesAsync( @@ -200,7 +194,6 @@ describe('swapQuoteConsumerUtils', () => { exchangeOrders, MarketOperation.Sell, GAS_PRICE, - protocolFeeUtils, ); }); diff --git a/packages/asset-swapper/test/utils/mocks.ts b/packages/asset-swapper/test/utils/mocks.ts index 4b21904040..d7be2985ec 100644 --- a/packages/asset-swapper/test/utils/mocks.ts +++ b/packages/asset-swapper/test/utils/mocks.ts @@ -57,10 +57,6 @@ const partiallyMockedSwapQuoter = (provider: Web3ProviderEngine, orderbook: Orde }; class ProtocolFeeUtilsClass extends ProtocolFeeUtils { - // tslint:disable-next-line:prefer-function-over-method - public async getProtocolFeeMultiplierAsync(): Promise { - return new BigNumber(PROTOCOL_FEE_MULTIPLIER); - } // tslint:disable-next-line:prefer-function-over-method public async getGasPriceEstimationOrThrowAsync(_shouldHardRefresh?: boolean): Promise { return new BigNumber(devConstants.DEFAULT_GAS_PRICE); diff --git a/packages/asset-swapper/test/utils/swap_quote.ts b/packages/asset-swapper/test/utils/swap_quote.ts index c347602961..de3bde6e72 100644 --- a/packages/asset-swapper/test/utils/swap_quote.ts +++ b/packages/asset-swapper/test/utils/swap_quote.ts @@ -4,7 +4,6 @@ import * as _ from 'lodash'; import { ERC20BridgeSource } from '../../src'; import { constants } from '../../src/constants'; import { MarketOperation, SignedOrderWithFillableAmounts, SwapQuote } from '../../src/types'; -import { ProtocolFeeUtils } from '../../src/utils/protocol_fee_utils'; /** * Creates a swap quote given orders. @@ -15,16 +14,16 @@ export async function getFullyFillableSwapQuoteWithNoFeesAsync( orders: SignedOrderWithFillableAmounts[], operation: MarketOperation, gasPrice: BigNumber, - protocolFeeUtils: ProtocolFeeUtils, ): Promise { const makerAssetFillAmount = BigNumber.sum(...[0, ...orders.map(o => o.makerAssetAmount)]); const totalTakerAssetAmount = BigNumber.sum(...[0, ...orders.map(o => o.takerAssetAmount)]); + const protocolFeePerOrder = constants.PROTOCOL_FEE_MULTIPLIER.times(gasPrice); const quoteInfo = { makerAssetAmount: makerAssetFillAmount, feeTakerAssetAmount: constants.ZERO_AMOUNT, takerAssetAmount: totalTakerAssetAmount, totalTakerAssetAmount, - protocolFeeInWeiAmount: await protocolFeeUtils.calculateWorstCaseProtocolFeeAsync(orders, gasPrice), + protocolFeeInWeiAmount: protocolFeePerOrder.times(orders.length), gas: 200e3, };