fix: LiquidityManager silently clamps anchorWidth to 100, undocumented upper bound (#689)

- Extract magic number into named constant MAX_ANCHOR_WIDTH = 100 in LiquidityManager.sol
- Document effective ceiling in IOptimizer.sol natspec for anchorWidth return value
- Add testAnchorWidthAbove100IsClamped in LiquidityManager.t.sol asserting that
  optimizer-returned anchorWidth=150 is silently clamped to 100 (not rejected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-13 18:14:37 +00:00
parent e484d4b03a
commit 39c25fa330
3 changed files with 54 additions and 2 deletions

View file

@ -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()

View file

@ -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
});

View file

@ -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");
}
}