From 3b2b061753b382a23feb611dd3186a442c0ce0c4 Mon Sep 17 00:00:00 2001 From: Lawrence Forman Date: Fri, 8 Apr 2022 00:00:34 -0400 Subject: [PATCH] feat: add direct RFQT fill suport to ZeroExApiAdapter --- contracts/mocks/external/ZeroExMock.sol | 59 ++- .../integration/exchange/ZeroExApiAdapter.sol | 89 ++-- hardhat.config.ts | 4 +- .../exchange/zeroExApiAdapter.spec.ts | 397 +++++++++++++----- 4 files changed, 386 insertions(+), 163 deletions(-) diff --git a/contracts/mocks/external/ZeroExMock.sol b/contracts/mocks/external/ZeroExMock.sol index f54e9f0a2..09c4d6512 100644 --- a/contracts/mocks/external/ZeroExMock.sol +++ b/contracts/mocks/external/ZeroExMock.sol @@ -29,28 +29,24 @@ contract ZeroExMock { bytes data; } - struct BatchFillData { - address inputToken; - address outputToken; - uint256 sellAmount; - WrappedBatchCall[] calls; - } - - struct WrappedBatchCall { - bytes4 selector; - uint256 sellAmount; - bytes data; - } - - struct MultiHopFillData { - address[] tokens; - uint256 sellAmount; - WrappedMultiHopCall[] calls; - } - - struct WrappedMultiHopCall { - bytes4 selector; - bytes data; + struct RfqOrder { + address makerToken; + address takerToken; + uint128 makerAmount; + uint128 takerAmount; + address maker; + address taker; + address txOrigin; + bytes32 pool; + uint64 expiry; + uint256 salt; + } + + struct Signature { + uint8 signatureType; + uint8 v; + bytes32 r; + bytes32 s; } struct BatchSellSubcall { @@ -145,24 +141,27 @@ contract ZeroExMock { _transferTokens(); } - function batchFill( - BatchFillData memory /* fillData */, - uint256 /* minBuyAmount */ + function fillRfqOrder( + RfqOrder memory /* order */, + Signature memory /* signature */, + uint128 /* takerTokenFillAmount */ ) external payable - returns (uint256) + returns (uint128, uint128) { _transferTokens(); } - function multiHopFill( - MultiHopFillData memory /* fillData */, - uint256 /* minBuyAmount */ + function batchFillRfqOrders( + RfqOrder[] memory /* order */, + Signature[] memory /* signature */, + uint128[] memory /* takerTokenFillAmount */, + bool /* revertIfIncomplete */ ) external payable - returns (uint256) + returns (uint128[] memory, uint128[] memory) { _transferTokens(); } diff --git a/contracts/protocol/integration/exchange/ZeroExApiAdapter.sol b/contracts/protocol/integration/exchange/ZeroExApiAdapter.sol index f0b420cdf..31347a0c3 100644 --- a/contracts/protocol/integration/exchange/ZeroExApiAdapter.sol +++ b/contracts/protocol/integration/exchange/ZeroExApiAdapter.sol @@ -28,28 +28,24 @@ pragma experimental "ABIEncoderV2"; contract ZeroExApiAdapter { - struct BatchFillData { - address inputToken; - address outputToken; - uint256 sellAmount; - WrappedBatchCall[] calls; - } - - struct WrappedBatchCall { - bytes4 selector; - uint256 sellAmount; - bytes data; + struct RfqOrder { + address makerToken; + address takerToken; + uint128 makerAmount; + uint128 takerAmount; + address maker; + address taker; + address txOrigin; + bytes32 pool; + uint64 expiry; + uint256 salt; } - struct MultiHopFillData { - address[] tokens; - uint256 sellAmount; - WrappedMultiHopCall[] calls; - } - - struct WrappedMultiHopCall { - bytes4 selector; - bytes data; + struct Signature { + uint8 signatureType; + uint8 v; + bytes32 r; + bytes32 s; } /* ============ State Variables ============ */ @@ -149,23 +145,31 @@ contract ZeroExApiAdapter { require(path.length > 1, "Uniswap token path too short"); inputToken = path[0]; outputToken = path[path.length - 1]; - } else if (selector == 0xafc6728e) { - // batchFill() - BatchFillData memory fillData; - (fillData, minOutputTokenAmount) = - abi.decode(_data[4:], (BatchFillData, uint256)); - inputToken = fillData.inputToken; - outputToken = fillData.outputToken; - inputTokenAmount = fillData.sellAmount; - } else if (selector == 0x21c184b6) { - // multiHopFill() - MultiHopFillData memory fillData; - (fillData, minOutputTokenAmount) = - abi.decode(_data[4:], (MultiHopFillData, uint256)); - require(fillData.tokens.length > 1, "Multihop token path too short"); - inputToken = fillData.tokens[0]; - outputToken = fillData.tokens[fillData.tokens.length - 1]; - inputTokenAmount = fillData.sellAmount; + } else if (selector == 0xaa77476c) { + // fillRfqOrder() + RfqOrder memory order; + uint128 takerTokenFillAmount; + (order, , takerTokenFillAmount) = + abi.decode(_data[4:], (RfqOrder, Signature, uint128)); + inputTokenAmount = uint256(takerTokenFillAmount); + inputToken = order.takerToken; + outputToken = order.makerToken; + minOutputTokenAmount = getRfqOrderMakerFillAmount(order, inputTokenAmount); + } else if (selector == 0x75103cb9) { + // batchFillRfqOrders() + RfqOrder[] memory orders; + uint128[] memory takerTokenFillAmounts; + bool revertIfIncomplete; + (orders, , takerTokenFillAmounts, revertIfIncomplete) = + abi.decode(_data[4:], (RfqOrder[], uint256, uint128[], bool)); + require(orders.length > 0, "Empty RFQ orders"); + require(revertIfIncomplete, "batchFillRfqOrder must be all or nothing"); + inputToken = orders[0].takerToken; + outputToken = orders[0].makerToken; + for (uint256 i = 0; i < orders.length; ++i) { + inputTokenAmount += uint256(takerTokenFillAmounts[i]); + minOutputTokenAmount += getRfqOrderMakerFillAmount(orders[i], takerTokenFillAmounts[i]); + } } else if (selector == 0x6af479b2) { // sellTokenForTokenToUniswapV3() bytes memory encodedPath; @@ -208,6 +212,17 @@ contract ZeroExApiAdapter { ); } + function getRfqOrderMakerFillAmount(RfqOrder memory order, uint256 takerTokenFillAmount) + private + pure + returns (uint256 makerTokenFillAmount) + { + if (order.takerAmount == 0 || order.makerAmount == 0 || takerTokenFillAmount == 0) { + return 0; + } + return uint256(order.makerAmount * takerTokenFillAmount / order.takerAmount); + } + // Decode input and output tokens from an arbitrary length encoded Uniswap V3 path function _decodeTokensFromUniswapV3EncodedPath(bytes memory encodedPath) private diff --git a/hardhat.config.ts b/hardhat.config.ts index f6ffdd4cd..fc1c4093b 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -73,7 +73,7 @@ const config: HardhatUserConfig = { contractSizer: { runOnCompile: false, }, - + mocha: mochaConfig, // These are external artifacts we don't compile but would like to improve @@ -106,4 +106,4 @@ function checkForkedProviderEnvironment() { } } -export default config; \ No newline at end of file +export default config; diff --git a/test/protocol/integration/exchange/zeroExApiAdapter.spec.ts b/test/protocol/integration/exchange/zeroExApiAdapter.spec.ts index 372702ff2..bea289a13 100644 --- a/test/protocol/integration/exchange/zeroExApiAdapter.spec.ts +++ b/test/protocol/integration/exchange/zeroExApiAdapter.spec.ts @@ -6,9 +6,85 @@ import { ZeroExApiAdapter, ZeroExMock } from "@utils/contracts"; import DeployHelper from "@utils/deploys"; import { addSnapshotBeforeRestoreAfterEach, getAccounts, getWaffleExpect } from "@utils/test/index"; import { hexUtils } from "@0x/utils"; +import { BigNumber } from "ethers"; import { take } from "lodash"; const expect = getWaffleExpect(); +const NULL_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; +const NULL_SIGNATURE = { + signatureType: 0, + v: 0, + r: NULL_BYTES32, + s: NULL_BYTES32, +}; + +interface RfqOrder { + makerToken: string; + takerToken: string; + makerAmount: BigNumber; + takerAmount: BigNumber; + maker: string; + taker: string; + txOrigin: string; + pool: string; + expiry: number; + salt: BigNumber; +} + +function createRfqOrder( + takerToken: string, + makerToken: string, + takerAmount: BigNumber, + makerAmount: BigNumber, + scaling = 1.0, +): RfqOrder { + return { + makerToken, + takerToken, + expiry: 0, + maker: ADDRESS_ZERO, + taker: ADDRESS_ZERO, + txOrigin: ADDRESS_ZERO, + salt: ZERO, + pool: NULL_BYTES32, + ...(scaling === 1 + ? { makerAmount, takerAmount } + : { + makerAmount: makerAmount.mul(Math.floor(scaling * 1e4)).div(1e4), + takerAmount: takerAmount.mul(Math.floor(scaling * 1e4)).div(1e4), + } + ), + }; +} + +interface BatchOrderQuantity { + orderSourceQuantity: BigNumber; + orderMinDestinationQuantity: BigNumber; +} + +function createBatchOrderQuantities( + totalSourceQuantity: BigNumber, + totalMinDestinationQuantity: BigNumber, + count: number = 3, +): Array { + if (count === 0) { + return []; + } + const osq = totalSourceQuantity.div(count); + const omdq = totalMinDestinationQuantity.div(count); + const orderQuantities = []; + for (let i = 0; i < count - 1; ++i) { + orderQuantities.push({ + orderSourceQuantity: osq, + orderMinDestinationQuantity: omdq, + }); + } + orderQuantities.push({ + orderSourceQuantity: totalSourceQuantity.sub(osq.mul(count - 1)), + orderMinDestinationQuantity: totalMinDestinationQuantity.sub(omdq.mul(count - 1)), + }); + return orderQuantities; +} describe("ZeroExApiAdapter", () => { let owner: Account; @@ -40,7 +116,7 @@ describe("ZeroExApiAdapter", () => { addSnapshotBeforeRestoreAfterEach(); - describe("getTradeCalldata", () => { + describe.only("getTradeCalldata", () => { it("rejects unsupported function", async () => { const data = zeroExMock.interface.encodeFunctionData("transformERC20", [ sourceToken, @@ -570,16 +646,12 @@ describe("ZeroExApiAdapter", () => { }); }); - describe("batchFill", () => { + describe("fillRfqOrder", () => { it("validates data", async () => { - const data = zeroExMock.interface.encodeFunctionData("batchFill", [ - { - inputToken: sourceToken, - outputToken: destToken, - sellAmount: sourceQuantity, - calls: [], - }, - minDestinationQuantity, + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(sourceToken, destToken, sourceQuantity, minDestinationQuantity), + NULL_SIGNATURE, + sourceQuantity, ]); const [target, value, _data] = await zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -594,15 +666,30 @@ describe("ZeroExApiAdapter", () => { expect(_data).to.deep.eq(data); }); - it("rejects wrong input token", async () => { - const data = zeroExMock.interface.encodeFunctionData("batchFill", [ - { - inputToken: otherToken, - outputToken: destToken, - sellAmount: sourceQuantity, - calls: [], - }, + it("accepts larger order", async () => { + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(sourceToken, destToken, sourceQuantity, minDestinationQuantity, 1.01), + NULL_SIGNATURE, + sourceQuantity, + ]); + const [target, value, _data] = await zeroExApiAdapter.getTradeCalldata( + sourceToken, + destToken, + destination, + sourceQuantity, minDestinationQuantity, + data, + ); + expect(target).to.eq(zeroExMock.address); + expect(value).to.deep.eq(ZERO); + expect(_data).to.deep.eq(data); + }); + + it("rejects bad order rate", async () => { + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(sourceToken, destToken, sourceQuantity, minDestinationQuantity.sub(1)), + NULL_SIGNATURE, + sourceQuantity, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -612,18 +699,14 @@ describe("ZeroExApiAdapter", () => { minDestinationQuantity, data, ); - await expect(tx).to.be.revertedWith("Mismatched input token"); + await expect(tx).to.be.revertedWith("Mismatched output token quantity"); }); - it("rejects wrong output token", async () => { - const data = zeroExMock.interface.encodeFunctionData("batchFill", [ - { - inputToken: sourceToken, - outputToken: otherToken, - sellAmount: sourceQuantity, - calls: [], - }, - minDestinationQuantity, + it("rejects too small order", async () => { + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(sourceToken, destToken, sourceQuantity, minDestinationQuantity, 0.99), + NULL_SIGNATURE, + sourceQuantity, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -633,18 +716,31 @@ describe("ZeroExApiAdapter", () => { minDestinationQuantity, data, ); - await expect(tx).to.be.revertedWith("Mismatched output token"); + await expect(tx).to.be.revertedWith("Mismatched output token quantity"); }); - it("rejects wrong input token quantity", async () => { - const data = zeroExMock.interface.encodeFunctionData("batchFill", [ - { - inputToken: sourceToken, - outputToken: destToken, - sellAmount: otherQuantity, - calls: [], - }, + it("rejects wrong input token", async () => { + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(otherToken, destToken, sourceQuantity, minDestinationQuantity), + NULL_SIGNATURE, + sourceQuantity, + ]); + const tx = zeroExApiAdapter.getTradeCalldata( + sourceToken, + destToken, + destination, + sourceQuantity, minDestinationQuantity, + data, + ); + await expect(tx).to.be.revertedWith("Mismatched input token"); + }); + + it("rejects wrong output token", async () => { + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(sourceToken, otherToken, sourceQuantity, minDestinationQuantity), + NULL_SIGNATURE, + sourceQuantity, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -654,17 +750,13 @@ describe("ZeroExApiAdapter", () => { minDestinationQuantity, data, ); - await expect(tx).to.be.revertedWith("Mismatched input token quantity"); + await expect(tx).to.be.revertedWith("Mismatched output token"); }); - it("rejects wrong output token quantity", async () => { - const data = zeroExMock.interface.encodeFunctionData("batchFill", [ - { - inputToken: sourceToken, - outputToken: destToken, - sellAmount: sourceQuantity, - calls: [], - }, + it("rejects wrong input token quantity", async () => { + const data = zeroExMock.interface.encodeFunctionData("fillRfqOrder", [ + createRfqOrder(sourceToken, destToken, sourceQuantity, minDestinationQuantity), + NULL_SIGNATURE, otherQuantity, ]); const tx = zeroExApiAdapter.getTradeCalldata( @@ -675,19 +767,54 @@ describe("ZeroExApiAdapter", () => { minDestinationQuantity, data, ); - await expect(tx).to.be.revertedWith("Mismatched output token quantity"); + await expect(tx).to.be.revertedWith("Mismatched input token quantity"); }); }); - describe("multiHopFill", () => { + describe("batchFillRfqOrder", () => { it("validates data", async () => { - const data = zeroExMock.interface.encodeFunctionData("multiHopFill", [ - { - tokens: [sourceToken, destToken], - sellAmount: sourceQuantity, - calls: [], - }, + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, + ]); + const [target, value, _data] = await zeroExApiAdapter.getTradeCalldata( + sourceToken, + destToken, + destination, + sourceQuantity, minDestinationQuantity, + data, + ); + expect(target).to.eq(zeroExMock.address); + expect(value).to.deep.eq(ZERO); + expect(_data).to.deep.eq(data); + }); + + it("validates data with larger orders", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + 1.01 + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, ]); const [target, value, _data] = await zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -702,14 +829,46 @@ describe("ZeroExApiAdapter", () => { expect(_data).to.deep.eq(data); }); - it("rejects wrong input token", async () => { - const data = zeroExMock.interface.encodeFunctionData("multiHopFill", [ - { - tokens: [otherToken, destToken], - sellAmount: sourceQuantity, - calls: [], - }, + it("rejects badly priced orders", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity.add(1), + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, + ]); + const tx = zeroExApiAdapter.getTradeCalldata( + sourceToken, + destToken, + destination, + sourceQuantity, minDestinationQuantity, + data, + ); + await expect(tx).to.be.revertedWith("Mismatched output token quantity"); + }); + + it("rejects if no orders", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity, 0); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -719,17 +878,49 @@ describe("ZeroExApiAdapter", () => { minDestinationQuantity, data, ); - await expect(tx).to.be.revertedWith("Mismatched input token"); + await expect(tx).to.be.revertedWith("Empty RFQ orders"); }); - it("rejects went path too short", async () => { - const data = zeroExMock.interface.encodeFunctionData("multiHopFill", [ - { - tokens: [sourceToken], - sellAmount: sourceQuantity, - calls: [], - }, + it("rejects if revertIfIncomplete is not true", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + false, + ]); + const tx = zeroExApiAdapter.getTradeCalldata( + sourceToken, + destToken, + destination, + sourceQuantity, minDestinationQuantity, + data, + ); + await expect(tx).to.be.revertedWith("batchFillRfqOrder must be all or nothing"); + }); + + it("rejects wrong input token", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + otherToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -739,17 +930,23 @@ describe("ZeroExApiAdapter", () => { minDestinationQuantity, data, ); - await expect(tx).to.be.revertedWith("Multihop token path too short"); + await expect(tx).to.be.revertedWith("Mismatched input token"); }); it("rejects wrong output token", async () => { - const data = zeroExMock.interface.encodeFunctionData("multiHopFill", [ - { - tokens: [sourceToken, otherToken], - sellAmount: sourceQuantity, - calls: [], - }, - minDestinationQuantity, + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + otherToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -762,14 +959,20 @@ describe("ZeroExApiAdapter", () => { await expect(tx).to.be.revertedWith("Mismatched output token"); }); - it("rejects wrong input token quantity", async () => { - const data = zeroExMock.interface.encodeFunctionData("multiHopFill", [ - { - tokens: [sourceToken, destToken], - sellAmount: otherQuantity, - calls: [], - }, - minDestinationQuantity, + it("rejects wrong input token amount", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity.add(1)), + true, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, @@ -782,21 +985,27 @@ describe("ZeroExApiAdapter", () => { await expect(tx).to.be.revertedWith("Mismatched input token quantity"); }); - it("rejects wrong output token quantity", async () => { - const data = zeroExMock.interface.encodeFunctionData("multiHopFill", [ - { - tokens: [sourceToken, destToken], - sellAmount: sourceQuantity, - calls: [], - }, - otherQuantity, + it("rejects wrong output token amount", async () => { + const orderQuantities = createBatchOrderQuantities(sourceQuantity, minDestinationQuantity); + const data = zeroExMock.interface.encodeFunctionData("batchFillRfqOrders", [ + orderQuantities.map(({ orderSourceQuantity, orderMinDestinationQuantity }) => + createRfqOrder( + sourceToken, + destToken, + orderSourceQuantity, + orderMinDestinationQuantity, + ), + ), + orderQuantities.map(() => NULL_SIGNATURE), + orderQuantities.map(({ orderSourceQuantity }) => orderSourceQuantity), + true, ]); const tx = zeroExApiAdapter.getTradeCalldata( sourceToken, destToken, destination, sourceQuantity, - minDestinationQuantity, + minDestinationQuantity.add(1), data, ); await expect(tx).to.be.revertedWith("Mismatched output token quantity");