diff --git a/packages/asset-swapper/CHANGELOG.json b/packages/asset-swapper/CHANGELOG.json index 117ce1fd7a..b965f4cd9a 100644 --- a/packages/asset-swapper/CHANGELOG.json +++ b/packages/asset-swapper/CHANGELOG.json @@ -1,4 +1,13 @@ [ + { + "version": "6.5.3", + "changes": [ + { + "note": "Apply slippage to bridge orders in consumer", + "pr": 198 + } + ] + }, { "timestamp": 1618314654, "version": "6.5.2", diff --git a/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts b/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts index d223e1a505..ecfe27b054 100644 --- a/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts +++ b/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts @@ -23,6 +23,7 @@ import { CalldataInfo, ExchangeProxyContractOpts, MarketBuySwapQuote, + MarketOperation, MarketSellSwapQuote, SwapQuote, SwapQuoteConsumerBase, @@ -43,6 +44,7 @@ import { LiquidityProviderFillData, MooniswapFillData, OptimizedMarketBridgeOrder, + OptimizedMarketOrder, UniswapV2FillData, } from '../utils/market_operation_utils/types'; @@ -136,7 +138,10 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { const buyToken = quote.makerToken; // Take the bounds from the worst case - const sellAmount = quote.worstCaseQuoteInfo.totalTakerAmount; + const sellAmount = BigNumber.max( + quote.bestCaseQuoteInfo.totalTakerAmount, + quote.worstCaseQuoteInfo.totalTakerAmount, + ); let minBuyAmount = quote.worstCaseQuoteInfo.makerAmount; let ethAmount = quote.worstCaseQuoteInfo.protocolFeeInWeiAmount; @@ -144,13 +149,15 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { ethAmount = ethAmount.plus(sellAmount); } + const slippedOrders = slipNonNativeOrders(quote); + // VIP routes. if ( this.chainId === ChainId.Mainnet && isDirectSwapCompatible(quote, optsWithDefaults, [ERC20BridgeSource.UniswapV2, ERC20BridgeSource.SushiSwap]) ) { - const source = quote.orders[0].source; - const fillData = (quote.orders[0] as OptimizedMarketBridgeOrder).fillData; + const source = slippedOrders[0].source; + const fillData = (slippedOrders[0] as OptimizedMarketBridgeOrder).fillData; return { calldataHexString: this._exchangeProxy .sellToUniswap( @@ -183,8 +190,8 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { ERC20BridgeSource.SushiSwap, ]) ) { - const source = quote.orders[0].source; - const fillData = (quote.orders[0] as OptimizedMarketBridgeOrder).fillData; + const source = slippedOrders[0].source; + const fillData = (slippedOrders[0] as OptimizedMarketBridgeOrder).fillData; return { calldataHexString: this._exchangeProxy .sellToPancakeSwap( @@ -213,7 +220,7 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { this.chainId === ChainId.Mainnet && isDirectSwapCompatible(quote, optsWithDefaults, [ERC20BridgeSource.LiquidityProvider]) ) { - const fillData = (quote.orders[0] as OptimizedMarketBridgeOrder).fillData; + const fillData = (slippedOrders[0] as OptimizedMarketBridgeOrder).fillData; const target = fillData.poolAddress; return { calldataHexString: this._exchangeProxy @@ -238,7 +245,7 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { this.chainId === ChainId.Mainnet && isDirectSwapCompatible(quote, optsWithDefaults, [ERC20BridgeSource.Curve, ERC20BridgeSource.Swerve]) ) { - const fillData = quote.orders[0].fills[0].fillData as CurveFillData; + const fillData = slippedOrders[0].fills[0].fillData as CurveFillData; return { calldataHexString: this._exchangeProxy .sellToLiquidityProvider( @@ -267,7 +274,7 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { this.chainId === ChainId.Mainnet && isDirectSwapCompatible(quote, optsWithDefaults, [ERC20BridgeSource.Mooniswap]) ) { - const fillData = quote.orders[0].fills[0].fillData as MooniswapFillData; + const fillData = slippedOrders[0].fills[0].fillData as MooniswapFillData; return { calldataHexString: this._exchangeProxy .sellToLiquidityProvider( @@ -289,7 +296,7 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { if (this.chainId === ChainId.Mainnet && isMultiplexBatchFillCompatible(quote, optsWithDefaults)) { return { - calldataHexString: this._encodeMultiplexBatchFillCalldata(quote), + calldataHexString: this._encodeMultiplexBatchFillCalldata({ ...quote, orders: slippedOrders }), ethAmount, toAddress: this._exchangeProxy.address, allowanceTarget: this._exchangeProxy.address, @@ -298,7 +305,10 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { } if (this.chainId === ChainId.Mainnet && isMultiplexMultiHopFillCompatible(quote, optsWithDefaults)) { return { - calldataHexString: this._encodeMultiplexMultiHopFillCalldata(quote, optsWithDefaults), + calldataHexString: this._encodeMultiplexMultiHopFillCalldata( + { ...quote, orders: slippedOrders }, + optsWithDefaults, + ), ethAmount, toAddress: this._exchangeProxy.address, allowanceTarget: this._exchangeProxy.address, @@ -321,10 +331,10 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { // If it's two hop we have an intermediate token this is needed to encode the individual FQT // and we also want to ensure no dust amount is left in the flash wallet - const intermediateToken = quote.isTwoHop ? quote.orders[0].makerToken : NULL_ADDRESS; + const intermediateToken = quote.isTwoHop ? slippedOrders[0].makerToken : NULL_ADDRESS; // This transformer will fill the quote. if (quote.isTwoHop) { - const [firstHopOrder, secondHopOrder] = quote.orders; + const [firstHopOrder, secondHopOrder] = slippedOrders; transforms.push({ deploymentNonce: this.transformerNonces.fillQuoteTransformer, data: encodeFillQuoteTransformerData({ @@ -349,14 +359,13 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { }); } else { const fillAmount = isBuyQuote(quote) ? quote.makerTokenFillAmount : quote.takerTokenFillAmount; - transforms.push({ deploymentNonce: this.transformerNonces.fillQuoteTransformer, data: encodeFillQuoteTransformerData({ side: isBuyQuote(quote) ? FillQuoteTransformerSide.Buy : FillQuoteTransformerSide.Sell, sellToken, buyToken, - ...getFQTTransformerDataFromOptimizedOrders(quote.orders), + ...getFQTTransformerDataFromOptimizedOrders(slippedOrders), refundReceiver: refundReceiver || NULL_ADDRESS, fillAmount: !isBuyQuote(quote) && shouldSellEntireBalance ? MAX_UINT256 : fillAmount, }), @@ -598,3 +607,38 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { .getABIEncodedTransactionData(); } } + +function slipNonNativeOrders(quote: MarketSellSwapQuote | MarketBuySwapQuote): OptimizedMarketOrder[] { + const slippage = getMaxQuoteSlippageRate(quote); + if (!slippage) { + return quote.orders; + } + return quote.orders.map(o => { + if (o.source === ERC20BridgeSource.Native) { + return o; + } + return { + ...o, + ...(quote.type === MarketOperation.Sell + ? { makerAmount: o.makerAmount.times(1 - slippage).integerValue(BigNumber.ROUND_DOWN) } + : { takerAmount: o.takerAmount.times(1 + slippage).integerValue(BigNumber.ROUND_UP) }), + }; + }); +} + +function getMaxQuoteSlippageRate(quote: MarketBuySwapQuote | MarketSellSwapQuote): number { + if (quote.type === MarketOperation.Buy) { + // (worstCaseTaker - bestCaseTaker) / bestCaseTaker + // where worstCaseTaker >= bestCaseTaker + return quote.worstCaseQuoteInfo.takerAmount + .minus(quote.bestCaseQuoteInfo.takerAmount) + .div(quote.bestCaseQuoteInfo.takerAmount) + .toNumber(); + } + // (bestCaseMaker - worstCaseMaker) / bestCaseMaker + // where bestCaseMaker >= worstCaseMaker + return quote.bestCaseQuoteInfo.makerAmount + .minus(quote.worstCaseQuoteInfo.makerAmount) + .div(quote.bestCaseQuoteInfo.makerAmount) + .toNumber(); +} diff --git a/packages/asset-swapper/src/swap_quoter.ts b/packages/asset-swapper/src/swap_quoter.ts index 1717f37c56..af1a281c23 100644 --- a/packages/asset-swapper/src/swap_quoter.ts +++ b/packages/asset-swapper/src/swap_quoter.ts @@ -591,9 +591,10 @@ function calculateTwoHopQuoteInfo( : secondHopOrder.makerAmount, takerAmount: MarketOperation.Sell ? firstHopOrder.takerAmount - : // tslint:disable-next-line: binary-expression-operand-order - firstHopOrder.takerAmount.times(1 + slippage).integerValue(), - totalTakerAmount: firstHopOrder.takerAmount, + : firstHopOrder.takerAmount.times(1 + slippage).integerValue(), + totalTakerAmount: MarketOperation.Sell + ? firstHopOrder.takerAmount + : firstHopOrder.takerAmount.times(1 + slippage).integerValue(), feeTakerTokenAmount: constants.ZERO_AMOUNT, protocolFeeInWeiAmount: constants.ZERO_AMOUNT, gas, diff --git a/packages/asset-swapper/src/utils/quote_simulation.ts b/packages/asset-swapper/src/utils/quote_simulation.ts index 2eb1851b4b..231ea2e832 100644 --- a/packages/asset-swapper/src/utils/quote_simulation.ts +++ b/packages/asset-swapper/src/utils/quote_simulation.ts @@ -129,11 +129,10 @@ export function simulateWorstCaseFill(quoteInfo: QuoteFillInfo): QuoteFillResult }; // Adjust the output by 1-slippage for the worst case if it is a sell // Adjust the output by 1+slippage for the worst case if it is a buy - const outputMultiplier = + result.output = quoteInfo.side === MarketOperation.Sell - ? new BigNumber(1).minus(opts.slippage) - : new BigNumber(1).plus(opts.slippage); - result.output = result.output.times(outputMultiplier).integerValue(); + ? result.output.times(1 - opts.slippage).integerValue(BigNumber.ROUND_DOWN) + : result.output.times(1 + opts.slippage).integerValue(BigNumber.ROUND_UP); return fromIntermediateQuoteFillResult(result, quoteInfo); } diff --git a/packages/asset-swapper/tslint.json b/packages/asset-swapper/tslint.json index 3342b321c7..c00b63f247 100644 --- a/packages/asset-swapper/tslint.json +++ b/packages/asset-swapper/tslint.json @@ -1,7 +1,8 @@ { "extends": ["@0x/tslint-config"], "rules": { - "max-file-line-count": false + "max-file-line-count": false, + "binary-expression-operand-order": false }, "linterOptions": { "exclude": ["src/artifacts.ts", "test/artifacts.ts"]