diff --git a/.env.sample b/.env.sample index 57cd4957..b6126542 100644 --- a/.env.sample +++ b/.env.sample @@ -4,3 +4,4 @@ GOERLI_API_KEY="provider api key" MUMBAI_MNEMONIC="your mumbai mnemonic" MUMBAI_PROVIDER_URL="provider url" MUMBAI_API_KEY="provider api key" +USDC_OWNER_ADDRESS=0x7713974908Be4BEd47172370115e8b1219F4A5f0 diff --git a/Makefile b/Makefile index 0c6358fb..2570cff9 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ +ifneq (,$(wildcard ./.env)) + include .env + export +endif + URL?=http://localhost:8545 install-deps: @@ -19,7 +24,7 @@ start-ganache: start-forkedMainnet: @echo " > \033[32mStarting forked environment... \033[0m " - ganache -f $(FORKED_TESTS_PROVIDER) & sleep 3 + ganache -f $(FORKED_TESTS_PROVIDER) -u ${USDC_OWNER_ADDRESS} & sleep 3 test-forked: @echo " > \033[32mTesting contracts... \033[0m " diff --git a/contracts/adapters/SwapAdapter.sol b/contracts/adapters/SwapAdapter.sol new file mode 100644 index 00000000..0c182caf --- /dev/null +++ b/contracts/adapters/SwapAdapter.sol @@ -0,0 +1,469 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.11; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../../contracts/interfaces/IBridge.sol"; +import "../../contracts/interfaces/IFeeHandler.sol"; +import "../../contracts/adapters/interfaces/INativeTokenAdapter.sol"; +import "../../contracts/adapters/interfaces/IWETH.sol"; +import "../../contracts/adapters/interfaces/IUniversalRouter.sol"; +import "../../contracts/adapters/interfaces/IPermit2.sol"; + +/** + @title Contract that swaps tokens to ETH or ETH to tokens using Uniswap + and then makes a deposit to the Bridge. + @author ChainSafe Systems. + */ +contract SwapAdapter is AccessControl { + + using SafeERC20 for IERC20; + + uint8 constant V3_SWAP_EXACT_OUT = 1; + + IBridge public immutable _bridge; + address public immutable _weth; + IUniversalRouter public immutable _swapRouter; + INativeTokenAdapter public immutable _nativeTokenAdapter; + bytes32 public immutable _nativeResourceID; + IPermit2 public immutable _permit2; + + mapping(address => bytes32) public tokenToResourceID; + + // Used to avoid "stack too deep" error + struct LocalVars { + uint256 fee; + uint256 totalAmountOut; + uint256 amountIn; + uint256 swapAmount; + address feeHandlerRouter; + bytes32 resourceID; + address ERC20HandlerAddress; + uint256 leftover; + bytes depositDataAfterAmount; + bytes path; + bytes depositData; + } + + error CallerNotAdmin(); + error AlreadySet(); + error TokenInvalid(); + error PathInvalid(); + error MsgValueLowerThanFee(uint256 value); + error InsufficientAmount(uint256 amount); + error FailedFundsTransfer(); + error AmountLowerThanFee(uint256 amount); + + event TokenResourceIDSet(address token, bytes32 resourceID); + event TokensSwapped(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut); + + /** + @dev The contract uses Uniswap UniversalRouter and Permit2. These addresses + should be set during initialization. Addresses of deployed Uniswap contracts can be + found in Uniswap docs. + */ + + constructor( + IBridge bridge, + address weth, + IUniversalRouter swapRouter, + IPermit2 permit2, + INativeTokenAdapter nativeTokenAdapter + ) { + _bridge = bridge; + _weth = weth; + _swapRouter = swapRouter; + _permit2 = permit2; + _nativeTokenAdapter = nativeTokenAdapter; + _nativeResourceID = nativeTokenAdapter._resourceID(); + IERC20(_weth).approve(address(_permit2), type(uint256).max); + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + modifier onlyAdmin() { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert CallerNotAdmin(); + _; + } + + // Admin functions + function setTokenResourceID(address token, bytes32 resourceID) external onlyAdmin { + if (tokenToResourceID[token] == resourceID) revert AlreadySet(); + tokenToResourceID[token] = resourceID; + emit TokenResourceIDSet(token, resourceID); + } + + /** + @notice Function for depositing tokens, performing swap to ETH and bridging the ETH. + @param destinationDomainID ID of chain deposit will be bridged to. + @param recipient Recipient of the deposit. + @param token Input token to be swapped. + @param amountInMax Max amount of input tokens to be swapped. It should exceed the desired + amount of output tokens because the amount of swapped ETH should also cover the bridging fee. + It's equal to tokenSwapRate * (amountOut + bridging fee) + @param amountOut Amount of ETH to be bridged. + @param pathTokens Addresses of the tokens for Uniswap swap (in reverse order). WETH address is used for ETH. + @param pathFees Fees for Uniswap pools (in reverse order). + */ + function depositTokensToEth( + uint8 destinationDomainID, + address recipient, + address token, + uint256 amountInMax, + uint256 amountOut, + address[] calldata pathTokens, + uint24[] calldata pathFees + ) external { + LocalVars memory vars; + vars.resourceID = _nativeResourceID; + + // Compose depositData + vars.depositDataAfterAmount = abi.encodePacked( + uint256(20), + recipient + ); + + vars.feeHandlerRouter = _bridge._feeHandler(); + (vars.fee, ) = IFeeHandler(vars.feeHandlerRouter).calculateFee( + address(this), + _bridge._domainID(), + destinationDomainID, + vars.resourceID, + abi.encodePacked(amountOut, vars.depositDataAfterAmount), + "" // feeData - not parsed + ); + + vars.totalAmountOut = amountOut + vars.fee; + + // Swap tokens to ETH (exact output) + IERC20(token).safeTransferFrom(msg.sender, address(this), amountInMax); + + vars.amountIn = swapTokens( + pathTokens, + pathFees, + token, + _weth, + amountInMax, + vars.totalAmountOut + ); + + IWETH(_weth).withdraw(vars.totalAmountOut); + + // Make Native Token deposit + _nativeTokenAdapter.depositToEVM{value: vars.totalAmountOut}(destinationDomainID, recipient); + + // Refund tokens + if (vars.amountIn < amountInMax) { + IERC20(token).safeTransfer(msg.sender, IERC20(token).balanceOf(address(this))); + } + + // Return unspent fee to msg.sender + uint256 leftover = address(this).balance; + if (leftover > 0) { + payable(msg.sender).call{value: leftover}(""); + // Do not revert if sender does not want to receive. + } + } + + /** + @notice Function for depositing ETH, performing swap to defined tokens and bridging + the tokens. + msg.value should not only cover the swap for desired amount of tokens + but it should also cover the bridging fee. + It's equal to bridging fee + tokenSwapRate * amountOut + @param destinationDomainID ID of chain deposit will be bridged to. + @param recipient Recipient of the deposit. + @param token Output token to be deposited after swapping. + @param amountOut Amount of tokens to be bridged. + @param pathTokens Addresses of the tokens for Uniswap swap (in reverse order). WETH address is used for ETH. + @param pathFees Fees for Uniswap pools (in reverse order). + */ + function depositEthToTokens( + uint8 destinationDomainID, + address recipient, + address token, + uint256 amountOut, + address[] calldata pathTokens, + uint24[] calldata pathFees + ) external payable { + LocalVars memory vars; + vars.resourceID = tokenToResourceID[token]; + if (vars.resourceID == bytes32(0)) revert TokenInvalid(); + + // Compose depositData + vars.depositDataAfterAmount = abi.encodePacked( + uint256(20), + recipient + ); + if (msg.value == 0) revert InsufficientAmount(msg.value); + vars.depositData = abi.encodePacked( + amountOut, + vars.depositDataAfterAmount + ); + + vars.feeHandlerRouter = _bridge._feeHandler(); + (vars.fee, ) = IFeeHandler(vars.feeHandlerRouter).calculateFee( + address(this), + _bridge._domainID(), + destinationDomainID, + vars.resourceID, + vars.depositData, + "" // feeData - not parsed + ); + + if (msg.value < vars.fee) revert MsgValueLowerThanFee(msg.value); + // Convert everything except the fee + vars.swapAmount = msg.value - vars.fee; + IWETH(_weth).deposit{value: vars.swapAmount}(); + vars.amountIn = swapTokens( + pathTokens, + pathFees, + _weth, + token, + vars.swapAmount, + amountOut + ); + + IWETH(_weth).withdraw(IERC20(_weth).balanceOf(address(this))); + vars.ERC20HandlerAddress = _bridge._resourceIDToHandlerAddress(vars.resourceID); + IERC20(token).safeApprove(address(vars.ERC20HandlerAddress), amountOut); + _bridge.deposit{value: vars.fee}(destinationDomainID, vars.resourceID, vars.depositData, ""); + + // Return unspent native currency to msg.sender + vars.leftover = address(this).balance; + if (vars.leftover > 0) { + payable(msg.sender).call{value: vars.leftover}(""); + // Do not revert if sender does not want to receive. + } + } + + /** + @notice Function for depositing tokens, performing swap to ETH, bridging the ETH and executing + a contract call on destination. + @param destinationDomainID ID of chain deposit will be bridged to. + @param recipient Recipient of the deposit. + @param gas The amount of gas needed to successfully execute the call to recipient on the destination. Fee amount is + directly affected by this value. + @param message Arbitrary encoded bytes array that will be passed as the third argument in the + ISygmaMessageReceiver(recipient).handleSygmaMessage(_, _, message) call. If you intend to use the + DefaultMessageReceiver, make sure to encode the message to comply with the + DefaultMessageReceiver.handleSygmaMessage() message decoding implementation. + @param token Input token to be swapped. + @param amountInMax Max amount of input tokens to be swapped. It should exceed the desired + amount of output tokens because the amount of swapped ETH should also cover the bridging fee. + It's equal to tokenSwapRate * (amountOut + bridging fee) + @param amountOut Amount of ETH to be bridged. + @param pathTokens Addresses of the tokens for Uniswap swap (in reverse order). WETH address is used for ETH. + @param pathFees Fees for Uniswap pools (in reverse order). + */ + function depositTokensToEthWithMessage( + uint8 destinationDomainID, + address recipient, + uint256 gas, + bytes memory message, + address token, + uint256 amountInMax, + uint256 amountOut, + address[] calldata pathTokens, + uint24[] calldata pathFees + ) external { + LocalVars memory vars; + vars.resourceID = _nativeResourceID; + + // Compose depositData + vars.depositDataAfterAmount = abi.encodePacked( + uint256(20), + recipient, + gas, + uint256(message.length), + message + ); + + vars.feeHandlerRouter = _bridge._feeHandler(); + (vars.fee, ) = IFeeHandler(vars.feeHandlerRouter).calculateFee( + address(this), + _bridge._domainID(), + destinationDomainID, + vars.resourceID, + abi.encodePacked(amountOut, vars.depositDataAfterAmount), + "" // feeData - not parsed + ); + + vars.totalAmountOut = amountOut + vars.fee; + + // Swap tokens to ETH (exact output) + IERC20(token).safeTransferFrom(msg.sender, address(this), amountInMax); + + vars.amountIn = swapTokens( + pathTokens, + pathFees, + token, + _weth, + amountInMax, + vars.totalAmountOut + ); + + IWETH(_weth).withdraw(vars.totalAmountOut); + + // Refund tokens + if (vars.amountIn < amountInMax) { + IERC20(token).safeTransfer(msg.sender, IERC20(token).balanceOf(address(this))); + } + + // Make Native Token deposit + _nativeTokenAdapter.depositToEVMWithMessage{value: vars.totalAmountOut}( + destinationDomainID, + recipient, + gas, + message + ); + + // Return unspent native currency to msg.sender + vars.leftover = address(this).balance; + if (vars.leftover > 0) { + payable(msg.sender).call{value: vars.leftover}(""); + // Do not revert if sender does not want to receive. + } + } + + /** + @notice Function for depositing ETH, performing swap to defined tokens and bridging + the tokens. + msg.value should not only cover the swap for desired amount of tokens + but it should also cover the bridging fee. + It's equal to bridging fee + tokenSwapRate * amountOut + @param destinationDomainID ID of chain deposit will be bridged to. + @param recipient Recipient of the deposit. + @param gas The amount of gas needed to successfully execute the call to recipient on the destination. Fee amount is + directly affected by this value. + @param message Arbitrary encoded bytes array that will be passed as the third argument in the + ISygmaMessageReceiver(recipient).handleSygmaMessage(_, _, message) call. If you intend to use the + DefaultMessageReceiver, make sure to encode the message to comply with the + DefaultMessageReceiver.handleSygmaMessage() message decoding implementation. + @param token Output token to be deposited after swapping. + @param amountOut Amount of tokens to be bridged. + @param pathTokens Addresses of the tokens for Uniswap swap (in reverse order). WETH address is used for ETH. + @param pathFees Fees for Uniswap pools (in reverse order). + */ + function depositEthToTokensWithMessage( + uint8 destinationDomainID, + address recipient, + uint256 gas, + bytes calldata message, + address token, + uint256 amountOut, + address[] calldata pathTokens, + uint24[] calldata pathFees + ) external payable { + LocalVars memory vars; + vars.resourceID = tokenToResourceID[token]; + if (vars.resourceID == bytes32(0)) revert TokenInvalid(); + + // Compose depositData + vars.depositDataAfterAmount = abi.encodePacked( + uint256(20), + recipient, + gas, + uint256(message.length), + message + ); + if (msg.value == 0) revert InsufficientAmount(msg.value); + vars.depositData = abi.encodePacked( + amountOut, + vars.depositDataAfterAmount + ); + + vars.feeHandlerRouter = _bridge._feeHandler(); + (vars.fee, ) = IFeeHandler(vars.feeHandlerRouter).calculateFee( + address(this), + _bridge._domainID(), + destinationDomainID, + vars.resourceID, + vars.depositData, + "" // feeData - not parsed + ); + + if (msg.value < vars.fee) revert MsgValueLowerThanFee(msg.value); + // Convert everything except the fee + vars.swapAmount = msg.value - vars.fee; + IWETH(_weth).deposit{value: vars.swapAmount}(); + vars.amountIn = swapTokens( + pathTokens, + pathFees, + _weth, + token, + vars.swapAmount, + amountOut + ); + IWETH(_weth).withdraw(IERC20(_weth).balanceOf(address(this))); + + vars.ERC20HandlerAddress = _bridge._resourceIDToHandlerAddress(vars.resourceID); + IERC20(token).safeApprove(address(vars.ERC20HandlerAddress), amountOut); + _bridge.deposit{value: vars.fee}(destinationDomainID, vars.resourceID, vars.depositData, ""); + + // Return unspent native currency to msg.sender + vars.leftover = address(this).balance; + if (vars.leftover > 0) { + payable(msg.sender).call{value: vars.leftover}(""); + // Do not revert if sender does not want to receive. + } + } + + function swapTokens( + address[] calldata pathTokens, + uint24[] calldata pathFees, + address tokenIn, + address tokenOut, + uint256 amountInMaximum, + uint256 amountOut + ) internal returns(uint256 amountIn) { + uint256 balanceBefore = IERC20(tokenIn).balanceOf(address(this)); + bytes memory path = _verifyAndEncodePath( + pathTokens, + pathFees, + tokenIn, + tokenOut + ); + IERC20(tokenIn).approve(address(_permit2), amountInMaximum); + IPermit2(_permit2).approve(tokenIn, address(_swapRouter), uint160(amountInMaximum), uint48(block.timestamp)); + bytes memory commands = abi.encodePacked(V3_SWAP_EXACT_OUT); // V3_SWAP_EXACT_OUT + bytes[] memory inputs = new bytes[](1); + inputs[0] = abi.encode( + address(this), // The recipient of the output of the trade + amountOut, // The amount of output tokens to receive + amountInMaximum, // The maximum number of input tokens that should be spent + path, // The UniswapV3 encoded path to trade along + true // A flag for whether the input tokens should come from the msg.sender + ); + _swapRouter.execute(commands, inputs, block.timestamp); + IERC20(tokenIn).approve(address(_permit2), 0); + uint256 balanceAfter = IERC20(tokenIn).balanceOf(address(this)); + amountIn = balanceBefore - balanceAfter; + emit TokensSwapped(tokenIn, tokenOut, amountIn, amountOut); + } + + function _verifyAndEncodePath( + address[] calldata tokens, + uint24[] calldata fees, + address tokenIn, + address tokenOut + ) internal view returns (bytes memory path) { + if (tokens.length != fees.length + 1) { + revert PathInvalid(); + } + + tokenIn = tokenIn == address(0) ? address(_weth) : tokenIn; + if (tokens[tokens.length - 1] != tokenIn) revert PathInvalid(); + + tokenOut = tokenOut == address(0) ? address(_weth) : tokenOut; + if (tokens[0] != tokenOut) revert PathInvalid(); + + for (uint256 i = 0; i < tokens.length - 1; i++){ + path = abi.encodePacked(path, tokens[i], fees[i]); + } + path = abi.encodePacked(path, tokens[tokens.length - 1]); + } + + receive() external payable {} +} diff --git a/contracts/adapters/interfaces/INativeTokenAdapter.sol b/contracts/adapters/interfaces/INativeTokenAdapter.sol new file mode 100644 index 00000000..a937998e --- /dev/null +++ b/contracts/adapters/interfaces/INativeTokenAdapter.sol @@ -0,0 +1,23 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.11; + +/** + @title Interface for Bridge contract. + @author ChainSafe Systems. + */ +interface INativeTokenAdapter { + function _resourceID() external view returns(bytes32); + + function depositToEVM( + uint8 destinationDomainID, + address recipientAddress + ) external payable; + + function depositToEVMWithMessage( + uint8 destinationDomainID, + address recipient, + uint256 gas, + bytes calldata message + ) external payable; +} diff --git a/contracts/adapters/interfaces/IPermit2.sol b/contracts/adapters/interfaces/IPermit2.sol new file mode 100644 index 00000000..e4c023fc --- /dev/null +++ b/contracts/adapters/interfaces/IPermit2.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.11; + +/// @notice Permit2 handles signature-based transfers in SignatureTransfer and allowance-based transfers in AllowanceTransfer. +/// @dev Users must approve Permit2 before calling any of the transfer functions. +interface IPermit2 { + /// @notice Approves the spender to use up to amount of the specified token up until the expiration + /// @param token The token to approve + /// @param spender The spender address to approve + /// @param amount The approved amount of the token + /// @param expiration The timestamp at which the approval is no longer valid + /// @dev The packed allowance also holds a nonce, which will stay unchanged in approve + /// @dev Setting amount to type(uint160).max sets an unlimited approval + function approve(address token, address spender, uint160 amount, uint48 expiration) external; +} \ No newline at end of file diff --git a/contracts/adapters/interfaces/IUniversalRouter.sol b/contracts/adapters/interfaces/IUniversalRouter.sol new file mode 100644 index 00000000..1c171401 --- /dev/null +++ b/contracts/adapters/interfaces/IUniversalRouter.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +interface IUniversalRouter { + + /// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. + /// @param commands A set of concatenated commands, each 1 byte in length + /// @param inputs An array of byte strings containing abi encoded inputs for each command + /// @param deadline The deadline by which the transaction must be executed + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; +} \ No newline at end of file diff --git a/contracts/adapters/interfaces/IWETH.sol b/contracts/adapters/interfaces/IWETH.sol new file mode 100644 index 00000000..e42225ba --- /dev/null +++ b/contracts/adapters/interfaces/IWETH.sol @@ -0,0 +1,6 @@ +pragma solidity 0.8.11; + +interface IWETH { + function withdraw(uint wad) external; + function deposit() external payable; +} diff --git a/testUnderForked/swapAdapter.js b/testUnderForked/swapAdapter.js new file mode 100644 index 00000000..69296b22 --- /dev/null +++ b/testUnderForked/swapAdapter.js @@ -0,0 +1,549 @@ +// The Licensed Work is (c) 2022 Sygma +// SPDX-License-Identifier: LGPL-3.0-only + +const TruffleAssert = require("truffle-assertions"); +const Ethers = require("ethers"); + +const Helpers = require("../test/helpers"); +const dotenv = require("dotenv"); +dotenv.config(); + +const ERC20HandlerContract = artifacts.require("ERC20Handler"); +const DefaultMessageReceiverContract = artifacts.require("DefaultMessageReceiver"); +const FeeHandlerRouterContract = artifacts.require("FeeHandlerRouter"); +const BasicFeeHandlerContract = artifacts.require("BasicFeeHandler"); +const NativeTokenAdapterContract = artifacts.require("NativeTokenAdapter"); +const NativeTokenHandlerContract = artifacts.require("NativeTokenHandler"); +const SwapAdapterContract = artifacts.require("SwapAdapter"); +const ERC20MintableContract = artifacts.require("ERC20PresetMinterPauser"); + +contract("SwapAdapter", async (accounts) => { + // Fee handler is mocked by BasicFeeHandler + // deploy bridge, ERC20Handler, NativeTokenHandler, BasicFeeHandler, SwapAdapter + // use Uniswap UniversalRouter, Permit2, USDC, WETH, user with USDC from mainnet fork + const recipientAddress = accounts[2]; + const fee = 1000; + const depositorAddress = accounts[3]; + const emptySetResourceData = "0x"; + const originDomainID = 1; + const destinationDomainID = 3; + const executionGasAmount = 30000000; + const expectedDepositNonce = 1; + const WETH_ADDRESS = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; + const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + const USDC_OWNER_ADDRESS = process.env.USDC_OWNER_ADDRESS; + const UNIVERSAL_ROUTER_ADDRESS = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; + const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + const resourceID_USDC = Helpers.createResourceID( + USDC_ADDRESS, + originDomainID + ); + const resourceID_Native = "0x0000000000000000000000000000000000000000000000000000000000000650"; + const transactionId = "0x0000000000000000000000000000000000000000000000000000000000000001"; + + let BridgeInstance; + let DefaultMessageReceiverInstance; + let BasicFeeHandlerInstance; + let ERC20HandlerInstance; + let ERC20MintableInstance; + let NativeTokenAdapterInstance; + let NativeTokenHandlerInstance; + let SwapAdapterInstance; + let usdc; + let weth; + let message; + + beforeEach(async () => { + BridgeInstance = await Helpers.deployBridge( + originDomainID, + accounts[0] + ); + DefaultMessageReceiverInstance = await DefaultMessageReceiverContract.new([], 100000); + ERC20HandlerInstance = await ERC20HandlerContract.new( + BridgeInstance.address, + DefaultMessageReceiverInstance.address + ); + ERC20MintableInstance = await ERC20MintableContract.new( + "token", + "TOK" + ); + FeeHandlerRouterInstance = await FeeHandlerRouterContract.new( + BridgeInstance.address + ); + BasicFeeHandlerInstance = await BasicFeeHandlerContract.new( + BridgeInstance.address, + FeeHandlerRouterInstance.address + ); + NativeTokenAdapterInstance = await NativeTokenAdapterContract.new( + BridgeInstance.address, + resourceID_Native + ); + NativeTokenHandlerInstance = await NativeTokenHandlerContract.new( + BridgeInstance.address, + NativeTokenAdapterInstance.address, + DefaultMessageReceiverInstance.address + ); + SwapAdapterInstance = await SwapAdapterContract.new( + BridgeInstance.address, + WETH_ADDRESS, + UNIVERSAL_ROUTER_ADDRESS, + PERMIT2_ADDRESS, + NativeTokenAdapterInstance.address + ); + usdc = await ERC20MintableContract.at(USDC_ADDRESS); + weth = await ERC20MintableContract.at(WETH_ADDRESS); + + await BridgeInstance.adminSetResource( + NativeTokenHandlerInstance.address, + resourceID_Native, + NativeTokenHandlerInstance.address, + emptySetResourceData + ); + + await BridgeInstance.adminSetResource( + ERC20HandlerInstance.address, + resourceID_USDC, + USDC_ADDRESS, + emptySetResourceData + ); + + await BasicFeeHandlerInstance.changeFee(destinationDomainID, resourceID_Native, fee); + await BasicFeeHandlerInstance.changeFee(destinationDomainID, resourceID_USDC, fee); + await BridgeInstance.adminChangeFeeHandler(FeeHandlerRouterInstance.address), + await FeeHandlerRouterInstance.adminSetResourceHandler( + destinationDomainID, + resourceID_Native, + BasicFeeHandlerInstance.address + ); + + await FeeHandlerRouterInstance.adminSetResourceHandler( + destinationDomainID, + resourceID_USDC, + BasicFeeHandlerInstance.address + ); + + const mintableERC20Iface = new Ethers.utils.Interface(["function mint(address to, uint256 amount)"]); + const actions = [{ + nativeValue: 0, + callTo: ERC20MintableInstance.address, + approveTo: Ethers.constants.AddressZero, + tokenSend: Ethers.constants.AddressZero, + tokenReceive: Ethers.constants.AddressZero, + data: mintableERC20Iface.encodeFunctionData("mint", [recipientAddress, "20"]), + }]; + message = Helpers.createMessageCallData( + transactionId, + actions, + DefaultMessageReceiverInstance.address + ); + + // set MPC address to unpause the Bridge + await BridgeInstance.endKeygen(Helpers.mpcAddress); + }); + + it("should swap tokens to ETH and bridge ETH", async () => { + const pathTokens = [WETH_ADDRESS, USDC_ADDRESS]; + const pathFees = [500]; + const amountInMax = 1000000; + const amountOut = Ethers.utils.parseUnits("200000", "gwei"); + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await usdc.approve(SwapAdapterInstance.address, amountInMax, {from: USDC_OWNER_ADDRESS}); + const balanceBefore = await usdc.balanceOf(USDC_OWNER_ADDRESS); + const depositTx = await SwapAdapterInstance.depositTokensToEth( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amountInMax, + amountOut, + pathTokens, + pathFees, + {from: USDC_OWNER_ADDRESS} + ); + expect(await web3.eth.getBalance(SwapAdapterInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(BasicFeeHandlerInstance.address)).to.eq(fee.toString()); + expect(await web3.eth.getBalance(NativeTokenHandlerInstance.address)).to.not.eq("0"); + + const depositCount = await BridgeInstance._depositCounts.call( + destinationDomainID + ); + const expectedDepositNonce = 1; + assert.strictEqual(depositCount.toNumber(), expectedDepositNonce); + + const internalTx = await TruffleAssert.createTransactionResult( + BridgeInstance, + depositTx.tx + ); + + const events = await SwapAdapterInstance.getPastEvents("TokensSwapped", {fromBlock: depositTx.receipt.blockNumber}); + const amountIn = events[events.length - 1].args.amountIn; + + const balanceAfter = await usdc.balanceOf(USDC_OWNER_ADDRESS); + expect(balanceAfter.toNumber()).to.eq(balanceBefore - amountIn); + expect(balanceAfter.toNumber()).to.be.gt(balanceBefore - amountInMax); + expect(balanceAfter.toNumber()).to.eq(balanceBefore - amountIn); + + const depositData = await Helpers.createERCDepositData(amountOut, 20, recipientAddress); + + TruffleAssert.eventEmitted(internalTx, "Deposit", (event) => { + return ( + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID_Native.toLowerCase() && + event.depositNonce.toNumber() === expectedDepositNonce && + event.user === NativeTokenAdapterInstance.address && + event.data === depositData.toLowerCase() && + event.handlerResponse === null + ); + }); + }); + + it("should swap ETH to tokens and bridge tokens", async () => { + const pathTokens = [USDC_ADDRESS, WETH_ADDRESS]; + const pathFees = [500]; + const amountInMax = Ethers.utils.parseEther("1"); + const amountOut = 2000000000; + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + const balanceBefore = await web3.eth.getBalance(depositorAddress); + const depositTx = await SwapAdapterInstance.depositEthToTokens( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amountOut, + pathTokens, + pathFees, + { + value: amountInMax, + from: depositorAddress + } + ); + const balanceAfter = await web3.eth.getBalance(depositorAddress); + expect((await usdc.balanceOf(SwapAdapterInstance.address)).toString()).to.eq("0"); + expect(await web3.eth.getBalance(SwapAdapterInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(BridgeInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(FeeHandlerRouterInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(ERC20HandlerInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(UNIVERSAL_ROUTER_ADDRESS)).to.eq("0"); + expect((await weth.balanceOf(UNIVERSAL_ROUTER_ADDRESS)).toNumber()).to.eq(0); + expect((await weth.balanceOf(SwapAdapterInstance.address)).toNumber()).to.eq(0); + expect((await usdc.balanceOf(ERC20HandlerInstance.address)).toString()).to.eq(amountOut.toString()); + + expect(await web3.eth.getBalance(BasicFeeHandlerInstance.address)).to.eq(fee.toString()); + expect((await usdc.balanceOf(ERC20HandlerInstance.address)).toNumber()).to.eq(amountOut); + + const depositCount = await BridgeInstance._depositCounts.call( + destinationDomainID + ); + const expectedDepositNonce = 1; + assert.strictEqual(depositCount.toNumber(), expectedDepositNonce); + + const internalTx = await TruffleAssert.createTransactionResult( + BridgeInstance, + depositTx.tx + ); + + const events = await SwapAdapterInstance.getPastEvents("TokensSwapped", {fromBlock: depositTx.receipt.blockNumber}); + const amountIn = events[events.length - 1].args.amountIn; + const gasPrice = (await web3.eth.getTransactionReceipt( + depositTx.tx + )).effectiveGasPrice; + const txCost = gasPrice * depositTx.receipt.gasUsed; + assert.equal(Ethers.BigNumber.from(balanceAfter).toString(), + Ethers.BigNumber.from(balanceBefore).sub(amountIn.toString()).sub(txCost).sub(fee).toString()); + + const depositData = await Helpers.createERCDepositData(amountOut, 20, recipientAddress); + + TruffleAssert.eventEmitted(internalTx, "Deposit", (event) => { + return ( + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID_USDC.toLowerCase() && + event.depositNonce.toNumber() === expectedDepositNonce && + event.user === SwapAdapterInstance.address && + event.data === depositData.toLowerCase() && + event.handlerResponse === null + ); + }); + }); + + it("should swap tokens to ETH and bridge ETH with contract call", async () => { + const pathTokens = [WETH_ADDRESS, USDC_ADDRESS]; + const pathFees = [500]; + const amountInMax = 1000000; + const amountOut = Ethers.utils.parseUnits("200000", "gwei"); + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await usdc.approve(SwapAdapterInstance.address, amountInMax, {from: USDC_OWNER_ADDRESS}); + const balanceBefore = await usdc.balanceOf(USDC_OWNER_ADDRESS); + const depositTx = await SwapAdapterInstance.depositTokensToEthWithMessage( + destinationDomainID, + recipientAddress, + executionGasAmount, + message, + USDC_ADDRESS, + amountInMax, + amountOut, + pathTokens, + pathFees, + {from: USDC_OWNER_ADDRESS} + ); + expect(await web3.eth.getBalance(SwapAdapterInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(BasicFeeHandlerInstance.address)).to.eq(fee.toString()); + expect(await web3.eth.getBalance(NativeTokenHandlerInstance.address)).to.not.eq("0"); + + const depositCount = await BridgeInstance._depositCounts.call( + destinationDomainID + ); + assert.strictEqual(depositCount.toNumber(), expectedDepositNonce); + + const internalTx = await TruffleAssert.createTransactionResult( + BridgeInstance, + depositTx.tx + ); + + const events = await SwapAdapterInstance.getPastEvents("TokensSwapped", {fromBlock: depositTx.receipt.blockNumber}); + const amountIn = events[events.length - 1].args.amountIn; + + const balanceAfter = await usdc.balanceOf(USDC_OWNER_ADDRESS); + expect(balanceAfter.toNumber()).to.eq(balanceBefore - amountIn); + expect(balanceAfter.toNumber()).to.be.gt(balanceBefore - amountInMax); + expect(balanceAfter.toNumber()).to.eq(balanceBefore - amountIn); + + const depositData = await Helpers.createOptionalContractCallDepositData( + amountOut, + recipientAddress, + executionGasAmount, + message + ); + + TruffleAssert.eventEmitted(internalTx, "Deposit", (event) => { + return ( + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID_Native.toLowerCase() && + event.depositNonce.toNumber() === expectedDepositNonce && + event.user === NativeTokenAdapterInstance.address && + event.data === depositData.toLowerCase() && + event.handlerResponse === null + ); + }); + }); + + it("should swap ETH to tokens and bridge tokens with contract call", async () => { + const pathTokens = [USDC_ADDRESS, WETH_ADDRESS]; + const pathFees = [500]; + const amountInMax = Ethers.utils.parseEther("1"); + const amountOut = 2000000000; + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + const balanceBefore = await web3.eth.getBalance(depositorAddress); + const depositTx = await SwapAdapterInstance.depositEthToTokensWithMessage( + destinationDomainID, + recipientAddress, + executionGasAmount, + message, + USDC_ADDRESS, + amountOut, + pathTokens, + pathFees, + { + value: amountInMax, + from: depositorAddress + } + ); + const balanceAfter = await web3.eth.getBalance(depositorAddress); + expect((await usdc.balanceOf(SwapAdapterInstance.address)).toString()).to.eq("0"); + expect(await web3.eth.getBalance(SwapAdapterInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(BridgeInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(FeeHandlerRouterInstance.address)).to.eq("0"); + expect(await web3.eth.getBalance(BasicFeeHandlerInstance.address)).to.eq(fee.toString()); + expect(await usdc.balanceOf(ERC20HandlerInstance.address)).to.not.eq("0"); + expect((await usdc.balanceOf(ERC20HandlerInstance.address)).toString()).to.eq(amountOut.toString()); + + const depositCount = await BridgeInstance._depositCounts.call( + destinationDomainID + ); + const expectedDepositNonce = 1; + assert.strictEqual(depositCount.toNumber(), expectedDepositNonce); + + const internalTx = await TruffleAssert.createTransactionResult( + BridgeInstance, + depositTx.tx + ); + + const events = await SwapAdapterInstance.getPastEvents("TokensSwapped", {fromBlock: depositTx.receipt.blockNumber}); + const amountIn = events[events.length - 1].args.amountIn; + + const gasPrice = (await web3.eth.getTransactionReceipt( + depositTx.tx + )).effectiveGasPrice; + const txCost = gasPrice * depositTx.receipt.gasUsed; + assert.equal(Ethers.BigNumber.from(balanceAfter).toString(), + Ethers.BigNumber.from(balanceBefore).sub(amountIn.toString()).sub(txCost).sub(fee).toString()); + + const depositData = await Helpers.createOptionalContractCallDepositData( + amountOut, + recipientAddress, + executionGasAmount, + message + ); + + TruffleAssert.eventEmitted(internalTx, "Deposit", (event) => { + return ( + event.destinationDomainID.toNumber() === destinationDomainID && + event.resourceID === resourceID_USDC.toLowerCase() && + event.depositNonce.toNumber() === expectedDepositNonce && + event.user === SwapAdapterInstance.address && + event.data === depositData.toLowerCase() && + event.handlerResponse === null + ); + }); + }); + + it("should fail if no approve", async () => { + const pathTokens = [USDC_ADDRESS, WETH_ADDRESS]; + const pathFees = [500]; + const amount = 1000000; + const amountOutMinimum = Ethers.utils.parseUnits("200000", "gwei"); + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await Helpers.reverts( + SwapAdapterInstance.depositTokensToEth( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amount, + amountOutMinimum, + pathTokens, + pathFees, + {from: USDC_OWNER_ADDRESS} + ) + ); + }); + + it("should fail if the path is invalid [tokens length and fees length]", async () => { + const pathTokens = [WETH_ADDRESS, USDC_ADDRESS]; + const pathFees = [500, 300]; + const amount = 1000000; + const amountOutMinimum = Ethers.utils.parseUnits("200000", "gwei"); + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await usdc.approve(SwapAdapterInstance.address, amount, {from: USDC_OWNER_ADDRESS}); + await Helpers.expectToRevertWithCustomError( + SwapAdapterInstance.depositTokensToEth.call( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amount, + amountOutMinimum, + pathTokens, + pathFees, + {from: USDC_OWNER_ADDRESS} + ), + "PathInvalid()" + ); + }); + + it("should fail if the path is invalid [tokenIn is not token0]", async () => { + const pathTokens = [USDC_ADDRESS, WETH_ADDRESS]; + const pathFees = [500]; + const amount = 1000000; + const amountOutMinimum = Ethers.utils.parseUnits("200000", "gwei"); + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await usdc.approve(SwapAdapterInstance.address, amount, {from: USDC_OWNER_ADDRESS}); + await Helpers.expectToRevertWithCustomError( + SwapAdapterInstance.depositTokensToEth.call( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amount, + amountOutMinimum, + pathTokens, + pathFees, + {from: USDC_OWNER_ADDRESS} + ), + "PathInvalid()" + ); + }); + + it("should fail if the path is invalid [tokenOut is not weth]", async () => { + const pathTokens = [USDC_ADDRESS, USDC_ADDRESS]; + const pathFees = [500]; + const amount = 1000000; + const amountOutMinimum = Ethers.utils.parseUnits("200000", "gwei"); + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await usdc.approve(SwapAdapterInstance.address, amount, {from: USDC_OWNER_ADDRESS}); + await Helpers.expectToRevertWithCustomError( + SwapAdapterInstance.depositTokensToEth.call( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amount, + amountOutMinimum, + pathTokens, + pathFees, + {from: USDC_OWNER_ADDRESS} + ), + "PathInvalid()" + ); + }); + + it("should fail if the resource id is not configured", async () => { + const pathTokens = [USDC_ADDRESS, ]; + const pathFees = [500]; + const amount = Ethers.utils.parseUnits("200000", "gwei"); + const amountOutMinimum = 1000000; + await usdc.approve(SwapAdapterInstance.address, amount, {from: USDC_OWNER_ADDRESS}); + await Helpers.expectToRevertWithCustomError( + SwapAdapterInstance.depositEthToTokens.call( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amountOutMinimum, + pathTokens, + pathFees, + { + from: USDC_OWNER_ADDRESS, + value: amount + } + ), + "TokenInvalid()" + ); + }); + + it("should fail if no msg.value supplied", async () => { + const pathTokens = [WETH_ADDRESS, USDC_ADDRESS]; + const pathFees = [500]; + const amountOutMinimum = 2000000000; + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await Helpers.expectToRevertWithCustomError( + SwapAdapterInstance.depositEthToTokens.call( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amountOutMinimum, + pathTokens, + pathFees, + { + value: 0, + from: depositorAddress + } + ), + "InsufficientAmount(uint256)" + ); + }); + + it("should fail if msg.value is less than fee", async () => { + const pathTokens = [WETH_ADDRESS, USDC_ADDRESS]; + const pathFees = [500]; + const amountOutMinimum = 2000000000; + await SwapAdapterInstance.setTokenResourceID(USDC_ADDRESS, resourceID_USDC); + await Helpers.expectToRevertWithCustomError( + SwapAdapterInstance.depositEthToTokens.call( + destinationDomainID, + recipientAddress, + USDC_ADDRESS, + amountOutMinimum, + pathTokens, + pathFees, + { + value: 5, + from: depositorAddress + } + ), + "MsgValueLowerThanFee(uint256)" + ); + }); +});