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] 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 + ) + }); + } }