├── image.png ├── assets ├── UI.jpg ├── Slides.pdf ├── diagram.png ├── slide_preview.jpg └── unicast_math.md ├── .gitignore ├── script └── Counter.s.sol ├── .gitmodules ├── foundry.toml ├── .github └── workflows │ └── test.yml ├── remappings.txt ├── test ├── shared │ └── UniCastImplementation.sol ├── e2e.t.sol └── UniCast.t.sol ├── src ├── UniCastVolitilityFee.sol ├── interface │ └── IUniCastOracle.sol ├── UniCastOracle.sol ├── UniCastHook.sol ├── util │ └── StateLibrary.sol └── UniCastVault.sol └── README.md /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goliao/UniCast/HEAD/image.png -------------------------------------------------------------------------------- /assets/UI.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goliao/UniCast/HEAD/assets/UI.jpg -------------------------------------------------------------------------------- /assets/Slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goliao/UniCast/HEAD/assets/Slides.pdf -------------------------------------------------------------------------------- /assets/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goliao/UniCast/HEAD/assets/diagram.png -------------------------------------------------------------------------------- /assets/slide_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goliao/UniCast/HEAD/assets/slide_preview.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | -------------------------------------------------------------------------------- /script/Counter.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import {Script, console} from "forge-std/Script.sol"; 5 | 6 | contract CounterScript is Script { 7 | function setUp() public {} 8 | 9 | function run() public { 10 | vm.broadcast(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/v4-periphery"] 5 | path = lib/v4-periphery 6 | url = https://github.com/Uniswap/v4-periphery 7 | [submodule "lib/v4-core"] 8 | path = lib/v4-core 9 | url = https://github.com/Uniswap/v4-core 10 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | # foundry.toml 8 | 9 | solc_version = '0.8.25' 10 | evm_version = "cancun" 11 | optimizer_runs = 800 12 | via_ir = false 13 | ffi = true -------------------------------------------------------------------------------- /.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@v4 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 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @ensdomains/=lib/v4-periphery/lib/v4-core/node_modules/@ensdomains/ 2 | @openzeppelin/=lib/v4-periphery/lib/openzeppelin-contracts/ 3 | @uniswap/v4-core/=lib/v4-periphery/lib/v4-core/ 4 | ds-test/=lib/v4-periphery/lib/forge-std/lib/ds-test/src/ 5 | erc4626-tests/=lib/v4-periphery/lib/openzeppelin-contracts/lib/erc4626-tests/ 6 | forge-gas-snapshot/=lib/v4-periphery/lib/forge-gas-snapshot/src/ 7 | forge-std/=lib/forge-std/src/ 8 | hardhat/=lib/v4-periphery/lib/v4-core/node_modules/hardhat/ 9 | openzeppelin-contracts/=lib/v4-periphery/lib/openzeppelin-contracts/ 10 | openzeppelin/=lib/v4-periphery/lib/openzeppelin-contracts/contracts/ 11 | solmate/=lib/v4-periphery/lib/solmate/src/ 12 | v4-core/=lib/v4-periphery/lib/v4-core/src/ 13 | v4-periphery/=lib/v4-periphery/contracts/ 14 | -------------------------------------------------------------------------------- /test/shared/UniCastImplementation.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.19; 3 | 4 | import {BaseHook} from "v4-periphery/BaseHook.sol"; 5 | import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; 6 | import {Hooks, IHooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; 7 | import {UniCastHook} from "../../src/UniCastHook.sol"; 8 | import {IUniCastOracle} from "../../src/interface/IUniCastOracle.sol"; 9 | 10 | contract UniCastImplementation is UniCastHook { 11 | constructor( 12 | IPoolManager _poolManager, 13 | IUniCastOracle _oracle, 14 | UniCastHook addressToEtch, 15 | int24 initialMinTick, 16 | int24 initialMaxTick 17 | ) UniCastHook(_poolManager, _oracle, initialMinTick, initialMaxTick) { 18 | Hooks.validateHookPermissions(addressToEtch, getHookPermissions()); 19 | } 20 | 21 | // make this a no-op in testing 22 | function validateHookAddress(BaseHook _this) internal pure override {} 23 | } 24 | -------------------------------------------------------------------------------- /src/UniCastVolitilityFee.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | 4 | import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; 5 | import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; 6 | import {IUniCastOracle} from "./interface/IUniCastOracle.sol"; 7 | import {PoolId} from "v4-core/types/PoolId.sol"; 8 | 9 | abstract contract UniCastVolitilityFee { 10 | using LPFeeLibrary for uint24; 11 | 12 | event VolEvent(uint256 value); 13 | 14 | error MustUseDynamicFee(); 15 | 16 | IUniCastOracle public volitilityOracle; 17 | IPoolManager public poolManagerFee; 18 | 19 | // The default base fees we will charge 20 | uint24 public constant BASE_FEE = 500; // 0.05% 21 | 22 | /** 23 | * @dev Constructor for the UniCastVolitilityFee contract. 24 | * @param _poolManager The address of the pool manager. 25 | * @param _oracle The address of the volatility oracle. 26 | */ 27 | constructor(IPoolManager _poolManager, IUniCastOracle _oracle) { 28 | poolManagerFee = _poolManager; 29 | volitilityOracle = _oracle; 30 | } 31 | 32 | /** 33 | * @dev Returns the address of the volatility oracle. 34 | * @return The address of the volatility oracle. 35 | */ 36 | function getVolatilityOracle() external view returns (address) { 37 | return address(volitilityOracle); 38 | } 39 | 40 | /** 41 | * @dev Calculates and returns the fee based on the current volatility. 42 | * @return The calculated fee as a uint24. 43 | */ 44 | function getFee(PoolId _poolId) public view returns (uint24) { 45 | return volitilityOracle.getFee(_poolId); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/interface/IUniCastOracle.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | import {PoolId} from "v4-core/types/PoolId.sol"; 4 | 5 | /** 6 | * @dev Struct to hold liquidity data. 7 | * @param tickLower The lower tick boundary. 8 | * @param tickUpper The upper tick boundary. 9 | */ 10 | struct LiquidityData { 11 | int24 tickLower; 12 | int24 tickUpper; 13 | } 14 | 15 | /** 16 | * @dev Interface for the UniCast Oracle. 17 | */ 18 | interface IUniCastOracle { 19 | /** 20 | * @dev Gets the current fee. 21 | * @param _poolId ID of the pool 22 | * @return The current fee as a uint24. 23 | */ 24 | function getFee(PoolId _poolId) external view returns (uint24); 25 | 26 | /** 27 | * @dev Gets the liquidity data for a given pool. 28 | * @param _poolId The ID of the pool. 29 | * @return The liquidity data of the pool. 30 | */ 31 | function getLiquidityData(PoolId _poolId) external view returns (LiquidityData memory); 32 | 33 | /** 34 | * @dev Sets the liquidity data for a given pool. 35 | * @param _poolId The ID of the pool. 36 | * @param _tickLower The lower tick boundary. 37 | * @param _tickUpper The upper tick boundary. 38 | */ 39 | function setLiquidityData(PoolId _poolId, int24 _tickLower, int24 _tickUpper) external; 40 | 41 | /** 42 | * @dev Sets the fee for a pool. 43 | * @param _poolId ID of the pool. 44 | * @param _fee The new fee. 45 | */ 46 | function setFee(PoolId _poolId, uint24 _fee) external; 47 | 48 | /** 49 | * @dev Updates the keeper address. 50 | * @param _newKeeper The address of the new keeper. 51 | */ 52 | function updateKeeper(address _newKeeper) external; 53 | } -------------------------------------------------------------------------------- /src/UniCastOracle.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {PoolId} from "v4-core/types/PoolId.sol"; 6 | import {IUniCastOracle, LiquidityData} from "./interface/IUniCastOracle.sol"; 7 | 8 | contract UniCastOracle is Ownable, IUniCastOracle { 9 | uint24 immutable public baseFee; // 500 == 0.05% 10 | address public keeper; 11 | 12 | event KeeperUpdated(address indexed newKeeper); 13 | event FeeChanged(PoolId poolId, uint256 fee); 14 | event LiquidityChanged(PoolId poolId, LiquidityData); 15 | 16 | error Unauthorized(); 17 | 18 | mapping (PoolId => int24) public feeAdditional; // could be less than base fee 19 | 20 | // right now, each pool's LiquidityData is initialized by keeper right after the pool is created, but 21 | // in the future, the hook can be granted access to initialize the liquidityData 22 | // in the afterInitialize hook. 23 | mapping (PoolId => LiquidityData) public liquidityData; 24 | 25 | modifier onlyKeeper() { 26 | if (msg.sender != keeper) revert Unauthorized(); 27 | _; 28 | } 29 | 30 | /** 31 | * @dev Constructor to initialize the UniCastOracle contract. 32 | * @param _keeper The address of the initial keeper. 33 | */ 34 | constructor(address _keeper, uint24 _baseFee) Ownable(_keeper) { 35 | keeper = _keeper; 36 | baseFee = _baseFee; 37 | } 38 | 39 | /** 40 | * @dev Sets the liquidity data for a given pool. 41 | * @param _poolId The ID of the pool. 42 | * @param _tickLower The lower tick boundary. 43 | * @param _tickUpper The upper tick boundary. 44 | */ 45 | function setLiquidityData(PoolId _poolId, int24 _tickLower, int24 _tickUpper) external onlyKeeper { 46 | liquidityData[_poolId] = LiquidityData({ 47 | tickLower: _tickLower, 48 | tickUpper: _tickUpper 49 | }); 50 | emit LiquidityChanged(_poolId, liquidityData[_poolId]); 51 | } 52 | 53 | /** 54 | * @dev Gets the liquidity data for a given pool. 55 | * @param _poolId The ID of the pool. 56 | * @return The liquidity data of the pool. 57 | */ 58 | function getLiquidityData(PoolId _poolId) external view returns (LiquidityData memory) { 59 | return liquidityData[_poolId]; 60 | } 61 | 62 | /** 63 | * This fee can be set as part of a dutch auction. 64 | * @dev Sets the implied volatility. 65 | * @param _fee The new implied volatility. 66 | */ 67 | function setFee(PoolId _poolId, uint24 _fee) external onlyKeeper { 68 | feeAdditional[_poolId] = int24(_fee) - int24(baseFee); 69 | emit FeeChanged(_poolId, _fee); 70 | } 71 | 72 | /** 73 | * @dev Updates the keeper address. 74 | * @param _newKeeper The address of the new keeper. 75 | */ 76 | function updateKeeper(address _newKeeper) external onlyKeeper { 77 | keeper = _newKeeper; 78 | emit KeeperUpdated(_newKeeper); 79 | } 80 | 81 | /** 82 | * @dev Gets the current implied volatility. 83 | * @return The current implied volatility. 84 | */ 85 | function getFee(PoolId poolId) external view override returns (uint24) { 86 | return uint24(feeAdditional[poolId] + int24(baseFee)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UniCast 2 | 3 | UniCast is a forward-looking dynamic Uniswap v4 hook that applied event-based and market-implied volatility to adjust LPfees and LP positions. 4 | 5 | 6 | ### Context: 7 | Volatilities rise predictably during expected events like CPI releases, Fed rate announcements, SEC ETF approval dates, and TradFi “off-market” hours. 8 | Price changes are predictable and LP “impermanent losses” are permanent for tokens with coupon payments, such as liquid staking rebasing, bond coupons, and equity dividends. 9 | 10 | ### Problem: 11 | Arbitrageurs capture all expected price and volatility changes at the expense of LPs. 12 | These predictable arbitrages harm liquidity, lead to MEV leaks, and deter swappers due to poor liquidity 13 | 14 | ### Solution: 15 | UniCast: A hook that adjusts LP fees and positions by incorporating: 16 | Forward-looking volatility to enable dynamic fees and shift value capture from arbs to LPs 17 | Forward-looking price changes to rebalance LP positions 18 | 19 | 20 | **Features**: 21 | - Improving LP return using forward-looking events and expected price dynamics rebalancing/fee. 22 | - Reduce informed trading (and MEV in the dex context) during known events is something that all tradfi market makers do, and this hook bring this tradfi practice to on-chain dex 23 | - Anticipate and preposition toward future events and expected pricing dynamics: 24 | 1) Economic news release schedule, e.g. CPI, NFPR, Fed interest rate decisions 1b) Crypto-specific events, e.g. ETF approval announcement, policy votes 25 | 2) Forward-looking volatility implied by options market (Deribit, Panoptics, Opyn) 26 | 3) Yield-bearing assets rebalancing, e.g. StETH/ETH pool, USDY/USDC pool 27 | 28 | **Details** 29 | [![See Slides](assets/slide_preview.jpg)](https://docs.google.com/presentation/d/1fNhfOWnzQpHvRUWAvRYE1kagD5t1hFjfn-abNbr1vew/edit?usp=sharing) ([Slides in pdf](assets/Slides.pdf)) 30 | 31 | 32 | ## Technical notes 33 | ### Contract architecture 34 | ![Implementation](assets/diagram.png) 35 | ### Call diagram for swap 36 | ![alt text](image.png) 37 | ### Notes 38 | 1. During rebalance, the vault needs to modify its token0:token1 ratio to fit the new range. Currently, the math behind the rebalancing (determining the new liquidity value of the position after rebalancing to the new range) makes the assumption that the price in the pool will stay the same throughout the rebalance (see [/assets/unicast_math.md](https://github.com/goliao/UniCast/blob/main/assets/unicast_math.md)). In the implementation however, the vault swaps with the pool itself during rebalancing, so this assumption is most likely untrue unless there is a tremendous amount of liquidity in the pool already coming from other LPs outside of the vault. 39 | To make this assumption true, the vault could instead swap with a different pool or on-chain DEX just for the rebalancing itself. Or, the math could be reworked to not require this assumption. 40 | 2. In practice, the rebalancing does not need to happen as part of the afterSwap hook; it doesn't need to really be a hook at all. It would live on a separate vault contract, and the rebalance can simply be triggered periodically directly, not needing to check the oracle first. This was simply implemented as a hook here to show how it could be done, and also because this was built for the Uniswap Hookathon. 41 | 42 | 43 | **UI** 44 | ![UI](assets/UI.jpg) 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/UniCastHook.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | 4 | import {BaseHook} from "v4-periphery/BaseHook.sol"; 5 | import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; 6 | import {Hooks} from "v4-core/libraries/Hooks.sol"; 7 | import {PoolKey} from "v4-core/types/PoolKey.sol"; 8 | import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; 9 | import {UniCastVolitilityFee} from "./UniCastVolitilityFee.sol"; 10 | import {UniCastVault} from "./UniCastVault.sol"; 11 | import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; 12 | import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; 13 | import {IUniCastOracle} from "./interface/IUniCastOracle.sol"; 14 | import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; 15 | import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; 16 | import {CurrencyLibrary, Currency} from "v4-core/types/Currency.sol"; 17 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 18 | import {UniswapV4ERC20} from "v4-periphery/libraries/UniswapV4ERC20.sol"; 19 | import {IHooks} from "v4-core/interfaces/IHooks.sol"; 20 | import {StateLibrary} from "./util/StateLibrary.sol"; 21 | 22 | contract UniCastHook is UniCastVolitilityFee, UniCastVault, BaseHook { 23 | using LPFeeLibrary for uint24; 24 | using PoolIdLibrary for PoolKey; 25 | using StateLibrary for IPoolManager; 26 | 27 | /** 28 | * @dev Constructor for the UniCastHook contract. 29 | * @param _poolManager The address of the pool manager. 30 | * @param _oracle The address of the oracle. 31 | */ 32 | constructor( 33 | IPoolManager _poolManager, 34 | IUniCastOracle _oracle, 35 | int24 initialMinTick, 36 | int24 initialMaxTick 37 | ) 38 | UniCastVault(_poolManager, _oracle, initialMinTick, initialMaxTick) 39 | UniCastVolitilityFee(_poolManager, _oracle) 40 | BaseHook(_poolManager) 41 | {} 42 | 43 | /** 44 | * @dev Returns the permissions for the hook. 45 | * @return A Hooks.Permissions struct with the permissions. 46 | */ 47 | function getHookPermissions() 48 | public 49 | pure 50 | override 51 | returns (Hooks.Permissions memory) 52 | { 53 | return 54 | Hooks.Permissions({ 55 | beforeInitialize: true, 56 | afterInitialize: false, 57 | beforeAddLiquidity: false, 58 | beforeRemoveLiquidity: false, 59 | afterAddLiquidity: false, 60 | afterRemoveLiquidity: false, 61 | beforeSwap: true, 62 | afterSwap: true, 63 | beforeDonate: false, 64 | afterDonate: false, 65 | beforeSwapReturnDelta: false, 66 | afterSwapReturnDelta: false, 67 | afterAddLiquidityReturnDelta: false, 68 | afterRemoveLiquidityReturnDelta: false 69 | }); 70 | } 71 | 72 | /** 73 | * @dev Hook that is called before pool initialization. 74 | * @param key The pool key. 75 | * @return A bytes4 selector. 76 | */ 77 | function beforeInitialize( 78 | address, 79 | PoolKey calldata key, 80 | uint160, 81 | bytes calldata 82 | ) external override returns (bytes4) { 83 | if (!key.fee.isDynamicFee()) revert MustUseDynamicFee(); 84 | PoolId poolId = key.toId(); 85 | string memory tokenSymbol = string( 86 | abi.encodePacked( 87 | "UniV4", 88 | "-", 89 | IERC20Metadata(Currency.unwrap(key.currency0)).symbol(), 90 | "-", 91 | IERC20Metadata(Currency.unwrap(key.currency1)).symbol(), 92 | "-", 93 | Strings.toString(uint256(key.fee)) 94 | ) 95 | ); 96 | vaultLiquidityToken[poolId] = new UniswapV4ERC20(tokenSymbol, tokenSymbol); 97 | return IHooks.beforeInitialize.selector; 98 | } 99 | 100 | /** 101 | * @dev Hook that is called before a swap. 102 | * @param key The pool key. 103 | * @return A tuple containing a bytes4 selector, a BeforeSwapDelta, and a uint24 fee. 104 | */ 105 | function beforeSwap( 106 | address, 107 | PoolKey calldata key, 108 | IPoolManager.SwapParams calldata, 109 | bytes calldata 110 | ) 111 | external 112 | override 113 | poolManagerOnly 114 | returns (bytes4, BeforeSwapDelta, uint24) 115 | { 116 | PoolId poolId = key.toId(); 117 | uint24 fee = getFee(poolId); 118 | (, , , uint24 currentFee) = poolManagerFee.getSlot0(poolId); 119 | if (currentFee != fee) poolManagerFee.updateDynamicLPFee(key, fee); 120 | 121 | return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); 122 | } 123 | 124 | /** 125 | * @dev Hook that is called after a swap. 126 | * @param poolKey The pool key. 127 | * @return A tuple containing a bytes4 selector and an int128 value. 128 | */ 129 | function afterSwap( 130 | address, 131 | PoolKey calldata poolKey, 132 | IPoolManager.SwapParams calldata, 133 | BalanceDelta, 134 | bytes calldata hookData 135 | ) external virtual override poolManagerOnly returns (bytes4, int128) { 136 | bool firstSwap = hookData.length == 0 || abi.decode(hookData, (bool)); 137 | if (firstSwap) { 138 | autoRebalance(poolKey); 139 | } 140 | 141 | return (IHooks.afterSwap.selector, 0); 142 | } 143 | 144 | /** 145 | * @dev Callback function for unlocking the vault. 146 | * @param rawData The raw data. 147 | * @return The result of the callback. 148 | */ 149 | function unlockCallback( 150 | bytes calldata rawData 151 | ) external override returns (bytes memory) { 152 | return _unlockVaultCallback(rawData); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /assets/unicast_math.md: -------------------------------------------------------------------------------- 1 | # UniCast 2 | 3 | 4 | ## Off-chain data input and compute 5 | 1. Forecasted volatility 6 | - data input: 7 | - poolId 8 | - new fee from implied volatility 9 | - Compute (offchain): 10 | - convert updateTime to blockNumber 11 | - compute fee from impVol 12 | - stored as: 13 | - a mapping from 14 | - poolId 15 | - to 16 | - fee 17 | 2. Forecasted path 18 | - data input: 19 | - poolId 20 | - updateTime 21 | - growthRate (expressed as a gross return) 22 | - Compute (offchain): 23 | - convert updateTime to blockNumber 24 | - compute new lowerTick, upperTick based on growthRate (and volatility) 25 | - stored as: 26 | - a mapping from 27 | - poolId 28 | - to 29 | - lowerTick 30 | - upperTick (if changing position range) 31 | - liquidityDelta 32 | 33 | 34 | 35 | ## On-chain compute: 36 | 1. Forecasted volatility: there should not be additional on-chain compute since fee updates are provided by oracle. 37 | 2. Forecasted path: liquidityDelta needs to be calculated for modifyLiquidity based on the total liquidity of the prior position and the shift in position range. 38 | 39 | ## Dynamic fees 40 | 41 | Fees can be priced as a function of option implied vol $i_v$. Under risk-neutral pricing, fees for event risk should breakeven with expected price movement, that is $E[|r_{t+1}|]$. In option pricing, this is given by the price of a straddle, which is approximately 42 | $$p_{straddle}=\sqrt{\frac{2}{\pi}}\sigma,$$ 43 | where $\sigma$ is the the period specific volatility. 44 | 45 | Suppose each period is 12 second (1 ethereum block), $\sigma_{12sec}=\sigma_{annual}/\sqrt(365*24*60*5).$ 46 | 47 | For Ethereum, $\sigma_{annual}$ has been around 80\%. This means that fee would be $\sqrt{\frac{2}{\pi}}0.80/\sqrt(365*24*60*5)\approx 4 bps$. Not a bad approximation for why 5 bps pool is the bulk of the liquidity for ETH-USDx pairs. 48 | 49 | We can extract $\sigma_{annual}$ from any options market (Deribit, Panoptics, Opyn) or forecast using historical volatility around certain events. We can then approximately set the fee to the price of straddle based on the implied vol as detailed above. This can be done off-chain and only the fee needs to be fed by the keeper oracles. 50 | 51 | 52 | 53 | 54 | 55 | 56 | ## LP position rebalancing 57 | positions are given by: 58 | - lowertick 59 | - uppertick 60 | - liquidity 61 | - salt 62 | 63 | Amount of tokens in each LP position can be backed out given lower, upper ticks, liquidity, and current price by: 64 | 65 | 1. Amount of Token0 (amount0): 66 | $\text{amount0} = \frac{L \times (\sqrt{P_u} - \sqrt{P_l})}{\sqrt{P_c} \times \sqrt{P_u}}$ 67 | 68 | 2. Amount of Token1 (amount1): 69 | $\text{amount1} = L \times (\sqrt{P_c} - \sqrt{P_l})$ 70 | 71 | Where: 72 | 73 | - $L$ is the liquidity of the position. 74 | - $P_c$ is the current price (ratio) of token1 in terms of token0. 75 | - $P_l$ is the price at the lower tick. 76 | - $P_u$ is the price at the upper tick. 77 | 78 | Prices are the price of token0 in terms of token1. Therefore, portfolio value in units of token1 is 79 | $$v(L,P_c,P_l,P_u)=amount0*P_c +amount1$$ 80 | 81 | Rebalance action by itself should not change the price of the portfolio. Otherwise, LP can just create value out of rebalancing. 82 | 83 | Suppose $P_l$ and $P_u$ both increase by 10\% and L and $P_c$ remain constant, amount0 and amount1 will both change by the corresponding amounts according to the formula above. The vault would need to settle the balance by depositing/withdrawing the change in amount0 and amoount1. This requires a swap. And the portfolio value would change since $P_c$ is the same unless $\Delta amount0=-\Delta amount1*P_c$, a counterfactural. 84 | 85 | Same value of portfolio before and after reblancing requires: 86 | 87 | $$\Delta amount1=- \Delta amount0*P_c,$$ 88 | 89 | That is, 90 | 91 | $$(\Delta L \times (\sqrt{P_c} - \sqrt{P_l}))=-P_c\frac{\Delta L \times (\sqrt{P_u} - \sqrt{P_l})}{\sqrt{P_c} \times \sqrt{P_u}},$$ 92 | 93 | which simplifies to 94 | 95 | $$\sqrt{P_u} = \frac{P_c\sqrt{P_l} }{2P_c - \sqrt{P_c}\sqrt{P_l}}.$$ 96 | 97 | 98 | 99 | Assuming $P_c$ is unchanged, one can change the Liquidity such that 100 | 101 | $$v(L',P_c,P_l',P_u')=v(L,P_c,P_l,P_u),$$ 102 | where $'$ denote new values. 103 | 104 | I.e., we calculate $L'-L$ for `modifiedliquidity.liquidityDelta` based on the new $P_l'$ and $P_u'$ values. We'll still have different amount0 and amount1 which requires swaps. Also when we swap, we'll have a different $P_c$ as a result of the swap. We can approximate this, but in reality the amount of $\Delta P_c$ depends on the total liquidity of the pool, i.e. amount of LPs outside of the vault. 105 | 106 | 107 | **Suppose the impact of the swap is infintisimal, that is $P_c'=P_c$,** then we need to solve the following $v'(.')=v(.)$ equation for $L'$ given $P_u$, $P_l$,$P_u'$, $P_l'$, $L$, and $P_c$. That is, 108 | 109 | 112 | $$ 113 | \begin{align} 114 | &\frac{L \times (\sqrt{P_u} - \sqrt{P_l})}{\sqrt{P_c} \times \sqrt{P_u}} \cdot P_c + (L \times (\sqrt{P_c} - \sqrt{P_l})) \notag \\ 115 | & = \frac{L' \times (\sqrt{P_u'} - \sqrt{P_l'})}{\sqrt{P_c} \times \sqrt{P_u'}} \cdot P_c + (L' \times (\sqrt{P_c} - \sqrt{P_l'})) \notag 116 | \end{align} 117 | $$ 118 | 119 | Simplifying, we get 120 | 121 | $$L{\prime} = L \times \frac{ \frac{P_c\left(\sqrt{P_u} - \sqrt{P_l}\right)}{\sqrt{P_c} \sqrt{P_u}} + \sqrt{P_c} - \sqrt{P_l} }{ \frac{P_c\left(\sqrt{P_u{\prime}} - \sqrt{P_l{\prime}}\right)}{\sqrt{P_c} \sqrt{P_u{\prime}}} + \sqrt{P_c} - \sqrt{P_l{\prime}} }$$ 122 | 123 | 124 | This liquidity delta is right only if there's a lot of other LP liquidity outside of the vault. We can indeed make this the case by artificially adding a large amount of liquidity outside of the vault for demo purpose. -------------------------------------------------------------------------------- /test/e2e.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; 6 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 7 | import {PoolManager} from "v4-core/PoolManager.sol"; 8 | import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; 9 | import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; 10 | import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; 11 | import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; 12 | import {PoolKey} from "v4-core/types/PoolKey.sol"; 13 | import {UniCastOracle} from "../src/UniCastOracle.sol"; 14 | import {Hooks} from "v4-core/libraries/Hooks.sol"; 15 | import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; 16 | import {UniCastVolitilityFee} from "../src/UniCastVolitilityFee.sol"; 17 | import {console} from "forge-std/console.sol"; 18 | import {TickMath} from "v4-core/libraries/TickMath.sol"; 19 | import {UniCastHook} from "../src/UniCastHook.sol"; 20 | import {IUniCastOracle} from "../src/interface/IUniCastOracle.sol"; 21 | import {LiquidityData} from "../src/interface/IUniCastOracle.sol"; 22 | import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/types/BalanceDelta.sol"; 23 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 24 | import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; 25 | import {StateLibrary} from "../src/util/StateLibrary.sol"; 26 | 27 | contract TestUniCast is Test, Deployers { 28 | using CurrencyLibrary for Currency; 29 | using PoolIdLibrary for PoolKey; 30 | using StateLibrary for IPoolManager; 31 | 32 | event RebalanceOccurred( 33 | PoolId poolId, 34 | int24 oldLowerTick, 35 | int24 oldUpperTick, 36 | int24 newLowerTick, 37 | int24 newUpperTick 38 | ); 39 | 40 | address targetAddr = 41 | address( 42 | uint160( 43 | Hooks.BEFORE_INITIALIZE_FLAG | 44 | Hooks.BEFORE_SWAP_FLAG | 45 | Hooks.AFTER_SWAP_FLAG 46 | ) 47 | ); 48 | address keeper = makeAddr("keeper"); 49 | UniCastHook hook = UniCastHook(targetAddr); 50 | IUniCastOracle oracle; 51 | MockERC20 token0; 52 | MockERC20 token1; 53 | int24 constant INITIAL_MAX_TICK = 120; 54 | 55 | error MustUseDynamicFee(); 56 | 57 | PoolSwapTest.TestSettings testSettings = 58 | PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); 59 | 60 | function setUp() public { 61 | emit log_named_address("targetAddr", targetAddr); 62 | // Deploy v4-core 63 | deployFreshManagerAndRouters(); 64 | 65 | oracle = new UniCastOracle(keeper, 500); 66 | 67 | deployCodeTo( 68 | "UniCastHook.sol", 69 | abi.encode( 70 | manager, 71 | address(oracle), 72 | -INITIAL_MAX_TICK, 73 | INITIAL_MAX_TICK 74 | ), 75 | targetAddr 76 | ); 77 | 78 | // Deploy, mint tokens, and approve all periphery contracts for two tokens 79 | (currency0, currency1) = deployMintAndApprove2Currencies(); 80 | token0 = MockERC20(Currency.unwrap(currency0)); 81 | token1 = MockERC20(Currency.unwrap(currency1)); 82 | token0.approve(address(hook), type(uint256).max); 83 | token1.approve(address(hook), type(uint256).max); 84 | 85 | emit log_address(address(hook)); 86 | 87 | // Initialize a pool 88 | (key, ) = initPool( 89 | currency0, 90 | currency1, 91 | hook, 92 | LPFeeLibrary.DYNAMIC_FEE_FLAG, // Set the `DYNAMIC_FEE_FLAG` in place of specifying a fixed fee 93 | SQRT_PRICE_1_1, 94 | ZERO_BYTES 95 | ); 96 | emit log_uint(key.fee); 97 | 98 | // issue liquidity and allowance 99 | address gordon = makeAddr("gordon"); 100 | vm.startPrank(gordon); 101 | token0.mint(gordon, 10000 ether); 102 | token1.mint(gordon, 10000 ether); 103 | token0.approve(address(hook), type(uint256).max); 104 | token1.approve(address(hook), type(uint256).max); 105 | 106 | hook.addLiquidity(key, 10 ether, 10 ether); 107 | vm.stopPrank(); 108 | 109 | //init oracle 110 | vm.startPrank(keeper); 111 | PoolId poolId = key.toId(); 112 | 113 | oracle.setLiquidityData(poolId, -INITIAL_MAX_TICK, INITIAL_MAX_TICK); 114 | vm.stopPrank(); 115 | 116 | // mint to vault, which becomes an LP basically 117 | token0.mint(address(hook), 1000 ether); 118 | token1.mint(address(hook), 1000 ether); 119 | } 120 | 121 | function testVolatilityOracleAddress() public view { 122 | assertEq(address(oracle), address(hook.getVolatilityOracle())); 123 | } 124 | 125 | function testGetFeeWithNoVolatility() public view { 126 | uint128 fee = hook.getFee(key.toId()); 127 | assertEq(fee, 500); 128 | } 129 | 130 | function testSetImpliedVolatility() public { 131 | PoolId poolId = key.toId(); 132 | 133 | vm.startPrank(keeper); 134 | oracle.setFee(poolId, 650); 135 | uint128 fee = hook.getFee(poolId); 136 | assertEq(fee, 650); 137 | } 138 | 139 | function testBeforeSwapNotVolatile() public { 140 | IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ 141 | zeroForOne: true, 142 | amountSpecified: -0.01 ether, 143 | sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 144 | }); 145 | // 1. Conduct a swap at baseline vol 146 | // This should just use `BASE_FEE` 147 | swapRouter.swap(key, params, testSettings, ZERO_BYTES); 148 | assertEq(_fetchPoolLPFee(key), 500); 149 | } 150 | 151 | function testRebalanceAfterSwap() public { 152 | int24 lowerTick = -60; 153 | int24 upperTick = 60; 154 | PoolId poolId = key.toId(); 155 | 156 | vm.startPrank(keeper); 157 | oracle.setLiquidityData(poolId, -60, 60); 158 | vm.stopPrank(); 159 | IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ 160 | zeroForOne: true, 161 | amountSpecified: -1 ether, 162 | sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 163 | }); 164 | vm.expectEmit(targetAddr); 165 | emit RebalanceOccurred( 166 | poolId, 167 | -INITIAL_MAX_TICK, 168 | INITIAL_MAX_TICK, 169 | lowerTick, 170 | upperTick 171 | ); 172 | swapRouter.swap(key, params, testSettings, ZERO_BYTES); 173 | 174 | // Check if rebalancing occurred 175 | vm.stopPrank(); 176 | } 177 | 178 | function _fetchPoolLPFee( 179 | PoolKey memory _key 180 | ) internal view returns (uint256 lpFee) { 181 | PoolId id = _key.toId(); 182 | (, , , lpFee) = manager.getSlot0(id); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /test/UniCast.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; 6 | import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; 7 | import {PoolManager} from "v4-core/PoolManager.sol"; 8 | import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; 9 | import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; 10 | import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; 11 | import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; 12 | import {Position} from "v4-core/libraries/Position.sol"; 13 | import {PoolKey} from "v4-core/types/PoolKey.sol"; 14 | import {UniCastOracle} from "../src/UniCastOracle.sol"; 15 | import {Hooks} from "v4-core/libraries/Hooks.sol"; 16 | import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; 17 | import {UniCastVolitilityFee} from "../src/UniCastVolitilityFee.sol"; 18 | import {console} from "forge-std/console.sol"; 19 | import {TickMath} from "v4-core/libraries/TickMath.sol"; 20 | import {UniCastHook} from "../src/UniCastHook.sol"; 21 | import {LiquidityData} from "../src/interface/IUniCastOracle.sol"; 22 | import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/types/BalanceDelta.sol"; 23 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 24 | import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; 25 | import {StateLibrary} from "../src/util/StateLibrary.sol"; 26 | 27 | contract TestUniCast is Test, Deployers { 28 | using CurrencyLibrary for Currency; 29 | using PoolIdLibrary for PoolKey; 30 | using StateLibrary for IPoolManager; 31 | 32 | address targetAddr = 33 | address( 34 | uint160( 35 | Hooks.BEFORE_INITIALIZE_FLAG | 36 | Hooks.BEFORE_SWAP_FLAG | 37 | Hooks.AFTER_SWAP_FLAG 38 | ) 39 | ); 40 | UniCastHook hook = UniCastHook(targetAddr); 41 | address oracleAddr = makeAddr("oracle"); 42 | address gordon = makeAddr("gordon"); 43 | address alice = makeAddr("alice"); 44 | address bob = makeAddr("bob"); 45 | address broke = makeAddr("broke"); 46 | UniCastOracle oracle = UniCastOracle(oracleAddr); 47 | MockERC20 token0; 48 | MockERC20 token1; 49 | 50 | uint128 constant EXPECTED_LIQUIDITY = 250763249753729650363; 51 | int24 constant INITIAL_MAX_TICK = 120; 52 | 53 | error MustUseDynamicFee(); 54 | event RebalanceOccurred( 55 | PoolId poolId, 56 | int24 oldLowerTick, 57 | int24 oldUpperTick, 58 | int24 newLowerTick, 59 | int24 newUpperTick 60 | ); 61 | 62 | PoolSwapTest.TestSettings testSettings = 63 | PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); 64 | 65 | function setUp() public { 66 | emit log_named_address("targetAddr", targetAddr); 67 | // Deploy v4-core 68 | deployFreshManagerAndRouters(); 69 | 70 | deployCodeTo( 71 | "UniCastHook.sol", 72 | abi.encode( 73 | manager, 74 | oracleAddr, 75 | -INITIAL_MAX_TICK, 76 | INITIAL_MAX_TICK 77 | ), 78 | targetAddr 79 | ); 80 | 81 | // Deploy, mint tokens, and approve all periphery contracts for two tokens 82 | (currency0, currency1) = deployMintAndApprove2Currencies(); 83 | token0 = MockERC20(Currency.unwrap(currency0)); 84 | token1 = MockERC20(Currency.unwrap(currency1)); 85 | 86 | emit log_address(address(hook)); 87 | 88 | // Initialize a pool 89 | (key, ) = initPool( 90 | currency0, 91 | currency1, 92 | hook, 93 | LPFeeLibrary.DYNAMIC_FEE_FLAG, // Set the `DYNAMIC_FEE_FLAG` in place of specifying a fixed fee 94 | SQRT_PRICE_1_1, 95 | ZERO_BYTES 96 | ); 97 | emit log_uint(key.fee); 98 | 99 | // mint to vault, which becomes an LP basically 100 | token0.mint(address(hook), 1000 ether); 101 | token1.mint(address(hook), 1000 ether); 102 | 103 | _mintTokensToAndApprove(gordon); 104 | _mintTokensToAndApprove(bob); 105 | _mintTokensToAndApprove(alice); 106 | 107 | // issue liquidity and allowance 108 | vm.startPrank(gordon); 109 | hook.addLiquidity(key, 10 ether, 10 ether); 110 | vm.stopPrank(); 111 | } 112 | 113 | function _mintTokensToAndApprove(address account) private { 114 | token0.mint(account, 1000 ether); 115 | token1.mint(account, 1000 ether); 116 | vm.startPrank(account); 117 | token0.approve(address(hook), type(uint256).max); 118 | token1.approve(address(hook), type(uint256).max); 119 | vm.stopPrank(); 120 | } 121 | 122 | function testVolatilityOracleAddress() public view { 123 | assertEq(oracleAddr, address(hook.getVolatilityOracle())); 124 | } 125 | 126 | function testGetFeeWithVolatility() public { 127 | PoolId poolId = key.toId(); 128 | vm.mockCall( 129 | oracleAddr, 130 | abi.encodeWithSelector(oracle.getFee.selector, poolId), 131 | abi.encode(uint24(650)) 132 | ); 133 | uint128 fee = hook.getFee(poolId); 134 | console.logUint(fee); 135 | assertEq(fee, 650); 136 | } 137 | 138 | function testBeforeInitializeRevertsIfNotDynamic() public { 139 | vm.expectRevert(abi.encodeWithSelector(MustUseDynamicFee.selector)); 140 | initPool( 141 | currency0, 142 | currency1, 143 | hook, 144 | 100, // Set the `DYNAMIC_FEE_FLAG` in place of specifying a fixed fee 145 | SQRT_PRICE_1_1, 146 | ZERO_BYTES 147 | ); 148 | } 149 | 150 | function testBeforeSwapVolatile() public { 151 | PoolId poolId = key.toId(); 152 | vm.mockCall( 153 | oracleAddr, 154 | abi.encodeWithSelector(oracle.getFee.selector), 155 | abi.encode(uint24(650)) 156 | ); 157 | vm.mockCall( 158 | oracleAddr, 159 | abi.encodeWithSelector(oracle.getLiquidityData.selector, poolId), 160 | abi.encode(LiquidityData(-INITIAL_MAX_TICK, INITIAL_MAX_TICK)) 161 | ); 162 | IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ 163 | zeroForOne: true, 164 | amountSpecified: -0.01 ether, 165 | sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 166 | }); 167 | // 1. Conduct a swap at baseline vol 168 | // This should just use `BASE_FEE` 169 | swapRouter.swap(key, params, testSettings, ZERO_BYTES); 170 | assertEq(_fetchPoolLPFee(key), 650); 171 | } 172 | 173 | function testBeforeSwapNotVolatile() public { 174 | PoolId poolId = key.toId(); 175 | vm.mockCall( 176 | oracleAddr, 177 | abi.encodeWithSelector(oracle.getFee.selector), 178 | abi.encode(uint24(500)) 179 | ); 180 | vm.mockCall( 181 | oracleAddr, 182 | abi.encodeWithSelector(oracle.getLiquidityData.selector, poolId), 183 | abi.encode(LiquidityData(-INITIAL_MAX_TICK, INITIAL_MAX_TICK)) 184 | ); 185 | IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ 186 | zeroForOne: true, 187 | amountSpecified: -0.01 ether, 188 | sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 189 | }); 190 | // 1. Conduct a swap at baseline vol 191 | // This should just use `BASE_FEE` 192 | swapRouter.swap(key, params, testSettings, ZERO_BYTES); 193 | assertEq(_fetchPoolLPFee(key), 500); 194 | } 195 | 196 | function _fetchPoolLPFee( 197 | PoolKey memory _key 198 | ) internal view returns (uint256 lpFee) { 199 | PoolId id = _key.toId(); 200 | (, , , lpFee) = manager.getSlot0(id); 201 | } 202 | 203 | function testAddLiquidity() public { 204 | vm.startPrank(alice); 205 | 206 | uint256 liquidity = hook.addLiquidity(key, 1.5 ether, 1.5 ether); 207 | assertEq( 208 | liquidity, 209 | EXPECTED_LIQUIDITY, 210 | "Liquidity should be exactly 250763249753729650363 according to equation" 211 | ); 212 | 213 | vm.stopPrank(); 214 | } 215 | 216 | function testAddLiquidityAdditional() public { 217 | PoolId poolId = key.toId(); 218 | 219 | vm.startPrank(alice); 220 | 221 | uint256 liquidity = hook.addLiquidity(key, 1.5 ether, 1.5 ether); 222 | assertEq( 223 | liquidity, 224 | EXPECTED_LIQUIDITY, 225 | "Liquidity should be exactly 250763249753729650363 according to equation" 226 | ); 227 | 228 | (uint128 oldLiquidity, , ) = manager.getPositionInfo( 229 | poolId, 230 | _getPositionKey( 231 | address(hook), 232 | -INITIAL_MAX_TICK, 233 | INITIAL_MAX_TICK, 234 | 0 235 | ) 236 | ); 237 | 238 | vm.stopPrank(); 239 | 240 | // add more liquidity 241 | vm.startPrank(bob); 242 | hook.addLiquidity(key, 1 ether, 1 ether); 243 | (uint128 newLiquidity, , ) = manager.getPositionInfo( 244 | poolId, 245 | _getPositionKey( 246 | address(hook), 247 | -INITIAL_MAX_TICK, 248 | INITIAL_MAX_TICK, 249 | 0 250 | ) 251 | ); 252 | 253 | vm.assertGt(newLiquidity, oldLiquidity); 254 | vm.stopPrank(); 255 | } 256 | 257 | function testAddLiquidityNegative() public { 258 | vm.startPrank(broke); 259 | token0.mint(broke, 0.5 ether); // Insufficient amount 260 | token1.mint(broke, 0.5 ether); // Insufficient amount 261 | 262 | vm.expectRevert(); 263 | hook.addLiquidity(key, 1.5 ether, 1.5 ether); 264 | 265 | vm.stopPrank(); 266 | } 267 | 268 | function testRemoveLiquidity() public { 269 | vm.startPrank(bob); 270 | 271 | uint256 liquidity = hook.addLiquidity(key, 1.5 ether, 1.5 ether); 272 | assertEq( 273 | liquidity, 274 | EXPECTED_LIQUIDITY, 275 | "Liquidity should be exactly 250763249753729650363" 276 | ); 277 | 278 | hook.removeLiquidity(key, 0.5 ether, 1 ether); 279 | 280 | uint256 balance0 = token0.balanceOf(bob); 281 | uint256 balance1 = token1.balanceOf(bob); 282 | assertApproxEqAbs( 283 | balance0, 284 | 998.5 ether, 285 | 0.5 ether, 286 | "Token0 balance should be approximately 998.5 ether after removing liquidity" 287 | ); 288 | assertApproxEqAbs( 289 | balance1, 290 | 999 ether, 291 | 0.5 ether, 292 | "Token1 balance should be approximately 999 ether after removing liquidity" 293 | ); 294 | vm.stopPrank(); 295 | } 296 | 297 | function testRemoveLiquidityNegative() public { 298 | vm.startPrank(bob); 299 | 300 | uint256 liquidity = hook.addLiquidity(key, 1.5 ether, 1.5 ether); 301 | assertEq( 302 | liquidity, 303 | EXPECTED_LIQUIDITY, 304 | "Liquidity should be exactly 250763249753729650363" 305 | ); 306 | 307 | vm.expectRevert(); 308 | hook.removeLiquidity(key, 2 ether, 2 ether); 309 | vm.stopPrank(); 310 | } 311 | 312 | function testRebalanceAfterSwap() public { 313 | PoolId poolId = key.toId(); 314 | vm.expectEmit(targetAddr); 315 | emit RebalanceOccurred( 316 | poolId, 317 | -INITIAL_MAX_TICK, 318 | INITIAL_MAX_TICK, 319 | -INITIAL_MAX_TICK, 320 | 2 * INITIAL_MAX_TICK 321 | ); 322 | vm.mockCall( 323 | oracleAddr, 324 | abi.encodeWithSelector(oracle.getFee.selector, poolId), 325 | abi.encode(uint24(500)) 326 | ); 327 | vm.expectCall( 328 | oracleAddr, 329 | abi.encodeCall(oracle.getLiquidityData, (poolId)) 330 | ); 331 | vm.mockCall( 332 | oracleAddr, 333 | abi.encodeWithSelector(oracle.getLiquidityData.selector, poolId), 334 | abi.encode(LiquidityData(-INITIAL_MAX_TICK, 2 * INITIAL_MAX_TICK)) 335 | ); 336 | IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ 337 | zeroForOne: true, 338 | amountSpecified: -1 ether, 339 | sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 340 | }); 341 | swapRouter.swap(key, params, testSettings, abi.encode(true)); // hookdata is firstSwap 342 | 343 | vm.stopPrank(); 344 | } 345 | 346 | function testRebalanceAfterSwapNegative() public { 347 | PoolId poolId = key.toId(); 348 | (uint128 oldLiquidity, , ) = manager.getPositionInfo( 349 | poolId, 350 | _getPositionKey( 351 | address(hook), 352 | -INITIAL_MAX_TICK, 353 | INITIAL_MAX_TICK, 354 | 0 355 | ) 356 | ); 357 | vm.mockCall( 358 | oracleAddr, 359 | abi.encodeWithSelector(oracle.getFee.selector, poolId), 360 | abi.encode(uint24(500)) 361 | ); 362 | vm.expectCall( 363 | oracleAddr, 364 | abi.encodeCall(oracle.getLiquidityData, (poolId)) 365 | ); 366 | vm.mockCall( 367 | oracleAddr, 368 | abi.encodeWithSelector(oracle.getLiquidityData.selector, poolId), 369 | abi.encode(LiquidityData(-INITIAL_MAX_TICK, INITIAL_MAX_TICK)) 370 | ); 371 | IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ 372 | zeroForOne: true, 373 | amountSpecified: -1 ether, 374 | sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 375 | }); 376 | swapRouter.swap(key, params, testSettings, abi.encode(true)); // hookdata is firstSwap 377 | // Check if rebalancing did not occur with no swaps 378 | (uint128 liquidity, , ) = manager.getPositionInfo( 379 | poolId, 380 | _getPositionKey( 381 | address(hook), 382 | -INITIAL_MAX_TICK, 383 | INITIAL_MAX_TICK, 384 | 0 385 | ) 386 | ); 387 | assertEq(oldLiquidity, liquidity); 388 | vm.stopPrank(); 389 | } 390 | 391 | function _getPositionKey( 392 | address owner, 393 | int24 tickLower, 394 | int24 tickUpper, 395 | bytes32 salt 396 | ) private pure returns (bytes32 positionKey) { 397 | // positionKey = keccak256(abi.encodePacked(owner, tickLower, tickUpper, salt)) 398 | positionKey; 399 | 400 | /// @solidity memory-safe-assembly 401 | assembly { 402 | mstore(0x26, salt) // [0x26, 0x46) 403 | mstore(0x06, tickUpper) // [0x23, 0x26) 404 | mstore(0x03, tickLower) // [0x20, 0x23) 405 | mstore(0, owner) // [0x0c, 0x20) 406 | positionKey := keccak256(0x0c, 0x3a) // len is 58 bytes 407 | mstore(0x26, 0) // rewrite 0x26 to 0 408 | } 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/util/StateLibrary.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; 5 | import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; 6 | 7 | library StateLibrary { 8 | // forge inspect src/PoolManager.sol:PoolManager storage --pretty 9 | // | Name | Type | Slot | Offset | Bytes | Contract | 10 | // |-----------------------|-----------------------------------------|------|--------|-------|---------------------------------| 11 | // | pools | mapping(PoolId => struct Pool.State) | 6 | 0 | 32 | src/PoolManager.sol:PoolManager | 12 | bytes32 public constant POOLS_SLOT = bytes32(uint256(6)); 13 | 14 | // index of feeGrowthGlobal0X128 in Pool.State 15 | uint256 public constant FEE_GROWTH_GLOBAL0_OFFSET = 1; 16 | // index of feeGrowthGlobal1X128 in Pool.State 17 | uint256 public constant FEE_GROWTH_GLOBAL1_OFFSET = 2; 18 | 19 | // index of liquidity in Pool.State 20 | uint256 public constant LIQUIDITY_OFFSET = 3; 21 | 22 | // index of TicksInfo mapping in Pool.State: mapping(int24 => TickInfo) ticks; 23 | uint256 public constant TICKS_OFFSET = 4; 24 | 25 | // index of tickBitmap mapping in Pool.State 26 | uint256 public constant TICK_BITMAP_OFFSET = 5; 27 | 28 | // index of Position.Info mapping in Pool.State: mapping(bytes32 => Position.Info) positions; 29 | uint256 public constant POSITIONS_OFFSET = 6; 30 | 31 | /** 32 | * @notice Get Slot0 of the pool: sqrtPriceX96, tick, protocolFee, lpFee 33 | * @dev Corresponds to pools[poolId].slot0 34 | * @param manager The pool manager contract. 35 | * @param poolId The ID of the pool. 36 | * @return sqrtPriceX96 The square root of the price of the pool, in Q96 precision. 37 | * @return tick The current tick of the pool. 38 | * @return protocolFee The protocol fee of the pool. 39 | * @return lpFee The swap fee of the pool. 40 | */ 41 | function getSlot0(IPoolManager manager, PoolId poolId) 42 | internal 43 | view 44 | returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee) 45 | { 46 | // slot key of Pool.State value: `pools[poolId]` 47 | bytes32 stateSlot = _getPoolStateSlot(poolId); 48 | 49 | bytes32 data = manager.extsload(stateSlot); 50 | 51 | // 24 bits |24bits|24bits |24 bits|160 bits 52 | // 0x000000 |000bb8|000000 |ffff75 |0000000000000000fe3aa841ba359daa0ea9eff7 53 | // ---------- | fee |protocolfee | tick | sqrtPriceX96 54 | assembly { 55 | // bottom 160 bits of data 56 | sqrtPriceX96 := and(data, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) 57 | // next 24 bits of data 58 | tick := signextend(2, shr(160, data)) 59 | // next 24 bits of data 60 | protocolFee := and(shr(184, data), 0xFFFFFF) 61 | // last 24 bits of data 62 | lpFee := and(shr(208, data), 0xFFFFFF) 63 | } 64 | } 65 | 66 | /** 67 | * @notice Retrieves the tick information of a pool at a specific tick. 68 | * @dev Corresponds to pools[poolId].ticks[tick] 69 | * @param manager The pool manager contract. 70 | * @param poolId The ID of the pool. 71 | * @param tick The tick to retrieve information for. 72 | * @return liquidityGross The total position liquidity that references this tick 73 | * @return liquidityNet The amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left) 74 | * @return feeGrowthOutside0X128 fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) 75 | * @return feeGrowthOutside1X128 fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) 76 | */ 77 | function getTickInfo(IPoolManager manager, PoolId poolId, int24 tick) 78 | internal 79 | view 80 | returns ( 81 | uint128 liquidityGross, 82 | int128 liquidityNet, 83 | uint256 feeGrowthOutside0X128, 84 | uint256 feeGrowthOutside1X128 85 | ) 86 | { 87 | bytes32 slot = _getTickInfoSlot(poolId, tick); 88 | 89 | // read all 3 words of the TickInfo struct 90 | bytes memory data = manager.extsload(slot, 3); 91 | assembly { 92 | let firstWord := mload(add(data, 32)) 93 | liquidityNet := sar(128, firstWord) 94 | liquidityGross := and(firstWord, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) 95 | feeGrowthOutside0X128 := mload(add(data, 64)) 96 | feeGrowthOutside1X128 := mload(add(data, 96)) 97 | } 98 | } 99 | 100 | /** 101 | * @notice Retrieves the liquidity information of a pool at a specific tick. 102 | * @dev Corresponds to pools[poolId].ticks[tick].liquidityGross and pools[poolId].ticks[tick].liquidityNet. A more gas efficient version of getTickInfo 103 | * @param manager The pool manager contract. 104 | * @param poolId The ID of the pool. 105 | * @param tick The tick to retrieve liquidity for. 106 | * @return liquidityGross The total position liquidity that references this tick 107 | * @return liquidityNet The amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left) 108 | */ 109 | function getTickLiquidity(IPoolManager manager, PoolId poolId, int24 tick) 110 | internal 111 | view 112 | returns (uint128 liquidityGross, int128 liquidityNet) 113 | { 114 | bytes32 slot = _getTickInfoSlot(poolId, tick); 115 | 116 | bytes32 value = manager.extsload(slot); 117 | assembly { 118 | liquidityNet := sar(128, value) 119 | liquidityGross := and(value, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) 120 | } 121 | } 122 | 123 | /** 124 | * @notice Retrieves the fee growth outside a tick range of a pool 125 | * @dev Corresponds to pools[poolId].ticks[tick].feeGrowthOutside0X128 and pools[poolId].ticks[tick].feeGrowthOutside1X128. A more gas efficient version of getTickInfo 126 | * @param manager The pool manager contract. 127 | * @param poolId The ID of the pool. 128 | * @param tick The tick to retrieve fee growth for. 129 | * @return feeGrowthOutside0X128 fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) 130 | * @return feeGrowthOutside1X128 fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) 131 | */ 132 | function getTickFeeGrowthOutside(IPoolManager manager, PoolId poolId, int24 tick) 133 | internal 134 | view 135 | returns (uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128) 136 | { 137 | bytes32 slot = _getTickInfoSlot(poolId, tick); 138 | 139 | // offset by 1 word, since the first word is liquidityGross + liquidityNet 140 | bytes memory data = manager.extsload(bytes32(uint256(slot) + 1), 2); 141 | assembly { 142 | feeGrowthOutside0X128 := mload(add(data, 32)) 143 | feeGrowthOutside1X128 := mload(add(data, 64)) 144 | } 145 | } 146 | 147 | /** 148 | * @notice Retrieves the global fee growth of a pool. 149 | * @dev Corresponds to pools[poolId].feeGrowthGlobal0X128 and pools[poolId].feeGrowthGlobal1X128 150 | * @param manager The pool manager contract. 151 | * @param poolId The ID of the pool. 152 | * @return feeGrowthGlobal0 The global fee growth for token0. 153 | * @return feeGrowthGlobal1 The global fee growth for token1. 154 | */ 155 | function getFeeGrowthGlobals(IPoolManager manager, PoolId poolId) 156 | internal 157 | view 158 | returns (uint256 feeGrowthGlobal0, uint256 feeGrowthGlobal1) 159 | { 160 | // slot key of Pool.State value: `pools[poolId]` 161 | bytes32 stateSlot = _getPoolStateSlot(poolId); 162 | 163 | // Pool.State, `uint256 feeGrowthGlobal0X128` 164 | bytes32 slot_feeGrowthGlobal0X128 = bytes32(uint256(stateSlot) + FEE_GROWTH_GLOBAL0_OFFSET); 165 | 166 | // read the 2 words of feeGrowthGlobal 167 | bytes memory data = manager.extsload(slot_feeGrowthGlobal0X128, 2); 168 | assembly { 169 | feeGrowthGlobal0 := mload(add(data, 32)) 170 | feeGrowthGlobal1 := mload(add(data, 64)) 171 | } 172 | } 173 | 174 | /** 175 | * @notice Retrieves total the liquidity of a pool. 176 | * @dev Corresponds to pools[poolId].liquidity 177 | * @param manager The pool manager contract. 178 | * @param poolId The ID of the pool. 179 | * @return liquidity The liquidity of the pool. 180 | */ 181 | function getLiquidity(IPoolManager manager, PoolId poolId) internal view returns (uint128 liquidity) { 182 | // slot key of Pool.State value: `pools[poolId]` 183 | bytes32 stateSlot = _getPoolStateSlot(poolId); 184 | 185 | // Pool.State: `uint128 liquidity` 186 | bytes32 slot = bytes32(uint256(stateSlot) + LIQUIDITY_OFFSET); 187 | 188 | liquidity = uint128(uint256(manager.extsload(slot))); 189 | } 190 | 191 | /** 192 | * @notice Retrieves the tick bitmap of a pool at a specific tick. 193 | * @dev Corresponds to pools[poolId].tickBitmap[tick] 194 | * @param manager The pool manager contract. 195 | * @param poolId The ID of the pool. 196 | * @param tick The tick to retrieve the bitmap for. 197 | * @return tickBitmap The bitmap of the tick. 198 | */ 199 | function getTickBitmap(IPoolManager manager, PoolId poolId, int16 tick) 200 | internal 201 | view 202 | returns (uint256 tickBitmap) 203 | { 204 | // slot key of Pool.State value: `pools[poolId]` 205 | bytes32 stateSlot = _getPoolStateSlot(poolId); 206 | 207 | // Pool.State: `mapping(int16 => uint256) tickBitmap;` 208 | bytes32 tickBitmapMapping = bytes32(uint256(stateSlot) + TICK_BITMAP_OFFSET); 209 | 210 | // slot id of the mapping key: `pools[poolId].tickBitmap[tick] 211 | bytes32 slot = keccak256(abi.encodePacked(int256(tick), tickBitmapMapping)); 212 | 213 | tickBitmap = uint256(manager.extsload(slot)); 214 | } 215 | 216 | /** 217 | * @notice Retrieves the position information of a pool at a specific position ID. 218 | * @dev Corresponds to pools[poolId].positions[positionId] 219 | * @param manager The pool manager contract. 220 | * @param poolId The ID of the pool. 221 | * @param positionId The ID of the position. 222 | * @return liquidity The liquidity of the position. 223 | * @return feeGrowthInside0LastX128 The fee growth inside the position for token0. 224 | * @return feeGrowthInside1LastX128 The fee growth inside the position for token1. 225 | */ 226 | function getPositionInfo(IPoolManager manager, PoolId poolId, bytes32 positionId) 227 | internal 228 | view 229 | returns (uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128) 230 | { 231 | bytes32 slot = _getPositionInfoSlot(poolId, positionId); 232 | 233 | // read all 3 words of the Position.Info struct 234 | bytes memory data = manager.extsload(slot, 3); 235 | 236 | assembly { 237 | liquidity := mload(add(data, 32)) 238 | feeGrowthInside0LastX128 := mload(add(data, 64)) 239 | feeGrowthInside1LastX128 := mload(add(data, 96)) 240 | } 241 | } 242 | 243 | /** 244 | * @notice Retrieves the liquidity of a position. 245 | * @dev Corresponds to pools[poolId].positions[positionId].liquidity. A more gas efficient version of getPositionInfo 246 | * @param manager The pool manager contract. 247 | * @param poolId The ID of the pool. 248 | * @param positionId The ID of the position. 249 | * @return liquidity The liquidity of the position. 250 | */ 251 | function getPositionLiquidity(IPoolManager manager, PoolId poolId, bytes32 positionId) 252 | internal 253 | view 254 | returns (uint128 liquidity) 255 | { 256 | bytes32 slot = _getPositionInfoSlot(poolId, positionId); 257 | liquidity = uint128(uint256(manager.extsload(slot))); 258 | } 259 | 260 | /** 261 | * @notice Live calculate the fee growth inside a tick range of a pool 262 | * @dev pools[poolId].feeGrowthInside0LastX128 in Position.Info is cached and can become stale. This function will live calculate the feeGrowthInside 263 | * @param manager The pool manager contract. 264 | * @param poolId The ID of the pool. 265 | * @param tickLower The lower tick of the range. 266 | * @param tickUpper The upper tick of the range. 267 | * @return feeGrowthInside0X128 The fee growth inside the tick range for token0. 268 | * @return feeGrowthInside1X128 The fee growth inside the tick range for token1. 269 | */ 270 | function getFeeGrowthInside(IPoolManager manager, PoolId poolId, int24 tickLower, int24 tickUpper) 271 | internal 272 | view 273 | returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) 274 | { 275 | (uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128) = getFeeGrowthGlobals(manager, poolId); 276 | 277 | (uint256 lowerFeeGrowthOutside0X128, uint256 lowerFeeGrowthOutside1X128) = 278 | getTickFeeGrowthOutside(manager, poolId, tickLower); 279 | (uint256 upperFeeGrowthOutside0X128, uint256 upperFeeGrowthOutside1X128) = 280 | getTickFeeGrowthOutside(manager, poolId, tickUpper); 281 | (, int24 tickCurrent,,) = getSlot0(manager, poolId); 282 | unchecked { 283 | if (tickCurrent < tickLower) { 284 | feeGrowthInside0X128 = lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128; 285 | feeGrowthInside1X128 = lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128; 286 | } else if (tickCurrent >= tickUpper) { 287 | feeGrowthInside0X128 = upperFeeGrowthOutside0X128 - lowerFeeGrowthOutside0X128; 288 | feeGrowthInside1X128 = upperFeeGrowthOutside1X128 - lowerFeeGrowthOutside1X128; 289 | } else { 290 | feeGrowthInside0X128 = feeGrowthGlobal0X128 - lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128; 291 | feeGrowthInside1X128 = feeGrowthGlobal1X128 - lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128; 292 | } 293 | } 294 | } 295 | 296 | function _getPoolStateSlot(PoolId poolId) internal pure returns (bytes32) { 297 | return keccak256(abi.encodePacked(PoolId.unwrap(poolId), POOLS_SLOT)); 298 | } 299 | 300 | function _getTickInfoSlot(PoolId poolId, int24 tick) internal pure returns (bytes32) { 301 | // slot key of Pool.State value: `pools[poolId]` 302 | bytes32 stateSlot = _getPoolStateSlot(poolId); 303 | 304 | // Pool.State: `mapping(int24 => TickInfo) ticks` 305 | bytes32 ticksMappingSlot = bytes32(uint256(stateSlot) + TICKS_OFFSET); 306 | 307 | // slot key of the tick key: `pools[poolId].ticks[tick] 308 | return keccak256(abi.encodePacked(int256(tick), ticksMappingSlot)); 309 | } 310 | 311 | function _getPositionInfoSlot(PoolId poolId, bytes32 positionId) internal pure returns (bytes32 slot) { 312 | // slot key of Pool.State value: `pools[poolId]` 313 | bytes32 stateSlot = _getPoolStateSlot(poolId); 314 | 315 | // Pool.State: `mapping(bytes32 => Position.Info) positions;` 316 | bytes32 positionMapping = bytes32(uint256(stateSlot) + POSITIONS_OFFSET); 317 | 318 | // slot of the mapping key: `pools[poolId].positions[positionId] 319 | return keccak256(abi.encodePacked(positionId, positionMapping)); 320 | } 321 | } -------------------------------------------------------------------------------- /src/UniCastVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.25; 3 | 4 | import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; 5 | import {PoolKey} from "v4-core/types/PoolKey.sol"; 6 | import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; 7 | import {Position} from "v4-core/libraries/Position.sol"; 8 | import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; 9 | import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; 10 | import {UniswapV4ERC20} from "v4-periphery/libraries/UniswapV4ERC20.sol"; 11 | import {CurrencyLibrary, Currency} from "v4-core/types/Currency.sol"; 12 | import {TickMath} from "v4-core/libraries/TickMath.sol"; 13 | import {FullMath} from "v4-core/libraries/FullMath.sol"; 14 | import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; 15 | import {FixedPoint96} from "v4-core/libraries/FixedPoint96.sol"; 16 | import {LiquidityAmounts} from "v4-periphery/libraries/LiquidityAmounts.sol"; 17 | import {SafeCast} from "v4-core/libraries/SafeCast.sol"; 18 | import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; 19 | import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; 20 | import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 21 | import {TransientStateLibrary} from "v4-core/libraries/TransientStateLibrary.sol"; 22 | import {IUniCastOracle, LiquidityData} from "./interface/IUniCastOracle.sol"; 23 | 24 | abstract contract UniCastVault { 25 | using LPFeeLibrary for uint24; 26 | using PoolIdLibrary for PoolKey; 27 | using StateLibrary for IPoolManager; 28 | using TransientStateLibrary for IPoolManager; 29 | using SafeCast for uint256; 30 | using SafeCast for uint128; 31 | using CurrencyLibrary for Currency; 32 | using SafeERC20 for IERC20; 33 | using FixedPointMathLib for uint256; 34 | using FullMath for uint256; 35 | 36 | event LiquidityAdded(uint256 amount0, uint256 amount1); 37 | event LiquidityRemoved(uint256 amount0, uint256 amount1); 38 | event RebalanceOccurred( 39 | PoolId poolId, 40 | int24 oldLowerTick, 41 | int24 oldUpperTick, 42 | int24 newLowerTick, 43 | int24 newUpperTick 44 | ); 45 | 46 | error PoolNotInitialized(); 47 | error InsufficientInitialLiquidity(); 48 | error SenderMustBeHook(); 49 | error TooMuchSlippage(); 50 | error LiquidityDoesntMeetMinimum(); 51 | 52 | int256 internal constant MAX_INT = type(int256).max; 53 | uint16 internal constant MINIMUM_LIQUIDITY = 1000; 54 | int24 internal minTick; // current active min tick 55 | int24 internal maxTick; // current active maxTick 56 | bytes internal constant ZERO_BYTES = ""; 57 | bool poolRebalancing; 58 | 59 | IPoolManager public immutable manager; 60 | IUniCastOracle public liquidityOracle; 61 | 62 | struct CallbackData { 63 | address sender; 64 | PoolKey key; 65 | IPoolManager.ModifyLiquidityParams params; 66 | bytes hookData; 67 | bool settleUsingBurn; 68 | bool takeClaims; 69 | } 70 | 71 | struct PoolInfo { 72 | UniswapV4ERC20 poolToken; 73 | } 74 | 75 | mapping(PoolId => UniswapV4ERC20) public vaultLiquidityToken; 76 | 77 | constructor( 78 | IPoolManager _poolManager, 79 | IUniCastOracle _oracle, 80 | int24 initialMinTick, 81 | int24 initialMaxTick 82 | ) { 83 | manager = _poolManager; 84 | liquidityOracle = _oracle; 85 | minTick = initialMinTick; 86 | maxTick = initialMaxTick; 87 | } 88 | 89 | /** 90 | * @notice Adds liquidity to the pool. 91 | * @param poolKey The key of the pool. 92 | * @param amount0 The amount of token0 to add. 93 | * @param amount1 The amount of token1 to add. 94 | * @return liquidity The amount of liquidity added. 95 | */ 96 | function addLiquidity( 97 | PoolKey memory poolKey, 98 | uint256 amount0, 99 | uint256 amount1 100 | ) external returns (uint256 liquidity) { 101 | PoolId poolId = poolKey.toId(); 102 | 103 | (uint160 sqrtPriceX96, , , ) = manager.getSlot0(poolId); 104 | 105 | if (sqrtPriceX96 == 0) revert PoolNotInitialized(); 106 | 107 | uint128 poolLiquidity = manager.getLiquidity(poolId); 108 | 109 | // Only supporting one range of liquidity for now 110 | liquidity = LiquidityAmounts.getLiquidityForAmounts( 111 | sqrtPriceX96, 112 | TickMath.getSqrtPriceAtTick(minTick), 113 | TickMath.getSqrtPriceAtTick(maxTick), 114 | amount0, 115 | amount1 116 | ); 117 | 118 | if (poolLiquidity + liquidity < MINIMUM_LIQUIDITY) { 119 | revert LiquidityDoesntMeetMinimum(); 120 | } 121 | 122 | BalanceDelta addedDelta = modifyLiquidity( 123 | poolKey, 124 | IPoolManager.ModifyLiquidityParams({ 125 | tickLower: minTick, 126 | tickUpper: maxTick, 127 | liquidityDelta: liquidity.toInt256(), 128 | salt: 0 129 | }), 130 | ZERO_BYTES, 131 | false 132 | ); 133 | 134 | if (poolLiquidity == 0) { 135 | liquidity -= MINIMUM_LIQUIDITY; 136 | UniswapV4ERC20(vaultLiquidityToken[poolId]).mint( 137 | address(0), 138 | MINIMUM_LIQUIDITY 139 | ); 140 | } 141 | 142 | // mint sender's share of the vault 143 | UniswapV4ERC20(vaultLiquidityToken[poolId]).mint(msg.sender, liquidity); 144 | 145 | if ( 146 | uint128(-addedDelta.amount0()) < amount0 || 147 | uint128(-addedDelta.amount1()) < amount1 148 | ) { 149 | revert TooMuchSlippage(); 150 | } 151 | 152 | emit LiquidityAdded(amount0, amount1); 153 | } 154 | 155 | /** 156 | * @notice Removes liquidity from the pool. 157 | * @param poolKey The key of the pool. 158 | * @param amount0 The amount of token0 to remove. 159 | * @param amount1 The amount of token1 to remove. 160 | */ 161 | function removeLiquidity( 162 | PoolKey memory poolKey, 163 | uint256 amount0, 164 | uint256 amount1 165 | ) external { 166 | PoolId poolId = poolKey.toId(); 167 | 168 | (uint160 sqrtPriceX96, , , ) = manager.getSlot0(poolId); 169 | 170 | if (sqrtPriceX96 == 0) revert PoolNotInitialized(); 171 | 172 | uint128 liquidityToRemove = LiquidityAmounts.getLiquidityForAmounts( 173 | sqrtPriceX96, 174 | TickMath.getSqrtPriceAtTick(minTick), 175 | TickMath.getSqrtPriceAtTick(maxTick), 176 | amount0, 177 | amount1 178 | ); 179 | 180 | BalanceDelta delta = modifyLiquidity( 181 | poolKey, 182 | IPoolManager.ModifyLiquidityParams({ 183 | tickLower: minTick, 184 | tickUpper: maxTick, 185 | liquidityDelta: -(liquidityToRemove.toInt256()), 186 | salt: 0 187 | }), 188 | ZERO_BYTES, 189 | true 190 | ); 191 | 192 | UniswapV4ERC20(vaultLiquidityToken[poolId]).burn( 193 | msg.sender, 194 | uint256(liquidityToRemove) 195 | ); 196 | 197 | if ( 198 | uint128(-delta.amount0()) < amount0 || 199 | uint128(-delta.amount1()) < amount1 200 | ) { 201 | revert TooMuchSlippage(); 202 | } 203 | 204 | emit LiquidityRemoved(amount0, amount1); 205 | } 206 | 207 | /** 208 | * @notice Automatically rebalances the pool if required. Called in afterSwap. 209 | * @param poolKey The key of the pool. 210 | */ 211 | function autoRebalance(PoolKey memory poolKey) public { 212 | ( 213 | bool _rebalanceRequired, 214 | LiquidityData memory liquidityData 215 | ) = rebalanceRequired(poolKey); 216 | if (_rebalanceRequired) { 217 | _rebalance(poolKey, liquidityData); 218 | } 219 | } 220 | 221 | /** 222 | * @notice Checks if rebalancing is required for the pool. 223 | * @param poolKey The key of the pool. 224 | * @return bool True if rebalancing is required, false otherwise. 225 | * @return LiquidityData to shift to 226 | */ 227 | function rebalanceRequired( 228 | PoolKey memory poolKey 229 | ) public view returns (bool, LiquidityData memory) { 230 | PoolId poolId = poolKey.toId(); 231 | 232 | LiquidityData memory liquidityData = liquidityOracle.getLiquidityData( 233 | poolId 234 | ); 235 | if ( 236 | liquidityData.tickLower != minTick || 237 | liquidityData.tickUpper != maxTick 238 | ) { 239 | return (true, liquidityData); 240 | } 241 | return (false, liquidityData); 242 | } 243 | 244 | /** 245 | * @notice Modifies the liquidity of the pool. 246 | * @param poolKey The key of the pool. 247 | * @param params The parameters for modifying liquidity. 248 | * @param hookData Additional data for the hook. 249 | * @param settleUsingBurn Whether to settle using burn. 250 | * @return delta The balance delta after modification. 251 | */ 252 | function modifyLiquidity( 253 | PoolKey memory poolKey, 254 | IPoolManager.ModifyLiquidityParams memory params, 255 | bytes memory hookData, 256 | bool settleUsingBurn 257 | ) internal returns (BalanceDelta delta) { 258 | delta = abi.decode( 259 | manager.unlock( 260 | abi.encode( 261 | CallbackData( 262 | msg.sender, 263 | poolKey, 264 | params, 265 | hookData, 266 | settleUsingBurn, 267 | false 268 | ) 269 | ) 270 | ), 271 | (BalanceDelta) 272 | ); 273 | } 274 | 275 | /** 276 | * @notice Callback function for unlocking the vault, which is called during modifyLiquidity. 277 | * @param rawData The raw data for the callback. 278 | * @return bytes The encoded balance delta. 279 | */ 280 | function _unlockVaultCallback( 281 | bytes calldata rawData 282 | ) internal virtual returns (bytes memory) { 283 | require( 284 | msg.sender == address(manager), 285 | "Callback not called by manager" 286 | ); 287 | 288 | CallbackData memory data = abi.decode(rawData, (CallbackData)); 289 | BalanceDelta delta; 290 | 291 | (delta, ) = manager.modifyLiquidity(data.key, data.params, ZERO_BYTES); 292 | _settleDeltas( 293 | data.sender, 294 | data.key, 295 | delta.amount0(), 296 | delta.amount1(), 297 | data.takeClaims, 298 | data.settleUsingBurn 299 | ); 300 | 301 | return abi.encode(delta); 302 | } 303 | 304 | /** 305 | * @notice Settles the deltas for a given sender and pool key. 306 | * @param sender The address of the sender. 307 | * @param key The key of the pool. 308 | * @param delta0 The balance of currency 0 to settle 309 | * @param delta1 the balance of currency 1 to settle 310 | */ 311 | function _settleDeltas( 312 | address sender, 313 | PoolKey memory key, 314 | int256 delta0, 315 | int256 delta1, 316 | bool takeClaims, 317 | bool settleUsingBurn 318 | ) internal { 319 | if (delta0 < 0) { 320 | _settle(key.currency0, sender, uint256(-delta0), settleUsingBurn); 321 | } 322 | if (delta1 < 0) { 323 | _settle(key.currency1, sender, uint256(-delta1), settleUsingBurn); 324 | } 325 | if (delta0 > 0) { 326 | _take(key.currency0, sender, uint256(delta0), takeClaims); 327 | } 328 | if (delta1 > 0) { 329 | _take(key.currency1, sender, uint256(delta1), takeClaims); 330 | } 331 | } 332 | 333 | /** 334 | * @notice Settles a given amount of currency, paying the pool. 335 | * @param currency The currency to settle. 336 | * @param payer The address of the payer. 337 | * @param amount The amount to settle. 338 | * @param burn Whether to burn the amount. 339 | */ 340 | function _settle( 341 | Currency currency, 342 | address payer, 343 | uint256 amount, 344 | bool burn 345 | ) internal { 346 | if (burn) { 347 | manager.burn(payer, currency.toId(), amount); 348 | } else if (currency.isNative()) { 349 | manager.settle{value: amount}(currency); 350 | } else { 351 | manager.sync(currency); 352 | if (payer != address(this)) { 353 | IERC20(Currency.unwrap(currency)).transferFrom( 354 | payer, 355 | address(manager), 356 | amount 357 | ); 358 | } else { 359 | IERC20(Currency.unwrap(currency)).transfer( 360 | address(manager), 361 | amount 362 | ); 363 | } 364 | manager.settle(currency); 365 | } 366 | } 367 | 368 | /** 369 | * @notice Takes a given amount of currency. 370 | * @param currency The currency to take. 371 | * @param recipient The address of the recipient. 372 | * @param amount The amount to take. 373 | * @param claims Whether to take claims. 374 | */ 375 | function _take( 376 | Currency currency, 377 | address recipient, 378 | uint256 amount, 379 | bool claims 380 | ) internal { 381 | if (claims) { 382 | manager.mint(recipient, currency.toId(), amount); 383 | } else { 384 | manager.take(currency, recipient, amount); 385 | } 386 | } 387 | 388 | /** 389 | * @notice Rebalances the pool. 390 | * @param key The key of the pool. 391 | */ 392 | function _rebalance( 393 | PoolKey memory key, 394 | LiquidityData memory liquidityData 395 | ) internal { 396 | PoolId poolId = key.toId(); 397 | 398 | Position.Info memory position = manager.getPosition( 399 | poolId, 400 | address(this), 401 | minTick, 402 | maxTick, 403 | 0 404 | ); 405 | 406 | uint256 oldLiquidity = uint256(position.liquidity); 407 | 408 | // remove liquidity in position 409 | (BalanceDelta balanceDelta, ) = manager.modifyLiquidity( 410 | key, 411 | IPoolManager.ModifyLiquidityParams({ 412 | tickLower: minTick, 413 | tickUpper: maxTick, 414 | liquidityDelta: -int256(oldLiquidity), 415 | salt: 0 416 | }), 417 | ZERO_BYTES 418 | ); 419 | 420 | // get current price 421 | (uint160 sqrtPriceX96, , , ) = manager.getSlot0(poolId); 422 | 423 | ( 424 | uint256 newLiquidity, 425 | int256 amount0Delta, 426 | int256 amount1Delta 427 | ) = _getLiquidityAndAmounts( 428 | oldLiquidity, 429 | sqrtPriceX96, 430 | liquidityData, 431 | balanceDelta 432 | ); 433 | 434 | if (amount0Delta != 0 && amount1Delta != 0) { 435 | // means amount0 must be sold if true 436 | bool zeroForOne = amount0Delta < 0; 437 | 438 | manager.swap( 439 | key, 440 | IPoolManager.SwapParams({ 441 | zeroForOne: zeroForOne, 442 | amountSpecified: zeroForOne ? amount0Delta : amount1Delta, // how much of the token to sell 443 | // allow for slippage 444 | sqrtPriceLimitX96: zeroForOne 445 | ? TickMath.MIN_SQRT_PRICE + 1 446 | : TickMath.MAX_SQRT_PRICE - 1 447 | }), 448 | abi.encode(false) 449 | ); 450 | 451 | // set optimal liquidity 452 | manager.modifyLiquidity( 453 | key, 454 | IPoolManager.ModifyLiquidityParams({ 455 | tickLower: liquidityData.tickLower, 456 | tickUpper: liquidityData.tickUpper, 457 | liquidityDelta: -int256(newLiquidity), // adding to the pool 458 | salt: 0 459 | }), 460 | ZERO_BYTES 461 | ); 462 | } 463 | 464 | int256 delta0 = manager.currencyDelta(address(this), key.currency0); 465 | int256 delta1 = manager.currencyDelta(address(this), key.currency1); 466 | 467 | _settleDeltas(address(this), key, delta0, delta1, true, false); 468 | emit RebalanceOccurred( 469 | poolId, 470 | minTick, 471 | maxTick, 472 | liquidityData.tickLower, 473 | liquidityData.tickUpper 474 | ); 475 | 476 | minTick = liquidityData.tickLower; 477 | maxTick = liquidityData.tickUpper; 478 | } 479 | 480 | function _getLiquidityAndAmounts( 481 | uint256 oldLiquidity, 482 | uint160 sqrtPriceX96, 483 | LiquidityData memory liquidityData, 484 | BalanceDelta balanceDelta 485 | ) 486 | internal 487 | view 488 | returns (uint256 newLiquidity, int256 amount0Delta, int256 amount1Delta) 489 | { 490 | uint256 sqrtPl = TickMath.getSqrtPriceAtTick(minTick); 491 | uint256 sqrtPu = TickMath.getSqrtPriceAtTick(maxTick); 492 | uint256 sqrtPlNew = TickMath.getSqrtPriceAtTick( 493 | liquidityData.tickLower 494 | ); 495 | uint256 sqrtPuNew = TickMath.getSqrtPriceAtTick( 496 | liquidityData.tickUpper 497 | ); 498 | 499 | // find new liquidity 500 | newLiquidity = _calculateNewLiquidity( 501 | oldLiquidity, 502 | sqrtPl, 503 | sqrtPu, 504 | sqrtPriceX96, 505 | sqrtPlNew, 506 | sqrtPuNew 507 | ); 508 | 509 | // calculate how much of token0 and token1 should be added 510 | (uint256 newAmount0, uint256 newAmount1) = LiquidityAmounts 511 | .getAmountsForLiquidity( 512 | sqrtPriceX96, 513 | sqrtPlNew.toUint160(), 514 | sqrtPuNew.toUint160(), 515 | newLiquidity.toUint128() 516 | ); 517 | 518 | // how much token0 to sell 519 | amount0Delta = int256(newAmount0) - balanceDelta.amount0(); 520 | amount1Delta = int256(newAmount1) - balanceDelta.amount1(); 521 | } 522 | 523 | /** 524 | * See Unicast math doc 525 | */ 526 | function _calculateNewLiquidity( 527 | uint256 L, 528 | uint256 sqrtPl, 529 | uint256 sqrtPu, 530 | uint256 sqrtPc, 531 | uint256 sqrtPlNew, 532 | uint256 sqrtPuNew 533 | ) internal pure returns (uint256) { 534 | // Calculate normal current price, but keep in X96 format 535 | // in order to do operations with the others 536 | uint256 PcX96 = (sqrtPc ** 2) >> FixedPoint96.RESOLUTION; 537 | 538 | // Calculate numerator terms 539 | uint256 numerator = (PcX96 * (sqrtPu - sqrtPl)) / 540 | (sqrtPc * sqrtPu) + 541 | sqrtPc - 542 | sqrtPl; 543 | 544 | // Calculate denominator terms 545 | uint256 denominator = (PcX96 * (sqrtPuNew - sqrtPlNew)) / 546 | (sqrtPc * sqrtPuNew) + 547 | sqrtPc - 548 | sqrtPlNew; 549 | 550 | // Calculate new L 551 | return 552 | (L << FixedPoint96.RESOLUTION).mulDiv(numerator, denominator) >> 553 | FixedPoint96.RESOLUTION; 554 | } 555 | } 556 | --------------------------------------------------------------------------------