harb/onchain/src/Optimizer.sol
openhands 42b4bf4149 fix: Shift field silently ignored — dyadic rational inputs effectively unsupported (#606)
Add require(shift == 0) guards to Optimizer.calculateParams and
OptimizerV3.calculateParams so non-zero shifts revert instead of being
silently discarded.  OptimizerV3Push3 already had this guard.

Update IOptimizer.sol NatSpec to document that shift is reserved for
future use and must be 0 in all current implementations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:42:50 +00:00

535 lines
26 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 {
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 500508 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 and negative mantissa.
// 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");
}
// Extract slots 0 and 1 (shift=0 assumed — 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;
}
}