// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "@uniswap-v3-periphery/libraries/PositionKey.sol"; import "@uniswap-v3-core/libraries/FixedPoint128.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "@aperture/uni-v3-lib/TickMath.sol"; import {LiquidityAmounts} from "@aperture/uni-v3-lib/LiquidityAmounts.sol"; import "@aperture/uni-v3-lib/PoolAddress.sol"; import "@aperture/uni-v3-lib/CallbackValidation.sol"; import "@openzeppelin/token/ERC20/IERC20.sol"; import "@openzeppelin/utils/math/SignedMath.sol"; import {ABDKMath64x64} from "@abdk/ABDKMath64x64.sol"; import "./interfaces/IWETH9.sol"; import {Harb} from "./Harb.sol"; /** * @title LiquidityManager - A contract that implements an automated market making strategy. * It maintains 3 positions: * - The floor position guarantees the capacity needed to maintain a minimum price of the HARB token It is a very tight liquidity range with enough reserve assets to buy back the circulating supply. * - The anchor range provides liquidity around the current market price, ensuring liquid trading conditions for the token, regardless of the market environment. * - The discovery range starts 500 ticks above the current market price and increases from there. It consists solely of unissued tokens, which are sold as the market price increases. * The liquidity surplus obtained from selling tokens in the discovery range is directed back into the floor and anchor positions. */ contract BaseLineLP { int24 constant TICK_SPACING = 200; int24 constant ANCHOR_SPACING = 5 * TICK_SPACING; int24 constant DISCOVERY_SPACING = 11000; int24 constant MAX_TICK_DEVIATION = 50; // how much is that? // default fee of 1% uint24 constant FEE = uint24(10_000); // uint256 constant FLOOR = 0; // uint256 constant ANCHOR = 1; // uint256 constant DISCOVERY = 2; enum Stage { FLOOR, ANCHOR, DISCOVERY } uint256 constant LIQUIDITY_RATIO_DIVISOR = 100; // the address of the Uniswap V3 factory address immutable factory; IWETH9 immutable weth; Harb immutable harb; IUniswapV3Pool immutable pool; PoolKey private poolKey; bool immutable token0isWeth; struct TokenPosition { // the liquidity of the position uint128 liquidity; int24 tickLower; int24 tickUpper; } // for minting limits uint256 private lastDay; uint256 private mintedToday; uint256 constant ANCHOR_LIQ_SHARE = 5; // 5% uint256 constant CAPITAL_INEFFICIENCY = 120; // State variables to track total ETH spent uint256 public cumulativeVolumeWeightedPrice; uint256 public cumulativeVolume; mapping(Stage => TokenPosition) public positions; address private feeDestination; modifier checkDeadline(uint256 deadline) { require(block.timestamp <= deadline, "Transaction too old"); _; } /// @notice Emitted when liquidity is increased for a position /// @param liquidity The amount by which liquidity for the NFT position was increased /// @param amount0 The amount of token0 that was paid for the increase in liquidity /// @param amount1 The amount of token1 that was paid for the increase in liquidity event IncreaseLiquidity(int24 indexed tickLower, int24 indexed tickUpper, uint128 liquidity, uint256 amount0, uint256 amount1); /// @notice Emitted when liquidity is decreased for a position /// @param liquidity The amount by which liquidity for the NFT position was decreased /// @param ethReceived The amount of WETH that was accounted for the decrease in liquidity event PositionLiquidated(int24 indexed tickLower, int24 indexed tickUpper, uint128 liquidity, uint256 ethReceived); constructor(address _factory, address _WETH9, address _harb) { factory = _factory; weth = IWETH9(_WETH9); poolKey = PoolAddress.getPoolKey(_WETH9, _harb, FEE); pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey)); harb = Harb(_harb); token0isWeth = _WETH9 < _harb; } event UniCallback(uint256 indexed amount0Owed, uint256 indexed amount1Owed); function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { CallbackValidation.verifyCallback(factory, poolKey); // take care of harb harb.mint(token0isWeth ? amount1Owed : amount0Owed); // pack ETH uint256 ethOwed = token0isWeth ? amount0Owed : amount1Owed; if (weth.balanceOf(address(this)) < ethOwed) { weth.deposit{value: address(this).balance}(); } // do transfers if (amount0Owed > 0) IERC20(poolKey.token0).transfer(msg.sender, amount0Owed); if (amount1Owed > 0) IERC20(poolKey.token1).transfer(msg.sender, amount1Owed); } function liquidityPool() external view returns (address) { return address(pool); } function setFeeDestination(address feeDestination_) external { // TODO: add trapdoor require(address(0) != feeDestination_, "zero addr"); feeDestination = feeDestination_; } //TODO: what to do with stuck funds if slide/shift become inoperable? receive() external payable { } function outstanding() public view returns (uint256 _outstanding) { _outstanding = (harb.totalSupply() - harb.balanceOf(address(pool)) - harb.balanceOf(address(this))); } function spendingLimit() public view returns (uint256, uint256) { return (lastDay, mintedToday); } function tokensIn(Stage s) public view returns (uint256 _ethInPosition, uint256 _harbInPosition) { uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(positions[s].tickLower); uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(positions[s].tickUpper); if (token0isWeth) { if (s == Stage.FLOOR) { _ethInPosition = LiquidityAmounts.getAmount0ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity ); _harbInPosition = 0; } else if (s == Stage.ANCHOR) { _ethInPosition = LiquidityAmounts.getAmount0ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity / 2 ); _harbInPosition = LiquidityAmounts.getAmount1ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity / 2 ); } else { _ethInPosition = 0; _harbInPosition = LiquidityAmounts.getAmount1ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity ); } } else { if (s == Stage.FLOOR) { _ethInPosition = LiquidityAmounts.getAmount1ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity ); _harbInPosition = 0; } else if (s == Stage.ANCHOR) { _ethInPosition = LiquidityAmounts.getAmount1ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity / 2 ); _harbInPosition = LiquidityAmounts.getAmount0ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity / 2 ); } else { _ethInPosition = 0; _harbInPosition = LiquidityAmounts.getAmount0ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, positions[s].liquidity ); } } } uint160 internal constant MIN_SQRT_RATIO = 4295128739; function tickAtPrice(uint256 tokenAmount, uint256 ethAmount) internal view returns (int24 tick_) { require(ethAmount > 0, "ETH amount cannot be zero"); uint160 sqrtPriceX96; if (tokenAmount == 0) { sqrtPriceX96 = MIN_SQRT_RATIO; } else { // Use a fixed-point library or more precise arithmetic for the division here. // For example, using ABDKMath64x64 for a more precise division and square root calculation. int128 priceRatio = ABDKMath64x64.div( int128(int256(tokenAmount)), int128(int256(ethAmount)) ); // Convert the price ratio into a sqrt price in the format expected by Uniswap's TickMath. sqrtPriceX96 = uint160( int160(ABDKMath64x64.sqrt(priceRatio) << 32) ); } // Proceed as before. tick_ = TickMath.getTickAtSqrtRatio(sqrtPriceX96); tick_ = tick_ / TICK_SPACING * TICK_SPACING; tick_ = token0isWeth ? tick_ : -tick_; } function _mint(Stage stage, int24 tickLower, int24 tickUpper, uint128 liquidity) internal { // create position pool.mint( address(this), tickLower, tickUpper, liquidity, abi.encode(poolKey) ); // put into storage positions[stage] = TokenPosition({ liquidity: liquidity, tickLower: tickLower, tickUpper: tickUpper }); } // Calculate current VWAP function calculateVWAP() public view returns (uint256) { if (cumulativeVolume == 0) return 0; return cumulativeVolumeWeightedPrice / cumulativeVolume; } function _set(uint160 sqrtPriceX96, int24 currentTick) internal { // ### set Floor position int24 vwapTick; { uint256 outstandingSupply = outstanding(); uint256 vwap = 0; uint256 requiredEthForBuyback = 0; if (cumulativeVolume > 0) { vwap = cumulativeVolumeWeightedPrice / cumulativeVolume; requiredEthForBuyback = outstandingSupply / vwap * 10**18; } uint256 ethBalance = (address(this).balance + weth.balanceOf(address(this))); // leave at least x% of supply for anchor ethBalance = ethBalance * (100 - ANCHOR_LIQ_SHARE) / 100; if (ethBalance < requiredEthForBuyback) { // not enough ETH, find a lower price requiredEthForBuyback = ethBalance; vwapTick = tickAtPrice(outstandingSupply * CAPITAL_INEFFICIENCY / 100, requiredEthForBuyback); } else if (vwap == 0) { requiredEthForBuyback = ethBalance; vwapTick = currentTick; } else { vwapTick = tickAtPrice(cumulativeVolumeWeightedPrice * CAPITAL_INEFFICIENCY / 100 / 10**18, cumulativeVolume); if (requiredEthForBuyback < ethBalance) { // invest a majority of the ETH still in floor, even though not needed requiredEthForBuyback = (requiredEthForBuyback + (5 * ethBalance)) / 6; } } // move floor below anchor, if needed if (token0isWeth) { vwapTick = (vwapTick < currentTick + ANCHOR_SPACING) ? currentTick + ANCHOR_SPACING : vwapTick; } else { vwapTick = (vwapTick > currentTick - ANCHOR_SPACING) ? currentTick - ANCHOR_SPACING : vwapTick; } // normalize tick position for pool vwapTick = vwapTick / TICK_SPACING * TICK_SPACING; int24 floorTick = token0isWeth ? vwapTick + TICK_SPACING: vwapTick - TICK_SPACING; // calculate liquidity uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(vwapTick); uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(floorTick); uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, token0isWeth ? requiredEthForBuyback : 0, token0isWeth ? 0 : requiredEthForBuyback ); // mint _mint(Stage.FLOOR, token0isWeth ? vwapTick : floorTick, token0isWeth ? floorTick : vwapTick, liquidity); } // ### set Anchor position uint128 anchorLiquidity; { int24 tickLower = token0isWeth ? currentTick - ANCHOR_SPACING : vwapTick; int24 tickUpper = token0isWeth ? vwapTick : currentTick + ANCHOR_SPACING; uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower); uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper); uint256 ethBalance = (address(this).balance + weth.balanceOf(address(this))); if (token0isWeth) { anchorLiquidity = LiquidityAmounts.getLiquidityForAmount0( sqrtRatioAX96, sqrtRatioBX96, ethBalance ); } else { anchorLiquidity = LiquidityAmounts.getLiquidityForAmount1( sqrtRatioAX96, sqrtRatioBX96, ethBalance ); } tickLower = tickLower / TICK_SPACING * TICK_SPACING; tickUpper = tickUpper / TICK_SPACING * TICK_SPACING; _mint(Stage.ANCHOR, tickLower, tickUpper, anchorLiquidity); } currentTick = currentTick / TICK_SPACING * TICK_SPACING; // ## set Discovery position { int24 tickLower = token0isWeth ? currentTick - DISCOVERY_SPACING - ANCHOR_SPACING : currentTick + ANCHOR_SPACING; int24 tickUpper = token0isWeth ? currentTick - ANCHOR_SPACING : currentTick + DISCOVERY_SPACING + ANCHOR_SPACING; uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower); uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper); // discovery with 1.5 times as much liquidity per tick as anchor // A * 3 11000 A * 55 // D = ----- * ----- = ------ // 2 600 2 uint128 liquidity = anchorLiquidity * 55 / 2; uint256 harbInDiscovery; if (token0isWeth) { harbInDiscovery = LiquidityAmounts.getAmount0ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, liquidity ); } else { harbInDiscovery = LiquidityAmounts.getAmount1ForLiquidity( sqrtRatioAX96, sqrtRatioBX96, liquidity ); } harb.mint(harbInDiscovery); _mint(Stage.DISCOVERY, tickLower, tickUpper, liquidity); harb.burn(harb.balanceOf(address(this))); } } function tickToPrice(int24 tick) public pure returns (uint256) { uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(tick); // Convert the sqrt price to price using fixed point arithmetic // sqrtPriceX96 is a Q64.96 format (96 fractional bits) // price = (sqrtPriceX96 ** 2) / 2**192 // To avoid overflow, perform the division by 2**96 first before squaring uint256 price = uint256(sqrtPriceX96) / (1 << 48); // Reducing the scale before squaring return price * price; } function _scrape() internal { uint256 fee0 = 0; uint256 fee1 = 0; uint256 currentPrice; for (uint256 i=uint256(Stage.FLOOR); i <= uint256(Stage.DISCOVERY); i++) { TokenPosition storage position = positions[Stage(i)]; if (position.liquidity > 0) { (uint256 amount0, uint256 amount1) = pool.burn(position.tickLower, position.tickUpper, position.liquidity); // Collect the maximum possible amounts which include fees (uint256 collected0, uint256 collected1) = pool.collect( address(this), position.tickLower, position.tickUpper, type(uint128).max, // Collect the max uint128 value, effectively trying to collect all type(uint128).max ); // Calculate the fees fee0 += collected0 - amount0; fee1 += collected1 - amount1; if (i == uint256(Stage.ANCHOR)) { int24 priceTick = position.tickLower + (position.tickUpper - position.tickLower); currentPrice = tickToPrice(priceTick); } } } // Transfer fees to the fee destination // and record transaction totals if (fee0 > 0) { if (token0isWeth) { IERC20(address(weth)).transfer(feeDestination, fee0); uint256 volume = fee0 * 100; uint256 volumeWeightedPrice = currentPrice * volume; cumulativeVolumeWeightedPrice += volumeWeightedPrice; cumulativeVolume += volume; } else { IERC20(address(harb)).transfer(feeDestination, fee0); } } if (fee1 > 0) { if (token0isWeth) { IERC20(address(harb)).transfer(feeDestination, fee1); } else { IERC20(address(weth)).transfer(feeDestination, fee1); uint256 volume = fee1 * 100; uint256 volumeWeightedPrice = currentPrice * volume; cumulativeVolumeWeightedPrice += volumeWeightedPrice; cumulativeVolume += volume; } } } function _isPriceStable(int24 currentTick) internal view returns (bool) { uint32 timeInterval = 300; // 5 minutes in seconds uint32[] memory secondsAgo = new uint32[](2); secondsAgo[0] = timeInterval; // 5 minutes ago secondsAgo[1] = 0; // current block timestamp (int56[] memory tickCumulatives,) = pool.observe(secondsAgo); int56 tickCumulativeDiff = tickCumulatives[1] - tickCumulatives[0]; int24 averageTick = int24(tickCumulativeDiff / int56(int32(timeInterval))); return (currentTick >= averageTick - MAX_TICK_DEVIATION && currentTick <= averageTick + MAX_TICK_DEVIATION); } // call this function when price has moved up 15% // TODO: write a bot that calls this function regularly function shift() external { require(positions[Stage.ANCHOR].liquidity > 0, "Not initialized"); // Fetch the current tick from the Uniswap V3 pool (uint160 sqrtPriceX96, int24 currentTick, , , , , ) = pool.slot0(); // check slippage with oracle require(_isPriceStable(currentTick), "price deviated from oracle"); // ## check price moved up { // Check if current tick is within the specified range int24 anchorTickLower = positions[Stage.ANCHOR].tickLower; int24 anchorTickUpper = positions[Stage.ANCHOR].tickUpper; // center tick can be calculated positive and negative numbers the same int24 centerTick = anchorTickLower + ((anchorTickUpper - anchorTickLower) / 2); uint256 minAmplitude = uint256(uint24((anchorTickUpper - anchorTickLower) * 3 / 20)); // Determine the correct comparison direction based on token0isWeth bool isUp = token0isWeth ? currentTick < centerTick : currentTick > centerTick; bool isEnough = SignedMath.abs(currentTick - centerTick) > minAmplitude; // Check Conditions require(isUp, "call slide(), not shift()"); require(isEnough, "amplitude not reached, come back later!"); } // ## scrape positions _scrape(); harb.setPreviousTotalSupply(harb.totalSupply()); _set(sqrtPriceX96, currentTick); } function slide() external { // Fetch the current tick from the Uniswap V3 pool (uint160 sqrtPriceX96, int24 currentTick, , , , , ) = pool.slot0(); // check slippage with oracle require(_isPriceStable(currentTick), "price deviated from oracle"); // ## check price moved down if (positions[Stage.ANCHOR].liquidity > 0) { // Check if current tick is within the specified range int24 anchorTickLower = positions[Stage.ANCHOR].tickLower; int24 anchorTickUpper = positions[Stage.ANCHOR].tickUpper; // center tick can be calculated positive and negative numbers the same int24 centerTick = anchorTickLower + ((anchorTickUpper - anchorTickLower) / 2); uint256 minAmplitude = uint256(uint24((anchorTickUpper - anchorTickLower) * 3 / 20)); // Determine the correct comparison direction based on token0isWeth bool isDown = token0isWeth ? currentTick > centerTick : currentTick < centerTick; bool isEnough = SignedMath.abs(currentTick - centerTick) > minAmplitude; // Check Conditions require(isDown, "call shift(), not slide()"); require(isEnough, "amplitude not reached, diamond hands!"); } _scrape(); _set(sqrtPriceX96, currentTick); } }