// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import { IOptimizer, OptimizerInput, BEAR_CAPITAL_INEFFICIENCY, BEAR_ANCHOR_SHARE, BEAR_ANCHOR_WIDTH, BEAR_DISCOVERY_DEPTH } from "./IOptimizer.sol"; import { Kraiken } from "./Kraiken.sol"; import { Stake } from "./Stake.sol"; import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; import { Initializable } from "@openzeppelin/proxy/utils/Initializable.sol"; import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol"; import { Math } from "@openzeppelin/utils/math/Math.sol"; // --------------------------------------------------------------------------- // Dyadic rational interface — Push3's native number format. // Represents: mantissa × 2^(-shift). // _toDyadic wraps an on-chain value with shift=0 (value == mantissa). // --------------------------------------------------------------------------- // Minimal interface for VWAPTracker (slots 2-4 computation) interface IVWAPTracker { function getVWAP() external view returns (uint256); } // Minimal interface for Uniswap V3 pool (slots 2-4, 6 computation) interface IUniswapV3PoolSlot0 { function slot0() external view returns ( uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked ); } // Minimal interface for pool TWAP observations (slots 3-4 computation) interface IUniswapV3PoolObserve { function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); } // Minimal interface for LiquidityManager position data (slot 6 computation) interface ILiquidityManagerPositions { function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper); } /** * @title Optimizer * @notice Calculates liquidity parameters for the LiquidityManager using an * 8-slot dyadic rational input interface (Push3's native format). * * @dev Upgradeable (UUPS). The core logic lives in `calculateParams`, which is * a pure function taking an OptimizerInput[8] array. Future upgrades may * replace `calculateParams` with a transpiled Push3 program via the * evolution pipeline (#544, #545, #546). * * Input slots (all values in [0, 1e18] — uniform range makes evolution feasible): * 0 percentageStaked 0..1e18 % of supply staked (Stake.getPercentageStaked()) * 1 averageTaxRate 0..1e18 Normalized tax rate (Stake.getAverageTaxRate()) * 2 pricePosition 0..1e18 Current price vs VWAP ± PRICE_BOUND_TICKS. * 0 = at lower bound, 0.5e18 = at VWAP, 1e18 = at upper bound. * (0 if vwapTracker/pool not configured or no VWAP data yet) * 3 volatility 0..1e18 Normalized recent price volatility: |shortTwap - longTwap| * ticks / MAX_VOLATILITY_TICKS, capped at 1e18. * (0 if pool not configured or insufficient TWAP history) * 4 momentum 0..1e18 Price trend: 0 = strongly falling, 0.5e18 = flat, * 1e18 = strongly rising. Derived from short vs long TWAP. * (0 if pool not configured or insufficient TWAP history) * 5 timeSinceRecenter 0..1e18 Normalized time since last recenter. * 0 = just recentered, 1e18 = MAX_STALE_SECONDS elapsed. * (0 if recordRecenter has never been called) * 6 utilizationRate 0..1e18 1e18 if current tick is within anchor position range, * 0 otherwise. (0 if liquidityManager/pool not configured) * 7 reserved 0 Future use. * * Four optimizer outputs (0..1e18 fractions unless noted): * capitalInefficiency capital buffer level * anchorShare fraction of non-floor ETH in anchor * anchorWidth anchor position width (tick units, uint24) * discoveryDepth discovery liquidity density */ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } Kraiken private kraiken; Stake private stake; // ---- Extended data sources for input slots 2-6 ---- // These are optional; unset addresses leave the corresponding slots as 0. address public vwapTracker; // slots 2-4 source (VWAPTracker) address public pool; // slots 2-4, 6 source (Uniswap V3 pool) uint256 public lastRecenterTimestamp; // slot 5 source (updated via recordRecenter) address public recenterRecorder; // authorized to call recordRecenter address public liquidityManager; // slot 6 source (LiquidityManager positions) bool public token0isWeth; // true when WETH is token0 in the pool (flips tick direction) // ---- Normalization constants ---- /// @notice Half-width in ticks for pricePosition normalization. /// pricePosition = 0 at (vwapTick - PRICE_BOUND_TICKS), 1e18 at (vwapTick + PRICE_BOUND_TICKS). /// 11 000 ticks ≈ the discovery position half-width (3× price from anchor). int256 internal constant PRICE_BOUND_TICKS = 11_000; /// @notice Maximum tick divergence (shortTwap vs longTwap) that maps to full volatility (1e18). /// 1 000 ticks ≈ 10% price swing. uint256 internal constant MAX_VOLATILITY_TICKS = 1000; /// @notice Maximum tick trend signal (shortTwap - longTwap) for momentum saturation. /// 1 000 ticks ≈ 10% price trend. int256 internal constant MAX_MOMENTUM_TICKS = 1000; /// @notice Time (seconds) beyond which timeSinceRecenter saturates at 1e18. 86 400 = 1 day. uint256 internal constant MAX_STALE_SECONDS = 86_400; /// @notice Short TWAP window for volatility / momentum (5 minutes = same as price-stability check). uint32 internal constant SHORT_TWAP_WINDOW = 300; /// @notice Long TWAP window for volatility / momentum baseline (30 minutes). uint32 internal constant LONG_TWAP_WINDOW = 1800; /// @dev Reverts if the caller is not the admin. error UnauthorizedAccount(address account); /// @dev Gas budget forwarded to calculateParams via staticcall. /// Evolved programs that exceed this are treated as crashes — same outcome /// as a revert — and getLiquidityParams() returns bear defaults instead. /// 500 000 gives ~33x headroom over the current seed (~15 k gas) while /// preventing unbounded growth from blocking recenter(). /// /// Note (EIP-150 / 63-64 rule): the outer getLiquidityParams() call must /// arrive with at least ⌈500_000 × 64/63⌉ ≈ 507_937 gas for the inner /// staticcall to actually receive 500 000. Callers with exactly 500–508 k /// gas will see a spurious bear-defaults fallback. This is not a practical /// concern from recenter(), which always has abundant gas. uint256 internal constant CALCULATE_PARAMS_GAS_LIMIT = 500_000; /** * @notice Initialize the Optimizer. * @param _kraiken The address of the Kraiken token. * @param _stake The address of the Stake contract. */ function initialize(address _kraiken, address _stake) public initializer { // Set the admin for upgradeability (using ERC1967Upgrade _changeAdmin) _changeAdmin(msg.sender); kraiken = Kraiken(_kraiken); stake = Stake(_stake); } modifier onlyAdmin() { _checkAdmin(); _; } function _checkAdmin() internal view virtual { if (_getAdmin() != msg.sender) { revert UnauthorizedAccount(msg.sender); } } function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { } // ---- Data-source configuration (admin only) ---- /** * @notice Configure optional on-chain data sources for input slots 2-6. * @param _vwapTracker VWAPTracker contract address (slots 2-4); zero = disabled. * @param _pool Uniswap V3 pool address (slots 2-4, 6); zero = disabled. * @param _liquidityManager LiquidityManager address (slot 6); zero = disabled. * @param _token0isWeth True when WETH is token0 in the pool. Needed to correctly * orient tick-based indicators (pricePosition, volatility, momentum). */ function setDataSources(address _vwapTracker, address _pool, address _liquidityManager, bool _token0isWeth) external onlyAdmin { vwapTracker = _vwapTracker; pool = _pool; liquidityManager = _liquidityManager; token0isWeth = _token0isWeth; } /** * @notice Set the address authorized to call recordRecenter. * @param _recorder The LiquidityManager or other authorized address. */ function setRecenterRecorder(address _recorder) external onlyAdmin { recenterRecorder = _recorder; } /** * @notice Record a recenter event for slot 5 (timeSinceLastRecenter). * @dev Called by the LiquidityManager (or recenterRecorder) after each recenter. */ function recordRecenter() external { if (msg.sender != recenterRecorder && msg.sender != _getAdmin()) { revert UnauthorizedAccount(msg.sender); } lastRecenterTimestamp = block.timestamp; } // ---- Dyadic rational helpers ---- /** * @notice Wrap an integer as a dyadic rational with shift=0. * value = mantissa × 2^(-0) = mantissa. */ function _toDyadic(int256 value) internal pure returns (OptimizerInput memory) { return OptimizerInput({ mantissa: value, shift: 0 }); } /** * @notice Safe bear-mode defaults returned when calculateParams exceeds its * gas budget or reverts. * @dev Constants defined in IOptimizer.sol, shared with LiquidityManager.recenter(). */ function _bearDefaults() internal pure returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) { return (BEAR_CAPITAL_INEFFICIENCY, BEAR_ANCHOR_SHARE, BEAR_ANCHOR_WIDTH, BEAR_DISCOVERY_DEPTH); } // ---- Normalization helpers ---- /** * @notice Convert a Q96 price (price * 2^96) to the corresponding Uniswap V3 tick. * * @dev VWAP is stored as `price * 2^96` where `price = sqrtPriceX96^2 / 2^96`. * Inverting: `sqrtPriceX96 = sqrt(vwapX96) << 48`. * Integer sqrt introduces at most ±1 ULP error in sqrtPriceX96, which * translates to at most ±1 tick error — acceptable for normalization. * * Overflow guard: for prices near TickMath extremes, `sqrt(vwapX96) << 48` * can approach or exceed uint160 max. We clamp to TickMath's valid range. * * @param vwapX96 VWAP in Q96 price format (token1/token0 × 2^96). * @return vwapTick The Uniswap V3 tick closest to the VWAP price. */ function _vwapToTick(uint256 vwapX96) internal pure returns (int24 vwapTick) { uint256 sqrtVwap = Math.sqrt(vwapX96); // = sqrt(price) * 2^48 uint256 shifted = sqrtVwap << 48; // ≈ sqrtPriceX96 = sqrt(price) * 2^96 uint160 sqrtPriceX96; if (shifted >= uint256(TickMath.MAX_SQRT_RATIO)) { sqrtPriceX96 = TickMath.MAX_SQRT_RATIO - 1; } else if (shifted < uint256(TickMath.MIN_SQRT_RATIO)) { sqrtPriceX96 = TickMath.MIN_SQRT_RATIO; } else { sqrtPriceX96 = uint160(shifted); } vwapTick = TickMath.getTickAtSqrtRatio(sqrtPriceX96); } // ---- Core computation ---- /** * @notice Calculates the sentiment based on the average tax rate and the percentage staked. * @param averageTaxRate The average tax rate (as returned by the Stake contract). * @param percentageStaked The percentage (in 1e18 precision) of the authorized stake that is currently staked. * @return sentimentValue A value in the range 0 to 1e18 where 1e18 represents the worst sentiment. */ function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) { // Ensure percentageStaked doesn't exceed 100% require(percentageStaked <= 1e18, "Invalid percentage staked"); // deltaS is the "slack" available below full staking uint256 deltaS = 1e18 - percentageStaked; if (percentageStaked > 92e16) { // If more than 92% of the authorized stake is in use, the sentiment drops rapidly. // Penalty is computed as: (deltaS^3 * averageTaxRate) / (20 * 1e48) uint256 penalty = (deltaS * deltaS * deltaS * averageTaxRate) / (20 * 1e48); sentimentValue = penalty / 2; } else { // For lower staked percentages, sentiment decreases roughly linearly. // Ensure we don't underflow if percentageStaked approaches 92% uint256 scaledStake = (percentageStaked * 1e18) / (92e16); uint256 baseSentiment = scaledStake >= 1e18 ? 0 : 1e18 - scaledStake; // Apply a penalty based on the average tax rate. if (averageTaxRate <= 1e16) { sentimentValue = baseSentiment; } else if (averageTaxRate <= 5e16) { uint256 ratePenalty = ((averageTaxRate - 1e16) * baseSentiment) / (4e16); sentimentValue = baseSentiment > ratePenalty ? baseSentiment - ratePenalty : 0; } else { // For very high tax rates, sentiment is maximally poor. sentimentValue = 1e18; } } return sentimentValue; } /** * @notice Returns the current sentiment. * @return sentiment A number (with 1e18 precision) representing the staker sentiment. */ function getSentiment() external view returns (uint256 sentiment) { uint256 percentageStaked = stake.getPercentageStaked(); uint256 averageTaxRate = stake.getAverageTaxRate(); sentiment = calculateSentiment(averageTaxRate, percentageStaked); } /** * @notice Calculates the optimal anchor width based on staking metrics. * @param percentageStaked The percentage of tokens staked (0 to 1e18) * @param averageTaxRate The average tax rate across all stakers (0 to 1e18) * @return anchorWidth The calculated anchor width (10 to 80) */ function _calculateAnchorWidth(uint256 percentageStaked, uint256 averageTaxRate) internal pure returns (uint24) { // Base width: 40% is our neutral starting point int256 baseWidth = 40; // Staking adjustment: -20% to +20% based on staking percentage int256 stakingAdjustment = 20 - int256(percentageStaked * 40 / 1e18); // Tax rate adjustment: -10% to +30% based on average tax rate int256 taxAdjustment = int256(averageTaxRate * 40 / 1e18) - 10; // Combine all adjustments int256 totalWidth = baseWidth + stakingAdjustment + taxAdjustment; // Clamp to safe bounds (10 to 80) if (totalWidth < 10) { return 10; } if (totalWidth > 80) { return 80; } return uint24(uint256(totalWidth)); } /** * @notice Pure computation of all four liquidity parameters from 8 dyadic inputs. * * @dev This is the transpilation target: future versions of this function will be * generated from evolved Push3 programs via the transpiler. The current * implementation uses slots 0 (percentageStaked) and 1 (averageTaxRate); * slots 2-7 are available to evolved programs that use the normalized indicators. * * @param inputs 8 dyadic rational slots. For shift == 0 (via _toDyadic), value == mantissa. * inputs[0].mantissa = percentageStaked (0..1e18) * inputs[1].mantissa = averageTaxRate (0..1e18) * inputs[2].mantissa = pricePosition (0..1e18) * inputs[3].mantissa = volatility (0..1e18) * inputs[4].mantissa = momentum (0..1e18) * inputs[5].mantissa = timeSinceRecenter (0..1e18) * inputs[6].mantissa = utilizationRate (0..1e18) * inputs[7] = reserved (0) * * @return capitalInefficiency Capital buffer level (0..1e18). CI=0 is safest. * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18). * @return anchorWidth Anchor position width in tick units (uint24). * @return discoveryDepth Discovery liquidity density (0..1e18). */ function calculateParams(OptimizerInput[8] memory inputs) public pure virtual returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) { // Guard against non-zero shift, negative mantissa, and overflow. // All 8 slots must be in [0, 1e18] — the uniform dyadic range. // shift is reserved for future use; uint256() cast silently wraps negatives. for (uint256 k; k < 8; k++) { require(inputs[k].shift == 0, "shift not yet supported"); require(inputs[k].mantissa >= 0, "negative mantissa"); require(inputs[k].mantissa <= 1e18, "mantissa overflow"); } // Extract slots 0 and 1 (shift=0 enforced above — mantissa IS the value) uint256 percentageStaked = uint256(inputs[0].mantissa); uint256 averageTaxRate = uint256(inputs[1].mantissa); uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked); if (sentiment > 1e18) { sentiment = 1e18; } capitalInefficiency = 1e18 - sentiment; anchorShare = sentiment; anchorWidth = _calculateAnchorWidth(percentageStaked, averageTaxRate); discoveryDepth = sentiment; } /** * @notice Returns liquidity parameters for the LiquidityManager. * * @dev Populates the 8-slot dyadic input array with normalized indicators * (all in [0, 1e18]) and delegates to calculateParams. Normalization * happens here so that evolved Push3 programs can reason about relative * positions without dealing with raw Q96 prices or absolute ticks. * * Slots populated: * 0 percentageStaked always * 1 averageTaxRate always * 2 pricePosition when vwapTracker + pool configured and VWAP > 0 * 3 volatility when pool configured and TWAP history available * 4 momentum when pool configured and TWAP history available * 5 timeSinceRecenter when recordRecenter has been called at least once * 6 utilizationRate when liquidityManager + pool configured * 7 reserved always 0 * */ /** * @notice Build the 8-slot normalized input array from on-chain data sources. * @dev Extracted so test harnesses can observe the computed inputs without * duplicating normalization logic. All slots are in [0, 1e18]. */ function _buildInputs() internal view returns (OptimizerInput[8] memory inputs) { // Slot 0: percentageStaked inputs[0] = _toDyadic(int256(stake.getPercentageStaked())); // Slot 1: averageTaxRate inputs[1] = _toDyadic(int256(stake.getAverageTaxRate())); // Slots 2-4 (pricePosition, volatility, momentum) and slot 6 (utilizationRate) // all require the pool address. Read slot0 once and reuse. if (pool != address(0)) { (, int24 poolTick,,,,,) = IUniswapV3PoolSlot0(pool).slot0(); // ---- Slot 2: pricePosition (also needs VWAP) ---- if (vwapTracker != address(0)) { uint256 vwapX96 = IVWAPTracker(vwapTracker).getVWAP(); if (vwapX96 > 0) { // Convert pool tick to KRK-price space: higher tick = more expensive KRK. // Uniswap convention: tick ↑ → token1 more expensive relative to token0. // If token0=WETH (token1=KRK): tick ↑ → KRK/WETH ↑ → KRK more expensive. // No sign flip needed — pool tick already tracks KRK price direction. // If token0=KRK (token1=WETH): tick ↑ → WETH/KRK ↑ → KRK cheaper → negate. // Same convention as LiquidityManager._priceAtTick(token0isWeth ? -tick : tick). int24 currentAdjTick = token0isWeth ? poolTick : -poolTick; // vwapTick in same adjusted (KRK-price) space int24 vwapAdjTick = _vwapToTick(vwapX96); // Slot 2: pricePosition — where is current price vs VWAP ± PRICE_BOUND_TICKS? // 0 = at lower bound (vwap − bound), 0.5e18 = at VWAP, 1e18 = at upper bound. int256 delta = int256(currentAdjTick) - int256(vwapAdjTick); int256 shifted = delta + PRICE_BOUND_TICKS; // map to [0, 2*bound] if (shifted < 0) shifted = 0; if (shifted > 2 * PRICE_BOUND_TICKS) shifted = 2 * PRICE_BOUND_TICKS; inputs[2] = _toDyadic(int256(uint256(shifted) * 1e18 / uint256(2 * PRICE_BOUND_TICKS))); } } // ---- Slots 3-4: volatility and momentum from pool TWAP ---- // Independent of VWAP — only the pool oracle is required. // Fails gracefully if the pool lacks sufficient observation history. { uint32[] memory secondsAgo = new uint32[](3); secondsAgo[0] = LONG_TWAP_WINDOW; // 1800 s — long baseline secondsAgo[1] = SHORT_TWAP_WINDOW; // 300 s — recent secondsAgo[2] = 0; // now try IUniswapV3PoolObserve(pool).observe(secondsAgo) returns (int56[] memory tickCumulatives, uint160[] memory) { int24 longTwap = int24((tickCumulatives[2] - tickCumulatives[0]) / int56(int32(LONG_TWAP_WINDOW))); int24 shortTwap = int24((tickCumulatives[2] - tickCumulatives[1]) / int56(int32(SHORT_TWAP_WINDOW))); // Adjust both TWAP ticks to KRK-price space (same sign convention) int24 longAdj = token0isWeth ? longTwap : -longTwap; int24 shortAdj = token0isWeth ? shortTwap : -shortTwap; int256 twapDelta = int256(shortAdj) - int256(longAdj); // Slot 3: volatility = |shortTwap − longTwap| / MAX_VOLATILITY_TICKS { uint256 absDelta = twapDelta >= 0 ? uint256(twapDelta) : uint256(-twapDelta); uint256 vol = absDelta >= MAX_VOLATILITY_TICKS ? 1e18 : absDelta * 1e18 / MAX_VOLATILITY_TICKS; inputs[3] = _toDyadic(int256(vol)); } // Slot 4: momentum — 0.5e18 flat, 1e18 strongly rising, 0 strongly falling { int256 momentum; if (twapDelta >= MAX_MOMENTUM_TICKS) { momentum = int256(1e18); } else if (twapDelta <= -MAX_MOMENTUM_TICKS) { momentum = 0; } else { momentum = int256(5e17) + twapDelta * int256(5e17) / MAX_MOMENTUM_TICKS; } inputs[4] = _toDyadic(momentum); } } catch { // Insufficient TWAP history — leave slots 3-4 as 0 } } // Slot 6: utilizationRate — 1e18 if current tick is within anchor range, else 0. // Stage.ANCHOR == 1 in the ThreePositionStrategy enum. if (liquidityManager != address(0)) { (, int24 anchorLower, int24 anchorUpper) = ILiquidityManagerPositions(liquidityManager).positions(1); if (poolTick >= anchorLower && poolTick <= anchorUpper) { inputs[6] = _toDyadic(int256(1e18)); } } } // Slot 5: timeSinceRecenter normalized to [0, 1e18]. // 0 = just recentered, 1e18 = MAX_STALE_SECONDS or more have elapsed. if (lastRecenterTimestamp > 0) { uint256 elapsed = block.timestamp - lastRecenterTimestamp; uint256 normalized = elapsed >= MAX_STALE_SECONDS ? 1e18 : elapsed * 1e18 / MAX_STALE_SECONDS; inputs[5] = _toDyadic(int256(normalized)); } // Slot 7: reserved (0) } /** * @return capitalInefficiency Capital buffer level (0..1e18) * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18) * @return anchorWidth Anchor position width in tick units (uint24) * @return discoveryDepth Discovery liquidity density (0..1e18) */ function getLiquidityParams() external view override returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) { OptimizerInput[8] memory inputs = _buildInputs(); // Call calculateParams with a fixed gas budget. Evolved programs that grow // too large hit the cap and fall back to bear defaults — preventing any // buggy or bloated optimizer from blocking recenter() with an OOG revert. (bool ok, bytes memory ret) = address(this).staticcall{ gas: CALCULATE_PARAMS_GAS_LIMIT }(abi.encodeCall(this.calculateParams, (inputs))); if (!ok) return _bearDefaults(); // ABI encoding of (uint256, uint256, uint24, uint256) is exactly 128 bytes // (each value padded to 32 bytes). A truncated return — e.g. from a // malformed evolved program — would cause abi.decode to revert; guard here // so all failure modes fall back via _bearDefaults(). if (ret.length < 128) return _bearDefaults(); (capitalInefficiency, anchorShare, anchorWidth, discoveryDepth) = abi.decode(ret, (uint256, uint256, uint24, uint256)); // Clamp fraction outputs to [0, 1e18] so a buggy evolved program cannot // produce out-of-range values that confuse the LiquidityManager. // anchorWidth is already bounded by uint24 at the ABI level. if (capitalInefficiency > 1e18) capitalInefficiency = 1e18; if (anchorShare > 1e18) anchorShare = 1e18; if (discoveryDepth > 1e18) discoveryDepth = 1e18; } }