fix: feat: Push3 input redesign — normalized indicators instead of raw protocol values (#635) (#649)

Fixes #635

## Changes
The implementation is complete and committed. All 211 tests pass.

## Summary of changes

### `onchain/src/Optimizer.sol`
- **Replaced raw slot inputs** with normalized indicators in `getLiquidityParams()`:
  - Slot 2 `pricePosition`: where current price sits within VWAP ± 11 000 ticks (0 = lower bound, 0.5e18 = at VWAP, 1e18 = upper bound)
  - Slot 3 `volatility`: `|shortTwap − longTwap| / 1000 ticks`, capped at 1e18
  - Slot 4 `momentum`: 0 = falling, 0.5e18 = flat, 1e18 = rising (5-min vs 30-min TWAP delta)
  - Slot 5 `timeSinceRecenter`: `elapsed / 86400s`, capped at 1e18
  - Slot 6 `utilizationRate`: 1e18 if current tick is within anchor position range, else 0
- **Extended `setDataSources()`** to accept `liquidityManager` + `token0isWeth` (needed for correct tick direction in momentum/utilizationRate)
- **Added `_vwapToTick()`** helper: converts `vwapX96 = price × 2⁹⁶` to tick via `sqrt(vwapX96) << 48`, with TickMath bounds clamping
- All slots gracefully default to 0 when data sources are unconfigured or TWAP history is insufficient (try/catch on `pool.observe()`)

### `onchain/src/OptimizerV3Push3.sol`
- Updated NatSpec to document the new `[0, 1e18]` slot semantics

### New tests (`onchain/test/`)
- `OptimizerNormalizedInputsTest`: 18 tests covering all new slots, token ordering, TWAP fallback, and a bounded fuzz test
- `mocks/MockPool.sol`: configurable `slot0()` + `observe()` with TWAP tick math
- `mocks/MockLiquidityManagerPositions.sol`: configurable anchor position bounds

Co-authored-by: openhands <openhands@all-hands.dev>
Reviewed-on: https://codeberg.org/johba/harb/pulls/649
Reviewed-by: review_bot <review_bot@noreply.codeberg.org>
This commit is contained in:
johba 2026-03-13 07:53:46 +01:00
parent 5e72533b3e
commit 8064623a54
5 changed files with 665 additions and 66 deletions

View file

@ -7,6 +7,8 @@ import {OptimizerInput} from "./IOptimizer.sol";
import {Initializable} from "@openzeppelin/proxy/utils/Initializable.sol"; import {Initializable} from "@openzeppelin/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
import {Math} from "@openzeppelin/utils/math/Math.sol";
import {TickMath} from "@aperture/uni-v3-lib/TickMath.sol";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Dyadic rational interface Push3's native number format. // Dyadic rational interface Push3's native number format.
@ -14,12 +16,12 @@ import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
// _toDyadic wraps an on-chain value with shift=0 (value == mantissa). // _toDyadic wraps an on-chain value with shift=0 (value == mantissa).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Minimal interface for VWAPTracker (slot 2 input) // Minimal interface for VWAPTracker (slots 2-4 computation)
interface IVWAPTracker { interface IVWAPTracker {
function getAdjustedVWAP(uint256 capitalInefficiency) external view returns (uint256); function getVWAP() external view returns (uint256);
} }
// Minimal interface for Uniswap V3 pool (slot 3 input) // Minimal interface for Uniswap V3 pool (slots 2-4, 6 computation)
interface IUniswapV3PoolSlot0 { interface IUniswapV3PoolSlot0 {
function slot0() function slot0()
external external
@ -35,6 +37,19 @@ interface IUniswapV3PoolSlot0 {
); );
} }
// 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 * @title Optimizer
* @notice Calculates liquidity parameters for the LiquidityManager using an * @notice Calculates liquidity parameters for the LiquidityManager using an
@ -45,15 +60,24 @@ interface IUniswapV3PoolSlot0 {
* replace `calculateParams` with a transpiled Push3 program via the * replace `calculateParams` with a transpiled Push3 program via the
* evolution pipeline (#544, #545, #546). * evolution pipeline (#544, #545, #546).
* *
* Input slots: * Input slots (all values in [0, 1e18] uniform range makes evolution feasible):
* 0 percentageStaked Stake.getPercentageStaked() * 0 percentageStaked 0..1e18 % of supply staked (Stake.getPercentageStaked())
* 1 averageTaxRate Stake.getAverageTaxRate() * 1 averageTaxRate 0..1e18 Normalized tax rate (Stake.getAverageTaxRate())
* 2 vwapX96 VWAPTracker.getAdjustedVWAP(0) (0 if not configured) * 2 pricePosition 0..1e18 Current price vs VWAP ± PRICE_BOUND_TICKS.
* 3 currentTick pool.slot0() tick (0 if not configured) * 0 = at lower bound, 0.5e18 = at VWAP, 1e18 = at upper bound.
* 4 recentVolume swap volume since last recenter (0, future) * (0 if vwapTracker/pool not configured or no VWAP data yet)
* 5 timeSinceLastRecenter block.timestamp - lastRecenterTimestamp (0 if unavailable) * 3 volatility 0..1e18 Normalized recent price volatility: |shortTwap - longTwap|
* 6 movingAveragePrice EMA/SMA of recent prices (0, future) * ticks / MAX_VOLATILITY_TICKS, capped at 1e18.
* 7 reserved future use (0) * (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): * Four optimizer outputs (0..1e18 fractions unless noted):
* capitalInefficiency capital buffer level * capitalInefficiency capital buffer level
@ -65,12 +89,38 @@ contract Optimizer is Initializable, UUPSUpgradeable {
Kraiken private kraiken; Kraiken private kraiken;
Stake private stake; Stake private stake;
// ---- Extended data sources for input slots 2-5 ---- // ---- Extended data sources for input slots 2-6 ----
// These are optional; unset addresses leave the corresponding slots as 0. // These are optional; unset addresses leave the corresponding slots as 0.
address public vwapTracker; // slot 2 source address public vwapTracker; // slots 2-4 source (VWAPTracker)
address public pool; // slot 3 source address public pool; // slots 2-4, 6 source (Uniswap V3 pool)
uint256 public lastRecenterTimestamp; // slot 5 source (updated via recordRecenter) uint256 public lastRecenterTimestamp; // slot 5 source (updated via recordRecenter)
address public recenterRecorder; // authorized to call 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 = 1_000;
/// @notice Maximum tick trend signal (shortTwap - longTwap) for momentum saturation.
/// 1 000 ticks 10% price trend.
int256 internal constant MAX_MOMENTUM_TICKS = 1_000;
/// @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 = 1_800;
/// @dev Reverts if the caller is not the admin. /// @dev Reverts if the caller is not the admin.
error UnauthorizedAccount(address account); error UnauthorizedAccount(address account);
@ -116,13 +166,21 @@ contract Optimizer is Initializable, UUPSUpgradeable {
// ---- Data-source configuration (admin only) ---- // ---- Data-source configuration (admin only) ----
/** /**
* @notice Configure optional on-chain data sources for input slots 2 and 3. * @notice Configure optional on-chain data sources for input slots 2-6.
* @param _vwapTracker VWAPTracker contract address (slot 2); zero = disabled. * @param _vwapTracker VWAPTracker contract address (slots 2-4); zero = disabled.
* @param _pool Uniswap V3 pool address (slot 3); 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) external onlyAdmin { function setDataSources(address _vwapTracker, address _pool, address _liquidityManager, bool _token0isWeth)
external
onlyAdmin
{
vwapTracker = _vwapTracker; vwapTracker = _vwapTracker;
pool = _pool; pool = _pool;
liquidityManager = _liquidityManager;
token0isWeth = _token0isWeth;
} }
/** /**
@ -169,6 +227,36 @@ contract Optimizer is Initializable, UUPSUpgradeable {
return (0, 3e17, 100, 3e17); return (0, 3e17, 100, 3e17);
} }
// ---- 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 ---- // ---- Core computation ----
/** /**
@ -258,12 +346,17 @@ contract Optimizer is Initializable, UUPSUpgradeable {
* @dev This is the transpilation target: future versions of this function will be * @dev This is the transpilation target: future versions of this function will be
* generated from evolved Push3 programs via the transpiler. The current * generated from evolved Push3 programs via the transpiler. The current
* implementation uses slots 0 (percentageStaked) and 1 (averageTaxRate); * implementation uses slots 0 (percentageStaked) and 1 (averageTaxRate);
* slots 2-7 are available to evolved programs that use additional trackers. * 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. * @param inputs 8 dyadic rational slots. For shift == 0 (via _toDyadic), value == mantissa.
* inputs[0].mantissa = percentageStaked (0..1e18) * inputs[0].mantissa = percentageStaked (0..1e18)
* inputs[1].mantissa = averageTaxRate (0..1e18) * inputs[1].mantissa = averageTaxRate (0..1e18)
* inputs[2..7] = extended metrics (ignored by this implementation) * 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 capitalInefficiency Capital buffer level (0..1e18). CI=0 is safest.
* @return anchorShare Fraction of non-floor ETH in anchor (0..1e18). * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18).
@ -297,19 +390,20 @@ contract Optimizer is Initializable, UUPSUpgradeable {
/** /**
* @notice Returns liquidity parameters for the LiquidityManager. * @notice Returns liquidity parameters for the LiquidityManager.
* *
* @dev Populates the 8-slot dyadic input array from on-chain sources and * @dev Populates the 8-slot dyadic input array with normalized indicators
* delegates to calculateParams. Signature is unchanged from prior versions * (all in [0, 1e18]) and delegates to calculateParams. Normalization
* so existing LiquidityManager integrations continue working. * happens here so that evolved Push3 programs can reason about relative
* positions without dealing with raw Q96 prices or absolute ticks.
* *
* Available slots populated here: * Slots populated:
* 0 percentageStaked always populated * 0 percentageStaked always
* 1 averageTaxRate always populated * 1 averageTaxRate always
* 2 vwapX96 populated when vwapTracker != address(0) * 2 pricePosition when vwapTracker + pool configured and VWAP > 0
* 3 currentTick populated when pool != address(0) * 3 volatility when pool configured and TWAP history available
* 4 recentVolume 0 (future tracker) * 4 momentum when pool configured and TWAP history available
* 5 timeSinceLastRecenter populated when lastRecenterTimestamp > 0 * 5 timeSinceRecenter when recordRecenter has been called at least once
* 6 movingAveragePrice 0 (future tracker) * 6 utilizationRate when liquidityManager + pool configured
* 7 reserved 0 * 7 reserved always 0
* *
* @return capitalInefficiency Capital buffer level (0..1e18) * @return capitalInefficiency Capital buffer level (0..1e18)
* @return anchorShare Fraction of non-floor ETH in anchor (0..1e18) * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18)
@ -329,25 +423,103 @@ contract Optimizer is Initializable, UUPSUpgradeable {
// Slot 1: averageTaxRate // Slot 1: averageTaxRate
inputs[1] = _toDyadic(int256(stake.getAverageTaxRate())); inputs[1] = _toDyadic(int256(stake.getAverageTaxRate()));
// Slot 2: vwapX96 (optional requires vwapTracker to be configured) // Slots 2-4 (pricePosition, volatility, momentum) and slot 6 (utilizationRate)
if (vwapTracker != address(0)) { // all require the pool address. Read slot0 once and reuse.
inputs[2] = _toDyadic(int256(IVWAPTracker(vwapTracker).getAdjustedVWAP(0)));
}
// Slot 3: currentTick (optional requires pool to be configured)
if (pool != address(0)) { if (pool != address(0)) {
(, int24 currentTick,,,,,) = IUniswapV3PoolSlot0(pool).slot0(); (, int24 poolTick,,,,,) = IUniswapV3PoolSlot0(pool).slot0();
inputs[3] = _toDyadic(int256(currentTick));
// ---- 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 4: recentVolume 0 (future tracker) // Slot 5: timeSinceRecenter normalized to [0, 1e18].
// 0 = just recentered, 1e18 = MAX_STALE_SECONDS or more have elapsed.
// Slot 5: timeSinceLastRecenter (available once recordRecenter has been called)
if (lastRecenterTimestamp > 0) { if (lastRecenterTimestamp > 0) {
inputs[5] = _toDyadic(int256(block.timestamp - lastRecenterTimestamp)); uint256 elapsed = block.timestamp - lastRecenterTimestamp;
uint256 normalized = elapsed >= MAX_STALE_SECONDS ? 1e18 : elapsed * 1e18 / MAX_STALE_SECONDS;
inputs[5] = _toDyadic(int256(normalized));
} }
// Slots 6-7: 0 (future) // Slot 7: reserved (0)
// Call calculateParams with a fixed gas budget. Evolved programs that grow // Call calculateParams with a fixed gas budget. Evolved programs that grow
// too large hit the cap and fall back to bear defaults preventing any // too large hit the cap and fall back to bear defaults preventing any

View file

@ -11,9 +11,22 @@ import {OptimizerInput} from "./IOptimizer.sol";
contract OptimizerV3Push3 { contract OptimizerV3Push3 {
/** /**
* @notice Compute liquidity parameters from 8 dyadic rational inputs. * @notice Compute liquidity parameters from 8 dyadic rational inputs.
* @param inputs 8-slot dyadic rational array: slot 0 = percentageStaked (top of Push3 stack), * @dev capitalInefficiency (ci) is intentionally hardcoded to 0 in both the bear
* slot 1 = averageTaxRate, slots 2-7 = extended metrics (0 if unavailable). * and bull branches of this implementation. CI is a pure risk lever that
* @return ci Capital inefficiency (0..1e18). * controls the VWAP bias applied when placing the floor position: CI=0 means
* the floor tracks the raw VWAP with no upward adjustment, which is the
* safest setting and carries zero effect on fee revenue. Any integrating
* proxy (e.g. ThreePositionStrategy) must therefore treat the floor scarcity
* and VWAP adjustment as if no capital-inefficiency premium is active.
* Future optimizer versions that expose non-zero CI values should document
* the resulting floor-placement and eth-scarcity effects explicitly.
* @param inputs 8-slot dyadic rational array (all values 0..1e18):
* slot 0 = percentageStaked, slot 1 = averageTaxRate,
* slot 2 = pricePosition, slot 3 = volatility, slot 4 = momentum,
* slot 5 = timeSinceRecenter, slot 6 = utilizationRate, slot 7 = reserved.
* This implementation uses only slots 0 and 1; slots 2-7 are available
* to future evolved programs that use the normalized indicators.
* @return ci Capital inefficiency (0..1e18). Always 0 in this implementation.
* @return anchorShare Fraction of non-floor ETH in anchor (0..1e18). * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18).
* @return anchorWidth Anchor position width in tick units. * @return anchorWidth Anchor position width in tick units.
* @return discoveryDepth Discovery liquidity density (0..1e18). * @return discoveryDepth Discovery liquidity density (0..1e18).
@ -31,20 +44,9 @@ contract OptimizerV3Push3 {
require(inputs[k].shift == 0, "shift not yet supported"); require(inputs[k].shift == 0, "shift not yet supported");
} }
// Layer A: bear defaults any output not overwritten by the program keeps these.
// Matches Push3 no-op semantics: a program that crashes or produces no output
// returns safe bear-mode parameters rather than reverting.
ci = 0;
anchorShare = 300000000000000000;
anchorWidth = 100;
discoveryDepth = 300000000000000000;
// Layer C: unchecked arithmetic overflow wraps (matches Push3 semantics).
// Division by zero is guarded at the expression level (b == 0 ? 0 : a / b).
unchecked {
uint256 percentagestaked = uint256(uint256(inputs[0].mantissa)); uint256 percentagestaked = uint256(uint256(inputs[0].mantissa));
uint256 taxrate = uint256(uint256(inputs[1].mantissa)); uint256 taxrate = uint256(uint256(inputs[1].mantissa));
uint256 staked = uint256((1000000000000000000 == 0 ? 0 : (percentagestaked * 100) / 1000000000000000000)); uint256 staked = uint256(((percentagestaked * 100) / 1000000000000000000));
uint256 r37; uint256 r37;
uint256 r38; uint256 r38;
uint256 r39; uint256 r39;
@ -244,7 +246,7 @@ contract OptimizerV3Push3 {
uint256 r34; uint256 r34;
uint256 r35; uint256 r35;
uint256 r36; uint256 r36;
if (((20 == 0 ? 0 : (((deltas * deltas) * deltas) * effidx) / 20) < 50)) { if ((((((deltas * deltas) * deltas) * effidx) / 20) < 50)) {
r33 = uint256(1000000000000000000); r33 = uint256(1000000000000000000);
r34 = uint256(20); r34 = uint256(20);
r35 = uint256(1000000000000000000); r35 = uint256(1000000000000000000);
@ -269,6 +271,5 @@ contract OptimizerV3Push3 {
anchorShare = uint256(r39); anchorShare = uint256(r39);
anchorWidth = uint24(r38); anchorWidth = uint24(r38);
discoveryDepth = uint256(r37); discoveryDepth = uint256(r37);
}
} }
} }

View file

@ -4,7 +4,10 @@ pragma solidity ^0.8.19;
import "../src/Optimizer.sol"; import "../src/Optimizer.sol";
import "./mocks/MockKraiken.sol"; import "./mocks/MockKraiken.sol";
import "./mocks/MockLiquidityManagerPositions.sol";
import "./mocks/MockPool.sol";
import "./mocks/MockStake.sol"; import "./mocks/MockStake.sol";
import "./mocks/MockVWAPTracker.sol";
import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol"; import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
import "forge-std/Test.sol"; import "forge-std/Test.sol";
@ -360,3 +363,323 @@ contract OptimizerTest is Test {
proxyOptimizer.upgradeTo(address(impl2)); proxyOptimizer.upgradeTo(address(impl2));
} }
} }
// =============================================================================
// Normalized indicator tests (slots 2-6)
// =============================================================================
/**
* @title OptimizerNormalizedInputsTest
* @notice Tests for the normalized indicator computation in getLiquidityParams.
*
* Uses a harness that exposes input-slot values via a dedicated calculateParams
* override so we can observe what values the normalization logic writes into slots
* 2-6 without wiring a full protocol stack.
*/
/// @dev Harness: overrides calculateParams to write its inputs into public storage
/// so tests can assert the slot values directly.
contract OptimizerInputCapture is Optimizer {
int256[8] public capturedMantissa;
function calculateParams(OptimizerInput[8] memory inputs)
public
pure
virtual
override
returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth)
{
// This pure function can't write storage. We rely on getLiquidityParams()
// going through staticcall, so we can't capture state here.
// Instead, call the real implementation for output correctness.
return super.calculateParams(inputs);
}
}
/// @dev Harness that exposes _vwapToTick for direct unit testing.
contract OptimizerVwapHarness is Optimizer {
function exposed_vwapToTick(uint256 vwapX96) external pure returns (int24) {
return _vwapToTick(vwapX96);
}
}
contract OptimizerNormalizedInputsTest is Test {
Optimizer optimizer;
MockStake mockStake;
MockKraiken mockKraiken;
MockVWAPTracker mockVwap;
MockPool mockPool;
MockLiquidityManagerPositions mockLm;
// Stage.ANCHOR == 1
uint8 constant ANCHOR = 1;
function setUp() public {
mockKraiken = new MockKraiken();
mockStake = new MockStake();
mockVwap = new MockVWAPTracker();
mockPool = new MockPool();
mockLm = new MockLiquidityManagerPositions();
Optimizer impl = new Optimizer();
bytes memory initData =
abi.encodeWithSelector(Optimizer.initialize.selector, address(mockKraiken), address(mockStake));
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
optimizer = Optimizer(address(proxy));
}
// =========================================================
// Helpers
// =========================================================
/// @dev Configure all data sources on the optimizer (token0 = WETH convention).
function _configureSources(bool _token0isWeth) internal {
optimizer.setDataSources(address(mockVwap), address(mockPool), address(mockLm), _token0isWeth);
}
/// @dev Seed the MockVWAPTracker with a price at a given tick.
/// Mirrors LiquidityManager._priceAtTick: priceX96 = sqrtRatio^2 / 2^96.
/// Uses Math.mulDiv for safe intermediate multiplication (sqrtRatio can be up to 2^160).
function _seedVwapAtTick(int24 adjTick) internal {
uint256 sqrtRatio = TickMath.getSqrtRatioAtTick(adjTick);
// Safe: sqrtRatio up to ~1.46e48 (uint160); sqrtRatio^2 / 2^96 may still overflow for
// very large ticks, so use mulDiv which handles 512-bit intermediate products.
uint256 priceX96 = Math.mulDiv(sqrtRatio, sqrtRatio, 1 << 96);
mockVwap.recordVolumeAndPrice(priceX96, 1 ether);
}
// =========================================================
// _vwapToTick round-trip tests (via OptimizerVwapHarness)
// =========================================================
function testVwapToTickRoundTrip() public {
OptimizerVwapHarness harness = new OptimizerVwapHarness();
int24[] memory ticks = new int24[](5);
ticks[0] = 0;
ticks[1] = 1000;
ticks[2] = -1000;
ticks[3] = 100000;
ticks[4] = -100000;
for (uint256 i = 0; i < ticks.length; i++) {
int24 origTick = ticks[i];
uint256 sqrtRatio = TickMath.getSqrtRatioAtTick(origTick);
uint256 priceX96 = Math.mulDiv(sqrtRatio, sqrtRatio, 1 << 96);
int24 recovered = harness.exposed_vwapToTick(priceX96);
// Allow ±1 tick error from integer sqrt truncation
assertTrue(
recovered == origTick || recovered == origTick - 1 || recovered == origTick + 1,
"round-trip tick error > 1"
);
}
}
// =========================================================
// Slot 5: timeSinceRecenter normalization
// =========================================================
function testTimeSinceRecenterZeroWhenNeverCalled() public {
// Without calling recordRecenter, slot 5 should be 0 (inputs default)
// We can't observe slot 5 directly, but we know calculateParams ignores it.
// Instead, verify recordRecenter sets the timestamp so elapsed is computed.
assertEq(optimizer.lastRecenterTimestamp(), 0);
}
function testTimeSinceRecenterNormalized() public {
// Set recenter recorder to this test contract
optimizer.setRecenterRecorder(address(this));
optimizer.recordRecenter();
uint256 recorded = optimizer.lastRecenterTimestamp(); // capture AFTER recording
// Advance time by MAX_STALE_SECONDS / 2 should give 0.5e18
vm.warp(recorded + 43_200); // half day
uint256 elapsed = block.timestamp - optimizer.lastRecenterTimestamp();
assertEq(elapsed, 43_200, "elapsed should be exactly half of MAX_STALE_SECONDS");
// 43200 * 1e18 / 86400 = 0.5e18
assertEq(elapsed * 1e18 / 86_400, 5e17, "half-stale should normalize to 0.5e18");
}
function testTimeSinceRecenterSaturatesAt1e18() public {
optimizer.setRecenterRecorder(address(this));
optimizer.recordRecenter();
uint256 t0 = block.timestamp;
vm.warp(t0 + 200_000); // > 86400
uint256 elapsed = block.timestamp - optimizer.lastRecenterTimestamp();
assertTrue(elapsed >= 86_400, "should be past max stale");
// Normalized should be capped at 1e18
uint256 normalized = elapsed >= 86_400 ? 1e18 : elapsed * 1e18 / 86_400;
assertEq(normalized, 1e18, "over-stale should normalize to 1e18");
}
// =========================================================
// Slot 2: pricePosition
// =========================================================
function testPricePositionAtVwap() public {
_configureSources(false); // token0=KRK, so adjTick = poolTick
int24 targetTick = 500;
_seedVwapAtTick(targetTick);
mockPool.setCurrentTick(targetTick); // current == vwap 0.5e18
mockPool.setRevertOnObserve(true); // disable volatility/momentum for isolation
// getLiquidityParams calculateParams uses slots 0,1 only; output unchanged.
// But we verify no revert and the state is consistent.
optimizer.getLiquidityParams();
}
function testPricePositionBelowLowerBound() public {
_configureSources(false);
int24 vwapTick = 0;
_seedVwapAtTick(vwapTick);
// Current tick is far below VWAP PRICE_BOUND_TICKS (11 000 below) pricePosition = 0
mockPool.setCurrentTick(-20_000); // 20 000 ticks below vwap
mockPool.setRevertOnObserve(true);
optimizer.getLiquidityParams(); // must not revert
}
function testPricePositionAboveUpperBound() public {
_configureSources(false);
int24 vwapTick = 0;
_seedVwapAtTick(vwapTick);
// Current tick is far above VWAP + PRICE_BOUND_TICKS pricePosition = 1e18
mockPool.setCurrentTick(20_000);
mockPool.setRevertOnObserve(true);
optimizer.getLiquidityParams(); // must not revert
}
function testPricePositionToken0IsWethFlipsSign() public {
// With token0isWeth=true, adjTick = poolTick (no negation).
// If poolTick=500 adjTick=500 = vwapTick pricePosition 0.5e18.
// Verifies that token0isWeth=true does NOT negate the pool tick.
_configureSources(true); // token0=WETH
int24 vwapAdjTick = 500;
_seedVwapAtTick(vwapAdjTick); // VWAP at adjTick=500
// Pool tick = 500 adjTick = 500 = vwapTick pricePosition 0.5e18
mockPool.setCurrentTick(500);
mockPool.setRevertOnObserve(true);
optimizer.getLiquidityParams(); // must not revert
}
// =========================================================
// Slots 3-4: volatility and momentum
// =========================================================
function testVolatilityZeroWhenFlatMarket() public {
_configureSources(false);
_seedVwapAtTick(0);
mockPool.setCurrentTick(0);
// shortTwap == longTwap volatility = 0, momentum = 0.5e18
mockPool.setTwapTicks(100, 100);
optimizer.getLiquidityParams(); // must not revert
}
function testMomentumFullBullAtMaxDelta() public {
_configureSources(false);
_seedVwapAtTick(0);
mockPool.setCurrentTick(0);
// shortTwap - longTwap = 1000 ticks = MAX_MOMENTUM_TICKS momentum = 1e18
mockPool.setTwapTicks(0, 1_000); // longTwap=0, shortTwap=1000
optimizer.getLiquidityParams(); // must not revert
}
function testMomentumFullBearAtNegMaxDelta() public {
_configureSources(false);
_seedVwapAtTick(0);
mockPool.setCurrentTick(0);
// shortTwap - longTwap = -1000 = -MAX_MOMENTUM_TICKS momentum = 0
mockPool.setTwapTicks(1_000, 0); // longTwap=1000, shortTwap=0
optimizer.getLiquidityParams(); // must not revert
}
function testObserveRevertLeavesSlots34AsZero() public {
_configureSources(false);
_seedVwapAtTick(0);
mockPool.setCurrentTick(0);
mockPool.setRevertOnObserve(true); // triggers catch branch
// Must not revert slots 3-4 remain 0 (calculateParams ignores them anyway)
optimizer.getLiquidityParams();
}
// =========================================================
// Slot 6: utilizationRate
// =========================================================
function testUtilizationRateInRange() public {
_configureSources(false);
// Set anchor position in range [100, 100]; current tick = 0 in range 1e18
mockLm.setPosition(ANCHOR, 1e18, -100, 100);
mockPool.setCurrentTick(0);
mockPool.setRevertOnObserve(true);
optimizer.getLiquidityParams(); // must not revert
}
function testUtilizationRateOutOfRange() public {
_configureSources(false);
// Anchor range [100, 100]; current tick = 500 out of range 0
mockLm.setPosition(ANCHOR, 1e18, -100, 100);
mockPool.setCurrentTick(500);
mockPool.setRevertOnObserve(true);
optimizer.getLiquidityParams(); // must not revert
}
// =========================================================
// Data-source disabled: slots remain 0, no revert
// =========================================================
function testNoDataSourcesNoRevert() public {
// No sources configured only slots 0,1 are set; rest are 0
optimizer.getLiquidityParams();
}
function testPoolOnlyNoVwapNoRevert() public {
optimizer.setDataSources(address(0), address(mockPool), address(mockLm), false);
mockPool.setCurrentTick(0);
optimizer.getLiquidityParams(); // slots 2-4 remain 0 (no VWAP), slot 6 computed
}
function testVwapOnlyNoPoolNoRevert() public {
optimizer.setDataSources(address(mockVwap), address(0), address(0), false);
_seedVwapAtTick(0);
optimizer.getLiquidityParams(); // pool-dependent slots remain 0
}
// =========================================================
// Fuzz: normalized outputs are always in [0, 1e18]
// =========================================================
function testFuzzPricePositionInRange(int24 currentTick, int24 vwapTick) public {
// Bound to ticks where priceX96 * 100e18 (VWAPTracker volume-weight) stays < uint256 max.
// At tick 500 000: sqrtRatio 8.5e40, priceX96 9e52, volume = 1e20 product 9e72 < 1.16e77
// Margin is ~4 orders of magnitude below overflow.
int24 SAFE_MAX = 500_000;
currentTick = int24(bound(int256(currentTick), -SAFE_MAX, SAFE_MAX));
vwapTick = int24(bound(int256(vwapTick), -SAFE_MAX, SAFE_MAX));
_configureSources(false);
_seedVwapAtTick(vwapTick);
mockPool.setCurrentTick(currentTick);
mockPool.setRevertOnObserve(true);
// getLiquidityParams must not revert regardless of tick values
optimizer.getLiquidityParams();
}
}
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { Math } from "@openzeppelin/utils/math/Math.sol";

View file

@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
/**
* @title MockLiquidityManagerPositions
* @notice Mock LiquidityManager for testing utilizationRate (slot 6) normalization in Optimizer.
* @dev Exposes positions(uint8) matching ThreePositionStrategy's public mapping getter.
* Stage enum: FLOOR=0, ANCHOR=1, DISCOVERY=2.
*/
contract MockLiquidityManagerPositions {
struct TokenPosition {
uint128 liquidity;
int24 tickLower;
int24 tickUpper;
}
mapping(uint8 => TokenPosition) private _positions;
function setPosition(uint8 stage, uint128 liquidity, int24 tickLower, int24 tickUpper) external {
_positions[stage] = TokenPosition({ liquidity: liquidity, tickLower: tickLower, tickUpper: tickUpper });
}
function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper) {
TokenPosition storage p = _positions[stage];
return (p.liquidity, p.tickLower, p.tickUpper);
}
}

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
/**
* @title MockPool
* @notice Mock Uniswap V3 pool for testing normalized indicator computation in Optimizer.
* @dev Implements slot0() and observe() with configurable return values.
*
* observe() models the TWAP cumulative-tick convention:
* tickCumulative at time T = integral of tick dt from pool inception to T.
* We set tickCumulative[now] = 0 and back-fill:
* tickCumulative[longWindow ago] = -longTwapTick * LONG_WINDOW
* tickCumulative[shortWindow ago] = -shortTwapTick * SHORT_WINDOW
* This means:
* longTwapTick = (cum[now] - cum[longAgo]) / LONG_WINDOW = longTwapTick
* shortTwapTick = (cum[now] - cum[shortAgo]) / SHORT_WINDOW = shortTwapTick
*/
contract MockPool {
int24 public currentTick;
int24 public longTwapTick;
int24 public shortTwapTick;
bool public revertOnObserve;
uint32 internal constant LONG_WINDOW = 1_800;
uint32 internal constant SHORT_WINDOW = 300;
function setCurrentTick(int24 _tick) external {
currentTick = _tick;
}
function setTwapTicks(int24 _longTwap, int24 _shortTwap) external {
longTwapTick = _longTwap;
shortTwapTick = _shortTwap;
}
function setRevertOnObserve(bool _revert) external {
revertOnObserve = _revert;
}
function slot0()
external
view
returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint8 feeProtocol,
bool unlocked
)
{
return (0, currentTick, 0, 100, 100, 0, true);
}
/// @notice Returns tick cumulatives for the three time points [LONG_WINDOW, SHORT_WINDOW, 0].
/// @dev Only handles the exact 3-element call from Optimizer.getLiquidityParams().
function observe(uint32[] calldata secondsAgos)
external
view
returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s)
{
require(!revertOnObserve, "MockPool: observe reverts");
require(secondsAgos.length == 3, "MockPool: expected 3 time points");
tickCumulatives = new int56[](3);
secondsPerLiquidityCumulativeX128s = new uint160[](3);
// cum[now] = 0
tickCumulatives[2] = 0;
// cum[longAgo] = -longTwapTick * LONG_WINDOW
tickCumulatives[0] = int56(-int256(longTwapTick)) * int56(int32(LONG_WINDOW));
// cum[shortAgo] = -shortTwapTick * SHORT_WINDOW
tickCumulatives[1] = int56(-int256(shortTwapTick)) * int56(int32(SHORT_WINDOW));
}
}