// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import { Kraiken } from "./Kraiken.sol"; import { Optimizer } from "./Optimizer.sol"; import { PriceOracle } from "./abstracts/PriceOracle.sol"; import { ThreePositionStrategy } from "./abstracts/ThreePositionStrategy.sol"; import { IWETH9 } from "./interfaces/IWETH9.sol"; import { CallbackValidation } from "@aperture/uni-v3-lib/CallbackValidation.sol"; import { PoolAddress, PoolKey } from "@aperture/uni-v3-lib/PoolAddress.sol"; import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import { PositionKey } from "@uniswap-v3-periphery/libraries/PositionKey.sol"; /** * @title LiquidityManager * @notice Manages liquidity provisioning on Uniswap V3 using the three-position anti-arbitrage strategy * @dev Inherits from modular contracts for better separation of concerns and testability * * Key features: * - Three-position anti-arbitrage strategy (ANCHOR, DISCOVERY, FLOOR) * - Dynamic parameter adjustment via Optimizer contract * - Asymmetric slippage profile prevents profitable arbitrage * - Exclusive minting rights for KRAIKEN token * * Price Validation: * - 5-minute TWAP with 50-tick tolerance * - Prevents oracle manipulation attacks */ contract LiquidityManager is ThreePositionStrategy, PriceOracle { using SafeERC20 for IERC20; /// @notice Uniswap V3 fee tier (1%) - 10,000 basis points uint24 internal constant FEE = uint24(10_000); /// @notice Immutable contract references address private immutable factory; IWETH9 private immutable weth; Kraiken private immutable kraiken; Optimizer private immutable optimizer; IUniswapV3Pool private immutable pool; bool private immutable token0isWeth; PoolKey private poolKey; /// @notice Access control and fee management address private immutable deployer; address public recenterAccess; address public feeDestination; /// @notice Last recenter tick — used to determine net trade direction between recenters int24 public lastRecenterTick; /// @notice Last recenter timestamp — rate limits open (permissionless) recenters uint256 public lastRecenterTime; /// @notice Minimum seconds between open recenters (when recenterAccess is unset) uint256 internal constant MIN_RECENTER_INTERVAL = 60; /// @notice Last recenter block timestamp — used to compute elapsed interval for pool TWAP oracle uint256 public lastRecenterTimestamp; /// @notice Emitted on each successful recenter for monitoring and indexing event Recentered(int24 indexed currentTick, bool indexed isUp); /// @notice Custom errors error ZeroAddressInSetter(); error AddressAlreadySet(); /// @notice Access control modifier modifier onlyFeeDestination() { require(msg.sender == address(feeDestination), "only callable by feeDestination"); _; } /// @notice Constructor initializes all contract references and pool configuration /// @param _factory The address of the Uniswap V3 factory /// @param _WETH9 The address of the WETH contract /// @param _kraiken The address of the Kraiken token contract /// @param _optimizer The address of the optimizer contract constructor(address _factory, address _WETH9, address _kraiken, address _optimizer) { deployer = msg.sender; factory = _factory; weth = IWETH9(_WETH9); poolKey = PoolAddress.getPoolKey(_WETH9, _kraiken, FEE); pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey)); kraiken = Kraiken(_kraiken); token0isWeth = _WETH9 < _kraiken; optimizer = Optimizer(_optimizer); // Increase observation cardinality so pool.observe() has sufficient history // for TWAP calculations between recenters. IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey)).increaseObservationCardinalityNext(100); } /// @notice Callback function for Uniswap V3 mint operations /// @param amount0Owed Amount of token0 owed for the liquidity provision /// @param amount1Owed Amount of token1 owed for the liquidity provision function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { CallbackValidation.verifyCallback(factory, poolKey); // Handle KRAIKEN minting - use existing balance first, then mint only the difference uint256 kraikenPulled = token0isWeth ? amount1Owed : amount0Owed; uint256 kraikenBalance = kraiken.balanceOf(address(this)); if (kraikenBalance < kraikenPulled) { kraiken.mint(kraikenPulled - kraikenBalance); } // Handle WETH conversion uint256 ethOwed = token0isWeth ? amount0Owed : amount1Owed; if (weth.balanceOf(address(this)) < ethOwed) { weth.deposit{ value: address(this).balance }(); } // Transfer tokens to pool if (amount0Owed > 0) IERC20(poolKey.token0).safeTransfer(msg.sender, amount0Owed); if (amount1Owed > 0) IERC20(poolKey.token1).safeTransfer(msg.sender, amount1Owed); } /// @notice Sets the fee destination address (can only be called once, deployer only) /// @param feeDestination_ The address that will receive trading fees function setFeeDestination(address feeDestination_) external { require(msg.sender == deployer, "only deployer"); if (address(0) == feeDestination_) revert ZeroAddressInSetter(); if (feeDestination != address(0)) revert AddressAlreadySet(); feeDestination = feeDestination_; } /// @notice Sets recenter access for testing/emergency purposes /// @param addr Address to grant recenter access function setRecenterAccess(address addr) external onlyFeeDestination { recenterAccess = addr; } /// @notice Revokes recenter access function revokeRecenterAccess() external onlyFeeDestination { recenterAccess = address(0); } /// @notice Adjusts liquidity positions in response to price movements /// @return isUp True if price moved up (relative to token ordering) function recenter() external returns (bool isUp) { (, int24 currentTick,,,,,) = pool.slot0(); // Validate access and price stability if (recenterAccess != address(0)) { require(msg.sender == recenterAccess, "access denied"); } else { require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown"); require(_isPriceStable(currentTick), "price deviated from oracle"); } uint256 prevTimestamp = lastRecenterTimestamp; lastRecenterTime = block.timestamp; lastRecenterTimestamp = block.timestamp; // Check if price movement is sufficient for recentering isUp = false; if (positions[Stage.ANCHOR].liquidity > 0) { int24 anchorTickLower = positions[Stage.ANCHOR].tickLower; int24 anchorTickUpper = positions[Stage.ANCHOR].tickUpper; int24 centerTick = anchorTickLower + (anchorTickUpper - anchorTickLower) / 2; bool isEnough; (isUp, isEnough) = _validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth); require(isEnough, "amplitude not reached."); } // Remove all existing positions and collect fees // Pass tick direction to determine if VWAP should record. // CRITICAL: record VWAP only when price FALLS (sells / ETH outflow), never when it rises. // If we recorded during buy events, an adversary could run N buy-recenter cycles to push // VWAP upward toward the inflated price. When VWAP ≈ current tick, mirrorTick ≈ currentTick, // so the floor is placed near the inflated price — crystallising IL when the adversary sells. // Freezing VWAP during buy-only cycles keeps the floor anchored to the historical baseline. bool shouldRecordVWAP; if (cumulativeVolume == 0) { // No VWAP data yet — always bootstrap to prevent vwapX96=0 fallback shouldRecordVWAP = true; } else if (lastRecenterTick != 0) { // token0isWeth: tick UP = price down in KRK terms = sells = ETH outflow // !token0isWeth: tick DOWN = price down in KRK terms = sells = ETH outflow // Only record when price falls — VWAP stays anchored to historical levels during buy attacks. shouldRecordVWAP = token0isWeth ? (currentTick > lastRecenterTick) : (currentTick < lastRecenterTick); } else { // First recenter — no reference point, record conservatively shouldRecordVWAP = true; } lastRecenterTick = currentTick; _scrapePositions(shouldRecordVWAP, prevTimestamp); // Update total supply tracking if price moved up if (isUp) { kraiken.setPreviousTotalSupply(kraiken.totalSupply()); } // Get optimizer parameters and set new positions try optimizer.getLiquidityParams() returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) { // Clamp parameters to valid ranges PositionParams memory params = PositionParams({ capitalInefficiency: (capitalInefficiency > 10 ** 18) ? 10 ** 18 : capitalInefficiency, anchorShare: (anchorShare > 10 ** 18) ? 10 ** 18 : anchorShare, anchorWidth: (anchorWidth > 100) ? 100 : anchorWidth, discoveryDepth: (discoveryDepth > 10 ** 18) ? 10 ** 18 : discoveryDepth }); _setPositions(currentTick, params); } catch { // Fallback to safe bear-mode defaults if optimizer fails PositionParams memory defaultParams = PositionParams({ capitalInefficiency: 0, // CI=0 proven safest anchorShare: 3e17, // 30% — defensive floor allocation anchorWidth: 100, // Max width — avoids AW 30-90 kill zone discoveryDepth: 3e17 // 0.3e18 }); _setPositions(currentTick, defaultParams); } emit Recentered(currentTick, isUp); } /// @notice Removes all positions and collects fees /// @param recordVWAP Whether to record VWAP (only when net ETH outflow / price fell since last recenter, or at bootstrap) /// @param prevTimestamp The block.timestamp of the previous recenter, used to compute TWAP interval function _scrapePositions(bool recordVWAP, uint256 prevTimestamp) 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) { // Burn liquidity and collect tokens + fees (uint256 amount0, uint256 amount1) = pool.burn(position.tickLower, position.tickUpper, position.liquidity); (uint256 collected0, uint256 collected1) = pool.collect(address(this), position.tickLower, position.tickUpper, type(uint128).max, type(uint128).max); // Calculate fees fee0 += collected0 - amount0; fee1 += collected1 - amount1; // Record price from anchor position for VWAP using pool TWAP oracle. // Falls back to anchor midpoint when elapsed == 0 or pool.observe() reverts. if (i == uint256(Stage.ANCHOR)) { currentPrice = _getTWAPOrFallback(prevTimestamp, position.tickLower, position.tickUpper); } } } // Transfer fees and record volume for VWAP // VWAP is recorded only on sell events (price fell) or at bootstrap — see recenter(). // Skip transfer when feeDestination is self — fees accrue as deployable liquidity. if (feeDestination != address(this)) { if (fee0 > 0) { if (token0isWeth) { IERC20(address(weth)).safeTransfer(feeDestination, fee0); } else { IERC20(address(kraiken)).safeTransfer(feeDestination, fee0); } } if (fee1 > 0) { if (token0isWeth) { IERC20(address(kraiken)).safeTransfer(feeDestination, fee1); } else { IERC20(address(weth)).safeTransfer(feeDestination, fee1); } } } // Always record VWAP regardless of fee destination if (recordVWAP) { uint256 ethFee = token0isWeth ? fee0 : fee1; if (ethFee > 0) _recordVolumeAndPrice(currentPrice, ethFee); } } /// @notice Computes price using pool TWAP oracle between prevTimestamp and now. /// @dev Falls back to anchor midpoint when the interval is zero or pool.observe() reverts /// (e.g. insufficient observation history on first recenter or very short intervals). /// @param prevTimestamp Timestamp of the previous recenter (0 on first recenter) /// @param tickLower Lower tick of the anchor position (used for fallback midpoint) /// @param tickUpper Upper tick of the anchor position (used for fallback midpoint) /// @return priceX96 Price in Q96 format (price * 2^96) function _getTWAPOrFallback(uint256 prevTimestamp, int24 tickLower, int24 tickUpper) internal view returns (uint256 priceX96) { // Only attempt TWAP when there is a measurable elapsed interval if (prevTimestamp > 0 && block.timestamp > prevTimestamp) { uint32 elapsed = uint32(block.timestamp - prevTimestamp); uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = elapsed; secondsAgos[1] = 0; try pool.observe(secondsAgos) returns (int56[] memory tickCumulatives, uint160[] memory) { int24 twapTick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(int32(elapsed))); return _priceAtTick(token0isWeth ? -1 * twapTick : twapTick); } catch { // Observation history not deep enough — fall through to anchor midpoint } } // Fallback: anchor midpoint (original single-snapshot behaviour) int24 tick = tickLower + ((tickUpper - tickLower) / 2); priceX96 = _priceAtTick(token0isWeth ? -1 * tick : tick); } /// @notice Allow contract to receive ETH receive() external payable { } // ======================================== // ABSTRACT FUNCTION IMPLEMENTATIONS // ======================================== /// @notice Implementation of abstract function from PriceOracle function _getPool() internal view override returns (IUniswapV3Pool) { return pool; } /// @notice Implementation of abstract function from ThreePositionStrategy function _getKraikenToken() internal view override returns (address) { return address(kraiken); } /// @notice Implementation of abstract function from ThreePositionStrategy function _getWethToken() internal view override returns (address) { return address(weth); } /// @notice Implementation of abstract function from ThreePositionStrategy function _isToken0Weth() internal view override returns (bool) { return token0isWeth; } /// @notice Implementation of abstract function from ThreePositionStrategy function _mintPosition(Stage stage, int24 tickLower, int24 tickUpper, uint128 liquidity) internal override { pool.mint(address(this), tickLower, tickUpper, liquidity, abi.encode(poolKey)); positions[stage] = TokenPosition({ liquidity: liquidity, tickLower: tickLower, tickUpper: tickUpper }); } /// @notice Implementation of abstract function from ThreePositionStrategy function _getEthBalance() internal view override returns (uint256) { return address(this).balance + weth.balanceOf(address(this)); } /// @notice Implementation of abstract function from ThreePositionStrategy /// @dev Subtracts KRK at feeDestination (protocol revenue) and stakingPool (locked in staking) /// since neither can be sold into the floor — only trader-held KRK matters for scarcity function _getOutstandingSupply() internal view override returns (uint256) { uint256 supply = kraiken.outstandingSupply(); // Skip subtraction when feeDestination is self: outstandingSupply() already // excludes kraiken.balanceOf(address(this)), so subtracting again would double-count. if (feeDestination != address(0) && feeDestination != address(this)) { supply -= kraiken.balanceOf(feeDestination); } (, address stakingPoolAddr) = kraiken.peripheryContracts(); if (stakingPoolAddr != address(0)) { supply -= kraiken.balanceOf(stakingPoolAddr); } return supply; } }