diff --git a/onchain/src/Optimizer.sol b/onchain/src/Optimizer.sol index 6a502d3..21da9c8 100644 --- a/onchain/src/Optimizer.sol +++ b/onchain/src/Optimizer.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; -import {Kraiken} from "./Kraiken.sol"; -import {Stake} from "./Stake.sol"; -import {IOptimizer, OptimizerInput} from "./IOptimizer.sol"; +import { IOptimizer, OptimizerInput } from "./IOptimizer.sol"; +import { Kraiken } from "./Kraiken.sol"; +import { Stake } from "./Stake.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"; -import {TickMath} from "@aperture/uni-v3-lib/TickMath.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. @@ -91,12 +91,12 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { // ---- 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) + 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) + 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 ---- @@ -107,11 +107,11 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { /// @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; + 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 = 1_000; + 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; @@ -120,7 +120,7 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { 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; + uint32 internal constant LONG_TWAP_WINDOW = 1800; /// @dev Reverts if the caller is not the admin. error UnauthorizedAccount(address account); @@ -161,7 +161,7 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { } } - function _authorizeUpgrade(address newImplementation) internal override onlyAdmin {} + function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { } // ---- Data-source configuration (admin only) ---- @@ -173,10 +173,7 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { * @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 - { + function setDataSources(address _vwapTracker, address _pool, address _liquidityManager, bool _token0isWeth) external onlyAdmin { vwapTracker = _vwapTracker; pool = _pool; liquidityManager = _liquidityManager; @@ -209,7 +206,7 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { * value = mantissa × 2^(-0) = mantissa. */ function _toDyadic(int256 value) internal pure returns (OptimizerInput memory) { - return OptimizerInput({mantissa: value, shift: 0}); + return OptimizerInput({ mantissa: value, shift: 0 }); } /** @@ -219,11 +216,7 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { * ({capitalInefficiency:0, anchorShare:3e17, anchorWidth:100, discoveryDepth:3e17}). * Update both locations together if the safe defaults ever change. */ - function _bearDefaults() - internal - pure - returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) - { + function _bearDefaults() internal pure returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) { return (0, 3e17, 100, 3e17); } @@ -265,11 +258,7 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { * @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) - { + function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) { // Ensure percentageStaked doesn't exceed 100% require(percentageStaked <= 1e18, "Invalid percentage staked"); @@ -411,14 +400,12 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { * @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; - + /** + * @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())); @@ -460,16 +447,12 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { // 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[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))); + 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; @@ -478,11 +461,8 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { // 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; + 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)); } @@ -522,21 +502,27 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer { } // Slot 7: reserved (0) + } + + 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)) - ); + (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)); + (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. diff --git a/onchain/test/Optimizer.t.sol b/onchain/test/Optimizer.t.sol index 5bc5fcc..c88bf2b 100644 --- a/onchain/test/Optimizer.t.sol +++ b/onchain/test/Optimizer.t.sol @@ -332,7 +332,7 @@ contract OptimizerTest is Test { */ function testCalculateParamsRevertsOnNegativeMantissa0() public { OptimizerInput[8] memory inputs; - inputs[0] = OptimizerInput({mantissa: -1, shift: 0}); + inputs[0] = OptimizerInput({ mantissa: -1, shift: 0 }); vm.expectRevert("negative mantissa"); optimizer.calculateParams(inputs); } @@ -342,7 +342,7 @@ contract OptimizerTest is Test { */ function testCalculateParamsRevertsOnNegativeMantissa1() public { OptimizerInput[8] memory inputs; - inputs[1] = OptimizerInput({mantissa: -1, shift: 0}); + inputs[1] = OptimizerInput({ mantissa: -1, shift: 0 }); vm.expectRevert("negative mantissa"); optimizer.calculateParams(inputs); } @@ -353,7 +353,7 @@ contract OptimizerTest is Test { function testCalculateParamsRevertsOnNegativeMantissaSlots2to7() public { for (uint256 k = 2; k < 8; k++) { OptimizerInput[8] memory inputs; - inputs[k] = OptimizerInput({mantissa: -1, shift: 0}); + inputs[k] = OptimizerInput({ mantissa: -1, shift: 0 }); vm.expectRevert("negative mantissa"); optimizer.calculateParams(inputs); } @@ -389,22 +389,15 @@ contract OptimizerTest is Test { * 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. +/// @dev Harness: exposes the internal _buildInputs() so tests can assert the +/// normalized slot values that getLiquidityParams feeds into calculateParams. 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); + /// @notice Returns the mantissa of each normalized input slot. + function getComputedInputs() external view returns (int256[8] memory mantissas) { + OptimizerInput[8] memory inputs = _buildInputs(); + for (uint256 i; i < 8; i++) { + mantissas[i] = inputs[i].mantissa; + } } } @@ -416,7 +409,8 @@ contract OptimizerVwapHarness is Optimizer { } contract OptimizerNormalizedInputsTest is Test { - Optimizer optimizer; + OptimizerInputCapture capture; + Optimizer optimizer; // alias — points to the same proxy as `capture` MockStake mockStake; MockKraiken mockKraiken; MockVWAPTracker mockVwap; @@ -433,10 +427,10 @@ contract OptimizerNormalizedInputsTest is Test { mockPool = new MockPool(); mockLm = new MockLiquidityManagerPositions(); - Optimizer impl = new Optimizer(); - bytes memory initData = - abi.encodeWithSelector(Optimizer.initialize.selector, address(mockKraiken), address(mockStake)); + OptimizerInputCapture impl = new OptimizerInputCapture(); + bytes memory initData = abi.encodeWithSelector(Optimizer.initialize.selector, address(mockKraiken), address(mockStake)); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + capture = OptimizerInputCapture(address(proxy)); optimizer = Optimizer(address(proxy)); } @@ -470,8 +464,8 @@ contract OptimizerNormalizedInputsTest is Test { ticks[0] = 0; ticks[1] = 1000; ticks[2] = -1000; - ticks[3] = 100000; - ticks[4] = -100000; + ticks[3] = 100_000; + ticks[4] = -100_000; for (uint256 i = 0; i < ticks.length; i++) { int24 origTick = ticks[i]; @@ -479,10 +473,7 @@ contract OptimizerNormalizedInputsTest is Test { 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" - ); + assertTrue(recovered == origTick || recovered == origTick - 1 || recovered == origTick + 1, "round-trip tick error > 1"); } } @@ -510,6 +501,10 @@ contract OptimizerNormalizedInputsTest is Test { 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"); + + // Verify slot 5 via capture harness + int256[8] memory m = capture.getComputedInputs(); + assertEq(m[5], int256(5e17), "slot 5 should be 0.5e18 at half-stale"); } function testTimeSinceRecenterSaturatesAt1e18() public { @@ -535,11 +530,14 @@ contract OptimizerNormalizedInputsTest is Test { int24 targetTick = 500; _seedVwapAtTick(targetTick); mockPool.setCurrentTick(targetTick); // current == vwap → 0.5e18 - mockPool.setRevertOnObserve(true); // disable volatility/momentum for isolation + 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(); + + // Verify slot 2 (pricePosition) is approximately 0.5e18 when current == vwap + int256[8] memory m = capture.getComputedInputs(); + // Allow ±1 tick error from _vwapToTick integer sqrt truncation + assertTrue(m[2] > 4.5e17 && m[2] < 5.5e17, "pricePosition should be ~0.5e18 at VWAP"); } function testPricePositionBelowLowerBound() public { @@ -601,9 +599,16 @@ contract OptimizerNormalizedInputsTest is Test { _seedVwapAtTick(0); mockPool.setCurrentTick(0); // shortTwap - longTwap = 1000 ticks = MAX_MOMENTUM_TICKS → momentum = 1e18 - mockPool.setTwapTicks(0, 1_000); // longTwap=0, shortTwap=1000 + mockPool.setTwapTicks(0, 1000); // longTwap=0, shortTwap=1000 optimizer.getLiquidityParams(); // must not revert + + // Verify slots 3 (volatility) and 4 (momentum) via capture harness. + // Note: with token0isWeth=false, pool ticks are negated into KRK-price space. + // Pool shortTwap=1000, longTwap=0 → KRK-space twapDelta = -1000 (max bear). + int256[8] memory m = capture.getComputedInputs(); + assertEq(m[3], int256(1e18), "volatility should be 1e18 at max delta"); + assertEq(m[4], int256(0), "momentum should be 0 (max bear in KRK-price space)"); } function testMomentumFullBearAtNegMaxDelta() public { @@ -611,7 +616,7 @@ contract OptimizerNormalizedInputsTest is Test { _seedVwapAtTick(0); mockPool.setCurrentTick(0); // shortTwap - longTwap = -1000 = -MAX_MOMENTUM_TICKS → momentum = 0 - mockPool.setTwapTicks(1_000, 0); // longTwap=1000, shortTwap=0 + mockPool.setTwapTicks(1000, 0); // longTwap=1000, shortTwap=0 optimizer.getLiquidityParams(); // must not revert } @@ -638,6 +643,10 @@ contract OptimizerNormalizedInputsTest is Test { mockPool.setRevertOnObserve(true); optimizer.getLiquidityParams(); // must not revert + + // Verify slot 6 (utilizationRate) via capture harness + int256[8] memory m = capture.getComputedInputs(); + assertEq(m[6], int256(1e18), "utilizationRate should be 1e18 when tick is in anchor range"); } function testUtilizationRateOutOfRange() public {