harb/onchain/src/LiquidityManager.sol

358 lines
18 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 { 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";
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 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
2024-07-13 14:56:13 +02:00
address private immutable factory;
IWETH9 private immutable weth;
Kraiken private immutable kraiken;
IOptimizer 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;
2024-07-09 18:00:39 +02:00
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
2024-07-13 14:56:13 +02:00
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);
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 (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
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();
// 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");
2024-06-09 16:06:41 +02:00
feeDestination = feeDestination_;
emit FeeDestinationSet(feeDestination_);
if (feeDestination_.code.length > 0) {
feeDestinationLocked = true;
emit FeeDestinationLocked(feeDestination_);
}
2024-06-09 16:06:41 +02:00
}
/// @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();
2025-07-06 10:08:59 +02:00
// 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;
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
2026-03-11 03:31:45 +00:00
// 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 {
2026-03-11 03:31:45 +00:00
// 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);
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 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 {
2024-06-09 16:06:41 +02:00
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++) {
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;
}
}
2024-07-06 18:36:13 +02:00
// 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);
}
2024-07-06 18:36:13 +02:00
}
2024-06-09 16:06:41 +02:00
}
// Always record VWAP regardless of fee destination
if (recordVWAP) {
uint256 ethFee = token0isWeth ? fee0 : fee1;
if (ethFee > 0) _recordVolumeAndPrice(currentPrice, ethFee);
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();
// 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;
}
}