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:
openhands 2026-02-13 18:21:18 +00:00
parent 21857ae8ca
commit 85350caf52
38 changed files with 3793 additions and 205 deletions

View file

@ -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_;

View file

@ -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
View 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
View 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 stakingconfig 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
}
}
}

View file

@ -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%

View file

@ -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);

View file

@ -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;
}
}
}

View file

@ -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));

View 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);
}
}
}

File diff suppressed because it is too large Load diff

View 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);
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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);
}
}