diff --git a/onchain/src/IOptimizer.sol b/onchain/src/IOptimizer.sol new file mode 100644 index 0000000..a438ecf --- /dev/null +++ b/onchain/src/IOptimizer.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +/** + * @notice Dyadic rational input: mantissa × 2^(-shift). + * For shift == 0 (current usage via _toDyadic), value == mantissa. + */ +struct OptimizerInput { + int256 mantissa; + int256 shift; +} diff --git a/onchain/src/Optimizer.sol b/onchain/src/Optimizer.sol index 2d9a1de..c7f721d 100644 --- a/onchain/src/Optimizer.sol +++ b/onchain/src/Optimizer.sol @@ -1,42 +1,77 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; -import { Kraiken } from "./Kraiken.sol"; -import { Stake } from "./Stake.sol"; +import {Kraiken} from "./Kraiken.sol"; +import {Stake} from "./Stake.sol"; +import {OptimizerInput} from "./IOptimizer.sol"; -import { Initializable } from "@openzeppelin/proxy/utils/Initializable.sol"; -import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol"; +import {Initializable} from "@openzeppelin/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.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 (slot 2 input) +interface IVWAPTracker { + function getAdjustedVWAP(uint256 capitalInefficiency) external view returns (uint256); +} + +// Minimal interface for Uniswap V3 pool (slot 3 input) +interface IUniswapV3PoolSlot0 { + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); +} /** * @title Optimizer - * @notice This contract (formerly Sentimenter) calculates a "sentiment" value and liquidity parameters - * based on the tax rate and the percentage of Kraiken staked. - * @dev It is upgradeable using UUPS. Only the admin (set during initialization) can upgrade. + * @notice Calculates liquidity parameters for the LiquidityManager using an + * 8-slot dyadic rational input interface (Push3's native format). * - * Key features: - * - Analyzes staking sentiment (% staked, average tax rate) - * - Returns four key parameters for liquidity management: - * 1. capitalInefficiency (0 to 1e18): Capital buffer level - * 2. anchorShare (0 to 1e18): % of non-floor ETH in anchor - * 3. anchorWidth (0 to 100): Anchor position width % - * 4. discoveryDepth (0 to 1e18): Discovery liquidity density (2x-10x) - * - Upgradeable for future algorithm improvements + * @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). * - * AnchorWidth Price Ranges: - * The anchor position's price range depends on anchorWidth value: - * - anchorWidth = 10: ±9% range (0.92x to 1.09x current price) - * - anchorWidth = 40: ±33% range (0.75x to 1.34x current price) - * - anchorWidth = 50: ±42% range (0.70x to 1.43x current price) - * - anchorWidth = 80: ±74% range (0.57x to 1.75x current price) - * - anchorWidth = 100: -50% to +100% range (0.50x to 2.00x current price) + * Input slots: + * 0 percentageStaked Stake.getPercentageStaked() + * 1 averageTaxRate Stake.getAverageTaxRate() + * 2 vwapX96 VWAPTracker.getAdjustedVWAP(0) (0 if not configured) + * 3 currentTick pool.slot0() tick (0 if not configured) + * 4 recentVolume swap volume since last recenter (0, future) + * 5 timeSinceLastRecenter block.timestamp - lastRecenterTimestamp (0 if unavailable) + * 6 movingAveragePrice EMA/SMA of recent prices (0, future) + * 7 reserved future use (0) * - * The formula: anchorSpacing = TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100) - * creates a non-linear price range due to Uniswap V3's tick-based system + * 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 { Kraiken private kraiken; Stake private stake; + // ---- Extended data sources for input slots 2-5 ---- + // These are optional; unset addresses leave the corresponding slots as 0. + address public vwapTracker; // slot 2 source + address public pool; // slot 3 source + uint256 public lastRecenterTimestamp; // slot 5 source (updated via recordRecenter) + address public recenterRecorder; // authorized to call recordRecenter + /// @dev Reverts if the caller is not the admin. error UnauthorizedAccount(address account); @@ -63,7 +98,50 @@ contract Optimizer is Initializable, UUPSUpgradeable { } } - function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { } + function _authorizeUpgrade(address newImplementation) internal override onlyAdmin {} + + // ---- Data-source configuration (admin only) ---- + + /** + * @notice Configure optional on-chain data sources for input slots 2 and 3. + * @param _vwapTracker VWAPTracker contract address (slot 2); zero = disabled. + * @param _pool Uniswap V3 pool address (slot 3); zero = disabled. + */ + function setDataSources(address _vwapTracker, address _pool) external onlyAdmin { + vwapTracker = _vwapTracker; + pool = _pool; + } + + /** + * @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}); + } + + // ---- Core computation ---- /** * @notice Calculates the sentiment based on the average tax rate and the percentage staked. @@ -71,7 +149,11 @@ contract Optimizer is Initializable, UUPSUpgradeable { * @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"); @@ -117,50 +199,21 @@ contract Optimizer is Initializable, UUPSUpgradeable { * @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) - * - * @dev This function implements a staking-based approach to determine anchor width: - * - * Base Strategy: - * - Start with base width of 40% (balanced default) - * - * Staking Adjustment (-20% to +20%): - * - High staking (>70%) indicates bullish confidence → narrow anchor for fee optimization - * - Low staking (<30%) indicates bearish/uncertainty → wide anchor for safety - * - Inverse relationship: higher staking = lower width adjustment - * - * Tax Rate Adjustment (-10% to +30%): - * - High tax rates signal expected volatility → wider anchor to reduce rebalancing - * - Low tax rates signal expected stability → narrower anchor for fee collection - * - Direct relationship: higher tax = higher width adjustment - * - * The Harberger tax mechanism acts as a decentralized prediction market where: - * - Tax rates reflect holders' expectations of being "snatched" (volatility) - * - Staking percentage reflects overall market confidence - * - * Final width is clamped between 10 (minimum safe) and 80 (maximum effective) */ 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 - // Formula: 20 - (percentageStaked * 40 / 1e18) - // High staking (1e18) → -20 adjustment → narrower width - // Low staking (0) → +20 adjustment → wider width int256 stakingAdjustment = 20 - int256(percentageStaked * 40 / 1e18); // Tax rate adjustment: -10% to +30% based on average tax rate - // Formula: (averageTaxRate * 40 / 1e18) - 10 - // High tax (1e18) → +30 adjustment → wider width for volatility - // Low tax (0) → -10 adjustment → narrower width for stability int256 taxAdjustment = int256(averageTaxRate * 40 / 1e18) - 10; // Combine all adjustments int256 totalWidth = baseWidth + stakingAdjustment + taxAdjustment; // Clamp to safe bounds (10 to 80) - // Below 10%: rebalancing costs exceed benefits - // Above 80%: capital efficiency degrades significantly if (totalWidth < 10) { return 10; } @@ -172,39 +225,98 @@ contract Optimizer is Initializable, UUPSUpgradeable { } /** - * @notice Returns liquidity parameters for the liquidity manager. - * @return capitalInefficiency Calculated as (1e18 - sentiment). Capital buffer level (0-1e18) - * @return anchorShare Set equal to the sentiment. % of non-floor ETH in anchor (0-1e18) - * @return anchorWidth Dynamically adjusted based on staking metrics. Anchor position width % (1-100) - * @return discoveryDepth Set equal to the sentiment. + * @notice Pure computation of all four liquidity parameters from 8 dyadic inputs. * - * @dev AnchorWidth Strategy: - * The anchorWidth parameter controls the price range of the anchor liquidity position. - * - anchorWidth = 50: Price range from 0.70x to 1.43x current price - * - anchorWidth = 100: Price range from 0.50x to 2.00x current price + * @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 additional trackers. * - * We use staking metrics as a decentralized prediction market: - * - High staking % → Bullish sentiment → Narrower width (30-50%) for fee optimization - * - Low staking % → Bearish/uncertain → Wider width (60-80%) for defensive positioning - * - High avg tax rate → Expects volatility → Wider anchor to reduce rebalancing - * - Low avg tax rate → Expects stability → Narrower anchor for fee collection + * @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..7] = extended metrics (ignored by this implementation) + * + * @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 getLiquidityParams() external view returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) { - uint256 percentageStaked = stake.getPercentageStaked(); - uint256 averageTaxRate = stake.getAverageTaxRate(); - uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked); + function calculateParams(OptimizerInput[8] memory inputs) + public + pure + returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) + { + // Extract slots 0 and 1 (shift=0 assumed — mantissa IS the value) + uint256 percentageStaked = uint256(inputs[0].mantissa); + uint256 averageTaxRate = uint256(inputs[1].mantissa); - // Ensure sentiment doesn't exceed 1e18 to prevent underflow - // Cap sentiment at 1e18 if it somehow exceeds it + uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked); if (sentiment > 1e18) { sentiment = 1e18; } + capitalInefficiency = 1e18 - sentiment; anchorShare = sentiment; - - // Calculate dynamic anchorWidth based on staking metrics anchorWidth = _calculateAnchorWidth(percentageStaked, averageTaxRate); - discoveryDepth = sentiment; } + + /** + * @notice Returns liquidity parameters for the LiquidityManager. + * + * @dev Populates the 8-slot dyadic input array from on-chain sources and + * delegates to calculateParams. Signature is unchanged from prior versions + * so existing LiquidityManager integrations continue working. + * + * Available slots populated here: + * 0 percentageStaked always populated + * 1 averageTaxRate always populated + * 2 vwapX96 populated when vwapTracker != address(0) + * 3 currentTick populated when pool != address(0) + * 4 recentVolume 0 (future tracker) + * 5 timeSinceLastRecenter populated when lastRecenterTimestamp > 0 + * 6 movingAveragePrice 0 (future tracker) + * 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 + returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) + { + OptimizerInput[8] memory inputs; + + // Slot 0: percentageStaked + inputs[0] = _toDyadic(int256(stake.getPercentageStaked())); + + // Slot 1: averageTaxRate + inputs[1] = _toDyadic(int256(stake.getAverageTaxRate())); + + // Slot 2: vwapX96 (optional — requires vwapTracker to be configured) + if (vwapTracker != address(0)) { + inputs[2] = _toDyadic(int256(IVWAPTracker(vwapTracker).getAdjustedVWAP(0))); + } + + // Slot 3: currentTick (optional — requires pool to be configured) + if (pool != address(0)) { + (, int24 currentTick,,,,,) = IUniswapV3PoolSlot0(pool).slot0(); + inputs[3] = _toDyadic(int256(currentTick)); + } + + // Slot 4: recentVolume — 0 (future tracker) + + // Slot 5: timeSinceLastRecenter (available once recordRecenter has been called) + if (lastRecenterTimestamp > 0) { + inputs[5] = _toDyadic(int256(block.timestamp - lastRecenterTimestamp)); + } + + // Slots 6-7: 0 (future) + + return calculateParams(inputs); + } } diff --git a/onchain/src/OptimizerV3Push3.sol b/onchain/src/OptimizerV3Push3.sol index 2e5a8b2..af5b2b4 100644 --- a/onchain/src/OptimizerV3Push3.sol +++ b/onchain/src/OptimizerV3Push3.sol @@ -1,164 +1,296 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; +import {OptimizerInput} from "./IOptimizer.sol"; + /** * @title OptimizerV3Push3 * @notice Auto-generated from optimizer_v3.push3 via Push3→Solidity transpiler. - * Implements the same isBullMarket logic as OptimizerV3. - * @dev This contract is an equivalence proof, not a deployable upgrade. - * It intentionally exposes only `isBullMarket` and does NOT implement - * the full optimizer interface (e.g. `getLiquidityParams`). Wiring it - * into the proxy upgrade path would require completing that interface first. + * Implements calculateParams with 8 dyadic rational inputs and 4 outputs. */ contract OptimizerV3Push3 { /** - * @notice Determines if the market is in bull configuration. - * @param percentageStaked Percentage of authorized stake in use (0 to 1e18). - * @param averageTaxRate Normalized average tax rate from Stake contract (0 to 1e18). - * @return bull True if bull config, false if bear. + * @notice Compute liquidity parameters from 8 dyadic rational inputs. + * @param inputs 8-slot dyadic rational array: slot 0 = percentageStaked (top of Push3 stack), + * slot 1 = averageTaxRate, slots 2-7 = extended metrics (0 if unavailable). + * @return ci Capital inefficiency (0..1e18). + * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18). + * @return anchorWidth Anchor position width in tick units. + * @return discoveryDepth Discovery liquidity density (0..1e18). */ - function isBullMarket(uint256 percentageStaked, uint256 averageTaxRate) public pure returns (bool bull) { - require(percentageStaked <= 1e18, "Invalid percentage staked"); - require(averageTaxRate <= 1e18, "Invalid tax rate"); - uint256 taxrate = uint256(averageTaxRate); - uint256 staked = uint256(((percentageStaked * 100) / 1_000_000_000_000_000_000)); + function calculateParams(OptimizerInput[8] memory inputs) + public + pure + returns (uint256 ci, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) + { + // Validate mantissa for percentageStaked + require(inputs[0].mantissa <= 1e18, "mantissa overflow"); + + // Validate that shift is 0 (future-only field, not yet supported) + for (uint256 k = 0; k < 8; k++) { + require(inputs[k].shift == 0, "shift not yet supported"); + } + + // Decode dyadic rational inputs (mantissa * 2^(-shift); shift=0 for current inputs) + uint256 _d0 = uint256(inputs[0].mantissa); + uint256 _d1 = uint256(inputs[1].mantissa); + /* _d2 = uint256(inputs[2].mantissa); */ + // Available but not used in current implementation + /* _d3 = uint256(inputs[3].mantissa); */ + // Available but not used in current implementation + /* _d4 = uint256(inputs[4].mantissa); */ + // Available but not used in current implementation + /* _d5 = uint256(inputs[5].mantissa); */ + // Available but not used in current implementation + /* _d6 = uint256(inputs[6].mantissa); */ + // Available but not used in current implementation + /* _d7 = uint256(inputs[7].mantissa); */ + // Available but not used in current implementation + uint256 rawpct = uint256(_d0); + uint256 taxrate = uint256(_d1); + uint256 staked = uint256(((rawpct * 100) / 1000000000000000000)); bool b33; if ((staked > 91)) { uint256 deltas = uint256((100 - staked)); uint256 r28; - if ((taxrate <= 206_185_567_010_309)) { + if ((taxrate <= 206185567010309)) { r28 = uint256(0); } else { uint256 r27; - if ((taxrate <= 412_371_134_020_618)) { + if ((taxrate <= 412371134020618)) { r27 = uint256(1); } else { uint256 r26; - if ((taxrate <= 618_556_701_030_927)) { + if ((taxrate <= 618556701030927)) { r26 = uint256(2); } else { uint256 r25; - if ((taxrate <= 1_030_927_835_051_546)) { + if ((taxrate <= 1030927835051546)) { r25 = uint256(3); } else { uint256 r24; - if ((taxrate <= 1_546_391_752_577_319)) { + if ((taxrate <= 1546391752577319)) { r24 = uint256(4); } else { uint256 r23; - if ((taxrate <= 2_164_948_453_608_247)) { + if ((taxrate <= 2164948453608247)) { r23 = uint256(5); } else { uint256 r22; - if ((taxrate <= 2_783_505_154_639_175)) { + if ((taxrate <= 2783505154639175)) { r22 = uint256(6); } else { uint256 r21; - if ((taxrate <= 3_608_247_422_680_412)) { + if ((taxrate <= 3608247422680412)) { r21 = uint256(7); } else { uint256 r20; - if ((taxrate <= 4_639_175_257_731_958)) { + if ((taxrate <= 4639175257731958)) { r20 = uint256(8); } else { uint256 r19; - if ((taxrate <= 5_670_103_092_783_505)) { + if ((taxrate <= 5670103092783505)) { r19 = uint256(9); } else { uint256 r18; - if ((taxrate <= 7_216_494_845_360_824)) { + if ((taxrate <= 7216494845360824)) { r18 = uint256(10); } else { uint256 r17; - if ((taxrate <= 9_278_350_515_463_917)) { + if ((taxrate <= 9278350515463917)) { r17 = uint256(11); } else { uint256 r16; - if ((taxrate <= 11_855_670_103_092_783)) { + if ((taxrate <= 11855670103092783)) { r16 = uint256(12); } else { uint256 r15; - if ((taxrate <= 15_979_381_443_298_969)) { + if ((taxrate <= 15979381443298969)) { r15 = uint256(13); } else { uint256 r14; - if ((taxrate <= 22_164_948_453_608_247)) { + if ((taxrate <= 22164948453608247)) { r14 = uint256(14); } else { uint256 r13; - if ((taxrate <= 29_381_443_298_969_072)) { + if ((taxrate <= 29381443298969072)) { r13 = uint256(15); } else { uint256 r12; - if ((taxrate <= 38_144_329_896_907_216)) { + if ((taxrate <= 38144329896907216)) { r12 = uint256(16); } else { uint256 r11; - if ((taxrate <= 49_484_536_082_474_226)) { + if ((taxrate <= 49484536082474226)) { r11 = uint256(17); } else { uint256 r10; - if ((taxrate <= 63_917_525_773_195_876)) { + if ((taxrate <= 63917525773195876)) + { r10 = uint256(18); } else { uint256 r9; - if ((taxrate <= 83_505_154_639_175_257)) { + if ( + ( + taxrate + <= 83505154639175257 + ) + ) { r9 = uint256(19); } else { uint256 r8; - if ((taxrate <= 109_278_350_515_463_917)) { + if ( + ( + taxrate + <= + 109278350515463917 + ) + ) { r8 = uint256(20); } else { uint256 r7; - if ((taxrate <= 144_329_896_907_216_494)) { + if ( + ( + taxrate + <= + 144329896907216494 + ) + ) { r7 = uint256(21); } else { uint256 r6; - if ((taxrate <= 185_567_010_309_278_350)) { + if ( + ( + taxrate + <= + 185567010309278350 + ) + ) { r6 = uint256(22); } else { uint256 r5; - if ((taxrate <= 237_113_402_061_855_670)) { - r5 = uint256(23); + if ( + ( + taxrate + <= + 237113402061855670 + ) + ) { + r5 = uint256( + 23 + ); } else { uint256 r4; - if ((taxrate <= 309_278_350_515_463_917)) { - r4 = uint256(24); + if ( + ( + taxrate + <= + 309278350515463917 + ) + ) { + r4 = + uint256( + 24 + ); } else { - uint256 r3; - if ((taxrate <= 402_061_855_670_103_092)) { - r3 = uint256(25); + uint256 + r3; + if ( + ( + taxrate + <= + 402061855670103092 + ) + ) { + r3 = + uint256( + 25 + ); } else { - uint256 r2; - if ((taxrate <= 520_618_556_701_030_927)) { - r2 = uint256(26); - } else { - uint256 r1; + uint256 + r2; + if ( + ( + taxrate + <= + 520618556701030927 + ) + ) { + r2 + = + uint256( + 26 + ); + } + else + { + uint256 + r1; if ( - (taxrate <= 680_412_371_134_020_618) - ) { - r1 = uint256(27); - } else { - uint256 r0; + ( + taxrate + <= + 680412371134020618 + ) + ) + { + r1 + = + uint256( + 27 + ); + } + else + { + uint256 + r0; if ( ( taxrate - <= 886_597_938_144_329_896 + <= + 886597938144329896 ) - ) { - r0 = uint256(28); - } else { - r0 = uint256(29); + ) + { + r0 + = + uint256( + 28 + ); } - r1 = uint256(r0); + else + { + r0 + = + uint256( + 29 + ); + } + r1 + = + uint256( + r0 + ); } - r2 = uint256(r1); + r2 + = + uint256( + r1 + ); } - r3 = uint256(r2); + r3 = + uint256( + r2 + ); } - r4 = uint256(r3); + r4 = + uint256( + r3 + ); } - r5 = uint256(r4); + r5 = uint256( + r4 + ); } r6 = uint256(r5); } @@ -225,6 +357,24 @@ contract OptimizerV3Push3 { } else { b33 = false; } - bull = b33; + uint256 r34; + uint256 r35; + uint256 r36; + uint256 r37; + if (b33) { + r34 = uint256(1000000000000000000); + r35 = uint256(20); + r36 = uint256(1000000000000000000); + r37 = uint256(0); + } else { + r34 = uint256(300000000000000000); + r35 = uint256(100); + r36 = uint256(300000000000000000); + r37 = uint256(0); + } + ci = uint256(r37); + anchorShare = uint256(r36); + anchorWidth = uint24(r35); + discoveryDepth = uint256(r34); } } diff --git a/onchain/test/OptimizerV3Push3.t.sol b/onchain/test/OptimizerV3Push3.t.sol index 83b958b..aba1183 100644 --- a/onchain/test/OptimizerV3Push3.t.sol +++ b/onchain/test/OptimizerV3Push3.t.sol @@ -1,24 +1,75 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; -import { OptimizerV3Push3 } from "../src/OptimizerV3Push3.sol"; +import {OptimizerV3Push3} from "../src/OptimizerV3Push3.sol"; +import {OptimizerInput} from "../src/IOptimizer.sol"; import "forge-std/Test.sol"; /** * @title OptimizerV3Push3Test - * @notice Verifies the correctness of OptimizerV3Push3 isBullMarket logic. + * @notice Verifies that the transpiled Push3 optimizer produces correct + * bear/bull parameters via the 8-slot dyadic rational interface. + * + * Bear output: CI=0, AS=0.3e18, AW=100, DD=0.3e18 + * Bull output: CI=0, AS=1e18, AW=20, DD=1e18 + * + * Bull condition: stakedPct > 91 AND penalty < 50 + * where penalty = deltaS^3 * effIdx / 20 */ contract OptimizerV3Push3Test is Test { OptimizerV3Push3 push3; - uint256[30] TAX_RATES = - [uint256(1), 3, 5, 8, 12, 18, 24, 30, 40, 50, 60, 80, 100, 130, 180, 250, 320, 420, 540, 700, 920, 1200, 1600, 2000, 2600, 3400, 4400, 5700, 7500, 9700]; + uint256[30] TAX_RATES = [ + uint256(1), + 3, + 5, + 8, + 12, + 18, + 24, + 30, + 40, + 50, + 60, + 80, + 100, + 130, + 180, + 250, + 320, + 420, + 540, + 700, + 920, + 1200, + 1600, + 2000, + 2600, + 3400, + 4400, + 5700, + 7500, + 9700 + ]; uint256 constant MAX_TAX = 9700; + // Expected bear/bull outputs + uint256 constant BEAR_CI = 0; + uint256 constant BEAR_ANCHOR_SHARE = 3e17; // 0.3e18 + uint24 constant BEAR_ANCHOR_WIDTH = 100; + uint256 constant BEAR_DISCOVERY = 3e17; // 0.3e18 + + uint256 constant BULL_CI = 0; + uint256 constant BULL_ANCHOR_SHARE = 1e18; + uint24 constant BULL_ANCHOR_WIDTH = 20; + uint256 constant BULL_DISCOVERY = 1e18; + function setUp() public { push3 = new OptimizerV3Push3(); } + // ---- Helpers ---- + function _norm(uint256 taxIdx) internal view returns (uint256) { return TAX_RATES[taxIdx] * 1e18 / MAX_TAX; } @@ -27,68 +78,126 @@ contract OptimizerV3Push3Test is Test { return pct * 1e18 / 100; } - // ---- Direct correctness tests ---- + /// @dev Build an 8-slot input array with only slots 0 and 1 populated. + function _inputs(uint256 percentageStaked, uint256 averageTaxRate) + internal + pure + returns (OptimizerInput[8] memory inp) + { + inp[0] = OptimizerInput({mantissa: int256(percentageStaked), shift: 0}); + inp[1] = OptimizerInput({mantissa: int256(averageTaxRate), shift: 0}); + // slots 2-7 default to (0, 0) + } + + function _assertBear(uint256 ci, uint256 as_, uint24 aw, uint256 dd) internal pure { + assertEq(ci, BEAR_CI, "bear: ci"); + assertEq(as_, BEAR_ANCHOR_SHARE, "bear: anchorShare"); + assertEq(aw, BEAR_ANCHOR_WIDTH, "bear: anchorWidth"); + assertEq(dd, BEAR_DISCOVERY, "bear: discoveryDepth"); + } + + function _assertBull(uint256 ci, uint256 as_, uint24 aw, uint256 dd) internal pure { + assertEq(ci, BULL_CI, "bull: ci"); + assertEq(as_, BULL_ANCHOR_SHARE, "bull: anchorShare"); + assertEq(aw, BULL_ANCHOR_WIDTH, "bull: anchorWidth"); + assertEq(dd, BULL_DISCOVERY, "bull: discoveryDepth"); + } + + // ---- Bear cases ---- function testAlwaysBearAt0Percent() public view { for (uint256 t = 0; t < 30; t++) { - assertFalse(push3.isBullMarket(0, _norm(t))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(0, _norm(t))); + _assertBear(ci, as_, aw, dd); } } function testAlwaysBearAt91Percent() public view { for (uint256 t = 0; t < 30; t++) { - assertFalse(push3.isBullMarket(_pct(91), _norm(t))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(91), _norm(t))); + _assertBear(ci, as_, aw, dd); } } + // ---- Bull cases ---- + function testBoundary92PercentLowestTax() public view { // deltaS=8, effIdx=0 → penalty=0 < 50 → BULL - assertTrue(push3.isBullMarket(_pct(92), _norm(0))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(92), _norm(0))); + _assertBull(ci, as_, aw, dd); } function testBoundary92PercentTaxIdx1() public view { // deltaS=8, effIdx=1 → penalty=512*1/20=25 < 50 → BULL - assertTrue(push3.isBullMarket(_pct(92), _norm(1))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(92), _norm(1))); + _assertBull(ci, as_, aw, dd); } - function testBoundary92PercentTaxIdx2() public view { + function testBoundary92PercentTaxIdx2Bear() public view { // deltaS=8, effIdx=2 → penalty=512*2/20=51 >= 50 → BEAR - assertFalse(push3.isBullMarket(_pct(92), _norm(2))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(92), _norm(2))); + _assertBear(ci, as_, aw, dd); } function testAt95PercentTaxIdx7() public view { // deltaS=5, effIdx=7 → penalty=125*7/20=43 < 50 → BULL - assertTrue(push3.isBullMarket(_pct(95), _norm(7))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(95), _norm(7))); + _assertBull(ci, as_, aw, dd); } - function testAt95PercentTaxIdx8() public view { + function testAt95PercentTaxIdx8Bear() public view { // deltaS=5, effIdx=8 → penalty=125*8/20=50 NOT < 50 → BEAR - assertFalse(push3.isBullMarket(_pct(95), _norm(8))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(95), _norm(8))); + _assertBear(ci, as_, aw, dd); } function testAt97PercentHighTax() public view { // deltaS=3, effIdx=29 → penalty=27*29/20=39 < 50 → BULL - assertTrue(push3.isBullMarket(_pct(97), _norm(29))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(97), _norm(29))); + _assertBull(ci, as_, aw, dd); } function testAt100PercentAlwaysBull() public view { for (uint256 t = 0; t < 30; t++) { - assertTrue(push3.isBullMarket(1e18, _norm(t))); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(1e18, _norm(t))); + _assertBull(ci, as_, aw, dd); } } function testEffIdxShiftAtBoundary() public view { // taxIdx=13: effIdx=13, penalty=64*13/20=41 < 50 → BULL - assertTrue(push3.isBullMarket(_pct(96), _norm(13))); + { + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(96), _norm(13))); + _assertBull(ci, as_, aw, dd); + } // taxIdx=14: effIdx=15 (shift!), penalty=64*15/20=48 < 50 → BULL - assertTrue(push3.isBullMarket(_pct(96), _norm(14))); + { + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(96), _norm(14))); + _assertBull(ci, as_, aw, dd); + } // taxIdx=15: effIdx=16, penalty=64*16/20=51 >= 50 → BEAR - assertFalse(push3.isBullMarket(_pct(96), _norm(15))); + { + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(96), _norm(15))); + _assertBear(ci, as_, aw, dd); + } } - function testRevertsAbove100Percent() public { - vm.expectRevert("Invalid percentage staked"); - push3.isBullMarket(1e18 + 1, 0); + // ---- Unused slots are ignored ---- + + function testUnusedSlotsIgnored() public view { + // Populate slots 2-7 with arbitrary values; output should be unchanged. + OptimizerInput[8] memory inp; + inp[0] = OptimizerInput({mantissa: int256(_pct(92)), shift: 0}); + inp[1] = OptimizerInput({mantissa: int256(_norm(0)), shift: 0}); + inp[2] = OptimizerInput({mantissa: 12345678, shift: 0}); + inp[3] = OptimizerInput({mantissa: 9876, shift: 0}); + inp[4] = OptimizerInput({mantissa: 1e17, shift: 0}); + inp[5] = OptimizerInput({mantissa: 3600, shift: 0}); + inp[6] = OptimizerInput({mantissa: 5e17, shift: 0}); + inp[7] = OptimizerInput({mantissa: 42, shift: 0}); + + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(inp); + _assertBull(ci, as_, aw, dd); } // ---- Fuzz ---- @@ -96,6 +205,21 @@ contract OptimizerV3Push3Test is Test { function testFuzzNeverReverts(uint256 percentageStaked, uint256 averageTaxRate) public view { percentageStaked = bound(percentageStaked, 0, 1e18); averageTaxRate = bound(averageTaxRate, 0, 1e18); - push3.isBullMarket(percentageStaked, averageTaxRate); + push3.calculateParams(_inputs(percentageStaked, averageTaxRate)); + } + + function testFuzzOutputsAreAlwaysBearOrBull(uint256 percentageStaked, uint256 averageTaxRate) public view { + percentageStaked = bound(percentageStaked, 0, 1e18); + averageTaxRate = bound(averageTaxRate, 0, 1e18); + (uint256 ci, uint256 as_, uint24 aw, uint256 dd) = + push3.calculateParams(_inputs(percentageStaked, averageTaxRate)); + + // CI is always 0 in the binary bear/bull model + assertEq(ci, 0, "ci always 0"); + + // Output is exactly BEAR or BULL + bool isBearOutput = (as_ == BEAR_ANCHOR_SHARE && aw == BEAR_ANCHOR_WIDTH && dd == BEAR_DISCOVERY); + bool isBullOutput = (as_ == BULL_ANCHOR_SHARE && aw == BULL_ANCHOR_WIDTH && dd == BULL_DISCOVERY); + assertTrue(isBearOutput || isBullOutput, "output must be bear or bull"); } }