├── .gas-snapshot ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── audit ├── PeckShield-Audit-Report-Revert-V3utils-v1.0.pdf └── Revert_Finance_Audit_Report_ Auto-Exit_Auto-Move_Range.pdf ├── foundry.toml ├── funding.json ├── remappings.txt ├── src ├── V3Utils.sol └── automators │ ├── AutoExit.sol │ ├── AutoRange.sol │ └── Automator.sol └── test ├── IntegrationTestBase.sol └── integration ├── V3Utils.t.sol └── automators ├── AutoExit.t.sol └── AutoRange.t.sol /.gas-snapshot: -------------------------------------------------------------------------------- 1 | AutoExitTest:testDirectSendNFT() (gas: 102874) 2 | AutoExitTest:testInvalidConfig() (gas: 20868) 3 | AutoExitTest:testLimitOrder() (gas: 412532) 4 | AutoExitTest:testLiquidityChanged() (gas: 70705) 5 | AutoExitTest:testNoLiquidity() (gas: 75211) 6 | AutoExitTest:testNonOperator() (gas: 13178) 7 | AutoExitTest:testOracleCheck() (gas: 2585936) 8 | AutoExitTest:testRangesAndActions() (gas: 743447) 9 | AutoExitTest:testResetConfig() (gas: 25973) 10 | AutoExitTest:testRunNotReady() (gas: 107959) 11 | AutoExitTest:testRunWithoutApprove() (gas: 88308) 12 | AutoExitTest:testRunWithoutConfig() (gas: 44306) 13 | AutoExitTest:testSetMaxTWAPTickDifference() (gas: 20371) 14 | AutoExitTest:testSetOperator() (gas: 33849) 15 | AutoExitTest:testSetTWAPSeconds() (gas: 20328) 16 | AutoExitTest:testStopLoss() (gas: 599477) 17 | AutoExitTest:testUnauthorizedSetConfig() (gas: 20274) 18 | AutoExitTest:testValidSetConfig() (gas: 113684) 19 | AutoRangeTest:testAdjustNotAdjustable() (gas: 349075) 20 | AutoRangeTest:testAdjustOutOfRange() (gas: 314113) 21 | AutoRangeTest:testAdjustWithSwap() (gas: 829145) 22 | AutoRangeTest:testAdjustWithTooLargeSwap() (gas: 300427) 23 | AutoRangeTest:testAdjustWithoutApprove() (gas: 79701) 24 | AutoRangeTest:testAdjustWithoutConfig() (gas: 44238) 25 | AutoRangeTest:testAdjustWithoutSwap() (gas: 930257) 26 | AutoRangeTest:testDoubleAdjust() (gas: 817255) 27 | AutoRangeTest:testInvalidConfig() (gas: 20518) 28 | AutoRangeTest:testLiquidityChanged() (gas: 95930) 29 | AutoRangeTest:testNonOperator() (gas: 13420) 30 | AutoRangeTest:testOracleCheck() (gas: 2955456) 31 | AutoRangeTest:testResetConfig() (gas: 25204) 32 | AutoRangeTest:testSetMaxTWAPTickDifference() (gas: 20611) 33 | AutoRangeTest:testSetOperator() (gas: 33889) 34 | AutoRangeTest:testSetTWAPSeconds() (gas: 20536) 35 | AutoRangeTest:testUnauthorizedSetConfig() (gas: 20034) 36 | AutoRangeTest:testValidSetConfig() (gas: 102839) 37 | V3UtilsIntegrationTest:testFailEmptySwapAndIncreaseLiquidity() (gas: 50658) 38 | V3UtilsIntegrationTest:testFailEmptySwapAndMint() (gas: 34504) 39 | V3UtilsIntegrationTest:testInvalidInstructions() (gas: 105063) 40 | V3UtilsIntegrationTest:testSendEtherNotAllowed() (gas: 15717) 41 | V3UtilsIntegrationTest:testSwapAndIncreaseLiquiditBothSides() (gas: 545260) 42 | V3UtilsIntegrationTest:testSwapAndIncreaseLiquidity() (gas: 464979) 43 | V3UtilsIntegrationTest:testSwapAndMint() (gas: 742446) 44 | V3UtilsIntegrationTest:testSwapAndMintOneSided0() (gas: 838661) 45 | V3UtilsIntegrationTest:testSwapAndMintOneSided1() (gas: 728737) 46 | V3UtilsIntegrationTest:testSwapAndMintWithETH() (gas: 938727) 47 | V3UtilsIntegrationTest:testSwapDataError() (gas: 114332) 48 | V3UtilsIntegrationTest:testSwapETHUSDC() (gas: 313682) 49 | V3UtilsIntegrationTest:testSwapSlippageError() (gas: 329780) 50 | V3UtilsIntegrationTest:testSwapUSDCDAI() (gas: 344197) 51 | V3UtilsIntegrationTest:testSwapUSDCDAIWithUniswapRouter() (gas: 3197049) 52 | V3UtilsIntegrationTest:testSwapUSDCETH() (gas: 325932) 53 | V3UtilsIntegrationTest:testTransferAmountError() (gas: 445068) 54 | V3UtilsIntegrationTest:testTransferDecreaseSlippageError() (gas: 372060) 55 | V3UtilsIntegrationTest:testTransferWithChangeRange() (gas: 1056914) 56 | V3UtilsIntegrationTest:testTransferWithCompoundNoSwap() (gas: 415713) 57 | V3UtilsIntegrationTest:testTransferWithCompoundSwap() (gas: 666707) 58 | V3UtilsIntegrationTest:testUnauthorizedTransfer() (gas: 27907) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/* 8 | /broadcast/*/31337/ 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/v3-core"] 2 | path = lib/v3-core 3 | url = https://github.com/Uniswap/v3-core 4 | branch = 6562c52e8f75f0c10f9deaf44861847585fc8129 5 | [submodule "lib/v3-periphery"] 6 | path = lib/v3-periphery 7 | url = https://github.com/Uniswap/v3-periphery 8 | branch = b325bb0905d922ae61fcc7df85ee802e8df5e96c 9 | [submodule "lib/openzeppelin-contracts"] 10 | path = lib/openzeppelin-contracts 11 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 12 | branch = v4.8.1 13 | [submodule "lib/forge-std"] 14 | path = lib/forge-std 15 | url = https://github.com/foundry-rs/forge-std 16 | branch = v1.4.0 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Revert Labs Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # revert v3utils 2 | 3 | This repository contains the smart contracts for revert v3utils. 4 | 5 | It uses Foundry as development toolchain. 6 | 7 | 8 | ## Setup 9 | 10 | Install foundry 11 | 12 | https://book.getfoundry.sh/getting-started/installation 13 | 14 | Install dependencies 15 | 16 | ```sh 17 | forge install 18 | ``` 19 | 20 | 21 | ## Tests 22 | 23 | Most tests use a forked state of Ethereum Mainnet. You can run all tests with: 24 | 25 | ```sh 26 | forge test --via-ir 27 | ``` 28 | 29 | 30 | Because the v3-periphery library (Solidity v0.8 branch) in PoolAddress.sol has a different POOL_INIT_CODE_HASH than the one deployed on Mainnet this needs to be changed for the integration tests to work properly. 31 | 32 | bytes32 internal constant POOL_INIT_CODE_HASH = 0xa598dd2fba360510c5a8f02f44423a4468e902df5857dbce3ca162a43a3a31ff; 33 | 34 | needs to be changed to 35 | 36 | bytes32 internal constant POOL_INIT_CODE_HASH = 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54; 37 | 38 | 39 | 40 | ## Deployment for Pancakeswap 41 | 42 | There need to be done some minimal changes to the code and linked library interfaces. 43 | 44 | 45 | Change POOL_INIT_CODE_HASH in PoolAddress.sol to 46 | 47 | ``` 48 | bytes32 internal constant POOL_INIT_CODE_HASH = 0x6ce8eb472fa82df5469c6ab6d485f17c3ad13c8cd7af59b3d4a8026c5ce0f7e2; 49 | ``` 50 | 51 | IUniswapV3PoolState change slot0() function to this (note the uint32 for feeProtocol) 52 | 53 | ``` 54 | function slot0() 55 | external 56 | view 57 | returns ( 58 | uint160 sqrtPriceX96, 59 | int24 tick, 60 | uint16 observationIndex, 61 | uint16 observationCardinality, 62 | uint16 observationCardinalityNext, 63 | uint32 feeProtocol, 64 | bool unlocked 65 | ); 66 | ``` 67 | 68 | Add this function to IPeripheryImmutableState 69 | 70 | ``` 71 | function deployer() external view returns (address); 72 | ``` 73 | 74 | Automator.sol 75 | 76 | 77 | Add to storage variables: 78 | ``` 79 | address private immutable deployer; 80 | ``` 81 | 82 | Add to constructor: 83 | ``` 84 | deployer = npm.deployer(); 85 | ``` 86 | 87 | Change method: 88 | ``` 89 | // get pool for token 90 | function _getPool( 91 | address tokenA, 92 | address tokenB, 93 | uint24 fee 94 | ) internal view returns (IUniswapV3Pool) { 95 | return 96 | IUniswapV3Pool( 97 | PoolAddress.computeAddress( 98 | deployer, 99 | PoolAddress.getPoolKey(tokenA, tokenB, fee) 100 | ) 101 | ); 102 | } 103 | ``` -------------------------------------------------------------------------------- /audit/PeckShield-Audit-Report-Revert-V3utils-v1.0.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revert-finance/v3utils/e29e49af36ee05d2c9734fc4cdb1855c929555e9/audit/PeckShield-Audit-Report-Revert-V3utils-v1.0.pdf -------------------------------------------------------------------------------- /audit/Revert_Finance_Audit_Report_ Auto-Exit_Auto-Move_Range.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revert-finance/v3utils/e29e49af36ee05d2c9734fc4cdb1855c929555e9/audit/Revert_Finance_Audit_Report_ Auto-Exit_Auto-Move_Range.pdf -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | optimizer = true 6 | optimizer_runs = 200 7 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x8016ec0d5302d0347a567897083883e11e112d7ed17319f88682213470a3ac49" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @openzeppelin/=lib/openzeppelin-contracts 2 | @uniswap/v3-core/=lib/v3-core 3 | @uniswap/v3-periphery/=lib/v3-periphery 4 | v3-core/=lib/v3-core/contracts 5 | v3-periphery/=lib/v3-periphery/contracts -------------------------------------------------------------------------------- /src/V3Utils.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "v3-periphery/interfaces/INonfungiblePositionManager.sol"; 5 | import "v3-periphery/interfaces/external/IWETH9.sol"; 6 | 7 | import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 8 | import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; 9 | import "@openzeppelin/contracts/utils/math/SafeCast.sol"; 10 | 11 | /// @title v3Utils v1.0 12 | /// @notice Utility functions for Uniswap V3 positions 13 | /// This is a completely ownerless/stateless contract - does not hold any ERC20 or NFTs. 14 | /// It can be simply redeployed when new / better functionality is implemented 15 | contract V3Utils is IERC721Receiver { 16 | 17 | using SafeCast for uint256; 18 | 19 | /// @notice Wrapped native token address 20 | IWETH9 immutable public weth; 21 | 22 | /// @notice Uniswap v3 position manager 23 | INonfungiblePositionManager immutable public nonfungiblePositionManager; 24 | 25 | /// @notice 0x Exchange Proxy 26 | address immutable public swapRouter; 27 | 28 | // error types 29 | error Unauthorized(); 30 | error WrongContract(); 31 | error SelfSend(); 32 | error NotSupportedWhatToDo(); 33 | error SameToken(); 34 | error SwapFailed(); 35 | error AmountError(); 36 | error SlippageError(); 37 | error CollectError(); 38 | error TransferError(); 39 | error EtherSendFailed(); 40 | error TooMuchEtherSent(); 41 | error NoEtherToken(); 42 | error NotWETH(); 43 | 44 | // events 45 | event CompoundFees(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); 46 | event ChangeRange(uint256 indexed tokenId, uint256 newTokenId); 47 | event WithdrawAndCollectAndSwap(uint256 indexed tokenId, address token, uint256 amount); 48 | event Swap(address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut); 49 | event SwapAndMint(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); 50 | event SwapAndIncreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); 51 | 52 | /// @notice Constructor 53 | /// @param _nonfungiblePositionManager Uniswap v3 position manager 54 | /// @param _swapRouter 0x Exchange Proxy 55 | constructor(INonfungiblePositionManager _nonfungiblePositionManager, address _swapRouter) { 56 | weth = IWETH9(_nonfungiblePositionManager.WETH9()); 57 | nonfungiblePositionManager = _nonfungiblePositionManager; 58 | swapRouter = _swapRouter; 59 | } 60 | 61 | /// @notice Action which should be executed on provided NFT 62 | enum WhatToDo { 63 | CHANGE_RANGE, 64 | WITHDRAW_AND_COLLECT_AND_SWAP, 65 | COMPOUND_FEES 66 | } 67 | 68 | /// @notice Complete description of what should be executed on provided NFT - different fields are used depending on specified WhatToDo 69 | struct Instructions { 70 | // what action to perform on provided Uniswap v3 position 71 | WhatToDo whatToDo; 72 | 73 | // target token for swaps (if this is address(0) no swaps are executed) 74 | address targetToken; 75 | 76 | // for removing liquidity slippage 77 | uint256 amountRemoveMin0; 78 | uint256 amountRemoveMin1; 79 | 80 | // amountIn0 is used for swap and also as minAmount0 for decreased liquidity + collected fees 81 | uint256 amountIn0; 82 | // if token0 needs to be swapped to targetToken - set values 83 | uint256 amountOut0Min; 84 | bytes swapData0; // encoded data from 0x api call (address,bytes) - allowanceTarget,data 85 | 86 | // amountIn1 is used for swap and also as minAmount1 for decreased liquidity + collected fees 87 | uint256 amountIn1; 88 | // if token1 needs to be swapped to targetToken - set values 89 | uint256 amountOut1Min; 90 | bytes swapData1; // encoded data from 0x api call (address,bytes) - allowanceTarget,data 91 | 92 | // collect fee amount for COMPOUND_FEES / CHANGE_RANGE / WITHDRAW_AND_COLLECT_AND_SWAP (if uint256(128).max - ALL) 93 | uint128 feeAmount0; 94 | uint128 feeAmount1; 95 | 96 | // for creating new positions with CHANGE_RANGE 97 | uint24 fee; 98 | int24 tickLower; 99 | int24 tickUpper; 100 | 101 | // remove liquidity amount for COMPOUND_FEES (in this case should be probably 0) / CHANGE_RANGE / WITHDRAW_AND_COLLECT_AND_SWAP 102 | uint128 liquidity; 103 | 104 | // for adding liquidity slippage 105 | uint256 amountAddMin0; 106 | uint256 amountAddMin1; 107 | 108 | // for all uniswap deadlineable functions 109 | uint256 deadline; 110 | 111 | // left over tokens will be sent to this address 112 | address recipient; 113 | 114 | // recipient of newly minted nft (the incoming NFT will ALWAYS be returned to from) 115 | address recipientNFT; 116 | 117 | // if tokenIn or tokenOut is WETH - unwrap 118 | bool unwrap; 119 | 120 | // data sent with returned token to IERC721Receiver (optional) 121 | bytes returnData; 122 | 123 | // data sent with minted token to IERC721Receiver (optional) 124 | bytes swapAndMintReturnData; 125 | } 126 | 127 | /// @notice Execute instruction by pulling approved NFT instead of direct safeTransferFrom call from owner 128 | /// @param tokenId Token to process 129 | /// @param instructions Instructions to execute 130 | function execute(uint256 tokenId, Instructions calldata instructions) external 131 | { 132 | // must be approved beforehand 133 | nonfungiblePositionManager.safeTransferFrom( 134 | msg.sender, 135 | address(this), 136 | tokenId, 137 | abi.encode(instructions) 138 | ); 139 | } 140 | 141 | /// @notice ERC721 callback function. Called on safeTransferFrom and does manipulation as configured in encoded Instructions parameter. 142 | /// At the end the NFT (and any newly minted NFT) is returned to sender. The leftover tokens are sent to instructions.recipient. 143 | function onERC721Received(address, address from, uint256 tokenId, bytes calldata data) external override returns (bytes4) { 144 | 145 | // only Uniswap v3 NFTs allowed 146 | if (msg.sender != address(nonfungiblePositionManager)) { 147 | revert WrongContract(); 148 | } 149 | 150 | // not allowed to send to itself 151 | if (from == address(this)) { 152 | revert SelfSend(); 153 | } 154 | 155 | Instructions memory instructions = abi.decode(data, (Instructions)); 156 | 157 | (,,address token0,address token1,,,,uint128 liquidity,,,,) = nonfungiblePositionManager.positions(tokenId); 158 | 159 | uint256 amount0; 160 | uint256 amount1; 161 | if (instructions.liquidity != 0) { 162 | (amount0, amount1) = _decreaseLiquidity(tokenId, instructions.liquidity, instructions.deadline, instructions.amountRemoveMin0, instructions.amountRemoveMin1); 163 | } 164 | (amount0, amount1) = _collectFees(tokenId, IERC20(token0), IERC20(token1), instructions.feeAmount0 == type(uint128).max ? type(uint128).max : (amount0 + instructions.feeAmount0).toUint128(), instructions.feeAmount1 == type(uint128).max ? type(uint128).max : (amount1 + instructions.feeAmount1).toUint128()); 165 | 166 | // check if enough tokens are available for swaps 167 | if (amount0 < instructions.amountIn0 || amount1 < instructions.amountIn1) { 168 | revert AmountError(); 169 | } 170 | 171 | if (instructions.whatToDo == WhatToDo.COMPOUND_FEES) { 172 | if (instructions.targetToken == token0) { 173 | (liquidity, amount0, amount1) = _swapAndIncrease(SwapAndIncreaseLiquidityParams(tokenId, amount0, amount1, instructions.recipient, instructions.deadline, IERC20(token1), instructions.amountIn1, instructions.amountOut1Min, instructions.swapData1, 0, 0, "", instructions.amountAddMin0, instructions.amountAddMin1), IERC20(token0), IERC20(token1), instructions.unwrap); 174 | } else if (instructions.targetToken == token1) { 175 | (liquidity, amount0, amount1) = _swapAndIncrease(SwapAndIncreaseLiquidityParams(tokenId, amount0, amount1, instructions.recipient, instructions.deadline, IERC20(token0), 0, 0, "", instructions.amountIn0, instructions.amountOut0Min, instructions.swapData0, instructions.amountAddMin0, instructions.amountAddMin1), IERC20(token0), IERC20(token1), instructions.unwrap); 176 | } else { 177 | // no swap is done here 178 | (liquidity,amount0, amount1) = _swapAndIncrease(SwapAndIncreaseLiquidityParams(tokenId, amount0, amount1, instructions.recipient, instructions.deadline, IERC20(address(0)), 0, 0, "", 0, 0, "", instructions.amountAddMin0, instructions.amountAddMin1), IERC20(token0), IERC20(token1), instructions.unwrap); 179 | } 180 | emit CompoundFees(tokenId, liquidity, amount0, amount1); 181 | } else if (instructions.whatToDo == WhatToDo.CHANGE_RANGE) { 182 | 183 | uint256 newTokenId; 184 | 185 | if (instructions.targetToken == token0) { 186 | (newTokenId,,,) = _swapAndMint(SwapAndMintParams(IERC20(token0), IERC20(token1), instructions.fee, instructions.tickLower, instructions.tickUpper, amount0, amount1, instructions.recipient, instructions.recipientNFT, instructions.deadline, IERC20(token1), instructions.amountIn1, instructions.amountOut1Min, instructions.swapData1, 0, 0, "", instructions.amountAddMin0, instructions.amountAddMin1, instructions.swapAndMintReturnData), instructions.unwrap); 187 | } else if (instructions.targetToken == token1) { 188 | (newTokenId,,,) = _swapAndMint(SwapAndMintParams(IERC20(token0), IERC20(token1), instructions.fee, instructions.tickLower, instructions.tickUpper, amount0, amount1, instructions.recipient, instructions.recipientNFT, instructions.deadline, IERC20(token0), 0, 0, "", instructions.amountIn0, instructions.amountOut0Min, instructions.swapData0, instructions.amountAddMin0, instructions.amountAddMin1, instructions.swapAndMintReturnData), instructions.unwrap); 189 | } else { 190 | // no swap is done here 191 | (newTokenId,,,) = _swapAndMint(SwapAndMintParams(IERC20(token0), IERC20(token1), instructions.fee, instructions.tickLower, instructions.tickUpper, amount0, amount1, instructions.recipient, instructions.recipientNFT, instructions.deadline, IERC20(address(0)), 0, 0, "", 0, 0, "", instructions.amountAddMin0, instructions.amountAddMin1, instructions.swapAndMintReturnData), instructions.unwrap); 192 | } 193 | 194 | emit ChangeRange(tokenId, newTokenId); 195 | } else if (instructions.whatToDo == WhatToDo.WITHDRAW_AND_COLLECT_AND_SWAP) { 196 | uint256 targetAmount; 197 | if (token0 != instructions.targetToken) { 198 | (uint256 amountInDelta, uint256 amountOutDelta) = _swap(IERC20(token0), IERC20(instructions.targetToken), amount0, instructions.amountOut0Min, instructions.swapData0); 199 | if (amountInDelta < amount0) { 200 | _transferToken(instructions.recipient, IERC20(token0), amount0 - amountInDelta, instructions.unwrap); 201 | } 202 | targetAmount += amountOutDelta; 203 | } else { 204 | targetAmount += amount0; 205 | } 206 | if (token1 != instructions.targetToken) { 207 | (uint256 amountInDelta, uint256 amountOutDelta) = _swap(IERC20(token1), IERC20(instructions.targetToken), amount1, instructions.amountOut1Min, instructions.swapData1); 208 | if (amountInDelta < amount1) { 209 | _transferToken(instructions.recipient, IERC20(token1), amount1 - amountInDelta, instructions.unwrap); 210 | } 211 | targetAmount += amountOutDelta; 212 | } else { 213 | targetAmount += amount1; 214 | } 215 | 216 | // send complete target amount 217 | if (targetAmount != 0 && instructions.targetToken != address(0)) { 218 | _transferToken(instructions.recipient, IERC20(instructions.targetToken), targetAmount, instructions.unwrap); 219 | } 220 | 221 | emit WithdrawAndCollectAndSwap(tokenId, instructions.targetToken, targetAmount); 222 | } else { 223 | revert NotSupportedWhatToDo(); 224 | } 225 | 226 | // return token to owner (this line guarantees that token is returned to originating owner) 227 | nonfungiblePositionManager.safeTransferFrom(address(this), from, tokenId, instructions.returnData); 228 | 229 | return IERC721Receiver.onERC721Received.selector; 230 | } 231 | 232 | /// @notice Params for swap() function 233 | struct SwapParams { 234 | IERC20 tokenIn; 235 | IERC20 tokenOut; 236 | uint256 amountIn; 237 | uint256 minAmountOut; 238 | address recipient; // recipient of tokenOut and leftover tokenIn (if any leftover) 239 | bytes swapData; 240 | bool unwrap; // if tokenIn or tokenOut is WETH - unwrap 241 | } 242 | 243 | /// @notice Swaps amountIn of tokenIn for tokenOut - returning at least minAmountOut 244 | /// @param params Swap configuration 245 | /// If tokenIn is wrapped native token - both the token or the wrapped token can be sent (the sum of both must be equal to amountIn) 246 | /// Optionally unwraps any wrapped native token and returns native token instead 247 | function swap(SwapParams calldata params) external payable returns (uint256 amountOut) { 248 | 249 | if (params.tokenIn == params.tokenOut) { 250 | revert SameToken(); 251 | } 252 | 253 | _prepareAdd(params.tokenIn, IERC20(address(0)), IERC20(address(0)), params.amountIn, 0, 0); 254 | 255 | uint256 amountInDelta; 256 | (amountInDelta, amountOut) = _swap(params.tokenIn, params.tokenOut, params.amountIn, params.minAmountOut, params.swapData); 257 | 258 | // send swapped amount of tokenOut 259 | if (amountOut != 0) { 260 | _transferToken(params.recipient, params.tokenOut, amountOut, params.unwrap); 261 | } 262 | 263 | // if not all was swapped - return leftovers of tokenIn 264 | uint256 leftOver = params.amountIn - amountInDelta; 265 | if (leftOver != 0) { 266 | _transferToken(params.recipient, params.tokenIn, leftOver, params.unwrap); 267 | } 268 | } 269 | 270 | /// @notice Params for swapAndMint() function 271 | struct SwapAndMintParams { 272 | IERC20 token0; 273 | IERC20 token1; 274 | uint24 fee; 275 | int24 tickLower; 276 | int24 tickUpper; 277 | 278 | // how much is provided of token0 and token1 279 | uint256 amount0; 280 | uint256 amount1; 281 | address recipient; // recipient of leftover tokens 282 | address recipientNFT; // recipient of nft 283 | uint256 deadline; 284 | 285 | // source token for swaps (maybe either address(0), token0, token1 or another token) 286 | // if swapSourceToken is another token than token0 or token1 -> amountIn0 + amountIn1 of swapSourceToken are expected to be available 287 | IERC20 swapSourceToken; 288 | 289 | // if swapSourceToken needs to be swapped to token0 - set values 290 | uint256 amountIn0; 291 | uint256 amountOut0Min; 292 | bytes swapData0; 293 | 294 | // if swapSourceToken needs to be swapped to token1 - set values 295 | uint256 amountIn1; 296 | uint256 amountOut1Min; 297 | bytes swapData1; 298 | 299 | // min amount to be added after swap 300 | uint256 amountAddMin0; 301 | uint256 amountAddMin1; 302 | 303 | // data to be sent along newly created NFT when transfered to recipientNFT (sent to IERC721Receiver callback) 304 | bytes returnData; 305 | } 306 | 307 | /// @notice Does 1 or 2 swaps from swapSourceToken to token0 and token1 and adds as much as possible liquidity to a newly minted position. 308 | /// @param params Swap and mint configuration 309 | /// Newly minted NFT and leftover tokens are returned to recipient 310 | function swapAndMint(SwapAndMintParams calldata params) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) { 311 | if (params.token0 == params.token1) { 312 | revert SameToken(); 313 | } 314 | _prepareAdd(params.token0, params.token1, params.swapSourceToken, params.amount0, params.amount1, params.amountIn0 + params.amountIn1); 315 | (tokenId, liquidity, amount0, amount1) = _swapAndMint(params, msg.value != 0); 316 | } 317 | 318 | /// @notice Params for swapAndIncreaseLiquidity() function 319 | struct SwapAndIncreaseLiquidityParams { 320 | uint256 tokenId; 321 | 322 | // how much is provided of token0 and token1 323 | uint256 amount0; 324 | uint256 amount1; 325 | address recipient; // recipient of leftover tokens 326 | uint256 deadline; 327 | 328 | // source token for swaps (maybe either address(0), token0, token1 or another token) 329 | // if swapSourceToken is another token than token0 or token1 -> amountIn0 + amountIn1 of swapSourceToken are expected to be available 330 | IERC20 swapSourceToken; 331 | 332 | // if swapSourceToken needs to be swapped to token0 - set values 333 | uint256 amountIn0; 334 | uint256 amountOut0Min; 335 | bytes swapData0; 336 | 337 | // if swapSourceToken needs to be swapped to token1 - set values 338 | uint256 amountIn1; 339 | uint256 amountOut1Min; 340 | bytes swapData1; 341 | 342 | // min amount to be added after swap 343 | uint256 amountAddMin0; 344 | uint256 amountAddMin1; 345 | } 346 | 347 | /// @notice Does 1 or 2 swaps from swapSourceToken to token0 and token1 and adds as much as possible liquidity to any existing position (no need to be position owner). 348 | /// @param params Swap and increase liquidity configuration 349 | // Sends any leftover tokens to recipient. 350 | function swapAndIncreaseLiquidity(SwapAndIncreaseLiquidityParams calldata params) external payable returns (uint128 liquidity, uint256 amount0, uint256 amount1) { 351 | (, , address token0, address token1, , , , , , , , ) = nonfungiblePositionManager.positions(params.tokenId); 352 | _prepareAdd(IERC20(token0), IERC20(token1), params.swapSourceToken, params.amount0, params.amount1, params.amountIn0 + params.amountIn1); 353 | (liquidity, amount0, amount1) = _swapAndIncrease(params, IERC20(token0), IERC20(token1), msg.value != 0); 354 | } 355 | 356 | // checks if required amounts are provided and are exact - wraps any provided ETH as WETH 357 | // if less or more provided reverts 358 | function _prepareAdd(IERC20 token0, IERC20 token1, IERC20 otherToken, uint256 amount0, uint256 amount1, uint256 amountOther) internal 359 | { 360 | uint256 amountAdded0; 361 | uint256 amountAdded1; 362 | uint256 amountAddedOther; 363 | 364 | // wrap ether sent 365 | if (msg.value != 0) { 366 | weth.deposit{ value: msg.value }(); 367 | 368 | if (address(weth) == address(token0)) { 369 | amountAdded0 = msg.value; 370 | if (amountAdded0 > amount0) { 371 | revert TooMuchEtherSent(); 372 | } 373 | } else if (address(weth) == address(token1)) { 374 | amountAdded1 = msg.value; 375 | if (amountAdded1 > amount1) { 376 | revert TooMuchEtherSent(); 377 | } 378 | } else if (address(weth) == address(otherToken)) { 379 | amountAddedOther = msg.value; 380 | if (amountAddedOther > amountOther) { 381 | revert TooMuchEtherSent(); 382 | } 383 | } else { 384 | revert NoEtherToken(); 385 | } 386 | } 387 | 388 | // get missing tokens (fails if not enough provided) 389 | if (amount0 > amountAdded0) { 390 | uint256 balanceBefore = token0.balanceOf(address(this)); 391 | SafeERC20.safeTransferFrom(token0, msg.sender, address(this), amount0 - amountAdded0); 392 | uint256 balanceAfter = token0.balanceOf(address(this)); 393 | if (balanceAfter - balanceBefore != amount0 - amountAdded0) { 394 | revert TransferError(); // reverts for fee-on-transfer tokens 395 | } 396 | } 397 | if (amount1 > amountAdded1) { 398 | uint256 balanceBefore = token1.balanceOf(address(this)); 399 | SafeERC20.safeTransferFrom(token1, msg.sender, address(this), amount1 - amountAdded1); 400 | uint256 balanceAfter = token1.balanceOf(address(this)); 401 | if (balanceAfter - balanceBefore != amount1 - amountAdded1) { 402 | revert TransferError(); // reverts for fee-on-transfer tokens 403 | } 404 | } 405 | if (amountOther > amountAddedOther && address(otherToken) != address(0) && token0 != otherToken && token1 != otherToken) { 406 | uint256 balanceBefore = otherToken.balanceOf(address(this)); 407 | SafeERC20.safeTransferFrom(otherToken, msg.sender, address(this), amountOther - amountAddedOther); 408 | uint256 balanceAfter = otherToken.balanceOf(address(this)); 409 | if (balanceAfter - balanceBefore != amountOther - amountAddedOther) { 410 | revert TransferError(); // reverts for fee-on-transfer tokens 411 | } 412 | } 413 | } 414 | 415 | // swap and mint logic 416 | function _swapAndMint(SwapAndMintParams memory params, bool unwrap) internal returns (uint256 tokenId, uint128 liquidity, uint256 added0, uint256 added1) { 417 | 418 | (uint256 total0, uint256 total1) = _swapAndPrepareAmounts(params, unwrap); 419 | 420 | INonfungiblePositionManager.MintParams memory mintParams = 421 | INonfungiblePositionManager.MintParams( 422 | address(params.token0), 423 | address(params.token1), 424 | params.fee, 425 | params.tickLower, 426 | params.tickUpper, 427 | total0, 428 | total1, 429 | params.amountAddMin0, 430 | params.amountAddMin1, 431 | address(this), // is sent to real recipient aftwards 432 | params.deadline 433 | ); 434 | 435 | // mint is done to address(this) because it is not a safemint and safeTransferFrom needs to be done manually afterwards 436 | (tokenId,liquidity,added0,added1) = nonfungiblePositionManager.mint(mintParams); 437 | nonfungiblePositionManager.safeTransferFrom(address(this), params.recipientNFT, tokenId, params.returnData); 438 | 439 | emit SwapAndMint(tokenId, liquidity, added0, added1); 440 | 441 | _returnLeftoverTokens(params.recipient, params.token0, params.token1, total0, total1, added0, added1, unwrap); 442 | } 443 | 444 | // swap and increase logic 445 | function _swapAndIncrease(SwapAndIncreaseLiquidityParams memory params, IERC20 token0, IERC20 token1, bool unwrap) internal returns (uint128 liquidity, uint256 added0, uint256 added1) { 446 | 447 | (uint256 total0, uint256 total1) = _swapAndPrepareAmounts( 448 | SwapAndMintParams(token0, token1, 0, 0, 0, params.amount0, params.amount1, params.recipient, params.recipient, params.deadline, params.swapSourceToken, params.amountIn0, params.amountOut0Min, params.swapData0, params.amountIn1, params.amountOut1Min, params.swapData1, params.amountAddMin0, params.amountAddMin1, ""), unwrap); 449 | 450 | INonfungiblePositionManager.IncreaseLiquidityParams memory increaseLiquidityParams = 451 | INonfungiblePositionManager.IncreaseLiquidityParams( 452 | params.tokenId, 453 | total0, 454 | total1, 455 | params.amountAddMin0, 456 | params.amountAddMin1, 457 | params.deadline 458 | ); 459 | 460 | (liquidity, added0, added1) = nonfungiblePositionManager.increaseLiquidity(increaseLiquidityParams); 461 | 462 | emit SwapAndIncreaseLiquidity(params.tokenId, liquidity, added0, added1); 463 | 464 | _returnLeftoverTokens(params.recipient, token0, token1, total0, total1, added0, added1, unwrap); 465 | } 466 | 467 | // swaps available tokens and prepares max amounts to be added to nonfungiblePositionManager 468 | function _swapAndPrepareAmounts(SwapAndMintParams memory params, bool unwrap) internal returns (uint256 total0, uint256 total1) { 469 | if (params.swapSourceToken == params.token0) { 470 | if (params.amount0 < params.amountIn1) { 471 | revert AmountError(); 472 | } 473 | (uint256 amountInDelta, uint256 amountOutDelta) = _swap(params.token0, params.token1, params.amountIn1, params.amountOut1Min, params.swapData1); 474 | total0 = params.amount0 - amountInDelta; 475 | total1 = params.amount1 + amountOutDelta; 476 | } else if (params.swapSourceToken == params.token1) { 477 | if (params.amount1 < params.amountIn0) { 478 | revert AmountError(); 479 | } 480 | (uint256 amountInDelta, uint256 amountOutDelta) = _swap(params.token1, params.token0, params.amountIn0, params.amountOut0Min, params.swapData0); 481 | total1 = params.amount1 - amountInDelta; 482 | total0 = params.amount0 + amountOutDelta; 483 | } else if (address(params.swapSourceToken) != address(0)) { 484 | 485 | (uint256 amountInDelta0, uint256 amountOutDelta0) = _swap(params.swapSourceToken, params.token0, params.amountIn0, params.amountOut0Min, params.swapData0); 486 | (uint256 amountInDelta1, uint256 amountOutDelta1) = _swap(params.swapSourceToken, params.token1, params.amountIn1, params.amountOut1Min, params.swapData1); 487 | total0 = params.amount0 + amountOutDelta0; 488 | total1 = params.amount1 + amountOutDelta1; 489 | 490 | // return third token leftover if any 491 | uint256 leftOver = params.amountIn0 + params.amountIn1 - amountInDelta0 - amountInDelta1; 492 | 493 | if (leftOver != 0) { 494 | _transferToken(params.recipient, params.swapSourceToken, leftOver, unwrap); 495 | } 496 | } else { 497 | total0 = params.amount0; 498 | total1 = params.amount1; 499 | } 500 | 501 | if (total0 != 0) { 502 | SafeERC20.safeApprove(params.token0, address(nonfungiblePositionManager), 0); 503 | SafeERC20.safeApprove(params.token0, address(nonfungiblePositionManager), total0); 504 | } 505 | if (total1 != 0) { 506 | SafeERC20.safeApprove(params.token1, address(nonfungiblePositionManager), 0); 507 | SafeERC20.safeApprove(params.token1, address(nonfungiblePositionManager), total1); 508 | } 509 | } 510 | 511 | // returns leftover token balances 512 | function _returnLeftoverTokens(address to, IERC20 token0, IERC20 token1, uint256 total0, uint256 total1, uint256 added0, uint256 added1, bool unwrap) internal { 513 | 514 | uint256 left0 = total0 - added0; 515 | uint256 left1 = total1 - added1; 516 | 517 | // return leftovers 518 | if (left0 != 0) { 519 | _transferToken(to, token0, left0, unwrap); 520 | } 521 | if (left1 != 0) { 522 | _transferToken(to, token1, left1, unwrap); 523 | } 524 | } 525 | 526 | // transfers token (or unwraps WETH and sends ETH) 527 | function _transferToken(address to, IERC20 token, uint256 amount, bool unwrap) internal { 528 | if (address(weth) == address(token) && unwrap) { 529 | weth.withdraw(amount); 530 | (bool sent, ) = to.call{value: amount}(""); 531 | if (!sent) { 532 | revert EtherSendFailed(); 533 | } 534 | } else { 535 | SafeERC20.safeTransfer(token, to, amount); 536 | } 537 | } 538 | 539 | // general swap function which uses external router with off-chain calculated swap instructions 540 | // does slippage check with amountOutMin param 541 | // returns token amounts deltas after swap 542 | function _swap(IERC20 tokenIn, IERC20 tokenOut, uint256 amountIn, uint256 amountOutMin, bytes memory swapData) internal returns (uint256 amountInDelta, uint256 amountOutDelta) { 543 | if (amountIn != 0 && swapData.length != 0 && address(tokenOut) != address(0)) { 544 | uint256 balanceInBefore = tokenIn.balanceOf(address(this)); 545 | uint256 balanceOutBefore = tokenOut.balanceOf(address(this)); 546 | 547 | // get router specific swap data 548 | (address allowanceTarget, bytes memory data) = abi.decode(swapData, (address, bytes)); 549 | 550 | // approve needed amount 551 | SafeERC20.safeApprove(tokenIn, allowanceTarget, amountIn); 552 | 553 | // execute swap 554 | (bool success,) = swapRouter.call(data); 555 | if (!success) { 556 | revert SwapFailed(); 557 | } 558 | 559 | // reset approval 560 | SafeERC20.safeApprove(tokenIn, allowanceTarget, 0); 561 | 562 | uint256 balanceInAfter = tokenIn.balanceOf(address(this)); 563 | uint256 balanceOutAfter = tokenOut.balanceOf(address(this)); 564 | 565 | amountInDelta = balanceInBefore - balanceInAfter; 566 | amountOutDelta = balanceOutAfter - balanceOutBefore; 567 | 568 | // amountMin slippage check 569 | if (amountOutDelta < amountOutMin) { 570 | revert SlippageError(); 571 | } 572 | 573 | // event for any swap with exact swapped value 574 | emit Swap(address(tokenIn), address(tokenOut), amountInDelta, amountOutDelta); 575 | } 576 | } 577 | 578 | // decreases liquidity from uniswap v3 position 579 | function _decreaseLiquidity(uint256 tokenId, uint128 liquidity, uint256 deadline, uint256 token0Min, uint256 token1Min) internal returns (uint256 amount0, uint256 amount1) { 580 | if (liquidity != 0) { 581 | (amount0, amount1) = nonfungiblePositionManager.decreaseLiquidity( 582 | INonfungiblePositionManager.DecreaseLiquidityParams( 583 | tokenId, 584 | liquidity, 585 | token0Min, 586 | token1Min, 587 | deadline 588 | ) 589 | ); 590 | } 591 | } 592 | 593 | // collects specified amount of fees from uniswap v3 position 594 | function _collectFees(uint256 tokenId, IERC20 token0, IERC20 token1, uint128 collectAmount0, uint128 collectAmount1) internal returns (uint256 amount0, uint256 amount1) { 595 | uint256 balanceBefore0 = token0.balanceOf(address(this)); 596 | uint256 balanceBefore1 = token1.balanceOf(address(this)); 597 | (amount0, amount1) = nonfungiblePositionManager.collect( 598 | INonfungiblePositionManager.CollectParams(tokenId, address(this), collectAmount0, collectAmount1) 599 | ); 600 | uint256 balanceAfter0 = token0.balanceOf(address(this)); 601 | uint256 balanceAfter1 = token1.balanceOf(address(this)); 602 | 603 | // reverts for fee-on-transfer tokens 604 | if (balanceAfter0 - balanceBefore0 != amount0) { 605 | revert CollectError(); 606 | } 607 | if (balanceAfter1 - balanceBefore1 != amount1) { 608 | revert CollectError(); 609 | } 610 | } 611 | 612 | // needed for WETH unwrapping 613 | receive() external payable { 614 | if (msg.sender != address(weth)) { 615 | revert NotWETH(); 616 | } 617 | } 618 | } -------------------------------------------------------------------------------- /src/automators/AutoExit.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "./Automator.sol"; 5 | 6 | /// @title AutoExit 7 | /// @notice Lets a v3 position to be automatically removed (limit order) or swapped to the opposite token (stop loss order) when it reaches a certain tick. 8 | /// A revert controlled bot (operator) is responsible for the execution of optimized swaps (using external swap router) 9 | /// Positions need to be approved (approve or setApprovalForAll) for the contract and configured with configToken method 10 | contract AutoExit is Automator { 11 | 12 | error NoLiquidity(); 13 | error MissingSwapData(); 14 | 15 | event Executed( 16 | uint256 indexed tokenId, 17 | address account, 18 | bool isSwap, 19 | uint256 amountReturned0, 20 | uint256 amountReturned1, 21 | address token0, 22 | address token1 23 | ); 24 | event PositionConfigured( 25 | uint256 indexed tokenId, 26 | bool isActive, 27 | bool token0Swap, 28 | bool token1Swap, 29 | int24 token0TriggerTick, 30 | int24 token1TriggerTick, 31 | uint64 token0SlippageX64, 32 | uint64 token1SlippageX64, 33 | bool onlyFees, 34 | uint64 maxRewardX64 35 | ); 36 | 37 | constructor(INonfungiblePositionManager _npm, address _operator, address _withdrawer, uint32 _TWAPSeconds, uint16 _maxTWAPTickDifference, address[] memory _swapRouterOptions) 38 | Automator(_npm, _operator, _withdrawer, _TWAPSeconds, _maxTWAPTickDifference, _swapRouterOptions) { 39 | } 40 | 41 | // define how stoploss / limit should be handled 42 | struct PositionConfig { 43 | bool isActive; // if position is active 44 | // should swap token to other token when triggered 45 | bool token0Swap; 46 | bool token1Swap; 47 | // when should action be triggered (when this tick is reached - allow execute) 48 | int24 token0TriggerTick; // when tick is below this one 49 | int24 token1TriggerTick; // when tick is equal or above this one 50 | // max price difference from current pool price for swap / Q64 51 | uint64 token0SlippageX64; // when token 0 is swapped to token 1 52 | uint64 token1SlippageX64; // when token 1 is swapped to token 0 53 | bool onlyFees; // if only fees maybe used for protocol reward 54 | uint64 maxRewardX64; // max allowed reward percentage of fees or full position 55 | } 56 | 57 | // configured tokens 58 | mapping (uint256 => PositionConfig) public positionConfigs; 59 | 60 | /// @notice params for execute() 61 | struct ExecuteParams { 62 | uint256 tokenId; // tokenid to process 63 | bytes swapData; // if its a swap order - must include swap data 64 | uint128 liquidity; // liquidity the calculations are based on 65 | uint256 amountRemoveMin0; // min amount to be removed from liquidity 66 | uint256 amountRemoveMin1; // min amount to be removed from liquidity 67 | uint256 deadline; // for uniswap operations - operator promises fair value 68 | uint64 rewardX64; // which reward will be used for protocol, can be max configured amount (considering onlyFees) 69 | } 70 | 71 | struct ExecuteState { 72 | address token0; 73 | address token1; 74 | uint24 fee; 75 | int24 tickLower; 76 | int24 tickUpper; 77 | uint128 liquidity; 78 | uint256 amount0; 79 | uint256 amount1; 80 | uint256 feeAmount0; 81 | uint256 feeAmount1; 82 | uint256 amountOutMin; 83 | uint256 amountInDelta; 84 | uint256 amountOutDelta; 85 | IUniswapV3Pool pool; 86 | uint256 swapAmount; 87 | int24 tick; 88 | bool isSwap; 89 | bool isAbove; 90 | address owner; 91 | } 92 | 93 | /** 94 | * @notice Handle token (must be in correct state) 95 | * Can only be called only from configured operator account 96 | * Swap needs to be done with max price difference from current pool price - otherwise reverts 97 | */ 98 | function execute(ExecuteParams calldata params) external { 99 | 100 | if (!operators[msg.sender]) { 101 | revert Unauthorized(); 102 | } 103 | 104 | ExecuteState memory state; 105 | PositionConfig memory config = positionConfigs[params.tokenId]; 106 | 107 | if (!config.isActive) { 108 | revert NotConfigured(); 109 | } 110 | 111 | if (config.onlyFees && params.rewardX64 > config.maxRewardX64 || !config.onlyFees && params.rewardX64 > config.maxRewardX64) { 112 | revert ExceedsMaxReward(); 113 | } 114 | 115 | // get position info 116 | (,,state.token0, state.token1, state.fee, state.tickLower, state.tickUpper, state.liquidity, , , , ) = nonfungiblePositionManager.positions(params.tokenId); 117 | 118 | // so can be executed only once 119 | if (state.liquidity == 0) { 120 | revert NoLiquidity(); 121 | } 122 | if (state.liquidity != params.liquidity) { 123 | revert LiquidityChanged(); 124 | } 125 | 126 | state.pool = _getPool(state.token0, state.token1, state.fee); 127 | (,state.tick,,,,,) = state.pool.slot0(); 128 | 129 | // not triggered 130 | if (config.token0TriggerTick <= state.tick && state.tick < config.token1TriggerTick) { 131 | revert NotReady(); 132 | } 133 | 134 | state.isAbove = state.tick >= config.token1TriggerTick; 135 | state.isSwap = !state.isAbove && config.token0Swap || state.isAbove && config.token1Swap; 136 | 137 | // decrease full liquidity for given position - and return fees as well 138 | (state.amount0, state.amount1, state.feeAmount0, state.feeAmount1) = _decreaseFullLiquidityAndCollect(params.tokenId, state.liquidity, params.amountRemoveMin0, params.amountRemoveMin1, params.deadline); 139 | 140 | // swap to other token 141 | if (state.isSwap) { 142 | if (params.swapData.length == 0) { 143 | revert MissingSwapData(); 144 | } 145 | 146 | // reward is taken before swap - if from fees only 147 | if (config.onlyFees) { 148 | state.amount0 -= state.feeAmount0 * params.rewardX64 / Q64; 149 | state.amount1 -= state.feeAmount1 * params.rewardX64 / Q64; 150 | } 151 | 152 | state.swapAmount = state.isAbove ? state.amount1 : state.amount0; 153 | 154 | // checks if price in valid oracle range and calculates amountOutMin 155 | (state.amountOutMin,,,) = _validateSwap(!state.isAbove, state.swapAmount, state.pool, TWAPSeconds, maxTWAPTickDifference, state.isAbove ? config.token1SlippageX64 : config.token0SlippageX64); 156 | 157 | (state.amountInDelta, state.amountOutDelta) = _swap(state.isAbove ? IERC20(state.token1) : IERC20(state.token0), state.isAbove ? IERC20(state.token0) : IERC20(state.token1), state.swapAmount, state.amountOutMin, params.swapData); 158 | 159 | state.amount0 = state.isAbove ? state.amount0 + state.amountOutDelta : state.amount0 - state.amountInDelta; 160 | state.amount1 = state.isAbove ? state.amount1 - state.amountInDelta : state.amount1 + state.amountOutDelta; 161 | 162 | // when swap and !onlyFees - protocol reward is removed only from target token (to incentivize optimal swap done by operator) 163 | if (!config.onlyFees) { 164 | if (state.isAbove) { 165 | state.amount0 -= state.amount0 * params.rewardX64 / Q64; 166 | } else { 167 | state.amount1 -= state.amount1 * params.rewardX64 / Q64; 168 | } 169 | } 170 | } else { 171 | // reward is taken as configured 172 | state.amount0 -= (config.onlyFees ? state.feeAmount0 : state.amount0) * params.rewardX64 / Q64; 173 | state.amount1 -= (config.onlyFees ? state.feeAmount1 : state.amount1) * params.rewardX64 / Q64; 174 | } 175 | 176 | state.owner = nonfungiblePositionManager.ownerOf(params.tokenId); 177 | if (state.amount0 > 0) { 178 | _transferToken(state.owner, IERC20(state.token0), state.amount0, true); 179 | } 180 | if (state.amount1 > 0) { 181 | _transferToken(state.owner, IERC20(state.token1), state.amount1, true); 182 | } 183 | 184 | // delete config for position 185 | delete positionConfigs[params.tokenId]; 186 | emit PositionConfigured(params.tokenId, false, false, false, 0, 0, 0, 0, false, 0); 187 | 188 | // log event 189 | emit Executed(params.tokenId, msg.sender, state.isSwap, state.amount0, state.amount1, state.token0, state.token1); 190 | } 191 | 192 | // function to configure a token to be used with this runner 193 | // it needs to have approvals set for this contract beforehand 194 | function configToken(uint256 tokenId, PositionConfig calldata config) external { 195 | address owner = nonfungiblePositionManager.ownerOf(tokenId); 196 | if (owner != msg.sender) { 197 | revert Unauthorized(); 198 | } 199 | 200 | if (config.isActive) { 201 | if (config.token0TriggerTick >= config.token1TriggerTick) { 202 | revert InvalidConfig(); 203 | } 204 | } 205 | 206 | positionConfigs[tokenId] = config; 207 | 208 | emit PositionConfigured( 209 | tokenId, 210 | config.isActive, 211 | config.token0Swap, 212 | config.token1Swap, 213 | config.token0TriggerTick, 214 | config.token1TriggerTick, 215 | config.token0SlippageX64, 216 | config.token1SlippageX64, 217 | config.onlyFees, 218 | config.maxRewardX64 219 | ); 220 | } 221 | } -------------------------------------------------------------------------------- /src/automators/AutoRange.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "./Automator.sol"; 5 | 6 | /// @title AutoRange 7 | /// @notice Allows operator of AutoRange contract (Revert controlled bot) to change range for configured positions 8 | /// Positions need to be approved (setApprovalForAll) for the contract and configured with configToken method 9 | /// When executed a new position is created and automatically configured the same way as the original position 10 | contract AutoRange is Automator { 11 | 12 | error SameRange(); 13 | error NotSupportedFeeTier(); 14 | error SwapAmountTooLarge(); 15 | 16 | event RangeChanged( 17 | uint256 indexed oldTokenId, 18 | uint256 indexed newTokenId 19 | ); 20 | event PositionConfigured( 21 | uint256 indexed tokenId, 22 | int32 lowerTickLimit, 23 | int32 upperTickLimit, 24 | int32 lowerTickDelta, 25 | int32 upperTickDelta, 26 | uint64 token0SlippageX64, 27 | uint64 token1SlippageX64, 28 | bool onlyFees, 29 | uint64 maxRewardX64 30 | ); 31 | 32 | constructor(INonfungiblePositionManager _npm, address _operator, address _withdrawer, uint32 _TWAPSeconds, uint16 _maxTWAPTickDifference, address[] memory _swapRouterOptions) 33 | Automator(_npm, _operator, _withdrawer, _TWAPSeconds, _maxTWAPTickDifference, _swapRouterOptions) { 34 | } 35 | 36 | // defines when and how a position can be changed by operator 37 | // when a position is adjusted config for the position is cleared and copied to the newly created position 38 | struct PositionConfig { 39 | // needs more than int24 because it can be [-type(uint24).max,type(uint24).max] 40 | int32 lowerTickLimit; // if negative also in-range positions may be adjusted / if 0 out of range positions may be adjusted 41 | int32 upperTickLimit; // if negative also in-range positions may be adjusted / if 0 out of range positions may be adjusted 42 | int32 lowerTickDelta; // this amount is added to current tick (floored to tickspacing) to define lowerTick of new position 43 | int32 upperTickDelta; // this amount is added to current tick (floored to tickspacing) to define upperTick of new position 44 | uint64 token0SlippageX64; // max price difference from current pool price for swap / Q64 for token0 45 | uint64 token1SlippageX64; // max price difference from current pool price for swap / Q64 for token1 46 | bool onlyFees; // if only fees maybe used for protocol reward 47 | uint64 maxRewardX64; // max allowed reward percentage of fees or full position 48 | } 49 | 50 | // configured tokens 51 | mapping (uint256 => PositionConfig) public positionConfigs; 52 | 53 | /// @notice params for execute() 54 | struct ExecuteParams { 55 | uint256 tokenId; 56 | bool swap0To1; 57 | uint256 amountIn; // if this is set to 0 no swap happens 58 | bytes swapData; 59 | uint128 liquidity; // liquidity the calculations are based on 60 | uint256 amountRemoveMin0; // min amount to be removed from liquidity 61 | uint256 amountRemoveMin1; // min amount to be removed from liquidity 62 | uint256 deadline; // for uniswap operations - operator promises fair value 63 | uint64 rewardX64; // which reward will be used for protocol, can be max configured amount (considering onlyFees) 64 | } 65 | 66 | struct ExecuteState { 67 | address owner; 68 | address currentOwner; 69 | IUniswapV3Pool pool; 70 | address token0; 71 | address token1; 72 | uint24 fee; 73 | int24 tickLower; 74 | int24 tickUpper; 75 | int24 currentTick; 76 | 77 | uint256 amount0; 78 | uint256 amount1; 79 | uint256 feeAmount0; 80 | uint256 feeAmount1; 81 | 82 | uint256 maxAddAmount0; 83 | uint256 maxAddAmount1; 84 | 85 | uint256 amountAdded0; 86 | uint256 amountAdded1; 87 | 88 | uint128 liquidity; 89 | 90 | uint256 protocolReward0; 91 | uint256 protocolReward1; 92 | uint256 amountOutMin; 93 | uint256 amountInDelta; 94 | uint256 amountOutDelta; 95 | 96 | 97 | uint256 newTokenId; 98 | } 99 | 100 | /** 101 | * @notice Adjust token (must be in correct state) 102 | * Can only be called only from configured operator account 103 | * Swap needs to be done with max price difference from current pool price - otherwise reverts 104 | */ 105 | function execute(ExecuteParams calldata params) external { 106 | 107 | if (!operators[msg.sender]) { 108 | revert Unauthorized(); 109 | } 110 | 111 | ExecuteState memory state; 112 | PositionConfig memory config = positionConfigs[params.tokenId]; 113 | 114 | if (config.lowerTickDelta == config.upperTickDelta) { 115 | revert NotConfigured(); 116 | } 117 | 118 | if (config.onlyFees && params.rewardX64 > config.maxRewardX64 || !config.onlyFees && params.rewardX64 > config.maxRewardX64) { 119 | revert ExceedsMaxReward(); 120 | } 121 | 122 | // get position info 123 | (,, state.token0, state.token1, state.fee, state.tickLower, state.tickUpper, state.liquidity, , , , ) = nonfungiblePositionManager.positions(params.tokenId); 124 | 125 | if (state.liquidity != params.liquidity) { 126 | revert LiquidityChanged(); 127 | } 128 | 129 | (state.amount0, state.amount1, state.feeAmount0, state.feeAmount1) = _decreaseFullLiquidityAndCollect(params.tokenId, state.liquidity, params.amountRemoveMin0, params.amountRemoveMin1, params.deadline); 130 | 131 | // if only fees reward is removed before adding 132 | if (config.onlyFees) { 133 | state.protocolReward0 = state.feeAmount0 * params.rewardX64 / Q64; 134 | state.protocolReward1 = state.feeAmount1 * params.rewardX64 / Q64; 135 | state.amount0 -= state.protocolReward0; 136 | state.amount1 -= state.protocolReward1; 137 | } 138 | 139 | if (params.swap0To1 && params.amountIn > state.amount0 || !params.swap0To1 && params.amountIn > state.amount1) { 140 | revert SwapAmountTooLarge(); 141 | } 142 | 143 | // get pool info 144 | state.pool = _getPool(state.token0, state.token1, state.fee); 145 | 146 | // check oracle for swap 147 | (state.amountOutMin,state.currentTick,,) = _validateSwap(params.swap0To1, params.amountIn, state.pool, TWAPSeconds, maxTWAPTickDifference, params.swap0To1 ? config.token0SlippageX64 : config.token1SlippageX64); 148 | 149 | if (state.currentTick < state.tickLower - config.lowerTickLimit || state.currentTick >= state.tickUpper + config.upperTickLimit) { 150 | 151 | int24 tickSpacing = _getTickSpacing(state.fee); 152 | int24 baseTick = state.currentTick - (((state.currentTick % tickSpacing) + tickSpacing) % tickSpacing); 153 | 154 | // check if new range same as old range 155 | if (baseTick + config.lowerTickDelta == state.tickLower && baseTick + config.upperTickDelta == state.tickUpper) { 156 | revert SameRange(); 157 | } 158 | 159 | (state.amountInDelta, state.amountOutDelta) = _swap(params.swap0To1 ? IERC20(state.token0) : IERC20(state.token1), params.swap0To1 ? IERC20(state.token1) : IERC20(state.token0), params.amountIn, state.amountOutMin, params.swapData); 160 | 161 | state.amount0 = params.swap0To1 ? state.amount0 - state.amountInDelta : state.amount0 + state.amountOutDelta; 162 | state.amount1 = params.swap0To1 ? state.amount1 + state.amountOutDelta : state.amount1 - state.amountInDelta; 163 | 164 | // max amount to add - removing max potential fees (if config.onlyFees - the have been removed already) 165 | state.maxAddAmount0 = config.onlyFees ? state.amount0 : state.amount0 * Q64 / (params.rewardX64 + Q64); 166 | state.maxAddAmount1 = config.onlyFees ? state.amount1 : state.amount1 * Q64 / (params.rewardX64 + Q64); 167 | 168 | INonfungiblePositionManager.MintParams memory mintParams = 169 | INonfungiblePositionManager.MintParams( 170 | address(state.token0), 171 | address(state.token1), 172 | state.fee, 173 | SafeCast.toInt24(baseTick + config.lowerTickDelta), // reverts if out of valid range 174 | SafeCast.toInt24(baseTick + config.upperTickDelta), // reverts if out of valid range 175 | state.maxAddAmount0, 176 | state.maxAddAmount1, 177 | 0, 178 | 0, 179 | address(this), // is sent to real recipient aftwards 180 | params.deadline 181 | ); 182 | 183 | // approve npm 184 | SafeERC20.safeApprove(IERC20(state.token0), address(nonfungiblePositionManager), state.maxAddAmount0); 185 | SafeERC20.safeApprove(IERC20(state.token1), address(nonfungiblePositionManager), state.maxAddAmount1); 186 | 187 | // mint is done to address(this) first - its not a safemint 188 | (state.newTokenId,,state.amountAdded0,state.amountAdded1) = nonfungiblePositionManager.mint(mintParams); 189 | 190 | // remove remaining approval 191 | SafeERC20.safeApprove(IERC20(state.token0), address(nonfungiblePositionManager), 0); 192 | SafeERC20.safeApprove(IERC20(state.token1), address(nonfungiblePositionManager), 0); 193 | 194 | state.owner = nonfungiblePositionManager.ownerOf(params.tokenId); 195 | 196 | // send it to current owner 197 | nonfungiblePositionManager.safeTransferFrom(address(this), state.owner, state.newTokenId); 198 | 199 | // protocol reward is calculated based on added amount (to incentivize optimal swap done by operator) 200 | if (!config.onlyFees) { 201 | state.protocolReward0 = state.amountAdded0 * params.rewardX64 / Q64; 202 | state.protocolReward1 = state.amountAdded1 * params.rewardX64 / Q64; 203 | state.amount0 -= state.protocolReward0; 204 | state.amount1 -= state.protocolReward1; 205 | } 206 | 207 | // send leftover to owner 208 | if (state.amount0 - state.amountAdded0 > 0) { 209 | _transferToken(state.owner, IERC20(state.token0), state.amount0 - state.amountAdded0, true); 210 | } 211 | if (state.amount1 - state.amountAdded1 > 0) { 212 | _transferToken(state.owner, IERC20(state.token1), state.amount1 - state.amountAdded1, true); 213 | } 214 | 215 | // copy token config for new token 216 | positionConfigs[state.newTokenId] = config; 217 | emit PositionConfigured( 218 | state.newTokenId, 219 | config.lowerTickLimit, 220 | config.upperTickLimit, 221 | config.lowerTickDelta, 222 | config.upperTickDelta, 223 | config.token0SlippageX64, 224 | config.token1SlippageX64, 225 | config.onlyFees, 226 | config.maxRewardX64 227 | ); 228 | 229 | // delete config for old position 230 | delete positionConfigs[params.tokenId]; 231 | emit PositionConfigured(params.tokenId, 0, 0, 0, 0, 0, 0, false, 0); 232 | 233 | emit RangeChanged(params.tokenId, state.newTokenId); 234 | 235 | } else { 236 | revert NotReady(); 237 | } 238 | } 239 | 240 | // function to configure a token to be used with this runner 241 | // it needs to have approvals set for this contract beforehand 242 | function configToken(uint256 tokenId, PositionConfig calldata config) external { 243 | address owner = nonfungiblePositionManager.ownerOf(tokenId); 244 | if (owner != msg.sender) { 245 | revert Unauthorized(); 246 | } 247 | 248 | // lower tick must be always below or equal to upper tick - if they are equal - range adjustment is deactivated 249 | if (config.lowerTickDelta > config.upperTickDelta) { 250 | revert InvalidConfig(); 251 | } 252 | 253 | positionConfigs[tokenId] = config; 254 | 255 | emit PositionConfigured( 256 | tokenId, 257 | config.lowerTickLimit, 258 | config.upperTickLimit, 259 | config.lowerTickDelta, 260 | config.upperTickDelta, 261 | config.token0SlippageX64, 262 | config.token1SlippageX64, 263 | config.onlyFees, 264 | config.maxRewardX64 265 | ); 266 | } 267 | 268 | // get tick spacing for fee tier (cached when possible) 269 | function _getTickSpacing(uint24 fee) internal view returns (int24) { 270 | if (fee == 10000) { 271 | return 200; 272 | } else if (fee == 3000) { 273 | return 60; 274 | } else if (fee == 500) { 275 | return 10; 276 | } else { 277 | int24 spacing = factory.feeAmountTickSpacing(fee); 278 | if (spacing <= 0) { 279 | revert NotSupportedFeeTier(); 280 | } 281 | return spacing; 282 | } 283 | } 284 | } -------------------------------------------------------------------------------- /src/automators/Automator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/access/Ownable.sol"; 5 | import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 6 | import "@openzeppelin/contracts/utils/math/SafeCast.sol"; 7 | 8 | import "v3-core/interfaces/IUniswapV3Factory.sol"; 9 | import "v3-core/interfaces/IUniswapV3Pool.sol"; 10 | import "v3-core/libraries/TickMath.sol"; 11 | import "v3-core/libraries/FullMath.sol"; 12 | 13 | import "v3-periphery/interfaces/INonfungiblePositionManager.sol"; 14 | import "v3-periphery/interfaces/external/IWETH9.sol"; 15 | 16 | abstract contract Automator is Ownable { 17 | 18 | uint256 internal constant Q64 = 2 ** 64; 19 | uint256 internal constant Q96 = 2 ** 96; 20 | 21 | uint32 public constant MIN_TWAP_SECONDS = 60; // 1 minute 22 | uint32 public constant MAX_TWAP_TICK_DIFFERENCE = 200; // 2% 23 | 24 | error NotConfigured(); 25 | error NotReady(); 26 | error Unauthorized(); 27 | error InvalidConfig(); 28 | error TWAPCheckFailed(); 29 | error EtherSendFailed(); 30 | error NotWETH(); 31 | error SwapFailed(); 32 | error SlippageError(); 33 | error LiquidityChanged(); 34 | error ExceedsMaxReward(); 35 | 36 | INonfungiblePositionManager public immutable nonfungiblePositionManager; 37 | IUniswapV3Factory public immutable factory; 38 | IWETH9 public immutable weth; 39 | 40 | // preconfigured options for swap routers 41 | address public immutable swapRouterOption0; 42 | address public immutable swapRouterOption1; 43 | address public immutable swapRouterOption2; 44 | 45 | // admin events 46 | event OperatorChanged(address newOperator, bool active); 47 | event WithdrawerChanged(address newWithdrawer); 48 | event TWAPConfigChanged(uint32 TWAPSeconds, uint16 maxTWAPTickDifference); 49 | event SwapRouterChanged(uint8 swapRouterIndex); 50 | 51 | // configurable by owner 52 | mapping(address => bool) public operators; 53 | address public withdrawer; 54 | uint32 public TWAPSeconds; 55 | uint16 public maxTWAPTickDifference; 56 | uint8 public swapRouterIndex; // default is 0 57 | 58 | constructor(INonfungiblePositionManager npm, address _operator, address _withdrawer, uint32 _TWAPSeconds, uint16 _maxTWAPTickDifference, address[] memory _swapRouterOptions) { 59 | 60 | nonfungiblePositionManager = npm; 61 | weth = IWETH9(npm.WETH9()); 62 | factory = IUniswapV3Factory(npm.factory()); 63 | 64 | // hardcoded 3 options for swap routers 65 | swapRouterOption0 = _swapRouterOptions[0]; 66 | swapRouterOption1 = _swapRouterOptions[1]; 67 | swapRouterOption2 = _swapRouterOptions[2]; 68 | 69 | emit SwapRouterChanged(0); 70 | 71 | setOperator(_operator, true); 72 | setWithdrawer(_withdrawer); 73 | 74 | setTWAPConfig(_maxTWAPTickDifference, _TWAPSeconds); 75 | } 76 | 77 | /** 78 | * @notice Owner controlled function to change swap router (onlyOwner) 79 | * @param _swapRouterIndex new swap router index 80 | */ 81 | function setSwapRouter(uint8 _swapRouterIndex) external onlyOwner { 82 | 83 | // only allow preconfigured routers 84 | if (_swapRouterIndex > 2) { 85 | revert InvalidConfig(); 86 | } 87 | 88 | emit SwapRouterChanged(_swapRouterIndex); 89 | swapRouterIndex = _swapRouterIndex; 90 | } 91 | 92 | /** 93 | * @notice Owner controlled function to set withdrawer address 94 | * @param _withdrawer withdrawer 95 | */ 96 | function setWithdrawer(address _withdrawer) public onlyOwner { 97 | emit WithdrawerChanged(_withdrawer); 98 | withdrawer = _withdrawer; 99 | } 100 | 101 | /** 102 | * @notice Owner controlled function to activate/deactivate operator address 103 | * @param _operator operator 104 | * @param _active active or not 105 | */ 106 | function setOperator(address _operator, bool _active) public onlyOwner { 107 | emit OperatorChanged(_operator, _active); 108 | operators[_operator] = _active; 109 | } 110 | 111 | /** 112 | * @notice Owner controlled function to increase TWAPSeconds / decrease maxTWAPTickDifference 113 | */ 114 | function setTWAPConfig(uint16 _maxTWAPTickDifference, uint32 _TWAPSeconds) public onlyOwner { 115 | if (_TWAPSeconds < MIN_TWAP_SECONDS) { 116 | revert InvalidConfig(); 117 | } 118 | if (_maxTWAPTickDifference > MAX_TWAP_TICK_DIFFERENCE) { 119 | revert InvalidConfig(); 120 | } 121 | emit TWAPConfigChanged(_TWAPSeconds, _maxTWAPTickDifference); 122 | TWAPSeconds = _TWAPSeconds; 123 | maxTWAPTickDifference = _maxTWAPTickDifference; 124 | } 125 | 126 | 127 | /** 128 | * @notice Withdraws token balance 129 | * @param tokens Addresses of tokens to withdraw 130 | * @param to Address to send to 131 | */ 132 | function withdrawBalances(address[] calldata tokens, address to) external { 133 | 134 | if (msg.sender != withdrawer) { 135 | revert Unauthorized(); 136 | } 137 | 138 | uint i; 139 | uint count = tokens.length; 140 | for(;i < count;++i) { 141 | uint256 balance = IERC20(tokens[i]).balanceOf(address(this)); 142 | if (balance > 0) { 143 | _transferToken(to, IERC20(tokens[i]), balance, true); 144 | } 145 | } 146 | } 147 | 148 | /** 149 | * @notice Withdraws ETH balance 150 | * @param to Address to send to 151 | */ 152 | function withdrawETH(address to) external { 153 | 154 | if (msg.sender != withdrawer) { 155 | revert Unauthorized(); 156 | } 157 | 158 | uint256 balance = address(this).balance; 159 | if (balance > 0) { 160 | (bool sent,) = to.call{value: balance}(""); 161 | if (!sent) { 162 | revert EtherSendFailed(); 163 | } 164 | } 165 | } 166 | 167 | // validate if swap can be done with specified oracle parameters - if not possible reverts 168 | // if possible returns minAmountOut 169 | function _validateSwap(bool swap0For1, uint256 amountIn, IUniswapV3Pool pool, uint32 twapPeriod, uint16 maxTickDifference, uint64 maxPriceDifferenceX64) internal view returns (uint256 amountOutMin, int24 currentTick, uint160 sqrtPriceX96, uint256 priceX96) { 170 | 171 | // get current price and tick 172 | (sqrtPriceX96,currentTick,,,,,) = pool.slot0(); 173 | 174 | // check if current tick not too far from TWAP 175 | if (!_hasMaxTWAPTickDifference(pool, twapPeriod, currentTick, maxTickDifference)) { 176 | revert TWAPCheckFailed(); 177 | } 178 | 179 | // calculate min output price price and percentage 180 | priceX96 = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, Q96); 181 | if (swap0For1) { 182 | amountOutMin = FullMath.mulDiv(amountIn * (Q64 - maxPriceDifferenceX64), priceX96, Q96 * Q64); 183 | } else { 184 | amountOutMin = FullMath.mulDiv(amountIn * (Q64 - maxPriceDifferenceX64), Q96, priceX96 * Q64); 185 | } 186 | } 187 | 188 | // general swap function which uses external router with off-chain calculated swap instructions 189 | // does price difference check with amountOutMin param (calculated based on oracle verified price) 190 | // NOTE: can be only called from (partially) trusted context (nft owner / contract owner / operator) because otherwise swapData can be manipulated to return always amountOutMin 191 | // returns new token amounts after swap 192 | function _swap(IERC20 tokenIn, IERC20 tokenOut, uint256 amountIn, uint256 amountOutMin, bytes memory swapData) internal returns (uint256 amountInDelta, uint256 amountOutDelta) { 193 | if (amountIn > 0 && swapData.length > 0) { 194 | uint256 balanceInBefore = tokenIn.balanceOf(address(this)); 195 | uint256 balanceOutBefore = tokenOut.balanceOf(address(this)); 196 | 197 | // get router specific swap data 198 | (address allowanceTarget, bytes memory data) = abi.decode(swapData, (address, bytes)); 199 | 200 | // approve needed amount 201 | SafeERC20.safeApprove(tokenIn, allowanceTarget, amountIn); 202 | 203 | // execute swap with configured router 204 | address swapRouter = swapRouterIndex == 0 ? swapRouterOption0 : (swapRouterIndex == 1 ? swapRouterOption1 : swapRouterOption2); 205 | (bool success,) = swapRouter.call(data); 206 | if (!success) { 207 | revert SwapFailed(); 208 | } 209 | 210 | // remove any remaining allowance 211 | SafeERC20.safeApprove(tokenIn, allowanceTarget, 0); 212 | 213 | uint256 balanceInAfter = tokenIn.balanceOf(address(this)); 214 | uint256 balanceOutAfter = tokenOut.balanceOf(address(this)); 215 | 216 | amountInDelta = balanceInBefore - balanceInAfter; 217 | amountOutDelta = balanceOutAfter - balanceOutBefore; 218 | 219 | // amountMin slippage check 220 | if (amountOutDelta < amountOutMin) { 221 | revert SlippageError(); 222 | } 223 | } 224 | } 225 | 226 | // Checks if there was not more tick difference 227 | // returns false if not enough data available or tick difference >= maxDifference 228 | function _hasMaxTWAPTickDifference(IUniswapV3Pool pool, uint32 twapPeriod, int24 currentTick, uint16 maxDifference) internal view returns (bool) { 229 | (int24 twapTick, bool twapOk) = _getTWAPTick(pool, twapPeriod); 230 | if (twapOk) { 231 | return twapTick - currentTick >= -int16(maxDifference) && twapTick - currentTick <= int16(maxDifference); 232 | } else { 233 | return false; 234 | } 235 | } 236 | 237 | // gets twap tick from pool history if enough history available 238 | function _getTWAPTick(IUniswapV3Pool pool, uint32 twapPeriod) internal view returns (int24, bool) { 239 | uint32[] memory secondsAgos = new uint32[](2); 240 | secondsAgos[0] = 0; // from (before) 241 | secondsAgos[1] = twapPeriod; // from (before) 242 | 243 | // pool observe may fail when there is not enough history available 244 | try pool.observe(secondsAgos) returns (int56[] memory tickCumulatives, uint160[] memory) { 245 | return (int24((tickCumulatives[0] - tickCumulatives[1]) / int56(uint56(twapPeriod))), true); 246 | } catch { 247 | return (0, false); 248 | } 249 | } 250 | 251 | // get pool for token 252 | function _getPool( 253 | address tokenA, 254 | address tokenB, 255 | uint24 fee 256 | ) internal view returns (IUniswapV3Pool) { 257 | return 258 | IUniswapV3Pool( 259 | PoolAddress.computeAddress( 260 | address(factory), 261 | PoolAddress.getPoolKey(tokenA, tokenB, fee) 262 | ) 263 | ); 264 | } 265 | 266 | function _decreaseFullLiquidityAndCollect(uint256 tokenId, uint128 liquidity, uint256 amountRemoveMin0, uint256 amountRemoveMin1, uint256 deadline) internal returns (uint256 amount0, uint256 amount1, uint256 feeAmount0, uint256 feeAmount1) { 267 | if (liquidity > 0) { 268 | // store in temporarely "misnamed" variables - see comment below 269 | (feeAmount0, feeAmount1) = nonfungiblePositionManager.decreaseLiquidity( 270 | INonfungiblePositionManager.DecreaseLiquidityParams( 271 | tokenId, 272 | liquidity, 273 | amountRemoveMin0, 274 | amountRemoveMin1, 275 | deadline 276 | ) 277 | ); 278 | } 279 | (amount0, amount1) = nonfungiblePositionManager.collect( 280 | INonfungiblePositionManager.CollectParams( 281 | tokenId, 282 | address(this), 283 | type(uint128).max, 284 | type(uint128).max 285 | ) 286 | ); 287 | 288 | // fee amount is what was collected additionally to liquidity amount 289 | feeAmount0 = amount0 - feeAmount0; 290 | feeAmount1 = amount1 - feeAmount1; 291 | } 292 | 293 | // transfers token (or unwraps WETH and sends ETH) 294 | function _transferToken(address to, IERC20 token, uint256 amount, bool unwrap) internal { 295 | if (address(weth) == address(token) && unwrap) { 296 | weth.withdraw(amount); 297 | (bool sent, ) = to.call{value: amount}(""); 298 | if (!sent) { 299 | revert EtherSendFailed(); 300 | } 301 | } else { 302 | SafeERC20.safeTransfer(token, to, amount); 303 | } 304 | } 305 | 306 | // needed for WETH unwrapping 307 | receive() external payable { 308 | if (msg.sender != address(weth)) { 309 | revert NotWETH(); 310 | } 311 | } 312 | } -------------------------------------------------------------------------------- /test/IntegrationTestBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | 7 | import "../src/V3Utils.sol"; 8 | 9 | abstract contract IntegrationTestBase is Test { 10 | 11 | uint256 constant Q64 = 2**64; 12 | 13 | int24 constant MIN_TICK_100 = -887272; 14 | int24 constant MIN_TICK_500 = -887270; 15 | 16 | IERC20 constant WETH_ERC20 = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); 17 | IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); 18 | IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); 19 | 20 | address constant WHALE_ACCOUNT = 0xF977814e90dA44bFA03b6295A0616a897441aceC; 21 | address constant OPERATOR_ACCOUNT = 0xF977814e90dA44bFA03b6295A0616a897441aceC; 22 | address constant WITHDRAWER_ACCOUNT = 0xF977814e90dA44bFA03b6295A0616a897441aceC; 23 | 24 | uint64 constant MAX_REWARD = uint64(Q64 / 400); //0.25% 25 | uint64 constant MAX_FEE_REWARD = uint64(Q64 / 20); //5% 26 | 27 | address FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; 28 | 29 | INonfungiblePositionManager constant NPM = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88); 30 | 31 | address EX0x = 0xDef1C0ded9bec7F1a1670819833240f027b25EfF; // 0x exchange proxy 32 | address UNIVERSAL_ROUTER = 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD; // uniswap universal router 33 | address UNISWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; // uniswap router 1.0 34 | 35 | // DAI/USDC 0.05% - one sided only DAI - current tick is near -276326 - no liquidity (-276320/-276310) 36 | uint256 constant TEST_NFT = 24181; 37 | address constant TEST_NFT_ACCOUNT = 0x8cadb20A4811f363Dadb863A190708bEd26245F8; 38 | address constant TEST_NFT_POOL = 0x6c6Bc977E13Df9b0de53b251522280BB72383700; 39 | 40 | 41 | uint256 constant TEST_NFT_2 = 7; // DAI/WETH 0.3% - one sided only WETH - with liquidity and fees (-84120/-78240) 42 | uint256 constant TEST_NFT_2_A = 126; // DAI/USDC 0.05% - in range (-276330/-276320) 43 | uint256 constant TEST_NFT_2_B = 37; // USDC/WETH 0.3% - out of range (192180/193380) 44 | address constant TEST_NFT_2_ACCOUNT = 0x3b8ccaa89FcD432f1334D35b10fF8547001Ce3e5; 45 | address constant TEST_NFT_2_POOL = 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8; 46 | 47 | // DAI/USDC 0.05% - in range - with liquidity and fees 48 | uint256 constant TEST_NFT_3 = 4660; 49 | address constant TEST_NFT_3_ACCOUNT = 0xa3eF006a7da5BcD1144d8BB86EfF1734f46A0c1E; 50 | address constant TEST_NFT_3_POOL = 0x6c6Bc977E13Df9b0de53b251522280BB72383700; 51 | 52 | // USDC/WETH 0.3% - in range - with liquidity and fees 53 | uint constant TEST_NFT_4 = 827; 54 | address constant TEST_NFT_4_ACCOUNT = 0x96653b13bD00842Eb8Bc77dCCFd48075178733ce; 55 | address constant TEST_NFT_4_POOL = 0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8; 56 | 57 | // DAI/USDC 0.05% - in range - with liquidity and fees 58 | uint constant TEST_NFT_5 = 23901; 59 | address constant TEST_NFT_5_ACCOUNT = 0x082d3e0f04664b65127876e9A05e2183451c792a; 60 | 61 | 62 | address constant TEST_FEE_ACCOUNT = 0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB; 63 | 64 | uint256 mainnetFork; 65 | 66 | V3Utils v3utils; 67 | 68 | function _setupBase() internal { 69 | 70 | mainnetFork = vm.createFork("https://rpc.ankr.com/eth", 15489169); 71 | vm.selectFork(mainnetFork); 72 | 73 | v3utils = new V3Utils(NPM, EX0x); 74 | } 75 | 76 | function _getSwapRouterOptions() internal returns (address[] memory swapRouterOptions) { 77 | swapRouterOptions = new address[](3); 78 | swapRouterOptions[0] = EX0x; 79 | swapRouterOptions[1] = UNIVERSAL_ROUTER; 80 | swapRouterOptions[2] = UNISWAP_ROUTER; 81 | } 82 | } -------------------------------------------------------------------------------- /test/integration/V3Utils.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import "../IntegrationTestBase.sol"; 5 | 6 | contract V3UtilsIntegrationTest is IntegrationTestBase { 7 | 8 | function setUp() external { 9 | _setupBase(); 10 | } 11 | 12 | function testUnauthorizedTransfer() external { 13 | vm.expectRevert( 14 | abi.encodePacked( 15 | "ERC721: transfer caller is not owner nor approved" 16 | ) 17 | ); 18 | V3Utils.Instructions memory inst = V3Utils.Instructions( 19 | V3Utils.WhatToDo.CHANGE_RANGE, 20 | address(0), 21 | 0, 22 | 0, 23 | 0, 24 | 0, 25 | "", 26 | 0, 27 | 0, 28 | "", 29 | 0, 30 | 0, 31 | 0, 32 | 0, 33 | 0, 34 | 0, 35 | 0, 36 | 0, 37 | 0, 38 | TEST_NFT_ACCOUNT, 39 | TEST_NFT_ACCOUNT, 40 | false, 41 | "", 42 | "" 43 | ); 44 | NPM.safeTransferFrom( 45 | TEST_NFT_ACCOUNT, 46 | address(v3utils), 47 | TEST_NFT, 48 | abi.encode(inst) 49 | ); 50 | } 51 | 52 | function testInvalidInstructions() external { 53 | // reverts with ERC721Receiver error if Instructions are invalid 54 | vm.expectRevert( 55 | abi.encodePacked( 56 | "ERC721: transfer to non ERC721Receiver implementer" 57 | ) 58 | ); 59 | vm.prank(TEST_NFT_ACCOUNT); 60 | NPM.safeTransferFrom( 61 | TEST_NFT_ACCOUNT, 62 | address(v3utils), 63 | TEST_NFT, 64 | abi.encode(true, false, 1, "test") 65 | ); 66 | } 67 | 68 | function testSendEtherNotAllowed() external { 69 | bool success; 70 | vm.expectRevert(V3Utils.NotWETH.selector); 71 | (success,) = address(v3utils).call{value: 123}(""); 72 | } 73 | 74 | function testTransferDecreaseSlippageError() external { 75 | // add liquidity to existing (empty) position (add 1 DAI / 0 USDC) 76 | _increaseLiquidity(); 77 | 78 | (, , , , , , , uint128 liquidityBefore, , , , ) = NPM.positions( 79 | TEST_NFT 80 | ); 81 | 82 | // swap a bit more dai than available - fails with slippage error because not enough liquidity + fees is collected 83 | V3Utils.Instructions memory inst = V3Utils.Instructions( 84 | V3Utils.WhatToDo.CHANGE_RANGE, 85 | address(USDC), 86 | 1000000000000000001, 87 | 400000, 88 | 1000000000000000001, 89 | 400000, 90 | _get05DAIToUSDCSwapData(), 91 | 0, 92 | 0, 93 | "", 94 | type(uint128).max, // take all fees 95 | type(uint128).max, // take all fees 96 | 100, // change fee as well 97 | MIN_TICK_100, 98 | -MIN_TICK_100, 99 | liquidityBefore, // take all liquidity 100 | 0, 101 | 0, 102 | block.timestamp, 103 | TEST_NFT_ACCOUNT, 104 | TEST_NFT_ACCOUNT, 105 | false, 106 | "", 107 | "" 108 | ); 109 | 110 | vm.prank(TEST_NFT_ACCOUNT); 111 | vm.expectRevert("Price slippage check"); 112 | NPM.safeTransferFrom( 113 | TEST_NFT_ACCOUNT, 114 | address(v3utils), 115 | TEST_NFT, 116 | abi.encode(inst) 117 | ); 118 | } 119 | 120 | function testTransferAmountError() external { 121 | // add liquidity to existing (empty) position (add 1 DAI / 0 USDC) 122 | _increaseLiquidity(); 123 | 124 | (, , , , , , , uint128 liquidityBefore, , , , ) = NPM.positions( 125 | TEST_NFT 126 | ); 127 | 128 | // swap a bit more dai than available - fails with slippage error because not enough liquidity + fees is collected 129 | V3Utils.Instructions memory inst = V3Utils.Instructions( 130 | V3Utils.WhatToDo.CHANGE_RANGE, 131 | address(USDC), 132 | 0, 133 | 0, 134 | 1000000000000000001, 135 | 400000, 136 | _get05DAIToUSDCSwapData(), 137 | 0, 138 | 0, 139 | "", 140 | type(uint128).max, // take all fees 141 | type(uint128).max, // take all fees 142 | 100, // change fee as well 143 | MIN_TICK_100, 144 | -MIN_TICK_100, 145 | liquidityBefore, // take all liquidity 146 | 0, 147 | 0, 148 | block.timestamp, 149 | TEST_NFT_ACCOUNT, 150 | TEST_NFT_ACCOUNT, 151 | false, 152 | "", 153 | "" 154 | ); 155 | 156 | vm.prank(TEST_NFT_ACCOUNT); 157 | vm.expectRevert(V3Utils.AmountError.selector); 158 | NPM.safeTransferFrom( 159 | TEST_NFT_ACCOUNT, 160 | address(v3utils), 161 | TEST_NFT, 162 | abi.encode(inst) 163 | ); 164 | } 165 | 166 | function testTransferWithChangeRange() external { 167 | // add liquidity to existing (empty) position (add 1 DAI / 0 USDC) 168 | _increaseLiquidity(); 169 | 170 | uint256 countBefore = NPM.balanceOf(TEST_NFT_ACCOUNT); 171 | 172 | (, , , , , , , uint128 liquidityBefore, , , , ) = NPM.positions( 173 | TEST_NFT 174 | ); 175 | 176 | // swap half of DAI to USDC and add full range 177 | V3Utils.Instructions memory inst = V3Utils.Instructions( 178 | V3Utils.WhatToDo.CHANGE_RANGE, 179 | address(USDC), 180 | 0, 181 | 0, 182 | 500000000000000000, 183 | 400000, 184 | _get05DAIToUSDCSwapData(), 185 | 0, 186 | 0, 187 | "", 188 | type(uint128).max, // take all fees 189 | type(uint128).max, // take all fees 190 | 100, // change fee as well 191 | MIN_TICK_100, 192 | -MIN_TICK_100, 193 | liquidityBefore, // take all liquidity 194 | 0, 195 | 0, 196 | block.timestamp, 197 | TEST_NFT_ACCOUNT, 198 | TEST_NFT_ACCOUNT, 199 | false, 200 | "", 201 | "" 202 | ); 203 | 204 | // using approve / execute pattern 205 | vm.prank(TEST_NFT_ACCOUNT); 206 | NPM.approve(address(v3utils), TEST_NFT); 207 | 208 | vm.prank(TEST_NFT_ACCOUNT); 209 | v3utils.execute(TEST_NFT, inst); 210 | 211 | // now we have 2 NFTs (1 empty) 212 | uint256 countAfter = NPM.balanceOf(TEST_NFT_ACCOUNT); 213 | assertGt(countAfter, countBefore); 214 | 215 | (, , , , , , , uint128 liquidityAfter, , , , ) = NPM.positions( 216 | TEST_NFT 217 | ); 218 | assertEq(liquidityAfter, 0); 219 | } 220 | 221 | function testTransferWithCompoundNoSwap() external { 222 | V3Utils.Instructions memory inst = V3Utils.Instructions( 223 | V3Utils.WhatToDo.COMPOUND_FEES, 224 | address(0), 225 | 0, 226 | 0, 227 | 0, 228 | 0, 229 | "", 230 | 0, 231 | 0, 232 | "", 233 | type(uint128).max, 234 | type(uint128).max, 235 | 0, 236 | 0, 237 | 0, 238 | 0, 239 | 0, 240 | 0, 241 | block.timestamp, 242 | TEST_NFT_3_ACCOUNT, 243 | TEST_NFT_3_ACCOUNT, 244 | false, 245 | "", 246 | "" 247 | ); 248 | 249 | uint256 daiBefore = DAI.balanceOf(TEST_NFT_3_ACCOUNT); 250 | uint256 usdcBefore = USDC.balanceOf(TEST_NFT_3_ACCOUNT); 251 | (, , , , , , , uint128 liquidityBefore, , , , ) = NPM.positions( 252 | TEST_NFT_3 253 | ); 254 | 255 | assertEq(daiBefore, 14382879654257202832190); 256 | assertEq(usdcBefore, 754563026); 257 | assertEq(liquidityBefore, 12922419498089422291); 258 | 259 | vm.prank(TEST_NFT_3_ACCOUNT); 260 | NPM.safeTransferFrom( 261 | TEST_NFT_3_ACCOUNT, 262 | address(v3utils), 263 | TEST_NFT_3, 264 | abi.encode(inst) 265 | ); 266 | 267 | uint256 daiAfter = DAI.balanceOf(TEST_NFT_3_ACCOUNT); 268 | uint256 usdcAfter = USDC.balanceOf(TEST_NFT_3_ACCOUNT); 269 | (, , , , , , , uint128 liquidityAfter, , , , ) = NPM.positions( 270 | TEST_NFT_3 271 | ); 272 | 273 | assertEq(daiAfter, 14382879654257202838632); 274 | assertEq(usdcAfter, 806331571); 275 | assertEq(liquidityAfter, 13034529712992826193); 276 | } 277 | 278 | function testTransferWithCompoundSwap() external { 279 | V3Utils.Instructions memory inst = V3Utils.Instructions( 280 | V3Utils.WhatToDo.COMPOUND_FEES, 281 | address(USDC), 282 | 0, 283 | 0, 284 | 500000000000000000, 285 | 400000, 286 | _get05DAIToUSDCSwapData(), 287 | 0, 288 | 0, 289 | "", 290 | type(uint128).max, 291 | type(uint128).max, 292 | 0, 293 | 0, 294 | 0, 295 | 0, 296 | 0, 297 | 0, 298 | block.timestamp, 299 | TEST_NFT_3_ACCOUNT, 300 | TEST_NFT_3_ACCOUNT, 301 | false, 302 | "", 303 | "" 304 | ); 305 | 306 | uint256 daiBefore = DAI.balanceOf(TEST_NFT_3_ACCOUNT); 307 | uint256 usdcBefore = USDC.balanceOf(TEST_NFT_3_ACCOUNT); 308 | (, , , , , , , uint128 liquidityBefore, , , , ) = NPM.positions( 309 | TEST_NFT_3 310 | ); 311 | 312 | assertEq(daiBefore, 14382879654257202832190); 313 | assertEq(usdcBefore, 754563026); 314 | assertEq(liquidityBefore, 12922419498089422291); 315 | 316 | vm.prank(TEST_NFT_3_ACCOUNT); 317 | NPM.safeTransferFrom( 318 | TEST_NFT_3_ACCOUNT, 319 | address(v3utils), 320 | TEST_NFT_3, 321 | abi.encode(inst) 322 | ); 323 | 324 | uint256 daiAfter = DAI.balanceOf(TEST_NFT_3_ACCOUNT); 325 | uint256 usdcAfter = USDC.balanceOf(TEST_NFT_3_ACCOUNT); 326 | (, , , , , , , uint128 liquidityAfter, , , , ) = NPM.positions( 327 | TEST_NFT_3 328 | ); 329 | 330 | assertEq(daiAfter, 14382879654257202836992); 331 | assertEq(usdcAfter, 807250914); 332 | assertEq(liquidityAfter, 13034375296304506054); 333 | } 334 | 335 | function _testTransferWithWithdrawAndSwap() internal { 336 | // add liquidity to existing (empty) position (add 1 DAI / 0 USDC) 337 | (uint128 liquidity, , ) = _increaseLiquidity(); 338 | 339 | uint256 countBefore = NPM.balanceOf(TEST_NFT_ACCOUNT); 340 | 341 | // swap half of DAI to USDC and add full range 342 | V3Utils.Instructions memory inst = V3Utils.Instructions( 343 | V3Utils.WhatToDo.WITHDRAW_AND_COLLECT_AND_SWAP, 344 | address(USDC), 345 | 0, 346 | 0, 347 | 990099009900989844, // uniswap returns 1 less when getting liquidity - this must be traded 348 | 900000, 349 | _get1DAIToUSDSwapData(), 350 | 0, 351 | 0, 352 | "", 353 | 0, 354 | 0, 355 | 0, 356 | 0, 357 | 0, 358 | liquidity, 359 | 0, 360 | 0, 361 | block.timestamp, 362 | TEST_NFT_ACCOUNT, 363 | TEST_NFT_ACCOUNT, 364 | false, 365 | "", 366 | "" 367 | ); 368 | 369 | vm.prank(TEST_NFT_ACCOUNT); 370 | NPM.safeTransferFrom( 371 | TEST_NFT_ACCOUNT, 372 | address(v3utils), 373 | TEST_NFT, 374 | abi.encode(inst) 375 | ); 376 | 377 | uint256 countAfter = NPM.balanceOf(TEST_NFT_ACCOUNT); 378 | 379 | assertEq(countAfter, countBefore); // nft returned 380 | } 381 | 382 | function _testTransferWithCollectAndSwap() internal { 383 | // add liquidity to existing (empty) position (add 1 DAI / 0 USDC) 384 | (uint128 liquidity, , ) = _increaseLiquidity(); 385 | 386 | // decrease liquidity without collect (simulate fee growth) 387 | vm.prank(TEST_NFT_ACCOUNT); 388 | (uint256 amount0, uint256 amount1) = NPM.decreaseLiquidity( 389 | INonfungiblePositionManager.DecreaseLiquidityParams( 390 | TEST_NFT, 391 | liquidity, 392 | 0, 393 | 0, 394 | block.timestamp 395 | ) 396 | ); 397 | 398 | // should be same amount as added 399 | assertEq(amount0, 1000000000000000000); 400 | assertEq(amount1, 0); 401 | 402 | uint256 countBefore = NPM.balanceOf(TEST_NFT_ACCOUNT); 403 | 404 | // swap half of DAI to USDC and add full range 405 | V3Utils.Instructions memory inst = V3Utils.Instructions( 406 | V3Utils.WhatToDo.WITHDRAW_AND_COLLECT_AND_SWAP, 407 | address(USDC), 408 | 0, 409 | 0, 410 | 990099009900989844, // uniswap returns 1 less when getting liquidity - this must be traded 411 | 900000, 412 | _get1DAIToUSDSwapData(), 413 | 0, 414 | 0, 415 | "", 416 | uint128(amount0), 417 | uint128(amount1), 418 | 0, 419 | 0, 420 | 0, 421 | 0, 422 | 0, 423 | 0, 424 | block.timestamp, 425 | TEST_NFT_ACCOUNT, 426 | TEST_NFT_ACCOUNT, 427 | false, 428 | "", 429 | "" 430 | ); 431 | 432 | vm.prank(TEST_NFT_ACCOUNT); 433 | NPM.safeTransferFrom( 434 | TEST_NFT_ACCOUNT, 435 | address(v3utils), 436 | TEST_NFT, 437 | abi.encode(inst) 438 | ); 439 | 440 | uint256 countAfter = NPM.balanceOf(TEST_NFT_ACCOUNT); 441 | 442 | assertEq(countAfter, countBefore); // nft returned 443 | } 444 | 445 | function testFailEmptySwapAndIncreaseLiquidity() external { 446 | V3Utils.SwapAndIncreaseLiquidityParams memory params = V3Utils 447 | .SwapAndIncreaseLiquidityParams( 448 | TEST_NFT, 449 | 0, 450 | 0, 451 | TEST_NFT_ACCOUNT, 452 | block.timestamp, 453 | IERC20(address(0)), 454 | 0, 455 | 0, 456 | "", 457 | 0, 458 | 0, 459 | "", 460 | 0, 461 | 0 462 | ); 463 | 464 | vm.prank(TEST_NFT_ACCOUNT); 465 | v3utils.swapAndIncreaseLiquidity(params); 466 | } 467 | 468 | function testSwapAndIncreaseLiquidity() external { 469 | V3Utils.SwapAndIncreaseLiquidityParams memory params = V3Utils 470 | .SwapAndIncreaseLiquidityParams( 471 | TEST_NFT, 472 | 0, 473 | 1000000, 474 | TEST_NFT_ACCOUNT, 475 | block.timestamp, 476 | USDC, 477 | 1000000, 478 | 900000000000000000, 479 | _get1USDCToDAISwapData(), 480 | 0, 481 | 0, 482 | "", 483 | 0, 484 | 0 485 | ); 486 | 487 | vm.prank(TEST_NFT_ACCOUNT); 488 | USDC.approve(address(v3utils), 1000000); 489 | 490 | vm.prank(TEST_NFT_ACCOUNT); 491 | (uint128 liquidity, uint256 amount0, uint256 amount1) = v3utils.swapAndIncreaseLiquidity(params); 492 | 493 | uint256 feeBalance = DAI.balanceOf(TEST_FEE_ACCOUNT); 494 | 495 | assertEq(liquidity, 1981476553512400); 496 | assertEq(amount0, 990241757080297141); 497 | assertEq(amount0 / feeBalance, 100); 498 | assertEq(amount1, 0); // one sided adding 499 | } 500 | 501 | function testSwapAndIncreaseLiquiditBothSides() external { 502 | 503 | // add liquidity to another positions which is not owned 504 | 505 | V3Utils.SwapAndIncreaseLiquidityParams memory params = V3Utils 506 | .SwapAndIncreaseLiquidityParams( 507 | TEST_NFT_5, 508 | 0, 509 | 2000000, 510 | TEST_NFT_ACCOUNT, 511 | block.timestamp, 512 | USDC, 513 | 1000000, 514 | 900000000000000000, 515 | _get1USDCToDAISwapData(), 516 | 0, 517 | 0, 518 | "", 519 | 0, 520 | 0 521 | ); 522 | 523 | vm.prank(TEST_NFT_ACCOUNT); 524 | USDC.approve(address(v3utils), 2000000); 525 | 526 | uint256 usdcBefore = USDC.balanceOf(TEST_NFT_ACCOUNT); 527 | uint256 daiBefore = DAI.balanceOf(TEST_NFT_ACCOUNT); 528 | 529 | vm.prank(TEST_NFT_ACCOUNT); 530 | (uint128 liquidity, uint256 amount0, uint256 amount1) = v3utils.swapAndIncreaseLiquidity(params); 531 | 532 | uint256 usdcAfter = USDC.balanceOf(TEST_NFT_ACCOUNT); 533 | uint256 daiAfter = DAI.balanceOf(TEST_NFT_ACCOUNT); 534 | 535 | // close to 1% of swapped amount 536 | uint256 feeBalance = DAI.balanceOf(TEST_FEE_ACCOUNT); 537 | assertEq(feeBalance, 9845545793003026); 538 | 539 | assertEq(liquidity, 19461088218850); 540 | assertEq(amount0, 907298600975927920); 541 | assertEq(amount1, 1000000); 542 | 543 | // all usdc spent 544 | assertEq(usdcBefore - usdcAfter, 2000000); 545 | //some dai returned - because not 100% correct swap ratio 546 | assertEq(daiAfter - daiBefore, 82943156104369254); 547 | } 548 | 549 | function testFailEmptySwapAndMint() external { 550 | V3Utils.SwapAndMintParams memory params = V3Utils.SwapAndMintParams( 551 | DAI, 552 | USDC, 553 | 500, 554 | MIN_TICK_500, 555 | -MIN_TICK_500, 556 | 0, 557 | 0, 558 | TEST_NFT_ACCOUNT, 559 | TEST_NFT_ACCOUNT, 560 | block.timestamp, 561 | IERC20(address(0)), 562 | 0, 563 | 0, 564 | "", 565 | 0, 566 | 0, 567 | "", 568 | 0, 569 | 0, 570 | "" 571 | ); 572 | 573 | vm.prank(TEST_NFT_ACCOUNT); 574 | v3utils.swapAndMint(params); 575 | } 576 | 577 | function testSwapAndMint() external { 578 | _testSwapAndMint( 579 | MIN_TICK_500, 580 | -MIN_TICK_500, 581 | 990200219842, 582 | 990241757079820864, 583 | 990159 584 | ); 585 | } 586 | 587 | function testSwapAndMintOneSided0() external { 588 | _testSwapAndMint( 589 | MIN_TICK_500, 590 | MIN_TICK_500 + 200000, 591 | 837822485815257126640, 592 | 0, 593 | 1000000 594 | ); 595 | } 596 | 597 | function testSwapAndMintOneSided1() external { 598 | _testSwapAndMint( 599 | -MIN_TICK_500 - 200000, 600 | -MIN_TICK_500, 601 | 829646810475079457895164424733679, 602 | 990241757080297174, 603 | 0 604 | ); 605 | } 606 | 607 | function _testSwapAndMint( 608 | int24 lower, 609 | int24 upper, 610 | uint256 eLiquidity, 611 | uint256 eAmount0, 612 | uint256 eAmount1 613 | ) internal { 614 | V3Utils.SwapAndMintParams memory params = V3Utils.SwapAndMintParams( 615 | DAI, 616 | USDC, 617 | 500, 618 | lower, 619 | upper, 620 | 0, 621 | 2000000, 622 | TEST_NFT_ACCOUNT, 623 | TEST_NFT_ACCOUNT, 624 | block.timestamp, 625 | USDC, 626 | 1000000, 627 | 900000000000000000, 628 | _get1USDCToDAISwapData(), 629 | 0, 630 | 0, 631 | "", 632 | 0, 633 | 0, 634 | "" 635 | ); 636 | 637 | vm.prank(TEST_NFT_ACCOUNT); 638 | USDC.approve(address(v3utils), 2000000); 639 | 640 | vm.prank(TEST_NFT_ACCOUNT); 641 | ( 642 | uint256 tokenId, 643 | uint128 liquidity, 644 | uint256 amount0, 645 | uint256 amount1 646 | ) = v3utils.swapAndMint(params); 647 | 648 | // close to 1% of swapped amount 649 | uint256 feeBalance = DAI.balanceOf(TEST_FEE_ACCOUNT); 650 | assertEq(feeBalance, 9845545793003026); 651 | 652 | assertGt(tokenId, 0); 653 | assertEq(liquidity, eLiquidity); 654 | assertEq(amount0, eAmount0); 655 | assertEq(amount1, eAmount1); 656 | } 657 | 658 | function testSwapAndMintWithETH() public { 659 | V3Utils.SwapAndMintParams memory params = V3Utils.SwapAndMintParams( 660 | DAI, 661 | USDC, 662 | 500, 663 | MIN_TICK_500, 664 | -MIN_TICK_500, 665 | 0, 666 | 0, 667 | TEST_NFT_ACCOUNT, 668 | TEST_NFT_ACCOUNT, 669 | block.timestamp, 670 | WETH_ERC20, 671 | 500000000000000000, // 0.5ETH 672 | 662616334956561731436, 673 | _get05ETHToDAISwapData(), 674 | 500000000000000000, // 0.5ETH 675 | 661794703, 676 | _get05ETHToUSDCSwapData(), 677 | 0, 678 | 0, 679 | "" 680 | ); 681 | 682 | vm.prank(TEST_NFT_ACCOUNT); 683 | ( 684 | uint256 tokenId, 685 | uint128 liquidity, 686 | uint256 amount0, 687 | uint256 amount1 688 | ) = v3utils.swapAndMint{value: 1 ether}(params); 689 | 690 | assertGt(tokenId, 0); 691 | assertEq(liquidity, 751622492052728); 692 | assertEq(amount0, 751654021355164315226); 693 | assertEq(amount1, 751590965); 694 | 695 | uint256 feeBalance0 = DAI.balanceOf(TEST_FEE_ACCOUNT); 696 | uint256 feeBalance1 = USDC.balanceOf(TEST_FEE_ACCOUNT); 697 | assertEq(feeBalance0, 4960241781990859038); 698 | assertEq(feeBalance1, 4953598); 699 | } 700 | 701 | function testSwapETHUSDC() public { 702 | V3Utils.SwapParams memory params = V3Utils.SwapParams( 703 | WETH_ERC20, 704 | USDC, 705 | 500000000000000000, // 0.5ETH 706 | 661794703, 707 | TEST_NFT_ACCOUNT, 708 | _get05ETHToUSDCSwapData(), 709 | false 710 | ); 711 | 712 | vm.prank(TEST_NFT_ACCOUNT); 713 | uint256 amountOut = v3utils.swap{value: (1 ether) / 2}(params); 714 | 715 | // fee in output token 716 | uint256 inputTokenBalance = WETH_ERC20.balanceOf(address(v3utils)); 717 | 718 | // swapped to USDC - fee 719 | assertEq(amountOut, 752453266); 720 | 721 | // input token no leftovers allowed 722 | assertEq(inputTokenBalance, 0); 723 | 724 | uint256 feeBalance = USDC.balanceOf(TEST_FEE_ACCOUNT); 725 | assertEq(feeBalance, 4953598); 726 | } 727 | 728 | function testSwapUSDCDAI() public { 729 | V3Utils.SwapParams memory params = V3Utils.SwapParams( 730 | USDC, 731 | DAI, 732 | 1000000, // 1 USDC 733 | 9 ether / 10, 734 | TEST_NFT_ACCOUNT, 735 | _get1USDCToDAISwapData(), 736 | false 737 | ); 738 | 739 | vm.startPrank(TEST_NFT_ACCOUNT); 740 | USDC.approve(address(v3utils), 1000000); 741 | uint256 amountOut = v3utils.swap(params); 742 | vm.stopPrank(); 743 | 744 | uint256 inputTokenBalance = USDC.balanceOf(address(v3utils)); 745 | 746 | // swapped to DAI - fee 747 | assertEq(amountOut, 990241757080297174); 748 | 749 | // input token no leftovers allowed 750 | assertEq(inputTokenBalance, 0); 751 | 752 | uint256 feeBalance = DAI.balanceOf(TEST_FEE_ACCOUNT); 753 | assertEq(feeBalance, 9845545793003026); 754 | 755 | uint256 otherFeeBalance = USDC.balanceOf(TEST_FEE_ACCOUNT); 756 | assertEq(otherFeeBalance, 0); 757 | } 758 | 759 | function testSwapUSDCDAIWithUniswapRouter() public { 760 | 761 | V3Utils v3utilsWithUniRouter = new V3Utils(NPM, UNISWAP_ROUTER); 762 | assertEq(address(v3utilsWithUniRouter), 0x2e234DAe75C793f67A35089C9d99245E1C58470b); 763 | 764 | V3Utils.SwapParams memory params = V3Utils.SwapParams( 765 | USDC, 766 | DAI, 767 | 1000000, // 1 USDC 768 | 9 ether / 10, 769 | TEST_NFT_ACCOUNT, 770 | _get1USDCToDAIUniswapSwapData(), 771 | false 772 | ); 773 | 774 | vm.startPrank(TEST_NFT_ACCOUNT); 775 | USDC.approve(address(v3utilsWithUniRouter), 1000000); 776 | uint256 amountOut = v3utilsWithUniRouter.swap(params); 777 | vm.stopPrank(); 778 | 779 | uint256 inputTokenBalance = USDC.balanceOf(address(v3utilsWithUniRouter)); 780 | 781 | // swapped to DAI - fee 782 | assertEq(amountOut, 999886165248978114); 783 | 784 | // input token no leftovers allowed 785 | assertEq(inputTokenBalance, 0); 786 | 787 | // no fees with router 788 | uint256 feeBalance = DAI.balanceOf(TEST_FEE_ACCOUNT); 789 | assertEq(feeBalance, 0); 790 | uint256 otherFeeBalance = USDC.balanceOf(TEST_FEE_ACCOUNT); 791 | assertEq(otherFeeBalance, 0); 792 | } 793 | 794 | function testSwapSlippageError() public { 795 | V3Utils.SwapParams memory params = V3Utils.SwapParams( 796 | USDC, 797 | DAI, 798 | 1000000, // 1 USDC 799 | 1 ether, // 1 DAI - will be less than 10**18 - this causes revert 800 | TEST_NFT_ACCOUNT, 801 | _get1USDCToDAISwapData(), 802 | false 803 | ); 804 | 805 | vm.startPrank(TEST_NFT_ACCOUNT); 806 | USDC.approve(address(v3utils), 1000000); 807 | 808 | vm.expectRevert(V3Utils.SlippageError.selector); 809 | v3utils.swap(params); 810 | vm.stopPrank(); 811 | } 812 | 813 | function testSwapDataError() public { 814 | V3Utils.SwapParams memory params = V3Utils.SwapParams( 815 | USDC, 816 | DAI, 817 | 1000000, // 1 USDC 818 | 1 ether, // 1 DAI 819 | TEST_NFT_ACCOUNT, 820 | _getInvalidSwapData(), 821 | false 822 | ); 823 | 824 | vm.startPrank(TEST_NFT_ACCOUNT); 825 | USDC.approve(address(v3utils), 1000000); 826 | 827 | vm.expectRevert(V3Utils.SwapFailed.selector); 828 | v3utils.swap(params); 829 | vm.stopPrank(); 830 | } 831 | 832 | function testSwapUSDCETH() public { 833 | V3Utils.SwapParams memory params = V3Utils.SwapParams( 834 | USDC, 835 | WETH_ERC20, 836 | 1000000, // 1 USDC 837 | 1 ether / 2000, 838 | TEST_NFT_ACCOUNT, 839 | _get1USDCToWETHSwapData(), 840 | true // unwrap to real ETH 841 | ); 842 | 843 | uint256 balanceBefore = TEST_NFT_ACCOUNT.balance; 844 | 845 | vm.startPrank(TEST_NFT_ACCOUNT); 846 | USDC.approve(address(v3utils), 1000000); 847 | uint256 amountOut = v3utils.swap(params); 848 | vm.stopPrank(); 849 | 850 | uint256 inputTokenBalance = USDC.balanceOf(address(v3utils)); 851 | uint256 balanceAfter = TEST_NFT_ACCOUNT.balance; 852 | 853 | // swapped to ETH - fee 854 | assertEq(amountOut, 650596134294829); 855 | assertEq(amountOut, balanceAfter - balanceBefore); 856 | 857 | // input token no leftovers allowed 858 | assertEq(inputTokenBalance, 0); 859 | 860 | uint256 feeBalance = WETH_ERC20.balanceOf(TEST_FEE_ACCOUNT); 861 | assertEq(feeBalance, 5561996757275); 862 | } 863 | 864 | function _increaseLiquidity() 865 | internal 866 | returns ( 867 | uint128 liquidity, 868 | uint256 amount0, 869 | uint256 amount1 870 | ) 871 | { 872 | V3Utils.SwapAndIncreaseLiquidityParams memory params = V3Utils 873 | .SwapAndIncreaseLiquidityParams( 874 | TEST_NFT, 875 | 1000000000000000000, 876 | 0, 877 | TEST_NFT_ACCOUNT, 878 | block.timestamp, 879 | IERC20(address(0)), 880 | 0, // no swap 881 | 0, 882 | "", 883 | 0, // no swap 884 | 0, 885 | "", 886 | 0, 887 | 0 888 | ); 889 | 890 | uint256 balanceBefore = DAI.balanceOf(TEST_NFT_ACCOUNT); 891 | 892 | vm.startPrank(TEST_NFT_ACCOUNT); 893 | DAI.approve(address(v3utils), 1000000000000000000); 894 | (liquidity, amount0, amount1) = v3utils.swapAndIncreaseLiquidity(params); 895 | vm.stopPrank(); 896 | 897 | uint256 balanceAfter = DAI.balanceOf(TEST_NFT_ACCOUNT); 898 | 899 | // uniswap sometimes adds not full balance (this tests that leftover tokens were returned correctly) 900 | assertEq(balanceBefore - balanceAfter, 999999999999999633); 901 | 902 | assertEq(liquidity, 2001002825163355); 903 | assertEq(amount0, 999999999999999633); // added amount 904 | assertEq(amount1, 0); // only added on one side 905 | 906 | uint256 balanceDAI = DAI.balanceOf(address(v3utils)); 907 | uint256 balanceUSDC = USDC.balanceOf(address(v3utils)); 908 | 909 | assertEq(balanceDAI, 0); 910 | assertEq(balanceUSDC, 0); 911 | } 912 | 913 | function _get1USDCToDAISwapData() internal view returns (bytes memory) { 914 | // https://api.0x.org/swap/v1/quote?sellToken=USDC&buyToken=DAI&sellAmount=1000000&slippagePercentage=0.01&feeRecipient=0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB&buyTokenPercentageFee=0.01 915 | return 916 | abi.encode( 917 | EX0x, 918 | hex"415565b0000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000da9d72c692dbf4e00000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000025375736869537761700000000000000000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000dccd1a52cca5d60000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000d9e1ce17f2641f24ae83637ab66a2cca9c378b9f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000022fa78c39c9e120000000000000000000000008df57e3d9ddde355dce1adb19ebce93419ffa0fb0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000000000d5d77f6b6f6356bff0" 919 | ); 920 | } 921 | 922 | function _get1USDCToWETHSwapData() internal view returns (bytes memory) { 923 | // https://api.0x.org/swap/v1/quote?sellToken=USDC&buyToken=WETH&sellAmount=1000000&slippagePercentage=0.25&feeRecipient=0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB&buyTokenPercentageFee=0.01 924 | return 925 | abi.encode( 926 | EX0x, 927 | hex"415565b0000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000001f9dc54188eb400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000004800000000000000000000000000000000000000000000000000000000000000019000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000003556e697377617000000000000000000000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000001feeb54efd7cf00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c0a47dfe034b400b47bdad5fecda2621de6c4d950000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000050f00d7491b0000000000000000000000008df57e3d9ddde355dce1adb19ebce93419ffa0fb0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000000000078be83f2d76356c092" 928 | ); 929 | } 930 | 931 | function _get1DAIToUSDSwapData() internal view returns (bytes memory) { 932 | // https://api.0x.org/swap/v1/quote?sellToken=DAI&buyToken=USDC&sellAmount=1000000000000000000&slippagePercentage=0.01&feeRecipient=0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB&buyTokenPercentageFee=0.01 933 | return 934 | abi.encode( 935 | EX0x, 936 | hex"415565b00000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000eef0f00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000340000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002c00000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000002536869626153776170000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000f154a000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000003f7724180aa6b939894b5ca4314783b0b36b329000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000263b0000000000000000000000008df57e3d9ddde355dce1adb19ebce93419ffa0fb0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000000000000000004dfad63cf16356c0a2" 937 | ); 938 | } 939 | 940 | function _get05DAIToUSDCSwapData() internal view returns (bytes memory) { 941 | // https://api.0x.org/swap/v1/quote?sellToken=DAI&buyToken=USDC&sellAmount=500000000000000000&slippagePercentage=0.01&feeRecipient=0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB&buyTokenPercentageFee=0.01 942 | return 943 | abi.encode( 944 | EX0x, 945 | hex"415565b00000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000777fa00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000340000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000025368696261537761700000000000000000000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000078b18000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000003f7724180aa6b939894b5ca4314783b0b36b329000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000131e0000000000000000000000008df57e3d9ddde355dce1adb19ebce93419ffa0fb0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000000000bde5ecabc66356c0b3" 946 | ); 947 | } 948 | 949 | function _get05ETHToDAISwapData() internal view returns (bytes memory) { 950 | // https://api.0x.org/swap/v1/quote?sellToken=WETH&buyToken=DAI&sellAmount=500000000000000000&slippagePercentage=0.25&feeRecipient=0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB&buyTokenPercentageFee=0.01 951 | return 952 | abi.encode( 953 | EX0x, 954 | hex"415565b0000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000001ae3b7e205f992effe00000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000520000000000000000000000000000000000000000000000000000000000000062000000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000046000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000003e000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000001942616c616e636572563200000000000000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000001b288e33a4c130911c000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000ba12222222228d8ba445958a75a0704d566bf2c80000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020c45d42f801105e861e86658648e3678ad7aa70f900010000000000000000011e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000044d6519ec79da11e0000000000000000000000008df57e3d9ddde355dce1adb19ebce93419ffa0fb0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000000000000000008699f81faa6356c0c5" 955 | ); 956 | } 957 | 958 | function _get05ETHToUSDCSwapData() internal view returns (bytes memory) { 959 | // https://api.0x.org/swap/v1/quote?sellToken=WETH&buyToken=USDC&sellAmount=500000000000000000&slippagePercentage=0.25&feeRecipient=0x8df57E3D9dDde355dCE1adb19eBCe93419ffa0FB&buyTokenPercentageFee=0.01 960 | return 961 | abi.encode( 962 | EX0x, 963 | hex"415565b0000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000006f05b59d3b20000000000000000000000000000000000000000000000000000000000001d86975100000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002c000000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000025375736869537761700000000000000000000000000000000000000000000000000000000000000006f05b59d3b20000000000000000000000000000000000000000000000000000000000001dd22d4f000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000d9e1ce17f2641f24ae83637ab66a2cca9c378b9f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000004b95fe0000000000000000000000008df57e3d9ddde355dce1adb19ebce93419ffa0fb0000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000000000bf186b7e2a6356c0d5" 964 | ); 965 | } 966 | 967 | function _get1USDCToDAIUniswapSwapData() internal view returns (bytes memory) { 968 | // cast calldata "exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))" "(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,0x6B175474E89094C44Da98b954EedeAC495271d0F,100,0x2e234DAe75C793f67A35089C9d99245E1C58470b,1682536092,1000000,900000000000000000,0)" 969 | return 970 | abi.encode( 971 | UNISWAP_ROUTER, 972 | hex"414bf389000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000640000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000006449769c00000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000c7d713b49da00000000000000000000000000000000000000000000000000000000000000000000" 973 | ); 974 | } 975 | 976 | function _getInvalidSwapData() internal view returns (bytes memory) { 977 | return abi.encode(address(v3utils), hex"1234567890"); 978 | } 979 | } 980 | -------------------------------------------------------------------------------- /test/integration/automators/AutoExit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import "../../IntegrationTestBase.sol"; 5 | 6 | import "../../../src/automators/AutoExit.sol"; 7 | 8 | contract AutoExitTest is IntegrationTestBase { 9 | 10 | AutoExit autoExit; 11 | 12 | function setUp() external { 13 | _setupBase(); 14 | autoExit = new AutoExit(NPM, OPERATOR_ACCOUNT, WITHDRAWER_ACCOUNT, 60, 100, _getSwapRouterOptions()); 15 | } 16 | 17 | function _setConfig( 18 | uint tokenId, 19 | bool isActive, 20 | bool token0Swap, 21 | bool token1Swap, 22 | uint64 token0SlippageX64, 23 | uint64 token1SlippageX64, 24 | int24 token0TriggerTick, 25 | int24 token1TriggerTick, 26 | bool onlyFees 27 | ) internal { 28 | AutoExit.PositionConfig memory config = AutoExit.PositionConfig( 29 | isActive, 30 | token0Swap, 31 | token1Swap, 32 | token0TriggerTick, 33 | token1TriggerTick, 34 | token0SlippageX64, 35 | token1SlippageX64, 36 | onlyFees, 37 | onlyFees ? MAX_FEE_REWARD : MAX_REWARD 38 | ); 39 | 40 | vm.prank(TEST_NFT_ACCOUNT); 41 | autoExit.configToken(tokenId, config); 42 | } 43 | 44 | function testNoLiquidity() external { 45 | _setConfig(TEST_NFT, true, false, false, 0, 0, type(int24).min, type(int24).max, false); 46 | 47 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT); 48 | 49 | assertEq(liquidity, 0); 50 | 51 | vm.expectRevert(AutoExit.NoLiquidity.selector); 52 | vm.prank(OPERATOR_ACCOUNT); 53 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 54 | } 55 | 56 | function _addLiquidity() internal returns (uint256 amount0, uint256 amount1) { 57 | // add onesided liquidity 58 | vm.startPrank(TEST_NFT_ACCOUNT); 59 | DAI.approve(address(NPM), 1000000000000000000); 60 | (, amount0, amount1) = NPM.increaseLiquidity(INonfungiblePositionManager.IncreaseLiquidityParams(TEST_NFT, 1000000000000000000, 0, 0, 0, block.timestamp)); 61 | 62 | assertEq(amount0, 999999999999999633); 63 | assertEq(amount1, 0); 64 | 65 | vm.stopPrank(); 66 | } 67 | 68 | struct SwapRangesState { 69 | uint128 liquidity; 70 | uint256 amount0; 71 | uint256 amount1; 72 | address token0; 73 | address token1; 74 | uint24 fee; 75 | int24 tickLower; 76 | int24 tickUpper; 77 | } 78 | 79 | function testRangesAndActions() external { 80 | 81 | SwapRangesState memory state; 82 | 83 | (state.amount0, state.amount1) = _addLiquidity(); 84 | 85 | (, , state.token0, state.token1, state.fee , state.tickLower, state.tickUpper, state.liquidity, , , , ) = NPM.positions(TEST_NFT); 86 | 87 | IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(FACTORY, PoolAddress.PoolKey({token0: state.token0, token1: state.token1, fee: state.fee}))); 88 | 89 | (, int24 tick, , , , , ) = pool.slot0(); 90 | 91 | assertGt(state.liquidity, 0); 92 | assertEq(state.tickLower, -276320); 93 | assertEq(state.tickUpper, -276310); 94 | assertEq(tick, -276325); 95 | 96 | // test with single approval 97 | vm.prank(TEST_NFT_ACCOUNT); 98 | NPM.approve(address(autoExit), TEST_NFT); 99 | 100 | _setConfig(TEST_NFT, true, false, false, 0, 0, -276325, type(int24).max, false); 101 | vm.expectRevert(Automator.NotReady.selector); 102 | vm.prank(OPERATOR_ACCOUNT); 103 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", state.liquidity, 0, 0, block.timestamp, MAX_REWARD)); 104 | 105 | uint balanceBeforeOwner = DAI.balanceOf(TEST_NFT_ACCOUNT); 106 | 107 | _setConfig(TEST_NFT, true, false, false, 0, 0, -276324, type(int24).max, false); 108 | 109 | // execute limit order - without swap 110 | vm.prank(OPERATOR_ACCOUNT); 111 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", state.liquidity, 0, 0, block.timestamp, MAX_REWARD)); 112 | 113 | (, ,, , ,, ,state.liquidity, , , , ) = NPM.positions(TEST_NFT); 114 | assertEq(state.liquidity, 0); 115 | 116 | uint balanceAfterOwner = DAI.balanceOf(TEST_NFT_ACCOUNT); 117 | 118 | // check paid fee 119 | uint balanceBefore = DAI.balanceOf(address(this)); 120 | address[] memory addresses = new address[](2); 121 | addresses[0] = address(DAI); 122 | addresses[1] = address(USDC); 123 | vm.prank(WITHDRAWER_ACCOUNT); 124 | autoExit.withdrawBalances(addresses, address(this)); 125 | uint balanceAfter = DAI.balanceOf(address(this)); 126 | 127 | assertEq(balanceAfterOwner + balanceAfter - balanceBeforeOwner - balanceBefore + 1, state.amount0); // +1 because Uniswap imprecision (remove same liquidity returns 1 less) 128 | 129 | // is not runnable anymore because configuration was removed 130 | vm.prank(OPERATOR_ACCOUNT); 131 | vm.expectRevert(Automator.NotConfigured.selector); 132 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", state.liquidity, 0, 0, block.timestamp, MAX_REWARD)); 133 | 134 | // add new liquidity 135 | (state.amount0, state.amount1) = _addLiquidity(); 136 | (, ,, , ,, ,state.liquidity, , , , ) = NPM.positions(TEST_NFT); 137 | 138 | // change to swap 139 | _setConfig(TEST_NFT, true, true, true, uint64(Q64 / 100), uint64(Q64 / 100), -276324, type(int24).max, false); 140 | 141 | // execute without swap data fails because not allowed by config 142 | vm.expectRevert(AutoExit.MissingSwapData.selector); 143 | vm.prank(OPERATOR_ACCOUNT); 144 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", state.liquidity, 0, 0, block.timestamp, MAX_REWARD)); 145 | 146 | // execute stop loss order - with swap 147 | uint swapBalanceBefore = USDC.balanceOf(TEST_NFT_ACCOUNT); 148 | 149 | vm.prank(OPERATOR_ACCOUNT); 150 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, _getDAIToUSDSwapData(), state.liquidity, 0, 0, block.timestamp, MAX_REWARD)); 151 | uint swapBalanceAfter = USDC.balanceOf(TEST_NFT_ACCOUNT); 152 | 153 | // protocol fee 154 | balanceBefore = USDC.balanceOf(address(this)); 155 | 156 | vm.prank(WITHDRAWER_ACCOUNT); 157 | autoExit.withdrawBalances(addresses, address(this)); 158 | 159 | balanceAfter = USDC.balanceOf(address(this)); 160 | 161 | assertEq(swapBalanceAfter - swapBalanceBefore, 991364); 162 | assertEq(balanceAfter - balanceBefore, 2484); 163 | } 164 | 165 | function testDirectSendNFT() external { 166 | vm.prank(TEST_NFT_ACCOUNT); 167 | vm.expectRevert(abi.encodePacked("ERC721: transfer to non ERC721Receiver implementer")); // NFT manager doesnt resend original error for some reason 168 | NPM.safeTransferFrom(TEST_NFT_ACCOUNT, address(autoExit), TEST_NFT); 169 | } 170 | 171 | function testSetTWAPSeconds() external { 172 | uint16 maxTWAPTickDifference = autoExit.maxTWAPTickDifference(); 173 | autoExit.setTWAPConfig(maxTWAPTickDifference, 120); 174 | assertEq(autoExit.TWAPSeconds(), 120); 175 | 176 | vm.expectRevert(Automator.InvalidConfig.selector); 177 | autoExit.setTWAPConfig(maxTWAPTickDifference, 30); 178 | } 179 | 180 | function testSetMaxTWAPTickDifference() external { 181 | uint32 TWAPSeconds = autoExit.TWAPSeconds(); 182 | autoExit.setTWAPConfig(5, TWAPSeconds); 183 | assertEq(autoExit.maxTWAPTickDifference(), 5); 184 | 185 | vm.expectRevert(Automator.InvalidConfig.selector); 186 | autoExit.setTWAPConfig(600, TWAPSeconds); 187 | } 188 | 189 | function testSetOperator() external { 190 | assertEq(autoExit.operators(TEST_NFT_ACCOUNT), false); 191 | autoExit.setOperator(TEST_NFT_ACCOUNT, true); 192 | assertEq(autoExit.operators(TEST_NFT_ACCOUNT), true); 193 | } 194 | 195 | 196 | function testUnauthorizedSetConfig() external { 197 | vm.expectRevert(Automator.Unauthorized.selector); 198 | vm.prank(TEST_NFT_ACCOUNT); 199 | autoExit.configToken(TEST_NFT_2, AutoExit.PositionConfig(false, false, false, 0, 0, 0, 0, false, MAX_REWARD)); 200 | } 201 | 202 | function testResetConfig() external { 203 | vm.prank(TEST_NFT_ACCOUNT); 204 | autoExit.configToken(TEST_NFT, AutoExit.PositionConfig(false, false, false, 0, 0, 0, 0, false, MAX_REWARD)); 205 | } 206 | 207 | function testInvalidConfig() external { 208 | vm.expectRevert(Automator.InvalidConfig.selector); 209 | vm.prank(TEST_NFT_ACCOUNT); 210 | autoExit.configToken(TEST_NFT, AutoExit.PositionConfig(true, false, false, 800000, -800000, 0, 0, false, MAX_REWARD)); 211 | } 212 | 213 | function testValidSetConfig() external { 214 | vm.prank(TEST_NFT_ACCOUNT); 215 | AutoExit.PositionConfig memory configIn = AutoExit.PositionConfig(true, false, false, -800000, 800000, 0, 0, false, MAX_REWARD); 216 | autoExit.configToken(TEST_NFT, configIn); 217 | (bool i1, bool i2, bool i3, int24 i4, int24 i5, uint64 i6, uint64 i7, bool i8, uint64 i9) = autoExit.positionConfigs(TEST_NFT); 218 | assertEq(abi.encode(configIn), abi.encode(AutoExit.PositionConfig(i1, i2, i3, i4, i5, i6, i7, i8, i9))); 219 | } 220 | 221 | function testNonOperator() external { 222 | vm.expectRevert(Automator.Unauthorized.selector); 223 | vm.prank(TEST_NFT_ACCOUNT); 224 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", 0, 0, 0, block.timestamp, MAX_REWARD)); 225 | } 226 | 227 | function testRunWithoutApprove() external { 228 | // out of range position 229 | vm.prank(TEST_NFT_2_ACCOUNT); 230 | autoExit.configToken(TEST_NFT_2, AutoExit.PositionConfig(true, false, false, -84121, -78240, 0, 0, false, MAX_REWARD)); 231 | 232 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 233 | 234 | // fails when sending NFT 235 | vm.expectRevert(abi.encodePacked("Not approved")); 236 | vm.prank(OPERATOR_ACCOUNT); 237 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 238 | } 239 | 240 | function testLiquidityChanged() external { 241 | vm.prank(TEST_NFT_2_ACCOUNT); 242 | autoExit.configToken(TEST_NFT_2, AutoExit.PositionConfig(true, false, false, -84121, -78240, 0, 0, false, MAX_REWARD)); 243 | 244 | // fails when sending NFT 245 | vm.expectRevert(Automator.LiquidityChanged.selector); 246 | vm.prank(OPERATOR_ACCOUNT); 247 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, "", 0, 0, 0, block.timestamp, MAX_REWARD)); 248 | } 249 | 250 | function testRunWithoutConfig() external { 251 | 252 | vm.prank(TEST_NFT_ACCOUNT); 253 | NPM.setApprovalForAll(address(autoExit), true); 254 | 255 | vm.expectRevert(Automator.NotConfigured.selector); 256 | vm.prank(OPERATOR_ACCOUNT); 257 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT, "", 0, 0, 0, block.timestamp, MAX_REWARD)); 258 | } 259 | 260 | function testRunNotReady() external { 261 | vm.prank(TEST_NFT_2_ACCOUNT); 262 | NPM.setApprovalForAll(address(autoExit), true); 263 | 264 | vm.prank(TEST_NFT_2_ACCOUNT); 265 | autoExit.configToken(TEST_NFT_2_A, AutoExit.PositionConfig(true, false, false, -276331, -276320, 0, 0, false, MAX_REWARD)); 266 | 267 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2_A); 268 | 269 | // in range position cant be run 270 | vm.expectRevert(Automator.NotReady.selector); 271 | vm.prank(OPERATOR_ACCOUNT); 272 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2_A, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 273 | } 274 | 275 | function testOracleCheck() external { 276 | 277 | // create range adjustor with more strict oracle config 278 | autoExit = new AutoExit(NPM, OPERATOR_ACCOUNT, WITHDRAWER_ACCOUNT, 60 * 30, 4, _getSwapRouterOptions()); 279 | 280 | vm.prank(TEST_NFT_2_ACCOUNT); 281 | NPM.setApprovalForAll(address(autoExit), true); 282 | 283 | vm.prank(TEST_NFT_2_ACCOUNT); 284 | autoExit.configToken(TEST_NFT_2, AutoExit.PositionConfig(true, true, true, -84121, -78240, uint64(Q64 / 100), uint64(Q64 / 100), false, MAX_REWARD)); 285 | 286 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 287 | 288 | // TWAPCheckFailed 289 | vm.prank(OPERATOR_ACCOUNT); 290 | vm.expectRevert(Automator.TWAPCheckFailed.selector); 291 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, _getWETHToDAISwapData(), liquidity, 0, 0, block.timestamp, MAX_REWARD)); 292 | } 293 | 294 | // tests LimitOrder without adding to module 295 | function testLimitOrder(bool onlyFees) external { 296 | 297 | // using out of range position TEST_NFT_2 298 | // available amounts -> DAI (fees) 311677619940061890346 WETH(fees + liquidity) 506903060556612041 299 | 300 | vm.prank(TEST_NFT_2_ACCOUNT); 301 | NPM.setApprovalForAll(address(autoExit), true); 302 | 303 | vm.prank(TEST_NFT_2_ACCOUNT); 304 | autoExit.configToken(TEST_NFT_2, AutoExit.PositionConfig(true, false, false, -84121, -78240, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD)); // 1% max slippage 305 | 306 | uint contractWETHBalanceBefore = WETH_ERC20.balanceOf(address(autoExit)); 307 | uint contractDAIBalanceBefore = DAI.balanceOf(address(autoExit)); 308 | 309 | uint ownerDAIBalanceBefore = DAI.balanceOf(TEST_NFT_2_ACCOUNT); 310 | uint ownerWETHBalanceBefore = TEST_NFT_2_ACCOUNT.balance; 311 | 312 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 313 | 314 | // test max withdraw slippage 315 | vm.prank(OPERATOR_ACCOUNT); 316 | vm.expectRevert("Price slippage check"); 317 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, "", liquidity, type(uint).max, type(uint).max, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD)); 318 | 319 | vm.prank(OPERATOR_ACCOUNT); 320 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, "", liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD)); // max fee with 1% is 7124618988448545 321 | 322 | (, , , , , , , liquidity, , , , ) = NPM.positions(TEST_NFT_2); 323 | 324 | // is not runnable anymore because configuration was removed 325 | vm.prank(OPERATOR_ACCOUNT); 326 | vm.expectRevert(Automator.NotConfigured.selector); 327 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 328 | 329 | // fee stored for owner in contract (only WETH because WETH is target token) 330 | assertEq(WETH_ERC20.balanceOf(address(autoExit)) - contractWETHBalanceBefore, onlyFees ? 4948445849078767 : 1267257651391530); 331 | assertEq(DAI.balanceOf(address(autoExit)) - contractDAIBalanceBefore, onlyFees ? 15583880997003094503 : 779194049850154725); 332 | 333 | // leftovers returned to owner 334 | assertEq(DAI.balanceOf(TEST_NFT_2_ACCOUNT) - ownerDAIBalanceBefore, onlyFees ? 296093738943058795843 : 310898425890211735621); // all available 335 | assertEq(TEST_NFT_2_ACCOUNT.balance - ownerWETHBalanceBefore, onlyFees ? 501954614707533274 : 505635802905220511); // all available 336 | } 337 | 338 | // tests StopLoss without adding to module 339 | function testStopLoss() external { 340 | // using out of range position TEST_NFT_2 341 | // available amounts -> DAI (fees) 311677619940061890346 WETH(fees + liquidity) 506903060556612041 342 | 343 | vm.prank(TEST_NFT_2_ACCOUNT); 344 | NPM.setApprovalForAll(address(autoExit), true); 345 | 346 | vm.prank(TEST_NFT_2_ACCOUNT); 347 | autoExit.configToken(TEST_NFT_2, AutoExit.PositionConfig(true, true, true, -84121, -78240, uint64(Q64 / 100), uint64(Q64 / 100), false, MAX_REWARD)); // 1% max slippage 348 | 349 | uint contractWETHBalanceBefore = WETH_ERC20.balanceOf(address(autoExit)); 350 | uint contractDAIBalanceBefore = DAI.balanceOf(address(autoExit)); 351 | 352 | uint ownerDAIBalanceBefore = DAI.balanceOf(TEST_NFT_2_ACCOUNT); 353 | uint ownerWETHBalanceBefore = TEST_NFT_2_ACCOUNT.balance; 354 | 355 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 356 | 357 | // is not runnable without swap 358 | vm.prank(OPERATOR_ACCOUNT); 359 | vm.expectRevert(AutoExit.MissingSwapData.selector); 360 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 361 | 362 | vm.prank(OPERATOR_ACCOUNT); 363 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, _getWETHToDAISwapData(), liquidity, 0, 0, block.timestamp, MAX_REWARD)); 364 | 365 | (, , , , , , , liquidity, , , , ) = NPM.positions(TEST_NFT_2); 366 | 367 | // is not runnable anymore because configuration was removed 368 | vm.prank(OPERATOR_ACCOUNT); 369 | vm.expectRevert(Automator.NotConfigured.selector); 370 | autoExit.execute(AutoExit.ExecuteParams(TEST_NFT_2, _getWETHToDAISwapData(), liquidity, 0, 0, block.timestamp, MAX_REWARD)); 371 | 372 | // fee stored for owner in contract (because perfect swap all fees are grabbed from target token DAI) 373 | assertEq(WETH_ERC20.balanceOf(address(autoExit)) - contractWETHBalanceBefore, 0); 374 | assertEq(DAI.balanceOf(address(autoExit)) - contractDAIBalanceBefore, 2703387416905290365); 375 | 376 | // leftovers returned to owner 377 | assertEq(DAI.balanceOf(TEST_NFT_2_ACCOUNT) - ownerDAIBalanceBefore, 1078651579345210856838); // all available 378 | assertEq(TEST_NFT_2_ACCOUNT.balance - ownerWETHBalanceBefore, 0); // all available 379 | } 380 | 381 | function _getWETHToDAISwapData() internal view returns (bytes memory) { 382 | // https://api.0x.org/swap/v1/quote?sellToken=WETH&buyToken=DAI&sellAmount=506903060556612041&slippagePercentage=0.25 383 | return 384 | abi.encode( 385 | EX0x, 386 | hex"6af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000708e1a5dc0901c90000000000000000000000000000000000000000000000259f6c7a7e07497b8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f46b175474e89094c44da98b954eedeac495271d0f000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000000000c4cce18ee664276707" 387 | ); 388 | } 389 | 390 | 391 | function _getDAIToUSDSwapData() internal view returns (bytes memory) { 392 | // https://api.0x.org/swap/v1/quote?sellToken=DAI&buyToken=USDC&sellAmount=999999999999999632&slippagePercentage=0.05 393 | return 394 | abi.encode( 395 | EX0x, 396 | hex"d9627aa400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a763fe9000000000000000000000000000000000000000000000000000000000000e777d000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000000000045643479ef636e6e94" 397 | ); 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /test/integration/automators/AutoRange.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import "../../IntegrationTestBase.sol"; 5 | 6 | import "../../../src/automators/AutoRange.sol"; 7 | 8 | import "v3-periphery/libraries/LiquidityAmounts.sol"; 9 | 10 | contract AutoRangeTest is IntegrationTestBase { 11 | 12 | AutoRange autoRange; 13 | 14 | function setUp() external { 15 | _setupBase(); 16 | autoRange = new AutoRange(NPM, OPERATOR_ACCOUNT, WITHDRAWER_ACCOUNT, 60, 100, _getSwapRouterOptions()); 17 | } 18 | 19 | function testSetTWAPSeconds() external { 20 | uint16 maxTWAPTickDifference = autoRange.maxTWAPTickDifference(); 21 | autoRange.setTWAPConfig(maxTWAPTickDifference, 120); 22 | assertEq(autoRange.TWAPSeconds(), 120); 23 | 24 | vm.expectRevert(Automator.InvalidConfig.selector); 25 | autoRange.setTWAPConfig(maxTWAPTickDifference, 30); 26 | } 27 | 28 | function testSetMaxTWAPTickDifference() external { 29 | uint32 TWAPSeconds = autoRange.TWAPSeconds(); 30 | autoRange.setTWAPConfig(5, TWAPSeconds); 31 | assertEq(autoRange.maxTWAPTickDifference(), 5); 32 | 33 | vm.expectRevert(Automator.InvalidConfig.selector); 34 | autoRange.setTWAPConfig(600, TWAPSeconds); 35 | } 36 | 37 | function testSetOperator() external { 38 | assertEq(autoRange.operators(TEST_NFT_ACCOUNT), false); 39 | autoRange.setOperator(TEST_NFT_ACCOUNT, true); 40 | assertEq(autoRange.operators(TEST_NFT_ACCOUNT), true); 41 | } 42 | 43 | function testUnauthorizedSetConfig() external { 44 | vm.expectRevert(Automator.Unauthorized.selector); 45 | vm.prank(TEST_NFT_ACCOUNT); 46 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, 0, 1, 0, 0, false, MAX_REWARD)); 47 | } 48 | 49 | function testResetConfig() external { 50 | vm.prank(TEST_NFT_ACCOUNT); 51 | autoRange.configToken(TEST_NFT, AutoRange.PositionConfig(0, 0, 0, 0, 0, 0, false, MAX_REWARD)); 52 | } 53 | 54 | function testInvalidConfig() external { 55 | vm.expectRevert(Automator.InvalidConfig.selector); 56 | vm.prank(TEST_NFT_ACCOUNT); 57 | autoRange.configToken(TEST_NFT, AutoRange.PositionConfig(0, 0, 1, 0, 0, 0, false, MAX_REWARD)); 58 | } 59 | 60 | function testValidSetConfig() external { 61 | vm.prank(TEST_NFT_ACCOUNT); 62 | AutoRange.PositionConfig memory configIn = AutoRange.PositionConfig(1, -1, 0, 1, 123, 456, false, MAX_REWARD); 63 | autoRange.configToken(TEST_NFT, configIn); 64 | (int32 i1, int32 i2, int32 i3, int32 i4, uint64 i5, uint64 i6, bool i7, uint64 i8) = autoRange.positionConfigs(TEST_NFT); 65 | assertEq(abi.encode(configIn), abi.encode(AutoRange.PositionConfig(i1, i2, i3, i4, i5, i6, i7, i8))); 66 | } 67 | 68 | function testNonOperator() external { 69 | vm.expectRevert(Automator.Unauthorized.selector); 70 | vm.prank(TEST_NFT_ACCOUNT); 71 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT, false, 0, "", 0, 0, 0, block.timestamp, MAX_REWARD)); 72 | } 73 | 74 | function testAdjustWithoutApprove() external { 75 | // out of range position 76 | vm.prank(TEST_NFT_2_ACCOUNT); 77 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, 0, 1, 0, 0, false, MAX_REWARD)); 78 | 79 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 80 | 81 | // fails when sending NFT 82 | vm.expectRevert(abi.encodePacked("Not approved")); 83 | 84 | vm.prank(OPERATOR_ACCOUNT); 85 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 86 | } 87 | 88 | function testAdjustWithoutConfig() external { 89 | 90 | vm.prank(TEST_NFT_ACCOUNT); 91 | NPM.setApprovalForAll(address(autoRange), true); 92 | 93 | vm.expectRevert(Automator.NotConfigured.selector); 94 | vm.prank(OPERATOR_ACCOUNT); 95 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT, false, 0, "", 0, 0, 0, block.timestamp, MAX_REWARD)); 96 | } 97 | 98 | function testAdjustNotAdjustable() external { 99 | vm.prank(TEST_NFT_2_ACCOUNT); 100 | NPM.setApprovalForAll(address(autoRange), true); 101 | 102 | vm.prank(TEST_NFT_2_ACCOUNT); 103 | autoRange.configToken(TEST_NFT_2_A, AutoRange.PositionConfig(0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), false, MAX_REWARD)); // 1% max fee, 1% max slippage 104 | 105 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2_A); 106 | 107 | // in range position cant be adjusted 108 | vm.expectRevert(Automator.NotReady.selector); 109 | vm.prank(OPERATOR_ACCOUNT); 110 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2_A, false, 0, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 111 | } 112 | 113 | function testAdjustOutOfRange() external { 114 | vm.prank(TEST_NFT_2_ACCOUNT); 115 | NPM.setApprovalForAll(address(autoRange), true); 116 | 117 | vm.prank(TEST_NFT_2_ACCOUNT); 118 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, -int32(uint32(type(uint24).max)), int32(uint32(type(uint24).max)), 0, 0, false, MAX_REWARD)); // 1% max fee, 1% max slippage 119 | 120 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 121 | 122 | // will be reverted because range Arithmetic over/underflow 123 | vm.expectRevert(abi.encodePacked("SafeCast: value doesn't fit in 24 bits")); 124 | vm.prank(OPERATOR_ACCOUNT); 125 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", liquidity, 0, 0, block.timestamp, MAX_REWARD)); 126 | } 127 | 128 | function testLiquidityChanged() external { 129 | vm.prank(TEST_NFT_2_ACCOUNT); 130 | NPM.setApprovalForAll(address(autoRange), true); 131 | 132 | vm.prank(TEST_NFT_2_ACCOUNT); 133 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, -int32(uint32(type(uint24).max)), int32(uint32(type(uint24).max)), 0, 0, false, MAX_REWARD)); // 1% max fee, 1% max slippage 134 | 135 | // will be reverted because LiquidityChanged 136 | vm.expectRevert(Automator.LiquidityChanged.selector); 137 | vm.prank(OPERATOR_ACCOUNT); 138 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", 0, 0, 0, block.timestamp, MAX_REWARD)); 139 | } 140 | 141 | struct SwapTestState { 142 | uint protocolDAIBalanceBefore; 143 | uint protocolWETHBalanceBefore; 144 | uint ownerDAIBalanceBefore; 145 | uint ownerWETHBalanceBefore; 146 | uint tokenId; 147 | uint128 liquidity; 148 | uint256 amount0; 149 | uint256 amount1; 150 | address token0; 151 | address token1; 152 | uint24 fee; 153 | uint128 liquidityOld; 154 | } 155 | 156 | function testAdjustWithoutSwap(bool onlyFees) external { 157 | 158 | // using out of range position TEST_NFT_2 159 | // available amounts -> 311677619940061890346 506903060556612041 160 | // added to new position -> 778675263877745419944 196199406163820963 161 | 162 | SwapTestState memory state; 163 | 164 | vm.prank(TEST_NFT_2_ACCOUNT); 165 | NPM.setApprovalForAll(address(autoRange), true); 166 | 167 | vm.prank(TEST_NFT_2_ACCOUNT); 168 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD)); // 1% max fee, 1% max slippage 169 | uint count = NPM.balanceOf(TEST_NFT_2_ACCOUNT); 170 | assertEq(count, 4); 171 | 172 | state.protocolDAIBalanceBefore = DAI.balanceOf(address(autoRange)); 173 | state.protocolWETHBalanceBefore = WETH_ERC20.balanceOf(address(autoRange)); 174 | 175 | state.ownerDAIBalanceBefore = DAI.balanceOf(TEST_NFT_2_ACCOUNT); 176 | state.ownerWETHBalanceBefore = TEST_NFT_2_ACCOUNT.balance; 177 | 178 | (, , , , , , , state.liquidity, , , , ) = NPM.positions(TEST_NFT_2); 179 | 180 | 181 | // test max withdraw slippage 182 | vm.prank(OPERATOR_ACCOUNT); 183 | vm.expectRevert("Price slippage check"); 184 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", state.liquidity, type(uint).max, type(uint).max, block.timestamp, MAX_REWARD)); 185 | 186 | vm.prank(OPERATOR_ACCOUNT); 187 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD: MAX_REWARD)); // max fee with 1% is 7124618988448545 188 | 189 | // is not adjustable yet because config was removed 190 | (, , , , , , , state.liquidity, , , , ) = NPM.positions(TEST_NFT_2); 191 | vm.prank(OPERATOR_ACCOUNT); 192 | vm.expectRevert(Automator.NotConfigured.selector); 193 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD: MAX_REWARD)); 194 | 195 | // protocol fee 196 | assertEq(DAI.balanceOf(address(autoRange)) - state.protocolDAIBalanceBefore, onlyFees ? 15583880997003094503 : 777250922543795237); 197 | assertEq(WETH_ERC20.balanceOf(address(autoRange)) - state.protocolWETHBalanceBefore, onlyFees ? 4948445849078767 : 193185163020990); 198 | 199 | // leftovers returned to owner 200 | assertEq(DAI.balanceOf(TEST_NFT_2_ACCOUNT) - state.ownerDAIBalanceBefore, onlyFees ? 0 : 1); // all was added to position 201 | assertEq(TEST_NFT_2_ACCOUNT.balance - state.ownerWETHBalanceBefore, onlyFees ? 428360726854687034 : 429435810185194946); // leftover + fee + deposited = total in old position 202 | 203 | 204 | count = NPM.balanceOf(TEST_NFT_2_ACCOUNT); 205 | assertEq(count, 5); 206 | 207 | // new NFT is latest NFT - because of the order they are added 208 | state.tokenId = NPM.tokenOfOwnerByIndex(TEST_NFT_2_ACCOUNT, count - 1); 209 | 210 | (, , , , , , , state.liquidity, , , , ) = NPM.positions(state.tokenId); 211 | 212 | // is not adjustable yet because in range 213 | vm.prank(OPERATOR_ACCOUNT); 214 | vm.expectRevert(Automator.NotReady.selector); 215 | autoRange.execute(AutoRange.ExecuteParams(state.tokenId, false, 0, "", state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD: MAX_REWARD)); 216 | 217 | // newly minted token 218 | assertEq(state.tokenId, 309207); 219 | 220 | (, , , , , , , state.liquidity, , , , ) = NPM.positions(state.tokenId); 221 | (, , , , , int24 tickLowerAfter, int24 tickUpperAfter , , , , , ) = NPM.positions(state.tokenId); 222 | (, , state.token0 , state.token1 , state.fee , , , state.liquidityOld, , , , ) = NPM.positions(TEST_NFT_2); 223 | 224 | IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(FACTORY, PoolAddress.getPoolKey(state.token0, state.token1, state.fee))); 225 | (uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0(); 226 | 227 | (state.amount0, state.amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, TickMath.getSqrtRatioAtTick(tickLowerAfter), TickMath.getSqrtRatioAtTick(tickUpperAfter), state.liquidity); 228 | 229 | // new position amounts 230 | assertEq(state.amount0, onlyFees ? 296093738943058795842 : 310900369017518095107); //DAI 231 | assertEq(state.amount1, onlyFees ? 73593887852846239 : 77274065208396104); //WETH 232 | 233 | // check tick range correct 234 | assertEq(tickLowerAfter, -73260); 235 | assertEq(currentTick, -73244); 236 | assertEq(tickUpperAfter, -73260 + 60); 237 | 238 | assertEq(state.liquidity, onlyFees ? 3493233994488865101709 : 3667918618704675260835); 239 | assertEq(state.liquidityOld, 0); 240 | } 241 | 242 | function testAdjustWithTooLargeSwap() external { 243 | 244 | vm.prank(TEST_NFT_2_ACCOUNT); 245 | NPM.setApprovalForAll(address(autoRange), true); 246 | 247 | vm.prank(TEST_NFT_2_ACCOUNT); 248 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), false, MAX_REWARD)); // 1% max fee, 1% max slippage 249 | 250 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 251 | 252 | vm.expectRevert(AutoRange.SwapAmountTooLarge.selector); 253 | vm.prank(OPERATOR_ACCOUNT); 254 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, type(uint).max, _get03WETHToDAISwapData(), liquidity, 0, 0, block.timestamp, MAX_REWARD)); 255 | } 256 | 257 | function testAdjustWithSwap(bool onlyFees) external { 258 | 259 | SwapTestState memory state; 260 | 261 | // using out of range position TEST_NFT_2 262 | // available amounts -> DAI 311677619940061890346 WETH 506903060556612041 263 | // swapping 0.3 WETH -> DAI (so more can be added to new position) 264 | // added to new position -> 782948862604141727748 194702024655849100 265 | 266 | vm.prank(TEST_NFT_2_ACCOUNT); 267 | NPM.setApprovalForAll(address(autoRange), true); 268 | 269 | vm.prank(TEST_NFT_2_ACCOUNT); 270 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(0, 0, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), onlyFees, onlyFees ? MAX_FEE_REWARD : MAX_REWARD)); // 1% max fee, 1% max slippage 271 | 272 | state.protocolDAIBalanceBefore = DAI.balanceOf(address(autoRange)); 273 | state.protocolWETHBalanceBefore = WETH_ERC20.balanceOf(address(autoRange)); 274 | 275 | state.ownerDAIBalanceBefore = DAI.balanceOf(TEST_NFT_2_ACCOUNT); 276 | state.ownerWETHBalanceBefore = TEST_NFT_2_ACCOUNT.balance; 277 | 278 | (, , , , , , , state.liquidity, , , , ) = NPM.positions(TEST_NFT_2); 279 | 280 | vm.prank(OPERATOR_ACCOUNT); 281 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 300000000000000000, _get03WETHToDAISwapData(), state.liquidity, 0, 0, block.timestamp, onlyFees ? MAX_FEE_REWARD : MAX_REWARD)); // max fee with 1% is 7124618988448545 282 | 283 | // protocol fee 284 | assertEq(DAI.balanceOf(address(autoRange)) - state.protocolDAIBalanceBefore, onlyFees ? 15583880997003094503 : 1913211476963758022); 285 | assertEq(WETH_ERC20.balanceOf(address(autoRange)) - state.protocolWETHBalanceBefore, onlyFees ? 4948445849078767 : 475527349470656); 286 | 287 | // leftovers returned to owner 288 | assertEq(DAI.balanceOf(TEST_NFT_2_ACCOUNT) - state.ownerDAIBalanceBefore, onlyFees ? 0 : 1); 289 | assertEq(TEST_NFT_2_ACCOUNT.balance - state.ownerWETHBalanceBefore, onlyFees ? 15141510088371046 : 16216593418878959); 290 | 291 | uint count = NPM.balanceOf(TEST_NFT_2_ACCOUNT); 292 | 293 | // new NFT is latest NFT - because of the order they are added 294 | state.tokenId = NPM.tokenOfOwnerByIndex(TEST_NFT_2_ACCOUNT, count - 1); 295 | 296 | // newly minted token 297 | assertEq(state.tokenId, 309207); 298 | 299 | (, , , , , , , state.liquidity, , , , ) = NPM.positions(state.tokenId); 300 | (, , , , , int24 tickLowerAfter, int24 tickUpperAfter , , , , , ) = NPM.positions(state.tokenId); 301 | (, , state.token0 , state.token1 , state.fee , , , state.liquidityOld, , , , ) = NPM.positions(TEST_NFT_2); 302 | 303 | IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(FACTORY, PoolAddress.getPoolKey(state.token0, state.token1, state.fee))); 304 | (uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0(); 305 | 306 | (state.amount0, state.amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, TickMath.getSqrtRatioAtTick(tickLowerAfter), TickMath.getSqrtRatioAtTick(tickUpperAfter), state.liquidity); 307 | 308 | // new position amounts 309 | assertEq(state.amount0, onlyFees ? 751613921265463873195 : 765284590785503209675); //DAI 310 | assertEq(state.amount1, onlyFees ? 186813104619162227 : 190210939788262425); //WETH 311 | 312 | // check tick range correct 313 | assertEq(tickLowerAfter, -73260); 314 | assertEq(currentTick, -73244); 315 | assertEq(tickUpperAfter, -73260 + 60); 316 | 317 | assertEq(state.liquidity, onlyFees ? 8867338126999411584017 : 9028620995273798933977); 318 | assertEq(state.liquidityOld, 0); 319 | } 320 | 321 | function testDoubleAdjust() external { 322 | 323 | vm.prank(TEST_NFT_2_ACCOUNT); 324 | NPM.setApprovalForAll(address(autoRange), true); 325 | 326 | // bad config so it can be adjusted multiple times 327 | vm.prank(TEST_NFT_2_ACCOUNT); 328 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(-100000, -100000, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), false, MAX_REWARD)); 329 | 330 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 331 | 332 | // first adjust ok 333 | vm.prank(OPERATOR_ACCOUNT); 334 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", liquidity, 0, 0, block.timestamp, 0)); 335 | 336 | uint count = NPM.balanceOf(TEST_NFT_2_ACCOUNT); 337 | uint tokenId = NPM.tokenOfOwnerByIndex(TEST_NFT_2_ACCOUNT, count - 1); 338 | 339 | // newly minted token 340 | assertEq(tokenId, 309207); 341 | 342 | (, , , , , , , liquidity, , , , ) = NPM.positions(tokenId); 343 | 344 | // second ajust leads to same range error 345 | vm.prank(OPERATOR_ACCOUNT); 346 | vm.expectRevert(AutoRange.SameRange.selector); 347 | autoRange.execute(AutoRange.ExecuteParams(tokenId, false, 0, "", liquidity, 0, 0, block.timestamp, 0)); 348 | } 349 | 350 | function testOracleCheck() external { 351 | 352 | // create range adjustor with more strict oracle config 353 | autoRange = new AutoRange(NPM, OPERATOR_ACCOUNT, WITHDRAWER_ACCOUNT, 60 * 30, 4, _getSwapRouterOptions()); 354 | 355 | vm.prank(TEST_NFT_2_ACCOUNT); 356 | NPM.setApprovalForAll(address(autoRange), true); 357 | 358 | vm.prank(TEST_NFT_2_ACCOUNT); 359 | autoRange.configToken(TEST_NFT_2, AutoRange.PositionConfig(-100000, -100000, 0, 60, uint64(Q64 / 100), uint64(Q64 / 100), false, MAX_REWARD)); 360 | 361 | (, , , , , , , uint128 liquidity, , , , ) = NPM.positions(TEST_NFT_2); 362 | 363 | // TWAPCheckFailed 364 | vm.prank(OPERATOR_ACCOUNT); 365 | vm.expectRevert(Automator.TWAPCheckFailed.selector); 366 | autoRange.execute(AutoRange.ExecuteParams(TEST_NFT_2, false, 0, "", liquidity, 0, 0, block.timestamp, 0)); 367 | } 368 | 369 | function _get03WETHToDAISwapData() internal view returns (bytes memory) { 370 | // https://api.0x.org/swap/v1/quote?sellToken=WETH&buyToken=DAI&sellAmount=300000000000000000&slippagePercentage=0.25 371 | return 372 | abi.encode( 373 | EX0x, 374 | hex"6af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000429d069189e00000000000000000000000000000000000000000000000000130ac08c36b9dfe37f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f46b175474e89094c44da98b954eedeac495271d0f000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000000000ce62b248cc6402739e" 375 | ); 376 | } 377 | } --------------------------------------------------------------------------------