harb/onchain/src/Optimizer.sol
openhands 9bb223cf95 fix: Optimizer.sol also silently accepts negative mantissa inputs (#582)
Add require(mantissa >= 0) guards in calculateParams before the uint256()
casts on inputs[0] and inputs[1], preventing negative int256 values from
wrapping to huge uint256 numbers and corrupting liquidity calculations.
Add two regression tests covering the revert paths for both slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:41:39 +00:00

326 lines
13 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

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

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import {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";
// ---------------------------------------------------------------------------
// 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 Calculates liquidity parameters for the LiquidityManager using an
* 8-slot dyadic rational input interface (Push3's native format).
*
* @dev Upgradeable (UUPS). The core logic lives in `calculateParams`, which is
* a pure function taking an OptimizerInput[8] array. Future upgrades may
* replace `calculateParams` with a transpiled Push3 program via the
* evolution pipeline (#544, #545, #546).
*
* Input slots:
* 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)
*
* 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);
/**
* @notice Initialize the Optimizer.
* @param _kraiken The address of the Kraiken token.
* @param _stake The address of the Stake contract.
*/
function initialize(address _kraiken, address _stake) public initializer {
// Set the admin for upgradeability (using ERC1967Upgrade _changeAdmin)
_changeAdmin(msg.sender);
kraiken = Kraiken(_kraiken);
stake = Stake(_stake);
}
modifier onlyAdmin() {
_checkAdmin();
_;
}
function _checkAdmin() internal view virtual {
if (_getAdmin() != msg.sender) {
revert UnauthorizedAccount(msg.sender);
}
}
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin {}
// ---- Data-source configuration (admin only) ----
/**
* @notice Configure optional on-chain data sources for input slots 2 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.
* @param averageTaxRate The average tax rate (as returned by the Stake contract).
* @param percentageStaked The percentage (in 1e18 precision) of the authorized stake that is currently staked.
* @return sentimentValue A value in the range 0 to 1e18 where 1e18 represents the worst sentiment.
*/
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked)
public
pure
returns (uint256 sentimentValue)
{
// Ensure percentageStaked doesn't exceed 100%
require(percentageStaked <= 1e18, "Invalid percentage staked");
// deltaS is the "slack" available below full staking
uint256 deltaS = 1e18 - percentageStaked;
if (percentageStaked > 92e16) {
// If more than 92% of the authorized stake is in use, the sentiment drops rapidly.
// Penalty is computed as: (deltaS^3 * averageTaxRate) / (20 * 1e48)
uint256 penalty = (deltaS * deltaS * deltaS * averageTaxRate) / (20 * 1e48);
sentimentValue = penalty / 2;
} else {
// For lower staked percentages, sentiment decreases roughly linearly.
// Ensure we don't underflow if percentageStaked approaches 92%
uint256 scaledStake = (percentageStaked * 1e18) / (92e16);
uint256 baseSentiment = scaledStake >= 1e18 ? 0 : 1e18 - scaledStake;
// Apply a penalty based on the average tax rate.
if (averageTaxRate <= 1e16) {
sentimentValue = baseSentiment;
} else if (averageTaxRate <= 5e16) {
uint256 ratePenalty = ((averageTaxRate - 1e16) * baseSentiment) / (4e16);
sentimentValue = baseSentiment > ratePenalty ? baseSentiment - ratePenalty : 0;
} else {
// For very high tax rates, sentiment is maximally poor.
sentimentValue = 1e18;
}
}
return sentimentValue;
}
/**
* @notice Returns the current sentiment.
* @return sentiment A number (with 1e18 precision) representing the staker sentiment.
*/
function getSentiment() external view returns (uint256 sentiment) {
uint256 percentageStaked = stake.getPercentageStaked();
uint256 averageTaxRate = stake.getAverageTaxRate();
sentiment = calculateSentiment(averageTaxRate, percentageStaked);
}
/**
* @notice Calculates the optimal anchor width based on staking metrics.
* @param percentageStaked The percentage of tokens staked (0 to 1e18)
* @param averageTaxRate The average tax rate across all stakers (0 to 1e18)
* @return anchorWidth The calculated anchor width (10 to 80)
*/
function _calculateAnchorWidth(uint256 percentageStaked, uint256 averageTaxRate) internal pure returns (uint24) {
// Base width: 40% is our neutral starting point
int256 baseWidth = 40;
// Staking adjustment: -20% to +20% based on staking percentage
int256 stakingAdjustment = 20 - int256(percentageStaked * 40 / 1e18);
// Tax rate adjustment: -10% to +30% based on average tax rate
int256 taxAdjustment = int256(averageTaxRate * 40 / 1e18) - 10;
// Combine all adjustments
int256 totalWidth = baseWidth + stakingAdjustment + taxAdjustment;
// Clamp to safe bounds (10 to 80)
if (totalWidth < 10) {
return 10;
}
if (totalWidth > 80) {
return 80;
}
return uint24(uint256(totalWidth));
}
/**
* @notice Pure computation of all four liquidity parameters from 8 dyadic inputs.
*
* @dev This is the transpilation target: future versions of this function will be
* generated from evolved Push3 programs via the transpiler. The current
* implementation uses slots 0 (percentageStaked) and 1 (averageTaxRate);
* slots 2-7 are available to evolved programs that use additional trackers.
*
* @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 calculateParams(OptimizerInput[8] memory inputs)
public
pure
virtual
returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth)
{
// Guard against negative mantissa — uint256() cast silently wraps negatives.
require(inputs[0].mantissa >= 0, "negative mantissa");
require(inputs[1].mantissa >= 0, "negative mantissa");
// Extract slots 0 and 1 (shift=0 assumed — mantissa IS the value)
uint256 percentageStaked = uint256(inputs[0].mantissa);
uint256 averageTaxRate = uint256(inputs[1].mantissa);
uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked);
if (sentiment > 1e18) {
sentiment = 1e18;
}
capitalInefficiency = 1e18 - sentiment;
anchorShare = sentiment;
anchorWidth = _calculateAnchorWidth(percentageStaked, averageTaxRate);
discoveryDepth = sentiment;
}
/**
* @notice Returns liquidity parameters for the LiquidityManager.
*
* @dev Populates the 8-slot dyadic input array 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);
}
}