diff --git a/onchain/src/IOptimizer.sol b/onchain/src/IOptimizer.sol index 653d44d..5c110be 100644 --- a/onchain/src/IOptimizer.sol +++ b/onchain/src/IOptimizer.sol @@ -22,7 +22,7 @@ interface IOptimizer { * @notice Returns the four liquidity parameters used by LiquidityManager.recenter(). * @return capitalInefficiency Capital buffer level (0..1e18). CI=0 is safest. * @return anchorShare Fraction of non-floor ETH in anchor (0..1e18). - * @return anchorWidth Anchor position width in tick units (uint24). + * @return anchorWidth Anchor position width in tick units (uint24); max 100 ticks, enforced by LiquidityManager. * @return discoveryDepth Discovery liquidity density (0..1e18). */ function getLiquidityParams() diff --git a/onchain/src/LiquidityManager.sol b/onchain/src/LiquidityManager.sol index 5707490..50649f5 100644 --- a/onchain/src/LiquidityManager.sol +++ b/onchain/src/LiquidityManager.sol @@ -34,6 +34,10 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { /// @notice Uniswap V3 fee tier (1%) - 10,000 basis points uint24 internal constant FEE = uint24(10_000); + /// @notice Maximum anchor width (in ticks) accepted from the optimizer. + /// Any optimizer-returned value above this ceiling is silently clamped down. + uint24 internal constant MAX_ANCHOR_WIDTH = 100; + /// @notice Immutable contract references address private immutable factory; IWETH9 private immutable weth; @@ -222,7 +226,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { PositionParams memory params = PositionParams({ capitalInefficiency: (capitalInefficiency > 10 ** 18) ? 10 ** 18 : capitalInefficiency, anchorShare: (anchorShare > 10 ** 18) ? 10 ** 18 : anchorShare, - anchorWidth: (anchorWidth > 100) ? 100 : anchorWidth, + anchorWidth: (anchorWidth > MAX_ANCHOR_WIDTH) ? MAX_ANCHOR_WIDTH : anchorWidth, discoveryDepth: (discoveryDepth > 10 ** 18) ? 10 ** 18 : discoveryDepth }); diff --git a/onchain/test/LiquidityManager.t.sol b/onchain/test/LiquidityManager.t.sol index 3fe09cf..dd9d4ad 100644 --- a/onchain/test/LiquidityManager.t.sol +++ b/onchain/test/LiquidityManager.t.sol @@ -27,6 +27,7 @@ import { ExceededAvailableStake, Stake } from "../src/Stake.sol"; import { ThreePositionStrategy } from "../src/abstracts/ThreePositionStrategy.sol"; import "../src/helpers/UniswapHelpers.sol"; import "../test/mocks/MockOptimizer.sol"; +import "../test/mocks/ConfigurableOptimizer.sol"; import { TestEnvironment } from "./helpers/TestBase.sol"; import { UniSwapHelper } from "./helpers/UniswapTestBase.sol"; @@ -1155,4 +1156,51 @@ contract LiquidityManagerTest is UniSwapHelper { assertEq(harness.exposed_getKraikenToken(), address(harberg), "_getKraikenToken should return kraiken"); assertEq(harness.exposed_getWethToken(), address(weth), "_getWethToken should return weth"); } + + /** + * @notice Optimizer returning anchorWidth > 100 is clamped to MAX_ANCHOR_WIDTH (100), not rejected. + * @dev Verifies that LiquidityManager silently truncates oversized anchorWidth values from + * the optimizer rather than reverting. The resulting anchor position tick range must + * equal exactly 100 * TICK_SPACING ticks wide. + */ + function testAnchorWidthAbove100IsClamped() public { + // Deploy a ConfigurableOptimizer that returns anchorWidth = 150 (above the 100-tick ceiling) + ConfigurableOptimizer highWidthOptimizer = new ConfigurableOptimizer( + 0, // capitalInefficiency = 0 (safest) + 3e17, // anchorShare = 30% + 150, // anchorWidth = 150 — intentionally above MAX_ANCHOR_WIDTH + 3e17 // discoveryDepth = 30% + ); + + TestEnvironment clampTestEnv = new TestEnvironment(feeDestination); + ( + , + , + , + , + , + LiquidityManager customLm, + , + ) = clampTestEnv.setupEnvironmentWithOptimizer( + DEFAULT_TOKEN0_IS_WETH, + RECENTER_CALLER, + address(highWidthOptimizer) + ); + + // recenter() must succeed (value clamped, not rejected) + vm.prank(RECENTER_CALLER); + customLm.recenter(); + + // Anchor position must exist and its tick range must match anchorWidth=100 (clamped), not 150. + // The anchor spacing formula is: anchorSpacing = TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100) + // For anchorWidth=100: anchorSpacing = 200 + 34*100*200/100 = 7000; tickWidth = 2*7000 = 14000 + // For anchorWidth=150: anchorSpacing = 200 + 34*150*200/100 = 10400; tickWidth = 2*10400 = 20800 + (uint128 liquidity, int24 tickLower, int24 tickUpper) = customLm.positions(ThreePositionStrategy.Stage.ANCHOR); + assertTrue(liquidity > 0, "anchor position should have been placed"); + int24 tickWidth = tickUpper - tickLower; + int24 expectedClamped = 2 * (TICK_SPACING + (34 * int24(100) * TICK_SPACING / 100)); // 14000 + int24 unclamped150 = 2 * (TICK_SPACING + (34 * int24(150) * TICK_SPACING / 100)); // 20800 + assertEq(tickWidth, expectedClamped, "anchorWidth above 100 must be clamped to MAX_ANCHOR_WIDTH=100"); + assertTrue(tickWidth < unclamped150, "clamped width must be narrower than unclamped anchorWidth=150 would produce"); + } }