feat: OptimizerV3 with direct 2D staking-to-LP parameter mapping
Core protocol changes for launch readiness: - OptimizerV3: binary bear/bull mapping from (staking%, avgTax) — avoids exploitable AW 30-90 kill zone. Bear: AS=30%, AW=100, CI=0, DD=0.3e18. Bull: AS=100%, AW=20, CI=0, DD=1e18. UUPS upgradeable with __gap[48]. - Directional VWAP: only records prices on ETH inflow (buys), preventing sell-side dilution of price memory - Floor formula: unified max(scarcity, mirror, clamp) — VWAP mirror uses distance from adjusted VWAP as floor distance, no branching - PriceOracle (M-1 fix): correct fallback TWAP divisor (60000s, not 300s) - Access control (M-2 fix): deployer-only guard on one-time setters - Recenter rate limit (M-3 fix): 60-second cooldown for open recenters - Safe fallback params: recenter() optimizer-failure defaults changed from exploitable CI=50%/AW=50 to safe bear-mode CI=0/AW=100 - Recentered event for monitoring and indexing - VERSION bump to 2, kraiken-lib COMPATIBLE_CONTRACT_VERSIONS updated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21857ae8ca
commit
85350caf52
38 changed files with 3793 additions and 205 deletions
|
|
@ -24,11 +24,14 @@ contract Kraiken is ERC20, ERC20Permit {
|
|||
*
|
||||
* Version History:
|
||||
* - v1: Initial deployment with 30-tier TAX_RATES
|
||||
* - v2: OptimizerV3, VWAP mirror floor, directional VWAP recording
|
||||
*/
|
||||
uint256 public constant VERSION = 1;
|
||||
uint256 public constant VERSION = 2;
|
||||
|
||||
// Minimum fraction of the total supply required for staking to prevent fragmentation of staking positions
|
||||
uint256 private constant MIN_STAKE_FRACTION = 3000;
|
||||
// Address authorized to call one-time setters (prevents frontrunning)
|
||||
address private immutable deployer;
|
||||
// Address of the liquidity manager
|
||||
address private liquidityManager;
|
||||
// Address of the staking pool
|
||||
|
|
@ -52,7 +55,9 @@ contract Kraiken is ERC20, ERC20Permit {
|
|||
* @param name_ The name of the token
|
||||
* @param symbol_ The symbol of the token
|
||||
*/
|
||||
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) ERC20Permit(name_) { }
|
||||
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) ERC20Permit(name_) {
|
||||
deployer = msg.sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Sets the address for the liquidityManager. Used once post-deployment to initialize the contract.
|
||||
|
|
@ -61,6 +66,7 @@ contract Kraiken is ERC20, ERC20Permit {
|
|||
* @param liquidityManager_ The address of the liquidity manager.
|
||||
*/
|
||||
function setLiquidityManager(address liquidityManager_) external {
|
||||
require(msg.sender == deployer, "only deployer");
|
||||
if (address(0) == liquidityManager_) revert ZeroAddressInSetter();
|
||||
if (liquidityManager != address(0)) revert AddressAlreadySet();
|
||||
liquidityManager = liquidityManager_;
|
||||
|
|
@ -73,6 +79,7 @@ contract Kraiken is ERC20, ERC20Permit {
|
|||
* @param stakingPool_ The address of the staking pool.
|
||||
*/
|
||||
function setStakingPool(address stakingPool_) external {
|
||||
require(msg.sender == deployer, "only deployer");
|
||||
if (address(0) == stakingPool_) revert ZeroAddressInSetter();
|
||||
if (stakingPool != address(0)) revert AddressAlreadySet();
|
||||
stakingPool = stakingPool_;
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ pragma solidity ^0.8.19;
|
|||
|
||||
import { Kraiken } from "./Kraiken.sol";
|
||||
import { Optimizer } from "./Optimizer.sol";
|
||||
|
||||
import "./abstracts/PriceOracle.sol";
|
||||
import "./abstracts/ThreePositionStrategy.sol";
|
||||
import "./interfaces/IWETH9.sol";
|
||||
import "@aperture/uni-v3-lib/CallbackValidation.sol";
|
||||
import "@aperture/uni-v3-lib/PoolAddress.sol";
|
||||
import "@openzeppelin/token/ERC20/IERC20.sol";
|
||||
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import "@uniswap-v3-periphery/libraries/PositionKey.sol";
|
||||
import { PriceOracle } from "./abstracts/PriceOracle.sol";
|
||||
import { ThreePositionStrategy } from "./abstracts/ThreePositionStrategy.sol";
|
||||
import { IWETH9 } from "./interfaces/IWETH9.sol";
|
||||
import { CallbackValidation } from "@aperture/uni-v3-lib/CallbackValidation.sol";
|
||||
import { PoolAddress, PoolKey } from "@aperture/uni-v3-lib/PoolAddress.sol";
|
||||
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
||||
import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol";
|
||||
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import { PositionKey } from "@uniswap-v3-periphery/libraries/PositionKey.sol";
|
||||
|
||||
/**
|
||||
* @title LiquidityManager
|
||||
|
|
@ -29,6 +29,8 @@ import "@uniswap-v3-periphery/libraries/PositionKey.sol";
|
|||
* - Prevents oracle manipulation attacks
|
||||
*/
|
||||
contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
||||
using SafeERC20 for IERC20;
|
||||
|
||||
/// @notice Uniswap V3 fee tier (1%) - 10,000 basis points
|
||||
uint24 internal constant FEE = uint24(10_000);
|
||||
|
||||
|
|
@ -42,9 +44,21 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
PoolKey private poolKey;
|
||||
|
||||
/// @notice Access control and fee management
|
||||
address private immutable deployer;
|
||||
address public recenterAccess;
|
||||
address public feeDestination;
|
||||
|
||||
/// @notice Last recenter tick — used to determine net trade direction between recenters
|
||||
int24 public lastRecenterTick;
|
||||
|
||||
/// @notice Last recenter timestamp — rate limits open (permissionless) recenters
|
||||
uint256 public lastRecenterTime;
|
||||
/// @notice Minimum seconds between open recenters (when recenterAccess is unset)
|
||||
uint256 internal constant MIN_RECENTER_INTERVAL = 60;
|
||||
|
||||
/// @notice Emitted on each successful recenter for monitoring and indexing
|
||||
event Recentered(int24 indexed currentTick, bool indexed isUp);
|
||||
|
||||
/// @notice Custom errors
|
||||
error ZeroAddressInSetter();
|
||||
error AddressAlreadySet();
|
||||
|
|
@ -61,6 +75,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
/// @param _kraiken The address of the Kraiken token contract
|
||||
/// @param _optimizer The address of the optimizer contract
|
||||
constructor(address _factory, address _WETH9, address _kraiken, address _optimizer) {
|
||||
deployer = msg.sender;
|
||||
factory = _factory;
|
||||
weth = IWETH9(_WETH9);
|
||||
poolKey = PoolAddress.getPoolKey(_WETH9, _kraiken, FEE);
|
||||
|
|
@ -90,13 +105,14 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
}
|
||||
|
||||
// Transfer tokens to pool
|
||||
if (amount0Owed > 0) IERC20(poolKey.token0).transfer(msg.sender, amount0Owed);
|
||||
if (amount1Owed > 0) IERC20(poolKey.token1).transfer(msg.sender, amount1Owed);
|
||||
if (amount0Owed > 0) IERC20(poolKey.token0).safeTransfer(msg.sender, amount0Owed);
|
||||
if (amount1Owed > 0) IERC20(poolKey.token1).safeTransfer(msg.sender, amount1Owed);
|
||||
}
|
||||
|
||||
/// @notice Sets the fee destination address (can only be called once)
|
||||
/// @notice Sets the fee destination address (can only be called once, deployer only)
|
||||
/// @param feeDestination_ The address that will receive trading fees
|
||||
function setFeeDestination(address feeDestination_) external {
|
||||
require(msg.sender == deployer, "only deployer");
|
||||
if (address(0) == feeDestination_) revert ZeroAddressInSetter();
|
||||
if (feeDestination != address(0)) revert AddressAlreadySet();
|
||||
feeDestination = feeDestination_;
|
||||
|
|
@ -122,8 +138,10 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
if (recenterAccess != address(0)) {
|
||||
require(msg.sender == recenterAccess, "access denied");
|
||||
} else {
|
||||
require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown");
|
||||
require(_isPriceStable(currentTick), "price deviated from oracle");
|
||||
}
|
||||
lastRecenterTime = block.timestamp;
|
||||
|
||||
// Check if price movement is sufficient for recentering
|
||||
isUp = false;
|
||||
|
|
@ -138,7 +156,22 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
}
|
||||
|
||||
// Remove all existing positions and collect fees
|
||||
_scrapePositions();
|
||||
// Pass tick direction to determine if VWAP should record (ETH inflow only)
|
||||
bool shouldRecordVWAP;
|
||||
if (cumulativeVolume == 0) {
|
||||
// No VWAP data yet — always bootstrap to prevent vwapX96=0 fallback
|
||||
shouldRecordVWAP = true;
|
||||
} else if (lastRecenterTick != 0) {
|
||||
// token0isWeth: tick DOWN = price up in KRK terms = buys = ETH inflow
|
||||
// !token0isWeth: tick UP = price up in KRK terms = buys = ETH inflow
|
||||
shouldRecordVWAP = token0isWeth ? (currentTick < lastRecenterTick) : (currentTick > lastRecenterTick);
|
||||
} else {
|
||||
// First recenter — no reference point, record conservatively
|
||||
shouldRecordVWAP = true;
|
||||
}
|
||||
lastRecenterTick = currentTick;
|
||||
|
||||
_scrapePositions(shouldRecordVWAP);
|
||||
|
||||
// Update total supply tracking if price moved up
|
||||
if (isUp) {
|
||||
|
|
@ -157,20 +190,23 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
|
||||
_setPositions(currentTick, params);
|
||||
} catch {
|
||||
// Fallback to default parameters if optimizer fails
|
||||
// Fallback to safe bear-mode defaults if optimizer fails
|
||||
PositionParams memory defaultParams = PositionParams({
|
||||
capitalInefficiency: 5 * 10 ** 17, // 50%
|
||||
anchorShare: 5 * 10 ** 17, // 50%
|
||||
anchorWidth: 50, // 50%
|
||||
discoveryDepth: 5 * 10 ** 17 // 50%
|
||||
capitalInefficiency: 0, // CI=0 proven safest
|
||||
anchorShare: 3e17, // 30% — defensive floor allocation
|
||||
anchorWidth: 100, // Max width — avoids AW 30-90 kill zone
|
||||
discoveryDepth: 3e17 // 0.3e18
|
||||
});
|
||||
|
||||
_setPositions(currentTick, defaultParams);
|
||||
}
|
||||
|
||||
emit Recentered(currentTick, isUp);
|
||||
}
|
||||
|
||||
/// @notice Removes all positions and collects fees
|
||||
function _scrapePositions() internal {
|
||||
/// @param recordVWAP Whether to record VWAP (only when net ETH inflow since last recenter)
|
||||
function _scrapePositions(bool recordVWAP) internal {
|
||||
uint256 fee0 = 0;
|
||||
uint256 fee1 = 0;
|
||||
uint256 currentPrice;
|
||||
|
|
@ -197,21 +233,23 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
}
|
||||
|
||||
// Transfer fees and record volume for VWAP
|
||||
// Only record VWAP when net ETH inflow (KRK sold out) — prevents sell-back
|
||||
// activity from diluting the price memory of original KRK distribution
|
||||
if (fee0 > 0) {
|
||||
if (token0isWeth) {
|
||||
IERC20(address(weth)).transfer(feeDestination, fee0);
|
||||
_recordVolumeAndPrice(currentPrice, fee0);
|
||||
IERC20(address(weth)).safeTransfer(feeDestination, fee0);
|
||||
if (recordVWAP) _recordVolumeAndPrice(currentPrice, fee0);
|
||||
} else {
|
||||
IERC20(address(kraiken)).transfer(feeDestination, fee0);
|
||||
IERC20(address(kraiken)).safeTransfer(feeDestination, fee0);
|
||||
}
|
||||
}
|
||||
|
||||
if (fee1 > 0) {
|
||||
if (token0isWeth) {
|
||||
IERC20(address(kraiken)).transfer(feeDestination, fee1);
|
||||
IERC20(address(kraiken)).safeTransfer(feeDestination, fee1);
|
||||
} else {
|
||||
IERC20(address(weth)).transfer(feeDestination, fee1);
|
||||
_recordVolumeAndPrice(currentPrice, fee1);
|
||||
IERC20(address(weth)).safeTransfer(feeDestination, fee1);
|
||||
if (recordVWAP) _recordVolumeAndPrice(currentPrice, fee1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -255,7 +293,17 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
|
|||
}
|
||||
|
||||
/// @notice Implementation of abstract function from ThreePositionStrategy
|
||||
/// @dev Subtracts KRK at feeDestination (protocol revenue) and stakingPool (locked in staking)
|
||||
/// since neither can be sold into the floor — only trader-held KRK matters for scarcity
|
||||
function _getOutstandingSupply() internal view override returns (uint256) {
|
||||
return kraiken.outstandingSupply();
|
||||
uint256 supply = kraiken.outstandingSupply();
|
||||
if (feeDestination != address(0)) {
|
||||
supply -= kraiken.balanceOf(feeDestination);
|
||||
}
|
||||
(, address stakingPoolAddr) = kraiken.peripheryContracts();
|
||||
if (stakingPoolAddr != address(0)) {
|
||||
supply -= kraiken.balanceOf(stakingPoolAddr);
|
||||
}
|
||||
return supply;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
131
onchain/src/OptimizerV2.sol
Normal file
131
onchain/src/OptimizerV2.sol
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { Kraiken } from "./Kraiken.sol";
|
||||
import { Stake } from "./Stake.sol";
|
||||
import { Math } from "@openzeppelin/utils/math/Math.sol";
|
||||
|
||||
import { Initializable } from "@openzeppelin/proxy/utils/Initializable.sol";
|
||||
import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
|
||||
|
||||
/**
|
||||
* @title OptimizerV2
|
||||
* @notice Sentiment-driven liquidity parameter optimizer based on empirical fuzzing results.
|
||||
* @dev Replaces the original Optimizer with a mapping informed by adversarial analysis:
|
||||
*
|
||||
* Key findings from parameter sweep + Gaussian competition model:
|
||||
*
|
||||
* 1. capitalInefficiency has ZERO effect on fee revenue. It only affects floor placement.
|
||||
* Always set to 0 for maximum safety (adjusted VWAP = 0.7× → furthest floor).
|
||||
*
|
||||
* 2. anchorShare and anchorWidth are the ONLY fee levers:
|
||||
* - Bull: AS=100%, AW=20 → deep narrow anchor → maximizes KRK fees (which appreciate)
|
||||
* - Bear: AS=10%, AW=100 → thin wide anchor → maximizes WETH fees + safe floor distance
|
||||
*
|
||||
* 3. The two regimes naturally align safety with fee optimization:
|
||||
* - Bearish config is also the safest against drain attacks (AW=100 → 7000 tick clamp)
|
||||
* - Bullish config maximizes revenue when floor safety is least needed
|
||||
*
|
||||
* Staking sentiment drives the interpolation:
|
||||
* - High staking % + low tax rate → bullish (sentiment=0) → aggressive fee capture
|
||||
* - Low staking % + high tax rate → bearish (sentiment=1e18) → defensive positioning
|
||||
*/
|
||||
contract OptimizerV2 is Initializable, UUPSUpgradeable {
|
||||
Kraiken private kraiken;
|
||||
Stake private stake;
|
||||
|
||||
/// @dev Reverts if the caller is not the admin.
|
||||
error UnauthorizedAccount(address account);
|
||||
|
||||
function initialize(address _kraiken, address _stake) public initializer {
|
||||
_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 { }
|
||||
|
||||
/**
|
||||
* @notice Calculates sentiment from staking metrics.
|
||||
* @dev Reuses the V1 sentiment formula for continuity.
|
||||
* sentiment = 0 → bullish, sentiment = 1e18 → bearish.
|
||||
*/
|
||||
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) {
|
||||
require(percentageStaked <= 1e18, "Invalid percentage staked");
|
||||
|
||||
uint256 deltaS = 1e18 - percentageStaked;
|
||||
|
||||
if (percentageStaked > 92e16) {
|
||||
uint256 penalty = (deltaS * deltaS * deltaS * averageTaxRate) / (20 * 1e48);
|
||||
sentimentValue = penalty / 2;
|
||||
} else {
|
||||
uint256 scaledStake = (percentageStaked * 1e18) / (92e16);
|
||||
uint256 baseSentiment = scaledStake >= 1e18 ? 0 : 1e18 - scaledStake;
|
||||
if (averageTaxRate <= 1e16) {
|
||||
sentimentValue = baseSentiment;
|
||||
} else if (averageTaxRate <= 5e16) {
|
||||
uint256 ratePenalty = ((averageTaxRate - 1e16) * baseSentiment) / (4e16);
|
||||
sentimentValue = baseSentiment > ratePenalty ? baseSentiment - ratePenalty : 0;
|
||||
} else {
|
||||
sentimentValue = 1e18;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSentiment() external view returns (uint256 sentiment) {
|
||||
uint256 percentageStaked = stake.getPercentageStaked();
|
||||
uint256 averageTaxRate = stake.getAverageTaxRate();
|
||||
sentiment = calculateSentiment(averageTaxRate, percentageStaked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Returns liquidity parameters driven by staking sentiment.
|
||||
*
|
||||
* @return capitalInefficiency Always 0 — maximizes floor safety with no fee cost.
|
||||
* @return anchorShare sqrt-scaled: bull(0)=100% → bear(1e18)=10%.
|
||||
* @return anchorWidth sqrt-scaled: bull(0)=20 → bear(1e18)=100.
|
||||
* @return discoveryDepth Interpolated with sentiment (unchanged from V1).
|
||||
*
|
||||
* @dev Uses square-root response curve for ASYMMETRIC transitions:
|
||||
* - Slow ramp to bull: requires sustained high staking to reach aggressive params
|
||||
* - Fast snap to bear: small drops in staking cause large safety jumps
|
||||
* Makes staking manipulation expensive: attacker must maintain >90% staking.
|
||||
*/
|
||||
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);
|
||||
|
||||
if (sentiment > 1e18) sentiment = 1e18;
|
||||
|
||||
// CI = 0 always. No fee impact, maximum floor safety.
|
||||
capitalInefficiency = 0;
|
||||
|
||||
// sqrt(sentiment) for aggressive bear transition:
|
||||
// sentiment=2.2% (staking=90%) → sqrtS=14.8% → already shifting defensive
|
||||
// sentiment=13% (staking=80%) → sqrtS=36% → well into defensive
|
||||
// sentiment=100% (staking=0%) → sqrtS=100% → full bear
|
||||
uint256 sqrtS = Math.sqrt(sentiment * 1e18);
|
||||
// sqrtS is now in range [0, 1e18]. Scale to match sentiment range.
|
||||
|
||||
// AS: 100% (bull) → 10% (bear), sqrt-scaled
|
||||
anchorShare = 1e18 - (sqrtS * 90 / 100);
|
||||
|
||||
// AW: 20 (bull) → 100 (bear), sqrt-scaled
|
||||
anchorWidth = uint24(20 + (sqrtS * 80 / 1e18));
|
||||
|
||||
// DD: keep sentiment-driven (V1 behavior)
|
||||
discoveryDepth = sentiment;
|
||||
}
|
||||
}
|
||||
191
onchain/src/OptimizerV3.sol
Normal file
191
onchain/src/OptimizerV3.sol
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* @title OptimizerV3
|
||||
* @notice Direct 2D (staking%, avgTax) → binary bear/bull liquidity optimizer.
|
||||
* @dev Replaces the three-zone score-based model with a direct mapping:
|
||||
*
|
||||
* staked ≤ 91% → BEAR always (no euphoria signal)
|
||||
* staked > 91% → BULL if deltaS³ × effIdx / 20 < 50, else BEAR
|
||||
*
|
||||
* where deltaS = 100 - stakedPct, effIdx = min(29, taxIdx + (taxIdx >= 14 ? 1 : 0))
|
||||
*
|
||||
* Bear: AS=30%, AW=100, CI=0, DD=0.3e18
|
||||
* Bull: AS=100%, AW=20, CI=0, DD=1e18
|
||||
*
|
||||
* The binary step avoids the AW 30-90 kill zone where intermediate params are exploitable.
|
||||
* CI = 0 always (proven to have zero effect on fee revenue).
|
||||
*
|
||||
* Bull requires >91% staked with low enough tax. Any decline → instant snap to bear.
|
||||
*/
|
||||
contract OptimizerV3 is Initializable, UUPSUpgradeable {
|
||||
Kraiken private kraiken;
|
||||
Stake private stake;
|
||||
|
||||
/// @dev Reserved storage gap for future upgrades (50 slots total: 2 used + 48 reserved)
|
||||
uint256[48] private __gap;
|
||||
|
||||
/// @dev Reverts if the caller is not the admin.
|
||||
error UnauthorizedAccount(address account);
|
||||
|
||||
/// @notice Initializes the proxy with Kraiken and Stake contract references.
|
||||
/// @param _kraiken The Kraiken token contract address
|
||||
/// @param _stake The Stake contract address
|
||||
function initialize(address _kraiken, address _stake) public initializer {
|
||||
_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 { }
|
||||
|
||||
/**
|
||||
* @notice Maps a normalized average tax rate (0-1e18) to an effective tax rate index.
|
||||
* @dev The Stake contract normalizes: averageTaxRate = rawRate * 1e18 / MAX_TAX_RATE.
|
||||
* We compare against pre-computed normalized midpoints between adjacent TAX_RATES
|
||||
* to find the closest index, avoiding double-truncation from denormalization.
|
||||
*
|
||||
* The effective index has a +1 shift at position ≥14 to account for the
|
||||
* non-uniform spacing in the TAX_RATES array (130% → 180% is a 38% jump),
|
||||
* capped at 29.
|
||||
*/
|
||||
function _taxRateToEffectiveIndex(uint256 averageTaxRate) internal pure returns (uint256) {
|
||||
// Pre-computed normalized midpoints between adjacent TAX_RATES:
|
||||
// midpoint_norm = ((TAX_RATES[i] + TAX_RATES[i+1]) / 2) * 1e18 / 9700
|
||||
// Using these directly avoids integer truncation from denormalization.
|
||||
uint256 idx;
|
||||
if (averageTaxRate <= 206_185_567_010_309) idx = 0; // midpoint(1,3)
|
||||
|
||||
else if (averageTaxRate <= 412_371_134_020_618) idx = 1; // midpoint(3,5)
|
||||
|
||||
else if (averageTaxRate <= 618_556_701_030_927) idx = 2; // midpoint(5,8)
|
||||
|
||||
else if (averageTaxRate <= 1_030_927_835_051_546) idx = 3; // midpoint(8,12)
|
||||
|
||||
else if (averageTaxRate <= 1_546_391_752_577_319) idx = 4; // midpoint(12,18)
|
||||
|
||||
else if (averageTaxRate <= 2_164_948_453_608_247) idx = 5; // midpoint(18,24)
|
||||
|
||||
else if (averageTaxRate <= 2_783_505_154_639_175) idx = 6; // midpoint(24,30)
|
||||
|
||||
else if (averageTaxRate <= 3_608_247_422_680_412) idx = 7; // midpoint(30,40)
|
||||
|
||||
else if (averageTaxRate <= 4_639_175_257_731_958) idx = 8; // midpoint(40,50)
|
||||
|
||||
else if (averageTaxRate <= 5_670_103_092_783_505) idx = 9; // midpoint(50,60)
|
||||
|
||||
else if (averageTaxRate <= 7_216_494_845_360_824) idx = 10; // midpoint(60,80)
|
||||
|
||||
else if (averageTaxRate <= 9_278_350_515_463_917) idx = 11; // midpoint(80,100)
|
||||
|
||||
else if (averageTaxRate <= 11_855_670_103_092_783) idx = 12; // midpoint(100,130)
|
||||
|
||||
else if (averageTaxRate <= 15_979_381_443_298_969) idx = 13; // midpoint(130,180)
|
||||
|
||||
else if (averageTaxRate <= 22_164_948_453_608_247) idx = 14; // midpoint(180,250)
|
||||
|
||||
else if (averageTaxRate <= 29_381_443_298_969_072) idx = 15; // midpoint(250,320)
|
||||
|
||||
else if (averageTaxRate <= 38_144_329_896_907_216) idx = 16; // midpoint(320,420)
|
||||
|
||||
else if (averageTaxRate <= 49_484_536_082_474_226) idx = 17; // midpoint(420,540)
|
||||
|
||||
else if (averageTaxRate <= 63_917_525_773_195_876) idx = 18; // midpoint(540,700)
|
||||
|
||||
else if (averageTaxRate <= 83_505_154_639_175_257) idx = 19; // midpoint(700,920)
|
||||
|
||||
else if (averageTaxRate <= 109_278_350_515_463_917) idx = 20; // midpoint(920,1200)
|
||||
|
||||
else if (averageTaxRate <= 144_329_896_907_216_494) idx = 21; // midpoint(1200,1600)
|
||||
|
||||
else if (averageTaxRate <= 185_567_010_309_278_350) idx = 22; // midpoint(1600,2000)
|
||||
|
||||
else if (averageTaxRate <= 237_113_402_061_855_670) idx = 23; // midpoint(2000,2600)
|
||||
|
||||
else if (averageTaxRate <= 309_278_350_515_463_917) idx = 24; // midpoint(2600,3400)
|
||||
|
||||
else if (averageTaxRate <= 402_061_855_670_103_092) idx = 25; // midpoint(3400,4400)
|
||||
|
||||
else if (averageTaxRate <= 520_618_556_701_030_927) idx = 26; // midpoint(4400,5700)
|
||||
|
||||
else if (averageTaxRate <= 680_412_371_134_020_618) idx = 27; // midpoint(5700,7500)
|
||||
|
||||
else if (averageTaxRate <= 886_597_938_144_329_896) idx = 28; // midpoint(7500,9700)
|
||||
|
||||
else idx = 29;
|
||||
|
||||
// Apply effective index shift: +1 at idx >= 14, capped at 29
|
||||
if (idx >= 14) {
|
||||
idx = idx + 1;
|
||||
if (idx > 29) idx = 29;
|
||||
}
|
||||
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* @dev Direct 2D mapping — no intermediate score:
|
||||
* staked ≤ 91% → always bear (no euphoria signal)
|
||||
* staked > 91% → bull if deltaS³ × effIdx / 20 < 50
|
||||
* where deltaS = 100 - stakedPct (integer percentage)
|
||||
*/
|
||||
function isBullMarket(uint256 percentageStaked, uint256 averageTaxRate) public pure returns (bool bull) {
|
||||
require(percentageStaked <= 1e18, "Invalid percentage staked");
|
||||
|
||||
uint256 stakedPct = percentageStaked * 100 / 1e18; // 0-100
|
||||
if (stakedPct <= 91) return false;
|
||||
|
||||
uint256 deltaS = 100 - stakedPct; // 0-8
|
||||
uint256 effIdx = _taxRateToEffectiveIndex(averageTaxRate);
|
||||
uint256 penalty = deltaS * deltaS * deltaS * effIdx / 20;
|
||||
return penalty < 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Returns liquidity parameters driven by the direct 2D staking→config mapping.
|
||||
*
|
||||
* @return capitalInefficiency Always 0 — proven to have zero effect on fee revenue.
|
||||
* @return anchorShare Bear=30% (0.3e18), Bull=100% (1e18).
|
||||
* @return anchorWidth Bear=100, Bull=20.
|
||||
* @return discoveryDepth Bear=0.3e18, Bull=1e18.
|
||||
*/
|
||||
function getLiquidityParams() external view returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) {
|
||||
uint256 percentageStaked = stake.getPercentageStaked();
|
||||
uint256 averageTaxRate = stake.getAverageTaxRate();
|
||||
|
||||
capitalInefficiency = 0;
|
||||
|
||||
if (isBullMarket(percentageStaked, averageTaxRate)) {
|
||||
anchorShare = 1e18; // 100%
|
||||
anchorWidth = 20;
|
||||
discoveryDepth = 1e18;
|
||||
} else {
|
||||
anchorShare = 3e17; // 30%
|
||||
anchorWidth = 100;
|
||||
discoveryDepth = 3e17; // 0.3e18
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,9 @@ abstract contract VWAPTracker {
|
|||
|
||||
/**
|
||||
* @notice Records volume and price data for VWAP calculation
|
||||
* @param currentPriceX96 The current price in X96 format (actually price² from _priceAtTick)
|
||||
* @param currentPriceX96 The current price in Q96 format (price * 2^96, from _priceAtTick)
|
||||
* @param fee The fee amount used to calculate volume
|
||||
* @dev Assumes fee represents 1% of volume, handles overflow by compressing historic data
|
||||
* @dev IMPORTANT: currentPriceX96 is expected to be price² (squared price), not regular price
|
||||
*/
|
||||
function _recordVolumeAndPrice(uint256 currentPriceX96, uint256 fee) internal {
|
||||
// assuming FEE is 1%
|
||||
|
|
|
|||
|
|
@ -34,10 +34,11 @@ abstract contract PriceOracle {
|
|||
averageTick = int24(tickCumulativeDiff / int56(int32(PRICE_STABILITY_INTERVAL)));
|
||||
} catch {
|
||||
// Fallback to longer timeframe if recent data unavailable
|
||||
secondsAgo[0] = PRICE_STABILITY_INTERVAL * 200;
|
||||
uint32 fallbackInterval = PRICE_STABILITY_INTERVAL * 200; // 60,000 seconds
|
||||
secondsAgo[0] = fallbackInterval;
|
||||
(int56[] memory tickCumulatives,) = pool.observe(secondsAgo);
|
||||
int56 tickCumulativeDiff = tickCumulatives[1] - tickCumulatives[0];
|
||||
averageTick = int24(tickCumulativeDiff / int56(int32(PRICE_STABILITY_INTERVAL)));
|
||||
averageTick = int24(tickCumulativeDiff / int56(int32(fallbackInterval)));
|
||||
}
|
||||
|
||||
isStable = (currentTick >= averageTick - MAX_TICK_DEVIATION && currentTick <= averageTick + MAX_TICK_DEVIATION);
|
||||
|
|
|
|||
|
|
@ -56,11 +56,14 @@ abstract contract ThreePositionStrategy is UniswapMath, VWAPTracker {
|
|||
/// @notice Storage for the three positions
|
||||
mapping(Stage => TokenPosition) public positions;
|
||||
|
||||
/// @notice Deprecated — was floor high-water mark. Kept for storage layout compatibility.
|
||||
int24 public __deprecated_floorHighWaterMark;
|
||||
|
||||
/// @notice Events for tracking ETH abundance/scarcity scenarios
|
||||
event EthScarcity(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, int24 vwapTick);
|
||||
event EthAbundance(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, int24 vwapTick);
|
||||
|
||||
/// @notice Abstract functions that must be implemented by inheriting contracts
|
||||
|
||||
function _getKraikenToken() internal view virtual returns (address);
|
||||
function _getWethToken() internal view virtual returns (address);
|
||||
function _isToken0Weth() internal view virtual returns (bool);
|
||||
|
|
@ -188,43 +191,10 @@ abstract contract ThreePositionStrategy is UniswapMath, VWAPTracker {
|
|||
outstandingSupply -= pulledKraiken;
|
||||
outstandingSupply -= (outstandingSupply >= discoveryAmount) ? discoveryAmount : outstandingSupply;
|
||||
|
||||
// Use VWAP for floor position (historical price memory for dormant whale protection)
|
||||
uint256 vwapX96 = getAdjustedVWAP(params.capitalInefficiency);
|
||||
uint256 ethBalance = _getEthBalance();
|
||||
int24 vwapTick;
|
||||
|
||||
if (vwapX96 > 0) {
|
||||
// vwapX96 is price² in X96 format, need to convert to regular price
|
||||
// price = sqrt(price²) = sqrt(vwapX96) * 2^48 / 2^96 = sqrt(vwapX96) / 2^48
|
||||
uint256 sqrtVwapX96 = Math.sqrt(vwapX96) << 48; // sqrt(price²) in X96 format
|
||||
uint256 requiredEthForBuyback = outstandingSupply.mulDiv(sqrtVwapX96, (1 << 96));
|
||||
|
||||
if (floorEthBalance < requiredEthForBuyback) {
|
||||
// ETH scarcity: not enough ETH to buy back at VWAP price
|
||||
uint256 balancedCapital = (7 * outstandingSupply / 10) + (outstandingSupply * params.capitalInefficiency / 10 ** 18);
|
||||
vwapTick = _tickAtPrice(token0isWeth, balancedCapital, floorEthBalance);
|
||||
emit EthScarcity(currentTick, ethBalance, outstandingSupply, vwapX96, vwapTick);
|
||||
} else {
|
||||
// ETH abundance: sufficient ETH reserves
|
||||
// vwapX96 is price² in X96 format, need to convert to regular price in X64 format
|
||||
// price = sqrt(price²), then convert from X96 to X64 by >> 32
|
||||
uint256 sqrtVwapX96Abundance = Math.sqrt(vwapX96) << 48; // sqrt(price²) in X96 format
|
||||
vwapTick = _tickAtPriceRatio(int128(int256(sqrtVwapX96Abundance >> 32)));
|
||||
vwapTick = token0isWeth ? -vwapTick : vwapTick;
|
||||
emit EthAbundance(currentTick, ethBalance, outstandingSupply, vwapX96, vwapTick);
|
||||
}
|
||||
} else {
|
||||
// No VWAP data available, use current tick
|
||||
vwapTick = currentTick;
|
||||
}
|
||||
|
||||
// Ensure floor doesn't overlap with anchor position
|
||||
int24 anchorSpacing = TICK_SPACING + (34 * int24(params.anchorWidth) * TICK_SPACING / 100);
|
||||
if (token0isWeth) {
|
||||
vwapTick = (vwapTick < currentTick + anchorSpacing) ? currentTick + anchorSpacing : vwapTick;
|
||||
} else {
|
||||
vwapTick = (vwapTick > currentTick - anchorSpacing) ? currentTick - anchorSpacing : vwapTick;
|
||||
}
|
||||
// Floor placement: max of (scarcity, VWAP mirror, clamp) toward KRK-cheap side.
|
||||
// VWAP mirror uses distance from VWAP as floor distance — during selling, price moves
|
||||
// away from VWAP so floor retreats automatically. No sell-pressure detection needed.
|
||||
int24 vwapTick = _computeFloorTick(currentTick, floorEthBalance, outstandingSupply, token0isWeth, params);
|
||||
|
||||
// Normalize and create floor position
|
||||
vwapTick = _clampToTickSpacing(vwapTick, TICK_SPACING);
|
||||
|
|
@ -246,4 +216,54 @@ abstract contract ThreePositionStrategy is UniswapMath, VWAPTracker {
|
|||
|
||||
_mintPosition(Stage.FLOOR, token0isWeth ? vwapTick : floorTick, token0isWeth ? floorTick : vwapTick, liquidity);
|
||||
}
|
||||
|
||||
/// @notice Computes floor tick from three signals: scarcity, VWAP mirror, and anti-overlap clamp.
|
||||
/// @dev Takes the one furthest into KRK-cheap territory (highest tick when token0isWeth, lowest when not).
|
||||
function _computeFloorTick(
|
||||
int24 currentTick,
|
||||
uint256 floorEthBalance,
|
||||
uint256 outstandingSupply,
|
||||
bool token0isWeth,
|
||||
PositionParams memory params
|
||||
)
|
||||
internal
|
||||
view
|
||||
returns (int24 floorTarget)
|
||||
{
|
||||
// 1. Scarcity tick: at what price can our ETH buy back the adjusted supply?
|
||||
uint256 balancedCapital = (7 * outstandingSupply / 10) + (outstandingSupply * params.capitalInefficiency / 10 ** 18);
|
||||
int24 scarcityTick = currentTick;
|
||||
if (outstandingSupply > 0 && floorEthBalance > 0) {
|
||||
scarcityTick = _tickAtPrice(token0isWeth, balancedCapital, floorEthBalance);
|
||||
}
|
||||
|
||||
// 2. Mirror tick: VWAP distance mirrored to KRK-cheap side
|
||||
// Uses adjusted VWAP (CI controls distance → CI is the risk lever).
|
||||
int24 mirrorTick = currentTick;
|
||||
{
|
||||
uint256 vwapX96 = getAdjustedVWAP(params.capitalInefficiency);
|
||||
if (vwapX96 > 0) {
|
||||
int24 rawVwapTick = _tickAtPriceRatio(int128(int256(vwapX96 >> 32)));
|
||||
rawVwapTick = token0isWeth ? -rawVwapTick : rawVwapTick;
|
||||
int24 vwapDistance = currentTick - rawVwapTick;
|
||||
if (vwapDistance < 0) vwapDistance = -vwapDistance;
|
||||
mirrorTick = token0isWeth ? currentTick + vwapDistance : currentTick - vwapDistance;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clamp tick: minimum distance (anti-overlap with anchor)
|
||||
int24 anchorSpacing = TICK_SPACING + (34 * int24(params.anchorWidth) * TICK_SPACING / 100);
|
||||
int24 clampTick = token0isWeth ? currentTick + anchorSpacing : currentTick - anchorSpacing;
|
||||
|
||||
// Take the one furthest into KRK-cheap territory
|
||||
if (token0isWeth) {
|
||||
floorTarget = scarcityTick;
|
||||
if (mirrorTick > floorTarget) floorTarget = mirrorTick;
|
||||
if (clampTick > floorTarget) floorTarget = clampTick;
|
||||
} else {
|
||||
floorTarget = scarcityTick;
|
||||
if (mirrorTick < floorTarget) floorTarget = mirrorTick;
|
||||
if (clampTick < floorTarget) floorTarget = clampTick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,12 +42,11 @@ abstract contract UniswapMath {
|
|||
tick_ = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
|
||||
}
|
||||
|
||||
/// @notice Calculates the price ratio from a given Uniswap V3 tick as KRAIKEN/ETH
|
||||
/// @dev IMPORTANT: Returns price² (squared price) in X96 format, NOT regular price
|
||||
/// This is intentional for capital requirement calculations
|
||||
/// To get regular price: sqrt(priceRatioX96) * 2^48
|
||||
/// @notice Calculates the price ratio from a given Uniswap V3 tick
|
||||
/// @dev Returns price (token1/token0) in Q96 format: price * 2^96
|
||||
/// Computed as sqrtRatioX96² / 2^96 = (sqrt(price) * 2^96)² / 2^96 = price * 2^96
|
||||
/// @param tick The tick for which to calculate the price ratio
|
||||
/// @return priceRatioX96 The price² (squared price) corresponding to the given tick
|
||||
/// @return priceRatioX96 The price ratio in Q96 format
|
||||
function _priceAtTick(int24 tick) internal pure returns (uint256 priceRatioX96) {
|
||||
uint256 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick);
|
||||
priceRatioX96 = sqrtRatioX96.mulDiv(sqrtRatioX96, (1 << 96));
|
||||
|
|
|
|||
480
onchain/test/EthScarcityAbundance.t.sol
Normal file
480
onchain/test/EthScarcityAbundance.t.sol
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { SwapExecutor } from "../analysis/helpers/SwapExecutor.sol";
|
||||
import { Kraiken } from "../src/Kraiken.sol";
|
||||
import { LiquidityManager } from "../src/LiquidityManager.sol";
|
||||
import { ThreePositionStrategy } from "../src/abstracts/ThreePositionStrategy.sol";
|
||||
import { UniswapHelpers } from "../src/helpers/UniswapHelpers.sol";
|
||||
import { IWETH9 } from "../src/interfaces/IWETH9.sol";
|
||||
import { TestEnvironment } from "./helpers/TestBase.sol";
|
||||
import { ConfigurableOptimizer } from "./mocks/ConfigurableOptimizer.sol";
|
||||
import { IUniswapV3Factory } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
||||
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
|
||||
import "forge-std/Test.sol";
|
||||
import "forge-std/console2.sol";
|
||||
|
||||
/// @title EthScarcityAbundance
|
||||
/// @notice Tests investigating when EthScarcity vs EthAbundance fires,
|
||||
/// and the floor ratchet's effect during each condition.
|
||||
contract EthScarcityAbundance is Test {
|
||||
TestEnvironment testEnv;
|
||||
IUniswapV3Factory factory;
|
||||
IUniswapV3Pool pool;
|
||||
IWETH9 weth;
|
||||
Kraiken kraiken;
|
||||
LiquidityManager lm;
|
||||
SwapExecutor swapExecutor;
|
||||
ConfigurableOptimizer optimizer;
|
||||
bool token0isWeth;
|
||||
|
||||
address trader = makeAddr("trader");
|
||||
address fees = makeAddr("fees");
|
||||
|
||||
bytes32 constant SCARCITY_SIG = keccak256("EthScarcity(int24,uint256,uint256,uint256,int24)");
|
||||
bytes32 constant ABUNDANCE_SIG = keccak256("EthAbundance(int24,uint256,uint256,uint256,int24)");
|
||||
|
||||
function setUp() public {
|
||||
testEnv = new TestEnvironment(fees);
|
||||
factory = UniswapHelpers.deployUniswapFactory();
|
||||
|
||||
// Default params: CI=50%, AS=50%, AW=50, DD=50%
|
||||
optimizer = new ConfigurableOptimizer(5e17, 5e17, 50, 5e17);
|
||||
|
||||
(factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(optimizer));
|
||||
|
||||
swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, true);
|
||||
|
||||
// Fund LM generously
|
||||
vm.deal(address(lm), 200 ether);
|
||||
vm.prank(address(lm));
|
||||
weth.deposit{ value: 100 ether }();
|
||||
|
||||
// Initial recenter
|
||||
vm.prank(fees);
|
||||
lm.recenter();
|
||||
|
||||
// Fund trader
|
||||
vm.deal(trader, 300 ether);
|
||||
vm.prank(trader);
|
||||
weth.deposit{ value: 300 ether }();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Q1: WHEN DOES EthScarcity/EthAbundance FIRE?
|
||||
// ================================================================
|
||||
|
||||
/// @notice After a small buy and sell-back, EthAbundance fires on recenter.
|
||||
/// This proves EthScarcity is NOT permanent.
|
||||
function test_floor_placed_after_sellback() public {
|
||||
console2.log("=== Floor placement after sell-back ===");
|
||||
|
||||
// Small buy to create some VWAP history
|
||||
_executeBuy(5 ether);
|
||||
_recenterAndLog("Post-buy");
|
||||
|
||||
// Sell ALL KRK back
|
||||
_executeSell(kraiken.balanceOf(trader));
|
||||
|
||||
// Recenter after sell-back — should not revert
|
||||
_recenterAndLog("Post-sellback");
|
||||
|
||||
// Floor position should exist (check via positions mapping)
|
||||
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
assertTrue(floorLiq > 0, "Floor should be placed after sell-back");
|
||||
}
|
||||
|
||||
/// @notice During sustained buy pressure, floor should be placed progressively
|
||||
function test_floor_during_buy_pressure() public {
|
||||
console2.log("=== Floor during buy pressure ===");
|
||||
|
||||
for (uint256 i = 0; i < 5; i++) {
|
||||
_executeBuy(15 ether);
|
||||
_recenterAndLog("Buy");
|
||||
}
|
||||
|
||||
// Floor should have been placed on each recenter
|
||||
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
assertTrue(floorLiq > 0, "Floor should be placed during buy pressure");
|
||||
}
|
||||
|
||||
/// @notice After heavy buying, floor persists through sells via VWAP mirror.
|
||||
function test_floor_retreats_through_bull_bear_cycle() public {
|
||||
console2.log("=== Floor through bull-bear cycle ===");
|
||||
|
||||
// Bull phase: 5 buys of 15 ETH = 75 ETH total
|
||||
for (uint256 i = 0; i < 5; i++) {
|
||||
_executeBuy(15 ether);
|
||||
}
|
||||
_recenterAndLog("End of bull");
|
||||
|
||||
// Bear phase: sell everything in chunks with recenters
|
||||
uint256 totalKrk = kraiken.balanceOf(trader);
|
||||
uint256 remaining = totalKrk;
|
||||
uint256 attempts;
|
||||
while (remaining > 0 && attempts < 10) {
|
||||
uint256 sellChunk = remaining > totalKrk / 3 ? totalKrk / 3 : remaining;
|
||||
if (sellChunk == 0) break;
|
||||
_executeSell(sellChunk);
|
||||
remaining = kraiken.balanceOf(trader);
|
||||
_recenterAndLog("Sell chunk");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Floor should still exist after selling
|
||||
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
assertTrue(floorLiq > 0, "Floor should persist through bear phase");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Q1 continued: IS THE FLOOR PERMANENTLY STUCK?
|
||||
// ================================================================
|
||||
|
||||
/// @notice With conditional ratchet: floor tracks currentTick during abundance
|
||||
/// but is locked during scarcity. Demonstrates the ratchet is now market-responsive.
|
||||
function test_floor_tracks_price_during_abundance() public {
|
||||
console2.log("=== Floor responds to market during EthAbundance ===");
|
||||
|
||||
// Record initial floor
|
||||
(, int24 floorTickInit,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log("Initial floor tickLower:", floorTickInit);
|
||||
|
||||
// Buy to push price, creating scarcity
|
||||
_executeBuy(10 ether);
|
||||
(bool scarcityA,,) = _recenterAndLog("After buy");
|
||||
(, int24 floorAfterScarcity,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log("Floor after scarcity recenter:", floorAfterScarcity);
|
||||
console2.log("EthScarcity fired:", scarcityA);
|
||||
|
||||
// Sell back to trigger abundance
|
||||
_executeSell(kraiken.balanceOf(trader));
|
||||
(, bool abundanceA,) = _recenterAndLog("After sell-back");
|
||||
|
||||
(, int24 floorAfterAbundance,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log("Floor after abundance recenter:", floorAfterAbundance);
|
||||
console2.log("EthAbundance fired:", abundanceA);
|
||||
|
||||
// During abundance, floor is set by anti-overlap clamp: currentTick + anchorSpacing
|
||||
// This tracks the current price rather than being permanently locked
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
// anchorSpacing for AW=50: 200 + (34 * 50 * 200 / 100) = 3600
|
||||
int24 expectedBoundary = currentTick + 3600;
|
||||
console2.log("Current tick:", currentTick);
|
||||
console2.log("Expected floor boundary:", expectedBoundary);
|
||||
|
||||
// With conditional ratchet: during abundance, the ratchet is off,
|
||||
// so floor can be at anti-overlap boundary (tracks current price)
|
||||
// With permanent ratchet: floor would be max(prevFloor, boundary)
|
||||
|
||||
console2.log("FINDING: Floor responds to market during abundance");
|
||||
console2.log(" Scarcity: floor locked (ratchet on)");
|
||||
console2.log(" Abundance: floor at currentTick + anchorSpacing");
|
||||
}
|
||||
|
||||
/// @notice During EthAbundance, the abundance vwapTick (from VWAP) is typically
|
||||
/// far below the anti-overlap boundary, so the anti-overlap clamp sets the floor
|
||||
/// to currentTick + anchorSpacing. With conditional ratchet, this tracks the
|
||||
/// current price. With permanent ratchet, it would be max(prevFloor, boundary).
|
||||
function test_abundance_floor_mechanism() public {
|
||||
console2.log("=== How floor is set during abundance ===");
|
||||
|
||||
// Buy to create VWAP then sell back for abundance
|
||||
_executeBuy(10 ether);
|
||||
_recenterAndLog("After buy");
|
||||
_executeSell(kraiken.balanceOf(trader));
|
||||
|
||||
// Recenter (expect abundance)
|
||||
vm.warp(block.timestamp + 1 hours);
|
||||
vm.roll(block.number + 1);
|
||||
vm.recordLogs();
|
||||
vm.prank(fees);
|
||||
lm.recenter();
|
||||
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
(, int24 floorTickLower,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
|
||||
// anchorSpacing for AW=50: 200 + (34*50*200/100) = 3600
|
||||
int24 antiOverlapBoundary = currentTick + 3600;
|
||||
|
||||
Vm.Log[] memory logs = vm.getRecordedLogs();
|
||||
int24 abundanceVwapTick;
|
||||
for (uint256 i = 0; i < logs.length; i++) {
|
||||
if (logs[i].topics.length > 0 && logs[i].topics[0] == ABUNDANCE_SIG) {
|
||||
(,,,, int24 vt) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
|
||||
abundanceVwapTick = vt;
|
||||
}
|
||||
}
|
||||
|
||||
console2.log("Current tick:", currentTick);
|
||||
console2.log("Anti-overlap boundary:", antiOverlapBoundary);
|
||||
console2.log("Abundance vwapTick:", abundanceVwapTick);
|
||||
console2.log("Floor tickLower:", floorTickLower);
|
||||
|
||||
// The abundance vwapTick is far below the anti-overlap boundary
|
||||
// because VWAP price converts to a negative tick (token0isWeth sign flip)
|
||||
assertTrue(abundanceVwapTick < antiOverlapBoundary, "VWAP tick below anti-overlap boundary");
|
||||
|
||||
// Floor ends up at anti-overlap boundary (clamped after tick spacing)
|
||||
// With conditional ratchet: this tracks current price
|
||||
// With permanent ratchet: this would be max(prevFloor, boundary)
|
||||
console2.log("Floor set by: anti-overlap clamp (tracks current price)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Q2: BULL/BEAR LIQUIDITY DISTRIBUTION & CONDITIONAL RATCHET
|
||||
// ================================================================
|
||||
|
||||
/// @notice Demonstrates ideal bull vs bear parameter distribution
|
||||
function test_bull_market_params() public {
|
||||
console2.log("=== Bull vs Bear parameter comparison ===");
|
||||
|
||||
// Bull optimizer: high anchorShare, wide anchor, deep discovery
|
||||
ConfigurableOptimizer bullOpt = new ConfigurableOptimizer(3e17, 8e17, 80, 8e17);
|
||||
|
||||
(,,,,, LiquidityManager bullLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bullOpt));
|
||||
|
||||
vm.deal(address(bullLm), 200 ether);
|
||||
vm.prank(address(bullLm));
|
||||
weth.deposit{ value: 100 ether }();
|
||||
vm.prank(fees);
|
||||
bullLm.recenter();
|
||||
|
||||
(uint128 floorLiq,,) = bullLm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
(uint128 anchorLiq, int24 aLow, int24 aHigh) = bullLm.positions(ThreePositionStrategy.Stage.ANCHOR);
|
||||
(uint128 discLiq,,) = bullLm.positions(ThreePositionStrategy.Stage.DISCOVERY);
|
||||
|
||||
console2.log("Bull (AS=80% AW=80 DD=80% CI=30%):");
|
||||
console2.log(" Floor liq:", uint256(floorLiq));
|
||||
console2.log(" Anchor liq:", uint256(anchorLiq));
|
||||
console2.log(" Anchor width:", uint256(int256(aHigh - aLow)));
|
||||
console2.log(" Discovery liq:", uint256(discLiq));
|
||||
|
||||
// Bear optimizer: low anchorShare, moderate anchor, thin discovery
|
||||
ConfigurableOptimizer bearOpt = new ConfigurableOptimizer(8e17, 1e17, 40, 2e17);
|
||||
|
||||
(,,,,, LiquidityManager bearLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bearOpt));
|
||||
|
||||
vm.deal(address(bearLm), 200 ether);
|
||||
vm.prank(address(bearLm));
|
||||
weth.deposit{ value: 100 ether }();
|
||||
vm.prank(fees);
|
||||
bearLm.recenter();
|
||||
|
||||
(uint128 bFloorLiq,,) = bearLm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
(uint128 bAnchorLiq, int24 bALow, int24 bAHigh) = bearLm.positions(ThreePositionStrategy.Stage.ANCHOR);
|
||||
(uint128 bDiscLiq,,) = bearLm.positions(ThreePositionStrategy.Stage.DISCOVERY);
|
||||
|
||||
console2.log("Bear (AS=10% AW=40 DD=20% CI=80%):");
|
||||
console2.log(" Floor liq:", uint256(bFloorLiq));
|
||||
console2.log(" Anchor liq:", uint256(bAnchorLiq));
|
||||
console2.log(" Anchor width:", uint256(int256(bAHigh - bALow)));
|
||||
console2.log(" Discovery liq:", uint256(bDiscLiq));
|
||||
|
||||
assertGt(anchorLiq, bAnchorLiq, "Bull should have more anchor liquidity than bear");
|
||||
}
|
||||
|
||||
/// @notice Tracks floor through scarcity -> abundance -> scarcity cycle.
|
||||
/// Shows what a conditional ratchet WOULD allow.
|
||||
function test_conditional_ratchet_concept() public {
|
||||
console2.log("=== Conditional ratchet concept ===");
|
||||
|
||||
(, int24 floorTickInit,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log("Initial floor:", floorTickInit);
|
||||
|
||||
// Phase 1: Buy pressure -> EthScarcity -> ratchet ACTIVE
|
||||
_executeBuy(30 ether);
|
||||
(bool s1,,) = _recenterAndLog("Buy#1");
|
||||
(, int24 floorAfterBuy1,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log(" Floor after buy1:", floorAfterBuy1);
|
||||
console2.log(" Ratchet held:", floorAfterBuy1 == floorTickInit);
|
||||
|
||||
_executeBuy(20 ether);
|
||||
(bool s2,,) = _recenterAndLog("Buy#2");
|
||||
(, int24 floorAfterBuy2,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log(" Floor after buy2:", floorAfterBuy2);
|
||||
console2.log(" Ratchet held:", floorAfterBuy2 == floorTickInit);
|
||||
|
||||
// Phase 2: Sell back -> EthAbundance -> ratchet would be INACTIVE
|
||||
_executeSell(kraiken.balanceOf(trader));
|
||||
(bool s3, bool a3, int24 vwapTick3) = _recenterAndLog("Sell-back");
|
||||
(, int24 floorAfterSell,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
|
||||
console2.log("Phase 2 (sell-back):");
|
||||
console2.log(" EthAbundance fired:", a3);
|
||||
console2.log(" Floor actual:", floorAfterSell);
|
||||
console2.log(" VwapTick from event:", vwapTick3);
|
||||
if (a3) {
|
||||
console2.log(" PROPOSED: Floor would move to vwapTick during abundance");
|
||||
}
|
||||
|
||||
// Phase 3: Buy again -> EthScarcity -> ratchet re-engages
|
||||
_executeBuy(25 ether);
|
||||
(bool s4,,) = _recenterAndLog("Buy#3");
|
||||
(, int24 floorAfterBuy3,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log(" EthScarcity fired:", s4);
|
||||
console2.log(" Floor after buy3:", floorAfterBuy3);
|
||||
|
||||
console2.log("");
|
||||
console2.log("=== CONDITIONAL RATCHET SUMMARY ===");
|
||||
console2.log("Current: Floor permanently locked at:", floorTickInit);
|
||||
console2.log("Proposed: Scarcity=locked, Abundance=free, re-lock on next scarcity");
|
||||
}
|
||||
|
||||
/// @notice Diagnostic: buy->recenter->sell IL extraction.
|
||||
/// Without a ratchet, trader CAN profit — this test documents the baseline.
|
||||
function test_buyRecenterSell_baseline() public {
|
||||
console2.log("=== Baseline: buy->recenter->sell (no ratchet) ===");
|
||||
|
||||
uint256 initWeth = weth.balanceOf(trader);
|
||||
|
||||
_executeBuy(78 ether);
|
||||
(bool s1,,) = _recenterAndLog("Attack buy1");
|
||||
|
||||
_executeBuy(47 ether);
|
||||
(bool s2,,) = _recenterAndLog("Attack buy2");
|
||||
|
||||
_executeBuy(40 ether);
|
||||
_executeBuy(20 ether);
|
||||
_recenterAndLog("Attack buy3+4");
|
||||
|
||||
_liquidateTrader();
|
||||
|
||||
int256 pnl = int256(weth.balanceOf(trader)) - int256(initWeth);
|
||||
console2.log("Baseline PnL (ETH):", pnl / 1e18);
|
||||
console2.log("Scarcity fired during buys:", s1, s2);
|
||||
// No assertion — this test documents IL extraction without ratchet
|
||||
}
|
||||
|
||||
/// @notice Diagnostic: sell-to-trigger-abundance then re-buy.
|
||||
/// Documents baseline behavior without ratchet.
|
||||
function test_sellToTriggerAbundance_baseline() public {
|
||||
console2.log("=== Baseline: sell-to-trigger-abundance (no ratchet) ===");
|
||||
|
||||
uint256 initWeth = weth.balanceOf(trader);
|
||||
|
||||
// Phase 1: Buy 100 ETH worth
|
||||
_executeBuy(50 ether);
|
||||
_recenterAndLog("Setup buy1");
|
||||
_executeBuy(50 ether);
|
||||
_recenterAndLog("Setup buy2");
|
||||
|
||||
(, int24 floorAfterBuys,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log("Floor after buys:", floorAfterBuys);
|
||||
|
||||
// Phase 2: Sell 90% to try to trigger abundance
|
||||
uint256 krkBal = kraiken.balanceOf(trader);
|
||||
_executeSell(krkBal * 90 / 100);
|
||||
(bool s, bool a, int24 vwapTick) = _recenterAndLog("Post-90pct-sell");
|
||||
|
||||
(, int24 floorAfterSell,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
|
||||
console2.log("After 90% sell:");
|
||||
console2.log(" Scarcity:", s);
|
||||
console2.log(" Abundance:", a);
|
||||
console2.log(" Floor:", floorAfterSell);
|
||||
|
||||
if (a) {
|
||||
console2.log(" Abundance fired - vwapTick:", vwapTick);
|
||||
}
|
||||
|
||||
// Liquidate remaining
|
||||
_liquidateTrader();
|
||||
int256 pnl = int256(weth.balanceOf(trader)) - int256(initWeth);
|
||||
console2.log("Final PnL:", pnl);
|
||||
// No assertion — this test documents baseline without ratchet
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// HELPERS
|
||||
// ================================================================
|
||||
|
||||
function _executeBuy(uint256 amount) internal {
|
||||
if (weth.balanceOf(trader) < amount) return;
|
||||
vm.startPrank(trader);
|
||||
weth.transfer(address(swapExecutor), amount);
|
||||
vm.stopPrank();
|
||||
try swapExecutor.executeBuy(amount, trader) { } catch { }
|
||||
_recoverStuck();
|
||||
}
|
||||
|
||||
function _executeSell(uint256 krkAmount) internal {
|
||||
if (krkAmount == 0) return;
|
||||
uint256 bal = kraiken.balanceOf(trader);
|
||||
if (bal < krkAmount) krkAmount = bal;
|
||||
vm.startPrank(trader);
|
||||
kraiken.transfer(address(swapExecutor), krkAmount);
|
||||
vm.stopPrank();
|
||||
try swapExecutor.executeSell(krkAmount, trader) { } catch { }
|
||||
_recoverStuck();
|
||||
}
|
||||
|
||||
function _recenterAndLog(string memory label) internal returns (bool sawScarcity, bool sawAbundance, int24 eventVwapTick) {
|
||||
vm.warp(block.timestamp + 1 hours);
|
||||
vm.roll(block.number + 1);
|
||||
vm.recordLogs();
|
||||
vm.prank(fees);
|
||||
try lm.recenter() { }
|
||||
catch {
|
||||
console2.log(" recenter FAILED:", label);
|
||||
return (false, false, 0);
|
||||
}
|
||||
|
||||
Vm.Log[] memory logs = vm.getRecordedLogs();
|
||||
for (uint256 i = 0; i < logs.length; i++) {
|
||||
if (logs[i].topics.length == 0) continue;
|
||||
if (logs[i].topics[0] == SCARCITY_SIG) {
|
||||
(int24 tick, uint256 ethBal, uint256 supply,, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
|
||||
sawScarcity = true;
|
||||
eventVwapTick = vwapTick;
|
||||
console2.log(" EthScarcity:", label);
|
||||
console2.log(" tick:", tick);
|
||||
console2.log(" ethBal:", ethBal / 1e18);
|
||||
console2.log(" supply:", supply / 1e18);
|
||||
console2.log(" vwapTick:", vwapTick);
|
||||
} else if (logs[i].topics[0] == ABUNDANCE_SIG) {
|
||||
(int24 tick, uint256 ethBal, uint256 supply,, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
|
||||
sawAbundance = true;
|
||||
eventVwapTick = vwapTick;
|
||||
console2.log(" EthAbundance:", label);
|
||||
console2.log(" tick:", tick);
|
||||
console2.log(" ethBal:", ethBal / 1e18);
|
||||
console2.log(" supply:", supply / 1e18);
|
||||
console2.log(" vwapTick:", vwapTick);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawScarcity && !sawAbundance) {
|
||||
console2.log(" No scarcity/abundance:", label);
|
||||
}
|
||||
}
|
||||
|
||||
function _liquidateTrader() internal {
|
||||
_recenterAndLog("Pre-liquidation");
|
||||
uint256 remaining = kraiken.balanceOf(trader);
|
||||
uint256 attempts;
|
||||
while (remaining > 0 && attempts < 20) {
|
||||
uint256 prev = remaining;
|
||||
_executeSell(remaining);
|
||||
remaining = kraiken.balanceOf(trader);
|
||||
if (remaining >= prev) break;
|
||||
if (attempts % 3 == 2) {
|
||||
_recenterAndLog("Liq recenter");
|
||||
}
|
||||
unchecked {
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _recoverStuck() internal {
|
||||
uint256 sk = kraiken.balanceOf(address(swapExecutor));
|
||||
if (sk > 0) {
|
||||
vm.prank(address(swapExecutor));
|
||||
kraiken.transfer(trader, sk);
|
||||
}
|
||||
uint256 sw = weth.balanceOf(address(swapExecutor));
|
||||
if (sw > 0) {
|
||||
vm.prank(address(swapExecutor));
|
||||
weth.transfer(trader, sw);
|
||||
}
|
||||
}
|
||||
}
|
||||
1317
onchain/test/FuzzingAnalyzerBugs.t.sol
Normal file
1317
onchain/test/FuzzingAnalyzerBugs.t.sol
Normal file
File diff suppressed because it is too large
Load diff
232
onchain/test/OptimizerV3.t.sol
Normal file
232
onchain/test/OptimizerV3.t.sol
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { OptimizerV3 } from "../src/OptimizerV3.sol";
|
||||
import { Stake } from "../src/Stake.sol";
|
||||
import "forge-std/Test.sol";
|
||||
|
||||
contract OptimizerV3Test is Test {
|
||||
OptimizerV3 optimizer;
|
||||
|
||||
// TAX_RATES from Stake.sol
|
||||
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;
|
||||
|
||||
function setUp() public {
|
||||
// Deploy without initialization (we only test pure functions)
|
||||
optimizer = new OptimizerV3();
|
||||
}
|
||||
|
||||
function _normalizedTaxRate(uint256 taxRateIndex) internal view returns (uint256) {
|
||||
return TAX_RATES[taxRateIndex] * 1e18 / MAX_TAX;
|
||||
}
|
||||
|
||||
function _percentageStaked(uint256 pct) internal pure returns (uint256) {
|
||||
return pct * 1e18 / 100;
|
||||
}
|
||||
|
||||
// ==================== Always Bear (staked <= 91%) ====================
|
||||
|
||||
function testAlwaysBearAt0Percent() public view {
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
assertFalse(optimizer.isBullMarket(0, _normalizedTaxRate(taxIdx)), "0% staked should always be bear");
|
||||
}
|
||||
}
|
||||
|
||||
function testAlwaysBearAt50Percent() public view {
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(50), _normalizedTaxRate(taxIdx)), "50% staked should always be bear");
|
||||
}
|
||||
}
|
||||
|
||||
function testAlwaysBearAt91Percent() public view {
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(91), _normalizedTaxRate(taxIdx)), "91% staked should always be bear");
|
||||
}
|
||||
}
|
||||
|
||||
function testAlwaysBearAt39Percent() public view {
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(39), _normalizedTaxRate(0)), "39% staked should be bear");
|
||||
}
|
||||
|
||||
function testAlwaysBearAt80Percent() public view {
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(80), _normalizedTaxRate(0)), "80% staked should be bear even with lowest tax");
|
||||
}
|
||||
|
||||
// ==================== 92% Boundary ====================
|
||||
|
||||
function testBoundary92PercentLowestTax() public view {
|
||||
// deltaS=8, effIdx=0 → penalty = 512*0/20 = 0 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(0)), "92% staked, lowest tax should be bull");
|
||||
}
|
||||
|
||||
function testBoundary92PercentTaxIdx1() public view {
|
||||
// deltaS=8, effIdx=1 → penalty = 512*1/20 = 25 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(1)), "92% staked, taxIdx=1 should be bull");
|
||||
}
|
||||
|
||||
function testBoundary92PercentTaxIdx2() public view {
|
||||
// deltaS=8, effIdx=2 → penalty = 512*2/20 = 51 >= 50 → BEAR
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(2)), "92% staked, taxIdx=2 should be bear");
|
||||
}
|
||||
|
||||
function testBoundary92PercentHighTax() public view {
|
||||
// deltaS=8, effIdx=29 → penalty = 512*29/20 = 742 → BEAR
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(29)), "92% staked, max tax should be bear");
|
||||
}
|
||||
|
||||
// ==================== 95% Staked ====================
|
||||
|
||||
function testAt95PercentLowTax() public view {
|
||||
// deltaS=5, effIdx=0 → penalty = 125*0/20 = 0 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(0)), "95% staked, lowest tax should be bull");
|
||||
}
|
||||
|
||||
function testAt95PercentTaxIdx7() public view {
|
||||
// deltaS=5, effIdx=7 → penalty = 125*7/20 = 43 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(7)), "95% staked, taxIdx=7 should be bull");
|
||||
}
|
||||
|
||||
function testAt95PercentTaxIdx8() public view {
|
||||
// deltaS=5, effIdx=8 → penalty = 125*8/20 = 50, NOT < 50 → BEAR
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(8)), "95% staked, taxIdx=8 should be bear");
|
||||
}
|
||||
|
||||
function testAt95PercentTaxIdx9() public view {
|
||||
// deltaS=5, effIdx=9 → penalty = 125*9/20 = 56 → BEAR
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(9)), "95% staked, taxIdx=9 should be bear");
|
||||
}
|
||||
|
||||
// ==================== 97% Staked ====================
|
||||
|
||||
function testAt97PercentLowTax() public view {
|
||||
// deltaS=3, effIdx=0 → penalty = 27*0/20 = 0 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(97), _normalizedTaxRate(0)), "97% staked, lowest tax should be bull");
|
||||
}
|
||||
|
||||
function testAt97PercentHighTax() public view {
|
||||
// deltaS=3, effIdx=29 → penalty = 27*29/20 = 39 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(97), _normalizedTaxRate(29)), "97% staked, max tax should still be bull");
|
||||
}
|
||||
|
||||
// ==================== 100% Staked ====================
|
||||
|
||||
function testAt100PercentAlwaysBull() public view {
|
||||
// deltaS=0 → penalty = 0 → always BULL
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
assertTrue(optimizer.isBullMarket(1e18, _normalizedTaxRate(taxIdx)), "100% staked should always be bull");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 96% Sweep ====================
|
||||
|
||||
function testAt96PercentSweep() public view {
|
||||
// deltaS=4, cubic=64
|
||||
// penalty = 64 * effIdx / 20
|
||||
// Bull when penalty < 50, i.e., 64 * effIdx / 20 < 50 → effIdx < 15.625
|
||||
// effIdx 0-15: bull (penalty 0..48). effIdx 16+: bear (penalty 51+)
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
bool result = optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(taxIdx));
|
||||
// Compute expected: effIdx from the tax rate
|
||||
uint256 effIdx = taxIdx;
|
||||
if (taxIdx >= 14) {
|
||||
effIdx = taxIdx + 1;
|
||||
if (effIdx > 29) effIdx = 29;
|
||||
}
|
||||
uint256 penalty = 64 * effIdx / 20;
|
||||
bool expectedBull = penalty < 50;
|
||||
assertEq(result, expectedBull, string.concat("96% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 94% Sweep ====================
|
||||
|
||||
function testAt94PercentSweep() public view {
|
||||
// deltaS=6, cubic=216
|
||||
// penalty = 216 * effIdx / 20
|
||||
// Bull when penalty < 50, i.e., 216 * effIdx / 20 < 50 → effIdx < 4.629
|
||||
// effIdx 0-4: bull. effIdx 5+: bear.
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
bool result = optimizer.isBullMarket(_percentageStaked(94), _normalizedTaxRate(taxIdx));
|
||||
uint256 effIdx = taxIdx;
|
||||
if (taxIdx >= 14) {
|
||||
effIdx = taxIdx + 1;
|
||||
if (effIdx > 29) effIdx = 29;
|
||||
}
|
||||
uint256 penalty = 216 * effIdx / 20;
|
||||
bool expectedBull = penalty < 50;
|
||||
assertEq(result, expectedBull, string.concat("94% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Revert on Invalid Input ====================
|
||||
|
||||
function testRevertsAbove100Percent() public {
|
||||
vm.expectRevert("Invalid percentage staked");
|
||||
optimizer.isBullMarket(1e18 + 1, 0);
|
||||
}
|
||||
|
||||
// ==================== 93% Staked ====================
|
||||
|
||||
function testAt93PercentSweep() public view {
|
||||
// deltaS=7, cubic=343
|
||||
// penalty = 343 * effIdx / 20
|
||||
// Bull when penalty < 50, i.e., 343 * effIdx / 20 < 50 → effIdx < 2.915
|
||||
// effIdx 0-2: bull. effIdx 3+: bear.
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
bool result = optimizer.isBullMarket(_percentageStaked(93), _normalizedTaxRate(taxIdx));
|
||||
uint256 effIdx = taxIdx;
|
||||
if (taxIdx >= 14) {
|
||||
effIdx = taxIdx + 1;
|
||||
if (effIdx > 29) effIdx = 29;
|
||||
}
|
||||
uint256 penalty = 343 * effIdx / 20;
|
||||
bool expectedBull = penalty < 50;
|
||||
assertEq(result, expectedBull, string.concat("93% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 99% Staked ====================
|
||||
|
||||
function testAt99PercentAlwaysBull() public view {
|
||||
// deltaS=1, cubic=1 → penalty = effIdx/20, always < 50 for effIdx <= 29
|
||||
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(99), _normalizedTaxRate(taxIdx)), "99% staked should always be bull");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EffIdx Shift at Boundary (taxIdx 13 vs 14) ====================
|
||||
|
||||
function testEffIdxShiftAtBoundary() public view {
|
||||
// At 96% staked, deltaS=4, cubic=64
|
||||
// taxIdx=13: effIdx=13, penalty = 64*13/20 = 41 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(13)), "taxIdx=13 should be bull at 96%");
|
||||
|
||||
// taxIdx=14: effIdx=15 (shift!), penalty = 64*15/20 = 48 < 50 → BULL
|
||||
assertTrue(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(14)), "taxIdx=14 should be bull at 96% (effIdx shift)");
|
||||
|
||||
// taxIdx=15: effIdx=16, penalty = 64*16/20 = 51 >= 50 → BEAR
|
||||
assertFalse(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(15)), "taxIdx=15 should be bear at 96%");
|
||||
}
|
||||
|
||||
// ==================== Fuzz Tests ====================
|
||||
|
||||
function testFuzzBearBelow92(uint256 percentageStaked, uint256 taxIdx) public view {
|
||||
percentageStaked = bound(percentageStaked, 0, 91e18 / 100);
|
||||
taxIdx = bound(taxIdx, 0, 29);
|
||||
assertFalse(optimizer.isBullMarket(percentageStaked, _normalizedTaxRate(taxIdx)), "Should always be bear below 92%");
|
||||
}
|
||||
|
||||
function testFuzz100PercentAlwaysBull(uint256 taxIdx) public view {
|
||||
taxIdx = bound(taxIdx, 0, 29);
|
||||
assertTrue(optimizer.isBullMarket(1e18, _normalizedTaxRate(taxIdx)), "100% staked should always be bull");
|
||||
}
|
||||
|
||||
function testFuzzNeverReverts(uint256 percentageStaked, uint256 averageTaxRate) public view {
|
||||
percentageStaked = bound(percentageStaked, 0, 1e18);
|
||||
averageTaxRate = bound(averageTaxRate, 0, 1e18);
|
||||
// Should not revert
|
||||
optimizer.isBullMarket(percentageStaked, averageTaxRate);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ contract MockUniswapV3Pool {
|
|||
uint160[] public liquidityCumulatives;
|
||||
bool public shouldRevert;
|
||||
|
||||
// Fallback path support: separate tick cumulatives for the 60000s window
|
||||
int56[] public fallbackTickCumulatives;
|
||||
bool public revertOnlyPrimary; // true = revert on 300s, succeed on 60000s
|
||||
|
||||
function setTickCumulatives(int56[] memory _tickCumulatives) external {
|
||||
tickCumulatives = _tickCumulatives;
|
||||
}
|
||||
|
|
@ -28,10 +32,25 @@ contract MockUniswapV3Pool {
|
|||
shouldRevert = _shouldRevert;
|
||||
}
|
||||
|
||||
function observe(uint32[] calldata) external view returns (int56[] memory, uint160[] memory) {
|
||||
function setFallbackTickCumulatives(int56[] memory _fallbackTickCumulatives) external {
|
||||
fallbackTickCumulatives = _fallbackTickCumulatives;
|
||||
}
|
||||
|
||||
function setRevertOnlyPrimary(bool _revertOnlyPrimary) external {
|
||||
revertOnlyPrimary = _revertOnlyPrimary;
|
||||
}
|
||||
|
||||
function observe(uint32[] calldata secondsAgo) external view returns (int56[] memory, uint160[] memory) {
|
||||
if (shouldRevert) {
|
||||
revert("Mock oracle failure");
|
||||
}
|
||||
// If revertOnlyPrimary is set, revert on 300s but succeed on 60000s
|
||||
if (revertOnlyPrimary && secondsAgo[0] == 300) {
|
||||
revert("Old observations not available");
|
||||
}
|
||||
if (revertOnlyPrimary && secondsAgo[0] == 60_000 && fallbackTickCumulatives.length > 0) {
|
||||
return (fallbackTickCumulatives, liquidityCumulatives);
|
||||
}
|
||||
return (tickCumulatives, liquidityCumulatives);
|
||||
}
|
||||
}
|
||||
|
|
@ -129,23 +148,53 @@ contract PriceOracleTest is Test {
|
|||
assertFalse(isStable, "Price should be unstable when outside deviation threshold");
|
||||
}
|
||||
|
||||
function testPriceStabilityOracleFailureFallback() public {
|
||||
// Test fallback behavior when oracle fails
|
||||
mockPool.setShouldRevert(true);
|
||||
function testFallbackPathUsesCorrectDivisor() public {
|
||||
// Primary observe (300s) reverts, fallback (60000s) succeeds
|
||||
// The fallback window is 60000 seconds, so tickCumulativeDiff / 60000 = averageTick
|
||||
int24 averageTick = 1000;
|
||||
uint32 fallbackInterval = 60_000;
|
||||
|
||||
// Should not revert but should still return a boolean
|
||||
// The actual implementation tries a longer timeframe on failure
|
||||
int24 currentTick = 1000;
|
||||
int56[] memory fallbackCumulatives = new int56[](2);
|
||||
fallbackCumulatives[0] = 0;
|
||||
fallbackCumulatives[1] = int56(averageTick) * int56(int32(fallbackInterval));
|
||||
|
||||
// This might fail or succeed depending on implementation details
|
||||
// The key is that it doesn't cause the entire transaction to revert
|
||||
try priceOracle.isPriceStable(currentTick) returns (bool result) {
|
||||
// If it succeeds, that's fine
|
||||
console.log("Oracle fallback succeeded, result:", result);
|
||||
} catch {
|
||||
// If it fails, that's also expected behavior for this test
|
||||
console.log("Oracle fallback failed as expected");
|
||||
}
|
||||
uint160[] memory liquidityCumulatives = new uint160[](2);
|
||||
liquidityCumulatives[0] = 1000;
|
||||
liquidityCumulatives[1] = 1000;
|
||||
|
||||
mockPool.setRevertOnlyPrimary(true);
|
||||
mockPool.setFallbackTickCumulatives(fallbackCumulatives);
|
||||
mockPool.setLiquidityCumulatives(liquidityCumulatives);
|
||||
|
||||
// currentTick = 1020, averageTick = 1000 → within 50-tick deviation → stable
|
||||
bool isStable = priceOracle.isPriceStable(1020);
|
||||
assertTrue(isStable, "Fallback: price within deviation should be stable");
|
||||
|
||||
// currentTick = 1100, averageTick = 1000 → 100 ticks away → unstable
|
||||
isStable = priceOracle.isPriceStable(1100);
|
||||
assertFalse(isStable, "Fallback: price outside deviation should be unstable");
|
||||
}
|
||||
|
||||
function testFallbackPathWithNegativeTick() public {
|
||||
// Verify fallback works correctly with negative ticks
|
||||
int24 averageTick = -500;
|
||||
uint32 fallbackInterval = 60_000;
|
||||
|
||||
int56[] memory fallbackCumulatives = new int56[](2);
|
||||
fallbackCumulatives[0] = 0;
|
||||
fallbackCumulatives[1] = int56(averageTick) * int56(int32(fallbackInterval));
|
||||
|
||||
uint160[] memory liquidityCumulatives = new uint160[](2);
|
||||
liquidityCumulatives[0] = 1000;
|
||||
liquidityCumulatives[1] = 1000;
|
||||
|
||||
mockPool.setRevertOnlyPrimary(true);
|
||||
mockPool.setFallbackTickCumulatives(fallbackCumulatives);
|
||||
mockPool.setLiquidityCumulatives(liquidityCumulatives);
|
||||
|
||||
// currentTick = -480, averageTick = -500 → diff = 20 → stable
|
||||
bool isStable = priceOracle.isPriceStable(-480);
|
||||
assertTrue(isStable, "Fallback with negative tick: within deviation should be stable");
|
||||
}
|
||||
|
||||
function testPriceStabilityExactBoundary() public {
|
||||
|
|
|
|||
|
|
@ -264,42 +264,47 @@ contract ThreePositionStrategyTest is TestConstants {
|
|||
assertNotEq(centerTick, CURRENT_TICK, "Floor should not be positioned at current tick when VWAP available");
|
||||
}
|
||||
|
||||
function testFloorPositionEthScarcity() public {
|
||||
function testFloorPositionScarcityDominates() public {
|
||||
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
|
||||
|
||||
// Set up scenario where ETH is insufficient for VWAP price
|
||||
// Set up scenario where ETH is insufficient → scarcity tick dominates
|
||||
uint256 vwapX96 = 79_228_162_514_264_337_593_543_950_336 * 10; // High VWAP price
|
||||
strategy.setVWAP(vwapX96, 1000 ether);
|
||||
|
||||
uint256 smallEthBalance = 1 ether; // Insufficient ETH
|
||||
uint256 smallEthBalance = 1 ether; // Very low ETH → scarcity tick far away
|
||||
uint256 pulledHarb = 1000 ether;
|
||||
uint256 discoveryAmount = 500 ether;
|
||||
|
||||
// Should emit EthScarcity event (check event type, not exact values)
|
||||
vm.expectEmit(true, false, false, false);
|
||||
emit ThreePositionStrategy.EthScarcity(CURRENT_TICK, 0, 0, 0, 0);
|
||||
|
||||
// Should not revert — floor placed using max(scarcity, mirror, clamp)
|
||||
strategy.setFloorPosition(CURRENT_TICK, smallEthBalance, pulledHarb, discoveryAmount, params);
|
||||
|
||||
// Floor should be minted (position exists)
|
||||
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
|
||||
assertTrue(pos.liquidity > 0, "Floor should have liquidity");
|
||||
}
|
||||
|
||||
function testFloorPositionEthAbundance() public {
|
||||
function testFloorPositionMirrorDominates() public {
|
||||
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
|
||||
|
||||
// Set up scenario where ETH is sufficient for VWAP price
|
||||
// Set up scenario where VWAP is far from current → mirror tick dominates
|
||||
uint256 baseVwap = 79_228_162_514_264_337_593_543_950_336; // 1.0 in X96 format
|
||||
uint256 vwapX96 = baseVwap / 100_000; // Very low VWAP price to ensure abundance
|
||||
uint256 vwapX96 = baseVwap / 100_000; // Very low VWAP price → far from current
|
||||
strategy.setVWAP(vwapX96, 1000 ether);
|
||||
|
||||
uint256 largeEthBalance = 100_000 ether; // Very large ETH balance
|
||||
uint256 largeEthBalance = 100_000 ether; // Lots of ETH
|
||||
uint256 pulledHarb = 1000 ether;
|
||||
uint256 discoveryAmount = 500 ether;
|
||||
|
||||
// Should emit EthAbundance event (check event type, not exact values)
|
||||
// The exact VWAP and vwapTick values are calculated, so we just check the event type
|
||||
vm.expectEmit(true, false, false, false);
|
||||
emit ThreePositionStrategy.EthAbundance(CURRENT_TICK, 0, 0, 0, 0);
|
||||
|
||||
strategy.setFloorPosition(CURRENT_TICK, largeEthBalance, pulledHarb, discoveryAmount, params);
|
||||
|
||||
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
|
||||
assertTrue(pos.liquidity > 0, "Floor should have liquidity");
|
||||
|
||||
// Floor should be further than just anchorSpacing (mirror should push it)
|
||||
int24 anchorSpacing = 200 + (34 * 50 * 200 / 100); // 3600
|
||||
int24 floorCenter = (pos.tickLower + pos.tickUpper) / 2;
|
||||
// Mirror should push floor significantly beyond clamp minimum
|
||||
assertTrue(floorCenter > CURRENT_TICK + anchorSpacing + 200, "Mirror should push floor beyond clamp minimum");
|
||||
}
|
||||
|
||||
function testFloorPositionNoVWAP() public {
|
||||
|
|
@ -316,16 +321,34 @@ contract ThreePositionStrategyTest is TestConstants {
|
|||
|
||||
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
|
||||
|
||||
// Without VWAP, should default to current tick but adjusted for anchor spacing
|
||||
// Without VWAP, mirror = current tick, so floor uses max(scarcity, clamp)
|
||||
// With these balances, scarcity tick should dominate (low ETH relative to supply)
|
||||
int24 centerTick = (pos.tickLower + pos.tickUpper) / 2;
|
||||
// Expected spacing: TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100) = 200 + (34 * 50 * 200 / 100) = 3600
|
||||
int24 expectedSpacing = 200 + (34 * 50 * 200 / 100);
|
||||
assertApproxEqAbs(
|
||||
uint256(int256(centerTick)),
|
||||
uint256(int256(CURRENT_TICK + expectedSpacing)),
|
||||
200,
|
||||
"Floor should be positioned away from current tick to avoid anchor overlap"
|
||||
);
|
||||
// Floor should be above current tick (on KRK-cheap side)
|
||||
assertTrue(centerTick > CURRENT_TICK, "Floor should be on KRK-cheap side of current tick");
|
||||
assertTrue(pos.liquidity > 0, "Floor should have liquidity");
|
||||
}
|
||||
|
||||
function testFloorPositionNoVWAPClampOrScarcity() public {
|
||||
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
|
||||
|
||||
// No VWAP data, large ETH balance, small supply
|
||||
strategy.setVWAP(0, 0);
|
||||
|
||||
uint256 floorEthBalance = 100_000 ether; // Very large
|
||||
uint256 pulledHarb = 100 ether; // Small supply
|
||||
uint256 discoveryAmount = 50 ether;
|
||||
|
||||
strategy.setFloorPosition(CURRENT_TICK, floorEthBalance, pulledHarb, discoveryAmount, params);
|
||||
|
||||
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
|
||||
|
||||
// With no VWAP: mirror = current. Floor uses max(scarcity, clamp).
|
||||
// The scarcity formula with small supply and large ETH may still push floor
|
||||
// significantly beyond the clamp minimum. Just verify floor is on correct side.
|
||||
int24 centerTick = (pos.tickLower + pos.tickUpper) / 2;
|
||||
int24 minSpacing = 200 + (34 * 50 * 200 / 100); // 3600
|
||||
assertTrue(centerTick >= CURRENT_TICK + minSpacing, "Floor should be positioned away from current tick to avoid anchor overlap");
|
||||
}
|
||||
|
||||
function testFloorPositionOutstandingSupplyCalculation() public {
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ contract TestEnvironment is TestConstants {
|
|||
bool setupComplete = false;
|
||||
uint256 retryCount = 0;
|
||||
|
||||
while (!setupComplete && retryCount < 5) {
|
||||
while (!setupComplete && retryCount < 20) {
|
||||
// Clean slate if retrying
|
||||
if (retryCount > 0) {
|
||||
// Deploy a dummy contract to shift addresses
|
||||
|
|
@ -195,7 +195,6 @@ contract TestEnvironment is TestConstants {
|
|||
*/
|
||||
function _configurePermissions() internal {
|
||||
harberg.setStakingPool(address(stake));
|
||||
vm.prank(feeDestination);
|
||||
harberg.setLiquidityManager(address(lm));
|
||||
vm.deal(address(lm), INITIAL_LM_ETH_BALANCE);
|
||||
}
|
||||
|
|
@ -313,5 +312,4 @@ contract TestEnvironment is TestConstants {
|
|||
|
||||
return (factory, pool, weth, harberg, stake, lm, optimizer, token0isWeth);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue