harb/onchain/src/LiquidityManager.sol
openhands d3ff2cd0bf fix: Bear defaults duplicated across Optimizer and LiquidityManager (#646)
Extract bear-mode default values (0, 3e17, 100, 3e17) into file-level
constants in IOptimizer.sol so both Optimizer._bearDefaults() and
LiquidityManager.recenter()'s catch block reference a single source of
truth instead of independent hardcoded literals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:22:43 +00:00

357 lines
18 KiB
Solidity

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { Kraiken } from "./Kraiken.sol";
import { IOptimizer, BEAR_CAPITAL_INEFFICIENCY, BEAR_ANCHOR_SHARE, BEAR_ANCHOR_WIDTH, BEAR_DISCOVERY_DEPTH } from "./IOptimizer.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
* @notice Manages liquidity provisioning on Uniswap V3 using the three-position anti-arbitrage strategy
* @dev Inherits from modular contracts for better separation of concerns and testability
*
* Key features:
* - Three-position anti-arbitrage strategy (ANCHOR, DISCOVERY, FLOOR)
* - Dynamic parameter adjustment via Optimizer contract
* - Asymmetric slippage profile prevents profitable arbitrage
* - Exclusive minting rights for KRAIKEN token
*
* Price Validation:
* - 5-minute TWAP with 50-tick tolerance
* - 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);
/// @notice Upper bound (inclusive) for scale-1 optimizer parameters: capitalInefficiency,
/// anchorShare, and discoveryDepth. Values above this ceiling are silently clamped.
uint256 internal constant MAX_PARAM_SCALE = 10 ** 18;
/// @notice Immutable contract references
address private immutable factory;
IWETH9 private immutable weth;
Kraiken private immutable kraiken;
IOptimizer private immutable optimizer;
IUniswapV3Pool private immutable pool;
bool private immutable token0isWeth;
PoolKey private poolKey;
/// @notice Access control and fee management
address private immutable deployer;
address public feeDestination;
bool public feeDestinationLocked;
/// @notice Last recenter tick — used to determine net trade direction between recenters
int24 public lastRecenterTick;
/// @notice Last recenter timestamp — rate limits recenters.
uint256 public lastRecenterTime;
/// @notice Minimum seconds between recenters
uint256 internal constant MIN_RECENTER_INTERVAL = 60;
/// @notice Target observation cardinality requested from the pool during construction
uint16 internal constant ORACLE_CARDINALITY = 100;
/// @notice Emitted on each successful recenter for monitoring and indexing
event Recentered(int24 indexed currentTick, bool indexed isUp);
/// @notice Emitted whenever feeDestination is updated
event FeeDestinationSet(address indexed newDest);
/// @notice Emitted when the fee destination lock is permanently engaged
event FeeDestinationLocked(address indexed dest);
/// @notice Custom errors
error ZeroAddressInSetter();
/// @notice Constructor initializes all contract references and pool configuration
/// @param _factory The address of the Uniswap V3 factory
/// @param _WETH9 The address of the WETH contract
/// @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);
pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
kraiken = Kraiken(_kraiken);
token0isWeth = _WETH9 < _kraiken;
optimizer = IOptimizer(_optimizer);
// Increase observation cardinality so pool.observe() has sufficient history
// for _isPriceStable() TWAP checks.
pool.increaseObservationCardinalityNext(ORACLE_CARDINALITY);
}
/// @notice Callback function for Uniswap V3 mint operations
/// @param amount0Owed Amount of token0 owed for the liquidity provision
/// @param amount1Owed Amount of token1 owed for the liquidity provision
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external {
CallbackValidation.verifyCallback(factory, poolKey);
// Handle KRAIKEN minting - use existing balance first, then mint only the difference
uint256 kraikenPulled = token0isWeth ? amount1Owed : amount0Owed;
uint256 kraikenBalance = kraiken.balanceOf(address(this));
if (kraikenBalance < kraikenPulled) {
kraiken.mint(kraikenPulled - kraikenBalance);
}
// Handle WETH conversion
uint256 ethOwed = token0isWeth ? amount0Owed : amount1Owed;
if (weth.balanceOf(address(this)) < ethOwed) {
weth.deposit{ value: address(this).balance }();
}
// Transfer tokens to pool
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 (deployer only).
/// @dev Trapdoor lock: EOA addresses can be set repeatedly (enables staged deployment/migration)
/// until the lock is triggered. The lock fires in two ways:
///
/// 1. Direct assignment: setting feeDestination to a contract address (code.length > 0)
/// immediately and permanently locks further changes. This is the expected production
/// path — set an EOA during development, upgrade to a treasury contract when ready.
///
/// 2. Defensive CREATE2 guard: if the current feeDestination was an EOA when set but has
/// since acquired bytecode (e.g. via CREATE2 deployment), the next call to this
/// function permanently sets feeDestinationLocked = true and returns WITHOUT reverting.
/// Not reverting is intentional: only a successful transaction commits the lock to
/// storage, so the lock survives a subsequent SELFDESTRUCT that would otherwise clear
/// the bytecode evidence and re-open the assignment window.
///
/// Remaining limitation: an atomic CREATE2+SELFDESTRUCT within a single transaction
/// (still possible post-EIP-6780 for contracts that deploy and destroy in one call)
/// cannot be detected here because the bytecode is already absent when this function
/// executes. No on-chain guard can close that sub-transaction window.
/// @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();
// Defensive CREATE2 guard: if the current destination has acquired bytecode since being
// set as an EOA, lock permanently and return WITHOUT reverting so the write is committed
// to storage. A subsequent SELFDESTRUCT clears the bytecode but cannot undo this write.
if (!feeDestinationLocked && feeDestination != address(0) && feeDestination.code.length > 0) {
feeDestinationLocked = true;
emit FeeDestinationLocked(feeDestination);
return;
}
require(!feeDestinationLocked, "fee destination locked");
feeDestination = feeDestination_;
emit FeeDestinationSet(feeDestination_);
if (feeDestination_.code.length > 0) {
feeDestinationLocked = true;
emit FeeDestinationLocked(feeDestination_);
}
}
/// @notice Adjusts liquidity positions in response to price movements.
/// Callable by anyone. Always enforces cooldown and TWAP price stability.
/// This function either completes a full recenter (removing all positions,
/// recording VWAP where applicable, and redeploying liquidity) or reverts —
/// it never returns silently without acting.
///
/// @dev Revert conditions (no silent false return for failure):
/// - "recenter cooldown" — MIN_RECENTER_INTERVAL has not elapsed since last recenter
/// - "price deviated from oracle" — price is outside TWAP bounds (manipulation guard)
/// - "amplitude not reached." — anchor position exists but price has not moved far enough
/// from the anchor centre to warrant repositioning
///
/// @return isUp True if the KRK price in ETH rose since the last recenter
/// (buy event / net ETH inflow), regardless of token0/token1 ordering.
/// False if the KRK price fell, or if no anchor position existed prior
/// to this recenter (bootstrap case, no directional reference point).
/// Both values indicate a successful, fully-executed recenter.
function recenter() external returns (bool isUp) {
(, int24 currentTick,,,,,) = pool.slot0();
// Always enforce cooldown and TWAP price stability — no bypass path
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;
if (positions[Stage.ANCHOR].liquidity > 0) {
int24 anchorTickLower = positions[Stage.ANCHOR].tickLower;
int24 anchorTickUpper = positions[Stage.ANCHOR].tickUpper;
int24 centerTick = anchorTickLower + (anchorTickUpper - anchorTickLower) / 2;
bool isEnough;
(isUp, isEnough) = _validatePriceMovement(currentTick, centerTick, TICK_SPACING, token0isWeth);
require(isEnough, "amplitude not reached.");
}
// Remove all existing positions and collect fees
// Pass tick direction to determine if VWAP should record.
// CRITICAL: record VWAP only when price FALLS (sells / ETH outflow), never when it rises.
// If we recorded during buy events, an adversary could run N buy-recenter cycles to push
// VWAP upward toward the inflated price. When VWAP ≈ current tick, mirrorTick ≈ currentTick,
// so the floor is placed near the inflated price — crystallising IL when the adversary sells.
// Freezing VWAP during buy-only cycles keeps the floor anchored to the historical baseline.
bool shouldRecordVWAP;
if (cumulativeVolume == 0) {
// No VWAP data yet — always bootstrap to prevent vwapX96=0 fallback
shouldRecordVWAP = true;
} else {
// token0isWeth: tick UP = price down in KRK terms = sells = ETH outflow
// !token0isWeth: tick DOWN = price down in KRK terms = sells = ETH outflow
// Only record when price falls — VWAP stays anchored to historical levels during buy attacks.
shouldRecordVWAP = token0isWeth ? (currentTick > lastRecenterTick) : (currentTick < lastRecenterTick);
}
lastRecenterTick = currentTick;
_scrapePositions(shouldRecordVWAP, currentTick);
// Update total supply tracking if price moved up
if (isUp) {
kraiken.setPreviousTotalSupply(kraiken.totalSupply());
}
// Get optimizer parameters and set new positions
try optimizer.getLiquidityParams() returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) {
// Clamp parameters to valid ranges
PositionParams memory params = PositionParams({
capitalInefficiency: (capitalInefficiency > MAX_PARAM_SCALE) ? MAX_PARAM_SCALE : capitalInefficiency,
anchorShare: (anchorShare > MAX_PARAM_SCALE) ? MAX_PARAM_SCALE : anchorShare,
anchorWidth: (anchorWidth > MAX_ANCHOR_WIDTH) ? MAX_ANCHOR_WIDTH : anchorWidth,
discoveryDepth: (discoveryDepth > MAX_PARAM_SCALE) ? MAX_PARAM_SCALE : discoveryDepth
});
_setPositions(currentTick, params);
} catch {
// Fallback to safe bear-mode defaults if optimizer fails
PositionParams memory defaultParams = PositionParams({
capitalInefficiency: BEAR_CAPITAL_INEFFICIENCY,
anchorShare: BEAR_ANCHOR_SHARE,
anchorWidth: BEAR_ANCHOR_WIDTH,
discoveryDepth: BEAR_DISCOVERY_DEPTH
});
_setPositions(currentTick, defaultParams);
}
emit Recentered(currentTick, isUp);
}
/// @notice Removes all positions and collects fees
/// @param recordVWAP Whether to record VWAP (only when net ETH outflow / price fell since last recenter, or at bootstrap)
/// @param currentTick The current pool tick at time of recenter, used as the VWAP price sample
function _scrapePositions(bool recordVWAP, int24 currentTick) internal {
uint256 fee0 = 0;
uint256 fee1 = 0;
// Price at current tick: volume-weighted, sampled once per recenter.
// token0isWeth: tick represents KRK/ETH — negate for price in ETH per KRK terms.
uint256 currentPrice = _priceAtTick(token0isWeth ? -1 * currentTick : currentTick);
for (uint256 i = uint256(Stage.FLOOR); i <= uint256(Stage.DISCOVERY); i++) {
TokenPosition storage position = positions[Stage(i)];
if (position.liquidity > 0) {
// Burn liquidity and collect tokens + fees
(uint256 amount0, uint256 amount1) = pool.burn(position.tickLower, position.tickUpper, position.liquidity);
(uint256 collected0, uint256 collected1) =
pool.collect(address(this), position.tickLower, position.tickUpper, type(uint128).max, type(uint128).max);
// Calculate fees
fee0 += collected0 - amount0;
fee1 += collected1 - amount1;
}
}
// Transfer fees and record volume for VWAP
// VWAP is recorded only on sell events (price fell) or at bootstrap — see recenter().
// Skip transfer when feeDestination is self — fees accrue as deployable liquidity.
if (feeDestination != address(this)) {
if (fee0 > 0) {
if (token0isWeth) {
IERC20(address(weth)).safeTransfer(feeDestination, fee0);
} else {
IERC20(address(kraiken)).safeTransfer(feeDestination, fee0);
}
}
if (fee1 > 0) {
if (token0isWeth) {
IERC20(address(kraiken)).safeTransfer(feeDestination, fee1);
} else {
IERC20(address(weth)).safeTransfer(feeDestination, fee1);
}
}
}
// Always record VWAP regardless of fee destination
if (recordVWAP) {
uint256 ethFee = token0isWeth ? fee0 : fee1;
if (ethFee > 0) _recordVolumeAndPrice(currentPrice, ethFee);
}
}
/// @notice Allow contract to receive ETH
receive() external payable { }
// ========================================
// ABSTRACT FUNCTION IMPLEMENTATIONS
// ========================================
/// @notice Implementation of abstract function from PriceOracle
function _getPool() internal view override returns (IUniswapV3Pool) {
return pool;
}
/// @notice Implementation of abstract function from ThreePositionStrategy
function _getKraikenToken() internal view override returns (address) {
return address(kraiken);
}
/// @notice Implementation of abstract function from ThreePositionStrategy
function _getWethToken() internal view override returns (address) {
return address(weth);
}
/// @notice Implementation of abstract function from ThreePositionStrategy
function _isToken0Weth() internal view override returns (bool) {
return token0isWeth;
}
/// @notice Implementation of abstract function from ThreePositionStrategy
function _mintPosition(Stage stage, int24 tickLower, int24 tickUpper, uint128 liquidity) internal override {
pool.mint(address(this), tickLower, tickUpper, liquidity, abi.encode(poolKey));
positions[stage] = TokenPosition({ liquidity: liquidity, tickLower: tickLower, tickUpper: tickUpper });
}
/// @notice Implementation of abstract function from ThreePositionStrategy
function _getEthBalance() internal view override returns (uint256) {
return address(this).balance + weth.balanceOf(address(this));
}
/// @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) {
uint256 supply = kraiken.outstandingSupply();
// Skip subtraction when feeDestination is self: outstandingSupply() already
// excludes kraiken.balanceOf(address(this)), so subtracting again would double-count.
if (feeDestination != address(0) && feeDestination != address(this)) {
supply -= kraiken.balanceOf(feeDestination);
}
(, address stakingPoolAddr) = kraiken.peripheryContracts();
// Guard against double-subtraction: if stakingPoolAddr == feeDestination,
// that balance was already deducted above.
if (stakingPoolAddr != address(0) && stakingPoolAddr != feeDestination) {
supply -= kraiken.balanceOf(stakingPoolAddr);
}
return supply;
}
}