// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import { Kraiken } from "./Kraiken.sol"; import { IOptimizer, BEAR_CAPITAL_INEFFICIENCY, BEAR_ANCHOR_SHARE, BEAR_ANCHOR_WIDTH, BEAR_DISCOVERY_DEPTH } from "./IOptimizer.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 Upper bound (inclusive) for scale-1 optimizer parameters: capitalInefficiency, /// anchorShare, and discoveryDepth. Values above this ceiling are silently clamped. uint256 internal constant MAX_PARAM_SCALE = 10 ** 18; /// @notice Immutable contract references address private immutable factory; IWETH9 private immutable weth; Kraiken private immutable kraiken; IOptimizer 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 feeDestination; bool public feeDestinationLocked; /// @notice Last recenter tick — used to determine net trade direction between recenters int24 public lastRecenterTick; /// @notice Last recenter timestamp — rate limits recenters. uint256 public lastRecenterTime; /// @notice Minimum seconds between recenters uint256 internal constant MIN_RECENTER_INTERVAL = 60; /// @notice Target observation cardinality requested from the pool during construction uint16 internal constant ORACLE_CARDINALITY = 100; /// @notice Emitted on each successful recenter for monitoring and indexing event Recentered(int24 indexed currentTick, bool indexed isUp); /// @notice Emitted whenever feeDestination is updated event FeeDestinationSet(address indexed newDest); /// @notice Emitted when the fee destination lock is permanently engaged event FeeDestinationLocked(address indexed dest); /// @notice Custom errors error ZeroAddressInSetter(); /// @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 = IOptimizer(_optimizer); // Increase observation cardinality so pool.observe() has sufficient history // for _isPriceStable() TWAP checks. pool.increaseObservationCardinalityNext(ORACLE_CARDINALITY); } /// @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 (deployer only). /// @dev Trapdoor lock: EOA addresses can be set repeatedly (enables staged deployment/migration) /// until the lock is triggered. The lock fires in two ways: /// /// 1. Direct assignment: setting feeDestination to a contract address (code.length > 0) /// immediately and permanently locks further changes. This is the expected production /// path — set an EOA during development, upgrade to a treasury contract when ready. /// /// 2. Defensive CREATE2 guard: if the current feeDestination was an EOA when set but has /// since acquired bytecode (e.g. via CREATE2 deployment), the next call to this /// function permanently sets feeDestinationLocked = true and returns WITHOUT reverting. /// Not reverting is intentional: only a successful transaction commits the lock to /// storage, so the lock survives a subsequent SELFDESTRUCT that would otherwise clear /// the bytecode evidence and re-open the assignment window. /// /// Remaining limitation: an atomic CREATE2+SELFDESTRUCT within a single transaction /// (still possible post-EIP-6780 for contracts that deploy and destroy in one call) /// cannot be detected here because the bytecode is already absent when this function /// executes. No on-chain guard can close that sub-transaction window. /// @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(); // Defensive CREATE2 guard: if the current destination has acquired bytecode since being // set as an EOA, lock permanently and return WITHOUT reverting so the write is committed // to storage. A subsequent SELFDESTRUCT clears the bytecode but cannot undo this write. if (!feeDestinationLocked && feeDestination != address(0) && feeDestination.code.length > 0) { feeDestinationLocked = true; emit FeeDestinationLocked(feeDestination); return; } require(!feeDestinationLocked, "fee destination locked"); feeDestination = feeDestination_; emit FeeDestinationSet(feeDestination_); if (feeDestination_.code.length > 0) { feeDestinationLocked = true; emit FeeDestinationLocked(feeDestination_); } } /// @notice Adjusts liquidity positions in response to price movements. /// Callable by anyone. Always enforces cooldown and TWAP price stability. /// This function either completes a full recenter (removing all positions, /// recording VWAP where applicable, and redeploying liquidity) or reverts — /// it never returns silently without acting. /// /// @dev Revert conditions (no silent false return for failure): /// - "recenter cooldown" — MIN_RECENTER_INTERVAL has not elapsed since last recenter /// - "price deviated from oracle" — price is outside TWAP bounds (manipulation guard) /// - "amplitude not reached." — anchor position exists but price has not moved far enough /// from the anchor centre to warrant repositioning /// /// @return isUp True if the KRK price in ETH rose since the last recenter /// (buy event / net ETH inflow), regardless of token0/token1 ordering. /// False if the KRK price fell, or if no anchor position existed prior /// to this recenter (bootstrap case, no directional reference point). /// Both values indicate a successful, fully-executed recenter. function recenter() external returns (bool isUp) { (, int24 currentTick,,,,,) = pool.slot0(); // Always enforce cooldown and TWAP price stability — no bypass path require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown"); require(_isPriceStable(currentTick), "price deviated from oracle"); lastRecenterTime = 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 { // 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); } lastRecenterTick = currentTick; _scrapePositions(shouldRecordVWAP, currentTick); // 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 > MAX_PARAM_SCALE) ? MAX_PARAM_SCALE : capitalInefficiency, anchorShare: (anchorShare > MAX_PARAM_SCALE) ? MAX_PARAM_SCALE : anchorShare, anchorWidth: (anchorWidth > MAX_ANCHOR_WIDTH) ? MAX_ANCHOR_WIDTH : anchorWidth, discoveryDepth: (discoveryDepth > MAX_PARAM_SCALE) ? MAX_PARAM_SCALE : discoveryDepth }); _setPositions(currentTick, params); } catch { // Fallback to safe bear-mode defaults if optimizer fails PositionParams memory defaultParams = PositionParams({ capitalInefficiency: BEAR_CAPITAL_INEFFICIENCY, anchorShare: BEAR_ANCHOR_SHARE, anchorWidth: BEAR_ANCHOR_WIDTH, discoveryDepth: BEAR_DISCOVERY_DEPTH }); _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 currentTick The current pool tick at time of recenter, used as the VWAP price sample function _scrapePositions(bool recordVWAP, int24 currentTick) internal { uint256 fee0 = 0; uint256 fee1 = 0; // Price at current tick: volume-weighted, sampled once per recenter. // token0isWeth: tick represents KRK/ETH — negate for price in ETH per KRK terms. uint256 currentPrice = _priceAtTick(token0isWeth ? -1 * currentTick : currentTick); 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; } } // 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 or unset — fees accrue as deployable liquidity. if (feeDestination != address(this) && feeDestination != address(0)) { 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 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(); // Guard against double-subtraction: if stakingPoolAddr == feeDestination, // that balance was already deducted above. if (stakingPoolAddr != address(0) && stakingPoolAddr != feeDestination) { supply -= kraiken.balanceOf(stakingPoolAddr); } return supply; } }