diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index a899469..e65a6b7 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. @@ -472,6 +578,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. * @@ -542,6 +717,27 @@ 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(); + calculatedAmount = + _callExecutor(swap.executor(), calculatedAmount, swap.protocolData()); + } + } + /** * @dev We use the fallback function to allow flexibility on callback. */ diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index 2314bb2..d68e924 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -230,7 +230,7 @@ contract TychoRouterTest is TychoRouterTestSetup { assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } - function testSplitSwapSimplePermit2() public { + function testSingleSwapSimplePermit2() public { // Trade 1 WETH for DAI with 1 swap on Uniswap V2 using Permit2 // 1 WETH -> DAI // (USV2) @@ -274,7 +274,7 @@ contract TychoRouterTest is TychoRouterTestSetup { vm.stopPrank(); } - function testSplitSwapMultipleHops() public { + function testSequentialSwapMultipleHops() public { // Trade 1 WETH for USDC through DAI with 2 swaps on Uniswap V2 // 1 WETH -> DAI -> USDC // (univ2) (univ2) @@ -302,7 +302,7 @@ contract TychoRouterTest is TychoRouterTestSetup { encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) ); - tychoRouter.exposedSplitSwap(amountIn, 3, pleEncode(swaps)); + tychoRouter.exposedSequentialSwap(amountIn, pleEncode(swaps)); uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); assertEq(usdcBalance, 2644659787); @@ -965,7 +965,7 @@ contract TychoRouterTest is TychoRouterTestSetup { function testCyclicSequentialSwap() public { // This test has start and end tokens that are the same // The flow is: - // USDC -> WETH -> USDC using two pools + // USDC --(USV3)--> WETH --(USV3)--> USDC uint256 amountIn = 100 * 10 ** 6; deal(USDC_ADDR, tychoRouterAddr, amountIn); @@ -995,10 +995,11 @@ contract TychoRouterTest is TychoRouterTestSetup { usdcWethV3Pool2OneZeroData ); - tychoRouter.exposedSplitSwap(amountIn, 2, pleEncode(swaps)); + tychoRouter.exposedSequentialSwap(amountIn, 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: diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index a92f44a..54b86d6 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 { @@ -193,6 +200,18 @@ contract TychoRouterTestSetup is Constants { ); } + function encodeSequentialSwap( + uint8 tokenInIndex, + uint8 tokenOutIndex, + uint24 split, + address executor, + bytes memory protocolData + ) internal pure returns (bytes memory) { + return abi.encodePacked( + tokenInIndex, tokenOutIndex, split, executor, protocolData + ); + } + function encodeUniswapV2Swap( address tokenIn, address target,