From 82d68e8450e3d29dc3ace8eebac232567d3ec4f9 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Fri, 26 Sep 2025 14:14:00 +0200 Subject: [PATCH 01/13] feat: v3 contracts which include events with explicit id associated --- foundry.lock | 20 + .../RouterAndApprovalProxyV2_1Deployer.s.sol | 2 +- ...ApprovalProxyV2_1_NonTstore_Deployer.s.sol | 2 +- src/{v2.1/interfaces => common}/IERC3009.sol | 0 src/{v2.1/utils => common}/Multicall3.sol | 12 +- .../Permits.sol} | 17 - .../ReentrancyGuardMsgSender.sol | 0 .../ReentrancyGuardMsgSender_NonTstore.sol | 0 src/v2.1/RelayApprovalProxyV2_1.sol | 6 +- src/v2.1/RelayRouterV2_1.sol | 6 +- src/v2.1/RelayRouterV2_1_NonTstore.sol | 6 +- src/v2.1/interfaces/IRelayRouterV2_1.sol | 2 +- src/v3/RelayApprovalProxyV3.sol | 388 +++++++++ src/v3/RelayRouterV3.sol | 299 +++++++ src/v3/RelayRouterV3_NonTstore.sol | 299 +++++++ src/v3/interfaces/IRelayRouterV3.sol | 11 + test/base/BaseTest.sol | 5 +- test/interfaces/IUniswapV2Router02.sol | 3 +- test/mocks/NoOpERC20.sol | 1 - test/v2.1/RouterAndApprovalV2_1Test.sol | 9 +- test/v3/RouterAndApprovalV3Test.sol | 743 ++++++++++++++++++ 21 files changed, 1787 insertions(+), 44 deletions(-) create mode 100644 foundry.lock rename src/{v2.1/interfaces => common}/IERC3009.sol (100%) rename src/{v2.1/utils => common}/Multicall3.sol (92%) rename src/{v2.1/utils/RelayV2_1Structs.sol => common/Permits.sol} (60%) rename src/{v2.1/utils => common}/ReentrancyGuardMsgSender.sol (100%) rename src/{v2.1/utils => common}/ReentrancyGuardMsgSender_NonTstore.sol (100%) create mode 100644 src/v3/RelayApprovalProxyV3.sol create mode 100644 src/v3/RelayRouterV3.sol create mode 100644 src/v3/RelayRouterV3_NonTstore.sol create mode 100644 src/v3/interfaces/IRelayRouterV3.sol create mode 100644 test/v3/RouterAndApprovalV3Test.sol diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..c6460a1 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,20 @@ +{ + "lib/0x-settler": { + "rev": "2051c492f0350acb7b50118248c2afe24274a5ac" + }, + "lib/forge-std": { + "rev": "1714bee72e286e73f76e320d110e0eaf5c4e649d" + }, + "lib/openzeppelin-contracts": { + "rev": "dbb6104ce834628e473d2173bbc9d47f81a9eec3" + }, + "lib/permit2-relay": { + "rev": "c4c481b643849db988d2f08610f37da7efcd2bda" + }, + "lib/solady": { + "rev": "f74d6b6cec7e5f72f57949dfa5d51b632bcc29a6" + }, + "lib/trustlessPermit": { + "rev": "07a51a046580c5a6c7bb71cbf4cf6738e85ab310" + } +} \ No newline at end of file diff --git a/script/RouterAndApprovalProxyV2_1Deployer.s.sol b/script/RouterAndApprovalProxyV2_1Deployer.s.sol index 26e1007..502121d 100644 --- a/script/RouterAndApprovalProxyV2_1Deployer.s.sol +++ b/script/RouterAndApprovalProxyV2_1Deployer.s.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import "forge-std/Script.sol"; +import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; import {RelayApprovalProxyV2_1} from "../src/v2.1/RelayApprovalProxyV2_1.sol"; diff --git a/script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol b/script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol index e95abb8..750b296 100644 --- a/script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol +++ b/script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import "forge-std/Script.sol"; +import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; import {RelayApprovalProxyV2_1} from "../src/v2.1/RelayApprovalProxyV2_1.sol"; diff --git a/src/v2.1/interfaces/IERC3009.sol b/src/common/IERC3009.sol similarity index 100% rename from src/v2.1/interfaces/IERC3009.sol rename to src/common/IERC3009.sol diff --git a/src/v2.1/utils/Multicall3.sol b/src/common/Multicall3.sol similarity index 92% rename from src/v2.1/utils/Multicall3.sol rename to src/common/Multicall3.sol index 993fa2c..5f3afbd 100644 --- a/src/v2.1/utils/Multicall3.sol +++ b/src/common/Multicall3.sol @@ -1,7 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.12; -import {Call3Value, Result} from "./RelayV2_1Structs.sol"; +struct Call3Value { + address target; + bool allowFailure; + uint256 value; + bytes callData; +} + +struct Result { + bool success; + bytes returnData; +} /// @title Multicall3 /// @notice Aggregate results from multiple function calls diff --git a/src/v2.1/utils/RelayV2_1Structs.sol b/src/common/Permits.sol similarity index 60% rename from src/v2.1/utils/RelayV2_1Structs.sol rename to src/common/Permits.sol index e0b57b8..1e391e5 100644 --- a/src/v2.1/utils/RelayV2_1Structs.sol +++ b/src/common/Permits.sol @@ -1,13 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -struct Call3Value { - address target; - bool allowFailure; - uint256 value; - bytes callData; -} - struct Permit2612 { address token; address owner; @@ -28,13 +21,3 @@ struct Permit3009 { bytes32 r; bytes32 s; } - -struct Result { - bool success; - bytes returnData; -} - -struct RelayerWitness { - address relayer; - Call3Value[] call3Values; -} diff --git a/src/v2.1/utils/ReentrancyGuardMsgSender.sol b/src/common/ReentrancyGuardMsgSender.sol similarity index 100% rename from src/v2.1/utils/ReentrancyGuardMsgSender.sol rename to src/common/ReentrancyGuardMsgSender.sol diff --git a/src/v2.1/utils/ReentrancyGuardMsgSender_NonTstore.sol b/src/common/ReentrancyGuardMsgSender_NonTstore.sol similarity index 100% rename from src/v2.1/utils/ReentrancyGuardMsgSender_NonTstore.sol rename to src/common/ReentrancyGuardMsgSender_NonTstore.sol diff --git a/src/v2.1/RelayApprovalProxyV2_1.sol b/src/v2.1/RelayApprovalProxyV2_1.sol index c20f890..0a83b82 100644 --- a/src/v2.1/RelayApprovalProxyV2_1.sol +++ b/src/v2.1/RelayApprovalProxyV2_1.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.23; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IPermit2} from "permit2-relay/src/interfaces/IPermit2.sol"; import {ISignatureTransfer} from "permit2-relay/src/interfaces/ISignatureTransfer.sol"; @@ -10,9 +9,10 @@ import {Ownable} from "solady/src/auth/Ownable.sol"; import {SignatureCheckerLib} from "solady/src/utils/SignatureCheckerLib.sol"; import {TrustlessPermit} from "trustlessPermit/TrustlessPermit.sol"; -import {IERC3009} from "./interfaces/IERC3009.sol"; import {IRelayRouterV2_1} from "./interfaces/IRelayRouterV2_1.sol"; -import {Call3Value, Permit2612, Permit3009, Result} from "./utils/RelayV2_1Structs.sol"; +import {IERC3009} from "../common/IERC3009.sol"; +import {Call3Value, Result} from "../common/Multicall3.sol"; +import {Permit2612, Permit3009} from "../common/Permits.sol"; contract RelayApprovalProxyV2_1 is Ownable { using SafeERC20 for IERC20; diff --git a/src/v2.1/RelayRouterV2_1.sol b/src/v2.1/RelayRouterV2_1.sol index d1207ee..4ec5534 100644 --- a/src/v2.1/RelayRouterV2_1.sol +++ b/src/v2.1/RelayRouterV2_1.sol @@ -2,14 +2,12 @@ pragma solidity ^0.8.25; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; -import {Multicall3} from "./utils/Multicall3.sol"; -import {ReentrancyGuardMsgSender} from "./utils/ReentrancyGuardMsgSender.sol"; -import {Call3Value, Result, RelayerWitness} from "./utils/RelayV2_1Structs.sol"; +import {Call3Value, Multicall3, Result} from "../common/Multicall3.sol"; +import {ReentrancyGuardMsgSender} from "../common/ReentrancyGuardMsgSender.sol"; contract RelayRouterV2_1 is Multicall3, ReentrancyGuardMsgSender { using SafeTransferLib for address; diff --git a/src/v2.1/RelayRouterV2_1_NonTstore.sol b/src/v2.1/RelayRouterV2_1_NonTstore.sol index 261cce4..72d8490 100644 --- a/src/v2.1/RelayRouterV2_1_NonTstore.sol +++ b/src/v2.1/RelayRouterV2_1_NonTstore.sol @@ -2,14 +2,12 @@ pragma solidity ^0.8.25; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; -import {Multicall3} from "./utils/Multicall3.sol"; -import {ReentrancyGuardMsgSender_NonTstore} from "./utils/ReentrancyGuardMsgSender_NonTstore.sol"; -import {Call3Value, Result, RelayerWitness} from "./utils/RelayV2_1Structs.sol"; +import {Call3Value, Multicall3, Result} from "../common/Multicall3.sol"; +import {ReentrancyGuardMsgSender_NonTstore} from "../common/ReentrancyGuardMsgSender_NonTstore.sol"; contract RelayRouterV2_1_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTstore { using SafeTransferLib for address; diff --git a/src/v2.1/interfaces/IRelayRouterV2_1.sol b/src/v2.1/interfaces/IRelayRouterV2_1.sol index e9a84e4..598360f 100644 --- a/src/v2.1/interfaces/IRelayRouterV2_1.sol +++ b/src/v2.1/interfaces/IRelayRouterV2_1.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import {Call3Value, Result} from "../utils/RelayV2_1Structs.sol"; +import {Call3Value, Result} from "../../common/Multicall3.sol"; interface IRelayRouterV2_1 { function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient) diff --git a/src/v3/RelayApprovalProxyV3.sol b/src/v3/RelayApprovalProxyV3.sol new file mode 100644 index 0000000..464fcb7 --- /dev/null +++ b/src/v3/RelayApprovalProxyV3.sol @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IPermit2} from "permit2-relay/src/interfaces/IPermit2.sol"; +import {ISignatureTransfer} from "permit2-relay/src/interfaces/ISignatureTransfer.sol"; +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {SignatureCheckerLib} from "solady/src/utils/SignatureCheckerLib.sol"; +import {TrustlessPermit} from "trustlessPermit/TrustlessPermit.sol"; + +import {IRelayRouterV3} from "./interfaces/IRelayRouterV3.sol"; +import {IERC3009} from "../common/IERC3009.sol"; +import {Call3Value, Result} from "../common/Multicall3.sol"; +import {Permit2612, Permit3009} from "../common/Permits.sol"; + +contract RelayApprovalProxyV3 is Ownable { + using SafeERC20 for IERC20; + using SignatureCheckerLib for address; + using TrustlessPermit for address; + + /// @notice Revert if the array lengths do not match + error ArrayLengthsMismatch(); + + /// @notice Revert if the native transfer fails + error NativeTransferFailed(); + + /// @notice Revert if the refundTo address is zero address + error RefundToCannotBeZeroAddress(); + + /// @notice Emitted when pulling funds from a user + event RouterPull( + address from, + address currency, + uint256 amount, + bytes32 indexed id + ); + + /// @notice The address of the router contract + address private immutable ROUTER; + + /// @notice The Permit2 contract + IPermit2 private immutable PERMIT2; + + bytes32 public constant _CALL3VALUE_TYPEHASH = + keccak256( + "Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + ); + string public constant _RELAYER_WITNESS_TYPE_STRING = + "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; + bytes32 public constant _RELAYER_WITNESS_TYPEHASH = + keccak256( + "RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + ); + + receive() external payable {} + + constructor(address _owner, address _router, address _permit2) { + _initializeOwner(_owner); + ROUTER = _router; + PERMIT2 = IPermit2(_permit2); + } + + /// @notice Withdraw function in case funds get stuck in contract + function withdraw() external onlyOwner { + _send(msg.sender, address(this).balance); + } + + /// @notice Transfer tokens to RelayRouter and perform multicall in a single tx + /// @dev This contract must be approved to transfer msg.sender's tokens to the RelayRouter. If leftover native tokens + /// is expected as part of the multicall, be sure to set refundTo to the expected recipient. If the multicall + /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. + /// @param tokens An array of token addresses to transfer + /// @param amounts An array of token amounts to transfer + /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + function transferAndMulticall( + address[] calldata tokens, + uint256[] calldata amounts, + Call3Value[] calldata calls, + address refundTo, + address nftRecipient, + bytes32 id + ) external payable returns (Result[] memory returnData) { + // Revert if array lengths do not match + if ((tokens.length != amounts.length)) { + revert ArrayLengthsMismatch(); + } + + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + + // Transfer the tokens to the router + for (uint256 i = 0; i < tokens.length; i++) { + IERC20(tokens[i]).safeTransferFrom(msg.sender, ROUTER, amounts[i]); + + emit RouterPull(msg.sender, tokens[i], amounts[i], id); + } + + // Call multicall on the router + // @dev msg.sender for the calls to targets will be the router + returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( + calls, + refundTo, + nftRecipient, + id + ); + } + + /// @notice Use ERC2612 permit to transfer tokens to RelayRouter and execute multicall in a single tx + /// @dev Approved spender must be address(this) to transfer user's tokens to the RelayRouter. If leftover native tokens + /// is expected as part of the multicall, be sure to set refundTo to the expected recipient. If the multicall + /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. + /// @param permits An array of permits + /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + /// @return returnData The return data from the multicall + function permitTransferAndMulticall( + Permit2612[] calldata permits, + Call3Value[] calldata calls, + address refundTo, + address nftRecipient, + bytes32 id + ) external payable returns (Result[] memory returnData) { + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + + for (uint256 i = 0; i < permits.length; i++) { + Permit2612 memory permit = permits[i]; + + // Revert if the permit owner is not the msg.sender + if (permit.owner != msg.sender) { + revert Unauthorized(); + } + + // Use the permit. Calling `trustlessPermit` allows tx to + // continue even if permit gets frontrun + permit.token.trustlessPermit( + permit.owner, + address(this), + permit.value, + permit.deadline, + permit.v, + permit.r, + permit.s + ); + + // Transfer the tokens to the router + IERC20(permit.token).safeTransferFrom( + permit.owner, + ROUTER, + permit.value + ); + + emit RouterPull(permit.owner, permit.token, permit.value, id); + } + + // Call multicall on the router + returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( + calls, + refundTo, + nftRecipient, + id + ); + } + + /// @notice Use Permit2 to transfer tokens to RelayRouter and perform an arbitrary multicall. + /// Pass in an empty permitSignature to only perform the multicall. + /// @dev msg.value will persist across all calls in the multicall. If leftover native tokens is expected + /// as part of the multicall, be sure to set refundTo to the expected recipient. If the multicall + /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. + /// @param user The address of the user + /// @param permit The permit details + /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + /// @param permitSignature The signature for the permit + function permit2TransferAndMulticall( + address user, + ISignatureTransfer.PermitBatchTransferFrom memory permit, + Call3Value[] calldata calls, + address refundTo, + address nftRecipient, + bytes32 id, + bytes memory permitSignature + ) external payable returns (Result[] memory returnData) { + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + + // If a permit signature is provided, use it to transfer tokens from user to router + if (permitSignature.length != 0) { + _handleBatchPermit( + user, + refundTo, + nftRecipient, + id, + permit, + calls, + permitSignature + ); + } + + // Call multicall on the router + returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( + calls, + refundTo, + nftRecipient, + id + ); + } + + /// @notice Use ERC3009 permit to transfer tokens to RelayRouter and execute multicall in a single tx + /// @dev Approved spender must be address(this) to transfer user's tokens to the RelayRouter. If leftover native tokens + /// is expected as part of the multicall, be sure to set refundTo to the expected recipient. If the multicall + /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. + /// @param permits An array of permits + /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + /// @return returnData The return data from the multicall + function permit3009TransferAndMulticall( + Permit3009[] calldata permits, + address[] calldata tokens, + Call3Value[] calldata calls, + address refundTo, + address nftRecipient, + bytes32 id + ) external payable returns (Result[] memory returnData) { + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + + // Revert if array lengths do not match + if ((tokens.length != permits.length)) { + revert ArrayLengthsMismatch(); + } + + for (uint256 i = 0; i < permits.length; i++) { + Permit3009 memory permit = permits[i]; + + // Use the permit + IERC3009(tokens[i]).receiveWithAuthorization( + permit.from, + address(this), + permit.value, + permit.validAfter, + permit.validBefore, + _getRelayerWitnessHash(refundTo, nftRecipient, calls), + permit.v, + permit.r, + permit.s + ); + + // Transfer the tokens to the router + IERC20(tokens[i]).safeTransfer(ROUTER, permit.value); + + emit RouterPull(permit.from, tokens[i], permit.value, id); + } + + // Call multicall on the router + returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( + calls, + refundTo, + nftRecipient, + id + ); + } + + /// @notice Internal function to get the hash of a list of `Call3Value` structs + /// @param calls The calls to perform + function _getCallsHash( + Call3Value[] memory calls + ) internal pure returns (bytes32) { + // Create an array of keccak256 hashes of the calls + bytes32[] memory callHashes = new bytes32[](calls.length); + for (uint256 i = 0; i < calls.length; i++) { + // Encode the call and hash it + callHashes[i] = keccak256( + abi.encode( + _CALL3VALUE_TYPEHASH, + calls[i].target, + calls[i].allowFailure, + calls[i].value, + keccak256(calls[i].callData) + ) + ); + } + + return keccak256(abi.encodePacked(callHashes)); + } + + /// @notice Internal function to get the hash of a relayer witness + /// @param refundTo The refundTo address + /// @param nftRecipient The nftRecipient address + /// @param calls The calls to be executed + function _getRelayerWitnessHash( + address refundTo, + address nftRecipient, + Call3Value[] memory calls + ) internal view returns (bytes32) { + return + keccak256( + abi.encode( + _RELAYER_WITNESS_TYPEHASH, + msg.sender, + refundTo, + nftRecipient, + _getCallsHash(calls) + ) + ); + } + + /// @notice Internal function to handle a permit batch transfer + /// @param user The address of the user + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + /// @param permit The permit details + /// @param calls The calls to perform + /// @param permitSignature The signature for the permit + function _handleBatchPermit( + address user, + address refundTo, + address nftRecipient, + bytes32 id, + ISignatureTransfer.PermitBatchTransferFrom memory permit, + Call3Value[] calldata calls, + bytes memory permitSignature + ) internal { + bytes32 witness = _getRelayerWitnessHash(refundTo, nftRecipient, calls); + + // Create the SignatureTransferDetails array + ISignatureTransfer.SignatureTransferDetails[] + memory signatureTransferDetails = new ISignatureTransfer.SignatureTransferDetails[]( + permit.permitted.length + ); + for (uint256 i = 0; i < permit.permitted.length; i++) { + uint256 amount = permit.permitted[i].amount; + + signatureTransferDetails[i] = ISignatureTransfer + .SignatureTransferDetails({ + to: address(ROUTER), + requestedAmount: amount + }); + + emit RouterPull(user, permit.permitted[i].token, amount, id); + } + + // Use the SignatureTransferDetails and permit signature to transfer tokens to the router + PERMIT2.permitWitnessTransferFrom( + permit, + signatureTransferDetails, + // When using a permit signature, cannot deposit on behalf of someone else other than `user` + user, + witness, + _RELAYER_WITNESS_TYPE_STRING, + permitSignature + ); + } + + function _send(address to, uint256 value) internal { + bool success; + assembly { + // Save gas by avoiding copying the return data to memory. + // Provide at most 100k gas to the internal call, which is + // more than enough to cover common use-cases of logic for + // receiving native tokens (eg. SCW payable fallbacks). + success := call(100000, to, value, 0, 0, 0, 0) + } + + if (!success) { + revert NativeTransferFailed(); + } + } +} diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol new file mode 100644 index 0000000..5f4e896 --- /dev/null +++ b/src/v3/RelayRouterV3.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; + +import {Call3Value, Multicall3, Result} from "../common/Multicall3.sol"; +import {ReentrancyGuardMsgSender} from "../common/ReentrancyGuardMsgSender.sol"; + +contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { + using SafeTransferLib for address; + + /// @notice Revert if this contract is set as the recipient + error InvalidRecipient(address recipient); + + /// @notice Revert if the target is invalid + error InvalidTarget(address target); + + /// @notice Revert if the native transfer failed + error NativeTransferFailed(); + + /// @notice Revert if no recipient is set + error NoRecipientSet(); + + /// @notice Revert if the array lengths do not match + error ArrayLengthsMismatch(); + + /// @notice Revert if a call fails + error CallFailed(); + + /// @notice Protocol event to be emitted when transferring native tokens + event SolverNativeTransfer(address to, uint256 amount); + + /// @notice Emitted when pulling funds from a user + event RouterPush(address to, address currency, uint256 amount, bytes32 id); + + uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; + + constructor() {} + + receive() external payable { + emit SolverNativeTransfer(address(this), msg.value); + } + + /// @notice Execute a multicall with the RelayRouter as msg.sender. + /// @dev If a multicall is expecting to mint ERC721s or ERC1155s, the recipient must be explicitly set + /// All calls to ERC721s and ERC1155s in the multicall will have the same recipient set in recipient + /// Be sure to transfer ERC20s or native tokens out of the router as part of the multicall + /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient, bytes32 id) + public + payable + virtual + nonReentrant + returns (Result[] memory returnData) + { + // Set the NFT recipient if provided + if (nftRecipient != address(0)) { + _setRecipient(nftRecipient); + } + + // Perform the multicall + returnData = _aggregate3Value(calls); + + // Clear the recipient in storage + _clearRecipient(); + + // Refund any leftover native tokens to the sender + cleanupNative(0, refundTo, id); + } + + /// @notice Send leftover ERC20 tokens to recipients + /// @dev Should be included in the multicall if the router is expecting to receive tokens + /// Set amount to 0 to transfer the full balance + /// @param tokens The addresses of the ERC20 tokens + /// @param recipients The addresses to refund the tokens to + /// @param amounts The amounts to send + /// @param id The id to associate the call to + function cleanupErc20s(address[] calldata tokens, address[] calldata recipients, uint256[] calldata amounts, bytes32 id) + public + virtual + { + // Revert if array lengths do not match + if (tokens.length != amounts.length || amounts.length != recipients.length) { + revert ArrayLengthsMismatch(); + } + + for (uint256 i; i < tokens.length; i++) { + address token = tokens[i]; + address recipient = recipients[i]; + + // Get the amount to transfer + uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + + if (amount > 0) { + // Transfer the token to the recipient address + token.safeTransfer(recipient, amount); + + emit RouterPush(recipient, token, amount, id); + } + } + } + + /// @notice Send leftover ERC20 tokens via explicit method calls + /// @dev Should be included in the multicall if the router is expecting to receive tokens + /// Set amount to 0 to transfer the full balance + /// @param tokens The addresses of the ERC20 tokens + /// @param tos The target addresses for the calls + /// @param datas The data for the calls + /// @param amounts The amounts to send + /// @param id The id to associate the call to + function cleanupErc20sViaCall( + address[] calldata tokens, + address[] calldata tos, + bytes[] calldata datas, + uint256[] calldata amounts, + bytes32 id + ) public virtual { + // Revert if array lengths do not match + if (tokens.length != amounts.length || amounts.length != tos.length || tos.length != datas.length) { + revert ArrayLengthsMismatch(); + } + + for (uint256 i; i < tokens.length; i++) { + address token = tokens[i]; + address to = tos[i]; + bytes calldata data = datas[i]; + + // Get the amount to transfer + uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + + if (amount > 0) { + // First approve the target address for the call + IERC20(token).approve(to, amount); + + // Make the call + (bool success,) = to.call(data); + if (!success) { + revert CallFailed(); + } + + emit RouterPush(to, token, amount, id); + } + } + } + + /// @notice Send leftover native tokens to the recipient address + /// @dev Set amount to 0 to transfer the full balance. Set recipient to address(0) to transfer to msg.sender + /// @param amount The amount of native tokens to transfer + /// @param recipient The recipient address + /// @param id The id to associate the call to + function cleanupNative(uint256 amount, address recipient, bytes32 id) public virtual { + // If recipient is address(0), set to msg.sender + address recipientAddr = recipient == address(0) ? msg.sender : recipient; + + uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; + + if (amountToTransfer > 0) { + recipientAddr.safeTransferETH(amountToTransfer); + emit SolverNativeTransfer(recipientAddr, amountToTransfer); + emit RouterPush(recipientAddr, address(0), amountToTransfer, id); + } + } + + /// @notice Send leftover native tokens via an explicit method call + /// @dev Set amount to 0 to transfer the full balance + /// @param amount The amount of native tokens to transfer + /// @param to The target address of the call + /// @param data The data for the call + /// @param id The id to associate the call to + function cleanupNativeViaCall(uint256 amount, address to, bytes calldata data, bytes32 id) public virtual { + uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; + + if (amountToTransfer > 0) { + (bool success,) = to.call{value: amountToTransfer}(data); + if (!success) { + revert CallFailed(); + } + + emit RouterPush(to, address(0), amountToTransfer, id); + } + } + + /// @notice Internal function to set the recipient address for ERC721 or ERC1155 mint + /// @dev If the chain does not support tstore, recipient will be saved in storage + /// @param recipient The address of the recipient + function _setRecipient(address recipient) internal { + // For safety, revert if the recipient is this contract + // Tokens should either be minted directly to recipient, or transferred to recipient through the onReceived hooks + if (recipient == address(this)) { + revert InvalidRecipient(address(this)); + } + + // Set the recipient in storage + uint256 recipientStorageSlot = RECIPIENT_STORAGE_SLOT; + uint256 recipientValue = uint256(uint160(recipient)); + assembly { + sstore(recipientStorageSlot, recipientValue) + } + } + + /// @notice Internal function to get the recipient address for ERC721 or ERC1155 mint + function _getRecipient() internal view returns (address) { + uint256 recipientStorageSlot = RECIPIENT_STORAGE_SLOT; + uint256 value; + + assembly { + value := sload(recipientStorageSlot) + } + + // Get the recipient from storage + return address(uint160(value)); + } + + /// @notice Internal function to clear the recipient address for ERC721 or ERC1155 mint + function _clearRecipient() internal { + // Return if recipient hasn't been set + if (_getRecipient() == address(0)) { + return; + } + + // Clear the recipient in storage + uint256 recipientStorageSlot = RECIPIENT_STORAGE_SLOT; + assembly { + sstore(recipientStorageSlot, 0) + } + } + + function onERC721Received(address, /*_operator*/ address, /*_from*/ uint256 _tokenId, bytes calldata _data) + external + returns (bytes4) + { + // Get the recipient from storage + address recipient = _getRecipient(); + + // Revert if no recipient is set + // Note this means transferring NFTs to this contract via `safeTransferFrom` will revert, + // unless the transfer is part of a multicall that sets the recipient in storage + if (recipient == address(0)) { + revert NoRecipientSet(); + } + + // Transfer the NFT to the recipient + IERC721(msg.sender).safeTransferFrom(address(this), recipient, _tokenId, _data); + + return this.onERC721Received.selector; + } + + function onERC1155Received( + address, /*_operator*/ + address, /*_from*/ + uint256 _id, + uint256 _value, + bytes calldata _data + ) external returns (bytes4) { + // Get the recipient from storage + address recipient = _getRecipient(); + + // Revert if no recipient is set + // Note this means transferring NFTs to this contract via `safeTransferFrom` will revert, + // unless the transfer is part of a multicall that sets the recipient in storage + if (recipient == address(0)) { + revert NoRecipientSet(); + } + + // Transfer the tokens to the recipient + IERC1155(msg.sender).safeTransferFrom(address(this), recipient, _id, _value, _data); + + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, /*_operator*/ + address, /*_from*/ + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4) { + // Get the recipient from storage + address recipient = _getRecipient(); + + // Revert if no recipient is set + // Note this means transferring NFTs to this contract via `safeTransferFrom` will revert, + // unless the transfer is part of a multicall that sets the recipient in storage + if (recipient == address(0)) { + revert NoRecipientSet(); + } + + // Transfer the tokens to the recipient + IERC1155(msg.sender).safeBatchTransferFrom(address(this), recipient, _ids, _values, _data); + + return this.onERC1155BatchReceived.selector; + } +} diff --git a/src/v3/RelayRouterV3_NonTstore.sol b/src/v3/RelayRouterV3_NonTstore.sol new file mode 100644 index 0000000..9a6377d --- /dev/null +++ b/src/v3/RelayRouterV3_NonTstore.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; + +import {Call3Value, Multicall3, Result} from "../common/Multicall3.sol"; +import {ReentrancyGuardMsgSender_NonTstore} from "../common/ReentrancyGuardMsgSender_NonTstore.sol"; + +contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTstore { + using SafeTransferLib for address; + + /// @notice Revert if this contract is set as the recipient + error InvalidRecipient(address recipient); + + /// @notice Revert if the target is invalid + error InvalidTarget(address target); + + /// @notice Revert if the native transfer failed + error NativeTransferFailed(); + + /// @notice Revert if no recipient is set + error NoRecipientSet(); + + /// @notice Revert if the array lengths do not match + error ArrayLengthsMismatch(); + + /// @notice Revert if a call fails + error CallFailed(); + + /// @notice Protocol event to be emitted when transferring native tokens + event SolverNativeTransfer(address to, uint256 amount); + + /// @notice Emitted when pulling funds from a user + event RouterPush(address to, address currency, uint256 amount, bytes32 id); + + uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; + + constructor() {} + + receive() external payable { + emit SolverNativeTransfer(address(this), msg.value); + } + + /// @notice Execute a multicall with the RelayRouter as msg.sender. + /// @dev If a multicall is expecting to mint ERC721s or ERC1155s, the recipient must be explicitly set + /// All calls to ERC721s and ERC1155s in the multicall will have the same recipient set in recipient + /// Be sure to transfer ERC20s or native tokens out of the router as part of the multicall + /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to + /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param id The id to associate the call to + function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient, bytes32 id) + public + payable + virtual + nonReentrant + returns (Result[] memory returnData) + { + // Set the NFT recipient if provided + if (nftRecipient != address(0)) { + _setRecipient(nftRecipient); + } + + // Perform the multicall + returnData = _aggregate3Value(calls); + + // Clear the recipient in storage + _clearRecipient(); + + // Refund any leftover native tokens to the sender + cleanupNative(0, refundTo, id); + } + + /// @notice Send leftover ERC20 tokens to recipients + /// @dev Should be included in the multicall if the router is expecting to receive tokens + /// Set amount to 0 to transfer the full balance + /// @param tokens The addresses of the ERC20 tokens + /// @param recipients The addresses to refund the tokens to + /// @param amounts The amounts to send + /// @param id The id to associate the call to + function cleanupErc20s(address[] calldata tokens, address[] calldata recipients, uint256[] calldata amounts, bytes32 id) + public + virtual + { + // Revert if array lengths do not match + if (tokens.length != amounts.length || amounts.length != recipients.length) { + revert ArrayLengthsMismatch(); + } + + for (uint256 i; i < tokens.length; i++) { + address token = tokens[i]; + address recipient = recipients[i]; + + // Get the amount to transfer + uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + + if (amount > 0) { + // Transfer the token to the recipient address + token.safeTransfer(recipient, amount); + + emit RouterPush(recipient, token, amount, id); + } + } + } + + /// @notice Send leftover ERC20 tokens via explicit method calls + /// @dev Should be included in the multicall if the router is expecting to receive tokens + /// Set amount to 0 to transfer the full balance + /// @param tokens The addresses of the ERC20 tokens + /// @param tos The target addresses for the calls + /// @param datas The data for the calls + /// @param amounts The amounts to send + /// @param id The id to associate the call to + function cleanupErc20sViaCall( + address[] calldata tokens, + address[] calldata tos, + bytes[] calldata datas, + uint256[] calldata amounts, + bytes32 id + ) public virtual { + // Revert if array lengths do not match + if (tokens.length != amounts.length || amounts.length != tos.length || tos.length != datas.length) { + revert ArrayLengthsMismatch(); + } + + for (uint256 i; i < tokens.length; i++) { + address token = tokens[i]; + address to = tos[i]; + bytes calldata data = datas[i]; + + // Get the amount to transfer + uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + + if (amount > 0) { + // First approve the target address for the call + IERC20(token).approve(to, amount); + + // Make the call + (bool success,) = to.call(data); + if (!success) { + revert CallFailed(); + } + + emit RouterPush(to, token, amount, id); + } + } + } + + /// @notice Send leftover native tokens to the recipient address + /// @dev Set amount to 0 to transfer the full balance. Set recipient to address(0) to transfer to msg.sender + /// @param amount The amount of native tokens to transfer + /// @param recipient The recipient address + /// @param id The id to associate the call to + function cleanupNative(uint256 amount, address recipient, bytes32 id) public virtual { + // If recipient is address(0), set to msg.sender + address recipientAddr = recipient == address(0) ? msg.sender : recipient; + + uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; + + if (amountToTransfer > 0) { + recipientAddr.safeTransferETH(amountToTransfer); + emit SolverNativeTransfer(recipientAddr, amountToTransfer); + emit RouterPush(recipientAddr, address(0), amountToTransfer, id); + } + } + + /// @notice Send leftover native tokens via an explicit method call + /// @dev Set amount to 0 to transfer the full balance + /// @param amount The amount of native tokens to transfer + /// @param to The target address of the call + /// @param data The data for the call + /// @param id The id to associate the call to + function cleanupNativeViaCall(uint256 amount, address to, bytes calldata data, bytes32 id) public virtual { + uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; + + if (amountToTransfer > 0) { + (bool success,) = to.call{value: amountToTransfer}(data); + if (!success) { + revert CallFailed(); + } + + emit RouterPush(to, address(0), amountToTransfer, id); + } + } + + /// @notice Internal function to set the recipient address for ERC721 or ERC1155 mint + /// @dev If the chain does not support tstore, recipient will be saved in storage + /// @param recipient The address of the recipient + function _setRecipient(address recipient) internal { + // For safety, revert if the recipient is this contract + // Tokens should either be minted directly to recipient, or transferred to recipient through the onReceived hooks + if (recipient == address(this)) { + revert InvalidRecipient(address(this)); + } + + // Set the recipient in storage + uint256 recipientStorageSlot = RECIPIENT_STORAGE_SLOT; + uint256 recipientValue = uint256(uint160(recipient)); + assembly { + sstore(recipientStorageSlot, recipientValue) + } + } + + /// @notice Internal function to get the recipient address for ERC721 or ERC1155 mint + function _getRecipient() internal view returns (address) { + uint256 recipientStorageSlot = RECIPIENT_STORAGE_SLOT; + uint256 value; + + assembly { + value := sload(recipientStorageSlot) + } + + // Get the recipient from storage + return address(uint160(value)); + } + + /// @notice Internal function to clear the recipient address for ERC721 or ERC1155 mint + function _clearRecipient() internal { + // Return if recipient hasn't been set + if (_getRecipient() == address(0)) { + return; + } + + // Clear the recipient in storage + uint256 recipientStorageSlot = RECIPIENT_STORAGE_SLOT; + assembly { + sstore(recipientStorageSlot, 0) + } + } + + function onERC721Received(address, /*_operator*/ address, /*_from*/ uint256 _tokenId, bytes calldata _data) + external + returns (bytes4) + { + // Get the recipient from storage + address recipient = _getRecipient(); + + // Revert if no recipient is set + // Note this means transferring NFTs to this contract via `safeTransferFrom` will revert, + // unless the transfer is part of a multicall that sets the recipient in storage + if (recipient == address(0)) { + revert NoRecipientSet(); + } + + // Transfer the NFT to the recipient + IERC721(msg.sender).safeTransferFrom(address(this), recipient, _tokenId, _data); + + return this.onERC721Received.selector; + } + + function onERC1155Received( + address, /*_operator*/ + address, /*_from*/ + uint256 _id, + uint256 _value, + bytes calldata _data + ) external returns (bytes4) { + // Get the recipient from storage + address recipient = _getRecipient(); + + // Revert if no recipient is set + // Note this means transferring NFTs to this contract via `safeTransferFrom` will revert, + // unless the transfer is part of a multicall that sets the recipient in storage + if (recipient == address(0)) { + revert NoRecipientSet(); + } + + // Transfer the tokens to the recipient + IERC1155(msg.sender).safeTransferFrom(address(this), recipient, _id, _value, _data); + + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, /*_operator*/ + address, /*_from*/ + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4) { + // Get the recipient from storage + address recipient = _getRecipient(); + + // Revert if no recipient is set + // Note this means transferring NFTs to this contract via `safeTransferFrom` will revert, + // unless the transfer is part of a multicall that sets the recipient in storage + if (recipient == address(0)) { + revert NoRecipientSet(); + } + + // Transfer the tokens to the recipient + IERC1155(msg.sender).safeBatchTransferFrom(address(this), recipient, _ids, _values, _data); + + return this.onERC1155BatchReceived.selector; + } +} diff --git a/src/v3/interfaces/IRelayRouterV3.sol b/src/v3/interfaces/IRelayRouterV3.sol new file mode 100644 index 0000000..a7bb3e9 --- /dev/null +++ b/src/v3/interfaces/IRelayRouterV3.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Call3Value, Result} from "../../common/Multicall3.sol"; + +interface IRelayRouterV3 { + function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient, bytes32 id) + external + payable + returns (Result[] memory returnData); +} diff --git a/test/base/BaseTest.sol b/test/base/BaseTest.sol index ca3f592..8380644 100644 --- a/test/base/BaseTest.sol +++ b/test/base/BaseTest.sol @@ -1,13 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; -import {Test, console} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {ISignatureTransfer} from "permit2-relay/src/interfaces/ISignatureTransfer.sol"; import {Permit2} from "permit2-relay/src/Permit2.sol"; -import {IUniswapV2Factory} from "../interfaces/IUniswapV2Factory.sol"; -import {IUniswapV2Router02} from "../interfaces/IUniswapV2Router02.sol"; - import {TestERC20} from "../mocks/TestERC20.sol"; import {TestERC20Permit} from "../mocks/TestERC20Permit.sol"; diff --git a/test/interfaces/IUniswapV2Router02.sol b/test/interfaces/IUniswapV2Router02.sol index acb5e1e..d0d87b3 100644 --- a/test/interfaces/IUniswapV2Router02.sol +++ b/test/interfaces/IUniswapV2Router02.sol @@ -1,6 +1,7 @@ +// SPDX-License-Identifier: UNKNOWN pragma solidity >=0.6.2; -import "./IUniswapV2Router01.sol"; +import {IUniswapV2Router01} from "./IUniswapV2Router01.sol"; interface IUniswapV2Router02 is IUniswapV2Router01 { function removeLiquidityETHSupportingFeeOnTransferTokens( diff --git a/test/mocks/NoOpERC20.sol b/test/mocks/NoOpERC20.sol index 6b32dd8..7784755 100644 --- a/test/mocks/NoOpERC20.sol +++ b/test/mocks/NoOpERC20.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.13; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // transferFrom just returns true contract NoOpERC20 is ERC20("Test20", "TST20") { diff --git a/test/v2.1/RouterAndApprovalV2_1Test.sol b/test/v2.1/RouterAndApprovalV2_1Test.sol index 8146ee3..70142ee 100644 --- a/test/v2.1/RouterAndApprovalV2_1Test.sol +++ b/test/v2.1/RouterAndApprovalV2_1Test.sol @@ -1,22 +1,19 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import {StdUtils} from "forge-std/StdUtils.sol"; - import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; -import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IAllowanceHolder} from "0x-settler/src/allowanceholder/IAllowanceHolder.sol"; import {ISignatureTransfer} from "permit2-relay/src/interfaces/ISignatureTransfer.sol"; import {EIP712} from "solady/src/utils/EIP712.sol"; +import {Call3Value} from "../../src/common/Multicall3.sol"; +import {Permit2612, Permit3009} from "../../src/common/Permits.sol"; import {RelayApprovalProxyV2_1} from "../../src/v2.1/RelayApprovalProxyV2_1.sol"; import {RelayRouterV2_1} from "../../src/v2.1/RelayRouterV2_1.sol"; -import {Call3Value, Permit2612, Permit3009} from "../../src/v2.1/utils/RelayV2_1Structs.sol"; -import {BaseTest, RelayerWitness} from "../base/BaseTest.sol"; +import {BaseTest} from "../base/BaseTest.sol"; import {IUniswapV2Router01} from "../interfaces/IUniswapV2Router02.sol"; import {NoOpERC20} from "../mocks/NoOpERC20.sol"; import {TestERC20Permit} from "../mocks/TestERC20Permit.sol"; diff --git a/test/v3/RouterAndApprovalV3Test.sol b/test/v3/RouterAndApprovalV3Test.sol new file mode 100644 index 0000000..874471d --- /dev/null +++ b/test/v3/RouterAndApprovalV3Test.sol @@ -0,0 +1,743 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAllowanceHolder} from "0x-settler/src/allowanceholder/IAllowanceHolder.sol"; +import {ISignatureTransfer} from "permit2-relay/src/interfaces/ISignatureTransfer.sol"; +import {EIP712} from "solady/src/utils/EIP712.sol"; + +import {Call3Value} from "../../src/common/Multicall3.sol"; +import {Permit2612, Permit3009} from "../../src/common/Permits.sol"; +import {RelayApprovalProxyV3} from "../../src/v3/RelayApprovalProxyV3.sol"; +import {RelayRouterV3} from "../../src/v3/RelayRouterV3.sol"; + +import {BaseTest} from "../base/BaseTest.sol"; +import {IUniswapV2Router01} from "../interfaces/IUniswapV2Router02.sol"; +import {NoOpERC20} from "../mocks/NoOpERC20.sol"; +import {TestERC20Permit} from "../mocks/TestERC20Permit.sol"; +import {TestERC721} from "../mocks/TestERC721.sol"; +import {TestERC721_ERC20PaymentToken} from "../mocks/TestERC721_ERC20PaymentToken.sol"; + +// Tests + +contract RouterAndApprovalV3Test is BaseTest, EIP712 { + using SafeERC20 for IERC20; + + // Errors + error Unauthorized(); + error InvalidSender(); + error InvalidSigner(); + error InvalidTarget(address target); + + // Events + event RouterUpdated(address newRouter); + + // Constants + IAllowanceHolder constant ALLOWANCE_HOLDER = IAllowanceHolder(payable(0x0000000000001fF3684f28c67538d4D072C22734)); + + // Fields to be set + RelayRouterV3 router; + RelayApprovalProxyV3 approvalProxy; + + // Various type-hashes / type-strings + bytes32 public constant _CALL3VALUE_TYPEHASH = + keccak256("Call3Value(address target,bool allowFailure,uint256 value,bytes callData)"); + bytes32 public constant _RELAYER_WITNESS_TYPEHASH = keccak256( + "RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + ); + bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_TYPEHASH = keccak256( + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" + ); + bytes32 public constant _PERMIT2_BATCH_TRANSFER_FROM_TYPEHASH = keccak256( + "PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ); + bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH = keccak256( + "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" + ); + bytes32 private constant _PERMIT2612_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 private constant _PERMIT3009_TYPEHASH = keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + string public constant _PERMIT2_RELAYER_WITNESS_TYPE_STRING = + "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; + + // Setup + function setUp() public override { + vm.createSelectFork(vm.rpcUrl("ethereum")); + + super.setUp(); + + // Deploy router and approval-proxy contracts + router = new RelayRouterV3(); + approvalProxy = new RelayApprovalProxyV3(address(this), address(router), address(PERMIT2)); + + // Mint tokens to alice + erc20_1.mint(alice.addr, 1 ether); + erc20_2.mint(alice.addr, 1 ether); + erc20_3.mint(alice.addr, 1 ether); + erc20_permit.mint(alice.addr, 1 ether); + + // Have alice approve permit2 + vm.startPrank(alice.addr); + erc20_1.approve(address(PERMIT2), type(uint256).max); + erc20_2.approve(address(PERMIT2), type(uint256).max); + erc20_3.approve(address(PERMIT2), type(uint256).max); + erc20_permit.approve(address(PERMIT2), type(uint256).max); + vm.stopPrank(); + } + + // Tests + + function testCorrectWitnessTypehashes() public pure { + assertEq( + keccak256(abi.encodePacked(_PERMIT2_WITNESS_TRANSFER_TYPEHASH_STUB, _PERMIT2_RELAYER_WITNESS_TYPE_STRING)), + _PERMIT2_FULL_RELAYER_WITNESS_TYPEHASH + ); + assertEq( + keccak256( + abi.encodePacked(_PERMIT2_BATCH_WITNESS_TRANSFER_TYPEHASH_STUB, _PERMIT2_RELAYER_WITNESS_TYPE_STRING) + ), + _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH + ); + } + + function testApprovalProxy__Permit2TransferAndMulticall() public { + // Create the permit + + ISignatureTransfer.TokenPermissions[] memory permitted = new ISignatureTransfer.TokenPermissions[](3); + permitted[0] = ISignatureTransfer.TokenPermissions({token: address(erc20_1), amount: 0.1 ether}); + permitted[1] = ISignatureTransfer.TokenPermissions({token: address(erc20_2), amount: 0.2 ether}); + permitted[2] = ISignatureTransfer.TokenPermissions({token: address(erc20_3), amount: 0.3 ether}); + + ISignatureTransfer.PermitBatchTransferFrom memory permit = ISignatureTransfer.PermitBatchTransferFrom({ + permitted: permitted, + nonce: 1, + deadline: block.timestamp + 100 + }); + + // Create calldata to transfer tokens from the router to bob + + bytes memory calldata1 = abi.encodeWithSelector(erc20_1.transfer.selector, bob.addr, 0.03 ether); + bytes memory calldata2 = abi.encodeWithSelector(erc20_2.transfer.selector, bob.addr, 0.15 ether); + bytes memory calldata3 = abi.encodeWithSelector(erc20_3.transfer.selector, bob.addr, 0.2 ether); + + Call3Value[] memory calls = new Call3Value[](3); + calls[0] = Call3Value({target: address(erc20_1), allowFailure: false, value: 0, callData: calldata1}); + calls[1] = Call3Value({target: address(erc20_2), allowFailure: false, value: 0, callData: calldata2}); + calls[2] = Call3Value({target: address(erc20_3), allowFailure: false, value: 0, callData: calldata3}); + + // Generate a permit from alice + + bytes32 witness = + keccak256(abi.encode(_RELAYER_WITNESS_TYPEHASH, bob.addr, alice.addr, address(0), _getCallsHash(calls))); + bytes memory permitSignature = getPermit2BatchWitnessSignature( + permit, + address(approvalProxy), + alice.key, + _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH, + witness, + PERMIT2_DOMAIN_SEPARATOR + ); + + // Only the "relayer" (in this case bob) can use the permit via the approval-proxy + vm.prank(cal.addr); + vm.expectRevert(InvalidSigner.selector); + approvalProxy.permit2TransferAndMulticall(alice.addr, permit, calls, alice.addr, address(0), bytes32(0), permitSignature); + + // Call the router + vm.prank(bob.addr); + approvalProxy.permit2TransferAndMulticall(alice.addr, permit, calls, alice.addr, address(0), bytes32(0), permitSignature); + + // Funds transferred as part of the calls are in bob's wallet + assertEq(erc20_1.balanceOf(bob.addr), 0.03 ether); + assertEq(erc20_2.balanceOf(bob.addr), 0.15 ether); + assertEq(erc20_3.balanceOf(bob.addr), 0.2 ether); + + // Any other funds are left in the router + assertEq(erc20_1.balanceOf(address(router)), 0.07 ether); + assertEq(erc20_2.balanceOf(address(router)), 0.05 ether); + assertEq(erc20_3.balanceOf(address(router)), 0.1 ether); + + // All tokens specified by alice were spent from her wallet + assertEq(erc20_1.balanceOf(alice.addr), 0.9 ether); + assertEq(erc20_2.balanceOf(alice.addr), 0.8 ether); + assertEq(erc20_3.balanceOf(alice.addr), 0.7 ether); + } + + function testRouter__Multicall__SwapWETHForUSDC() public { + // Encode swap calldata + + address[] memory path = new address[](2); + path[0] = WETH; + path[1] = USDC; + + bytes memory data = abi.encodeWithSelector( + IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + ); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: data}); + + uint256 aliceEthBalanceBefore = alice.addr.balance; + uint256 aliceUsdcBalanceBefore = IERC20(USDC).balanceOf(alice.addr); + + vm.prank(alice.addr); + router.multicall{value: 1 ether}(calls, address(0), address(0), bytes32(0)); + + uint256 aliceEthBalanceAfter = alice.addr.balance; + uint256 aliceUsdcBalanceAfter = IERC20(USDC).balanceOf(alice.addr); + + assertEq(aliceEthBalanceBefore - aliceEthBalanceAfter, 1 ether); + assertGt(aliceUsdcBalanceAfter, aliceUsdcBalanceBefore); + } + + function testRouter__Multicall__TwoSwaps() public { + // Encode swap calldata + + address[] memory path = new address[](2); + path[0] = WETH; + path[1] = USDC; + + bytes memory calldata1 = abi.encodeWithSelector( + IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + ); + bytes memory calldata2 = abi.encodeWithSelector( + IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + ); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](2); + calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: calldata1}); + calls[1] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: calldata2}); + + uint256 aliceEthBalanceBefore = alice.addr.balance; + uint256 aliceUsdcBalanceBefore = IERC20(USDC).balanceOf(alice.addr); + + vm.prank(alice.addr); + router.multicall{value: 2 ether}(calls, address(0), address(0), bytes32(0)); + + uint256 aliceEthBalanceAfter = alice.addr.balance; + uint256 aliceUsdcBalanceAfter = IERC20(USDC).balanceOf(alice.addr); + + assertEq(aliceEthBalanceBefore - aliceEthBalanceAfter, 2 ether); + assertGt(aliceUsdcBalanceAfter, aliceUsdcBalanceBefore); + } + + function testRouter__Multicall__SwapAndCallWithCleanup() public { + // Deploy NFT that costs 20 USDC to mint + + TestERC721_ERC20PaymentToken nft = new TestERC721_ERC20PaymentToken(USDC); + + // Encode swap calldata + + address[] memory path = new address[](2); + path[0] = WETH; + path[1] = USDC; + + // Swap ETH to USDC + bytes memory calldata1 = abi.encodeWithSelector( + IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, address(router), block.timestamp + ); + // Approve USDC to the NFT contract + bytes memory calldata2 = abi.encodeWithSelector(IERC20.approve.selector, address(nft), type(uint256).max); + // Mint on the NFT contract + bytes memory calldata3 = abi.encodeWithSelector(nft.mint.selector, alice.addr, 10); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](3); + calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: calldata1}); + calls[1] = Call3Value({target: USDC, allowFailure: false, value: 0, callData: calldata2}); + calls[2] = Call3Value({target: address(nft), allowFailure: false, value: 0, callData: calldata3}); + + uint256 aliceEthBalanceBefore = alice.addr.balance; + uint256 routerUsdcBalanceBefore = IERC20(USDC).balanceOf(address(router)); + + vm.prank(alice.addr); + router.multicall{value: 1 ether}(calls, address(0), address(0), bytes32(0)); + + uint256 aliceEthBalanceAfterMulticall = alice.addr.balance; + uint256 routerUsdcBalanceAfterMulticall = IERC20(USDC).balanceOf(address(router)); + + assertEq(aliceEthBalanceBefore - aliceEthBalanceAfterMulticall, 1 ether); + assertGt(routerUsdcBalanceAfterMulticall, routerUsdcBalanceBefore); + assertEq(nft.ownerOf(10), alice.addr); + + // Cleanup on the router + + address[] memory tokens = new address[](1); + tokens[0] = USDC; + address[] memory recipients = new address[](1); + recipients[0] = alice.addr; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 0; + router.cleanupErc20s(tokens, recipients, amounts, bytes32(0)); + + uint256 aliceUsdcBalanceAfterCleanup = IERC20(USDC).balanceOf(alice.addr); + uint256 routerUsdcBalanceAfterCleanup = IERC20(USDC).balanceOf(address(this)); + assertEq(aliceUsdcBalanceAfterCleanup, routerUsdcBalanceAfterMulticall); + assertEq(routerUsdcBalanceAfterCleanup, 0); + } + + function testApprovalProxy__TransferAndMulticall__TransferFrom() public { + // Approve the approval proxy to spend erc20_1 + + vm.prank(alice.addr); + erc20_1.approve(address(approvalProxy), 1 ether); + + // Encode transfer calldata + + bytes memory calldata1 = abi.encodeWithSelector(IERC20.transferFrom.selector, alice.addr, bob.addr, 1 ether); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({target: address(erc20_1), allowFailure: false, value: 0, callData: calldata1}); + + address[] memory tokens = new address[](1); + tokens[0] = address(erc20_1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1 ether; + + // The below call will fail because it's a "transferFrom(alice, bob)" which + // requires alice to give an approval to the router (which is the sender) + + vm.prank(alice.addr); + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(router), 0, 1 ether) + ); + approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); + + // Encode router calls + + calls[0] = Call3Value({ + target: address(erc20_1), + allowFailure: false, + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether) + }); + + // This time the call should work because we're using "transfer(bob)" which doesn't require any approval + + vm.prank(alice.addr); + approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); + + assertEq(erc20_1.balanceOf(bob.addr), 1 ether); + } + + function testApprovalProxy__TransferAndMulticall__SwapExactTokensForTokens() public { + // Deal alice some USDC + + deal(USDC, alice.addr, 1000 * 10 ** 6); + + // Approve the approval proxy to spend USDC + + vm.prank(alice.addr); + IERC20(USDC).approve(address(approvalProxy), 1 ether); + + // Encode the swap calldata + + address[] memory path = new address[](2); + path[0] = USDC; + path[1] = DAI; + + // Approve the uniswap router to spend USDC + bytes memory calldata1 = abi.encodeWithSelector(IERC20.approve.selector, ROUTER_V2, 1000 * 10 ** 6); + // Swap USDC for DAI + bytes memory calldata2 = abi.encodeWithSelector( + IUniswapV2Router01.swapExactTokensForTokens.selector, + 1000 * 10 ** 6, + 990 * 10 ** 18, + path, + alice.addr, + block.timestamp + ); + + // Encode the router calls + + Call3Value[] memory calls = new Call3Value[](2); + calls[0] = Call3Value({target: USDC, allowFailure: false, value: 0, callData: calldata1}); + calls[1] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 0, callData: calldata2}); + + address[] memory tokens = new address[](1); + tokens[0] = USDC; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000 * 10 ** 6; + + vm.prank(alice.addr); + approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); + + assertEq(IERC20(USDC).balanceOf(alice.addr), 0); + assertEq(IERC20(USDC).balanceOf(address(router)), 0); + assertGt(IERC20(DAI).balanceOf(alice.addr), 990 * 10 ** 18); + } + + function testApprovalProxy__TransferAndMulticall__RevertNoOpErc20() public { + // Deploy a no-op token (which doesn't actually anything on transfers) + + NoOpERC20 noOpErc20 = new NoOpERC20(); + + // Mint and approve the approval-proxy + + vm.startPrank(alice.addr); + noOpErc20.mint(alice.addr, 1 ether); + noOpErc20.approve(address(approvalProxy), 1 ether); + + // Encode the calldata + + bytes memory calldata1 = abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether); + + // Encode the router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({target: address(noOpErc20), allowFailure: false, value: 0, callData: calldata1}); + + address[] memory tokens = new address[](1); + tokens[0] = address(noOpErc20); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1 ether; + + // The below call should fail given that the no-op token is not going to process any transfers + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(router), 0, 1 ether) + ); + approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); + } + + function testApprovalProxy__PermitTransferAndMulticall_Eip2612() public { + // Generate permit + + bytes32 structHash = keccak256( + abi.encode(_PERMIT2612_TYPEHASH, alice.addr, address(approvalProxy), 1 ether, 0, block.timestamp + 100) + ); + bytes32 eip712PermitHash = _hashTypedData(erc20_permit.DOMAIN_SEPARATOR(), structHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alice.key, eip712PermitHash); + + Permit2612[] memory permits = new Permit2612[](1); + permits[0] = Permit2612({ + token: address(erc20_permit), + owner: alice.addr, + value: 1 ether, + nonce: 0, + deadline: block.timestamp + 100, + v: v, + r: r, + s: s + }); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({ + target: address(erc20_permit), + allowFailure: false, + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether) + }); + + // Only the permit owner is allowed to use their permit + vm.prank(bob.addr); + vm.expectRevert(Unauthorized.selector); + approvalProxy.permitTransferAndMulticall(permits, calls, bob.addr, address(0), bytes32(0)); + + vm.prank(alice.addr); + approvalProxy.permitTransferAndMulticall(permits, calls, alice.addr, address(0), bytes32(0)); + + assertEq(erc20_permit.balanceOf(alice.addr), 0); + assertEq(erc20_permit.balanceOf(bob.addr), 1 ether); + } + + function testApprovalProxy__PermitTransferAndMulticall__FrontrunEip2612() public { + // Generate permit + + bytes32 structHash = keccak256( + abi.encode(_PERMIT2612_TYPEHASH, alice.addr, address(approvalProxy), 1 ether, 0, block.timestamp + 100) + ); + bytes32 eip712PermitHash = _hashTypedData(erc20_permit.DOMAIN_SEPARATOR(), structHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alice.key, eip712PermitHash); + + Permit2612[] memory permits = new Permit2612[](1); + permits[0] = Permit2612({ + token: address(erc20_permit), + owner: alice.addr, + value: 1 ether, + nonce: 0, + deadline: block.timestamp + 100, + v: v, + r: r, + s: s + }); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({ + target: address(erc20_permit), + allowFailure: false, + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether) + }); + + // Frontrun the permit + vm.prank(cal.addr); + erc20_permit.permit(alice.addr, address(approvalProxy), 1 ether, block.timestamp + 100, v, r, s); + + // Frontran permits are successfully skipped + + vm.prank(alice.addr); + approvalProxy.permitTransferAndMulticall(permits, calls, alice.addr, address(0), bytes32(0)); + + assertEq(erc20_permit.balanceOf(alice.addr), 0); + assertEq(erc20_permit.balanceOf(bob.addr), 1 ether); + } + + function testApprovalProxy__Permit2TransferAndMulticall__MaliciousSenderChangingRefundToAndNftRecipient() public { + // Deploy NFT that costs 20 USDC to mint + + TestERC721_ERC20PaymentToken nft = new TestERC721_ERC20PaymentToken(USDC); + + // Generate permit + + ISignatureTransfer.TokenPermissions[] memory permitted = new ISignatureTransfer.TokenPermissions[](1); + permitted[0] = ISignatureTransfer.TokenPermissions({token: address(erc20_1), amount: 0.1 ether}); + ISignatureTransfer.PermitBatchTransferFrom memory permit = ISignatureTransfer.PermitBatchTransferFrom({ + permitted: permitted, + nonce: 1, + deadline: block.timestamp + 100 + }); + + // Encode swap calldata + + address[] memory path = new address[](2); + path[0] = address(erc20_1); + path[1] = USDC; + + bytes memory calldata1 = abi.encodeWithSelector( + IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + ); + bytes memory calldata2 = abi.encodeWithSelector(IERC20.approve.selector, address(nft), type(uint256).max); + bytes memory calldata3 = abi.encodeWithSelector(nft.mint.selector, alice.addr, 10); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](3); + calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 0, callData: calldata1}); + calls[1] = Call3Value({target: USDC, allowFailure: false, value: 0, callData: calldata2}); + calls[2] = Call3Value({target: address(nft), allowFailure: false, value: 0, callData: calldata3}); + + // Get permit signature + + bytes32 witness = + keccak256(abi.encode(_RELAYER_WITNESS_TYPEHASH, bob.addr, alice.addr, alice.addr, _getCallsHash(calls))); + bytes memory permitSignature = getPermit2BatchWitnessSignature( + permit, + address(approvalProxy), + alice.key, + _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH, + witness, + PERMIT2_DOMAIN_SEPARATOR + ); + + // Replace some fields and expect the router call to fail + vm.expectRevert(InvalidSigner.selector); + vm.prank(bob.addr); + approvalProxy.permit2TransferAndMulticall(alice.addr, permit, calls, bob.addr, bob.addr, bytes32(0), permitSignature); + } + + function testRouter_USDTCleanupWithSafeERC20() public { + // Deal router some USDT + + deal(USDT, address(router), 1000 * 10 ** 6); + + // Encode cleanup calldata + + address[] memory tokens = new address[](1); + tokens[0] = USDT; + address[] memory recipients = new address[](1); + recipients[0] = bob.addr; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 0; + + bytes memory calldata1 = abi.encodeWithSelector(router.cleanupErc20s.selector, tokens, recipients, amounts, bytes32(0)); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({target: address(router), allowFailure: false, value: 0, callData: calldata1}); + + uint256 bobUsdtBalanceBefore = IERC20(USDT).balanceOf(bob.addr); + + vm.prank(bob.addr); + router.multicall(calls, address(0), address(0), bytes32(0)); + + assertEq(IERC20(USDT).balanceOf(bob.addr) - bobUsdtBalanceBefore, 1000 * 10 ** 6); + } + + function testRouter_NativeCleanupViaCall() public { + // Deal router some native tokens + vm.deal(address(router), 1 ether); + + bytes memory calldata1 = + abi.encodeWithSelector(router.cleanupNativeViaCall.selector, 0, bob.addr, bytes("0x1234567890"), bytes32(0)); + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({target: address(router), allowFailure: false, value: 0, callData: calldata1}); + + uint256 bobBalanceBefore = address(bob.addr).balance; + + vm.prank(alice.addr); + router.multicall(calls, address(0), address(0), bytes32(0)); + + assertEq(address(bob.addr).balance - bobBalanceBefore, 1 ether); + } + + function testRouter__OnERC721Received__SafeMintCorrectRecipient() public { + // Deploy NFT + + TestERC721 erc721 = new TestERC721(); + + // Encode mint calldata + + // "safeMint" is not going to call "onERC721Received" + bytes memory calldata1 = abi.encodeWithSignature("safeMint(address,uint256)", address(router), 1); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({target: address(erc721), allowFailure: false, value: 0, callData: calldata1}); + + vm.prank(alice.addr); + router.multicall(calls, address(0), alice.addr, bytes32(0)); + + // The router should have automatically forward the minted token to the sender + assertEq(erc721.ownerOf(1), alice.addr); + } + + function testRouter__OnERC721Received__MintMsgSender() public { + // Deploy NFT + + TestERC721 erc721 = new TestERC721(); + + // Encode mint and transfer calldata + + // "mint" is not going to call "onERC721Received" + bytes memory calldata1 = abi.encodeWithSignature("mint(uint256)", 1); + bytes memory calldata2 = + abi.encodeWithSignature("safeTransferFrom(address,address,uint256)", address(router), alice.addr, 1); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](2); + calls[0] = Call3Value({target: address(erc721), allowFailure: false, value: 0, callData: calldata1}); + calls[1] = Call3Value({target: address(erc721), allowFailure: false, value: 0, callData: calldata2}); + + vm.prank(alice.addr); + router.multicall(calls, address(0), alice.addr, bytes32(0)); + + assertEq(erc721.ownerOf(1), alice.addr); + } + + function testApprovalProxy__Permit3009TransferAndMulticall() public { + uint256 amount = 1000 * 10 ** 6; + + // Deal alice some USDC + + deal(USDC, alice.addr, amount); + + // Encode router calls + + Call3Value[] memory calls = new Call3Value[](1); + calls[0] = Call3Value({ + target: address(USDC), + allowFailure: false, + value: 0, + callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, amount) + }); + + bytes32 witness = _getRelayerWitnessHash(alice.addr, alice.addr, alice.addr, calls); + uint256 validBefore = block.timestamp + 100; + + // Generate permit + + bytes32 structHash = keccak256( + abi.encode(_PERMIT3009_TYPEHASH, alice.addr, address(approvalProxy), amount, 0, validBefore, witness) + ); + bytes32 eip712PermitHash = _hashTypedData( + // The only purpose of the conversion is to be able to call "DOMAIN_SEPARATOR" + TestERC20Permit(USDC).DOMAIN_SEPARATOR(), + structHash + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alice.key, eip712PermitHash); + + Permit3009[] memory permits = new Permit3009[](1); + permits[0] = + Permit3009({from: alice.addr, value: amount, validAfter: 0, validBefore: validBefore, v: v, r: r, s: s}); + address[] memory tokens = new address[](1); + tokens[0] = USDC; + + // Any changes in the call parameters should result in a failure + + vm.prank(bob.addr); + vm.expectRevert("FiatTokenV2: invalid signature"); + approvalProxy.permit3009TransferAndMulticall(permits, tokens, calls, bob.addr, alice.addr, bytes32(0)); + + vm.prank(alice.addr); + approvalProxy.permit3009TransferAndMulticall(permits, tokens, calls, alice.addr, alice.addr, bytes32(0)); + } + + // Utility methods + + function _getCallsHash(Call3Value[] memory calls) internal pure returns (bytes32) { + bytes32[] memory callHashes = new bytes32[](calls.length); + for (uint256 i = 0; i < calls.length; i++) { + // Encode the call and hash it + callHashes[i] = keccak256( + abi.encode( + _CALL3VALUE_TYPEHASH, + calls[i].target, + calls[i].allowFailure, + calls[i].value, + keccak256(calls[i].callData) + ) + ); + } + + return keccak256(abi.encodePacked(callHashes)); + } + + function _getRelayerWitnessHash(address relayer, address refundTo, address nftRecipient, Call3Value[] memory calls) + internal + pure + returns (bytes32) + { + return keccak256(abi.encode(_RELAYER_WITNESS_TYPEHASH, relayer, refundTo, nftRecipient, _getCallsHash(calls))); + } + + function _hashTypedData(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32 digest) { + digest = domainSeparator; + /// @solidity memory-safe-assembly + assembly { + // Compute the digest + mstore(0x00, 0x1901000000000000) // Store "\x19\x01" + mstore(0x1a, digest) // Store the domain separator + mstore(0x3a, structHash) // Store the struct hash + digest := keccak256(0x18, 0x42) + // Restore the part of the free memory slot that was overwritten + mstore(0x3a, 0) + } + } + + // Not actually used but still required to be defined + + function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) { + name = "UNUSED"; + version = "UNUSED"; + } +} From 4e3415213fcdb9c50e59811c32b0f84f3f588740 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:07:30 +0200 Subject: [PATCH 02/13] feat: modify v3 contracts to attach metadata to every push and pull event --- src/v3/RelayApprovalProxyV3.sol | 97 +--- src/v3/RelayRouterV3.sol | 135 +++-- src/v3/RelayRouterV3_NonTstore.sol | 140 ++++-- src/v3/interfaces/IRelayRouterV3.sol | 8 +- test/v3/RouterAndApprovalV3Test.sol | 703 +++++++++++++++++++++------ 5 files changed, 776 insertions(+), 307 deletions(-) diff --git a/src/v3/RelayApprovalProxyV3.sol b/src/v3/RelayApprovalProxyV3.sol index 464fcb7..7f8a7c4 100644 --- a/src/v3/RelayApprovalProxyV3.sol +++ b/src/v3/RelayApprovalProxyV3.sol @@ -25,15 +25,12 @@ contract RelayApprovalProxyV3 is Ownable { /// @notice Revert if the native transfer fails error NativeTransferFailed(); - /// @notice Revert if the refundTo address is zero address - error RefundToCannotBeZeroAddress(); - /// @notice Emitted when pulling funds from a user event RouterPull( address from, address currency, uint256 amount, - bytes32 indexed id + bytes indexed metadata ); /// @notice The address of the router contract @@ -47,10 +44,10 @@ contract RelayApprovalProxyV3 is Ownable { "Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" ); string public constant _RELAYER_WITNESS_TYPE_STRING = - "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; + "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; bytes32 public constant _RELAYER_WITNESS_TYPEHASH = keccak256( - "RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + "RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" ); receive() external payable {} @@ -73,41 +70,31 @@ contract RelayApprovalProxyV3 is Ownable { /// @param tokens An array of token addresses to transfer /// @param amounts An array of token amounts to transfer /// @param calls The calls to perform - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to function transferAndMulticall( address[] calldata tokens, uint256[] calldata amounts, Call3Value[] calldata calls, - address refundTo, address nftRecipient, - bytes32 id + bytes calldata metadata ) external payable returns (Result[] memory returnData) { // Revert if array lengths do not match if ((tokens.length != amounts.length)) { revert ArrayLengthsMismatch(); } - // Revert if refundTo is zero address - if (refundTo == address(0)) { - revert RefundToCannotBeZeroAddress(); - } - // Transfer the tokens to the router for (uint256 i = 0; i < tokens.length; i++) { IERC20(tokens[i]).safeTransferFrom(msg.sender, ROUTER, amounts[i]); - emit RouterPull(msg.sender, tokens[i], amounts[i], id); + emit RouterPull(msg.sender, tokens[i], amounts[i], metadata); } // Call multicall on the router - // @dev msg.sender for the calls to targets will be the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - refundTo, - nftRecipient, - id + nftRecipient ); } @@ -117,22 +104,15 @@ contract RelayApprovalProxyV3 is Ownable { /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. /// @param permits An array of permits /// @param calls The calls to perform - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to /// @return returnData The return data from the multicall function permitTransferAndMulticall( Permit2612[] calldata permits, Call3Value[] calldata calls, - address refundTo, address nftRecipient, - bytes32 id + bytes calldata metadata ) external payable returns (Result[] memory returnData) { - // Revert if refundTo is zero address - if (refundTo == address(0)) { - revert RefundToCannotBeZeroAddress(); - } - for (uint256 i = 0; i < permits.length; i++) { Permit2612 memory permit = permits[i]; @@ -160,15 +140,13 @@ contract RelayApprovalProxyV3 is Ownable { permit.value ); - emit RouterPull(permit.owner, permit.token, permit.value, id); + emit RouterPull(permit.owner, permit.token, permit.value, metadata); } // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - refundTo, - nftRecipient, - id + nftRecipient ); } @@ -180,31 +158,23 @@ contract RelayApprovalProxyV3 is Ownable { /// @param user The address of the user /// @param permit The permit details /// @param calls The calls to perform - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to /// @param permitSignature The signature for the permit function permit2TransferAndMulticall( address user, ISignatureTransfer.PermitBatchTransferFrom memory permit, Call3Value[] calldata calls, - address refundTo, address nftRecipient, - bytes32 id, + bytes calldata metadata, bytes memory permitSignature ) external payable returns (Result[] memory returnData) { - // Revert if refundTo is zero address - if (refundTo == address(0)) { - revert RefundToCannotBeZeroAddress(); - } - // If a permit signature is provided, use it to transfer tokens from user to router if (permitSignature.length != 0) { _handleBatchPermit( user, - refundTo, nftRecipient, - id, + metadata, permit, calls, permitSignature @@ -214,9 +184,7 @@ contract RelayApprovalProxyV3 is Ownable { // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - refundTo, - nftRecipient, - id + nftRecipient ); } @@ -226,23 +194,16 @@ contract RelayApprovalProxyV3 is Ownable { /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. /// @param permits An array of permits /// @param calls The calls to perform - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to /// @return returnData The return data from the multicall function permit3009TransferAndMulticall( Permit3009[] calldata permits, address[] calldata tokens, Call3Value[] calldata calls, - address refundTo, address nftRecipient, - bytes32 id + bytes calldata metadata ) external payable returns (Result[] memory returnData) { - // Revert if refundTo is zero address - if (refundTo == address(0)) { - revert RefundToCannotBeZeroAddress(); - } - // Revert if array lengths do not match if ((tokens.length != permits.length)) { revert ArrayLengthsMismatch(); @@ -258,7 +219,7 @@ contract RelayApprovalProxyV3 is Ownable { permit.value, permit.validAfter, permit.validBefore, - _getRelayerWitnessHash(refundTo, nftRecipient, calls), + _getRelayerWitnessHash(nftRecipient, metadata, calls), permit.v, permit.r, permit.s @@ -267,15 +228,13 @@ contract RelayApprovalProxyV3 is Ownable { // Transfer the tokens to the router IERC20(tokens[i]).safeTransfer(ROUTER, permit.value); - emit RouterPull(permit.from, tokens[i], permit.value, id); + emit RouterPull(permit.from, tokens[i], permit.value, metadata); } // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - refundTo, - nftRecipient, - id + nftRecipient ); } @@ -303,12 +262,12 @@ contract RelayApprovalProxyV3 is Ownable { } /// @notice Internal function to get the hash of a relayer witness - /// @param refundTo The refundTo address /// @param nftRecipient The nftRecipient address + /// @param metadata Additional data to associate the call to /// @param calls The calls to be executed function _getRelayerWitnessHash( - address refundTo, address nftRecipient, + bytes memory metadata, Call3Value[] memory calls ) internal view returns (bytes32) { return @@ -316,8 +275,8 @@ contract RelayApprovalProxyV3 is Ownable { abi.encode( _RELAYER_WITNESS_TYPEHASH, msg.sender, - refundTo, nftRecipient, + metadata, _getCallsHash(calls) ) ); @@ -325,22 +284,20 @@ contract RelayApprovalProxyV3 is Ownable { /// @notice Internal function to handle a permit batch transfer /// @param user The address of the user - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to /// @param permit The permit details /// @param calls The calls to perform /// @param permitSignature The signature for the permit function _handleBatchPermit( address user, - address refundTo, address nftRecipient, - bytes32 id, + bytes calldata metadata, ISignatureTransfer.PermitBatchTransferFrom memory permit, Call3Value[] calldata calls, bytes memory permitSignature ) internal { - bytes32 witness = _getRelayerWitnessHash(refundTo, nftRecipient, calls); + bytes32 witness = _getRelayerWitnessHash(nftRecipient, metadata, calls); // Create the SignatureTransferDetails array ISignatureTransfer.SignatureTransferDetails[] @@ -356,7 +313,7 @@ contract RelayApprovalProxyV3 is Ownable { requestedAmount: amount }); - emit RouterPull(user, permit.permitted[i].token, amount, id); + emit RouterPull(user, permit.permitted[i].token, amount, metadata); } // Use the SignatureTransferDetails and permit signature to transfer tokens to the router diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol index 5f4e896..b42fa65 100644 --- a/src/v3/RelayRouterV3.sol +++ b/src/v3/RelayRouterV3.sol @@ -34,9 +34,10 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { event SolverNativeTransfer(address to, uint256 amount); /// @notice Emitted when pulling funds from a user - event RouterPush(address to, address currency, uint256 amount, bytes32 id); + event RouterPush(address to, address currency, uint256 amount, bytes data); - uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; + uint256 RECIPIENT_STORAGE_SLOT = + uint256(keccak256("RelayRouter.recipient")) - 1; constructor() {} @@ -49,16 +50,11 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// All calls to ERC721s and ERC1155s in the multicall will have the same recipient set in recipient /// Be sure to transfer ERC20s or native tokens out of the router as part of the multicall /// @param calls The calls to perform - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to - function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient, bytes32 id) - public - payable - virtual - nonReentrant - returns (Result[] memory returnData) - { + function multicall( + Call3Value[] calldata calls, + address nftRecipient + ) public payable virtual nonReentrant returns (Result[] memory returnData) { // Set the NFT recipient if provided if (nftRecipient != address(0)) { _setRecipient(nftRecipient); @@ -69,9 +65,6 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { // Clear the recipient in storage _clearRecipient(); - - // Refund any leftover native tokens to the sender - cleanupNative(0, refundTo, id); } /// @notice Send leftover ERC20 tokens to recipients @@ -80,13 +73,18 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @param tokens The addresses of the ERC20 tokens /// @param recipients The addresses to refund the tokens to /// @param amounts The amounts to send - /// @param id The id to associate the call to - function cleanupErc20s(address[] calldata tokens, address[] calldata recipients, uint256[] calldata amounts, bytes32 id) - public - virtual - { + /// @param metadata Additional data to associate the call to + function cleanupErc20s( + address[] calldata tokens, + address[] calldata recipients, + uint256[] calldata amounts, + bytes calldata metadata + ) public virtual { // Revert if array lengths do not match - if (tokens.length != amounts.length || amounts.length != recipients.length) { + if ( + tokens.length != amounts.length || + amounts.length != recipients.length + ) { revert ArrayLengthsMismatch(); } @@ -95,13 +93,15 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { address recipient = recipients[i]; // Get the amount to transfer - uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + uint256 amount = amounts[i] == 0 + ? IERC20(token).balanceOf(address(this)) + : amounts[i]; if (amount > 0) { // Transfer the token to the recipient address token.safeTransfer(recipient, amount); - emit RouterPush(recipient, token, amount, id); + emit RouterPush(recipient, token, amount, metadata); } } } @@ -113,16 +113,20 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @param tos The target addresses for the calls /// @param datas The data for the calls /// @param amounts The amounts to send - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to function cleanupErc20sViaCall( address[] calldata tokens, address[] calldata tos, bytes[] calldata datas, uint256[] calldata amounts, - bytes32 id + bytes calldata metadata ) public virtual { // Revert if array lengths do not match - if (tokens.length != amounts.length || amounts.length != tos.length || tos.length != datas.length) { + if ( + tokens.length != amounts.length || + amounts.length != tos.length || + tos.length != datas.length + ) { revert ArrayLengthsMismatch(); } @@ -132,19 +136,21 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { bytes calldata data = datas[i]; // Get the amount to transfer - uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + uint256 amount = amounts[i] == 0 + ? IERC20(token).balanceOf(address(this)) + : amounts[i]; if (amount > 0) { // First approve the target address for the call IERC20(token).approve(to, amount); // Make the call - (bool success,) = to.call(data); + (bool success, ) = to.call(data); if (!success) { revert CallFailed(); } - emit RouterPush(to, token, amount, id); + emit RouterPush(to, token, amount, metadata); } } } @@ -153,17 +159,28 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @dev Set amount to 0 to transfer the full balance. Set recipient to address(0) to transfer to msg.sender /// @param amount The amount of native tokens to transfer /// @param recipient The recipient address - /// @param id The id to associate the call to - function cleanupNative(uint256 amount, address recipient, bytes32 id) public virtual { + /// @param metadata Additional data to associate the call to + function cleanupNative( + uint256 amount, + address recipient, + bytes calldata metadata + ) public virtual { // If recipient is address(0), set to msg.sender - address recipientAddr = recipient == address(0) ? msg.sender : recipient; + address recipientAddr = recipient == address(0) + ? msg.sender + : recipient; uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; if (amountToTransfer > 0) { recipientAddr.safeTransferETH(amountToTransfer); emit SolverNativeTransfer(recipientAddr, amountToTransfer); - emit RouterPush(recipientAddr, address(0), amountToTransfer, id); + emit RouterPush( + recipientAddr, + address(0), + amountToTransfer, + metadata + ); } } @@ -172,17 +189,22 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @param amount The amount of native tokens to transfer /// @param to The target address of the call /// @param data The data for the call - /// @param id The id to associate the call to - function cleanupNativeViaCall(uint256 amount, address to, bytes calldata data, bytes32 id) public virtual { + /// @param metadata Additional data to associate the call to + function cleanupNativeViaCall( + uint256 amount, + address to, + bytes calldata data, + bytes calldata metadata + ) public virtual { uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; if (amountToTransfer > 0) { - (bool success,) = to.call{value: amountToTransfer}(data); + (bool success, ) = to.call{value: amountToTransfer}(data); if (!success) { revert CallFailed(); } - emit RouterPush(to, address(0), amountToTransfer, id); + emit RouterPush(to, address(0), amountToTransfer, metadata); } } @@ -231,10 +253,12 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { } } - function onERC721Received(address, /*_operator*/ address, /*_from*/ uint256 _tokenId, bytes calldata _data) - external - returns (bytes4) - { + function onERC721Received( + address, + /*_operator*/ address, + /*_from*/ uint256 _tokenId, + bytes calldata _data + ) external returns (bytes4) { // Get the recipient from storage address recipient = _getRecipient(); @@ -246,14 +270,19 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { } // Transfer the NFT to the recipient - IERC721(msg.sender).safeTransferFrom(address(this), recipient, _tokenId, _data); + IERC721(msg.sender).safeTransferFrom( + address(this), + recipient, + _tokenId, + _data + ); return this.onERC721Received.selector; } function onERC1155Received( - address, /*_operator*/ - address, /*_from*/ + address /*_operator*/, + address /*_from*/, uint256 _id, uint256 _value, bytes calldata _data @@ -269,14 +298,20 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { } // Transfer the tokens to the recipient - IERC1155(msg.sender).safeTransferFrom(address(this), recipient, _id, _value, _data); + IERC1155(msg.sender).safeTransferFrom( + address(this), + recipient, + _id, + _value, + _data + ); return this.onERC1155Received.selector; } function onERC1155BatchReceived( - address, /*_operator*/ - address, /*_from*/ + address /*_operator*/, + address /*_from*/, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data @@ -292,7 +327,13 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { } // Transfer the tokens to the recipient - IERC1155(msg.sender).safeBatchTransferFrom(address(this), recipient, _ids, _values, _data); + IERC1155(msg.sender).safeBatchTransferFrom( + address(this), + recipient, + _ids, + _values, + _data + ); return this.onERC1155BatchReceived.selector; } diff --git a/src/v3/RelayRouterV3_NonTstore.sol b/src/v3/RelayRouterV3_NonTstore.sol index 9a6377d..27c3ea7 100644 --- a/src/v3/RelayRouterV3_NonTstore.sol +++ b/src/v3/RelayRouterV3_NonTstore.sol @@ -9,7 +9,10 @@ import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; import {Call3Value, Multicall3, Result} from "../common/Multicall3.sol"; import {ReentrancyGuardMsgSender_NonTstore} from "../common/ReentrancyGuardMsgSender_NonTstore.sol"; -contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTstore { +contract RelayRouterV3_NonTstore is + Multicall3, + ReentrancyGuardMsgSender_NonTstore +{ using SafeTransferLib for address; /// @notice Revert if this contract is set as the recipient @@ -34,9 +37,10 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto event SolverNativeTransfer(address to, uint256 amount); /// @notice Emitted when pulling funds from a user - event RouterPush(address to, address currency, uint256 amount, bytes32 id); + event RouterPush(address to, address currency, uint256 amount, bytes data); - uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; + uint256 RECIPIENT_STORAGE_SLOT = + uint256(keccak256("RelayRouter.recipient")) - 1; constructor() {} @@ -49,16 +53,11 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto /// All calls to ERC721s and ERC1155s in the multicall will have the same recipient set in recipient /// Be sure to transfer ERC20s or native tokens out of the router as part of the multicall /// @param calls The calls to perform - /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints - /// @param id The id to associate the call to - function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient, bytes32 id) - public - payable - virtual - nonReentrant - returns (Result[] memory returnData) - { + function multicall( + Call3Value[] calldata calls, + address nftRecipient + ) public payable virtual nonReentrant returns (Result[] memory returnData) { // Set the NFT recipient if provided if (nftRecipient != address(0)) { _setRecipient(nftRecipient); @@ -69,9 +68,6 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto // Clear the recipient in storage _clearRecipient(); - - // Refund any leftover native tokens to the sender - cleanupNative(0, refundTo, id); } /// @notice Send leftover ERC20 tokens to recipients @@ -80,13 +76,18 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto /// @param tokens The addresses of the ERC20 tokens /// @param recipients The addresses to refund the tokens to /// @param amounts The amounts to send - /// @param id The id to associate the call to - function cleanupErc20s(address[] calldata tokens, address[] calldata recipients, uint256[] calldata amounts, bytes32 id) - public - virtual - { + /// @param metadata Additional data to associate the call to + function cleanupErc20s( + address[] calldata tokens, + address[] calldata recipients, + uint256[] calldata amounts, + bytes calldata metadata + ) public virtual { // Revert if array lengths do not match - if (tokens.length != amounts.length || amounts.length != recipients.length) { + if ( + tokens.length != amounts.length || + amounts.length != recipients.length + ) { revert ArrayLengthsMismatch(); } @@ -95,13 +96,15 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto address recipient = recipients[i]; // Get the amount to transfer - uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + uint256 amount = amounts[i] == 0 + ? IERC20(token).balanceOf(address(this)) + : amounts[i]; if (amount > 0) { // Transfer the token to the recipient address token.safeTransfer(recipient, amount); - emit RouterPush(recipient, token, amount, id); + emit RouterPush(recipient, token, amount, metadata); } } } @@ -113,16 +116,20 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto /// @param tos The target addresses for the calls /// @param datas The data for the calls /// @param amounts The amounts to send - /// @param id The id to associate the call to + /// @param metadata Additional data to associate the call to function cleanupErc20sViaCall( address[] calldata tokens, address[] calldata tos, bytes[] calldata datas, uint256[] calldata amounts, - bytes32 id + bytes calldata metadata ) public virtual { // Revert if array lengths do not match - if (tokens.length != amounts.length || amounts.length != tos.length || tos.length != datas.length) { + if ( + tokens.length != amounts.length || + amounts.length != tos.length || + tos.length != datas.length + ) { revert ArrayLengthsMismatch(); } @@ -132,19 +139,21 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto bytes calldata data = datas[i]; // Get the amount to transfer - uint256 amount = amounts[i] == 0 ? IERC20(token).balanceOf(address(this)) : amounts[i]; + uint256 amount = amounts[i] == 0 + ? IERC20(token).balanceOf(address(this)) + : amounts[i]; if (amount > 0) { // First approve the target address for the call IERC20(token).approve(to, amount); // Make the call - (bool success,) = to.call(data); + (bool success, ) = to.call(data); if (!success) { revert CallFailed(); } - emit RouterPush(to, token, amount, id); + emit RouterPush(to, token, amount, metadata); } } } @@ -153,17 +162,28 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto /// @dev Set amount to 0 to transfer the full balance. Set recipient to address(0) to transfer to msg.sender /// @param amount The amount of native tokens to transfer /// @param recipient The recipient address - /// @param id The id to associate the call to - function cleanupNative(uint256 amount, address recipient, bytes32 id) public virtual { + /// @param metadata Additional data to associate the call to + function cleanupNative( + uint256 amount, + address recipient, + bytes calldata metadata + ) public virtual { // If recipient is address(0), set to msg.sender - address recipientAddr = recipient == address(0) ? msg.sender : recipient; + address recipientAddr = recipient == address(0) + ? msg.sender + : recipient; uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; if (amountToTransfer > 0) { recipientAddr.safeTransferETH(amountToTransfer); emit SolverNativeTransfer(recipientAddr, amountToTransfer); - emit RouterPush(recipientAddr, address(0), amountToTransfer, id); + emit RouterPush( + recipientAddr, + address(0), + amountToTransfer, + metadata + ); } } @@ -172,17 +192,22 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto /// @param amount The amount of native tokens to transfer /// @param to The target address of the call /// @param data The data for the call - /// @param id The id to associate the call to - function cleanupNativeViaCall(uint256 amount, address to, bytes calldata data, bytes32 id) public virtual { + /// @param metadata Additional data to associate the call to + function cleanupNativeViaCall( + uint256 amount, + address to, + bytes calldata data, + bytes calldata metadata + ) public virtual { uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; if (amountToTransfer > 0) { - (bool success,) = to.call{value: amountToTransfer}(data); + (bool success, ) = to.call{value: amountToTransfer}(data); if (!success) { revert CallFailed(); } - emit RouterPush(to, address(0), amountToTransfer, id); + emit RouterPush(to, address(0), amountToTransfer, metadata); } } @@ -231,10 +256,12 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto } } - function onERC721Received(address, /*_operator*/ address, /*_from*/ uint256 _tokenId, bytes calldata _data) - external - returns (bytes4) - { + function onERC721Received( + address, + /*_operator*/ address, + /*_from*/ uint256 _tokenId, + bytes calldata _data + ) external returns (bytes4) { // Get the recipient from storage address recipient = _getRecipient(); @@ -246,14 +273,19 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto } // Transfer the NFT to the recipient - IERC721(msg.sender).safeTransferFrom(address(this), recipient, _tokenId, _data); + IERC721(msg.sender).safeTransferFrom( + address(this), + recipient, + _tokenId, + _data + ); return this.onERC721Received.selector; } function onERC1155Received( - address, /*_operator*/ - address, /*_from*/ + address /*_operator*/, + address /*_from*/, uint256 _id, uint256 _value, bytes calldata _data @@ -269,14 +301,20 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto } // Transfer the tokens to the recipient - IERC1155(msg.sender).safeTransferFrom(address(this), recipient, _id, _value, _data); + IERC1155(msg.sender).safeTransferFrom( + address(this), + recipient, + _id, + _value, + _data + ); return this.onERC1155Received.selector; } function onERC1155BatchReceived( - address, /*_operator*/ - address, /*_from*/ + address /*_operator*/, + address /*_from*/, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data @@ -292,7 +330,13 @@ contract RelayRouterV3_NonTstore is Multicall3, ReentrancyGuardMsgSender_NonTsto } // Transfer the tokens to the recipient - IERC1155(msg.sender).safeBatchTransferFrom(address(this), recipient, _ids, _values, _data); + IERC1155(msg.sender).safeBatchTransferFrom( + address(this), + recipient, + _ids, + _values, + _data + ); return this.onERC1155BatchReceived.selector; } diff --git a/src/v3/interfaces/IRelayRouterV3.sol b/src/v3/interfaces/IRelayRouterV3.sol index a7bb3e9..5e4debd 100644 --- a/src/v3/interfaces/IRelayRouterV3.sol +++ b/src/v3/interfaces/IRelayRouterV3.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.23; import {Call3Value, Result} from "../../common/Multicall3.sol"; interface IRelayRouterV3 { - function multicall(Call3Value[] calldata calls, address refundTo, address nftRecipient, bytes32 id) - external - payable - returns (Result[] memory returnData); + function multicall( + Call3Value[] calldata calls, + address nftRecipient + ) external payable returns (Result[] memory returnData); } diff --git a/test/v3/RouterAndApprovalV3Test.sol b/test/v3/RouterAndApprovalV3Test.sol index 874471d..a3c1bf9 100644 --- a/test/v3/RouterAndApprovalV3Test.sol +++ b/test/v3/RouterAndApprovalV3Test.sol @@ -35,7 +35,8 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { event RouterUpdated(address newRouter); // Constants - IAllowanceHolder constant ALLOWANCE_HOLDER = IAllowanceHolder(payable(0x0000000000001fF3684f28c67538d4D072C22734)); + IAllowanceHolder constant ALLOWANCE_HOLDER = + IAllowanceHolder(payable(0x0000000000001fF3684f28c67538d4D072C22734)); // Fields to be set RelayRouterV3 router; @@ -43,26 +44,35 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Various type-hashes / type-strings bytes32 public constant _CALL3VALUE_TYPEHASH = - keccak256("Call3Value(address target,bool allowFailure,uint256 value,bytes callData)"); - bytes32 public constant _RELAYER_WITNESS_TYPEHASH = keccak256( - "RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" - ); - bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_TYPEHASH = keccak256( - "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" - ); - bytes32 public constant _PERMIT2_BATCH_TRANSFER_FROM_TYPEHASH = keccak256( - "PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" - ); - bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH = keccak256( - "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" - ); + keccak256( + "Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + ); + bytes32 public constant _RELAYER_WITNESS_TYPEHASH = + keccak256( + "RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + ); + bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_TYPEHASH = + keccak256( + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" + ); + bytes32 public constant _PERMIT2_BATCH_TRANSFER_FROM_TYPEHASH = + keccak256( + "PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ); + bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH = + keccak256( + "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" + ); bytes32 private constant _PERMIT2612_TYPEHASH = - keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); - bytes32 private constant _PERMIT3009_TYPEHASH = keccak256( - "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" - ); + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + bytes32 private constant _PERMIT3009_TYPEHASH = + keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); string public constant _PERMIT2_RELAYER_WITNESS_TYPE_STRING = - "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; + "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; // Setup function setUp() public override { @@ -72,7 +82,11 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Deploy router and approval-proxy contracts router = new RelayRouterV3(); - approvalProxy = new RelayApprovalProxyV3(address(this), address(router), address(PERMIT2)); + approvalProxy = new RelayApprovalProxyV3( + address(this), + address(router), + address(PERMIT2) + ); // Mint tokens to alice erc20_1.mint(alice.addr, 1 ether); @@ -93,12 +107,20 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { function testCorrectWitnessTypehashes() public pure { assertEq( - keccak256(abi.encodePacked(_PERMIT2_WITNESS_TRANSFER_TYPEHASH_STUB, _PERMIT2_RELAYER_WITNESS_TYPE_STRING)), + keccak256( + abi.encodePacked( + _PERMIT2_WITNESS_TRANSFER_TYPEHASH_STUB, + _PERMIT2_RELAYER_WITNESS_TYPE_STRING + ) + ), _PERMIT2_FULL_RELAYER_WITNESS_TYPEHASH ); assertEq( keccak256( - abi.encodePacked(_PERMIT2_BATCH_WITNESS_TRANSFER_TYPEHASH_STUB, _PERMIT2_RELAYER_WITNESS_TYPE_STRING) + abi.encodePacked( + _PERMIT2_BATCH_WITNESS_TRANSFER_TYPEHASH_STUB, + _PERMIT2_RELAYER_WITNESS_TYPE_STRING + ) ), _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH ); @@ -107,32 +129,77 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { function testApprovalProxy__Permit2TransferAndMulticall() public { // Create the permit - ISignatureTransfer.TokenPermissions[] memory permitted = new ISignatureTransfer.TokenPermissions[](3); - permitted[0] = ISignatureTransfer.TokenPermissions({token: address(erc20_1), amount: 0.1 ether}); - permitted[1] = ISignatureTransfer.TokenPermissions({token: address(erc20_2), amount: 0.2 ether}); - permitted[2] = ISignatureTransfer.TokenPermissions({token: address(erc20_3), amount: 0.3 ether}); - - ISignatureTransfer.PermitBatchTransferFrom memory permit = ISignatureTransfer.PermitBatchTransferFrom({ - permitted: permitted, - nonce: 1, - deadline: block.timestamp + 100 + ISignatureTransfer.TokenPermissions[] + memory permitted = new ISignatureTransfer.TokenPermissions[](3); + permitted[0] = ISignatureTransfer.TokenPermissions({ + token: address(erc20_1), + amount: 0.1 ether + }); + permitted[1] = ISignatureTransfer.TokenPermissions({ + token: address(erc20_2), + amount: 0.2 ether + }); + permitted[2] = ISignatureTransfer.TokenPermissions({ + token: address(erc20_3), + amount: 0.3 ether }); + ISignatureTransfer.PermitBatchTransferFrom + memory permit = ISignatureTransfer.PermitBatchTransferFrom({ + permitted: permitted, + nonce: 1, + deadline: block.timestamp + 100 + }); + // Create calldata to transfer tokens from the router to bob - bytes memory calldata1 = abi.encodeWithSelector(erc20_1.transfer.selector, bob.addr, 0.03 ether); - bytes memory calldata2 = abi.encodeWithSelector(erc20_2.transfer.selector, bob.addr, 0.15 ether); - bytes memory calldata3 = abi.encodeWithSelector(erc20_3.transfer.selector, bob.addr, 0.2 ether); + bytes memory calldata1 = abi.encodeWithSelector( + erc20_1.transfer.selector, + bob.addr, + 0.03 ether + ); + bytes memory calldata2 = abi.encodeWithSelector( + erc20_2.transfer.selector, + bob.addr, + 0.15 ether + ); + bytes memory calldata3 = abi.encodeWithSelector( + erc20_3.transfer.selector, + bob.addr, + 0.2 ether + ); Call3Value[] memory calls = new Call3Value[](3); - calls[0] = Call3Value({target: address(erc20_1), allowFailure: false, value: 0, callData: calldata1}); - calls[1] = Call3Value({target: address(erc20_2), allowFailure: false, value: 0, callData: calldata2}); - calls[2] = Call3Value({target: address(erc20_3), allowFailure: false, value: 0, callData: calldata3}); + calls[0] = Call3Value({ + target: address(erc20_1), + allowFailure: false, + value: 0, + callData: calldata1 + }); + calls[1] = Call3Value({ + target: address(erc20_2), + allowFailure: false, + value: 0, + callData: calldata2 + }); + calls[2] = Call3Value({ + target: address(erc20_3), + allowFailure: false, + value: 0, + callData: calldata3 + }); // Generate a permit from alice - bytes32 witness = - keccak256(abi.encode(_RELAYER_WITNESS_TYPEHASH, bob.addr, alice.addr, address(0), _getCallsHash(calls))); + bytes32 witness = keccak256( + abi.encode( + _RELAYER_WITNESS_TYPEHASH, + bob.addr, + alice.addr, + bytes(""), + _getCallsHash(calls) + ) + ); bytes memory permitSignature = getPermit2BatchWitnessSignature( permit, address(approvalProxy), @@ -145,11 +212,25 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Only the "relayer" (in this case bob) can use the permit via the approval-proxy vm.prank(cal.addr); vm.expectRevert(InvalidSigner.selector); - approvalProxy.permit2TransferAndMulticall(alice.addr, permit, calls, alice.addr, address(0), bytes32(0), permitSignature); + approvalProxy.permit2TransferAndMulticall( + alice.addr, + permit, + calls, + alice.addr, + bytes(""), + permitSignature + ); // Call the router vm.prank(bob.addr); - approvalProxy.permit2TransferAndMulticall(alice.addr, permit, calls, alice.addr, address(0), bytes32(0), permitSignature); + approvalProxy.permit2TransferAndMulticall( + alice.addr, + permit, + calls, + alice.addr, + bytes(""), + permitSignature + ); // Funds transferred as part of the calls are in bob's wallet assertEq(erc20_1.balanceOf(bob.addr), 0.03 ether); @@ -175,19 +256,28 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { path[1] = USDC; bytes memory data = abi.encodeWithSelector( - IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + IUniswapV2Router01.swapExactETHForTokens.selector, + 0, + path, + alice.addr, + block.timestamp ); // Encode router calls Call3Value[] memory calls = new Call3Value[](1); - calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: data}); + calls[0] = Call3Value({ + target: ROUTER_V2, + allowFailure: false, + value: 1 ether, + callData: data + }); uint256 aliceEthBalanceBefore = alice.addr.balance; uint256 aliceUsdcBalanceBefore = IERC20(USDC).balanceOf(alice.addr); vm.prank(alice.addr); - router.multicall{value: 1 ether}(calls, address(0), address(0), bytes32(0)); + router.multicall{value: 1 ether}(calls, address(0)); uint256 aliceEthBalanceAfter = alice.addr.balance; uint256 aliceUsdcBalanceAfter = IERC20(USDC).balanceOf(alice.addr); @@ -204,23 +294,41 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { path[1] = USDC; bytes memory calldata1 = abi.encodeWithSelector( - IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + IUniswapV2Router01.swapExactETHForTokens.selector, + 0, + path, + alice.addr, + block.timestamp ); bytes memory calldata2 = abi.encodeWithSelector( - IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + IUniswapV2Router01.swapExactETHForTokens.selector, + 0, + path, + alice.addr, + block.timestamp ); // Encode router calls Call3Value[] memory calls = new Call3Value[](2); - calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: calldata1}); - calls[1] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: calldata2}); + calls[0] = Call3Value({ + target: ROUTER_V2, + allowFailure: false, + value: 1 ether, + callData: calldata1 + }); + calls[1] = Call3Value({ + target: ROUTER_V2, + allowFailure: false, + value: 1 ether, + callData: calldata2 + }); uint256 aliceEthBalanceBefore = alice.addr.balance; uint256 aliceUsdcBalanceBefore = IERC20(USDC).balanceOf(alice.addr); vm.prank(alice.addr); - router.multicall{value: 2 ether}(calls, address(0), address(0), bytes32(0)); + router.multicall{value: 2 ether}(calls, address(0)); uint256 aliceEthBalanceAfter = alice.addr.balance; uint256 aliceUsdcBalanceAfter = IERC20(USDC).balanceOf(alice.addr); @@ -232,7 +340,9 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { function testRouter__Multicall__SwapAndCallWithCleanup() public { // Deploy NFT that costs 20 USDC to mint - TestERC721_ERC20PaymentToken nft = new TestERC721_ERC20PaymentToken(USDC); + TestERC721_ERC20PaymentToken nft = new TestERC721_ERC20PaymentToken( + USDC + ); // Encode swap calldata @@ -242,30 +352,64 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Swap ETH to USDC bytes memory calldata1 = abi.encodeWithSelector( - IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, address(router), block.timestamp + IUniswapV2Router01.swapExactETHForTokens.selector, + 0, + path, + address(router), + block.timestamp ); // Approve USDC to the NFT contract - bytes memory calldata2 = abi.encodeWithSelector(IERC20.approve.selector, address(nft), type(uint256).max); + bytes memory calldata2 = abi.encodeWithSelector( + IERC20.approve.selector, + address(nft), + type(uint256).max + ); // Mint on the NFT contract - bytes memory calldata3 = abi.encodeWithSelector(nft.mint.selector, alice.addr, 10); + bytes memory calldata3 = abi.encodeWithSelector( + nft.mint.selector, + alice.addr, + 10 + ); // Encode router calls Call3Value[] memory calls = new Call3Value[](3); - calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 1 ether, callData: calldata1}); - calls[1] = Call3Value({target: USDC, allowFailure: false, value: 0, callData: calldata2}); - calls[2] = Call3Value({target: address(nft), allowFailure: false, value: 0, callData: calldata3}); + calls[0] = Call3Value({ + target: ROUTER_V2, + allowFailure: false, + value: 1 ether, + callData: calldata1 + }); + calls[1] = Call3Value({ + target: USDC, + allowFailure: false, + value: 0, + callData: calldata2 + }); + calls[2] = Call3Value({ + target: address(nft), + allowFailure: false, + value: 0, + callData: calldata3 + }); uint256 aliceEthBalanceBefore = alice.addr.balance; - uint256 routerUsdcBalanceBefore = IERC20(USDC).balanceOf(address(router)); + uint256 routerUsdcBalanceBefore = IERC20(USDC).balanceOf( + address(router) + ); vm.prank(alice.addr); - router.multicall{value: 1 ether}(calls, address(0), address(0), bytes32(0)); + router.multicall{value: 1 ether}(calls, address(0)); uint256 aliceEthBalanceAfterMulticall = alice.addr.balance; - uint256 routerUsdcBalanceAfterMulticall = IERC20(USDC).balanceOf(address(router)); + uint256 routerUsdcBalanceAfterMulticall = IERC20(USDC).balanceOf( + address(router) + ); - assertEq(aliceEthBalanceBefore - aliceEthBalanceAfterMulticall, 1 ether); + assertEq( + aliceEthBalanceBefore - aliceEthBalanceAfterMulticall, + 1 ether + ); assertGt(routerUsdcBalanceAfterMulticall, routerUsdcBalanceBefore); assertEq(nft.ownerOf(10), alice.addr); @@ -277,10 +421,14 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { recipients[0] = alice.addr; uint256[] memory amounts = new uint256[](1); amounts[0] = 0; - router.cleanupErc20s(tokens, recipients, amounts, bytes32(0)); + router.cleanupErc20s(tokens, recipients, amounts, bytes("")); - uint256 aliceUsdcBalanceAfterCleanup = IERC20(USDC).balanceOf(alice.addr); - uint256 routerUsdcBalanceAfterCleanup = IERC20(USDC).balanceOf(address(this)); + uint256 aliceUsdcBalanceAfterCleanup = IERC20(USDC).balanceOf( + alice.addr + ); + uint256 routerUsdcBalanceAfterCleanup = IERC20(USDC).balanceOf( + address(this) + ); assertEq(aliceUsdcBalanceAfterCleanup, routerUsdcBalanceAfterMulticall); assertEq(routerUsdcBalanceAfterCleanup, 0); } @@ -293,12 +441,22 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Encode transfer calldata - bytes memory calldata1 = abi.encodeWithSelector(IERC20.transferFrom.selector, alice.addr, bob.addr, 1 ether); + bytes memory calldata1 = abi.encodeWithSelector( + IERC20.transferFrom.selector, + alice.addr, + bob.addr, + 1 ether + ); // Encode router calls Call3Value[] memory calls = new Call3Value[](1); - calls[0] = Call3Value({target: address(erc20_1), allowFailure: false, value: 0, callData: calldata1}); + calls[0] = Call3Value({ + target: address(erc20_1), + allowFailure: false, + value: 0, + callData: calldata1 + }); address[] memory tokens = new address[](1); tokens[0] = address(erc20_1); @@ -310,9 +468,20 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { vm.prank(alice.addr); vm.expectRevert( - abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(router), 0, 1 ether) + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(router), + 0, + 1 ether + ) + ); + approvalProxy.transferAndMulticall( + tokens, + amounts, + calls, + alice.addr, + bytes("") ); - approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); // Encode router calls @@ -320,18 +489,30 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { target: address(erc20_1), allowFailure: false, value: 0, - callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether) + callData: abi.encodeWithSelector( + IERC20.transfer.selector, + bob.addr, + 1 ether + ) }); // This time the call should work because we're using "transfer(bob)" which doesn't require any approval vm.prank(alice.addr); - approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); + approvalProxy.transferAndMulticall( + tokens, + amounts, + calls, + alice.addr, + bytes("") + ); assertEq(erc20_1.balanceOf(bob.addr), 1 ether); } - function testApprovalProxy__TransferAndMulticall__SwapExactTokensForTokens() public { + function testApprovalProxy__TransferAndMulticall__SwapExactTokensForTokens() + public + { // Deal alice some USDC deal(USDC, alice.addr, 1000 * 10 ** 6); @@ -348,7 +529,11 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { path[1] = DAI; // Approve the uniswap router to spend USDC - bytes memory calldata1 = abi.encodeWithSelector(IERC20.approve.selector, ROUTER_V2, 1000 * 10 ** 6); + bytes memory calldata1 = abi.encodeWithSelector( + IERC20.approve.selector, + ROUTER_V2, + 1000 * 10 ** 6 + ); // Swap USDC for DAI bytes memory calldata2 = abi.encodeWithSelector( IUniswapV2Router01.swapExactTokensForTokens.selector, @@ -362,8 +547,18 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Encode the router calls Call3Value[] memory calls = new Call3Value[](2); - calls[0] = Call3Value({target: USDC, allowFailure: false, value: 0, callData: calldata1}); - calls[1] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 0, callData: calldata2}); + calls[0] = Call3Value({ + target: USDC, + allowFailure: false, + value: 0, + callData: calldata1 + }); + calls[1] = Call3Value({ + target: ROUTER_V2, + allowFailure: false, + value: 0, + callData: calldata2 + }); address[] memory tokens = new address[](1); tokens[0] = USDC; @@ -371,7 +566,13 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { amounts[0] = 1000 * 10 ** 6; vm.prank(alice.addr); - approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); + approvalProxy.transferAndMulticall( + tokens, + amounts, + calls, + alice.addr, + bytes("") + ); assertEq(IERC20(USDC).balanceOf(alice.addr), 0); assertEq(IERC20(USDC).balanceOf(address(router)), 0); @@ -391,12 +592,21 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Encode the calldata - bytes memory calldata1 = abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether); + bytes memory calldata1 = abi.encodeWithSelector( + IERC20.transfer.selector, + bob.addr, + 1 ether + ); // Encode the router calls Call3Value[] memory calls = new Call3Value[](1); - calls[0] = Call3Value({target: address(noOpErc20), allowFailure: false, value: 0, callData: calldata1}); + calls[0] = Call3Value({ + target: address(noOpErc20), + allowFailure: false, + value: 0, + callData: calldata1 + }); address[] memory tokens = new address[](1); tokens[0] = address(noOpErc20); @@ -406,18 +616,39 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // The below call should fail given that the no-op token is not going to process any transfers vm.expectRevert( - abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(router), 0, 1 ether) + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, + address(router), + 0, + 1 ether + ) + ); + approvalProxy.transferAndMulticall( + tokens, + amounts, + calls, + alice.addr, + bytes("") ); - approvalProxy.transferAndMulticall(tokens, amounts, calls, alice.addr, address(0), bytes32(0)); } function testApprovalProxy__PermitTransferAndMulticall_Eip2612() public { // Generate permit bytes32 structHash = keccak256( - abi.encode(_PERMIT2612_TYPEHASH, alice.addr, address(approvalProxy), 1 ether, 0, block.timestamp + 100) + abi.encode( + _PERMIT2612_TYPEHASH, + alice.addr, + address(approvalProxy), + 1 ether, + 0, + block.timestamp + 100 + ) + ); + bytes32 eip712PermitHash = _hashTypedData( + erc20_permit.DOMAIN_SEPARATOR(), + structHash ); - bytes32 eip712PermitHash = _hashTypedData(erc20_permit.DOMAIN_SEPARATOR(), structHash); (uint8 v, bytes32 r, bytes32 s) = vm.sign(alice.key, eip712PermitHash); Permit2612[] memory permits = new Permit2612[](1); @@ -439,28 +670,54 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { target: address(erc20_permit), allowFailure: false, value: 0, - callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether) + callData: abi.encodeWithSelector( + IERC20.transfer.selector, + bob.addr, + 1 ether + ) }); // Only the permit owner is allowed to use their permit vm.prank(bob.addr); vm.expectRevert(Unauthorized.selector); - approvalProxy.permitTransferAndMulticall(permits, calls, bob.addr, address(0), bytes32(0)); + approvalProxy.permitTransferAndMulticall( + permits, + calls, + bob.addr, + bytes("") + ); vm.prank(alice.addr); - approvalProxy.permitTransferAndMulticall(permits, calls, alice.addr, address(0), bytes32(0)); + approvalProxy.permitTransferAndMulticall( + permits, + calls, + alice.addr, + bytes("") + ); assertEq(erc20_permit.balanceOf(alice.addr), 0); assertEq(erc20_permit.balanceOf(bob.addr), 1 ether); } - function testApprovalProxy__PermitTransferAndMulticall__FrontrunEip2612() public { + function testApprovalProxy__PermitTransferAndMulticall__FrontrunEip2612() + public + { // Generate permit bytes32 structHash = keccak256( - abi.encode(_PERMIT2612_TYPEHASH, alice.addr, address(approvalProxy), 1 ether, 0, block.timestamp + 100) + abi.encode( + _PERMIT2612_TYPEHASH, + alice.addr, + address(approvalProxy), + 1 ether, + 0, + block.timestamp + 100 + ) + ); + bytes32 eip712PermitHash = _hashTypedData( + erc20_permit.DOMAIN_SEPARATOR(), + structHash ); - bytes32 eip712PermitHash = _hashTypedData(erc20_permit.DOMAIN_SEPARATOR(), structHash); (uint8 v, bytes32 r, bytes32 s) = vm.sign(alice.key, eip712PermitHash); Permit2612[] memory permits = new Permit2612[](1); @@ -482,36 +739,62 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { target: address(erc20_permit), allowFailure: false, value: 0, - callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, 1 ether) + callData: abi.encodeWithSelector( + IERC20.transfer.selector, + bob.addr, + 1 ether + ) }); // Frontrun the permit vm.prank(cal.addr); - erc20_permit.permit(alice.addr, address(approvalProxy), 1 ether, block.timestamp + 100, v, r, s); + erc20_permit.permit( + alice.addr, + address(approvalProxy), + 1 ether, + block.timestamp + 100, + v, + r, + s + ); // Frontran permits are successfully skipped vm.prank(alice.addr); - approvalProxy.permitTransferAndMulticall(permits, calls, alice.addr, address(0), bytes32(0)); + approvalProxy.permitTransferAndMulticall( + permits, + calls, + alice.addr, + bytes("") + ); assertEq(erc20_permit.balanceOf(alice.addr), 0); assertEq(erc20_permit.balanceOf(bob.addr), 1 ether); } - function testApprovalProxy__Permit2TransferAndMulticall__MaliciousSenderChangingRefundToAndNftRecipient() public { + function testApprovalProxy__Permit2TransferAndMulticall__MaliciousSenderChangingRefundToAndNftRecipient() + public + { // Deploy NFT that costs 20 USDC to mint - TestERC721_ERC20PaymentToken nft = new TestERC721_ERC20PaymentToken(USDC); + TestERC721_ERC20PaymentToken nft = new TestERC721_ERC20PaymentToken( + USDC + ); // Generate permit - ISignatureTransfer.TokenPermissions[] memory permitted = new ISignatureTransfer.TokenPermissions[](1); - permitted[0] = ISignatureTransfer.TokenPermissions({token: address(erc20_1), amount: 0.1 ether}); - ISignatureTransfer.PermitBatchTransferFrom memory permit = ISignatureTransfer.PermitBatchTransferFrom({ - permitted: permitted, - nonce: 1, - deadline: block.timestamp + 100 + ISignatureTransfer.TokenPermissions[] + memory permitted = new ISignatureTransfer.TokenPermissions[](1); + permitted[0] = ISignatureTransfer.TokenPermissions({ + token: address(erc20_1), + amount: 0.1 ether }); + ISignatureTransfer.PermitBatchTransferFrom + memory permit = ISignatureTransfer.PermitBatchTransferFrom({ + permitted: permitted, + nonce: 1, + deadline: block.timestamp + 100 + }); // Encode swap calldata @@ -520,22 +803,56 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { path[1] = USDC; bytes memory calldata1 = abi.encodeWithSelector( - IUniswapV2Router01.swapExactETHForTokens.selector, 0, path, alice.addr, block.timestamp + IUniswapV2Router01.swapExactETHForTokens.selector, + 0, + path, + alice.addr, + block.timestamp + ); + bytes memory calldata2 = abi.encodeWithSelector( + IERC20.approve.selector, + address(nft), + type(uint256).max + ); + bytes memory calldata3 = abi.encodeWithSelector( + nft.mint.selector, + alice.addr, + 10 ); - bytes memory calldata2 = abi.encodeWithSelector(IERC20.approve.selector, address(nft), type(uint256).max); - bytes memory calldata3 = abi.encodeWithSelector(nft.mint.selector, alice.addr, 10); // Encode router calls Call3Value[] memory calls = new Call3Value[](3); - calls[0] = Call3Value({target: ROUTER_V2, allowFailure: false, value: 0, callData: calldata1}); - calls[1] = Call3Value({target: USDC, allowFailure: false, value: 0, callData: calldata2}); - calls[2] = Call3Value({target: address(nft), allowFailure: false, value: 0, callData: calldata3}); + calls[0] = Call3Value({ + target: ROUTER_V2, + allowFailure: false, + value: 0, + callData: calldata1 + }); + calls[1] = Call3Value({ + target: USDC, + allowFailure: false, + value: 0, + callData: calldata2 + }); + calls[2] = Call3Value({ + target: address(nft), + allowFailure: false, + value: 0, + callData: calldata3 + }); // Get permit signature - bytes32 witness = - keccak256(abi.encode(_RELAYER_WITNESS_TYPEHASH, bob.addr, alice.addr, alice.addr, _getCallsHash(calls))); + bytes32 witness = keccak256( + abi.encode( + _RELAYER_WITNESS_TYPEHASH, + bob.addr, + alice.addr, + bytes(""), + _getCallsHash(calls) + ) + ); bytes memory permitSignature = getPermit2BatchWitnessSignature( permit, address(approvalProxy), @@ -548,7 +865,14 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Replace some fields and expect the router call to fail vm.expectRevert(InvalidSigner.selector); vm.prank(bob.addr); - approvalProxy.permit2TransferAndMulticall(alice.addr, permit, calls, bob.addr, bob.addr, bytes32(0), permitSignature); + approvalProxy.permit2TransferAndMulticall( + alice.addr, + permit, + calls, + bob.addr, + bytes(""), + permitSignature + ); } function testRouter_USDTCleanupWithSafeERC20() public { @@ -565,35 +889,59 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { uint256[] memory amounts = new uint256[](1); amounts[0] = 0; - bytes memory calldata1 = abi.encodeWithSelector(router.cleanupErc20s.selector, tokens, recipients, amounts, bytes32(0)); + bytes memory calldata1 = abi.encodeWithSelector( + router.cleanupErc20s.selector, + tokens, + recipients, + amounts, + bytes32(0) + ); // Encode router calls Call3Value[] memory calls = new Call3Value[](1); - calls[0] = Call3Value({target: address(router), allowFailure: false, value: 0, callData: calldata1}); + calls[0] = Call3Value({ + target: address(router), + allowFailure: false, + value: 0, + callData: calldata1 + }); uint256 bobUsdtBalanceBefore = IERC20(USDT).balanceOf(bob.addr); vm.prank(bob.addr); - router.multicall(calls, address(0), address(0), bytes32(0)); + router.multicall(calls, address(0)); - assertEq(IERC20(USDT).balanceOf(bob.addr) - bobUsdtBalanceBefore, 1000 * 10 ** 6); + assertEq( + IERC20(USDT).balanceOf(bob.addr) - bobUsdtBalanceBefore, + 1000 * 10 ** 6 + ); } function testRouter_NativeCleanupViaCall() public { // Deal router some native tokens vm.deal(address(router), 1 ether); - bytes memory calldata1 = - abi.encodeWithSelector(router.cleanupNativeViaCall.selector, 0, bob.addr, bytes("0x1234567890"), bytes32(0)); + bytes memory calldata1 = abi.encodeWithSelector( + router.cleanupNativeViaCall.selector, + 0, + bob.addr, + bytes("0x1234567890"), + bytes("") + ); Call3Value[] memory calls = new Call3Value[](1); - calls[0] = Call3Value({target: address(router), allowFailure: false, value: 0, callData: calldata1}); + calls[0] = Call3Value({ + target: address(router), + allowFailure: false, + value: 0, + callData: calldata1 + }); uint256 bobBalanceBefore = address(bob.addr).balance; vm.prank(alice.addr); - router.multicall(calls, address(0), address(0), bytes32(0)); + router.multicall(calls, address(0)); assertEq(address(bob.addr).balance - bobBalanceBefore, 1 ether); } @@ -606,15 +954,24 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Encode mint calldata // "safeMint" is not going to call "onERC721Received" - bytes memory calldata1 = abi.encodeWithSignature("safeMint(address,uint256)", address(router), 1); + bytes memory calldata1 = abi.encodeWithSignature( + "safeMint(address,uint256)", + address(router), + 1 + ); // Encode router calls Call3Value[] memory calls = new Call3Value[](1); - calls[0] = Call3Value({target: address(erc721), allowFailure: false, value: 0, callData: calldata1}); + calls[0] = Call3Value({ + target: address(erc721), + allowFailure: false, + value: 0, + callData: calldata1 + }); vm.prank(alice.addr); - router.multicall(calls, address(0), alice.addr, bytes32(0)); + router.multicall(calls, alice.addr); // The router should have automatically forward the minted token to the sender assertEq(erc721.ownerOf(1), alice.addr); @@ -629,17 +986,31 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // "mint" is not going to call "onERC721Received" bytes memory calldata1 = abi.encodeWithSignature("mint(uint256)", 1); - bytes memory calldata2 = - abi.encodeWithSignature("safeTransferFrom(address,address,uint256)", address(router), alice.addr, 1); + bytes memory calldata2 = abi.encodeWithSignature( + "safeTransferFrom(address,address,uint256)", + address(router), + alice.addr, + 1 + ); // Encode router calls Call3Value[] memory calls = new Call3Value[](2); - calls[0] = Call3Value({target: address(erc721), allowFailure: false, value: 0, callData: calldata1}); - calls[1] = Call3Value({target: address(erc721), allowFailure: false, value: 0, callData: calldata2}); + calls[0] = Call3Value({ + target: address(erc721), + allowFailure: false, + value: 0, + callData: calldata1 + }); + calls[1] = Call3Value({ + target: address(erc721), + allowFailure: false, + value: 0, + callData: calldata2 + }); vm.prank(alice.addr); - router.multicall(calls, address(0), alice.addr, bytes32(0)); + router.multicall(calls, alice.addr); assertEq(erc721.ownerOf(1), alice.addr); } @@ -658,16 +1029,33 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { target: address(USDC), allowFailure: false, value: 0, - callData: abi.encodeWithSelector(IERC20.transfer.selector, bob.addr, amount) + callData: abi.encodeWithSelector( + IERC20.transfer.selector, + bob.addr, + amount + ) }); - bytes32 witness = _getRelayerWitnessHash(alice.addr, alice.addr, alice.addr, calls); + bytes32 witness = _getRelayerWitnessHash( + alice.addr, + alice.addr, + bytes(""), + calls + ); uint256 validBefore = block.timestamp + 100; // Generate permit bytes32 structHash = keccak256( - abi.encode(_PERMIT3009_TYPEHASH, alice.addr, address(approvalProxy), amount, 0, validBefore, witness) + abi.encode( + _PERMIT3009_TYPEHASH, + alice.addr, + address(approvalProxy), + amount, + 0, + validBefore, + witness + ) ); bytes32 eip712PermitHash = _hashTypedData( // The only purpose of the conversion is to be able to call "DOMAIN_SEPARATOR" @@ -677,8 +1065,15 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { (uint8 v, bytes32 r, bytes32 s) = vm.sign(alice.key, eip712PermitHash); Permit3009[] memory permits = new Permit3009[](1); - permits[0] = - Permit3009({from: alice.addr, value: amount, validAfter: 0, validBefore: validBefore, v: v, r: r, s: s}); + permits[0] = Permit3009({ + from: alice.addr, + value: amount, + validAfter: 0, + validBefore: validBefore, + v: v, + r: r, + s: s + }); address[] memory tokens = new address[](1); tokens[0] = USDC; @@ -686,15 +1081,29 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { vm.prank(bob.addr); vm.expectRevert("FiatTokenV2: invalid signature"); - approvalProxy.permit3009TransferAndMulticall(permits, tokens, calls, bob.addr, alice.addr, bytes32(0)); + approvalProxy.permit3009TransferAndMulticall( + permits, + tokens, + calls, + bob.addr, + bytes("") + ); vm.prank(alice.addr); - approvalProxy.permit3009TransferAndMulticall(permits, tokens, calls, alice.addr, alice.addr, bytes32(0)); + approvalProxy.permit3009TransferAndMulticall( + permits, + tokens, + calls, + alice.addr, + bytes("") + ); } // Utility methods - function _getCallsHash(Call3Value[] memory calls) internal pure returns (bytes32) { + function _getCallsHash( + Call3Value[] memory calls + ) internal pure returns (bytes32) { bytes32[] memory callHashes = new bytes32[](calls.length); for (uint256 i = 0; i < calls.length; i++) { // Encode the call and hash it @@ -712,15 +1121,28 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { return keccak256(abi.encodePacked(callHashes)); } - function _getRelayerWitnessHash(address relayer, address refundTo, address nftRecipient, Call3Value[] memory calls) - internal - pure - returns (bytes32) - { - return keccak256(abi.encode(_RELAYER_WITNESS_TYPEHASH, relayer, refundTo, nftRecipient, _getCallsHash(calls))); + function _getRelayerWitnessHash( + address relayer, + address nftRecipient, + bytes memory metadata, + Call3Value[] memory calls + ) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + _RELAYER_WITNESS_TYPEHASH, + relayer, + nftRecipient, + metadata, + _getCallsHash(calls) + ) + ); } - function _hashTypedData(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32 digest) { + function _hashTypedData( + bytes32 domainSeparator, + bytes32 structHash + ) internal pure returns (bytes32 digest) { digest = domainSeparator; /// @solidity memory-safe-assembly assembly { @@ -736,7 +1158,12 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { // Not actually used but still required to be defined - function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) { + function _domainNameAndVersion() + internal + pure + override + returns (string memory name, string memory version) + { name = "UNUSED"; version = "UNUSED"; } From 1beef14110d4407b073ced5308c4c38367a1dc0b Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:21:01 +0200 Subject: [PATCH 03/13] chore: deploy v3 contracts to base --- deployments/v2.1/scripts/deploy.sh | 2 +- deployments/v2.1/scripts/deploy_non_tstore.sh | 2 +- deployments/v3/addresses.json | 13 ++ deployments/v3/scripts/deploy.sh | 7 + deployments/v3/scripts/verify.sh | 17 ++ .../RouterAndApprovalProxyV2_1Deployer.s.sol | 28 ++-- ...ApprovalProxyV2_1_NonTstore_Deployer.s.sol | 4 +- .../v3/RouterAndApprovalProxyV3Deployer.s.sol | 145 ++++++++++++++++++ 8 files changed, 200 insertions(+), 18 deletions(-) create mode 100644 deployments/v3/addresses.json create mode 100755 deployments/v3/scripts/deploy.sh create mode 100755 deployments/v3/scripts/verify.sh rename script/{ => v2.1}/RouterAndApprovalProxyV2_1Deployer.s.sol (87%) rename script/{ => v2.1}/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol (96%) create mode 100644 script/v3/RouterAndApprovalProxyV3Deployer.s.sol diff --git a/deployments/v2.1/scripts/deploy.sh b/deployments/v2.1/scripts/deploy.sh index 1f4b4db..473ed8f 100755 --- a/deployments/v2.1/scripts/deploy.sh +++ b/deployments/v2.1/scripts/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash -forge script ./script/RouterAndApprovalProxyV2_1Deployer.s.sol:RouterAndApprovalProxyV2_1Deployer \ +forge script ./script/v2.1/RouterAndApprovalProxyV2_1Deployer.s.sol:RouterAndApprovalProxyV2_1Deployer \ --slow \ --broadcast \ --private-key $DEPLOYER_PK \ diff --git a/deployments/v2.1/scripts/deploy_non_tstore.sh b/deployments/v2.1/scripts/deploy_non_tstore.sh index c2c1412..d0c200e 100755 --- a/deployments/v2.1/scripts/deploy_non_tstore.sh +++ b/deployments/v2.1/scripts/deploy_non_tstore.sh @@ -2,7 +2,7 @@ export FOUNDRY_PROFILE=london -forge script ./script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol:RouterAndApprovalProxyV2_1_NonTstore_Deployer \ +forge script ./script/v2.1/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol:RouterAndApprovalProxyV2_1_NonTstore_Deployer \ --slow \ --broadcast \ --contracts ./src/v2.1/Relay \ diff --git a/deployments/v3/addresses.json b/deployments/v3/addresses.json new file mode 100644 index 0000000..d93d71f --- /dev/null +++ b/deployments/v3/addresses.json @@ -0,0 +1,13 @@ +[ + { + "name": "base", + "chainId": 8453, + "compilerVersion": "0.8.28", + "evmVersion": "cancun", + "explorerUrl": "https://basescan.org", + "relayRouter": "0x78d20c200a1cd32dd111f68a739d61a3f1c48b35", + "relayApprovalProxy": "0xcc0ad492495c6fb0cc5381969cdc3116ba6cd123", + "create2Factory": "0x4e59b44847b379578588920ca78fbf26c0b4956c", + "permit2": "0x000000000022d473030f116ddee9f6b43ac78ba3" + } +] diff --git a/deployments/v3/scripts/deploy.sh b/deployments/v3/scripts/deploy.sh new file mode 100755 index 0000000..49bac47 --- /dev/null +++ b/deployments/v3/scripts/deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +forge script ./script/v3/RouterAndApprovalProxyV3Deployer.s.sol:RouterAndApprovalProxyV3Deployer \ + --slow \ + --broadcast \ + --private-key $DEPLOYER_PK \ + --create2-deployer $CREATE2_FACTORY \ No newline at end of file diff --git a/deployments/v3/scripts/verify.sh b/deployments/v3/scripts/verify.sh new file mode 100755 index 0000000..c5c40f0 --- /dev/null +++ b/deployments/v3/scripts/verify.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Get the verification flags from the deployment file +VERIFICATION_FLAGS=$(jq -c ".[] | select(.name == \"$CHAIN\") | .verificationFlags" "./deployments/$DEPLOYMENT_FILE") + +if [ "$VERIFICATION_FLAGS" != "null" ]; then + # Split the verification flags into an array + expanded_verification_flags=(`echo $VERIFICATION_FLAGS | tr -d '"'`) + + # Verify the contracts using the above flags + forge verify-contract ${expanded_verification_flags[@]} $RELAY_ROUTER ./src/v3/RelayRouterV3.sol:RelayRouterV3 + forge verify-contract ${expanded_verification_flags[@]} $RELAY_APPROVAL_PROXY ./src/v3/RelayApprovalProxyV3.sol:RelayApprovalProxyV3 --constructor-args $(cast abi-encode "constructor(address, address, address)" $DEPLOYER_ADDRESS $RELAY_ROUTER $PERMIT2) +else + # Verify the contracts + forge verify-contract --chain $CHAIN $RELAY_ROUTER ./src/v3/RelayRouterV3.sol:RelayRouterV3 + forge verify-contract --chain $CHAIN $RELAY_APPROVAL_PROXY ./src/v3/RelayApprovalProxyV3.sol:RelayApprovalProxyV3 --constructor-args $(cast abi-encode "constructor(address, address, address)" $DEPLOYER_ADDRESS $RELAY_ROUTER $PERMIT2) +fi \ No newline at end of file diff --git a/script/RouterAndApprovalProxyV2_1Deployer.s.sol b/script/v2.1/RouterAndApprovalProxyV2_1Deployer.s.sol similarity index 87% rename from script/RouterAndApprovalProxyV2_1Deployer.s.sol rename to script/v2.1/RouterAndApprovalProxyV2_1Deployer.s.sol index 502121d..4daf049 100644 --- a/script/RouterAndApprovalProxyV2_1Deployer.s.sol +++ b/script/v2.1/RouterAndApprovalProxyV2_1Deployer.s.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.23; import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; -import {RelayApprovalProxyV2_1} from "../src/v2.1/RelayApprovalProxyV2_1.sol"; -import {RelayRouterV2_1} from "../src/v2.1/RelayRouterV2_1.sol"; +import {RelayApprovalProxyV2_1} from "../../src/v2.1/RelayApprovalProxyV2_1.sol"; +import {RelayRouterV2_1} from "../../src/v2.1/RelayRouterV2_1.sol"; contract RouterAndApprovalProxyV2_1Deployer is Script { // Thrown when the predicted address doesn't match the deployed address @@ -47,7 +47,9 @@ contract RouterAndApprovalProxyV2_1Deployer is Script { create2Factory, SALT, keccak256( - abi.encodePacked(type(RelayRouterV2_1).creationCode) + abi.encodePacked( + type(RelayRouterV2_1).creationCode + ) ) ) ) @@ -68,10 +70,7 @@ contract RouterAndApprovalProxyV2_1Deployer is Script { // Ensure the predicted and actual addresses match if (predictedAddress != address(router)) { - revert IncorrectContractAddress( - predictedAddress, - address(router) - ); + revert IncorrectContractAddress(predictedAddress, address(router)); } console2.log("RelayRouterV2_1 deployed"); @@ -106,7 +105,10 @@ contract RouterAndApprovalProxyV2_1Deployer is Script { ) ); - console2.log("Predicted address for RelayApprovalProxyV2_1", predictedAddress); + console2.log( + "Predicted address for RelayApprovalProxyV2_1", + predictedAddress + ); // Verify if the contract has already been deployed if (_hasBeenDeployed(predictedAddress)) { @@ -115,11 +117,9 @@ contract RouterAndApprovalProxyV2_1Deployer is Script { } // Deploy - RelayApprovalProxyV2_1 approvalProxy = new RelayApprovalProxyV2_1{salt: SALT}( - msg.sender, - router, - permit2 - ); + RelayApprovalProxyV2_1 approvalProxy = new RelayApprovalProxyV2_1{ + salt: SALT + }(msg.sender, router, permit2); // Ensure the predicted and actual addresses match if (predictedAddress != address(approvalProxy)) { @@ -143,4 +143,4 @@ contract RouterAndApprovalProxyV2_1Deployer is Script { } return (size > 0); } -} \ No newline at end of file +} diff --git a/script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol b/script/v2.1/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol similarity index 96% rename from script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol rename to script/v2.1/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol index 750b296..287bd4d 100644 --- a/script/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol +++ b/script/v2.1/RouterAndApprovalProxyV2_1_NonTstore_Deployer.s.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.23; import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; -import {RelayApprovalProxyV2_1} from "../src/v2.1/RelayApprovalProxyV2_1.sol"; -import {RelayRouterV2_1_NonTstore} from "../src/v2.1/RelayRouterV2_1_NonTstore.sol"; +import {RelayApprovalProxyV2_1} from "../../src/v2.1/RelayApprovalProxyV2_1.sol"; +import {RelayRouterV2_1_NonTstore} from "../../src/v2.1/RelayRouterV2_1_NonTstore.sol"; contract RouterAndApprovalProxyV2_1_NonTstore_Deployer is Script { // Thrown when the predicted address doesn't match the deployed address diff --git a/script/v3/RouterAndApprovalProxyV3Deployer.s.sol b/script/v3/RouterAndApprovalProxyV3Deployer.s.sol new file mode 100644 index 0000000..c3eeb76 --- /dev/null +++ b/script/v3/RouterAndApprovalProxyV3Deployer.s.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; + +import {RelayApprovalProxyV3} from "../../src/v3/RelayApprovalProxyV3.sol"; +import {RelayRouterV3} from "../../src/v3/RelayRouterV3.sol"; + +contract RouterAndApprovalProxyV3Deployer is Script { + // Thrown when the predicted address doesn't match the deployed address + error IncorrectContractAddress(address predicted, address actual); + + // Modify for vanity address generation + bytes32 public SALT = bytes32(uint256(1)); + + function setUp() public {} + + function run() public { + vm.createSelectFork(vm.envString("CHAIN")); + + vm.startBroadcast(); + + RelayRouterV3 router = RelayRouterV3(payable(deployRouter())); + RelayApprovalProxyV3 approvalProxy = RelayApprovalProxyV3( + payable(deployApprovalProxy(address(router))) + ); + + assert(approvalProxy.owner() == msg.sender); + + vm.stopBroadcast(); + } + + function deployRouter() public returns (address) { + console2.log("Deploying RelayRouterV3"); + + address create2Factory = vm.envAddress("CREATE2_FACTORY"); + + // Compute predicted address + address predictedAddress = address( + uint160( + uint( + keccak256( + abi.encodePacked( + bytes1(0xff), + create2Factory, + SALT, + keccak256( + abi.encodePacked( + type(RelayRouterV3).creationCode + ) + ) + ) + ) + ) + ) + ); + + console2.log("Predicted address for RelayRouterV3", predictedAddress); + + // Verify if the contract has already been deployed + if (_hasBeenDeployed(predictedAddress)) { + console2.log("RelayRouterV3 was already deployed"); + return predictedAddress; + } + + // Deploy + RelayRouterV3 router = new RelayRouterV3{salt: SALT}(); + + // Ensure the predicted and actual addresses match + if (predictedAddress != address(router)) { + revert IncorrectContractAddress(predictedAddress, address(router)); + } + + console2.log("RelayRouterV3 deployed"); + + return address(router); + } + + function deployApprovalProxy(address router) public returns (address) { + console2.log("Deploying ApprovalProxyV3"); + + address create2Factory = vm.envAddress("CREATE2_FACTORY"); + address permit2 = vm.envAddress("PERMIT2"); + + // Compute predicted address + address predictedAddress = address( + uint160( + uint( + keccak256( + abi.encodePacked( + bytes1(0xff), + create2Factory, + SALT, + keccak256( + abi.encodePacked( + type(RelayApprovalProxyV3).creationCode, + abi.encode(msg.sender, router, permit2) + ) + ) + ) + ) + ) + ) + ); + + console2.log( + "Predicted address for RelayApprovalProxyV3", + predictedAddress + ); + + // Verify if the contract has already been deployed + if (_hasBeenDeployed(predictedAddress)) { + console2.log("RelayApprovalProxyV3 was already deployed"); + return predictedAddress; + } + + // Deploy + RelayApprovalProxyV3 approvalProxy = new RelayApprovalProxyV3{ + salt: SALT + }(msg.sender, router, permit2); + + // Ensure the predicted and actual addresses match + if (predictedAddress != address(approvalProxy)) { + revert IncorrectContractAddress( + predictedAddress, + address(approvalProxy) + ); + } + + console2.log("RelayApprovalProxyV3 deployed"); + + return address(approvalProxy); + } + + function _hasBeenDeployed( + address addressToCheck + ) internal view returns (bool) { + uint256 size; + assembly { + size := extcodesize(addressToCheck) + } + return (size > 0); + } +} From 2d1adf0ada66d86debaddf72c9519f2df3a0f64a Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:20:18 +0200 Subject: [PATCH 04/13] tweak: add back refundTo --- src/v3/RelayApprovalProxyV3.sol | 66 ++++++++++++++++++++++++---- src/v3/RelayRouterV3.sol | 9 +++- src/v3/RelayRouterV3_NonTstore.sol | 9 +++- src/v3/interfaces/IRelayRouterV3.sol | 4 +- test/v3/RouterAndApprovalV3Test.sol | 56 ++++++++++++++++++----- 5 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/v3/RelayApprovalProxyV3.sol b/src/v3/RelayApprovalProxyV3.sol index 7f8a7c4..a3e6735 100644 --- a/src/v3/RelayApprovalProxyV3.sol +++ b/src/v3/RelayApprovalProxyV3.sol @@ -25,6 +25,9 @@ contract RelayApprovalProxyV3 is Ownable { /// @notice Revert if the native transfer fails error NativeTransferFailed(); + /// @notice Revert if the refundTo address is zero address + error RefundToCannotBeZeroAddress(); + /// @notice Emitted when pulling funds from a user event RouterPull( address from, @@ -44,10 +47,10 @@ contract RelayApprovalProxyV3 is Ownable { "Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" ); string public constant _RELAYER_WITNESS_TYPE_STRING = - "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; + "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; bytes32 public constant _RELAYER_WITNESS_TYPEHASH = keccak256( - "RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + "RelayerWitness(address relayer,address refundTo,address nftRecipient,bytes metadata,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" ); receive() external payable {} @@ -70,12 +73,14 @@ contract RelayApprovalProxyV3 is Ownable { /// @param tokens An array of token addresses to transfer /// @param amounts An array of token amounts to transfer /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints /// @param metadata Additional data to associate the call to function transferAndMulticall( address[] calldata tokens, uint256[] calldata amounts, Call3Value[] calldata calls, + address refundTo, address nftRecipient, bytes calldata metadata ) external payable returns (Result[] memory returnData) { @@ -84,6 +89,11 @@ contract RelayApprovalProxyV3 is Ownable { revert ArrayLengthsMismatch(); } + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + // Transfer the tokens to the router for (uint256 i = 0; i < tokens.length; i++) { IERC20(tokens[i]).safeTransferFrom(msg.sender, ROUTER, amounts[i]); @@ -94,7 +104,9 @@ contract RelayApprovalProxyV3 is Ownable { // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - nftRecipient + refundTo, + nftRecipient, + metadata ); } @@ -104,15 +116,22 @@ contract RelayApprovalProxyV3 is Ownable { /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. /// @param permits An array of permits /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints /// @param metadata Additional data to associate the call to /// @return returnData The return data from the multicall function permitTransferAndMulticall( Permit2612[] calldata permits, Call3Value[] calldata calls, + address refundTo, address nftRecipient, bytes calldata metadata ) external payable returns (Result[] memory returnData) { + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + for (uint256 i = 0; i < permits.length; i++) { Permit2612 memory permit = permits[i]; @@ -146,7 +165,9 @@ contract RelayApprovalProxyV3 is Ownable { // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - nftRecipient + refundTo, + nftRecipient, + metadata ); } @@ -158,6 +179,7 @@ contract RelayApprovalProxyV3 is Ownable { /// @param user The address of the user /// @param permit The permit details /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints /// @param metadata Additional data to associate the call to /// @param permitSignature The signature for the permit @@ -165,14 +187,21 @@ contract RelayApprovalProxyV3 is Ownable { address user, ISignatureTransfer.PermitBatchTransferFrom memory permit, Call3Value[] calldata calls, + address refundTo, address nftRecipient, bytes calldata metadata, bytes memory permitSignature ) external payable returns (Result[] memory returnData) { + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + // If a permit signature is provided, use it to transfer tokens from user to router if (permitSignature.length != 0) { _handleBatchPermit( user, + refundTo, nftRecipient, metadata, permit, @@ -184,7 +213,9 @@ contract RelayApprovalProxyV3 is Ownable { // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - nftRecipient + refundTo, + nftRecipient, + metadata ); } @@ -194,6 +225,7 @@ contract RelayApprovalProxyV3 is Ownable { /// includes ERC721/ERC1155 mints or transfers, be sure to set nftRecipient to the expected recipient. /// @param permits An array of permits /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints /// @param metadata Additional data to associate the call to /// @return returnData The return data from the multicall @@ -201,6 +233,7 @@ contract RelayApprovalProxyV3 is Ownable { Permit3009[] calldata permits, address[] calldata tokens, Call3Value[] calldata calls, + address refundTo, address nftRecipient, bytes calldata metadata ) external payable returns (Result[] memory returnData) { @@ -209,6 +242,11 @@ contract RelayApprovalProxyV3 is Ownable { revert ArrayLengthsMismatch(); } + // Revert if refundTo is zero address + if (refundTo == address(0)) { + revert RefundToCannotBeZeroAddress(); + } + for (uint256 i = 0; i < permits.length; i++) { Permit3009 memory permit = permits[i]; @@ -219,7 +257,7 @@ contract RelayApprovalProxyV3 is Ownable { permit.value, permit.validAfter, permit.validBefore, - _getRelayerWitnessHash(nftRecipient, metadata, calls), + _getRelayerWitnessHash(refundTo, nftRecipient, metadata, calls), permit.v, permit.r, permit.s @@ -234,7 +272,9 @@ contract RelayApprovalProxyV3 is Ownable { // Call multicall on the router returnData = IRelayRouterV3(ROUTER).multicall{value: msg.value}( calls, - nftRecipient + refundTo, + nftRecipient, + metadata ); } @@ -262,10 +302,12 @@ contract RelayApprovalProxyV3 is Ownable { } /// @notice Internal function to get the hash of a relayer witness + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The nftRecipient address /// @param metadata Additional data to associate the call to /// @param calls The calls to be executed function _getRelayerWitnessHash( + address refundTo, address nftRecipient, bytes memory metadata, Call3Value[] memory calls @@ -275,6 +317,7 @@ contract RelayApprovalProxyV3 is Ownable { abi.encode( _RELAYER_WITNESS_TYPEHASH, msg.sender, + refundTo, nftRecipient, metadata, _getCallsHash(calls) @@ -284,6 +327,7 @@ contract RelayApprovalProxyV3 is Ownable { /// @notice Internal function to handle a permit batch transfer /// @param user The address of the user + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints /// @param metadata Additional data to associate the call to /// @param permit The permit details @@ -291,13 +335,19 @@ contract RelayApprovalProxyV3 is Ownable { /// @param permitSignature The signature for the permit function _handleBatchPermit( address user, + address refundTo, address nftRecipient, bytes calldata metadata, ISignatureTransfer.PermitBatchTransferFrom memory permit, Call3Value[] calldata calls, bytes memory permitSignature ) internal { - bytes32 witness = _getRelayerWitnessHash(nftRecipient, metadata, calls); + bytes32 witness = _getRelayerWitnessHash( + refundTo, + nftRecipient, + metadata, + calls + ); // Create the SignatureTransferDetails array ISignatureTransfer.SignatureTransferDetails[] diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol index b42fa65..909dbdc 100644 --- a/src/v3/RelayRouterV3.sol +++ b/src/v3/RelayRouterV3.sol @@ -50,10 +50,14 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// All calls to ERC721s and ERC1155s in the multicall will have the same recipient set in recipient /// Be sure to transfer ERC20s or native tokens out of the router as part of the multicall /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param metadata Additional data to associate the call to function multicall( Call3Value[] calldata calls, - address nftRecipient + address refundTo, + address nftRecipient, + bytes calldata metadata ) public payable virtual nonReentrant returns (Result[] memory returnData) { // Set the NFT recipient if provided if (nftRecipient != address(0)) { @@ -65,6 +69,9 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { // Clear the recipient in storage _clearRecipient(); + + // Refund any leftover native tokens to the sender + cleanupNative(0, refundTo, metadata); } /// @notice Send leftover ERC20 tokens to recipients diff --git a/src/v3/RelayRouterV3_NonTstore.sol b/src/v3/RelayRouterV3_NonTstore.sol index 27c3ea7..df21291 100644 --- a/src/v3/RelayRouterV3_NonTstore.sol +++ b/src/v3/RelayRouterV3_NonTstore.sol @@ -53,10 +53,14 @@ contract RelayRouterV3_NonTstore is /// All calls to ERC721s and ERC1155s in the multicall will have the same recipient set in recipient /// Be sure to transfer ERC20s or native tokens out of the router as part of the multicall /// @param calls The calls to perform + /// @param refundTo The address to refund any leftover native tokens to /// @param nftRecipient The address to set as recipient of ERC721/ERC1155 mints + /// @param metadata Additional data to associate the call to function multicall( Call3Value[] calldata calls, - address nftRecipient + address refundTo, + address nftRecipient, + bytes calldata metadata ) public payable virtual nonReentrant returns (Result[] memory returnData) { // Set the NFT recipient if provided if (nftRecipient != address(0)) { @@ -68,6 +72,9 @@ contract RelayRouterV3_NonTstore is // Clear the recipient in storage _clearRecipient(); + + // Refund any leftover native tokens to the sender + cleanupNative(0, refundTo, metadata); } /// @notice Send leftover ERC20 tokens to recipients diff --git a/src/v3/interfaces/IRelayRouterV3.sol b/src/v3/interfaces/IRelayRouterV3.sol index 5e4debd..30fa02f 100644 --- a/src/v3/interfaces/IRelayRouterV3.sol +++ b/src/v3/interfaces/IRelayRouterV3.sol @@ -6,6 +6,8 @@ import {Call3Value, Result} from "../../common/Multicall3.sol"; interface IRelayRouterV3 { function multicall( Call3Value[] calldata calls, - address nftRecipient + address refundTo, + address nftRecipient, + bytes calldata metadata ) external payable returns (Result[] memory returnData); } diff --git a/test/v3/RouterAndApprovalV3Test.sol b/test/v3/RouterAndApprovalV3Test.sol index a3c1bf9..ff7a3e4 100644 --- a/test/v3/RouterAndApprovalV3Test.sol +++ b/test/v3/RouterAndApprovalV3Test.sol @@ -49,11 +49,11 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { ); bytes32 public constant _RELAYER_WITNESS_TYPEHASH = keccak256( - "RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" + "RelayerWitness(address relayer,address refundTo,address nftRecipient,bytes metadata,Call3Value[] call3Values)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)" ); bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_TYPEHASH = keccak256( - "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" ); bytes32 public constant _PERMIT2_BATCH_TRANSFER_FROM_TYPEHASH = keccak256( @@ -61,7 +61,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { ); bytes32 public constant _PERMIT2_FULL_RELAYER_WITNESS_BATCH_TYPEHASH = keccak256( - "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" + "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)" ); bytes32 private constant _PERMIT2612_TYPEHASH = keccak256( @@ -72,7 +72,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); string public constant _PERMIT2_RELAYER_WITNESS_TYPE_STRING = - "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; + "RelayerWitness witness)Call3Value(address target,bool allowFailure,uint256 value,bytes callData)RelayerWitness(address relayer,address refundTo,address nftRecipient,bytes metadata,Call3Value[] call3Values)TokenPermissions(address token,uint256 amount)"; // Setup function setUp() public override { @@ -196,6 +196,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { _RELAYER_WITNESS_TYPEHASH, bob.addr, alice.addr, + alice.addr, bytes(""), _getCallsHash(calls) ) @@ -217,6 +218,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { permit, calls, alice.addr, + alice.addr, bytes(""), permitSignature ); @@ -228,6 +230,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { permit, calls, alice.addr, + alice.addr, bytes(""), permitSignature ); @@ -277,7 +280,12 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { uint256 aliceUsdcBalanceBefore = IERC20(USDC).balanceOf(alice.addr); vm.prank(alice.addr); - router.multicall{value: 1 ether}(calls, address(0)); + router.multicall{value: 1 ether}( + calls, + address(0), + address(0), + bytes("") + ); uint256 aliceEthBalanceAfter = alice.addr.balance; uint256 aliceUsdcBalanceAfter = IERC20(USDC).balanceOf(alice.addr); @@ -328,7 +336,12 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { uint256 aliceUsdcBalanceBefore = IERC20(USDC).balanceOf(alice.addr); vm.prank(alice.addr); - router.multicall{value: 2 ether}(calls, address(0)); + router.multicall{value: 2 ether}( + calls, + address(0), + address(0), + bytes("") + ); uint256 aliceEthBalanceAfter = alice.addr.balance; uint256 aliceUsdcBalanceAfter = IERC20(USDC).balanceOf(alice.addr); @@ -399,7 +412,12 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { ); vm.prank(alice.addr); - router.multicall{value: 1 ether}(calls, address(0)); + router.multicall{value: 1 ether}( + calls, + address(0), + address(0), + bytes("") + ); uint256 aliceEthBalanceAfterMulticall = alice.addr.balance; uint256 routerUsdcBalanceAfterMulticall = IERC20(USDC).balanceOf( @@ -480,6 +498,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { amounts, calls, alice.addr, + alice.addr, bytes("") ); @@ -504,6 +523,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { amounts, calls, alice.addr, + alice.addr, bytes("") ); @@ -571,6 +591,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { amounts, calls, alice.addr, + alice.addr, bytes("") ); @@ -628,6 +649,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { amounts, calls, alice.addr, + alice.addr, bytes("") ); } @@ -683,7 +705,8 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { approvalProxy.permitTransferAndMulticall( permits, calls, - bob.addr, + alice.addr, + alice.addr, bytes("") ); @@ -692,6 +715,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { permits, calls, alice.addr, + alice.addr, bytes("") ); @@ -765,6 +789,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { permits, calls, alice.addr, + alice.addr, bytes("") ); @@ -849,6 +874,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { _RELAYER_WITNESS_TYPEHASH, bob.addr, alice.addr, + alice.addr, bytes(""), _getCallsHash(calls) ) @@ -870,6 +896,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { permit, calls, bob.addr, + bob.addr, bytes(""), permitSignature ); @@ -910,7 +937,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { uint256 bobUsdtBalanceBefore = IERC20(USDT).balanceOf(bob.addr); vm.prank(bob.addr); - router.multicall(calls, address(0)); + router.multicall(calls, address(0), address(0), bytes("")); assertEq( IERC20(USDT).balanceOf(bob.addr) - bobUsdtBalanceBefore, @@ -941,7 +968,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { uint256 bobBalanceBefore = address(bob.addr).balance; vm.prank(alice.addr); - router.multicall(calls, address(0)); + router.multicall(calls, address(0), address(0), bytes("")); assertEq(address(bob.addr).balance - bobBalanceBefore, 1 ether); } @@ -971,7 +998,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { }); vm.prank(alice.addr); - router.multicall(calls, alice.addr); + router.multicall(calls, alice.addr, alice.addr, bytes("")); // The router should have automatically forward the minted token to the sender assertEq(erc721.ownerOf(1), alice.addr); @@ -1010,7 +1037,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { }); vm.prank(alice.addr); - router.multicall(calls, alice.addr); + router.multicall(calls, alice.addr, alice.addr, bytes("")); assertEq(erc721.ownerOf(1), alice.addr); } @@ -1037,6 +1064,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { }); bytes32 witness = _getRelayerWitnessHash( + alice.addr, alice.addr, alice.addr, bytes(""), @@ -1086,6 +1114,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { tokens, calls, bob.addr, + alice.addr, bytes("") ); @@ -1095,6 +1124,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { tokens, calls, alice.addr, + alice.addr, bytes("") ); } @@ -1123,6 +1153,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { function _getRelayerWitnessHash( address relayer, + address refundTo, address nftRecipient, bytes memory metadata, Call3Value[] memory calls @@ -1132,6 +1163,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { abi.encode( _RELAYER_WITNESS_TYPEHASH, relayer, + refundTo, nftRecipient, metadata, _getCallsHash(calls) From 98f3c61893beea62796de613e196597e049bad57 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:24:27 +0200 Subject: [PATCH 05/13] chore: redeploy to base --- deployments/v3/addresses.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deployments/v3/addresses.json b/deployments/v3/addresses.json index d93d71f..3dd2ac7 100644 --- a/deployments/v3/addresses.json +++ b/deployments/v3/addresses.json @@ -5,8 +5,8 @@ "compilerVersion": "0.8.28", "evmVersion": "cancun", "explorerUrl": "https://basescan.org", - "relayRouter": "0x78d20c200a1cd32dd111f68a739d61a3f1c48b35", - "relayApprovalProxy": "0xcc0ad492495c6fb0cc5381969cdc3116ba6cd123", + "relayRouter": "0xa3572394e3f29e271a5803a389045ca5105e1e0e", + "relayApprovalProxy": "0x214aedd162aff981e405102253db8657bf6c089d", "create2Factory": "0x4e59b44847b379578588920ca78fbf26c0b4956c", "permit2": "0x000000000022d473030f116ddee9f6b43ac78ba3" } From 54d27e46b74dc0cfb171a635c3b45d2b3f0ed6d5 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:07:08 +0200 Subject: [PATCH 06/13] tweak: change events --- deployments/v3/addresses.json | 4 +- src/v3/RelayApprovalProxyV3.sol | 37 ++++++++++++++--- src/v3/RelayRouterV3.sol | 65 +++++++++++++++++++++++++----- src/v3/RelayRouterV3_NonTstore.sol | 65 +++++++++++++++++++++++++----- 4 files changed, 141 insertions(+), 30 deletions(-) diff --git a/deployments/v3/addresses.json b/deployments/v3/addresses.json index 3dd2ac7..c043c3c 100644 --- a/deployments/v3/addresses.json +++ b/deployments/v3/addresses.json @@ -5,8 +5,8 @@ "compilerVersion": "0.8.28", "evmVersion": "cancun", "explorerUrl": "https://basescan.org", - "relayRouter": "0xa3572394e3f29e271a5803a389045ca5105e1e0e", - "relayApprovalProxy": "0x214aedd162aff981e405102253db8657bf6c089d", + "relayRouter": "0x694352414e223d89148e0f16c7c86d0c3e4c343f", + "relayApprovalProxy": "0xc3a7b87dcf383e4b6eb693a427b0e13382386b65", "create2Factory": "0x4e59b44847b379578588920ca78fbf26c0b4956c", "permit2": "0x000000000022d473030f116ddee9f6b43ac78ba3" } diff --git a/src/v3/RelayApprovalProxyV3.sol b/src/v3/RelayApprovalProxyV3.sol index a3e6735..bac824d 100644 --- a/src/v3/RelayApprovalProxyV3.sol +++ b/src/v3/RelayApprovalProxyV3.sol @@ -28,9 +28,10 @@ contract RelayApprovalProxyV3 is Ownable { /// @notice Revert if the refundTo address is zero address error RefundToCannotBeZeroAddress(); - /// @notice Emitted when pulling funds from a user - event RouterPull( + /// @notice Emitted on any explicit movement of funds + event FundsMovement( address from, + address to, address currency, uint256 amount, bytes indexed metadata @@ -98,7 +99,13 @@ contract RelayApprovalProxyV3 is Ownable { for (uint256 i = 0; i < tokens.length; i++) { IERC20(tokens[i]).safeTransferFrom(msg.sender, ROUTER, amounts[i]); - emit RouterPull(msg.sender, tokens[i], amounts[i], metadata); + emit FundsMovement( + msg.sender, + ROUTER, + tokens[i], + amounts[i], + metadata + ); } // Call multicall on the router @@ -159,7 +166,13 @@ contract RelayApprovalProxyV3 is Ownable { permit.value ); - emit RouterPull(permit.owner, permit.token, permit.value, metadata); + emit FundsMovement( + permit.owner, + ROUTER, + permit.token, + permit.value, + metadata + ); } // Call multicall on the router @@ -266,7 +279,13 @@ contract RelayApprovalProxyV3 is Ownable { // Transfer the tokens to the router IERC20(tokens[i]).safeTransfer(ROUTER, permit.value); - emit RouterPull(permit.from, tokens[i], permit.value, metadata); + emit FundsMovement( + permit.from, + ROUTER, + tokens[i], + permit.value, + metadata + ); } // Call multicall on the router @@ -363,7 +382,13 @@ contract RelayApprovalProxyV3 is Ownable { requestedAmount: amount }); - emit RouterPull(user, permit.permitted[i].token, amount, metadata); + emit FundsMovement( + user, + ROUTER, + permit.permitted[i].token, + amount, + metadata + ); } // Use the SignatureTransferDetails and permit signature to transfer tokens to the router diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol index 909dbdc..1f7a04e 100644 --- a/src/v3/RelayRouterV3.sol +++ b/src/v3/RelayRouterV3.sol @@ -33,8 +33,17 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @notice Protocol event to be emitted when transferring native tokens event SolverNativeTransfer(address to, uint256 amount); - /// @notice Emitted when pulling funds from a user - event RouterPush(address to, address currency, uint256 amount, bytes data); + /// @notice Emitted on any explicit movement of funds + event FundsMovement( + address from, + address to, + address currency, + uint256 amount, + bytes indexed metadata + ); + + /// @notice Emitted when explicitly requested to get the current balance + event FundsCheckpoint(address token, uint256 amount); uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; @@ -58,7 +67,17 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { address refundTo, address nftRecipient, bytes calldata metadata - ) public payable virtual nonReentrant returns (Result[] memory returnData) { + ) public payable nonReentrant returns (Result[] memory returnData) { + if (msg.value > 0) { + emit FundsMovement( + msg.sender, + address(this), + address(0), + msg.value, + metadata + ); + } + // Set the NFT recipient if provided if (nftRecipient != address(0)) { _setRecipient(nftRecipient); @@ -74,6 +93,16 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { cleanupNative(0, refundTo, metadata); } + /// @notice Emit an event with the funds available in the contract + function checkpointFunds(address token) public { + if (token == address(0)) { + emit FundsCheckpoint(token, address(this).balance); + } else { + uint256 amount = IERC20(token).balanceOf(address(this)); + emit FundsCheckpoint(token, amount); + } + } + /// @notice Send leftover ERC20 tokens to recipients /// @dev Should be included in the multicall if the router is expecting to receive tokens /// Set amount to 0 to transfer the full balance @@ -86,7 +115,7 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { address[] calldata recipients, uint256[] calldata amounts, bytes calldata metadata - ) public virtual { + ) public { // Revert if array lengths do not match if ( tokens.length != amounts.length || @@ -108,7 +137,13 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { // Transfer the token to the recipient address token.safeTransfer(recipient, amount); - emit RouterPush(recipient, token, amount, metadata); + emit FundsMovement( + address(this), + recipient, + token, + amount, + metadata + ); } } } @@ -127,7 +162,7 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { bytes[] calldata datas, uint256[] calldata amounts, bytes calldata metadata - ) public virtual { + ) public { // Revert if array lengths do not match if ( tokens.length != amounts.length || @@ -157,7 +192,7 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { revert CallFailed(); } - emit RouterPush(to, token, amount, metadata); + emit FundsMovement(address(this), to, token, amount, metadata); } } } @@ -171,7 +206,7 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { uint256 amount, address recipient, bytes calldata metadata - ) public virtual { + ) public { // If recipient is address(0), set to msg.sender address recipientAddr = recipient == address(0) ? msg.sender @@ -182,7 +217,9 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { if (amountToTransfer > 0) { recipientAddr.safeTransferETH(amountToTransfer); emit SolverNativeTransfer(recipientAddr, amountToTransfer); - emit RouterPush( + + emit FundsMovement( + address(this), recipientAddr, address(0), amountToTransfer, @@ -202,7 +239,7 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { address to, bytes calldata data, bytes calldata metadata - ) public virtual { + ) public { uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; if (amountToTransfer > 0) { @@ -211,7 +248,13 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { revert CallFailed(); } - emit RouterPush(to, address(0), amountToTransfer, metadata); + emit FundsMovement( + address(this), + to, + address(0), + amountToTransfer, + metadata + ); } } diff --git a/src/v3/RelayRouterV3_NonTstore.sol b/src/v3/RelayRouterV3_NonTstore.sol index df21291..746e610 100644 --- a/src/v3/RelayRouterV3_NonTstore.sol +++ b/src/v3/RelayRouterV3_NonTstore.sol @@ -36,8 +36,17 @@ contract RelayRouterV3_NonTstore is /// @notice Protocol event to be emitted when transferring native tokens event SolverNativeTransfer(address to, uint256 amount); - /// @notice Emitted when pulling funds from a user - event RouterPush(address to, address currency, uint256 amount, bytes data); + /// @notice Emitted on any explicit movement of funds + event FundsMovement( + address from, + address to, + address currency, + uint256 amount, + bytes indexed metadata + ); + + /// @notice Emitted when explicitly requested to get the current balance + event FundsCheckpoint(address token, uint256 amount); uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; @@ -61,7 +70,17 @@ contract RelayRouterV3_NonTstore is address refundTo, address nftRecipient, bytes calldata metadata - ) public payable virtual nonReentrant returns (Result[] memory returnData) { + ) public payable nonReentrant returns (Result[] memory returnData) { + if (msg.value > 0) { + emit FundsMovement( + msg.sender, + address(this), + address(0), + msg.value, + metadata + ); + } + // Set the NFT recipient if provided if (nftRecipient != address(0)) { _setRecipient(nftRecipient); @@ -77,6 +96,16 @@ contract RelayRouterV3_NonTstore is cleanupNative(0, refundTo, metadata); } + /// @notice Emit an event with the funds available in the contract + function checkpointFunds(address token) public { + if (token == address(0)) { + emit FundsCheckpoint(token, address(this).balance); + } else { + uint256 amount = IERC20(token).balanceOf(address(this)); + emit FundsCheckpoint(token, amount); + } + } + /// @notice Send leftover ERC20 tokens to recipients /// @dev Should be included in the multicall if the router is expecting to receive tokens /// Set amount to 0 to transfer the full balance @@ -89,7 +118,7 @@ contract RelayRouterV3_NonTstore is address[] calldata recipients, uint256[] calldata amounts, bytes calldata metadata - ) public virtual { + ) public { // Revert if array lengths do not match if ( tokens.length != amounts.length || @@ -111,7 +140,13 @@ contract RelayRouterV3_NonTstore is // Transfer the token to the recipient address token.safeTransfer(recipient, amount); - emit RouterPush(recipient, token, amount, metadata); + emit FundsMovement( + address(this), + recipient, + token, + amount, + metadata + ); } } } @@ -130,7 +165,7 @@ contract RelayRouterV3_NonTstore is bytes[] calldata datas, uint256[] calldata amounts, bytes calldata metadata - ) public virtual { + ) public { // Revert if array lengths do not match if ( tokens.length != amounts.length || @@ -160,7 +195,7 @@ contract RelayRouterV3_NonTstore is revert CallFailed(); } - emit RouterPush(to, token, amount, metadata); + emit FundsMovement(address(this), to, token, amount, metadata); } } } @@ -174,7 +209,7 @@ contract RelayRouterV3_NonTstore is uint256 amount, address recipient, bytes calldata metadata - ) public virtual { + ) public { // If recipient is address(0), set to msg.sender address recipientAddr = recipient == address(0) ? msg.sender @@ -185,7 +220,9 @@ contract RelayRouterV3_NonTstore is if (amountToTransfer > 0) { recipientAddr.safeTransferETH(amountToTransfer); emit SolverNativeTransfer(recipientAddr, amountToTransfer); - emit RouterPush( + + emit FundsMovement( + address(this), recipientAddr, address(0), amountToTransfer, @@ -205,7 +242,7 @@ contract RelayRouterV3_NonTstore is address to, bytes calldata data, bytes calldata metadata - ) public virtual { + ) public { uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; if (amountToTransfer > 0) { @@ -214,7 +251,13 @@ contract RelayRouterV3_NonTstore is revert CallFailed(); } - emit RouterPush(to, address(0), amountToTransfer, metadata); + emit FundsMovement( + address(this), + to, + address(0), + amountToTransfer, + metadata + ); } } From 3cf2dce3ddba4abbaede047fd58a3c09be73d3d1 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:09:52 +0200 Subject: [PATCH 07/13] fix: do not index the metadata field --- src/v3/RelayApprovalProxyV3.sol | 2 +- src/v3/RelayRouterV3.sol | 14 ++++++++++---- src/v3/RelayRouterV3_NonTstore.sol | 12 +++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/v3/RelayApprovalProxyV3.sol b/src/v3/RelayApprovalProxyV3.sol index bac824d..4e727ea 100644 --- a/src/v3/RelayApprovalProxyV3.sol +++ b/src/v3/RelayApprovalProxyV3.sol @@ -34,7 +34,7 @@ contract RelayApprovalProxyV3 is Ownable { address to, address currency, uint256 amount, - bytes indexed metadata + bytes metadata ); /// @notice The address of the router contract diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol index 1f7a04e..b1e826b 100644 --- a/src/v3/RelayRouterV3.sol +++ b/src/v3/RelayRouterV3.sol @@ -43,7 +43,11 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { ); /// @notice Emitted when explicitly requested to get the current balance - event FundsCheckpoint(address token, uint256 amount); + event FundsCheckpoint( + address token, + uint256 amount, + bytes indexed metadata + ); uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; @@ -94,12 +98,14 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { } /// @notice Emit an event with the funds available in the contract - function checkpointFunds(address token) public { + /// @param token The token to checkpoint + /// @param metadata Additional data to associate the call to + function checkpointFunds(address token, bytes calldata metadata) public { if (token == address(0)) { - emit FundsCheckpoint(token, address(this).balance); + emit FundsCheckpoint(token, address(this).balance, metadata); } else { uint256 amount = IERC20(token).balanceOf(address(this)); - emit FundsCheckpoint(token, amount); + emit FundsCheckpoint(token, amount, metadata); } } diff --git a/src/v3/RelayRouterV3_NonTstore.sol b/src/v3/RelayRouterV3_NonTstore.sol index 746e610..47aca02 100644 --- a/src/v3/RelayRouterV3_NonTstore.sol +++ b/src/v3/RelayRouterV3_NonTstore.sol @@ -42,11 +42,11 @@ contract RelayRouterV3_NonTstore is address to, address currency, uint256 amount, - bytes indexed metadata + bytes metadata ); /// @notice Emitted when explicitly requested to get the current balance - event FundsCheckpoint(address token, uint256 amount); + event FundsCheckpoint(address token, uint256 amount, bytes metadata); uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; @@ -97,12 +97,14 @@ contract RelayRouterV3_NonTstore is } /// @notice Emit an event with the funds available in the contract - function checkpointFunds(address token) public { + /// @param token The token to checkpoint + /// @param metadata Additional data to associate the call to + function checkpointFunds(address token, bytes calldata metadata) public { if (token == address(0)) { - emit FundsCheckpoint(token, address(this).balance); + emit FundsCheckpoint(token, address(this).balance, metadata); } else { uint256 amount = IERC20(token).balanceOf(address(this)); - emit FundsCheckpoint(token, amount); + emit FundsCheckpoint(token, amount, metadata); } } From f4ad60cc7301df2c335049bc225c82ffa0480737 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:13:16 +0200 Subject: [PATCH 08/13] fix: do not index the metadata field --- src/v3/RelayRouterV3.sol | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol index b1e826b..c46a2ff 100644 --- a/src/v3/RelayRouterV3.sol +++ b/src/v3/RelayRouterV3.sol @@ -39,15 +39,11 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { address to, address currency, uint256 amount, - bytes indexed metadata + bytes metadata ); /// @notice Emitted when explicitly requested to get the current balance - event FundsCheckpoint( - address token, - uint256 amount, - bytes indexed metadata - ); + event FundsCheckpoint(address token, uint256 amount, bytes metadata); uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; From a1fa6e753bc56323de9a82245bf56812bc747f24 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:41:38 +0200 Subject: [PATCH 09/13] fix: update v3 swap contracts --- deployments/v3/addresses.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deployments/v3/addresses.json b/deployments/v3/addresses.json index c043c3c..703e767 100644 --- a/deployments/v3/addresses.json +++ b/deployments/v3/addresses.json @@ -5,8 +5,8 @@ "compilerVersion": "0.8.28", "evmVersion": "cancun", "explorerUrl": "https://basescan.org", - "relayRouter": "0x694352414e223d89148e0f16c7c86d0c3e4c343f", - "relayApprovalProxy": "0xc3a7b87dcf383e4b6eb693a427b0e13382386b65", + "relayRouter": "0xe9b82e5900251f51078a69d0e5ff31bd0e7512dc", + "relayApprovalProxy": "0xade8dd113fdd2280eede7842d4e2ca8e8cda88ba", "create2Factory": "0x4e59b44847b379578588920ca78fbf26c0b4956c", "permit2": "0x000000000022d473030f116ddee9f6b43ac78ba3" } From c70f6c18727ab9f814bf99a5b88218ba63daf173 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:55:52 +0200 Subject: [PATCH 10/13] fix: remove redundant logic --- src/v3/RelayRouterV3.sol | 33 ++---------------------------- src/v3/RelayRouterV3_NonTstore.sol | 33 ++---------------------------- 2 files changed, 4 insertions(+), 62 deletions(-) diff --git a/src/v3/RelayRouterV3.sol b/src/v3/RelayRouterV3.sol index c46a2ff..d078d14 100644 --- a/src/v3/RelayRouterV3.sol +++ b/src/v3/RelayRouterV3.sol @@ -42,9 +42,6 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { bytes metadata ); - /// @notice Emitted when explicitly requested to get the current balance - event FundsCheckpoint(address token, uint256 amount, bytes metadata); - uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; @@ -93,18 +90,6 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { cleanupNative(0, refundTo, metadata); } - /// @notice Emit an event with the funds available in the contract - /// @param token The token to checkpoint - /// @param metadata Additional data to associate the call to - function checkpointFunds(address token, bytes calldata metadata) public { - if (token == address(0)) { - emit FundsCheckpoint(token, address(this).balance, metadata); - } else { - uint256 amount = IERC20(token).balanceOf(address(this)); - emit FundsCheckpoint(token, amount, metadata); - } - } - /// @notice Send leftover ERC20 tokens to recipients /// @dev Should be included in the multicall if the router is expecting to receive tokens /// Set amount to 0 to transfer the full balance @@ -157,13 +142,11 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @param tos The target addresses for the calls /// @param datas The data for the calls /// @param amounts The amounts to send - /// @param metadata Additional data to associate the call to function cleanupErc20sViaCall( address[] calldata tokens, address[] calldata tos, bytes[] calldata datas, - uint256[] calldata amounts, - bytes calldata metadata + uint256[] calldata amounts ) public { // Revert if array lengths do not match if ( @@ -193,8 +176,6 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { if (!success) { revert CallFailed(); } - - emit FundsMovement(address(this), to, token, amount, metadata); } } } @@ -235,12 +216,10 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { /// @param amount The amount of native tokens to transfer /// @param to The target address of the call /// @param data The data for the call - /// @param metadata Additional data to associate the call to function cleanupNativeViaCall( uint256 amount, address to, - bytes calldata data, - bytes calldata metadata + bytes calldata data ) public { uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; @@ -249,14 +228,6 @@ contract RelayRouterV3 is Multicall3, ReentrancyGuardMsgSender { if (!success) { revert CallFailed(); } - - emit FundsMovement( - address(this), - to, - address(0), - amountToTransfer, - metadata - ); } } diff --git a/src/v3/RelayRouterV3_NonTstore.sol b/src/v3/RelayRouterV3_NonTstore.sol index 47aca02..96a95e3 100644 --- a/src/v3/RelayRouterV3_NonTstore.sol +++ b/src/v3/RelayRouterV3_NonTstore.sol @@ -45,9 +45,6 @@ contract RelayRouterV3_NonTstore is bytes metadata ); - /// @notice Emitted when explicitly requested to get the current balance - event FundsCheckpoint(address token, uint256 amount, bytes metadata); - uint256 RECIPIENT_STORAGE_SLOT = uint256(keccak256("RelayRouter.recipient")) - 1; @@ -96,18 +93,6 @@ contract RelayRouterV3_NonTstore is cleanupNative(0, refundTo, metadata); } - /// @notice Emit an event with the funds available in the contract - /// @param token The token to checkpoint - /// @param metadata Additional data to associate the call to - function checkpointFunds(address token, bytes calldata metadata) public { - if (token == address(0)) { - emit FundsCheckpoint(token, address(this).balance, metadata); - } else { - uint256 amount = IERC20(token).balanceOf(address(this)); - emit FundsCheckpoint(token, amount, metadata); - } - } - /// @notice Send leftover ERC20 tokens to recipients /// @dev Should be included in the multicall if the router is expecting to receive tokens /// Set amount to 0 to transfer the full balance @@ -160,13 +145,11 @@ contract RelayRouterV3_NonTstore is /// @param tos The target addresses for the calls /// @param datas The data for the calls /// @param amounts The amounts to send - /// @param metadata Additional data to associate the call to function cleanupErc20sViaCall( address[] calldata tokens, address[] calldata tos, bytes[] calldata datas, - uint256[] calldata amounts, - bytes calldata metadata + uint256[] calldata amounts ) public { // Revert if array lengths do not match if ( @@ -196,8 +179,6 @@ contract RelayRouterV3_NonTstore is if (!success) { revert CallFailed(); } - - emit FundsMovement(address(this), to, token, amount, metadata); } } } @@ -238,12 +219,10 @@ contract RelayRouterV3_NonTstore is /// @param amount The amount of native tokens to transfer /// @param to The target address of the call /// @param data The data for the call - /// @param metadata Additional data to associate the call to function cleanupNativeViaCall( uint256 amount, address to, - bytes calldata data, - bytes calldata metadata + bytes calldata data ) public { uint256 amountToTransfer = amount == 0 ? address(this).balance : amount; @@ -252,14 +231,6 @@ contract RelayRouterV3_NonTstore is if (!success) { revert CallFailed(); } - - emit FundsMovement( - address(this), - to, - address(0), - amountToTransfer, - metadata - ); } } From 7f1a458fd248b099397b06b73bd30131328210bc Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:45:14 +0200 Subject: [PATCH 11/13] chore: redeploy to base --- deployments/v3/addresses.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deployments/v3/addresses.json b/deployments/v3/addresses.json index 703e767..1a8d423 100644 --- a/deployments/v3/addresses.json +++ b/deployments/v3/addresses.json @@ -5,8 +5,8 @@ "compilerVersion": "0.8.28", "evmVersion": "cancun", "explorerUrl": "https://basescan.org", - "relayRouter": "0xe9b82e5900251f51078a69d0e5ff31bd0e7512dc", - "relayApprovalProxy": "0xade8dd113fdd2280eede7842d4e2ca8e8cda88ba", + "relayRouter": "0x82603d1713b410337d8a84dcad1f645fdf1463b4", + "relayApprovalProxy": "0x2ed919ca59bfd6ff2093f31c640c2f8bff031f14", "create2Factory": "0x4e59b44847b379578588920ca78fbf26c0b4956c", "permit2": "0x000000000022d473030f116ddee9f6b43ac78ba3" } From 4099f3ecf526b4a727d6fa358baebd9801979dec Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:56:18 +0100 Subject: [PATCH 12/13] fix: hash metadata --- src/v3/RelayApprovalProxyV3.sol | 2 +- test/v3/RouterAndApprovalV3Test.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/v3/RelayApprovalProxyV3.sol b/src/v3/RelayApprovalProxyV3.sol index 4e727ea..366be65 100644 --- a/src/v3/RelayApprovalProxyV3.sol +++ b/src/v3/RelayApprovalProxyV3.sol @@ -338,7 +338,7 @@ contract RelayApprovalProxyV3 is Ownable { msg.sender, refundTo, nftRecipient, - metadata, + keccak256(metadata), _getCallsHash(calls) ) ); diff --git a/test/v3/RouterAndApprovalV3Test.sol b/test/v3/RouterAndApprovalV3Test.sol index ff7a3e4..22db80a 100644 --- a/test/v3/RouterAndApprovalV3Test.sol +++ b/test/v3/RouterAndApprovalV3Test.sol @@ -1165,7 +1165,7 @@ contract RouterAndApprovalV3Test is BaseTest, EIP712 { relayer, refundTo, nftRecipient, - metadata, + keccak256(metadata), _getCallsHash(calls) ) ); From 186be522f2e9f59f30f485c752f8202520f2cc74 Mon Sep 17 00:00:00 2001 From: George Roman <30772943+georgeroman@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:00:00 +0100 Subject: [PATCH 13/13] chore: redeploy to base --- deployments/v3/addresses.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployments/v3/addresses.json b/deployments/v3/addresses.json index 1a8d423..7bd7ce6 100644 --- a/deployments/v3/addresses.json +++ b/deployments/v3/addresses.json @@ -6,7 +6,7 @@ "evmVersion": "cancun", "explorerUrl": "https://basescan.org", "relayRouter": "0x82603d1713b410337d8a84dcad1f645fdf1463b4", - "relayApprovalProxy": "0x2ed919ca59bfd6ff2093f31c640c2f8bff031f14", + "relayApprovalProxy": "0x8bb01bb4a1798e5adbb304d29e85e0952f63ce5c", "create2Factory": "0x4e59b44847b379578588920ca78fbf26c0b4956c", "permit2": "0x000000000022d473030f116ddee9f6b43ac78ba3" }