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>
326 lines
13 KiB
Solidity
326 lines
13 KiB
Solidity
// 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);
|
||
}
|
||
}
|