// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import {TickMath} from "@aperture/uni-v3-lib/TickMath.sol"; import {LiquidityAmounts} from "@aperture/uni-v3-lib/LiquidityAmounts.sol"; import {SqrtPriceMath} from "@aperture/uni-v3-lib/SqrtPriceMath.sol"; import "../../src/interfaces/IWETH9.sol"; import {Kraiken} from "../../src/Kraiken.sol"; import {ThreePositionStrategy} from "../../src/abstracts/ThreePositionStrategy.sol"; import {LiquidityBoundaryHelper} from "./LiquidityBoundaryHelper.sol"; /** * @title UniSwapHelper * @dev Helper contract for Uniswap V3 testing, providing reusable swap logic. */ abstract contract UniSwapHelper is Test { address account = makeAddr("alice"); IUniswapV3Pool public pool; IWETH9 public weth; Kraiken public harberg; bool public token0isWeth; /** * @dev Performs a swap in the Uniswap V3 pool. * @param amount The amount to swap. * @param isBuy True if buying WETH, false if selling. */ function performSwap(uint256 amount, bool isBuy) public { uint160 limit; // Determine the swap direction bool zeroForOne = isBuy ? token0isWeth : !token0isWeth; if (isBuy) { vm.prank(account); weth.transfer(address(this), amount); } else { vm.prank(account); harberg.approve(address(this), amount); } // Set the sqrtPriceLimitX96 based on the swap direction // Get current price to set appropriate limits (uint160 currentSqrtPrice,,,,,,) = pool.slot0(); if (zeroForOne) { // Swapping token0 for token1 - price goes down // sqrtPriceLimitX96 must be less than current price but greater than MIN_SQRT_RATIO uint160 minAllowedLimit = TickMath.MIN_SQRT_RATIO + 1; // Safety check: ensure we have enough room to set a valid limit if (currentSqrtPrice <= minAllowedLimit + 1) { // Emergency fallback: current price is at or very close to minimum // We can't safely set a limit, so use the minimum possible limit = minAllowedLimit; } else { // Use aggressive limit close to MIN_SQRT_RATIO to allow full price movement limit = minAllowedLimit; } } else { // Swapping token1 for token0 - price goes up // sqrtPriceLimitX96 must be greater than current price but less than MAX_SQRT_RATIO uint160 maxAllowedLimit = TickMath.MAX_SQRT_RATIO - 1; // Safety check: ensure we have enough room to set a valid limit if (currentSqrtPrice >= maxAllowedLimit - 1) { // Emergency fallback: current price is at or very close to maximum // We can't safely set a limit, so use the maximum possible limit = maxAllowedLimit; } else { // Use aggressive limit close to MAX_SQRT_RATIO to allow full price movement limit = maxAllowedLimit; } } pool.swap(account, zeroForOne, int256(amount), limit, abi.encode(account, int256(amount), isBuy)); } /** * @notice Performs a swap with aggressive price limits for extreme price normalization * @param amount The amount to swap * @param isBuy True if buying HARB, false if selling HARB */ function performSwapWithAggressiveLimits(uint256 amount, bool isBuy) internal { uint160 limit; // Determine the swap direction bool zeroForOne = isBuy ? token0isWeth : !token0isWeth; if (isBuy) { vm.prank(account); weth.transfer(address(this), amount); } else { vm.prank(account); harberg.approve(address(this), amount); } // Set aggressive price limits that allow price to move to liquidity ranges if (zeroForOne) { // Swapping token0 for token1 - price goes down // Use very aggressive limit close to MIN_SQRT_RATIO limit = TickMath.MIN_SQRT_RATIO + 1; } else { // Swapping token1 for token0 - price goes up // Use very aggressive limit close to MAX_SQRT_RATIO limit = TickMath.MAX_SQRT_RATIO - 1; } pool.swap(account, zeroForOne, int256(amount), limit, abi.encode(account, int256(amount), isBuy)); } /** * @dev The Uniswap V3 swap callback. */ function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata _data) external { // Handle the case where no swap occurred (both deltas are 0) if (amount0Delta == 0 && amount1Delta == 0) { return; } require(amount0Delta > 0 || amount1Delta > 0); (address seller,, bool isBuy) = abi.decode(_data, (address, uint256, bool)); (, uint256 amountToPay) = amount0Delta > 0 ? (!token0isWeth, uint256(amount0Delta)) : (token0isWeth, uint256(amount1Delta)); if (isBuy) { weth.transfer(msg.sender, amountToPay); } else { require(harberg.transferFrom(seller, msg.sender, amountToPay), "Transfer failed"); } } /// @notice Callback function that Uniswap V3 calls for liquidity actions requiring minting or burning of tokens. /// @param amount0Owed The amount of token0 owed for the liquidity provision. /// @param amount1Owed The amount of token1 owed for the liquidity provision. /// @dev This function mints Kraiken tokens as needed and handles WETH deposits for ETH conversions during liquidity interactions. function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { // CallbackValidation.verifyCallback(factory, poolKey); // take care of harb uint256 harbPulled = token0isWeth ? amount1Owed : amount0Owed; if (harbPulled > 0) { harberg.mint(harbPulled); harberg.transfer(msg.sender, harbPulled); } // pack ETH uint256 ethOwed = token0isWeth ? amount0Owed : amount1Owed; if (weth.balanceOf(address(this)) < ethOwed) { weth.deposit{value: address(this).balance}(); } if (ethOwed > 0) { weth.transfer(msg.sender, amount1Owed); } } // ======================================== // EXTREME PRICE HANDLING // ======================================== // Safety margin to prevent tick boundary violations (conservative approach) int24 constant TICK_BOUNDARY_SAFETY_MARGIN = 15000; // Price normalization constants uint256 constant NORMALIZATION_HARB_PERCENTAGE = 100; // 1% of HARB balance uint256 constant NORMALIZATION_ETH_AMOUNT = 0.01 ether; // Fixed ETH amount for normalization uint256 constant MAX_NORMALIZATION_ATTEMPTS = 3; // Prevent infinite loops uint256 constant PRICE_LIMIT_BUFFER = 1000; // Buffer from sqrt price limits /** * @notice Handles extreme price conditions by executing normalizing trades * @dev This function should be called before any recenter operation to ensure * the price is within safe boundaries for liquidity position creation */ function handleExtremePrice() internal { uint256 attempts = 0; while (attempts < MAX_NORMALIZATION_ATTEMPTS) { (, int24 currentTick,,,,,) = pool.slot0(); if (currentTick >= TickMath.MAX_TICK - TICK_BOUNDARY_SAFETY_MARGIN) { _executeNormalizingTrade(true); // Move price down attempts++; } else if (currentTick <= TickMath.MIN_TICK + TICK_BOUNDARY_SAFETY_MARGIN) { _executeNormalizingTrade(false); // Move price up attempts++; } else { // Price is now safe, exit loop break; } } } /** * @notice Executes a small trade to move price away from tick boundaries * @param moveDown True to move price down (sell HARB), false to move price up (buy HARB) */ function _executeNormalizingTrade(bool moveDown) internal { if (moveDown) { // Need to move price DOWN (reduce HARB price) // This means: sell HARB for ETH (increase HARB supply in pool) uint256 harbBalance = harberg.balanceOf(account); if (harbBalance > 0) { // Use 1% of account's HARB balance (conservative approach like original) uint256 harbToSell = harbBalance / NORMALIZATION_HARB_PERCENTAGE; if (harbToSell == 0) harbToSell = 1; vm.prank(account); harberg.transfer(address(this), harbToSell); harberg.approve(address(pool), harbToSell); // Sell HARB for ETH with aggressive price limits for normalization performSwapWithAggressiveLimits(harbToSell, false); } } else { // Need to move price UP (increase HARB price) // This means: buy HARB with ETH (reduce HARB supply in pool) uint256 ethBalance = weth.balanceOf(account); if (ethBalance > 0) { // Use small amount for normalization (like original) uint256 ethToBuy = NORMALIZATION_ETH_AMOUNT; if (ethToBuy > ethBalance) ethToBuy = ethBalance; // Buy HARB with ETH with aggressive price limits for normalization performSwapWithAggressiveLimits(ethToBuy, true); } } } // ======================================== // LIQUIDITY-AWARE TRADE SIZE CALCULATION // ======================================== /** * @notice Calculates the maximum ETH amount that can be traded (buy HARB) without exceeding position liquidity limits * @dev When currentTick is in anchor range, calculates trade size to make anchor and discovery positions "full" of ETH * @return maxEthAmount Maximum ETH that can be safely traded, 0 if no positions exist or already at limit */ function buyLimitToLiquidityBoundary() internal view returns (uint256 maxEthAmount) { // Get LiquidityManager reference from test context // This assumes the test has a 'lm' variable for the LiquidityManager try this.getLiquidityManager() returns (ThreePositionStrategy liquidityManager) { return LiquidityBoundaryHelper.calculateBuyLimit(pool, liquidityManager, token0isWeth); } catch { return 0; // Safe fallback if LiquidityManager access fails } } /** * @notice Calculates the maximum HARB amount that can be traded (sell HARB) without exceeding position liquidity limits * @dev When currentTick is in anchor range, calculates trade size to make anchor and floor positions "full" of HARB * @return maxHarbAmount Maximum HARB that can be safely traded, 0 if no positions exist or already at limit */ function sellLimitToLiquidityBoundary() internal view returns (uint256 maxHarbAmount) { try this.getLiquidityManager() returns (ThreePositionStrategy liquidityManager) { return LiquidityBoundaryHelper.calculateSellLimit(pool, liquidityManager, token0isWeth); } catch { return 0; // Safe fallback if LiquidityManager access fails } } /** * @notice Helper function to get LiquidityManager reference from test context * @dev This function should be overridden in the test contract to return the actual LiquidityManager instance * @return liquidityManager The LiquidityManager contract instance */ function getLiquidityManager() external view virtual returns (ThreePositionStrategy liquidityManager) { revert("getLiquidityManager must be implemented in test contract"); } /** * @notice Raw buy operation without liquidity limit checking * @param amountEth Amount of ETH to spend buying HARB */ function buyRaw(uint256 amountEth) internal { performSwap(amountEth, true); // Note: No checkLiquidity call - this is the "raw" version } /** * @notice Raw sell operation without liquidity limit checking * @param amountHarb Amount of HARB to sell for ETH */ function sellRaw(uint256 amountHarb) internal { performSwap(amountHarb, false); // Note: No checkLiquidity call - this is the "raw" version } // ======================================== // INTERNAL LIQUIDITY CALCULATION HELPERS // ======================================== function _calculateBuyLimitToken0IsWeth( int24 currentTick, uint128 anchorLiquidity, int24 anchorLower, int24 anchorUpper, uint128 discoveryLiquidity, int24 discoveryLower, int24 discoveryUpper ) internal pure returns (uint256) { // When token0 is WETH, buying HARB moves price up (towards higher ticks) // We want to calculate how much ETH needed to move to the upper bound of discovery if (discoveryLiquidity == 0) { return type(uint256).max; // No discovery position, no limit } // Find the highest upper bound (outermost position boundary) int24 targetTick = discoveryUpper > anchorUpper ? discoveryUpper : anchorUpper; // If we're already at or above the target, return 0 if (currentTick >= targetTick) { return 0; } // Calculate total ETH needed to move price from currentTick to targetTick // This requires summing up ETH consumption across all positions uint256 totalEthNeeded = 0; // Calculate ETH needed from anchor position (if current tick is within its range) if (currentTick >= anchorLower && currentTick < anchorUpper && anchorLiquidity > 0) { int24 anchorEndTick = targetTick < anchorUpper ? targetTick : anchorUpper; totalEthNeeded += _calculateEthToMoveBetweenTicks(currentTick, anchorEndTick, anchorLiquidity); } // Calculate ETH needed from discovery position (if applicable) if (targetTick > anchorUpper && discoveryLiquidity > 0) { int24 discoveryStartTick = currentTick > discoveryLower ? currentTick : discoveryLower; if (discoveryStartTick < discoveryUpper) { totalEthNeeded += _calculateEthToMoveBetweenTicks(discoveryStartTick, targetTick, discoveryLiquidity); } } return totalEthNeeded; } function _calculateBuyLimitToken1IsWeth( int24 currentTick, uint128 anchorLiquidity, int24 anchorLower, int24 anchorUpper, uint128 discoveryLiquidity, int24 discoveryLower, int24 discoveryUpper ) internal pure returns (uint256) { // When token1 is WETH, buying HARB (token0) moves price down (towards lower ticks) // We want to calculate how much ETH needed to move to the lower bound of discovery if (discoveryLiquidity == 0) { return type(uint256).max; // No discovery position, no limit } // Find the lowest lower bound (outermost position boundary) int24 targetTick = discoveryLower < anchorLower ? discoveryLower : anchorLower; // If we're already at or below the target, return 0 if (currentTick <= targetTick) { return 0; } // Calculate total ETH needed to move price from currentTick down to targetTick uint256 totalEthNeeded = 0; // Calculate ETH needed from anchor position (if current tick is within its range) if (currentTick <= anchorUpper && currentTick > anchorLower && anchorLiquidity > 0) { int24 anchorEndTick = targetTick > anchorLower ? targetTick : anchorLower; totalEthNeeded += _calculateEthToMoveBetweenTicksDown(currentTick, anchorEndTick, anchorLiquidity); } // Calculate ETH needed from discovery position (if applicable) if (targetTick < anchorLower && discoveryLiquidity > 0) { int24 discoveryStartTick = currentTick < discoveryUpper ? currentTick : discoveryUpper; if (discoveryStartTick > discoveryLower) { totalEthNeeded += _calculateEthToMoveBetweenTicksDown(discoveryStartTick, targetTick, discoveryLiquidity); } } return totalEthNeeded; } function _calculateSellLimitToken0IsWeth( int24 currentTick, uint128 anchorLiquidity, int24 anchorLower, int24 anchorUpper, uint128 floorLiquidity, int24 floorLower, int24 floorUpper ) internal pure returns (uint256) { // When token0 is WETH, selling HARB moves price down (towards lower ticks) // We want to calculate how much HARB needed to move to the lower bound of floor if (floorLiquidity == 0) { return type(uint256).max; // No floor position, no limit } // Find the lowest lower bound (outermost position boundary) int24 targetTick = floorLower < anchorLower ? floorLower : anchorLower; // If we're already at or below the target, return 0 if (currentTick <= targetTick) { return 0; } // Calculate total HARB needed to move price from currentTick down to targetTick uint256 totalHarbNeeded = 0; // Calculate HARB needed from anchor position (if current tick is within its range) if (currentTick <= anchorUpper && currentTick > anchorLower && anchorLiquidity > 0) { int24 anchorEndTick = targetTick > anchorLower ? targetTick : anchorLower; totalHarbNeeded += _calculateHarbToMoveBetweenTicks(currentTick, anchorEndTick, anchorLiquidity); } // Calculate HARB needed from floor position (if applicable) if (targetTick < anchorLower && floorLiquidity > 0) { int24 floorStartTick = currentTick < floorUpper ? currentTick : floorUpper; if (floorStartTick > floorLower) { totalHarbNeeded += _calculateHarbToMoveBetweenTicks(floorStartTick, targetTick, floorLiquidity); } } return totalHarbNeeded; } function _calculateSellLimitToken1IsWeth( int24 currentTick, uint128 anchorLiquidity, int24 anchorLower, int24 anchorUpper, uint128 floorLiquidity, int24 floorLower, int24 floorUpper ) internal pure returns (uint256) { // When token1 is WETH, selling HARB (token0) moves price up (towards higher ticks) // We want to calculate how much HARB needed to move to the upper bound of floor if (floorLiquidity == 0) { return type(uint256).max; // No floor position, no limit } // Find the highest upper bound (outermost position boundary) int24 targetTick = floorUpper > anchorUpper ? floorUpper : anchorUpper; // If we're already at or above the target, return 0 if (currentTick >= targetTick) { return 0; } // Calculate total HARB needed to move price from currentTick up to targetTick uint256 totalHarbNeeded = 0; // Calculate HARB needed from anchor position (if current tick is within its range) if (currentTick >= anchorLower && currentTick < anchorUpper && anchorLiquidity > 0) { int24 anchorEndTick = targetTick < anchorUpper ? targetTick : anchorUpper; totalHarbNeeded += _calculateHarbToMoveUpBetweenTicks(currentTick, anchorEndTick, anchorLiquidity); } // Calculate HARB needed from floor position (if applicable) if (targetTick > anchorUpper && floorLiquidity > 0) { int24 floorStartTick = currentTick > floorLower ? currentTick : floorLower; if (floorStartTick < floorUpper) { totalHarbNeeded += _calculateHarbToMoveUpBetweenTicks(floorStartTick, targetTick, floorLiquidity); } } return totalHarbNeeded; } /** * @notice Calculates ETH needed to move price between two ticks given liquidity * @param fromTick Starting tick * @param toTick Target tick (must be > fromTick) * @param liquidity Amount of liquidity in this range * @return ethAmount ETH needed for this price movement */ function _calculateEthToMoveBetweenTicks(int24 fromTick, int24 toTick, uint128 liquidity) internal pure returns (uint256 ethAmount) { if (fromTick >= toTick || liquidity == 0) { return 0; } // Get sqrt prices for the tick range uint160 sqrtPriceFromX96 = TickMath.getSqrtRatioAtTick(fromTick); uint160 sqrtPriceToX96 = TickMath.getSqrtRatioAtTick(toTick); // For moving price up (buying token1 with token0), token0 is consumed // Amount of token0 needed = liquidity * (1/sqrt(Pa) - 1/sqrt(Pb)) // Where Pa is lower price, Pb is higher price return LiquidityAmounts.getAmount0ForLiquidity(sqrtPriceFromX96, sqrtPriceToX96, liquidity); } /** * @notice Calculates ETH needed to move price down between two ticks given liquidity * @param fromTick Starting tick (must be > toTick for downward movement) * @param toTick Target tick * @param liquidity Amount of liquidity in this range * @return ethAmount ETH needed for this downward price movement */ function _calculateEthToMoveBetweenTicksDown(int24 fromTick, int24 toTick, uint128 liquidity) internal pure returns (uint256 ethAmount) { if (fromTick <= toTick || liquidity == 0) { return 0; } // Get sqrt prices for the tick range uint160 sqrtPriceFromX96 = TickMath.getSqrtRatioAtTick(fromTick); uint160 sqrtPriceToX96 = TickMath.getSqrtRatioAtTick(toTick); // For moving price down (selling token0 for token1), when token1 is WETH // We're actually buying token0 (HARB) with token1 (WETH) // Amount of token1 needed = liquidity * (sqrt(Pa) - sqrt(Pb)) // Where Pa is higher price, Pb is lower price return LiquidityAmounts.getAmount1ForLiquidity(sqrtPriceToX96, sqrtPriceFromX96, liquidity); } /** * @notice Calculates HARB needed to move price between two ticks given liquidity * @param fromTick Starting tick (must be > toTick for downward movement) * @param toTick Target tick * @param liquidity Amount of liquidity in this range * @return harbAmount HARB needed for this price movement */ function _calculateHarbToMoveBetweenTicks(int24 fromTick, int24 toTick, uint128 liquidity) internal pure returns (uint256 harbAmount) { if (fromTick <= toTick || liquidity == 0) { return 0; } // Get sqrt prices for the tick range (note: fromTick > toTick for downward movement) uint160 sqrtPriceFromX96 = TickMath.getSqrtRatioAtTick(fromTick); uint160 sqrtPriceToX96 = TickMath.getSqrtRatioAtTick(toTick); // For moving price down (selling token1 for token0), token1 is consumed // Amount of token1 needed = liquidity * (sqrt(Pb) - sqrt(Pa)) // Where Pa is lower price, Pb is higher price return LiquidityAmounts.getAmount1ForLiquidity(sqrtPriceToX96, sqrtPriceFromX96, liquidity); } /** * @notice Calculates HARB needed to move price up between two ticks given liquidity * @param fromTick Starting tick * @param toTick Target tick (must be > fromTick for upward movement) * @param liquidity Amount of liquidity in this range * @return harbAmount HARB needed for this upward price movement */ function _calculateHarbToMoveUpBetweenTicks(int24 fromTick, int24 toTick, uint128 liquidity) internal pure returns (uint256 harbAmount) { if (fromTick >= toTick || liquidity == 0) { return 0; } // Get sqrt prices for the tick range uint160 sqrtPriceFromX96 = TickMath.getSqrtRatioAtTick(fromTick); uint160 sqrtPriceToX96 = TickMath.getSqrtRatioAtTick(toTick); // For moving price up (selling token0 for token1), when token1 is WETH // We're selling token0 (HARB) for token1 (WETH) // Amount of token0 needed = liquidity * (1/sqrt(Pb) - 1/sqrt(Pa)) // Where Pa is lower price, Pb is higher price return LiquidityAmounts.getAmount0ForLiquidity(sqrtPriceFromX96, sqrtPriceToX96, liquidity); } }