Merge pull request 'fix: OptimizerInputCapture test harness is structurally broken (#652)' (#1005) from fix/issue-652 into master

This commit is contained in:
johba 2026-03-19 18:55:13 +01:00
commit b348c4872c
2 changed files with 92 additions and 95 deletions

View file

@ -1,14 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { IOptimizer, OptimizerInput } from "./IOptimizer.sol";
import { Kraiken } from "./Kraiken.sol";
import { Stake } from "./Stake.sol";
import {IOptimizer, OptimizerInput} from "./IOptimizer.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";
import {TickMath} from "@aperture/uni-v3-lib/TickMath.sol";
// ---------------------------------------------------------------------------
// Dyadic rational interface Push3's native number format.
@ -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);
@ -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;
@ -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");
@ -406,19 +395,13 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer {
* 6 utilizationRate when liquidityManager + pool configured
* 7 reserved always 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;
/**
* @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()));
@ -463,13 +446,9 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer {
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)));
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 +457,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 +498,33 @@ contract Optimizer is Initializable, UUPSUpgradeable, IOptimizer {
}
// 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))
);
(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.

View file

@ -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 {
@ -537,9 +532,12 @@ contract OptimizerNormalizedInputsTest is Test {
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();
// 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 {