diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ff440..b46ceb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ +## [0.66.1](https://github.com/propeller-heads/tycho-execution/compare/0.66.0...0.66.1) (2025-03-19) + + +### Bug Fixes + +* Slippage precision calculation ([d644b63](https://github.com/propeller-heads/tycho-execution/commit/d644b63851a63babadfb909af97c5bf80dd03376)) + +## [0.66.0](https://github.com/propeller-heads/tycho-execution/compare/0.65.1...0.66.0) (2025-03-14) + + +### Features + +* Add check to don't support cyclical swaps with native actions ([27c9c53](https://github.com/propeller-heads/tycho-execution/commit/27c9c53889687b890bb4e4e01329f6a67ae7957c)) +* Add validation for cyclical trades ([f62a9d2](https://github.com/propeller-heads/tycho-execution/commit/f62a9d28c0683490d841439d6a0543370d238387)) + + +### Bug Fixes + +* Add individual tests for each case ([e96bcdf](https://github.com/propeller-heads/tycho-execution/commit/e96bcdfd0f7f1951ef711efe8a3e45c5bb18fc8b)) +* In test asset, use 0 for the last split, and not 0.5 ([0aba7ed](https://github.com/propeller-heads/tycho-execution/commit/0aba7edf830da0f0efaa465c8069484b62fb7a4d)) + +## [0.65.1](https://github.com/propeller-heads/tycho-execution/compare/0.65.0...0.65.1) (2025-03-13) + + +### Reverts + +* Revert "feat: Add validation for cyclical trades" ([3d7dcef](https://github.com/propeller-heads/tycho-execution/commit/3d7dcef1bd01db283d787f0bc86b4e9cfc28bbaa)) + +## [0.65.0](https://github.com/propeller-heads/tycho-execution/compare/0.64.0...0.65.0) (2025-03-13) + + +### Features + +* Add validation for cyclical trades ([55ffa4e](https://github.com/propeller-heads/tycho-execution/commit/55ffa4eb457ea8dd1ed57cdaac01f45880d34b0f)) + +## [0.64.0](https://github.com/propeller-heads/tycho-execution/compare/0.63.0...0.64.0) (2025-03-13) + + +### Features + +* update tycho-core to 0.61.1 ([53b8c6a](https://github.com/propeller-heads/tycho-execution/commit/53b8c6afee6efdb2a878e2535a8daadfb29d91be)) + +## [0.63.0](https://github.com/propeller-heads/tycho-execution/compare/0.62.0...0.63.0) (2025-03-10) + + +### Features + +* add cyclicSwapAmountOut tracker in _swap, add split cylic tests ([4d67df4](https://github.com/propeller-heads/tycho-execution/commit/4d67df40965414caff94f8660c70f4acad51482f)) + + +### Bug Fixes + +* amountConsumed check in _swapChecked for cyclic swap ([91f36fe](https://github.com/propeller-heads/tycho-execution/commit/91f36fe3285ae4e3e010b9138e8226e257f8499c)) +* remove amountIn addition to amountOut in _swap for cyclic swaps, add testCyclicSwapWithTwoPools test to verify ([57acbd5](https://github.com/propeller-heads/tycho-execution/commit/57acbd58c5146c88098e9bc274ec702ef25add32)) + ## [0.62.0](https://github.com/propeller-heads/tycho-execution/compare/0.61.0...0.62.0) (2025-03-06) diff --git a/Cargo.lock b/Cargo.lock index 4b91323..a326a08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4317,8 +4317,8 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tycho-core" -version = "0.56.5" -source = "git+https://github.com/propeller-heads/tycho-indexer.git?tag=0.56.5#2af8c1a5a61c5479eab5f7903b69943efa61e2c8" +version = "0.61.1" +source = "git+https://github.com/propeller-heads/tycho-indexer.git?tag=0.61.1#aae2c11bffe9ae5e436adc019b769438fa038272" dependencies = [ "anyhow", "async-trait", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.62.0" +version = "0.66.1" dependencies = [ "alloy", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 38e560b..cc569cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycho-execution" -version = "0.62.0" +version = "0.66.1" edition = "2021" [[bin]] @@ -24,7 +24,7 @@ clap = { version = "4.5.3", features = ["derive"] } alloy = { version = "0.9.2", features = ["providers", "rpc-types-eth", "eip712", "signer-local"], optional = true } alloy-sol-types = { version = "0.8.14", optional = true } alloy-primitives = { version = "0.8.9", optional = true } -tycho-core = { git = "https://github.com/propeller-heads/tycho-indexer.git", package = "tycho-core", tag = "0.56.5" } +tycho-core = { git = "https://github.com/propeller-heads/tycho-indexer.git", package = "tycho-core", tag = "0.61.1" } once_cell = "1.20.2" [dev-dependencies] diff --git a/config/executor_addresses.json b/config/executor_addresses.json index da05b18..02e98e6 100644 --- a/config/executor_addresses.json +++ b/config/executor_addresses.json @@ -19,4 +19,4 @@ "tenderly_base": { "uniswap_v3": "0x7c7E06d7317e620a185078e236879D2a87fC8d22" } -} \ No newline at end of file +} diff --git a/docs/audits/Max_2025-03-04.pdf b/docs/audits/Max_2025-03-04.pdf index 9ec9b53..2628289 100644 Binary files a/docs/audits/Max_2025-03-04.pdf and b/docs/audits/Max_2025-03-04.pdf differ diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index 29ac360..47d6d14 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -111,8 +111,7 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { /** * @notice Executes a swap operation based on a predefined swap graph, supporting internal token amount splits. * This function enables multi-step swaps, optional ETH wrapping/unwrapping, and validates the output amount - * against a user-specified minimum. This function expects the input tokens to already be in the router at - * the time of calling. + * 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. @@ -259,14 +258,13 @@ contract TychoRouter is AccessControl, Dispatcher, Pausable, ReentrancyGuard { : IERC20(tokenIn).balanceOf(address(this)); amountOut = _swap(amountIn, nTokens, swaps); - uint256 currentBalance = tokenIn == address(0) ? address(this).balance : IERC20(tokenIn).balanceOf(address(this)); uint256 amountConsumed = initialBalance - currentBalance; - if (amountConsumed != amountIn) { + if (tokenIn != tokenOut && amountConsumed != amountIn) { revert TychoRouter__AmountInDiffersFromConsumed( amountIn, amountConsumed ); @@ -299,12 +297,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 +330,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 +347,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..3801a2e 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -1192,6 +1192,203 @@ 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); + } + + function testCyclicSequentialSwapIntegration() public { + deal(USDC_ADDR, ALICE, 100 * 10 ** 6); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(USDC_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); + // Encoded solution generated using `test_cyclic_sequential_swap` + // but manually replacing the executor address + // `dd8559c917393fc8dd2b4dd289c52ff445fde1b0` with the one in this test + // `2e234dae75c793f67a35089c9d99245e1c58470b` + (bool success,) = tychoRouterAddr.call( + hex"d499aa880000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005f4308e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000000000000000067f67a8b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067cef493000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000041c07077fc73bb0f5129006061288fa0583c101631307377281d6b8f3feb50aa2d564f9948c92e0e4abc3771d592bd2f22ebb18ccf21b270459b05f272251ce1c71b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de006d00010000002e234dae75c793f67a35089c9d99245e1c58470ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f43ede3eca2a72b3aecc820e955b36f38437d0139588e6a0c2ddd26feeb64f039a2c41296fcb3f564001006d01000000002e234dae75c793f67a35089c9d99245e1c58470bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000bb83ede3eca2a72b3aecc820e955b36f38437d013958ad599c3a0ff1de082011efddc58f1908eb6e6d8000000" + ); + + assertEq(IERC20(USDC_ADDR).balanceOf(ALICE), 99889294); + + vm.stopPrank(); + } + + function testSplitInputCyclicSwapIntegration() public { + deal(USDC_ADDR, ALICE, 100 * 10 ** 6); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(USDC_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); + // Encoded solution generated using `test_split_input_cyclic_swap` + // but manually replacing the executor addresses with the ones in this test + // `dd8559c917393fc8dd2b4dd289c52ff445fde1b0` to `2e234dae75c793f67a35089c9d99245e1c58470b` + // `f6c5be66fff9dc69962d73da0a617a827c382329` to `5615deb798bb3e4dfa0139dfa1b3d433cc23b72f` + (bool success,) = tychoRouterAddr.call( + hex"d499aa880000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005ef619b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000000000000000067f6c08700000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067cf3a8f000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000041f248bfa39e6801b4173cd4d61e5e5d0c31942eb3c194785f964a82b2c3e05b4b302bccc0924fa4c4ef90854e42865db11f458d3b6a62afddee833f3eb069cd521b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000136006d00019999992e234dae75c793f67a35089c9d99245e1c58470ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f43ede3eca2a72b3aecc820e955b36f38437d0139588e6a0c2ddd26feeb64f039a2c41296fcb3f564001006d00010000002e234dae75c793f67a35089c9d99245e1c58470ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000bb83ede3eca2a72b3aecc820e955b36f38437d013958ad599c3a0ff1de082011efddc58f1908eb6e6d801005601000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2b4e16d0168e52d35cacd2c6185b44281ec28c9dc3ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000" + ); + + assertEq(IERC20(USDC_ADDR).balanceOf(ALICE), 99574171); + + vm.stopPrank(); + } + + function testSplitOutputCyclicSwapIntegration() public { + deal(USDC_ADDR, ALICE, 100 * 10 ** 6); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(USDC_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); + // Encoded solution generated using `test_split_output_cyclic_swap` + // but manually replacing the executor addresses with the ones in this test + // `dd8559c917393fc8dd2b4dd289c52ff445fde1b0` to `2e234dae75c793f67a35089c9d99245e1c58470b` + // `f6c5be66fff9dc69962d73da0a617a827c382329` to `5615deb798bb3e4dfa0139dfa1b3d433cc23b72f` + (bool success,) = tychoRouterAddr.call( + hex"d499aa880000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005eea514000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000000000000000067f6be9400000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067cf389c000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000041c02ad8eceede50085f35ce8e8313ebbac9b379396c6e72a35bb4df0970cbdaaa1a91e6f787641af55b13b926199c844df42fdd2ae7bb287db7e5cc2a8bc1d7f51b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000136005600010000005615deb798bb3e4dfa0139dfa1b3d433cc23b72fa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48b4e16d0168e52d35cacd2c6185b44281ec28c9dc3ede3eca2a72b3aecc820e955b36f38437d0139501006d01009999992e234dae75c793f67a35089c9d99245e1c58470bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480001f43ede3eca2a72b3aecc820e955b36f38437d0139588e6a0c2ddd26feeb64f039a2c41296fcb3f564000006d01000000002e234dae75c793f67a35089c9d99245e1c58470bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000bb83ede3eca2a72b3aecc820e955b36f38437d013958ad599c3a0ff1de082011efddc58f1908eb6e6d80000000000000000000000" + ); + + assertEq(IERC20(USDC_ADDR).balanceOf(ALICE), 99525908); + + vm.stopPrank(); + } + // Base Network Tests // Make sure to set the RPC_URL to base network function testSwapSingleBase() public { diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index ea2b191..92d1af8 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -206,6 +206,11 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { } let encoded_swaps = self.ple_encode(swaps); + let tokens_len = if solution.given_token == solution.checked_token { + tokens.len() - 1 + } else { + tokens.len() + }; let method_calldata = if let Some(permit2) = self.permit2.clone() { let (permit, signature) = permit2.get_permit( &solution.router_address, @@ -220,7 +225,7 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { biguint_to_u256(&min_amount_out), wrap, unwrap, - U256::from(tokens.len()), + U256::from(tokens_len), bytes_to_address(&solution.receiver)?, permit, signature.as_bytes().to_vec(), @@ -235,7 +240,7 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { biguint_to_u256(&min_amount_out), wrap, unwrap, - U256::from(tokens.len()), + U256::from(tokens_len), bytes_to_address(&solution.receiver)?, encoded_swaps, ) @@ -1212,4 +1217,448 @@ mod tests { let hex_calldata = encode(&calldata); println!("{}", hex_calldata); } + + #[test] + fn test_cyclic_sequential_swap() { + // This test has start and end tokens that are the same + // The flow is: + // USDC -> WETH -> USDC using two pools + + // Set up a mock private key for signing (Alice's pk in our router tests) + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + // Create two Uniswap V3 pools for the cyclic swap + // USDC -> WETH (Pool 1) + let swap_usdc_weth = Swap { + component: ProtocolComponent { + id: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640".to_string(), /* USDC-WETH USV3 + * Pool 1 */ + protocol_system: "uniswap_v3".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(500).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: usdc.clone(), + token_out: weth.clone(), + split: 0f64, + }; + + // WETH -> USDC (Pool 2) + let swap_weth_usdc = Swap { + component: ProtocolComponent { + id: "0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8".to_string(), /* USDC-WETH USV3 + * Pool 2 */ + protocol_system: "uniswap_v3".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(3000).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = + SplitSwapStrategyEncoder::new(eth_chain(), swap_encoder_registry, Some(private_key)) + .unwrap(); + + let solution = Solution { + exact_out: false, + given_token: usdc.clone(), + given_amount: BigUint::from_str("100000000").unwrap(), // 100 USDC (6 decimals) + checked_token: usdc.clone(), + expected_amount: None, + checked_amount: Some(BigUint::from_str("99889294").unwrap()), /* Expected output from + * test */ + slippage: None, + swaps: vec![swap_usdc_weth, swap_weth_usdc], + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + let hex_calldata = hex::encode(&calldata); + let expected_input = [ + "d499aa88", // selector + "0000000000000000000000000000000000000000000000000000000005f5e100", // given amount + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // given token + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // checked token + "0000000000000000000000000000000000000000000000000000000005f4308e", // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap action + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap action + "0000000000000000000000000000000000000000000000000000000000000002", // tokens length + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + ] + .join(""); + + let expected_swaps = [ + "00000000000000000000000000000000000000000000000000000000000000de", // length of ple encoded swaps without padding + "006d", // ple encoded swaps + "00", // token in index + "01", // token out index + "000000", // split + "dd8559c917393fc8dd2b4dd289c52ff445fde1b0", // executor address + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token in + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token out + "0001f4", // pool fee + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", // component id + "01", // zero2one + "006d", // ple encoded swaps + "01", // token in index + "00000000", // split + "dd8559c917393fc8dd2b4dd289c52ff445fde1b0", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token out + "000bb8", // pool fee + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "8ad599c3a0ff1de082011efddc58f1908eb6e6d8", // component id + "000000", // zero2one + ] + .join(""); + + assert_eq!(hex_calldata[..520], expected_input); + assert_eq!(hex_calldata[1288..], expected_swaps); + } + + #[test] + fn test_split_input_cyclic_swap() { + // 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 ─┘ + + // Set up a mock private key for signing (Alice's pk in our router tests) + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + // USDC -> WETH (Pool 1) - 60% of input + let swap_usdc_weth_pool1 = Swap { + component: ProtocolComponent { + id: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640".to_string(), /* USDC-WETH USV3 + * Pool 1 */ + protocol_system: "uniswap_v3".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(500).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: usdc.clone(), + token_out: weth.clone(), + split: 0.6f64, // 60% of input + }; + + // USDC -> WETH (Pool 2) - 40% of input (remaining) + let swap_usdc_weth_pool2 = Swap { + component: ProtocolComponent { + id: "0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8".to_string(), /* USDC-WETH USV3 + * Pool 2 */ + protocol_system: "uniswap_v3".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(3000).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: usdc.clone(), + token_out: weth.clone(), + split: 0f64, // Remaining 40% + }; + + // WETH -> USDC (Pool 2) + let swap_weth_usdc_pool2 = Swap { + component: ProtocolComponent { + id: "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc".to_string(), /* USDC-WETH USV2 + * Pool 2 */ + protocol_system: "uniswap_v2".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(3000).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0.0f64, + }; + + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SplitSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + Some(private_key.clone()), + ) + .unwrap(); + + let solution = Solution { + exact_out: false, + given_token: usdc.clone(), + given_amount: BigUint::from_str("100000000").unwrap(), // 100 USDC (6 decimals) + checked_token: usdc.clone(), + expected_amount: None, + checked_amount: Some(BigUint::from_str("99574171").unwrap()), /* Expected output from + * test */ + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + slippage: None, + swaps: vec![swap_usdc_weth_pool1, swap_usdc_weth_pool2, swap_weth_usdc_pool2], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let hex_calldata = hex::encode(&calldata); + let expected_input = [ + "d499aa88", // selector + "0000000000000000000000000000000000000000000000000000000005f5e100", // given amount + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // given token + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // checked token + "0000000000000000000000000000000000000000000000000000000005ef619b", // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap action + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap action + "0000000000000000000000000000000000000000000000000000000000000002", // tokens length + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + ] + .join(""); + let expected_swaps = [ + "0000000000000000000000000000000000000000000000000000000000000136", // length of ple encoded swaps without padding + "006d", // ple encoded swaps + "00", // token in index + "01", // token out index + "999999", // split + "dd8559c917393fc8dd2b4dd289c52ff445fde1b0", // executor address + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token in + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token out + "0001f4", // pool fee + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", // component id + "01", // zero2one + "006d", // ple encoded swaps + "00", // token in index + "01", // token out index + "000000", // split + "dd8559c917393fc8dd2b4dd289c52ff445fde1b0", // executor address + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token in + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token out + "000bb8", // pool fee + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "8ad599c3a0ff1de082011efddc58f1908eb6e6d8", // component id + "01", // zero2one + "0056", // ple encoded swaps + "01", // token in index + "00", // token out index + "000000", // split + "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address, + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "b4e16d0168e52d35cacd2c6185b44281ec28c9dc", // component id, + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "00", // zero2one + "00000000000000000000" // padding + ] + .join(""); + assert_eq!(hex_calldata[..520], expected_input); + assert_eq!(hex_calldata[1288..], expected_swaps); + } + + #[test] + fn test_split_output_cyclic_swap() { + // This test has start and end tokens that are the same + // The flow is: + // ┌─── (USV3, 60% split) ───┐ + // │ │ + // USDC ──(USV2) ── WETH──| ├─> USDC + // │ │ + // └─── (USV3, 40% split) ───┘ + + // Set up a mock private key for signing (Alice's pk in our router tests) + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let swap_usdc_weth_v2 = Swap { + component: ProtocolComponent { + id: "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc".to_string(), // USDC-WETH USV2 + protocol_system: "uniswap_v2".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(500).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: usdc.clone(), + token_out: weth.clone(), + split: 0.0f64, + }; + + let swap_weth_usdc_v3_pool1 = Swap { + component: ProtocolComponent { + id: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640".to_string(), /* USDC-WETH USV3 + * Pool 1 */ + protocol_system: "uniswap_v3".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(500).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0.6f64, + }; + + let swap_weth_usdc_v3_pool2 = Swap { + component: ProtocolComponent { + id: "0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8".to_string(), /* USDC-WETH USV3 + * Pool 2 */ + protocol_system: "uniswap_v3".to_string(), + static_attributes: { + let mut attrs = HashMap::new(); + attrs.insert( + "fee".to_string(), + Bytes::from(BigInt::from(3000).to_signed_bytes_be()), + ); + attrs + }, + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0.0f64, + }; + + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SplitSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + Some(private_key.clone()), + ) + .unwrap(); + + let solution = Solution { + exact_out: false, + given_token: usdc.clone(), + given_amount: BigUint::from_str("100000000").unwrap(), // 100 USDC (6 decimals) + checked_token: usdc.clone(), + expected_amount: None, + checked_amount: Some(BigUint::from_str("99525908").unwrap()), /* Expected output from + * test */ + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + slippage: None, + swaps: vec![swap_usdc_weth_v2, swap_weth_usdc_v3_pool1, swap_weth_usdc_v3_pool2], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let hex_calldata = hex::encode(&calldata); + let expected_input = [ + "d499aa88", // selector + "0000000000000000000000000000000000000000000000000000000005f5e100", // given amount + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // given token + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // checked token + "0000000000000000000000000000000000000000000000000000000005eea514", // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap action + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap action + "0000000000000000000000000000000000000000000000000000000000000002", // tokens length + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + ] + .join(""); + + let expected_swaps = [ + "0000000000000000000000000000000000000000000000000000000000000136", // length of ple encoded swaps without padding + "0056", // ple encoded swaps + "00", // token in index + "01", // token out index + "000000", // split + "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token in + "b4e16d0168e52d35cacd2c6185b44281ec28c9dc", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "01", // zero2one + "006d", // ple encoded swaps + "01", // token in index + "00", // token out index + "999999", // split + "dd8559c917393fc8dd2b4dd289c52ff445fde1b0", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token out + "0001f4", // pool fee + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", // component id + "00", // zero2one + "006d", // ple encoded swaps + "01", // token in index + "00", // token out index + "000000", // split + "dd8559c917393fc8dd2b4dd289c52ff445fde1b0", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token out + "000bb8", // pool fee + "3ede3eca2a72b3aecc820e955b36f38437d01395", // router address + "8ad599c3a0ff1de082011efddc58f1908eb6e6d8", // component id + "00", // zero2one + "00000000000000000000" // padding + ] + .join(""); + + assert_eq!(hex_calldata[..520], expected_input); + assert_eq!(hex_calldata[1288..], expected_swaps); + } } diff --git a/src/encoding/evm/strategy_encoder/strategy_validators.rs b/src/encoding/evm/strategy_encoder/strategy_validators.rs index 266769d..d4bf22f 100644 --- a/src/encoding/evm/strategy_encoder/strategy_validators.rs +++ b/src/encoding/evm/strategy_encoder/strategy_validators.rs @@ -143,11 +143,14 @@ impl SplitSwapValidator { // Build directed graph of token flows let mut graph: HashMap<&Bytes, HashSet<&Bytes>> = HashMap::new(); + let mut all_tokens = HashSet::new(); for swap in swaps { graph .entry(&swap.token_in) .or_default() .insert(&swap.token_out); + all_tokens.insert(&swap.token_in); + all_tokens.insert(&swap.token_out); } // BFS from validation_given @@ -160,8 +163,8 @@ impl SplitSwapValidator { continue; } - // Early success check - if token == checked_token && visited.len() == graph.len() + 1 { + // Early success check - if we've reached the checked token and visited all tokens + if token == checked_token && visited.len() == all_tokens.len() { return Ok(()); } @@ -174,6 +177,13 @@ impl SplitSwapValidator { } } + // After BFS completes, check if both conditions are met: + // 1. The checked token is in the visited set + // 2. All unique tokens from the swaps are visited + if visited.contains(checked_token) && visited.len() == all_tokens.len() { + return Ok(()); + } + // If we get here, either checked_token wasn't reached or not all tokens were visited if !visited.contains(checked_token) { Err(EncodingError::InvalidInput( @@ -291,6 +301,41 @@ mod tests { )); } + #[test] + fn test_validate_path_cyclic_swap() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let cyclic_swaps = vec![ + Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: usdc.clone(), + token_out: weth.clone(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "pool2".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0f64, + }, + ]; + + // Test with USDC as both given token and checked token + let result = validator.validate_swap_path(&cyclic_swaps, &usdc, &usdc, &None, ð, &weth); + assert_eq!(result, Ok(())); + } + #[test] fn test_validate_path_unreachable_checked_token() { let validator = SplitSwapValidator; diff --git a/src/encoding/evm/swap_encoder/builder.rs b/src/encoding/evm/swap_encoder/builder.rs index 2b50334..075dad0 100644 --- a/src/encoding/evm/swap_encoder/builder.rs +++ b/src/encoding/evm/swap_encoder/builder.rs @@ -25,7 +25,6 @@ impl SwapEncoderBuilder { "uniswap_v2" => Ok(Box::new(UniswapV2SwapEncoder::new(self.executor_address))), "vm:balancer_v2" => Ok(Box::new(BalancerV2SwapEncoder::new(self.executor_address))), "uniswap_v3" => Ok(Box::new(UniswapV3SwapEncoder::new(self.executor_address))), - // TODO replace this with V4 encoder once implemented "uniswap_v4" => Ok(Box::new(UniswapV4SwapEncoder::new(self.executor_address))), _ => Err(EncodingError::FatalError(format!( "Unknown protocol system: {}", diff --git a/src/encoding/evm/tycho_encoder.rs b/src/encoding/evm/tycho_encoder.rs index 0937fe9..183e215 100644 --- a/src/encoding/evm/tycho_encoder.rs +++ b/src/encoding/evm/tycho_encoder.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use num_bigint::BigUint; use tycho_core::Bytes; @@ -92,6 +94,50 @@ impl EVMTychoEncoder { } } } + + let mut solution_tokens = vec![]; + let mut split_tokens_already_considered = HashSet::new(); + for (i, swap) in solution.swaps.iter().enumerate() { + // so we don't count the split tokens more than once + if swap.split != 0.0 { + if !split_tokens_already_considered.contains(&swap.token_in) { + solution_tokens.push(swap.token_in.clone()); + split_tokens_already_considered.insert(swap.token_in.clone()); + } + } else { + // it might be the last swap of the split or a regular swap + if !split_tokens_already_considered.contains(&swap.token_in) { + solution_tokens.push(swap.token_in.clone()); + } + } + if i == solution.swaps.len() - 1 { + solution_tokens.push(swap.token_out.clone()); + } + } + + if solution_tokens.len() != + solution_tokens + .iter() + .cloned() + .collect::>() + .len() + { + if let Some(last_swap) = solution.swaps.last() { + if solution.swaps[0].token_in != last_swap.token_out { + return Err(EncodingError::FatalError( + "Cyclical swaps are only allowed if they are the first and last token of a solution".to_string(), + )); + } else { + // it is a valid cyclical swap + // we don't support any wrapping or unwrapping in this case + if let Some(_native_action) = solution.clone().native_action { + return Err(EncodingError::FatalError( + "Wrapping/Unwrapping is not available in cyclical swaps".to_string(), + )); + } + } + } + } Ok(()) } } @@ -148,6 +194,14 @@ mod tests { Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap() } + fn usdc() -> Bytes { + Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap() + } + + fn wbtc() -> Bytes { + Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap() + } + #[derive(Clone)] struct MockStrategy; @@ -436,4 +490,230 @@ mod tests { ) ); } + + #[test] + fn test_validate_cyclical_swap() { + // This validation passes because the cyclical swap is the first and last token + // 50% -> WETH + // DAI - -> DAI + // 50% -> WETH + // (some of the pool addresses in this test are fake) + let encoder = get_mocked_tycho_encoder(); + let swaps = vec![ + Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: weth(), + split: 0.5f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: weth(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth(), + token_out: dai(), + split: 0f64, + }, + ]; + + let solution = Solution { + exact_out: false, + given_token: dai(), + checked_token: dai(), + swaps, + ..Default::default() + }; + + let result = encoder.validate_solution(&solution); + + assert!(result.is_ok()); + } + + #[test] + fn test_validate_cyclical_swap_fail() { + // This test should fail because the cyclical swap is not the first and last token + // DAI -> WETH -> USDC -> DAI -> WBTC + // (some of the pool addresses in this test are fake) + let encoder = get_mocked_tycho_encoder(); + let swaps = vec![ + Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: weth(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth(), + token_out: usdc(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: usdc(), + token_out: dai(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: wbtc(), + split: 0f64, + }, + ]; + + let solution = Solution { + exact_out: false, + given_token: dai(), + checked_token: wbtc(), + swaps, + ..Default::default() + }; + + let result = encoder.validate_solution(&solution); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap(), + EncodingError::FatalError( + "Cyclical swaps are only allowed if they are the first and last token of a solution".to_string() + ) + ); + } + #[test] + fn test_validate_cyclical_swap_split_output() { + // This validation passes because it is a valid cyclical swap + // -> WETH + // WETH -> DAI + // -> WETH + // (some of the pool addresses in this test are fake) + let encoder = get_mocked_tycho_encoder(); + let swaps = vec![ + Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth(), + token_out: dai(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: weth(), + split: 0.5f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: weth(), + split: 0f64, + }, + ]; + + let solution = Solution { + exact_out: false, + given_token: weth(), + checked_token: weth(), + swaps, + ..Default::default() + }; + + let result = encoder.validate_solution(&solution); + + assert!(result.is_ok()); + } + + #[test] + fn test_validate_cyclical_swap_native_action_fail() { + // This validation fails because there is a native action with a valid cyclical swap + // ETH -> WETH -> DAI -> WETH + // (some of the pool addresses in this test are fake) + let encoder = get_mocked_tycho_encoder(); + let swaps = vec![ + Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth(), + token_out: dai(), + split: 0f64, + }, + Swap { + component: ProtocolComponent { + id: "0x0000000000000000000000000000000000000000".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai(), + token_out: weth(), + split: 0f64, + }, + ]; + + let solution = Solution { + exact_out: false, + given_token: eth(), + checked_token: weth(), + swaps, + native_action: Some(NativeAction::Wrap), + ..Default::default() + }; + + let result = encoder.validate_solution(&solution); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap(), + EncodingError::FatalError( + "Wrapping/Unwrapping is not available in cyclical swaps" + .to_string() + .to_string() + ) + ); + } } diff --git a/src/encoding/evm/utils.rs b/src/encoding/evm/utils.rs index 96e04ea..f284ee4 100644 --- a/src/encoding/evm/utils.rs +++ b/src/encoding/evm/utils.rs @@ -74,10 +74,10 @@ pub fn get_min_amount_for_solution(solution: Solution) -> BigUint { if let (Some(expected_amount), Some(slippage)) = (solution.expected_amount.as_ref(), solution.slippage) { - let one_hundred = BigUint::from(100u32); - let slippage_percent = BigUint::from((slippage * 100.0) as u32); - let multiplier = &one_hundred - slippage_percent; - let expected_amount_with_slippage = (expected_amount * &multiplier) / &one_hundred; + let bps = BigUint::from(10_000u32); + let slippage_percent = BigUint::from((slippage * 10000.0) as u32); + let multiplier = &bps - slippage_percent; + let expected_amount_with_slippage = (expected_amount * &multiplier) / &bps; min_amount_out = max(min_amount_out, expected_amount_with_slippage); } min_amount_out @@ -133,3 +133,27 @@ pub fn get_runtime() -> Result<(Handle, Option>), EncodingError> { } } } +#[cfg(test)] +mod tests { + use num_bigint::BigUint; + + use super::*; + use crate::encoding::models::Solution; + + #[test] + fn test_min_amount_out_small_slippage() { + // Tests that the calculation's precision is high enough to support a slippage of 0.1%. + + let solution = Solution { + exact_out: false, + given_amount: BigUint::from(1000000000000000000u64), + checked_amount: None, + slippage: Some(0.001f64), + expected_amount: Some(BigUint::from(1000000000000000000u64)), + ..Default::default() + }; + + let min_amount_out = get_min_amount_for_solution(solution); + assert_eq!(min_amount_out, BigUint::from(999000000000000000u64)); + } +}