├── .forge-snapshots ├── testVolumeFee_feeIncreaseAndDecreaseAtSameSwap.snap ├── testVolumeFee_feeIncreaseMaximumFeeReached.snap ├── testVolumeFee_feeIncreaseTriggeredByTime.snap ├── testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwapFirst.snap ├── testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwapSecond.snap ├── testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0First.snap ├── testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparisonFirst.snap ├── testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparisonSecond.snap ├── testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0Second.snap ├── testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1Frist.snap ├── testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1Second.snap ├── testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0.snap ├── testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0WithVolumeManipulation.snap ├── testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1.snap └── testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1WithVolumeManipulation.snap ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── Readme.md ├── foundry.toml ├── remappings.txt ├── src ├── Combo.sol ├── LiquidityLocking.sol ├── VolumeFee.sol ├── liquidityManager │ ├── LiquidityManager.sol │ ├── LiquidityManagerLib.sol │ └── LiquidityManagerStructs.sol ├── periphery │ ├── LiquidityAmounts.sol │ └── UniswapV4ERC20.sol └── utils │ ├── BaseHook.sol │ └── Utils.sol └── test ├── Combo.t.sol ├── LiquidityLocking.t.sol ├── LiquidityManager.t.sol └── VolumeFee.t.sol /.forge-snapshots/testVolumeFee_feeIncreaseAndDecreaseAtSameSwap.snap: -------------------------------------------------------------------------------- 1 | 232350 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseMaximumFeeReached.snap: -------------------------------------------------------------------------------- 1 | 237069 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredByTime.snap: -------------------------------------------------------------------------------- 1 | 252250 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwapFirst.snap: -------------------------------------------------------------------------------- 1 | 236385 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwapSecond.snap: -------------------------------------------------------------------------------- 1 | 142354 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0First.snap: -------------------------------------------------------------------------------- 1 | 239109 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparisonFirst.snap: -------------------------------------------------------------------------------- 1 | 192983 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparisonSecond.snap: -------------------------------------------------------------------------------- 1 | 115486 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0Second.snap: -------------------------------------------------------------------------------- 1 | 141905 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1Frist.snap: -------------------------------------------------------------------------------- 1 | 236299 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1Second.snap: -------------------------------------------------------------------------------- 1 | 139094 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0.snap: -------------------------------------------------------------------------------- 1 | 251801 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0WithVolumeManipulation.snap: -------------------------------------------------------------------------------- 1 | 179764 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1.snap: -------------------------------------------------------------------------------- 1 | 229090 -------------------------------------------------------------------------------- /.forge-snapshots/testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1WithVolumeManipulation.snap: -------------------------------------------------------------------------------- 1 | 157888 -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: workflow_dispatch 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: Foundry project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test -vvv 34 | id: test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache 2 | out 3 | *.swp 4 | .vscode 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/v4-core"] 2 | path = lib/v4-core 3 | url = git@github.com:Uniswap/v4-core 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 7 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Proof of Concept Hooks for Uniswap V4 2 | 3 | The following hooks were created as part of the Uniswap Foundation Grant. The purpose of the Grant was to showcase the new UniswapV4 Hooks feature and provide early feedback to the Uniswap Labs team. 4 | 5 | ## Liquidity Locking Hook 6 | 7 | The Liquidity Locking Hook locks the liquidity into the pool for a specified amount of time. 8 | 9 | 1. In order to compensate the liquidity providers for locking their liquidity, the hook will mint and distribute rewards among the liquidity providers. 10 | 2. The more liquidity is provided the more reward token the liquidity provider is entitled to. 11 | 3. The longer time the liquidity is locked the more reward token the liquidity provider is entitled to. 12 | 4. If the liquidity provider would like to withdraw liquidity before the locking period expires, he will suffer a penalty. 13 | 5. The early withdrawl penalty is distributed evenly amongst the rest of the liquidity providers. 14 | 15 | ## Liquidity Management Hook 16 | 17 | The Liquidity Management Hook will manage UniswapV4 ranges on behalf of the liquidity providers. 18 | 19 | 1. The liquidity provided to the hook is automatically split up into 2 ranges: the narrow and the wide range. 20 | 2. X% of the liquidity will be invested into the wide range which is currently the full Uniswap range. 21 | 3. 100-X% of the liquidity will be invested into the narrow range around the current price. 22 | 4. When the price moves as a result of a swap, the narrow range will automatically rebalances and follow the current price. This is done without involving any offchain component. 23 | 24 | ## Dynamic Fee Hook 25 | 26 | The Dynamic Fee Hook automatically changes the swap fee based on the pool's volatility. During higher volatility periods, higher fees are charged. 27 | 28 | 1. Volume is used as a proxy for price volatility. 29 | 2. All swaps on the pool increases the aggregated volume. 30 | 3. As time goes on, the aggregated volume automatically decreases. 31 | 4. The swap fee is a function of the aggregated volume at the time of the swap. 32 | 33 | ## Combo Hook 34 | 35 | Hook that combines the functionaility of the Liquidity Locking Hook and the Dynamic Fee Hook. 36 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | gas_reports = ["src/*"] 6 | ffi = true 7 | fs_permissions = [{ access = "read-write", path = "./.forge-snapshots"}, { access = "read", path = "./out"}] 8 | cancun = true 9 | 10 | solc = "lib/v4-core/bin/solc-static-linux" 11 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @uniswap/v4-core/=lib/v4-core/ 2 | @openzeppelin/=lib/openzeppelin-contracts/ 3 | forge-gas-snapshot/=lib/v4-core/lib/forge-gas-snapshot/src/ 4 | forge-std/=lib/v4-core/lib/forge-std/src/ 5 | hardhat/=lib/v4-core/node_modules/hardhat/ 6 | solmate/=lib/v4-core/lib/solmate/src/ 7 | -------------------------------------------------------------------------------- /src/Combo.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | pragma solidity ^0.8.0; 3 | 4 | import {LiquidityLocking} from "./LiquidityLocking.sol"; 5 | import {VolumeFee} from "./VolumeFee.sol"; 6 | import {BaseHook} from "./utils/BaseHook.sol"; 7 | 8 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 9 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 10 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 11 | import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; 12 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 13 | 14 | import {console} from "forge-std/Test.sol"; 15 | 16 | contract Combo is LiquidityLocking, VolumeFee { 17 | struct InitParamsCombo { 18 | bytes volumeFeeData; 19 | bytes liquidityLockingData; 20 | } 21 | 22 | constructor( 23 | IPoolManager _poolManager, 24 | address _owner 25 | ) LiquidityLocking(_poolManager) VolumeFee(_poolManager, _owner) {} 26 | 27 | function beforeInitialize( 28 | address sender, 29 | PoolKey calldata key, 30 | uint160 sqrtPriceX96, 31 | bytes memory data 32 | ) public override(LiquidityLocking, VolumeFee) returns (bytes4) { 33 | InitParamsCombo memory initParamsCombo = abi.decode( 34 | data, 35 | (InitParamsCombo) 36 | ); 37 | 38 | LiquidityLocking.beforeInitialize( 39 | sender, 40 | key, 41 | sqrtPriceX96, 42 | initParamsCombo.liquidityLockingData 43 | ); 44 | 45 | VolumeFee.beforeInitialize( 46 | sender, 47 | key, 48 | sqrtPriceX96, 49 | initParamsCombo.volumeFeeData 50 | ); 51 | 52 | return IHooks.beforeInitialize.selector; 53 | } 54 | 55 | function getHooksPermissions() 56 | public 57 | pure 58 | override(LiquidityLocking, VolumeFee) 59 | returns (Hooks.Permissions memory) 60 | { 61 | return 62 | Hooks.Permissions({ 63 | beforeInitialize: true, 64 | afterInitialize: false, 65 | beforeAddLiquidity: true, 66 | afterAddLiquidity: false, 67 | beforeRemoveLiquidity: false, 68 | afterRemoveLiquidity: false, 69 | beforeSwap: true, 70 | afterSwap: true, 71 | beforeDonate: false, 72 | afterDonate: false, 73 | noOp: false, 74 | accessLock: false 75 | }); 76 | } 77 | 78 | function beforeSwap( 79 | address sender, 80 | PoolKey calldata key, 81 | IPoolManager.SwapParams calldata params, 82 | bytes calldata hookData 83 | ) public override(LiquidityLocking, VolumeFee) returns (bytes4) { 84 | LiquidityLocking.beforeSwap(sender, key, params, hookData); 85 | VolumeFee.beforeSwap(sender, key, params, hookData); 86 | 87 | return IHooks.beforeSwap.selector; 88 | } 89 | 90 | function afterSwap( 91 | address sender, 92 | PoolKey calldata key, 93 | IPoolManager.SwapParams calldata params, 94 | BalanceDelta delta, 95 | bytes calldata hookData 96 | ) public virtual override(VolumeFee, BaseHook) returns (bytes4) { 97 | return VolumeFee.afterSwap(sender, key, params, delta, hookData); 98 | } 99 | 100 | function beforeAddLiquidity( 101 | address sender, 102 | PoolKey calldata key, 103 | IPoolManager.ModifyLiquidityParams calldata params, 104 | bytes calldata hookData 105 | ) public override(LiquidityLocking, BaseHook) returns (bytes4) { 106 | return 107 | LiquidityLocking.beforeAddLiquidity(sender, key, params, hookData); 108 | } 109 | 110 | function lockAcquired( 111 | address lockCaller, 112 | bytes calldata rawData 113 | ) public override returns (bytes memory) { 114 | return LiquidityLocking.lockAcquired(lockCaller, rawData); 115 | } 116 | 117 | function removeLiquidity( 118 | RemoveLiquidityParams calldata params 119 | ) public virtual override returns (BalanceDelta delta) { 120 | // Removing the liquidity from the pool triggers rebalancing. 121 | // During rebalancing a swap occures with amountSpecified = MAX_INT. 122 | // This would cause overflow and revert in the VolumeFee hook, so we will skip executing them. 123 | skipBeforeAfterHooks = true; 124 | delta = LiquidityLocking.removeLiquidity(params); 125 | skipBeforeAfterHooks = false; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/LiquidityLocking.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.19; 3 | 4 | import {BaseHook} from "./utils/BaseHook.sol"; 5 | import {LiquidityAmounts} from "./periphery/LiquidityAmounts.sol"; 6 | import {UniswapV4ERC20} from "./periphery/UniswapV4ERC20.sol"; 7 | 8 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 9 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 10 | import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; 11 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 12 | import {CurrencyLibrary, Currency} from "@uniswap/v4-core/src/types/Currency.sol"; 13 | import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; 14 | import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; 15 | import {IERC20Minimal} from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; 16 | import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; 17 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 18 | import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; 19 | import {FixedPoint96} from "@uniswap/v4-core/src/libraries/FixedPoint96.sol"; 20 | 21 | import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; 22 | 23 | import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; 24 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 25 | import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; 26 | 27 | import {console} from "forge-std/Test.sol"; 28 | 29 | uint256 constant FIXED_POINT_SCALING = 1_000_000; 30 | 31 | contract LiquidityLocking is BaseHook { 32 | using CurrencyLibrary for Currency; 33 | using PoolIdLibrary for PoolKey; 34 | using SafeCast for uint256; 35 | using Math for uint256; 36 | using SafeCast for uint128; 37 | 38 | /// @notice Thrown when trying to interact with a non-initialized pool 39 | error PoolNotInitialized(); 40 | error TickSpacingNotDefault(); 41 | error LiquidityDoesntMeetMinimum(); 42 | error SenderMustBeHook(); 43 | error ExpiredPastDeadline(); 44 | error ExpiredPastlockedUntil(); 45 | error ShorteninglockedUntil(); 46 | error EarlyWithdrawal(); 47 | error TooMuchSlippage(); 48 | 49 | bytes internal constant ZERO_BYTES = bytes(""); 50 | 51 | /// @dev Min tick for full range with tick spacing of 60 52 | int24 internal constant MIN_TICK = -887220; 53 | /// @dev Max tick for full range with tick spacing of 60 54 | int24 internal constant MAX_TICK = -MIN_TICK; 55 | 56 | int256 internal constant MAX_INT = type(int256).max; 57 | uint16 internal constant MINIMUM_LIQUIDITY = 1000; 58 | 59 | struct CallbackData { 60 | address sender; 61 | PoolKey key; 62 | IPoolManager.ModifyLiquidityParams params; 63 | bool applyEarlyWithdrawalPenalty; 64 | } 65 | 66 | struct LockingInfo { 67 | uint256 lockingTime; 68 | uint256 lockedUntil; 69 | uint256 liquidityShare; 70 | } 71 | 72 | struct PoolInfoLiquidityLocking { 73 | bool hasAccruedFees; 74 | UniswapV4ERC20 rewardToken; 75 | // reward token amount gained per liquidity provided per seconds, scaled by FIXED_POINT_SCALING 76 | uint256 rewardGenerationRate; 77 | uint256 totalLiquidityShares; 78 | uint24 earlyWithdrawalPenaltyPct; // scaled by FIXED_POINT_SCALING, 50000 would be 5% 79 | mapping(address => LockingInfo) lockingInfos; 80 | } 81 | 82 | struct InitParamsLiquidityLocking { 83 | uint256 rewardGenerationRate; 84 | uint24 earlyWithdrawalPenaltyPct; 85 | } 86 | 87 | struct AddLiquidityParams { 88 | Currency currency0; 89 | Currency currency1; 90 | uint24 fee; 91 | uint256 amount0Desired; 92 | uint256 amount1Desired; 93 | uint256 amount0Min; 94 | uint256 amount1Min; 95 | uint256 deadline; 96 | uint256 lockedUntil; 97 | } 98 | 99 | struct RemoveLiquidityParams { 100 | Currency currency0; 101 | Currency currency1; 102 | uint24 fee; 103 | uint256 liquidity; 104 | uint256 deadline; 105 | } 106 | 107 | IPoolManager public immutable poolManagerLiquidityLocking; 108 | 109 | mapping(PoolId => PoolInfoLiquidityLocking) public poolInfoLiquidityLocking; 110 | 111 | constructor(IPoolManager _poolManager) { 112 | poolManagerLiquidityLocking = _poolManager; 113 | } 114 | 115 | modifier ensure(uint256 deadline) { 116 | if (deadline < block.timestamp) revert ExpiredPastDeadline(); 117 | _; 118 | } 119 | 120 | modifier poolManagerOnlyLiquidityLocking() { 121 | if (msg.sender != address(poolManagerLiquidityLocking)) 122 | revert NotPoolManager(); 123 | _; 124 | } 125 | 126 | function poolUserInfo( 127 | PoolId poolId, 128 | address user 129 | ) external view returns (LockingInfo memory) { 130 | return poolInfoLiquidityLocking[poolId].lockingInfos[user]; 131 | } 132 | 133 | function beforeInitialize( 134 | address, 135 | PoolKey calldata key, 136 | uint160, 137 | bytes memory data 138 | ) public virtual override poolManagerOnlyLiquidityLocking returns (bytes4) { 139 | if (key.tickSpacing != 60) revert TickSpacingNotDefault(); 140 | 141 | PoolId poolId = key.toId(); 142 | 143 | string memory tokenSymbol = string( 144 | abi.encodePacked( 145 | "UniV4", 146 | "-", 147 | IERC20Metadata(Currency.unwrap(key.currency0)).symbol(), 148 | "-", 149 | IERC20Metadata(Currency.unwrap(key.currency1)).symbol(), 150 | "-", 151 | Strings.toString(uint256(key.fee)) 152 | ) 153 | ); 154 | InitParamsLiquidityLocking memory initParams = abi.decode( 155 | data, 156 | (InitParamsLiquidityLocking) 157 | ); 158 | 159 | PoolInfoLiquidityLocking 160 | storage poolInfoToInit = poolInfoLiquidityLocking[poolId]; 161 | poolInfoToInit.hasAccruedFees = false; 162 | poolInfoToInit.rewardToken = new UniswapV4ERC20( 163 | tokenSymbol, 164 | tokenSymbol 165 | ); 166 | poolInfoToInit.rewardGenerationRate = initParams.rewardGenerationRate; 167 | poolInfoToInit.earlyWithdrawalPenaltyPct = initParams 168 | .earlyWithdrawalPenaltyPct; 169 | 170 | return IHooks.beforeInitialize.selector; 171 | } 172 | 173 | function getHooksPermissions() 174 | public 175 | pure 176 | virtual 177 | override 178 | returns (Hooks.Permissions memory) 179 | { 180 | return 181 | Hooks.Permissions({ 182 | beforeInitialize: true, 183 | afterInitialize: false, 184 | beforeAddLiquidity: true, 185 | afterAddLiquidity: false, 186 | beforeRemoveLiquidity: false, 187 | afterRemoveLiquidity: false, 188 | beforeSwap: true, 189 | afterSwap: false, 190 | beforeDonate: false, 191 | afterDonate: false, 192 | noOp: false, 193 | accessLock: false 194 | }); 195 | } 196 | 197 | function addLiquidity( 198 | AddLiquidityParams calldata params 199 | ) external ensure(params.deadline) returns (uint128 liquidity) { 200 | // The hook was based on the FullRange hook, and has the same issue as raised here 201 | // https://github.com/Uniswap/v4-periphery/issues/68 202 | 203 | if (params.lockedUntil <= block.timestamp) { 204 | revert ExpiredPastlockedUntil(); 205 | } 206 | 207 | PoolKey memory key = PoolKey({ 208 | currency0: params.currency0, 209 | currency1: params.currency1, 210 | fee: params.fee, 211 | tickSpacing: 60, 212 | hooks: IHooks(address(this)) 213 | }); 214 | 215 | PoolId poolId = key.toId(); 216 | 217 | (uint160 sqrtPriceX96, , ) = poolManagerLiquidityLocking.getSlot0( 218 | poolId 219 | ); 220 | 221 | if (sqrtPriceX96 == 0) revert PoolNotInitialized(); 222 | 223 | PoolInfoLiquidityLocking storage pool = poolInfoLiquidityLocking[ 224 | poolId 225 | ]; 226 | 227 | uint128 poolLiquidity = poolManagerLiquidityLocking.getLiquidity( 228 | poolId 229 | ); 230 | 231 | liquidity = LiquidityAmounts.getLiquidityForAmounts( 232 | sqrtPriceX96, 233 | TickMath.getSqrtRatioAtTick(MIN_TICK), 234 | TickMath.getSqrtRatioAtTick(MAX_TICK), 235 | params.amount0Desired, 236 | params.amount1Desired 237 | ); 238 | 239 | if (poolLiquidity == 0 && liquidity <= MINIMUM_LIQUIDITY) { 240 | revert LiquidityDoesntMeetMinimum(); 241 | } 242 | BalanceDelta addedDelta = modifyLiquidity( 243 | key, 244 | IPoolManager.ModifyLiquidityParams({ 245 | tickLower: MIN_TICK, 246 | tickUpper: MAX_TICK, 247 | liquidityDelta: liquidity.toInt256() 248 | }), 249 | false 250 | ); 251 | 252 | pool.totalLiquidityShares += liquidity; 253 | 254 | if (poolLiquidity == 0) { 255 | // permanently lock the first MINIMUM_LIQUIDITY tokens 256 | liquidity -= MINIMUM_LIQUIDITY; 257 | LockingInfo storage zeroAddressLockingInfo = pool.lockingInfos[ 258 | address(0) 259 | ]; 260 | zeroAddressLockingInfo.lockedUntil = type(uint256).max; 261 | zeroAddressLockingInfo.lockingTime = block.timestamp; 262 | zeroAddressLockingInfo.liquidityShare = MINIMUM_LIQUIDITY; 263 | } 264 | 265 | LockingInfo storage lockingInfo = pool.lockingInfos[msg.sender]; 266 | // lockedUntil supplied as a parameter has to be at least as far in the future as the existing lockedUntil 267 | if (params.lockedUntil < lockingInfo.lockedUntil) { 268 | revert ShorteninglockedUntil(); 269 | } 270 | 271 | // 1. users can add more liquidity to their existing locked liquidity 272 | // 2. reward tokens are minted for the time period of [lockingTime, min(block.timestamp, lockedUntil)] 273 | // 3. reward tokens earned are proportional to the length of the time period above and also proportional to the locked liquidity 274 | uint256 tokensToMint = (pool.rewardGenerationRate * 275 | lockingInfo.liquidityShare * 276 | (Math.min(block.timestamp, lockingInfo.lockedUntil) - 277 | lockingInfo.lockingTime)) / FIXED_POINT_SCALING; 278 | if (tokensToMint != 0) { 279 | pool.rewardToken.mint(msg.sender, tokensToMint); 280 | } 281 | 282 | // updating the locking structure for the user after generating the reward tokens 283 | lockingInfo.lockingTime = block.timestamp; 284 | lockingInfo.lockedUntil = params.lockedUntil; 285 | lockingInfo.liquidityShare += liquidity; 286 | 287 | if ( 288 | uint128(addedDelta.amount0()) < params.amount0Min || 289 | uint128(addedDelta.amount1()) < params.amount1Min 290 | ) { 291 | revert TooMuchSlippage(); 292 | } 293 | } 294 | 295 | function removeLiquidity( 296 | RemoveLiquidityParams calldata params 297 | ) public virtual ensure(params.deadline) returns (BalanceDelta delta) { 298 | PoolKey memory key = PoolKey({ 299 | currency0: params.currency0, 300 | currency1: params.currency1, 301 | fee: params.fee, 302 | tickSpacing: 60, 303 | hooks: IHooks(address(this)) 304 | }); 305 | 306 | PoolId poolId = key.toId(); 307 | 308 | PoolInfoLiquidityLocking storage pool = poolInfoLiquidityLocking[ 309 | poolId 310 | ]; 311 | 312 | (uint160 sqrtPriceX96, , ) = poolManagerLiquidityLocking.getSlot0( 313 | poolId 314 | ); 315 | 316 | if (sqrtPriceX96 == 0) revert PoolNotInitialized(); 317 | 318 | LockingInfo storage lockingInfo = pool.lockingInfos[msg.sender]; 319 | 320 | delta = modifyLiquidity( 321 | key, 322 | IPoolManager.ModifyLiquidityParams({ 323 | tickLower: MIN_TICK, 324 | tickUpper: MAX_TICK, 325 | liquidityDelta: -(params.liquidity.toInt256()) 326 | }), 327 | block.timestamp < lockingInfo.lockedUntil // penalities will be applied for early withdrawals 328 | ); 329 | 330 | // 1. users can remove part of their liquidity (or all of it) either before the lockedUntil passed (penalties will be applied) or 331 | // after the lockedUntil period passed (no penalties will be applied) 332 | // 2. rewards are minted for the time period of [lockingTime, min(block.timestamp, lockedUntil)] 333 | // 3. rewards earned are proportional to the length of the time period and to the locked liquidity 334 | uint256 newLockingTime = Math.min( 335 | block.timestamp, 336 | lockingInfo.lockedUntil 337 | ); 338 | uint256 tokensToMint = (pool.rewardGenerationRate * 339 | lockingInfo.liquidityShare * 340 | (newLockingTime - lockingInfo.lockingTime)) / FIXED_POINT_SCALING; 341 | if (tokensToMint > 0) { 342 | pool.rewardToken.mint(msg.sender, tokensToMint); 343 | } 344 | 345 | lockingInfo.liquidityShare -= params.liquidity; 346 | if (lockingInfo.liquidityShare == 0) { 347 | lockingInfo.lockedUntil = 0; 348 | lockingInfo.lockingTime = 0; 349 | } else { 350 | // potentially setting the lockingTime to lockingInfo.lockedUntil, 351 | // as no more rewards should be generated after the lockedUntil passed 352 | lockingInfo.lockingTime = newLockingTime; 353 | } 354 | pool.totalLiquidityShares -= params.liquidity; 355 | } 356 | 357 | function modifyLiquidity( 358 | PoolKey memory key, 359 | IPoolManager.ModifyLiquidityParams memory params, 360 | bool applyEarlyWithdrawalPenalty 361 | ) internal returns (BalanceDelta delta) { 362 | delta = abi.decode( 363 | poolManagerLiquidityLocking.lock( 364 | address(this), 365 | abi.encode( 366 | CallbackData( 367 | msg.sender, 368 | key, 369 | params, 370 | applyEarlyWithdrawalPenalty 371 | ) 372 | ) 373 | ), 374 | (BalanceDelta) 375 | ); 376 | } 377 | 378 | function lockAcquired( 379 | address, 380 | bytes calldata rawData 381 | ) public virtual poolManagerOnlyLiquidityLocking returns (bytes memory) { 382 | CallbackData memory data = abi.decode(rawData, (CallbackData)); 383 | BalanceDelta delta; 384 | 385 | if (data.params.liquidityDelta < 0) { 386 | delta = _removeLiquidity(data.key, data.params); 387 | if (data.applyEarlyWithdrawalPenalty) { 388 | // For early withdrawals a flat percentage of penalty will be applied on the withdrawn assets (not on the full amount of assets 389 | // that the user locked). Assets taken as penalties will be donated to the pool. 390 | int128 earlyWithdrawalPenaltyPct = int128( 391 | uint128( 392 | poolInfoLiquidityLocking[data.key.toId()] 393 | .earlyWithdrawalPenaltyPct 394 | ) 395 | ); 396 | int128 token0AmountToDonate = (-delta.amount0() * 397 | earlyWithdrawalPenaltyPct) / 398 | int128(int256(FIXED_POINT_SCALING)); 399 | int128 token1AmountToDonate = (-delta.amount1() * 400 | earlyWithdrawalPenaltyPct) / 401 | int128(int256(FIXED_POINT_SCALING)); 402 | 403 | delta = 404 | delta + 405 | toBalanceDelta(token0AmountToDonate, token1AmountToDonate); 406 | poolManagerLiquidityLocking.donate( 407 | data.key, 408 | uint256(uint128(token0AmountToDonate)), 409 | uint256(uint128(token1AmountToDonate)), 410 | ZERO_BYTES 411 | ); 412 | } 413 | _takeDeltas(data.sender, data.key, delta); 414 | } else { 415 | delta = poolManagerLiquidityLocking.modifyLiquidity( 416 | data.key, 417 | data.params, 418 | ZERO_BYTES 419 | ); 420 | _settleDeltas(data.sender, data.key, delta); 421 | } 422 | return abi.encode(delta); 423 | } 424 | 425 | function _removeLiquidity( 426 | PoolKey memory key, 427 | IPoolManager.ModifyLiquidityParams memory params 428 | ) internal returns (BalanceDelta delta) { 429 | PoolId poolId = key.toId(); 430 | PoolInfoLiquidityLocking storage pool = poolInfoLiquidityLocking[ 431 | poolId 432 | ]; 433 | 434 | if (pool.hasAccruedFees) { 435 | _rebalance(key); 436 | } 437 | 438 | uint256 liquidityToRemove = FullMath.mulDiv( 439 | uint256(-params.liquidityDelta), 440 | poolManagerLiquidityLocking.getLiquidity(poolId), 441 | pool.totalLiquidityShares 442 | ); 443 | 444 | params.liquidityDelta = -(liquidityToRemove.toInt256()); 445 | delta = poolManagerLiquidityLocking.modifyLiquidity( 446 | key, 447 | params, 448 | ZERO_BYTES 449 | ); 450 | pool.hasAccruedFees = false; 451 | } 452 | 453 | function _settleDeltas( 454 | address sender, 455 | PoolKey memory key, 456 | BalanceDelta delta 457 | ) internal { 458 | _settleDelta(sender, key.currency0, uint128(delta.amount0())); 459 | _settleDelta(sender, key.currency1, uint128(delta.amount1())); 460 | } 461 | 462 | function _settleDelta( 463 | address sender, 464 | Currency currency, 465 | uint128 amount 466 | ) internal { 467 | if (currency.isNative()) { 468 | poolManagerLiquidityLocking.settle{value: amount}(currency); 469 | } else { 470 | if (sender == address(this)) { 471 | currency.transfer(address(poolManagerLiquidityLocking), amount); 472 | } else { 473 | IERC20Minimal(Currency.unwrap(currency)).transferFrom( 474 | sender, 475 | address(poolManagerLiquidityLocking), 476 | amount 477 | ); 478 | } 479 | poolManagerLiquidityLocking.settle(currency); 480 | } 481 | } 482 | 483 | function _takeDeltas( 484 | address sender, 485 | PoolKey memory key, 486 | BalanceDelta delta 487 | ) internal { 488 | poolManagerLiquidityLocking.take( 489 | key.currency0, 490 | sender, 491 | uint256(uint128(-delta.amount0())) 492 | ); 493 | poolManagerLiquidityLocking.take( 494 | key.currency1, 495 | sender, 496 | uint256(uint128(-delta.amount1())) 497 | ); 498 | } 499 | 500 | function _rebalance(PoolKey memory key) internal { 501 | PoolId poolId = key.toId(); 502 | BalanceDelta balanceDelta = poolManagerLiquidityLocking.modifyLiquidity( 503 | key, 504 | IPoolManager.ModifyLiquidityParams({ 505 | tickLower: MIN_TICK, 506 | tickUpper: MAX_TICK, 507 | liquidityDelta: -( 508 | poolManagerLiquidityLocking.getLiquidity(poolId).toInt256() 509 | ) 510 | }), 511 | ZERO_BYTES 512 | ); 513 | 514 | uint160 newSqrtPriceX96 = (FixedPointMathLib.sqrt( 515 | FullMath.mulDiv( 516 | uint128(-balanceDelta.amount1()), 517 | FixedPoint96.Q96, 518 | uint128(-balanceDelta.amount0()) 519 | ) 520 | ) * FixedPointMathLib.sqrt(FixedPoint96.Q96)).toUint160(); 521 | 522 | (uint160 sqrtPriceX96, , ) = poolManagerLiquidityLocking.getSlot0( 523 | poolId 524 | ); 525 | 526 | poolManagerLiquidityLocking.swap( 527 | key, 528 | IPoolManager.SwapParams({ 529 | zeroForOne: newSqrtPriceX96 < sqrtPriceX96, 530 | amountSpecified: MAX_INT, 531 | sqrtPriceLimitX96: newSqrtPriceX96 532 | }), 533 | ZERO_BYTES 534 | ); 535 | 536 | uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts( 537 | newSqrtPriceX96, 538 | TickMath.getSqrtRatioAtTick(MIN_TICK), 539 | TickMath.getSqrtRatioAtTick(MAX_TICK), 540 | uint256(uint128(-balanceDelta.amount0())), 541 | uint256(uint128(-balanceDelta.amount1())) 542 | ); 543 | 544 | BalanceDelta balanceDeltaAfter = poolManagerLiquidityLocking 545 | .modifyLiquidity( 546 | key, 547 | IPoolManager.ModifyLiquidityParams({ 548 | tickLower: MIN_TICK, 549 | tickUpper: MAX_TICK, 550 | liquidityDelta: liquidity.toInt256() 551 | }), 552 | ZERO_BYTES 553 | ); 554 | 555 | // Donate any "dust" from the sqrtRatio change as fees 556 | uint128 donateAmount0 = uint128( 557 | -balanceDelta.amount0() - balanceDeltaAfter.amount0() 558 | ); 559 | uint128 donateAmount1 = uint128( 560 | -balanceDelta.amount1() - balanceDeltaAfter.amount1() 561 | ); 562 | 563 | poolManagerLiquidityLocking.donate( 564 | key, 565 | donateAmount0, 566 | donateAmount1, 567 | ZERO_BYTES 568 | ); 569 | } 570 | 571 | function beforeAddLiquidity( 572 | address sender, 573 | PoolKey calldata, 574 | IPoolManager.ModifyLiquidityParams calldata, 575 | bytes calldata 576 | ) public virtual override poolManagerOnlyLiquidityLocking returns (bytes4) { 577 | if (sender != address(this)) revert SenderMustBeHook(); 578 | 579 | return IHooks.beforeAddLiquidity.selector; 580 | } 581 | 582 | function beforeSwap( 583 | address, 584 | PoolKey calldata key, 585 | IPoolManager.SwapParams calldata, 586 | bytes calldata 587 | ) public virtual override poolManagerOnlyLiquidityLocking returns (bytes4) { 588 | PoolId poolId = key.toId(); 589 | 590 | if (!poolInfoLiquidityLocking[poolId].hasAccruedFees) { 591 | PoolInfoLiquidityLocking storage pool = poolInfoLiquidityLocking[ 592 | poolId 593 | ]; 594 | pool.hasAccruedFees = true; 595 | } 596 | 597 | return IHooks.beforeSwap.selector; 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/VolumeFee.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.19; 3 | 4 | import {Utils} from "./utils/Utils.sol"; 5 | import {BaseHook} from "./utils/BaseHook.sol"; 6 | 7 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 8 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 9 | import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; 10 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 11 | import {BalanceDelta, BalanceDeltaLibrary} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; 12 | import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; 13 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 14 | 15 | import {IDynamicFeeManager} from "@uniswap/v4-core/src/interfaces/IDynamicFeeManager.sol"; 16 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 17 | 18 | import {console} from "forge-std/Test.sol"; 19 | 20 | // fee can only be increased, if the aggreageted volume is greater than FEE_INCREASE_TOKEN1_UNIT 21 | // fee increase = aggregated volume / FEE_INCREASE_TOKEN1_UNIT * feeIncreasePerToken1Unit 22 | uint256 constant FEE_INCREASE_TOKEN1_UNIT = 1e16; 23 | // fee can only be decrased, if the elapsed time since the last fee decrease is greater than FEE_DECREASE_TIME_UNIT 24 | // fee decrease = time elapsed since last decrease / FEE_DECREASE_TIME_UNIT * feeDecreasePerTimeUnit 25 | uint256 constant FEE_DECREASE_TIME_UNIT = 100; 26 | // the fee charged for swaps has to be always greater than MINIMUM_FEE 27 | // represented in basis points 28 | uint256 constant MINIMUM_FEE = 100; 29 | // the fee charged for swaps has to be always less than MAXIMUM_FEE 30 | // represented in basis points 31 | uint256 constant MAXIMUM_FEE = 200000; 32 | // fee changes will only be written to storage, if they are bigger than MINIMUM_FEE_THRESHOLD bps 33 | uint256 constant MINIMUM_FEE_THRESHOLD = 100; 34 | // swaps will revert, if the actual swap amount is less than SWAP_MISMATCH_PCT_THRESHOLD percent of the indicated swap amount 35 | // this is needed to prevent swap volume manipulation, and artificially increase fees 36 | // imagine an attacker wanting to swap 200e18 ether but set the sqrtPriceLimitX96 swap parameter to current price + 1 37 | // without revering, he would be able to manipulate the volume, while the actual swap amount might just be 0.001 ether 38 | // SWAP_MISMATCH_PCT_THRESHOLD is represented in fixed decimal point with precision of 4 39 | uint256 constant SWAP_MISMATCH_PCT_THRESHOLD = 99_0000; 40 | 41 | contract VolumeFee is Ownable, IDynamicFeeManager, BaseHook { 42 | using PoolIdLibrary for PoolKey; 43 | using SafeCast for uint256; 44 | using SafeCast for uint128; 45 | 46 | error SWAP_AMOUNT_MISMATCH_ERROR(); 47 | 48 | struct PoolInfo { 49 | // fee increase in basis points per token1 units. 50 | // 0.05 percent is represented as 500. 51 | uint24 feeIncreasePerToken1Unit; 52 | // fee decrease in basis point per second 53 | // 0.01 fee decrease per time unit is represented as 100 54 | uint24 feeDecreasePerTimeUnit; 55 | // the current aggregated swap volume for which no fee increase has yet been accounted for 56 | uint256 token1SoFar; 57 | // the last time the fee was decreased, represented in unixtime 58 | uint256 lastFeeDecreaseTime; 59 | // the current fee that is used to charge swappers 60 | uint24 currentFee; 61 | } 62 | 63 | struct InitParams { 64 | uint24 feeIncreasePerToken1Unit; 65 | uint24 feeDecreasePerTimeUnit; 66 | uint24 initialFee; 67 | } 68 | 69 | IPoolManager public immutable poolManager; 70 | 71 | mapping(PoolId => PoolInfo) public poolInfos; 72 | 73 | bool internal skipBeforeAfterHooks; 74 | 75 | constructor(IPoolManager _poolManager, address _owner) Ownable(_owner) { 76 | poolManager = _poolManager; 77 | } 78 | 79 | modifier poolManagerOnly() { 80 | if (msg.sender != address(poolManager)) revert NotPoolManager(); 81 | _; 82 | } 83 | 84 | function beforeInitialize( 85 | address, 86 | PoolKey calldata key, 87 | uint160, 88 | bytes memory data 89 | ) public virtual override poolManagerOnly returns (bytes4) { 90 | InitParams memory initParams = abi.decode(data, (InitParams)); 91 | 92 | PoolInfo storage poolInfo = poolInfos[key.toId()]; 93 | poolInfo.feeIncreasePerToken1Unit = initParams.feeIncreasePerToken1Unit; 94 | poolInfo.feeDecreasePerTimeUnit = initParams.feeDecreasePerTimeUnit; 95 | poolInfo.lastFeeDecreaseTime = block.timestamp; 96 | poolInfo.currentFee = initParams.initialFee; 97 | 98 | return IHooks.beforeInitialize.selector; 99 | } 100 | 101 | function getHooksPermissions() 102 | public 103 | pure 104 | virtual 105 | override 106 | returns (Hooks.Permissions memory) 107 | { 108 | return 109 | Hooks.Permissions({ 110 | beforeInitialize: true, 111 | afterInitialize: false, 112 | beforeAddLiquidity: false, 113 | afterAddLiquidity: false, 114 | beforeRemoveLiquidity: false, 115 | afterRemoveLiquidity: false, 116 | beforeSwap: true, 117 | afterSwap: true, 118 | beforeDonate: false, 119 | afterDonate: false, 120 | noOp: false, 121 | accessLock: false 122 | }); 123 | } 124 | 125 | struct BeforeSwapLocals { 126 | PoolId poolId; 127 | uint256 lastFeeDecreaseTime; 128 | uint256 feeDecreasePerTimeUnit; 129 | uint256 feeDecrease; 130 | int256 feeChange; 131 | uint256 token1SoFar; 132 | uint256 feeIncrease; 133 | uint256 currentFee; 134 | uint256 newFee; 135 | } 136 | 137 | function beforeSwap( 138 | address, 139 | PoolKey calldata poolKey, 140 | IPoolManager.SwapParams calldata swapParams, 141 | bytes calldata 142 | ) public virtual override poolManagerOnly returns (bytes4) { 143 | if (skipBeforeAfterHooks) return IHooks.beforeSwap.selector; 144 | 145 | BeforeSwapLocals memory beforeSwapLocals; 146 | 147 | beforeSwapLocals.poolId = poolKey.toId(); 148 | PoolInfo storage poolInfo = poolInfos[beforeSwapLocals.poolId]; 149 | 150 | // decreasing fees as time goes by 151 | beforeSwapLocals.lastFeeDecreaseTime = poolInfo.lastFeeDecreaseTime; 152 | beforeSwapLocals.feeDecreasePerTimeUnit = poolInfo 153 | .feeDecreasePerTimeUnit; 154 | beforeSwapLocals.feeDecrease = 155 | ((block.timestamp - beforeSwapLocals.lastFeeDecreaseTime) / 156 | FEE_DECREASE_TIME_UNIT) * 157 | beforeSwapLocals.feeDecreasePerTimeUnit; 158 | beforeSwapLocals.feeChange = -int256(beforeSwapLocals.feeDecrease); 159 | 160 | // increasing fees as volume increases 161 | beforeSwapLocals.token1SoFar = poolInfo.token1SoFar; 162 | if (swapParams.zeroForOne) { 163 | (uint160 sqrtPriceX96, , ) = poolManager.getSlot0( 164 | beforeSwapLocals.poolId 165 | ); 166 | beforeSwapLocals.token1SoFar += 167 | (((uint256(sqrtPriceX96) * uint256(sqrtPriceX96)) / (2 ** 96)) * 168 | uint256(swapParams.amountSpecified)) / 169 | (2 ** 96); 170 | } else { 171 | beforeSwapLocals.token1SoFar += uint256(swapParams.amountSpecified); 172 | } 173 | beforeSwapLocals.feeIncrease = ((beforeSwapLocals.token1SoFar / 174 | FEE_INCREASE_TOKEN1_UNIT) * poolInfo.feeIncreasePerToken1Unit); 175 | beforeSwapLocals.feeChange += int256(beforeSwapLocals.feeIncrease); 176 | 177 | // changing the fees 178 | if ( 179 | Utils.abs(beforeSwapLocals.feeChange) > 180 | uint256(MINIMUM_FEE_THRESHOLD) 181 | ) { 182 | if (beforeSwapLocals.feeDecrease != 0) { 183 | poolInfo.lastFeeDecreaseTime = 184 | beforeSwapLocals.lastFeeDecreaseTime + 185 | ((beforeSwapLocals.feeDecrease / 186 | beforeSwapLocals.feeDecreasePerTimeUnit) * 187 | FEE_DECREASE_TIME_UNIT); 188 | } 189 | poolInfo.token1SoFar = 190 | beforeSwapLocals.token1SoFar - 191 | (beforeSwapLocals.feeIncrease * FEE_INCREASE_TOKEN1_UNIT) / 192 | poolInfo.feeIncreasePerToken1Unit; 193 | 194 | beforeSwapLocals.currentFee = poolInfo.currentFee; 195 | beforeSwapLocals.newFee = uint256( 196 | Utils.max( 197 | Utils.min( 198 | int256(beforeSwapLocals.currentFee) + 199 | beforeSwapLocals.feeChange, 200 | int256(MAXIMUM_FEE) 201 | ), 202 | int256(MINIMUM_FEE) 203 | ) 204 | ); 205 | 206 | // if the currentFee was at the MAXIMUM_FEE or MINIMUM_FEE, then even when abs(feeChange) > 0 207 | // the storage write might be avoided 208 | if (beforeSwapLocals.newFee != beforeSwapLocals.currentFee) { 209 | poolInfo.currentFee = uint24(beforeSwapLocals.newFee); 210 | poolManager.updateDynamicSwapFee(poolKey); 211 | } 212 | } else { 213 | poolInfo.token1SoFar = beforeSwapLocals.token1SoFar; 214 | } 215 | return IHooks.beforeSwap.selector; 216 | } 217 | 218 | function afterSwap( 219 | address, 220 | PoolKey calldata, 221 | IPoolManager.SwapParams calldata swapParams, 222 | BalanceDelta balanceDelta, 223 | bytes calldata 224 | ) public virtual override poolManagerOnly returns (bytes4) { 225 | if (skipBeforeAfterHooks) return IHooks.afterSwap.selector; 226 | 227 | // see the comments for SWAP_MISMATCH_PCT_THRESHOLD 228 | if ( 229 | (uint256( 230 | uint128( 231 | Utils.abs( 232 | (swapParams.zeroForOne) 233 | ? BalanceDeltaLibrary.amount0(balanceDelta) 234 | : BalanceDeltaLibrary.amount1(balanceDelta) 235 | ) 236 | ) 237 | ) * 100_0000) / 238 | uint256(swapParams.amountSpecified) < 239 | SWAP_MISMATCH_PCT_THRESHOLD 240 | ) { 241 | revert SWAP_AMOUNT_MISMATCH_ERROR(); 242 | } 243 | return IHooks.afterSwap.selector; 244 | } 245 | 246 | function getFee( 247 | address, 248 | PoolKey calldata key 249 | ) external view returns (uint24) { 250 | PoolInfo storage poolInfo = poolInfos[key.toId()]; 251 | return poolInfo.currentFee; 252 | } 253 | 254 | function setPoolParameters( 255 | PoolId poolId, 256 | uint24 feeIncreasePerToken1Unit, 257 | uint24 feeDecreasePerTimeUnit 258 | ) external onlyOwner { 259 | PoolInfo storage poolInfo = poolInfos[poolId]; 260 | poolInfo.feeIncreasePerToken1Unit = feeIncreasePerToken1Unit; 261 | poolInfo.feeDecreasePerTimeUnit = feeDecreasePerTimeUnit; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/liquidityManager/LiquidityManager.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.19; 3 | 4 | import {Utils} from "../utils/Utils.sol"; 5 | import {LiquidityAmounts} from "../periphery/LiquidityAmounts.sol"; 6 | import {UniswapV4ERC20} from "../periphery/UniswapV4ERC20.sol"; 7 | import {BaseHook} from "../utils/BaseHook.sol"; 8 | import {LiquidityManagerLib} from "./LiquidityManagerLib.sol"; 9 | import {CallbackData, InitParams, PoolInfo, AddLiquidityParams, RemoveLiquidityParams, TICK_SPACING, FIXED_POINT_SCALING, INITIAL_LIQUIDITY} from "./LiquidityManagerStructs.sol"; 10 | 11 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 12 | import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; 13 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 14 | import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; 15 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 16 | import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; 17 | import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; 18 | import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; 19 | import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; 20 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 21 | import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; 22 | 23 | import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; 24 | 25 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 26 | import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; 27 | import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 28 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 29 | 30 | import {console} from "forge-std/Test.sol"; 31 | 32 | /* 33 | The LiquidtyManagement proof of concept (PoC) hook manages assets that are deployed on it. The hook divedes the deposited 34 | assets and use X percent of them to provide liquidity on the full range and uses 100-X percent 35 | as a narrow range liquidity around the current price. If the price of the pool moves, the narrow range liquidity might be 36 | automatically rebalanced to follow the new pool price. As it was implemented as a PoC, it has several limitations. 37 | 38 | - Only pools with 18 digits ERC20 tokens are allowed, native tokens are not supported. 39 | - Minting for others then the message sender is not implemented. 40 | - Slippage protection is not implemented. 41 | - The PoC is susceptible to sandwich attacks, as it moves the assets in the narrow range in one transaction. 42 | - Tick spacing of the pool is fixed at 60. 43 | - Although the liquidity ratio between the narrow and full range (specified by narrowToFullLiquidityRatio parameter) 44 | is kept during the first deposits to the pool, the ratio might change naturally as fees are collected and the narrow range moves. 45 | In the PoC we don't aim to bring back the ratio of narrow to full range, and let it diverge over time. 46 | - The condition when the price is reaching the extreme ends of uniswap price range is not handled. 47 | - More liquidity management specific events need to be emitted. 48 | - Reentrancy protection is not implemented. 49 | - More unit tests need to be written. 50 | */ 51 | 52 | contract LiquidityManager is Ownable, BaseHook { 53 | using PoolIdLibrary for PoolKey; 54 | using SafeCast for uint256; 55 | using SafeCast for uint128; 56 | using SafeERC20 for IERC20; 57 | 58 | event LiquidityRebalanced( 59 | int24 newCenterTick, // the new center of the narrow range after rebalancing 60 | int24 oldCenterTick, // the old center of the narrow range, before the swap happened 61 | int24 oldPriceTick // the price tick after the swap which swap triggered the rebalance 62 | ); 63 | 64 | error PoolNotInitialized(); 65 | error InsufficientInitialLiquidity(); 66 | error SenderMustBeHook(); 67 | error ExpiredPastDeadline(); 68 | 69 | bytes internal constant ZERO_BYTES = bytes(""); 70 | 71 | // prevents multiple rebalances in the same transaction 72 | bool rebalanceInProgress; 73 | 74 | IPoolManager public immutable poolManager; 75 | 76 | mapping(PoolId => PoolInfo) public poolInfos; 77 | 78 | constructor(IPoolManager _poolManager, address owner) Ownable(owner) { 79 | poolManager = _poolManager; 80 | } 81 | 82 | modifier ensure(uint256 deadline) { 83 | if (deadline < block.timestamp) revert ExpiredPastDeadline(); 84 | _; 85 | } 86 | 87 | modifier poolManagerOnly() { 88 | if (msg.sender != address(poolManager)) revert NotPoolManager(); 89 | _; 90 | } 91 | 92 | function beforeInitialize( 93 | address, 94 | PoolKey calldata key, 95 | uint160 sqrtPriceX96, 96 | bytes calldata data 97 | ) external override poolManagerOnly returns (bytes4) { 98 | InitParams memory initParams = abi.decode(data, (InitParams)); 99 | PoolId poolId = key.toId(); 100 | 101 | string memory tokenSymbol = string( 102 | abi.encodePacked( 103 | "UniV4", 104 | "-", 105 | IERC20Metadata(Currency.unwrap(key.currency0)).symbol(), 106 | "-", 107 | IERC20Metadata(Currency.unwrap(key.currency1)).symbol(), 108 | "-", 109 | Strings.toString(uint256(key.fee)) 110 | ) 111 | ); 112 | UniswapV4ERC20 poolToken = new UniswapV4ERC20(tokenSymbol, tokenSymbol); 113 | 114 | poolInfos[poolId] = PoolInfo({ 115 | hasAccruedFees: false, 116 | vaultToken: poolToken, 117 | centerTick: LiquidityManagerLib.getAlignedTickFromSqrtPriceQ96( 118 | sqrtPriceX96, 119 | TICK_SPACING 120 | ), 121 | halfRangeWidthInTickSpaces: initParams.halfRangeWidthInTickSpaces, 122 | halfRangeRebalanceWidthInTickSpaces: initParams 123 | .halfRangeRebalanceWidthInTickSpaces, 124 | narrowToFullLiquidityRatio: initParams.narrowToFullLiquidityRatio, 125 | token0Balance: 0, 126 | token1Balance: 0 127 | }); 128 | 129 | return IHooks.beforeInitialize.selector; 130 | } 131 | 132 | function addLiquidity( 133 | AddLiquidityParams calldata params 134 | ) external ensure(params.deadline) { 135 | PoolKey memory poolKey = PoolKey({ 136 | currency0: params.currency0, 137 | currency1: params.currency1, 138 | fee: params.fee, 139 | tickSpacing: TICK_SPACING, 140 | hooks: IHooks(address(this)) 141 | }); 142 | 143 | PoolId poolId = poolKey.toId(); 144 | (uint160 sqrtPriceX96, , ) = poolManager.getSlot0(poolId); 145 | if (sqrtPriceX96 == 0) revert PoolNotInitialized(); 146 | 147 | PoolInfo storage poolInfo = poolInfos[poolId]; 148 | 149 | uint256 vaultTokenTotalSupply = poolInfo.vaultToken.totalSupply(); 150 | if (vaultTokenTotalSupply == 0) { 151 | // first time deposits are made to the pool through the hook 152 | if (params.vaultTokenAmount != INITIAL_LIQUIDITY) { 153 | revert InsufficientInitialLiquidity(); 154 | } 155 | 156 | uint256 fullRangeLiquidity = FullMath.mulDivRoundingUp( 157 | INITIAL_LIQUIDITY, 158 | FIXED_POINT_SCALING, 159 | (poolInfo.narrowToFullLiquidityRatio + FIXED_POINT_SCALING) 160 | ); 161 | 162 | uint256 narrowRangeLiquidity = INITIAL_LIQUIDITY - 163 | fullRangeLiquidity; 164 | 165 | // add liquidity to the pool based on the liquidity ratio specified by the narrowToFullLiquidityRatio parameter 166 | modifyLiquidity( 167 | poolKey, 168 | LiquidityManagerLib.createModifyLiquidityParams( 169 | fullRangeLiquidity.toInt256(), 170 | narrowRangeLiquidity.toInt256(), 171 | poolInfo 172 | ) 173 | ); 174 | 175 | poolInfo.vaultToken.mint(address(0), INITIAL_LIQUIDITY); 176 | } else { 177 | ( 178 | uint128 fullRangeLiquidity, 179 | uint128 narrowRangeLiquidity 180 | ) = getLiquidityInRanges( 181 | poolId, 182 | poolInfo.centerTick, 183 | poolInfo.halfRangeWidthInTickSpaces 184 | ); 185 | 186 | // add liquidity to the pool while respecting the existing asset ratio of the assets that are managed by the hook 187 | modifyLiquidity( 188 | poolKey, 189 | LiquidityManagerLib.createModifyLiquidityParams( 190 | FullMath 191 | .mulDivRoundingUp( 192 | fullRangeLiquidity, 193 | params.vaultTokenAmount, 194 | vaultTokenTotalSupply 195 | ) 196 | .toInt256(), 197 | int256( 198 | FullMath 199 | .mulDivRoundingUp( 200 | narrowRangeLiquidity, 201 | params.vaultTokenAmount, 202 | vaultTokenTotalSupply 203 | ) 204 | .toInt256() 205 | ), 206 | poolInfo 207 | ) 208 | ); 209 | 210 | // transferring extra token0 and token1 from the user to match the existing 211 | // poolInfo.token0Balance and poolInfo.token1Balance balances which variables store the uninvested 212 | // token holdings 213 | uint256 expectedToken0BalanceIncrease = FullMath.mulDivRoundingUp( 214 | poolInfo.token0Balance, 215 | params.vaultTokenAmount, 216 | vaultTokenTotalSupply 217 | ); 218 | if (expectedToken0BalanceIncrease != 0) { 219 | IERC20(Currency.unwrap(poolKey.currency0)).safeTransferFrom( 220 | msg.sender, 221 | address(poolManager), 222 | expectedToken0BalanceIncrease 223 | ); 224 | poolInfo.token0Balance += expectedToken0BalanceIncrease; 225 | } 226 | 227 | uint256 expectedToken1BalanceIncrease = FullMath.mulDivRoundingUp( 228 | poolInfo.token1Balance, 229 | params.vaultTokenAmount, 230 | vaultTokenTotalSupply 231 | ); 232 | if (expectedToken1BalanceIncrease != 0) { 233 | IERC20(Currency.unwrap(poolKey.currency1)).safeTransferFrom( 234 | msg.sender, 235 | address(poolManager), 236 | expectedToken1BalanceIncrease 237 | ); 238 | poolInfo.token1Balance += expectedToken1BalanceIncrease; 239 | } 240 | 241 | poolInfo.vaultToken.mint(params.to, params.vaultTokenAmount); 242 | } 243 | } 244 | 245 | function removeLiquidity( 246 | RemoveLiquidityParams calldata params 247 | ) public virtual ensure(params.deadline) { 248 | PoolKey memory poolKey = PoolKey({ 249 | currency0: params.currency0, 250 | currency1: params.currency1, 251 | fee: params.fee, 252 | tickSpacing: TICK_SPACING, 253 | hooks: IHooks(address(this)) 254 | }); 255 | PoolId poolId = poolKey.toId(); 256 | PoolInfo storage poolInfo = poolInfos[poolId]; 257 | uint256 vaultTokenTotalSupply = poolInfo.vaultToken.totalSupply(); 258 | 259 | ( 260 | uint128 fullRangeLiquidity, 261 | uint128 narrowRangeLiquidity 262 | ) = getLiquidityInRanges( 263 | poolId, 264 | poolInfo.centerTick, 265 | poolInfo.halfRangeWidthInTickSpaces 266 | ); 267 | 268 | modifyLiquidity( 269 | poolKey, 270 | LiquidityManagerLib.createModifyLiquidityParams( 271 | -FullMath 272 | .mulDivRoundingUp( 273 | fullRangeLiquidity, 274 | params.vaultTokenAmount, 275 | vaultTokenTotalSupply 276 | ) 277 | .toInt256(), 278 | -FullMath 279 | .mulDivRoundingUp( 280 | narrowRangeLiquidity, 281 | params.vaultTokenAmount, 282 | vaultTokenTotalSupply 283 | ) 284 | .toInt256(), 285 | poolInfo 286 | ) 287 | ); 288 | 289 | // transferring extra token0 and token1 to the user from the uninvested balance (proportionally to the tokens burnt) 290 | uint256 expectedToken0BalanceDecrease = FullMath.mulDivRoundingUp( 291 | poolInfo.token0Balance, 292 | params.vaultTokenAmount, 293 | vaultTokenTotalSupply 294 | ); 295 | if (expectedToken0BalanceDecrease != 0) { 296 | IERC20(Currency.unwrap(poolKey.currency0)).safeTransfer( 297 | msg.sender, 298 | expectedToken0BalanceDecrease 299 | ); 300 | poolInfo.token0Balance -= expectedToken0BalanceDecrease; 301 | } 302 | 303 | uint256 expectedToken1BalanceDecrease = FullMath.mulDivRoundingUp( 304 | poolInfo.token1Balance, 305 | params.vaultTokenAmount, 306 | vaultTokenTotalSupply 307 | ); 308 | if (expectedToken1BalanceDecrease != 0) { 309 | IERC20(Currency.unwrap(poolKey.currency1)).safeTransfer( 310 | msg.sender, 311 | expectedToken1BalanceDecrease 312 | ); 313 | poolInfo.token1Balance -= expectedToken1BalanceDecrease; 314 | } 315 | 316 | poolInfo.vaultToken.burn(msg.sender, params.vaultTokenAmount); 317 | } 318 | 319 | function modifyLiquidityCallback( 320 | CallbackData memory callbackData 321 | ) internal returns (BalanceDelta delta) { 322 | collectFees(callbackData.poolKey); 323 | uint256 modifyLiquidityParamsLength = callbackData 324 | .modifyLiquidityParams 325 | .length; 326 | for (uint256 i; i < modifyLiquidityParamsLength; i++) { 327 | delta = 328 | delta + 329 | poolManager.modifyLiquidity( 330 | callbackData.poolKey, 331 | callbackData.modifyLiquidityParams[i], 332 | ZERO_BYTES 333 | ); 334 | } 335 | 336 | LiquidityManagerLib.handleDeltas( 337 | callbackData.sender, 338 | callbackData.poolKey, 339 | delta, 340 | poolManager 341 | ); 342 | } 343 | 344 | /* Rebalance can be necessary if any of the following 3 condition is satisfied 345 | 1. The current price of the pool drifted too far from center of the narrow range liquidity. 346 | 2. The uninvested token balances on the hook became sufficiently large due to either fees collected or inaccurate rebalancing. 347 | 3. The manager of the hook forces rebalancing. 348 | 349 | Rebalancing for condition 1 and 2 can happen after each swap. 350 | */ 351 | function isRebalanceNecessary( 352 | PoolKey memory poolKey, 353 | bool forceRebalance 354 | ) public view returns (bool) { 355 | PoolId poolId = poolKey.toId(); 356 | PoolInfo storage poolInfo = poolInfos[poolId]; 357 | (, int24 currentTick, ) = poolManager.getSlot0(poolId); 358 | 359 | bool isNarrowRangeCenterTooFar = (Utils.abs( 360 | currentTick - poolInfo.centerTick 361 | ) > uint256(int256(poolInfo.halfRangeRebalanceWidthInTickSpaces))); 362 | 363 | // for PoC we ignore this condition 364 | bool isContractTokenBalanceTooLarge = false; 365 | 366 | bool shouldForceRebalance = msg.sender == owner() && forceRebalance; 367 | 368 | return 369 | !rebalanceInProgress && 370 | (isNarrowRangeCenterTooFar || 371 | isContractTokenBalanceTooLarge || 372 | shouldForceRebalance); 373 | } 374 | 375 | function rebalanceIfNecessary( 376 | PoolKey memory poolKey, 377 | bool forceRebalance 378 | ) public { 379 | PoolId poolId = poolKey.toId(); 380 | PoolInfo storage poolInfo = poolInfos[poolId]; 381 | 382 | // rebalance if necessary 383 | if (isRebalanceNecessary(poolKey, forceRebalance)) { 384 | rebalanceInProgress = true; 385 | modifyLiquidity( 386 | poolKey, 387 | new IPoolManager.ModifyLiquidityParams[](0) 388 | ); 389 | rebalanceInProgress = false; 390 | poolInfo.hasAccruedFees = true; 391 | } 392 | } 393 | 394 | struct RebalanceVars { 395 | PoolId poolId; 396 | int24 tickLower; 397 | int24 tickUpper; 398 | int24 newTickLower; 399 | int24 newTickUpper; 400 | uint128 narrowRangeLiquidity; 401 | uint256 token0Available; 402 | uint256 token1Available; 403 | uint160 sqrtPriceCurrentX96; 404 | int24 currentTick; 405 | int24 oldTick; 406 | BalanceDelta removeLiquidityDelta; 407 | BalanceDelta swapDelta; 408 | BalanceDelta addLiquidityDelta; 409 | uint256 swapAmount; 410 | bool zeroForOne; 411 | } 412 | 413 | /* Rebalancing is a 3 step process. 414 | 415 | 1. Remoe all liquidity from the narrow range. 416 | 2. Swap the sufficient amount between token0 and token1 to be able to provide maximum liquidity at the current price (this swap will change the current price) 417 | 3. Add back as much liquidity as possible to the narrow range and keep track of all the tokens the hook was not able to invest. 418 | */ 419 | function rebalanceCallback( 420 | CallbackData memory callbackData 421 | ) internal returns (BalanceDelta totalDelta) { 422 | collectFees(callbackData.poolKey); 423 | 424 | RebalanceVars memory vars; 425 | vars.poolId = callbackData.poolKey.toId(); 426 | PoolInfo storage poolInfo = poolInfos[vars.poolId]; 427 | 428 | // 1. withdraw all liquidity from the narrow range 429 | vars.tickLower = 430 | poolInfo.centerTick - 431 | TICK_SPACING * 432 | poolInfo.halfRangeWidthInTickSpaces; 433 | 434 | vars.tickUpper = 435 | poolInfo.centerTick + 436 | TICK_SPACING * 437 | poolInfo.halfRangeWidthInTickSpaces; 438 | 439 | vars.narrowRangeLiquidity = poolManager 440 | .getPosition( 441 | vars.poolId, 442 | address(this), 443 | vars.tickLower, 444 | vars.tickUpper 445 | ) 446 | .liquidity; 447 | 448 | vars.removeLiquidityDelta = poolManager.modifyLiquidity( 449 | callbackData.poolKey, 450 | IPoolManager.ModifyLiquidityParams({ 451 | tickLower: vars.tickLower, 452 | tickUpper: vars.tickUpper, 453 | liquidityDelta: -vars.narrowRangeLiquidity.toInt128() 454 | }), 455 | ZERO_BYTES 456 | ); 457 | 458 | vars.token0Available = uint256( 459 | int256(poolInfo.token0Balance) - vars.removeLiquidityDelta.amount0() 460 | ); 461 | vars.token1Available = uint256( 462 | int256(poolInfo.token1Balance) - vars.removeLiquidityDelta.amount1() 463 | ); 464 | 465 | // 2. swap sufficient amount 466 | (vars.sqrtPriceCurrentX96, vars.oldTick, ) = poolManager.getSlot0( 467 | vars.poolId 468 | ); 469 | (vars.swapAmount, vars.zeroForOne) = LiquidityManagerLib 470 | .calculateSwapAmount( 471 | vars.token0Available, 472 | vars.token1Available, 473 | vars.sqrtPriceCurrentX96 474 | ); 475 | vars.swapDelta = poolManager.swap( 476 | callbackData.poolKey, 477 | IPoolManager.SwapParams({ 478 | zeroForOne: vars.zeroForOne, 479 | amountSpecified: int256(vars.swapAmount), 480 | sqrtPriceLimitX96: (vars.zeroForOne) 481 | ? TickMath.MIN_SQRT_RATIO + 1 482 | : TickMath.MAX_SQRT_RATIO - 1 483 | }), 484 | ZERO_BYTES 485 | ); 486 | 487 | // 3. adding back liquidity to the narrow range 488 | (vars.sqrtPriceCurrentX96, vars.currentTick, ) = poolManager.getSlot0( 489 | vars.poolId 490 | ); 491 | 492 | vars.currentTick = LiquidityManagerLib.getAlignedTickFromTick( 493 | vars.currentTick, 494 | TICK_SPACING 495 | ); 496 | 497 | vars.newTickLower = 498 | vars.currentTick - 499 | poolInfo.halfRangeWidthInTickSpaces * 500 | TICK_SPACING; 501 | 502 | vars.newTickUpper = 503 | vars.currentTick + 504 | poolInfo.halfRangeWidthInTickSpaces * 505 | TICK_SPACING; 506 | 507 | totalDelta = vars.removeLiquidityDelta + vars.swapDelta; 508 | 509 | uint128 liquidityToProvide = LiquidityAmounts.getLiquidityForAmounts( 510 | vars.sqrtPriceCurrentX96, 511 | TickMath.getSqrtRatioAtTick(vars.newTickLower), 512 | TickMath.getSqrtRatioAtTick(vars.newTickUpper), 513 | uint256( 514 | int256(poolInfo.token0Balance) - int256(totalDelta.amount0()) 515 | ), 516 | uint256( 517 | int256(poolInfo.token1Balance) - int256(totalDelta.amount1()) 518 | ) 519 | ); 520 | 521 | vars.addLiquidityDelta = poolManager.modifyLiquidity( 522 | callbackData.poolKey, 523 | IPoolManager.ModifyLiquidityParams({ 524 | tickLower: vars.newTickLower, 525 | tickUpper: vars.newTickUpper, 526 | liquidityDelta: int256(int128(liquidityToProvide)) 527 | }), 528 | ZERO_BYTES 529 | ); 530 | 531 | totalDelta = totalDelta + vars.addLiquidityDelta; 532 | 533 | // updating contract variables 534 | emit LiquidityRebalanced( 535 | vars.currentTick, 536 | poolInfo.centerTick, 537 | vars.oldTick 538 | ); 539 | 540 | poolInfo.centerTick = vars.currentTick; 541 | poolInfo.token0Balance = uint256( 542 | uint128( 543 | int128(uint128(poolInfo.token0Balance)) - totalDelta.amount0() 544 | ) 545 | ); 546 | poolInfo.token1Balance = uint256( 547 | uint128( 548 | int128(uint128(poolInfo.token1Balance)) - totalDelta.amount1() 549 | ) 550 | ); 551 | LiquidityManagerLib.handleDeltas( 552 | address(this), 553 | callbackData.poolKey, 554 | totalDelta, 555 | poolManager 556 | ); 557 | } 558 | 559 | function modifyLiquidity( 560 | PoolKey memory key, 561 | IPoolManager.ModifyLiquidityParams[] memory params 562 | ) internal returns (BalanceDelta delta) { 563 | delta = abi.decode( 564 | poolManager.lock( 565 | address(this), 566 | abi.encode(CallbackData(msg.sender, key, params)) 567 | ), 568 | (BalanceDelta) 569 | ); 570 | } 571 | 572 | function lockAcquired( 573 | address, 574 | bytes calldata rawData 575 | ) external poolManagerOnly returns (bytes memory) { 576 | CallbackData memory callbackData = abi.decode(rawData, (CallbackData)); 577 | 578 | BalanceDelta delta; 579 | if (callbackData.modifyLiquidityParams.length == 0) { 580 | delta = rebalanceCallback(callbackData); 581 | } else { 582 | delta = modifyLiquidityCallback(callbackData); 583 | } 584 | return abi.encode(delta); 585 | } 586 | 587 | // Collect fees both on the narrow and the full range, leave them uninvested until the next rebalance. 588 | function collectFees(PoolKey memory poolKey) public { 589 | PoolId poolId = poolKey.toId(); 590 | PoolInfo storage poolInfo = poolInfos[poolId]; 591 | if (poolInfo.hasAccruedFees) { 592 | BalanceDelta delta; 593 | IPoolManager.ModifyLiquidityParams[] 594 | memory modifyLiquidityParams = LiquidityManagerLib 595 | .createModifyLiquidityParams(0, 0, poolInfo); 596 | uint256 modifyLiquidityParamsLength = modifyLiquidityParams.length; 597 | for (uint256 i; i < modifyLiquidityParamsLength; i++) { 598 | delta = 599 | delta + 600 | poolManager.modifyLiquidity( 601 | poolKey, 602 | modifyLiquidityParams[i], 603 | ZERO_BYTES 604 | ); 605 | } 606 | poolInfo.token0Balance += uint256(uint128(-delta.amount0())); 607 | poolInfo.token1Balance += uint256(uint128(-delta.amount1())); 608 | poolInfo.hasAccruedFees = false; 609 | LiquidityManagerLib.handleDeltas( 610 | address(this), 611 | poolKey, 612 | delta, 613 | poolManager 614 | ); 615 | } 616 | } 617 | 618 | function getLiquidityInRanges( 619 | PoolId poolId, 620 | int24 centerTick, 621 | int24 halfRangeWidthInTickSpaces 622 | ) 623 | public 624 | view 625 | returns (uint128 fullRangeLiquidity, uint128 narrowRangeLiquidity) 626 | { 627 | return 628 | LiquidityManagerLib.getLiquidityInRanges( 629 | poolId, 630 | centerTick, 631 | halfRangeWidthInTickSpaces, 632 | poolManager 633 | ); 634 | } 635 | 636 | function getAssetsInRanges( 637 | PoolId poolId 638 | ) 639 | external 640 | view 641 | returns ( 642 | uint256 fullRangeLiquidity, 643 | uint256 narrowRangeLiquidity, 644 | uint256 fullRangeToken0, 645 | uint256 fullRangeToken1, 646 | uint256 narrowRangeToken0, 647 | uint256 narrowRangeToken1 648 | ) 649 | { 650 | PoolInfo storage poolInfo = poolInfos[poolId]; 651 | 652 | return 653 | LiquidityManagerLib.getAssetsInRanges( 654 | poolId, 655 | poolManager, 656 | poolInfo 657 | ); 658 | } 659 | 660 | function getHooksPermissions() 661 | public 662 | pure 663 | override 664 | returns (Hooks.Permissions memory) 665 | { 666 | return 667 | Hooks.Permissions({ 668 | beforeInitialize: true, 669 | afterInitialize: false, 670 | beforeAddLiquidity: false, 671 | afterAddLiquidity: false, 672 | beforeRemoveLiquidity: false, 673 | afterRemoveLiquidity: false, 674 | beforeSwap: false, 675 | afterSwap: true, 676 | beforeDonate: false, 677 | afterDonate: false, 678 | noOp: false, 679 | accessLock: true 680 | }); 681 | } 682 | 683 | function afterSwap( 684 | address, 685 | PoolKey calldata poolKey, 686 | IPoolManager.SwapParams calldata, 687 | BalanceDelta, 688 | bytes calldata 689 | ) external virtual override poolManagerOnly returns (bytes4) { 690 | PoolId poolId = poolKey.toId(); 691 | PoolInfo storage poolInfo = poolInfos[poolId]; 692 | 693 | poolInfo.hasAccruedFees = true; 694 | 695 | rebalanceIfNecessary(poolKey, false); 696 | 697 | return IHooks.afterSwap.selector; 698 | } 699 | } 700 | -------------------------------------------------------------------------------- /src/liquidityManager/LiquidityManagerLib.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import {LiquidityAmounts} from "../periphery/LiquidityAmounts.sol"; 5 | import {PoolInfo, MIN_TICK, MAX_TICK, TICK_SPACING} from "./LiquidityManagerStructs.sol"; 6 | 7 | import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; 8 | import {CurrencyLibrary, Currency} from "@uniswap/v4-core/src/types/Currency.sol"; 9 | import {FixedPoint96} from "@uniswap/v4-core/src/libraries/FixedPoint96.sol"; 10 | import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; 11 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 12 | import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; 13 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 14 | import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; 15 | 16 | import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; 17 | import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 18 | 19 | library LiquidityManagerLib { 20 | using CurrencyLibrary for Currency; 21 | using SafeERC20 for IERC20; 22 | 23 | function handleDeltas( 24 | address sender, 25 | PoolKey memory poolKey, 26 | BalanceDelta delta, 27 | IPoolManager poolManager 28 | ) internal { 29 | handleDelta(sender, poolKey.currency0, delta.amount0(), poolManager); 30 | handleDelta(sender, poolKey.currency1, delta.amount1(), poolManager); 31 | } 32 | 33 | function handleDelta( 34 | address sender, 35 | Currency currency, 36 | int128 amount, 37 | IPoolManager poolManager 38 | ) internal { 39 | if (amount > 0) { 40 | settleDelta(sender, currency, uint128(amount), poolManager); 41 | } else if (amount < 0) { 42 | takeDelta(sender, currency, uint128(-amount), poolManager); 43 | } 44 | } 45 | 46 | function settleDelta( 47 | address sender, 48 | Currency currency, 49 | uint128 amount, 50 | IPoolManager poolManager 51 | ) internal { 52 | if (sender == address(this)) { 53 | currency.transfer(address(poolManager), amount); 54 | } else { 55 | IERC20(Currency.unwrap(currency)).safeTransferFrom( 56 | sender, 57 | address(poolManager), 58 | amount 59 | ); 60 | } 61 | poolManager.settle(currency); 62 | } 63 | 64 | function takeDelta( 65 | address sender, 66 | Currency currency, 67 | uint256 amount, 68 | IPoolManager poolManager 69 | ) internal { 70 | poolManager.take(currency, sender, amount); 71 | } 72 | 73 | /* This is a very crude way of calculating the amount that needs to be swapped. 74 | 1. It ignores fees taken during the swap. 75 | 2. It ignores the price effect of the swap, and assumes we would be able to provide liquidity at the old pool price. 76 | 3. It assumes that for a symmetric V3 range the correct ratio of token0 and token1 has to be 77 | token1/token0 = current pool price. The accuracy of this approximation can be seen here: 78 | https://www.desmos.com/calculator/zh37idwezb 79 | 80 | A more accurate (and gas intensive) way of calculating the amount we need to swap can be seen here: 81 | https://www.desmos.com/calculator/oiv0rti0ss 82 | 83 | */ 84 | function calculateSwapAmount( 85 | uint256 token0Amount, 86 | uint256 token1Amount, 87 | uint160 sqrtPriceQ96 88 | ) internal pure returns (uint256 swapAmount, bool zeroForOne) { 89 | uint256 price = FullMath.mulDiv( 90 | sqrtPriceQ96, 91 | sqrtPriceQ96, 92 | FixedPoint96.Q96 93 | ); 94 | 95 | if (token0Amount == 0) { 96 | zeroForOne = false; 97 | swapAmount = token1Amount / 2; 98 | } else if ( 99 | FullMath.mulDiv(token1Amount, FixedPoint96.Q96, token0Amount) > 100 | price 101 | ) { 102 | zeroForOne = false; 103 | swapAmount = 104 | (token1Amount - 105 | FullMath.mulDiv(token0Amount, price, FixedPoint96.Q96)) / 106 | 2; 107 | } else { 108 | zeroForOne = true; 109 | swapAmount = 110 | (token0Amount / 2) - 111 | FullMath.mulDiv(token1Amount, FixedPoint96.Q96, 2 * price); 112 | } 113 | } 114 | 115 | function getAlignedTickFromTick( 116 | int24 tick, 117 | int24 tickSize 118 | ) internal pure returns (int24) { 119 | return (tick / tickSize) * tickSize; 120 | } 121 | 122 | function getAlignedTickFromSqrtPriceQ96( 123 | uint160 sqrtPriceQ96, 124 | int24 tickSize 125 | ) internal pure returns (int24) { 126 | return 127 | getAlignedTickFromTick( 128 | TickMath.getTickAtSqrtRatio(sqrtPriceQ96), 129 | tickSize 130 | ); 131 | } 132 | 133 | function createModifyLiquidityParams( 134 | int256 fullRangeLiquidity, 135 | int256 narrowRangeLiquidity, 136 | PoolInfo storage poolInfo 137 | ) 138 | internal 139 | view 140 | returns ( 141 | IPoolManager.ModifyLiquidityParams[] memory modifyLiquidityParams 142 | ) 143 | { 144 | modifyLiquidityParams = new IPoolManager.ModifyLiquidityParams[](2); 145 | 146 | modifyLiquidityParams[0] = IPoolManager.ModifyLiquidityParams({ 147 | tickLower: MIN_TICK, 148 | tickUpper: MAX_TICK, 149 | liquidityDelta: fullRangeLiquidity 150 | }); 151 | 152 | modifyLiquidityParams[1] = IPoolManager.ModifyLiquidityParams({ 153 | tickLower: poolInfo.centerTick - 154 | TICK_SPACING * 155 | poolInfo.halfRangeWidthInTickSpaces, 156 | tickUpper: poolInfo.centerTick + 157 | TICK_SPACING * 158 | poolInfo.halfRangeWidthInTickSpaces, 159 | liquidityDelta: narrowRangeLiquidity 160 | }); 161 | } 162 | 163 | function getAssetsInRanges( 164 | PoolId poolId, 165 | IPoolManager poolManager, 166 | PoolInfo storage poolInfo 167 | ) 168 | internal 169 | view 170 | returns ( 171 | uint256 fullRangeLiquidity, 172 | uint256 narrowRangeLiquidity, 173 | uint256 fullRangeToken0, 174 | uint256 fullRangeToken1, 175 | uint256 narrowRangeToken0, 176 | uint256 narrowRangeToken1 177 | ) 178 | { 179 | (uint160 sqrtCurrentPriceX96, , ) = poolManager.getSlot0(poolId); 180 | 181 | fullRangeLiquidity = poolManager 182 | .getPosition(poolId, address(this), MIN_TICK, MAX_TICK) 183 | .liquidity; 184 | 185 | narrowRangeLiquidity = poolManager 186 | .getPosition( 187 | poolId, 188 | address(this), 189 | poolInfo.centerTick - 190 | TICK_SPACING * 191 | poolInfo.halfRangeWidthInTickSpaces, 192 | poolInfo.centerTick + 193 | TICK_SPACING * 194 | poolInfo.halfRangeWidthInTickSpaces 195 | ) 196 | .liquidity; 197 | 198 | (fullRangeToken0, fullRangeToken1) = LiquidityAmounts 199 | .getAmountsForLiquidity( 200 | sqrtCurrentPriceX96, 201 | TickMath.getSqrtRatioAtTick(MIN_TICK), 202 | TickMath.getSqrtRatioAtTick(MAX_TICK), 203 | uint128(fullRangeLiquidity) 204 | ); 205 | 206 | (narrowRangeToken0, narrowRangeToken1) = LiquidityAmounts 207 | .getAmountsForLiquidity( 208 | sqrtCurrentPriceX96, 209 | TickMath.getSqrtRatioAtTick( 210 | poolInfo.centerTick - 211 | TICK_SPACING * 212 | poolInfo.halfRangeWidthInTickSpaces 213 | ), 214 | TickMath.getSqrtRatioAtTick( 215 | poolInfo.centerTick + 216 | TICK_SPACING * 217 | poolInfo.halfRangeWidthInTickSpaces 218 | ), 219 | uint128(narrowRangeLiquidity) 220 | ); 221 | } 222 | 223 | function getLiquidityInRanges( 224 | PoolId poolId, 225 | int24 centerTick, 226 | int24 halfRangeWidthInTickSpaces, 227 | IPoolManager poolManager 228 | ) 229 | internal 230 | view 231 | returns (uint128 fullRangeLiquidity, uint128 narrowRangeLiquidity) 232 | { 233 | fullRangeLiquidity = poolManager 234 | .getPosition(poolId, address(this), MIN_TICK, MAX_TICK) 235 | .liquidity; 236 | 237 | narrowRangeLiquidity = poolManager 238 | .getPosition( 239 | poolId, 240 | address(this), 241 | centerTick - TICK_SPACING * halfRangeWidthInTickSpaces, 242 | centerTick + TICK_SPACING * halfRangeWidthInTickSpaces 243 | ) 244 | .liquidity; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/liquidityManager/LiquidityManagerStructs.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | pragma solidity ^0.8.0; 3 | 4 | import {UniswapV4ERC20} from "../periphery/UniswapV4ERC20.sol"; 5 | 6 | import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; 7 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 8 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 9 | 10 | uint256 constant FIXED_POINT_SCALING = 100_0000; 11 | uint256 constant INITIAL_LIQUIDITY = 100_000; 12 | 13 | int24 constant MIN_TICK = -887220; 14 | int24 constant MAX_TICK = -MIN_TICK; 15 | int24 constant TICK_SPACING = 60; 16 | 17 | struct CallbackData { 18 | address sender; 19 | PoolKey poolKey; 20 | IPoolManager.ModifyLiquidityParams[] modifyLiquidityParams; 21 | } 22 | 23 | struct InitParams { 24 | // a range width of 3 would mean that the narrow range liquidity is concentrated in a 25 | // (centerTick - halfRangeWidthInTickSpaces * TICK_SPACING, narrowRangeCenter + halfRangeWidthInTickSpaces * TICK_SPACING) 26 | // interval 27 | int24 halfRangeWidthInTickSpaces; 28 | // a rebalance width of 2 would mean the centerTick of the narrow range has to be within the interval of 29 | // (currentTick - halfRangeRebalanceWidthInTickSpaces * TICK_SPACING, currentTick + halfRangeRebalanceWidthInTickSpaces * TICK_SPACING) 30 | // and if it falls outside of that range, then rebalance is necessary 31 | int24 halfRangeRebalanceWidthInTickSpaces; 32 | // a ratio of 20 would mean the liquidity provided to the narrow range is 20 times the liquidity in the full range 33 | uint256 narrowToFullLiquidityRatio; 34 | } 35 | 36 | struct PoolInfo { 37 | bool hasAccruedFees; 38 | UniswapV4ERC20 vaultToken; 39 | int24 centerTick; // the center of the narrow range 40 | int24 halfRangeWidthInTickSpaces; 41 | int24 halfRangeRebalanceWidthInTickSpaces; 42 | uint256 narrowToFullLiquidityRatio; 43 | uint256 token0Balance; 44 | uint256 token1Balance; 45 | } 46 | 47 | struct AddLiquidityParams { 48 | Currency currency0; 49 | Currency currency1; 50 | uint24 fee; 51 | uint256 vaultTokenAmount; 52 | address to; 53 | uint256 deadline; 54 | } 55 | 56 | struct RemoveLiquidityParams { 57 | Currency currency0; 58 | Currency currency1; 59 | uint24 fee; 60 | uint256 vaultTokenAmount; 61 | uint256 deadline; 62 | } 63 | -------------------------------------------------------------------------------- /src/periphery/LiquidityAmounts.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import "@uniswap/v4-core/src/libraries/FullMath.sol"; 5 | import "@uniswap/v4-core/src/libraries/FixedPoint96.sol"; 6 | 7 | /// @title Liquidity amount functions 8 | /// @notice Provides functions for computing liquidity amounts from token amounts and prices 9 | library LiquidityAmounts { 10 | /// @notice Downcasts uint256 to uint128 11 | /// @param x The uint258 to be downcasted 12 | /// @return y The passed value, downcasted to uint128 13 | function toUint128(uint256 x) private pure returns (uint128 y) { 14 | require((y = uint128(x)) == x); 15 | } 16 | 17 | /// @notice Computes the amount of liquidity received for a given amount of token0 and price range 18 | /// @dev Calculates amount0 * (sqrt(upper) * sqrt(lower)) / (sqrt(upper) - sqrt(lower)) 19 | /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary 20 | /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary 21 | /// @param amount0 The amount0 being sent in 22 | /// @return liquidity The amount of returned liquidity 23 | function getLiquidityForAmount0( 24 | uint160 sqrtRatioAX96, 25 | uint160 sqrtRatioBX96, 26 | uint256 amount0 27 | ) internal pure returns (uint128 liquidity) { 28 | if (sqrtRatioAX96 > sqrtRatioBX96) 29 | (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); 30 | uint256 intermediate = FullMath.mulDiv( 31 | sqrtRatioAX96, 32 | sqrtRatioBX96, 33 | FixedPoint96.Q96 34 | ); 35 | return 36 | toUint128( 37 | FullMath.mulDiv( 38 | amount0, 39 | intermediate, 40 | sqrtRatioBX96 - sqrtRatioAX96 41 | ) 42 | ); 43 | } 44 | 45 | /// @notice Computes the amount of liquidity received for a given amount of token1 and price range 46 | /// @dev Calculates amount1 / (sqrt(upper) - sqrt(lower)). 47 | /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary 48 | /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary 49 | /// @param amount1 The amount1 being sent in 50 | /// @return liquidity The amount of returned liquidity 51 | function getLiquidityForAmount1( 52 | uint160 sqrtRatioAX96, 53 | uint160 sqrtRatioBX96, 54 | uint256 amount1 55 | ) internal pure returns (uint128 liquidity) { 56 | if (sqrtRatioAX96 > sqrtRatioBX96) 57 | (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); 58 | return 59 | toUint128( 60 | FullMath.mulDiv( 61 | amount1, 62 | FixedPoint96.Q96, 63 | sqrtRatioBX96 - sqrtRatioAX96 64 | ) 65 | ); 66 | } 67 | 68 | /// @notice Computes the maximum amount of liquidity received for a given amount of token0, token1, the current 69 | /// pool prices and the prices at the tick boundaries 70 | /// @param sqrtRatioX96 A sqrt price representing the current pool prices 71 | /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary 72 | /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary 73 | /// @param amount0 The amount of token0 being sent in 74 | /// @param amount1 The amount of token1 being sent in 75 | /// @return liquidity The maximum amount of liquidity received 76 | function getLiquidityForAmounts( 77 | uint160 sqrtRatioX96, 78 | uint160 sqrtRatioAX96, 79 | uint160 sqrtRatioBX96, 80 | uint256 amount0, 81 | uint256 amount1 82 | ) internal pure returns (uint128 liquidity) { 83 | if (sqrtRatioAX96 > sqrtRatioBX96) 84 | (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); 85 | 86 | if (sqrtRatioX96 <= sqrtRatioAX96) { 87 | liquidity = getLiquidityForAmount0( 88 | sqrtRatioAX96, 89 | sqrtRatioBX96, 90 | amount0 91 | ); 92 | } else if (sqrtRatioX96 < sqrtRatioBX96) { 93 | uint128 liquidity0 = getLiquidityForAmount0( 94 | sqrtRatioX96, 95 | sqrtRatioBX96, 96 | amount0 97 | ); 98 | uint128 liquidity1 = getLiquidityForAmount1( 99 | sqrtRatioAX96, 100 | sqrtRatioX96, 101 | amount1 102 | ); 103 | 104 | liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1; 105 | } else { 106 | liquidity = getLiquidityForAmount1( 107 | sqrtRatioAX96, 108 | sqrtRatioBX96, 109 | amount1 110 | ); 111 | } 112 | } 113 | 114 | /// @notice Computes the amount of token0 for a given amount of liquidity and a price range 115 | /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary 116 | /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary 117 | /// @param liquidity The liquidity being valued 118 | /// @return amount0 The amount of token0 119 | function getAmount0ForLiquidity( 120 | uint160 sqrtRatioAX96, 121 | uint160 sqrtRatioBX96, 122 | uint128 liquidity 123 | ) internal pure returns (uint256 amount0) { 124 | if (sqrtRatioAX96 > sqrtRatioBX96) 125 | (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); 126 | 127 | return 128 | FullMath.mulDiv( 129 | uint256(liquidity) << FixedPoint96.RESOLUTION, 130 | sqrtRatioBX96 - sqrtRatioAX96, 131 | sqrtRatioBX96 132 | ) / sqrtRatioAX96; 133 | } 134 | 135 | /// @notice Computes the amount of token1 for a given amount of liquidity and a price range 136 | /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary 137 | /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary 138 | /// @param liquidity The liquidity being valued 139 | /// @return amount1 The amount of token1 140 | function getAmount1ForLiquidity( 141 | uint160 sqrtRatioAX96, 142 | uint160 sqrtRatioBX96, 143 | uint128 liquidity 144 | ) internal pure returns (uint256 amount1) { 145 | if (sqrtRatioAX96 > sqrtRatioBX96) 146 | (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); 147 | 148 | return 149 | FullMath.mulDiv( 150 | liquidity, 151 | sqrtRatioBX96 - sqrtRatioAX96, 152 | FixedPoint96.Q96 153 | ); 154 | } 155 | 156 | /// @notice Computes the token0 and token1 value for a given amount of liquidity, the current 157 | /// pool prices and the prices at the tick boundaries 158 | /// @param sqrtRatioX96 A sqrt price representing the current pool prices 159 | /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary 160 | /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary 161 | /// @param liquidity The liquidity being valued 162 | /// @return amount0 The amount of token0 163 | /// @return amount1 The amount of token1 164 | function getAmountsForLiquidity( 165 | uint160 sqrtRatioX96, 166 | uint160 sqrtRatioAX96, 167 | uint160 sqrtRatioBX96, 168 | uint128 liquidity 169 | ) internal pure returns (uint256 amount0, uint256 amount1) { 170 | if (sqrtRatioAX96 > sqrtRatioBX96) 171 | (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); 172 | 173 | if (sqrtRatioX96 <= sqrtRatioAX96) { 174 | amount0 = getAmount0ForLiquidity( 175 | sqrtRatioAX96, 176 | sqrtRatioBX96, 177 | liquidity 178 | ); 179 | } else if (sqrtRatioX96 < sqrtRatioBX96) { 180 | amount0 = getAmount0ForLiquidity( 181 | sqrtRatioX96, 182 | sqrtRatioBX96, 183 | liquidity 184 | ); 185 | amount1 = getAmount1ForLiquidity( 186 | sqrtRatioAX96, 187 | sqrtRatioX96, 188 | liquidity 189 | ); 190 | } else { 191 | amount1 = getAmount1ForLiquidity( 192 | sqrtRatioAX96, 193 | sqrtRatioBX96, 194 | liquidity 195 | ); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/periphery/UniswapV4ERC20.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.0; 2 | 3 | import {ERC20} from "solmate/tokens/ERC20.sol"; 4 | import {Owned} from "solmate/auth/Owned.sol"; 5 | 6 | contract UniswapV4ERC20 is ERC20, Owned { 7 | constructor( 8 | string memory name, 9 | string memory symbol 10 | ) ERC20(name, symbol, 18) Owned(msg.sender) {} 11 | 12 | function mint(address account, uint256 amount) external onlyOwner { 13 | _mint(account, amount); 14 | } 15 | 16 | function burn(address account, uint256 amount) external onlyOwner { 17 | _burn(account, amount); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/BaseHook.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | pragma solidity ^0.8.0; 3 | 4 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 5 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 6 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 7 | import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; 8 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 9 | 10 | abstract contract BaseHook is IHooks { 11 | using Hooks for IHooks; 12 | 13 | error HookNotImplemented(); 14 | error NotPoolManager(); 15 | 16 | constructor() { 17 | IHooks(this).validateHookPermissions(getHooksPermissions()); 18 | } 19 | 20 | function getHooksPermissions() 21 | public 22 | pure 23 | virtual 24 | returns (Hooks.Permissions memory); 25 | 26 | function beforeInitialize( 27 | address, 28 | PoolKey calldata, 29 | uint160, 30 | bytes calldata 31 | ) external virtual returns (bytes4) { 32 | revert HookNotImplemented(); 33 | } 34 | 35 | function afterInitialize( 36 | address, 37 | PoolKey calldata, 38 | uint160, 39 | int24, 40 | bytes calldata 41 | ) external virtual returns (bytes4) { 42 | revert HookNotImplemented(); 43 | } 44 | 45 | function beforeAddLiquidity( 46 | address, 47 | PoolKey calldata, 48 | IPoolManager.ModifyLiquidityParams calldata, 49 | bytes calldata 50 | ) external virtual returns (bytes4) { 51 | revert HookNotImplemented(); 52 | } 53 | 54 | function afterAddLiquidity( 55 | address, 56 | PoolKey calldata, 57 | IPoolManager.ModifyLiquidityParams calldata, 58 | BalanceDelta, 59 | bytes calldata 60 | ) external virtual returns (bytes4) { 61 | revert HookNotImplemented(); 62 | } 63 | 64 | function beforeRemoveLiquidity( 65 | address, 66 | PoolKey calldata, 67 | IPoolManager.ModifyLiquidityParams calldata, 68 | bytes calldata 69 | ) external virtual returns (bytes4) { 70 | revert HookNotImplemented(); 71 | } 72 | 73 | function afterRemoveLiquidity( 74 | address, 75 | PoolKey calldata, 76 | IPoolManager.ModifyLiquidityParams calldata, 77 | BalanceDelta, 78 | bytes calldata 79 | ) external virtual returns (bytes4) { 80 | revert HookNotImplemented(); 81 | } 82 | 83 | function beforeSwap( 84 | address, 85 | PoolKey calldata, 86 | IPoolManager.SwapParams calldata, 87 | bytes calldata 88 | ) external virtual returns (bytes4) { 89 | revert HookNotImplemented(); 90 | } 91 | 92 | function afterSwap( 93 | address, 94 | PoolKey calldata, 95 | IPoolManager.SwapParams calldata, 96 | BalanceDelta, 97 | bytes calldata 98 | ) external virtual returns (bytes4) { 99 | revert HookNotImplemented(); 100 | } 101 | 102 | function beforeDonate( 103 | address, 104 | PoolKey calldata, 105 | uint256, 106 | uint256, 107 | bytes calldata 108 | ) external virtual returns (bytes4) { 109 | revert HookNotImplemented(); 110 | } 111 | 112 | function afterDonate( 113 | address, 114 | PoolKey calldata, 115 | uint256, 116 | uint256, 117 | bytes calldata 118 | ) external virtual returns (bytes4) { 119 | revert HookNotImplemented(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/utils/Utils.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | library Utils { 5 | function max(int256 a, int256 b) internal pure returns (int256) { 6 | return a > b ? a : b; 7 | } 8 | 9 | function min(int256 a, int256 b) internal pure returns (int256) { 10 | return a < b ? a : b; 11 | } 12 | 13 | function abs(int256 x) internal pure returns (uint256) { 14 | return uint256(x >= 0 ? x : -x); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/Combo.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import {UniswapV4ERC20} from "../src/periphery/UniswapV4ERC20.sol"; 5 | import {LiquidityLocking, FIXED_POINT_SCALING} from "../src/LiquidityLocking.sol"; 6 | 7 | import {VolumeFee} from "../src/VolumeFee.sol"; 8 | import {Combo} from "../src/Combo.sol"; 9 | 10 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 11 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 12 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 13 | import {FeeLibrary} from "@uniswap/v4-core/src/libraries/FeeLibrary.sol"; 14 | import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; 15 | import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; 16 | import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; 17 | import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; 18 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 19 | import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; 20 | import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; 21 | 22 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 23 | 24 | import {Test, console, console2} from "forge-std/Test.sol"; 25 | import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; 26 | 27 | contract ComboTest is Test, Deployers, GasSnapshot { 28 | using CurrencyLibrary for Currency; 29 | using SafeCast for uint256; 30 | 31 | Combo combo = 32 | Combo( 33 | address( 34 | uint160( 35 | Hooks.BEFORE_INITIALIZE_FLAG | 36 | Hooks.BEFORE_SWAP_FLAG | 37 | Hooks.AFTER_SWAP_FLAG | 38 | Hooks.BEFORE_ADD_LIQUIDITY_FLAG 39 | ) 40 | ) 41 | ); 42 | 43 | MockERC20 token0; 44 | MockERC20 token1; 45 | PoolKey poolKey; 46 | PoolId poolId; 47 | UniswapV4ERC20 rewardToken; 48 | 49 | uint256 constant MAX_DEADLINE = 12329839823; 50 | uint256 constant INITIAL_BLOCK_TIMESTAMP = 100; 51 | uint24 constant FEE_INCREASE_PER_TOKEN1_UNIT = 5; 52 | uint24 constant FEE_DECREASE_PER_TIME_UNIT = 30; 53 | uint24 constant INITIAL_FEE = 2000; 54 | 55 | int24 constant TICK_SPACING = 60; 56 | uint256 constant REWARD_GENERATION_RATE = 2_000_000; // 2 rewards/liquidity/second 57 | uint24 WITHDRAWAL_PENALTY_PCT = 10_0000; // 10% 58 | 59 | function setUp() public { 60 | vm.warp(INITIAL_BLOCK_TIMESTAMP); 61 | 62 | deployFreshManagerAndRouters(); 63 | deployCodeTo( 64 | "Combo.sol", 65 | abi.encode(manager, address(this)), 66 | address(combo) 67 | ); 68 | (currency0, currency1) = deployMintAndApprove2Currencies(); 69 | 70 | token0 = MockERC20(Currency.unwrap(currency0)); 71 | token1 = MockERC20(Currency.unwrap(currency1)); 72 | token0.approve(address(combo), type(uint256).max); 73 | token1.approve(address(combo), type(uint256).max); 74 | 75 | // create a pool with VolumeFee hook 76 | (poolKey, poolId) = initPool( 77 | currency0, 78 | currency1, 79 | IHooks(combo), 80 | FeeLibrary.DYNAMIC_FEE_FLAG, 81 | SQRT_RATIO_1_1, 82 | abi.encode( 83 | Combo.InitParamsCombo( 84 | abi.encode( 85 | VolumeFee.InitParams( 86 | FEE_INCREASE_PER_TOKEN1_UNIT, 87 | FEE_DECREASE_PER_TIME_UNIT, 88 | INITIAL_FEE 89 | ) 90 | ), 91 | abi.encode( 92 | LiquidityLocking.InitParamsLiquidityLocking( 93 | REWARD_GENERATION_RATE, 94 | WITHDRAWAL_PENALTY_PCT 95 | ) 96 | ) 97 | ) 98 | ) 99 | ); 100 | 101 | (, rewardToken, , , ) = combo.poolInfoLiquidityLocking(poolId); 102 | 103 | address charlie = makeAddr("charlie"); 104 | vm.startPrank(charlie); 105 | token0.mint(charlie, 10000 ether); 106 | token1.mint(charlie, 10000 ether); 107 | token0.approve(address(combo), type(uint256).max); 108 | token1.approve(address(combo), type(uint256).max); 109 | combo.addLiquidity( 110 | LiquidityLocking.AddLiquidityParams( 111 | poolKey.currency0, 112 | poolKey.currency1, 113 | FeeLibrary.DYNAMIC_FEE_FLAG, 114 | 100 ether, 115 | 100 ether, 116 | 99 ether, 117 | 99 ether, 118 | MAX_DEADLINE, 119 | block.timestamp + 100 120 | ) 121 | ); 122 | vm.stopPrank(); 123 | } 124 | 125 | struct ContractStateLiquidityLocking { 126 | uint256 hookBalance0; 127 | uint256 hookBalance1; 128 | uint256 managerBalance0; 129 | uint256 managerBalance1; 130 | uint256 totalLiquidityShares; 131 | LiquidityLocking.LockingInfo lockingInfo; 132 | uint256 totalRewardToken; 133 | uint256 userRewardToken; 134 | } 135 | 136 | function getContractStateLiquidityLocking( 137 | address user 138 | ) 139 | private 140 | view 141 | returns ( 142 | ContractStateLiquidityLocking memory contractStateLiquidityLocking 143 | ) 144 | { 145 | contractStateLiquidityLocking.hookBalance0 = poolKey 146 | .currency0 147 | .balanceOf(address(this)); 148 | contractStateLiquidityLocking.hookBalance1 = poolKey 149 | .currency1 150 | .balanceOf(address(this)); 151 | contractStateLiquidityLocking.managerBalance0 = poolKey 152 | .currency0 153 | .balanceOf(address(manager)); 154 | contractStateLiquidityLocking.managerBalance1 = poolKey 155 | .currency1 156 | .balanceOf(address(manager)); 157 | (, , , contractStateLiquidityLocking.totalLiquidityShares, ) = combo 158 | .poolInfoLiquidityLocking(poolId); 159 | contractStateLiquidityLocking.lockingInfo = combo.poolUserInfo( 160 | poolId, 161 | user 162 | ); 163 | contractStateLiquidityLocking.totalRewardToken = rewardToken 164 | .totalSupply(); 165 | contractStateLiquidityLocking.userRewardToken = rewardToken.balanceOf( 166 | user 167 | ); 168 | } 169 | 170 | struct ContractStateVolumeFee { 171 | uint256 token1SoFar; 172 | uint256 lastFeeDecreaseTime; 173 | uint24 currentFee; 174 | } 175 | 176 | function getContractStateVolumeFee() 177 | internal 178 | view 179 | returns (ContractStateVolumeFee memory contractStateVolumeFee) 180 | { 181 | ( 182 | , 183 | , 184 | contractStateVolumeFee.token1SoFar, 185 | contractStateVolumeFee.lastFeeDecreaseTime, 186 | contractStateVolumeFee.currentFee 187 | ) = combo.poolInfos(poolId); 188 | } 189 | 190 | struct ContractState { 191 | ContractStateLiquidityLocking liquidityLocking; 192 | ContractStateVolumeFee volumeFee; 193 | } 194 | 195 | function getContractState( 196 | address user 197 | ) internal view returns (ContractState memory contractState) { 198 | contractState.liquidityLocking = getContractStateLiquidityLocking(user); 199 | contractState.volumeFee = getContractStateVolumeFee(); 200 | } 201 | 202 | function swapExactTokensForTokens( 203 | PoolKey memory poolKeyParam, 204 | bool zeroForOne, 205 | int256 exactAmountIn 206 | ) private { 207 | swapRouter.swap( 208 | poolKeyParam, 209 | IPoolManager.SwapParams( 210 | zeroForOne, 211 | exactAmountIn, 212 | (zeroForOne) 213 | ? TickMath.MIN_SQRT_RATIO + 1 214 | : TickMath.MAX_SQRT_RATIO - 1 215 | ), 216 | PoolSwapTest.TestSettings(true, true, false), 217 | ZERO_BYTES 218 | ); 219 | } 220 | 221 | function swapExactTokensForTokensUpToPricePoint( 222 | PoolKey memory poolKeyParam, 223 | bool zeroForOne, 224 | int256 exactAmountIn, 225 | uint160 sqrtPriceLimitX96 226 | ) private { 227 | swapRouter.swap( 228 | poolKeyParam, 229 | IPoolManager.SwapParams( 230 | zeroForOne, 231 | exactAmountIn, 232 | sqrtPriceLimitX96 233 | ), 234 | PoolSwapTest.TestSettings(true, true, false), 235 | ZERO_BYTES 236 | ); 237 | } 238 | 239 | function testCombo_addLiquidityWaitForExpiryAndWithdraw() public { 240 | uint256 lockedUntil = INITIAL_BLOCK_TIMESTAMP + 50; 241 | ContractState memory contractStateBeforeAdd = getContractState( 242 | address(this) 243 | ); 244 | 245 | // add liquidity 246 | combo.addLiquidity( 247 | LiquidityLocking.AddLiquidityParams( 248 | poolKey.currency0, 249 | poolKey.currency1, 250 | FeeLibrary.DYNAMIC_FEE_FLAG, 251 | 100 ether, 252 | 100 ether, 253 | 99 ether, 254 | 99 ether, 255 | MAX_DEADLINE, 256 | lockedUntil 257 | ) 258 | ); 259 | ContractState memory contractStateAfterAdd = getContractState( 260 | address(this) 261 | ); 262 | 263 | // check state after adding liquidity 264 | 265 | // checking total liquidty share increase 266 | assertApproxEqRel( 267 | contractStateBeforeAdd.liquidityLocking.totalLiquidityShares + 268 | 100e18, 269 | contractStateAfterAdd.liquidityLocking.totalLiquidityShares, 270 | 1e15, 271 | "Total liquidity share mismatch after adding liquidity" 272 | ); 273 | 274 | // checking user liquidity share increase 275 | assertApproxEqRel( 276 | contractStateBeforeAdd.liquidityLocking.lockingInfo.liquidityShare + 277 | 100e18, 278 | contractStateAfterAdd.liquidityLocking.lockingInfo.liquidityShare, 279 | 1e15, 280 | "User liquidity share mismatch after adding liquidity" 281 | ); 282 | 283 | // checking the total supply of reward token is unchanged 284 | assertEq( 285 | contractStateBeforeAdd.liquidityLocking.totalRewardToken, 286 | contractStateAfterAdd.liquidityLocking.totalRewardToken, 287 | "Total reward token mismatch after adding liquidity" 288 | ); 289 | 290 | // checking the user amount of reward token is unchanged 291 | assertEq( 292 | contractStateBeforeAdd.liquidityLocking.userRewardToken, 293 | contractStateAfterAdd.liquidityLocking.userRewardToken, 294 | "User reward token mismatch after adding liquidity" 295 | ); 296 | 297 | // remove liquidity 298 | uint256 liquiditySharesToRemove = contractStateAfterAdd 299 | .liquidityLocking 300 | .lockingInfo 301 | .liquidityShare; 302 | 303 | vm.warp(lockedUntil + 1); 304 | combo.removeLiquidity( 305 | LiquidityLocking.RemoveLiquidityParams( 306 | poolKey.currency0, 307 | poolKey.currency1, 308 | FeeLibrary.DYNAMIC_FEE_FLAG, 309 | liquiditySharesToRemove, 310 | MAX_DEADLINE 311 | ) 312 | ); 313 | ContractState memory contractStateAfterRemove = getContractState( 314 | address(this) 315 | ); 316 | 317 | // checking total liquidty share decrease 318 | assertApproxEqRel( 319 | contractStateAfterAdd.liquidityLocking.totalLiquidityShares - 320 | liquiditySharesToRemove, 321 | contractStateAfterRemove.liquidityLocking.totalLiquidityShares, 322 | 1e15, 323 | "Total liquidity share mismatch after full removal" 324 | ); 325 | 326 | // checking user liquidity share decrease 327 | assertApproxEqRel( 328 | contractStateAfterAdd.liquidityLocking.lockingInfo.liquidityShare - 329 | liquiditySharesToRemove, 330 | contractStateAfterRemove 331 | .liquidityLocking 332 | .lockingInfo 333 | .liquidityShare, 334 | 1e15, 335 | "User liquidity share mismatch after full removal" 336 | ); 337 | 338 | // checking the total supply of reward token increase 339 | assertEq( 340 | contractStateAfterRemove.liquidityLocking.totalRewardToken - 341 | contractStateAfterAdd.liquidityLocking.totalRewardToken, 342 | (REWARD_GENERATION_RATE * 343 | liquiditySharesToRemove * 344 | (lockedUntil - INITIAL_BLOCK_TIMESTAMP)) / FIXED_POINT_SCALING, 345 | "Total reward token mismatch after full removal" 346 | ); 347 | 348 | // checking the user amount of reward token is the same as the cange in total supply 349 | assertEq( 350 | contractStateAfterRemove.liquidityLocking.totalRewardToken - 351 | contractStateAfterAdd.liquidityLocking.totalRewardToken, 352 | contractStateAfterRemove.liquidityLocking.userRewardToken - 353 | contractStateAfterAdd.liquidityLocking.userRewardToken, 354 | "User reward token mismatch after full removal" 355 | ); 356 | 357 | // checking if the locking info is deleted 358 | assertEq( 359 | contractStateAfterRemove.liquidityLocking.lockingInfo.lockingTime, 360 | 0, 361 | "Locking time is not 0 after full removal" 362 | ); 363 | assertEq( 364 | contractStateAfterRemove.liquidityLocking.lockingInfo.lockedUntil, 365 | 0, 366 | "Liquidity delta is not 0 after full removal" 367 | ); 368 | assertEq( 369 | contractStateAfterRemove 370 | .liquidityLocking 371 | .lockingInfo 372 | .liquidityShare, 373 | 0, 374 | "Liquidity share is not 0 after full removal" 375 | ); 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /test/LiquidityLocking.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import {LiquidityLocking, FIXED_POINT_SCALING} from "../src/LiquidityLocking.sol"; 5 | import {UniswapV4ERC20} from "../src/periphery/UniswapV4ERC20.sol"; 6 | 7 | import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; 8 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 9 | import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; 10 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 11 | import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; 12 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 13 | 14 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 15 | 16 | import {Test, console, console2} from "forge-std/Test.sol"; 17 | 18 | contract LiquidtyLockingTest is Deployers, Test { 19 | using CurrencyLibrary for Currency; 20 | using PoolIdLibrary for PoolKey; 21 | 22 | LiquidityLocking liquidityLocking = 23 | LiquidityLocking( 24 | address( 25 | uint160( 26 | Hooks.BEFORE_INITIALIZE_FLAG | 27 | Hooks.BEFORE_ADD_LIQUIDITY_FLAG | 28 | Hooks.BEFORE_SWAP_FLAG 29 | ) 30 | ) 31 | ); 32 | 33 | using PoolIdLibrary for PoolKey; 34 | 35 | MockERC20 token0; 36 | MockERC20 token1; 37 | PoolKey poolKey; 38 | PoolId poolId; 39 | UniswapV4ERC20 rewardToken; 40 | 41 | int24 constant TICK_SPACING = 60; 42 | uint256 constant MAX_DEADLINE = 12329839823; 43 | uint256 constant INITIAL_BLOCK_TIMESTAMP = 100; 44 | uint256 constant REWARD_GENERATION_RATE = 2_000_000; // 2 rewards/liquidity/second 45 | uint24 WITHDRAWAL_PENALTY_PCT = 10_0000; // 10% 46 | 47 | function setUp() public { 48 | vm.warp(INITIAL_BLOCK_TIMESTAMP); 49 | 50 | deployFreshManagerAndRouters(); 51 | deployCodeTo( 52 | "LiquidityLocking.sol", 53 | abi.encode(manager), 54 | address(liquidityLocking) 55 | ); 56 | (currency0, currency1) = deployMintAndApprove2Currencies(); 57 | 58 | token0 = MockERC20(Currency.unwrap(currency0)); 59 | token1 = MockERC20(Currency.unwrap(currency1)); 60 | token0.approve(address(liquidityLocking), type(uint256).max); 61 | token1.approve(address(liquidityLocking), type(uint256).max); 62 | 63 | (poolKey, poolId) = initPool( 64 | currency0, 65 | currency1, 66 | IHooks(liquidityLocking), 67 | 3000, 68 | SQRT_RATIO_1_1, 69 | abi.encode( 70 | LiquidityLocking.InitParamsLiquidityLocking( 71 | REWARD_GENERATION_RATE, 72 | WITHDRAWAL_PENALTY_PCT 73 | ) 74 | ) 75 | ); 76 | 77 | (, rewardToken, , , ) = liquidityLocking.poolInfoLiquidityLocking( 78 | poolId 79 | ); 80 | 81 | address charlie = makeAddr("charlie"); 82 | vm.startPrank(charlie); 83 | token0.mint(charlie, 10000 ether); 84 | token1.mint(charlie, 10000 ether); 85 | token0.approve(address(liquidityLocking), type(uint256).max); 86 | token1.approve(address(liquidityLocking), type(uint256).max); 87 | 88 | liquidityLocking.addLiquidity( 89 | LiquidityLocking.AddLiquidityParams( 90 | currency0, 91 | currency1, 92 | 3000, 93 | 100 ether, 94 | 100 ether, 95 | 99 ether, 96 | 99 ether, 97 | MAX_DEADLINE, 98 | block.timestamp + 100 99 | ) 100 | ); 101 | vm.stopPrank(); 102 | } 103 | 104 | struct ContractState { 105 | uint256 hookBalance0; 106 | uint256 hookBalance1; 107 | uint256 managerBalance0; 108 | uint256 managerBalance1; 109 | uint256 totalLiquidityShares; 110 | LiquidityLocking.LockingInfo lockingInfo; 111 | uint256 totalRewardToken; 112 | uint256 userRewardToken; 113 | } 114 | 115 | function getContractState( 116 | address user 117 | ) internal view returns (ContractState memory contractState) { 118 | contractState.hookBalance0 = currency0.balanceOf(address(this)); 119 | contractState.hookBalance1 = currency1.balanceOf(address(this)); 120 | contractState.managerBalance0 = currency0.balanceOf(address(manager)); 121 | contractState.managerBalance1 = currency1.balanceOf(address(manager)); 122 | (, , , contractState.totalLiquidityShares, ) = liquidityLocking 123 | .poolInfoLiquidityLocking(poolId); 124 | contractState.lockingInfo = liquidityLocking.poolUserInfo(poolId, user); 125 | contractState.totalRewardToken = rewardToken.totalSupply(); 126 | contractState.userRewardToken = rewardToken.balanceOf(user); 127 | } 128 | 129 | function testLiquidityLocking_withdrawEarlyWithPenalties() public { 130 | ContractState memory contractStateBeforeAdd = getContractState( 131 | address(this) 132 | ); 133 | 134 | uint256 beforeLockExpires1 = INITIAL_BLOCK_TIMESTAMP + 49; 135 | uint256 lockedUntil = INITIAL_BLOCK_TIMESTAMP + 50; 136 | uint256 afterLockExpires1 = lockedUntil + 20; 137 | 138 | ////////// Adding liquidity to the pool //////////// 139 | 140 | // adding liquidity 141 | liquidityLocking.addLiquidity( 142 | LiquidityLocking.AddLiquidityParams( 143 | currency0, 144 | currency1, 145 | 3000, 146 | 100 ether, 147 | 100 ether, 148 | 99 ether, 149 | 99 ether, 150 | MAX_DEADLINE, 151 | lockedUntil 152 | ) 153 | ); 154 | ContractState memory contractStateAfterAdd = getContractState( 155 | address(this) 156 | ); 157 | 158 | ////////// Remove partial liquidity from the pool, before the deadline //////////// 159 | vm.warp(beforeLockExpires1); 160 | 161 | // remove liquidity 162 | uint256 liquiditySharesToRemove = contractStateAfterAdd 163 | .lockingInfo 164 | .liquidityShare / 3; 165 | liquidityLocking.removeLiquidity( 166 | LiquidityLocking.RemoveLiquidityParams( 167 | currency0, 168 | currency1, 169 | 3000, 170 | liquiditySharesToRemove, 171 | MAX_DEADLINE 172 | ) 173 | ); 174 | ContractState memory contractStateAfterPartialRemove = getContractState( 175 | address(this) 176 | ); 177 | 178 | // checking total liquidty share decrease 179 | assertApproxEqRel( 180 | contractStateAfterAdd.totalLiquidityShares - 181 | liquiditySharesToRemove, 182 | contractStateAfterPartialRemove.totalLiquidityShares, 183 | 1e15, 184 | "Total liquidity share mismatch after partial removal" 185 | ); 186 | 187 | // checking user liquidity share decrease 188 | assertApproxEqRel( 189 | contractStateAfterAdd.lockingInfo.liquidityShare - 190 | liquiditySharesToRemove, 191 | contractStateAfterPartialRemove.lockingInfo.liquidityShare, 192 | 1e15, 193 | "User liquidity share mismatch after partial removal" 194 | ); 195 | 196 | // checking the total supply of reward token increase 197 | assertEq( 198 | contractStateAfterPartialRemove.totalRewardToken - 199 | contractStateAfterAdd.totalRewardToken, 200 | (REWARD_GENERATION_RATE * 201 | contractStateAfterAdd.lockingInfo.liquidityShare * 202 | (beforeLockExpires1 - INITIAL_BLOCK_TIMESTAMP)) / 203 | FIXED_POINT_SCALING, 204 | "Total reward token mismatch after partial removal" 205 | ); 206 | 207 | // checking the user amount of reward token is the same as the cange in total supply 208 | assertEq( 209 | contractStateAfterPartialRemove.totalRewardToken - 210 | contractStateAfterAdd.totalRewardToken, 211 | contractStateAfterPartialRemove.userRewardToken - 212 | contractStateAfterAdd.userRewardToken, 213 | "User reward token mismatch after partial removal" 214 | ); 215 | 216 | // checking if the withdrawal penalty is applied to the user's token balance 217 | assertApproxEqRel( 218 | contractStateAfterPartialRemove.hookBalance0 - 219 | contractStateAfterAdd.hookBalance0, 220 | uint256(((100e18 / 3) * 9) / 10), 221 | 1e15, 222 | "Hook token0 balance mismatch after partial removal" 223 | ); 224 | 225 | assertApproxEqRel( 226 | contractStateAfterPartialRemove.hookBalance1 - 227 | contractStateAfterAdd.hookBalance1, 228 | ((100e18 / 3) * 9) / 10, 229 | 1e15, 230 | "Hook token1 balance mismatch after partial removal" 231 | ); 232 | 233 | // checking if the withdrawal penalty is applied to the manager's balance 234 | assertApproxEqRel( 235 | contractStateAfterAdd.managerBalance0 - 236 | contractStateAfterPartialRemove.managerBalance0, 237 | uint256(100e18) / 3 - uint256(100e18) / 3 / 10, 238 | 1e15, 239 | "Hook token0 balance mismatch after partial removal" 240 | ); 241 | 242 | assertApproxEqRel( 243 | contractStateAfterAdd.managerBalance1 - 244 | contractStateAfterPartialRemove.managerBalance1, 245 | uint256(100e18) / 3 - uint256(100e18) / 3 / 10, 246 | 1e15, 247 | "Hook token1 balance mismatch after partial removal" 248 | ); 249 | 250 | ////////// Remove the rest of the liquidity from the pool at a future time //////////// 251 | 252 | vm.warp(afterLockExpires1); 253 | 254 | // remove rest of the liquidity 255 | liquiditySharesToRemove = 256 | contractStateAfterAdd.lockingInfo.liquidityShare - 257 | liquiditySharesToRemove; 258 | liquidityLocking.removeLiquidity( 259 | LiquidityLocking.RemoveLiquidityParams( 260 | currency0, 261 | currency1, 262 | 3000, 263 | liquiditySharesToRemove, 264 | MAX_DEADLINE 265 | ) 266 | ); 267 | ContractState memory contractStateAfterFullRemove = getContractState( 268 | address(this) 269 | ); 270 | 271 | // checking total liquidty share decrease 272 | assertApproxEqRel( 273 | contractStateAfterPartialRemove.totalLiquidityShares - 274 | liquiditySharesToRemove, 275 | contractStateAfterFullRemove.totalLiquidityShares, 276 | 1e15, 277 | "Total liquidity share mismatch after full removal" 278 | ); 279 | 280 | // checking user liquidity share decrease 281 | assertApproxEqRel( 282 | contractStateAfterPartialRemove.lockingInfo.liquidityShare - 283 | liquiditySharesToRemove, 284 | contractStateAfterFullRemove.lockingInfo.liquidityShare, 285 | 1e15, 286 | "User liquidity share mismatch after full removal" 287 | ); 288 | 289 | // checking the total supply of reward token increase 290 | assertEq( 291 | contractStateAfterFullRemove.totalRewardToken - 292 | contractStateAfterPartialRemove.totalRewardToken, 293 | (REWARD_GENERATION_RATE * 294 | liquiditySharesToRemove * 295 | (lockedUntil - beforeLockExpires1)) / FIXED_POINT_SCALING, 296 | "Total reward token mismatch after full removal" 297 | ); 298 | 299 | // checking the user amount of reward token is the same as the cange in total supply 300 | assertEq( 301 | contractStateAfterFullRemove.totalRewardToken - 302 | contractStateAfterPartialRemove.totalRewardToken, 303 | contractStateAfterFullRemove.userRewardToken - 304 | contractStateAfterPartialRemove.userRewardToken, 305 | "User reward token mismatch after full removal" 306 | ); 307 | 308 | // checking if the locking info is deleted 309 | assertEq( 310 | contractStateAfterFullRemove.lockingInfo.lockingTime, 311 | 0, 312 | "Locking time is not 0 after full removal" 313 | ); 314 | assertEq( 315 | contractStateAfterFullRemove.lockingInfo.lockedUntil, 316 | 0, 317 | "Liquidity delta is not 0 after full removal" 318 | ); 319 | assertEq( 320 | contractStateAfterFullRemove.lockingInfo.liquidityShare, 321 | 0, 322 | "Liquidity share is not 0 after full removal" 323 | ); 324 | 325 | // checking if hook balance is increased by the penalty amount 326 | assertApproxEqRel( 327 | contractStateBeforeAdd.hookBalance0 + 328 | uint256(100e18) / 329 | 3 - 330 | uint256(100e18) / 331 | 3 / 332 | 10, 333 | contractStateAfterFullRemove.hookBalance0, 334 | 1e15, 335 | "Hook balance for token0 has not been changed by the penalty amount" 336 | ); 337 | assertApproxEqRel( 338 | contractStateBeforeAdd.hookBalance1 + 339 | uint256(100e18) / 340 | 3 - 341 | uint256(100e18) / 342 | 3 / 343 | 10, 344 | contractStateAfterFullRemove.hookBalance1, 345 | 1e15, 346 | "Hook balance for token0 has not been changed by the penalty amount" 347 | ); 348 | } 349 | 350 | function testLiquidityLocking_withdrawAfterLockExpires() public { 351 | ContractState memory contractStateBeforeAdd = getContractState( 352 | address(this) 353 | ); 354 | uint256 lockedUntil = INITIAL_BLOCK_TIMESTAMP + 50; 355 | uint256 afterLockExpires1 = lockedUntil + 20; 356 | uint256 afterLockExpires2 = afterLockExpires1 + 20; 357 | 358 | ////////// Adding liquidity to the pool //////////// 359 | 360 | // adding liquidity 361 | liquidityLocking.addLiquidity( 362 | LiquidityLocking.AddLiquidityParams( 363 | currency0, 364 | currency1, 365 | 3000, 366 | 100 ether, 367 | 100 ether, 368 | 99 ether, 369 | 99 ether, 370 | MAX_DEADLINE, 371 | lockedUntil 372 | ) 373 | ); 374 | ContractState memory contractStateAfterAdd = getContractState( 375 | address(this) 376 | ); 377 | 378 | // checking total liquidty share increase 379 | assertApproxEqRel( 380 | contractStateBeforeAdd.totalLiquidityShares + 100e18, 381 | contractStateAfterAdd.totalLiquidityShares, 382 | 1e15, 383 | "Total liquidity share mismatch after adding liquidity" 384 | ); 385 | 386 | // checking user liquidity share increase 387 | assertApproxEqRel( 388 | contractStateBeforeAdd.lockingInfo.liquidityShare + 100e18, 389 | contractStateAfterAdd.lockingInfo.liquidityShare, 390 | 1e15, 391 | "User liquidity share mismatch after adding liquidity" 392 | ); 393 | 394 | // checking the total supply of reward token is unchanged 395 | assertEq( 396 | contractStateBeforeAdd.totalRewardToken, 397 | contractStateAfterAdd.totalRewardToken, 398 | "Total reward token mismatch after adding liquidity" 399 | ); 400 | 401 | // checking the user amount of reward token is unchanged 402 | assertEq( 403 | contractStateBeforeAdd.userRewardToken, 404 | contractStateAfterAdd.userRewardToken, 405 | "User reward token mismatch after adding liquidity" 406 | ); 407 | 408 | ////////// Remove partial liquidity from the pool //////////// 409 | vm.warp(afterLockExpires1); 410 | 411 | // remove liquidity 412 | uint256 liquiditySharesToRemove = contractStateAfterAdd 413 | .lockingInfo 414 | .liquidityShare / 3; 415 | liquidityLocking.removeLiquidity( 416 | LiquidityLocking.RemoveLiquidityParams( 417 | currency0, 418 | currency1, 419 | 3000, 420 | liquiditySharesToRemove, 421 | MAX_DEADLINE 422 | ) 423 | ); 424 | ContractState memory contractStateAfterPartialRemove = getContractState( 425 | address(this) 426 | ); 427 | 428 | // checking total liquidty share decrease 429 | assertApproxEqRel( 430 | contractStateAfterAdd.totalLiquidityShares - 431 | liquiditySharesToRemove, 432 | contractStateAfterPartialRemove.totalLiquidityShares, 433 | 1e15, 434 | "Total liquidity share mismatch after partial removal" 435 | ); 436 | 437 | // checking user liquidity share decrease 438 | assertApproxEqRel( 439 | contractStateAfterAdd.lockingInfo.liquidityShare - 440 | liquiditySharesToRemove, 441 | contractStateAfterPartialRemove.lockingInfo.liquidityShare, 442 | 1e15, 443 | "User liquidity share mismatch after partial removal" 444 | ); 445 | 446 | // checking the total supply of reward token increase 447 | assertEq( 448 | contractStateAfterPartialRemove.totalRewardToken - 449 | contractStateAfterAdd.totalRewardToken, 450 | (REWARD_GENERATION_RATE * 451 | contractStateAfterAdd.lockingInfo.liquidityShare * 452 | (lockedUntil - INITIAL_BLOCK_TIMESTAMP)) / FIXED_POINT_SCALING, 453 | "Total reward token mismatch after partial removal" 454 | ); 455 | 456 | // checking the user amount of reward token is the same as the cange in total supply 457 | assertEq( 458 | contractStateAfterPartialRemove.totalRewardToken - 459 | contractStateAfterAdd.totalRewardToken, 460 | contractStateAfterPartialRemove.userRewardToken - 461 | contractStateAfterAdd.userRewardToken, 462 | "User reward token mismatch after partial removal" 463 | ); 464 | 465 | ////////// Remove the rest of the liquidity from the pool at a future time //////////// 466 | 467 | vm.warp(afterLockExpires2); 468 | 469 | // remove rest of the liquidity 470 | liquiditySharesToRemove = 471 | contractStateAfterAdd.lockingInfo.liquidityShare - 472 | liquiditySharesToRemove; 473 | liquidityLocking.removeLiquidity( 474 | LiquidityLocking.RemoveLiquidityParams( 475 | currency0, 476 | currency1, 477 | 3000, 478 | liquiditySharesToRemove, 479 | MAX_DEADLINE 480 | ) 481 | ); 482 | ContractState memory contractStateAfterFullRemove = getContractState( 483 | address(this) 484 | ); 485 | 486 | // checking total liquidty share decrease 487 | assertApproxEqRel( 488 | contractStateAfterPartialRemove.totalLiquidityShares - 489 | liquiditySharesToRemove, 490 | contractStateAfterFullRemove.totalLiquidityShares, 491 | 1e15, 492 | "Total liquidity share mismatch after full removal" 493 | ); 494 | 495 | // checking user liquidity share decrease 496 | assertApproxEqRel( 497 | contractStateAfterPartialRemove.lockingInfo.liquidityShare - 498 | liquiditySharesToRemove, 499 | contractStateAfterFullRemove.lockingInfo.liquidityShare, 500 | 1e15, 501 | "User liquidity share mismatch after full removal" 502 | ); 503 | 504 | // checking the total supply of reward token increase 505 | assertEq( 506 | contractStateAfterFullRemove.totalRewardToken - 507 | contractStateAfterPartialRemove.totalRewardToken, 508 | 0, 509 | "Total reward token mismatch after full removal" 510 | ); 511 | 512 | // checking the user amount of reward token is the same as the cange in total supply 513 | assertEq( 514 | contractStateAfterFullRemove.totalRewardToken - 515 | contractStateAfterPartialRemove.totalRewardToken, 516 | contractStateAfterFullRemove.userRewardToken - 517 | contractStateAfterPartialRemove.userRewardToken, 518 | "User reward token mismatch after full removal" 519 | ); 520 | 521 | // checking if the locking info is deleted 522 | assertEq( 523 | contractStateAfterFullRemove.lockingInfo.lockingTime, 524 | 0, 525 | "Locking time is not 0 after full removal" 526 | ); 527 | assertEq( 528 | contractStateAfterFullRemove.lockingInfo.lockedUntil, 529 | 0, 530 | "Liquidity delta is not 0 after full removal" 531 | ); 532 | assertEq( 533 | contractStateAfterFullRemove.lockingInfo.liquidityShare, 534 | 0, 535 | "Liquidity share is not 0 after full removal" 536 | ); 537 | 538 | // checking if hook balance is unchanged 539 | assertApproxEqRel( 540 | contractStateBeforeAdd.hookBalance0, 541 | contractStateAfterFullRemove.hookBalance0, 542 | 1e15, 543 | "Hook balance for token0 changed" 544 | ); 545 | assertApproxEqRel( 546 | contractStateBeforeAdd.hookBalance1, 547 | contractStateAfterFullRemove.hookBalance1, 548 | 1e15, 549 | "Hook balance for token0 changed" 550 | ); 551 | } 552 | 553 | function testLiquidityLocking_addingMoreLiquidityBeforeWithdraw() public { 554 | ContractState memory contractStateBeforeAdd = getContractState( 555 | address(this) 556 | ); 557 | 558 | uint256 beforelockedUntil1 = INITIAL_BLOCK_TIMESTAMP + 20; 559 | uint256 beforelockedUntil2 = beforelockedUntil1 + 3; 560 | uint256 lockedUntil = INITIAL_BLOCK_TIMESTAMP + 50; 561 | uint256 afterlockedUntil1 = lockedUntil + 10; 562 | uint256 afterlockedUntil2 = afterlockedUntil1 + 5; 563 | uint256 afterlockedUntil3 = afterlockedUntil1 + 100; 564 | 565 | ////////// Adding liquidity to the pool //////////// 566 | 567 | // adding liquidity 568 | liquidityLocking.addLiquidity( 569 | LiquidityLocking.AddLiquidityParams( 570 | currency0, 571 | currency1, 572 | 3000, 573 | 100 ether, 574 | 100 ether, 575 | 99 ether, 576 | 99 ether, 577 | MAX_DEADLINE, 578 | lockedUntil 579 | ) 580 | ); 581 | ContractState memory contractStateAfterFirstAdd = getContractState( 582 | address(this) 583 | ); 584 | 585 | ////////// Adding liquidity to the pool before the deadline without changing the deadline //////////// 586 | 587 | // trying to shorten the duration of the liquidity that has already been locked 588 | vm.warp(beforelockedUntil1); 589 | vm.expectRevert(LiquidityLocking.ShorteninglockedUntil.selector); 590 | 591 | liquidityLocking.addLiquidity( 592 | LiquidityLocking.AddLiquidityParams( 593 | currency0, 594 | currency1, 595 | 3000, 596 | 100 ether, 597 | 100 ether, 598 | 99 ether, 599 | 99 ether, 600 | MAX_DEADLINE, 601 | lockedUntil - 1 602 | ) 603 | ); 604 | 605 | // adding more liquidity before the deadline without changing the deadline 606 | liquidityLocking.addLiquidity( 607 | LiquidityLocking.AddLiquidityParams( 608 | currency0, 609 | currency1, 610 | 3000, 611 | 100 ether, 612 | 100 ether, 613 | 99 ether, 614 | 99 ether, 615 | MAX_DEADLINE, 616 | lockedUntil 617 | ) 618 | ); 619 | ContractState memory contractStateAfterSecondAdd = getContractState( 620 | address(this) 621 | ); 622 | 623 | // checking total liquidty share increase 624 | assertApproxEqRel( 625 | contractStateAfterSecondAdd.totalLiquidityShares, 626 | contractStateAfterFirstAdd.totalLiquidityShares + 100e18, 627 | 1e15, 628 | "Total liquidity share mismatch after adding liquidity second time" 629 | ); 630 | 631 | // checking user liquidity share increase 632 | assertApproxEqRel( 633 | contractStateAfterSecondAdd.lockingInfo.liquidityShare, 634 | contractStateAfterFirstAdd.lockingInfo.liquidityShare + 100e18, 635 | 1e15, 636 | "User liquidity share mismatch after adding liquidity second time" 637 | ); 638 | 639 | // checking the total supply of reward token increase 640 | assertEq( 641 | contractStateAfterSecondAdd.totalRewardToken - 642 | contractStateAfterFirstAdd.totalRewardToken, 643 | (REWARD_GENERATION_RATE * 644 | contractStateAfterFirstAdd.lockingInfo.liquidityShare * 645 | (beforelockedUntil1 - INITIAL_BLOCK_TIMESTAMP)) / 646 | FIXED_POINT_SCALING, 647 | "Total reward token mismatch after adding liquidity second time" 648 | ); 649 | 650 | // checking the user amount of reward token is the same as the cange in total supply 651 | assertEq( 652 | contractStateAfterSecondAdd.totalRewardToken - 653 | contractStateAfterFirstAdd.totalRewardToken, 654 | contractStateAfterSecondAdd.userRewardToken - 655 | contractStateAfterFirstAdd.userRewardToken, 656 | "User reward token mismatch after adding liquidity second time" 657 | ); 658 | 659 | // checking if the locking info is correct 660 | assertEq( 661 | contractStateAfterSecondAdd.lockingInfo.lockingTime, 662 | beforelockedUntil1, 663 | "Locking time mismatch after adding liquidity second time" 664 | ); 665 | assertEq( 666 | contractStateAfterSecondAdd.lockingInfo.lockedUntil, 667 | lockedUntil, 668 | "Liquidity delta mismatch after adding liquidity second time" 669 | ); 670 | assertApproxEqRel( 671 | contractStateAfterSecondAdd.lockingInfo.liquidityShare, 672 | 2 * 100e18, 673 | 1e15, 674 | "Liquidity share mismatch after adding liquidity second time" 675 | ); 676 | 677 | ////////// Adding liquidity to the pool before the deadline and changing the deadline to a future date //////////// 678 | 679 | vm.warp(beforelockedUntil2); 680 | 681 | liquidityLocking.addLiquidity( 682 | LiquidityLocking.AddLiquidityParams( 683 | currency0, 684 | currency1, 685 | 3000, 686 | 100 ether, 687 | 100 ether, 688 | 99 ether, 689 | 99 ether, 690 | MAX_DEADLINE, 691 | afterlockedUntil1 692 | ) 693 | ); 694 | ContractState memory contractStateAfterThirdAdd = getContractState( 695 | address(this) 696 | ); 697 | 698 | // checking total liquidty share increase 699 | assertApproxEqRel( 700 | contractStateAfterThirdAdd.totalLiquidityShares, 701 | contractStateAfterSecondAdd.totalLiquidityShares + 100e18, 702 | 1e15, 703 | "Total liquidity share mismatch after adding liquidity third time" 704 | ); 705 | 706 | // checking user liquidity share increase 707 | assertApproxEqRel( 708 | contractStateAfterThirdAdd.lockingInfo.liquidityShare, 709 | contractStateAfterSecondAdd.lockingInfo.liquidityShare + 100e18, 710 | 1e15, 711 | "User liquidity share mismatch after adding liquidity third time" 712 | ); 713 | 714 | // checking the total supply of reward token increase 715 | assertEq( 716 | contractStateAfterThirdAdd.totalRewardToken - 717 | contractStateAfterSecondAdd.totalRewardToken, 718 | (REWARD_GENERATION_RATE * 719 | contractStateAfterSecondAdd.lockingInfo.liquidityShare * 720 | (beforelockedUntil2 - beforelockedUntil1)) / 721 | FIXED_POINT_SCALING, 722 | "Total reward token mismatch after adding liquidity third time" 723 | ); 724 | 725 | // checking the user amount of reward token is the same as the cange in total supply 726 | assertEq( 727 | contractStateAfterThirdAdd.totalRewardToken - 728 | contractStateAfterSecondAdd.totalRewardToken, 729 | contractStateAfterThirdAdd.userRewardToken - 730 | contractStateAfterSecondAdd.userRewardToken, 731 | "User reward token mismatch after adding liquidity third time" 732 | ); 733 | 734 | // checking if the locking info is correct 735 | assertEq( 736 | contractStateAfterThirdAdd.lockingInfo.lockingTime, 737 | beforelockedUntil2, 738 | "Locking time mismatch after adding liquidity third time" 739 | ); 740 | assertEq( 741 | contractStateAfterThirdAdd.lockingInfo.lockedUntil, 742 | afterlockedUntil1, 743 | "Liquidity delta mismatch after adding liquidity third time" 744 | ); 745 | assertApproxEqRel( 746 | contractStateAfterThirdAdd.lockingInfo.liquidityShare, 747 | 3 * 100e18, 748 | 1e15, 749 | "Liquidity share mismatch after adding liquidity third time" 750 | ); 751 | 752 | //////// Adding liquidity to the pool after the deadline //////////// 753 | 754 | vm.warp(afterlockedUntil2); 755 | 756 | liquidityLocking.addLiquidity( 757 | LiquidityLocking.AddLiquidityParams( 758 | currency0, 759 | currency1, 760 | 3000, 761 | 100 ether, 762 | 100 ether, 763 | 99 ether, 764 | 99 ether, 765 | MAX_DEADLINE, 766 | afterlockedUntil3 767 | ) 768 | ); 769 | ContractState memory contractStateAfterFourthAdd = getContractState( 770 | address(this) 771 | ); 772 | 773 | // checking total liquidty share increase 774 | assertApproxEqRel( 775 | contractStateAfterFourthAdd.totalLiquidityShares, 776 | contractStateAfterThirdAdd.totalLiquidityShares + 100e18, 777 | 1e15, 778 | "Total liquidity share mismatch after adding liquidity fourth time" 779 | ); 780 | 781 | // checking user liquidity share increase 782 | assertApproxEqRel( 783 | contractStateAfterFourthAdd.lockingInfo.liquidityShare, 784 | contractStateAfterThirdAdd.lockingInfo.liquidityShare + 100e18, 785 | 1e15, 786 | "User liquidity share mismatch after adding liquidity fourth time" 787 | ); 788 | 789 | // checking the total supply of reward token increase 790 | assertEq( 791 | contractStateAfterFourthAdd.totalRewardToken - 792 | contractStateAfterThirdAdd.totalRewardToken, 793 | (REWARD_GENERATION_RATE * 794 | contractStateAfterThirdAdd.lockingInfo.liquidityShare * 795 | (afterlockedUntil1 - beforelockedUntil2)) / FIXED_POINT_SCALING, 796 | "Total reward token mismatch after adding liquidity fourth time" 797 | ); 798 | 799 | // checking the user amount of reward token is the same as the cange in total supply 800 | assertEq( 801 | contractStateAfterFourthAdd.totalRewardToken - 802 | contractStateAfterThirdAdd.totalRewardToken, 803 | contractStateAfterFourthAdd.userRewardToken - 804 | contractStateAfterThirdAdd.userRewardToken, 805 | "User reward token mismatch after adding liquidity fourth time" 806 | ); 807 | 808 | // checking if the locking info is correct 809 | assertEq( 810 | contractStateAfterFourthAdd.lockingInfo.lockingTime, 811 | afterlockedUntil2, 812 | "Locking time mismatch after adding liquidity fourth time" 813 | ); 814 | assertEq( 815 | contractStateAfterFourthAdd.lockingInfo.lockedUntil, 816 | afterlockedUntil3, 817 | "Liquidity delta mismatch after adding liquidity fourth time" 818 | ); 819 | assertApproxEqRel( 820 | contractStateAfterFourthAdd.lockingInfo.liquidityShare, 821 | 4 * 100e18, 822 | 1e15, 823 | "Liquidity share mismatch after adding liquidity fourth time" 824 | ); 825 | 826 | ////////// Remove all of the liquidity //////////// 827 | 828 | vm.warp(afterlockedUntil3); 829 | 830 | // remove rest of the liquidity 831 | uint256 liquiditySharesToRemove = contractStateAfterFourthAdd 832 | .lockingInfo 833 | .liquidityShare; 834 | liquidityLocking.removeLiquidity( 835 | LiquidityLocking.RemoveLiquidityParams( 836 | currency0, 837 | currency1, 838 | 3000, 839 | liquiditySharesToRemove, 840 | MAX_DEADLINE 841 | ) 842 | ); 843 | ContractState memory contractStateAfterFullRemove = getContractState( 844 | address(this) 845 | ); 846 | 847 | // checking total liquidty share decrease 848 | assertApproxEqRel( 849 | contractStateAfterFourthAdd.totalLiquidityShares - 850 | liquiditySharesToRemove, 851 | contractStateAfterFullRemove.totalLiquidityShares, 852 | 1e15, 853 | "Total liquidity share mismatch after full removal" 854 | ); 855 | 856 | // checking user liquidity share decrease 857 | assertApproxEqRel( 858 | contractStateAfterFourthAdd.lockingInfo.liquidityShare - 859 | liquiditySharesToRemove, 860 | contractStateAfterFullRemove.lockingInfo.liquidityShare, 861 | 1e15, 862 | "User liquidity share mismatch after full removal" 863 | ); 864 | 865 | // checking the total supply of reward token increase 866 | assertEq( 867 | contractStateAfterFullRemove.totalRewardToken - 868 | contractStateAfterFourthAdd.totalRewardToken, 869 | (REWARD_GENERATION_RATE * 870 | liquiditySharesToRemove * 871 | (afterlockedUntil3 - afterlockedUntil2)) / FIXED_POINT_SCALING, 872 | "Total reward token mismatch after full removal" 873 | ); 874 | 875 | // checking the user amount of reward token is the same as the cange in total supply 876 | assertEq( 877 | contractStateAfterFullRemove.totalRewardToken - 878 | contractStateAfterFourthAdd.totalRewardToken, 879 | contractStateAfterFullRemove.userRewardToken - 880 | contractStateAfterFourthAdd.userRewardToken, 881 | "User reward token mismatch after full removal" 882 | ); 883 | 884 | // checking if the locking info is deleted 885 | assertEq( 886 | contractStateAfterFullRemove.lockingInfo.lockingTime, 887 | 0, 888 | "Locking time is not 0 after full removal" 889 | ); 890 | assertEq( 891 | contractStateAfterFullRemove.lockingInfo.lockedUntil, 892 | 0, 893 | "Liquidity delta is not 0 after full removal" 894 | ); 895 | assertEq( 896 | contractStateAfterFullRemove.lockingInfo.liquidityShare, 897 | 0, 898 | "Liquidity share is not 0 after full removal" 899 | ); 900 | 901 | // checking if hook balance is unchanged 902 | assertApproxEqRel( 903 | contractStateBeforeAdd.hookBalance0, 904 | contractStateAfterFullRemove.hookBalance0, 905 | 1e15, 906 | "Hook balance for token0 changed" 907 | ); 908 | assertApproxEqRel( 909 | contractStateBeforeAdd.hookBalance1, 910 | contractStateAfterFullRemove.hookBalance1, 911 | 1e15, 912 | "Hook balance for token0 changed" 913 | ); 914 | } 915 | } 916 | -------------------------------------------------------------------------------- /test/LiquidityManager.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import {LiquidityManager} from "../src/liquidityManager/LiquidityManager.sol"; 5 | import {InitParams, PoolInfo, AddLiquidityParams, RemoveLiquidityParams, FIXED_POINT_SCALING, INITIAL_LIQUIDITY} from "../src/liquidityManager/LiquidityManagerStructs.sol"; 6 | 7 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 8 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 9 | import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; 10 | import {FixedPoint96} from "@uniswap/v4-core/src/libraries/FixedPoint96.sol"; 11 | import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; 12 | import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; 13 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 14 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 15 | import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; 16 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 17 | import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; 18 | 19 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 20 | 21 | import {Test, console, console2} from "forge-std/Test.sol"; 22 | 23 | contract LiquidtyManagementTest is Deployers, Test { 24 | using CurrencyLibrary for Currency; 25 | using PoolIdLibrary for PoolKey; 26 | 27 | LiquidityManager liquidityManager = 28 | LiquidityManager( 29 | address( 30 | uint160( 31 | Hooks.BEFORE_INITIALIZE_FLAG | 32 | Hooks.AFTER_SWAP_FLAG | 33 | Hooks.ACCESS_LOCK_FLAG 34 | ) 35 | ) 36 | ); 37 | 38 | using PoolIdLibrary for PoolKey; 39 | 40 | MockERC20 token0; 41 | MockERC20 token1; 42 | PoolKey poolKey; 43 | PoolId poolId; 44 | 45 | uint256 constant MAX_DEADLINE = 12329839823; 46 | 47 | event LiquidityRebalanced( 48 | int24 newCenterTick, 49 | int24 oldCenterTick, 50 | int24 oldPriceTick 51 | ); 52 | 53 | function setUp() public { 54 | deployFreshManagerAndRouters(); 55 | deployCodeTo( 56 | "LiquidityManager.sol", 57 | abi.encode(manager, address(this)), 58 | address(liquidityManager) 59 | ); 60 | 61 | (currency0, currency1) = deployMintAndApprove2Currencies(); 62 | 63 | token0 = MockERC20(Currency.unwrap(currency0)); 64 | token1 = MockERC20(Currency.unwrap(currency1)); 65 | token0.approve(address(liquidityManager), type(uint256).max); 66 | token1.approve(address(liquidityManager), type(uint256).max); 67 | 68 | // create a pool with LiquidityManager hook 69 | (poolKey, poolId) = initPool( 70 | currency0, 71 | currency1, 72 | IHooks(liquidityManager), 73 | 3000, 74 | SQRT_RATIO_1_1, 75 | abi.encode(InitParams(12, 5, 20 * FIXED_POINT_SCALING)) 76 | ); 77 | 78 | address charlie = makeAddr("charlie"); 79 | vm.startPrank(charlie); 80 | token0.mint(charlie, 100000 ether); 81 | token1.mint(charlie, 100000 ether); 82 | token0.approve(address(modifyLiquidityRouter), type(uint256).max); 83 | token1.approve(address(modifyLiquidityRouter), type(uint256).max); 84 | token0.approve(address(liquidityManager), type(uint256).max); 85 | token1.approve(address(liquidityManager), type(uint256).max); 86 | 87 | // provide initial liquidity through the liquidityManager 88 | liquidityManager.addLiquidity( 89 | AddLiquidityParams({ 90 | currency0: poolKey.currency0, 91 | currency1: poolKey.currency1, 92 | fee: poolKey.fee, 93 | vaultTokenAmount: INITIAL_LIQUIDITY, 94 | to: address(this), 95 | deadline: MAX_DEADLINE 96 | }) 97 | ); 98 | 99 | // provide more liquidity through the liquidityManager 100 | liquidityManager.addLiquidity( 101 | AddLiquidityParams({ 102 | currency0: poolKey.currency0, 103 | currency1: poolKey.currency1, 104 | fee: poolKey.fee, 105 | vaultTokenAmount: 1 ether, 106 | to: address(this), 107 | deadline: MAX_DEADLINE 108 | }) 109 | ); 110 | 111 | // provide liquidty to the full range through a router contract 112 | modifyLiquidityRouter.modifyLiquidity( 113 | poolKey, 114 | IPoolManager.ModifyLiquidityParams( 115 | TickMath.minUsableTick(60), 116 | TickMath.maxUsableTick(60), 117 | 95240000000000000 118 | ), 119 | ZERO_BYTES 120 | ); 121 | 122 | vm.stopPrank(); 123 | } 124 | 125 | struct ContractState { 126 | uint256 poolManagerToken0Balance; 127 | uint256 poolManagerToken1Balance; 128 | uint256 fullRangeLiquidity; 129 | uint256 narrowRangeLiquidity; 130 | uint256 poolManagerFullRangeToken0Balance; 131 | uint256 poolManagerFullRangeToken1Balance; 132 | uint256 poolManagerNarrowRangeToken0Balance; 133 | uint256 poolManagerNarrowRangeToken1Balance; 134 | uint256 userToken0Balance; 135 | uint256 userToken1Balance; 136 | uint256 userVaultTokenBalance; 137 | uint256 vaultTokenSupply; 138 | PoolInfo poolInfo; 139 | uint256 poolSqrtCurrentPriceX96; 140 | int256 poolTick; 141 | } 142 | 143 | function swapExactTokensForTokens( 144 | PoolKey memory poolKeyParam, 145 | bool zeroForOne, 146 | int256 exactAmountIn 147 | ) private { 148 | swapRouter.swap( 149 | poolKeyParam, 150 | IPoolManager.SwapParams( 151 | zeroForOne, 152 | exactAmountIn, 153 | (zeroForOne) 154 | ? TickMath.MIN_SQRT_RATIO + 1 155 | : TickMath.MAX_SQRT_RATIO - 1 156 | ), 157 | PoolSwapTest.TestSettings(true, true, false), 158 | ZERO_BYTES 159 | ); 160 | } 161 | 162 | function getContractState( 163 | address user 164 | ) internal view returns (ContractState memory contractState) { 165 | contractState.poolManagerToken0Balance = poolKey.currency0.balanceOf( 166 | address(manager) 167 | ); 168 | contractState.poolManagerToken1Balance = poolKey.currency1.balanceOf( 169 | address(manager) 170 | ); 171 | contractState.poolManagerToken0Balance = poolKey.currency0.balanceOf( 172 | address(manager) 173 | ); 174 | contractState.poolManagerToken1Balance = poolKey.currency1.balanceOf( 175 | address(manager) 176 | ); 177 | contractState.userToken0Balance = poolKey.currency0.balanceOf( 178 | address(user) 179 | ); 180 | contractState.userToken1Balance = poolKey.currency1.balanceOf( 181 | address(user) 182 | ); 183 | ( 184 | contractState.poolInfo.hasAccruedFees, 185 | contractState.poolInfo.vaultToken, 186 | contractState.poolInfo.centerTick, 187 | contractState.poolInfo.halfRangeWidthInTickSpaces, 188 | contractState.poolInfo.halfRangeRebalanceWidthInTickSpaces, 189 | contractState.poolInfo.narrowToFullLiquidityRatio, 190 | contractState.poolInfo.token0Balance, 191 | contractState.poolInfo.token1Balance 192 | ) = liquidityManager.poolInfos(poolId); 193 | contractState.userVaultTokenBalance = contractState 194 | .poolInfo 195 | .vaultToken 196 | .balanceOf(user); 197 | contractState.vaultTokenSupply = contractState 198 | .poolInfo 199 | .vaultToken 200 | .totalSupply(); 201 | ( 202 | contractState.poolSqrtCurrentPriceX96, 203 | contractState.poolTick, 204 | 205 | ) = manager.getSlot0(poolId); 206 | 207 | ( 208 | contractState.fullRangeLiquidity, 209 | contractState.narrowRangeLiquidity, 210 | contractState.poolManagerFullRangeToken0Balance, 211 | contractState.poolManagerFullRangeToken1Balance, 212 | contractState.poolManagerNarrowRangeToken0Balance, 213 | contractState.poolManagerNarrowRangeToken1Balance 214 | ) = liquidityManager.getAssetsInRanges(poolId); 215 | } 216 | 217 | function printContractState( 218 | ContractState memory contractState 219 | ) internal view { 220 | console.log("\n## Start contact state"); 221 | console.log( 222 | "liquidityManagerToken0StoredBalance:", 223 | contractState.poolInfo.token0Balance 224 | ); 225 | console.log( 226 | "liquidityManagerToken1StoredBalance:", 227 | contractState.poolInfo.token1Balance 228 | ); 229 | console.log( 230 | "poolManagerToken0Balance:", 231 | contractState.poolManagerToken0Balance 232 | ); 233 | console.log( 234 | "poolManagerToken1Balance:", 235 | contractState.poolManagerToken1Balance 236 | ); 237 | console.log("fullRangeLiquidity:", contractState.fullRangeLiquidity); 238 | console.log( 239 | "narrowRangeLiquidity:", 240 | contractState.narrowRangeLiquidity 241 | ); 242 | console.log( 243 | "poolManagerFullRangeToken0Balance:", 244 | contractState.poolManagerFullRangeToken0Balance 245 | ); 246 | console.log( 247 | "poolManagerFullRangeToken1Balance:", 248 | contractState.poolManagerFullRangeToken1Balance 249 | ); 250 | console.log( 251 | "poolManagerNarrowRangeToken0Balance:", 252 | contractState.poolManagerNarrowRangeToken0Balance 253 | ); 254 | console.log( 255 | "poolManagerNarrowRangeToken1Balance:", 256 | contractState.poolManagerNarrowRangeToken1Balance 257 | ); 258 | console.log("userToken0Balance:", contractState.userToken0Balance); 259 | console.log("userToken1Balance:", contractState.userToken1Balance); 260 | console.log( 261 | "userVaultTokenBalance:", 262 | contractState.userVaultTokenBalance 263 | ); 264 | console.log("vaultTokenSupply:", contractState.vaultTokenSupply); 265 | console.log( 266 | "poolSqrtCurrentPriceX96:", 267 | contractState.poolSqrtCurrentPriceX96 268 | ); 269 | console.log("poolTick:"); 270 | console.logInt(contractState.poolTick); 271 | console.log("centerTick: "); 272 | console.logInt(contractState.poolInfo.centerTick); 273 | console.log("## End contact state\n"); 274 | } 275 | 276 | function testLiquidityManager_testRebalanceToHigherPriceNotNeeded() public { 277 | swapExactTokensForTokens(poolKey, false, 0.00025 ether); 278 | ContractState memory contractState = getContractState(address(this)); 279 | 280 | assertEq(contractState.poolInfo.centerTick, 0, "centerTick mismatch"); 281 | assertEq(contractState.poolTick, 4, "poolTick mismatch"); 282 | assertEq( 283 | contractState.poolInfo.token0Balance, 284 | 0, 285 | "poolInfo token0Balance mismatch" 286 | ); 287 | assertEq( 288 | contractState.poolInfo.token1Balance, 289 | 0, 290 | "poolInfo token1Balance mismatch" 291 | ); 292 | assertEq( 293 | contractState.poolInfo.hasAccruedFees, 294 | true, 295 | "hasAccruedFees mismatch" 296 | ); 297 | } 298 | 299 | function testLiquidityManager_testRebalanceToLowerPriceNotNeeded() public { 300 | swapExactTokensForTokens(poolKey, true, 0.00025 ether); 301 | ContractState memory contractState = getContractState(address(this)); 302 | 303 | assertEq(contractState.poolInfo.centerTick, 0, "centerTick mismatch"); 304 | assertEq(contractState.poolTick, -5, "poolTick mismatch"); 305 | assertEq( 306 | contractState.poolInfo.token0Balance, 307 | 0, 308 | "poolInfo token0Balance mismatch" 309 | ); 310 | assertEq( 311 | contractState.poolInfo.token1Balance, 312 | 0, 313 | "poolInfo token1Balance mismatch" 314 | ); 315 | assertEq( 316 | contractState.poolInfo.hasAccruedFees, 317 | true, 318 | "hasAccruedFees mismatch" 319 | ); 320 | } 321 | 322 | function testLiquidityManager_testRebalanceToHigherPriceNarrowRangeStillHasBothTokens() 323 | public 324 | { 325 | vm.expectEmit(address(liquidityManager)); 326 | emit LiquidityRebalanced(60, 0, 9); 327 | swapExactTokensForTokens(poolKey, false, 0.0005 ether); 328 | 329 | ContractState memory contractState = getContractState(address(this)); 330 | assertEq(contractState.poolTick, 67, "poolTick mismatch"); 331 | assertLt( 332 | (contractState.poolInfo.token0Balance * FixedPoint96.Q96) / 333 | contractState.poolManagerNarrowRangeToken0Balance, 334 | FixedPoint96.Q96 / 5, 335 | "uninvested token0 balance is more than 20% of the narrow range token holdings" 336 | ); 337 | assertLt( 338 | (contractState.poolInfo.token1Balance * FixedPoint96.Q96) / 339 | contractState.poolManagerNarrowRangeToken0Balance, 340 | FixedPoint96.Q96 / 5, 341 | "uninvested token1 balance is more than 20% of the narrow range token holdings" 342 | ); 343 | } 344 | 345 | function testLiquidityManager_testRebalanceToLowerPriceNarrowRangeStillHasBothTokens() 346 | public 347 | { 348 | vm.expectEmit(address(liquidityManager)); 349 | emit LiquidityRebalanced(-60, 0, -10); 350 | swapExactTokensForTokens(poolKey, true, 0.0005 ether); 351 | ContractState memory contractState = getContractState(address(this)); 352 | 353 | assertEq(contractState.poolTick, -68, "poolTick mismatch"); 354 | assertLt( 355 | (contractState.poolInfo.token0Balance * FixedPoint96.Q96) / 356 | contractState.poolManagerNarrowRangeToken0Balance, 357 | FixedPoint96.Q96 / 5, 358 | "uninvested token0 balance is more than 20% of the narrow range token holdings" 359 | ); 360 | assertLt( 361 | (contractState.poolInfo.token1Balance * FixedPoint96.Q96) / 362 | contractState.poolManagerNarrowRangeToken0Balance, 363 | FixedPoint96.Q96 / 5, 364 | "uninvested token1 balance is more than 20% of the narrow range token holdings" 365 | ); 366 | } 367 | 368 | function testLiquidityManager_testRebalanceToHigherPriceNarrowRangeOnlyHasOneToken() 369 | public 370 | { 371 | vm.expectEmit(address(liquidityManager)); 372 | emit LiquidityRebalanced(5880, 0, 1989); 373 | swapExactTokensForTokens(poolKey, false, 0.05 ether); 374 | ContractState memory contractState = getContractState(address(this)); 375 | 376 | assertEq(contractState.poolTick, 5918, "poolTick mismatch"); 377 | 378 | // uninvested token0 balance is more than 20% due to the crude algorithm that is used to calculate swap amount during rebalancing 379 | assertGt( 380 | (contractState.poolInfo.token0Balance * FixedPoint96.Q96) / 381 | contractState.poolManagerNarrowRangeToken0Balance, 382 | FixedPoint96.Q96 / 5, 383 | "uninvested token0 balance is more than 20% of the narrow range token holdings" 384 | ); 385 | 386 | // couple of rebalancing will sort it out 387 | // rebalancing will be executed naturally as part of subsequent swaps, but rebalancing is called directly in this test 388 | liquidityManager.rebalanceIfNecessary(poolKey, true); 389 | liquidityManager.rebalanceIfNecessary(poolKey, true); 390 | liquidityManager.rebalanceIfNecessary(poolKey, true); 391 | contractState = getContractState(address(this)); 392 | assertLt( 393 | (contractState.poolInfo.token0Balance * FixedPoint96.Q96) / 394 | contractState.poolManagerNarrowRangeToken0Balance, 395 | FixedPoint96.Q96 / 5, 396 | "uninvested token0 balance is more than 20% of the narrow range token holdings" 397 | ); 398 | assertLt( 399 | (contractState.poolInfo.token1Balance * FixedPoint96.Q96) / 400 | contractState.poolManagerNarrowRangeToken0Balance, 401 | FixedPoint96.Q96 / 5, 402 | "uninvested token1 balance is more than 20% of the narrow range token holdings" 403 | ); 404 | } 405 | 406 | function testLiquidityManager_testRebalanceToLowerPriceNarrowRangeOnlyHasOneToken() 407 | public 408 | { 409 | vm.expectEmit(address(liquidityManager)); 410 | emit LiquidityRebalanced(-5880, 0, -1990); 411 | swapExactTokensForTokens(poolKey, true, 0.05 ether); 412 | ContractState memory contractState = getContractState(address(this)); 413 | assertEq(contractState.poolTick, -5919, "poolTick mismatch"); 414 | 415 | // uninvested token0 balance is more than 20% due to the crude algorithm that is used to calculate swap amount during rebalancing 416 | assertGt( 417 | (contractState.poolInfo.token1Balance * FixedPoint96.Q96) / 418 | contractState.poolManagerNarrowRangeToken1Balance, 419 | FixedPoint96.Q96 / 5, 420 | "uninvested token0 balance is more than 20% of the narrow range token holdings" 421 | ); 422 | 423 | // couple of rebalancing will sort it out 424 | // rebalancing will be executed naturally as part of subsequent swaps, but rebalancing is called directly in this test 425 | liquidityManager.rebalanceIfNecessary(poolKey, true); 426 | liquidityManager.rebalanceIfNecessary(poolKey, true); 427 | liquidityManager.rebalanceIfNecessary(poolKey, true); 428 | contractState = getContractState(address(this)); 429 | 430 | assertLt( 431 | (contractState.poolInfo.token0Balance * FixedPoint96.Q96) / 432 | contractState.poolManagerNarrowRangeToken0Balance, 433 | FixedPoint96.Q96 / 5, 434 | "uninvested token0 balance is more than 20% of the narrow range token holdings" 435 | ); 436 | assertLt( 437 | (contractState.poolInfo.token1Balance * FixedPoint96.Q96) / 438 | contractState.poolManagerNarrowRangeToken0Balance, 439 | FixedPoint96.Q96 / 5, 440 | "uninvested token1 balance is more than 20% of the narrow range token holdings" 441 | ); 442 | } 443 | 444 | function testLiquidityManager_testDepositWithNonZeroContractStoredTokenBalances() 445 | public 446 | { 447 | // swapping to accumulate fees 448 | swapExactTokensForTokens(poolKey, true, 0.0005 ether); 449 | swapExactTokensForTokens(poolKey, false, 0.0005 ether); 450 | 451 | // add zero liquidity to trigger fee collection 452 | liquidityManager.addLiquidity( 453 | AddLiquidityParams({ 454 | currency0: poolKey.currency0, 455 | currency1: poolKey.currency1, 456 | fee: poolKey.fee, 457 | vaultTokenAmount: 0 ether, 458 | to: address(this), 459 | deadline: MAX_DEADLINE 460 | }) 461 | ); 462 | 463 | // adding more liquidity 464 | ContractState 465 | memory contractStateBeforeAddingLiquidity = getContractState( 466 | address(this) 467 | ); 468 | liquidityManager.addLiquidity( 469 | AddLiquidityParams({ 470 | currency0: poolKey.currency0, 471 | currency1: poolKey.currency1, 472 | fee: poolKey.fee, 473 | vaultTokenAmount: 0.5 ether, 474 | to: address(this), 475 | deadline: MAX_DEADLINE 476 | }) 477 | ); 478 | ContractState 479 | memory contractStateAfterAddingLiquidity = getContractState( 480 | address(this) 481 | ); 482 | 483 | // liquidity manager updated its uninvested token balances 484 | assertApproxEqRel( 485 | contractStateAfterAddingLiquidity.poolInfo.token0Balance, 486 | (contractStateBeforeAddingLiquidity.poolInfo.token0Balance * 487 | contractStateAfterAddingLiquidity.vaultTokenSupply) / 488 | contractStateBeforeAddingLiquidity.vaultTokenSupply, 489 | 1e16, 490 | "token0Balance mismatch" 491 | ); 492 | assertApproxEqRel( 493 | contractStateAfterAddingLiquidity.poolInfo.token1Balance, 494 | (contractStateBeforeAddingLiquidity.poolInfo.token1Balance * 495 | contractStateAfterAddingLiquidity.vaultTokenSupply) / 496 | contractStateBeforeAddingLiquidity.vaultTokenSupply, 497 | 1e16, 498 | "token1Balance mismatch" 499 | ); 500 | // user transferred the right amount of tokens 501 | assertApproxEqRel( 502 | contractStateAfterAddingLiquidity 503 | .poolManagerNarrowRangeToken0Balance - 504 | contractStateBeforeAddingLiquidity 505 | .poolManagerNarrowRangeToken0Balance + 506 | contractStateAfterAddingLiquidity 507 | .poolManagerFullRangeToken0Balance - 508 | contractStateBeforeAddingLiquidity 509 | .poolManagerFullRangeToken0Balance + 510 | contractStateAfterAddingLiquidity.poolInfo.token0Balance - 511 | contractStateBeforeAddingLiquidity.poolInfo.token0Balance, 512 | contractStateBeforeAddingLiquidity.userToken0Balance - 513 | contractStateAfterAddingLiquidity.userToken0Balance, 514 | 1e2, 515 | "userToken0Balance mismatch" 516 | ); 517 | assertApproxEqRel( 518 | contractStateAfterAddingLiquidity 519 | .poolManagerNarrowRangeToken1Balance - 520 | contractStateBeforeAddingLiquidity 521 | .poolManagerNarrowRangeToken1Balance + 522 | contractStateAfterAddingLiquidity 523 | .poolManagerFullRangeToken1Balance - 524 | contractStateBeforeAddingLiquidity 525 | .poolManagerFullRangeToken1Balance + 526 | contractStateAfterAddingLiquidity.poolInfo.token1Balance - 527 | contractStateBeforeAddingLiquidity.poolInfo.token1Balance, 528 | contractStateBeforeAddingLiquidity.userToken1Balance - 529 | contractStateAfterAddingLiquidity.userToken1Balance, 530 | 1e2, 531 | "userToken1Balance mismatch" 532 | ); 533 | } 534 | 535 | function testLiquidityManager_testWithdrawalWithNonZeroContractStoredTokenBalances() 536 | public 537 | { 538 | // swapping to accumulate fees 539 | swapExactTokensForTokens(poolKey, true, 0.0005 ether); 540 | swapExactTokensForTokens(poolKey, false, 0.0005 ether); 541 | 542 | // add zero liquidity to trigger fee collection 543 | liquidityManager.addLiquidity( 544 | AddLiquidityParams({ 545 | currency0: poolKey.currency0, 546 | currency1: poolKey.currency1, 547 | fee: poolKey.fee, 548 | vaultTokenAmount: 0 ether, 549 | to: address(this), 550 | deadline: MAX_DEADLINE 551 | }) 552 | ); 553 | 554 | // adding more liquidity 555 | ContractState 556 | memory contractStateBeforeAddingLiquidity = getContractState( 557 | address(this) 558 | ); 559 | liquidityManager.removeLiquidity( 560 | RemoveLiquidityParams({ 561 | currency0: poolKey.currency0, 562 | currency1: poolKey.currency1, 563 | fee: poolKey.fee, 564 | vaultTokenAmount: 0.3 ether, 565 | deadline: MAX_DEADLINE 566 | }) 567 | ); 568 | ContractState 569 | memory contractStateAfterAddingLiquidity = getContractState( 570 | address(this) 571 | ); 572 | 573 | // liquidity manager updated its uninvested token balances 574 | assertApproxEqRel( 575 | contractStateAfterAddingLiquidity.poolInfo.token0Balance, 576 | (contractStateBeforeAddingLiquidity.poolInfo.token0Balance * 577 | contractStateAfterAddingLiquidity.vaultTokenSupply) / 578 | contractStateBeforeAddingLiquidity.vaultTokenSupply, 579 | 1e16, 580 | "token0Balance mismatch" 581 | ); 582 | assertApproxEqRel( 583 | contractStateAfterAddingLiquidity.poolInfo.token1Balance, 584 | (contractStateBeforeAddingLiquidity.poolInfo.token1Balance * 585 | contractStateAfterAddingLiquidity.vaultTokenSupply) / 586 | contractStateBeforeAddingLiquidity.vaultTokenSupply, 587 | 1e16, 588 | "token1Balance mismatch" 589 | ); 590 | 591 | // user transferred the right amount of tokens 592 | assertApproxEqRel( 593 | contractStateBeforeAddingLiquidity 594 | .poolManagerNarrowRangeToken0Balance - 595 | contractStateAfterAddingLiquidity 596 | .poolManagerNarrowRangeToken0Balance + 597 | contractStateBeforeAddingLiquidity 598 | .poolManagerFullRangeToken0Balance - 599 | contractStateAfterAddingLiquidity 600 | .poolManagerFullRangeToken0Balance + 601 | contractStateBeforeAddingLiquidity.poolInfo.token0Balance - 602 | contractStateAfterAddingLiquidity.poolInfo.token0Balance, 603 | contractStateAfterAddingLiquidity.userToken0Balance - 604 | contractStateBeforeAddingLiquidity.userToken0Balance, 605 | 1e2, 606 | "userToken0Balance mismatch" 607 | ); 608 | assertApproxEqRel( 609 | contractStateBeforeAddingLiquidity 610 | .poolManagerNarrowRangeToken1Balance - 611 | contractStateAfterAddingLiquidity 612 | .poolManagerNarrowRangeToken1Balance + 613 | contractStateBeforeAddingLiquidity 614 | .poolManagerFullRangeToken1Balance - 615 | contractStateAfterAddingLiquidity 616 | .poolManagerFullRangeToken1Balance + 617 | contractStateBeforeAddingLiquidity.poolInfo.token1Balance - 618 | contractStateAfterAddingLiquidity.poolInfo.token1Balance, 619 | contractStateAfterAddingLiquidity.userToken1Balance - 620 | contractStateBeforeAddingLiquidity.userToken1Balance, 621 | 1e2, 622 | "userToken1Balance mismatch" 623 | ); 624 | } 625 | } 626 | -------------------------------------------------------------------------------- /test/VolumeFee.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import {VolumeFee, FEE_INCREASE_TOKEN1_UNIT, FEE_DECREASE_TIME_UNIT, MAXIMUM_FEE} from "../src/VolumeFee.sol"; 5 | 6 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 7 | import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; 8 | import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 9 | import {FeeLibrary} from "@uniswap/v4-core/src/libraries/FeeLibrary.sol"; 10 | import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; 11 | import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; 12 | import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; 13 | import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; 14 | import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; 15 | import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; 16 | import {FixedPoint96} from "@uniswap/v4-core/src/libraries/FixedPoint96.sol"; 17 | import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; 18 | 19 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 20 | import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; 21 | 22 | import {Test, console, console2} from "forge-std/Test.sol"; 23 | import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; 24 | 25 | contract VolumeFeeTest is Test, Deployers, GasSnapshot { 26 | using CurrencyLibrary for Currency; 27 | using SafeCast for uint256; 28 | 29 | VolumeFee volumeFee = 30 | VolumeFee( 31 | address( 32 | uint160( 33 | Hooks.BEFORE_INITIALIZE_FLAG | 34 | Hooks.BEFORE_SWAP_FLAG | 35 | Hooks.AFTER_SWAP_FLAG 36 | ) 37 | ) 38 | ); 39 | 40 | using PoolIdLibrary for PoolKey; 41 | 42 | MockERC20 token0; 43 | MockERC20 token1; 44 | 45 | PoolKey poolKey; 46 | PoolId poolId; 47 | 48 | PoolKey gasComparisonPoolKey; 49 | PoolId gasComparisonPoolId; 50 | 51 | uint256 constant MAX_DEADLINE = 12329839823; 52 | uint256 constant INITIAL_BLOCK_TIMESTAMP = 100; 53 | uint24 constant FEE_INCREASE_PER_TOKEN1_UNIT = 5; 54 | uint24 constant FEE_DECREASE_PER_TIME_UNIT = 30; 55 | uint24 constant INITIAL_FEE = 2000; 56 | 57 | function setUp() public { 58 | vm.warp(INITIAL_BLOCK_TIMESTAMP); 59 | 60 | deployFreshManagerAndRouters(); 61 | deployCodeTo( 62 | "VolumeFee.sol", 63 | abi.encode(manager, address(this)), 64 | address(volumeFee) 65 | ); 66 | (currency0, currency1) = deployMintAndApprove2Currencies(); 67 | 68 | token0 = MockERC20(Currency.unwrap(currency0)); 69 | token1 = MockERC20(Currency.unwrap(currency1)); 70 | 71 | // create a pool with VolumeFee hook 72 | (poolKey, poolId) = initPool( 73 | currency0, 74 | currency1, 75 | IHooks(volumeFee), 76 | FeeLibrary.DYNAMIC_FEE_FLAG, 77 | SQRT_RATIO_1_2, 78 | abi.encode( 79 | VolumeFee.InitParams( 80 | FEE_INCREASE_PER_TOKEN1_UNIT, 81 | FEE_DECREASE_PER_TIME_UNIT, 82 | INITIAL_FEE 83 | ) 84 | ) 85 | ); 86 | 87 | // providing liquidity for the pool with VolumeFee hook 88 | modifyLiquidityRouter.modifyLiquidity( 89 | poolKey, 90 | IPoolManager.ModifyLiquidityParams( 91 | TickMath.minUsableTick(60), 92 | TickMath.maxUsableTick(60), 93 | 500000 ether 94 | ), 95 | ZERO_BYTES 96 | ); 97 | 98 | // create a pool without any hooks for gas comparison 99 | (gasComparisonPoolKey, gasComparisonPoolId) = initPool( 100 | currency0, 101 | currency1, 102 | IHooks(address(0)), 103 | 3000, 104 | SQRT_RATIO_1_2, 105 | ZERO_BYTES 106 | ); 107 | 108 | // providing liquidity for the pool without any hooks 109 | modifyLiquidityRouter.modifyLiquidity( 110 | gasComparisonPoolKey, 111 | IPoolManager.ModifyLiquidityParams( 112 | TickMath.minUsableTick(60), 113 | TickMath.maxUsableTick(60), 114 | 500000 ether 115 | ), 116 | ZERO_BYTES 117 | ); 118 | } 119 | 120 | struct ContractState { 121 | uint256 token1SoFar; 122 | uint256 lastFeeDecreaseTime; 123 | uint24 currentFee; 124 | } 125 | 126 | function getContractState() 127 | internal 128 | view 129 | returns (ContractState memory contractState) 130 | { 131 | ( 132 | , 133 | , 134 | contractState.token1SoFar, 135 | contractState.lastFeeDecreaseTime, 136 | contractState.currentFee 137 | ) = volumeFee.poolInfos(poolId); 138 | } 139 | 140 | function swapExactTokensForTokens( 141 | PoolKey memory poolKey_, 142 | bool zeroForOne, 143 | int256 exactAmountIn, 144 | string memory testName 145 | ) private { 146 | snapStart(testName); 147 | swapRouter.swap( 148 | poolKey_, 149 | IPoolManager.SwapParams( 150 | zeroForOne, 151 | exactAmountIn, 152 | (zeroForOne) 153 | ? TickMath.MIN_SQRT_RATIO + 1 154 | : TickMath.MAX_SQRT_RATIO - 1 155 | ), 156 | PoolSwapTest.TestSettings(true, true, false), 157 | ZERO_BYTES 158 | ); 159 | snapEnd(); 160 | } 161 | 162 | function swapExactTokensForTokensUpToPricePoint( 163 | bool zeroForOne, 164 | int256 exactAmountIn, 165 | uint160 sqrtPriceLimitX96, 166 | string memory testName 167 | ) private { 168 | snapStart(testName); 169 | swapRouter.swap( 170 | poolKey, 171 | IPoolManager.SwapParams( 172 | zeroForOne, 173 | exactAmountIn, 174 | sqrtPriceLimitX96 175 | ), 176 | PoolSwapTest.TestSettings(true, true, false), 177 | ZERO_BYTES 178 | ); 179 | snapEnd(); 180 | } 181 | 182 | function checkState( 183 | ContractState memory expectedContractState, 184 | string memory message 185 | ) private { 186 | ContractState memory actualContractState = getContractState(); 187 | 188 | assertEq( 189 | actualContractState.token1SoFar, 190 | expectedContractState.token1SoFar, 191 | string.concat("token1SoFarMismatch", message) 192 | ); 193 | assertEq( 194 | actualContractState.lastFeeDecreaseTime, 195 | expectedContractState.lastFeeDecreaseTime, 196 | string.concat("lastFeeDecreaseTime", message) 197 | ); 198 | assertEq( 199 | actualContractState.currentFee, 200 | expectedContractState.currentFee, 201 | string.concat("currentFee", message) 202 | ); 203 | } 204 | 205 | function testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0() public { 206 | uint256 amountToSwap = 2 ether; 207 | swapExactTokensForTokens( 208 | poolKey, 209 | true, 210 | int256(amountToSwap), 211 | "testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0" 212 | ); 213 | checkState( 214 | ContractState({ 215 | token1SoFar: FEE_INCREASE_TOKEN1_UNIT - 1, 216 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 217 | currentFee: INITIAL_FEE + 218 | uint24( 219 | ((amountToSwap / 2 - 1) / FEE_INCREASE_TOKEN1_UNIT) * 220 | FEE_INCREASE_PER_TOKEN1_UNIT 221 | ) 222 | }), 223 | "" 224 | ); 225 | } 226 | 227 | function testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1() public { 228 | uint256 amountToSwap = 1 ether; 229 | swapExactTokensForTokens( 230 | poolKey, 231 | false, 232 | int256(amountToSwap), 233 | "testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1" 234 | ); 235 | checkState( 236 | ContractState({ 237 | token1SoFar: 0, 238 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 239 | currentFee: INITIAL_FEE + 240 | uint24( 241 | (FEE_INCREASE_PER_TOKEN1_UNIT * amountToSwap) / 242 | FEE_INCREASE_TOKEN1_UNIT 243 | ) 244 | }), 245 | "" 246 | ); 247 | } 248 | 249 | // attacker tries to manipulate volume by only swapping up to next price point 250 | function testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0WithVolumeManipulation() 251 | public 252 | { 253 | uint256 amountToSwap = 2 ether; 254 | 255 | vm.expectRevert(VolumeFee.SWAP_AMOUNT_MISMATCH_ERROR.selector); 256 | swapExactTokensForTokensUpToPricePoint( 257 | true, 258 | int256(amountToSwap), 259 | (FixedPointMathLib.sqrt(FixedPoint96.Q96 ** 2 / 2) - 1).toUint160(), 260 | "testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken0WithVolumeManipulation" 261 | ); 262 | checkState( 263 | ContractState({ 264 | token1SoFar: 0, 265 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 266 | currentFee: INITIAL_FEE 267 | }), 268 | "" 269 | ); 270 | } 271 | 272 | // attacker tries to manipulate volume by only swapping up to next price point 273 | function testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1WithVolumeManipulation() 274 | public 275 | { 276 | uint256 amountToSwap = 1 ether; 277 | 278 | vm.expectRevert(VolumeFee.SWAP_AMOUNT_MISMATCH_ERROR.selector); 279 | swapExactTokensForTokensUpToPricePoint( 280 | false, 281 | int256(amountToSwap), 282 | (FixedPointMathLib.sqrt(FixedPoint96.Q96 ** 2 / 2) + 1).toUint160(), 283 | "testVolumeFee_feeIncreaseTriggeredImmediatelySwapToken1WithVolumeManipulation" 284 | ); 285 | checkState( 286 | ContractState({ 287 | token1SoFar: 0, 288 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 289 | currentFee: INITIAL_FEE 290 | }), 291 | "" 292 | ); 293 | } 294 | 295 | function testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparison() 296 | public 297 | { 298 | uint256 amountToSwap = 0.36 ether; 299 | swapExactTokensForTokens( 300 | gasComparisonPoolKey, 301 | true, 302 | int256(amountToSwap), 303 | "testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparisonFirst" 304 | ); 305 | 306 | uint256 amountToSwapSecondTime = 0.08 ether; 307 | swapExactTokensForTokens( 308 | gasComparisonPoolKey, 309 | true, 310 | int256(amountToSwapSecondTime), 311 | "testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0GasComparisonSecond" 312 | ); 313 | } 314 | 315 | function testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0() public { 316 | uint256 amountToSwap = 0.36 ether; 317 | swapExactTokensForTokens( 318 | poolKey, 319 | true, 320 | int256(amountToSwap), 321 | "testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0First" 322 | ); 323 | checkState( 324 | ContractState({ 325 | token1SoFar: amountToSwap / 2 - 1, 326 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 327 | currentFee: INITIAL_FEE 328 | }), 329 | "" 330 | ); 331 | 332 | uint256 amountToSwapSecondTime = 0.08 ether; 333 | swapExactTokensForTokens( 334 | poolKey, 335 | true, 336 | int256(amountToSwapSecondTime), 337 | "testVolumeFee_feeIncreaseTriggeredForSecondSwapToken0Second" 338 | ); 339 | checkState( 340 | ContractState({ 341 | token1SoFar: 9999959352139083, 342 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 343 | currentFee: INITIAL_FEE + 344 | uint24( 345 | (((amountToSwap + amountToSwapSecondTime) / 2 - 1) / 346 | FEE_INCREASE_TOKEN1_UNIT) * 347 | FEE_INCREASE_PER_TOKEN1_UNIT 348 | ) 349 | }), 350 | "" 351 | ); 352 | } 353 | 354 | function testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1() public { 355 | // increases the fee by 90bps 356 | uint256 amountToSwap = 0.18 ether; 357 | swapExactTokensForTokens( 358 | poolKey, 359 | false, 360 | int256(amountToSwap), 361 | "testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1Frist" 362 | ); 363 | checkState( 364 | ContractState({ 365 | token1SoFar: amountToSwap, 366 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 367 | currentFee: INITIAL_FEE 368 | }), 369 | "" 370 | ); 371 | 372 | uint256 amountToSwapSecondTime = 0.03 ether; 373 | swapExactTokensForTokens( 374 | poolKey, 375 | false, 376 | int256(amountToSwapSecondTime), 377 | "testVolumeFee_feeIncreaseTriggeredForSecondSwapToken1Second" 378 | ); 379 | checkState( 380 | ContractState({ 381 | token1SoFar: 0, 382 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 383 | currentFee: INITIAL_FEE + 384 | uint24( 385 | ((amountToSwap + amountToSwapSecondTime) / 386 | FEE_INCREASE_TOKEN1_UNIT) * 387 | FEE_INCREASE_PER_TOKEN1_UNIT 388 | ) 389 | }), 390 | "" 391 | ); 392 | } 393 | 394 | function testVolumeFee_feeIncreaseTriggeredByTime() public { 395 | uint256 units_to_decrease_by = 4; 396 | uint256 spillover_time = 5; 397 | uint256 WARP_TIME = INITIAL_BLOCK_TIMESTAMP + 398 | FEE_DECREASE_TIME_UNIT * 399 | units_to_decrease_by + 400 | spillover_time; 401 | 402 | vm.warp(WARP_TIME); 403 | 404 | uint256 amountToSwap = 100 wei; 405 | swapExactTokensForTokens( 406 | poolKey, 407 | false, 408 | int256(amountToSwap), 409 | "testVolumeFee_feeIncreaseTriggeredByTime" 410 | ); 411 | checkState( 412 | ContractState({ 413 | token1SoFar: amountToSwap, 414 | lastFeeDecreaseTime: WARP_TIME - spillover_time, 415 | currentFee: uint24( 416 | INITIAL_FEE - 417 | ((WARP_TIME - INITIAL_BLOCK_TIMESTAMP) / 418 | FEE_DECREASE_TIME_UNIT) * 419 | FEE_DECREASE_PER_TIME_UNIT 420 | ) 421 | }), 422 | "" 423 | ); 424 | } 425 | 426 | function testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwap() public { 427 | uint256 units_to_decrease_by1 = 3; 428 | uint256 spillover_time1 = 5; 429 | uint256 WARP_TIME1 = INITIAL_BLOCK_TIMESTAMP + 430 | FEE_DECREASE_TIME_UNIT * 431 | units_to_decrease_by1 + 432 | spillover_time1; 433 | 434 | uint256 units_to_decrease_by2 = 2; 435 | uint256 spillover_time2 = 6; 436 | uint256 WARP_TIME2 = WARP_TIME1 + 437 | FEE_DECREASE_TIME_UNIT * 438 | units_to_decrease_by2 + 439 | spillover_time2; 440 | 441 | vm.warp(WARP_TIME1); 442 | 443 | uint256 amountToSwap = 100 wei; 444 | swapExactTokensForTokens( 445 | poolKey, 446 | false, 447 | int256(amountToSwap), 448 | "testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwapFirst" 449 | ); 450 | checkState( 451 | ContractState({ 452 | token1SoFar: amountToSwap, 453 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 454 | currentFee: INITIAL_FEE 455 | }), 456 | "" 457 | ); 458 | 459 | vm.warp(WARP_TIME2); 460 | 461 | swapExactTokensForTokens( 462 | poolKey, 463 | false, 464 | int256(amountToSwap), 465 | "testVolumeFee_feeIncreaseTriggeredByTimeForSecondSwapSecond" 466 | ); 467 | checkState( 468 | ContractState({ 469 | token1SoFar: amountToSwap * 2, 470 | lastFeeDecreaseTime: WARP_TIME2 - 471 | spillover_time1 - 472 | spillover_time2, 473 | currentFee: uint24( 474 | INITIAL_FEE - 475 | ((WARP_TIME2 - INITIAL_BLOCK_TIMESTAMP) / 476 | FEE_DECREASE_TIME_UNIT) * 477 | FEE_DECREASE_PER_TIME_UNIT 478 | ) 479 | }), 480 | "" 481 | ); 482 | } 483 | 484 | function testVolumeFee_feeIncreaseMaximumFeeReached() public { 485 | uint256 amountToSwap = (MAXIMUM_FEE * 1e18) * 2; 486 | swapExactTokensForTokens( 487 | poolKey, 488 | false, 489 | int256(amountToSwap), 490 | "testVolumeFee_feeIncreaseMaximumFeeReached" 491 | ); 492 | checkState( 493 | ContractState({ 494 | token1SoFar: 0, 495 | lastFeeDecreaseTime: INITIAL_BLOCK_TIMESTAMP, 496 | currentFee: uint24(MAXIMUM_FEE) 497 | }), 498 | "" 499 | ); 500 | } 501 | 502 | function testVolumeFee_feeIncreaseAndDecreaseAtSameSwap() public { 503 | uint256 units_to_decrease_by = 10; 504 | uint256 spillover_time = 5; 505 | uint256 WARP_TIME = INITIAL_BLOCK_TIMESTAMP + 506 | FEE_DECREASE_TIME_UNIT * 507 | units_to_decrease_by + 508 | spillover_time; 509 | 510 | vm.warp(WARP_TIME); 511 | 512 | uint256 amountToSwap = 0.2 ether; 513 | swapExactTokensForTokens( 514 | poolKey, 515 | false, 516 | int256(amountToSwap), 517 | "testVolumeFee_feeIncreaseAndDecreaseAtSameSwap" 518 | ); 519 | checkState( 520 | ContractState({ 521 | token1SoFar: amountToSwap - 522 | (amountToSwap / FEE_INCREASE_TOKEN1_UNIT) * 523 | FEE_INCREASE_TOKEN1_UNIT, 524 | lastFeeDecreaseTime: WARP_TIME - spillover_time, 525 | currentFee: uint24( 526 | INITIAL_FEE - 527 | (((WARP_TIME - INITIAL_BLOCK_TIMESTAMP) / 528 | FEE_DECREASE_TIME_UNIT) * 529 | FEE_DECREASE_PER_TIME_UNIT) + 530 | ((amountToSwap) / FEE_INCREASE_TOKEN1_UNIT) * 531 | FEE_INCREASE_PER_TOKEN1_UNIT 532 | ) 533 | }), 534 | "" 535 | ); 536 | } 537 | } 538 | --------------------------------------------------------------------------------