diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index 29ac360..32fb7f5 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -266,7 +266,7 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { uint256 amountConsumed = initialBalance - currentBalance; - if (amountConsumed != amountIn) { + if (tokenIn != tokenOut && amountConsumed != amountIn) { revert TychoRouter__AmountInDiffersFromConsumed( amountIn, amountConsumed ); @@ -299,12 +299,15 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { * - The indices of the input and output tokens (via `tokenInIndex()` and `tokenOutIndex()`). * - The portion of the available amount to be used for the swap, indicated by the `split` value. * - * Two important notes: + * Three important notes: * - The contract assumes that token indexes follow a specific order: the sell token is at index 0, followed by any * intermediary tokens, and finally the buy token. * - A `split` value of 0 is interpreted as 100% of the available amount (i.e., the entire remaining balance). * This means that in scenarios without explicit splits the value should be 0, and when splits are present, * the last swap should also have a split value of 0. + * - In case of cyclic swaps, the output token is the same as the input token. + * `cyclicSwapAmountOut` is used to track the amount of the output token, and is updated when + * the `tokenOutIndex` is 0. * * @param amountIn The initial amount of the sell token to be swapped. * @param nTokens The total number of tokens involved in the swap path, used to initialize arrays for internal tracking. @@ -329,6 +332,7 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { uint256[] memory remainingAmounts = new uint256[](nTokens); uint256[] memory amounts = new uint256[](nTokens); + uint256 cyclicSwapAmountOut = 0; amounts[0] = amountIn; remainingAmounts[0] = amountIn; @@ -345,11 +349,16 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { currentAmountOut = _callExecutor( swapData.executor(), currentAmountIn, swapData.protocolData() ); - amounts[tokenOutIndex] += currentAmountOut; + // Checks if the output token is the same as the input token + if (tokenOutIndex == 0) { + cyclicSwapAmountOut += currentAmountOut; + } else { + amounts[tokenOutIndex] += currentAmountOut; + } remainingAmounts[tokenOutIndex] += currentAmountOut; remainingAmounts[tokenInIndex] -= currentAmountIn; } - return amounts[tokenOutIndex]; + return tokenOutIndex == 0 ? cyclicSwapAmountOut : amounts[tokenOutIndex]; } /** diff --git a/foundry/test/Constants.sol b/foundry/test/Constants.sol index 6a0eb01..9149dcf 100644 --- a/foundry/test/Constants.sol +++ b/foundry/test/Constants.sol @@ -45,11 +45,14 @@ contract Constants is Test, BaseConstants { address DAI_USDC_POOL = 0xAE461cA67B15dc8dc81CE7615e0320dA1A9aB8D5; address WETH_WBTC_POOL = 0xBb2b8038a1640196FbE3e38816F3e67Cba72D940; address USDC_WBTC_POOL = 0x004375Dff511095CC5A197A54140a24eFEF3A416; + address USDC_WETH_USV2 = 0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc; // Uniswap v3 address USV3_FACTORY_ETHEREUM = 0x1F98431c8aD98523631AE4a59f267346ea31F984; address USV2_FACTORY_ETHEREUM = 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f; address DAI_WETH_USV3 = 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8; + address USDC_WETH_USV3 = 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640; // 0.05% fee + address USDC_WETH_USV3_2 = 0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8; // 0.3% fee // Uniswap universal router address UNIVERSAL_ROUTER = 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af; diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index 3676b59..e86b622 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -1192,6 +1192,146 @@ contract TychoRouterTest is TychoRouterTestSetup { 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] = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv3Executor), + usdcWethV3Pool1ZeroOneData + ); + // WETH -> USDC + swaps[1] = encodeSwap( + uint8(1), + uint8(0), + uint24(0), + address(usv3Executor), + usdcWethV3Pool2OneZeroData + ); + + tychoRouter.exposedSwap(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] = encodeSwap( + uint8(0), + uint8(1), + (0xffffff * 60) / 100, // 60% + address(usv3Executor), + usdcWethV3Pool1ZeroOneData + ); + // USDC -> WETH (40% remainder) + swaps[1] = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv3Executor), + usdcWethV3Pool2ZeroOneData + ); + // WETH -> USDC + swaps[2] = encodeSwap( + uint8(1), + uint8(0), + uint24(0), + address(usv2Executor), + wethUsdcV2OneZeroData + ); + tychoRouter.exposedSwap(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] = encodeSwap( + uint8(0), uint8(1), uint24(0), address(usv2Executor), usdcWethV2Data + ); + // WETH -> USDC + swaps[1] = encodeSwap( + uint8(1), + uint8(0), + (0xffffff * 60) / 100, + address(usv3Executor), + usdcWethV3Pool1OneZeroData + ); + + // WETH -> USDC + swaps[2] = encodeSwap( + uint8(1), + uint8(0), + uint24(0), + address(usv3Executor), + usdcWethV3Pool2OneZeroData + ); + + tychoRouter.exposedSwap(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 testSwapSingleBase() public {