diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index 19e6b9c..3fceeb1 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -222,6 +222,117 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { ); } + /** + * @notice Executes a single swap operation. + * This function enables 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. + * - 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 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. + * + * @return amountOut The total amount of the output token received by the receiver, after deducting fees if applicable. + */ + function singleSwap( + uint256 amountIn, + address tokenIn, + address tokenOut, + uint256 minAmountOut, + bool wrapEth, + bool unwrapEth, + uint256 nTokens, + address receiver, + bytes calldata swapData + ) public payable whenNotPaused nonReentrant returns (uint256 amountOut) { + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + return _singleSwap( + amountIn, + tokenIn, + tokenOut, + minAmountOut, + wrapEth, + unwrapEth, + nTokens, + receiver, + swapData + ); + } + + /** + * @notice Executes a single swap operation. + * This function enables 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 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 swapData Encoded swap details. + * + * @return amountOut The total amount of the output token received by the receiver, after deducting fees if applicable. + */ + function singleSwapPermit2( + uint256 amountIn, + address tokenIn, + address tokenOut, + uint256 minAmountOut, + bool wrapEth, + bool unwrapEth, + uint256 nTokens, + address receiver, + IAllowanceTransfer.PermitSingle calldata permitSingle, + bytes calldata signature, + bytes calldata swapData + ) 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 _singleSwap( + amountIn, + tokenIn, + tokenOut, + minAmountOut, + wrapEth, + unwrapEth, + nTokens, + receiver, + swapData + ); + } + /** * @notice Internal implementation of the core swap logic shared between splitSwap() and splitSwapPermit2(). * @@ -291,6 +402,76 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { } } + /** + * @notice Internal implementation of the core swap logic shared between singleSwap() and singleSwapPermit2(). + * + * @notice This function centralizes the swap execution logic. + * @notice For detailed documentation on parameters and behavior, see the documentation for + * singleSwap() and singleSwapPermit2() functions. + * + */ + function _singleSwap( + uint256 amountIn, + address tokenIn, + address tokenOut, + uint256 minAmountOut, + bool wrapEth, + bool unwrapEth, + uint256 nTokens, + address receiver, + bytes calldata swap_ + ) 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 = + _callExecutor(swap_.executor(), amountIn, swap_.protocolData()); + 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. * diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index ec0c5f2..2314bb2 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -366,7 +366,7 @@ contract TychoRouterTest is TychoRouterTestSetup { assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } - function testSplitSwapChecked() public { + 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 @@ -387,11 +387,9 @@ contract TychoRouterTest is TychoRouterTestSetup { 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; - uint256 amountOut = tychoRouter.splitSwapPermit2( + uint256 amountOut = tychoRouter.singleSwapPermit2( amountIn, WETH_ADDR, DAI_ADDR, @@ -402,7 +400,7 @@ contract TychoRouterTest is TychoRouterTestSetup { ALICE, permitSingle, signature, - pleEncode(swaps) + swap ); uint256 expectedAmount = 2659881924818443699787; @@ -454,7 +452,7 @@ contract TychoRouterTest is TychoRouterTestSetup { vm.stopPrank(); } - function testSplitSwapCheckedNoPermit2() public { + function testSingleSwapCheckedNoPermit2() public { // Trade 1 WETH for DAI with 1 swap on Uniswap V2 // Checks amount out at the end uint256 amountIn = 1 ether; @@ -471,11 +469,9 @@ contract TychoRouterTest is TychoRouterTestSetup { 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; - uint256 amountOut = tychoRouter.splitSwap( + uint256 amountOut = tychoRouter.singleSwap( amountIn, WETH_ADDR, DAI_ADDR, @@ -484,7 +480,7 @@ contract TychoRouterTest is TychoRouterTestSetup { false, 2, ALICE, - pleEncode(swaps) + swap ); uint256 expectedAmount = 2659881924818443699787;