import { getContractAddressesForChainOrThrow } from '@0x/contract-addresses'; import { assertRoughlyEquals, constants, expect, getRandomFloat, getRandomInteger, Numberish, randomAddress, } from '@0x/contracts-test-utils'; import { Web3Wrapper } from '@0x/dev-utils'; import { assetDataUtils, generatePseudoRandomSalt } from '@0x/order-utils'; import { AssetProxyId, ERC20BridgeAssetData, SignedOrder } from '@0x/types'; import { BigNumber, fromTokenUnitAmount, hexUtils, NULL_ADDRESS } from '@0x/utils'; import * as _ from 'lodash'; import { constants as assetSwapperConstants } from '../src/constants'; import { MarketOperationUtils } from '../src/utils/market_operation_utils/'; import { constants as marketOperationUtilConstants } from '../src/utils/market_operation_utils/constants'; import { DexOrderSampler } from '../src/utils/market_operation_utils/sampler'; import { DexSample, ERC20BridgeSource } from '../src/utils/market_operation_utils/types'; const { BUY_SOURCES, SELL_SOURCES } = marketOperationUtilConstants; // tslint:disable: custom-no-magic-numbers describe('MarketOperationUtils tests', () => { const CHAIN_ID = 1; const contractAddresses = getContractAddressesForChainOrThrow(CHAIN_ID); const ETH2DAI_BRIDGE_ADDRESS = contractAddresses.eth2DaiBridge; const KYBER_BRIDGE_ADDRESS = contractAddresses.kyberBridge; const UNISWAP_BRIDGE_ADDRESS = contractAddresses.uniswapBridge; const CURVE_BRIDGE_ADDRESS = contractAddresses.curveBridge; const MAKER_TOKEN = randomAddress(); const TAKER_TOKEN = randomAddress(); const MAKER_ASSET_DATA = assetDataUtils.encodeERC20AssetData(MAKER_TOKEN); const TAKER_ASSET_DATA = assetDataUtils.encodeERC20AssetData(TAKER_TOKEN); let originalSamplerOperations: any; before(() => { originalSamplerOperations = DexOrderSampler.ops; }); after(() => { DexOrderSampler.ops = originalSamplerOperations; }); function createOrder(overrides?: Partial): SignedOrder { return { chainId: CHAIN_ID, exchangeAddress: contractAddresses.exchange, makerAddress: constants.NULL_ADDRESS, takerAddress: constants.NULL_ADDRESS, senderAddress: constants.NULL_ADDRESS, feeRecipientAddress: randomAddress(), salt: generatePseudoRandomSalt(), expirationTimeSeconds: getRandomInteger(0, 2 ** 64), makerAssetData: MAKER_ASSET_DATA, takerAssetData: TAKER_ASSET_DATA, makerFeeAssetData: constants.NULL_BYTES, takerFeeAssetData: constants.NULL_BYTES, makerAssetAmount: getRandomInteger(1, 1e18), takerAssetAmount: getRandomInteger(1, 1e18), makerFee: constants.ZERO_AMOUNT, takerFee: constants.ZERO_AMOUNT, signature: hexUtils.random(), ...overrides, }; } function getSourceFromAssetData(assetData: string): ERC20BridgeSource { if (assetData.length === 74) { return ERC20BridgeSource.Native; } const bridgeAddress = hexUtils.slice(assetData, 48, 68).toLowerCase(); switch (bridgeAddress) { case KYBER_BRIDGE_ADDRESS.toLowerCase(): return ERC20BridgeSource.Kyber; case ETH2DAI_BRIDGE_ADDRESS.toLowerCase(): return ERC20BridgeSource.Eth2Dai; case UNISWAP_BRIDGE_ADDRESS.toLowerCase(): return ERC20BridgeSource.Uniswap; case CURVE_BRIDGE_ADDRESS.toLowerCase(): const curveSource = Object.keys(assetSwapperConstants.DEFAULT_CURVE_OPTS).filter( k => assetData.indexOf(assetSwapperConstants.DEFAULT_CURVE_OPTS[k].curveAddress.slice(2)) !== -1, ); return curveSource[0] as ERC20BridgeSource; default: break; } throw new Error(`Unknown bridge address: ${bridgeAddress}`); } function assertSamePrefix(actual: string, expected: string): void { expect(actual.substr(0, expected.length)).to.eq(expected); } function createOrdersFromSellRates(takerAssetAmount: BigNumber, rates: Numberish[]): SignedOrder[] { const singleTakerAssetAmount = takerAssetAmount.div(rates.length).integerValue(BigNumber.ROUND_UP); return rates.map(r => createOrder({ makerAssetAmount: singleTakerAssetAmount.times(r).integerValue(), takerAssetAmount: singleTakerAssetAmount, }), ); } function createOrdersFromBuyRates(makerAssetAmount: BigNumber, rates: Numberish[]): SignedOrder[] { const singleMakerAssetAmount = makerAssetAmount.div(rates.length).integerValue(BigNumber.ROUND_UP); return rates.map(r => createOrder({ makerAssetAmount: singleMakerAssetAmount, takerAssetAmount: singleMakerAssetAmount.div(r).integerValue(), }), ); } const ORDER_DOMAIN = { exchangeAddress: contractAddresses.exchange, chainId: CHAIN_ID, }; type GetQuotesOperation = (makerToken: string, takerToken: string, fillAmounts: BigNumber[]) => BigNumber[]; function createGetSellQuotesOperationFromRates(rates: Numberish[]): GetQuotesOperation { return (...args) => { const fillAmounts = args.pop() as BigNumber[]; return fillAmounts.map((a, i) => a.times(rates[i]).integerValue()); }; } function createGetBuyQuotesOperationFromRates(rates: Numberish[]): GetQuotesOperation { return (...args) => { const fillAmounts = args.pop() as BigNumber[]; return fillAmounts.map((a, i) => a.div(rates[i]).integerValue()); }; } type GetMultipleQuotesOperation = ( sources: ERC20BridgeSource[], makerToken: string, takerToken: string, fillAmounts: BigNumber[], liquidityProviderAddress?: string, ) => DexSample[][]; function createGetMultipleSellQuotesOperationFromRates(rates: RatesBySource): GetMultipleQuotesOperation { return (sources: ERC20BridgeSource[], makerToken: string, takerToken: string, fillAmounts: BigNumber[]) => { return sources.map(s => fillAmounts.map((a, i) => ({ source: s, input: a, output: a.times(rates[s][i]).integerValue(), })), ); }; } function callTradeOperationAndRetainLiquidityProviderParams( tradeOperation: (rates: RatesBySource) => GetMultipleQuotesOperation, rates: RatesBySource, ): [{ sources: ERC20BridgeSource[]; liquidityProviderAddress?: string }, GetMultipleQuotesOperation] { const liquidityPoolParams: { sources: ERC20BridgeSource[]; liquidityProviderAddress?: string } = { sources: [], liquidityProviderAddress: undefined, }; const fn = ( sources: ERC20BridgeSource[], makerToken: string, takerToken: string, fillAmounts: BigNumber[], liquidityProviderAddress?: string, ) => { liquidityPoolParams.liquidityProviderAddress = liquidityProviderAddress; liquidityPoolParams.sources = sources; return tradeOperation(rates)(sources, makerToken, takerToken, fillAmounts, liquidityProviderAddress); }; return [liquidityPoolParams, fn]; } function createGetMultipleBuyQuotesOperationFromRates(rates: RatesBySource): GetMultipleQuotesOperation { return (sources: ERC20BridgeSource[], makerToken: string, takerToken: string, fillAmounts: BigNumber[]) => { return sources.map(s => fillAmounts.map((a, i) => ({ source: s, input: a, output: a.div(rates[s][i]).integerValue(), })), ); }; } type GetMedianRateOperation = ( sources: ERC20BridgeSource[], makerToken: string, takerToken: string, fillAmounts: BigNumber[], liquidityProviderAddress?: string, ) => BigNumber; type GetLiquidityProviderFromRegistryOperation = ( registryAddress: string, takerToken: string, makerToken: string, ) => string; function createGetMedianSellRate(rate: Numberish): GetMedianRateOperation { return (sources: ERC20BridgeSource[], makerToken: string, takerToken: string, fillAmounts: BigNumber[]) => { return new BigNumber(rate); }; } function getLiquidityProviderFromRegistry(): GetLiquidityProviderFromRegistryOperation { return (registryAddress: string, takerToken: string, makerToken: string): string => { return NULL_ADDRESS; }; } function getLiquidityProviderFromRegistryAndReturnCallParameters( liquidityProviderAddress: string = NULL_ADDRESS, ): [ { registryAddress?: string; takerToken?: string; makerToken?: string }, GetLiquidityProviderFromRegistryOperation ] { const callArgs: { registryAddress?: string; takerToken?: string; makerToken?: string } = { registryAddress: undefined, takerToken: undefined, makerToken: undefined, }; const fn = (registryAddress: string, takerToken: string, makerToken: string): string => { callArgs.makerToken = makerToken; callArgs.takerToken = takerToken; callArgs.registryAddress = registryAddress; return liquidityProviderAddress; }; return [callArgs, fn]; } function createDecreasingRates(count: number): BigNumber[] { const rates: BigNumber[] = []; const initialRate = getRandomFloat(1e-3, 1e2); _.times(count, () => getRandomFloat(0.95, 1)).forEach((r, i) => { const prevRate = i === 0 ? initialRate : rates[i - 1]; rates.push(prevRate.times(r)); }); return rates; } const NUM_SAMPLES = 3; interface RatesBySource { [source: string]: Numberish[]; } const DEFAULT_RATES: RatesBySource = { [ERC20BridgeSource.Native]: createDecreasingRates(NUM_SAMPLES), [ERC20BridgeSource.Eth2Dai]: createDecreasingRates(NUM_SAMPLES), [ERC20BridgeSource.Kyber]: createDecreasingRates(NUM_SAMPLES), [ERC20BridgeSource.Uniswap]: createDecreasingRates(NUM_SAMPLES), [ERC20BridgeSource.CurveUsdcDai]: _.times(NUM_SAMPLES, () => 0), [ERC20BridgeSource.CurveUsdcDaiUsdt]: _.times(NUM_SAMPLES, () => 0), [ERC20BridgeSource.CurveUsdcDaiUsdtTusd]: _.times(NUM_SAMPLES, () => 0), [ERC20BridgeSource.CurveUsdcDaiUsdtBusd]: _.times(NUM_SAMPLES, () => 0), [ERC20BridgeSource.LiquidityProvider]: _.times(NUM_SAMPLES, () => 0), }; function findSourceWithMaxOutput(rates: RatesBySource): ERC20BridgeSource { const minSourceRates = Object.keys(rates).map(s => _.last(rates[s]) as BigNumber); const bestSourceRate = BigNumber.max(...minSourceRates); let source = Object.keys(rates)[_.findIndex(minSourceRates, t => bestSourceRate.eq(t))] as ERC20BridgeSource; // Native order rates play by different rules. if (source !== ERC20BridgeSource.Native) { const nativeTotalRate = BigNumber.sum(...rates[ERC20BridgeSource.Native]).div( rates[ERC20BridgeSource.Native].length, ); if (nativeTotalRate.gt(bestSourceRate)) { source = ERC20BridgeSource.Native; } } return source; } const DEFAULT_OPS = { getOrderFillableTakerAmounts(orders: SignedOrder[]): BigNumber[] { return orders.map(o => o.takerAssetAmount); }, getOrderFillableMakerAmounts(orders: SignedOrder[]): BigNumber[] { return orders.map(o => o.makerAssetAmount); }, getKyberSellQuotes: createGetSellQuotesOperationFromRates(DEFAULT_RATES[ERC20BridgeSource.Kyber]), getUniswapSellQuotes: createGetSellQuotesOperationFromRates(DEFAULT_RATES[ERC20BridgeSource.Uniswap]), getEth2DaiSellQuotes: createGetSellQuotesOperationFromRates(DEFAULT_RATES[ERC20BridgeSource.Eth2Dai]), getUniswapBuyQuotes: createGetBuyQuotesOperationFromRates(DEFAULT_RATES[ERC20BridgeSource.Uniswap]), getEth2DaiBuyQuotes: createGetBuyQuotesOperationFromRates(DEFAULT_RATES[ERC20BridgeSource.Eth2Dai]), getCurveSellQuotes: createGetSellQuotesOperationFromRates(DEFAULT_RATES[ERC20BridgeSource.CurveUsdcDai]), getSellQuotes: createGetMultipleSellQuotesOperationFromRates(DEFAULT_RATES), getBuyQuotes: createGetMultipleBuyQuotesOperationFromRates(DEFAULT_RATES), getMedianSellRate: createGetMedianSellRate(1), getLiquidityProviderFromRegistry: getLiquidityProviderFromRegistry(), }; function replaceSamplerOps(ops: Partial = {}): void { DexOrderSampler.ops = { ...DEFAULT_OPS, ...ops, } as any; } const MOCK_SAMPLER = ({ async executeAsync(...ops: any[]): Promise { return ops; }, async executeBatchAsync(ops: any[]): Promise { return ops; }, } as any) as DexOrderSampler; describe('MarketOperationUtils', () => { let marketOperationUtils: MarketOperationUtils; before(async () => { marketOperationUtils = new MarketOperationUtils(MOCK_SAMPLER, contractAddresses, ORDER_DOMAIN); }); describe('getMarketSellOrdersAsync()', () => { const FILL_AMOUNT = getRandomInteger(1, 1e18); const ORDERS = createOrdersFromSellRates( FILL_AMOUNT, _.times(NUM_SAMPLES, i => DEFAULT_RATES[ERC20BridgeSource.Native][i]), ); const DEFAULT_OPTS = { numSamples: NUM_SAMPLES, runLimit: 0, sampleDistributionBase: 1, bridgeSlippage: 0, excludedSources: Object.keys(assetSwapperConstants.DEFAULT_CURVE_OPTS) as ERC20BridgeSource[], }; beforeEach(() => { replaceSamplerOps(); }); it('queries `numSamples` samples', async () => { const numSamples = _.random(1, 16); let actualNumSamples = 0; replaceSamplerOps({ getSellQuotes: (sources, makerToken, takerToken, amounts) => { actualNumSamples = amounts.length; return DEFAULT_OPS.getSellQuotes(sources, makerToken, takerToken, amounts); }, }); await marketOperationUtils.getMarketSellOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples, }); expect(actualNumSamples).eq(numSamples); }); it('polls all DEXes if `excludedSources` is empty', async () => { let sourcesPolled: ERC20BridgeSource[] = []; replaceSamplerOps({ getSellQuotes: (sources, makerToken, takerToken, amounts) => { sourcesPolled = sources.slice(); return DEFAULT_OPS.getSellQuotes(sources, makerToken, takerToken, amounts); }, }); await marketOperationUtils.getMarketSellOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, excludedSources: [], }); expect(sourcesPolled.sort()).to.deep.eq(SELL_SOURCES.slice().sort()); }); it('polls the liquidity provider when the registry is provided in the arguments', async () => { const [args, fn] = callTradeOperationAndRetainLiquidityProviderParams( createGetMultipleSellQuotesOperationFromRates, DEFAULT_RATES, ); replaceSamplerOps({ getSellQuotes: fn, }); const registryAddress = randomAddress(); const newMarketOperationUtils = new MarketOperationUtils( MOCK_SAMPLER, contractAddresses, ORDER_DOMAIN, registryAddress, ); await newMarketOperationUtils.getMarketSellOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, excludedSources: [], }); expect(args.sources.sort()).to.deep.eq( SELL_SOURCES.concat([ERC20BridgeSource.LiquidityProvider]).sort(), ); expect(args.liquidityProviderAddress).to.eql(registryAddress); }); it('does not poll DEXes in `excludedSources`', async () => { const excludedSources = _.sampleSize(SELL_SOURCES, _.random(1, SELL_SOURCES.length)); let sourcesPolled: ERC20BridgeSource[] = []; replaceSamplerOps({ getSellQuotes: (sources, makerToken, takerToken, amounts) => { sourcesPolled = sources.slice(); return DEFAULT_OPS.getSellQuotes(sources, makerToken, takerToken, amounts); }, }); await marketOperationUtils.getMarketSellOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, excludedSources, }); expect(sourcesPolled.sort()).to.deep.eq(_.without(SELL_SOURCES, ...excludedSources).sort()); }); it('returns the most cost-effective single source if `runLimit == 0`', async () => { const bestSource = findSourceWithMaxOutput(DEFAULT_RATES); expect(bestSource).to.exist(''); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, runLimit: 0, }); const uniqueAssetDatas = _.uniq(improvedOrders.map(o => o.makerAssetData)); expect(uniqueAssetDatas).to.be.length(1); expect(getSourceFromAssetData(uniqueAssetDatas[0])).to.be.eq(bestSource); }); it('generates bridge orders with correct asset data', async () => { const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( // Pass in empty orders to prevent native orders from being used. ORDERS.map(o => ({ ...o, makerAssetAmount: constants.ZERO_AMOUNT })), FILL_AMOUNT, DEFAULT_OPTS, ); expect(improvedOrders).to.not.be.length(0); for (const order of improvedOrders) { expect(getSourceFromAssetData(order.makerAssetData)).to.exist(''); const makerAssetDataPrefix = hexUtils.slice( assetDataUtils.encodeERC20BridgeAssetData( MAKER_TOKEN, constants.NULL_ADDRESS, constants.NULL_BYTES, ), 0, 36, ); assertSamePrefix(order.makerAssetData, makerAssetDataPrefix); expect(order.takerAssetData).to.eq(TAKER_ASSET_DATA); } }); it('generates bridge orders with correct taker amount', async () => { const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( // Pass in empty orders to prevent native orders from being used. ORDERS.map(o => ({ ...o, makerAssetAmount: constants.ZERO_AMOUNT })), FILL_AMOUNT, DEFAULT_OPTS, ); const totalTakerAssetAmount = BigNumber.sum(...improvedOrders.map(o => o.takerAssetAmount)); expect(totalTakerAssetAmount).to.bignumber.gte(FILL_AMOUNT); }); it('generates bridge orders with max slippage of `bridgeSlippage`', async () => { const bridgeSlippage = _.random(0.1, true); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( // Pass in empty orders to prevent native orders from being used. ORDERS.map(o => ({ ...o, makerAssetAmount: constants.ZERO_AMOUNT })), FILL_AMOUNT, { ...DEFAULT_OPTS, bridgeSlippage }, ); expect(improvedOrders).to.not.be.length(0); for (const order of improvedOrders) { const source = getSourceFromAssetData(order.makerAssetData); const expectedMakerAmount = FILL_AMOUNT.times(_.last(DEFAULT_RATES[source]) as BigNumber); const slippage = 1 - order.makerAssetAmount.div(expectedMakerAmount.plus(1)).toNumber(); assertRoughlyEquals(slippage, bridgeSlippage, 8); } }); it('can mix convex sources', async () => { const rates: RatesBySource = {}; rates[ERC20BridgeSource.Native] = [0.4, 0.3, 0.2, 0.1]; rates[ERC20BridgeSource.Uniswap] = [0.5, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Eth2Dai] = [0.6, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Kyber] = [0.7, 0.05, 0.05, 0.05]; replaceSamplerOps({ getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), }); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, noConflicts: false }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Kyber, ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, ERC20BridgeSource.Native, ]; expect(orderSources).to.deep.eq(expectedSources); }); it('excludes Kyber when `noConflicts` enabled and Uniswap or Eth2Dai are used first', async () => { const rates: RatesBySource = {}; rates[ERC20BridgeSource.Native] = [0.3, 0.2, 0.1, 0.05]; rates[ERC20BridgeSource.Uniswap] = [0.5, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Eth2Dai] = [0.6, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Kyber] = [0.4, 0.05, 0.05, 0.05]; replaceSamplerOps({ getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), }); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, noConflicts: true }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, ERC20BridgeSource.Native, ERC20BridgeSource.Native, ]; expect(orderSources).to.deep.eq(expectedSources); }); it('excludes Uniswap and Eth2Dai when `noConflicts` enabled and Kyber is used first', async () => { const rates: RatesBySource = {}; rates[ERC20BridgeSource.Native] = [0.1, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Uniswap] = [0.15, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Eth2Dai] = [0.15, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Kyber] = [0.7, 0.05, 0.05, 0.05]; replaceSamplerOps({ getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), }); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, noConflicts: true }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Kyber, ERC20BridgeSource.Native, ERC20BridgeSource.Native, ERC20BridgeSource.Native, ]; expect(orderSources).to.deep.eq(expectedSources); }); const ETH_TO_MAKER_RATE = 1.5; it('factors in fees for native orders', async () => { // Native orders will have the best rates but have fees, // dropping their effective rates. const nativeFeeRate = 0.06; const rates: RatesBySource = { [ERC20BridgeSource.Native]: [1, 0.99, 0.98, 0.97], // Effectively [0.94, ~0.93, ~0.92, ~0.91] [ERC20BridgeSource.Uniswap]: [0.96, 0.1, 0.1, 0.1], [ERC20BridgeSource.Eth2Dai]: [0.95, 0.1, 0.1, 0.1], [ERC20BridgeSource.Kyber]: [0.1, 0.1, 0.1, 0.1], }; const fees = { [ERC20BridgeSource.Native]: FILL_AMOUNT.div(4) .times(nativeFeeRate) .dividedToIntegerBy(ETH_TO_MAKER_RATE), }; replaceSamplerOps({ getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), getMedianSellRate: createGetMedianSellRate(ETH_TO_MAKER_RATE), }); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, noConflicts: false, fees }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Native, ERC20BridgeSource.Native, ]; expect(orderSources).to.deep.eq(expectedSources); }); it('factors in fees for dexes', async () => { // Kyber will have the best rates but will have fees, // dropping its effective rates. const kyberFeeRate = 0.2; const rates: RatesBySource = { [ERC20BridgeSource.Native]: [0.95, 0.1, 0.1, 0.1], [ERC20BridgeSource.Uniswap]: [0.1, 0.1, 0.1, 0.1], [ERC20BridgeSource.Eth2Dai]: [0.92, 0.1, 0.1, 0.1], // Effectively [0.8, ~0.5, ~0, ~0] [ERC20BridgeSource.Kyber]: [1, 0.7, 0.2, 0.2], }; const fees = { [ERC20BridgeSource.Kyber]: FILL_AMOUNT.div(4) .times(kyberFeeRate) .dividedToIntegerBy(ETH_TO_MAKER_RATE), }; replaceSamplerOps({ getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), getMedianSellRate: createGetMedianSellRate(ETH_TO_MAKER_RATE), }); const improvedOrders = await marketOperationUtils.getMarketSellOrdersAsync( createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, noConflicts: false, fees }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ERC20BridgeSource.Native, ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Kyber]; expect(orderSources).to.deep.eq(expectedSources); }); }); describe('getMarketBuyOrdersAsync()', () => { const FILL_AMOUNT = getRandomInteger(1, 1e18); const ORDERS = createOrdersFromBuyRates( FILL_AMOUNT, _.times(NUM_SAMPLES, () => DEFAULT_RATES[ERC20BridgeSource.Native][0]), ); const DEFAULT_OPTS = { numSamples: NUM_SAMPLES, runLimit: 0, sampleDistributionBase: 1, excludedSources: Object.keys(assetSwapperConstants.DEFAULT_CURVE_OPTS) as ERC20BridgeSource[], }; beforeEach(() => { replaceSamplerOps(); }); it('queries `numSamples` samples', async () => { const numSamples = _.random(1, 16); let actualNumSamples = 0; replaceSamplerOps({ getBuyQuotes: (sources, makerToken, takerToken, amounts) => { actualNumSamples = amounts.length; return DEFAULT_OPS.getBuyQuotes(sources, makerToken, takerToken, amounts); }, }); await marketOperationUtils.getMarketBuyOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples, }); expect(actualNumSamples).eq(numSamples); }); it('polls all DEXes if `excludedSources` is empty', async () => { let sourcesPolled: ERC20BridgeSource[] = []; replaceSamplerOps({ getBuyQuotes: (sources, makerToken, takerToken, amounts) => { sourcesPolled = sources.slice(); return DEFAULT_OPS.getBuyQuotes(sources, makerToken, takerToken, amounts); }, }); await marketOperationUtils.getMarketBuyOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, excludedSources: [], }); expect(sourcesPolled).to.deep.eq(BUY_SOURCES); }); it('polls the liquidity provider when the registry is provided in the arguments', async () => { const [args, fn] = callTradeOperationAndRetainLiquidityProviderParams( createGetMultipleBuyQuotesOperationFromRates, DEFAULT_RATES, ); replaceSamplerOps({ getBuyQuotes: fn, }); const registryAddress = randomAddress(); const newMarketOperationUtils = new MarketOperationUtils( MOCK_SAMPLER, contractAddresses, ORDER_DOMAIN, registryAddress, ); await newMarketOperationUtils.getMarketBuyOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, excludedSources: [], }); expect(args.sources.sort()).to.deep.eq( BUY_SOURCES.concat([ERC20BridgeSource.LiquidityProvider]).sort(), ); expect(args.liquidityProviderAddress).to.eql(registryAddress); }); it('does not poll DEXes in `excludedSources`', async () => { const excludedSources = _.sampleSize(SELL_SOURCES, _.random(1, SELL_SOURCES.length)); let sourcesPolled: ERC20BridgeSource[] = []; replaceSamplerOps({ getBuyQuotes: (sources, makerToken, takerToken, amounts) => { sourcesPolled = sources.slice(); return DEFAULT_OPS.getBuyQuotes(sources, makerToken, takerToken, amounts); }, }); await marketOperationUtils.getMarketBuyOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, excludedSources, }); expect(sourcesPolled).to.deep.eq(_.without(BUY_SOURCES, ...excludedSources)); }); it('returns the most cost-effective single source if `runLimit == 0`', async () => { const bestSource = findSourceWithMaxOutput( _.omit( DEFAULT_RATES, ERC20BridgeSource.Kyber, ERC20BridgeSource.CurveUsdcDai, ERC20BridgeSource.CurveUsdcDaiUsdt, ERC20BridgeSource.CurveUsdcDaiUsdtTusd, ), ); expect(bestSource).to.exist(''); const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync(ORDERS, FILL_AMOUNT, { ...DEFAULT_OPTS, runLimit: 0, }); const uniqueAssetDatas = _.uniq(improvedOrders.map(o => o.makerAssetData)); expect(uniqueAssetDatas).to.be.length(1); expect(getSourceFromAssetData(uniqueAssetDatas[0])).to.be.eq(bestSource); }); it('generates bridge orders with correct asset data', 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 })), FILL_AMOUNT, DEFAULT_OPTS, ); expect(improvedOrders).to.not.be.length(0); for (const order of improvedOrders) { expect(getSourceFromAssetData(order.makerAssetData)).to.exist(''); const makerAssetDataPrefix = hexUtils.slice( assetDataUtils.encodeERC20BridgeAssetData( MAKER_TOKEN, constants.NULL_ADDRESS, constants.NULL_BYTES, ), 0, 36, ); assertSamePrefix(order.makerAssetData, makerAssetDataPrefix); expect(order.takerAssetData).to.eq(TAKER_ASSET_DATA); } }); it('generates bridge orders with correct taker 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 })), FILL_AMOUNT, DEFAULT_OPTS, ); const totalMakerAssetAmount = BigNumber.sum(...improvedOrders.map(o => o.makerAssetAmount)); expect(totalMakerAssetAmount).to.bignumber.gte(FILL_AMOUNT); }); it('generates bridge orders with max slippage of `bridgeSlippage`', async () => { const bridgeSlippage = _.random(0.1, true); const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync( // Pass in empty orders to prevent native orders from being used. ORDERS.map(o => ({ ...o, makerAssetAmount: constants.ZERO_AMOUNT })), FILL_AMOUNT, { ...DEFAULT_OPTS, bridgeSlippage }, ); expect(improvedOrders).to.not.be.length(0); for (const order of improvedOrders) { const source = getSourceFromAssetData(order.makerAssetData); const expectedTakerAmount = FILL_AMOUNT.div(_.last(DEFAULT_RATES[source]) as BigNumber); const slippage = order.takerAssetAmount.div(expectedTakerAmount.plus(1)).toNumber() - 1; assertRoughlyEquals(slippage, bridgeSlippage, 8); } }); it('can mix convex sources', async () => { const rates: RatesBySource = {}; rates[ERC20BridgeSource.Native] = [0.4, 0.3, 0.2, 0.1]; rates[ERC20BridgeSource.Uniswap] = [0.5, 0.05, 0.05, 0.05]; rates[ERC20BridgeSource.Eth2Dai] = [0.6, 0.05, 0.05, 0.05]; replaceSamplerOps({ getBuyQuotes: createGetMultipleBuyQuotesOperationFromRates(rates), }); const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync( createOrdersFromBuyRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512 }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, ERC20BridgeSource.Native, ERC20BridgeSource.Native, ]; expect(orderSources).to.deep.eq(expectedSources); }); const ETH_TO_TAKER_RATE = 1.5; it('factors in fees for native orders', async () => { // Native orders will have the best rates but have fees, // dropping their effective rates. const nativeFeeRate = 0.06; const rates: RatesBySource = { [ERC20BridgeSource.Native]: [1, 0.99, 0.98, 0.97], // Effectively [0.94, ~0.93, ~0.92, ~0.91] [ERC20BridgeSource.Uniswap]: [0.96, 0.1, 0.1, 0.1], [ERC20BridgeSource.Eth2Dai]: [0.95, 0.1, 0.1, 0.1], [ERC20BridgeSource.Kyber]: [0.1, 0.1, 0.1, 0.1], }; const fees = { [ERC20BridgeSource.Native]: FILL_AMOUNT.div(4) .times(nativeFeeRate) .dividedToIntegerBy(ETH_TO_TAKER_RATE), }; replaceSamplerOps({ getBuyQuotes: createGetMultipleBuyQuotesOperationFromRates(rates), getMedianSellRate: createGetMedianSellRate(ETH_TO_TAKER_RATE), }); const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync( createOrdersFromBuyRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, fees }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Native, ERC20BridgeSource.Native, ]; expect(orderSources).to.deep.eq(expectedSources); }); it('factors in fees for dexes', async () => { // Uniswap will have the best rates but will have fees, // dropping its effective rates. const uniswapFeeRate = 0.2; const rates: RatesBySource = { [ERC20BridgeSource.Native]: [0.95, 0.1, 0.1, 0.1], // Effectively [0.8, ~0.5, ~0, ~0] [ERC20BridgeSource.Uniswap]: [1, 0.7, 0.2, 0.2], [ERC20BridgeSource.Eth2Dai]: [0.92, 0.1, 0.1, 0.1], }; const fees = { [ERC20BridgeSource.Uniswap]: FILL_AMOUNT.div(4) .times(uniswapFeeRate) .dividedToIntegerBy(ETH_TO_TAKER_RATE), }; replaceSamplerOps({ getBuyQuotes: createGetMultipleBuyQuotesOperationFromRates(rates), getMedianSellRate: createGetMedianSellRate(ETH_TO_TAKER_RATE), }); const improvedOrders = await marketOperationUtils.getMarketBuyOrdersAsync( createOrdersFromBuyRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), FILL_AMOUNT, { ...DEFAULT_OPTS, numSamples: 4, runLimit: 512, fees }, ); const orderSources = improvedOrders.map(o => o.fill.source); const expectedSources = [ ERC20BridgeSource.Native, ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap, ]; expect(orderSources).to.deep.eq(expectedSources); }); it('is able to create a order from LiquidityProvider', async () => { const registryAddress = randomAddress(); const liquidityProviderAddress = randomAddress(); const xAsset = randomAddress(); const yAsset = randomAddress(); const toSell = fromTokenUnitAmount(10); const [getSellQuotesParams, getSellQuotesFn] = callTradeOperationAndRetainLiquidityProviderParams( createGetMultipleSellQuotesOperationFromRates, { [ERC20BridgeSource.LiquidityProvider]: createDecreasingRates(5), }, ); const [ getLiquidityProviderParams, getLiquidityProviderFn, ] = getLiquidityProviderFromRegistryAndReturnCallParameters(liquidityProviderAddress); replaceSamplerOps({ getOrderFillableTakerAmounts: () => [constants.ZERO_AMOUNT], getSellQuotes: getSellQuotesFn, getLiquidityProviderFromRegistry: getLiquidityProviderFn, }); const sampler = new MarketOperationUtils( MOCK_SAMPLER, contractAddresses, ORDER_DOMAIN, registryAddress, ); const result = await sampler.getMarketSellOrdersAsync( [ createOrder({ makerAssetData: assetDataUtils.encodeERC20AssetData(xAsset), takerAssetData: assetDataUtils.encodeERC20AssetData(yAsset), }), ], Web3Wrapper.toBaseUnitAmount(10, 18), { excludedSources: SELL_SOURCES, numSamples: 4 }, ); expect(result.length).to.eql(1); expect(result[0].makerAddress).to.eql(liquidityProviderAddress); // tslint:disable-next-line:no-unnecessary-type-assertion const decodedAssetData = assetDataUtils.decodeAssetDataOrThrow( result[0].makerAssetData, ) as ERC20BridgeAssetData; expect(decodedAssetData.assetProxyId).to.eql(AssetProxyId.ERC20Bridge); expect(decodedAssetData.bridgeAddress).to.eql(liquidityProviderAddress); expect(result[0].takerAssetAmount).to.bignumber.eql(toSell); expect(getSellQuotesParams.sources).contains(ERC20BridgeSource.LiquidityProvider); expect(getSellQuotesParams.liquidityProviderAddress).is.eql(registryAddress); expect(getLiquidityProviderParams.registryAddress).is.eql(registryAddress); expect(getLiquidityProviderParams.makerToken).is.eql(xAsset); expect(getLiquidityProviderParams.takerToken).is.eql(yAsset); }); }); }); }); // tslint:disable-next-line: max-file-line-count