From d8b44f623b8175f4759f8a8cbd42c46e5abad3b4 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Wed, 29 Jan 2025 16:56:45 -0500 Subject: [PATCH 1/4] fix: Remove exactOut from USV3 encoding - We don't support exactOut atm --- src/encoding/evm/swap_encoder/encoders.rs | 3 --- src/encoding/models.rs | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/encoding/evm/swap_encoder/encoders.rs b/src/encoding/evm/swap_encoder/encoders.rs index de13cfc..f4fbf24 100644 --- a/src/encoding/evm/swap_encoder/encoders.rs +++ b/src/encoding/evm/swap_encoder/encoders.rs @@ -113,7 +113,6 @@ impl SwapEncoder for UniswapV3SwapEncoder { bytes_to_address(&encoding_context.receiver)?, component_id, zero_to_one, - encoding_context.exact_out, ); Ok(args.abi_encode_packed()) @@ -259,8 +258,6 @@ mod tests { "88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", // zero for one "00", - // exact out - "00", )) ); } diff --git a/src/encoding/models.rs b/src/encoding/models.rs index 4e2cbbd..5e37500 100644 --- a/src/encoding/models.rs +++ b/src/encoding/models.rs @@ -65,6 +65,7 @@ pub struct Transaction { #[allow(dead_code)] pub struct EncodingContext { pub receiver: Bytes, + // TODO should we keep this? pub exact_out: bool, pub router_address: Bytes, } From ca32446a9ee28118d8857c02abefd24389485b7e Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Wed, 29 Jan 2025 19:51:17 -0500 Subject: [PATCH 2/4] feat: UniswapV3Executor and integration tests - Note: I think we can get the fee straight from the pool... why did we always encode this and send it from the solver? Is this bound to change sometimes? --- foundry/src/TychoRouter.sol | 6 +- foundry/src/executors/UniswapV3Executor.sol | 85 +++++++++++++++++++ foundry/test/Constants.sol | 3 + foundry/test/TychoRouter.t.sol | 56 +++++++++++- foundry/test/TychoRouterTestSetup.sol | 22 ++++- .../test/executors/UniswapV3Executor.t.sol | 68 +++++++++++++++ 6 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 foundry/src/executors/UniswapV3Executor.sol create mode 100644 foundry/test/executors/UniswapV3Executor.t.sol diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index 52b00e1..2b98667 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -369,8 +369,8 @@ contract TychoRouter is int256 amount0Delta, int256 amount1Delta, bytes calldata data - ) internal view returns (uint256 amountOwed, address tokenOwed) { - address tokenIn = address(bytes20(data[0:20])); + ) internal view returns (uint256 amountOwed, address tokenIn) { + tokenIn = address(bytes20(data[0:20])); address tokenOut = address(bytes20(data[20:40])); uint24 poolFee = uint24(bytes3(data[40:43])); @@ -382,6 +382,6 @@ contract TychoRouter is amountOwed = amount0Delta > 0 ? uint256(amount0Delta) : uint256(amount1Delta); - return (amountOwed, tokenOwed); + return (amountOwed, tokenIn); } } diff --git a/foundry/src/executors/UniswapV3Executor.sol b/foundry/src/executors/UniswapV3Executor.sol new file mode 100644 index 0000000..84c0044 --- /dev/null +++ b/foundry/src/executors/UniswapV3Executor.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@interfaces/IExecutor.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; + +error UniswapV3Executor__InvalidDataLength(); + +contract UniswapV3Executor is IExecutor { + uint160 private constant MIN_SQRT_RATIO = 4295128739; + uint160 private constant MAX_SQRT_RATIO = + 1461446703485210103287273052203988822378723970342; + address private constant factoryV3 = + 0x1F98431c8aD98523631AE4a59f267346ea31F984; + + // slither-disable-next-line locked-ether + function swap(uint256 amountIn, bytes calldata data) + external + payable + returns (uint256 amountOut) + { + ( + address tokenIn, + address tokenOut, + uint24 fee, + address receiver, + address target, + bool zeroForOne + ) = _decodeData(data); + int256 amount0; + int256 amount1; + IUniswapV3Pool pool = IUniswapV3Pool(target); + + bytes memory callbackData = _makeV3CallbackData(tokenIn, tokenOut, fee); + + { + (amount0, amount1) = pool.swap( + receiver, + zeroForOne, + // positive means exactIn + int256(amountIn), + zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1, + callbackData + ); + } + + if (zeroForOne) { + amountOut = amount1 > 0 ? uint256(amount1) : uint256(-amount1); + } else { + amountOut = amount0 > 0 ? uint256(amount0) : uint256(-amount0); + } + } + + function _decodeData(bytes calldata data) + internal + pure + returns ( + address tokenIn, + address tokenOut, + uint24 fee, + address receiver, + address target, + bool zeroForOne + ) + { + if (data.length != 84) { + revert UniswapV3Executor__InvalidDataLength(); + } + tokenIn = address(bytes20(data[0:20])); + tokenOut = address(bytes20(data[20:40])); + fee = uint24(bytes3(data[40:43])); + receiver = address(bytes20(data[43:63])); + target = address(bytes20(data[63:83])); + zeroForOne = uint8(data[83]) > 0; + } + + function _makeV3CallbackData(address tokenIn, address tokenOut, uint24 fee) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(tokenIn, tokenOut, fee); + } +} diff --git a/foundry/test/Constants.sol b/foundry/test/Constants.sol index 8776c7d..a82b9e1 100644 --- a/foundry/test/Constants.sol +++ b/foundry/test/Constants.sol @@ -32,6 +32,9 @@ contract Constants is Test { address WETH_WBTC_POOL = 0xBb2b8038a1640196FbE3e38816F3e67Cba72D940; address USDC_WBTC_POOL = 0x004375Dff511095CC5A197A54140a24eFEF3A416; + // uniswap v3 + address DAI_WETH_USV3 = 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8; + /** * @dev Deploys a dummy contract with non-empty bytecode */ diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index d736494..15feb1d 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -215,7 +215,7 @@ contract TychoRouterTest is TychoRouterTestSetup { function testSwapSimple() public { // Trade 1 WETH for DAI with 1 swap on Uniswap V2 // 1 WETH -> DAI - // (univ2) + // (USV2) uint256 amountIn = 1 ether; deal(WETH_ADDR, tychoRouterAddr, amountIn); @@ -234,7 +234,7 @@ contract TychoRouterTest is TychoRouterTestSetup { bytes[] memory swaps = new bytes[](1); swaps[0] = swap; - tychoRouter.ExposedSwap(amountIn, 2, pleEncode(swaps)); + tychoRouter.exposedSwap(amountIn, 2, pleEncode(swaps)); uint256 daiBalance = IERC20(DAI_ADDR).balanceOf(tychoRouterAddr); assertEq(daiBalance, 2630432278145144658455); @@ -271,7 +271,7 @@ contract TychoRouterTest is TychoRouterTestSetup { encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) ); - tychoRouter.ExposedSwap(amountIn, 3, pleEncode(swaps)); + tychoRouter.exposedSwap(amountIn, 3, pleEncode(swaps)); uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); assertEq(usdcBalance, 2610580090); @@ -332,7 +332,7 @@ contract TychoRouterTest is TychoRouterTestSetup { encodeUniswapV2Swap(DAI_ADDR, DAI_USDC_POOL, tychoRouterAddr, true) ); - tychoRouter.ExposedSwap(amountIn, 4, pleEncode(swaps)); + tychoRouter.exposedSwap(amountIn, 4, pleEncode(swaps)); uint256 usdcBalance = IERC20(USDC_ADDR).balanceOf(tychoRouterAddr); assertEq(usdcBalance, 2581503157); @@ -606,4 +606,52 @@ contract TychoRouterTest is TychoRouterTestSetup { vm.stopPrank(); } + + function testUSV3Callback() public { + uint24 poolFee = 3000; + uint256 amountOwed = 1000000000000000000; + deal(WETH_ADDR, tychoRouterAddr, amountOwed); + uint256 initialPoolReserve = IERC20(WETH_ADDR).balanceOf(DAI_WETH_USV3); + + vm.startPrank(DAI_WETH_USV3); + tychoRouter.uniswapV3SwapCallback( + -2631245338449998525223, + int256(amountOwed), + abi.encodePacked(WETH_ADDR, DAI_ADDR, poolFee) + ); + vm.stopPrank(); + + uint256 finalPoolReserve = IERC20(WETH_ADDR).balanceOf(DAI_WETH_USV3); + assertEq(finalPoolReserve - initialPoolReserve, amountOwed); + } + + function testSwapSingleUSV3() 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 = encodeSwap( + uint8(0), + uint8(1), + uint24(0), + address(usv3Executor), + bytes4(0), + protocolData + ); + + bytes[] memory swaps = new bytes[](1); + swaps[0] = swap; + + tychoRouter.exposedSwap(amountIn, 2, pleEncode(swaps)); + + uint256 finalBalance = IERC20(DAI_ADDR).balanceOf(tychoRouterAddr); + assertGe(finalBalance, expAmountOut); + } } diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index a4404c0..22ae546 100644 --- a/foundry/test/TychoRouterTestSetup.sol +++ b/foundry/test/TychoRouterTestSetup.sol @@ -6,6 +6,7 @@ import "./Constants.sol"; import "./mock/MockERC20.sol"; import "@src/TychoRouter.sol"; import {WETH} from "../lib/permit2/lib/solmate/src/tokens/WETH.sol"; +import "../src/executors/UniswapV3Executor.sol"; contract TychoRouterExposed is TychoRouter { constructor(address _permit2, address weth, address usv3Factory) @@ -20,7 +21,7 @@ contract TychoRouterExposed is TychoRouter { return _unwrapETH(amount); } - function ExposedSwap( + function exposedSwap( uint256 amountIn, uint256 nTokens, bytes calldata swaps @@ -34,6 +35,7 @@ contract TychoRouterTestSetup is Test, Constants { address tychoRouterAddr; address permit2Address = address(0x000000000022D473030F116dDEE9F6B43aC78BA3); UniswapV2Executor public usv2Executor; + UniswapV3Executor public usv3Executor; MockERC20[] tokens; function setUp() public { @@ -41,8 +43,9 @@ contract TychoRouterTestSetup is Test, Constants { vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); vm.startPrank(ADMIN); + address factoryV3 = address(0x1F98431c8aD98523631AE4a59f267346ea31F984); tychoRouter = - new TychoRouterExposed(permit2Address, WETH_ADDR, address(1)); + new TychoRouterExposed(permit2Address, WETH_ADDR, factoryV3); tychoRouterAddr = address(tychoRouter); tychoRouter.grantRole(keccak256("FUND_RESCUER_ROLE"), FUND_RESCUER); tychoRouter.grantRole(keccak256("FEE_SETTER_ROLE"), FEE_SETTER); @@ -55,8 +58,10 @@ contract TychoRouterTestSetup is Test, Constants { vm.stopPrank(); usv2Executor = new UniswapV2Executor(); + usv3Executor = new UniswapV3Executor(); vm.startPrank(EXECUTOR_SETTER); tychoRouter.setExecutor(address(usv2Executor)); + tychoRouter.setExecutor(address(usv3Executor)); vm.stopPrank(); vm.startPrank(BOB); @@ -190,4 +195,17 @@ contract TychoRouterTestSetup is Test, Constants { ) internal pure returns (bytes memory) { return abi.encodePacked(tokenIn, target, receiver, zero2one); } + + function encodeUniswapV3Swap( + address tokenIn, + address tokenOut, + address receiver, + address target, + bool zero2one + ) internal view returns (bytes memory) { + IUniswapV3Pool pool = IUniswapV3Pool(target); + return abi.encodePacked( + tokenIn, tokenOut, pool.fee(), receiver, target, zero2one + ); + } } diff --git a/foundry/test/executors/UniswapV3Executor.t.sol b/foundry/test/executors/UniswapV3Executor.t.sol new file mode 100644 index 0000000..b73d580 --- /dev/null +++ b/foundry/test/executors/UniswapV3Executor.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@src/executors/UniswapV3Executor.sol"; +import {Test} from "../../lib/forge-std/src/Test.sol"; +import {Constants} from "../Constants.sol"; + +contract UniswapV3ExecutorExposed is UniswapV3Executor { + function decodeData(bytes calldata data) + external + pure + returns ( + address inToken, + address outToken, + uint24 fee, + address receiver, + address target, + bool zeroForOne + ) + { + return _decodeData(data); + } +} + +contract UniswapV3ExecutorTest is UniswapV3ExecutorExposed, Test, Constants { + using SafeERC20 for IERC20; + + UniswapV3ExecutorExposed uniswapV3Exposed; + IERC20 WETH = IERC20(WETH_ADDR); + IERC20 DAI = IERC20(DAI_ADDR); + + function setUp() public { + uint256 forkBlock = 17323404; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + uniswapV3Exposed = new UniswapV3ExecutorExposed(); + } + + function testDecodeParams() public view { + uint24 expectedPoolFee = 500; + bytes memory data = abi.encodePacked( + WETH_ADDR, DAI_ADDR, expectedPoolFee, address(2), address(3), false + ); + + ( + address tokenIn, + address tokenOut, + uint24 fee, + address receiver, + address target, + bool zeroForOne + ) = uniswapV3Exposed.decodeData(data); + + assertEq(tokenIn, WETH_ADDR); + assertEq(tokenOut, DAI_ADDR); + assertEq(fee, expectedPoolFee); + assertEq(receiver, address(2)); + assertEq(target, address(3)); + assertEq(zeroForOne, false); + } + + function testDecodeParamsInvalidDataLength() public { + bytes memory invalidParams = + abi.encodePacked(WETH_ADDR, address(2), address(3)); + + vm.expectRevert(UniswapV3Executor__InvalidDataLength.selector); + uniswapV3Exposed.decodeData(invalidParams); + } +} From 92b4ee00d308c4ecb385ad7838c957708c9b5977 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Wed, 29 Jan 2025 19:53:22 -0500 Subject: [PATCH 3/4] chore: remove unused factory variable --- foundry/src/executors/UniswapV3Executor.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/foundry/src/executors/UniswapV3Executor.sol b/foundry/src/executors/UniswapV3Executor.sol index 84c0044..64ae64d 100644 --- a/foundry/src/executors/UniswapV3Executor.sol +++ b/foundry/src/executors/UniswapV3Executor.sol @@ -11,8 +11,6 @@ contract UniswapV3Executor is IExecutor { uint160 private constant MIN_SQRT_RATIO = 4295128739; uint160 private constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; - address private constant factoryV3 = - 0x1F98431c8aD98523631AE4a59f267346ea31F984; // slither-disable-next-line locked-ether function swap(uint256 amountIn, bytes calldata data) From 83bbabddd102a476ec641f49e1ac434b060601b3 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 30 Jan 2025 10:33:09 -0500 Subject: [PATCH 4/4] chore: rename amountOwed -> amountIn ...for consistency. Also remove comment. We will keep exact_out to avoid future interface changes. --- foundry/src/TychoRouter.sol | 6 +++--- src/encoding/models.rs | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/foundry/src/TychoRouter.sol b/foundry/src/TychoRouter.sol index 2b98667..dbdf089 100644 --- a/foundry/src/TychoRouter.sol +++ b/foundry/src/TychoRouter.sol @@ -369,7 +369,7 @@ contract TychoRouter is int256 amount0Delta, int256 amount1Delta, bytes calldata data - ) internal view returns (uint256 amountOwed, address tokenIn) { + ) internal view returns (uint256 amountIn, address tokenIn) { tokenIn = address(bytes20(data[0:20])); address tokenOut = address(bytes20(data[20:40])); uint24 poolFee = uint24(bytes3(data[40:43])); @@ -379,9 +379,9 @@ contract TychoRouter is _usv3Factory, tokenIn, tokenOut, poolFee ); - amountOwed = + amountIn = amount0Delta > 0 ? uint256(amount0Delta) : uint256(amount1Delta); - return (amountOwed, tokenIn); + return (amountIn, tokenIn); } } diff --git a/src/encoding/models.rs b/src/encoding/models.rs index 5e37500..4e2cbbd 100644 --- a/src/encoding/models.rs +++ b/src/encoding/models.rs @@ -65,7 +65,6 @@ pub struct Transaction { #[allow(dead_code)] pub struct EncodingContext { pub receiver: Bytes, - // TODO should we keep this? pub exact_out: bool, pub router_address: Bytes, }