harb/onchain/src/LiquidityManager.sol

310 lines
14 KiB
Solidity
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-or-later
2024-03-28 19:55:01 +01:00
pragma solidity ^0.8.19;
import { Kraiken } from "./Kraiken.sol";
import { Optimizer } from "./Optimizer.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";
2024-03-28 21:50:22 +01:00
/**
* @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
*
2025-08-18 00:16:09 +02:00
* 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
*
2025-08-18 00:16:09 +02:00
* Price Validation:
* - 5-minute TWAP with 50-tick tolerance
* - Prevents oracle manipulation attacks
2024-07-13 18:33:47 +02:00
*/
contract LiquidityManager is ThreePositionStrategy, PriceOracle {
using SafeERC20 for IERC20;
2025-08-18 00:16:09 +02:00
/// @notice Uniswap V3 fee tier (1%) - 10,000 basis points
2024-07-09 18:00:39 +02:00
uint24 internal constant FEE = uint24(10_000);
2024-07-16 19:47:39 +02:00
/// @notice Immutable contract references
2024-07-13 14:56:13 +02:00
address private immutable factory;
IWETH9 private immutable weth;
Kraiken private immutable kraiken;
Optimizer private immutable optimizer;
2024-07-13 14:56:13 +02:00
IUniswapV3Pool private immutable pool;
bool private immutable token0isWeth;
2024-07-09 18:00:39 +02:00
PoolKey private poolKey;
/// @notice Access control and fee management
address private immutable deployer;
address public recenterAccess;
2024-07-09 18:00:39 +02:00
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
2024-07-13 14:56:13 +02:00
error ZeroAddressInSetter();
error AddressAlreadySet();
/// @notice Access control modifier
2024-07-16 19:47:39 +02:00
modifier onlyFeeDestination() {
require(msg.sender == address(feeDestination), "only callable by feeDestination");
_;
}
/// @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 = Optimizer(_optimizer);
}
/// @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);
fix(onchain): resolve KRK token supply corruption during recenter (#98) PROBLEM: Recenter operations were burning ~137,866 KRK tokens instead of minting them, causing severe deflation when inflation should occur. This was due to the liquidity manager burning ALL collected tokens from old positions and then minting tokens for new positions separately, causing asymmetric supply adjustments to the staking pool. ROOT CAUSE: During recenter(): 1. _scrapePositions() collected tokens from old positions and immediately burned them ALL (+ proportional staking pool adjustment) 2. _setPositions() minted tokens for new positions (+ proportional staking pool adjustment) 3. The burn and mint operations used DIFFERENT totalSupply values in their proportion calculations, causing imbalanced adjustments 4. When old positions had more tokens than new positions needed, the net result was deflation WHY THIS HAPPENED: When KRK price increases (users buying), the same liquidity depth requires fewer KRK tokens. The old code would: - Burn 120k KRK from old positions (+ 30k from staking pool) - Mint 10k KRK for new positions (+ 2.5k to staking pool) - Net: -137.5k KRK total supply (WRONG!) FIX: 1. Modified uniswapV3MintCallback() to use existing KRK balance first before minting new tokens 2. Removed burn() from _scrapePositions() - keep collected tokens 3. Removed burn() from end of recenter() - don't burn "excess" 4. Tokens held by LiquidityManager are already excluded from outstandingSupply(), so they don't affect staking calculations RESULT: Now during recenter, only the NET difference is minted or used: - Collect old positions into LiquidityManager balance - Use that balance for new positions - Only mint additional tokens if more are needed - Keep any unused balance for future recenters - No more asymmetric burn/mint causing supply corruption VERIFICATION: - All 107 existing tests pass - Added 2 new regression tests in test/SupplyCorruption.t.sol - testRecenterDoesNotCorruptSupply: verifies single recenter preserves supply - testMultipleRecentersPreserveSupply: verifies no accumulation over time Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:20:57 +00:00
// Handle KRAIKEN minting - use existing balance first, then mint only the difference
uint256 kraikenPulled = token0isWeth ? amount1Owed : amount0Owed;
fix(onchain): resolve KRK token supply corruption during recenter (#98) PROBLEM: Recenter operations were burning ~137,866 KRK tokens instead of minting them, causing severe deflation when inflation should occur. This was due to the liquidity manager burning ALL collected tokens from old positions and then minting tokens for new positions separately, causing asymmetric supply adjustments to the staking pool. ROOT CAUSE: During recenter(): 1. _scrapePositions() collected tokens from old positions and immediately burned them ALL (+ proportional staking pool adjustment) 2. _setPositions() minted tokens for new positions (+ proportional staking pool adjustment) 3. The burn and mint operations used DIFFERENT totalSupply values in their proportion calculations, causing imbalanced adjustments 4. When old positions had more tokens than new positions needed, the net result was deflation WHY THIS HAPPENED: When KRK price increases (users buying), the same liquidity depth requires fewer KRK tokens. The old code would: - Burn 120k KRK from old positions (+ 30k from staking pool) - Mint 10k KRK for new positions (+ 2.5k to staking pool) - Net: -137.5k KRK total supply (WRONG!) FIX: 1. Modified uniswapV3MintCallback() to use existing KRK balance first before minting new tokens 2. Removed burn() from _scrapePositions() - keep collected tokens 3. Removed burn() from end of recenter() - don't burn "excess" 4. Tokens held by LiquidityManager are already excluded from outstandingSupply(), so they don't affect staking calculations RESULT: Now during recenter, only the NET difference is minted or used: - Collect old positions into LiquidityManager balance - Use that balance for new positions - Only mint additional tokens if more are needed - Keep any unused balance for future recenters - No more asymmetric burn/mint causing supply corruption VERIFICATION: - All 107 existing tests pass - Added 2 new regression tests in test/SupplyCorruption.t.sol - testRecenterDoesNotCorruptSupply: verifies single recenter preserves supply - testMultipleRecentersPreserveSupply: verifies no accumulation over time Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:20:57 +00:00
uint256 kraikenBalance = kraiken.balanceOf(address(this));
if (kraikenBalance < kraikenPulled) {
kraiken.mint(kraikenPulled - kraikenBalance);
}
// Handle WETH conversion
2024-04-28 07:00:53 +02:00
uint256 ethOwed = token0isWeth ? amount0Owed : amount1Owed;
if (weth.balanceOf(address(this)) < ethOwed) {
weth.deposit{ value: address(this).balance }();
2024-04-28 07:00:53 +02:00
}
// 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 (can only be called once, deployer only)
/// @param feeDestination_ The address that will receive trading fees
2024-06-09 16:06:41 +02:00
function setFeeDestination(address feeDestination_) external {
require(msg.sender == deployer, "only deployer");
2024-07-13 14:56:13 +02:00
if (address(0) == feeDestination_) revert ZeroAddressInSetter();
if (feeDestination != address(0)) revert AddressAlreadySet();
2024-06-09 16:06:41 +02:00
feeDestination = feeDestination_;
}
/// @notice Sets recenter access for testing/emergency purposes
/// @param addr Address to grant recenter access
function setRecenterAccess(address addr) external onlyFeeDestination {
recenterAccess = addr;
}
/// @notice Revokes recenter access
function revokeRecenterAccess() external onlyFeeDestination {
recenterAccess = address(0);
}
/// @notice Adjusts liquidity positions in response to price movements
/// @return isUp True if price moved up (relative to token ordering)
function recenter() external returns (bool isUp) {
(, int24 currentTick,,,,,) = pool.slot0();
2025-07-06 10:08:59 +02:00
// Validate access and price stability
if (recenterAccess != address(0)) {
require(msg.sender == recenterAccess, "access denied");
2024-04-11 07:28:54 +02:00
} else {
require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown");
require(_isPriceStable(currentTick), "price deviated from oracle");
2024-04-11 07:28:54 +02:00
}
lastRecenterTime = block.timestamp;
2024-03-28 21:50:22 +01:00
// 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;
2025-07-25 10:52:56 +02:00
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 (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) {
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 > 10 ** 18) ? 10 ** 18 : capitalInefficiency,
anchorShare: (anchorShare > 10 ** 18) ? 10 ** 18 : anchorShare,
anchorWidth: (anchorWidth > 100) ? 100 : anchorWidth,
discoveryDepth: (discoveryDepth > 10 ** 18) ? 10 ** 18 : discoveryDepth
});
_setPositions(currentTick, params);
} catch {
// Fallback to safe bear-mode defaults if optimizer fails
PositionParams memory defaultParams = PositionParams({
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);
2024-07-06 18:36:13 +02:00
}
emit Recentered(currentTick, isUp);
2024-07-18 16:50:23 +02:00
}
/// @notice Removes all positions and collects fees
/// @param recordVWAP Whether to record VWAP (only when net ETH inflow since last recenter)
function _scrapePositions(bool recordVWAP) internal {
2024-06-09 16:06:41 +02:00
uint256 fee0 = 0;
uint256 fee1 = 0;
2024-07-06 18:36:13 +02:00
uint256 currentPrice;
for (uint256 i = uint256(Stage.FLOOR); i <= uint256(Stage.DISCOVERY); i++) {
2024-06-09 16:06:41 +02:00
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
2024-06-09 16:06:41 +02:00
fee0 += collected0 - amount0;
fee1 += collected1 - amount1;
// Record price from anchor position for VWAP
2024-06-09 16:06:41 +02:00
if (i == uint256(Stage.ANCHOR)) {
int24 tick = position.tickLower + ((position.tickUpper - position.tickLower) / 2);
currentPrice = _priceAtTick(token0isWeth ? -1 * tick : tick);
2024-06-09 16:06:41 +02:00
}
}
}
2024-07-06 18:36:13 +02:00
// 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
2024-06-09 16:06:41 +02:00
if (fee0 > 0) {
2024-07-06 18:36:13 +02:00
if (token0isWeth) {
IERC20(address(weth)).safeTransfer(feeDestination, fee0);
if (recordVWAP) _recordVolumeAndPrice(currentPrice, fee0);
2024-07-06 18:36:13 +02:00
} else {
IERC20(address(kraiken)).safeTransfer(feeDestination, fee0);
2024-07-06 18:36:13 +02:00
}
2024-06-09 16:06:41 +02:00
}
2024-06-09 16:06:41 +02:00
if (fee1 > 0) {
2024-07-06 18:36:13 +02:00
if (token0isWeth) {
IERC20(address(kraiken)).safeTransfer(feeDestination, fee1);
2024-07-06 18:36:13 +02:00
} else {
IERC20(address(weth)).safeTransfer(feeDestination, fee1);
if (recordVWAP) _recordVolumeAndPrice(currentPrice, fee1);
2024-07-06 18:36:13 +02:00
}
2024-06-09 16:06:41 +02:00
}
}
/// @notice Allow contract to receive ETH
receive() external payable { }
2024-06-07 11:22:22 +02:00
// ========================================
// ABSTRACT FUNCTION IMPLEMENTATIONS
// ========================================
2024-07-09 18:00:39 +02:00
/// @notice Implementation of abstract function from PriceOracle
function _getPool() internal view override returns (IUniswapV3Pool) {
return pool;
2024-06-07 11:22:22 +02:00
}
/// @notice Implementation of abstract function from ThreePositionStrategy
function _getKraikenToken() internal view override returns (address) {
return address(kraiken);
}
2024-04-03 21:43:12 +02:00
/// @notice Implementation of abstract function from ThreePositionStrategy
function _getWethToken() internal view override returns (address) {
return address(weth);
}
2024-06-09 16:06:41 +02:00
/// @notice Implementation of abstract function from ThreePositionStrategy
function _isToken0Weth() internal view override returns (bool) {
return token0isWeth;
}
2024-04-03 21:43:12 +02:00
/// @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 });
}
2024-04-03 21:43:12 +02:00
/// @notice Implementation of abstract function from ThreePositionStrategy
function _getEthBalance() internal view override returns (uint256) {
return address(this).balance + weth.balanceOf(address(this));
}
2024-04-03 21:43:12 +02:00
/// @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();
if (feeDestination != address(0)) {
supply -= kraiken.balanceOf(feeDestination);
}
(, address stakingPoolAddr) = kraiken.peripheryContracts();
if (stakingPoolAddr != address(0)) {
supply -= kraiken.balanceOf(stakingPoolAddr);
}
return supply;
}
}