diff --git a/foundry/lib/LibSwap.sol b/foundry/lib/LibSwap.sol index f73a759..a892594 100644 --- a/foundry/lib/LibSwap.sol +++ b/foundry/lib/LibSwap.sol @@ -2,36 +2,42 @@ pragma solidity ^0.8.26; 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]); + /** + * @dev Returns arguments required to perform a single swap + */ + function decodeSingleSwap(bytes calldata swap) + internal + pure + returns (address executor, bytes calldata protocolData) + { + executor = address(uint160(bytes20(swap[0:20]))); + protocolData = swap[20:]; } - /// The OutToken index into an array of tokens - function tokenOutIndex( - bytes calldata swap - ) internal pure returns (uint8 res) { - res = uint8(swap[1]); + /** + * @dev Returns arguments required to perform a sequential swap + */ + function decodeSequentialSwap(bytes calldata swap) + internal + pure + returns (address executor, bytes calldata protocolData) + { + executor = address(uint160(bytes20(swap[0:20]))); + protocolData = swap[20:]; } - /// 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]))); - } - - /// Remaining bytes are interpreted as protocol data - function protocolData( - bytes calldata swap - ) internal pure returns (bytes calldata res) { - res = swap[25:]; + /** + * @dev Returns arguments required to perform a split swap + */ + function decodeSplitSwap(bytes calldata swap) + internal + pure + returns (uint8 tokenInIndex, uint8 tokenOutIndex, uint24 split, address executor, bytes calldata protocolData) + { + tokenInIndex = uint8(swap[0]); + tokenOutIndex = uint8(swap[1]); + split = uint24(bytes3(swap[2:5])); + executor = address(uint160(bytes20(swap[5:25]))); + protocolData = swap[25:]; } } diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index a899469..0bce8dc 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -222,6 +222,112 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { ); } + /** + * @notice Executes a swap operation based on a predefined swap graph with no split routes. + * This function enables multi-step swaps, optional ETH wrapping/unwrapping, and validates the output amount + * against a user-specified minimum. This function performs a transferFrom to retrieve tokens from the caller. + * + * @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. + * - Swaps are executed sequentially using the `_swap` 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 greater 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 + * @param tokenOut The address of the output token. Use `address(0)` for native ETH + * @param minAmountOut The minimum acceptable amount of the output token. Reverts if this condition is not met. This should always be set to avoid losing funds due to slippage. + * @param wrapEth If true, wraps the input token (native ETH) into WETH. + * @param unwrapEth If true, unwraps the resulting WETH into native ETH and sends it to the receiver. + * @param receiver The address to receive the output tokens. + * @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 sequentialSwap( + uint256 amountIn, + address tokenIn, + address tokenOut, + uint256 minAmountOut, + bool wrapEth, + bool unwrapEth, + address receiver, + bytes calldata swaps + ) public payable whenNotPaused nonReentrant returns (uint256 amountOut) { + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + return _sequentialSwapChecked( + amountIn, + tokenIn, + tokenOut, + minAmountOut, + wrapEth, + unwrapEth, + receiver, + swaps + ); + } + + /** + * @notice Executes a swap operation based on a predefined swap graph with no split routes. + * 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. + * - 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 greater 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 + * @param tokenOut The address of the output token. Use `address(0)` for native ETH + * @param minAmountOut The minimum acceptable amount of the output token. Reverts if this condition is not met. This should always be set to avoid losing funds due to slippage. + * @param wrapEth If true, wraps the input token (native ETH) into WETH. + * @param unwrapEth If true, unwraps the resulting WETH into native ETH and sends it to the receiver. + * @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 sequentialSwapPermit2( + uint256 amountIn, + address tokenIn, + address tokenOut, + uint256 minAmountOut, + bool wrapEth, + bool unwrapEth, + address receiver, + IAllowanceTransfer.PermitSingle calldata permitSingle, + bytes calldata signature, + bytes calldata swaps + ) external payable whenNotPaused nonReentrant returns (uint256 amountOut) { + // For native ETH, assume funds already in our router. Else, transfer and handle approval. + if (tokenIn != address(0)) { + permit2.permit(msg.sender, permitSingle, signature); + permit2.transferFrom( + msg.sender, + address(this), + uint160(amountIn), + permitSingle.details.token + ); + } + + return _sequentialSwapChecked( + amountIn, + tokenIn, + tokenOut, + minAmountOut, + wrapEth, + unwrapEth, + receiver, + swaps + ); + } + /** * @notice Executes a single swap operation. * This function enables optional ETH wrapping/unwrapping, and validates the output amount against a user-specified minimum. @@ -239,7 +345,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { * @param minAmountOut The minimum acceptable amount of the output token. Reverts if this condition is not met. This should always be set to avoid losing funds due to slippage. * @param wrapEth If true, wraps the input token (native ETH) 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 swapData Encoded swap details. * @@ -252,7 +357,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { uint256 minAmountOut, bool wrapEth, bool unwrapEth, - uint256 nTokens, address receiver, bytes calldata swapData ) public payable whenNotPaused nonReentrant returns (uint256 amountOut) { @@ -264,7 +368,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { minAmountOut, wrapEth, unwrapEth, - nTokens, receiver, swapData ); @@ -288,7 +391,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { * @param minAmountOut The minimum acceptable amount of the output token. Reverts if this condition is not met. This should always be set to avoid losing funds due to slippage. * @param wrapEth If true, wraps the input token (native ETH) 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. @@ -303,7 +405,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { uint256 minAmountOut, bool wrapEth, bool unwrapEth, - uint256 nTokens, address receiver, IAllowanceTransfer.PermitSingle calldata permitSingle, bytes calldata signature, @@ -327,7 +428,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { minAmountOut, wrapEth, unwrapEth, - nTokens, receiver, swapData ); @@ -417,7 +517,6 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { uint256 minAmountOut, bool wrapEth, bool unwrapEth, - uint256 nTokens, address receiver, bytes calldata swap_ ) internal returns (uint256 amountOut) { @@ -438,8 +537,10 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { ? address(this).balance : IERC20(tokenIn).balanceOf(address(this)); - amountOut = - _callExecutor(swap_.executor(), amountIn, swap_.protocolData()); + (address executor, bytes calldata protocolData) = + swap_.decodeSingleSwap(); + + amountOut = _callExecutor(executor, amountIn, protocolData); uint256 currentBalance = tokenIn == address(0) ? address(this).balance : IERC20(tokenIn).balanceOf(address(this)); @@ -472,6 +573,75 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { } } + /** + * @notice Internal implementation of the core swap logic shared between sequentialSwap() and sequentialSwapPermit2(). + * + * @notice This function centralizes the swap execution logic. + * @notice For detailed documentation on parameters and behavior, see the documentation for + * sequentialSwap() and sequentialSwapPermit2() functions. + * + */ + function _sequentialSwapChecked( + uint256 amountIn, + address tokenIn, + address tokenOut, + uint256 minAmountOut, + bool wrapEth, + bool unwrapEth, + address receiver, + bytes calldata swaps + ) internal returns (uint256 amountOut) { + if (receiver == address(0)) { + revert TychoRouter__AddressZero(); + } + if (minAmountOut == 0) { + revert TychoRouter__UndefinedMinAmountOut(); + } + + // Assume funds are already in the router. + if (wrapEth) { + _wrapETH(amountIn); + tokenIn = address(_weth); + } + + uint256 initialBalance = tokenIn == address(0) + ? address(this).balance + : IERC20(tokenIn).balanceOf(address(this)); + + amountOut = _sequentialSwap(amountIn, swaps); + + uint256 currentBalance = tokenIn == address(0) + ? address(this).balance + : IERC20(tokenIn).balanceOf(address(this)); + + uint256 amountConsumed = initialBalance - currentBalance; + + if (tokenIn != tokenOut && amountConsumed != amountIn) { + revert TychoRouter__AmountInDiffersFromConsumed( + amountIn, amountConsumed + ); + } + + if (fee > 0) { + uint256 feeAmount = (amountOut * fee) / 10000; + amountOut -= feeAmount; + IERC20(tokenOut).safeTransfer(feeReceiver, feeAmount); + } + + if (amountOut < minAmountOut) { + revert TychoRouter__NegativeSlippage(amountOut, minAmountOut); + } + + if (unwrapEth) { + _unwrapETH(amountOut); + } + if (tokenOut == address(0)) { + Address.sendValue(payable(receiver), amountOut); + } else { + IERC20(tokenOut).safeTransfer(receiver, amountOut); + } + } + /** * @dev Executes sequential swaps as defined by the provided swap graph. * @@ -509,6 +679,8 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { uint8 tokenInIndex = 0; uint8 tokenOutIndex = 0; uint24 split; + address executor; + bytes calldata protocolData; bytes calldata swapData; uint256[] memory remainingAmounts = new uint256[](nTokens); @@ -519,17 +691,16 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { while (swaps_.length > 0) { (swapData, swaps_) = swaps_.next(); - tokenInIndex = swapData.tokenInIndex(); - tokenOutIndex = swapData.tokenOutIndex(); - split = swapData.splitPercentage(); + + (tokenInIndex, tokenOutIndex, split, executor, protocolData) = + swapData.decodeSplitSwap(); currentAmountIn = split > 0 ? (amounts[tokenInIndex] * split) / 0xffffff : remainingAmounts[tokenInIndex]; - currentAmountOut = _callExecutor( - swapData.executor(), currentAmountIn, swapData.protocolData() - ); + currentAmountOut = + _callExecutor(executor, currentAmountIn, protocolData); // Checks if the output token is the same as the input token if (tokenOutIndex == 0) { cyclicSwapAmountOut += currentAmountOut; @@ -542,6 +713,31 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { return tokenOutIndex == 0 ? cyclicSwapAmountOut : amounts[tokenOutIndex]; } + /** + * @dev Executes sequential swaps as defined by the provided swap graph. + * + * @param amountIn The initial amount of the sell token to be swapped. + * @param swaps_ Encoded swap graph data containing the details of each swap operation. + * + * @return calculatedAmount The total amount of the buy token obtained after all swaps have been executed. + */ + function _sequentialSwap(uint256 amountIn, bytes calldata swaps_) + internal + returns (uint256 calculatedAmount) + { + bytes calldata swap; + calculatedAmount = amountIn; + while (swaps_.length > 0) { + (swap, swaps_) = swaps_.next(); + + (address executor, bytes calldata protocolData) = + swap.decodeSingleSwap(); + + calculatedAmount = + _callExecutor(executor, calculatedAmount, protocolData); + } + } + /** * @dev We use the fallback function to allow flexibility on callback. */ diff --git a/foundry/test/LibSwap.t.sol b/foundry/test/LibSwap.t.sol index 75c94f7..326df54 100644 --- a/foundry/test/LibSwap.t.sol +++ b/foundry/test/LibSwap.t.sol @@ -7,7 +7,45 @@ import "../lib/LibSwap.sol"; contract LibSwapTest is Test { using LibSwap for bytes; - function testSwap() public view { + function testSingleSwap() public view { + address executor = 0x1234567890123456789012345678901234567890; + bytes memory protocolData = abi.encodePacked(uint256(123)); + + bytes memory swap = abi.encodePacked(executor, protocolData); + this.assertSingleSwap(swap, executor, protocolData); + } + + function assertSingleSwap( + bytes calldata swap, + address executor, + bytes calldata protocolData + ) public pure { + (address decodedExecutor, bytes memory decodedProtocolData) = + swap.decodeSingleSwap(); + assertEq(decodedExecutor, executor); + assertEq(decodedProtocolData, protocolData); + } + + function testSequentialSwap() public view { + address executor = 0x1234567890123456789012345678901234567890; + bytes memory protocolData = abi.encodePacked(uint256(234)); + + bytes memory swap = abi.encodePacked(executor, protocolData); + this.assertSequentialSwap(swap, executor, protocolData); + } + + function assertSequentialSwap( + bytes calldata swap, + address executor, + bytes calldata protocolData + ) public pure { + (address decodedExecutor, bytes memory decodedProtocolData) = + swap.decodeSequentialSwap(); + assertEq(decodedExecutor, executor); + assertEq(decodedProtocolData, protocolData); + } + + function testSplitSwap() public view { uint8 tokenInIndex = 1; uint8 tokenOutIndex = 2; uint24 split = 3; @@ -17,20 +55,32 @@ contract LibSwapTest is Test { bytes memory swap = abi.encodePacked( tokenInIndex, tokenOutIndex, split, executor, protocolData ); - this.assertSwap(swap, tokenInIndex, tokenOutIndex, split, executor); + this.assertSplitSwap( + swap, tokenInIndex, tokenOutIndex, split, executor, protocolData + ); } - // This is necessary so that the compiler accepts bytes as a LibSwap.sol - function assertSwap( + // This is necessary so that the compiler accepts bytes as a LibSwap.sol for testing + // This is because this function takes calldata as input + function assertSplitSwap( bytes calldata swap, uint8 tokenInIndex, uint8 tokenOutIndex, uint24 split, - address executor + address executor, + bytes calldata protocolData ) public pure { - assert(swap.tokenInIndex() == tokenInIndex); - assert(swap.tokenOutIndex() == tokenOutIndex); - assert(swap.splitPercentage() == split); - assert(swap.executor() == executor); + ( + uint8 decodedTokenInIndex, + uint8 decodedTokenOutIndex, + uint24 decodedSplit, + address decodedExecutor, + bytes memory decodedProtocolData + ) = swap.decodeSplitSwap(); + assertEq(decodedTokenInIndex, tokenInIndex); + assertEq(decodedTokenOutIndex, tokenOutIndex); + assertEq(decodedSplit, split); + assertEq(decodedExecutor, executor); + assertEq(decodedProtocolData, protocolData); } } diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index 2314bb2..4a79e5c 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -206,921 +206,10 @@ contract TychoRouterTest is TychoRouterTestSetup { assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } - function testSplitSwapSimple() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V2 - // 1 WETH -> DAI - // (USV2) - uint256 amountIn = 1 ether; - deal(WETH_ADDR, tychoRouterAddr, amountIn); - - bytes memory protocolData = encodeUniswapV2Swap( - WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - - uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(tychoRouterAddr); - assertEq(daiBalance, 2659881924818443699787); - assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); - } - - function testSplitSwapSimplePermit2() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V2 using Permit2 - // 1 WETH -> DAI - // (USV2) - vm.startPrank(ALICE); - - uint256 amountIn = 1 ether; - deal(WETH_ADDR, ALICE, amountIn); - ( - 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.splitSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - 2659881924818443699786, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - - uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); - assertEq(daiBalance, 2659881924818443699787); - assertEq(IERC20(WETH_ADDR).balanceOf(ALICE), 0); - - vm.stopPrank(); - } - - function testSplitSwapMultipleHops() 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] = encodeSplitSwap( - uint8(0), - uint8(1), - uint24(0), - address(usv2Executor), - encodeUniswapV2Swap( - WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false - ) - ); - - // DAI -> USDC - swaps[1] = encodeSplitSwap( - uint8(1), - uint8(2), - uint24(0), - address(usv2Executor), - encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) - ); - - tychoRouter.exposedSplitSwap(amountIn, 3, pleEncode(swaps)); - - uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); - assertEq(usdcBalance, 2644659787); - assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); - } - - function testSplitSwapSplitHops() 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] = encodeSplitSwap( - uint8(0), - uint8(1), - (0xffffff * 60) / 100, // 60% - address(usv2Executor), - encodeUniswapV2Swap( - WETH_ADDR, WETH_WBTC_POOL, tychoRouterAddr, false - ) - ); - // WBTC -> USDC - swaps[1] = encodeSplitSwap( - uint8(1), - uint8(2), - uint24(0), - address(usv2Executor), - encodeUniswapV2Swap( - WBTC_ADDR, USDC_WBTC_POOL, tychoRouterAddr, true - ) - ); - // WETH -> DAI - swaps[2] = encodeSplitSwap( - uint8(0), - uint8(3), - uint24(0), - address(usv2Executor), - encodeUniswapV2Swap( - WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false - ) - ); - - // DAI -> USDC - swaps[3] = encodeSplitSwap( - uint8(3), - uint8(2), - uint24(0), - address(usv2Executor), - encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) - ); - - tychoRouter.exposedSplitSwap(amountIn, 4, pleEncode(swaps)); - - uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); - assertEq(usdcBalance, 2615491639); - assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); - } - - function testSingleSwapChecked() 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - - uint256 minAmountOut = 2600 * 1e18; - uint256 amountOut = tychoRouter.singleSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - minAmountOut, - false, - false, - 2, - ALICE, - permitSingle, - signature, - swap - ); - - uint256 expectedAmount = 2659881924818443699787; - assertEq(amountOut, expectedAmount); - uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); - assertEq(daiBalance, expectedAmount); - assertEq(IERC20(WETH_ADDR).balanceOf(ALICE), 0); - - vm.stopPrank(); - } - - function testSplitSwapCheckedUndefinedMinAmount() public { - // Min amount should always be non-zero. If zero, swap attempt should revert. - - 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - uint256 minAmountOut = 0; - - vm.expectRevert(TychoRouter__UndefinedMinAmountOut.selector); - tychoRouter.splitSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - minAmountOut, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - vm.stopPrank(); - } - - function testSingleSwapCheckedNoPermit2() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V2 - // Checks amount out at the end - uint256 amountIn = 1 ether; - - deal(WETH_ADDR, ALICE, amountIn); - vm.startPrank(ALICE); - // Approve the tokenIn to be transferred to the router - IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); - - bytes memory protocolData = encodeUniswapV2Swap( - WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - - uint256 minAmountOut = 2600 * 1e18; - uint256 amountOut = tychoRouter.singleSwap( - amountIn, - WETH_ADDR, - DAI_ADDR, - minAmountOut, - false, - false, - 2, - ALICE, - swap - ); - - uint256 expectedAmount = 2659881924818443699787; - assertEq(amountOut, expectedAmount); - uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); - assertEq(daiBalance, expectedAmount); - assertEq(IERC20(WETH_ADDR).balanceOf(ALICE), 0); - - vm.stopPrank(); - } - - function testSplitSwapCheckedLessApprovalFailure() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V2 - // Fails while transferring the tokenIn to the router due to insufficient approval - uint256 amountIn = 1 ether; - - deal(WETH_ADDR, ALICE, amountIn); - vm.startPrank(ALICE); - // Approve less than the amountIn - IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn - 1); - - bytes memory protocolData = encodeUniswapV2Swap( - WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - uint256 minAmountOut = 2600 * 1e18; - vm.expectRevert(); - tychoRouter.splitSwap( - amountIn, - WETH_ADDR, - DAI_ADDR, - minAmountOut, - false, - false, - 2, - ALICE, - pleEncode(swaps) - ); - - vm.stopPrank(); - } - - function testSplitSwapCheckedNegativeSlippageFailure() 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - uint256 minAmountOut = 3000 * 1e18; - vm.expectRevert( - abi.encodeWithSelector( - TychoRouter__NegativeSlippage.selector, - 2659881924818443699787, // actual amountOut - minAmountOut - ) - ); - tychoRouter.splitSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - minAmountOut, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - vm.stopPrank(); - } - - function testSplitSwapFee() 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - uint256 amountOut = tychoRouter.splitSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - 2633283105570259262780, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - - uint256 expectedAmount = 2633283105570259262790; - assertEq(amountOut, expectedAmount); - uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); - assertEq(daiBalance, expectedAmount); - assertEq(IERC20(DAI_ADDR).balanceOf(FEE_RECEIVER), 26598819248184436997); - - vm.stopPrank(); - } - - function testSplitSwapWrapETH() 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - uint256 amountOut = tychoRouter.splitSwapPermit2{value: amountIn}( - amountIn, - address(0), - DAI_ADDR, - 2659881924818443699780, - true, - false, - 2, - ALICE, - emptyPermitSingle, - "", - pleEncode(swaps) - ); - uint256 expectedAmount = 2659881924818443699787; - assertEq(amountOut, expectedAmount); - uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); - assertEq(daiBalance, expectedAmount); - assertEq(ALICE.balance, 0); - - vm.stopPrank(); - } - - function testSplitSwapUnwrapETH() 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 = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - uint256 amountOut = tychoRouter.splitSwapPermit2( - amountIn, - DAI_ADDR, - address(0), - 1120007305574805920, - false, - true, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - - uint256 expectedAmount = 1120007305574805922; // 1.12 ETH - assertEq(amountOut, expectedAmount); - assertEq(ALICE.balance, expectedAmount); - - vm.stopPrank(); - } - - function testSplitSwapSingleUSV3() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V3 - // 1 WETH -> DAI - // (USV3) - uint256 amountIn = 10 ** 18; - deal(WETH_ADDR, tychoRouterAddr, amountIn); - - uint256 expAmountOut = 1205_128428842122129186; //Swap 1 WETH for 1205.12 DAI - bool zeroForOne = false; - bytes memory protocolData = encodeUniswapV3Swap( - WETH_ADDR, DAI_ADDR, tychoRouterAddr, DAI_WETH_USV3, zeroForOne - ); - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv3Executor), protocolData - ); - - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - - uint256 finalBalance = IERC20(DAI_ADDR).balanceOf(tychoRouterAddr); - assertGe(finalBalance, expAmountOut); - } - - function testSplitSwapSingleUSV3Permit2() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V3 using Permit2 - // 1 WETH -> DAI - // (USV3) - vm.startPrank(ALICE); - uint256 amountIn = 10 ** 18; - deal(WETH_ADDR, ALICE, amountIn); - ( - IAllowanceTransfer.PermitSingle memory permitSingle, - bytes memory signature - ) = handlePermit2Approval(WETH_ADDR, amountIn); - - uint256 expAmountOut = 1205_128428842122129186; //Swap 1 WETH for 1205.12 DAI - bool zeroForOne = false; - bytes memory protocolData = encodeUniswapV3Swap( - WETH_ADDR, DAI_ADDR, tychoRouterAddr, DAI_WETH_USV3, zeroForOne - ); - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv3Executor), protocolData - ); - - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.splitSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - expAmountOut - 1, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - - uint256 finalBalance = IERC20(DAI_ADDR).balanceOf(ALICE); - assertGe(finalBalance, expAmountOut); - - vm.stopPrank(); - } - function testEmptySwapsRevert() public { uint256 amountIn = 10 ** 18; bytes memory swaps = ""; vm.expectRevert(TychoRouter__EmptySwaps.selector); tychoRouter.exposedSplitSwap(amountIn, 2, swaps); } - - function testSplitSwapAmountInNotFullySpent() public { - // Trade 1 WETH for DAI with 1 swap on Uniswap V2 - // Has invalid data as input! There is only one swap with 60% of the input amount - 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 = encodeSplitSwap( - uint8(0), - uint8(1), - (0xffffff * 60) / 100, // 60% - address(usv2Executor), - protocolData - ); - - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - vm.expectRevert( - abi.encodeWithSelector( - TychoRouter__AmountInDiffersFromConsumed.selector, - 1000000000000000000, - 600000000000000000 - ) - ); - - tychoRouter.splitSwapPermit2( - amountIn, - WETH_ADDR, - DAI_ADDR, - 1, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - - vm.stopPrank(); - } - - function testSplitSwapSingleUSV4Callback() public { - uint256 amountIn = 100 ether; - deal(USDE_ADDR, tychoRouterAddr, amountIn); - - UniswapV4Executor.UniswapV4Pool[] memory pools = - new UniswapV4Executor.UniswapV4Pool[](1); - pools[0] = UniswapV4Executor.UniswapV4Pool({ - intermediaryToken: USDT_ADDR, - fee: uint24(100), - tickSpacing: int24(1) - }); - - bytes memory protocolData = UniswapV4Utils.encodeExactInput( - USDE_ADDR, USDT_ADDR, true, address(usv4Executor), pools - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv4Executor), protocolData - ); - - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - - assertEq(IERC20(USDT_ADDR).balanceOf(tychoRouterAddr), 99943852); - } - - function testSplitSwapSingleUSV4CallbackPermit2() public { - vm.startPrank(ALICE); - uint256 amountIn = 100 ether; - deal(USDE_ADDR, ALICE, amountIn); - ( - IAllowanceTransfer.PermitSingle memory permitSingle, - bytes memory signature - ) = handlePermit2Approval(USDE_ADDR, amountIn); - - UniswapV4Executor.UniswapV4Pool[] memory pools = - new UniswapV4Executor.UniswapV4Pool[](1); - pools[0] = UniswapV4Executor.UniswapV4Pool({ - intermediaryToken: USDT_ADDR, - fee: uint24(100), - tickSpacing: int24(1) - }); - - bytes memory protocolData = UniswapV4Utils.encodeExactInput( - USDE_ADDR, USDT_ADDR, true, address(usv4Executor), pools - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv4Executor), protocolData - ); - - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.splitSwapPermit2( - amountIn, - USDE_ADDR, - USDT_ADDR, - 99943850, - false, - false, - 2, - ALICE, - permitSingle, - signature, - pleEncode(swaps) - ); - - assertEq(IERC20(USDT_ADDR).balanceOf(ALICE), 99943852); - vm.stopPrank(); - } - - function testSplitSwapMultipleUSV4Callback() public { - // This test has two uniswap v4 hops that will be executed inside of the V4 pool manager - // USDE -> USDT -> WBTC - uint256 amountIn = 100 ether; - deal(USDE_ADDR, tychoRouterAddr, amountIn); - - UniswapV4Executor.UniswapV4Pool[] memory pools = - new UniswapV4Executor.UniswapV4Pool[](2); - pools[0] = UniswapV4Executor.UniswapV4Pool({ - intermediaryToken: USDT_ADDR, - fee: uint24(100), - tickSpacing: int24(1) - }); - pools[1] = UniswapV4Executor.UniswapV4Pool({ - intermediaryToken: WBTC_ADDR, - fee: uint24(3000), - tickSpacing: int24(60) - }); - - bytes memory protocolData = UniswapV4Utils.encodeExactInput( - USDE_ADDR, WBTC_ADDR, true, address(usv4Executor), pools - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv4Executor), protocolData - ); - - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - - assertEq(IERC20(WBTC_ADDR).balanceOf(tychoRouterAddr), 102718); - } - - function testCyclicSequentialSwap() public { - // This test has start and end tokens that are the same - // The flow is: - // USDC -> WETH -> USDC using two pools - uint256 amountIn = 100 * 10 ** 6; - deal(USDC_ADDR, tychoRouterAddr, amountIn); - - bytes memory usdcWethV3Pool1ZeroOneData = encodeUniswapV3Swap( - USDC_ADDR, WETH_ADDR, tychoRouterAddr, USDC_WETH_USV3, true - ); - - bytes memory usdcWethV3Pool2OneZeroData = encodeUniswapV3Swap( - WETH_ADDR, USDC_ADDR, tychoRouterAddr, USDC_WETH_USV3_2, false - ); - - bytes[] memory swaps = new bytes[](2); - // USDC -> WETH - swaps[0] = encodeSplitSwap( - uint8(0), - uint8(1), - uint24(0), - address(usv3Executor), - usdcWethV3Pool1ZeroOneData - ); - // WETH -> USDC - swaps[1] = encodeSplitSwap( - uint8(1), - uint8(0), - uint24(0), - address(usv3Executor), - usdcWethV3Pool2OneZeroData - ); - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - assertEq(IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), 99889294); - } - - function testSplitInputCyclicSwap() public { - // This test has start and end tokens that are the same - // The flow is: - // ┌─ (USV3, 60% split) ──> WETH ─┐ - // │ │ - // USDC ──────┤ ├──(USV2)──> USDC - // │ │ - // └─ (USV3, 40% split) ──> WETH ─┘ - uint256 amountIn = 100 * 10 ** 6; - deal(USDC_ADDR, tychoRouterAddr, amountIn); - - bytes memory usdcWethV3Pool1ZeroOneData = encodeUniswapV3Swap( - USDC_ADDR, WETH_ADDR, tychoRouterAddr, USDC_WETH_USV3, true - ); - - bytes memory usdcWethV3Pool2ZeroOneData = encodeUniswapV3Swap( - USDC_ADDR, WETH_ADDR, tychoRouterAddr, USDC_WETH_USV3_2, true - ); - - bytes memory wethUsdcV2OneZeroData = encodeUniswapV2Swap( - WETH_ADDR, USDC_WETH_USV2, tychoRouterAddr, false - ); - - bytes[] memory swaps = new bytes[](3); - // USDC -> WETH (60% split) - swaps[0] = encodeSplitSwap( - uint8(0), - uint8(1), - (0xffffff * 60) / 100, // 60% - address(usv3Executor), - usdcWethV3Pool1ZeroOneData - ); - // USDC -> WETH (40% remainder) - swaps[1] = encodeSplitSwap( - uint8(0), - uint8(1), - uint24(0), - address(usv3Executor), - usdcWethV3Pool2ZeroOneData - ); - // WETH -> USDC - swaps[2] = encodeSplitSwap( - uint8(1), - uint8(0), - uint24(0), - address(usv2Executor), - wethUsdcV2OneZeroData - ); - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - assertEq(IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), 99574171); - } - - function testSplitOutputCyclicSwap() public { - // This test has start and end tokens that are the same - // The flow is: - // ┌─── (USV3, 60% split) ───┐ - // │ │ - // USDC ──(USV2) ── WETH──| ├─> USDC - // │ │ - // └─── (USV3, 40% split) ───┘ - - uint256 amountIn = 100 * 10 ** 6; - deal(USDC_ADDR, tychoRouterAddr, amountIn); - - bytes memory usdcWethV2Data = encodeUniswapV2Swap( - USDC_ADDR, USDC_WETH_USV2, tychoRouterAddr, true - ); - - bytes memory usdcWethV3Pool1OneZeroData = encodeUniswapV3Swap( - WETH_ADDR, USDC_ADDR, tychoRouterAddr, USDC_WETH_USV3, false - ); - - bytes memory usdcWethV3Pool2OneZeroData = encodeUniswapV3Swap( - WETH_ADDR, USDC_ADDR, tychoRouterAddr, USDC_WETH_USV3_2, false - ); - - bytes[] memory swaps = new bytes[](3); - // USDC -> WETH - swaps[0] = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), usdcWethV2Data - ); - // WETH -> USDC - swaps[1] = encodeSplitSwap( - uint8(1), - uint8(0), - (0xffffff * 60) / 100, - address(usv3Executor), - usdcWethV3Pool1OneZeroData - ); - - // WETH -> USDC - swaps[2] = encodeSplitSwap( - uint8(1), - uint8(0), - uint24(0), - address(usv3Executor), - usdcWethV3Pool2OneZeroData - ); - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - assertEq(IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), 99525908); - } - - // Base Network Tests - // Make sure to set the RPC_URL to base network - function testSplitSwapSingleBase() public { - vm.skip(true); - vm.rollFork(26857267); - uint256 amountIn = 10 * 10 ** 6; - deal(BASE_USDC, tychoRouterAddr, amountIn); - - bytes memory protocolData = encodeUniswapV2Swap( - BASE_USDC, USDC_MAG7_POOL, tychoRouterAddr, true - ); - - bytes memory swap = encodeSplitSwap( - uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData - ); - bytes[] memory swaps = new bytes[](1); - swaps[0] = swap; - - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); - assertGt(IERC20(BASE_MAG7).balanceOf(tychoRouterAddr), 1379830606); - } } diff --git a/foundry/test/TychoRouterSequentialSwap.t.sol b/foundry/test/TychoRouterSequentialSwap.t.sol new file mode 100644 index 0000000..75b179f --- /dev/null +++ b/foundry/test/TychoRouterSequentialSwap.t.sol @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@src/executors/UniswapV4Executor.sol"; +import {TychoRouter} from "@src/TychoRouter.sol"; +import "./TychoRouterTestSetup.sol"; +import "./executors/UniswapV4Utils.sol"; +import {SafeCallback} from "@uniswap/v4-periphery/src/base/SafeCallback.sol"; + +contract TychoRouterSequentialSwapTest is TychoRouterTestSetup { + bytes32 public constant FEE_SETTER_ROLE = + 0xe6ad9a47fbda1dc18de1eb5eeb7d935e5e81b4748f3cfc61e233e64f88182060; + + function _getSequentialSwaps() internal view returns (bytes[] memory) { + // Trade 1 WETH for USDC through DAI with 2 swaps on Uniswap V2 + // 1 WETH -> DAI -> USDC + // (univ2) (univ2) + bytes[] memory swaps = new bytes[](2); + // WETH -> DAI + swaps[0] = encodeSequentialSwap( + address(usv2Executor), + encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ) + ); + + // DAI -> USDC + swaps[1] = encodeSequentialSwap( + address(usv2Executor), + encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) + ); + return swaps; + } + + function testSequentialSwapInternalMethod() public { + // Trade 1 WETH for USDC through DAI - see _getSequentialSwaps for more info + uint256 amountIn = 1 ether; + deal(WETH_ADDR, tychoRouterAddr, amountIn); + + bytes[] memory swaps = _getSequentialSwaps(); + tychoRouter.exposedSequentialSwap(amountIn, pleEncode(swaps)); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); + assertEq(usdcBalance, 2644659787); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSequentialSwapPermit2() public { + // Trade 1 WETH for USDC through DAI - see _getSequentialSwaps for more info + 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 swaps = _getSequentialSwaps(); + tychoRouter.sequentialSwapPermit2( + amountIn, + WETH_ADDR, + USDC_ADDR, + 1000_000000, // min amount + false, + false, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, 2644659787); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSequentialSwapNoPermit2() public { + // Trade 1 WETH for USDC through DAI - see _getSequentialSwaps for more info + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(tychoRouterAddr, amountIn); + + bytes[] memory swaps = _getSequentialSwaps(); + tychoRouter.sequentialSwap( + amountIn, + WETH_ADDR, + USDC_ADDR, + 1000_000000, // min amount + false, + false, + ALICE, + pleEncode(swaps) + ); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, 2644659787); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSequentialSwapUndefinedMinAmount() public { + // Trade 1 WETH for USDC through DAI - see _getSequentialSwaps for more info + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(tychoRouterAddr, amountIn); + + bytes[] memory swaps = _getSequentialSwaps(); + vm.expectRevert(TychoRouter__UndefinedMinAmountOut.selector); + tychoRouter.sequentialSwap( + amountIn, + WETH_ADDR, + USDC_ADDR, + 0, // min amount + false, + false, + ALICE, + pleEncode(swaps) + ); + } + + function testSequentialSwapInsufficientApproval() public { + // Trade 1 WETH for USDC through DAI - see _getSequentialSwaps for more info + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(tychoRouterAddr, amountIn - 1); + + bytes[] memory swaps = _getSequentialSwaps(); + vm.expectRevert(); + tychoRouter.sequentialSwap( + amountIn, + WETH_ADDR, + USDC_ADDR, + 0, // min amount + false, + false, + ALICE, + pleEncode(swaps) + ); + } + + function testSequentialSwapNegativeSlippageFailure() public { + // Trade 1 WETH for USDC through DAI - see _getSequentialSwaps for more info + + 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 swaps = _getSequentialSwaps(); + + uint256 minAmountOut = 3000 * 1e18; + + vm.expectRevert( + abi.encodeWithSelector( + TychoRouter__NegativeSlippage.selector, + 2644659787, // actual amountOut + minAmountOut + ) + ); + tychoRouter.sequentialSwapPermit2( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + vm.stopPrank(); + } + + function testSequentialSwapFee() public { + // Trade 1 WETH for USDC + // Takes 1% 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 swaps = _getSequentialSwaps(); + + uint256 amountOut = tychoRouter.sequentialSwapPermit2( + amountIn, + WETH_ADDR, + USDC_ADDR, + 1000_000000, + false, + false, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 2618213190; + assertEq(amountOut, expectedAmount); + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, expectedAmount); + assertEq(IERC20(USDC_ADDR).balanceOf(FEE_RECEIVER), 26446597); + + vm.stopPrank(); + } + + function testSequentialSwapWrapETH() public { + 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 swaps = _getSequentialSwaps(); + + uint256 amountOut = tychoRouter.sequentialSwapPermit2{value: amountIn}( + amountIn, + address(0), + USDC_ADDR, + 1000_000000, + true, + false, + ALICE, + emptyPermitSingle, + "", + pleEncode(swaps) + ); + uint256 expectedAmount = 2644659787; + assertEq(amountOut, expectedAmount); + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, expectedAmount); + assertEq(ALICE.balance, 0); + + vm.stopPrank(); + } + + function testSequentialSwapUnwrapETH() public { + // Trade 3k DAI for WETH with 1 swap on Uniswap V2 and unwrap it at the end + + uint256 amountIn = 3_000 * 10 ** 6; + deal(USDC_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(USDC_ADDR, amountIn); + + bytes[] memory swaps = new bytes[](2); + + // USDC -> DAI + swaps[0] = encodeSequentialSwap( + address(usv2Executor), + encodeUniswapV2Swap( + USDC_ADDR, DAI_USDC_POOL, tychoRouterAddr, false + ) + ); + + // DAI -> WETH + swaps[1] = encodeSequentialSwap( + address(usv2Executor), + encodeUniswapV2Swap(DAI_ADDR, WETH_DAI_POOL, tychoRouterAddr, true) + ); + + uint256 amountOut = tychoRouter.sequentialSwapPermit2( + amountIn, + USDC_ADDR, + address(0), + 1 * 10 ** 18, // min amount + false, + true, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 1111174255471849849; // 1.11 ETH + assertEq(amountOut, expectedAmount); + assertEq(ALICE.balance, expectedAmount); + + vm.stopPrank(); + } + + function testCyclicSequentialSwap() public { + // This test has start and end tokens that are the same + // The flow is: + // USDC --(USV3)--> WETH --(USV3)--> USDC + uint256 amountIn = 100 * 10 ** 6; + deal(USDC_ADDR, tychoRouterAddr, amountIn); + + bytes memory usdcWethV3Pool1ZeroOneData = encodeUniswapV3Swap( + USDC_ADDR, WETH_ADDR, tychoRouterAddr, USDC_WETH_USV3, true + ); + + bytes memory usdcWethV3Pool2OneZeroData = encodeUniswapV3Swap( + WETH_ADDR, USDC_ADDR, tychoRouterAddr, USDC_WETH_USV3_2, false + ); + + bytes[] memory swaps = new bytes[](2); + // USDC -> WETH + swaps[0] = encodeSequentialSwap( + address(usv3Executor), usdcWethV3Pool1ZeroOneData + ); + // WETH -> USDC + swaps[1] = encodeSequentialSwap( + address(usv3Executor), usdcWethV3Pool2OneZeroData + ); + + tychoRouter.exposedSequentialSwap(amountIn, pleEncode(swaps)); + assertEq(IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), 99889294); + } +} diff --git a/foundry/test/TychoRouterSingleSwap.t.sol b/foundry/test/TychoRouterSingleSwap.t.sol new file mode 100644 index 0000000..ceb4f6c --- /dev/null +++ b/foundry/test/TychoRouterSingleSwap.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@src/executors/UniswapV4Executor.sol"; +import {TychoRouter} from "@src/TychoRouter.sol"; +import "./TychoRouterTestSetup.sol"; +import "./executors/UniswapV4Utils.sol"; +import {SafeCallback} from "@uniswap/v4-periphery/src/base/SafeCallback.sol"; + +contract TychoRouterSingleSwapTest is TychoRouterTestSetup { + bytes32 public constant FEE_SETTER_ROLE = + 0xe6ad9a47fbda1dc18de1eb5eeb7d935e5e81b4748f3cfc61e233e64f88182060; + + function testSingleSwapPermit2() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 using Permit2 + // 1 WETH -> DAI + // (USV2) + vm.startPrank(ALICE); + + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + ( + 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 = + encodeSingleSwap(address(usv2Executor), protocolData); + + tychoRouter.singleSwapPermit2( + amountIn, + WETH_ADDR, + DAI_ADDR, + 2659881924818443699786, + false, + false, + ALICE, + permitSingle, + signature, + swap + ); + + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, 2659881924818443699787); + assertEq(IERC20(WETH_ADDR).balanceOf(ALICE), 0); + + vm.stopPrank(); + } + + function testSingleSwapNoPermit2() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Checks amount out at the end + uint256 amountIn = 1 ether; + + deal(WETH_ADDR, ALICE, amountIn); + vm.startPrank(ALICE); + // Approve the tokenIn to be transferred to the router + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = + encodeSingleSwap(address(usv2Executor), protocolData); + + uint256 minAmountOut = 2600 * 1e18; + uint256 amountOut = tychoRouter.singleSwap( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + ALICE, + swap + ); + + uint256 expectedAmount = 2659881924818443699787; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(IERC20(WETH_ADDR).balanceOf(ALICE), 0); + + vm.stopPrank(); + } + + function testSingleSwapUndefinedMinAmount() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Checks amount out at the end + uint256 amountIn = 1 ether; + + deal(WETH_ADDR, ALICE, amountIn); + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = + encodeSingleSwap(address(usv2Executor), protocolData); + + vm.expectRevert(TychoRouter__UndefinedMinAmountOut.selector); + tychoRouter.singleSwap( + amountIn, WETH_ADDR, DAI_ADDR, 0, false, false, ALICE, swap + ); + } + + function testSingleSwapInsufficientApproval() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Checks amount out at the end + uint256 amountIn = 1 ether; + + deal(WETH_ADDR, ALICE, amountIn); + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn - 1); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = + encodeSingleSwap(address(usv2Executor), protocolData); + + uint256 minAmountOut = 2600 * 1e18; + vm.expectRevert(); + tychoRouter.singleSwap( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + ALICE, + swap + ); + } + + function testSingleSwapNegativeSlippageFailure() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Checks amount out at the end + uint256 amountIn = 1 ether; + + deal(WETH_ADDR, ALICE, amountIn); + vm.startPrank(ALICE); + // Approve the tokenIn to be transferred to the router + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = + encodeSingleSwap(address(usv2Executor), protocolData); + + uint256 minAmountOut = 5600 * 1e18; + + vm.expectRevert( + abi.encodeWithSelector( + TychoRouter__NegativeSlippage.selector, + 2659881924818443699787, // actual amountOut + minAmountOut + ) + ); + tychoRouter.singleSwap( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + ALICE, + swap + ); + } + + function testSingleSwapFee() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Takes 1% 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); + // Approve the tokenIn to be transferred to the router + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ); + + bytes memory swap = + encodeSingleSwap(address(usv2Executor), protocolData); + + uint256 minAmountOut = 2600 * 1e18; + uint256 amountOut = tychoRouter.singleSwap( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + ALICE, + swap + ); + + uint256 expectedAmount = 2633283105570259262790; + assertEq(amountOut, expectedAmount); + uint256 usdcBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, expectedAmount); + assertEq(IERC20(DAI_ADDR).balanceOf(FEE_RECEIVER), 26598819248184436997); + + vm.stopPrank(); + } + + function testSingleSwapWrapETH() public { + 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 = + encodeSingleSwap(address(usv2Executor), protocolData); + + uint256 amountOut = tychoRouter.singleSwapPermit2{value: amountIn}( + amountIn, + address(0), + DAI_ADDR, + 1000_000000, + true, + false, + ALICE, + emptyPermitSingle, + "", + swap + ); + uint256 expectedAmount = 2659881924818443699787; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(ALICE.balance, 0); + + vm.stopPrank(); + } + + function testSingleSwapUnwrapETH() public { + // DAI -> WETH with unwrapping to ETH + uint256 amountIn = 3000 ether; + 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 = + encodeSingleSwap(address(usv2Executor), protocolData); + + uint256 amountOut = tychoRouter.singleSwapPermit2( + amountIn, + DAI_ADDR, + address(0), + 1000_000000, + false, + true, + ALICE, + permitSingle, + signature, + swap + ); + + uint256 expectedAmount = 1120007305574805922; + assertEq(amountOut, expectedAmount); + assertEq(ALICE.balance, expectedAmount); + + vm.stopPrank(); + } +} diff --git a/foundry/test/TychoRouterSplitSwap.t.sol b/foundry/test/TychoRouterSplitSwap.t.sol new file mode 100644 index 0000000..8e8e20e --- /dev/null +++ b/foundry/test/TychoRouterSplitSwap.t.sol @@ -0,0 +1,683 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@src/executors/UniswapV4Executor.sol"; +import {TychoRouter} from "@src/TychoRouter.sol"; +import "./TychoRouterTestSetup.sol"; +import "./executors/UniswapV4Utils.sol"; +import {SafeCallback} from "@uniswap/v4-periphery/src/base/SafeCallback.sol"; + +contract TychoRouterSplitSwapTest is TychoRouterTestSetup { + bytes32 public constant FEE_SETTER_ROLE = + 0xe6ad9a47fbda1dc18de1eb5eeb7d935e5e81b4748f3cfc61e233e64f88182060; + + function _getSplitSwaps() private view returns (bytes[] memory) { + // Trade 1 WETH for USDC through DAI and WBTC with 4 swaps on Uniswap V2 + // -> DAI -> + // 1 WETH USDC + // -> WBTC -> + // (univ2) (univ2) + bytes[] memory swaps = new bytes[](4); + // WETH -> WBTC (60%) + swaps[0] = encodeSplitSwap( + uint8(0), + uint8(1), + (0xffffff * 60) / 100, // 60% + address(usv2Executor), + encodeUniswapV2Swap( + WETH_ADDR, WETH_WBTC_POOL, tychoRouterAddr, false + ) + ); + // WBTC -> USDC + swaps[1] = encodeSplitSwap( + uint8(1), + uint8(2), + uint24(0), + address(usv2Executor), + encodeUniswapV2Swap( + WBTC_ADDR, USDC_WBTC_POOL, tychoRouterAddr, true + ) + ); + // WETH -> DAI + swaps[2] = encodeSplitSwap( + uint8(0), + uint8(3), + uint24(0), + address(usv2Executor), + encodeUniswapV2Swap( + WETH_ADDR, WETH_DAI_POOL, tychoRouterAddr, false + ) + ); + + // DAI -> USDC + swaps[3] = encodeSplitSwap( + uint8(3), + uint8(2), + uint24(0), + address(usv2Executor), + encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) + ); + + return swaps; + } + + function testSplitSwapInternalMethod() public { + // Trade 1 WETH for USDC through DAI and WBTC - see _getSplitSwaps for more info + + uint256 amountIn = 1 ether; + deal(WETH_ADDR, tychoRouterAddr, amountIn); + bytes[] memory swaps = _getSplitSwaps(); + tychoRouter.exposedSplitSwap(amountIn, 4, pleEncode(swaps)); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); + assertEq(usdcBalance, 2615491639); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSplitSwapPermit2() public { + // Trade 1 WETH for USDC through DAI and WBTC - see _getSplitSwaps for more info + + 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 swaps = _getSplitSwaps(); + + tychoRouter.splitSwapPermit2( + amountIn, + WETH_ADDR, + USDC_ADDR, + 1, // min amount + false, + false, + 4, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, 2615491639); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSplitSwapNoPermit2() public { + // Trade 1 WETH for USDC through DAI and WBTC - see _getSplitSwaps for more info + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); + + bytes[] memory swaps = _getSplitSwaps(); + + tychoRouter.splitSwap( + amountIn, + WETH_ADDR, + USDC_ADDR, + 1000_000000, // min amount + false, + false, + 4, + ALICE, + pleEncode(swaps) + ); + + uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(ALICE); + assertEq(usdcBalance, 2615491639); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSplitSwapUndefinedMinAmount() public { + // Min amount should always be non-zero. If zero, swap attempt should revert. + uint256 amountIn = 1 ether; + deal(WETH_ADDR, ALICE, amountIn); + + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn); + + bytes[] memory swaps = _getSplitSwaps(); + + vm.expectRevert(TychoRouter__UndefinedMinAmountOut.selector); + tychoRouter.splitSwap( + amountIn, + WETH_ADDR, + USDC_ADDR, + 0, // min amount + false, + false, + 4, + ALICE, + pleEncode(swaps) + ); + vm.stopPrank(); + } + + function testSplitSwapInsufficientApproval() public { + // Trade 1 WETH for USDC through DAI and WBTC - see _getSplitSwaps for more info + uint256 amountIn = 1 ether; + + deal(WETH_ADDR, ALICE, amountIn); + vm.startPrank(ALICE); + // Approve less than the amountIn + IERC20(WETH_ADDR).approve(address(tychoRouterAddr), amountIn - 1); + bytes[] memory swaps = _getSplitSwaps(); + + vm.expectRevert(); + tychoRouter.splitSwap( + amountIn, + WETH_ADDR, + USDC_ADDR, + 1000_000000, // min amount + false, + false, + 2, + ALICE, + pleEncode(swaps) + ); + + vm.stopPrank(); + } + + function testSplitSwapNegativeSlippageFailure() public { + // Trade 1 WETH for USDC through DAI and WBTC - see _getSplitSwaps for more info + + 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 swaps = _getSplitSwaps(); + + uint256 minAmountOut = 3000 * 1e18; + + vm.expectRevert( + abi.encodeWithSelector( + TychoRouter__NegativeSlippage.selector, + 2615491639, // actual amountOut + minAmountOut + ) + ); + tychoRouter.splitSwapPermit2( + amountIn, + WETH_ADDR, + DAI_ADDR, + minAmountOut, + false, + false, + 4, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + vm.stopPrank(); + } + + function testSplitSwapFee() 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 = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 amountOut = tychoRouter.splitSwapPermit2( + amountIn, + WETH_ADDR, + DAI_ADDR, + 2633283105570259262780, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 2633283105570259262790; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(IERC20(DAI_ADDR).balanceOf(FEE_RECEIVER), 26598819248184436997); + + vm.stopPrank(); + } + + function testSplitSwapWrapETH() 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 = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 amountOut = tychoRouter.splitSwapPermit2{value: amountIn}( + amountIn, + address(0), + DAI_ADDR, + 2659881924818443699780, + true, + false, + 2, + ALICE, + emptyPermitSingle, + "", + pleEncode(swaps) + ); + uint256 expectedAmount = 2659881924818443699787; + assertEq(amountOut, expectedAmount); + uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertEq(daiBalance, expectedAmount); + assertEq(ALICE.balance, 0); + + vm.stopPrank(); + } + + function testSplitSwapUnwrapETH() 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 = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + uint256 amountOut = tychoRouter.splitSwapPermit2( + amountIn, + DAI_ADDR, + address(0), + 1120007305574805920, + false, + true, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 expectedAmount = 1120007305574805922; // 1.12 ETH + assertEq(amountOut, expectedAmount); + assertEq(ALICE.balance, expectedAmount); + + vm.stopPrank(); + } + + function testSplitSwapSingleUSV3Permit2() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V3 using Permit2 + // Tests entire USV3 flow including callback + // 1 WETH -> DAI + // (USV3) + vm.startPrank(ALICE); + uint256 amountIn = 10 ** 18; + deal(WETH_ADDR, ALICE, amountIn); + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(WETH_ADDR, amountIn); + + uint256 expAmountOut = 1205_128428842122129186; //Swap 1 WETH for 1205.12 DAI + bool zeroForOne = false; + bytes memory protocolData = encodeUniswapV3Swap( + WETH_ADDR, DAI_ADDR, tychoRouterAddr, DAI_WETH_USV3, zeroForOne + ); + bytes memory swap = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv3Executor), protocolData + ); + + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + tychoRouter.splitSwapPermit2( + amountIn, + WETH_ADDR, + DAI_ADDR, + expAmountOut - 1, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + uint256 finalBalance = IERC20(DAI_ADDR).balanceOf(ALICE); + assertGe(finalBalance, expAmountOut); + + vm.stopPrank(); + } + + function testEmptySwapsRevert() public { + uint256 amountIn = 10 ** 18; + bytes memory swaps = ""; + vm.expectRevert(TychoRouter__EmptySwaps.selector); + tychoRouter.exposedSplitSwap(amountIn, 2, swaps); + } + + function testSplitSwapAmountInNotFullySpent() public { + // Trade 1 WETH for DAI with 1 swap on Uniswap V2 + // Has invalid data as input! There is only one swap with 60% of the input amount + 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 = encodeSplitSwap( + uint8(0), + uint8(1), + (0xffffff * 60) / 100, // 60% + address(usv2Executor), + protocolData + ); + + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + vm.expectRevert( + abi.encodeWithSelector( + TychoRouter__AmountInDiffersFromConsumed.selector, + 1000000000000000000, + 600000000000000000 + ) + ); + + tychoRouter.splitSwapPermit2( + amountIn, + WETH_ADDR, + DAI_ADDR, + 1, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + vm.stopPrank(); + } + + function testSplitSwapSingleUSV4CallbackPermit2() public { + vm.startPrank(ALICE); + uint256 amountIn = 100 ether; + deal(USDE_ADDR, ALICE, amountIn); + ( + IAllowanceTransfer.PermitSingle memory permitSingle, + bytes memory signature + ) = handlePermit2Approval(USDE_ADDR, amountIn); + + UniswapV4Executor.UniswapV4Pool[] memory pools = + new UniswapV4Executor.UniswapV4Pool[](1); + pools[0] = UniswapV4Executor.UniswapV4Pool({ + intermediaryToken: USDT_ADDR, + fee: uint24(100), + tickSpacing: int24(1) + }); + + bytes memory protocolData = UniswapV4Utils.encodeExactInput( + USDE_ADDR, USDT_ADDR, true, address(usv4Executor), pools + ); + + bytes memory swap = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv4Executor), protocolData + ); + + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + tychoRouter.splitSwapPermit2( + amountIn, + USDE_ADDR, + USDT_ADDR, + 99943850, + false, + false, + 2, + ALICE, + permitSingle, + signature, + pleEncode(swaps) + ); + + assertEq(IERC20(USDT_ADDR).balanceOf(ALICE), 99943852); + vm.stopPrank(); + } + + function testSplitSwapMultipleUSV4Callback() public { + // This test has two uniswap v4 hops that will be executed inside of the V4 pool manager + // USDE -> USDT -> WBTC + uint256 amountIn = 100 ether; + deal(USDE_ADDR, tychoRouterAddr, amountIn); + + UniswapV4Executor.UniswapV4Pool[] memory pools = + new UniswapV4Executor.UniswapV4Pool[](2); + pools[0] = UniswapV4Executor.UniswapV4Pool({ + intermediaryToken: USDT_ADDR, + fee: uint24(100), + tickSpacing: int24(1) + }); + pools[1] = UniswapV4Executor.UniswapV4Pool({ + intermediaryToken: WBTC_ADDR, + fee: uint24(3000), + tickSpacing: int24(60) + }); + + bytes memory protocolData = UniswapV4Utils.encodeExactInput( + USDE_ADDR, WBTC_ADDR, true, address(usv4Executor), pools + ); + + bytes memory swap = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv4Executor), protocolData + ); + + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); + + assertEq(IERC20(WBTC_ADDR).balanceOf(tychoRouterAddr), 102718); + } + + function testSplitInputCyclicSwapInternalMethod() public { + // This test has start and end tokens that are the same + // The flow is: + // ┌─ (USV3, 60% split) ──> WETH ─┐ + // │ │ + // USDC ──────┤ ├──(USV2)──> USDC + // │ │ + // └─ (USV3, 40% split) ──> WETH ─┘ + uint256 amountIn = 100 * 10 ** 6; + deal(USDC_ADDR, tychoRouterAddr, amountIn); + + bytes memory usdcWethV3Pool1ZeroOneData = encodeUniswapV3Swap( + USDC_ADDR, WETH_ADDR, tychoRouterAddr, USDC_WETH_USV3, true + ); + + bytes memory usdcWethV3Pool2ZeroOneData = encodeUniswapV3Swap( + USDC_ADDR, WETH_ADDR, tychoRouterAddr, USDC_WETH_USV3_2, true + ); + + bytes memory wethUsdcV2OneZeroData = encodeUniswapV2Swap( + WETH_ADDR, USDC_WETH_USV2, tychoRouterAddr, false + ); + + bytes[] memory swaps = new bytes[](3); + // USDC -> WETH (60% split) + swaps[0] = encodeSplitSwap( + uint8(0), + uint8(1), + (0xffffff * 60) / 100, // 60% + address(usv3Executor), + usdcWethV3Pool1ZeroOneData + ); + // USDC -> WETH (40% remainder) + swaps[1] = encodeSplitSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv3Executor), + usdcWethV3Pool2ZeroOneData + ); + // WETH -> USDC + swaps[2] = encodeSplitSwap( + uint8(1), + uint8(0), + uint24(0), + address(usv2Executor), + wethUsdcV2OneZeroData + ); + tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); + assertEq(IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), 99574171); + } + + function testSplitOutputCyclicSwapInternalMethod() public { + // This test has start and end tokens that are the same + // The flow is: + // ┌─── (USV3, 60% split) ───┐ + // │ │ + // USDC ──(USV2) ── WETH──| ├─> USDC + // │ │ + // └─── (USV3, 40% split) ───┘ + + uint256 amountIn = 100 * 10 ** 6; + deal(USDC_ADDR, tychoRouterAddr, amountIn); + + bytes memory usdcWethV2Data = encodeUniswapV2Swap( + USDC_ADDR, USDC_WETH_USV2, tychoRouterAddr, true + ); + + bytes memory usdcWethV3Pool1OneZeroData = encodeUniswapV3Swap( + WETH_ADDR, USDC_ADDR, tychoRouterAddr, USDC_WETH_USV3, false + ); + + bytes memory usdcWethV3Pool2OneZeroData = encodeUniswapV3Swap( + WETH_ADDR, USDC_ADDR, tychoRouterAddr, USDC_WETH_USV3_2, false + ); + + bytes[] memory swaps = new bytes[](3); + // USDC -> WETH + swaps[0] = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv2Executor), usdcWethV2Data + ); + // WETH -> USDC + swaps[1] = encodeSplitSwap( + uint8(1), + uint8(0), + (0xffffff * 60) / 100, + address(usv3Executor), + usdcWethV3Pool1OneZeroData + ); + + // WETH -> USDC + swaps[2] = encodeSplitSwap( + uint8(1), + uint8(0), + uint24(0), + address(usv3Executor), + usdcWethV3Pool2OneZeroData + ); + + tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); + assertEq(IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), 99525908); + } + + // Base Network Tests + // Make sure to set the RPC_URL to base network + function testSplitSwapInternalMethodBase() public { + vm.skip(true); + vm.rollFork(26857267); + uint256 amountIn = 10 * 10 ** 6; + deal(BASE_USDC, tychoRouterAddr, amountIn); + + bytes memory protocolData = encodeUniswapV2Swap( + BASE_USDC, USDC_MAG7_POOL, tychoRouterAddr, true + ); + + bytes memory swap = encodeSplitSwap( + uint8(0), uint8(1), uint24(0), address(usv2Executor), protocolData + ); + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); + assertGt(IERC20(BASE_MAG7).balanceOf(tychoRouterAddr), 1379830606); + } +} diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index a92f44a..edbbe54 100644 --- a/foundry/test/TychoRouterTestSetup.sol +++ b/foundry/test/TychoRouterTestSetup.sol @@ -29,6 +29,13 @@ contract TychoRouterExposed is TychoRouter { ) external returns (uint256) { return _splitSwap(amountIn, nTokens, swaps); } + + function exposedSequentialSwap(uint256 amountIn, bytes calldata swaps) + external + returns (uint256) + { + return _sequentialSwap(amountIn, swaps); + } } contract TychoRouterTestSetup is Constants { @@ -181,6 +188,22 @@ contract TychoRouterTestSetup is Constants { } } + function encodeSingleSwap(address executor, bytes memory protocolData) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(executor, protocolData); + } + + function encodeSequentialSwap(address executor, bytes memory protocolData) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(executor, protocolData); + } + function encodeSplitSwap( uint8 tokenInIndex, uint8 tokenOutIndex,