From db4bf7b61b2208739f19b3894aece909a3299ef9 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 08:48:13 -0500 Subject: [PATCH 01/27] feat(SpokePoolPeriphery): Support multiple exchanges Currently we can only initialize the periphery contract with a single exchange to swap with. This PR allows us to initialize it with multiple exchanges to swap with. Like before, these initial set of exchanges and function selectors cannot be changed post-initialization, which gives the user assurances. --- ...V3Periphery.sol => SpokePoolPeriphery.sol} | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) rename contracts/{SpokePoolV3Periphery.sol => SpokePoolPeriphery.sol} (91%) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolPeriphery.sol similarity index 91% rename from contracts/SpokePoolV3Periphery.sol rename to contracts/SpokePoolPeriphery.sol index 1a0b3acd2..7efff84cf 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolPeriphery.sol @@ -12,25 +12,28 @@ import { IERC20Auth } from "./external/interfaces/IERC20Auth.sol"; import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; /** - * @title SpokePoolV3Periphery - * @notice Contract for performing more complex interactions with an AcrossV3 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. + * @title SpokePoolPeriphery + * @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 Lockable, MultiCaller { +contract SpokePoolPeriphery is Lockable, MultiCaller { using SafeERC20 for IERC20; using Address 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; + // surface and we should whitelist which function selectors are allowed to be called on which exchange. + mapping(address => mapping(bytes4 => bool)) public allowedSelectors; + + struct WhitelistedExchanges { + address exchange; + bytes4[] allowedSelectors; + } // Across SpokePool we'll submit deposits to with acrossInputToken as the input token. V3SpokePoolInterface public spokePool; - // Exchange address or router where the swapping will happen. - address public exchange; - // Wrapped native token contract address. WETH9Interface internal wrappedNativeToken; @@ -87,40 +90,45 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { error InvalidMsgValue(); error InvalidSpokePool(); error InvalidSwapToken(); + error InvalidExchange(); /** * @notice Construct a new SwapAndBridgeBase contract. - * @param _allowedSelectors Function selectors that are allowed to be called on the exchange. + * @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(bytes4[] memory _allowedSelectors) { - for (uint256 i = 0; i < _allowedSelectors.length; i++) { - allowedSelectors[_allowedSelectors[i]] = true; - } - } + constructor() {} /** * @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 _exchange Address of the exchange where tokens will be swapped. + * @param exchanges Array of exchange addresses and their allowed function selectors. * @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, the exchange this contract uses to - * swap and bridge, and this network's corresponding spoke pool contract. This is to allow this contract to be deterministically - * deployed with CREATE2. - * @dev This function can be front-run by anybody, so it is critical to check that the `spokePool`, `wrappedNativeToken`, and `exchange` - * values used in the single call to this function were passed in correctly before enabling the usage 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. + * @dev This function can be front-run by anybody, so it is critical to check that the values used in the + * single call to this function were passed in correctly before enabling the usage of this contract. */ function initialize( V3SpokePoolInterface _spokePool, WETH9Interface _wrappedNativeToken, - address _exchange + WhitelistedExchanges[] calldata exchanges ) external { if (initialized) revert ContractInitialized(); initialized = true; spokePool = _spokePool; wrappedNativeToken = _wrappedNativeToken; - exchange = _exchange; + for (uint256 i = 0; i < exchanges.length; i++) { + WhitelistedExchanges memory _exchange = exchanges[i]; + for (uint256 j = 0; j < _exchange.allowedSelectors.length; j++) { + bytes4 selector = _exchange.allowedSelectors[j]; + allowedSelectors[_exchange.exchange][selector] = true; + } + } } /** @@ -184,6 +192,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { * 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 exchange Address of the exchange contract to call. * @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. @@ -194,6 +203,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { function swapAndBridge( IERC20 swapToken, IERC20 acrossInputToken, + address exchange, bytes calldata routerCalldata, uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, @@ -209,6 +219,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); } _swapAndBridge( + exchange, routerCalldata, swapTokenAmount, minExpectedInputTokenAmount, @@ -224,6 +235,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { * @dev If swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. * @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 exchange Address of the exchange contract to call. * @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. @@ -238,6 +250,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { function swapAndBridgeWithPermit( IERC20Permit swapToken, IERC20 acrossInputToken, + address exchange, bytes calldata routerCalldata, uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, @@ -255,6 +268,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { _swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); _swapAndBridge( + exchange, routerCalldata, swapTokenAmount, minExpectedInputTokenAmount, @@ -270,6 +284,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { * @dev If swapToken does not implement `receiveWithAuthorization` to the specifications of EIP-3009, this call will revert. * @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 exchange Address of the exchange contract to call. * @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. @@ -286,6 +301,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { function swapAndBridgeWithAuthorization( IERC20Auth swapToken, IERC20 acrossInputToken, + address exchange, bytes calldata routerCalldata, uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, @@ -314,6 +330,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { IERC20 _swapToken = IERC20(address(swapToken)); // Cast IERC20Auth to IERC20. _swapAndBridge( + exchange, routerCalldata, swapTokenAmount, minExpectedInputTokenAmount, @@ -422,6 +439,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // 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( + address exchange, bytes calldata routerCalldata, uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, @@ -432,7 +450,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // 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(); + if (!allowedSelectors[exchange][bytes4(routerCalldata)]) revert InvalidFunctionSelector(); // Swap and run safety checks. uint256 srcBalanceBefore = _swapToken.balanceOf(address(this)); @@ -444,6 +462,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { require(success, string(result)); _checkSwapOutputAndDeposit( + exchange, swapTokenAmount, srcBalanceBefore, dstBalanceBefore, @@ -462,6 +481,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { * @param minExpectedInputTokenAmount Minimum amount of received acrossInputToken that we'll bridge **/ function _checkSwapOutputAndDeposit( + address exchange, uint256 swapTokenAmount, uint256 swapTokenBalanceBefore, uint256 inputTokenBalanceBefore, From df0440465e57a9e75ff30d8a270d54a66aa5f685 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 08:48:35 -0500 Subject: [PATCH 02/27] rename --- contracts/{SpokePoolPeriphery.sol => SpokeV3PoolPeriphery.sol} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename contracts/{SpokePoolPeriphery.sol => SpokeV3PoolPeriphery.sol} (100%) diff --git a/contracts/SpokePoolPeriphery.sol b/contracts/SpokeV3PoolPeriphery.sol similarity index 100% rename from contracts/SpokePoolPeriphery.sol rename to contracts/SpokeV3PoolPeriphery.sol From 1954009298254462a1d19cd6ce75e6663bbb546f Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 08:49:42 -0500 Subject: [PATCH 03/27] Update SpokeV3PoolPeriphery.sol --- contracts/SpokeV3PoolPeriphery.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/SpokeV3PoolPeriphery.sol b/contracts/SpokeV3PoolPeriphery.sol index 7efff84cf..9f6f25bcd 100644 --- a/contracts/SpokeV3PoolPeriphery.sol +++ b/contracts/SpokeV3PoolPeriphery.sol @@ -12,13 +12,13 @@ import { IERC20Auth } from "./external/interfaces/IERC20Auth.sol"; import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; /** - * @title SpokePoolPeriphery + * @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 SpokePoolPeriphery is Lockable, MultiCaller { +contract SpokePoolV3Periphery is Lockable, MultiCaller { using SafeERC20 for IERC20; using Address for address; From fc1ff8fe02589c8be3addc33610f2308236258d5 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 08:51:16 -0500 Subject: [PATCH 04/27] Update SpokeV3PoolPeriphery.sol --- contracts/SpokeV3PoolPeriphery.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/SpokeV3PoolPeriphery.sol b/contracts/SpokeV3PoolPeriphery.sol index 9f6f25bcd..cd507c671 100644 --- a/contracts/SpokeV3PoolPeriphery.sol +++ b/contracts/SpokeV3PoolPeriphery.sol @@ -120,10 +120,12 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { if (initialized) revert ContractInitialized(); initialized = true; + if (!address(_spokePool).isContract()) revert InvalidSpokePool(); spokePool = _spokePool; wrappedNativeToken = _wrappedNativeToken; for (uint256 i = 0; i < exchanges.length; i++) { WhitelistedExchanges memory _exchange = exchanges[i]; + if (!_exchange.exchange.isContract()) revert InvalidExchange(); for (uint256 j = 0; j < _exchange.allowedSelectors.length; j++) { bytes4 selector = _exchange.allowedSelectors[j]; allowedSelectors[_exchange.exchange][selector] = true; @@ -165,7 +167,6 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { bytes memory message ) external payable 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, From c1b6d4f39b626f1b1371a7b24854156cbb33be83 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 10:44:28 -0500 Subject: [PATCH 05/27] Update SpokeV3PoolPeriphery.sol --- contracts/SpokeV3PoolPeriphery.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contracts/SpokeV3PoolPeriphery.sol b/contracts/SpokeV3PoolPeriphery.sol index cd507c671..86b00c22b 100644 --- a/contracts/SpokeV3PoolPeriphery.sol +++ b/contracts/SpokeV3PoolPeriphery.sol @@ -123,10 +123,12 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { if (!address(_spokePool).isContract()) revert InvalidSpokePool(); spokePool = _spokePool; wrappedNativeToken = _wrappedNativeToken; - for (uint256 i = 0; i < exchanges.length; i++) { + uint256 nExchanges = exchanges.length; + for (uint256 i = 0; i < nExchanges; i++) { WhitelistedExchanges memory _exchange = exchanges[i]; if (!_exchange.exchange.isContract()) revert InvalidExchange(); - for (uint256 j = 0; j < _exchange.allowedSelectors.length; j++) { + uint256 nSelectors = _exchange.allowedSelectors.length; + for (uint256 j = 0; j < nSelectors; j++) { bytes4 selector = _exchange.allowedSelectors[j]; allowedSelectors[_exchange.exchange][selector] = true; } From 3b3352e43c3538e821316f25b190c4f958baec3b Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 14:49:45 -0500 Subject: [PATCH 06/27] Add unit tests --- contracts/SpokeV3PoolPeriphery.sol | 9 +- .../foundry/local/SpokePoolPeriphery.t.sol | 260 ++++++++++++++++++ 2 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 test/evm/foundry/local/SpokePoolPeriphery.t.sol diff --git a/contracts/SpokeV3PoolPeriphery.sol b/contracts/SpokeV3PoolPeriphery.sol index 86b00c22b..25c0d0fe1 100644 --- a/contracts/SpokeV3PoolPeriphery.sol +++ b/contracts/SpokeV3PoolPeriphery.sol @@ -35,7 +35,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { V3SpokePoolInterface public spokePool; // Wrapped native token contract address. - WETH9Interface internal wrappedNativeToken; + WETH9Interface public wrappedNativeToken; // Boolean indicating whether the contract is initialized. bool private initialized; @@ -55,7 +55,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // chain and if this is a contract then they will receive an ERC20. address recipient; // The destination chain identifier. - uint256 destinationChainid; + 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 @@ -116,7 +116,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { V3SpokePoolInterface _spokePool, WETH9Interface _wrappedNativeToken, WhitelistedExchanges[] calldata exchanges - ) external { + ) external nonReentrant { if (initialized) revert ContractInitialized(); initialized = true; @@ -169,6 +169,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { bytes memory message ) external payable 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, @@ -431,7 +432,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { depositData.outputToken, // output token _acrossInputAmount, // input amount. depositData.outputAmount, // output amount - depositData.destinationChainid, + depositData.destinationChainId, depositData.exclusiveRelayer, depositData.quoteTimestamp, depositData.fillDeadline, diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol new file mode 100644 index 000000000..0b2958f6b --- /dev/null +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -0,0 +1,260 @@ +// 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 } from "../../../../contracts/SpokeV3PoolPeriphery.sol"; +import { Ethereum_SpokePool } from "../../../../contracts/Ethereum_SpokePool.sol"; +import { V3SpokePoolInterface } from "../../../../contracts/interfaces/V3SpokePoolInterface.sol"; +import { WETH9 } from "../../../../contracts/external/WETH9.sol"; +import { WETH9Interface } from "../../../../contracts/external/interfaces/WETH9Interface.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract Exchange { + function swap( + IERC20 tokenIn, + IERC20 tokenOut, + uint256 amountIn, + uint256 amountOutMin + ) external { + require(tokenIn.transferFrom(msg.sender, address(this), amountIn)); + require(tokenOut.transfer(msg.sender, amountOutMin)); + } + + function stealYourMoney(IERC20 tokenIn, uint256 amount) external { + require(tokenIn.transferFrom(msg.sender, address(this), amount)); + } +} + +contract SpokePoolPeripheryTest is Test { + Ethereum_SpokePool ethereumSpokePool; + SpokePoolV3Periphery spokePoolPeriphery; + Exchange dex; + Exchange cex; + + WETH9Interface mockWETH; + ERC20 mockERC20; + + address depositor; + address owner; + address recipient; + + uint256 destinationChainId = 10; + uint256 mintAmount = 10**22; + uint256 depositAmount = 5 * (10**18); + uint32 fillDeadlineBuffer = 7200; + + SpokePoolV3Periphery.WhitelistedExchanges[] exchanges; + + function setUp() public { + spokePoolPeriphery = new SpokePoolV3Periphery(); + dex = new Exchange(); + cex = new Exchange(); + + mockWETH = WETH9Interface(address(new WETH9())); + mockERC20 = new ERC20("ERC20", "ERC20"); + + depositor = vm.addr(1); + owner = vm.addr(2); + recipient = vm.addr(3); + + vm.startPrank(owner); + Ethereum_SpokePool implementation = new Ethereum_SpokePool( + address(mockWETH), + fillDeadlineBuffer, + fillDeadlineBuffer + ); + address proxy = address( + new ERC1967Proxy(address(implementation), abi.encodeCall(Ethereum_SpokePool.initialize, (0, owner))) + ); + ethereumSpokePool = Ethereum_SpokePool(payable(proxy)); + ethereumSpokePool.setEnableRoute(address(mockWETH), destinationChainId, true); + ethereumSpokePool.setEnableRoute(address(mockERC20), destinationChainId, true); + vm.stopPrank(); + + deal(depositor, mintAmount); + deal(address(mockERC20), depositor, mintAmount, true); + deal(address(mockERC20), address(dex), depositAmount, true); + // deal(address(mockWETH), address(dex), depositAmount, true); + vm.startPrank(depositor); + mockWETH.deposit{ value: mintAmount }(); + mockERC20.approve(address(spokePoolPeriphery), mintAmount); + IERC20(address(mockWETH)).approve(address(spokePoolPeriphery), mintAmount); + vm.stopPrank(); + + exchanges = new SpokePoolV3Periphery.WhitelistedExchanges[](2); + exchanges[0] = SpokePoolV3Periphery.WhitelistedExchanges({ + exchange: address(dex), + allowedSelectors: new bytes4[](1) + }); + exchanges[0].allowedSelectors[0] = dex.swap.selector; + exchanges[1] = SpokePoolV3Periphery.WhitelistedExchanges({ + exchange: address(cex), + allowedSelectors: new bytes4[](1) + }); + exchanges[1].allowedSelectors[0] = cex.swap.selector; + } + + function testInitialize() public { + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + + assertEq(address(spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); + assertEq(address(spokePoolPeriphery.wrappedNativeToken()), address(mockWETH)); + assertTrue(spokePoolPeriphery.allowedSelectors(address(dex), dex.swap.selector)); + assertTrue(spokePoolPeriphery.allowedSelectors(address(cex), cex.swap.selector)); + assertFalse(spokePoolPeriphery.allowedSelectors(address(dex), dex.stealYourMoney.selector)); + + vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + } + + function testSwapAndBridge() public { + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.V3FundsDeposited( + address(mockERC20), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridge( + IERC20(address(mockWETH)), // swapToken + IERC20(mockERC20), // acrossInputToken + address(dex), + abi.encodeWithSelector( + dex.swap.selector, + IERC20(address(mockWETH)), + IERC20(mockERC20), + mintAmount, + depositAmount + ), + mintAmount, // swapTokenAmount + depositAmount, // minExpectedInputTokenAmount + SpokePoolV3Periphery.DepositData({ + outputToken: address(0), + outputAmount: depositAmount, + depositor: depositor, + recipient: depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }) + ); + + vm.stopPrank(); + } + + function testSwapAndBridgeWithValue() public { + deal(depositor, mintAmount); + + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + + // Should emit expected deposit event + vm.startPrank(depositor); + + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.V3FundsDeposited( + address(mockERC20), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridge{ value: mintAmount }( + IERC20(address(mockWETH)), // swapToken + IERC20(mockERC20), // acrossInputToken + address(dex), + abi.encodeWithSelector( + dex.swap.selector, + IERC20(address(mockWETH)), + IERC20(mockERC20), + mintAmount, + depositAmount + ), + mintAmount, // swapTokenAmount + depositAmount, // minExpectedInputTokenAmount + SpokePoolV3Periphery.DepositData({ + outputToken: address(0), + outputAmount: depositAmount, + depositor: depositor, + recipient: depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }) + ); + + vm.stopPrank(); + } + + function testDepositWithValue() public { + spokePoolPeriphery.initialize( + V3SpokePoolInterface(ethereumSpokePool), + mockWETH, + new SpokePoolV3Periphery.WhitelistedExchanges[](0) + ); + deal(depositor, mintAmount); + + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.V3FundsDeposited( + address(mockWETH), + address(0), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // 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(); + } +} From aca4b06cc1381eaed0bd0c038f3a6bd145bdc774 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 15:25:49 -0500 Subject: [PATCH 07/27] Add whitelistExchanges only owner method --- contracts/SpokeV3PoolPeriphery.sol | 44 +++++++++++++------ .../foundry/local/SpokePoolPeriphery.t.sol | 39 ++++++++++++++-- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/contracts/SpokeV3PoolPeriphery.sol b/contracts/SpokeV3PoolPeriphery.sol index 25c0d0fe1..7c1425a8b 100644 --- a/contracts/SpokeV3PoolPeriphery.sol +++ b/contracts/SpokeV3PoolPeriphery.sol @@ -4,6 +4,7 @@ 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 "@openzeppelin/contracts/access/Ownable.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { MultiCaller } from "@uma/core/contracts/common/implementation/MultiCaller.sol"; import { Lockable } from "./Lockable.sol"; @@ -18,7 +19,7 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; * contract may be deployed deterministically at the same address across different networks. * @custom:security-contact bugs@across.to */ -contract SpokePoolV3Periphery is Lockable, MultiCaller { +contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { using SafeERC20 for IERC20; using Address for address; @@ -29,6 +30,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { struct WhitelistedExchanges { address exchange; bytes4[] allowedSelectors; + bool[] enabled; } // Across SpokePool we'll submit deposits to with acrossInputToken as the input token. @@ -91,6 +93,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { error InvalidSpokePool(); error InvalidSwapToken(); error InvalidExchange(); + error InvalidExchangeData(); /** * @notice Construct a new SwapAndBridgeBase contract. @@ -103,36 +106,35 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { /** * @notice Initializes the SwapAndBridgeBase contract. + * @dev Only the owner can call this function. * @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 exchanges Array of exchange addresses and their allowed function selectors. * @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. - * @dev This function can be front-run by anybody, so it is critical to check that the values used in the - * single call to this function were passed in correctly before enabling the usage of this contract. */ function initialize( V3SpokePoolInterface _spokePool, WETH9Interface _wrappedNativeToken, WhitelistedExchanges[] calldata exchanges - ) external nonReentrant { + ) external nonReentrant onlyOwner { if (initialized) revert ContractInitialized(); initialized = true; if (!address(_spokePool).isContract()) revert InvalidSpokePool(); spokePool = _spokePool; wrappedNativeToken = _wrappedNativeToken; - uint256 nExchanges = exchanges.length; - for (uint256 i = 0; i < nExchanges; i++) { - WhitelistedExchanges memory _exchange = exchanges[i]; - if (!_exchange.exchange.isContract()) revert InvalidExchange(); - uint256 nSelectors = _exchange.allowedSelectors.length; - for (uint256 j = 0; j < nSelectors; j++) { - bytes4 selector = _exchange.allowedSelectors[j]; - allowedSelectors[_exchange.exchange][selector] = true; - } - } + _whitelistExchanges(exchanges); + } + + /** + * @notice Whitelists exchanges and their allowed function selectors. Can also be used to disable exchanges. + * @dev Only the owner can call this function. + * @param exchanges Array of exchange addresses and their allowed function selectors and an enable flag. + */ + function whitelistExchanges(WhitelistedExchanges[] calldata exchanges) public nonReentrant onlyOwner { + _whitelistExchanges(exchanges); } /** @@ -514,4 +516,18 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // Deposit the swapped tokens into Across and bridge them using remainder of input params. _depositV3(_acrossInputToken, returnAmount, depositData); } + + function _whitelistExchanges(WhitelistedExchanges[] calldata exchanges) internal { + uint256 nExchanges = exchanges.length; + for (uint256 i = 0; i < nExchanges; i++) { + WhitelistedExchanges memory _exchange = exchanges[i]; + if (!_exchange.exchange.isContract()) revert InvalidExchange(); + uint256 nSelectors = _exchange.allowedSelectors.length; + if (_exchange.enabled.length != nSelectors) revert InvalidExchangeData(); + for (uint256 j = 0; j < nSelectors; j++) { + bytes4 selector = _exchange.allowedSelectors[j]; + allowedSelectors[_exchange.exchange][selector] = _exchange.enabled[j]; + } + } + } } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index 0b2958f6b..5247e5fa5 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -50,7 +50,6 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3Periphery.WhitelistedExchanges[] exchanges; function setUp() public { - spokePoolPeriphery = new SpokePoolV3Periphery(); dex = new Exchange(); cex = new Exchange(); @@ -62,6 +61,7 @@ contract SpokePoolPeripheryTest is Test { recipient = vm.addr(3); vm.startPrank(owner); + spokePoolPeriphery = new SpokePoolV3Periphery(); Ethereum_SpokePool implementation = new Ethereum_SpokePool( address(mockWETH), fillDeadlineBuffer, @@ -88,17 +88,25 @@ contract SpokePoolPeripheryTest is Test { exchanges = new SpokePoolV3Periphery.WhitelistedExchanges[](2); exchanges[0] = SpokePoolV3Periphery.WhitelistedExchanges({ exchange: address(dex), - allowedSelectors: new bytes4[](1) + allowedSelectors: new bytes4[](1), + enabled: new bool[](1) }); exchanges[0].allowedSelectors[0] = dex.swap.selector; + exchanges[0].enabled[0] = true; exchanges[1] = SpokePoolV3Periphery.WhitelistedExchanges({ exchange: address(cex), - allowedSelectors: new bytes4[](1) + allowedSelectors: new bytes4[](1), + enabled: new bool[](1) }); exchanges[1].allowedSelectors[0] = cex.swap.selector; + exchanges[1].enabled[0] = true; } function testInitialize() public { + vm.expectRevert(); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + + vm.startPrank(owner); spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); assertEq(address(spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); @@ -109,9 +117,32 @@ contract SpokePoolPeripheryTest is Test { vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + + vm.stopPrank(); + } + + function testWhitelistExchanges() public { + vm.prank(owner); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + + // Enable new function selector for one exchange: + exchanges[0].allowedSelectors[0] = dex.stealYourMoney.selector; + // Disable existing function selector for other exchange: + exchanges[1].enabled[0] = false; + + vm.prank(owner); + spokePoolPeriphery.whitelistExchanges(exchanges); + + // New function selector enabled for an exchange: + assertTrue(spokePoolPeriphery.allowedSelectors(exchanges[0].exchange, dex.stealYourMoney.selector)); + // If we didn't update the function selector enabled flag for an exchange it should be unchanged: + assertTrue(spokePoolPeriphery.allowedSelectors(exchanges[0].exchange, dex.swap.selector)); + // Existing function selector for an exchange was disabled: + assertFalse(spokePoolPeriphery.allowedSelectors(exchanges[1].exchange, cex.swap.selector)); } function testSwapAndBridge() public { + vm.prank(owner); spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); // Should emit expected deposit event @@ -165,6 +196,7 @@ contract SpokePoolPeripheryTest is Test { function testSwapAndBridgeWithValue() public { deal(depositor, mintAmount); + vm.prank(owner); spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); // Should emit expected deposit event @@ -217,6 +249,7 @@ contract SpokePoolPeripheryTest is Test { } function testDepositWithValue() public { + vm.prank(owner); spokePoolPeriphery.initialize( V3SpokePoolInterface(ethereumSpokePool), mockWETH, From 7149287f535647ac8ebd9c040347254cb402b281 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 15:27:57 -0500 Subject: [PATCH 08/27] rename --- .../{SpokeV3PoolPeriphery.sol => SpokePoolV3Periphery.sol} | 0 test/evm/foundry/local/SpokePoolPeriphery.t.sol | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename contracts/{SpokeV3PoolPeriphery.sol => SpokePoolV3Periphery.sol} (100%) diff --git a/contracts/SpokeV3PoolPeriphery.sol b/contracts/SpokePoolV3Periphery.sol similarity index 100% rename from contracts/SpokeV3PoolPeriphery.sol rename to contracts/SpokePoolV3Periphery.sol diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index 5247e5fa5..d59399159 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; import { SpokePoolVerifier } from "../../../../contracts/SpokePoolVerifier.sol"; -import { SpokePoolV3Periphery } from "../../../../contracts/SpokeV3PoolPeriphery.sol"; +import { SpokePoolV3Periphery } from "../../../../contracts/SpokePoolV3Periphery.sol"; import { Ethereum_SpokePool } from "../../../../contracts/Ethereum_SpokePool.sol"; import { V3SpokePoolInterface } from "../../../../contracts/interfaces/V3SpokePoolInterface.sol"; import { WETH9 } from "../../../../contracts/external/WETH9.sol"; From dda84998fe9c9501da08b03b41016c47f5e4c3d4 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 27 Nov 2024 16:39:42 -0500 Subject: [PATCH 09/27] Remove onlyOwner --- contracts/SpokePoolV3Periphery.sol | 19 ++--------- .../foundry/local/SpokePoolPeriphery.t.sol | 34 ++----------------- 2 files changed, 5 insertions(+), 48 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 7c1425a8b..b0585e0a0 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -4,7 +4,6 @@ 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 "@openzeppelin/contracts/access/Ownable.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { MultiCaller } from "@uma/core/contracts/common/implementation/MultiCaller.sol"; import { Lockable } from "./Lockable.sol"; @@ -19,7 +18,7 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; * contract may be deployed deterministically at the same address across different networks. * @custom:security-contact bugs@across.to */ -contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { +contract SpokePoolV3Periphery is Lockable, MultiCaller { using SafeERC20 for IERC20; using Address for address; @@ -30,7 +29,6 @@ contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { struct WhitelistedExchanges { address exchange; bytes4[] allowedSelectors; - bool[] enabled; } // Across SpokePool we'll submit deposits to with acrossInputToken as the input token. @@ -93,7 +91,6 @@ contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { error InvalidSpokePool(); error InvalidSwapToken(); error InvalidExchange(); - error InvalidExchangeData(); /** * @notice Construct a new SwapAndBridgeBase contract. @@ -118,7 +115,7 @@ contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { V3SpokePoolInterface _spokePool, WETH9Interface _wrappedNativeToken, WhitelistedExchanges[] calldata exchanges - ) external nonReentrant onlyOwner { + ) external nonReentrant { if (initialized) revert ContractInitialized(); initialized = true; @@ -128,15 +125,6 @@ contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { _whitelistExchanges(exchanges); } - /** - * @notice Whitelists exchanges and their allowed function selectors. Can also be used to disable exchanges. - * @dev Only the owner can call this function. - * @param exchanges Array of exchange addresses and their allowed function selectors and an enable flag. - */ - function whitelistExchanges(WhitelistedExchanges[] calldata exchanges) public nonReentrant onlyOwner { - _whitelistExchanges(exchanges); - } - /** * @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 @@ -523,10 +511,9 @@ contract SpokePoolV3Periphery is Ownable, Lockable, MultiCaller { WhitelistedExchanges memory _exchange = exchanges[i]; if (!_exchange.exchange.isContract()) revert InvalidExchange(); uint256 nSelectors = _exchange.allowedSelectors.length; - if (_exchange.enabled.length != nSelectors) revert InvalidExchangeData(); for (uint256 j = 0; j < nSelectors; j++) { bytes4 selector = _exchange.allowedSelectors[j]; - allowedSelectors[_exchange.exchange][selector] = _exchange.enabled[j]; + allowedSelectors[_exchange.exchange][selector] = true; } } } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index d59399159..c3ce32daf 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -88,25 +88,17 @@ contract SpokePoolPeripheryTest is Test { exchanges = new SpokePoolV3Periphery.WhitelistedExchanges[](2); exchanges[0] = SpokePoolV3Periphery.WhitelistedExchanges({ exchange: address(dex), - allowedSelectors: new bytes4[](1), - enabled: new bool[](1) + allowedSelectors: new bytes4[](1) }); exchanges[0].allowedSelectors[0] = dex.swap.selector; - exchanges[0].enabled[0] = true; exchanges[1] = SpokePoolV3Periphery.WhitelistedExchanges({ exchange: address(cex), - allowedSelectors: new bytes4[](1), - enabled: new bool[](1) + allowedSelectors: new bytes4[](1) }); exchanges[1].allowedSelectors[0] = cex.swap.selector; - exchanges[1].enabled[0] = true; } function testInitialize() public { - vm.expectRevert(); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); - - vm.startPrank(owner); spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); assertEq(address(spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); @@ -117,28 +109,6 @@ contract SpokePoolPeripheryTest is Test { vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); - - vm.stopPrank(); - } - - function testWhitelistExchanges() public { - vm.prank(owner); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); - - // Enable new function selector for one exchange: - exchanges[0].allowedSelectors[0] = dex.stealYourMoney.selector; - // Disable existing function selector for other exchange: - exchanges[1].enabled[0] = false; - - vm.prank(owner); - spokePoolPeriphery.whitelistExchanges(exchanges); - - // New function selector enabled for an exchange: - assertTrue(spokePoolPeriphery.allowedSelectors(exchanges[0].exchange, dex.stealYourMoney.selector)); - // If we didn't update the function selector enabled flag for an exchange it should be unchanged: - assertTrue(spokePoolPeriphery.allowedSelectors(exchanges[0].exchange, dex.swap.selector)); - // Existing function selector for an exchange was disabled: - assertFalse(spokePoolPeriphery.allowedSelectors(exchanges[1].exchange, cex.swap.selector)); } function testSwapAndBridge() public { From 2da9c63ec7c2943d240061e9cb04294088ad1eb3 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Thu, 28 Nov 2024 00:43:50 -0500 Subject: [PATCH 10/27] Remove whitelist of exchanges, add proxy to bypass approval abuse Make user approve proxy contract so no one can use `exchange` + `routerCalldata` to steal their already approved funds via the `SpokePoolPeriphery` --- contracts/SpokePoolV3Periphery.sol | 77 ++++++++++--------- .../foundry/local/SpokePoolPeriphery.t.sol | 35 ++------- 2 files changed, 46 insertions(+), 66 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index b0585e0a0..4f1ceedbe 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -11,6 +11,42 @@ import { V3SpokePoolInterface } from "./interfaces/V3SpokePoolInterface.sol"; import { IERC20Auth } from "./external/interfaces/IERC20Auth.sol"; import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; +/** + * @title SpokePoolProxy + * @notice User should only interact with SpokePool via the SpokePoolV3Periphery contract through this + * contract. This is purposefully a simple passthrough contract so that the user only approves this contract + * to pull its assets while the SpokePoolV3Periphery contract can be used to call any calldata on any exchange + * that the user wants to. By separating the contract that gets approved 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 SpokePoolProxy is Lockable { + using SafeERC20 for IERC20; + + // 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 constructor argument. + address public immutable SPOKE_POOL_PERIPHERY; + + constructor(address _spokePoolPeriphery) { + SPOKE_POOL_PERIPHERY = _spokePoolPeriphery; + } + + function callSpokePoolPeriphery( + address inputToken, + uint256 inputAmount, + bytes memory peripheryFunctionCalldata + ) external payable nonReentrant { + IERC20(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount); + IERC20(inputToken).forceApprove(SPOKE_POOL_PERIPHERY, inputAmount); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = SPOKE_POOL_PERIPHERY.call{ value: msg.value }(peripheryFunctionCalldata); + require(success, string(result)); + } +} + /** * @title SpokePoolV3Periphery * @notice Contract for performing more complex interactions with an Across spoke pool deployment. @@ -22,15 +58,6 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { using SafeERC20 for IERC20; using Address 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 which exchange. - mapping(address => mapping(bytes4 => bool)) public allowedSelectors; - - struct WhitelistedExchanges { - address exchange; - bytes4[] allowedSelectors; - } - // Across SpokePool we'll submit deposits to with acrossInputToken as the input token. V3SpokePoolInterface public spokePool; @@ -72,6 +99,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { event SwapBeforeBridge( address exchange, + bytes exchangeCalldata, address indexed swapToken, address indexed acrossInputToken, uint256 swapTokenAmount, @@ -85,12 +113,10 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { ****************************************/ error MinimumExpectedInputAmount(); error LeftoverSrcTokens(); - error InvalidFunctionSelector(); error ContractInitialized(); error InvalidMsgValue(); error InvalidSpokePool(); error InvalidSwapToken(); - error InvalidExchange(); /** * @notice Construct a new SwapAndBridgeBase contract. @@ -106,23 +132,17 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { * @dev Only the owner can call this function. * @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 exchanges Array of exchange addresses and their allowed function selectors. * @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, - WhitelistedExchanges[] calldata exchanges - ) external nonReentrant { + function initialize(V3SpokePoolInterface _spokePool, WETH9Interface _wrappedNativeToken) external nonReentrant { if (initialized) revert ContractInitialized(); initialized = true; if (!address(_spokePool).isContract()) revert InvalidSpokePool(); spokePool = _spokePool; wrappedNativeToken = _wrappedNativeToken; - _whitelistExchanges(exchanges); } /** @@ -441,11 +461,6 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { IERC20 _swapToken, IERC20 _acrossInputToken ) private { - // 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[exchange][bytes4(routerCalldata)]) revert InvalidFunctionSelector(); - // Swap and run safety checks. uint256 srcBalanceBefore = _swapToken.balanceOf(address(this)); uint256 dstBalanceBefore = _acrossInputToken.balanceOf(address(this)); @@ -457,6 +472,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { _checkSwapOutputAndDeposit( exchange, + routerCalldata, swapTokenAmount, srcBalanceBefore, dstBalanceBefore, @@ -476,6 +492,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { **/ function _checkSwapOutputAndDeposit( address exchange, + bytes memory routerCalldata, uint256 swapTokenAmount, uint256 swapTokenBalanceBefore, uint256 inputTokenBalanceBefore, @@ -494,6 +511,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { emit SwapBeforeBridge( exchange, + routerCalldata, address(_swapToken), address(_acrossInputToken), swapTokenAmount, @@ -504,17 +522,4 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // Deposit the swapped tokens into Across and bridge them using remainder of input params. _depositV3(_acrossInputToken, returnAmount, depositData); } - - function _whitelistExchanges(WhitelistedExchanges[] calldata exchanges) internal { - uint256 nExchanges = exchanges.length; - for (uint256 i = 0; i < nExchanges; i++) { - WhitelistedExchanges memory _exchange = exchanges[i]; - if (!_exchange.exchange.isContract()) revert InvalidExchange(); - uint256 nSelectors = _exchange.allowedSelectors.length; - for (uint256 j = 0; j < nSelectors; j++) { - bytes4 selector = _exchange.allowedSelectors[j]; - allowedSelectors[_exchange.exchange][selector] = true; - } - } - } } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index c3ce32daf..b5e67c183 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -23,10 +23,6 @@ contract Exchange { require(tokenIn.transferFrom(msg.sender, address(this), amountIn)); require(tokenOut.transfer(msg.sender, amountOutMin)); } - - function stealYourMoney(IERC20 tokenIn, uint256 amount) external { - require(tokenIn.transferFrom(msg.sender, address(this), amount)); - } } contract SpokePoolPeripheryTest is Test { @@ -47,8 +43,6 @@ contract SpokePoolPeripheryTest is Test { uint256 depositAmount = 5 * (10**18); uint32 fillDeadlineBuffer = 7200; - SpokePoolV3Periphery.WhitelistedExchanges[] exchanges; - function setUp() public { dex = new Exchange(); cex = new Exchange(); @@ -84,36 +78,21 @@ contract SpokePoolPeripheryTest is Test { mockERC20.approve(address(spokePoolPeriphery), mintAmount); IERC20(address(mockWETH)).approve(address(spokePoolPeriphery), mintAmount); vm.stopPrank(); - - exchanges = new SpokePoolV3Periphery.WhitelistedExchanges[](2); - exchanges[0] = SpokePoolV3Periphery.WhitelistedExchanges({ - exchange: address(dex), - allowedSelectors: new bytes4[](1) - }); - exchanges[0].allowedSelectors[0] = dex.swap.selector; - exchanges[1] = SpokePoolV3Periphery.WhitelistedExchanges({ - exchange: address(cex), - allowedSelectors: new bytes4[](1) - }); - exchanges[1].allowedSelectors[0] = cex.swap.selector; } function testInitialize() public { - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); assertEq(address(spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); assertEq(address(spokePoolPeriphery.wrappedNativeToken()), address(mockWETH)); - assertTrue(spokePoolPeriphery.allowedSelectors(address(dex), dex.swap.selector)); - assertTrue(spokePoolPeriphery.allowedSelectors(address(cex), cex.swap.selector)); - assertFalse(spokePoolPeriphery.allowedSelectors(address(dex), dex.stealYourMoney.selector)); vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); } function testSwapAndBridge() public { vm.prank(owner); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); // Should emit expected deposit event vm.startPrank(depositor); @@ -167,7 +146,7 @@ contract SpokePoolPeripheryTest is Test { deal(depositor, mintAmount); vm.prank(owner); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, exchanges); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); // Should emit expected deposit event vm.startPrank(depositor); @@ -220,11 +199,7 @@ contract SpokePoolPeripheryTest is Test { function testDepositWithValue() public { vm.prank(owner); - spokePoolPeriphery.initialize( - V3SpokePoolInterface(ethereumSpokePool), - mockWETH, - new SpokePoolV3Periphery.WhitelistedExchanges[](0) - ); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); deal(depositor, mintAmount); // Should emit expected deposit event From 90e7cd094037e157cff67ac13117ff565ca2706c Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Thu, 28 Nov 2024 00:50:44 -0500 Subject: [PATCH 11/27] Add some protection to callSpokePoolPeriphery --- contracts/SpokePoolV3Periphery.sol | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 4f1ceedbe..2a7ff361c 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -25,6 +25,8 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; contract SpokePoolProxy is Lockable { using SafeERC20 for IERC20; + error LeftoverInputTokens(); + // 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 constructor argument. @@ -34,16 +36,27 @@ contract SpokePoolProxy is Lockable { SPOKE_POOL_PERIPHERY = _spokePoolPeriphery; } + /** + * @notice Caller must insure that exactly `inputAmount` of `inputToken` is used in the subsequent call to + * the SpokePoolPeriphery contract. All of the periphery functions have an easily identifiable input + * amount and input token so this should be easy to verify. Any leftover tokens would be locked in this contract + * and could be used by ANYONE in a subsequent call to the SpokePoolPeriphery contract. So, this function + * attempts to protect the user from locking tokens by reverting if not exactly `inputAmount` of `inputToken` + * is used in the periphery contract call. + */ function callSpokePoolPeriphery( address inputToken, uint256 inputAmount, bytes memory peripheryFunctionCalldata ) external payable nonReentrant { + uint256 balanceBefore = IERC20(inputToken).balanceOf(address(this)); IERC20(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount); IERC20(inputToken).forceApprove(SPOKE_POOL_PERIPHERY, inputAmount); // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory result) = SPOKE_POOL_PERIPHERY.call{ value: msg.value }(peripheryFunctionCalldata); require(success, string(result)); + uint256 balanceAfter = IERC20(inputToken).balanceOf(address(this)); + if (balanceAfter != balanceBefore) revert LeftoverInputTokens(); } } From 95116666524b72d84901c6e0e083d306ecf63700 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Sun, 1 Dec 2024 16:55:21 -0500 Subject: [PATCH 12/27] Only call swapAndBridge through proxy --- contracts/SpokePoolV3Periphery.sol | 58 +++++++++------ .../foundry/local/SpokePoolPeriphery.t.sol | 72 +++++++++++++++---- 2 files changed, 95 insertions(+), 35 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 2a7ff361c..4ba453e97 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -13,7 +13,7 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; /** * @title SpokePoolProxy - * @notice User should only interact with SpokePool via the SpokePoolV3Periphery contract through this + * @notice User should only call the SpokePoolV3Periphery contract functions that require an ERC20.approval through this * contract. This is purposefully a simple passthrough contract so that the user only approves this contract * to pull its assets while the SpokePoolV3Periphery contract can be used to call any calldata on any exchange * that the user wants to. By separating the contract that gets approved from the contract that executes arbitrary @@ -22,7 +22,7 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; * 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 SpokePoolProxy is Lockable { +contract SpokePoolPeripheryApproveProxy is Lockable { using SafeERC20 for IERC20; error LeftoverInputTokens(); @@ -30,33 +30,47 @@ contract SpokePoolProxy is Lockable { // 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 constructor argument. - address public immutable SPOKE_POOL_PERIPHERY; + SpokePoolV3Periphery public immutable SPOKE_POOL_PERIPHERY; - constructor(address _spokePoolPeriphery) { + constructor(SpokePoolV3Periphery _spokePoolPeriphery) { SPOKE_POOL_PERIPHERY = _spokePoolPeriphery; } /** - * @notice Caller must insure that exactly `inputAmount` of `inputToken` is used in the subsequent call to - * the SpokePoolPeriphery contract. All of the periphery functions have an easily identifiable input - * amount and input token so this should be easy to verify. Any leftover tokens would be locked in this contract - * and could be used by ANYONE in a subsequent call to the SpokePoolPeriphery contract. So, this function - * attempts to protect the user from locking tokens by reverting if not exactly `inputAmount` of `inputToken` - * is used in the periphery contract call. + * @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 exchange Address of the exchange contract to call. + * @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 callSpokePoolPeriphery( - address inputToken, - uint256 inputAmount, - bytes memory peripheryFunctionCalldata + function swapAndBridge( + IERC20 swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData ) external payable nonReentrant { - uint256 balanceBefore = IERC20(inputToken).balanceOf(address(this)); - IERC20(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount); - IERC20(inputToken).forceApprove(SPOKE_POOL_PERIPHERY, inputAmount); - // solhint-disable-next-line avoid-low-level-calls - (bool success, bytes memory result) = SPOKE_POOL_PERIPHERY.call{ value: msg.value }(peripheryFunctionCalldata); - require(success, string(result)); - uint256 balanceAfter = IERC20(inputToken).balanceOf(address(this)); - if (balanceAfter != balanceBefore) revert LeftoverInputTokens(); + IERC20(swapToken).safeTransferFrom(msg.sender, address(this), swapTokenAmount); + swapToken.forceApprove(address(SPOKE_POOL_PERIPHERY), swapTokenAmount); + SPOKE_POOL_PERIPHERY.swapAndBridge{ value: msg.value }( + swapToken, + acrossInputToken, + exchange, + routerCalldata, + swapTokenAmount, + minExpectedInputTokenAmount, + depositData + ); } } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index b5e67c183..6ff0f703d 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; import { SpokePoolVerifier } from "../../../../contracts/SpokePoolVerifier.sol"; -import { SpokePoolV3Periphery } from "../../../../contracts/SpokePoolV3Periphery.sol"; +import { SpokePoolV3Periphery, SpokePoolPeripheryApproveProxy } from "../../../../contracts/SpokePoolV3Periphery.sol"; import { Ethereum_SpokePool } from "../../../../contracts/Ethereum_SpokePool.sol"; import { V3SpokePoolInterface } from "../../../../contracts/interfaces/V3SpokePoolInterface.sol"; import { WETH9 } from "../../../../contracts/external/WETH9.sol"; @@ -28,6 +28,7 @@ contract Exchange { contract SpokePoolPeripheryTest is Test { Ethereum_SpokePool ethereumSpokePool; SpokePoolV3Periphery spokePoolPeriphery; + SpokePoolPeripheryApproveProxy approveProxy; Exchange dex; Exchange cex; @@ -56,6 +57,7 @@ contract SpokePoolPeripheryTest is Test { vm.startPrank(owner); spokePoolPeriphery = new SpokePoolV3Periphery(); + approveProxy = new SpokePoolPeripheryApproveProxy(spokePoolPeriphery); Ethereum_SpokePool implementation = new Ethereum_SpokePool( address(mockWETH), fillDeadlineBuffer, @@ -67,6 +69,7 @@ contract SpokePoolPeripheryTest is Test { ethereumSpokePool = Ethereum_SpokePool(payable(proxy)); ethereumSpokePool.setEnableRoute(address(mockWETH), destinationChainId, true); ethereumSpokePool.setEnableRoute(address(mockERC20), destinationChainId, true); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); vm.stopPrank(); deal(depositor, mintAmount); @@ -77,23 +80,22 @@ contract SpokePoolPeripheryTest is Test { mockWETH.deposit{ value: mintAmount }(); mockERC20.approve(address(spokePoolPeriphery), mintAmount); IERC20(address(mockWETH)).approve(address(spokePoolPeriphery), mintAmount); + IERC20(address(mockWETH)).approve(address(approveProxy), mintAmount); vm.stopPrank(); } function testInitialize() public { - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); + SpokePoolV3Periphery _spokePoolPeriphery = new SpokePoolV3Periphery(); + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); - assertEq(address(spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); - assertEq(address(spokePoolPeriphery.wrappedNativeToken()), address(mockWETH)); + assertEq(address(_spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); + assertEq(address(_spokePoolPeriphery.wrappedNativeToken()), address(mockWETH)); vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); } function testSwapAndBridge() public { - vm.prank(owner); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); - // Should emit expected deposit event vm.startPrank(depositor); vm.expectEmit(address(ethereumSpokePool)); @@ -145,9 +147,6 @@ contract SpokePoolPeripheryTest is Test { function testSwapAndBridgeWithValue() public { deal(depositor, mintAmount); - vm.prank(owner); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); - // Should emit expected deposit event vm.startPrank(depositor); @@ -198,8 +197,6 @@ contract SpokePoolPeripheryTest is Test { } function testDepositWithValue() public { - vm.prank(owner); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); deal(depositor, mintAmount); // Should emit expected deposit event @@ -235,4 +232,53 @@ contract SpokePoolPeripheryTest is Test { vm.stopPrank(); } + + function testApproveProxy() public { + // Should emit expected deposit event + vm.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.V3FundsDeposited( + address(mockERC20), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + approveProxy.swapAndBridge( + IERC20(address(mockWETH)), // swapToken + IERC20(mockERC20), // acrossInputToken + address(dex), + abi.encodeWithSelector( + dex.swap.selector, + IERC20(address(mockWETH)), + IERC20(mockERC20), + mintAmount, + depositAmount + ), + mintAmount, // swapTokenAmount + depositAmount, // minExpectedInputTokenAmount + SpokePoolV3Periphery.DepositData({ + outputToken: address(0), + outputAmount: depositAmount, + depositor: depositor, + recipient: depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }) + ); + + vm.stopPrank(); + } } From e0bead20c010c815392b94d21f092d08f891b53b Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Mon, 2 Dec 2024 12:06:54 -0500 Subject: [PATCH 13/27] move periphery funcs into proxy --- contracts/SpokePoolV3Periphery.sol | 483 +++++++++++------- .../foundry/local/SpokePoolPeriphery.t.sol | 118 ++--- 2 files changed, 353 insertions(+), 248 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 4ba453e97..057c9732f 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -13,26 +13,50 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; /** * @title SpokePoolProxy - * @notice User should only call the SpokePoolV3Periphery contract functions that require an ERC20.approval through this - * contract. This is purposefully a simple passthrough contract so that the user only approves this contract - * to pull its assets while the SpokePoolV3Periphery contract can be used to call any calldata on any exchange - * that the user wants to. By separating the contract that gets approved from the contract that executes arbitrary - * calldata, the SpokePoolPeriphery does not need to validate the calldata that gets executed. + * @notice User should only call the SpokePoolV3Periphery contract functions through this + * contract. This is purposefully a simple passthrough contract so that the user only approves (or permits or + * authorizes) this contract to pull its assets while the SpokePoolV3Periphery contract can be used to call + * any calldata on any exchange that the user wants to. By separating the contract that pulls user funds from the + * contract that executes arbitrary calldata, the SpokePoolPeriphery does not need to validate the calldata + * that gets executed. If the user's request to pull funds into this contract gets front-run, then there won't be + * avenues for a blackhat to exploit the user because they'd still need to make a call on the SpokePoolPeriphery + * contract through this contract but they wouldn't have access to the user's signature. * @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 SpokePoolPeripheryApproveProxy is Lockable { +contract SpokePoolPeripheryProxy is Lockable, MultiCaller { using SafeERC20 for IERC20; + using Address for address; - error LeftoverInputTokens(); + 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 constructor argument. - SpokePoolV3Periphery public immutable SPOKE_POOL_PERIPHERY; + SpokePoolV3Periphery public SPOKE_POOL_PERIPHERY; - constructor(SpokePoolV3Periphery _spokePoolPeriphery) { + error LeftoverInputTokens(); + error InvalidPeriphery(); + error ContractInitialized(); + + /** + * @notice Construct a new Proxy 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(); SPOKE_POOL_PERIPHERY = _spokePoolPeriphery; } @@ -59,10 +83,221 @@ contract SpokePoolPeripheryApproveProxy is Lockable { uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, SpokePoolV3Periphery.DepositData calldata depositData - ) external payable nonReentrant { + ) external nonReentrant { IERC20(swapToken).safeTransferFrom(msg.sender, address(this), swapTokenAmount); + _callSwapAndBridge( + swapToken, + acrossInputToken, + exchange, + routerCalldata, + swapTokenAmount, + minExpectedInputTokenAmount, + depositData + ); + } + + /** + * @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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. + * @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 exchange Address of the exchange contract to call. + * @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. + * @param deadline Deadline before which the permit signature is valid. + * @param v v of the permit signature. + * @param r r of the permit signature. + * @param s s of the permit signature. + */ + function swapAndBridgeWithPermit( + IERC20Permit swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} + _callSwapAndBridge( + IERC20(address(swapToken)), + acrossInputToken, + exchange, + routerCalldata, + swapTokenAmount, + minExpectedInputTokenAmount, + depositData + ); + } + + /** + * @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 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 exchange Address of the exchange contract to call. + * @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. + * @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 v v of the EIP-3009 signature. + * @param r r of the EIP-3009 signature. + * @param s s of the EIP-3009 signature. + */ + function swapAndBridgeWithAuthorization( + IERC20Auth swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), + // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` + // in _swapAndBridge will revert. + swapToken.receiveWithAuthorization( + msg.sender, + address(this), + swapTokenAmount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + _callSwapAndBridge( + IERC20(address(swapToken)), + acrossInputToken, + exchange, + routerCalldata, + swapTokenAmount, + minExpectedInputTokenAmount, + depositData + ); + } + + /** + * @notice Deposits an ERC20 token into the Spoke Pool contract. + * @dev User should probably just call SpokePool.depositV3 directly to save marginal gas costs, + * but this is added here for convenience in case caller only wants to interface Across through a single + * address. + * @param acrossInputToken ERC20 token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @param depositData Specifies the Across deposit params to send. + */ + function depositERC20( + IERC20 acrossInputToken, + uint256 acrossInputAmount, + SpokePoolV3Periphery.DepositData calldata depositData + ) external nonReentrant { + IERC20(acrossInputToken).safeTransferFrom(msg.sender, address(this), acrossInputAmount); + _callDeposit(IERC20(address(acrossInputToken)), acrossInputAmount, depositData); + } + + /** + * @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 acrossInputToken EIP-2612 compliant token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @param depositData Specifies the Across deposit params to send. + * @param deadline Deadline before which the permit signature is valid. + * @param v v of the permit signature. + * @param r r of the permit signature. + * @param s s of the permit signature. + */ + function depositWithPermit( + IERC20Permit acrossInputToken, + uint256 acrossInputAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast IERC20Permit to an IERC20 type. + // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} + + _callDeposit(_acrossInputToken, acrossInputAmount, depositData); + } + + /** + * @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 acrossInputToken EIP-3009 compliant token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @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 v v of the EIP-3009 signature. + * @param r r of the EIP-3009 signature. + * @param s s of the EIP-3009 signature. + */ + function depositWithAuthorization( + IERC20Auth acrossInputToken, + uint256 acrossInputAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + acrossInputToken.receiveWithAuthorization( + msg.sender, + address(this), + acrossInputAmount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + _callDeposit(IERC20(address(acrossInputToken)), acrossInputAmount, depositData); + } + + function _callSwapAndBridge( + IERC20 swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData + ) internal { swapToken.forceApprove(address(SPOKE_POOL_PERIPHERY), swapTokenAmount); - SPOKE_POOL_PERIPHERY.swapAndBridge{ value: msg.value }( + SPOKE_POOL_PERIPHERY.swapAndBridge( swapToken, acrossInputToken, exchange, @@ -72,6 +307,15 @@ contract SpokePoolPeripheryApproveProxy is Lockable { depositData ); } + + function _callDeposit( + IERC20 acrossInputToken, + uint256 acrossInputAmount, + SpokePoolV3Periphery.DepositData calldata depositData + ) internal { + acrossInputToken.forceApprove(address(SPOKE_POOL_PERIPHERY), acrossInputAmount); + SPOKE_POOL_PERIPHERY.depositERC20(acrossInputToken, acrossInputAmount, depositData); + } } /** @@ -91,6 +335,12 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // Wrapped native token contract address. WETH9Interface public wrappedNativeToken; + // 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; + // Boolean indicating whether the contract is initialized. bool private initialized; @@ -143,7 +393,9 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { error ContractInitialized(); error InvalidMsgValue(); error InvalidSpokePool(); + error InvalidProxy(); error InvalidSwapToken(); + error NotProxy(); /** * @notice Construct a new SwapAndBridgeBase contract. @@ -154,22 +406,33 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { */ constructor() {} + modifier onlyProxy() { + if (msg.sender != proxy) revert NotProxy(); + _; + } + /** * @notice Initializes the SwapAndBridgeBase contract. - * @dev Only the owner can call this function. * @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. * @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) external nonReentrant { + function initialize( + V3SpokePoolInterface _spokePool, + WETH9Interface _wrappedNativeToken, + address _proxy + ) 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; } /** @@ -257,6 +520,9 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { if (address(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(); swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); } _swapAndBridge( @@ -271,183 +537,18 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { } /** - * @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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. - * @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 exchange Address of the exchange contract to call. - * @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. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. + * @notice Calls depositV3 on spokepool. + * @param acrossInputToken inputToken to deposit. + * @param acrossInputAmount inputAmount to deposit. + * @param depositData DepositData required to specify Across deposit. */ - function swapAndBridgeWithPermit( - IERC20Permit swapToken, + function depositERC20( IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - IERC20 _swapToken = IERC20(address(swapToken)); // Cast IERC20Permit to IERC20. - // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} - - _swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); - _swapAndBridge( - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - _swapToken, - acrossInputToken - ); - } - - /** - * @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 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 exchange Address of the exchange contract to call. - * @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. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ - function swapAndBridgeWithAuthorization( - IERC20Auth swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), - // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` - // in _swapAndBridge will revert. - swapToken.receiveWithAuthorization( - msg.sender, - address(this), - swapTokenAmount, - validAfter, - validBefore, - nonce, - v, - r, - s - ); - IERC20 _swapToken = IERC20(address(swapToken)); // Cast IERC20Auth to IERC20. - - _swapAndBridge( - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - _swapToken, - acrossInputToken - ); - } - - /** - * @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 acrossInputToken EIP-2612 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @param depositData Specifies the Across deposit params to send. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ - function depositWithPermit( - IERC20Permit acrossInputToken, uint256 acrossInputAmount, - DepositData calldata depositData, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast IERC20Permit to an IERC20 type. - // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} - - _acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); - _depositV3(_acrossInputToken, acrossInputAmount, depositData); - } - - /** - * @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 acrossInputToken EIP-3009 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ - function depositWithAuthorization( - IERC20Auth acrossInputToken, - uint256 acrossInputAmount, - DepositData calldata depositData, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - acrossInputToken.receiveWithAuthorization( - msg.sender, - address(this), - acrossInputAmount, - validAfter, - validBefore, - nonce, - v, - r, - s - ); - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast the input token to an IERC20. - _depositV3(_acrossInputToken, acrossInputAmount, depositData); + DepositData calldata depositData + ) external nonReentrant onlyProxy { + acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); + _depositV3(acrossInputToken, acrossInputAmount, depositData); } /** @@ -461,7 +562,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint256 _acrossInputAmount, DepositData calldata depositData ) private { - _acrossInputToken.safeIncreaseAllowance(address(spokePool), _acrossInputAmount); + _acrossInputToken.forceApprove(address(spokePool), _acrossInputAmount); spokePool.depositV3( depositData.depositor, depositData.recipient, @@ -492,7 +593,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint256 srcBalanceBefore = _swapToken.balanceOf(address(this)); uint256 dstBalanceBefore = _acrossInputToken.balanceOf(address(this)); - _swapToken.safeIncreaseAllowance(exchange, swapTokenAmount); + _swapToken.forceApprove(exchange, swapTokenAmount); // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory result) = exchange.call(routerCalldata); require(success, string(result)); @@ -549,4 +650,8 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // Deposit the swapped tokens into Across and bridge them using remainder of input params. _depositV3(_acrossInputToken, returnAmount, depositData); } + + function _calledByProxy() internal view { + if (msg.sender != proxy) revert NotProxy(); + } } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index 6ff0f703d..fd8f0a4a3 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; import { SpokePoolVerifier } from "../../../../contracts/SpokePoolVerifier.sol"; -import { SpokePoolV3Periphery, SpokePoolPeripheryApproveProxy } from "../../../../contracts/SpokePoolV3Periphery.sol"; +import { SpokePoolV3Periphery, SpokePoolPeripheryProxy } from "../../../../contracts/SpokePoolV3Periphery.sol"; import { Ethereum_SpokePool } from "../../../../contracts/Ethereum_SpokePool.sol"; import { V3SpokePoolInterface } from "../../../../contracts/interfaces/V3SpokePoolInterface.sol"; import { WETH9 } from "../../../../contracts/external/WETH9.sol"; @@ -28,7 +28,7 @@ contract Exchange { contract SpokePoolPeripheryTest is Test { Ethereum_SpokePool ethereumSpokePool; SpokePoolV3Periphery spokePoolPeriphery; - SpokePoolPeripheryApproveProxy approveProxy; + SpokePoolPeripheryProxy proxy; Exchange dex; Exchange cex; @@ -57,42 +57,48 @@ contract SpokePoolPeripheryTest is Test { vm.startPrank(owner); spokePoolPeriphery = new SpokePoolV3Periphery(); - approveProxy = new SpokePoolPeripheryApproveProxy(spokePoolPeriphery); + proxy = new SpokePoolPeripheryProxy(); + proxy.initialize(spokePoolPeriphery); Ethereum_SpokePool implementation = new Ethereum_SpokePool( address(mockWETH), fillDeadlineBuffer, fillDeadlineBuffer ); - address proxy = address( + address spokePoolProxy = address( new ERC1967Proxy(address(implementation), abi.encodeCall(Ethereum_SpokePool.initialize, (0, owner))) ); - ethereumSpokePool = Ethereum_SpokePool(payable(proxy)); + ethereumSpokePool = Ethereum_SpokePool(payable(spokePoolProxy)); ethereumSpokePool.setEnableRoute(address(mockWETH), destinationChainId, true); ethereumSpokePool.setEnableRoute(address(mockERC20), destinationChainId, true); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy)); vm.stopPrank(); deal(depositor, mintAmount); deal(address(mockERC20), depositor, mintAmount, true); deal(address(mockERC20), address(dex), depositAmount, true); - // deal(address(mockWETH), address(dex), depositAmount, true); vm.startPrank(depositor); mockWETH.deposit{ value: mintAmount }(); - mockERC20.approve(address(spokePoolPeriphery), mintAmount); - IERC20(address(mockWETH)).approve(address(spokePoolPeriphery), mintAmount); - IERC20(address(mockWETH)).approve(address(approveProxy), mintAmount); + mockERC20.approve(address(proxy), mintAmount); + IERC20(address(mockWETH)).approve(address(proxy), mintAmount); vm.stopPrank(); } - function testInitialize() public { + function testInitializePeriphery() public { SpokePoolV3Periphery _spokePoolPeriphery = new SpokePoolV3Periphery(); - _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); - + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy)); assertEq(address(_spokePoolPeriphery.spokePool()), address(ethereumSpokePool)); assertEq(address(_spokePoolPeriphery.wrappedNativeToken()), address(mockWETH)); - + assertEq(address(_spokePoolPeriphery.proxy()), address(proxy)); vm.expectRevert(SpokePoolV3Periphery.ContractInitialized.selector); - _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH); + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy)); + } + + function testInitializeProxy() public { + SpokePoolPeripheryProxy _proxy = new SpokePoolPeripheryProxy(); + _proxy.initialize(spokePoolPeriphery); + assertEq(address(_proxy.SPOKE_POOL_PERIPHERY()), address(spokePoolPeriphery)); + vm.expectRevert(SpokePoolPeripheryProxy.ContractInitialized.selector); + _proxy.initialize(spokePoolPeriphery); } function testSwapAndBridge() public { @@ -114,7 +120,7 @@ contract SpokePoolPeripheryTest is Test { address(0), // exclusiveRelayer new bytes(0) ); - spokePoolPeriphery.swapAndBridge( + proxy.swapAndBridge( IERC20(address(mockWETH)), // swapToken IERC20(mockERC20), // acrossInputToken address(dex), @@ -145,6 +151,8 @@ contract SpokePoolPeripheryTest is Test { } 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 @@ -196,9 +204,7 @@ contract SpokePoolPeripheryTest is Test { vm.stopPrank(); } - function testDepositWithValue() public { - deal(depositor, mintAmount); - + function testDeposit() public { // Should emit expected deposit event vm.startPrank(depositor); vm.expectEmit(address(ethereumSpokePool)); @@ -217,31 +223,39 @@ contract SpokePoolPeripheryTest is Test { address(0), // 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) + proxy.depositERC20( + IERC20(address(mockWETH)), // inputToken + mintAmount, // inputAmount + SpokePoolV3Periphery.DepositData({ + outputToken: address(0), + outputAmount: mintAmount, + depositor: depositor, + recipient: depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }) ); vm.stopPrank(); } - function testApproveProxy() public { + 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.V3FundsDeposited( - address(mockERC20), + address(mockWETH), address(0), - depositAmount, - depositAmount, + mintAmount, + mintAmount, destinationChainId, 0, // depositId uint32(block.timestamp), @@ -252,31 +266,17 @@ contract SpokePoolPeripheryTest is Test { address(0), // exclusiveRelayer new bytes(0) ); - approveProxy.swapAndBridge( - IERC20(address(mockWETH)), // swapToken - IERC20(mockERC20), // acrossInputToken - address(dex), - abi.encodeWithSelector( - dex.swap.selector, - IERC20(address(mockWETH)), - IERC20(mockERC20), - mintAmount, - depositAmount - ), - mintAmount, // swapTokenAmount - depositAmount, // minExpectedInputTokenAmount - SpokePoolV3Periphery.DepositData({ - outputToken: address(0), - outputAmount: depositAmount, - depositor: depositor, - recipient: depositor, - destinationChainId: destinationChainId, - exclusiveRelayer: address(0), - quoteTimestamp: uint32(block.timestamp), - fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, - exclusivityParameter: 0, - message: 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(); From 5494ee58181759cfd0f591e7b58bef430ab29d7a Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 3 Dec 2024 12:34:00 -0500 Subject: [PATCH 14/27] Update SpokePoolV3Periphery.sol --- contracts/SpokePoolV3Periphery.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 057c9732f..cdf00c996 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -96,6 +96,11 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { ); } + // TODO: Consider moving swapAndBridgeWithPermit, swapAndBridgeWithPermit2 + // and swapAndBridgeWithAuthorization to SpokePoolPeriphery because there is no way for a third party to call those + // functions to "steal" a user's funds. swapAndBridge() on the periphery contract can only be used to steal a + // user's approval. + /** * @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. From b6db47bf4314469a3579d5adec87984912d3f38c Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 3 Dec 2024 12:46:16 -0500 Subject: [PATCH 15/27] remove depositERC20 --- contracts/SpokePoolV3Periphery.sol | 22 ++--------- .../foundry/local/SpokePoolPeriphery.t.sol | 39 ------------------- 2 files changed, 4 insertions(+), 57 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index cdf00c996..129217907 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -207,24 +207,6 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { ); } - /** - * @notice Deposits an ERC20 token into the Spoke Pool contract. - * @dev User should probably just call SpokePool.depositV3 directly to save marginal gas costs, - * but this is added here for convenience in case caller only wants to interface Across through a single - * address. - * @param acrossInputToken ERC20 token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @param depositData Specifies the Across deposit params to send. - */ - function depositERC20( - IERC20 acrossInputToken, - uint256 acrossInputAmount, - SpokePoolV3Periphery.DepositData calldata depositData - ) external nonReentrant { - IERC20(acrossInputToken).safeTransferFrom(msg.sender, address(this), acrossInputAmount); - _callDeposit(IERC20(address(acrossInputToken)), acrossInputAmount, depositData); - } - /** * @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. @@ -319,6 +301,8 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { SpokePoolV3Periphery.DepositData calldata depositData ) internal { acrossInputToken.forceApprove(address(SPOKE_POOL_PERIPHERY), acrossInputAmount); + // Periphery.depositERC20 sets msg.sender as the spender in a safeTransferFrom call so there is no way + // for the caller to use this method to steal a third party user's funds. SPOKE_POOL_PERIPHERY.depositERC20(acrossInputToken, acrossInputAmount, depositData); } } @@ -553,6 +537,8 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { DepositData calldata depositData ) external nonReentrant onlyProxy { acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); + // _depositV3 calls spokePool.depositV3 which calls safeTransferFrom with the spender as the msg.sender + // so there is no way for someone to use this function to deposit using a third party's funds. _depositV3(acrossInputToken, acrossInputAmount, depositData); } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index fd8f0a4a3..39cdcf39a 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -204,45 +204,6 @@ contract SpokePoolPeripheryTest is Test { vm.stopPrank(); } - function testDeposit() public { - // Should emit expected deposit event - vm.startPrank(depositor); - vm.expectEmit(address(ethereumSpokePool)); - emit V3SpokePoolInterface.V3FundsDeposited( - address(mockWETH), - address(0), - mintAmount, - mintAmount, - destinationChainId, - 0, // depositId - uint32(block.timestamp), - uint32(block.timestamp) + fillDeadlineBuffer, - 0, // exclusivityDeadline - depositor, - depositor, - address(0), // exclusiveRelayer - new bytes(0) - ); - proxy.depositERC20( - IERC20(address(mockWETH)), // inputToken - mintAmount, // inputAmount - SpokePoolV3Periphery.DepositData({ - outputToken: address(0), - outputAmount: mintAmount, - depositor: depositor, - recipient: depositor, - destinationChainId: destinationChainId, - exclusiveRelayer: address(0), - quoteTimestamp: uint32(block.timestamp), - fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, - exclusivityParameter: 0, - message: new bytes(0) - }) - ); - - 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. From d0a9d0fbfa199d622eccd73ed5d822133ba45d84 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 4 Dec 2024 11:06:49 -0500 Subject: [PATCH 16/27] Update SpokePoolV3Periphery.sol --- contracts/SpokePoolV3Periphery.sol | 388 +++++++++++++---------------- 1 file changed, 175 insertions(+), 213 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 129217907..ae25a27c1 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -13,14 +13,12 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; /** * @title SpokePoolProxy - * @notice User should only call the SpokePoolV3Periphery contract functions through this - * contract. This is purposefully a simple passthrough contract so that the user only approves (or permits or - * authorizes) this contract to pull its assets while the SpokePoolV3Periphery contract can be used to call - * any calldata on any exchange that the user wants to. By separating the contract that pulls user funds from the - * contract that executes arbitrary calldata, the SpokePoolPeriphery does not need to validate the calldata - * that gets executed. If the user's request to pull funds into this contract gets front-run, then there won't be - * avenues for a blackhat to exploit the user because they'd still need to make a call on the SpokePoolPeriphery - * contract through this contract but they wouldn't have access to the user's signature. + * @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. @@ -96,184 +94,6 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { ); } - // TODO: Consider moving swapAndBridgeWithPermit, swapAndBridgeWithPermit2 - // and swapAndBridgeWithAuthorization to SpokePoolPeriphery because there is no way for a third party to call those - // functions to "steal" a user's funds. swapAndBridge() on the periphery contract can only be used to steal a - // user's approval. - - /** - * @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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. - * @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 exchange Address of the exchange contract to call. - * @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. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ - function swapAndBridgeWithPermit( - IERC20Permit swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} - _callSwapAndBridge( - IERC20(address(swapToken)), - acrossInputToken, - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData - ); - } - - /** - * @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 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 exchange Address of the exchange contract to call. - * @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. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ - function swapAndBridgeWithAuthorization( - IERC20Auth swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), - // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` - // in _swapAndBridge will revert. - swapToken.receiveWithAuthorization( - msg.sender, - address(this), - swapTokenAmount, - validAfter, - validBefore, - nonce, - v, - r, - s - ); - _callSwapAndBridge( - IERC20(address(swapToken)), - acrossInputToken, - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData - ); - } - - /** - * @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 acrossInputToken EIP-2612 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @param depositData Specifies the Across deposit params to send. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ - function depositWithPermit( - IERC20Permit acrossInputToken, - uint256 acrossInputAmount, - SpokePoolV3Periphery.DepositData calldata depositData, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast IERC20Permit to an IERC20 type. - // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} - - _callDeposit(_acrossInputToken, acrossInputAmount, depositData); - } - - /** - * @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 acrossInputToken EIP-3009 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ - function depositWithAuthorization( - IERC20Auth acrossInputToken, - uint256 acrossInputAmount, - SpokePoolV3Periphery.DepositData calldata depositData, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - acrossInputToken.receiveWithAuthorization( - msg.sender, - address(this), - acrossInputAmount, - validAfter, - validBefore, - nonce, - v, - r, - s - ); - _callDeposit(IERC20(address(acrossInputToken)), acrossInputAmount, depositData); - } - function _callSwapAndBridge( IERC20 swapToken, IERC20 acrossInputToken, @@ -294,17 +114,6 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { depositData ); } - - function _callDeposit( - IERC20 acrossInputToken, - uint256 acrossInputAmount, - SpokePoolV3Periphery.DepositData calldata depositData - ) internal { - acrossInputToken.forceApprove(address(SPOKE_POOL_PERIPHERY), acrossInputAmount); - // Periphery.depositERC20 sets msg.sender as the spender in a safeTransferFrom call so there is no way - // for the caller to use this method to steal a third party user's funds. - SPOKE_POOL_PERIPHERY.depositERC20(acrossInputToken, acrossInputAmount, depositData); - } } /** @@ -395,11 +204,6 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { */ constructor() {} - modifier onlyProxy() { - if (msg.sender != proxy) revert NotProxy(); - _; - } - /** * @notice Initializes the SwapAndBridgeBase contract. * @param _spokePool Address of the SpokePool contract that we'll submit deposits to. @@ -481,6 +285,8 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { /** * @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 swapToken Address of the token that will be swapped for acrossInputToken. @@ -526,20 +332,176 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { } /** - * @notice Calls depositV3 on spokepool. - * @param acrossInputToken inputToken to deposit. - * @param acrossInputAmount inputAmount to deposit. - * @param depositData DepositData required to specify Across deposit. + * @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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. + * @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 exchange Address of the exchange contract to call. + * @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. + * @param deadline Deadline before which the permit signature is valid. + * @param v v of the permit signature. + * @param r r of the permit signature. + * @param s s of the permit signature. + */ + function swapAndBridgeWithPermit( + IERC20Permit swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + DepositData calldata depositData, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} + _swapAndBridge( + exchange, + routerCalldata, + swapTokenAmount, + minExpectedInputTokenAmount, + depositData, + IERC20(address(swapToken)), + acrossInputToken + ); + } + + /** + * @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 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 exchange Address of the exchange contract to call. + * @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. + * @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 v v of the EIP-3009 signature. + * @param r r of the EIP-3009 signature. + * @param s s of the EIP-3009 signature. */ - function depositERC20( + function swapAndBridgeWithAuthorization( + IERC20Auth swapToken, IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), + // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` + // in _swapAndBridge will revert. + swapToken.receiveWithAuthorization( + msg.sender, + address(this), + swapTokenAmount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + _swapAndBridge( + exchange, + routerCalldata, + swapTokenAmount, + minExpectedInputTokenAmount, + depositData, + IERC20(address(swapToken)), + acrossInputToken + ); + } + + /** + * @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 acrossInputToken EIP-2612 compliant token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @param depositData Specifies the Across deposit params to send. + * @param deadline Deadline before which the permit signature is valid. + * @param v v of the permit signature. + * @param r r of the permit signature. + * @param s s of the permit signature. + */ + function depositWithPermit( + IERC20Permit acrossInputToken, uint256 acrossInputAmount, - DepositData calldata depositData - ) external nonReentrant onlyProxy { - acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); - // _depositV3 calls spokePool.depositV3 which calls safeTransferFrom with the spender as the msg.sender - // so there is no way for someone to use this function to deposit using a third party's funds. - _depositV3(acrossInputToken, acrossInputAmount, depositData); + DepositData calldata depositData, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast IERC20Permit to an IERC20 type. + // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} + + _depositV3(_acrossInputToken, acrossInputAmount, depositData); + } + + /** + * @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 acrossInputToken EIP-3009 compliant token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @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 v v of the EIP-3009 signature. + * @param r r of the EIP-3009 signature. + * @param s s of the EIP-3009 signature. + */ + function depositWithAuthorization( + IERC20Auth acrossInputToken, + uint256 acrossInputAmount, + DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant { + acrossInputToken.receiveWithAuthorization( + msg.sender, + address(this), + acrossInputAmount, + validAfter, + validBefore, + nonce, + v, + r, + s + ); + _depositV3(IERC20(address(acrossInputToken)), acrossInputAmount, depositData); } /** From 663580318af289d60f2f6028f1a57bd8650c4359 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 4 Dec 2024 11:11:16 -0500 Subject: [PATCH 17/27] Add back safeTransferFron's to permit funcs --- contracts/SpokePoolV3Periphery.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index ae25a27c1..02cd938b3 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -366,13 +366,15 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} + IERC20 _swapToken = IERC20(address(swapToken)); + _swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); _swapAndBridge( exchange, routerCalldata, swapTokenAmount, minExpectedInputTokenAmount, depositData, - IERC20(address(swapToken)), + _swapToken, acrossInputToken ); } @@ -457,12 +459,12 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { bytes32 r, bytes32 s ) external nonReentrant { - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast IERC20Permit to an IERC20 type. // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} - + IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); + _acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); _depositV3(_acrossInputToken, acrossInputAmount, depositData); } From 0de384eb4c64a9008388d3980a4fdcd823953915 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 4 Dec 2024 21:22:54 -0500 Subject: [PATCH 18/27] Add unit tests that check if calling deposit and swapAndBridge with no value fails directly --- .../foundry/local/SpokePoolPeriphery.t.sol | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index 39cdcf39a..637a78c68 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -150,6 +150,40 @@ contract SpokePoolPeripheryTest is Test { vm.stopPrank(); } + function testSwapAndBridgeNoValueNoProxy() public { + // Cannot call swapAndBridge with no value directly. + vm.startPrank(depositor); + vm.expectRevert(SpokePoolV3Periphery.NotProxy.selector); + spokePoolPeriphery.swapAndBridge( + IERC20(address(mockWETH)), // swapToken + IERC20(mockERC20), // acrossInputToken + address(dex), + abi.encodeWithSelector( + dex.swap.selector, + IERC20(address(mockWETH)), + IERC20(mockERC20), + mintAmount, + depositAmount + ), + mintAmount, // swapTokenAmount + depositAmount, // minExpectedInputTokenAmount + SpokePoolV3Periphery.DepositData({ + outputToken: address(0), + outputAmount: depositAmount, + depositor: depositor, + recipient: depositor, + destinationChainId: destinationChainId, + exclusiveRelayer: address(0), + quoteTimestamp: uint32(block.timestamp), + fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, + exclusivityParameter: 0, + message: new bytes(0) + }) + ); + + 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. @@ -242,4 +276,24 @@ contract SpokePoolPeripheryTest is Test { 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(); + } } From 100e707550f7d2b7cb9884b0f183fe5661e03d40 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 4 Dec 2024 21:23:16 -0500 Subject: [PATCH 19/27] Add interfaces to make sure we don't add new functions as easily --- contracts/SpokePoolV3Periphery.sol | 19 +- .../SpokePoolV3PeripheryInterface.sol | 218 ++++++++++++++++++ 2 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 contracts/interfaces/SpokePoolV3PeripheryInterface.sol diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 02cd938b3..71bd5230a 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -10,6 +10,7 @@ import { Lockable } from "./Lockable.sol"; import { V3SpokePoolInterface } from "./interfaces/V3SpokePoolInterface.sol"; import { IERC20Auth } from "./external/interfaces/IERC20Auth.sol"; import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; +import { SpokePoolV3PeripheryProxyInterface, SpokePoolV3PeripheryInterface } from "./interfaces/SpokePoolV3PeripheryInterface.sol"; /** * @title SpokePoolProxy @@ -23,7 +24,7 @@ import { WETH9Interface } from "./external/interfaces/WETH9Interface.sol"; * 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 Lockable, MultiCaller { +contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable, MultiCaller { using SafeERC20 for IERC20; using Address for address; @@ -81,7 +82,7 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, SpokePoolV3Periphery.DepositData calldata depositData - ) external nonReentrant { + ) external override nonReentrant { IERC20(swapToken).safeTransferFrom(msg.sender, address(this), swapTokenAmount); _callSwapAndBridge( swapToken, @@ -123,7 +124,7 @@ contract SpokePoolPeripheryProxy is Lockable, MultiCaller { * contract may be deployed deterministically at the same address across different networks. * @custom:security-contact bugs@across.to */ -contract SpokePoolV3Periphery is Lockable, MultiCaller { +contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiCaller { using SafeERC20 for IERC20; using Address for address; @@ -260,7 +261,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint32 fillDeadline, uint32 exclusivityParameter, bytes memory message - ) external payable nonReentrant { + ) 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. @@ -307,7 +308,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint256 swapTokenAmount, uint256 minExpectedInputTokenAmount, DepositData calldata depositData - ) external payable nonReentrant { + ) 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) { @@ -361,7 +362,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant { + ) external override nonReentrant { // 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. @@ -413,7 +414,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant { + ) external override nonReentrant { // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` // in _swapAndBridge will revert. @@ -458,7 +459,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant { + ) external override nonReentrant { // 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. @@ -491,7 +492,7 @@ contract SpokePoolV3Periphery is Lockable, MultiCaller { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant { + ) external override nonReentrant { acrossInputToken.receiveWithAuthorization( msg.sender, address(this), diff --git a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol new file mode 100644 index 000000000..2cda7f54d --- /dev/null +++ b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol @@ -0,0 +1,218 @@ +//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"; + +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 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 exchange Address of the exchange contract to call. + * @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, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData + ) 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 { + /** + * @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 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 exchange Address of the exchange contract to call. + * @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, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData + ) 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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. + * @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 exchange Address of the exchange contract to call. + * @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. + * @param deadline Deadline before which the permit signature is valid. + * @param v v of the permit signature. + * @param r r of the permit signature. + * @param s s of the permit signature. + */ + function swapAndBridgeWithPermit( + IERC20Permit swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) 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 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 exchange Address of the exchange contract to call. + * @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. + * @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 v v of the EIP-3009 signature. + * @param r r of the EIP-3009 signature. + * @param s s of the EIP-3009 signature. + */ + function swapAndBridgeWithAuthorization( + IERC20Auth swapToken, + IERC20 acrossInputToken, + address exchange, + bytes calldata routerCalldata, + uint256 swapTokenAmount, + uint256 minExpectedInputTokenAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) 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 acrossInputToken EIP-2612 compliant token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @param depositData Specifies the Across deposit params to send. + * @param deadline Deadline before which the permit signature is valid. + * @param v v of the permit signature. + * @param r r of the permit signature. + * @param s s of the permit signature. + */ + function depositWithPermit( + IERC20Permit acrossInputToken, + uint256 acrossInputAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) 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 acrossInputToken EIP-3009 compliant token to deposit. + * @param acrossInputAmount Amount of the input token to deposit. + * @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 v v of the EIP-3009 signature. + * @param r r of the EIP-3009 signature. + * @param s s of the EIP-3009 signature. + */ + function depositWithAuthorization( + IERC20Auth acrossInputToken, + uint256 acrossInputAmount, + SpokePoolV3Periphery.DepositData calldata depositData, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} From 6db7d87b23924385680d9326ae52a96db82995d8 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Thu, 5 Dec 2024 21:53:54 -0500 Subject: [PATCH 20/27] Add Create2Factory --- contracts/Create2Factory.sol | 36 +++++++++++++++++++++ contracts/SpokePoolV3Periphery.sol | 2 +- contracts/SwapAndBridge.sol | 2 +- test/evm/foundry/local/Create2Factory.t.sol | 35 ++++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 contracts/Create2Factory.sol create mode 100644 test/evm/foundry/local/Create2Factory.t.sol 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/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 71bd5230a..5c790b382 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -1,4 +1,4 @@ -//SPDX-License-Identifier: Unlicense +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/contracts/SwapAndBridge.sol b/contracts/SwapAndBridge.sol index 4c6cf84d7..df5eff4d1 100644 --- a/contracts/SwapAndBridge.sol +++ b/contracts/SwapAndBridge.sol @@ -1,4 +1,4 @@ -//SPDX-License-Identifier: Unlicense +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; import "./interfaces/V3SpokePoolInterface.sol"; 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()); + } +} From 022a8ec05aa916c53200e72d96f86ffb69fb942a Mon Sep 17 00:00:00 2001 From: bmzig <57361391+bmzig@users.noreply.github.com> Date: Fri, 6 Dec 2024 17:33:37 -0600 Subject: [PATCH 21/27] feat: add permit2 entrypoints to the periphery (#782) * feat: add permit2 entrypoints to the periphery Signed-off-by: Bennett * Update test/evm/foundry/local/SpokePoolPeriphery.t.sol * Update SpokePoolPeriphery.t.sol * move permit2 to proxy * fix permit2 Signed-off-by: bennett * wip: swap arguments refactor Signed-off-by: bennett * implement isValidSignature Signed-off-by: bennett * 1271 Signed-off-by: bennett * simplify isValidSignature Signed-off-by: bennett * rebase /programs on master Signed-off-by: nicholaspai * clean up comments * rebase programs * fix: consolidate structs so that permit2 witnesses cover inputs Signed-off-by: bennett * begin permit2 unit tests Signed-off-by: bennett * rebase * Update SpokePoolPeriphery.t.sol * move type definitions to interface Signed-off-by: bennett * fix permit2 test Signed-off-by: bennett * transfer type tests Signed-off-by: bennett * rename EIP1271Signature to Permi2Approval Signed-off-by: bennett --------- Signed-off-by: Bennett Signed-off-by: bennett Signed-off-by: nicholaspai Co-authored-by: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Co-authored-by: nicholaspai --- contracts/SpokePoolV3Periphery.sol | 598 ++++++++++-------- contracts/external/interfaces/IPermit2.sol | 19 + .../SpokePoolV3PeripheryInterface.sol | 260 +++----- contracts/libraries/PeripherySigningLib.sol | 155 +++++ contracts/test/MockPermit2.sol | 206 ++++++ .../foundry/local/SpokePoolPeriphery.t.sol | 358 ++++++++--- 6 files changed, 1076 insertions(+), 520 deletions(-) create mode 100644 contracts/libraries/PeripherySigningLib.sol create mode 100644 contracts/test/MockPermit2.sol diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 5c790b382..a324750ec 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -10,10 +10,12 @@ import { Lockable } from "./Lockable.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 SpokePoolProxy + * @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 @@ -28,6 +30,7 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable 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, @@ -35,7 +38,6 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable // since the periphery address is the only constructor argument. SpokePoolV3Periphery public SPOKE_POOL_PERIPHERY; - error LeftoverInputTokens(); error InvalidPeriphery(); error ContractInitialized(); @@ -64,56 +66,28 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable * 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 exchange Address of the exchange contract to call. - * @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. + * @param swapAndDepositData Specifies the params we need to perform a swap on a generic exchange. */ - function swapAndBridge( - IERC20 swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData - ) external override nonReentrant { - IERC20(swapToken).safeTransferFrom(msg.sender, address(this), swapTokenAmount); - _callSwapAndBridge( - swapToken, - acrossInputToken, - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData - ); + function swapAndBridge(SpokePoolV3PeripheryInterface.SwapAndDepositData calldata swapAndDepositData) + external + override + nonReentrant + { + _callSwapAndBridge(swapAndDepositData); } - function _callSwapAndBridge( - IERC20 swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData - ) internal { - swapToken.forceApprove(address(SPOKE_POOL_PERIPHERY), swapTokenAmount); - SPOKE_POOL_PERIPHERY.swapAndBridge( - swapToken, - acrossInputToken, - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData - ); + /** + * @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(SPOKE_POOL_PERIPHERY), _swapTokenAmount); + SPOKE_POOL_PERIPHERY.swapAndBridge(swapAndDepositData); } } @@ -134,44 +108,30 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC // 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; - // 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 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; - } + // 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, @@ -187,9 +147,11 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC /**************************************** * ERRORS * ****************************************/ + error InvalidPermit2(); + error ContractInitialized(); + error InvalidSignatureLength(); error MinimumExpectedInputAmount(); error LeftoverSrcTokens(); - error ContractInitialized(); error InvalidMsgValue(); error InvalidSpokePool(); error InvalidProxy(); @@ -197,7 +159,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC error NotProxy(); /** - * @notice Construct a new SwapAndBridgeBase contract. + * @notice Construct a new Proxy 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 @@ -210,6 +172,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC * @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. @@ -217,7 +180,8 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC function initialize( V3SpokePoolInterface _spokePool, WETH9Interface _wrappedNativeToken, - address _proxy + address _proxy, + IPermit2 _permit2 ) external nonReentrant { if (initialized) revert ContractInitialized(); initialized = true; @@ -227,6 +191,8 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC wrappedNativeToken = _wrappedNativeToken; if (!_proxy.isContract()) revert InvalidProxy(); proxy = _proxy; + if (!address(_permit2).isContract()) revert InvalidPermit2(); + permit2 = _permit2; } /** @@ -290,138 +256,113 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC * 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 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 exchange Address of the exchange contract to call. - * @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. + * @param swapAndDepositData Specifies the data needed to perform a swap on a generic exchange. */ - function swapAndBridge( - IERC20 swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData - ) external payable override nonReentrant { + 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 != swapTokenAmount) revert InvalidMsgValue(); - if (address(swapToken) != address(wrappedNativeToken)) revert InvalidSwapToken(); + 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(); - swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); + IERC20(swapAndDepositData.swapToken).safeTransferFrom( + msg.sender, + address(this), + swapAndDepositData.swapTokenAmount + ); } - _swapAndBridge( - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - swapToken, - acrossInputToken - ); + _swapAndBridge(swapAndDepositData); } /** * @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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. - * @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 exchange Address of the exchange contract to call. - * @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. + * @dev If the swapToken in swapData does not implement `permit` to the specifications of EIP-2612, this function will fail. + * @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 v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. + * @param permitSignature Permit signature encoded as (bytes32 r, bytes32 s, uint8 v). */ function swapAndBridgeWithPermit( - IERC20Permit swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, + SwapAndDepositData calldata swapAndDepositData, uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata permitSignature ) 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; + // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} - IERC20 _swapToken = IERC20(address(swapToken)); - _swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); - _swapAndBridge( - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - _swapToken, - acrossInputToken + try IERC20Permit(_swapToken).permit(msg.sender, address(this), _swapTokenAmount, deadline, v, r, s) {} catch {} + IERC20(_swapToken).safeTransferFrom(msg.sender, address(this), _swapTokenAmount); + + _swapAndBridge(swapAndDepositData); + } + + /** + * @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 override nonReentrant { + bytes32 witness = PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData); + IPermit2.SignatureTransferDetails memory transferDetails = IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: swapAndDepositData.swapTokenAmount + }); + + permit2.permitWitnessTransferFrom( + permit, + transferDetails, + signatureOwner, + witness, + PeripherySigningLib.EIP712_SWAP_AND_DEPOSIT_TYPE_STRING, + signature ); + _swapAndBridge(swapAndDepositData); } /** * @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 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 exchange Address of the exchange contract to call. - * @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. + * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. + * @param receiveWithAuthSignature EIP3009 signature encoded adepositors (bytes32 r, bytes32 s, uint8 v). */ function swapAndBridgeWithAuthorization( - IERC20Auth swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, + SwapAndDepositData calldata swapAndDepositData, uint256 validAfter, uint256 validBefore, bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata receiveWithAuthSignature ) external override nonReentrant { + (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(receiveWithAuthSignature); // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), - // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` - // in _swapAndBridge will revert. - swapToken.receiveWithAuthorization( + // 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( msg.sender, address(this), - swapTokenAmount, + swapAndDepositData.swapTokenAmount, validAfter, validBefore, nonce, @@ -429,74 +370,119 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC r, s ); - _swapAndBridge( - exchange, - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - IERC20(address(swapToken)), - acrossInputToken - ); + + _swapAndBridge(swapAndDepositData); } /** * @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 acrossInputToken EIP-2612 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. * @param depositData Specifies the Across deposit params to send. * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. + * @param permitSignature Permit signature encoded as (bytes32 r, bytes32 s, uint8 v). */ function depositWithPermit( - IERC20Permit acrossInputToken, - uint256 acrossInputAmount, DepositData calldata depositData, uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata permitSignature ) 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; + // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); - _acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); - _depositV3(_acrossInputToken, acrossInputAmount, depositData); + try IERC20Permit(_inputToken).permit(msg.sender, address(this), _inputAmount, deadline, v, r, s) {} catch {} + IERC20(_inputToken).safeTransferFrom(msg.sender, address(this), _inputAmount); + + _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 + ); + } + + /** + * @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 override nonReentrant { + bytes32 witness = PeripherySigningLib.hashDepositData(depositData); + IPermit2.SignatureTransferDetails memory transferDetails = IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: depositData.inputAmount + }); + + permit2.permitWitnessTransferFrom( + permit, + transferDetails, + signatureOwner, + witness, + PeripherySigningLib.EIP712_DEPOSIT_TYPE_STRING, + signature + ); + _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 + ); } /** * @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 acrossInputToken EIP-3009 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. + * @param receiveWithAuthSignature EIP3009 signature encoded as (bytes32 r, bytes32 s, uint8 v). */ function depositWithAuthorization( - IERC20Auth acrossInputToken, - uint256 acrossInputAmount, DepositData calldata depositData, uint256 validAfter, uint256 validBefore, bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata receiveWithAuthSignature ) external override nonReentrant { - acrossInputToken.receiveWithAuthorization( + // Load variables used multiple times onto the stack. + uint256 _inputAmount = depositData.inputAmount; + + // Redeem the receiveWithAuthSignature. + (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(receiveWithAuthSignature); + IERC20Auth(depositData.baseDepositData.inputToken).receiveWithAuthorization( msg.sender, address(this), - acrossInputAmount, + _inputAmount, validAfter, validBefore, nonce, @@ -504,109 +490,165 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC r, s ); - _depositV3(IERC20(address(acrossInputToken)), acrossInputAmount, depositData); + + _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 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. + * @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( - IERC20 _acrossInputToken, - uint256 _acrossInputAmount, - DepositData calldata depositData + 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 { - _acrossInputToken.forceApprove(address(spokePool), _acrossInputAmount); + IERC20(inputToken).forceApprove(address(spokePool), inputAmount); spokePool.depositV3( - depositData.depositor, - depositData.recipient, - address(_acrossInputToken), // input token - depositData.outputToken, // output token - _acrossInputAmount, // input amount. - depositData.outputAmount, // output amount - depositData.destinationChainId, - depositData.exclusiveRelayer, - depositData.quoteTimestamp, - depositData.fillDeadline, - depositData.exclusivityParameter, - depositData.message + depositor, + recipient, + inputToken, // input token + outputToken, // output token + inputAmount, // input amount. + outputAmount, // output amount + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityParameter, + message ); } - // 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( - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - IERC20 _swapToken, - IERC20 _acrossInputToken - ) private { + /** + * @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)); - _swapToken.forceApprove(exchange, swapTokenAmount); + // 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(routerCalldata); + (bool success, bytes memory result) = _exchange.call(swapAndDepositData.routerCalldata); require(success, string(result)); - _checkSwapOutputAndDeposit( - exchange, - routerCalldata, - 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( - address exchange, - bytes memory routerCalldata, - uint256 swapTokenAmount, - uint256 swapTokenBalanceBefore, - uint256 inputTokenBalanceBefore, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - IERC20 _swapToken, - IERC20 _acrossInputToken - ) private { // Sanity check that we received as many tokens as we require: - uint256 returnAmount = _acrossInputToken.balanceOf(address(this)) - inputTokenBalanceBefore; + uint256 returnAmount = _acrossInputToken.balanceOf(address(this)) - dstBalanceBefore; + // Sanity check that received amount from swap is enough to submit Across deposit with. - if (returnAmount < minExpectedInputTokenAmount) revert MinimumExpectedInputAmount(); + 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 (swapTokenBalanceBefore - _swapToken.balanceOf(address(this)) != swapTokenAmount) revert LeftoverSrcTokens(); + if (srcBalanceBefore - _swapToken.balanceOf(address(this)) != _swapTokenAmount) revert LeftoverSrcTokens(); emit SwapBeforeBridge( - exchange, - routerCalldata, + _exchange, + swapAndDepositData.routerCalldata, address(_swapToken), address(_acrossInputToken), - swapTokenAmount, + _swapTokenAmount, returnAmount, - depositData.outputToken, - depositData.outputAmount + swapAndDepositData.depositData.outputToken, + swapAndDepositData.depositData.outputAmount ); + // Deposit the swapped tokens into Across and bridge them using remainder of input params. - _depositV3(_acrossInputToken, returnAmount, depositData); + _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 + ); } + /** + * @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/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 index 2cda7f54d..a9af1ed4d 100644 --- a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol +++ b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol @@ -5,32 +5,11 @@ 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 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 exchange Address of the exchange contract to call. - * @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, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData - ) external; + function swapAndBridge(SpokePoolV3PeripheryInterface.SwapAndDepositData calldata swapAndDepositData) external; } /** @@ -41,27 +20,75 @@ interface SpokePoolV3PeripheryProxyInterface { * @custom:security-contact bugs@across.to */ interface SpokePoolV3PeripheryInterface { - /** - * @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. - */ + // 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 + } + + // 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 { + // 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 { + // Deposit data describing the parameters for the V3 Across deposit. + BaseDepositData baseDepositData; + // The precise input amount to deposit into the spoke pool. + uint256 inputAmount; + } + function deposit( address recipient, address inputToken, @@ -75,144 +102,47 @@ interface SpokePoolV3PeripheryInterface { 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 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 exchange Address of the exchange contract to call. - * @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, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData - ) external payable; + 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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. - * @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 exchange Address of the exchange contract to call. - * @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. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ function swapAndBridgeWithPermit( - IERC20Permit swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData, + SwapAndDepositData calldata swapAndDepositData, uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata permitSignature + ) external; + + 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 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 exchange Address of the exchange contract to call. - * @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. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ function swapAndBridgeWithAuthorization( - IERC20Auth swapToken, - IERC20 acrossInputToken, - address exchange, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - SpokePoolV3Periphery.DepositData calldata depositData, + SwapAndDepositData calldata swapAndDepositData, uint256 validAfter, uint256 validBefore, bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata receiveWithAuthSignature ) 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 acrossInputToken EIP-2612 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @param depositData Specifies the Across deposit params to send. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ function depositWithPermit( - IERC20Permit acrossInputToken, - uint256 acrossInputAmount, - SpokePoolV3Periphery.DepositData calldata depositData, + DepositData calldata depositData, uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata permitSignature + ) external; + + 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 acrossInputToken EIP-3009 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ function depositWithAuthorization( - IERC20Auth acrossInputToken, - uint256 acrossInputAmount, - SpokePoolV3Periphery.DepositData calldata depositData, + DepositData calldata depositData, uint256 validAfter, uint256 validBefore, bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s + bytes calldata receiveWithAuthSignature ) external; } diff --git a/contracts/libraries/PeripherySigningLib.sol b/contracts/libraries/PeripherySigningLib.sol new file mode 100644 index 000000000..eb6e55dd6 --- /dev/null +++ b/contracts/libraries/PeripherySigningLib.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { SpokePoolV3PeripheryInterface } from "../interfaces/SpokePoolV3PeripheryInterface.sol"; + +library PeripherySigningLib { + // Typed structured data for the structs to sign against in the periphery. + bytes internal constant EIP712_BASE_DEPOSIT_DATA_TYPE = + abi.encodePacked( + "BaseDepositData(", + "address inputToken", + "address outputToken", + "uint256 outputAmount", + "address depositor", + "address recipient", + "uint256 destinationChainId", + "address exclusiveRelayer", + "uint32 quoteTimestamp", + "uint32 fillDeadline", + "uint32 exclusivityParameter", + "bytes message)" + ); + bytes internal constant EIP712_DEPOSIT_DATA_TYPE = + abi.encodePacked("DepositData(BaseDepositData baseDepositData,uint256 inputAmount)"); + bytes internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPE = + abi.encodePacked( + "SwapAndDepositData(", + "BaseDepositData depositData", + "address swapToken", + "address exchange", + "TransferType transferType", + "uint256 swapTokenAmount", + "uint256 minExpectedInputTokenAmount", + "bytes routerCalldata)" + ); + + // EIP712 Type hashes. + bytes32 internal constant EIP712_DEPOSIT_DATA_TYPEHASH = + keccak256(abi.encode(EIP712_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); + bytes32 internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPEHASH = + keccak256(abi.encode(EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_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_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, + 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_TYPE, + 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 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_TYPE, + 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, + 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/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/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index 637a78c68..aee7a5970 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -7,30 +7,67 @@ 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 } from "../../../../contracts/test/MockPermit2.sol"; +import { PeripherySigningLib } from "../../../../contracts/libraries/PeripherySigningLib.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.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"; contract Exchange { + IPermit2 permit2; + + constructor(IPermit2 _permit2) { + permit2 = _permit2; + } + function swap( IERC20 tokenIn, IERC20 tokenOut, uint256 amountIn, - uint256 amountOutMin + 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); + } +} + contract SpokePoolPeripheryTest is Test { Ethereum_SpokePool ethereumSpokePool; + HashUtils hashUtils; SpokePoolV3Periphery spokePoolPeriphery; SpokePoolPeripheryProxy proxy; Exchange dex; Exchange cex; + IPermit2 permit2; WETH9Interface mockWETH; ERC20 mockERC20; @@ -43,20 +80,34 @@ contract SpokePoolPeripheryTest is Test { uint256 mintAmount = 10**22; uint256 depositAmount = 5 * (10**18); 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 { - dex = new Exchange(); - cex = new Exchange(); + hashUtils = new HashUtils(); mockWETH = WETH9Interface(address(new WETH9())); mockERC20 = new ERC20("ERC20", "ERC20"); - depositor = vm.addr(1); + depositor = vm.addr(privateKey); owner = vm.addr(2); recipient = vm.addr(3); + 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( @@ -70,7 +121,7 @@ contract SpokePoolPeripheryTest is Test { ethereumSpokePool = Ethereum_SpokePool(payable(spokePoolProxy)); ethereumSpokePool.setEnableRoute(address(mockWETH), destinationChainId, true); ethereumSpokePool.setEnableRoute(address(mockERC20), destinationChainId, true); - spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy)); + spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy), permit2); vm.stopPrank(); deal(depositor, mintAmount); @@ -80,17 +131,21 @@ contract SpokePoolPeripheryTest is Test { mockWETH.deposit{ value: mintAmount }(); mockERC20.approve(address(proxy), mintAmount); IERC20(address(mockWETH)).approve(address(proxy), mintAmount); + + // Approve permit2 + IERC20(address(mockWETH)).approve(address(permit2), mintAmount * 10); vm.stopPrank(); } function testInitializePeriphery() public { SpokePoolV3Periphery _spokePoolPeriphery = new SpokePoolV3Periphery(); - _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy)); + _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)); + _spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy), permit2); } function testInitializeProxy() public { @@ -121,32 +176,82 @@ contract SpokePoolPeripheryTest is Test { new bytes(0) ); proxy.swapAndBridge( - IERC20(address(mockWETH)), // swapToken - IERC20(mockERC20), // acrossInputToken - address(dex), - abi.encodeWithSelector( - dex.swap.selector, - IERC20(address(mockWETH)), - IERC20(mockERC20), + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + 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.V3FundsDeposited( + address(mockERC20), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + proxy.swapAndBridge( + _defaultSwapAndDepositData( + address(mockWETH), mintAmount, - depositAmount - ), - mintAmount, // swapTokenAmount - depositAmount, // minExpectedInputTokenAmount - SpokePoolV3Periphery.DepositData({ - outputToken: address(0), - outputAmount: depositAmount, - depositor: depositor, - recipient: depositor, - destinationChainId: destinationChainId, - exclusiveRelayer: address(0), - quoteTimestamp: uint32(block.timestamp), - fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, - exclusivityParameter: 0, - message: new bytes(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.V3FundsDeposited( + address(mockERC20), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + proxy.swapAndBridge( + _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + dex, + SpokePoolV3PeripheryInterface.TransferType.Transfer, + address(mockERC20), + depositAmount, + depositor + ) + ); vm.stopPrank(); } @@ -155,30 +260,15 @@ contract SpokePoolPeripheryTest is Test { vm.startPrank(depositor); vm.expectRevert(SpokePoolV3Periphery.NotProxy.selector); spokePoolPeriphery.swapAndBridge( - IERC20(address(mockWETH)), // swapToken - IERC20(mockERC20), // acrossInputToken - address(dex), - abi.encodeWithSelector( - dex.swap.selector, - IERC20(address(mockWETH)), - IERC20(mockERC20), + _defaultSwapAndDepositData( + address(mockWETH), mintAmount, - depositAmount - ), - mintAmount, // swapTokenAmount - depositAmount, // minExpectedInputTokenAmount - SpokePoolV3Periphery.DepositData({ - outputToken: address(0), - outputAmount: depositAmount, - depositor: depositor, - recipient: depositor, - destinationChainId: destinationChainId, - exclusiveRelayer: address(0), - quoteTimestamp: uint32(block.timestamp), - fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, - exclusivityParameter: 0, - message: new bytes(0) - }) + dex, + SpokePoolV3PeripheryInterface.TransferType.Approval, + address(mockERC20), + depositAmount, + depositor + ) ); vm.stopPrank(); @@ -209,32 +299,16 @@ contract SpokePoolPeripheryTest is Test { new bytes(0) ); spokePoolPeriphery.swapAndBridge{ value: mintAmount }( - IERC20(address(mockWETH)), // swapToken - IERC20(mockERC20), // acrossInputToken - address(dex), - abi.encodeWithSelector( - dex.swap.selector, - IERC20(address(mockWETH)), - IERC20(mockERC20), + _defaultSwapAndDepositData( + address(mockWETH), mintAmount, - depositAmount - ), - mintAmount, // swapTokenAmount - depositAmount, // minExpectedInputTokenAmount - SpokePoolV3Periphery.DepositData({ - outputToken: address(0), - outputAmount: depositAmount, - depositor: depositor, - recipient: depositor, - destinationChainId: destinationChainId, - exclusiveRelayer: address(0), - quoteTimestamp: uint32(block.timestamp), - fillDeadline: uint32(block.timestamp) + fillDeadlineBuffer, - exclusivityParameter: 0, - message: new bytes(0) - }) + dex, + SpokePoolV3PeripheryInterface.TransferType.Approval, + address(mockERC20), + depositAmount, + depositor + ) ); - vm.stopPrank(); } @@ -273,7 +347,72 @@ contract SpokePoolPeripheryTest is Test { 0, new bytes(0) ); + vm.stopPrank(); + } + + function testPermit2DepositValidWitness() public { + SpokePoolV3PeripheryInterface.DepositData memory depositData = _defaultDepositData( + address(mockWETH), + mintAmount, + depositor + ); + // Signature transfer details + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmount }), + 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.startPrank(depositor); + vm.expectEmit(address(ethereumSpokePool)); + emit V3SpokePoolInterface.V3FundsDeposited( + address(mockWETH), + address(0), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.depositWithPermit2( + depositor, // signatureOwner + depositData, + permit, // permit + signature // permit2 signature + ); vm.stopPrank(); } @@ -296,4 +435,69 @@ contract SpokePoolPeripheryTest is Test { vm.stopPrank(); } + + function _defaultDepositData( + address _token, + uint256 _amount, + address _depositor + ) internal returns (SpokePoolV3Periphery.DepositData memory) { + return + SpokePoolV3PeripheryInterface.DepositData({ + 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, + Exchange _exchange, + SpokePoolV3PeripheryInterface.TransferType _transferType, + address _inputToken, + uint256 _amount, + address _depositor + ) internal returns (SpokePoolV3Periphery.SwapAndDepositData memory) { + bool usePermit2 = _transferType == SpokePoolV3PeripheryInterface.TransferType.Permit2Approval; + return + SpokePoolV3PeripheryInterface.SwapAndDepositData({ + 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 + ) + }); + } } From c9017ff5ebac707188bc741a2eef8623855610dc Mon Sep 17 00:00:00 2001 From: bmzig <57361391+bmzig@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:57:28 -0600 Subject: [PATCH 22/27] feat: sponsored swap and deposits (#790) * feat: add permit2 entrypoints to the periphery Signed-off-by: Bennett * Update test/evm/foundry/local/SpokePoolPeriphery.t.sol * Update SpokePoolPeriphery.t.sol * move permit2 to proxy * fix permit2 Signed-off-by: bennett * wip: swap arguments refactor Signed-off-by: bennett * implement isValidSignature Signed-off-by: bennett * 1271 Signed-off-by: bennett * simplify isValidSignature Signed-off-by: bennett * rebase /programs on master Signed-off-by: nicholaspai * clean up comments * rebase programs * feat: sponsored swap and deposits Signed-off-by: bennett * fix: consolidate structs so that permit2 witnesses cover inputs Signed-off-by: bennett * begin permit2 unit tests Signed-off-by: bennett * rebase * Update SpokePoolPeriphery.t.sol * move type definitions to interface Signed-off-by: bennett * fix permit2 test Signed-off-by: bennett * transfer type tests Signed-off-by: bennett * rename EIP1271Signature to Permi2Approval Signed-off-by: bennett * add mockERC20 which implements permit/receiveWithAuthorization Signed-off-by: bennett * add tests for permit, permit2, and receiveWithAuth swaps/deposits Signed-off-by: bennett * add tests for invalid witnesses Signed-off-by: bennett * factor out signature checking Signed-off-by: bennett --------- Signed-off-by: Bennett Signed-off-by: bennett Signed-off-by: nicholaspai Co-authored-by: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Co-authored-by: nicholaspai --- contracts/SpokePoolV3Periphery.sol | 85 ++- .../SpokePoolV3PeripheryInterface.sol | 16 +- contracts/libraries/PeripherySigningLib.sol | 5 +- contracts/test/MockERC20.sol | 51 ++ .../foundry/local/SpokePoolPeriphery.t.sol | 592 +++++++++++++++++- 5 files changed, 713 insertions(+), 36 deletions(-) create mode 100644 contracts/test/MockERC20.sol diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index a324750ec..bbcda740e 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -7,6 +7,8 @@ import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IER 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"; @@ -98,7 +100,7 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable * contract may be deployed deterministically at the same address across different networks. * @custom:security-contact bugs@across.to */ -contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiCaller { +contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiCaller, EIP712 { using SafeERC20 for IERC20; using Address for address; @@ -157,6 +159,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC error InvalidProxy(); error InvalidSwapToken(); error NotProxy(); + error InvalidSignature(); /** * @notice Construct a new Proxy contract. @@ -165,7 +168,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC * across different networks is the same. Constructor parameters affect the bytecode so we can only * add parameters here that are consistent across networks. */ - constructor() {} + constructor() EIP712("ACROSS-V3-PERIPHERY", "1.0.0") {} /** * @notice Initializes the SwapAndBridgeBase contract. @@ -282,14 +285,18 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC * @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 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. @@ -299,9 +306,17 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC // 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(msg.sender, address(this), _swapTokenAmount, deadline, v, r, s) {} catch {} - IERC20(_swapToken).safeTransferFrom(msg.sender, address(this), _swapTokenAmount); + try + IERC20Permit(_swapToken).permit(signatureOwner, address(this), _swapTokenAmount, deadline, v, r, s) + {} catch {} + IERC20(_swapToken).safeTransferFrom(signatureOwner, address(this), _swapTokenAmount); + // Verify that the signatureOwner signed the input swapAndDepositData. + _validateSignature( + signatureOwner, + PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData), + swapAndDepositDataSignature + ); _swapAndBridge(swapAndDepositData); } @@ -342,25 +357,29 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC * @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 adepositors (bytes32 r, bytes32 s, uint8 v). + * @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 receiveWithAuthSignature, + bytes calldata swapAndDepositDataSignature ) external override nonReentrant { (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(receiveWithAuthSignature); // 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( - msg.sender, + signatureOwner, address(this), swapAndDepositData.swapTokenAmount, validAfter, @@ -371,20 +390,30 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC s ); + // Verify that the signatureOwner signed the input swapAndDepositData. + _validateSignature( + signatureOwner, + PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData), + swapAndDepositDataSignature + ); _swapAndBridge(swapAndDepositData); } /** * @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 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. @@ -394,9 +423,11 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC // 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(msg.sender, address(this), _inputAmount, deadline, v, r, s) {} catch {} - IERC20(_inputToken).safeTransferFrom(msg.sender, address(this), _inputAmount); + try IERC20Permit(_inputToken).permit(signatureOwner, address(this), _inputAmount, deadline, v, r, s) {} catch {} + IERC20(_inputToken).safeTransferFrom(signatureOwner, address(this), _inputAmount); + // Verify that the signatureOwner signed the input depositData. + _validateSignature(signatureOwner, PeripherySigningLib.hashDepositData(depositData), depositDataSignature); _depositV3( depositData.baseDepositData.depositor, depositData.baseDepositData.recipient, @@ -461,18 +492,22 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC /** * @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 receiveWithAuthSignature, + bytes calldata depositDataSignature ) external override nonReentrant { // Load variables used multiple times onto the stack. uint256 _inputAmount = depositData.inputAmount; @@ -480,7 +515,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC // Redeem the receiveWithAuthSignature. (bytes32 r, bytes32 s, uint8 v) = PeripherySigningLib.deserializeSignature(receiveWithAuthSignature); IERC20Auth(depositData.baseDepositData.inputToken).receiveWithAuthorization( - msg.sender, + signatureOwner, address(this), _inputAmount, validAfter, @@ -491,6 +526,8 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC s ); + // Verify that the signatureOwner signed the input depositData. + _validateSignature(signatureOwner, PeripherySigningLib.hashDepositData(depositData), depositDataSignature); _depositV3( depositData.baseDepositData.depositor, depositData.baseDepositData.recipient, @@ -519,6 +556,28 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC : 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 { + 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 diff --git a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol index a9af1ed4d..4664b83ea 100644 --- a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol +++ b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol @@ -105,9 +105,11 @@ interface SpokePoolV3PeripheryInterface { function swapAndBridge(SwapAndDepositData calldata swapAndDepositData) external payable; function swapAndBridgeWithPermit( + address signatureOwner, SwapAndDepositData calldata swapAndDepositData, uint256 deadline, - bytes calldata permitSignature + bytes calldata permitSignature, + bytes calldata swapAndDepositDataSignature ) external; function swapAndBridgeWithPermit2( @@ -118,17 +120,21 @@ interface SpokePoolV3PeripheryInterface { ) external; function swapAndBridgeWithAuthorization( + address signatureOwner, SwapAndDepositData calldata swapAndDepositData, uint256 validAfter, uint256 validBefore, bytes32 nonce, - bytes calldata receiveWithAuthSignature + bytes calldata receiveWithAuthSignature, + bytes calldata swapAndDepositDataSignature ) external; function depositWithPermit( + address signatureOwner, DepositData calldata depositData, uint256 deadline, - bytes calldata permitSignature + bytes calldata permitSignature, + bytes calldata depositDataSignature ) external; function depositWithPermit2( @@ -139,10 +145,12 @@ interface SpokePoolV3PeripheryInterface { ) external; function depositWithAuthorization( + address signatureOwner, DepositData calldata depositData, uint256 validAfter, uint256 validBefore, bytes32 nonce, - bytes calldata receiveWithAuthSignature + bytes calldata receiveWithAuthSignature, + bytes calldata depositDataSignature ) external; } diff --git a/contracts/libraries/PeripherySigningLib.sol b/contracts/libraries/PeripherySigningLib.sol index eb6e55dd6..4b8950fd8 100644 --- a/contracts/libraries/PeripherySigningLib.sol +++ b/contracts/libraries/PeripherySigningLib.sol @@ -35,6 +35,7 @@ library PeripherySigningLib { ); // EIP712 Type hashes. + bytes32 internal constant EIP712_BASE_DEPOSIT_DATA_TYPEHASH = keccak256(EIP712_BASE_DEPOSIT_DATA_TYPE); bytes32 internal constant EIP712_DEPOSIT_DATA_TYPEHASH = keccak256(abi.encode(EIP712_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); bytes32 internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPEHASH = @@ -76,7 +77,7 @@ library PeripherySigningLib { return keccak256( abi.encode( - EIP712_BASE_DEPOSIT_DATA_TYPE, + EIP712_BASE_DEPOSIT_DATA_TYPEHASH, baseDepositData.outputToken, baseDepositData.outputAmount, baseDepositData.depositor, @@ -103,7 +104,7 @@ library PeripherySigningLib { return keccak256( abi.encode( - EIP712_DEPOSIT_DATA_TYPE, + EIP712_DEPOSIT_DATA_TYPEHASH, hashBaseDepositData(depositData.baseDepositData), depositData.inputAmount ) diff --git a/contracts/test/MockERC20.sol b/contracts/test/MockERC20.sol new file mode 100644 index 000000000..ab5c6a0eb --- /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 returns (bytes32) { + return _hashTypedDataV4(typedData); + } +} diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index aee7a5970..a77aac108 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -11,9 +11,9 @@ import { SpokePoolV3PeripheryInterface } from "../../../../contracts/interfaces/ 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 } from "../../../../contracts/test/MockPermit2.sol"; +import { MockPermit2, Permit2EIP712, SignatureVerification } from "../../../../contracts/test/MockPermit2.sol"; import { PeripherySigningLib } from "../../../../contracts/libraries/PeripherySigningLib.sol"; -import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.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"; @@ -58,6 +58,14 @@ contract HashUtils { { return PeripherySigningLib.hashDepositData(depositData); } + + function hashSwapAndDepositData(SpokePoolV3Periphery.SwapAndDepositData calldata swapAndDepositData) + external + pure + returns (bytes32) + { + return PeripherySigningLib.hashSwapAndDepositData(swapAndDepositData); + } } contract SpokePoolPeripheryTest is Test { @@ -70,7 +78,7 @@ contract SpokePoolPeripheryTest is Test { IPermit2 permit2; WETH9Interface mockWETH; - ERC20 mockERC20; + MockERC20 mockERC20; address depositor; address owner; @@ -96,7 +104,7 @@ contract SpokePoolPeripheryTest is Test { hashUtils = new HashUtils(); mockWETH = WETH9Interface(address(new WETH9())); - mockERC20 = new ERC20("ERC20", "ERC20"); + mockERC20 = new MockERC20(); depositor = vm.addr(privateKey); owner = vm.addr(2); @@ -156,6 +164,9 @@ contract SpokePoolPeripheryTest is Test { _proxy.initialize(spokePoolPeriphery); } + /** + * Approval based flows + */ function testSwapAndBridge() public { // Should emit expected deposit event vm.startPrank(depositor); @@ -255,6 +266,9 @@ contract SpokePoolPeripheryTest is Test { vm.stopPrank(); } + /** + * Value based flows + */ function testSwapAndBridgeNoValueNoProxy() public { // Cannot call swapAndBridge with no value directly. vm.startPrank(depositor); @@ -350,6 +364,434 @@ contract SpokePoolPeripheryTest is Test { 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, + depositor + ); + + bytes32 nonce = 0; + + // Get the permit signature. + bytes32 structHash = keccak256( + abi.encode( + mockERC20.PERMIT_TYPEHASH_EXTERNAL(), + depositor, + address(spokePoolPeriphery), + mintAmount, + 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.V3FundsDeposited( + address(mockERC20), + address(0), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.depositWithPermit( + depositor, // signatureOwner + depositData, + block.timestamp, // deadline + signature, // permitSignature + depositDataSignature + ); + } + + 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, + 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), + mintAmount, + 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.V3FundsDeposited( + address(mockWETH), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridgeWithPermit( + depositor, // signatureOwner + swapAndDepositData, + block.timestamp, // deadline + signature, // permitSignature + swapAndDepositDataSignature + ); + } + + 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, + 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), + mintAmount, + 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, + 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, + 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), + mintAmount, + 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.V3FundsDeposited( + address(mockERC20), + address(0), + mintAmount, + mintAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.depositWithAuthorization( + depositor, // signatureOwner + depositData, + block.timestamp, // valid before + block.timestamp, // valid after + nonce, // nonce + signature, // receiveWithAuthSignature + depositDataSignature + ); + } + + 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, + 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), + mintAmount, + 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.V3FundsDeposited( + address(mockWETH), + address(0), + depositAmount, + depositAmount, + destinationChainId, + 0, // depositId + uint32(block.timestamp), + uint32(block.timestamp) + fillDeadlineBuffer, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer + new bytes(0) + ); + spokePoolPeriphery.swapAndBridgeWithAuthorization( + depositor, // signatureOwner + swapAndDepositData, + block.timestamp, // validAfter + block.timestamp, // validBefore + nonce, // nonce + signature, // receiveWithAuthSignature + swapAndDepositDataSignature + ); + } + + 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, + 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), + mintAmount, + 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, + 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), @@ -390,7 +832,6 @@ contract SpokePoolPeripheryTest is Test { bytes memory signature = bytes.concat(r, s, bytes1(v)); // Should emit expected deposit event - vm.startPrank(depositor); vm.expectEmit(address(ethereumSpokePool)); emit V3SpokePoolInterface.V3FundsDeposited( address(mockWETH), @@ -413,29 +854,146 @@ contract SpokePoolPeripheryTest is Test { permit, // permit signature // permit2 signature ); - 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, + function testPermit2SwapAndBridgeValidWitness() public { + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockWETH), mintAmount, + dex, + SpokePoolV3PeripheryInterface.TransferType.Transfer, + address(mockERC20), + depositAmount, + depositor + ); + + // Signature transfer details + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmount }), + 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.V3FundsDeposited( + address(mockERC20), + address(0), + depositAmount, + depositAmount, destinationChainId, - address(0), // exclusiveRelayer + 0, // depositId uint32(block.timestamp), uint32(block.timestamp) + fillDeadlineBuffer, - 0, + 0, // exclusivityDeadline + depositor, + depositor, + address(0), // exclusiveRelayer new bytes(0) ); + spokePoolPeriphery.swapAndBridgeWithPermit2( + depositor, // signatureOwner + swapAndDepositData, + permit, + signature + ); + } - vm.stopPrank(); + function testPermit2SwapAndBridgeInvalidWitness(address rando) public { + vm.assume(rando != depositor); + SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( + address(mockWETH), + mintAmount, + dex, + SpokePoolV3PeripheryInterface.TransferType.Transfer, + address(mockERC20), + depositAmount, + depositor + ); + + // Signature transfer details + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmount }), + 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, + 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, From 9bc7d91aa41550d7142c6b2c0990e83353b5f099 Mon Sep 17 00:00:00 2001 From: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Date: Thu, 19 Dec 2024 08:05:30 -0500 Subject: [PATCH 23/27] feat: Delete SwapAndBridge and add submission fees to gasless flow (#809) * feat: add permit2 entrypoints to the periphery Signed-off-by: Bennett * Update test/evm/foundry/local/SpokePoolPeriphery.t.sol * Update SpokePoolPeriphery.t.sol * move permit2 to proxy * fix permit2 Signed-off-by: bennett * wip: swap arguments refactor Signed-off-by: bennett * implement isValidSignature Signed-off-by: bennett * 1271 Signed-off-by: bennett * simplify isValidSignature Signed-off-by: bennett * rebase /programs on master Signed-off-by: nicholaspai * clean up comments * rebase programs * feat: sponsored swap and deposits Signed-off-by: bennett * fix: consolidate structs so that permit2 witnesses cover inputs Signed-off-by: bennett * begin permit2 unit tests Signed-off-by: bennett * rebase * Update SpokePoolPeriphery.t.sol * move type definitions to interface Signed-off-by: bennett * fix permit2 test Signed-off-by: bennett * transfer type tests Signed-off-by: bennett * rename EIP1271Signature to Permi2Approval Signed-off-by: bennett * add mockERC20 which implements permit/receiveWithAuthorization Signed-off-by: bennett * add tests for permit, permit2, and receiveWithAuth swaps/deposits Signed-off-by: bennett * add tests for invalid witnesses Signed-off-by: bennett * feat: Delete SwapAndBridge and add submission fees to gasless flow SwapAndBridge is to be replaced with SpokePoolV3Periphery Gasless flows will require user to cover gas cost of whoever submits the transaction, but they can be set to 0 if the user wants to submit themselves. * Internal refactor * Update SpokePoolV3Periphery.sol * Update PeripherySigningLib.sol * Update SpokePoolV3Periphery.sol * Update PeripherySigningLib.sol --------- Signed-off-by: Bennett Signed-off-by: bennett Signed-off-by: nicholaspai Co-authored-by: Bennett --- contracts/SpokePoolV3Periphery.sol | 64 ++- contracts/SwapAndBridge.sol | 505 ------------------ .../SpokePoolV3PeripheryInterface.sol | 13 + contracts/libraries/PeripherySigningLib.sol | 23 +- contracts/test/MockERC20.sol | 2 +- .../foundry/local/SpokePoolPeriphery.t.sol | 97 +++- 6 files changed, 166 insertions(+), 538 deletions(-) delete mode 100644 contracts/SwapAndBridge.sol diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index bbcda740e..744a1f672 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -302,15 +302,16 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC // 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), _swapTokenAmount, deadline, v, r, s) - {} catch {} - IERC20(_swapToken).safeTransferFrom(signatureOwner, address(this), _swapTokenAmount); - + 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, @@ -337,9 +338,10 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC 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 + requestedAmount: swapAndDepositData.swapTokenAmount + _submissionFeeAmount }); permit2.permitWitnessTransferFrom( @@ -350,6 +352,11 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC PeripherySigningLib.EIP712_SWAP_AND_DEPOSIT_TYPE_STRING, signature ); + _paySubmissionFees( + swapAndDepositData.swapToken, + swapAndDepositData.submissionFees.recipient, + _submissionFeeAmount + ); _swapAndBridge(swapAndDepositData); } @@ -375,13 +382,14 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC 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, + swapAndDepositData.swapTokenAmount + _submissionFeeAmount, validAfter, validBefore, nonce, @@ -389,6 +397,11 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC r, s ); + _paySubmissionFees( + swapAndDepositData.swapToken, + swapAndDepositData.submissionFees.recipient, + _submissionFeeAmount + ); // Verify that the signatureOwner signed the input swapAndDepositData. _validateSignature( @@ -419,12 +432,16 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC // 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), _inputAmount, deadline, v, r, s) {} catch {} - IERC20(_inputToken).safeTransferFrom(signatureOwner, address(this), _inputAmount); + 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); @@ -460,9 +477,10 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC 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 + requestedAmount: depositData.inputAmount + _submissionFeeAmount }); permit2.permitWitnessTransferFrom( @@ -473,6 +491,12 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC PeripherySigningLib.EIP712_DEPOSIT_TYPE_STRING, signature ); + _paySubmissionFees( + depositData.baseDepositData.inputToken, + depositData.submissionFees.recipient, + _submissionFeeAmount + ); + _depositV3( depositData.baseDepositData.depositor, depositData.baseDepositData.recipient, @@ -511,13 +535,14 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC ) 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, + _inputAmount + _submissionFeeAmount, validAfter, validBefore, nonce, @@ -525,6 +550,11 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC r, s ); + _paySubmissionFees( + depositData.baseDepositData.inputToken, + depositData.submissionFees.recipient, + _submissionFeeAmount + ); // Verify that the signatureOwner signed the input depositData. _validateSignature(signatureOwner, PeripherySigningLib.hashDepositData(depositData), depositDataSignature); @@ -573,7 +603,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC address signatureOwner, bytes32 typedDataHash, bytes calldata signature - ) private { + ) private view { if (!SignatureChecker.isValidSignatureNow(signatureOwner, _hashTypedDataV4(typedDataHash), signature)) revert InvalidSignature(); } @@ -705,6 +735,16 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC ); } + 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. */ diff --git a/contracts/SwapAndBridge.sol b/contracts/SwapAndBridge.sol deleted file mode 100644 index df5eff4d1..000000000 --- a/contracts/SwapAndBridge.sol +++ /dev/null @@ -1,505 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.0; - -import "./interfaces/V3SpokePoolInterface.sol"; -import "./external/interfaces/IERC20Auth.sol"; -import "./external/interfaces/WETH9Interface.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; -import "./Lockable.sol"; -import "@uma/core/contracts/common/implementation/MultiCaller.sol"; - -/** - * @title SwapAndBridgeBase - * @notice Base contract for both variants of SwapAndBridge. - * @dev Variables which may be immutable are not marked as immutable so that this contract may be deployed deterministically. - * @custom:security-contact bugs@across.to - */ -abstract contract SwapAndBridgeBase is Lockable, MultiCaller { - using SafeERC20 for IERC20; - - // 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 spokePool; - - // Exchange address or router where the swapping will happen. - address public exchange; - - // Wrapped native token contract address. - WETH9Interface internal wrappedNativeToken; - - // 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, - WETH9Interface _wrappedNativeToken, - address _exchange, - bytes4[] memory _allowedSelectors - ) { - spokePool = _spokePool; - exchange = _exchange; - wrappedNativeToken = _wrappedNativeToken; - 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(); - - // 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(spokePool), _acrossInputAmount); - spokePool.depositV3( - depositData.depositor, - depositData.recipient, - address(_acrossInputToken), // input token - depositData.outputToken, // output token - _acrossInputAmount, // input amount. - depositData.outputAmount, // output amount - depositData.destinationChainid, - depositData.exclusiveRelayer, - 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, - WETH9Interface _wrappedNativeToken, - address _exchange, - bytes4[] memory _allowedSelectors, - IERC20 _swapToken, - IERC20 _acrossInputToken - ) SwapAndBridgeBase(_spokePool, _wrappedNativeToken, _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 { - using SafeERC20 for IERC20; - - error InsufficientSwapValue(); - error InvalidSwapToken(); - - /** - * @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, - WETH9Interface _wrappedNativeToken, - address _exchange, - bytes4[] memory _allowedSelectors - ) SwapAndBridgeBase(_spokePool, _wrappedNativeToken, _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 payable 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 != swapTokenAmount) revert InsufficientSwapValue(); - if (address(swapToken) != address(wrappedNativeToken)) revert InvalidSwapToken(); - wrappedNativeToken.deposit{ value: msg.value }(); - } else { - swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); - } - _swapAndBridge( - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - swapToken, - acrossInputToken - ); - } - - /** - * @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 swapToken does not implement `permit` to the specifications of EIP-2612, this function will fail. - * @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. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ - function swapAndBridgeWithPermit( - IERC20Permit swapToken, - IERC20 acrossInputToken, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - IERC20 _swapToken = IERC20(address(swapToken)); // Cast IERC20Permit to IERC20. - // 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 swapToken.permit(msg.sender, address(this), swapTokenAmount, deadline, v, r, s) {} catch {} - - _swapToken.safeTransferFrom(msg.sender, address(this), swapTokenAmount); - _swapAndBridge( - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - _swapToken, - acrossInputToken - ); - } - - /** - * @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 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. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ - function swapAndBridgeWithAuthorization( - IERC20Auth swapToken, - IERC20 acrossInputToken, - bytes calldata routerCalldata, - uint256 swapTokenAmount, - uint256 minExpectedInputTokenAmount, - DepositData calldata depositData, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - // While any contract can vacuously implement `transferWithAuthorization` (or just have a fallback), - // if tokens were not sent to this contract, by this call to the swapToken, the call to `transferFrom` - // in _swapAndBridge will revert. - swapToken.receiveWithAuthorization( - msg.sender, - address(this), - swapTokenAmount, - validAfter, - validBefore, - nonce, - v, - r, - s - ); - IERC20 _swapToken = IERC20(address(swapToken)); // Cast IERC20Auth to IERC20. - - _swapAndBridge( - routerCalldata, - swapTokenAmount, - minExpectedInputTokenAmount, - depositData, - _swapToken, - acrossInputToken - ); - } - - /** - * @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 acrossInputToken EIP-2612 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @param depositData Specifies the Across deposit params to send. - * @param deadline Deadline before which the permit signature is valid. - * @param v v of the permit signature. - * @param r r of the permit signature. - * @param s s of the permit signature. - */ - function depositWithPermit( - IERC20Permit acrossInputToken, - uint256 acrossInputAmount, - DepositData calldata depositData, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast IERC20Permit to an IERC20 type. - // 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 acrossInputToken.permit(msg.sender, address(this), acrossInputAmount, deadline, v, r, s) {} catch {} - - _acrossInputToken.safeTransferFrom(msg.sender, address(this), acrossInputAmount); - _depositV3(_acrossInputToken, acrossInputAmount, depositData); - } - - /** - * @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 acrossInputToken EIP-3009 compliant token to deposit. - * @param acrossInputAmount Amount of the input token to deposit. - * @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 v v of the EIP-3009 signature. - * @param r r of the EIP-3009 signature. - * @param s s of the EIP-3009 signature. - */ - function depositWithAuthorization( - IERC20Auth acrossInputToken, - uint256 acrossInputAmount, - DepositData calldata depositData, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external nonReentrant { - acrossInputToken.receiveWithAuthorization( - msg.sender, - address(this), - acrossInputAmount, - validAfter, - validBefore, - nonce, - v, - r, - s - ); - IERC20 _acrossInputToken = IERC20(address(acrossInputToken)); // Cast the input token to an IERC20. - _depositV3(_acrossInputToken, acrossInputAmount, depositData); - } -} diff --git a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol index 4664b83ea..1daa0e96b 100644 --- a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol +++ b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol @@ -30,6 +30,15 @@ interface SpokePoolV3PeripheryInterface { 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. @@ -65,6 +74,8 @@ interface SpokePoolV3PeripheryInterface { // 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. @@ -83,6 +94,8 @@ interface SpokePoolV3PeripheryInterface { // 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. diff --git a/contracts/libraries/PeripherySigningLib.sol b/contracts/libraries/PeripherySigningLib.sol index 4b8950fd8..9ad0820eb 100644 --- a/contracts/libraries/PeripherySigningLib.sol +++ b/contracts/libraries/PeripherySigningLib.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import { SpokePoolV3PeripheryInterface } from "../interfaces/SpokePoolV3PeripheryInterface.sol"; library PeripherySigningLib { + bytes internal constant EIP712_FEES_TYPE = abi.encodePacked("Fees(", "uint256 amount", "address recipient)"); // Typed structured data for the structs to sign against in the periphery. bytes internal constant EIP712_BASE_DEPOSIT_DATA_TYPE = abi.encodePacked( @@ -21,10 +22,11 @@ library PeripherySigningLib { "bytes message)" ); bytes internal constant EIP712_DEPOSIT_DATA_TYPE = - abi.encodePacked("DepositData(BaseDepositData baseDepositData,uint256 inputAmount)"); + abi.encodePacked("DepositData(Fees submissionFees,BaseDepositData baseDepositData,uint256 inputAmount)"); bytes internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPE = abi.encodePacked( "SwapAndDepositData(", + "Fees submissionFees", "BaseDepositData depositData", "address swapToken", "address exchange", @@ -35,11 +37,12 @@ library PeripherySigningLib { ); // EIP712 Type hashes. + bytes32 internal constant EIP712_FEES_TYPEHASH = keccak256(EIP712_FEES_TYPE); bytes32 internal constant EIP712_BASE_DEPOSIT_DATA_TYPEHASH = keccak256(EIP712_BASE_DEPOSIT_DATA_TYPE); bytes32 internal constant EIP712_DEPOSIT_DATA_TYPEHASH = - keccak256(abi.encode(EIP712_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); + keccak256(abi.encode(EIP712_DEPOSIT_DATA_TYPE, EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); bytes32 internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPEHASH = - keccak256(abi.encode(EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); + keccak256(abi.encode(EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); // EIP712 Type strings. string internal constant TOKEN_PERMISSIONS_TYPE = "TokenPermissions(address token,uint256 amount)"; @@ -47,6 +50,7 @@ library PeripherySigningLib { string( abi.encodePacked( "SwapAndDepositData witness)", + EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE, EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, TOKEN_PERMISSIONS_TYPE @@ -56,6 +60,7 @@ library PeripherySigningLib { string( abi.encodePacked( "DepositData witness)", + EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE, EIP712_DEPOSIT_DATA_TYPE, TOKEN_PERMISSIONS_TYPE @@ -78,6 +83,7 @@ library PeripherySigningLib { keccak256( abi.encode( EIP712_BASE_DEPOSIT_DATA_TYPEHASH, + baseDepositData.inputToken, baseDepositData.outputToken, baseDepositData.outputAmount, baseDepositData.depositor, @@ -92,6 +98,15 @@ library PeripherySigningLib { ); } + /** + * @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. @@ -105,6 +120,7 @@ library PeripherySigningLib { keccak256( abi.encode( EIP712_DEPOSIT_DATA_TYPEHASH, + hashFees(depositData.submissionFees), hashBaseDepositData(depositData.baseDepositData), depositData.inputAmount ) @@ -124,6 +140,7 @@ library PeripherySigningLib { keccak256( abi.encode( EIP712_SWAP_AND_DEPOSIT_DATA_TYPEHASH, + hashFees(swapAndDepositData.submissionFees), hashBaseDepositData(swapAndDepositData.depositData), swapAndDepositData.swapToken, swapAndDepositData.exchange, diff --git a/contracts/test/MockERC20.sol b/contracts/test/MockERC20.sol index ab5c6a0eb..84150eee4 100644 --- a/contracts/test/MockERC20.sol +++ b/contracts/test/MockERC20.sol @@ -45,7 +45,7 @@ contract MockERC20 is IERC20Auth, ERC20Permit { _transfer(from, to, value); } - function hashTypedData(bytes32 typedData) external returns (bytes32) { + function hashTypedData(bytes32 typedData) external view returns (bytes32) { return _hashTypedDataV4(typedData); } } diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index a77aac108..085aa2685 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -83,10 +83,14 @@ contract SpokePoolPeripheryTest is Test { 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; @@ -109,6 +113,7 @@ contract SpokePoolPeripheryTest is Test { 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); @@ -132,16 +137,16 @@ contract SpokePoolPeripheryTest is Test { spokePoolPeriphery.initialize(V3SpokePoolInterface(ethereumSpokePool), mockWETH, address(proxy), permit2); vm.stopPrank(); - deal(depositor, mintAmount); - deal(address(mockERC20), depositor, mintAmount, true); + deal(depositor, mintAmountWithSubmissionFee); + deal(address(mockERC20), depositor, mintAmountWithSubmissionFee, true); deal(address(mockERC20), address(dex), depositAmount, true); vm.startPrank(depositor); - mockWETH.deposit{ value: mintAmount }(); - mockERC20.approve(address(proxy), mintAmount); - IERC20(address(mockWETH)).approve(address(proxy), mintAmount); + mockWETH.deposit{ value: mintAmountWithSubmissionFee }(); + mockERC20.approve(address(proxy), mintAmountWithSubmissionFee); + IERC20(address(mockWETH)).approve(address(proxy), mintAmountWithSubmissionFee); // Approve permit2 - IERC20(address(mockWETH)).approve(address(permit2), mintAmount * 10); + IERC20(address(mockWETH)).approve(address(permit2), mintAmountWithSubmissionFee * 10); vm.stopPrank(); } @@ -190,6 +195,8 @@ contract SpokePoolPeripheryTest is Test { _defaultSwapAndDepositData( address(mockWETH), mintAmount, + 0, + address(0), dex, SpokePoolV3PeripheryInterface.TransferType.Approval, address(mockERC20), @@ -223,6 +230,8 @@ contract SpokePoolPeripheryTest is Test { _defaultSwapAndDepositData( address(mockWETH), mintAmount, + 0, + address(0), dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockERC20), @@ -256,6 +265,8 @@ contract SpokePoolPeripheryTest is Test { _defaultSwapAndDepositData( address(mockWETH), mintAmount, + 0, + address(0), dex, SpokePoolV3PeripheryInterface.TransferType.Transfer, address(mockERC20), @@ -277,6 +288,8 @@ contract SpokePoolPeripheryTest is Test { _defaultSwapAndDepositData( address(mockWETH), mintAmount, + 0, + address(0), dex, SpokePoolV3PeripheryInterface.TransferType.Approval, address(mockERC20), @@ -316,6 +329,8 @@ contract SpokePoolPeripheryTest is Test { _defaultSwapAndDepositData( address(mockWETH), mintAmount, + 0, + address(0), dex, SpokePoolV3PeripheryInterface.TransferType.Approval, address(mockERC20), @@ -391,6 +406,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.DepositData memory depositData = _defaultDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, depositor ); @@ -402,7 +419,7 @@ contract SpokePoolPeripheryTest is Test { mockERC20.PERMIT_TYPEHASH_EXTERNAL(), depositor, address(spokePoolPeriphery), - mintAmount, + mintAmountWithSubmissionFee, nonce, block.timestamp ) @@ -443,6 +460,9 @@ contract SpokePoolPeripheryTest is Test { signature, // permitSignature depositDataSignature ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); } function testPermitSwapAndBridgeValidWitness() public { @@ -453,6 +473,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -468,7 +490,7 @@ contract SpokePoolPeripheryTest is Test { mockERC20.PERMIT_TYPEHASH_EXTERNAL(), depositor, address(spokePoolPeriphery), - mintAmount, + mintAmountWithSubmissionFee, nonce, block.timestamp ) @@ -513,6 +535,9 @@ contract SpokePoolPeripheryTest is Test { signature, // permitSignature swapAndDepositDataSignature ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); } function testPermitSwapAndBridgeInvalidWitness(address rando) public { @@ -524,6 +549,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -539,7 +566,7 @@ contract SpokePoolPeripheryTest is Test { mockERC20.PERMIT_TYPEHASH_EXTERNAL(), depositor, address(spokePoolPeriphery), - mintAmount, + mintAmountWithSubmissionFee, nonce, block.timestamp ) @@ -564,6 +591,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory invalidSwapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -589,6 +618,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.DepositData memory depositData = _defaultDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, depositor ); @@ -600,7 +631,7 @@ contract SpokePoolPeripheryTest is Test { mockERC20.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), depositor, address(spokePoolPeriphery), - mintAmount, + mintAmountWithSubmissionFee, block.timestamp, block.timestamp, nonce @@ -644,6 +675,9 @@ contract SpokePoolPeripheryTest is Test { signature, // receiveWithAuthSignature depositDataSignature ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); } function testTransferWithAuthSwapAndBridgeValidWitness() public { @@ -654,6 +688,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -669,7 +705,7 @@ contract SpokePoolPeripheryTest is Test { mockERC20.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), depositor, address(spokePoolPeriphery), - mintAmount, + mintAmountWithSubmissionFee, block.timestamp, block.timestamp, nonce @@ -717,6 +753,9 @@ contract SpokePoolPeripheryTest is Test { signature, // receiveWithAuthSignature swapAndDepositDataSignature ); + + // Check that fee recipient receives expected amount + assertEq(mockERC20.balanceOf(relayer), submissionFeeAmount); } function testTransferWithAuthSwapAndBridgeInvalidWitness(address rando) public { @@ -728,6 +767,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -743,7 +784,7 @@ contract SpokePoolPeripheryTest is Test { mockERC20.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), depositor, address(spokePoolPeriphery), - mintAmount, + mintAmountWithSubmissionFee, block.timestamp, block.timestamp, nonce @@ -769,6 +810,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory invalidSwapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -796,11 +839,13 @@ contract SpokePoolPeripheryTest is Test { 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: mintAmount }), + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmountWithSubmissionFee }), nonce: 1, deadline: block.timestamp + 100 }); @@ -854,12 +899,17 @@ contract SpokePoolPeripheryTest is Test { 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), @@ -869,7 +919,7 @@ contract SpokePoolPeripheryTest is Test { // Signature transfer details IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ - permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmount }), + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmountWithSubmissionFee }), nonce: 1, deadline: block.timestamp + 100 }); @@ -923,6 +973,9 @@ contract SpokePoolPeripheryTest is Test { permit, signature ); + + // Check that fee recipient receives expected amount + assertEq(mockWETH.balanceOf(relayer), submissionFeeAmount); } function testPermit2SwapAndBridgeInvalidWitness(address rando) public { @@ -930,6 +983,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory swapAndDepositData = _defaultSwapAndDepositData( address(mockWETH), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Transfer, address(mockERC20), @@ -939,7 +994,7 @@ contract SpokePoolPeripheryTest is Test { // Signature transfer details IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ - permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmount }), + permitted: IPermit2.TokenPermissions({ token: address(mockWETH), amount: mintAmountWithSubmissionFee }), nonce: 1, deadline: block.timestamp + 100 }); @@ -974,6 +1029,8 @@ contract SpokePoolPeripheryTest is Test { SpokePoolV3PeripheryInterface.SwapAndDepositData memory invalidSwapAndDepositData = _defaultSwapAndDepositData( address(mockERC20), mintAmount, + submissionFeeAmount, + relayer, dex, SpokePoolV3PeripheryInterface.TransferType.Permit2Approval, address(mockWETH), @@ -997,10 +1054,13 @@ contract SpokePoolPeripheryTest is Test { function _defaultDepositData( address _token, uint256 _amount, + uint256 _feeAmount, + address _feeRecipient, address _depositor - ) internal returns (SpokePoolV3Periphery.DepositData memory) { + ) internal view returns (SpokePoolV3Periphery.DepositData memory) { return SpokePoolV3PeripheryInterface.DepositData({ + submissionFees: SpokePoolV3PeripheryInterface.Fees({ amount: _feeAmount, recipient: _feeRecipient }), baseDepositData: SpokePoolV3PeripheryInterface.BaseDepositData({ inputToken: _token, outputToken: address(0), @@ -1021,15 +1081,18 @@ contract SpokePoolPeripheryTest is Test { function _defaultSwapAndDepositData( address _swapToken, uint256 _swapAmount, + uint256 _feeAmount, + address _feeRecipient, Exchange _exchange, SpokePoolV3PeripheryInterface.TransferType _transferType, address _inputToken, uint256 _amount, address _depositor - ) internal returns (SpokePoolV3Periphery.SwapAndDepositData memory) { + ) 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), From f4250d0c3483f1a9bad6cc733436b872d52b96a0 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Thu, 19 Dec 2024 08:07:44 -0500 Subject: [PATCH 24/27] Update SpokePoolV3Periphery.sol --- contracts/SpokePoolV3Periphery.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 744a1f672..59323b30f 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -37,8 +37,8 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable // 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 constructor argument. - SpokePoolV3Periphery public SPOKE_POOL_PERIPHERY; + // since the periphery address is the only initializer argument. + SpokePoolV3Periphery public spokePoolPeriphery; error InvalidPeriphery(); error ContractInitialized(); @@ -60,7 +60,7 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable if (initialized) revert ContractInitialized(); initialized = true; if (!address(_spokePoolPeriphery).isContract()) revert InvalidPeriphery(); - SPOKE_POOL_PERIPHERY = _spokePoolPeriphery; + spokePoolPeriphery = _spokePoolPeriphery; } /** @@ -88,8 +88,8 @@ contract SpokePoolPeripheryProxy is SpokePoolV3PeripheryProxyInterface, Lockable uint256 _swapTokenAmount = swapAndDepositData.swapTokenAmount; _swapToken.safeTransferFrom(msg.sender, address(this), _swapTokenAmount); - _swapToken.forceApprove(address(SPOKE_POOL_PERIPHERY), _swapTokenAmount); - SPOKE_POOL_PERIPHERY.swapAndBridge(swapAndDepositData); + _swapToken.forceApprove(address(spokePoolPeriphery), _swapTokenAmount); + spokePoolPeriphery.swapAndBridge(swapAndDepositData); } } From 9a4ef73dd39d9eefeee376adf05f168f246a35d3 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Thu, 19 Dec 2024 08:23:22 -0500 Subject: [PATCH 25/27] Update SpokePoolPeriphery.t.sol --- test/evm/foundry/local/SpokePoolPeriphery.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/evm/foundry/local/SpokePoolPeriphery.t.sol b/test/evm/foundry/local/SpokePoolPeriphery.t.sol index 085aa2685..da9af4d74 100644 --- a/test/evm/foundry/local/SpokePoolPeriphery.t.sol +++ b/test/evm/foundry/local/SpokePoolPeriphery.t.sol @@ -164,7 +164,7 @@ contract SpokePoolPeripheryTest is Test { function testInitializeProxy() public { SpokePoolPeripheryProxy _proxy = new SpokePoolPeripheryProxy(); _proxy.initialize(spokePoolPeriphery); - assertEq(address(_proxy.SPOKE_POOL_PERIPHERY()), address(spokePoolPeriphery)); + assertEq(address(_proxy.spokePoolPeriphery()), address(spokePoolPeriphery)); vm.expectRevert(SpokePoolPeripheryProxy.ContractInitialized.selector); _proxy.initialize(spokePoolPeriphery); } From 26110a9fb53f64e7690e5c706276924b033bbfa2 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Tue, 24 Dec 2024 19:12:48 +0100 Subject: [PATCH 26/27] fix: eip712 types and hashes (#821) --- contracts/libraries/PeripherySigningLib.sol | 51 ++++++--------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/contracts/libraries/PeripherySigningLib.sol b/contracts/libraries/PeripherySigningLib.sol index 9ad0820eb..5956a6de8 100644 --- a/contracts/libraries/PeripherySigningLib.sol +++ b/contracts/libraries/PeripherySigningLib.sol @@ -4,45 +4,22 @@ pragma solidity ^0.8.0; import { SpokePoolV3PeripheryInterface } from "../interfaces/SpokePoolV3PeripheryInterface.sol"; library PeripherySigningLib { - bytes internal constant EIP712_FEES_TYPE = abi.encodePacked("Fees(", "uint256 amount", "address recipient)"); - // Typed structured data for the structs to sign against in the periphery. - bytes internal constant EIP712_BASE_DEPOSIT_DATA_TYPE = - abi.encodePacked( - "BaseDepositData(", - "address inputToken", - "address outputToken", - "uint256 outputAmount", - "address depositor", - "address recipient", - "uint256 destinationChainId", - "address exclusiveRelayer", - "uint32 quoteTimestamp", - "uint32 fillDeadline", - "uint32 exclusivityParameter", - "bytes message)" - ); - bytes internal constant EIP712_DEPOSIT_DATA_TYPE = - abi.encodePacked("DepositData(Fees submissionFees,BaseDepositData baseDepositData,uint256 inputAmount)"); - bytes internal constant EIP712_SWAP_AND_DEPOSIT_DATA_TYPE = - abi.encodePacked( - "SwapAndDepositData(", - "Fees submissionFees", - "BaseDepositData depositData", - "address swapToken", - "address exchange", - "TransferType transferType", - "uint256 swapTokenAmount", - "uint256 minExpectedInputTokenAmount", - "bytes routerCalldata)" - ); + 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(EIP712_FEES_TYPE); - bytes32 internal constant EIP712_BASE_DEPOSIT_DATA_TYPEHASH = keccak256(EIP712_BASE_DEPOSIT_DATA_TYPE); + 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.encode(EIP712_DEPOSIT_DATA_TYPE, EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); + 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.encode(EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE)); + 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)"; @@ -50,8 +27,8 @@ library PeripherySigningLib { string( abi.encodePacked( "SwapAndDepositData witness)", - EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE, + EIP712_FEES_TYPE, EIP712_SWAP_AND_DEPOSIT_DATA_TYPE, TOKEN_PERMISSIONS_TYPE ) @@ -60,9 +37,9 @@ library PeripherySigningLib { string( abi.encodePacked( "DepositData witness)", - EIP712_FEES_TYPE, EIP712_BASE_DEPOSIT_DATA_TYPE, EIP712_DEPOSIT_DATA_TYPE, + EIP712_FEES_TYPE, TOKEN_PERMISSIONS_TYPE ) ); From c1f618173eace1dd03ea2a80124c07264e473840 Mon Sep 17 00:00:00 2001 From: bennett Date: Wed, 25 Dec 2024 15:21:21 -0600 Subject: [PATCH 27/27] refactor comments Signed-off-by: bennett --- contracts/SpokePoolV3Periphery.sol | 99 ++----------------- .../SpokePoolV3PeripheryInterface.sol | 91 +++++++++++++++++ 2 files changed, 99 insertions(+), 91 deletions(-) diff --git a/contracts/SpokePoolV3Periphery.sol b/contracts/SpokePoolV3Periphery.sol index 59323b30f..32790dbe2 100644 --- a/contracts/SpokePoolV3Periphery.sol +++ b/contracts/SpokePoolV3Periphery.sol @@ -198,27 +198,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC permit2 = _permit2; } - /** - * @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. - */ + // @inheritdoc SpokePoolV3PeripheryInterface function deposit( address recipient, address inputToken, @@ -252,15 +232,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC ); } - /** - * @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. - */ + // @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. @@ -281,16 +253,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC _swapAndBridge(swapAndDepositData); } - /** - * @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). - */ + // @inheritdoc SpokePoolV3PeripheryInterface function swapAndBridgeWithPermit( address signatureOwner, SwapAndDepositData calldata swapAndDepositData, @@ -321,16 +284,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC _swapAndBridge(swapAndDepositData); } - /** - * @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. - */ + // @inheritdoc SpokePoolV3PeripheryInterface function swapAndBridgeWithPermit2( address signatureOwner, SwapAndDepositData calldata swapAndDepositData, @@ -360,18 +314,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC _swapAndBridge(swapAndDepositData); } - /** - * @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). - */ + // @inheritdoc SpokePoolV3PeripheryInterface function swapAndBridgeWithAuthorization( address signatureOwner, SwapAndDepositData calldata swapAndDepositData, @@ -412,15 +355,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC _swapAndBridge(swapAndDepositData); } - /** - * @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). - */ + // @inheritdoc SpokePoolV3PeripheryInterface function depositWithPermit( address signatureOwner, DepositData calldata depositData, @@ -461,15 +396,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC ); } - /** - * @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. - */ + // @inheritdoc SpokePoolV3PeripheryInterface function depositWithPermit2( address signatureOwner, DepositData calldata depositData, @@ -513,17 +440,7 @@ contract SpokePoolV3Periphery is SpokePoolV3PeripheryInterface, Lockable, MultiC ); } - /** - * @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). - */ + // @inheritdoc SpokePoolV3PeripheryInterface function depositWithAuthorization( address signatureOwner, DepositData calldata depositData, diff --git a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol index 1daa0e96b..ec5e031d7 100644 --- a/contracts/interfaces/SpokePoolV3PeripheryInterface.sol +++ b/contracts/interfaces/SpokePoolV3PeripheryInterface.sol @@ -102,6 +102,27 @@ interface SpokePoolV3PeripheryInterface { 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, @@ -115,8 +136,27 @@ interface SpokePoolV3PeripheryInterface { 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, @@ -125,6 +165,16 @@ interface SpokePoolV3PeripheryInterface { 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, @@ -132,6 +182,18 @@ interface SpokePoolV3PeripheryInterface { 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, @@ -142,6 +204,15 @@ interface SpokePoolV3PeripheryInterface { 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, @@ -150,6 +221,15 @@ interface SpokePoolV3PeripheryInterface { 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, @@ -157,6 +237,17 @@ interface SpokePoolV3PeripheryInterface { 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,