diff --git a/foundry/interfaces/IExecutor.sol b/foundry/interfaces/IExecutor.sol index 764623a..9c5a55c 100644 --- a/foundry/interfaces/IExecutor.sol +++ b/foundry/interfaces/IExecutor.sol @@ -22,6 +22,7 @@ interface IExecutor { */ function swap(uint256 givenAmount, bytes calldata data) external + payable returns (uint256 calculatedAmount); } diff --git a/foundry/lib/IWETH.sol b/foundry/lib/IWETH.sol new file mode 100644 index 0000000..3b4a359 --- /dev/null +++ b/foundry/lib/IWETH.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IWETH is IERC20 { + function deposit() external payable; + function withdraw(uint256) external; +} diff --git a/foundry/lib/LibSwap.sol b/foundry/lib/LibSwap.sol new file mode 100644 index 0000000..f581d8c --- /dev/null +++ b/foundry/lib/LibSwap.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +library LibSwap { + /// Returns the InToken index into an array of tokens + function tokenInIndex(bytes calldata swap) + internal + pure + returns (uint8 res) + { + res = uint8(swap[0]); + } + + /// The OutToken index into an array of tokens + function tokenOutIndex(bytes calldata swap) + internal + pure + returns (uint8 res) + { + res = uint8(swap[1]); + } + + /// The relative amount of token quantity routed into this swap + function splitPercentage(bytes calldata swap) + internal + pure + returns (uint24 res) + { + res = uint24(bytes3(swap[2:5])); + } + + /// The address of the executor contract + function executor(bytes calldata swap) + internal + pure + returns (address res) + { + res = address(uint160(bytes20(swap[5:25]))); + } + + /// The selector to be used of the executor contract + function executorSelector(bytes calldata swap) + internal + pure + returns (bytes4 res) + { + res = bytes4(swap[25:29]); + } + + /// Remaining bytes are interpreted as protocol data + function protocolData(bytes calldata swap) + internal + pure + returns (bytes calldata res) + { + res = swap[29:]; + } +} diff --git a/foundry/lib/openzeppelin-contracts b/foundry/lib/openzeppelin-contracts index acd4ff7..840c974 160000 --- a/foundry/lib/openzeppelin-contracts +++ b/foundry/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 +Subproject commit 840c974028316f3c8172c1b8e5ed67ad95e255ca diff --git a/foundry/lib/v2-core b/foundry/lib/v2-core index 4dd5906..ee547b1 160000 --- a/foundry/lib/v2-core +++ b/foundry/lib/v2-core @@ -1 +1 @@ -Subproject commit 4dd59067c76dea4a0e8e4bfdda41877a6b16dedc +Subproject commit ee547b17853e71ed4e0101ccfd52e70d5acded58 diff --git a/foundry/src/CallbackVerificationDispatcher.sol b/foundry/src/CallbackVerificationDispatcher.sol index 0e2a058..48d76d9 100644 --- a/foundry/src/CallbackVerificationDispatcher.sol +++ b/foundry/src/CallbackVerificationDispatcher.sol @@ -49,6 +49,7 @@ contract CallbackVerificationDispatcher { // slither-disable-next-line dead-code function _callVerifyCallback(bytes calldata data) internal + view returns ( uint256 amountOwed, uint256 amountReceived, diff --git a/foundry/src/ExecutionDispatcher.sol b/foundry/src/ExecutionDispatcher.sol index 090368e..e8b36e8 100644 --- a/foundry/src/ExecutionDispatcher.sol +++ b/foundry/src/ExecutionDispatcher.sol @@ -50,29 +50,21 @@ contract ExecutionDispatcher { * @dev Calls an executor, assumes swap.protocolData contains * protocol-specific data required by the executor. */ - // slither-disable-next-line dead-code - function _callExecutor(uint256 amount, bytes calldata data) - internal - returns (uint256 calculatedAmount) - { - address executor; - bytes4 decodedSelector; - bytes memory protocolData; - - (executor, decodedSelector, protocolData) = - _decodeExecutorAndSelector(data); - + // slither-disable-next-line delegatecall-loop + function _callExecutor( + address executor, + bytes4 selector, + uint256 amount, + bytes calldata data + ) internal returns (uint256 calculatedAmount) { if (!executors[executor]) { revert ExecutionDispatcher__UnapprovedExecutor(); } - bytes4 selector = decodedSelector == bytes4(0) - ? IExecutor.swap.selector - : decodedSelector; - - // slither-disable-next-line low-level-calls + selector = selector == bytes4(0) ? IExecutor.swap.selector : selector; + // slither-disable-next-line controlled-delegatecall,low-level-calls (bool success, bytes memory result) = executor.delegatecall( - abi.encodeWithSelector(selector, amount, protocolData) + abi.encodeWithSelector(selector, amount, data) ); if (!success) { @@ -87,16 +79,4 @@ contract ExecutionDispatcher { calculatedAmount = abi.decode(result, (uint256)); } - - // slither-disable-next-line dead-code - function _decodeExecutorAndSelector(bytes calldata data) - internal - pure - returns (address executor, bytes4 selector, bytes memory protocolData) - { - require(data.length >= 24, "Invalid data length"); - executor = address(uint160(bytes20(data[:20]))); - selector = bytes4(data[20:24]); - protocolData = data[24:]; - } } diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index f929e9b..bac2f47 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -1,26 +1,37 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.28; +import "../lib/IWETH.sol"; +import "../lib/bytes/LibPrefixLengthEncodedByteArray.sol"; +import "./CallbackVerificationDispatcher.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; import "@permit2/src/interfaces/IAllowanceTransfer.sol"; import "./ExecutionDispatcher.sol"; import "./CallbackVerificationDispatcher.sol"; -import "@openzeppelin/contracts/utils/Pausable.sol"; +import {LibSwap} from "../lib/LibSwap.sol"; error TychoRouter__WithdrawalFailed(); error TychoRouter__AddressZero(); +error TychoRouter__NegativeSlippage(uint256 amount, uint256 minAmount); +error TychoRouter__MessageValueMismatch(uint256 value, uint256 amount); contract TychoRouter is AccessControl, ExecutionDispatcher, CallbackVerificationDispatcher, - Pausable + Pausable, + ReentrancyGuard { IAllowanceTransfer public immutable permit2; + IWETH private immutable _weth; using SafeERC20 for IERC20; + using LibPrefixLengthEncodedByteArray for bytes; + using LibSwap for bytes; //keccak256("NAME_OF_ROLE") : save gas on deployment bytes32 public constant EXECUTOR_SETTER_ROLE = @@ -48,9 +59,10 @@ contract TychoRouter is ); event FeeSet(uint256 indexed oldFee, uint256 indexed newFee); - constructor(address _permit2) { + constructor(address _permit2, address weth) { permit2 = IAllowanceTransfer(_permit2); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _weth = IWETH(weth); } /** @@ -77,22 +89,116 @@ contract TychoRouter is } /** - * @dev Executes a swap graph supporting internal splits token amount - * splits, checking that the user gets more than minUserAmount of buyToken. + * @notice Executes a swap operation based on a predefined swap graph, supporting internal token amount splits. + * This function enables multi-step swaps, optional ETH wrapping/unwrapping, and validates the output amount + * against a user-specified minimum. + * + * @dev + * - If `wrapEth` is true, the contract wraps the provided native ETH into WETH and uses it as the sell token. + * - If `unwrapEth` is true, the contract converts the resulting WETH back into native ETH before sending it to the receiver. + * - For ERC20 tokens, Permit2 is used to approve and transfer tokens from the caller to the router. + * - Swaps are executed sequentially using the `_splitSwap` function. + * - A fee is deducted from the output token if `fee > 0`, and the remaining amount is sent to the receiver. + * - Reverts with `TychoRouter__NegativeSlippage` if the output amount is less than `minAmountOut` and `minAmountOut` is bigger than 0. + * + * @param amountIn The input token amount to be swapped. + * @param tokenIn The address of the input token. Use `address(0)` for native ETH when `wrapEth` is true. + * @param tokenOut The address of the output token. Use `address(0)` for native ETH when `unwrapEth` is true. + * @param minAmountOut The minimum acceptable amount of the output token. Reverts if this condition is not met. If it's 0, no check is performed. + * @param wrapEth If true, treats the input token as native ETH and wraps it into WETH. + * @param unwrapEth If true, unwraps the resulting WETH into native ETH and sends it to the receiver. + * @param nTokens The total number of tokens involved in the swap graph (used to initialize arrays for internal calculations). + * @param receiver The address to receive the output tokens. + * @param permitSingle A Permit2 structure containing token approval details for the input token. Ignored if `wrapEth` is true. + * @param signature A valid signature authorizing the Permit2 approval. Ignored if `wrapEth` is true. + * @param swaps Encoded swap graph data containing details of each swap. + * + * @return amountOut The total amount of the output token received by the receiver, after deducting fees if applicable. */ function swap( uint256 amountIn, address tokenIn, - uint256 minUserAmount, + address tokenOut, + uint256 minAmountOut, bool wrapEth, bool unwrapEth, uint256 nTokens, - bytes calldata swaps, + address receiver, IAllowanceTransfer.PermitSingle calldata permitSingle, - bytes calldata signature - ) external whenNotPaused returns (uint256 amountOut) { - amountOut = 0; - // TODO + bytes calldata signature, + bytes calldata swaps + ) external payable whenNotPaused nonReentrant returns (uint256 amountOut) { + require(receiver != address(0), "Invalid receiver address"); + + // For native ETH, assume funds already in our router. Else, transfer and handle approval. + if (wrapEth) { + _wrapETH(amountIn); + } else if (tokenIn != address(0)) { + permit2.permit(msg.sender, permitSingle, signature); + permit2.transferFrom( + msg.sender, + address(this), + uint160(amountIn), + permitSingle.details.token + ); + } + + amountOut = _swap(amountIn, nTokens, swaps); + + if (fee > 0) { + uint256 feeAmount = (amountOut * fee) / 10000; + amountOut -= feeAmount; + IERC20(tokenOut).safeTransfer(feeReceiver, feeAmount); + } + + if (minAmountOut > 0 && amountOut < minAmountOut) { + revert TychoRouter__NegativeSlippage(amountOut, minAmountOut); + } + + if (unwrapEth) { + _unwrapETH(amountOut); + // slither-disable-next-line arbitrary-send-eth + payable(receiver).transfer(amountOut); + } else { + IERC20(tokenOut).safeTransfer(receiver, amountOut); + } + } + + function _swap(uint256 amountIn, uint256 nTokens, bytes calldata swaps_) + internal + returns (uint256) + { + uint256 currentAmountIn; + uint256 currentAmountOut; + uint8 tokenInIndex; + uint8 tokenOutIndex; + uint24 split; + bytes calldata swapData; + + uint256[] memory remainingAmounts = new uint256[](nTokens); + uint256[] memory amounts = new uint256[](nTokens); + amounts[0] = amountIn; + remainingAmounts[0] = amountIn; + + while (swaps_.length > 0) { + (swapData, swaps_) = swaps_.next(); + tokenInIndex = swapData.tokenInIndex(); + tokenOutIndex = swapData.tokenOutIndex(); + split = swapData.splitPercentage(); + currentAmountIn = split > 0 + ? (amounts[tokenInIndex] * split) / 0xffffff + : remainingAmounts[tokenInIndex]; + currentAmountOut = _callExecutor( + swapData.executor(), + swapData.executorSelector(), + currentAmountIn, + swapData.protocolData() + ); + amounts[tokenOutIndex] += currentAmountOut; + remainingAmounts[tokenOutIndex] += currentAmountOut; + remainingAmounts[tokenInIndex] -= currentAmountIn; + } + return amounts[tokenOutIndex]; } /** @@ -209,6 +315,27 @@ contract TychoRouter is } } + /** + * @dev Wraps a defined amount of ETH. + * @param amount of native ETH to wrap. + */ + function _wrapETH(uint256 amount) internal { + if (msg.value > 0 && msg.value != amount) { + revert TychoRouter__MessageValueMismatch(msg.value, amount); + } + _weth.deposit{value: amount}(); + } + + /** + * @dev Unwraps a defined amount of WETH. + * @param amount of WETH to unwrap. + */ + function _unwrapETH(uint256 amount) internal { + uint256 unwrapAmount = + amount == 0 ? _weth.balanceOf(address(this)) : amount; + _weth.withdraw(unwrapAmount); + } + /** * @dev Allows this contract to receive native token */ diff --git a/foundry/src/executors/UniswapV2Executor.sol b/foundry/src/executors/UniswapV2Executor.sol index 01a507e..9f447f1 100644 --- a/foundry/src/executors/UniswapV2Executor.sol +++ b/foundry/src/executors/UniswapV2Executor.sol @@ -10,8 +10,10 @@ error UniswapV2Executor__InvalidDataLength(); contract UniswapV2Executor is IExecutor { using SafeERC20 for IERC20; + // slither-disable-next-line locked-ether function swap(uint256 givenAmount, bytes calldata data) external + payable returns (uint256 calculatedAmount) { address target; diff --git a/foundry/test/CallbackVerificationDispatcher.t.sol b/foundry/test/CallbackVerificationDispatcher.t.sol index 8480a96..e672642 100644 --- a/foundry/test/CallbackVerificationDispatcher.t.sol +++ b/foundry/test/CallbackVerificationDispatcher.t.sol @@ -9,6 +9,7 @@ contract CallbackVerificationDispatcherExposed is { function exposedCallVerifier(bytes calldata data) external + view returns ( uint256 amountOwed, uint256 amountReceived, @@ -176,7 +177,7 @@ contract CallbackVerificationDispatcherTest is Constants { dispatcherExposed.exposedCallVerifier(data); } - function testDecodeVerifierAndSelector() public { + function testDecodeVerifierAndSelector() public view { bytes memory data = hex"2C960bD1CFE09A26105ad3C351bEa0a3fAD0F8e876b20f8aA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; (address executor, bytes4 selector, bytes memory verifierData) = diff --git a/foundry/test/Constants.sol b/foundry/test/Constants.sol index 1410654..8776c7d 100644 --- a/foundry/test/Constants.sol +++ b/foundry/test/Constants.sol @@ -9,6 +9,10 @@ contract Constants is Test { address FUND_RESCUER = makeAddr("fundRescuer"); address FEE_SETTER = makeAddr("feeSetter"); address FEE_RECEIVER = makeAddr("feeReceiver"); + address EXECUTOR_SETTER = makeAddr("executorSetter"); + address ALICE = 0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2; + uint256 ALICE_PK = + 0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234; // Dummy contracts address DUMMY = makeAddr("dummy"); @@ -19,6 +23,14 @@ contract Constants is Test { address WETH_ADDR = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address DAI_ADDR = address(0x6B175474E89094C44Da98b954EedeAC495271d0F); address BAL_ADDR = address(0xba100000625a3754423978a60c9317c58a424e3D); + address USDC_ADDR = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + address WBTC_ADDR = address(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); + + // uniswap v2 + address WETH_DAI_POOL = 0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11; + address DAI_USDC_POOL = 0xAE461cA67B15dc8dc81CE7615e0320dA1A9aB8D5; + address WETH_WBTC_POOL = 0xBb2b8038a1640196FbE3e38816F3e67Cba72D940; + address USDC_WBTC_POOL = 0x004375Dff511095CC5A197A54140a24eFEF3A416; /** * @dev Deploys a dummy contract with non-empty bytecode diff --git a/foundry/test/ExecutionDispatcher.t.sol b/foundry/test/ExecutionDispatcher.t.sol index 4895d03..a88dc44 100644 --- a/foundry/test/ExecutionDispatcher.t.sol +++ b/foundry/test/ExecutionDispatcher.t.sol @@ -5,19 +5,13 @@ import "@src/ExecutionDispatcher.sol"; import "./TychoRouterTestSetup.sol"; contract ExecutionDispatcherExposed is ExecutionDispatcher { - function exposedCallExecutor(uint256 amount, bytes calldata data) - external - returns (uint256 calculatedAmount) - { - return _callExecutor(amount, data); - } - - function exposedDecodeExecutorAndSelector(bytes calldata data) - external - pure - returns (address executor, bytes4 selector, bytes memory protocolData) - { - return _decodeExecutorAndSelector(data); + function exposedCallExecutor( + address executor, + bytes4 selector, + uint256 amount, + bytes calldata data + ) external returns (uint256 calculatedAmount) { + return _callExecutor(executor, selector, amount, data); } function exposedSetExecutor(address target) external { @@ -88,10 +82,14 @@ contract ExecutionDispatcherTest is Constants { address(0xe592557AB9F4A75D992283fD6066312FF013ba3d) ); bytes memory data = - hex"e592557AB9F4A75D992283fD6066312FF013ba3dbd0625ab5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72fc8c39af7983bf329086de522229a7be5fc4e41cc51c72848c68a965f66fa7a88855f9f7784502a7f2606beffe61000613d6a25b5bfef4cd7652aa94777d4a46b39f2e206411280a12c9344b769ff1066c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000d02ab486cedc0000000000000000000000000000000000000000000000000000000000082ec8ad1b0000000000000000000000000000000000000000000000000000000066d7b65800000000000000000000000000000000000000000000000000000191ba9f843c125000064000640000d52de09955f0ffffffffffffff00225c389e595fe9000001fcc910754b349f821e4bb5d8444822a63920be943aba6f1b31ee14ef0fc6840b6d28d604e04a78834b668dba24a6c082ffb901e4fffa9600649e8d991af593c81c"; + hex"5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72fc8c39af7983bf329086de522229a7be5fc4e41cc51c72848c68a965f66fa7a88855f9f7784502a7f2606beffe61000613d6a25b5bfef4cd7652aa94777d4a46b39f2e206411280a12c9344b769ff1066c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000d02ab486cedc0000000000000000000000000000000000000000000000000000000000082ec8ad1b0000000000000000000000000000000000000000000000000000000066d7b65800000000000000000000000000000000000000000000000000000191ba9f843c125000064000640000d52de09955f0ffffffffffffff00225c389e595fe9000001fcc910754b349f821e4bb5d8444822a63920be943aba6f1b31ee14ef0fc6840b6d28d604e04a78834b668dba24a6c082ffb901e4fffa9600649e8d991af593c81c"; uint256 givenAmount = 15 ether; - uint256 amount = - dispatcherExposed.exposedCallExecutor(givenAmount, data); + uint256 amount = dispatcherExposed.exposedCallExecutor( + 0xe592557AB9F4A75D992283fD6066312FF013ba3d, + IExecutor.swap.selector, + givenAmount, + data + ); assert(amount == 35144641819); } @@ -111,10 +109,14 @@ contract ExecutionDispatcherTest is Constants { address(0xe592557AB9F4A75D992283fD6066312FF013ba3d) ); bytes memory data = - hex"e592557AB9F4A75D992283fD6066312FF013ba3d000000005615dEB798BB3E4dFa0139dFa1b3D433Cc23b72fc8c39af7983bf329086de522229a7be5fc4e41cc51c72848c68a965f66fa7a88855f9f7784502a7f2606beffe61000613d6a25b5bfef4cd7652aa94777d4a46b39f2e206411280a12c9344b769ff1066c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000d02ab486cedc0000000000000000000000000000000000000000000000000000000000082ec8ad1b0000000000000000000000000000000000000000000000000000000066d7b65800000000000000000000000000000000000000000000000000000191ba9f843c125000064000640000d52de09955f0ffffffffffffff00225c389e595fe9000001fcc910754b349f821e4bb5d8444822a63920be943aba6f1b31ee14ef0fc6840b6d28d604e04a78834b668dba24a6c082ffb901e4fffa9600649e8d991af593c81c"; + hex"5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72fc8c39af7983bf329086de522229a7be5fc4e41cc51c72848c68a965f66fa7a88855f9f7784502a7f2606beffe61000613d6a25b5bfef4cd7652aa94777d4a46b39f2e206411280a12c9344b769ff1066c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000d02ab486cedc0000000000000000000000000000000000000000000000000000000000082ec8ad1b0000000000000000000000000000000000000000000000000000000066d7b65800000000000000000000000000000000000000000000000000000191ba9f843c125000064000640000d52de09955f0ffffffffffffff00225c389e595fe9000001fcc910754b349f821e4bb5d8444822a63920be943aba6f1b31ee14ef0fc6840b6d28d604e04a78834b668dba24a6c082ffb901e4fffa9600649e8d991af593c81c"; uint256 givenAmount = 15 ether; - uint256 amount = - dispatcherExposed.exposedCallExecutor(givenAmount, data); + uint256 amount = dispatcherExposed.exposedCallExecutor( + 0xe592557AB9F4A75D992283fD6066312FF013ba3d, + bytes4(0), + givenAmount, + data + ); assert(amount == 35144641819); } @@ -124,26 +126,21 @@ contract ExecutionDispatcherTest is Constants { address(0xe592557AB9F4A75D992283fD6066312FF013ba3d) ); bytes memory data = - hex"e592557AB9F4A75D992283fD6066312FF013ba3dbd0625ab5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72fc8c39af7983bf329086de522229a7be5fc4e41cc51c72848c68a965f66fa7a88855f9f7784502a7f2606beffe61000613d6a25b5bfef4cd7652aa94777d4a46b39f2e206411280a12c9344b769ff1066c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000d02ab486cedc0000000000000000000000000000000000000000000000000000000000082ec8ad1b0000000000000000000000000000000000000000000000000000000066d7b65800000000000000000000000000000000000000000000000000000191ba9f843c125000064000640000d52de09955f0ffffffffffffff00225c389e595fe9000001fcc910754b349f821e4bb5d8444822a63920be943aba6f1b31ee14ef0fc6840b6d28d604e04a78834b668dba24a6c082ffb901e4fffa9600649e8d991af593"; + hex"5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72fc8c39af7983bf329086de522229a7be5fc4e41cc51c72848c68a965f66fa7a88855f9f7784502a7f2606beffe61000613d6a25b5bfef4cd7652aa94777d4a46b39f2e206411280a12c9344b769ff1066c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000d02ab486cedc0000000000000000000000000000000000000000000000000000000000082ec8ad1b0000000000000000000000000000000000000000000000000000000066d7b65800000000000000000000000000000000000000000000000000000191ba9f843c125000064000640000d52de09955f0ffffffffffffff00225c389e595fe9000001fcc910754b349f821e4bb5d8444822a63920be943aba6f1b31ee14ef0fc6840b6d28d604e04a78834b668dba24a6c082ffb901e4fffa9600649e8d991af593"; vm.expectRevert(); - dispatcherExposed.exposedCallExecutor(0, data); + dispatcherExposed.exposedCallExecutor( + 0xe592557AB9F4A75D992283fD6066312FF013ba3d, + IExecutor.swap.selector, + 0, + data + ); } function testCallExecutorUnapprovedExecutor() public { - bytes memory data = - hex"5d622C9053b8FFB1B3465495C8a42E603632bA70aabbccdd1111111111111111"; + bytes memory data = hex"aabbccdd1111111111111111"; vm.expectRevert(); - dispatcherExposed.exposedCallExecutor(0, data); - } - - function testDecodeExecutorAndSelector() public { - bytes memory data = - hex"6611e616d2db3244244a54c754a16dd3ac7ca7a2aabbccdd1111111111111111"; - (address executor, bytes4 selector, bytes memory protocolData) = - dispatcherExposed.exposedDecodeExecutorAndSelector(data); - assert(executor == address(0x6611e616d2db3244244A54c754A16dd3ac7cA7a2)); - assert(selector == bytes4(0xaabbccdd)); - // Direct bytes comparison not supported - must use keccak - assert(keccak256(protocolData) == keccak256(hex"1111111111111111")); + dispatcherExposed.exposedCallExecutor( + 0x5d622C9053b8FFB1B3465495C8a42E603632bA70, bytes4(0), 0, data + ); } } diff --git a/foundry/test/LibPrefixLengthEncodedByteArray.t.sol b/foundry/test/LibPrefixLengthEncodedByteArray.t.sol index c06eb47..d56b298 100644 --- a/foundry/test/LibPrefixLengthEncodedByteArray.t.sol +++ b/foundry/test/LibPrefixLengthEncodedByteArray.t.sol @@ -8,14 +8,14 @@ import {LibPrefixLengthEncodedByteArray} from contract LibPrefixLengthEncodedByteArrayTest is Test { using LibPrefixLengthEncodedByteArray for bytes; - function testNextEmpty() public { + function testNextEmpty() public view { bytes memory encoded = ""; (bytes memory elem, bytes memory remaining) = this.next(encoded); assertEq(elem.length, 0); assertEq(remaining.length, 0); } - function testNextSingleElement() public { + function testNextSingleElement() public view { // Create encoded data: length prefix (0003) followed by "ABC" bytes memory encoded = hex"0003414243"; (bytes memory elem, bytes memory remaining) = this.next(encoded); @@ -25,7 +25,7 @@ contract LibPrefixLengthEncodedByteArrayTest is Test { assertEq(remaining.length, 0); } - function testNextMultipleElements() public { + function testNextMultipleElements() public view { // Encoded data: [0003]ABC[0002]DE bytes memory encoded = hex"000341424300024445"; @@ -40,7 +40,7 @@ contract LibPrefixLengthEncodedByteArrayTest is Test { assertEq(remaining2.length, 0); } - function testSize() public { + function testSize() public view { bytes memory empty = ""; assertEq(this.size(empty), 0); @@ -51,19 +51,19 @@ contract LibPrefixLengthEncodedByteArrayTest is Test { assertEq(this.size(multiple), 3); } - function testFailInvalidLength() public { + function testFailInvalidLength() public view { // Length prefix larger than remaining data bytes memory invalid = hex"0004414243"; - (bytes memory elem, bytes memory remaining) = this.next(invalid); + this.next(invalid); } - function testFailIncompletePrefix() public { + function testFailIncompletePrefix() public view { // Only 1 byte instead of 2 bytes prefix bytes memory invalid = hex"01"; - (bytes memory elem, bytes memory remaining) = this.next(invalid); + this.next(invalid); } - function testLargeElement() public { + function testLargeElement() public view { // Test with a large but manageable size (1000 bytes) bytes memory large = new bytes(1002); // 2 bytes prefix + 1000 bytes data large[0] = bytes1(uint8(0x03)); // 03 @@ -79,7 +79,7 @@ contract LibPrefixLengthEncodedByteArrayTest is Test { assertEq(remaining.length, 0); } - function testSizeWithLargeElements() public { + function testSizeWithLargeElements() public view { // Two elements: 1000 bytes + 500 bytes bytes memory data = new bytes(1504); // 1000 + 2 + 500 + 2 diff --git a/foundry/test/LibSwap.t.sol b/foundry/test/LibSwap.t.sol new file mode 100644 index 0000000..eedc931 --- /dev/null +++ b/foundry/test/LibSwap.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../lib/LibSwap.sol"; + +contract LibSwapTest is Test { + using LibSwap for bytes; + + function testSwap() public view { + uint8 tokenInIndex = 1; + uint8 tokenOutIndex = 2; + uint24 split = 3; + address executor = 0x1234567890123456789012345678901234567890; + bytes4 selector = 0x12345678; + bytes memory protocolData = abi.encodePacked(uint256(456)); + + bytes memory swap = abi.encodePacked( + tokenInIndex, tokenOutIndex, split, executor, selector, protocolData + ); + this.assertSwap( + swap, tokenInIndex, tokenOutIndex, split, executor, selector + ); + } + + // This is necessary so that the compiler accepts bytes as a LibSwap.sol + function assertSwap( + bytes calldata swap, + uint8 tokenInIndex, + uint8 tokenOutIndex, + uint24 split, + address executor, + bytes4 selector + ) public pure { + assert(swap.tokenInIndex() == tokenInIndex); + assert(swap.tokenOutIndex() == tokenOutIndex); + assert(swap.splitPercentage() == split); + assert(swap.executor() == executor); + assert(swap.executorSelector() == selector); + } +} diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index 3d6f6d0..d736494 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -20,14 +20,14 @@ contract TychoRouterTest is TychoRouterTestSetup { ); function testSetExecutorValidRole() public { - vm.startPrank(executorSetter); + vm.startPrank(EXECUTOR_SETTER); tychoRouter.setExecutor(DUMMY); vm.stopPrank(); assert(tychoRouter.executors(DUMMY) == true); } function testRemoveExecutorValidRole() public { - vm.startPrank(executorSetter); + vm.startPrank(EXECUTOR_SETTER); tychoRouter.setExecutor(DUMMY); tychoRouter.removeExecutor(DUMMY); vm.stopPrank(); @@ -45,14 +45,14 @@ contract TychoRouterTest is TychoRouterTestSetup { } function testSetVerifierValidRole() public { - vm.startPrank(executorSetter); + vm.startPrank(EXECUTOR_SETTER); tychoRouter.setCallbackVerifier(DUMMY); vm.stopPrank(); assert(tychoRouter.callbackVerifiers(DUMMY) == true); } function testRemoveVerifierValidRole() public { - vm.startPrank(executorSetter); + vm.startPrank(EXECUTOR_SETTER); tychoRouter.setCallbackVerifier(DUMMY); tychoRouter.removeCallbackVerifier(DUMMY); vm.stopPrank(); @@ -72,19 +72,19 @@ contract TychoRouterTest is TychoRouterTestSetup { function testWithdrawNative() public { vm.startPrank(FUND_RESCUER); // Send 100 ether to tychoRouter - assertEq(address(tychoRouter).balance, 0); + assertEq(tychoRouterAddr.balance, 0); assertEq(FUND_RESCUER.balance, 0); - vm.deal(address(tychoRouter), 100 ether); + vm.deal(tychoRouterAddr, 100 ether); vm.expectEmit(); emit Withdrawal(address(0), 100 ether, FUND_RESCUER); tychoRouter.withdrawNative(FUND_RESCUER); - assertEq(address(tychoRouter).balance, 0); + assertEq(tychoRouterAddr.balance, 0); assertEq(FUND_RESCUER.balance, 100 ether); vm.stopPrank(); } function testWithdrawNativeFailures() public { - vm.deal(address(tychoRouter), 100 ether); + vm.deal(tychoRouterAddr, 100 ether); vm.startPrank(FUND_RESCUER); vm.expectRevert(TychoRouter__AddressZero.selector); tychoRouter.withdrawNative(address(0)); @@ -99,7 +99,7 @@ contract TychoRouterTest is TychoRouterTestSetup { function testWithdrawERC20Tokens() public { vm.startPrank(BOB); - mintTokens(100 ether, address(tychoRouter)); + mintTokens(100 ether, tychoRouterAddr); vm.stopPrank(); vm.startPrank(FUND_RESCUER); @@ -112,7 +112,7 @@ contract TychoRouterTest is TychoRouterTestSetup { // Check balances after withdrawing for (uint256 i = 0; i < tokens.length; i++) { // slither-disable-next-line calls-loop - assertEq(tokens[i].balanceOf(address(tychoRouter)), 0); + assertEq(tokens[i].balanceOf(tychoRouterAddr), 0); // slither-disable-next-line calls-loop assertEq(tokens[i].balanceOf(FUND_RESCUER), 100 ether); } @@ -120,7 +120,7 @@ contract TychoRouterTest is TychoRouterTestSetup { } function testWithdrawERC20TokensFailures() public { - mintTokens(100 ether, address(tychoRouter)); + mintTokens(100 ether, tychoRouterAddr); IERC20[] memory tokensArray = new IERC20[](3); tokensArray[0] = IERC20(address(tokens[0])); tokensArray[1] = IERC20(address(tokens[1])); @@ -189,4 +189,421 @@ contract TychoRouterTest is TychoRouterTestSetup { tychoRouter.pause(); vm.stopPrank(); } + + function testWrapETH() public { + uint256 amount = 1 ether; + vm.deal(BOB, amount); + + vm.startPrank(BOB); + tychoRouter.wrapETH{value: amount}(amount); + vm.stopPrank(); + + assertEq(tychoRouterAddr.balance, 0); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), amount); + } + + function testUnwrapETH() public { + uint256 amount = 1 ether; + deal(WETH_ADDR, tychoRouterAddr, amount); + + tychoRouter.unwrapETH(amount); + + assertEq(tychoRouterAddr.balance, amount); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSwapSimple() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // 1 WETH -> DAI + // (univ2) + uint256 amountIn = 1 ether; + deal(WETH_ADDR, tychoRouterAddr, amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + tychoRouter.ExposedSwap(amountIn, 2, pleEncode(swaps)); + + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(tychoRouterAddr); + assertEq(daiBalance, 2630432278145144658455); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSwapMultipleHops() public { + // Trade 1 WETH for USDC through DAI with 2 swaps on Uniswap V2 + // 1 WETH -> DAI -> USDC + // (univ2) (univ2) + uint256 amountIn = 1 ether; + deal(WETH_ADDR, tychoRouterAddr, amountIn); + + bytes[] memory swaps = new bytes[](2); + // WETH -> DAI + swaps[0] = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ) + ); + + // DAI -> USDC + swaps[1] = encodeSwap( + uint8(1), + uint8(2), + uint24(0), + address(usv2Executor), + bytes4(0), + encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) + ); + + tychoRouter.ExposedSwap(amountIn, 3, pleEncode(swaps)); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); + assertEq(usdcBalance, 2610580090); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSwapSplitHops() public { + // Trade 1 WETH for USDC through DAI and WBTC with 4 swaps on Uniswap V2 + // -> DAI -> + // 1 WETH USDC + // -> WBTC -> + // (univ2) (univ2) + uint256 amountIn = 1 ether; + deal(WETH_ADDR, tychoRouterAddr, amountIn); + + bytes[] memory swaps = new bytes[](4); + // WETH -> WBTC (60%) + swaps[0] = encodeSwap( + uint8(0), + uint8(1), + (0xffffff * 60) / 100, // 60% + address(usv2Executor), + bytes4(0), + encodeUniswapV2Swap( + WETH_ADDR, WETH_WBTC_POOL, tychoRouterAddr, false + ) + ); + // WBTC -> USDC + swaps[1] = encodeSwap( + uint8(1), + uint8(2), + uint24(0), + address(usv2Executor), + bytes4(0), + encodeUniswapV2Swap( + WBTC_ADDR, USDC_WBTC_POOL, tychoRouterAddr, true + ) + ); + // WETH -> DAI + swaps[2] = encodeSwap( + uint8(0), + uint8(3), + uint24(0), + address(usv2Executor), + bytes4(0), + encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ) + ); + + // DAI -> USDC + swaps[3] = encodeSwap( + uint8(3), + uint8(2), + uint24(0), + address(usv2Executor), + bytes4(0), + encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) + ); + + tychoRouter.ExposedSwap(amountIn, 4, pleEncode(swaps)); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); + assertEq(usdcBalance, 2581503157); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSwapChecked() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Does permit2 token approval and transfer + // Checks amount out at the end + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(WETH_ADDR, amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 minAmountOut = 2600 * 1e18; + uint256 amountOut = tychoRouter.swap( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 2630432278145144658455; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(IERC20(WETH_ADDR).balanceOf(ALICE), 0); + + vm.stopPrank(); + } + + function testSwapCheckedFailure() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Does permit2 token approval and transfer + // Checks amount out at the end and fails + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(WETH_ADDR, amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 minAmountOut = 3000 * 1e18; + vm.expectRevert( + abi.encodeWithSelector( + TychoRouter__NegativeSlippage.selector, + 2630432278145144658455, // actual amountOut + minAmountOut + ) + ); + tychoRouter.swap( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + vm.stopPrank(); + } + + function testSwapFee() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Does permit2 token approval and transfer + // Takes fee at the end + + vm.startPrank(FEE_SETTER); + tychoRouter.setFee(100); + tychoRouter.setFeeReceiver(FEE_RECEIVER); + vm.stopPrank(); + + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(WETH_ADDR, amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 amountOut = tychoRouter.swap( + amountIn, + WETH_ADDR, + DAI_ADDR, + 0, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 2604127955363693211871; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(IERC20(DAI_ADDR).balanceOf(FEE_RECEIVER), 26304322781451446584); + + vm.stopPrank(); + } + + function testSwapWrapETH() public { + // Trade 1 ETH (and wrap it) for DAI with 1 swap on Uniswap V2 + + uint256 amountIn = 1 ether; + deal(ALICE, amountIn); + + vm.startPrank(ALICE); + + IAllowanceTransfer.PermitSingle memory emptyPermitSingle = + IAllowanceTransfer.PermitSingle({ + details: IAllowanceTransfer.PermitDetails({ + token: address(0), + amount: 0, + expiration: 0, + nonce: 0 + }), + spender: address(0), + sigDeadline: 0 + }); + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 amountOut = tychoRouter.swap{value: amountIn}( + amountIn, + address(0), + DAI_ADDR, + 0, + true, + false, + 2, + ALICE, + emptyPermitSingle, + "", + pleEncode(swaps) + ); + uint256 expectedAmount = 2630432278145144658455; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(ALICE.balance, 0); + + vm.stopPrank(); + } + + function testSwapUnwrapETH() public { + // Trade 3k DAI for WETH with 1 swap on Uniswap V2 and unwrap it at the end + + uint256 amountIn = 3_000 * 10 ** 18; + deal(DAI_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(DAI_ADDR, amountIn); + + bytes memory protocolData = + encodeUniswapV2Swap(DAI_ADDR, WETH_DAI_POOL, tychoRouterAddr, true); + + bytes memory swap = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv2Executor), + bytes4(0), + protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 amountOut = tychoRouter.swap( + amountIn, + DAI_ADDR, + address(0), + 0, + false, + true, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 1132829934891544187; // 1.13 ETH + assertEq(amountOut, expectedAmount); + assertEq(ALICE.balance, expectedAmount); + + vm.stopPrank(); + } } diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index 8958313..91edbf1 100644 --- a/foundry/test/TychoRouterTestSetup.sol +++ b/foundry/test/TychoRouterTestSetup.sol @@ -1,28 +1,61 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import "@src/TychoRouter.sol"; +import "../src/executors/UniswapV2Executor.sol"; import "./Constants.sol"; import "./mock/MockERC20.sol"; +import "@src/TychoRouter.sol"; +import {WETH} from "../lib/permit2/lib/solmate/src/tokens/WETH.sol"; + +contract TychoRouterExposed is TychoRouter { + constructor(address _permit2, address weth) TychoRouter(_permit2, weth) {} + + function wrapETH(uint256 amount) external payable { + return _wrapETH(amount); + } + + function unwrapETH(uint256 amount) external { + return _unwrapETH(amount); + } + + function ExposedSwap( + uint256 amountIn, + uint256 nTokens, + bytes calldata swaps + ) external returns (uint256) { + return _swap(amountIn, nTokens, swaps); + } +} contract TychoRouterTestSetup is Test, Constants { - TychoRouter tychoRouter; - address executorSetter; + TychoRouterExposed tychoRouter; + address tychoRouterAddr; address permit2Address = address(0x000000000022D473030F116dDEE9F6B43aC78BA3); + UniswapV2Executor public usv2Executor; MockERC20[] tokens; function setUp() public { + uint256 forkBlock = 21000000; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + vm.startPrank(ADMIN); - tychoRouter = new TychoRouter(permit2Address); - tychoRouter.grantRole(keccak256("EXECUTOR_SETTER_ROLE"), BOB); + tychoRouter = new TychoRouterExposed(permit2Address, WETH_ADDR); + tychoRouterAddr = address(tychoRouter); tychoRouter.grantRole(keccak256("FUND_RESCUER_ROLE"), FUND_RESCUER); tychoRouter.grantRole(keccak256("FEE_SETTER_ROLE"), FEE_SETTER); tychoRouter.grantRole(keccak256("PAUSER_ROLE"), PAUSER); tychoRouter.grantRole(keccak256("UNPAUSER_ROLE"), UNPAUSER); - executorSetter = BOB; + tychoRouter.grantRole( + keccak256("EXECUTOR_SETTER_ROLE"), EXECUTOR_SETTER + ); deployDummyContract(); vm.stopPrank(); + usv2Executor = new UniswapV2Executor(); + vm.startPrank(EXECUTOR_SETTER); + tychoRouter.setExecutor(address(usv2Executor)); + vm.stopPrank(); + vm.startPrank(BOB); tokens.push(new MockERC20("Token A", "A")); tokens.push(new MockERC20("Token B", "B")); @@ -41,4 +74,117 @@ contract TychoRouterTestSetup is Test, Constants { tokens[i].mint(to, amount); } } + + /** + * @dev Handles the Permit2 approval process for Alice, allowing the TychoRouter contract + * to spend `amount_in` of `tokenIn` on her behalf. + * + * This function approves the Permit2 contract to transfer the specified token amount + * and constructs a `PermitSingle` struct for the approval. It also generates a valid + * EIP-712 signature for the approval using Alice's private key. + * + * @param tokenIn The address of the token being approved. + * @param amount_in The amount of tokens to approve for transfer. + * @return permitSingle The `PermitSingle` struct containing the approval details. + * @return signature The EIP-712 signature for the approval. + */ + function handlePermit2Approval(address tokenIn, uint256 amount_in) + internal + returns (IAllowanceTransfer.PermitSingle memory, bytes memory) + { + IERC20(tokenIn).approve(permit2Address, amount_in); + IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer + .PermitSingle({ + details: IAllowanceTransfer.PermitDetails({ + token: tokenIn, + amount: uint160(amount_in), + expiration: uint48(block.timestamp + 1 days), + nonce: 0 + }), + spender: tychoRouterAddr, + sigDeadline: block.timestamp + 1 days + }); + + bytes memory signature = signPermit2(permitSingle, ALICE_PK); + return (permitSingle, signature); + } + + /** + * @dev Signs a Permit2 `PermitSingle` struct with the given private key. + * @param permit The `PermitSingle` struct to sign. + * @param privateKey The private key of the signer. + * @return The signature as a `bytes` array. + */ + function signPermit2( + IAllowanceTransfer.PermitSingle memory permit, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes32 _PERMIT_DETAILS_TYPEHASH = keccak256( + "PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)" + ); + bytes32 _PERMIT_SINGLE_TYPEHASH = keccak256( + "PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)" + ); + bytes32 domainSeparator = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,uint256 chainId,address verifyingContract)" + ), + keccak256("Permit2"), + block.chainid, + permit2Address + ) + ); + bytes32 detailsHash = + keccak256(abi.encode(_PERMIT_DETAILS_TYPEHASH, permit.details)); + bytes32 permitHash = keccak256( + abi.encode( + _PERMIT_SINGLE_TYPEHASH, + detailsHash, + permit.spender, + permit.sigDeadline + ) + ); + + bytes32 digest = + keccak256(abi.encodePacked("\x19\x01", domainSeparator, permitHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + + return abi.encodePacked(r, s, v); + } + + function pleEncode(bytes[] memory data) + public + pure + returns (bytes memory encoded) + { + for (uint256 i = 0; i < data.length; i++) { + encoded = bytes.concat( + encoded, + abi.encodePacked(bytes2(uint16(data[i].length)), data[i]) + ); + } + } + + function encodeSwap( + uint8 tokenInIndex, + uint8 tokenOutIndex, + uint24 split, + address executor, + bytes4 selector, + bytes memory protocolData + ) internal pure returns (bytes memory) { + return abi.encodePacked( + tokenInIndex, tokenOutIndex, split, executor, selector, protocolData + ); + } + + function encodeUniswapV2Swap( + address tokenIn, + address target, + address receiver, + bool zero2one + ) internal pure returns (bytes memory) { + return abi.encodePacked(tokenIn, target, receiver, zero2one); + } } diff --git a/foundry/test/executors/UniswapV2Executor.t.sol b/foundry/test/executors/UniswapV2Executor.t.sol index 1598a6a..cdc0c89 100644 --- a/foundry/test/executors/UniswapV2Executor.t.sol +++ b/foundry/test/executors/UniswapV2Executor.t.sol @@ -34,7 +34,6 @@ contract UniswapV2ExecutorTest is UniswapV2ExecutorExposed, Test, Constants { UniswapV2ExecutorExposed uniswapV2Exposed; IERC20 WETH = IERC20(WETH_ADDR); IERC20 DAI = IERC20(DAI_ADDR); - address WETH_DAI_POOL = 0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11; function setUp() public { uint256 forkBlock = 17323404;