diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 155fe30f8..7ce7f0313 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -120,7 +120,7 @@ jobs: name: evm-artifacts-${{ runner.os }}-node-${{ env.NODE_VERSION }} - name: Test evm-hardhat shell: bash - run: yarn test-evm + run: yarn test-evm-hardhat test-svm-verified: name: Test verified SVM build needs: upload-svm-artifacts @@ -208,4 +208,4 @@ jobs: - name: Inspect storage layouts run: ./scripts/checkStorageLayout.sh - name: Test evm-foundry - run: forge test --match-path test/evm/foundry/local/**/*.t.sol + run: yarn test-evm-foundry diff --git a/contracts/AdapterStore.sol b/contracts/AdapterStore.sol new file mode 100644 index 000000000..69b83dd4b --- /dev/null +++ b/contracts/AdapterStore.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +library MessengerTypes { + bytes32 public constant OFT_MESSENGER = bytes32("OFT_MESSENGER"); +} + +/** + * @dev A helper contract for chain adapters on the hub chain that support OFT or xERC20(via Hyperlane) messaging. + * @dev Handles token => messenger/router mapping storage. Adapters can't store this themselves as they're called + * @dev via `delegateCall` and their storage is not part of available context. + */ +contract AdapterStore is Ownable { + // (messengerType, dstDomainId, srcChainToken) => messenger address + mapping(bytes32 => mapping(uint256 => mapping(address => address))) public crossChainMessengers; + + event MessengerSet( + bytes32 indexed messengerType, + uint256 indexed dstDomainId, + address indexed srcChainToken, + address srcChainMessenger + ); + + error ArrayLengthMismatch(); + + function setMessenger( + bytes32 messengerType, + uint256 dstDomainId, + address srcChainToken, + address srcChainMessenger + ) external onlyOwner { + _setMessenger(messengerType, dstDomainId, srcChainToken, srcChainMessenger); + } + + function batchSetMessengers( + bytes32[] calldata messengerTypes, + uint256[] calldata dstDomainIds, + address[] calldata srcChainTokens, + address[] calldata srcChainMessengers + ) external onlyOwner { + if ( + messengerTypes.length != dstDomainIds.length || + messengerTypes.length != srcChainTokens.length || + messengerTypes.length != srcChainMessengers.length + ) { + revert ArrayLengthMismatch(); + } + + for (uint256 i = 0; i < dstDomainIds.length; i++) { + _setMessenger(messengerTypes[i], dstDomainIds[i], srcChainTokens[i], srcChainMessengers[i]); + } + } + + function _setMessenger( + bytes32 _messengerType, + uint256 _dstDomainId, + address _srcChainToken, + address _srcChainMessenger + ) internal { + crossChainMessengers[_messengerType][_dstDomainId][_srcChainToken] = _srcChainMessenger; + emit MessengerSet(_messengerType, _dstDomainId, _srcChainToken, _srcChainMessenger); + } +} diff --git a/contracts/AlephZero_SpokePool.sol b/contracts/AlephZero_SpokePool.sol index 86da45fa8..354feec4b 100644 --- a/contracts/AlephZero_SpokePool.sol +++ b/contracts/AlephZero_SpokePool.sol @@ -17,14 +17,18 @@ contract AlephZero_SpokePool is Arbitrum_SpokePool { uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer, IERC20 _l2Usdc, - ITokenMessenger _cctpTokenMessenger + ITokenMessenger _cctpTokenMessenger, + uint32 _oftDstEid, + uint256 _oftFeeCap ) Arbitrum_SpokePool( _wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer, _l2Usdc, - _cctpTokenMessenger + _cctpTokenMessenger, + _oftDstEid, + _oftFeeCap ) {} // solhint-disable-line no-empty-blocks } diff --git a/contracts/Arbitrum_SpokePool.sol b/contracts/Arbitrum_SpokePool.sol index ff3cc9a10..418998ef2 100644 --- a/contracts/Arbitrum_SpokePool.sol +++ b/contracts/Arbitrum_SpokePool.sol @@ -27,9 +27,11 @@ contract Arbitrum_SpokePool is SpokePool, CircleCCTPAdapter { uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer, IERC20 _l2Usdc, - ITokenMessenger _cctpTokenMessenger + ITokenMessenger _cctpTokenMessenger, + uint32 _oftDstEid, + uint256 _oftFeeCap ) - SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer, _oftDstEid, _oftFeeCap) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) {} // solhint-disable-line no-empty-blocks @@ -83,9 +85,13 @@ contract Arbitrum_SpokePool is SpokePool, CircleCCTPAdapter { **************************************/ function _bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) internal override { + address oftMessenger = _getOftMessenger(l2TokenAddress); + // If the l2TokenAddress is UDSC, we need to use the CCTP bridge. if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { _transferUsdc(withdrawalRecipient, amountToReturn); + } else if (oftMessenger != address(0)) { + _transferViaOFT(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn); } else { // Check that the Ethereum counterpart of the L2 token is stored on this contract. address ethereumTokenToBridge = whitelistedTokens[l2TokenAddress]; @@ -112,4 +118,9 @@ contract Arbitrum_SpokePool is SpokePool, CircleCCTPAdapter { // Apply AVM-specific transformation to cross domain admin address on L1. function _requireAdminSender() internal override onlyFromCrossDomainAdmin {} + + // Reserve storage slots for future versions of this base contract to add state variables without + // affecting the storage layout of child contracts. Decrement the size of __gap whenever state variables + // are added. This is at bottom of contract to make sure it's always at the end of storage. + uint256[1000] private __gap; } diff --git a/contracts/Create2Factory.sol b/contracts/Create2Factory.sol new file mode 100644 index 000000000..a6d43481b --- /dev/null +++ b/contracts/Create2Factory.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { Lockable } from "./Lockable.sol"; + +/** + * @title Create2Factory + * @notice Deploys a new contract via create2 at a deterministic address and then atomically initializes the contract + * @dev Contracts designed to be deployed at deterministic addresses should initialize via a non-constructor + * initializer to maintain bytecode across different chains. + * @custom:security-contact bugs@across.to + */ +contract Create2Factory is Lockable { + /// @notice Emitted when the initialization to a newly deployed contract fails + error InitializationFailed(); + + /** + * @notice Deploys a new contract via create2 at a deterministic address and then atomically initializes the contract + * @param amount The amount of ETH to send with the deployment. If this is not zero then the contract must have a payable constructor + * @param salt The salt to use for the create2 deployment. Must not have been used before for the bytecode + * @param bytecode The bytecode of the contract to deploy + * @param initializationCode The initialization code to call on the deployed contract + */ + function deploy( + uint256 amount, + bytes32 salt, + bytes calldata bytecode, + bytes calldata initializationCode + ) external nonReentrant returns (address) { + address deployedAddress = Create2.deploy(amount, salt, bytecode); + (bool success, ) = deployedAddress.call(initializationCode); + if (!success) revert InitializationFailed(); + return deployedAddress; + } +} diff --git a/contracts/Ethereum_SpokePool.sol b/contracts/Ethereum_SpokePool.sol index 1487ed931..de8660934 100644 --- a/contracts/Ethereum_SpokePool.sol +++ b/contracts/Ethereum_SpokePool.sol @@ -16,7 +16,16 @@ contract Ethereum_SpokePool is SpokePool, OwnableUpgradeable { address _wrappedNativeTokenAddress, uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer - ) SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) {} // solhint-disable-line no-empty-blocks + ) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // Ethereum_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) + {} // solhint-disable-line no-empty-blocks /** * @notice Construct the Ethereum SpokePool. diff --git a/contracts/Linea_SpokePool.sol b/contracts/Linea_SpokePool.sol index f08fb8c58..926cba52d 100644 --- a/contracts/Linea_SpokePool.sol +++ b/contracts/Linea_SpokePool.sol @@ -54,7 +54,14 @@ contract Linea_SpokePool is SpokePool, CircleCCTPAdapter { IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger ) - SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // Linea_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) {} // solhint-disable-line no-empty-blocks diff --git a/contracts/Ovm_SpokePool.sol b/contracts/Ovm_SpokePool.sol index a3abbfd01..b8bed35c9 100644 --- a/contracts/Ovm_SpokePool.sol +++ b/contracts/Ovm_SpokePool.sol @@ -34,6 +34,7 @@ interface IL2ERC20Bridge { */ contract Ovm_SpokePool is SpokePool, CircleCCTPAdapter { using SafeERC20 for IERC20; + // "l1Gas" parameter used in call to bridge tokens from this contract back to L1 via IL2ERC20Bridge. Currently // unused by bridge but included for future compatibility. uint32 public l1Gas; @@ -71,7 +72,14 @@ contract Ovm_SpokePool is SpokePool, CircleCCTPAdapter { IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger ) - SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // Ovm_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) {} // solhint-disable-line no-empty-blocks diff --git a/contracts/PolygonZkEVM_SpokePool.sol b/contracts/PolygonZkEVM_SpokePool.sol index 54251d88c..0165a3913 100644 --- a/contracts/PolygonZkEVM_SpokePool.sol +++ b/contracts/PolygonZkEVM_SpokePool.sol @@ -93,7 +93,16 @@ contract PolygonZkEVM_SpokePool is SpokePool, IBridgeMessageReceiver { address _wrappedNativeTokenAddress, uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer - ) SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) {} // solhint-disable-line no-empty-blocks + ) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // PolygonZkEVM_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) + {} // solhint-disable-line no-empty-blocks /** * @notice Construct the Polygon zkEVM SpokePool. diff --git a/contracts/Polygon_SpokePool.sol b/contracts/Polygon_SpokePool.sol index 3516b2d1d..ef36acecc 100644 --- a/contracts/Polygon_SpokePool.sol +++ b/contracts/Polygon_SpokePool.sol @@ -87,7 +87,14 @@ contract Polygon_SpokePool is IFxMessageProcessor, SpokePool, CircleCCTPAdapter IERC20 _l2Usdc, ITokenMessenger _cctpTokenMessenger ) - SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // Polygon_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) {} // solhint-disable-line no-empty-blocks diff --git a/contracts/Scroll_SpokePool.sol b/contracts/Scroll_SpokePool.sol index 4ba64b77f..4cd151b2e 100644 --- a/contracts/Scroll_SpokePool.sol +++ b/contracts/Scroll_SpokePool.sol @@ -48,7 +48,16 @@ contract Scroll_SpokePool is SpokePool { address _wrappedNativeTokenAddress, uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer - ) SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) {} // solhint-disable-line no-empty-blocks + ) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // Scroll_SpokePool does not use OFT messaging, setting destination id and fee cap to 0 + 0, + 0 + ) + {} // solhint-disable-line no-empty-blocks /** * @notice Construct the Scroll SpokePool. diff --git a/contracts/SpokePool.sol b/contracts/SpokePool.sol index c740e3258..06a6e9dc1 100644 --- a/contracts/SpokePool.sol +++ b/contracts/SpokePool.sol @@ -12,6 +12,7 @@ import "./upgradeable/MultiCallerUpgradeable.sol"; import "./upgradeable/EIP712CrossChainUpgradeable.sol"; import "./upgradeable/AddressLibUpgradeable.sol"; import "./libraries/AddressConverters.sol"; +import "./libraries/OFTTransportAdapter.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; @@ -37,7 +38,8 @@ abstract contract SpokePool is ReentrancyGuardUpgradeable, MultiCallerUpgradeable, EIP712CrossChainUpgradeable, - IDestinationSettler + IDestinationSettler, + OFTTransportAdapter { using SafeERC20Upgradeable for IERC20Upgradeable; using AddressLibUpgradeable for address; @@ -111,6 +113,9 @@ abstract contract SpokePool is // reason (eg blacklist) to track their outstanding liability, thereby letting them claim it later. mapping(address => mapping(address => uint256)) public relayerRefund; + // Mapping of L2 token address to L2 IOFT messenger address. Required to support bridging via OFT standard + mapping(address => address) public oftMessengers; + /************************************************************** * CONSTANT/IMMUTABLE VARIABLES * **************************************************************/ @@ -190,6 +195,9 @@ abstract contract SpokePool is event EmergencyDeletedRootBundle(uint256 indexed rootBundleId); event PausedDeposits(bool isPaused); event PausedFills(bool isPaused); + event SetOFTMessenger(address indexed token, address indexed messenger); + + error OFTTokenMismatch(); /** * @notice Construct the SpokePool. Normally, logic contracts used in upgradeable proxies shouldn't @@ -205,13 +213,17 @@ abstract contract SpokePool is * into the past from the block time of the deposit. * @param _fillDeadlineBuffer fillDeadlineBuffer to set. Fill deadlines can't be set more than this amount * into the future from the block time of the deposit. + * @param _oftDstEid destination endpoint id for OFT messaging + * @param _oftFeeCap fee cap in native token when paying for cross-chain OFT transfers */ /// @custom:oz-upgrades-unsafe-allow constructor constructor( address _wrappedNativeTokenAddress, uint32 _depositQuoteTimeBuffer, - uint32 _fillDeadlineBuffer - ) { + uint32 _fillDeadlineBuffer, + uint32 _oftDstEid, + uint256 _oftFeeCap + ) OFTTransportAdapter(_oftDstEid, _oftFeeCap) { wrappedNativeToken = WETH9Interface(_wrappedNativeTokenAddress); depositQuoteTimeBuffer = _depositQuoteTimeBuffer; fillDeadlineBuffer = _fillDeadlineBuffer; @@ -342,6 +354,15 @@ abstract contract SpokePool is emit EmergencyDeletedRootBundle(rootBundleId); } + /** + * @notice Add token -> OFTMessenger relationship. Callable only by admin. + * @param token token address on the current chain + * @param messenger IOFT contract address on the current chain for the specified token. Acts as a 'mailbox' + */ + function setOftMessenger(address token, address messenger) public onlyAdmin nonReentrant { + _setOftMessenger(token, messenger); + } + /************************************** * LEGACY DEPOSITOR FUNCTIONS * **************************************/ @@ -1715,6 +1736,18 @@ abstract contract SpokePool is else return keccak256(message); } + function _setOftMessenger(address _token, address _messenger) internal { + if (IOFT(_messenger).token() != _token) { + revert OFTTokenMismatch(); + } + oftMessengers[_token] = _messenger; + emit SetOFTMessenger(_token, _messenger); + } + + function _getOftMessenger(address _token) internal view returns (address) { + return oftMessengers[_token]; + } + // Implementing contract needs to override this to ensure that only the appropriate cross chain admin can execute // certain admin functions. For L2 contracts, the cross chain admin refers to some L1 address or contract, and for // L1, this would just be the same admin of the HubPool. @@ -1726,5 +1759,5 @@ abstract contract SpokePool is // Reserve storage slots for future versions of this base contract to add state variables without // affecting the storage layout of child contracts. Decrement the size of __gap whenever state variables // are added. This is at bottom of contract to make sure it's always at the end of storage. - uint256[998] private __gap; + uint256[997] private __gap; } diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol new file mode 100644 index 000000000..0bac784d4 --- /dev/null +++ b/contracts/SpokePoolV3Periphery.sol @@ -0,0 +1,683 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { MultiCaller } from "@uma/core/contracts/common/implementation/MultiCaller.sol"; +import { Lockable } from "./Lockable.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { V3SpokePoolInterface } from "./interfaces/V3SpokePoolInterface.sol"; +import { IERC20Auth } from "./external/interfaces/IERC20Auth.sol"; +import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; +import { IPermit2 } from "./external/interfaces/IPermit2.sol"; +import { PeripherySigningLib } from "./libraries/PeripherySigningLib.sol"; +import { SpokePoolV3PeripheryProxyInterface, SpokePoolV3PeripheryInterface } from "./interfaces/SpokePoolV3PeripheryInterface.sol"; + +/** + * @title SpokePoolPeripheryProxy + * @notice User should only call SpokePoolV3Periphery contract functions that require approvals through this + * contract. This is purposefully a simple passthrough contract so that the user only approves this contract to + * pull its assets because the SpokePoolV3Periphery contract can be used to call + * any calldata on any exchange that the user wants to. By separating the contract that is approved to spend + * user funds from the contract that executes arbitrary calldata, the SpokePoolPeriphery does not + * need to validate the calldata that gets executed. + * @dev If this proxy didn't exist and users instead approved and interacted directly with the SpokePoolV3Periphery + * then users would run the unneccessary risk that another user could instruct the Periphery contract to steal + * any approved tokens that the user had left outstanding. + */ +contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable, MultiCaller { + using SafeERC20 for IERC20; + using Address for address; + + // Flag set for one time initialization. + bool private initialized; + + // The SpokePoolPeriphery should be deterministically deployed at the same address across all networks, + // so this contract should also be able to be deterministically deployed at the same address across all networks + // since the periphery address is the only initializer argument. + SpokePoolV3Periphery public spokePoolPeriphery; + + error InvalidPeriphery(); + error ContractInitialized(); + + /** + * @notice Construct a new PeripheryProxy contract. + * @dev Is empty and all of the state variables are initialized in the initialize function + * to allow for deployment at a deterministic address via create2, which requires that the bytecode + * across different networks is the same. Constructor parameters affect the bytecode so we can only + * add parameters here that are consistent across networks. + */ + constructor() {} + + /** + * @notice Initialize the SpokePoolPeripheryProxy contract. + * @param _spokePoolPeriphery Address of the SpokePoolPeriphery contract that this proxy will call. + */ + function initialize(SpokePoolV3Periphery _spokePoolPeriphery) external nonReentrant { + if (initialized) revert ContractInitialized(); + initialized = true; + if (!address(_spokePoolPeriphery).isContract()) revert InvalidPeriphery(); + spokePoolPeriphery = _spokePoolPeriphery; + } + + /** + * @inheritdoc SpokePoolV3PeripheryProxyInterface + */ + function swapAndBridge(SpokePoolV3PeripheryInterface.SwapAndDepositData calldata swapAndDepositData) + external + override + nonReentrant + { + _callSwapAndBridge(swapAndDepositData); + } + + /** + * @notice Calls swapAndBridge on the spoke pool periphery contract. + * @param swapAndDepositData The data outlining the conditions for the swap and across deposit when calling the periphery contract. + */ + function _callSwapAndBridge(SpokePoolV3PeripheryInterface.SwapAndDepositData calldata swapAndDepositData) internal { + // Load relevant variables on the stack. + IERC20 _swapToken = IERC20(swapAndDepositData.swapToken); + uint256 _swapTokenAmount = swapAndDepositData.swapTokenAmount; + + _swapToken.safeTransferFrom(msg.sender, address(this), _swapTokenAmount); + _swapToken.forceApprove(address(spokePoolPeriphery), _swapTokenAmount); + spokePoolPeriphery.swapAndBridge(swapAndDepositData); + } +} + +/** + * @title SpokePoolV3Periphery + * @notice Contract for performing more complex interactions with an Across spoke pool deployment. + * @dev Variables which may be immutable are not marked as immutable, nor defined in the constructor, so that this + * contract may be deployed deterministically at the same address across different networks. + * @custom:security-contact bugs@across.to + */ +contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiCaller, EIP712 { + using SafeERC20 for IERC20; + using Address for address; + + // Across SpokePool we'll submit deposits to with acrossInputToken as the input token. + V3SpokePoolInterface public spokePool; + + // Wrapped native token contract address. + WETH9Interface public wrappedNativeToken; + + // Canonical Permit2 contract address. + IPermit2 public permit2; + + // Address of the proxy contract that users should interact with to call this contract. + // Force users to call through this contract to make sure they don't leave any approvals/permits + // outstanding on this contract that could be abused because this contract executes arbitrary + // calldata. + address public proxy; + + // Nonce for this contract to use for EIP1271 "signatures". + uint48 private eip1271Nonce; + + // Boolean indicating whether the contract is initialized. + bool private initialized; + + // Slot for checking whether this contract is expecting a callback from permit2. Used to confirm whether it should return a valid signature response. + // When solidity 0.8.24 becomes more widely available, this should be replaced with a TSTORE caching method. + bool private expectingPermit2Callback; + + // EIP 1271 magic bytes indicating a valid signature. + bytes4 private constant EIP1271_VALID_SIGNATURE = 0x1626ba7e; + + // EIP 1271 bytes indicating an invalid signature. + bytes4 private constant EIP1271_INVALID_SIGNATURE = 0xffffffff; + + event SwapBeforeBridge( + address exchange, + bytes exchangeCalldata, + address indexed swapToken, + address indexed acrossInputToken, + uint256 swapTokenAmount, + uint256 acrossInputAmount, + address indexed acrossOutputToken, + uint256 acrossOutputAmount + ); + + /**************************************** + * ERRORS * + ****************************************/ + error InvalidPermit2(); + error ContractInitialized(); + error InvalidSignatureLength(); + error MinimumExpectedInputAmount(); + error LeftoverSrcTokens(); + error InvalidMsgValue(); + error InvalidSpokePool(); + error InvalidProxy(); + error InvalidSwapToken(); + error NotProxy(); + error InvalidSignature(); + + /** + * @notice Construct a new Periphery contract. + * @dev Is empty and all of the state variables are initialized in the initialize function + * to allow for deployment at a deterministic address via create2, which requires that the bytecode + * across different networks is the same. Constructor parameters affect the bytecode so we can only + * add parameters here that are consistent across networks. + */ + constructor() EIP712("ACROSS-V3-PERIPHERY", "1.0.0") {} + + /** + * @notice Initializes the SwapAndBridgeBase contract. + * @param _spokePool Address of the SpokePool contract that we'll submit deposits to. + * @param _wrappedNativeToken Address of the wrapped native token for the network this contract is deployed to. + * @param _proxy Address of the proxy contract that users should interact with to call this contract. + * @param _permit2 Address of the deployed network's canonical permit2 contract. + * @dev These values are initialized in a function and not in the constructor so that the creation code of this contract + * is the same across networks with different addresses for the wrapped native token and this network's + * corresponding spoke pool contract. This is to allow this contract to be deterministically deployed with CREATE2. + */ + function initialize( + V3SpokePoolInterface _spokePool, + WETH9Interface _wrappedNativeToken, + address _proxy, + IPermit2 _permit2 + ) external nonReentrant { + if (initialized) revert ContractInitialized(); + initialized = true; + + if (!address(_spokePool).isContract()) revert InvalidSpokePool(); + spokePool = _spokePool; + wrappedNativeToken = _wrappedNativeToken; + if (!_proxy.isContract()) revert InvalidProxy(); + proxy = _proxy; + if (!address(_permit2).isContract()) revert InvalidPermit2(); + permit2 = _permit2; + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function deposit( + address recipient, + address inputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + address exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityParameter, + bytes memory message + ) external payable override nonReentrant { + if (msg.value != inputAmount) revert InvalidMsgValue(); + if (!address(spokePool).isContract()) revert InvalidSpokePool(); + // Set msg.sender as the depositor so that msg.sender can speed up the deposit. + spokePool.depositV3{ value: msg.value }( + msg.sender, + recipient, + inputToken, + // @dev Setting outputToken to 0x0 to instruct fillers to use the equivalent token + // as the originToken on the destination chain. + address(0), + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityParameter, + message + ); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function swapAndBridge(SwapAndDepositData calldata swapAndDepositData) external payable override nonReentrant { + // If a user performs a swapAndBridge with the swap token as the native token, wrap the value and treat the rest of transaction + // as though the user deposited a wrapped native token. + if (msg.value != 0) { + if (msg.value != swapAndDepositData.swapTokenAmount) revert InvalidMsgValue(); + if (address(swapAndDepositData.swapToken) != address(wrappedNativeToken)) revert InvalidSwapToken(); + wrappedNativeToken.deposit{ value: msg.value }(); + } else { + // If swap requires an approval to this contract, then force user to go through proxy + // to prevent their approval from being abused. + _calledByProxy(); + IERC20(swapAndDepositData.swapToken).safeTransferFrom( + msg.sender, + address(this), + swapAndDepositData.swapTokenAmount + ); + } + _swapAndBridge(swapAndDepositData); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function swapAndBridgeWithPermit( + address signatureOwner, + SwapAndDepositData calldata swapAndDepositData, + uint256 deadline, + bytes calldata permitSignature, + bytes calldata swapAndDepositDataSignature + ) external override nonReentrant { + (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(permitSignature); + // Load variables used in this function onto the stack. + address _swapToken = swapAndDepositData.swapToken; + uint256 _swapTokenAmount = swapAndDepositData.swapTokenAmount; + uint256 _submissionFeeAmount = swapAndDepositData.submissionFees.amount; + address _submissionFeeRecipient = swapAndDepositData.submissionFees.recipient; + uint256 _pullAmount = _submissionFeeAmount + _swapTokenAmount; + + // For permit transactions, we wrap the call in a try/catch block so that the transaction will continue even if the call to + // permit fails. For example, this may be useful if the permit signature, which can be redeemed by anyone, is executed by somebody + // other than this contract. + try IERC20Permit(_swapToken).permit(signatureOwner, address(this), _pullAmount, deadline, v, r, s) {} catch {} + IERC20(_swapToken).safeTransferFrom(signatureOwner, address(this), _pullAmount); + _paySubmissionFees(_swapToken, _submissionFeeRecipient, _submissionFeeAmount); + // Verify that the signatureOwner signed the input swapAndDepositData. + _validateSignature( + signatureOwner, + PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData), + swapAndDepositDataSignature + ); + _swapAndBridge(swapAndDepositData); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function swapAndBridgeWithPermit2( + address signatureOwner, + SwapAndDepositData calldata swapAndDepositData, + IPermit2.PermitTransferFrom calldata permit, + bytes calldata signature + ) external override nonReentrant { + bytes32 witness = PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData); + uint256 _submissionFeeAmount = swapAndDepositData.submissionFees.amount; + IPermit2.SignatureTransferDetails memory transferDetails = IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: swapAndDepositData.swapTokenAmount + _submissionFeeAmount + }); + + permit2.permitWitnessTransferFrom( + permit, + transferDetails, + signatureOwner, + witness, + PeripherySigningLib.EIP712_SWAP_AND_DEPOSIT_TYPE_STRING, + signature + ); + _paySubmissionFees( + swapAndDepositData.swapToken, + swapAndDepositData.submissionFees.recipient, + _submissionFeeAmount + ); + _swapAndBridge(swapAndDepositData); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function swapAndBridgeWithAuthorization( + address signatureOwner, + SwapAndDepositData calldata swapAndDepositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes calldata receiveWithAuthSignature, + bytes calldata swapAndDepositDataSignature + ) external override nonReentrant { + (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(receiveWithAuthSignature); + uint256 _submissionFeeAmount = swapAndDepositData.submissionFees.amount; + // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), + // if tokens were not sent to this contract, by this call to swapData.swapToken, this function will revert + // when attempting to swap tokens it does not own. + IERC20Auth(address(swapAndDepositData.swapToken)).receiveWithAuthorization( + signatureOwner, + address(this), + swapAndDepositData.swapTokenAmount + _submissionFeeAmount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + _paySubmissionFees( + swapAndDepositData.swapToken, + swapAndDepositData.submissionFees.recipient, + _submissionFeeAmount + ); + + // Verify that the signatureOwner signed the input swapAndDepositData. + _validateSignature( + signatureOwner, + PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData), + swapAndDepositDataSignature + ); + _swapAndBridge(swapAndDepositData); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function depositWithPermit( + address signatureOwner, + DepositData calldata depositData, + uint256 deadline, + bytes calldata permitSignature, + bytes calldata depositDataSignature + ) external override nonReentrant { + (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(permitSignature); + // Load variables used in this function onto the stack. + address _inputToken = depositData.baseDepositData.inputToken; + uint256 _inputAmount = depositData.inputAmount; + uint256 _submissionFeeAmount = depositData.submissionFees.amount; + address _submissionFeeRecipient = depositData.submissionFees.recipient; + uint256 _pullAmount = _submissionFeeAmount + _inputAmount; + + // For permit transactions, we wrap the call in a try/catch block so that the transaction will continue even if the call to + // permit fails. For example, this may be useful if the permit signature, which can be redeemed by anyone, is executed by somebody + // other than this contract. + try IERC20Permit(_inputToken).permit(signatureOwner, address(this), _pullAmount, deadline, v, r, s) {} catch {} + IERC20(_inputToken).safeTransferFrom(signatureOwner, address(this), _pullAmount); + _paySubmissionFees(_inputToken, _submissionFeeRecipient, _submissionFeeAmount); + + // Verify that the signatureOwner signed the input depositData. + _validateSignature(signatureOwner, PeripherySigningLib.hashDepositData(depositData), depositDataSignature); + _depositV3( + depositData.baseDepositData.depositor, + depositData.baseDepositData.recipient, + _inputToken, + depositData.baseDepositData.outputToken, + _inputAmount, + depositData.baseDepositData.outputAmount, + depositData.baseDepositData.destinationChainId, + depositData.baseDepositData.exclusiveRelayer, + depositData.baseDepositData.quoteTimestamp, + depositData.baseDepositData.fillDeadline, + depositData.baseDepositData.exclusivityParameter, + depositData.baseDepositData.message + ); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function depositWithPermit2( + address signatureOwner, + DepositData calldata depositData, + IPermit2.PermitTransferFrom calldata permit, + bytes calldata signature + ) external override nonReentrant { + bytes32 witness = PeripherySigningLib.hashDepositData(depositData); + uint256 _submissionFeeAmount = depositData.submissionFees.amount; + IPermit2.SignatureTransferDetails memory transferDetails = IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: depositData.inputAmount + _submissionFeeAmount + }); + + permit2.permitWitnessTransferFrom( + permit, + transferDetails, + signatureOwner, + witness, + PeripherySigningLib.EIP712_DEPOSIT_TYPE_STRING, + signature + ); + _paySubmissionFees( + depositData.baseDepositData.inputToken, + depositData.submissionFees.recipient, + _submissionFeeAmount + ); + + _depositV3( + depositData.baseDepositData.depositor, + depositData.baseDepositData.recipient, + depositData.baseDepositData.inputToken, + depositData.baseDepositData.outputToken, + depositData.inputAmount, + depositData.baseDepositData.outputAmount, + depositData.baseDepositData.destinationChainId, + depositData.baseDepositData.exclusiveRelayer, + depositData.baseDepositData.quoteTimestamp, + depositData.baseDepositData.fillDeadline, + depositData.baseDepositData.exclusivityParameter, + depositData.baseDepositData.message + ); + } + + /** + * @inheritdoc SpokePoolV3PeripheryInterface + */ + function depositWithAuthorization( + address signatureOwner, + DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes calldata receiveWithAuthSignature, + bytes calldata depositDataSignature + ) external override nonReentrant { + // Load variables used multiple times onto the stack. + uint256 _inputAmount = depositData.inputAmount; + uint256 _submissionFeeAmount = depositData.submissionFees.amount; + + // Redeem the receiveWithAuthSignature. + (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(receiveWithAuthSignature); + IERC20Auth(depositData.baseDepositData.inputToken).receiveWithAuthorization( + signatureOwner, + address(this), + _inputAmount + _submissionFeeAmount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + _paySubmissionFees( + depositData.baseDepositData.inputToken, + depositData.submissionFees.recipient, + _submissionFeeAmount + ); + + // Verify that the signatureOwner signed the input depositData. + _validateSignature(signatureOwner, PeripherySigningLib.hashDepositData(depositData), depositDataSignature); + _depositV3( + depositData.baseDepositData.depositor, + depositData.baseDepositData.recipient, + depositData.baseDepositData.inputToken, + depositData.baseDepositData.outputToken, + _inputAmount, + depositData.baseDepositData.outputAmount, + depositData.baseDepositData.destinationChainId, + depositData.baseDepositData.exclusiveRelayer, + depositData.baseDepositData.quoteTimestamp, + depositData.baseDepositData.fillDeadline, + depositData.baseDepositData.exclusivityParameter, + depositData.baseDepositData.message + ); + } + + /** + * @notice Verifies that the signer is the owner of the signing contract. + * @dev The _hash and _signature fields are intentionally ignored since this contract will accept + * any signature which originated from permit2 after the call to the exchange. + * @dev This is safe since this contract should never hold funds nor approvals, other than when it is depositing or swapping. + */ + function isValidSignature(bytes32, bytes calldata) external view returns (bytes4 magicBytes) { + magicBytes = (msg.sender == address(permit2) && expectingPermit2Callback) + ? EIP1271_VALID_SIGNATURE + : EIP1271_INVALID_SIGNATURE; + } + + /** + * @notice Returns the contract's EIP712 domain separator, used to sign hashed depositData/swapAndDepositData types. + */ + function domainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @notice Validates that the typed data hash corresponds to the input signature owner and corresponding signature. + * @param signatureOwner The alledged signer of the input hash. + * @param typedDataHash The EIP712 data hash to check the signature against. + * @param signature The signature to validate. + */ + function _validateSignature( + address signatureOwner, + bytes32 typedDataHash, + bytes calldata signature + ) private view { + if (!SignatureChecker.isValidSignatureNow(signatureOwner, _hashTypedDataV4(typedDataHash), signature)) + revert InvalidSignature(); + } + + /** + * @notice Approves the spoke pool and calls `depositV3` function with the specified input parameters. + * @param depositor The address on the origin chain which should be treated as the depositor by Across, and will therefore receive refunds if this deposit + * is unfilled. + * @param recipient The address on the destination chain which should receive outputAmount of outputToken. + * @param inputToken The token to deposit on the origin chain. + * @param outputToken The token to receive on the destination chain. + * @param inputAmount The amount of the input token to deposit. + * @param outputAmount The amount of the output token to receive. + * @param destinationChainId The network ID for the destination chain. + * @param exclusiveRelayer The optional address for an Across relayer which may fill the deposit exclusively. + * @param quoteTimestamp The timestamp at which the relay and LP fee was calculated. + * @param fillDeadline The timestamp at which the deposit must be filled before it will be refunded by Across. + * @param exclusivityParameter The deadline or offset during which the exclusive relayer has rights to fill the deposit without contention. + * @param message The message to execute on the destination chain. + */ + function _depositV3( + address depositor, + address recipient, + address inputToken, + address outputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + address exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityParameter, + bytes calldata message + ) private { + IERC20(inputToken).forceApprove(address(spokePool), inputAmount); + spokePool.depositV3( + depositor, + recipient, + inputToken, // input token + outputToken, // output token + inputAmount, // input amount. + outputAmount, // output amount + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityParameter, + message + ); + } + + /** + * @notice Swaps a token on the origin chain before depositing into the Across spoke pool atomically. + * @param swapAndDepositData The parameters to use when calling both the swap on an exchange and bridging via an Across spoke pool. + */ + function _swapAndBridge(SwapAndDepositData calldata swapAndDepositData) private { + // Load variables we use multiple times onto the stack. + IERC20 _swapToken = IERC20(swapAndDepositData.swapToken); + IERC20 _acrossInputToken = IERC20(swapAndDepositData.depositData.inputToken); + TransferType _transferType = swapAndDepositData.transferType; + address _exchange = swapAndDepositData.exchange; + uint256 _swapTokenAmount = swapAndDepositData.swapTokenAmount; + + // Swap and run safety checks. + uint256 srcBalanceBefore = _swapToken.balanceOf(address(this)); + uint256 dstBalanceBefore = _acrossInputToken.balanceOf(address(this)); + + // The exchange will either receive funds from this contract via a direct transfer, an approval to spend funds on this contract, or via an + // EIP1271 permit2 signature. + if (_transferType == TransferType.Approval) _swapToken.forceApprove(_exchange, _swapTokenAmount); + else if (_transferType == TransferType.Transfer) _swapToken.transfer(_exchange, _swapTokenAmount); + else { + _swapToken.forceApprove(address(permit2), _swapTokenAmount); + expectingPermit2Callback = true; + permit2.permit( + address(this), // owner + IPermit2.PermitSingle({ + details: IPermit2.PermitDetails({ + token: address(_swapToken), + amount: uint160(_swapTokenAmount), + expiration: uint48(block.timestamp), + nonce: eip1271Nonce++ + }), + spender: _exchange, + sigDeadline: block.timestamp + }), // permitSingle + "0x" // signature is unused. The only verification for a valid signature is if we are at this code block. + ); + expectingPermit2Callback = false; + } + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = _exchange.call(swapAndDepositData.routerCalldata); + require(success, string(result)); + + // Sanity check that we received as many tokens as we require: + uint256 returnAmount = _acrossInputToken.balanceOf(address(this)) - dstBalanceBefore; + + // Sanity check that received amount from swap is enough to submit Across deposit with. + if (returnAmount < swapAndDepositData.minExpectedInputTokenAmount) revert MinimumExpectedInputAmount(); + // Sanity check that we don't have any leftover swap tokens that would be locked in this contract (i.e. check + // that we weren't partial filled). + if (srcBalanceBefore - _swapToken.balanceOf(address(this)) != _swapTokenAmount) revert LeftoverSrcTokens(); + + emit SwapBeforeBridge( + _exchange, + swapAndDepositData.routerCalldata, + address(_swapToken), + address(_acrossInputToken), + _swapTokenAmount, + returnAmount, + swapAndDepositData.depositData.outputToken, + swapAndDepositData.depositData.outputAmount + ); + + // Deposit the swapped tokens into Across and bridge them using remainder of input params. + _depositV3( + swapAndDepositData.depositData.depositor, + swapAndDepositData.depositData.recipient, + address(_acrossInputToken), + swapAndDepositData.depositData.outputToken, + returnAmount, + swapAndDepositData.depositData.outputAmount, + swapAndDepositData.depositData.destinationChainId, + swapAndDepositData.depositData.exclusiveRelayer, + swapAndDepositData.depositData.quoteTimestamp, + swapAndDepositData.depositData.fillDeadline, + swapAndDepositData.depositData.exclusivityParameter, + swapAndDepositData.depositData.message + ); + } + + function _paySubmissionFees( + address feeToken, + address recipient, + uint256 amount + ) private { + if (amount > 0) { + IERC20(feeToken).safeTransfer(recipient, amount); + } + } + + /** + * @notice Function to check that the msg.sender is the initialized proxy contract. + */ + function _calledByProxy() internal view { + if (msg.sender != proxy) revert NotProxy(); + } +} diff --git a/contracts/Succinct_SpokePool.sol b/contracts/Succinct_SpokePool.sol index 021952910..aa37b0b82 100644 --- a/contracts/Succinct_SpokePool.sol +++ b/contracts/Succinct_SpokePool.sol @@ -47,7 +47,16 @@ contract Succinct_SpokePool is SpokePool, ITelepathyHandler { address _wrappedNativeTokenAddress, uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer - ) SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) {} // solhint-disable-line no-empty-blocks + ) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // Succinct_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) + {} // solhint-disable-line no-empty-blocks /** * @notice Construct the Succinct SpokePool. diff --git a/contracts/SwapAndBridge.sol b/contracts/SwapAndBridge.sol deleted file mode 100644 index 9951c2e8a..000000000 --- a/contracts/SwapAndBridge.sol +++ /dev/null @@ -1,310 +0,0 @@ -//SPDX-License-Identifier: Unlicense -pragma solidity ^0.8.0; - -import "./interfaces/V3SpokePoolInterface.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "./Lockable.sol"; -import "@uma/core/contracts/common/implementation/MultiCaller.sol"; -import "./libraries/AddressConverters.sol"; - -/** - * @title SwapAndBridgeBase - * @notice Base contract for both variants of SwapAndBridge. - * @custom:security-contact bugs@across.to - */ -abstract contract SwapAndBridgeBase is Lockable, MultiCaller { - using SafeERC20 for IERC20; - using AddressToBytes32 for address; - - // This contract performs a low level call with arbirary data to an external contract. This is a large attack - // surface and we should whitelist which function selectors are allowed to be called on the exchange. - mapping(bytes4 => bool) public allowedSelectors; - - // Across SpokePool we'll submit deposits to with acrossInputToken as the input token. - V3SpokePoolInterface public immutable SPOKE_POOL; - - // Exchange address or router where the swapping will happen. - address public immutable EXCHANGE; - - // Params we'll need caller to pass in to specify an Across Deposit. The input token will be swapped into first - // before submitting a bridge deposit, which is why we don't include the input token amount as it is not known - // until after the swap. - struct DepositData { - // Token received on destination chain. - address outputToken; - // Amount of output token to be received by recipient. - uint256 outputAmount; - // The account credited with deposit who can submit speedups to the Across deposit. - address depositor; - // The account that will receive the output token on the destination chain. If the output token is - // wrapped native token, then if this is an EOA then they will receive native token on the destination - // chain and if this is a contract then they will receive an ERC20. - address recipient; - // The destination chain identifier. - uint256 destinationChainid; - // The account that can exclusively fill the deposit before the exclusivity deadline. - address exclusiveRelayer; - // Timestamp of the deposit used by system to charge fees. Must be within short window of time into the past - // relative to this chain's current time or deposit will revert. - uint32 quoteTimestamp; - // The timestamp on the destination chain after which this deposit can no longer be filled. - uint32 fillDeadline; - // The timestamp on the destination chain after which anyone can fill the deposit. - uint32 exclusivityDeadline; - // Data that is forwarded to the recipient if the recipient is a contract. - bytes message; - } - - event SwapBeforeBridge( - address exchange, - address indexed swapToken, - address indexed acrossInputToken, - uint256 swapTokenAmount, - uint256 acrossInputAmount, - address indexed acrossOutputToken, - uint256 acrossOutputAmount - ); - - /**************************************** - * ERRORS * - ****************************************/ - error MinimumExpectedInputAmount(); - error LeftoverSrcTokens(); - error InvalidFunctionSelector(); - - /** - * @notice Construct a new SwapAndBridgeBase contract. - * @param _spokePool Address of the SpokePool contract that we'll submit deposits to. - * @param _exchange Address of the exchange where tokens will be swapped. - * @param _allowedSelectors Function selectors that are allowed to be called on the exchange. - */ - constructor( - V3SpokePoolInterface _spokePool, - address _exchange, - bytes4[] memory _allowedSelectors - ) { - SPOKE_POOL = _spokePool; - EXCHANGE = _exchange; - for (uint256 i = 0; i < _allowedSelectors.length; i++) { - allowedSelectors[_allowedSelectors[i]] = true; - } - } - - // This contract supports two variants of swap and bridge, one that allows one token and another that allows the caller to pass them in. - function _swapAndBridge( - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - IERC20 _swapToken, - IERC20 _acrossInputToken - ) internal { - // Note: this check should never be impactful, but is here out of an abundance of caution. - // For example, if the exchange address in the contract is also an ERC20 token that is approved by some - // user on this contract, a malicious actor could call transferFrom to steal the user's tokens. - if (!allowedSelectors[bytes4(routerCalldata)]) revert InvalidFunctionSelector(); - - // Pull tokens from caller into this contract. - _swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); - // Swap and run safety checks. - uint256 srcBalanceBefore = _swapToken.balanceOf(address(this)); - uint256 dstBalanceBefore = _acrossInputToken.balanceOf(address(this)); - - _swapToken.safeIncreaseAllowance(EXCHANGE, swapTokenAmount); - // solhint-disable-next-line avoid-low-level-calls - (bool success, bytes memory result) = EXCHANGE.call(routerCalldata); - require(success, string(result)); - - _checkSwapOutputAndDeposit( - swapTokenAmount, - srcBalanceBefore, - dstBalanceBefore, - minExpectedInputTokenAmount, - depositData, - _swapToken, - _acrossInputToken - ); - } - - /** - * @notice Check that the swap returned enough tokens to submit an Across deposit with and then submit the deposit. - * @param swapTokenAmount Amount of swapToken to swap for a minimum amount of acrossInputToken. - * @param swapTokenBalanceBefore Balance of swapToken before swap. - * @param inputTokenBalanceBefore Amount of Across input token we held before swap - * @param minExpectedInputTokenAmount Minimum amount of received acrossInputToken that we'll bridge - **/ - function _checkSwapOutputAndDeposit( - uint256 swapTokenAmount, - uint256 swapTokenBalanceBefore, - uint256 inputTokenBalanceBefore, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - IERC20 _swapToken, - IERC20 _acrossInputToken - ) internal { - // Sanity check that we received as many tokens as we require: - uint256 returnAmount = _acrossInputToken.balanceOf(address(this)) - inputTokenBalanceBefore; - // Sanity check that received amount from swap is enough to submit Across deposit with. - if (returnAmount < minExpectedInputTokenAmount) revert MinimumExpectedInputAmount(); - // Sanity check that we don't have any leftover swap tokens that would be locked in this contract (i.e. check - // that we weren't partial filled). - if (swapTokenBalanceBefore - _swapToken.balanceOf(address(this)) != swapTokenAmount) revert LeftoverSrcTokens(); - - emit SwapBeforeBridge( - EXCHANGE, - address(_swapToken), - address(_acrossInputToken), - swapTokenAmount, - returnAmount, - depositData.outputToken, - depositData.outputAmount - ); - // Deposit the swapped tokens into Across and bridge them using remainder of input params. - _depositV3(_acrossInputToken, returnAmount, depositData); - } - - /** - * @notice Approves the spoke pool and calls `depositV3` function with the specified input parameters. - * @param _acrossInputToken Token to deposit into the spoke pool. - * @param _acrossInputAmount Amount of the input token to deposit into the spoke pool. - * @param depositData Specifies the Across deposit params to use. - */ - function _depositV3( - IERC20 _acrossInputToken, - uint256 _acrossInputAmount, - DepositData calldata depositData - ) internal { - _acrossInputToken.safeIncreaseAllowance(address(SPOKE_POOL), _acrossInputAmount); - SPOKE_POOL.deposit( - depositData.depositor.toBytes32(), - depositData.recipient.toBytes32(), - address(_acrossInputToken).toBytes32(), // input token - depositData.outputToken.toBytes32(), // output token - _acrossInputAmount, // input amount. - depositData.outputAmount, // output amount - depositData.destinationChainid, - depositData.exclusiveRelayer.toBytes32(), - depositData.quoteTimestamp, - depositData.fillDeadline, - depositData.exclusivityDeadline, - depositData.message - ); - } -} - -/** - * @title SwapAndBridge - * @notice Allows caller to swap between two pre-specified tokens on a chain before bridging the received token - * via Across atomically. Provides safety checks post-swap and before-deposit. - * @dev This variant primarily exists - */ -contract SwapAndBridge is SwapAndBridgeBase { - using SafeERC20 for IERC20; - - // This contract simply enables the caller to swap a token on this chain for another specified one - // and bridge it as the input token via Across. This simplification is made to make the code - // easier to reason about and solve a specific use case for Across. - IERC20 public immutable SWAP_TOKEN; - - // The token that will be bridged via Across as the inputToken. - IERC20 public immutable ACROSS_INPUT_TOKEN; - - /** - * @notice Construct a new SwapAndBridge contract. - * @param _spokePool Address of the SpokePool contract that we'll submit deposits to. - * @param _exchange Address of the exchange where tokens will be swapped. - * @param _allowedSelectors Function selectors that are allowed to be called on the exchange. - * @param _swapToken Address of the token that will be swapped for acrossInputToken. Cannot be 0x0 - * @param _acrossInputToken Address of the token that will be bridged via Across as the inputToken. - */ - constructor( - V3SpokePoolInterface _spokePool, - address _exchange, - bytes4[] memory _allowedSelectors, - IERC20 _swapToken, - IERC20 _acrossInputToken - ) SwapAndBridgeBase(_spokePool, _exchange, _allowedSelectors) { - SWAP_TOKEN = _swapToken; - ACROSS_INPUT_TOKEN = _acrossInputToken; - } - - /** - * @notice Swaps tokens on this chain via specified router before submitting Across deposit atomically. - * Caller can specify their slippage tolerance for the swap and Across deposit params. - * @dev If swapToken or acrossInputToken are the native token for this chain then this function might fail. - * the assumption is that this function will handle only ERC20 tokens. - * @param routerCalldata ABI encoded function data to call on router. Should form a swap of swapToken for - * enough of acrossInputToken, otherwise this function will revert. - * @param swapTokenAmount Amount of swapToken to swap for a minimum amount of depositData.inputToken. - * @param minExpectedInputTokenAmount Minimum amount of received depositData.inputToken that we'll submit bridge - * deposit with. - * @param depositData Specifies the Across deposit params we'll send after the swap. - */ - function swapAndBridge( - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData - ) external nonReentrant { - _swapAndBridge( - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - SWAP_TOKEN, - ACROSS_INPUT_TOKEN - ); - } -} - -/** - * @title UniversalSwapAndBridge - * @notice Allows caller to swap between any two tokens specified at runtime on a chain before - * bridging the received token via Across atomically. Provides safety checks post-swap and before-deposit. - */ -contract UniversalSwapAndBridge is SwapAndBridgeBase { - /** - * @notice Construct a new SwapAndBridgeBase contract. - * @param _spokePool Address of the SpokePool contract that we'll submit deposits to. - * @param _exchange Address of the exchange where tokens will be swapped. - * @param _allowedSelectors Function selectors that are allowed to be called on the exchange. - */ - constructor( - V3SpokePoolInterface _spokePool, - address _exchange, - bytes4[] memory _allowedSelectors - ) SwapAndBridgeBase(_spokePool, _exchange, _allowedSelectors) {} - - /** - * @notice Swaps tokens on this chain via specified router before submitting Across deposit atomically. - * Caller can specify their slippage tolerance for the swap and Across deposit params. - * @dev If swapToken or acrossInputToken are the native token for this chain then this function might fail. - * the assumption is that this function will handle only ERC20 tokens. - * @param swapToken Address of the token that will be swapped for acrossInputToken. - * @param acrossInputToken Address of the token that will be bridged via Across as the inputToken. - * @param routerCalldata ABI encoded function data to call on router. Should form a swap of swapToken for - * enough of acrossInputToken, otherwise this function will revert. - * @param swapTokenAmount Amount of swapToken to swap for a minimum amount of depositData.inputToken. - * @param minExpectedInputTokenAmount Minimum amount of received depositData.inputToken that we'll submit bridge - * deposit with. - * @param depositData Specifies the Across deposit params we'll send after the swap. - */ - function swapAndBridge( - IERC20 swapToken, - IERC20 acrossInputToken, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData - ) external nonReentrant { - _swapAndBridge( - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - swapToken, - acrossInputToken - ); - } -} diff --git a/contracts/Universal_SpokePool.sol b/contracts/Universal_SpokePool.sol index c098f590c..f8f054374 100644 --- a/contracts/Universal_SpokePool.sol +++ b/contracts/Universal_SpokePool.sol @@ -83,9 +83,11 @@ contract Universal_SpokePool is OwnableUpgradeable, SpokePool, CircleCCTPAdapter uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer, IERC20 _l2Usdc, - ITokenMessenger _cctpTokenMessenger + ITokenMessenger _cctpTokenMessenger, + uint32 _oftDstEid, + uint256 _oftFeeCap ) - SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer, _oftDstEid, _oftFeeCap) CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) { ADMIN_UPDATE_BUFFER = _adminUpdateBufferSeconds; @@ -178,8 +180,12 @@ contract Universal_SpokePool is OwnableUpgradeable, SpokePool, CircleCCTPAdapter } function _bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) internal override { + address oftMessenger = _getOftMessenger(l2TokenAddress); + if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { _transferUsdc(withdrawalRecipient, amountToReturn); + } else if (oftMessenger != address(0)) { + _transferViaOFT(IERC20(l2TokenAddress), IOFT(oftMessenger), withdrawalRecipient, amountToReturn); } else { revert NotImplemented(); } diff --git a/contracts/ZkSync_SpokePool.sol b/contracts/ZkSync_SpokePool.sol index 9a64662d3..326c55602 100644 --- a/contracts/ZkSync_SpokePool.sol +++ b/contracts/ZkSync_SpokePool.sol @@ -67,7 +67,14 @@ contract ZkSync_SpokePool is SpokePool, CircleCCTPAdapter { uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer ) - SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) + SpokePool( + _wrappedNativeTokenAddress, + _depositQuoteTimeBuffer, + _fillDeadlineBuffer, + // ZkSync_SpokePool does not use OFT messaging; setting destination eid and fee cap to 0 + 0, + 0 + ) CircleCCTPAdapter(_circleUSDC, _cctpTokenMessenger, CircleDomainIds.Ethereum) { address zero = address(0); diff --git a/contracts/chain-adapters/Arbitrum_Adapter.sol b/contracts/chain-adapters/Arbitrum_Adapter.sol index 1db12d421..5d3e1134b 100644 --- a/contracts/chain-adapters/Arbitrum_Adapter.sol +++ b/contracts/chain-adapters/Arbitrum_Adapter.sol @@ -5,8 +5,10 @@ import "./interfaces/AdapterInterface.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IOFT } from "../interfaces/IOFT.sol"; import "../external/interfaces/CCTPInterfaces.sol"; import "../libraries/CircleCCTPAdapter.sol"; +import "../libraries/OFTTransportAdapterWithStore.sol"; import { ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumL1ERC20GatewayLike } from "../interfaces/ArbitrumBridge.sol"; /** @@ -18,7 +20,7 @@ import { ArbitrumInboxLike as ArbitrumL1InboxLike, ArbitrumL1ERC20GatewayLike } */ // solhint-disable-next-line contract-name-camelcase -contract Arbitrum_Adapter is AdapterInterface, CircleCCTPAdapter { +contract Arbitrum_Adapter is AdapterInterface, CircleCCTPAdapter, OFTTransportAdapterWithStore { using SafeERC20 for IERC20; // Amount of ETH allocated to pay for the base submission fee. The base submission fee is a parameter unique to @@ -61,14 +63,23 @@ contract Arbitrum_Adapter is AdapterInterface, CircleCCTPAdapter { * @param _l2RefundL2Address L2 address to receive gas refunds on after a message is relayed. * @param _l1Usdc USDC address on L1. * @param _cctpTokenMessenger TokenMessenger contract to bridge via CCTP. + * @param _adapterStore Helper storage contract to support bridging via differnt token standards: OFT, XERC20 + * @param _oftDstEid destination endpoint id for OFT messaging + * @param _oftFeeCap A fee cap we apply to OFT bridge native payment. A good default is 1 ether */ constructor( ArbitrumL1InboxLike _l1ArbitrumInbox, ArbitrumL1ERC20GatewayLike _l1ERC20GatewayRouter, address _l2RefundL2Address, IERC20 _l1Usdc, - ITokenMessenger _cctpTokenMessenger - ) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Arbitrum) { + ITokenMessenger _cctpTokenMessenger, + address _adapterStore, + uint32 _oftDstEid, + uint256 _oftFeeCap + ) + CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, CircleDomainIds.Arbitrum) + OFTTransportAdapterWithStore(_oftDstEid, _oftFeeCap, _adapterStore) + { L1_INBOX = _l1ArbitrumInbox; L1_ERC20_GATEWAY_ROUTER = _l1ERC20GatewayRouter; L2_REFUND_L2_ADDRESS = _l2RefundL2Address; @@ -113,9 +124,13 @@ contract Arbitrum_Adapter is AdapterInterface, CircleCCTPAdapter { uint256 amount, address to ) external payable override { - // Check if this token is USDC, which requires a custom bridge via CCTP. + address oftMessenger = _getOftMessenger(l1Token); + + // Check if the token needs to use any of the custom bridge solutions first if (_isCCTPEnabled() && l1Token == address(usdcToken)) { _transferUsdc(to, amount); + } else if (oftMessenger != address(0)) { + _transferViaOFT(IERC20(l1Token), IOFT(oftMessenger), to, amount); } // If not, we can use the Arbitrum gateway else { diff --git a/contracts/chain-adapters/Universal_Adapter.sol b/contracts/chain-adapters/Universal_Adapter.sol index 4a7eb539a..0858af4bd 100644 --- a/contracts/chain-adapters/Universal_Adapter.sol +++ b/contracts/chain-adapters/Universal_Adapter.sol @@ -6,6 +6,9 @@ import "./interfaces/AdapterInterface.sol"; import "../libraries/CircleCCTPAdapter.sol"; import { SpokePoolInterface } from "../interfaces/SpokePoolInterface.sol"; import { HubPoolStore } from "./utilities/HubPoolStore.sol"; +import { IOFT } from "../interfaces/IOFT.sol"; + +import "../libraries/OFTTransportAdapterWithStore.sol"; interface IOwnable { function owner() external view returns (address); @@ -21,7 +24,7 @@ interface IOwnable { * also need to be redeployed to point to the new HubPoolStore. * @custom:security-contact bugs@across.to */ -contract Universal_Adapter is AdapterInterface, CircleCCTPAdapter { +contract Universal_Adapter is AdapterInterface, CircleCCTPAdapter, OFTTransportAdapterWithStore { /// @notice Contract that stores calldata to be relayed to L2 via storage proofs. HubPoolStore public immutable DATA_STORE; @@ -31,8 +34,14 @@ contract Universal_Adapter is AdapterInterface, CircleCCTPAdapter { HubPoolStore _store, IERC20 _l1Usdc, ITokenMessenger _cctpTokenMessenger, - uint32 _cctpDestinationDomainId - ) CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, _cctpDestinationDomainId) { + uint32 _cctpDestinationDomainId, + address _adapterStore, + uint32 _oftDstEid, + uint256 _oftFeeCap + ) + CircleCCTPAdapter(_l1Usdc, _cctpTokenMessenger, _cctpDestinationDomainId) + OFTTransportAdapterWithStore(_oftDstEid, _oftFeeCap, _adapterStore) + { DATA_STORE = _store; } @@ -75,8 +84,11 @@ contract Universal_Adapter is AdapterInterface, CircleCCTPAdapter { uint256 amount, address to ) external payable override { + address oftMessenger = _getOftMessenger(l1Token); if (_isCCTPEnabled() && l1Token == address(usdcToken)) { _transferUsdc(to, amount); + } else if (oftMessenger != address(0)) { + _transferViaOFT(IERC20(l1Token), IOFT(oftMessenger), to, amount); } else { revert NotImplemented(); } diff --git a/contracts/external/interfaces/IERC20Auth.sol b/contracts/external/interfaces/IERC20Auth.sol new file mode 100644 index 000000000..762af027b --- /dev/null +++ b/contracts/external/interfaces/IERC20Auth.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/* + * @notice Minimal interface for an EIP-3009 compliant token. + * https://eips.ethereum.org/EIPS/eip-3009 + */ +interface IERC20Auth { + /** + * @notice Receive a transfer with a signed authorization from the payer + * @dev This has an additional check to ensure that the payee's address matches + * the caller of this function to prevent front-running attacks. (See security + * considerations) + * @param from Payer's address (Authorizer) + * @param to Payee's address + * @param value Amount to be transferred + * @param validAfter The time after which this is valid (unix time) + * @param validBefore The time before which this is valid (unix time) + * @param nonce Unique nonce + * @param v v of the signature + * @param r r of the signature + * @param s s of the signature + */ + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} diff --git a/contracts/external/interfaces/IPermit2.sol b/contracts/external/interfaces/IPermit2.sol index c091bf8a6..131d2218d 100644 --- a/contracts/external/interfaces/IPermit2.sol +++ b/contracts/external/interfaces/IPermit2.sol @@ -2,6 +2,19 @@ pragma solidity ^0.8.0; interface IPermit2 { + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + struct TokenPermissions { address token; uint256 amount; @@ -18,6 +31,12 @@ interface IPermit2 { uint256 requestedAmount; } + function permit( + address owner, + PermitSingle memory permitSingle, + bytes calldata signature + ) external; + function permitWitnessTransferFrom( PermitTransferFrom memory permit, SignatureTransferDetails calldata transferDetails, diff --git a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol new file mode 100644 index 000000000..655a81374 --- /dev/null +++ b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol @@ -0,0 +1,267 @@ +//SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import { IERC20Auth } from "../external/interfaces/IERC20Auth.sol"; +import { SpokePoolV3Periphery } from "../SpokePoolV3Periphery.sol"; +import { PeripherySigningLib } from "../libraries/PeripherySigningLib.sol"; +import { IPermit2 } from "../external/interfaces/IPermit2.sol"; + +interface SpokePoolV3PeripheryProxyInterface { + /** + * @notice Swaps tokens on this chain via specified router before submitting Across deposit atomically. + * Caller can specify their slippage tolerance for the swap and Across deposit params. + * @dev If swapToken or acrossInputToken are the native token for this chain then this function might fail. + * the assumption is that this function will handle only ERC20 tokens. + * @param swapAndDepositData Specifies the params we need to perform a swap on a generic exchange. + */ + function swapAndBridge(SpokePoolV3PeripheryInterface.SwapAndDepositData calldata swapAndDepositData) external; +} + +/** + * @title SpokePoolV3Periphery + * @notice Contract for performing more complex interactions with an Across spoke pool deployment. + * @dev Variables which may be immutable are not marked as immutable, nor defined in the constructor, so that this + * contract may be deployed deterministically at the same address across different networks. + * @custom:security-contact bugs@across.to + */ +interface SpokePoolV3PeripheryInterface { + // Enum describing the method of transferring tokens to an exchange. + enum TransferType { + // Approve the exchange so that it may transfer tokens from this contract. + Approval, + // Transfer tokens to the exchange before calling it in this contract. + Transfer, + // Approve the exchange by authorizing a transfer with Permit2. + Permit2Approval + } + + // Submission fees can be set by user to pay whoever submits the transaction in a gasless flow. + // These are assumed to be in the same currency that is input into the contract. + struct Fees { + // Amount of fees to pay recipient for submitting transaction. + uint256 amount; + // Recipient of fees amount. + address recipient; + } + + // Params we'll need caller to pass in to specify an Across Deposit. The input token will be swapped into first + // before submitting a bridge deposit, which is why we don't include the input token amount as it is not known + // until after the swap. + struct BaseDepositData { + // Token deposited on origin chain. + address inputToken; + // Token received on destination chain. + address outputToken; + // Amount of output token to be received by recipient. + uint256 outputAmount; + // The account credited with deposit who can submit speedups to the Across deposit. + address depositor; + // The account that will receive the output token on the destination chain. If the output token is + // wrapped native token, then if this is an EOA then they will receive native token on the destination + // chain and if this is a contract then they will receive an ERC20. + address recipient; + // The destination chain identifier. + uint256 destinationChainId; + // The account that can exclusively fill the deposit before the exclusivity parameter. + address exclusiveRelayer; + // Timestamp of the deposit used by system to charge fees. Must be within short window of time into the past + // relative to this chain's current time or deposit will revert. + uint32 quoteTimestamp; + // The timestamp on the destination chain after which this deposit can no longer be filled. + uint32 fillDeadline; + // The timestamp or offset on the destination chain after which anyone can fill the deposit. A detailed description on + // how the parameter is interpreted by the V3 spoke pool can be found at https://github.com/across-protocol/contracts/blob/fa67f5e97eabade68c67127f2261c2d44d9b007e/contracts/SpokePool.sol#L476 + uint32 exclusivityParameter; + // Data that is forwarded to the recipient if the recipient is a contract. + bytes message; + } + + // Minimum amount of parameters needed to perform a swap on an exchange specified. We include information beyond just the router calldata + // and exchange address so that we may ensure that the swap was performed properly. + struct SwapAndDepositData { + // Amount of fees to pay for submitting transaction. Unused in gasful flows. + Fees submissionFees; + // Deposit data to use when interacting with the Across spoke pool. + BaseDepositData depositData; + // Token to swap. + address swapToken; + // Address of the exchange to use in the swap. + address exchange; + // Method of transferring tokens to the exchange. + TransferType transferType; + // Amount of the token to swap on the exchange. + uint256 swapTokenAmount; + // Minimum output amount of the exchange, and, by extension, the minimum required amount to deposit into an Across spoke pool. + uint256 minExpectedInputTokenAmount; + // The calldata to use when calling the exchange. + bytes routerCalldata; + } + + // Extended deposit data to be used specifically for signing off on periphery deposits. + struct DepositData { + // Amount of fees to pay for submitting transaction. Unused in gasful flows. + Fees submissionFees; + // Deposit data describing the parameters for the V3 Across deposit. + BaseDepositData baseDepositData; + // The precise input amount to deposit into the spoke pool. + uint256 inputAmount; + } + + /** + * @notice Passthrough function to `depositV3()` on the SpokePool contract. + * @dev Protects the caller from losing their ETH (or other native token) by reverting if the SpokePool address + * they intended to call does not exist on this chain. Because this contract can be deployed at the same address + * everywhere callers should be protected even if the transaction is submitted to an unintended network. + * This contract should only be used for native token deposits, as this problem only exists for native tokens. + * @param recipient Address to receive funds at on destination chain. + * @param inputToken Token to lock into this contract to initiate deposit. + * @param inputAmount Amount of tokens to deposit. + * @param outputAmount Amount of tokens to receive on destination chain. + * @param destinationChainId Denotes network where user will receive funds from SpokePool by a relayer. + * @param quoteTimestamp Timestamp used by relayers to compute this deposit's realizedLPFeePct which is paid + * to LP pool on HubPool. + * @param message Arbitrary data that can be used to pass additional information to the recipient along with the tokens. + * Note: this is intended to be used to pass along instructions for how a contract should use or allocate the tokens. + * @param exclusiveRelayer Address of the relayer who has exclusive rights to fill this deposit. Can be set to + * 0x0 if no period is desired. If so, then must set exclusivityParameter to 0. + * @param exclusivityParameter Timestamp or offset, after which any relayer can fill this deposit. Must set + * to 0 if exclusiveRelayer is set to 0x0, and vice versa. + * @param fillDeadline Timestamp after which this deposit can no longer be filled. + */ + function deposit( + address recipient, + address inputToken, + uint256 inputAmount, + uint256 outputAmount, + uint256 destinationChainId, + address exclusiveRelayer, + uint32 quoteTimestamp, + uint32 fillDeadline, + uint32 exclusivityParameter, + bytes memory message + ) external payable; + + /** + * @notice Swaps tokens on this chain via specified router before submitting Across deposit atomically. + * Caller can specify their slippage tolerance for the swap and Across deposit params. + * @dev If msg.value is 0, then this function is only callable by the proxy contract, to protect against + * approval abuse attacks where a user has set an approval on this contract to spend any ERC20 token. + * @dev If swapToken or acrossInputToken are the native token for this chain then this function might fail. + * the assumption is that this function will handle only ERC20 tokens. + * @param swapAndDepositData Specifies the data needed to perform a swap on a generic exchange. + */ + function swapAndBridge(SwapAndDepositData calldata swapAndDepositData) external payable; + + /** + * @notice Swaps an EIP-2612 token on this chain via specified router before submitting Across deposit atomically. + * Caller can specify their slippage tolerance for the swap and Across deposit params. + * @dev If the swapToken in swapData does not implement `permit` to the specifications of EIP-2612, this function will fail. + * @param signatureOwner The owner of the permit signature and swapAndDepositData signature. Assumed to be the depositor for the Across spoke pool. + * @param swapAndDepositData Specifies the params we need to perform a swap on a generic exchange. + * @param deadline Deadline before which the permit signature is valid. + * @param permitSignature Permit signature encoded as (bytes32 r, bytes32 s, uint8 v). + * @param swapAndDepositDataSignature The signature against the input swapAndDepositData encoded as (bytes32 r, bytes32 s, uint8 v). + */ + function swapAndBridgeWithPermit( + address signatureOwner, + SwapAndDepositData calldata swapAndDepositData, + uint256 deadline, + bytes calldata permitSignature, + bytes calldata swapAndDepositDataSignature + ) external; + + /** + * @notice Uses permit2 to transfer tokens from a user before swapping a token on this chain via specified router and submitting an Across deposit atomically. + * Caller can specify their slippage tolerance for the swap and Across deposit params. + * @dev This function assumes the caller has properly set an allowance for the permit2 contract on this network. + * @dev This function assumes that the amount of token to be swapped is equal to the amount of the token to be received from permit2. + * @param signatureOwner The owner of the permit2 signature and depositor for the Across spoke pool. + * @param swapAndDepositData Specifies the params we need to perform a swap on a generic exchange. + * @param permit The permit data signed over by the owner. + * @param signature The permit2 signature to verify against the deposit data. + */ + function swapAndBridgeWithPermit2( + address signatureOwner, + SwapAndDepositData calldata swapAndDepositData, + IPermit2.PermitTransferFrom calldata permit, + bytes calldata signature + ) external; + + /** + * @notice Swaps an EIP-3009 token on this chain via specified router before submitting Across deposit atomically. + * Caller can specify their slippage tolerance for the swap and Across deposit params. + * @dev If swapToken does not implement `receiveWithAuthorization` to the specifications of EIP-3009, this call will revert. + * @param signatureOwner The owner of the EIP3009 signature and swapAndDepositData signature. Assumed to be the depositor for the Across spoke pool. + * @param swapAndDepositData Specifies the params we need to perform a swap on a generic exchange. + * @param validAfter The unix time after which the `receiveWithAuthorization` signature is valid. + * @param validBefore The unix time before which the `receiveWithAuthorization` signature is valid. + * @param nonce Unique nonce used in the `receiveWithAuthorization` signature. + * @param receiveWithAuthSignature EIP3009 signature encoded as (bytes32 r, bytes32 s, uint8 v). + * @param swapAndDepositDataSignature The signature against the input swapAndDepositData encoded as (bytes32 r, bytes32 s, uint8 v). + */ + function swapAndBridgeWithAuthorization( + address signatureOwner, + SwapAndDepositData calldata swapAndDepositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes calldata receiveWithAuthSignature, + bytes calldata swapAndDepositDataSignature + ) external; + + /** + * @notice Deposits an EIP-2612 token Across input token into the Spoke Pool contract. + * @dev If `acrossInputToken` does not implement `permit` to the specifications of EIP-2612, this function will fail. + * @param signatureOwner The owner of the permit signature and depositData signature. Assumed to be the depositor for the Across spoke pool. + * @param depositData Specifies the Across deposit params to send. + * @param deadline Deadline before which the permit signature is valid. + * @param permitSignature Permit signature encoded as (bytes32 r, bytes32 s, uint8 v). + * @param depositDataSignature The signature against the input depositData encoded as (bytes32 r, bytes32 s, uint8 v). + */ + function depositWithPermit( + address signatureOwner, + DepositData calldata depositData, + uint256 deadline, + bytes calldata permitSignature, + bytes calldata depositDataSignature + ) external; + + /** + * @notice Uses permit2 to transfer and submit an Across deposit to the Spoke Pool contract. + * @dev This function assumes the caller has properly set an allowance for the permit2 contract on this network. + * @dev This function assumes that the amount of token to be swapped is equal to the amount of the token to be received from permit2. + * @param signatureOwner The owner of the permit2 signature and depositor for the Across spoke pool. + * @param depositData Specifies the Across deposit params we'll send after the swap. + * @param permit The permit data signed over by the owner. + * @param signature The permit2 signature to verify against the deposit data. + */ + function depositWithPermit2( + address signatureOwner, + DepositData calldata depositData, + IPermit2.PermitTransferFrom calldata permit, + bytes calldata signature + ) external; + + /** + * @notice Deposits an EIP-3009 compliant Across input token into the Spoke Pool contract. + * @dev If `acrossInputToken` does not implement `receiveWithAuthorization` to the specifications of EIP-3009, this call will revert. + * @param signatureOwner The owner of the EIP3009 signature and depositData signature. Assumed to be the depositor for the Across spoke pool. + * @param depositData Specifies the Across deposit params to send. + * @param validAfter The unix time after which the `receiveWithAuthorization` signature is valid. + * @param validBefore The unix time before which the `receiveWithAuthorization` signature is valid. + * @param nonce Unique nonce used in the `receiveWithAuthorization` signature. + * @param receiveWithAuthSignature EIP3009 signature encoded as (bytes32 r, bytes32 s, uint8 v). + * @param depositDataSignature The signature against the input depositData encoded as (bytes32 r, bytes32 s, uint8 v). + */ + function depositWithAuthorization( + address signatureOwner, + DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + bytes calldata receiveWithAuthSignature, + bytes calldata depositDataSignature + ) external; +} diff --git a/contracts/libraries/OFTTransportAdapter.sol b/contracts/libraries/OFTTransportAdapter.sol new file mode 100644 index 000000000..6b4d72a3e --- /dev/null +++ b/contracts/libraries/OFTTransportAdapter.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IOFT, SendParam, MessagingFee, OFTReceipt } from "../interfaces/IOFT.sol"; +import { AddressToBytes32 } from "../libraries/AddressConverters.sol"; + +/** + * @notice Facilitate bridging tokens via LayerZero's OFT. + * @dev This contract is intended to be inherited by other chain-specific adapters and spoke pools. + * @custom:security-contact bugs@across.to + */ +contract OFTTransportAdapter { + using SafeERC20 for IERC20; + using AddressToBytes32 for address; + + bytes public constant EMPTY_MSG_BYTES = new bytes(0); + + /** + * @dev a fee cap we check against before sending a message with value to OFTMessenger as fees. + * @dev this cap should be pretty conservative (high) to not interfere with operations under normal conditions. + */ + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint256 public immutable OFT_FEE_CAP; + + /** + * @notice The destination endpoint id in the OFT messaging protocol. + * @dev Source https://docs.layerzero.network/v2/developers/evm/technical-reference/deployed-contracts. + */ + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + uint32 public immutable OFT_DST_EID; + + error OftFeeCapExceeded(); + error OftInsufficientBalanceForFee(); + error OftIncorrectAmountReceivedLD(); + + /** + * @notice intiailizes the OFTTransportAdapter contract. + * @param _oftDstEid the endpoint ID that OFT protocol will transfer funds to. + * @param _feeCap a fee cap we check against before sending a message with value to OFTMessenger as fees. + */ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(uint32 _oftDstEid, uint256 _feeCap) { + OFT_DST_EID = _oftDstEid; + OFT_FEE_CAP = _feeCap; + } + + /** + * @notice transfer token to the other dstEid (e.g. chain) via OFT messaging protocol + * @dev the caller has to provide both _token and _messenger. The caller is responsible for knowing the correct _messenger + * @param _token token we're sending on current chain. + * @param _messenger corresponding OFT messenger on current chain. + * @param _to address to receive a trasnfer on the destination chain. + * @param _amount amount to send. + */ + function _transferViaOFT( + IERC20 _token, + IOFT _messenger, + address _to, + uint256 _amount + ) internal { + bytes32 to = _to.toBytes32(); + + SendParam memory sendParam = SendParam( + OFT_DST_EID, + to, + /** + * _amount, _amount here specify `amountLD` and `minAmountLD`. Setting `minAmountLD` equal to `amountLD` protects us + * from any changes to the sent amount due to internal OFT contract logic, e.g. `_removeDust`. Meaning that if any + * dust is subtracted, the `.send()` should revert + */ + _amount, + _amount, + /** + * EMPTY_MSG_BYTES, EMPTY_MSG_BYTES, EMPTY_MSG_BYTES here specify `extraOptions`, `composeMsg` and `oftCmd`. + * These can be set to empty bytes arrays for the purposes of sending a simple cross-chain transfer. + */ + EMPTY_MSG_BYTES, + EMPTY_MSG_BYTES, + EMPTY_MSG_BYTES + ); + + // `false` in the 2nd param here refers to `bool _payInLzToken`. We will pay in native token, so set to `false` + MessagingFee memory fee = _messenger.quoteSend(sendParam, false); + // Create a stack variable to optimize gas usage on subsequent reads + uint256 nativeFee = fee.nativeFee; + if (nativeFee > OFT_FEE_CAP) revert OftFeeCapExceeded(); + if (nativeFee > address(this).balance) revert OftInsufficientBalanceForFee(); + + // Approve the exact _amount for `_messenger` to spend. Fee will be paid in native token + _token.forceApprove(address(_messenger), _amount); + + (, OFTReceipt memory oftReceipt) = _messenger.send{ value: nativeFee }(sendParam, fee, address(this)); + + // The HubPool expects that the amount received by the SpokePool is exactly the sent amount + if (_amount != oftReceipt.amountReceivedLD) revert OftIncorrectAmountReceivedLD(); + } +} diff --git a/contracts/libraries/OFTTransportAdapterWithStore.sol b/contracts/libraries/OFTTransportAdapterWithStore.sol new file mode 100644 index 000000000..f2a0443bf --- /dev/null +++ b/contracts/libraries/OFTTransportAdapterWithStore.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { OFTTransportAdapter } from "./OFTTransportAdapter.sol"; +import { AdapterStore, MessengerTypes } from "../AdapterStore.sol"; + +// A wrapper of `OFTTransportAdapter` to be used by chain-specific adapters +contract OFTTransportAdapterWithStore is OFTTransportAdapter { + // Helper storage contract to keep track of token => IOFT relationships + AdapterStore public immutable OFT_ADAPTER_STORE; + + constructor( + uint32 _oftDstEid, + uint256 _feeCap, + address _adapterStore + ) OFTTransportAdapter(_oftDstEid, _feeCap) { + OFT_ADAPTER_STORE = AdapterStore(_adapterStore); + } + + function _getOftMessenger(address _token) internal view returns (address) { + return OFT_ADAPTER_STORE.crossChainMessengers(MessengerTypes.OFT_MESSENGER, OFT_DST_EID, _token); + } +} diff --git a/contracts/libraries/PeripherySigningLib.sol b/contracts/libraries/PeripherySigningLib.sol new file mode 100644 index 000000000..5956a6de8 --- /dev/null +++ b/contracts/libraries/PeripherySigningLib.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { SpokePoolV3PeripheryInterface } from "../interfaces/SpokePoolV3PeripheryInterface.sol"; + +library PeripherySigningLib { + string internal constant EIP712_FEES_TYPE = "Fees(uint256 amount,address recipient)"; + string internal constant EIP712_BASE_DEPOSIT_DATA_TYPE = + "BaseDepositData(address inputToken,address outputToken,uint256 outputAmount,address depositor,address recipient,uint256 destinationChainId,address exclusiveRelayer,uint32 quoteTimestamp,uint32 fillDeadline,uint32 exclusivityParameter,bytes message)"; + string internal constant EIP712_DEPOSIT_DATA_TYPE = + "DepositData(Fees submissionFees,BaseDepositData baseDepositData,uint256 inputAmount)"; + string internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPE = + "SwapAndDepositData(Fees submissionFees,BaseDepositData depositData,address swapToken,address exchange,TransferType transferType,uint256 swapTokenAmount,uint256 minExpectedInputTokenAmount,bytes routerCalldata)"; + + // EIP712 Type hashes. + bytes32 internal constant EIP712_FEES_TYPEHASH = keccak256(abi.encodePacked(EIP712_FEES_TYPE)); + bytes32 internal constant EIP712_BASE_DEPOSIT_DATA_TYPEHASH = + keccak256(abi.encodePacked(EIP712_BASE_DEPOSIT_DATA_TYPE)); + bytes32 internal constant EIP712_DEPOSIT_DATA_TYPEHASH = + keccak256(abi.encodePacked(EIP712_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE, EIP712_FEES_TYPE)); + bytes32 internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPEHASH = + keccak256(abi.encodePacked(EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE, EIP712_FEES_TYPE)); + + // EIP712 Type strings. + string internal constant TOKEN_PERMISSIONS_TYPE = "TokenPermissions(address token,uint256 amount)"; + string internal constant EIP712_SWAP_AND_DEPOSIT_TYPE_STRING = + string( + abi.encodePacked( + "SwapAndDepositData witness)", + EIP712_BASE_DEPOSIT_DATA_TYPE, + EIP712_FEES_TYPE, + EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, + TOKEN_PERMISSIONS_TYPE + ) + ); + string internal constant EIP712_DEPOSIT_TYPE_STRING = + string( + abi.encodePacked( + "DepositData witness)", + EIP712_BASE_DEPOSIT_DATA_TYPE, + EIP712_DEPOSIT_DATA_TYPE, + EIP712_FEES_TYPE, + TOKEN_PERMISSIONS_TYPE + ) + ); + + error InvalidSignature(); + + /** + * @notice Creates the EIP712 compliant hashed data corresponding to the BaseDepositData struct. + * @param baseDepositData Input struct whose values are hashed. + * @dev BaseDepositData is only used as a nested struct for both DepositData and SwapAndDepositData. + */ + function hashBaseDepositData(SpokePoolV3PeripheryInterface.BaseDepositData calldata baseDepositData) + internal + pure + returns (bytes32) + { + return + keccak256( + abi.encode( + EIP712_BASE_DEPOSIT_DATA_TYPEHASH, + baseDepositData.inputToken, + baseDepositData.outputToken, + baseDepositData.outputAmount, + baseDepositData.depositor, + baseDepositData.recipient, + baseDepositData.destinationChainId, + baseDepositData.exclusiveRelayer, + baseDepositData.quoteTimestamp, + baseDepositData.fillDeadline, + baseDepositData.exclusivityParameter, + keccak256(baseDepositData.message) + ) + ); + } + + /** + * @notice Creates the EIP712 compliant hashed data corresponding to the Fees struct. + * @param fees Input struct whose values are hashed. + * @dev Fees is only used as a nested struct for both DepositData and SwapAndDepositData. + */ + function hashFees(SpokePoolV3PeripheryInterface.Fees calldata fees) internal pure returns (bytes32) { + return keccak256(abi.encode(EIP712_FEES_TYPEHASH, fees.amount, fees.recipient)); + } + + /** + * @notice Creates the EIP712 compliant hashed data corresponding to the DepositData struct. + * @param depositData Input struct whose values are hashed. + */ + function hashDepositData(SpokePoolV3PeripheryInterface.DepositData calldata depositData) + internal + pure + returns (bytes32) + { + return + keccak256( + abi.encode( + EIP712_DEPOSIT_DATA_TYPEHASH, + hashFees(depositData.submissionFees), + hashBaseDepositData(depositData.baseDepositData), + depositData.inputAmount + ) + ); + } + + /** + * @notice Creates the EIP712 compliant hashed data corresponding to the SwapAndDepositData struct. + * @param swapAndDepositData Input struct whose values are hashed. + */ + function hashSwapAndDepositData(SpokePoolV3PeripheryInterface.SwapAndDepositData calldata swapAndDepositData) + internal + pure + returns (bytes32) + { + return + keccak256( + abi.encode( + EIP712_SWAP_AND_DEPOSIT_DATA_TYPEHASH, + hashFees(swapAndDepositData.submissionFees), + hashBaseDepositData(swapAndDepositData.depositData), + swapAndDepositData.swapToken, + swapAndDepositData.exchange, + swapAndDepositData.transferType, + swapAndDepositData.swapTokenAmount, + swapAndDepositData.minExpectedInputTokenAmount, + keccak256(swapAndDepositData.routerCalldata) + ) + ); + } + + /** + * @notice Reads an input bytes, and, assuming it is a signature for a 32-byte hash, returns the v, r, and s values. + * @param _signature The input signature to deserialize. + */ + function deserializeSignature(bytes calldata _signature) + internal + pure + returns ( + bytes32 r, + bytes32 s, + uint8 v + ) + { + if (_signature.length != 65) revert InvalidSignature(); + v = uint8(_signature[64]); + r = bytes32(_signature[0:32]); + s = bytes32(_signature[32:64]); + } +} diff --git a/contracts/test/MockERC20.sol b/contracts/test/MockERC20.sol new file mode 100644 index 000000000..84150eee4 --- /dev/null +++ b/contracts/test/MockERC20.sol @@ -0,0 +1,51 @@ +//SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import { IERC20Auth } from "../external/interfaces/IERC20Auth.sol"; +import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; + +/** + * @title MockERC20 + * @notice Implements mocked ERC20 contract with various features. + */ +contract MockERC20 is IERC20Auth, ERC20Permit { + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = + keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + // Expose the typehash in ERC20Permit. + bytes32 public constant PERMIT_TYPEHASH_EXTERNAL = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + constructor() ERC20Permit("MockERC20") ERC20("MockERC20", "ERC20") {} + + // This does no nonce checking. + function receiveWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(validAfter <= block.timestamp && validBefore >= block.timestamp, "Invalid time bounds"); + require(msg.sender == to, "Receiver not caller"); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + bytes32 structHash = keccak256( + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + ); + bytes32 sigHash = _hashTypedDataV4(structHash); + require(SignatureChecker.isValidSignatureNow(from, sigHash, signature), "Invalid signature"); + _transfer(from, to, value); + } + + function hashTypedData(bytes32 typedData) external view returns (bytes32) { + return _hashTypedDataV4(typedData); + } +} diff --git a/contracts/test/MockOFTMessenger.sol b/contracts/test/MockOFTMessenger.sol new file mode 100644 index 000000000..225dec992 --- /dev/null +++ b/contracts/test/MockOFTMessenger.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import "../interfaces/IOFT.sol"; + +/** + * @notice Facilitate bridging tokens via LayerZero's OFT. + * @dev This contract is intended to be inherited by other chain-specific adapters and spoke pools. + * @custom:security-contact bugs@across.to + */ +contract MockOFTMessenger is IOFT { + address public token; + + constructor(address _token) { + token = _token; + } + + function quoteSend(SendParam calldata _sendParam, bool _payInLzToken) external view returns (MessagingFee memory) { + return MessagingFee(0, 0); + } + + function send( + SendParam calldata _sendParam, + MessagingFee calldata _fee, + address _refundAddress + ) external payable returns (MessagingReceipt memory, OFTReceipt memory) { + return (MessagingReceipt(0, 0, MessagingFee(0, 0)), OFTReceipt(_sendParam.amountLD, _sendParam.amountLD)); + } +} diff --git a/contracts/test/MockPermit2.sol b/contracts/test/MockPermit2.sol new file mode 100644 index 000000000..aa4ad1b5f --- /dev/null +++ b/contracts/test/MockPermit2.sol @@ -0,0 +1,206 @@ +pragma solidity ^0.8.0; + +import { IPermit2 } from "../external/interfaces/IPermit2.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +// Taken from https://github.com/Uniswap/permit2/blob/main/src/EIP712.sol +contract Permit2EIP712 { + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + uint256 private immutable _CACHED_CHAIN_ID; + + bytes32 private constant _HASHED_NAME = keccak256("Permit2"); + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); + + constructor() { + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME); + } + + function DOMAIN_SEPARATOR() public view returns (bytes32) { + return + block.chainid == _CACHED_CHAIN_ID + ? _CACHED_DOMAIN_SEPARATOR + : _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME); + } + + function _buildDomainSeparator(bytes32 typeHash, bytes32 nameHash) private view returns (bytes32) { + return keccak256(abi.encode(typeHash, nameHash, block.chainid, address(this))); + } + + function _hashTypedData(bytes32 dataHash) internal view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), dataHash)); + } +} + +contract MockPermit2 is IPermit2, Permit2EIP712 { + using SafeERC20 for IERC20; + + mapping(address => mapping(uint256 => uint256)) public nonceBitmap; + mapping(address => mapping(address => mapping(address => uint256))) public allowance; + + bytes32 public constant _TOKEN_PERMISSIONS_TYPEHASH = keccak256("TokenPermissions(address token,uint256 amount)"); + string public constant _PERMIT_TRANSFER_FROM_WITNESS_TYPEHASH_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + + error SignatureExpired(); + error InvalidAmount(); + error InvalidNonce(); + error AllowanceExpired(); + error InsufficientAllowance(); + + function permitWitnessTransferFrom( + PermitTransferFrom memory _permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external override { + _permitTransferFrom( + _permit, + transferDetails, + owner, + hashWithWitness(_permit, witness, witnessTypeString), + signature + ); + } + + function transferFrom( + address from, + address to, + uint160 amount, + address token + ) external { + _transfer(from, to, amount, token); + } + + // This is not a copy of permit2's permit. + function permit( + address owner, + PermitSingle memory permitSingle, + bytes calldata signature + ) external { + if (block.timestamp > permitSingle.sigDeadline) revert SignatureExpired(); + + // Verify the signer address from the signature. + SignatureVerification.verify(signature, _hashTypedData(keccak256(abi.encode(permitSingle))), owner); + + allowance[owner][permitSingle.details.token][permitSingle.spender] = permitSingle.details.amount; + } + + // This is not a copy of permit2's permit. + function _transfer( + address from, + address to, + uint160 amount, + address token + ) private { + uint256 allowed = allowance[from][token][msg.sender]; + + if (allowed != type(uint160).max) { + if (amount > allowed) { + revert InsufficientAllowance(); + } else { + unchecked { + allowance[from][token][msg.sender] = uint160(allowed) - amount; + } + } + } + + // Transfer the tokens from the from address to the recipient. + IERC20(token).safeTransferFrom(from, to, amount); + } + + function _permitTransferFrom( + PermitTransferFrom memory _permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 dataHash, + bytes calldata signature + ) private { + uint256 requestedAmount = transferDetails.requestedAmount; + + if (block.timestamp > _permit.deadline) revert SignatureExpired(); + if (requestedAmount > _permit.permitted.amount) revert InvalidAmount(); + + _useUnorderedNonce(owner, _permit.nonce); + + SignatureVerification.verify(signature, _hashTypedData(dataHash), owner); + + IERC20(_permit.permitted.token).safeTransferFrom(owner, transferDetails.to, requestedAmount); + } + + function bitmapPositions(uint256 nonce) private pure returns (uint256 wordPos, uint256 bitPos) { + wordPos = uint248(nonce >> 8); + bitPos = uint8(nonce); + } + + function _useUnorderedNonce(address from, uint256 nonce) internal { + (uint256 wordPos, uint256 bitPos) = bitmapPositions(nonce); + uint256 bit = 1 << bitPos; + uint256 flipped = nonceBitmap[from][wordPos] ^= bit; + + if (flipped & bit == 0) revert InvalidNonce(); + } + + function hashWithWitness( + PermitTransferFrom memory _permit, + bytes32 witness, + string calldata witnessTypeString + ) internal view returns (bytes32) { + bytes32 typeHash = keccak256(abi.encodePacked(_PERMIT_TRANSFER_FROM_WITNESS_TYPEHASH_STUB, witnessTypeString)); + + bytes32 tokenPermissionsHash = _hashTokenPermissions(_permit.permitted); + return + keccak256(abi.encode(typeHash, tokenPermissionsHash, msg.sender, _permit.nonce, _permit.deadline, witness)); + } + + function _hashTokenPermissions(TokenPermissions memory permitted) private pure returns (bytes32) { + return keccak256(abi.encode(_TOKEN_PERMISSIONS_TYPEHASH, permitted)); + } +} + +// Taken from https://github.com/Uniswap/permit2/blob/main/src/libraries/SignatureVerification.sol +library SignatureVerification { + error InvalidSignatureLength(); + error InvalidSignature(); + error InvalidSigner(); + error InvalidContractSignature(); + + bytes32 constant UPPER_BIT_MASK = (0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff); + + function verify( + bytes calldata signature, + bytes32 hash, + address claimedSigner + ) internal view { + bytes32 r; + bytes32 s; + uint8 v; + + if (claimedSigner.code.length == 0) { + if (signature.length == 65) { + (r, s) = abi.decode(signature, (bytes32, bytes32)); + v = uint8(signature[64]); + } else if (signature.length == 64) { + // EIP-2098 + bytes32 vs; + (r, vs) = abi.decode(signature, (bytes32, bytes32)); + s = vs & UPPER_BIT_MASK; + v = uint8(uint256(vs >> 255)) + 27; + } else { + revert InvalidSignatureLength(); + } + address signer = ecrecover(hash, v, r, s); + if (signer == address(0)) revert InvalidSignature(); + if (signer != claimedSigner) revert InvalidSigner(); + } else { + bytes4 magicValue = IERC1271(claimedSigner).isValidSignature(hash, signature); + if (magicValue != IERC1271.isValidSignature.selector) revert InvalidContractSignature(); + } + } +} diff --git a/contracts/test/MockSpokePool.sol b/contracts/test/MockSpokePool.sol index 2b30a681e..cca9e449f 100644 --- a/contracts/test/MockSpokePool.sol +++ b/contracts/test/MockSpokePool.sol @@ -31,7 +31,7 @@ contract MockSpokePool is SpokePool, MockV2SpokePoolInterface, OwnableUpgradeabl event PreLeafExecuteHook(bytes32 token); /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address _wrappedNativeTokenAddress) SpokePool(_wrappedNativeTokenAddress, 1 hours, 9 hours) {} // solhint-disable-line no-empty-blocks + constructor(address _wrappedNativeTokenAddress) SpokePool(_wrappedNativeTokenAddress, 1 hours, 9 hours, 0, 0) {} // solhint-disable-line no-empty-blocks function initialize( uint32 _initialDepositId, diff --git a/deploy/004_deploy_arbitrum_adapter.ts b/deploy/004_deploy_arbitrum_adapter.ts index 9536abf8b..19878e858 100644 --- a/deploy/004_deploy_arbitrum_adapter.ts +++ b/deploy/004_deploy_arbitrum_adapter.ts @@ -1,3 +1,5 @@ +import { CHAIN_IDs } from "@across-protocol/constants"; +import { getHyperlaneDomainId, getOftEid, toWei } from "../utils/utils"; import { L1_ADDRESS_MAP, USDC } from "./consts"; import { DeployFunction } from "hardhat-deploy/types"; import { HardhatRuntimeEnvironment } from "hardhat/types"; @@ -10,12 +12,20 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { // set to the Risk Labs relayer address. The deployer should change this if necessary. const l2RefundAddress = "0x07aE8551Be970cB1cCa11Dd7a11F47Ae82e70E67"; + const spokeChainId = chainId == CHAIN_IDs.MAINNET ? CHAIN_IDs.ARBITRUM : CHAIN_IDs.ARBITRUM_SEPOLIA; + + const oftDstEid = getOftEid(spokeChainId); + const oftFeeCap = toWei("1"); // 1 eth transfer fee cap + const args = [ L1_ADDRESS_MAP[chainId].l1ArbitrumInbox, L1_ADDRESS_MAP[chainId].l1ERC20GatewayRouter, l2RefundAddress, USDC[chainId], L1_ADDRESS_MAP[chainId].cctpTokenMessenger, + L1_ADDRESS_MAP[chainId].adapterStore, + oftDstEid, + oftFeeCap, ]; const instance = await hre.deployments.deploy("Arbitrum_Adapter", { from: deployer, @@ -27,6 +37,9 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { l2RefundAddress, USDC[chainId], L1_ADDRESS_MAP[chainId].cctpTokenMessenger, + L1_ADDRESS_MAP[chainId].adapterStore, + oftDstEid, + oftFeeCap, ], }); await hre.run("verify:verify", { address: instance.address, constructorArguments: args }); diff --git a/deploy/005_deploy_arbitrum_spokepool.ts b/deploy/005_deploy_arbitrum_spokepool.ts index 3c2b52a41..5c1a17a19 100644 --- a/deploy/005_deploy_arbitrum_spokepool.ts +++ b/deploy/005_deploy_arbitrum_spokepool.ts @@ -2,6 +2,7 @@ import { DeployFunction } from "hardhat-deploy/types"; import { HardhatRuntimeEnvironment } from "hardhat/types"; import { deployNewProxy, getSpokePoolDeploymentInfo } from "../utils/utils.hre"; import { FILL_DEADLINE_BUFFER, L2_ADDRESS_MAP, QUOTE_TIME_BUFFER, USDC, WETH } from "./consts"; +import { getHyperlaneDomainId, getOftEid, toWei } from "../utils/utils"; const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const { hubPool, spokeChainId } = await getSpokePoolDeploymentInfo(hre); @@ -16,12 +17,16 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { hubPool.address, ]; + const oftEid = getOftEid(hubChainId); + const oftFeeCap = toWei(1); // 1 eth fee cap const constructorArgs = [ WETH[spokeChainId], QUOTE_TIME_BUFFER, FILL_DEADLINE_BUFFER, USDC[spokeChainId], L2_ADDRESS_MAP[spokeChainId].cctpTokenMessenger, + oftEid, + oftFeeCap, ]; await deployNewProxy("Arbitrum_SpokePool", constructorArgs, initArgs); }; diff --git a/deploy/054_deploy_alephzero_spokepool.ts b/deploy/054_deploy_alephzero_spokepool.ts index 8beba323e..ead7b95d7 100644 --- a/deploy/054_deploy_alephzero_spokepool.ts +++ b/deploy/054_deploy_alephzero_spokepool.ts @@ -23,6 +23,10 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { // For now, we are not using the CCTP bridge and can disable by setting // the cctpTokenMessenger to the zero address. ZERO_ADDRESS, + // For now, we are not using OFT bridge and can disable by setting the + // oftMessenger and USDT token to the zero address. + ZERO_ADDRESS, + ZERO_ADDRESS, ]; await deployNewProxy("AlephZero_SpokePool", constructorArgs, initArgs); }; diff --git a/deploy/063_deploy_adapter_store.ts b/deploy/063_deploy_adapter_store.ts new file mode 100644 index 000000000..60b936cb9 --- /dev/null +++ b/deploy/063_deploy_adapter_store.ts @@ -0,0 +1,18 @@ +import { DeployFunction } from "hardhat-deploy/types"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import "hardhat-deploy"; + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { deployer } = await hre.getNamedAccounts(); + + const instance = await hre.deployments.deploy("AdapterStore", { + from: deployer, + log: true, + skipIfAlreadyDeployed: true, + }); + + await hre.run("verify:verify", { address: instance.address }); +}; + +module.exports = func; +func.tags = ["AdapterStore", "mainnet"]; diff --git a/deploy/consts.ts b/deploy/consts.ts index 42f2b2344..fcada0155 100644 --- a/deploy/consts.ts +++ b/deploy/consts.ts @@ -45,6 +45,7 @@ export const L1_ADDRESS_MAP: { [key: number]: { [contractName: string]: string } l1AlephZeroInbox: "0x56D8EC76a421063e1907503aDd3794c395256AEb", l1AlephZeroERC20GatewayRouter: "0xeBb17f398ed30d02F2e8733e7c1e5cf566e17812", donationBox: "0x0d57392895Db5aF3280e9223323e20F3951E81B1", + adapterStore: "", // to be deployed hubPoolStore: "0x1Ace3BbD69b63063F859514Eca29C9BDd8310E61", zkBridgeHub: "0x303a465B659cBB0ab36eE643eA362c509EEb5213", zkUsdcSharedBridge_232: "0xf553E6D903AA43420ED7e3bc2313bE9286A8F987", @@ -272,3 +273,12 @@ export const POLYGON_CHAIN_IDS: { [l1ChainId: number]: number } = { export const CIRCLE_DOMAIN_IDs = Object.fromEntries( Object.entries(PUBLIC_NETWORKS).map(([chainId, { cctpDomain }]) => [Number(chainId), cctpDomain]) ); + +const createChainMap = (selector: (network: any) => T) => + new Map(Object.entries(PUBLIC_NETWORKS).map(([id, network]) => [Number(id), selector(network)])); + +/** + * Mapping chainId => oft endpoint id + * @link https://docs.layerzero.network/v2/deployments/deployed-contracts + */ +export const OFT_EIDs = createChainMap((network) => network.oftEid); diff --git a/hardhat.config.ts b/hardhat.config.ts index f51bb7b76..7246e2a9c 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -61,6 +61,15 @@ const DEFAULT_CONTRACT_COMPILER_SETTINGS = { debug: { revertStrings: isTest ? "debug" : "strip" }, }, }; +// This is only used by Blast_SpokePool for now, as it's the largest bytecode-wise +const LARGEST_CONTRACT_COMPILER_SETTINGS = { + version: solcVersion, + settings: { + optimizer: { enabled: true, runs: 50 }, + viaIR: true, + debug: { revertStrings: isTest ? "debug" : "strip" }, + }, +}; const config: HardhatUserConfig = { solidity: { @@ -82,7 +91,6 @@ const config: HardhatUserConfig = { "contracts/Universal_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/Arbitrum_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/Scroll_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, - "contracts/Blast_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/Lisk_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/Redstone_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/Zora_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, @@ -93,6 +101,7 @@ const config: HardhatUserConfig = { "contracts/Ink_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/Cher_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, "contracts/DoctorWho_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, + "contracts/Blast_SpokePool.sol": LARGEST_CONTRACT_COMPILER_SETTINGS, "contracts/Tatara_SpokePool.sol": LARGE_CONTRACT_COMPILER_SETTINGS, }, }, diff --git a/package.json b/package.json index db6897370..6c2054d54 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,15 @@ "export-idl": "mkdir -p dist/src/svm/assets/idl && cp src/svm/assets/idl/*.json dist/src/svm/assets/idl/", "build": "yarn build-evm && yarn build-svm && yarn generate-svm-artifacts && yarn build-ts", "build-verified": "yarn build-evm && yarn build-svm-solana-verify && yarn generate-svm-artifacts && yarn build-ts", - "test-evm": "IS_TEST=true hardhat test", + "test-evm": "yarn test-evm-hardhat && yarn test-evm-foundry", + "test-evm-hardhat": "IS_TEST=true hardhat test", + "test-evm-foundry": "FOUNDRY_PROFILE=local forge test --match-path test/evm/foundry/local/**/*.t.sol", "test-svm": "IS_TEST=true yarn build-svm && yarn generate-svm-artifacts && anchor test --skip-build", "test-svm-solana-verify": "IS_TEST=true yarn build-svm-solana-verify && yarn generate-svm-artifacts && anchor test --skip-build", "test": "yarn test-evm && yarn test-svm", "test-verified": "yarn test-evm && yarn test-svm-solana-verify", "test:report-gas": "IS_TEST=true REPORT_GAS=true hardhat test", + "evm-contract-sizes": "sh scripts/evm-contract-sizes.sh", "generate-evm-artifacts": "rm -rf typechain && TYPECHAIN=ethers yarn hardhat typechain", "process-hardhat-export": "hardhat export --export-all ./cache/massExport.json && ts-node ./scripts/processHardhatExport.ts && prettier --write ./deployments/deployments.json", "pre-commit-hook": "sh scripts/preCommitHook.sh" diff --git a/scripts/evm-contract-sizes.sh b/scripts/evm-contract-sizes.sh new file mode 100644 index 000000000..9e0845436 --- /dev/null +++ b/scripts/evm-contract-sizes.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Read the current optimizer runs value from foundry.toml +DEFAULT_RUNS=$(grep "optimizer_runs" foundry.toml | awk '{print $3}') + +# Prompt for optimizer runs +read -p "Enter number of optimizer runs (press enter to use $DEFAULT_RUNS): " OPTIMIZER_RUNS + +# If no input provided, use the default value +if [ -z "$OPTIMIZER_RUNS" ]; then + OPTIMIZER_RUNS=$DEFAULT_RUNS +fi + +# Run forge build with specified optimizer runs +forge build --sizes --skip test --optimizer-runs $OPTIMIZER_RUNS + +exit 0 \ No newline at end of file diff --git a/storage-layouts/AlephZero_SpokePool.json b/storage-layouts/AlephZero_SpokePool.json index d22430ed9..e081ea941 100644 --- a/storage-layouts/AlephZero_SpokePool.json +++ b/storage-layouts/AlephZero_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", "label": "l2GatewayRouter", @@ -167,6 +173,12 @@ "label": "whitelistedTokens", "offset": 0, "slot": "3163" + }, + { + "contract": "contracts/AlephZero_SpokePool.sol:AlephZero_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "3164" } ] } diff --git a/storage-layouts/Arbitrum_SpokePool.json b/storage-layouts/Arbitrum_SpokePool.json index a22965fda..40d45f994 100644 --- a/storage-layouts/Arbitrum_SpokePool.json +++ b/storage-layouts/Arbitrum_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", "label": "l2GatewayRouter", @@ -167,6 +173,12 @@ "label": "whitelistedTokens", "offset": 0, "slot": "3163" + }, + { + "contract": "contracts/Arbitrum_SpokePool.sol:Arbitrum_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "3164" } ] } diff --git a/storage-layouts/Base_SpokePool.json b/storage-layouts/Base_SpokePool.json index 9c6877c37..d3690e821 100644 --- a/storage-layouts/Base_SpokePool.json +++ b/storage-layouts/Base_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Base_SpokePool.sol:Base_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Base_SpokePool.sol:Base_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Base_SpokePool.sol:Base_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Blast_SpokePool.json b/storage-layouts/Blast_SpokePool.json index c0b95f53c..b9471876f 100644 --- a/storage-layouts/Blast_SpokePool.json +++ b/storage-layouts/Blast_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Blast_SpokePool.sol:Blast_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Blast_SpokePool.sol:Blast_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Blast_SpokePool.sol:Blast_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Ethereum_SpokePool.json b/storage-layouts/Ethereum_SpokePool.json index c6515c53e..2cd44299e 100644 --- a/storage-layouts/Ethereum_SpokePool.json +++ b/storage-layouts/Ethereum_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Ethereum_SpokePool.sol:Ethereum_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Ethereum_SpokePool.sol:Ethereum_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Ethereum_SpokePool.sol:Ethereum_SpokePool", "label": "__gap", diff --git a/storage-layouts/Linea_SpokePool.json b/storage-layouts/Linea_SpokePool.json index 6a1969cac..b83aa19de 100644 --- a/storage-layouts/Linea_SpokePool.json +++ b/storage-layouts/Linea_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Linea_SpokePool.sol:Linea_SpokePool", "label": "l2MessageService", diff --git a/storage-layouts/Mode_SpokePool.json b/storage-layouts/Mode_SpokePool.json index 2f1362150..1a30a44a2 100644 --- a/storage-layouts/Mode_SpokePool.json +++ b/storage-layouts/Mode_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Mode_SpokePool.sol:Mode_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Mode_SpokePool.sol:Mode_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Mode_SpokePool.sol:Mode_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Optimism_SpokePool.json b/storage-layouts/Optimism_SpokePool.json index b0f7937a4..acaca24c5 100644 --- a/storage-layouts/Optimism_SpokePool.json +++ b/storage-layouts/Optimism_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Optimism_SpokePool.sol:Optimism_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Optimism_SpokePool.sol:Optimism_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Optimism_SpokePool.sol:Optimism_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/PolygonZkEVM_SpokePool.json b/storage-layouts/PolygonZkEVM_SpokePool.json index 6996d2728..598cfcf7f 100644 --- a/storage-layouts/PolygonZkEVM_SpokePool.json +++ b/storage-layouts/PolygonZkEVM_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/PolygonZkEVM_SpokePool.sol:PolygonZkEVM_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/PolygonZkEVM_SpokePool.sol:PolygonZkEVM_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/PolygonZkEVM_SpokePool.sol:PolygonZkEVM_SpokePool", "label": "l2PolygonZkEVMBridge", diff --git a/storage-layouts/Polygon_SpokePool.json b/storage-layouts/Polygon_SpokePool.json index c7524b29a..73a348caa 100644 --- a/storage-layouts/Polygon_SpokePool.json +++ b/storage-layouts/Polygon_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Polygon_SpokePool.sol:Polygon_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Polygon_SpokePool.sol:Polygon_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Polygon_SpokePool.sol:Polygon_SpokePool", "label": "fxChild", diff --git a/storage-layouts/Redstone_SpokePool.json b/storage-layouts/Redstone_SpokePool.json index 630b8904f..b4247c638 100644 --- a/storage-layouts/Redstone_SpokePool.json +++ b/storage-layouts/Redstone_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Redstone_SpokePool.sol:Redstone_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Redstone_SpokePool.sol:Redstone_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Redstone_SpokePool.sol:Redstone_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/Scroll_SpokePool.json b/storage-layouts/Scroll_SpokePool.json index 1a6bd7119..5005a8d3f 100644 --- a/storage-layouts/Scroll_SpokePool.json +++ b/storage-layouts/Scroll_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Scroll_SpokePool.sol:Scroll_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Scroll_SpokePool.sol:Scroll_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Scroll_SpokePool.sol:Scroll_SpokePool", "label": "l2GatewayRouter", diff --git a/storage-layouts/Universal_SpokePool.json b/storage-layouts/Universal_SpokePool.json index 2f3c7d328..8a17e43e8 100644 --- a/storage-layouts/Universal_SpokePool.json +++ b/storage-layouts/Universal_SpokePool.json @@ -170,10 +170,16 @@ }, { "contract": "contracts/Universal_SpokePool.sol:Universal_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2264" }, + { + "contract": "contracts/Universal_SpokePool.sol:Universal_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2265" + }, { "contract": "contracts/Universal_SpokePool.sol:Universal_SpokePool", "label": "executedMessages", diff --git a/storage-layouts/WorldChain_SpokePool.json b/storage-layouts/WorldChain_SpokePool.json index d15295df6..f944364f9 100644 --- a/storage-layouts/WorldChain_SpokePool.json +++ b/storage-layouts/WorldChain_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/WorldChain_SpokePool.sol:WorldChain_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/WorldChain_SpokePool.sol:WorldChain_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/WorldChain_SpokePool.sol:WorldChain_SpokePool", "label": "l1Gas", diff --git a/storage-layouts/ZkSync_SpokePool.json b/storage-layouts/ZkSync_SpokePool.json index 60e84dc68..3cf37900a 100644 --- a/storage-layouts/ZkSync_SpokePool.json +++ b/storage-layouts/ZkSync_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/ZkSync_SpokePool.sol:ZkSync_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/ZkSync_SpokePool.sol:ZkSync_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/ZkSync_SpokePool.sol:ZkSync_SpokePool", "label": "l2Eth", diff --git a/storage-layouts/Zora_SpokePool.json b/storage-layouts/Zora_SpokePool.json index 79c7343be..9b19fa067 100644 --- a/storage-layouts/Zora_SpokePool.json +++ b/storage-layouts/Zora_SpokePool.json @@ -152,10 +152,16 @@ }, { "contract": "contracts/Zora_SpokePool.sol:Zora_SpokePool", - "label": "__gap", + "label": "oftMessengers", "offset": 0, "slot": "2164" }, + { + "contract": "contracts/Zora_SpokePool.sol:Zora_SpokePool", + "label": "__gap", + "offset": 0, + "slot": "2165" + }, { "contract": "contracts/Zora_SpokePool.sol:Zora_SpokePool", "label": "l1Gas", diff --git a/test/evm/foundry/local/Create2Factory.t.sol b/test/evm/foundry/local/Create2Factory.t.sol new file mode 100644 index 000000000..70250e38d --- /dev/null +++ b/test/evm/foundry/local/Create2Factory.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; + +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { Create2Factory } from "../../../../contracts/Create2Factory.sol"; + +contract InitializedContract { + bool public initialized; + + function initialize(bool _initialized) external { + initialized = _initialized; + } +} + +contract Create2FactoryTest is Test { + Create2Factory create2Factory; + + function setUp() public { + create2Factory = new Create2Factory(); + } + + function testDeterministicDeployNoValue() public { + bytes32 salt = "12345"; + bytes memory creationCode = abi.encodePacked(type(InitializedContract).creationCode); + + address computedAddress = Create2.computeAddress(salt, keccak256(creationCode), address(create2Factory)); + bytes memory initializationData = abi.encodeWithSelector(InitializedContract.initialize.selector, true); + address deployedAddress = create2Factory.deploy(0, salt, creationCode, initializationData); + + assertEq(computedAddress, deployedAddress); + assertTrue(InitializedContract(deployedAddress).initialized()); + } +} diff --git a/test/evm/foundry/local/Forwarder.t.sol b/test/evm/foundry/local/Forwarder.t.sol index 5ff33cfad..d2b478b36 100644 --- a/test/evm/foundry/local/Forwarder.t.sol +++ b/test/evm/foundry/local/Forwarder.t.sol @@ -18,6 +18,7 @@ import { Arbitrum_Forwarder } from "../../../../contracts/chain-adapters/Arbitru import { ForwarderBase } from "../../../../contracts/chain-adapters/ForwarderBase.sol"; import { CrossDomainAddressUtils } from "../../../../contracts/libraries/CrossDomainAddressUtils.sol"; import { ForwarderInterface } from "../../../../contracts/chain-adapters/interfaces/ForwarderInterface.sol"; +import { AdapterStore } from "../../../../contracts/AdapterStore.sol"; contract Token_ERC20 is ERC20 { constructor(string memory name, string memory symbol) ERC20(name, symbol) {} @@ -40,6 +41,7 @@ contract ForwarderTest is Test { WETH9 l2Weth; MockBedrockCrossDomainMessenger crossDomainMessenger; MockBedrockL1StandardBridge standardBridge; + AdapterStore adapterStore; address owner; address aliasedOwner; @@ -57,6 +59,8 @@ contract ForwarderTest is Test { crossDomainMessenger = new MockBedrockCrossDomainMessenger(); standardBridge = new MockBedrockL1StandardBridge(); + adapterStore = new AdapterStore(); + optimismAdapter = new Optimism_Adapter( WETH9Interface(address(l2Weth)), address(crossDomainMessenger), diff --git a/test/evm/foundry/local/Router_Adapter.t.sol b/test/evm/foundry/local/Router_Adapter.t.sol index 9059c71e9..95052c846 100644 --- a/test/evm/foundry/local/Router_Adapter.t.sol +++ b/test/evm/foundry/local/Router_Adapter.t.sol @@ -18,6 +18,7 @@ import { MockBedrockL1StandardBridge, MockBedrockCrossDomainMessenger } from ".. import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; import { HubPool } from "../../../../contracts/HubPool.sol"; import { LpTokenFactoryInterface } from "../../../../contracts/interfaces/LpTokenFactoryInterface.sol"; +import { AdapterStore } from "../../../../contracts/AdapterStore.sol"; // We normally delegatecall these from the hub pool, which has receive(). In this test, we call the adapter // directly, so in order to withdraw Weth, we need to have receive(). @@ -56,6 +57,7 @@ contract RouterAdapterTest is Test { MockBedrockCrossDomainMessenger crossDomainMessenger; MockBedrockL1StandardBridge standardBridge; HubPool hubPool; + AdapterStore adapterStore; address l2Target; address owner; @@ -94,6 +96,8 @@ contract RouterAdapterTest is Test { crossDomainMessenger = new MockBedrockCrossDomainMessenger(); standardBridge = new MockBedrockL1StandardBridge(); + adapterStore = new AdapterStore(); + optimismAdapter = new Optimism_Adapter( WETH9Interface(address(l1Weth)), address(crossDomainMessenger), diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol new file mode 100644 index 000000000..8153a1dc8 --- /dev/null +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -0,0 +1,1125 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; + +import { SpokePoolVerifier } from "../../../../contracts/SpokePoolVerifier.sol"; +import { SpokePoolV3Periphery, SpokePoolPeripheryProxy } from "../../../../contracts/SpokePoolV3Periphery.sol"; +import { Ethereum_SpokePool } from "../../../../contracts/Ethereum_SpokePool.sol"; +import { V3SpokePoolInterface } from "../../../../contracts/interfaces/V3SpokePoolInterface.sol"; +import { SpokePoolV3PeripheryInterface } from "../../../../contracts/interfaces/SpokePoolV3PeripheryInterface.sol"; +import { WETH9 } from "../../../../contracts/external/WETH9.sol"; +import { WETH9Interface } from "../../../../contracts/external/interfaces/WETH9Interface.sol"; +import { IPermit2 } from "../../../../contracts/external/interfaces/IPermit2.sol"; +import { MockPermit2, Permit2EIP712, SignatureVerification } from "../../../../contracts/test/MockPermit2.sol"; +import { PeripherySigningLib } from "../../../../contracts/libraries/PeripherySigningLib.sol"; +import { MockERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { AddressToBytes32 } from "../../../../contracts/libraries/AddressConverters.sol"; + +contract Exchange { + IPermit2 permit2; + + constructor(IPermit2 _permit2) { + permit2 = _permit2; + } + + function swap( + IERC20 tokenIn, + IERC20 tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bool usePermit2 + ) external { + if (tokenIn.balanceOf(address(this)) >= amountIn) { + tokenIn.transfer(address(1), amountIn); + require(tokenOut.transfer(msg.sender, amountOutMin)); + return; + } + // The periphery contract should call the exchange, which should call permit2. Permit2 should call the periphery contract, and + // should allow the exchange to take tokens away from the periphery. + if (usePermit2) { + permit2.transferFrom(msg.sender, address(this), uint160(amountIn), address(tokenIn)); + tokenOut.transfer(msg.sender, amountOutMin); + return; + } + require(tokenIn.transferFrom(msg.sender, address(this), amountIn)); + require(tokenOut.transfer(msg.sender, amountOutMin)); + } +} + +// Utility contract which lets us perform external calls to an internal library. +contract HashUtils { + function hashDepositData(SpokePoolV3PeripheryInterface.DepositData calldata depositData) + external + pure + returns (bytes32) + { + return PeripherySigningLib.hashDepositData(depositData); + } + + function hashSwapAndDepositData(SpokePoolV3Periphery.SwapAndDepositData calldata swapAndDepositData) + external + pure + returns (bytes32) + { + return PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData); + } +} + +contract SpokePoolPeripheryTest is Test { + using AddressToBytes32 for address; + + Ethereum_SpokePool ethereumSpokePool; + HashUtils hashUtils; + SpokePoolV3Periphery spokePoolPeriphery; + SpokePoolPeripheryProxy proxy; + Exchange dex; + Exchange cex; + IPermit2 permit2; + + WETH9Interface mockWETH; + MockERC20 mockERC20; + + address depositor; + address owner; + address recipient; + address relayer; + + uint256 destinationChainId = 10; + uint256 mintAmount = 10**22; + uint256 submissionFeeAmount = 1; + uint256 depositAmount = 5 * (10**18); + uint256 depositAmountWithSubmissionFee = depositAmount + submissionFeeAmount; + uint256 mintAmountWithSubmissionFee = mintAmount + submissionFeeAmount; + uint32 fillDeadlineBuffer = 7200; + uint256 privateKey = 0x12345678910; + + bytes32 domainSeparator; + bytes32 private constant TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + string private constant PERMIT_TRANSFER_TYPE_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + + bytes32 private constant TOKEN_PERMISSIONS_TYPEHASH = + keccak256(abi.encodePacked(PeripherySigningLib.TOKEN_PERMISSIONS_TYPE)); + + function setUp() public { + hashUtils = new HashUtils(); + + mockWETH = WETH9Interface(address(new WETH9())); + mockERC20 = new MockERC20(); + + depositor = vm.addr(privateKey); + owner = vm.addr(2); + recipient = vm.addr(3); + relayer = vm.addr(4); + permit2 = IPermit2(new MockPermit2()); + dex = new Exchange(permit2); + cex = new Exchange(permit2); + + vm.startPrank(owner); + spokePoolPeriphery = new SpokePoolV3Periphery(); + domainSeparator = Permit2EIP712(address(permit2)).DOMAIN_SEPARATOR(); + proxy = new SpokePoolPeripheryProxy(); + proxy.initialize(spokePoolPeriphery); + Ethereum_SpokePool implementation = new Ethereum_SpokePool( + address(mockWETH), + fillDeadlineBuffer, + fillDeadlineBuffer + ); + address spokePoolProxy = address( + new ERC1967Proxy(address(implementation), abi.encodeCall(Ethereum_SpokePool.initialize, (0, owner))) + ); + ethereumSpokePool = Ethereum_SpokePool(payable(spokePoolProxy)); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy), permit2); + vm.stopPrank(); + + deal(depositor, mintAmountWithSubmissionFee); + deal(address(mockERC20), depositor, mintAmountWithSubmissionFee, true); + deal(address(mockERC20), address(dex), depositAmount, true); + vm.startPrank(depositor); + mockWETH.deposit{ value: mintAmountWithSubmissionFee }(); + mockERC20.approve(address(proxy), mintAmountWithSubmissionFee); + IERC20(address(mockWETH)).approve(address(proxy), mintAmountWithSubmissionFee); + + // Approve permit2 + IERC20(address(mockWETH)).approve(address(permit2), mintAmountWithSubmissionFee * 10); + vm.stopPrank(); + } + + function testInitializePeriphery() public { + SpokePoolV3Periphery _spokePoolPeriphery = new SpokePoolV3Periphery(); + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy), permit2); + assertEq(address(_spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); + assertEq(address(_spokePoolPeriphery.wrappedNativeToken()), address(mockWETH)); + assertEq(address(_spokePoolPeriphery.proxy()), address(proxy)); + assertEq(address(_spokePoolPeriphery.permit2()), address(permit2)); + vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy), permit2); + } + + function testInitializeProxy() public { + SpokePoolPeripheryProxy _proxy = new SpokePoolPeripheryProxy(); + _proxy.initialize(spokePoolPeriphery); + assertEq(address(_proxy.spokePoolPeriphery()), address(spokePoolPeriphery)); + vm.expectRevert(SpokePoolPeripheryProxy.ContractInitialized.selector); + _proxy.initialize(spokePoolPeriphery); + } + + /** + * Approval based flows + */ + function testSwapAndBridge() public { + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + proxy.swapAndBridge( + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + 0, + address(0), + dex, + SpokePoolV3PeripheryInterface.TransferType.Approval, + address(mockERC20), + depositAmount, + depositor + ) + ); + vm.stopPrank(); + } + + function testSwapAndBridgePermitTransferType() public { + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + proxy.swapAndBridge( + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + 0, + address(0), + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockERC20), + depositAmount, + depositor + ) + ); + vm.stopPrank(); + } + + function testSwapAndBridgeTransferTransferType() public { + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + proxy.swapAndBridge( + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + 0, + address(0), + dex, + SpokePoolV3PeripheryInterface.TransferType.Transfer, + address(mockERC20), + depositAmount, + depositor + ) + ); + vm.stopPrank(); + } + + /** + * Value based flows + */ + function testSwapAndBridgeNoValueNoProxy() public { + // Cannot call swapAndBridge with no value directly. + vm.startPrank(depositor); + vm.expectRevert(SpokePoolV3Periphery.NotProxy.selector); + spokePoolPeriphery.swapAndBridge( + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + 0, + address(0), + dex, + SpokePoolV3PeripheryInterface.TransferType.Approval, + address(mockERC20), + depositAmount, + depositor + ) + ); + + vm.stopPrank(); + } + + function testSwapAndBridgeWithValue() public { + // Unlike previous test, this one calls the spokePoolPeriphery directly rather than through the proxy + // because there is no approval required to be set on the periphery. + deal(depositor, mintAmount); + + // Should emit expected deposit event + vm.startPrank(depositor); + + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridge{ value: mintAmount }( + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + 0, + address(0), + dex, + SpokePoolV3PeripheryInterface.TransferType.Approval, + address(mockERC20), + depositAmount, + depositor + ) + ); + vm.stopPrank(); + } + + function testDepositWithValue() public { + // Unlike previous test, this one calls the spokePoolPeriphery directly rather than through the proxy + // because there is no approval required to be set on the periphery. + deal(depositor, mintAmount); + + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockWETH).toBytes32(), + address(0).toBytes32(), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.deposit{ value: mintAmount }( + depositor, // recipient + address(mockWETH), // inputToken + mintAmount, + mintAmount, + destinationChainId, + address(0), // exclusiveRelayer + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, + new bytes(0) + ); + vm.stopPrank(); + } + + function testDepositNoValueNoProxy() public { + // Cannot call deposit with no value directly. + vm.startPrank(depositor); + vm.expectRevert(SpokePoolV3Periphery.InvalidMsgValue.selector); + spokePoolPeriphery.deposit( + depositor, // recipient + address(mockWETH), // inputToken + mintAmount, + mintAmount, + destinationChainId, + address(0), // exclusiveRelayer + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, + new bytes(0) + ); + + vm.stopPrank(); + } + + /** + * Permit (2612) based flows + */ + function testPermitDepositValidWitness() public { + SpokePoolV3PeripheryInterface.DepositData memory depositData = _defaultDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + depositor + ); + + bytes32 nonce = 0; + + // Get the permit signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.PERMIT_TYPEHASH_EXTERNAL(), + depositor, + address(spokePoolPeriphery), + mintAmountWithSubmissionFee, + nonce, + block.timestamp + ) + ); + bytes32 msgHash = mockERC20.hashTypedData(structHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Get the deposit data signature. + bytes32 depositMsgHash = keccak256( + abi.encodePacked("\x19\x01", spokePoolPeriphery.domainSeparator(), hashUtils.hashDepositData(depositData)) + ); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(privateKey, depositMsgHash); + bytes memory depositDataSignature = bytes.concat(_r, _s, bytes1(_v)); + + // Should emit expected deposit event + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.depositWithPermit( + depositor, // signatureOwner + depositData, + block.timestamp, // deadline + signature, // permitSignature + depositDataSignature + ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); + } + + function testPermitSwapAndBridgeValidWitness() public { + // We need to deal the exchange some WETH in this test since we swap a permit ERC20 to WETH. + mockWETH.deposit{ value: depositAmount }(); + mockWETH.transfer(address(dex), depositAmount); + + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + depositor + ); + + bytes32 nonce = 0; + + // Get the permit signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.PERMIT_TYPEHASH_EXTERNAL(), + depositor, + address(spokePoolPeriphery), + mintAmountWithSubmissionFee, + nonce, + block.timestamp + ) + ); + bytes32 msgHash = mockERC20.hashTypedData(structHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Get the swap and deposit data signature. + bytes32 swapAndDepositMsgHash = keccak256( + abi.encodePacked( + "\x19\x01", + spokePoolPeriphery.domainSeparator(), + hashUtils.hashSwapAndDepositData(swapAndDepositData) + ) + ); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(privateKey, swapAndDepositMsgHash); + bytes memory swapAndDepositDataSignature = bytes.concat(_r, _s, bytes1(_v)); + + // Should emit expected deposit event + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockWETH).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridgeWithPermit( + depositor, // signatureOwner + swapAndDepositData, + block.timestamp, // deadline + signature, // permitSignature + swapAndDepositDataSignature + ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); + } + + function testPermitSwapAndBridgeInvalidWitness(address rando) public { + vm.assume(rando != depositor); + // We need to deal the exchange some WETH in this test since we swap a permit ERC20 to WETH. + mockWETH.deposit{ value: depositAmount }(); + mockWETH.transfer(address(dex), depositAmount); + + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + depositor + ); + + bytes32 nonce = 0; + + // Get the permit signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.PERMIT_TYPEHASH_EXTERNAL(), + depositor, + address(spokePoolPeriphery), + mintAmountWithSubmissionFee, + nonce, + block.timestamp + ) + ); + bytes32 msgHash = mockERC20.hashTypedData(structHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Get the swap and deposit data signature. + bytes32 swapAndDepositMsgHash = keccak256( + abi.encodePacked( + "\x19\x01", + spokePoolPeriphery.domainSeparator(), + hashUtils.hashSwapAndDepositData(swapAndDepositData) + ) + ); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(privateKey, swapAndDepositMsgHash); + bytes memory swapAndDepositDataSignature = bytes.concat(_r, _s, bytes1(_v)); + + // Make a swapAndDepositStruct which is different from the one the depositor signed off on. For example, make one where we set somebody else as the recipient/depositor. + SpokePoolV3PeripheryInterface.SwapAndDepositData memory invalidSwapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + rando + ); + + // Should emit expected deposit event + vm.expectRevert(SpokePoolV3Periphery.InvalidSignature.selector); + spokePoolPeriphery.swapAndBridgeWithPermit( + depositor, // signatureOwner + invalidSwapAndDepositData, + block.timestamp, // deadline + signature, // permitSignature + swapAndDepositDataSignature + ); + } + + /** + * Transfer with authorization based flows + */ + function testTransferWithAuthDepositValidWitness() public { + SpokePoolV3PeripheryInterface.DepositData memory depositData = _defaultDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + depositor + ); + + bytes32 nonce = bytes32(block.prevrandao); + + // Get the transfer with auth signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), + depositor, + address(spokePoolPeriphery), + mintAmountWithSubmissionFee, + block.timestamp, + block.timestamp, + nonce + ) + ); + bytes32 msgHash = mockERC20.hashTypedData(structHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Get the deposit data signature. + bytes32 depositMsgHash = keccak256( + abi.encodePacked("\x19\x01", spokePoolPeriphery.domainSeparator(), hashUtils.hashDepositData(depositData)) + ); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(privateKey, depositMsgHash); + bytes memory depositDataSignature = bytes.concat(_r, _s, bytes1(_v)); + + // Should emit expected deposit event + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.depositWithAuthorization( + depositor, // signatureOwner + depositData, + block.timestamp, // valid before + block.timestamp, // valid after + nonce, // nonce + signature, // receiveWithAuthSignature + depositDataSignature + ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); + } + + function testTransferWithAuthSwapAndBridgeValidWitness() public { + // We need to deal the exchange some WETH in this test since we swap a eip3009 ERC20 to WETH. + mockWETH.deposit{ value: depositAmount }(); + mockWETH.transfer(address(dex), depositAmount); + + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + depositor + ); + + bytes32 nonce = bytes32(block.prevrandao); + + // Get the transfer with auth signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), + depositor, + address(spokePoolPeriphery), + mintAmountWithSubmissionFee, + block.timestamp, + block.timestamp, + nonce + ) + ); + bytes32 msgHash = mockERC20.hashTypedData(structHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Get the swap and deposit data signature. + bytes32 swapAndDepositMsgHash = keccak256( + abi.encodePacked( + "\x19\x01", + spokePoolPeriphery.domainSeparator(), + hashUtils.hashSwapAndDepositData(swapAndDepositData) + ) + ); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(privateKey, swapAndDepositMsgHash); + bytes memory swapAndDepositDataSignature = bytes.concat(_r, _s, bytes1(_v)); + + // Should emit expected deposit event + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockWETH).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridgeWithAuthorization( + depositor, // signatureOwner + swapAndDepositData, + block.timestamp, // validAfter + block.timestamp, // validBefore + nonce, // nonce + signature, // receiveWithAuthSignature + swapAndDepositDataSignature + ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); + } + + function testTransferWithAuthSwapAndBridgeInvalidWitness(address rando) public { + vm.assume(rando != depositor); + // We need to deal the exchange some WETH in this test since we swap a eip3009 ERC20 to WETH. + mockWETH.deposit{ value: depositAmount }(); + mockWETH.transfer(address(dex), depositAmount); + + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + depositor + ); + + bytes32 nonce = bytes32(block.prevrandao); + + // Get the transfer with auth signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), + depositor, + address(spokePoolPeriphery), + mintAmountWithSubmissionFee, + block.timestamp, + block.timestamp, + nonce + ) + ); + bytes32 msgHash = mockERC20.hashTypedData(structHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Get the swap and deposit data signature. + bytes32 swapAndDepositMsgHash = keccak256( + abi.encodePacked( + "\x19\x01", + spokePoolPeriphery.domainSeparator(), + hashUtils.hashSwapAndDepositData(swapAndDepositData) + ) + ); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(privateKey, swapAndDepositMsgHash); + bytes memory swapAndDepositDataSignature = bytes.concat(_r, _s, bytes1(_v)); + + // Make a swapAndDepositStruct which is different from the one the depositor signed off on. For example, make one where we set somebody else as the recipient/depositor. + SpokePoolV3PeripheryInterface.SwapAndDepositData memory invalidSwapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + rando + ); + + // Should emit expected deposit event + vm.expectRevert(SpokePoolV3Periphery.InvalidSignature.selector); + spokePoolPeriphery.swapAndBridgeWithAuthorization( + depositor, // signatureOwner + invalidSwapAndDepositData, + block.timestamp, // validAfter + block.timestamp, // validBefore + nonce, // nonce + signature, // receiveWithAuthSignature + swapAndDepositDataSignature + ); + } + + /** + * Permit2 based flows + */ + function testPermit2DepositValidWitness() public { + SpokePoolV3PeripheryInterface.DepositData memory depositData = _defaultDepositData( + address(mockWETH), + mintAmount, + submissionFeeAmount, + relayer, + depositor + ); + // Signature transfer details + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmountWithSubmissionFee }), + nonce: 1, + deadline: block.timestamp + 100 + }); + + bytes32 typehash = keccak256( + abi.encodePacked(PERMIT_TRANSFER_TYPE_STUB, PeripherySigningLib.EIP712_DEPOSIT_TYPE_STRING) + ); + bytes32 tokenPermissions = keccak256(abi.encode(TOKEN_PERMISSIONS_TYPEHASH, permit.permitted)); + + // Get the permit2 signature. + bytes32 msgHash = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparator, + keccak256( + abi.encode( + typehash, + tokenPermissions, + address(spokePoolPeriphery), + permit.nonce, + permit.deadline, + hashUtils.hashDepositData(depositData) + ) + ) + ) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Should emit expected deposit event + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockWETH).toBytes32(), + address(0).toBytes32(), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.depositWithPermit2( + depositor, // signatureOwner + depositData, + permit, // permit + signature // permit2 signature + ); + + // Check that fee recipient receives expected amount + assertEq(mockWETH.balanceOf(relayer), submissionFeeAmount); + } + + function testPermit2SwapAndBridgeValidWitness() public { + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Transfer, + address(mockERC20), + depositAmount, + depositor + ); + + // Signature transfer details + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmountWithSubmissionFee }), + nonce: 1, + deadline: block.timestamp + 100 + }); + + bytes32 typehash = keccak256( + abi.encodePacked(PERMIT_TRANSFER_TYPE_STUB, PeripherySigningLib.EIP712_SWAP_AND_DEPOSIT_TYPE_STRING) + ); + bytes32 tokenPermissions = keccak256(abi.encode(TOKEN_PERMISSIONS_TYPEHASH, permit.permitted)); + + // Get the permit2 signature. + bytes32 msgHash = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparator, + keccak256( + abi.encode( + typehash, + tokenPermissions, + address(spokePoolPeriphery), + permit.nonce, + permit.deadline, + hashUtils.hashSwapAndDepositData(swapAndDepositData) + ) + ) + ) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Should emit expected deposit event + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.FundsDeposited( + address(mockERC20).toBytes32(), + address(0).toBytes32(), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor.toBytes32(), + depositor.toBytes32(), + address(0).toBytes32(), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridgeWithPermit2( + depositor, // signatureOwner + swapAndDepositData, + permit, + signature + ); + + // Check that fee recipient receives expected amount + assertEq(mockWETH.balanceOf(relayer), submissionFeeAmount); + } + + function testPermit2SwapAndBridgeInvalidWitness(address rando) public { + vm.assume(rando != depositor); + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Transfer, + address(mockERC20), + depositAmount, + depositor + ); + + // Signature transfer details + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmountWithSubmissionFee }), + nonce: 1, + deadline: block.timestamp + 100 + }); + + bytes32 typehash = keccak256( + abi.encodePacked(PERMIT_TRANSFER_TYPE_STUB, PeripherySigningLib.EIP712_SWAP_AND_DEPOSIT_TYPE_STRING) + ); + bytes32 tokenPermissions = keccak256(abi.encode(TOKEN_PERMISSIONS_TYPEHASH, permit.permitted)); + + // Get the permit2 signature. + bytes32 msgHash = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparator, + keccak256( + abi.encode( + typehash, + tokenPermissions, + address(spokePoolPeriphery), + permit.nonce, + permit.deadline, + hashUtils.hashSwapAndDepositData(swapAndDepositData) + ) + ) + ) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash); + bytes memory signature = bytes.concat(r, s, bytes1(v)); + + // Make a swapAndDepositStruct which is different from the one the depositor signed off on. For example, make one where we set somebody else as the recipient/depositor. + SpokePoolV3PeripheryInterface.SwapAndDepositData memory invalidSwapAndDepositData = _defaultSwapAndDepositData( + address(mockERC20), + mintAmount, + submissionFeeAmount, + relayer, + dex, + SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, + address(mockWETH), + depositAmount, + rando + ); + + // Should emit expected deposit event + vm.expectRevert(SignatureVerification.InvalidSigner.selector); + spokePoolPeriphery.swapAndBridgeWithPermit2( + depositor, // signatureOwner + invalidSwapAndDepositData, + permit, + signature + ); + } + + /** + * Helper functions + */ + function _defaultDepositData( + address _token, + uint256 _amount, + uint256 _feeAmount, + address _feeRecipient, + address _depositor + ) internal view returns (SpokePoolV3Periphery.DepositData memory) { + return + SpokePoolV3PeripheryInterface.DepositData({ + submissionFees: SpokePoolV3PeripheryInterface.Fees({ amount: _feeAmount, recipient: _feeRecipient }), + baseDepositData: SpokePoolV3PeripheryInterface.BaseDepositData({ + inputToken: _token, + outputToken: address(0), + outputAmount: _amount, + depositor: _depositor, + recipient: _depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }), + inputAmount: _amount + }); + } + + function _defaultSwapAndDepositData( + address _swapToken, + uint256 _swapAmount, + uint256 _feeAmount, + address _feeRecipient, + Exchange _exchange, + SpokePoolV3PeripheryInterface.TransferType _transferType, + address _inputToken, + uint256 _amount, + address _depositor + ) internal view returns (SpokePoolV3Periphery.SwapAndDepositData memory) { + bool usePermit2 = _transferType == SpokePoolV3PeripheryInterface.TransferType.Permit2Approval; + return + SpokePoolV3PeripheryInterface.SwapAndDepositData({ + submissionFees: SpokePoolV3PeripheryInterface.Fees({ amount: _feeAmount, recipient: _feeRecipient }), + depositData: SpokePoolV3PeripheryInterface.BaseDepositData({ + inputToken: _inputToken, + outputToken: address(0), + outputAmount: _amount, + depositor: _depositor, + recipient: _depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }), + swapToken: _swapToken, + exchange: address(_exchange), + transferType: _transferType, + swapTokenAmount: _swapAmount, // swapTokenAmount + minExpectedInputTokenAmount: _amount, + routerCalldata: abi.encodeWithSelector( + _exchange.swap.selector, + IERC20(_swapToken), + IERC20(_inputToken), + _swapAmount, + _amount, + usePermit2 + ) + }); + } +} diff --git a/test/evm/foundry/local/Universal_Adapter.t.sol b/test/evm/foundry/local/Universal_Adapter.t.sol index a16415c1f..650ab1716 100644 --- a/test/evm/foundry/local/Universal_Adapter.t.sol +++ b/test/evm/foundry/local/Universal_Adapter.t.sol @@ -8,19 +8,28 @@ import { Universal_Adapter, HubPoolStore } from "../../../../contracts/chain-ada import { MockHubPool } from "../../../../contracts/test/MockHubPool.sol"; import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; import "../../../../contracts/test/MockCCTP.sol"; +import { AdapterStore, MessengerTypes } from "../../../../contracts/AdapterStore.sol"; +import { IOFT, SendParam, MessagingFee } from "../../../../contracts/interfaces/IOFT.sol"; +import { MockOFTMessenger } from "../../../../contracts/test/MockOFTMessenger.sol"; +import { AddressToBytes32 } from "../../../../contracts/libraries/AddressConverters.sol"; contract UniversalAdapterTest is Test { + using AddressToBytes32 for address; + Universal_Adapter adapter; HubPoolStore store; MockHubPool hubPool; address spokePoolTarget; uint256 relayRootBundleNonce = 0; address relayRootBundleTargetAddress = address(0); - address adapterStore = address(0); + AdapterStore adapterStore; + IOFT oftMessenger; ERC20 usdc; + ERC20 usdt; uint256 usdcMintAmount = 100e6; MockCCTPMessenger cctpMessenger; uint32 cctpDestinationDomainId = 7; + uint256 oftDstEid = 42161; address owner = vm.addr(7); @@ -40,18 +49,26 @@ contract UniversalAdapterTest is Test { function setUp() public { spokePoolTarget = vm.addr(1); vm.startPrank(owner); + adapterStore = new AdapterStore(); + hubPool = new MockHubPool(address(0)); // Initialize adapter to address 0 and we'll overwrite // it after we use this hub pool to initialize the hub pool store which is used to initialize // the adapter. store = new HubPoolStore(address(hubPool)); usdc = new ERC20("USDC", "USDC"); + usdt = new ERC20("USDT", "USDT"); + oftMessenger = IOFT(new MockOFTMessenger(address(usdt))); + adapterStore.setMessenger(MessengerTypes.OFT_MESSENGER, oftDstEid, address(usdt), address(oftMessenger)); MockCCTPMinter minter = new MockCCTPMinter(); cctpMessenger = new MockCCTPMessenger(ITokenMinter(minter)); adapter = new Universal_Adapter( store, IERC20(address(usdc)), ITokenMessenger(address(cctpMessenger)), - cctpDestinationDomainId + cctpDestinationDomainId, + address(adapterStore), + uint32(oftDstEid), + 1e18 ); hubPool.changeAdapter(address(adapter)); hubPool.setPendingRootBundle(pendingRootBundle); @@ -255,6 +272,30 @@ contract UniversalAdapterTest is Test { hubPool.relayTokens(address(usdc), makeAddr("l2Usdc"), usdcMintAmount, spokePoolTarget); } + function testRelayTokens_oft() public { + // Uses OFT to send USDT + vm.expectCall( + address(oftMessenger), + abi.encodeCall( + oftMessenger.send, + ( + SendParam({ + dstEid: uint32(oftDstEid), + to: spokePoolTarget.toBytes32(), + amountLD: usdcMintAmount, + minAmountLD: usdcMintAmount, + extraOptions: bytes(""), + composeMsg: bytes(""), + oftCmd: bytes("") + }), + MessagingFee({ nativeFee: 0, lzTokenFee: 0 }), + address(hubPool) + ) + ) + ); + hubPool.relayTokens(address(usdt), makeAddr("l2Usdt"), usdcMintAmount, spokePoolTarget); + } + function testRelayTokens_default() public { vm.expectRevert(); hubPool.relayTokens(makeAddr("erc20"), makeAddr("l2Erc20"), usdcMintAmount, spokePoolTarget); diff --git a/test/evm/foundry/local/Universal_SpokePool.t.sol b/test/evm/foundry/local/Universal_SpokePool.t.sol index 464f13c4f..f0d8b900e 100644 --- a/test/evm/foundry/local/Universal_SpokePool.t.sol +++ b/test/evm/foundry/local/Universal_SpokePool.t.sol @@ -8,6 +8,9 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { Universal_SpokePool, IHelios } from "../../../../contracts/Universal_SpokePool.sol"; import "../../../../contracts/libraries/CircleCCTPAdapter.sol"; import "../../../../contracts/test/MockCCTP.sol"; +import { IOFT, SendParam, MessagingFee } from "../../../../contracts/interfaces/IOFT.sol"; +import { MockOFTMessenger } from "../../../../contracts/test/MockOFTMessenger.sol"; +import { AddressToBytes32 } from "../../../../contracts/libraries/AddressConverters.sol"; contract MockHelios is IHelios { mapping(bytes32 => bytes32) public storageSlots; @@ -40,7 +43,9 @@ contract MockUniversalSpokePool is Universal_SpokePool { uint32 _depositQuoteTimeBuffer, uint32 _fillDeadlineBuffer, IERC20 _l2Usdc, - ITokenMessenger _cctpTokenMessenger + ITokenMessenger _cctpTokenMessenger, + uint32 _oftDstId, + uint256 _oftFeeCap ) Universal_SpokePool( _adminUpdateBuffer, @@ -50,7 +55,9 @@ contract MockUniversalSpokePool is Universal_SpokePool { _depositQuoteTimeBuffer, _fillDeadlineBuffer, _l2Usdc, - _cctpTokenMessenger + _cctpTokenMessenger, + _oftDstId, + _oftFeeCap ) {} @@ -60,8 +67,10 @@ contract MockUniversalSpokePool is Universal_SpokePool { } contract UniversalSpokePoolTest is Test { + using AddressToBytes32 for address; MockUniversalSpokePool spokePool; MockHelios helios; + IOFT oftMessenger; address hubPoolStore; address hubPool; @@ -71,12 +80,15 @@ contract UniversalSpokePoolTest is Test { uint256 adminUpdateBuffer = 1 days; ERC20 usdc; + ERC20 usdt; uint256 usdcMintAmount = 100e6; MockCCTPMessenger cctpMessenger; + uint256 oftDstEid = 1; function setUp() public { helios = new MockHelios(); usdc = new ERC20("USDC", "USDC"); + usdt = new ERC20("USDT", "USDT"); MockCCTPMinter minter = new MockCCTPMinter(); cctpMessenger = new MockCCTPMessenger(ITokenMinter(minter)); hubPool = makeAddr("hubPool"); @@ -90,13 +102,16 @@ contract UniversalSpokePoolTest is Test { 7200, 7200, IERC20(address(usdc)), - ITokenMessenger(address(cctpMessenger)) + ITokenMessenger(address(cctpMessenger)), + uint32(oftDstEid), + 1e18 ); vm.prank(owner); address proxy = address( new ERC1967Proxy(address(spokePool), abi.encodeCall(Universal_SpokePool.initialize, (0, hubPool, hubPool))) ); spokePool = MockUniversalSpokePool(payable(proxy)); + oftMessenger = IOFT(new MockOFTMessenger(address(usdt))); deal(address(usdc), address(spokePool), usdcMintAmount, true); } @@ -277,4 +292,48 @@ contract UniversalSpokePoolTest is Test { spokePool.setCrossDomainAdmin(makeAddr("randomAdmin")); vm.stopPrank(); } + + function testSetOftMessenger() public { + bytes memory message = abi.encodeWithSignature( + "setOftMessenger(address,address)", + address(usdt), + address(oftMessenger) + ); + bytes memory value = abi.encode(address(spokePool), message); + helios.updateStorageSlot(spokePool.getSlotKey(nonce), keccak256(value)); + spokePool.executeMessage(nonce, value, 100); + assertEq(spokePool.oftMessengers(address(usdt)), address(oftMessenger)); + } + + function testBridgeTokensToHubPool_oft() public { + bytes memory message = abi.encodeWithSignature( + "setOftMessenger(address,address)", + address(usdt), + address(oftMessenger) + ); + bytes memory value = abi.encode(address(spokePool), message); + helios.updateStorageSlot(spokePool.getSlotKey(nonce), keccak256(value)); + spokePool.executeMessage(nonce, value, 100); + + vm.expectCall( + address(oftMessenger), + abi.encodeCall( + oftMessenger.send, + ( + SendParam({ + dstEid: uint32(oftDstEid), + to: hubPool.toBytes32(), + amountLD: usdcMintAmount, + minAmountLD: usdcMintAmount, + extraOptions: bytes(""), + composeMsg: bytes(""), + oftCmd: bytes("") + }), + MessagingFee({ nativeFee: 0, lzTokenFee: 0 }), + address(spokePool) + ) + ) + ); + spokePool.test_bridgeTokensToHubPool(usdcMintAmount, address(usdt)); + } } diff --git a/test/evm/hardhat/MerkleLib.utils.ts b/test/evm/hardhat/MerkleLib.utils.ts index ff2b605f9..8ab1a9bbb 100644 --- a/test/evm/hardhat/MerkleLib.utils.ts +++ b/test/evm/hardhat/MerkleLib.utils.ts @@ -106,10 +106,16 @@ export function buildPoolRebalanceLeaves( }); } -export async function constructSingleRelayerRefundTree(l2Token: Contract | String, destinationChainId: number) { +export async function constructSingleRelayerRefundTree( + l2Token: Contract | String, + destinationChainId: number, + amount?: BigNumber +) { + const amountToUse = amount !== undefined ? amount : amountToReturn; + const leaves = buildRelayerRefundLeaves( [destinationChainId], // Destination chain ID. - [amountToReturn], // amountToReturn. + [amountToUse], // Use the explicitly determined amount [l2Token as string], // l2Token. [[]], // refundAddresses. [[]] // refundAmounts. diff --git a/test/evm/hardhat/chain-adapters/Arbitrum_Adapter.ts b/test/evm/hardhat/chain-adapters/Arbitrum_Adapter.ts index e30b40620..1a1c899c8 100644 --- a/test/evm/hardhat/chain-adapters/Arbitrum_Adapter.ts +++ b/test/evm/hardhat/chain-adapters/Arbitrum_Adapter.ts @@ -14,38 +14,58 @@ import { seedWallet, randomAddress, createFakeFromABI, + createTypedFakeFromABI, + BigNumber, + randomBytes32, + toWeiWithDecimals, + getOftEid, } from "../../../../utils/utils"; import { CCTPTokenMessengerInterface, CCTPTokenMinterInterface } from "../../../../utils/abis"; +import { + IOFT, + MessagingFeeStructOutput, + MessagingReceiptStructOutput, + OFTReceiptStructOutput, + SendParamStruct, +} from "../../../../typechain/contracts/interfaces/IOFT"; +import { IOFT__factory } from "../../../../typechain/factories/contracts/interfaces/IOFT__factory"; import { hubPoolFixture, enableTokensForLP } from "../fixtures/HubPool.Fixture"; import { constructSingleChainTree } from "../MerkleLib.utils"; import { CIRCLE_DOMAIN_IDs } from "../../../../deploy/consts"; +import { AdapterStore, AdapterStore__factory } from "../../../../typechain"; +import { CHAIN_IDs } from "@across-protocol/constants"; let hubPool: Contract, arbitrumAdapter: Contract, weth: Contract, dai: Contract, usdc: Contract, + usdt: Contract, + ezETH: Contract, timer: Contract, mockSpoke: Contract; -let l2Weth: string, l2Dai: string, gatewayAddress: string, l2Usdc: string; +let l2Weth: string, l2Dai: string, gatewayAddress: string, l2Usdc: string, l2Usdt: string; let owner: SignerWithAddress, dataWorker: SignerWithAddress; let liquidityProvider: SignerWithAddress, refundAddress: SignerWithAddress; let l1ERC20GatewayRouter: FakeContract, l1Inbox: FakeContract, cctpMessenger: FakeContract, - cctpTokenMinter: FakeContract; - -const arbitrumChainId = 42161; + cctpTokenMinter: FakeContract, + oftMessenger: FakeContract, + adapterStore: FakeContract; +const arbitrumChainId = CHAIN_IDs.ARBITRUM; +const oftArbitrumEid = getOftEid(arbitrumChainId); describe("Arbitrum Chain Adapter", function () { beforeEach(async function () { [owner, dataWorker, liquidityProvider, refundAddress] = await ethers.getSigners(); - ({ weth, dai, l2Weth, l2Dai, hubPool, mockSpoke, timer, usdc, l2Usdc } = await hubPoolFixture()); - await seedWallet(dataWorker, [dai, usdc], weth, consts.amountToLp); - await seedWallet(liquidityProvider, [dai, usdc], weth, consts.amountToLp.mul(10)); + ({ weth, dai, l2Weth, l2Dai, hubPool, mockSpoke, timer, usdc, l2Usdc, usdt, l2Usdt } = await hubPoolFixture()); - await enableTokensForLP(owner, hubPool, weth, [weth, dai, usdc]); - for (const token of [weth, dai, usdc]) { + await seedWallet(dataWorker, [dai, usdc, usdt], weth, consts.amountToLp); + await seedWallet(liquidityProvider, [dai, usdc, usdt], weth, consts.amountToLp.mul(10)); + + await enableTokensForLP(owner, hubPool, weth, [weth, dai, usdc, usdt]); + for (const token of [weth, dai, usdc, usdt]) { await token.connect(liquidityProvider).approve(hubPool.address, consts.amountToLp); await hubPool.connect(liquidityProvider).addLiquidity(token.address, consts.amountToLp); await token.connect(dataWorker).approve(hubPool.address, consts.bondAmount.mul(10)); @@ -55,14 +75,29 @@ describe("Arbitrum Chain Adapter", function () { cctpTokenMinter = await createFakeFromABI(CCTPTokenMinterInterface); cctpMessenger.localMinter.returns(cctpTokenMinter.address); cctpTokenMinter.burnLimitsPerMessage.returns(toWei("1000000")); + + oftMessenger = await createTypedFakeFromABI([...IOFT__factory.abi]); + adapterStore = await createTypedFakeFromABI([...AdapterStore__factory.abi]); + l1Inbox = await createFake("Inbox"); l1ERC20GatewayRouter = await createFake("ArbitrumMockErc20GatewayRouter"); gatewayAddress = randomAddress(); l1ERC20GatewayRouter.getGateway.returns(gatewayAddress); + const oftFeeCap = toWei("1"); + arbitrumAdapter = await ( await getContractFactory("Arbitrum_Adapter", owner) - ).deploy(l1Inbox.address, l1ERC20GatewayRouter.address, refundAddress.address, usdc.address, cctpMessenger.address); + ).deploy( + l1Inbox.address, + l1ERC20GatewayRouter.address, + refundAddress.address, + usdc.address, + cctpMessenger.address, + adapterStore.address, + oftArbitrumEid, + oftFeeCap + ); // Seed the HubPool some funds so it can send L1->L2 messages. await hubPool.connect(liquidityProvider).loadEthForL2Calls({ value: toWei("1") }); @@ -72,6 +107,7 @@ describe("Arbitrum Chain Adapter", function () { await hubPool.setPoolRebalanceRoute(arbitrumChainId, dai.address, l2Dai); await hubPool.setPoolRebalanceRoute(arbitrumChainId, weth.address, l2Weth); await hubPool.setPoolRebalanceRoute(arbitrumChainId, usdc.address, l2Usdc); + await hubPool.setPoolRebalanceRoute(arbitrumChainId, usdt.address, l2Usdt); }); it("relayMessage calls spoke pool functions", async function () { @@ -225,4 +261,62 @@ describe("Arbitrum Chain Adapter", function () { usdc.address ); }); + it("Correctly calls the OFT bridge adapter when attempting to bridge USDT", async function () { + const internalChainId = arbitrumChainId; + + oftMessenger.token.returns(usdt.address); + + const oftMessengerType = ethers.utils.formatBytes32String("OFT_MESSENGER"); + await adapterStore + .connect(owner) + .setMessenger(oftMessengerType, oftArbitrumEid, usdt.address, oftMessenger.address); + + const { leaves, tree, tokensSendToL2 } = await constructSingleChainTree(usdt.address, 1, internalChainId, 6); + await hubPool + .connect(dataWorker) + .proposeRootBundle([3117], 1, tree.getHexRoot(), consts.mockRelayerRefundRoot, consts.mockSlowRelayRoot); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + consts.refundProposalLiveness + 1); + + // set up correct messenger to be returned on a proper `oftMessengers` call + adapterStore.crossChainMessengers + .whenCalledWith(oftMessengerType, oftArbitrumEid, usdt.address) + .returns(oftMessenger.address); + + // set up `quoteSend` return val + const msgFeeStruct: MessagingFeeStructOutput = [ + toWeiWithDecimals("1", 9).mul(200_000), // nativeFee: 1 GWEI gas price * 200,000 gas cost + BigNumber.from(0), // lzTokenFee: 0 + ] as MessagingFeeStructOutput; + oftMessenger.quoteSend.returns(msgFeeStruct); + + // set up `send` return val + const msgReceipt: MessagingReceiptStructOutput = [ + randomBytes32(), // guid + BigNumber.from("1"), // nonce + msgFeeStruct, // fee + ] as MessagingReceiptStructOutput; + + const oftReceipt: OFTReceiptStructOutput = [tokensSendToL2, tokensSendToL2] as OFTReceiptStructOutput; + + oftMessenger.send.returns([msgReceipt, oftReceipt]); + + await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])); + + // Adapter should have approved gateway to spend its ERC20. + expect(await usdt.allowance(hubPool.address, oftMessenger.address)).to.equal(tokensSendToL2); + + const sendParam: SendParamStruct = { + dstEid: oftArbitrumEid, + to: ethers.utils.hexZeroPad(mockSpoke.address, 32).toLowerCase(), + amountLD: tokensSendToL2, + minAmountLD: tokensSendToL2, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + + // We should have called send on the oftMessenger once with correct params + expect(oftMessenger.send).to.have.been.calledOnce; + expect(oftMessenger.send).to.have.been.calledWith(sendParam, msgFeeStruct, hubPool.address); + }); }); diff --git a/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts b/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts index e30fbf584..b06661cfa 100644 --- a/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts +++ b/test/evm/hardhat/chain-specific-spokepools/Arbitrum_SpokePool.ts @@ -11,23 +11,40 @@ import { seedContract, avmL1ToL2Alias, createFakeFromABI, - addressToBytes, + createTypedFakeFromABI, + BigNumber, + randomBytes32, + toWeiWithDecimals, + getOftEid, } from "../../../../utils/utils"; import { hre } from "../../../../utils/utils.hre"; import { hubPoolFixture } from "../fixtures/HubPool.Fixture"; import { constructSingleRelayerRefundTree } from "../MerkleLib.utils"; import { CCTPTokenMessengerInterface, CCTPTokenMinterInterface } from "../../../../utils/abis"; - -let hubPool: Contract, arbitrumSpokePool: Contract, dai: Contract, weth: Contract; +import { + MessagingFeeStructOutput, + MessagingReceiptStructOutput, + OFTReceiptStructOutput, + SendParamStruct, +} from "../../../../typechain/contracts/interfaces/IOFT"; +import { IOFT__factory } from "../../../../typechain/factories/contracts/interfaces/IOFT__factory"; +import { CHAIN_IDs } from "@across-protocol/constants"; + +let hubPool: Contract, arbitrumSpokePool: Contract, dai: Contract, weth: Contract, l2UsdtContract: Contract; let l2Weth: string, l2Dai: string, l2Usdc: string, crossDomainAliasAddress; let owner: SignerWithAddress, relayer: SignerWithAddress, rando: SignerWithAddress, crossDomainAlias: SignerWithAddress; -let l2GatewayRouter: FakeContract, l2CctpTokenMessenger: FakeContract, cctpTokenMinter: FakeContract; +let l2GatewayRouter: FakeContract, + l2CctpTokenMessenger: FakeContract, + cctpTokenMinter: FakeContract, + l2OftMessenger: FakeContract; + +const oftHubEid = getOftEid(CHAIN_IDs.MAINNET); describe("Arbitrum Spoke Pool", function () { beforeEach(async function () { [owner, relayer, rando] = await ethers.getSigners(); - ({ weth, l2Weth, dai, l2Dai, hubPool, l2Usdc } = await hubPoolFixture()); + ({ weth, l2Weth, dai, l2Dai, hubPool, l2Usdc, l2UsdtContract } = await hubPoolFixture()); // Create an alias for the Owner. Impersonate the account. Crate a signer for it and send it ETH. crossDomainAliasAddress = avmL1ToL2Alias(owner.address); @@ -40,6 +57,7 @@ describe("Arbitrum Spoke Pool", function () { cctpTokenMinter = await createFakeFromABI(CCTPTokenMinterInterface); l2CctpTokenMessenger.localMinter.returns(cctpTokenMinter.address); cctpTokenMinter.burnLimitsPerMessage.returns(toWei("1000000")); + l2OftMessenger = await createTypedFakeFromABI([...IOFT__factory.abi]); arbitrumSpokePool = await hre.upgrades.deployProxy( await getContractFactory("Arbitrum_SpokePool", owner), @@ -47,7 +65,7 @@ describe("Arbitrum Spoke Pool", function () { { kind: "uups", unsafeAllow: ["delegatecall"], - constructorArgs: [l2Weth, 60 * 60, 9 * 60 * 60, l2Usdc, l2CctpTokenMessenger.address], + constructorArgs: [l2Weth, 60 * 60, 9 * 60 * 60, l2Usdc, l2CctpTokenMessenger.address, oftHubEid, toWei("1")], } ); @@ -62,7 +80,7 @@ describe("Arbitrum Spoke Pool", function () { { kind: "uups", unsafeAllow: ["delegatecall"], - constructorArgs: [l2Weth, 60 * 60, 9 * 60 * 60, l2Usdc, l2CctpTokenMessenger.address], + constructorArgs: [l2Weth, 60 * 60, 9 * 60 * 60, l2Usdc, l2CctpTokenMessenger.address, oftHubEid, toWei("1")], } ); @@ -132,4 +150,62 @@ describe("Arbitrum Spoke Pool", function () { expect(l2GatewayRouter[functionKey]).to.have.been.calledOnce; expect(l2GatewayRouter[functionKey]).to.have.been.calledWith(dai.address, hubPool.address, amountToReturn, "0x"); }); + + it("Bridge tokens to hub pool correctly using the OFT messaging for L2 USDT token", async function () { + l2OftMessenger.token.returns(l2UsdtContract.address); + await arbitrumSpokePool.connect(crossDomainAlias).setOftMessenger(l2UsdtContract.address, l2OftMessenger.address); + + l2OftMessenger.token.returns(l2UsdtContract.address); + await arbitrumSpokePool.connect(crossDomainAlias).setOftMessenger(l2UsdtContract.address, l2OftMessenger.address); + + const l2UsdtSendAmount = BigNumber.from("1234567"); + const { leaves, tree } = await constructSingleRelayerRefundTree( + l2UsdtContract.address, + await arbitrumSpokePool.callStatic.chainId(), + l2UsdtSendAmount + ); + await arbitrumSpokePool.connect(crossDomainAlias).relayRootBundle(tree.getHexRoot(), mockTreeRoot); + + const oftNativeFee = toWeiWithDecimals("1", 9).mul(200_000); // 1 GWEI gas price * 200,000 gas cost + + // set up `quoteSend` return val + const msgFeeStruct: MessagingFeeStructOutput = [ + oftNativeFee, // nativeFee + BigNumber.from(0), // lzTokenFee + ] as MessagingFeeStructOutput; + l2OftMessenger.quoteSend.returns(msgFeeStruct); + + // set up `send` return val + const msgReceipt: MessagingReceiptStructOutput = [ + randomBytes32(), // guid + BigNumber.from("1"), // nonce + msgFeeStruct, // fee + ] as MessagingReceiptStructOutput; + + const oftReceipt: OFTReceiptStructOutput = [l2UsdtSendAmount, l2UsdtSendAmount] as OFTReceiptStructOutput; + + l2OftMessenger.send.returns([msgReceipt, oftReceipt]); + + await arbitrumSpokePool + .connect(relayer) + .executeRelayerRefundLeaf(0, leaves[0], tree.getHexProof(leaves[0]), { value: oftNativeFee }); + // Adapter should have approved gateway to spend its ERC20. + expect(await l2UsdtContract.allowance(arbitrumSpokePool.address, l2OftMessenger.address)).to.equal( + l2UsdtSendAmount + ); + + const sendParam: SendParamStruct = { + dstEid: oftHubEid, + to: ethers.utils.hexZeroPad(hubPool.address, 32).toLowerCase(), + amountLD: l2UsdtSendAmount, + minAmountLD: l2UsdtSendAmount, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + + // We should have called send on the l2OftMessenger once with correct params + expect(l2OftMessenger.send).to.have.been.calledOnce; + expect(l2OftMessenger.send).to.have.been.calledWith(sendParam, msgFeeStruct, arbitrumSpokePool.address); + }); }); diff --git a/test/evm/hardhat/constants.ts b/test/evm/hardhat/constants.ts index 0553201f5..9dcabb548 100644 --- a/test/evm/hardhat/constants.ts +++ b/test/evm/hardhat/constants.ts @@ -50,6 +50,8 @@ export const finalFee = toWei("1"); export const finalFeeUsdc = ethers.utils.parseUnits("1", 6); +export const finalFeeUsdt = ethers.utils.parseUnits("1", 6); + export const totalBond = bondAmount.add(finalFee); export const refundProposalLiveness = 7200; diff --git a/test/evm/hardhat/fixtures/HubPool.Fixture.ts b/test/evm/hardhat/fixtures/HubPool.Fixture.ts index 9fee69b90..ebfa39c5c 100644 --- a/test/evm/hardhat/fixtures/HubPool.Fixture.ts +++ b/test/evm/hardhat/fixtures/HubPool.Fixture.ts @@ -1,6 +1,6 @@ import { getContractFactory, randomAddress, Contract, Signer } from "../../../../utils/utils"; import { hre } from "../../../../utils/utils.hre"; -import { originChainId, bondAmount, refundProposalLiveness, finalFee } from "../constants"; +import { originChainId, bondAmount, refundProposalLiveness, finalFee, finalFeeUsdt } from "../constants"; import { repaymentChainId, finalFeeUsdc, TokenRolesEnum } from "../constants"; import { umaEcosystemFixture } from "./UmaEcosystem.Fixture"; @@ -18,23 +18,28 @@ export async function deployHubPool(ethers: any, spokePoolName = "MockSpokePool" // deployments that follows. The output is spread when returning contract instances from this fixture. const parentFixture = await umaEcosystemFixture(); - // Create 3 tokens: WETH for wrapping unwrapping and 2 ERC20s with different decimals. + // Create 4 tokens: WETH for wrapping unwrapping and 3 ERC20s with different decimals. const weth = await (await getContractFactory("WETH9", signer)).deploy(); const usdc = await (await getContractFactory("ExpandedERC20", signer)).deploy("USD Coin", "USDC", 6); await usdc.addMember(TokenRolesEnum.MINTER, signer.address); const dai = await (await getContractFactory("ExpandedERC20", signer)).deploy("DAI Stablecoin", "DAI", 18); await dai.addMember(TokenRolesEnum.MINTER, signer.address); - const tokens = { weth, usdc, dai }; + // todo: `usdt` is not strictly `ExpandedERC20`. Does that matter for our tests? + const usdt = await (await getContractFactory("ExpandedERC20", signer)).deploy("USDT Stablecoin", "USDT", 6); + await usdt.addMember(TokenRolesEnum.MINTER, signer.address); + const tokens = { weth, usdc, dai, usdt }; // Set the above currencies as approved in the UMA collateralWhitelist. await parentFixture.collateralWhitelist.addToWhitelist(weth.address); await parentFixture.collateralWhitelist.addToWhitelist(usdc.address); await parentFixture.collateralWhitelist.addToWhitelist(dai.address); + await parentFixture.collateralWhitelist.addToWhitelist(usdt.address); // Set the finalFee for all the new tokens. await parentFixture.store.setFinalFee(weth.address, { rawValue: finalFee }); await parentFixture.store.setFinalFee(usdc.address, { rawValue: finalFeeUsdc }); await parentFixture.store.setFinalFee(dai.address, { rawValue: finalFee }); + await parentFixture.store.setFinalFee(usdt.address, { rawValue: finalFeeUsdt }); // Deploy the hubPool. const lpTokenFactory = await (await getContractFactory("LpTokenFactory", signer)).deploy(); @@ -64,15 +69,34 @@ export async function deployHubPool(ethers: any, spokePoolName = "MockSpokePool" ); await hubPool.setCrossChainContracts(mainnetChainId, mockAdapterMainnet.address, mockSpokeMainnet.address); + // we need `l2Usdt` to be a real contract, rather than just a random address for testing OFT bridging, that's why we create is separately here compared to other l2 tokens + const l2UsdtContract = await (await getContractFactory("ExpandedERC20", signer)).deploy("USDT Stablecoin", "USDT", 6); + await l2UsdtContract.addMember(TokenRolesEnum.MINTER, signer.address); + // Deploy mock l2 tokens for each token created before and whitelist the routes. - const mockTokens = { l2Weth: randomAddress(), l2Dai: randomAddress(), l2Usdc: randomAddress() }; + const mockTokens = { + l2Weth: randomAddress(), + l2Dai: randomAddress(), + l2Usdc: randomAddress(), + l2Usdt: l2UsdtContract.address, + }; // Whitelist pool rebalance routes but don't relay any messages to SpokePool await hubPool.setPoolRebalanceRoute(repaymentChainId, weth.address, mockTokens.l2Weth); await hubPool.setPoolRebalanceRoute(repaymentChainId, dai.address, mockTokens.l2Dai); await hubPool.setPoolRebalanceRoute(repaymentChainId, usdc.address, mockTokens.l2Usdc); + await hubPool.setPoolRebalanceRoute(repaymentChainId, usdt.address, mockTokens.l2Usdt); - return { ...tokens, ...mockTokens, hubPool, mockAdapter, mockSpoke, crossChainAdmin, ...parentFixture }; + return { + ...tokens, + l2UsdtContract, + ...mockTokens, + hubPool, + mockAdapter, + mockSpoke, + crossChainAdmin, + ...parentFixture, + }; } export async function enableTokensForLP(owner: Signer, hubPool: Contract, weth: Contract, tokens: Contract[]) { diff --git a/utils/utils.hre.ts b/utils/utils.hre.ts index c3057fc41..e818749b2 100644 --- a/utils/utils.hre.ts +++ b/utils/utils.hre.ts @@ -3,7 +3,7 @@ import { HardhatRuntimeEnvironment } from "hardhat/types"; import { Deployment, DeploymentSubmission } from "hardhat-deploy/types"; import { CHAIN_IDs } from "@across-protocol/constants"; import { getDeployedAddress } from "../src/DeploymentUtils"; -import { getContractFactory, toBN } from "./utils"; +import { BigNumber, getContractFactory, toBN } from "./utils"; type unsafeAllowTypes = ( | "delegatecall" @@ -37,7 +37,7 @@ export async function getSpokePoolDeploymentInfo( return { hubPool, hubChainId, spokeChainId }; } -type FnArgs = number | string; +type FnArgs = number | string | BigNumber; export async function deployNewProxy( name: string, constructorArgs: FnArgs[], diff --git a/utils/utils.ts b/utils/utils.ts index 05bcc8d6a..3a67e13f9 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -7,7 +7,8 @@ import * as optimismContracts from "@eth-optimism/contracts"; import { smock, FakeContract } from "@defi-wonderland/smock"; import { FactoryOptions } from "hardhat/types"; import { ethers } from "hardhat"; -import { BigNumber, Signer, Contract, ContractFactory } from "ethers"; +import { BigNumber, Signer, Contract, ContractFactory, BaseContract } from "ethers"; +import { OFT_EIDs } from "../deploy/consts"; export { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; chai.use(smock.matchers); @@ -166,8 +167,12 @@ export async function createFake(contractName: string, targetAddress: string = " } export async function createFakeFromABI(abi: any[], targetAddress: string = "") { + return createTypedFakeFromABI(abi, targetAddress); +} + +export async function createTypedFakeFromABI(abi: any[], targetAddress: string = "") { const signer = new ethers.VoidSigner(ethers.constants.AddressZero); - return smock.fake(abi, { + return smock.fake(abi, { address: !targetAddress ? undefined : targetAddress, provider: signer.provider, }); @@ -205,3 +210,11 @@ export function hashNonEmptyMessage(message: string) { const { defaultAbiCoder, keccak256 } = ethers.utils; export { avmL1ToL2Alias, expect, Contract, ethers, BigNumber, defaultAbiCoder, keccak256, FakeContract, Signer }; + +export function getOftEid(chainId: number): number { + const value = OFT_EIDs.get(chainId); + if (value === undefined) { + throw new Error(`Chain id ${chainId} not present in OFT_EIDs`); + } + return value; +}