diff --git a/packages/authorizer/contracts/AuthorizedHelpers.sol b/packages/authorizer/contracts/AuthorizedHelpers.sol index 04bf69bc..b6d504a7 100644 --- a/packages/authorizer/contracts/AuthorizedHelpers.sol +++ b/packages/authorizer/contracts/AuthorizedHelpers.sol @@ -120,4 +120,22 @@ contract AuthorizedHelpers { r[3] = p4; r[4] = p5; } + + function authParams(address p1, uint256 p2, uint256 p3, uint24 p4, address[] memory p5, uint24[] memory p6) + internal + pure + returns (uint256[] memory r) + { + r = new uint256[](4 + p5.length + p6.length); + r[0] = uint256(uint160(p1)); + r[1] = p2; + r[2] = p3; + r[3] = p4; + for (uint256 i = 0; i < p5.length; i++) { + r[i + 4] = uint256(uint160(p5[i])); + } + for (uint256 i = 0; i < p6.length; i++) { + r[i + 4 + p5.length] = p6[i]; + } + } } diff --git a/packages/connectors/contracts/swap/uniswap-v3/UniswapV3Connector.sol b/packages/connectors/contracts/swap/uniswap-v3/IUniswapV3Connector.sol similarity index 100% rename from packages/connectors/contracts/swap/uniswap-v3/UniswapV3Connector.sol rename to packages/connectors/contracts/swap/uniswap-v3/IUniswapV3Connector.sol diff --git a/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol b/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol new file mode 100644 index 00000000..1961db75 --- /dev/null +++ b/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.0; + +import './IBaseSwapTask.sol'; + +/** + * @dev UniSwap v3 swapper action interface + */ +interface IUniswapV3Swapper is IBaseSwapTask { + /** + * @dev Execution function + */ + function call( + address tokenIn, + uint256 amountIn, + uint256 minAmountOut, + uint24 fee, + address[] memory hopTokens, + uint24[] memory hopFees + ) external; +} diff --git a/packages/tasks/contracts/swap/UniswapV3Swapper.sol b/packages/tasks/contracts/swap/UniswapV3Swapper.sol new file mode 100644 index 00000000..166ffcdc --- /dev/null +++ b/packages/tasks/contracts/swap/UniswapV3Swapper.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.0; + +import '@mimic-fi/v3-helpers/contracts/math/FixedPoint.sol'; +import '@mimic-fi/v3-helpers/contracts/utils/BytesHelpers.sol'; +import '@mimic-fi/v3-connectors/contracts/swap/uniswap-v3/IUniswapV3Connector.sol'; + +import './BaseSwapTask.sol'; +import '../interfaces/swap/IUniswapV3Swapper.sol'; + +contract UniswapV3Swapper is IUniswapV3Swapper, BaseSwapTask { + using FixedPoint for uint256; + using BytesHelpers for bytes; + + // Execution type for relayers + bytes32 public constant override EXECUTION_TYPE = keccak256('UNISWAP_V3_SWAPPER'); + + /** + * @dev Uniswap v3 swapper task config. Only used in the initializer. + */ + struct UniswapV3SwapperConfig { + BaseSwapConfig baseSwapConfig; + } + + /** + * @dev Initializes the Uniswap v3 swapper action. + * @param config Uniswap v3 swap config. + */ + function initialize(UniswapV3SwapperConfig memory config) external initializer { + __UniswapV3Swapper_init(config); + } + + /** + * @dev Initializes the Uniswap V3 swapper. It does call upper contracts. + * @param config Uniswap v3 swap config. + */ + function __UniswapV3Swapper_init(UniswapV3SwapperConfig memory config) internal onlyInitializing { + __BaseSwapTask_init(config.baseSwapConfig); + __UniswapV3Swapper_init_unchained(config); + } + + /** + * @dev Initilizes the uniswap V3 swapper. It does not call upper contracts. + * @param config Uniswap v3 swap config. + */ + function __UniswapV3Swapper_init_unchained(UniswapV3SwapperConfig memory config) internal onlyInitializing { + // solhint-disable-previous-line no-empty-blocks + } + + /** + * @dev Executes the Uniswap v3 swapper task + */ + function call( + address tokenIn, + uint256 amountIn, + uint256 slippage, + uint24 fee, + address[] memory hopTokens, + uint24[] memory hopFees + ) external override authP(authParams(tokenIn, amountIn, slippage, fee, hopTokens, hopFees)) { + if (amountIn == 0) amountIn = getTaskAmount(tokenIn); + _beforeUniswapV3Swapper(tokenIn, amountIn, slippage); + + address tokenOut = getTokenOut(tokenIn); + uint256 price = _getPrice(tokenIn, tokenOut); + uint256 minAmountOut = amountIn.mulUp(price).mulUp(FixedPoint.ONE - slippage); + + bytes memory connectorData = abi.encodeWithSelector( + UniswapV3Connector.execute.selector, + tokenIn, + tokenOut, + amountIn, + minAmountOut, + fee, + hopTokens, + hopFees + ); + + bytes memory result = ISmartVault(smartVault).execute(connector, connectorData); + _afterUniswapV3Swapper(tokenIn, amountIn, slippage, tokenOut, result.toUint256()); + } + + /** + * @dev Before Uniswap v3Swapper Task + */ + function _beforeUniswapV3Swapper(address token, uint256 amount, uint256 slippage) internal virtual { + _beforeBaseSwapTask(token, amount, slippage); + } + + /** + * @dev After Uniswap V3 swapper hook + */ + function _afterUniswapV3Swapper( + address tokenIn, + uint256 amountIn, + uint256 slippage, + address tokenOut, + uint256 amountOut + ) internal virtual { + _afterBaseSwapTask(tokenIn, amountIn, slippage, tokenOut, amountOut); + } +} diff --git a/packages/tasks/contracts/test/swap/UniswapV3ConnectorMock.sol b/packages/tasks/contracts/test/swap/UniswapV3ConnectorMock.sol new file mode 100644 index 00000000..3d6fec76 --- /dev/null +++ b/packages/tasks/contracts/test/swap/UniswapV3ConnectorMock.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma solidity ^0.8.0; + +contract UniswapV3ConnectorMock { + event LogExecute( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + uint24 fee, + address[] hopTokens, + uint24[] hopFees + ); + + function execute( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + uint24 fee, + address[] memory hopTokens, + uint24[] memory hopFees + ) external returns (uint256) { + emit LogExecute(tokenIn, tokenOut, amountIn, minAmountOut, fee, hopTokens, hopFees); + return minAmountOut; + } +} diff --git a/packages/tasks/test/swap/UniswapV3Swapper.test.ts b/packages/tasks/test/swap/UniswapV3Swapper.test.ts new file mode 100644 index 00000000..1920cba7 --- /dev/null +++ b/packages/tasks/test/swap/UniswapV3Swapper.test.ts @@ -0,0 +1,506 @@ +import { OP } from '@mimic-fi/v3-authorizer' +import { + assertIndirectEvent, + assertNoEvent, + BigNumberish, + deploy, + deployFeedMock, + deployProxy, + deployTokenMock, + fp, + getSigners, + MAX_UINT256, + ZERO_ADDRESS, +} from '@mimic-fi/v3-helpers' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract, ContractTransaction } from 'ethers' +import { defaultAbiCoder } from 'ethers/lib/utils' +import { ethers } from 'hardhat' + +import { buildEmptyTaskConfig, deployEnvironment } from '../../src/setup' +import { itBehavesLikeBaseSwapTask } from './BaseSwapTask.behavior' + +describe('UniswapV3Swapper', () => { + let task: Contract + let smartVault: Contract, authorizer: Contract, priceOracle: Contract, connector: Contract, owner: SignerWithAddress + + before('setup', async () => { + // eslint-disable-next-line prettier/prettier + [, owner] = await getSigners() + ;({ authorizer, smartVault, priceOracle } = await deployEnvironment(owner)) + }) + + before('deploy connector', async () => { + connector = await deploy('UniswapV3ConnectorMock') + const overrideConnectorCheckRole = smartVault.interface.getSighash('overrideConnectorCheck') + await authorizer.connect(owner).authorize(owner.address, smartVault.address, overrideConnectorCheckRole, []) + await smartVault.connect(owner).overrideConnectorCheck(connector.address, true) + }) + + beforeEach('deploy task', async () => { + task = await deployProxy( + 'UniswapV3Swapper', + [], + [ + { + baseSwapConfig: { + connector: connector.address, + tokenOut: ZERO_ADDRESS, + maxSlippage: 0, + customTokensOut: [], + customMaxSlippages: [], + taskConfig: buildEmptyTaskConfig(owner, smartVault), + }, + }, + ] + ) + }) + + describe('swapper', () => { + beforeEach('set params', async function () { + this.owner = owner + this.task = task + this.authorizer = authorizer + }) + + itBehavesLikeBaseSwapTask('UNISWAP_V3_SWAPPER') + }) + + describe('call', () => { + beforeEach('authorize task', async () => { + const executeRole = smartVault.interface.getSighash('execute') + const params = [{ op: OP.EQ, value: connector.address }] + await authorizer.connect(owner).authorize(task.address, smartVault.address, executeRole, params) + }) + + context('when the sender is authorized', () => { + beforeEach('set sender', async () => { + const callRole = task.interface.getSighash('call') + await authorizer.connect(owner).authorize(owner.address, task.address, callRole, []) + task = task.connect(owner) + }) + + context('when the token in is not the zero address', () => { + let tokenIn: Contract + + beforeEach('set token in', async () => { + tokenIn = await deployTokenMock('TKN') + }) + + context('when the amount in is not zero', () => { + const tokenRate = 2 // 1 token in = 2 token out + const thresholdAmount = fp(0.1) // in token out + const maxThresholdAmount = fp(0.0) + const thresholdAmountInTokenIn = thresholdAmount.div(tokenRate) // threshold expressed in token in + const amountIn = thresholdAmountInTokenIn + + context('when the token in is allowed', () => { + context('when there is a token out set', () => { + let tokenOut: Contract, + extraCallData = '' + + beforeEach('set default token out', async () => { + tokenOut = await deployTokenMock('TKN') + const setDefaultTokenOutRole = task.interface.getSighash('setDefaultTokenOut') + await authorizer.connect(owner).authorize(owner.address, task.address, setDefaultTokenOutRole, []) + await task.connect(owner).setDefaultTokenOut(tokenOut.address) + }) + + context('when an off-chain oracle is given', () => { + beforeEach('sign off-chain oracle', async () => { + const setSignerRole = priceOracle.interface.getSighash('setSigner') + await authorizer.connect(owner).authorize(owner.address, priceOracle.address, setSignerRole, []) + await priceOracle.connect(owner).setSigner(owner.address, true) + + type PriceData = { base: string; quote: string; rate: BigNumberish; deadline: BigNumberish } + const pricesData: PriceData[] = [ + { + base: tokenIn.address, + quote: tokenOut.address, + rate: fp(tokenRate), + deadline: MAX_UINT256, + }, + { + base: tokenOut.address, + quote: tokenIn.address, + rate: fp(1).mul(fp(1)).div(fp(tokenRate)), + deadline: MAX_UINT256, + }, + ] + + const PricesDataType = 'PriceData(address base, address quote, uint256 rate, uint256 deadline)[]' + const encodedPrices = await defaultAbiCoder.encode([PricesDataType], [pricesData]) + const message = ethers.utils.solidityKeccak256(['bytes'], [encodedPrices]) + const signature = await owner.signMessage(ethers.utils.arrayify(message)) + const data = defaultAbiCoder.encode([PricesDataType, 'bytes'], [pricesData, signature]).slice(2) + const dataLength = defaultAbiCoder.encode(['uint256'], [data.length / 2]).slice(2) + extraCallData = `${data}${dataLength}` + }) + + beforeEach('set threshold', async () => { + const setDefaultTokenThresholdRole = task.interface.getSighash('setDefaultTokenThreshold') + await authorizer + .connect(owner) + .authorize(owner.address, task.address, setDefaultTokenThresholdRole, []) + await task.connect(owner).setDefaultTokenThreshold(tokenOut.address, thresholdAmount, 0) + }) + + const executeTask = async ( + amountIn, + slippage, + fee, + hopTokens, + hopFees + ): Promise => { + const callTx = await task.populateTransaction.call( + tokenIn.address, + amountIn, + slippage, + fee, + hopTokens, + hopFees + ) + const callData = `${callTx.data}${extraCallData}` + return owner.sendTransaction({ to: task.address, data: callData }) + } + + context('when the smart vault balance passes the threshold', () => { + beforeEach('fund smart vault', async () => { + await tokenIn.mint(smartVault.address, amountIn) + }) + + context('when the slippage is below the limit', () => { + const hopTokens = [] + const hopFees = [] + const fee = 1 + const slippage = fp(0.01) + const expectedAmountOut = amountIn.mul(tokenRate) + const minAmountOut = expectedAmountOut.mul(fp(1).sub(slippage)).div(fp(1)) + + beforeEach('set max slippage', async () => { + const setDefaultMaxSlippageRole = task.interface.getSighash('setDefaultMaxSlippage') + await authorizer + .connect(owner) + .authorize(owner.address, task.address, setDefaultMaxSlippageRole, []) + await task.connect(owner).setDefaultMaxSlippage(slippage) + }) + const itExecutesTheTaskProperly = (requestedAmount: BigNumberish) => { + it('executes the expected connector', async () => { + const tx = await executeTask(requestedAmount, slippage, fee, hopTokens, hopFees) + const connectorData = connector.interface.encodeFunctionData('execute', [ + tokenIn.address, + tokenOut.address, + amountIn, + minAmountOut, + fee, + hopTokens, + hopFees, + ]) + + await assertIndirectEvent(tx, smartVault.interface, 'Executed', { + connector, + data: connectorData, + }) + + await assertIndirectEvent(tx, connector.interface, 'LogExecute', { + tokenIn, + tokenOut, + amountIn, + minAmountOut, + hopTokens, + hopFees, + }) + }) + + it('emits an Executed event', async () => { + const tx = await executeTask(requestedAmount, slippage, fee, hopTokens, hopFees) + + await assertIndirectEvent(tx, task.interface, 'Executed') + }) + } + + context('without balance connectors', () => { + const requestedAmount = amountIn + + itExecutesTheTaskProperly(requestedAmount) + + it('does not update any balance connectors', async () => { + const tx = await executeTask(requestedAmount, slippage, fee, hopTokens, hopFees) + + await assertNoEvent(tx, 'BalanceConnectorUpdated') + }) + }) + + context('with balance connectors', () => { + const requestedAmount = 0 + const prevConnectorId = '0x0000000000000000000000000000000000000000000000000000000000000001' + const nextConnectorId = '0x0000000000000000000000000000000000000000000000000000000000000002' + + beforeEach('set balance connectors', async () => { + const setBalanceConnectorsRole = task.interface.getSighash('setBalanceConnectors') + await authorizer + .connect(owner) + .authorize(owner.address, task.address, setBalanceConnectorsRole, []) + await task.connect(owner).setBalanceConnectors(prevConnectorId, nextConnectorId) + }) + + beforeEach('authorize task to update balance connectors', async () => { + const updateBalanceConnectorRole = smartVault.interface.getSighash('updateBalanceConnector') + await authorizer + .connect(owner) + .authorize(task.address, smartVault.address, updateBalanceConnectorRole, []) + }) + + beforeEach('assign amount in to previous balance connector', async () => { + const updateBalanceConnectorRole = smartVault.interface.getSighash('updateBalanceConnector') + await authorizer + .connect(owner) + .authorize(owner.address, smartVault.address, updateBalanceConnectorRole, []) + await smartVault + .connect(owner) + .updateBalanceConnector(prevConnectorId, tokenIn.address, amountIn, true) + }) + + itExecutesTheTaskProperly(requestedAmount) + + it('updates the balance connectors properly', async () => { + const tx = await executeTask(requestedAmount, slippage, fee, hopTokens, hopFees) + + await assertIndirectEvent(tx, smartVault.interface, 'BalanceConnectorUpdated', { + id: prevConnectorId, + token: tokenIn.address, + amount: amountIn, + added: false, + }) + + await assertIndirectEvent(tx, smartVault.interface, 'BalanceConnectorUpdated', { + id: nextConnectorId, + token: tokenOut.address, + amount: minAmountOut, + added: true, + }) + }) + }) + }) + + context('when the slippage is above the limit', () => { + const slippage = fp(0.01) + const hopTokens = [] + const hopFees = [] + const fee = 1 + + it('reverts', async () => { + await expect(executeTask(amountIn, slippage, fee, hopTokens, hopFees)).to.be.revertedWith( + 'TaskSlippageAboveMax' + ) + }) + }) + }) + + context('when the smart vault balance does not pass the threshold', () => { + const amountIn = thresholdAmountInTokenIn.div(2) + const hopTokens = [] + const hopFees = [] + const fee = 1 + + beforeEach('fund smart vault', async () => { + await tokenIn.mint(smartVault.address, amountIn) + }) + + it('reverts', async () => { + await expect(executeTask(amountIn, 0, fee, hopTokens, hopFees)).to.be.revertedWith( + 'TaskTokenThresholdNotMet' + ) + }) + }) + }) + + context('when no off-chain oracle is given', () => { + context('when an on-chain oracle is given', () => { + beforeEach('set price feed', async () => { + const feed = await deployFeedMock(fp(tokenRate), 18) + const setFeedRole = priceOracle.interface.getSighash('setFeed') + await authorizer.connect(owner).authorize(owner.address, priceOracle.address, setFeedRole, []) + await priceOracle.connect(owner).setFeed(tokenIn.address, tokenOut.address, feed.address) + }) + + beforeEach('set threshold', async () => { + const setDefaultTokenThresholdRole = task.interface.getSighash('setDefaultTokenThreshold') + await authorizer + .connect(owner) + .authorize(owner.address, task.address, setDefaultTokenThresholdRole, []) + await task + .connect(owner) + .setDefaultTokenThreshold(tokenOut.address, thresholdAmount, maxThresholdAmount) + }) + + context('when the smart vault balance passes the threshold', () => { + beforeEach('fund smart vault', async () => { + await tokenIn.mint(smartVault.address, amountIn) + }) + + context('when the slippage is below the limit', () => { + const hopTokens = [] + const hopFees = [] + const slippage = fp(0.01) + const expectedAmountOut = amountIn.mul(tokenRate) + const minAmountOut = expectedAmountOut.mul(fp(1).sub(slippage)).div(fp(1)) + const fee = 1 + + beforeEach('set max slippage', async () => { + const setDefaultMaxSlippageRole = task.interface.getSighash('setDefaultMaxSlippage') + await authorizer + .connect(owner) + .authorize(owner.address, task.address, setDefaultMaxSlippageRole, []) + await task.connect(owner).setDefaultMaxSlippage(slippage) + }) + + it('executes the expected connector', async () => { + const tx = await task.call(tokenIn.address, amountIn, slippage, fee, hopTokens, hopFees) + + const connectorData = connector.interface.encodeFunctionData('execute', [ + tokenIn.address, + tokenOut.address, + amountIn, + minAmountOut, + fee, + hopTokens, + hopFees, + ]) + + await assertIndirectEvent(tx, smartVault.interface, 'Executed', { + connector, + data: connectorData, + }) + + await assertIndirectEvent(tx, connector.interface, 'LogExecute', { + tokenIn, + tokenOut, + amountIn, + minAmountOut, + fee, + hopTokens, + hopFees, + }) + }) + + it('emits an Executed event', async () => { + const tx = await task.call(tokenIn.address, amountIn, slippage, fee, hopTokens, hopFees) + + await assertIndirectEvent(tx, task.interface, 'Executed') + }) + }) + + context('when the slippage is above the limit', () => { + const slippage = fp(0.01) + const hopTokens = [] + const hopFees = [] + const fee = 1 + + it('reverts', async () => { + await expect( + task.call(tokenIn.address, amountIn, slippage, fee, hopTokens, hopFees) + ).to.be.revertedWith('TaskSlippageAboveMax') + }) + }) + }) + + context('when the smart vault balance does not pass the threshold', () => { + const amountIn = thresholdAmountInTokenIn.div(2) + const hopTokens = [] + const hopFees = [] + const fee = 1 + + beforeEach('fund smart vault', async () => { + await tokenIn.mint(smartVault.address, amountIn) + }) + + it('reverts', async () => { + await expect(task.call(tokenIn.address, amountIn, 0, fee, hopTokens, hopFees)).to.be.revertedWith( + 'TaskTokenThresholdNotMet' + ) + }) + }) + }) + + context('when no on-chain oracle is given', () => { + const fee = 1 + const hopTokens = [] + const hopFees = [] + it('reverts', async () => { + // TODO: Hardhat does not decode price oracle error properly + await expect(task.call(tokenIn.address, amountIn, 0, 1, fee, hopFees, hopTokens)).to.be.reverted + }) + }) + }) + }) + + context('when the token out is not set', () => { + const fee = 1 + const hopTokens = [] + const hopFees = [] + it('reverts', async () => { + await expect(task.call(tokenIn.address, amountIn, 0, fee, hopTokens, hopFees)).to.be.revertedWith( + 'TaskTokenOutNotSet' + ) + }) + }) + }) + + context('when the token in is denied', () => { + const fee = 1 + const hopTokens = [] + const hopFees = [] + + beforeEach('deny token in', async () => { + const setTokensAcceptanceListRole = task.interface.getSighash('setTokensAcceptanceList') + await authorizer.connect(owner).authorize(owner.address, task.address, setTokensAcceptanceListRole, []) + await task.connect(owner).setTokensAcceptanceList([tokenIn.address], [true]) + }) + + it('reverts', async () => { + await expect(task.call(tokenIn.address, 0, 0, fee, hopTokens, hopFees)).to.be.revertedWith( + 'TaskTokenNotAllowed' + ) + }) + }) + }) + + context('when the amount in is zero', () => { + const amountIn = 0 + const fee = 1 + const hopTokens = [] + const hopFees = [] + + it('reverts', async () => { + await expect(task.call(tokenIn.address, amountIn, 0, fee, hopTokens, hopFees)).to.be.revertedWith( + 'TaskAmountZero' + ) + }) + }) + }) + + context('when the token in is the zero address', () => { + const tokenIn = ZERO_ADDRESS + const fee = 1 + const hopTokens = [] + const hopFees = [] + + it('reverts', async () => { + await expect(task.call(tokenIn, 0, 0, fee, hopTokens, hopFees)).to.be.revertedWith('TaskTokenZero') + }) + }) + }) + + context('when the sender is not authorized', () => { + const fee = 1 + const hopTokens = [] + const hopFees = [] + + it('reverts', async () => { + await expect(task.call(ZERO_ADDRESS, 0, 0, fee, hopTokens, hopFees)).to.be.revertedWith('AuthSenderNotAllowed') + }) + }) + }) +})